git.schokokeks.org
Repositories
Help
Report an Issue
derivepassphrase.git
Code
Commits
Branches
Tags
Suche
Strukturansicht:
6e05ad2
Branches
Tags
documentation-tree
master
0.1.0
0.1.1
0.1.2
0.1.3
0.2.0
0.3.0
0.3.1
0.3.2
derivepassphrase.git
tests
test_derivepassphrase_cli.py
Add finished command-line interface, with tests
Marco Ricci
commited
6e05ad2
at 2024-06-22 21:19:30
test_derivepassphrase_cli.py
Blame
History
Raw
# SPDX-FileCopyrightText: 2024 Marco Ricci <m@the13thletter.info> # # SPDX-License-Identifier: MIT from __future__ import annotations import base64 import contextlib import errno import json import os from typing import Any, cast, TYPE_CHECKING, NamedTuple import click.testing import derivepassphrase as dpp import derivepassphrase.cli as cli import ssh_agent_client.types import pytest DUMMY_SERVICE = 'service1' DUMMY_PASSPHRASE = b'my secret passphrase\n' DUMMY_CONFIG_SETTINGS = {"length": 10, "upper": 1, "lower": 1, "repeat": 5, "number": 1, "space": 1, "dash": 1, "symbol": 1} DUMMY_RESULT_PASSPHRASE = b'.2V_QJkd o' DUMMY_RESULT_KEY1 = b'E<b<{ -7iG' DUMMY_PHRASE_FROM_KEY1_RAW = ( b'\x00\x00\x00\x0bssh-ed25519' b'\x00\x00\x00@\xf0\x98\x19\x80l\x1a\x97\xd5&\x03n' b'\xcc\xe3e\x8f\x86f\x07\x13\x19\x13\t!33\xf9\xe46S' b'\x1d\xaf\xfd\r\x08\x1f\xec\xf8s\x9b\x8c_U9\x16|ST,' b'\x1eR\xbb0\xed\x7f\x89\xe2/iQU\xd8\x9e\xa6\x02' ) DUMMY_PHRASE_FROM_KEY1 = b'8JgZgGwal9UmA27M42WPhmYHExkTCSEzM/nkNlMdr/0NCB/s+HObjF9VORZ8U1QsHlK7MO1/ieIvaVFV2J6mAg==' # See Ed25519 and RSA test keys in test_key_signing.py DUMMY_KEY1 = 'AAAAC3NzaC1lZDI1NTE5AAAAIIF4gWgm1gJIXw//Mkhv5MEwidwcakUGCekJD/vCEml2' DUMMY_KEY2 = 'AAAAB3NzaC1yc2EAAAADAQABAAABgQCxoe7pezhxWy4NI0mUwKqg9WCYOAS+IjxN9eYcqpfcmQiojcuy9XsiN/xYJ1O94SrsKS5mEia2xHnYA4RUChTyYNcM2v6cnnBQ/N/VQhpGMN7SVxdbhKUXTWFCwbjBgO6rGyHB6WtoH8vd7TOEPt+NgcXwhsWyoaUUdYTA62V+GF9vEmxMaC4ubgDz+B0QkPnauSoNxmkhcIe0lsLNb1pClZyz88PDnKXCX/d0HuN/HJ+sbPg7dCvOyqFYSyKn3uY6bCXqoIdurxXzH3O7z0P8f5sbmKOrGGKNuNxVRbeVl/D/3uDL0nqsbfUc1qvkfwbJwtMXC4IV6kOZMSk2BAsqh7x48gQ+rhYeEVSi8F3CWs4HJQoqrGt7K9a3mCSlMBHP70u3w6ME7eumoryxlUofewTd17ZEkzdX08l2ZlKzZvwQUrc+xQZ2Uw8z2mfW6Ti4gi0pYGaig7Ke4PwuXpo/C5YAWfeXycsvJZ2uaYRjMdZeJGNAnHLUGLkBscw5aI8=' class IncompatibleConfiguration(NamedTuple): other_options: list[tuple[str, ...]] needs_service: bool | None input: bytes | None class SingleConfiguration(NamedTuple): needs_service: bool | None input: bytes | None check_success: bool class OptionCombination(NamedTuple): options: list[str] incompatible: bool needs_service: bool | None input: bytes | None check_success: bool PASSWORD_GENERATION_OPTIONS: list[tuple[str, ...]] = [ ('--phrase',), ('--key',), ('--length', '20'), ('--repeat', '20'), ('--lower', '1'), ('--upper', '1'), ('--number', '1'), ('--space', '1'), ('--dash', '1'), ('--symbol', '1') ] CONFIGURATION_OPTIONS: list[tuple[str, ...]] = [ ('--notes',), ('--config',), ('--delete',), ('--delete-globals',), ('--clear',) ] CONFIGURATION_COMMANDS: list[tuple[str, ...]] = [ ('--notes',), ('--delete',), ('--delete-globals',), ('--clear',) ] STORAGE_OPTIONS: list[tuple[str, ...]] = [ ('--export', '-'), ('--import', '-') ] INCOMPATIBLE: dict[tuple[str, ...], IncompatibleConfiguration] = { ('--phrase',): IncompatibleConfiguration( [('--key',)] + CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE), ('--key',): IncompatibleConfiguration( CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE), ('--length', '20'): IncompatibleConfiguration( CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE), ('--repeat', '20'): IncompatibleConfiguration( CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE), ('--lower', '1'): IncompatibleConfiguration( CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE), ('--upper', '1'): IncompatibleConfiguration( CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE), ('--number', '1'): IncompatibleConfiguration( CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE), ('--space', '1'): IncompatibleConfiguration( CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE), ('--dash', '1'): IncompatibleConfiguration( CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE), ('--symbol', '1'): IncompatibleConfiguration( CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE), ('--notes',): IncompatibleConfiguration( [('--config',), ('--delete',), ('--delete-globals',), ('--clear',)] + STORAGE_OPTIONS, True, None), ('--config', '-p'): IncompatibleConfiguration( [('--delete',), ('--delete-globals',), ('--clear',)] + STORAGE_OPTIONS, None, DUMMY_PASSPHRASE), ('--delete',): IncompatibleConfiguration( [('--delete-globals',), ('--clear',)] + STORAGE_OPTIONS, True, None), ('--delete-globals',): IncompatibleConfiguration( [('--clear',)] + STORAGE_OPTIONS, False, None), ('--clear',): IncompatibleConfiguration(STORAGE_OPTIONS, False, None), ('--export', '-'): IncompatibleConfiguration( [('--import', '-')], False, None), ('--import', '-'): IncompatibleConfiguration( [], False, None), } SINGLES: dict[tuple[str, ...], SingleConfiguration] = { ('--phrase',): SingleConfiguration(True, DUMMY_PASSPHRASE, True), ('--key',): SingleConfiguration(True, None, False), ('--length', '20'): SingleConfiguration(True, DUMMY_PASSPHRASE, True), ('--repeat', '20'): SingleConfiguration(True, DUMMY_PASSPHRASE, True), ('--lower', '1'): SingleConfiguration(True, DUMMY_PASSPHRASE, True), ('--upper', '1'): SingleConfiguration(True, DUMMY_PASSPHRASE, True), ('--number', '1'): SingleConfiguration(True, DUMMY_PASSPHRASE, True), ('--space', '1'): SingleConfiguration(True, DUMMY_PASSPHRASE, True), ('--dash', '1'): SingleConfiguration(True, DUMMY_PASSPHRASE, True), ('--symbol', '1'): SingleConfiguration(True, DUMMY_PASSPHRASE, True), ('--notes',): SingleConfiguration(True, None, False), ('--config', '-p'): SingleConfiguration(None, DUMMY_PASSPHRASE, False), ('--delete',): SingleConfiguration(True, None, False), ('--delete-globals',): SingleConfiguration(False, None, True), ('--clear',): SingleConfiguration(False, None, True), ('--export', '-'): SingleConfiguration(False, None, True), ('--import', '-'): SingleConfiguration(False, b'{"services": {}}', True), } INTERESTING_OPTION_COMBINATIONS: list[OptionCombination] = [] config: OptionCombination | SingleConfiguration for opt, config in INCOMPATIBLE.items(): for opt2 in config.other_options: INTERESTING_OPTION_COMBINATIONS.extend([ OptionCombination(options=list(opt + opt2), incompatible=True, needs_service=config.needs_service, input=config.input, check_success=False), OptionCombination(options=list(opt2 + opt), incompatible=True, needs_service=config.needs_service, input=config.input, check_success=False) ]) for opt, config in SINGLES.items(): INTERESTING_OPTION_COMBINATIONS.append( OptionCombination(options=list(opt), incompatible=False, needs_service=config.needs_service, input=config.input, check_success=config.check_success)) @contextlib.contextmanager def isolated_config( monkeypatch: Any, runner: click.testing.CliRunner, config: Any, ): with runner.isolated_filesystem(): monkeypatch.setenv('HOME', os.getcwd()) monkeypatch.setenv('USERPROFILE', os.getcwd()) monkeypatch.delenv(cli.prog_name.replace(' ', '_').upper() + '_PATH', raising=False) os.makedirs(os.path.dirname(cli._config_filename()), exist_ok=True) with open(cli._config_filename(), 'wt') as outfile: json.dump(config, outfile) yield def test_100_save_bad_config(monkeypatch: Any) -> None: runner = click.testing.CliRunner() with isolated_config(monkeypatch=monkeypatch, runner=runner, config={}): with pytest.raises(ValueError, match='Invalid vault config'): cli._save_config(None) # type: ignore def test_101_prompt_for_selection_multiple(monkeypatch: Any) -> None: @click.command() @click.option('--heading', default='Our menu:') @click.argument('items', nargs=-1) def driver(heading, items): # from https://montypython.fandom.com/wiki/Spam#The_menu items = items or [ 'Egg and bacon', 'Egg, sausage and bacon', 'Egg and spam', 'Egg, bacon and spam', 'Egg, bacon, sausage and spam', 'Spam, bacon, sausage and spam', 'Spam, egg, spam, spam, bacon and spam', 'Spam, spam, spam, egg and spam', ('Spam, spam, spam, spam, spam, spam, baked beans, ' 'spam, spam, spam and spam'), ('Lobster thermidor aux crevettes with a mornay sauce ' 'garnished with truffle paté, brandy ' 'and a fried egg on top and spam'), ] index = cli._prompt_for_selection(items, heading=heading) click.echo('A fine choice: ', nl=False) click.echo(items[index]) click.echo('(Note: Vikings strictly optional.)') runner = click.testing.CliRunner(mix_stderr=True) result = runner.invoke(driver, [], input='9') assert result.exit_code == 0, 'driver program failed' assert result.stdout == '''\ Our menu: [1] Egg and bacon [2] Egg, sausage and bacon [3] Egg and spam [4] Egg, bacon and spam [5] Egg, bacon, sausage and spam [6] Spam, bacon, sausage and spam [7] Spam, egg, spam, spam, bacon and spam [8] Spam, spam, spam, egg and spam [9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam [10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam Your selection? (1-10, leave empty to abort): 9 A fine choice: Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam (Note: Vikings strictly optional.) ''', 'driver program produced unexpected output' result = runner.invoke(driver, ['--heading='], input='', catch_exceptions=True) assert result.exit_code > 0, 'driver program succeeded?!' assert result.stdout == '''\ [1] Egg and bacon [2] Egg, sausage and bacon [3] Egg and spam [4] Egg, bacon and spam [5] Egg, bacon, sausage and spam [6] Spam, bacon, sausage and spam [7] Spam, egg, spam, spam, bacon and spam [8] Spam, spam, spam, egg and spam [9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam [10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam Your selection? (1-10, leave empty to abort): \n''', ( 'driver program produced unexpected output' ) assert isinstance(result.exception, IndexError), ( 'driver program did not raise IndexError?!' ) def test_102_prompt_for_selection_single(monkeypatch: Any) -> None: @click.command() @click.option('--item', default='baked beans') @click.argument('prompt') def driver(item, prompt): try: cli._prompt_for_selection([item], heading='', single_choice_prompt=prompt) except IndexError as e: click.echo('Boo.') raise e else: click.echo('Great!') runner = click.testing.CliRunner(mix_stderr=True) result = runner.invoke(driver, ['Will replace with spam. Confirm, y/n?'], input='y') assert result.exit_code == 0, 'driver program failed' assert result.stdout == '''\ [1] baked beans Will replace with spam. Confirm, y/n? y Great! ''', 'driver program produced unexpected output' result = runner.invoke(driver, ['Will replace with spam, okay? ' + '(Please say "y" or "n".)'], input='') assert result.exit_code > 0, 'driver program succeeded?!' assert result.stdout == '''\ [1] baked beans Will replace with spam, okay? (Please say "y" or "n".): Boo. ''', 'driver program produced unexpected output' assert isinstance(result.exception, IndexError), ( 'driver program did not raise IndexError?!' ) def test_103_prompt_for_passphrase(monkeypatch: Any) -> None: monkeypatch.setattr(click, 'prompt', lambda *a, **kw: json.dumps({'args': a, 'kwargs': kw})) res = json.loads(cli._prompt_for_passphrase()) assert 'args' in res and 'kwargs' in res, ( 'missing arguments to passphrase prompt' ) assert res['args'][:1] == ['Passphrase'], ( 'missing arguments to passphrase prompt' ) assert (res['kwargs'].get('default') == '' and not res['kwargs'].get('show_default', True)), ( 'missing arguments to passphrase prompt' ) assert res['kwargs'].get('err') and res['kwargs'].get('hide_input'), ( 'missing arguments to passphrase prompt' ) def test_200_help_output(): runner = click.testing.CliRunner(mix_stderr=False) result = runner.invoke(cli.derivepassphrase, ['--help'], catch_exceptions=False) assert result.exit_code == 0 assert 'Password generation:\n' in result.output, ( 'Option groups not respected in help text.' ) assert 'Use NUMBER=0, e.g. "--symbol 0"' in result.output, ( 'Option group epilog not printed.' ) @pytest.mark.parametrize(['charset_name'], [('lower',), ('upper',), ('number',), ('space',), ('dash',), ('symbol',)]) def test_201_disable_character_set( monkeypatch: Any, charset_name: str ) -> None: monkeypatch.setattr(cli, '_prompt_for_passphrase', lambda *a, **kw: DUMMY_PASSPHRASE.decode('UTF-8')) option = f'--{charset_name}' charset = dpp.Vault._CHARSETS[charset_name].decode('ascii') runner = click.testing.CliRunner(mix_stderr=False) result = runner.invoke(cli.derivepassphrase, [option, '0', '-p', DUMMY_SERVICE], input=DUMMY_PASSPHRASE, catch_exceptions=False) assert result.exit_code == 0, ( f'program died unexpectedly with exit code {result.exit_code}' ) assert not result.stderr_bytes, ( f'program barfed on stderr: {result.stderr_bytes}' ) for c in charset: assert c not in result.stdout, ( f'derived password contains forbidden character {c!r}: ' f'{result.stdout!r}' ) def test_202_disable_repetition(monkeypatch: Any) -> None: monkeypatch.setattr(cli, '_prompt_for_passphrase', lambda *a, **kw: DUMMY_PASSPHRASE.decode('UTF-8')) runner = click.testing.CliRunner(mix_stderr=False) result = runner.invoke(cli.derivepassphrase, ['--repeat', '0', '-p', DUMMY_SERVICE], input=DUMMY_PASSPHRASE, catch_exceptions=False) assert result.exit_code == 0, ( f'program died unexpectedly with exit code {result.exit_code}' ) assert not result.stderr_bytes, ( f'program barfed on stderr: {result.stderr_bytes}' ) passphrase = result.stdout.rstrip('\r\n') for i in range(len(passphrase) - 1): assert passphrase[i:i+1] != passphrase[i+1:i+2], ( f'derived password contains repeated character ' f'at position {i}: {result.stdout!r}' ) @pytest.mark.parametrize(['command_line', 'config', 'result_config'], [ (['--delete-globals'], {'global': {'phrase': 'abc'}, 'services': {}}, {'services': {}}), (['--delete', DUMMY_SERVICE], {'global': {'phrase': 'abc'}, 'services': {DUMMY_SERVICE: {'notes': '...'}}}, {'global': {'phrase': 'abc'}, 'services': {}}), (['--clear'], {'global': {'phrase': 'abc'}, 'services': {DUMMY_SERVICE: {'notes': '...'}}}, {'services': {}}), ]) def test_203_repeated_config_deletion( monkeypatch: Any, command_line: list[str], config: dpp.types.VaultConfig, result_config: dpp.types.VaultConfig, ) -> None: runner = click.testing.CliRunner(mix_stderr=False) for start_config in [config, result_config]: with isolated_config(monkeypatch=monkeypatch, runner=runner, config=start_config): result = runner.invoke(cli.derivepassphrase, command_line, catch_exceptions=False) assert (result.exit_code, result.stderr_bytes) == (0, b''), ( 'program exited with failure' ) with open(cli._config_filename(), 'rt') as infile: config_readback = json.load(infile) assert config_readback == result_config def test_204_phrase_from_key_manually() -> None: assert ( dpp.Vault(phrase=DUMMY_PHRASE_FROM_KEY1, **DUMMY_CONFIG_SETTINGS) .generate(DUMMY_SERVICE) == DUMMY_RESULT_KEY1 ) @pytest.mark.parametrize(['config'], [ pytest.param({'global': {'key': DUMMY_KEY1}, 'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}}, id='global'), pytest.param({'global': {'phrase': DUMMY_PASSPHRASE.rstrip(b'\n').decode('ASCII')}, 'services': {DUMMY_SERVICE: {'key': DUMMY_KEY1, **DUMMY_CONFIG_SETTINGS}}}, id='service'), ]) def test_204a_key_from_config( monkeypatch: Any, config: dpp.types.VaultConfig, ) -> None: def phrase_from_key(key: bytes) -> bytes: if key == base64.standard_b64decode(DUMMY_KEY1): # pragma: no branch return DUMMY_PHRASE_FROM_KEY1 raise KeyError(key) # pragma: no cover runner = click.testing.CliRunner(mix_stderr=False) with isolated_config(monkeypatch=monkeypatch, runner=runner, config=config): monkeypatch.setattr(dpp.Vault, 'phrase_from_key', phrase_from_key) result = runner.invoke(cli.derivepassphrase, [DUMMY_SERVICE], catch_exceptions=False) assert (result.exit_code, result.stderr_bytes) == (0, b''), ( 'program exited with failure' ) assert result.stdout_bytes.rstrip(b'\n') != DUMMY_RESULT_PASSPHRASE, ( 'program generated unexpected result (phrase instead of key)' ) assert result.stdout_bytes.rstrip(b'\n') == DUMMY_RESULT_KEY1, ( 'program generated unexpected result (wrong settings?)' ) def test_204b_key_from_command_line(monkeypatch: Any) -> None: KeyCommentPair = ssh_agent_client.types.KeyCommentPair key_list = [ KeyCommentPair(base64.standard_b64decode(DUMMY_KEY1), b'no comment'), KeyCommentPair(base64.standard_b64decode(DUMMY_KEY2), b'a comment'), ] def _suitable_ssh_keys(conn: Any) -> Iterator[KeyCommentPair]: yield from key_list def phrase_from_key(key: bytes) -> bytes: if key == base64.standard_b64decode(DUMMY_KEY1): # pragma: no branch return DUMMY_PHRASE_FROM_KEY1 raise KeyError(key) # pragma: no cover runner = click.testing.CliRunner(mix_stderr=False) with isolated_config(monkeypatch=monkeypatch, runner=runner, config={'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}}): monkeypatch.setattr(cli, '_get_suitable_ssh_keys', _suitable_ssh_keys) monkeypatch.setattr(dpp.Vault, 'phrase_from_key', phrase_from_key) result = runner.invoke(cli.derivepassphrase, ['-k', DUMMY_SERVICE], input=b'1\n', catch_exceptions=False) assert result.exit_code == 0, 'program exited with failure' assert result.stdout_bytes, 'program output expected' last_line = result.stdout_bytes.splitlines(True)[-1] assert last_line.rstrip(b'\n') != DUMMY_RESULT_PASSPHRASE, ( 'program generated unexpected result (phrase instead of key)' ) assert last_line.rstrip(b'\n') == DUMMY_RESULT_KEY1, ( 'program generated unexpected result (wrong settings?)' ) def test_205_service_phrase_if_key_in_global_config(monkeypatch: Any) -> None: runner = click.testing.CliRunner(mix_stderr=False) with isolated_config( monkeypatch=monkeypatch, runner=runner, config={ 'global': {'key': DUMMY_KEY1}, 'services': { DUMMY_SERVICE: { 'phrase': DUMMY_PASSPHRASE.rstrip(b'\n').decode('ASCII'), **DUMMY_CONFIG_SETTINGS}}} ): result = runner.invoke(cli.derivepassphrase, [DUMMY_SERVICE], catch_exceptions=False) assert result.exit_code == 0, 'program exited with failure' assert result.stdout_bytes, 'program output expected' last_line = result.stdout_bytes.splitlines(True)[-1] assert last_line.rstrip(b'\n') != DUMMY_RESULT_KEY1, ( 'program generated unexpected result (key instead of phrase)' ) assert last_line.rstrip(b'\n') == DUMMY_RESULT_PASSPHRASE, ( 'program generated unexpected result (wrong settings?)' ) @pytest.mark.parametrize(['option'], [('--lower',), ('--upper',), ('--number',), ('--space',), ('--dash',), ('--symbol',), ('--repeat',), ('--length',)]) def test_210_invalid_argument_range(option: str) -> None: runner = click.testing.CliRunner(mix_stderr=False) value: str | int for value in '-42', 'invalid': result = runner.invoke(cli.derivepassphrase, [option, cast(str, value), '-p', DUMMY_SERVICE], input=DUMMY_PASSPHRASE, catch_exceptions=False) assert result.exit_code > 0, ( 'program unexpectedly succeeded' ) assert result.stderr_bytes, ( 'program did not print any error message' ) assert b'Error: Invalid value' in result.stderr_bytes, ( 'program did not print the expected error message' ) @pytest.mark.parametrize(['vfunc', 'input'], [ (cli._validate_occurrence_constraint, 20), (cli._validate_length, 20), ]) def test_210a_validate_constraints_manually( vfunc: Callable[[click.Context, click.Parameter, Any], int | None], input: int, ) -> None: ctx = cli.derivepassphrase.make_context(cli.prog_name, []) param = cli.derivepassphrase.params[0] assert vfunc(ctx, param, input) == input @pytest.mark.parametrize( ['options', 'service', 'input', 'check_success'], [(o.options, o.needs_service, o.input, o.check_success) for o in INTERESTING_OPTION_COMBINATIONS if not o.incompatible], ) def test_211_service_needed( monkeypatch: Any, options: list[str], service: bool | None, input: bytes | None, check_success: bool, ) -> None: def _prompt1(*args, **kwargs): """Needed for --config handling.""" return DUMMY_PASSPHRASE.decode('UTF-8') monkeypatch.setattr(cli, '_prompt_for_passphrase', _prompt1) runner = click.testing.CliRunner(mix_stderr=False) with isolated_config(monkeypatch=monkeypatch, runner=runner, config={'global': {'phrase': 'abc'}, 'services': {}}): result = runner.invoke(cli.derivepassphrase, options if service else options + [DUMMY_SERVICE], input=input, catch_exceptions=False) if service is not None: assert result.exit_code > 0, ( 'program unexpectedly succeeded' ) assert result.stderr_bytes, ( 'program did not print any error message' ) err_msg = (b' requires a SERVICE' if service else b' does not take a SERVICE argument') assert err_msg in result.stderr_bytes, ( 'program did not print the expected error message' ) else: assert (result.exit_code, result.stderr_bytes) == (0, b''), ( 'program unexpectedly failed' ) if check_success: with isolated_config(monkeypatch=monkeypatch, runner=runner, config={'global': {'phrase': 'abc'}, 'services': {}}): def _prompt2(*args, **kwargs): return DUMMY_PASSPHRASE.decode('UTF-8') monkeypatch.setattr(cli, '_prompt_for_passphrase', _prompt2) result = runner.invoke(cli.derivepassphrase, options + [DUMMY_SERVICE] if service else options, input=input, catch_exceptions=False) assert (result.exit_code, result.stderr_bytes) == (0, b''), ( 'program unexpectedly failed' ) @pytest.mark.parametrize( ['options', 'service', 'input'], [(o.options, o.needs_service, o.input) for o in INTERESTING_OPTION_COMBINATIONS if o.incompatible], ) def test_212_incompatible_options( options: list[str], service: bool | None, input: bytes | None, ) -> None: runner = click.testing.CliRunner(mix_stderr=False) result = runner.invoke(cli.derivepassphrase, options + [DUMMY_SERVICE] if service else options, input=DUMMY_PASSPHRASE, catch_exceptions=False) assert result.exit_code > 0, ( 'program unexpectedly succeeded' ) assert result.stderr_bytes, ( 'program did not print any error message' ) assert b'mutually exclusive with ' in result.stderr_bytes, ( 'program did not print the expected error message' ) def test_213_import_bad_config_not_vault_config(monkeypatch: Any) -> None: runner = click.testing.CliRunner(mix_stderr=False) with isolated_config(monkeypatch=monkeypatch, runner=runner, config={'services': {}}): result = runner.invoke(cli.derivepassphrase, ['--import', '-'], input=b'null', catch_exceptions=False) assert result.exit_code > 0, ( 'program unexpectedly succeeded' ) assert result.stderr_bytes, ( 'program did not print any error message' ) assert b'not a valid config' in result.stderr_bytes, ( 'program did not print the expected error message' ) def test_213a_import_bad_config_not_json_data(monkeypatch: Any) -> None: runner = click.testing.CliRunner(mix_stderr=False) with isolated_config(monkeypatch=monkeypatch, runner=runner, config={'services': {}}): result = runner.invoke(cli.derivepassphrase, ['--import', '-'], input=b'This string is not valid JSON.', catch_exceptions=False) assert result.exit_code > 0, ( 'program unexpectedly succeeded' ) assert result.stderr_bytes, ( 'program did not print any error message' ) assert b'cannot decode JSON' in result.stderr_bytes, ( 'program did not print the expected error message' ) def test_213b_import_bad_config_not_a_file(monkeypatch: Any) -> None: runner = click.testing.CliRunner(mix_stderr=False) with isolated_config(monkeypatch=monkeypatch, runner=runner, config={'services': {}}): with open(cli._config_filename(), 'wt') as outfile: print('This string is not valid JSON.', file=outfile) result = runner.invoke( cli.derivepassphrase, ['--import', os.path.dirname(cli._config_filename())], catch_exceptions=False) assert result.exit_code > 0, ( 'program unexpectedly succeeded' ) assert result.stderr_bytes, ( 'program did not print any error message' ) # Don't test the actual error message, because it is subject to # locale settings. TODO: find a way anyway. def test_214_export_settings_no_stored_settings(monkeypatch: Any) -> None: runner = click.testing.CliRunner(mix_stderr=False) with isolated_config(monkeypatch=monkeypatch, runner=runner, config={'services': {}}): try: os.remove(cli._config_filename()) except FileNotFoundError: # pragma: no cover pass result = runner.invoke(cli.derivepassphrase, ['--export', '-'], catch_exceptions=False) assert (result.exit_code, result.stderr_bytes) == (0, b''), ( 'program exited with failure' ) def test_214a_export_settings_bad_stored_config(monkeypatch: Any) -> None: runner = click.testing.CliRunner(mix_stderr=False) with isolated_config(monkeypatch=monkeypatch, runner=runner, config={}): result = runner.invoke(cli.derivepassphrase, ['--export', '-'], input=b'null', catch_exceptions=False) assert result.exit_code > 0, ( 'program unexpectedly succeeded' ) assert result.stderr_bytes, ( 'program did not print any error message' ) assert b'cannot load config' in result.stderr_bytes, ( 'program did not print the expected error message' ) def test_214b_export_settings_not_a_file(monkeypatch: Any) -> None: runner = click.testing.CliRunner(mix_stderr=False) with isolated_config(monkeypatch=monkeypatch, runner=runner, config={'services': {}}): try: os.remove(cli._config_filename()) except FileNotFoundError: # pragma: no cover pass os.makedirs(cli._config_filename()) result = runner.invoke(cli.derivepassphrase, ['--export', '-'], input=b'null', catch_exceptions=False) assert result.exit_code > 0, ( 'program unexpectedly succeeded' ) assert result.stderr_bytes, ( 'program did not print any error message' ) assert b'cannot load config' in result.stderr_bytes, ( 'program did not print the expected error message' ) def test_214c_export_settings_target_not_a_file(monkeypatch: Any) -> None: runner = click.testing.CliRunner(mix_stderr=False) with isolated_config(monkeypatch=monkeypatch, runner=runner, config={'services': {}}): dname = os.path.dirname(cli._config_filename()) result = runner.invoke(cli.derivepassphrase, ['--export', dname], input=b'null', catch_exceptions=False) assert result.exit_code > 0, ( 'program unexpectedly succeeded' ) assert result.stderr_bytes, ( 'program did not print any error message' ) assert b'cannot write config' in result.stderr_bytes, ( 'program did not print the expected error message' ) def test_220_edit_notes_successfully(monkeypatch: Any) -> None: edit_result = ''' # - - - - - >8 - - - - - >8 - - - - - >8 - - - - - >8 - - - - - contents go here ''' runner = click.testing.CliRunner(mix_stderr=False) with isolated_config(monkeypatch=monkeypatch, runner=runner, config={'global': {'phrase': 'abc'}, 'services': {}}): monkeypatch.setattr(click, 'edit', lambda *a, **kw: edit_result) result = runner.invoke(cli.derivepassphrase, ['--notes', 'sv'], catch_exceptions=False) assert (result.exit_code, result.stderr_bytes) == (0, b''), ( 'program exited with failure' ) with open(cli._config_filename(), 'rt') as infile: config = json.load(infile) assert config == {'global': {'phrase': 'abc'}, 'services': {'sv': {'notes': 'contents go here'}}} def test_221_edit_notes_noop(monkeypatch: Any) -> None: runner = click.testing.CliRunner(mix_stderr=False) with isolated_config(monkeypatch=monkeypatch, runner=runner, config={'global': {'phrase': 'abc'}, 'services': {}}): monkeypatch.setattr(click, 'edit', lambda *a, **kw: None) result = runner.invoke(cli.derivepassphrase, ['--notes', 'sv'], catch_exceptions=False) assert (result.exit_code, result.stderr_bytes) == (0, b''), ( 'program exited with failure' ) with open(cli._config_filename(), 'rt') as infile: config = json.load(infile) assert config == {'global': {'phrase': 'abc'}, 'services': {}} def test_222_edit_notes_marker_removed(monkeypatch: Any) -> None: runner = click.testing.CliRunner(mix_stderr=False) with isolated_config(monkeypatch=monkeypatch, runner=runner, config={'global': {'phrase': 'abc'}, 'services': {}}): monkeypatch.setattr(click, 'edit', lambda *a, **kw: 'long\ntext') result = runner.invoke(cli.derivepassphrase, ['--notes', 'sv'], catch_exceptions=False) assert (result.exit_code, result.stderr_bytes) == (0, b''), ( 'program exited with failure' ) with open(cli._config_filename(), 'rt') as infile: config = json.load(infile) assert config == {'global': {'phrase': 'abc'}, 'services': {'sv': {'notes': 'long\ntext'}}} def test_223_edit_notes_abort(monkeypatch: Any) -> None: runner = click.testing.CliRunner(mix_stderr=False) with isolated_config(monkeypatch=monkeypatch, runner=runner, config={'global': {'phrase': 'abc'}, 'services': {}}): monkeypatch.setattr(click, 'edit', lambda *a, **kw: '\n\n') result = runner.invoke(cli.derivepassphrase, ['--notes', 'sv'], catch_exceptions=False) assert result.exit_code != 0, 'program unexpectedly succeeded' assert b'user aborted request' in result.stderr_bytes, ( 'expected error message missing' ) with open(cli._config_filename(), 'rt') as infile: config = json.load(infile) assert config == {'global': {'phrase': 'abc'}, 'services': {}} @pytest.mark.parametrize(['command_line', 'input', 'result_config'], [ ( ['--phrase'], b'my passphrase\n', {'global': {'phrase': 'my passphrase'}, 'services': {}}, ), ( ['--key'], b'1\n', {'global': {'key': DUMMY_KEY1}, 'services': {}}, ), ( ['--phrase', 'sv'], b'my passphrase\n', {'global': {'phrase': 'abc'}, 'services': {'sv': {'phrase': 'my passphrase'}}}, ), ( ['--key', 'sv'], b'1\n', {'global': {'phrase': 'abc'}, 'services': {'sv': {'key': DUMMY_KEY1}}}, ), ( ['--key', '--length', '15', 'sv'], b'1\n', {'global': {'phrase': 'abc'}, 'services': {'sv': {'key': DUMMY_KEY1, 'length': 15}}}, ), ]) def test_224_store_config_good( monkeypatch: Any, command_line: list[str], input: bytes, result_config: Any, ) -> None: KeyCommentPair = ssh_agent_client.types.KeyCommentPair key_list = [ KeyCommentPair(base64.standard_b64decode(DUMMY_KEY1), b'no comment'), KeyCommentPair(base64.standard_b64decode(DUMMY_KEY2), b'a comment'), ] def _suitable_ssh_keys(conn: Any) -> Iterator[KeyCommentPair]: yield from key_list runner = click.testing.CliRunner(mix_stderr=False) with isolated_config(monkeypatch=monkeypatch, runner=runner, config={'global': {'phrase': 'abc'}, 'services': {}}): monkeypatch.setattr(cli, '_get_suitable_ssh_keys', _suitable_ssh_keys) result = runner.invoke(cli.derivepassphrase, ['--config'] + command_line, catch_exceptions=False, input=input) assert result.exit_code == 0, 'program exited with failure' with open(cli._config_filename(), 'rt') as infile: config = json.load(infile) assert config == result_config, ( 'stored config does not match expectation' ) @pytest.mark.parametrize(['command_line', 'input', 'err_text'], [ ([], b'', b'cannot update global settings without actual settings'), (['sv'], b'', b'cannot update service settings without actual settings'), (['--phrase', 'sv'], b'', b'no passphrase given'), (['--key'], b'', b'no valid SSH key selected'), ]) def test_225_store_config_fail( monkeypatch: Any, command_line: list[str], input: bytes, err_text: str, ) -> None: KeyCommentPair = ssh_agent_client.types.KeyCommentPair key_list = [ KeyCommentPair(base64.standard_b64decode(DUMMY_KEY1), b'no comment'), KeyCommentPair(base64.standard_b64decode(DUMMY_KEY2), b'a comment'), ] def _suitable_ssh_keys(conn: Any) -> Iterator[KeyCommentPair]: yield from key_list runner = click.testing.CliRunner(mix_stderr=False) with isolated_config(monkeypatch=monkeypatch, runner=runner, config={'global': {'phrase': 'abc'}, 'services': {}}): monkeypatch.setattr(cli, '_get_suitable_ssh_keys', _suitable_ssh_keys) result = runner.invoke(cli.derivepassphrase, ['--config'] + command_line, catch_exceptions=False, input=input) assert result.exit_code != 0, 'program unexpectedly succeeded?!' assert err_text in result.stderr_bytes, ( 'expected error message missing' ) def test_225a_store_config_fail_manual_no_ssh_key_selection( monkeypatch: Any, ) -> None: runner = click.testing.CliRunner(mix_stderr=False) with isolated_config(monkeypatch=monkeypatch, runner=runner, config={'global': {'phrase': 'abc'}, 'services': {}}): def raiser(): raise RuntimeError('custom error message') monkeypatch.setattr(cli, '_select_ssh_key', raiser) result = runner.invoke(cli.derivepassphrase, ['--key', '--config'], catch_exceptions=False) assert result.exit_code != 0, 'program unexpectedly succeeded' assert b'custom error message' in result.stderr_bytes, ( 'expected error message missing' ) def test_226_no_arguments() -> None: runner = click.testing.CliRunner(mix_stderr=False) result = runner.invoke(cli.derivepassphrase, [], catch_exceptions=False) assert result.exit_code != 0, 'program unexpectedly succeeded' assert b'SERVICE is required' in result.stderr_bytes, ( 'expected error message missing' ) def test_226a_no_passphrase_or_key() -> None: runner = click.testing.CliRunner(mix_stderr=False) result = runner.invoke(cli.derivepassphrase, [DUMMY_SERVICE], catch_exceptions=False) assert result.exit_code != 0, 'program unexpectedly succeeded' assert b'no passphrase or key given' in result.stderr_bytes, ( 'expected error message missing' )