From 6ada26d6297e27f429e94588e03de5df5a6b542e Mon Sep 17 00:00:00 2001 From: Jonathan Wren Date: Sat, 5 Sep 2020 17:34:15 -0700 Subject: [PATCH] clean up some code around keyrings, clean up more tests --- features/encryption.feature | 43 +++++++++++---- features/steps/core.py | 101 ++++++++++++++++++++++++++---------- 2 files changed, 106 insertions(+), 38 deletions(-) diff --git a/features/encryption.feature b/features/encryption.feature index 6e75cbc6..cc79b8f5 100644 --- a/features/encryption.feature +++ b/features/encryption.feature @@ -1,8 +1,9 @@ - Feature: Encrypted journals + Feature: Encrypting and decrypting journals + Scenario: Loading an encrypted journal Given we use the config "encrypted.yaml" When we run "jrnl -n 1" and enter "bad doggie no biscuit" - Then the output should contain "Password" + Then we should be prompted for a password And the output should contain "2013-06-10 15:40 Life is good" Scenario: Decrypting a journal @@ -12,6 +13,13 @@ And we should see the message "Journal decrypted" And the journal should have 2 entries + Scenario: Trying to decrypt an unencrypted journal + Given we use the config "basic.yaml" + When we run "jrnl --decrypt" + Then the config for journal "default" should have "encrypt" set to "bool:False" + And we should get no error + And the journal should have 2 entries + Scenario: Encrypting a journal Given we use the config "basic.yaml" When we run "jrnl --encrypt" and enter @@ -23,10 +31,22 @@ Then we should see the message "Journal encrypted" And the config for journal "default" should have "encrypt" set to "bool:True" When we run "jrnl -n 1" and enter "swordfish" - Then the output should contain "Password" + Then we should be prompted for a password And the output should contain "2013-06-10 15:40 Life is good" Scenario: Mistyping your password + Given we use the config "basic.yaml" + When we run "jrnl --encrypt" and enter + """ + swordfish + sordfish + """ + Then we should be prompted for a password + And we should see the message "Passwords did not match" + And the config for journal "default" should not have "encrypt" set + And the journal should have 2 entries + + Scenario: Mistyping your password, then getting it right Given we use the config "basic.yaml" When we run "jrnl --encrypt" and enter """ @@ -36,22 +56,23 @@ swordfish n """ - Then we should see the message "Passwords did not match" + Then we should be prompted for a password + And we should see the message "Passwords did not match" And we should see the message "Journal encrypted" And the config for journal "default" should have "encrypt" set to "bool:True" When we run "jrnl -n 1" and enter "swordfish" - Then the output should contain "Password" + Then we should be prompted for a password And the output should contain "2013-06-10 15:40 Life is good" - Scenario: Storing a password in Keychain + Scenario: Storing a password in keyring Given we use the config "multiple.yaml" + And we have a keyring When we run "jrnl simple --encrypt" and enter """ sabertooth sabertooth y """ - When we set the keychain password of "simple" to "sabertooth" Then the config for journal "simple" should have "encrypt" set to "bool:True" When we run "jrnl simple -n 1" Then the output should contain "2013-06-10 15:40 Life is good" @@ -59,8 +80,8 @@ Scenario: Encrypt journal with no keyring backend and do not store in keyring Given we use the config "basic.yaml" - When we disable the keychain - And we run "jrnl test entry" + And we do not have a keyring + When we run "jrnl test entry" And we run "jrnl --encrypt" and enter """ password @@ -71,8 +92,8 @@ Scenario: Encrypt journal with no keyring backend and do store in keyring Given we use the config "basic.yaml" - When we disable the keychain - And we run "jrnl test entry" + And we do not have a keyring + When we run "jrnl test entry" And we run "jrnl --encrypt" and enter """ password diff --git a/features/steps/core.py b/features/steps/core.py index da25d831..bf269f7b 100644 --- a/features/steps/core.py +++ b/features/steps/core.py @@ -64,8 +64,24 @@ class NoKeyring(keyring.backend.KeyringBackend): raise keyring.errors.NoKeyringError -# set the keyring for keyring lib -keyring.set_keyring(TestKeyring()) +class FailedKeyring(keyring.backend.KeyringBackend): + """ + A keyring that simulates an environment with a keyring that has passwords, but fails + to return them. + """ + + priority = 2 + keys = defaultdict(dict) + + + def set_password(self, servicename, username, password): + self.keys[servicename][username] = password + + def get_password(self, servicename, username): + raise keyring.errors.NoKeyringError + + def delete_password(self, servicename, username): + self.keys[servicename][username] = None def ushlex(command): @@ -93,6 +109,18 @@ def open_journal(journal_name="default"): return Journal.open_journal(journal_name, config) +def read_value_from_string(string): + if string[0] == "{": + # Handle value being a dictionary + return ast.literal_eval(string) + + # Takes strings like "bool:true" or "int:32" and coerces them into proper type + t, value = string.split(":") + value = {"bool": lambda v: v.lower() == "true", "int": int, "str": str}[t]( + value + ) + return value + @given('we use the config "{config_file}"') def set_config(context, config_file): full_path = os.path.join("features/configs", config_file) @@ -103,6 +131,16 @@ def set_config(context, config_file): cf.write("version: {}".format(__version__)) +@given('we have a keyring') +def set_keyring(context): + keyring.set_keyring(TestKeyring()) + + +@given('we do not have a keyring') +def disable_keyring(context): + keyring.core.set_keyring(NoKeyring()) + + @when('we change directory to "{path}"') def move_up_dir(context, path): os.chdir(path) @@ -175,16 +213,22 @@ def matches_editor_arg(context, regex): def _mock_getpass(inputs): def prompt_return(prompt="Password: "): print(prompt) - return next(inputs) + try: + return next(inputs) + except StopIteration: + raise KeyboardInterrupt return prompt_return def _mock_input(inputs): def prompt_return(prompt=""): - val = next(inputs) - print(prompt, val) - return val + try: + val = next(inputs) + print(prompt, val) + return val + except StopIteration: + raise KeyboardInterrupt return prompt_return @@ -247,11 +291,16 @@ def inputs_were_called(context): ) -@then("we were prompted for a password") -def password_was_called(context, method): +@then("we should be prompted for a password") +def password_was_called(context): assert context.getpass.called +@then("we should not be prompted for a password") +def password_was_not_called(context): + assert not context.getpass.called + + @then("all input was used") def all_input_was_used(context): # all inputs were used (ignore if empty string) @@ -292,16 +341,11 @@ def load_template(context, filename): plugins.__exporter_types[exporter.names[0]] = exporter -@when('we set the keychain password of "{journal}" to "{password}"') -def set_keychain(context, journal, password): +@when('we set the keyring password of "{journal}" to "{password}"') +def set_keyring(context, journal, password): keyring.set_password("jrnl", journal, password) -@when("we disable the keychain") -def disable_keychain(context): - keyring.core.set_keyring(NoKeyring()) - - @then("we should get an error") def has_error(context): assert context.exit_status != 0, context.exit_status @@ -397,8 +441,8 @@ def check_not_journal_content(context, text, journal_name="default"): @then('journal "{journal_name}" should not exist') def journal_doesnt_exist(context, journal_name="default"): - with open(install.CONFIG_FILE_PATH) as config_file: - config = yaml.load(config_file, Loader=yaml.FullLoader) + config = load_config(install.CONFIG_FILE_PATH) + journal_path = config["journals"][journal_name] assert not os.path.exists(journal_path) @@ -407,23 +451,26 @@ def journal_doesnt_exist(context, journal_name="default"): @then('the config should have "{key}" set to "{value}"') @then('the config for journal "{journal}" should have "{key}" set to "{value}"') def config_var(context, key, value="", journal=None): - value = value or context.text or "" - if not value[0] == "{": - t, value = value.split(":") - value = {"bool": lambda v: v.lower() == "true", "int": int, "str": str}[t]( - value - ) - else: - # Handle value being a dictionary - value = ast.literal_eval(value) - + value = read_value_from_string(value or context.text or "") config = load_config(install.CONFIG_FILE_PATH) + if journal: config = config["journals"][journal] + assert key in config assert config[key] == value +@then('the config for journal "{journal}" should not have "{key}" set') +def config_var(context, key, value="", journal=None): + config = load_config(install.CONFIG_FILE_PATH) + + if journal: + config = config["journals"][journal] + + assert key not in config + + @then("the journal should have {number:d} entries") @then("the journal should have {number:d} entry") @then('journal "{journal_name}" should have {number:d} entries')