Coverage for tests/test_derivepassphrase_cli.py: 99.778%
356 statements
« prev ^ index » next coverage.py v7.6.0, created at 2024-07-14 11:39 +0200
« prev ^ index » next coverage.py v7.6.0, created at 2024-07-14 11:39 +0200
1# SPDX-FileCopyrightText: 2024 Marco Ricci <m@the13thletter.info>
2#
3# SPDX-License-Identifier: MIT
5from __future__ import annotations
7from collections.abc import Callable
8import json
9import os
10import socket
11from typing_extensions import Any, cast, NamedTuple
13import click.testing
14import derivepassphrase as dpp
15import derivepassphrase.cli as cli
16import ssh_agent_client.types
17import pytest
18import tests
20DUMMY_SERVICE = tests.DUMMY_SERVICE
21DUMMY_PASSPHRASE = tests.DUMMY_PASSPHRASE
22DUMMY_CONFIG_SETTINGS = tests.DUMMY_CONFIG_SETTINGS
23DUMMY_RESULT_PASSPHRASE = tests.DUMMY_RESULT_PASSPHRASE
24DUMMY_RESULT_KEY1 = tests.DUMMY_RESULT_KEY1
25DUMMY_PHRASE_FROM_KEY1_RAW = tests.DUMMY_PHRASE_FROM_KEY1_RAW
26DUMMY_PHRASE_FROM_KEY1 = tests.DUMMY_PHRASE_FROM_KEY1
28DUMMY_KEY1 = tests.DUMMY_KEY1
29DUMMY_KEY1_B64 = tests.DUMMY_KEY1_B64
30DUMMY_KEY2 = tests.DUMMY_KEY2
33class IncompatibleConfiguration(NamedTuple):
34 other_options: list[tuple[str, ...]]
35 needs_service: bool | None
36 input: bytes | None
38class SingleConfiguration(NamedTuple):
39 needs_service: bool | None
40 input: bytes | None
41 check_success: bool
43class OptionCombination(NamedTuple):
44 options: list[str]
45 incompatible: bool
46 needs_service: bool | None
47 input: bytes | None
48 check_success: bool
50PASSWORD_GENERATION_OPTIONS: list[tuple[str, ...]] = [
51 ('--phrase',), ('--key',), ('--length', '20'), ('--repeat', '20'),
52 ('--lower', '1'), ('--upper', '1'), ('--number', '1'),
53 ('--space', '1'), ('--dash', '1'), ('--symbol', '1')
54]
55CONFIGURATION_OPTIONS: list[tuple[str, ...]] = [
56 ('--notes',), ('--config',), ('--delete',), ('--delete-globals',),
57 ('--clear',)
58]
59CONFIGURATION_COMMANDS: list[tuple[str, ...]] = [
60 ('--notes',), ('--delete',), ('--delete-globals',), ('--clear',)
61]
62STORAGE_OPTIONS: list[tuple[str, ...]] = [
63 ('--export', '-'), ('--import', '-')
64]
65INCOMPATIBLE: dict[tuple[str, ...], IncompatibleConfiguration] = {
66 ('--phrase',): IncompatibleConfiguration(
67 [('--key',)] + CONFIGURATION_COMMANDS + STORAGE_OPTIONS,
68 True, DUMMY_PASSPHRASE),
69 ('--key',): IncompatibleConfiguration(
70 CONFIGURATION_COMMANDS + STORAGE_OPTIONS,
71 True, DUMMY_PASSPHRASE),
72 ('--length', '20'): IncompatibleConfiguration(
73 CONFIGURATION_COMMANDS + STORAGE_OPTIONS,
74 True, DUMMY_PASSPHRASE),
75 ('--repeat', '20'): IncompatibleConfiguration(
76 CONFIGURATION_COMMANDS + STORAGE_OPTIONS,
77 True, DUMMY_PASSPHRASE),
78 ('--lower', '1'): IncompatibleConfiguration(
79 CONFIGURATION_COMMANDS + STORAGE_OPTIONS,
80 True, DUMMY_PASSPHRASE),
81 ('--upper', '1'): IncompatibleConfiguration(
82 CONFIGURATION_COMMANDS + STORAGE_OPTIONS,
83 True, DUMMY_PASSPHRASE),
84 ('--number', '1'): IncompatibleConfiguration(
85 CONFIGURATION_COMMANDS + STORAGE_OPTIONS,
86 True, DUMMY_PASSPHRASE),
87 ('--space', '1'): IncompatibleConfiguration(
88 CONFIGURATION_COMMANDS + STORAGE_OPTIONS,
89 True, DUMMY_PASSPHRASE),
90 ('--dash', '1'): IncompatibleConfiguration(
91 CONFIGURATION_COMMANDS + STORAGE_OPTIONS,
92 True, DUMMY_PASSPHRASE),
93 ('--symbol', '1'): IncompatibleConfiguration(
94 CONFIGURATION_COMMANDS + STORAGE_OPTIONS,
95 True, DUMMY_PASSPHRASE),
96 ('--notes',): IncompatibleConfiguration(
97 [('--config',), ('--delete',), ('--delete-globals',),
98 ('--clear',)] + STORAGE_OPTIONS,
99 True, None),
100 ('--config', '-p'): IncompatibleConfiguration(
101 [('--delete',), ('--delete-globals',),
102 ('--clear',)] + STORAGE_OPTIONS,
103 None, DUMMY_PASSPHRASE),
104 ('--delete',): IncompatibleConfiguration(
105 [('--delete-globals',), ('--clear',)] + STORAGE_OPTIONS, True, None),
106 ('--delete-globals',): IncompatibleConfiguration(
107 [('--clear',)] + STORAGE_OPTIONS, False, None),
108 ('--clear',): IncompatibleConfiguration(STORAGE_OPTIONS, False, None),
109 ('--export', '-'): IncompatibleConfiguration(
110 [('--import', '-')], False, None),
111 ('--import', '-'): IncompatibleConfiguration(
112 [], False, None),
113}
114SINGLES: dict[tuple[str, ...], SingleConfiguration] = {
115 ('--phrase',): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
116 ('--key',): SingleConfiguration(True, None, False),
117 ('--length', '20'): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
118 ('--repeat', '20'): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
119 ('--lower', '1'): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
120 ('--upper', '1'): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
121 ('--number', '1'): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
122 ('--space', '1'): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
123 ('--dash', '1'): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
124 ('--symbol', '1'): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
125 ('--notes',): SingleConfiguration(True, None, False),
126 ('--config', '-p'): SingleConfiguration(None, DUMMY_PASSPHRASE, False),
127 ('--delete',): SingleConfiguration(True, None, False),
128 ('--delete-globals',): SingleConfiguration(False, None, True),
129 ('--clear',): SingleConfiguration(False, None, True),
130 ('--export', '-'): SingleConfiguration(False, None, True),
131 ('--import', '-'): SingleConfiguration(False, b'{"services": {}}', True),
132}
133INTERESTING_OPTION_COMBINATIONS: list[OptionCombination] = []
134config: IncompatibleConfiguration | SingleConfiguration
135for opt, config in INCOMPATIBLE.items():
136 for opt2 in config.other_options:
137 INTERESTING_OPTION_COMBINATIONS.extend([
138 OptionCombination(options=list(opt + opt2), incompatible=True,
139 needs_service=config.needs_service,
140 input=config.input, check_success=False),
141 OptionCombination(options=list(opt2 + opt), incompatible=True,
142 needs_service=config.needs_service,
143 input=config.input, check_success=False)
144 ])
145for opt, config in SINGLES.items():
146 INTERESTING_OPTION_COMBINATIONS.append(
147 OptionCombination(options=list(opt), incompatible=False,
148 needs_service=config.needs_service,
149 input=config.input,
150 check_success=config.check_success))
153class TestCLI:
154 def test_200_help_output(self):
155 runner = click.testing.CliRunner(mix_stderr=False)
156 result = runner.invoke(cli.derivepassphrase, ['--help'],
157 catch_exceptions=False)
158 assert result.exit_code == 0
159 assert 'Password generation:\n' in result.output, (
160 'Option groups not respected in help text.'
161 )
162 assert 'Use NUMBER=0, e.g. "--symbol 0"' in result.output, (
163 'Option group epilog not printed.'
164 )
166 @pytest.mark.parametrize(['charset_name'],
167 [('lower',), ('upper',), ('number',), ('space',),
168 ('dash',), ('symbol',)])
169 def test_201_disable_character_set(
170 self, monkeypatch: Any, charset_name: str
171 ) -> None:
172 monkeypatch.setattr(cli, '_prompt_for_passphrase', tests.auto_prompt)
173 option = f'--{charset_name}'
174 charset = dpp.Vault._CHARSETS[charset_name].decode('ascii')
175 runner = click.testing.CliRunner(mix_stderr=False)
176 result = runner.invoke(cli.derivepassphrase,
177 [option, '0', '-p', DUMMY_SERVICE],
178 input=DUMMY_PASSPHRASE, catch_exceptions=False)
179 assert result.exit_code == 0, (
180 f'program died unexpectedly with exit code {result.exit_code}'
181 )
182 assert not result.stderr_bytes, (
183 f'program barfed on stderr: {result.stderr_bytes!r}'
184 )
185 for c in charset:
186 assert c not in result.stdout, (
187 f'derived password contains forbidden character {c!r}: '
188 f'{result.stdout!r}'
189 )
191 def test_202_disable_repetition(self, monkeypatch: Any) -> None:
192 monkeypatch.setattr(cli, '_prompt_for_passphrase', tests.auto_prompt)
193 runner = click.testing.CliRunner(mix_stderr=False)
194 result = runner.invoke(cli.derivepassphrase,
195 ['--repeat', '0', '-p', DUMMY_SERVICE],
196 input=DUMMY_PASSPHRASE, catch_exceptions=False)
197 assert result.exit_code == 0, (
198 f'program died unexpectedly with exit code {result.exit_code}'
199 )
200 assert not result.stderr_bytes, (
201 f'program barfed on stderr: {result.stderr_bytes!r}'
202 )
203 passphrase = result.stdout.rstrip('\r\n')
204 for i in range(len(passphrase) - 1):
205 assert passphrase[i:i+1] != passphrase[i+1:i+2], (
206 f'derived password contains repeated character '
207 f'at position {i}: {result.stdout!r}'
208 )
210 @pytest.mark.parametrize(['config'], [
211 pytest.param({'global': {'key': DUMMY_KEY1_B64},
212 'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}},
213 id='global'),
214 pytest.param({'global': {'phrase': DUMMY_PASSPHRASE.rstrip(b'\n')
215 .decode('ASCII')},
216 'services': {DUMMY_SERVICE: {'key': DUMMY_KEY1_B64,
217 **DUMMY_CONFIG_SETTINGS}}},
218 id='service'),
219 ])
220 def test_204a_key_from_config(
221 self, monkeypatch: Any, config: dpp.types.VaultConfig,
222 ) -> None:
223 runner = click.testing.CliRunner(mix_stderr=False)
224 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
225 config=config):
226 monkeypatch.setattr(dpp.Vault, 'phrase_from_key',
227 tests.phrase_from_key)
228 result = runner.invoke(cli.derivepassphrase, [DUMMY_SERVICE],
229 catch_exceptions=False)
230 assert (result.exit_code, result.stderr_bytes) == (0, b''), (
231 'program exited with failure'
232 )
233 assert (
234 result.stdout_bytes.rstrip(b'\n') != DUMMY_RESULT_PASSPHRASE
235 ), (
236 'program generated unexpected result (phrase instead of key)'
237 )
238 assert result.stdout_bytes.rstrip(b'\n') == DUMMY_RESULT_KEY1, (
239 'program generated unexpected result (wrong settings?)'
240 )
242 def test_204b_key_from_command_line(self, monkeypatch: Any) -> None:
243 runner = click.testing.CliRunner(mix_stderr=False)
244 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
245 config={'services': {DUMMY_SERVICE:
246 DUMMY_CONFIG_SETTINGS}}):
247 monkeypatch.setattr(cli, '_get_suitable_ssh_keys',
248 tests.suitable_ssh_keys)
249 monkeypatch.setattr(dpp.Vault, 'phrase_from_key',
250 tests.phrase_from_key)
251 result = runner.invoke(cli.derivepassphrase,
252 ['-k', DUMMY_SERVICE],
253 input=b'1\n', catch_exceptions=False)
254 assert result.exit_code == 0, 'program exited with failure'
255 assert result.stdout_bytes, 'program output expected'
256 last_line = result.stdout_bytes.splitlines(True)[-1]
257 assert last_line.rstrip(b'\n') != DUMMY_RESULT_PASSPHRASE, (
258 'program generated unexpected result (phrase instead of key)'
259 )
260 assert last_line.rstrip(b'\n') == DUMMY_RESULT_KEY1, (
261 'program generated unexpected result (wrong settings?)'
262 )
264 def test_205_service_phrase_if_key_in_global_config(
265 self, monkeypatch: Any,
266 ) -> None:
267 runner = click.testing.CliRunner(mix_stderr=False)
268 with tests.isolated_config(
269 monkeypatch=monkeypatch, runner=runner,
270 config={
271 'global': {'key': DUMMY_KEY1_B64},
272 'services': {
273 DUMMY_SERVICE: {
274 'phrase': DUMMY_PASSPHRASE.rstrip(b'\n')
275 .decode('ASCII'),
276 **DUMMY_CONFIG_SETTINGS}}}
277 ):
278 result = runner.invoke(cli.derivepassphrase, [DUMMY_SERVICE],
279 catch_exceptions=False)
280 assert result.exit_code == 0, 'program exited with failure'
281 assert result.stdout_bytes, 'program output expected'
282 last_line = result.stdout_bytes.splitlines(True)[-1]
283 assert last_line.rstrip(b'\n') != DUMMY_RESULT_KEY1, (
284 'program generated unexpected result (key instead of phrase)'
285 )
286 assert last_line.rstrip(b'\n') == DUMMY_RESULT_PASSPHRASE, (
287 'program generated unexpected result (wrong settings?)'
288 )
290 @pytest.mark.parametrize(['option'],
291 [('--lower',), ('--upper',), ('--number',),
292 ('--space',), ('--dash',), ('--symbol',),
293 ('--repeat',), ('--length',)])
294 def test_210_invalid_argument_range(self, option: str) -> None:
295 runner = click.testing.CliRunner(mix_stderr=False)
296 value: str | int
297 for value in '-42', 'invalid':
298 result = runner.invoke(cli.derivepassphrase,
299 [option, cast(str, value), '-p',
300 DUMMY_SERVICE],
301 input=DUMMY_PASSPHRASE,
302 catch_exceptions=False)
303 assert result.exit_code > 0, (
304 'program unexpectedly succeeded'
305 )
306 assert result.stderr_bytes, (
307 'program did not print any error message'
308 )
309 assert b'Error: Invalid value' in result.stderr_bytes, (
310 'program did not print the expected error message'
311 )
313 @pytest.mark.parametrize(
314 ['options', 'service', 'input', 'check_success'],
315 [(o.options, o.needs_service, o.input, o.check_success)
316 for o in INTERESTING_OPTION_COMBINATIONS if not o.incompatible],
317 )
318 def test_211_service_needed(
319 self, monkeypatch: Any, options: list[str],
320 service: bool | None, input: bytes | None, check_success: bool,
321 ) -> None:
322 monkeypatch.setattr(cli, '_prompt_for_passphrase', tests.auto_prompt)
323 runner = click.testing.CliRunner(mix_stderr=False)
324 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
325 config={'global': {'phrase': 'abc'},
326 'services': {}}):
327 result = runner.invoke(cli.derivepassphrase,
328 options if service
329 else options + [DUMMY_SERVICE],
330 input=input, catch_exceptions=False)
331 if service is not None:
332 assert result.exit_code > 0, (
333 'program unexpectedly succeeded'
334 )
335 assert result.stderr_bytes, (
336 'program did not print any error message'
337 )
338 err_msg = (b' requires a SERVICE' if service
339 else b' does not take a SERVICE argument')
340 assert err_msg in result.stderr_bytes, (
341 'program did not print the expected error message'
342 )
343 else:
344 assert (result.exit_code, result.stderr_bytes) == (0, b''), (
345 'program unexpectedly failed'
346 )
347 if check_success:
348 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
349 config={'global': {'phrase': 'abc'},
350 'services': {}}):
351 monkeypatch.setattr(cli, '_prompt_for_passphrase',
352 tests.auto_prompt)
353 result = runner.invoke(cli.derivepassphrase,
354 options + [DUMMY_SERVICE]
355 if service else options,
356 input=input, catch_exceptions=False)
357 assert (result.exit_code, result.stderr_bytes) == (0, b''), (
358 'program unexpectedly failed'
359 )
361 @pytest.mark.parametrize(
362 ['options', 'service', 'input'],
363 [(o.options, o.needs_service, o.input)
364 for o in INTERESTING_OPTION_COMBINATIONS if o.incompatible],
365 )
366 def test_212_incompatible_options(
367 self, options: list[str], service: bool | None, input: bytes | None,
368 ) -> None:
369 runner = click.testing.CliRunner(mix_stderr=False)
370 result = runner.invoke(cli.derivepassphrase,
371 options + [DUMMY_SERVICE] if service
372 else options,
373 input=DUMMY_PASSPHRASE, catch_exceptions=False)
374 assert result.exit_code > 0, (
375 'program unexpectedly succeeded'
376 )
377 assert result.stderr_bytes, (
378 'program did not print any error message'
379 )
380 assert b'mutually exclusive with ' in result.stderr_bytes, (
381 'program did not print the expected error message'
382 )
384 def test_213_import_bad_config_not_vault_config(
385 self, monkeypatch: Any,
386 ) -> None:
387 runner = click.testing.CliRunner(mix_stderr=False)
388 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
389 config={'services': {}}):
390 result = runner.invoke(cli.derivepassphrase, ['--import', '-'],
391 input=b'null', catch_exceptions=False)
392 assert result.exit_code > 0, (
393 'program unexpectedly succeeded'
394 )
395 assert result.stderr_bytes, (
396 'program did not print any error message'
397 )
398 assert b'not a valid config' in result.stderr_bytes, (
399 'program did not print the expected error message'
400 )
402 def test_213a_import_bad_config_not_json_data(
403 self, monkeypatch: Any,
404 ) -> None:
405 runner = click.testing.CliRunner(mix_stderr=False)
406 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
407 config={'services': {}}):
408 result = runner.invoke(cli.derivepassphrase, ['--import', '-'],
409 input=b'This string is not valid JSON.',
410 catch_exceptions=False)
411 assert result.exit_code > 0, (
412 'program unexpectedly succeeded'
413 )
414 assert result.stderr_bytes, (
415 'program did not print any error message'
416 )
417 assert b'cannot decode JSON' in result.stderr_bytes, (
418 'program did not print the expected error message'
419 )
421 def test_213b_import_bad_config_not_a_file(
422 self, monkeypatch: Any,
423 ) -> None:
424 runner = click.testing.CliRunner(mix_stderr=False)
425 # `isolated_config` validates the configuration. So, to pass an
426 # actual broken configuration, we must open the configuration file
427 # ourselves afterwards, inside the context.
428 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
429 config={'services': {}}):
430 with open(cli._config_filename(), 'wt') as outfile:
431 print('This string is not valid JSON.', file=outfile)
432 dname = os.path.dirname(cli._config_filename())
433 result = runner.invoke(
434 cli.derivepassphrase,
435 ['--import', os.fsdecode(dname)],
436 catch_exceptions=False)
437 assert result.exit_code > 0, (
438 'program unexpectedly succeeded'
439 )
440 assert result.stderr_bytes, (
441 'program did not print any error message'
442 )
443 # Don't test the actual error message, because it is subject to
444 # locale settings. TODO: find a way anyway.
446 def test_214_export_settings_no_stored_settings(
447 self, monkeypatch: Any,
448 ) -> None:
449 runner = click.testing.CliRunner(mix_stderr=False)
450 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
451 config={'services': {}}):
452 try:
453 os.remove(cli._config_filename())
454 except FileNotFoundError: # pragma: no cover
455 pass
456 result = runner.invoke(cli.derivepassphrase, ['--export', '-'],
457 catch_exceptions=False)
458 assert (result.exit_code, result.stderr_bytes) == (0, b''), (
459 'program exited with failure'
460 )
462 def test_214a_export_settings_bad_stored_config(
463 self, monkeypatch: Any,
464 ) -> None:
465 runner = click.testing.CliRunner(mix_stderr=False)
466 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
467 config={}):
468 result = runner.invoke(cli.derivepassphrase, ['--export', '-'],
469 input=b'null', catch_exceptions=False)
470 assert result.exit_code > 0, (
471 'program unexpectedly succeeded'
472 )
473 assert result.stderr_bytes, (
474 'program did not print any error message'
475 )
476 assert b'cannot load config' in result.stderr_bytes, (
477 'program did not print the expected error message'
478 )
480 def test_214b_export_settings_not_a_file(
481 self, monkeypatch: Any,
482 ) -> None:
483 runner = click.testing.CliRunner(mix_stderr=False)
484 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
485 config={'services': {}}):
486 try:
487 os.remove(cli._config_filename())
488 except FileNotFoundError: # pragma: no cover
489 pass
490 os.makedirs(cli._config_filename())
491 result = runner.invoke(cli.derivepassphrase, ['--export', '-'],
492 input=b'null', catch_exceptions=False)
493 assert result.exit_code > 0, (
494 'program unexpectedly succeeded'
495 )
496 assert result.stderr_bytes, (
497 'program did not print any error message'
498 )
499 assert b'cannot load config' in result.stderr_bytes, (
500 'program did not print the expected error message'
501 )
503 def test_214c_export_settings_target_not_a_file(
504 self, monkeypatch: Any,
505 ) -> None:
506 runner = click.testing.CliRunner(mix_stderr=False)
507 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
508 config={'services': {}}):
509 dname = os.path.dirname(cli._config_filename())
510 result = runner.invoke(cli.derivepassphrase,
511 ['--export', os.fsdecode(dname)],
512 input=b'null', catch_exceptions=False)
513 assert result.exit_code > 0, (
514 'program unexpectedly succeeded'
515 )
516 assert result.stderr_bytes, (
517 'program did not print any error message'
518 )
519 assert b'cannot write config' in result.stderr_bytes, (
520 'program did not print the expected error message'
521 )
523 def test_220_edit_notes_successfully(self, monkeypatch: Any) -> None:
524 edit_result = '''
526# - - - - - >8 - - - - - >8 - - - - - >8 - - - - - >8 - - - - -
527contents go here
528'''
529 runner = click.testing.CliRunner(mix_stderr=False)
530 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
531 config={'global': {'phrase': 'abc'},
532 'services': {}}):
533 monkeypatch.setattr(click, 'edit',
534 lambda *a, **kw: edit_result)
535 result = runner.invoke(cli.derivepassphrase, ['--notes', 'sv'],
536 catch_exceptions=False)
537 assert (result.exit_code, result.stderr_bytes) == (0, b''), (
538 'program exited with failure'
539 )
540 with open(cli._config_filename(), 'rt') as infile:
541 config = json.load(infile)
542 assert config == {'global': {'phrase': 'abc'},
543 'services': {'sv': {'notes':
544 'contents go here'}}}
546 def test_221_edit_notes_noop(self, monkeypatch: Any) -> None:
547 runner = click.testing.CliRunner(mix_stderr=False)
548 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
549 config={'global': {'phrase': 'abc'},
550 'services': {}}):
551 monkeypatch.setattr(click, 'edit', lambda *a, **kw: None)
552 result = runner.invoke(cli.derivepassphrase, ['--notes', 'sv'],
553 catch_exceptions=False)
554 assert (result.exit_code, result.stderr_bytes) == (0, b''), (
555 'program exited with failure'
556 )
557 with open(cli._config_filename(), 'rt') as infile:
558 config = json.load(infile)
559 assert config == {'global': {'phrase': 'abc'}, 'services': {}}
561 def test_222_edit_notes_marker_removed(self, monkeypatch: Any) -> None:
562 runner = click.testing.CliRunner(mix_stderr=False)
563 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
564 config={'global': {'phrase': 'abc'},
565 'services': {}}):
566 monkeypatch.setattr(click, 'edit', lambda *a, **kw: 'long\ntext')
567 result = runner.invoke(cli.derivepassphrase, ['--notes', 'sv'],
568 catch_exceptions=False)
569 assert (result.exit_code, result.stderr_bytes) == (0, b''), (
570 'program exited with failure'
571 )
572 with open(cli._config_filename(), 'rt') as infile:
573 config = json.load(infile)
574 assert config == {'global': {'phrase': 'abc'},
575 'services': {'sv': {'notes': 'long\ntext'}}}
577 def test_223_edit_notes_abort(self, monkeypatch: Any) -> None:
578 runner = click.testing.CliRunner(mix_stderr=False)
579 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
580 config={'global': {'phrase': 'abc'},
581 'services': {}}):
582 monkeypatch.setattr(click, 'edit', lambda *a, **kw: '\n\n')
583 result = runner.invoke(cli.derivepassphrase, ['--notes', 'sv'],
584 catch_exceptions=False)
585 assert result.exit_code != 0, 'program unexpectedly succeeded'
586 assert result.stderr_bytes is not None
587 assert b'user aborted request' in result.stderr_bytes, (
588 'expected error message missing'
589 )
590 with open(cli._config_filename(), 'rt') as infile:
591 config = json.load(infile)
592 assert config == {'global': {'phrase': 'abc'}, 'services': {}}
594 @pytest.mark.parametrize(['command_line', 'input', 'result_config'], [
595 (
596 ['--phrase'],
597 b'my passphrase\n',
598 {'global': {'phrase': 'my passphrase'}, 'services': {}},
599 ),
600 (
601 ['--key'],
602 b'1\n',
603 {'global': {'key': DUMMY_KEY1_B64}, 'services': {}},
604 ),
605 (
606 ['--phrase', 'sv'],
607 b'my passphrase\n',
608 {'global': {'phrase': 'abc'},
609 'services': {'sv': {'phrase': 'my passphrase'}}},
610 ),
611 (
612 ['--key', 'sv'],
613 b'1\n',
614 {'global': {'phrase': 'abc'},
615 'services': {'sv': {'key': DUMMY_KEY1_B64}}},
616 ),
617 (
618 ['--key', '--length', '15', 'sv'],
619 b'1\n',
620 {'global': {'phrase': 'abc'},
621 'services': {'sv': {'key': DUMMY_KEY1_B64, 'length': 15}}},
622 ),
623 ])
624 def test_224_store_config_good(
625 self, monkeypatch: Any, command_line: list[str], input: bytes,
626 result_config: Any,
627 ) -> None:
628 runner = click.testing.CliRunner(mix_stderr=False)
629 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
630 config={'global': {'phrase': 'abc'},
631 'services': {}}):
632 monkeypatch.setattr(cli, '_get_suitable_ssh_keys',
633 tests.suitable_ssh_keys)
634 result = runner.invoke(cli.derivepassphrase,
635 ['--config'] + command_line,
636 catch_exceptions=False, input=input)
637 assert result.exit_code == 0, 'program exited with failure'
638 with open(cli._config_filename(), 'rt') as infile:
639 config = json.load(infile)
640 assert config == result_config, (
641 'stored config does not match expectation'
642 )
644 @pytest.mark.parametrize(['command_line', 'input', 'err_text'], [
645 ([], b'', b'cannot update global settings without actual settings'),
646 (
647 ['sv'],
648 b'',
649 b'cannot update service settings without actual settings',
650 ),
651 (['--phrase', 'sv'], b'', b'no passphrase given'),
652 (['--key'], b'', b'no valid SSH key selected'),
653 ])
654 def test_225_store_config_fail(
655 self, monkeypatch: Any, command_line: list[str],
656 input: bytes, err_text: bytes,
657 ) -> None:
658 runner = click.testing.CliRunner(mix_stderr=False)
659 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
660 config={'global': {'phrase': 'abc'},
661 'services': {}}):
662 monkeypatch.setattr(cli, '_get_suitable_ssh_keys',
663 tests.suitable_ssh_keys)
664 result = runner.invoke(cli.derivepassphrase,
665 ['--config'] + command_line,
666 catch_exceptions=False, input=input)
667 assert result.exit_code != 0, 'program unexpectedly succeeded?!'
668 assert result.stderr_bytes is not None
669 assert err_text in result.stderr_bytes, (
670 'expected error message missing'
671 )
673 def test_225a_store_config_fail_manual_no_ssh_key_selection(
674 self, monkeypatch: Any,
675 ) -> None:
676 runner = click.testing.CliRunner(mix_stderr=False)
677 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
678 config={'global': {'phrase': 'abc'},
679 'services': {}}):
680 def raiser():
681 raise RuntimeError('custom error message')
682 monkeypatch.setattr(cli, '_select_ssh_key', raiser)
683 result = runner.invoke(cli.derivepassphrase,
684 ['--key', '--config'],
685 catch_exceptions=False)
686 assert result.exit_code != 0, 'program unexpectedly succeeded'
687 assert result.stderr_bytes is not None
688 assert b'custom error message' in result.stderr_bytes, (
689 'expected error message missing'
690 )
692 def test_226_no_arguments(self) -> None:
693 runner = click.testing.CliRunner(mix_stderr=False)
694 result = runner.invoke(cli.derivepassphrase, [],
695 catch_exceptions=False)
696 assert result.exit_code != 0, 'program unexpectedly succeeded'
697 assert result.stderr_bytes is not None
698 assert b'SERVICE is required' in result.stderr_bytes, (
699 'expected error message missing'
700 )
702 def test_226a_no_passphrase_or_key(self) -> None:
703 runner = click.testing.CliRunner(mix_stderr=False)
704 result = runner.invoke(cli.derivepassphrase, [DUMMY_SERVICE],
705 catch_exceptions=False)
706 assert result.exit_code != 0, 'program unexpectedly succeeded'
707 assert result.stderr_bytes is not None
708 assert b'no passphrase or key given' in result.stderr_bytes, (
709 'expected error message missing'
710 )
713class TestCLIUtils:
715 def test_100_save_bad_config(self, monkeypatch: Any) -> None:
716 runner = click.testing.CliRunner()
717 with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
718 config={}):
719 with pytest.raises(ValueError, match='Invalid vault config'):
720 cli._save_config(None) # type: ignore
723 def test_101_prompt_for_selection_multiple(self, monkeypatch: Any) -> None:
724 @click.command()
725 @click.option('--heading', default='Our menu:')
726 @click.argument('items', nargs=-1)
727 def driver(heading, items):
728 # from https://montypython.fandom.com/wiki/Spam#The_menu
729 items = items or [
730 'Egg and bacon',
731 'Egg, sausage and bacon',
732 'Egg and spam',
733 'Egg, bacon and spam',
734 'Egg, bacon, sausage and spam',
735 'Spam, bacon, sausage and spam',
736 'Spam, egg, spam, spam, bacon and spam',
737 'Spam, spam, spam, egg and spam',
738 ('Spam, spam, spam, spam, spam, spam, baked beans, '
739 'spam, spam, spam and spam'),
740 ('Lobster thermidor aux crevettes with a mornay sauce '
741 'garnished with truffle paté, brandy '
742 'and a fried egg on top and spam'),
743 ]
744 index = cli._prompt_for_selection(items, heading=heading)
745 click.echo('A fine choice: ', nl=False)
746 click.echo(items[index])
747 click.echo('(Note: Vikings strictly optional.)')
748 runner = click.testing.CliRunner(mix_stderr=True)
749 result = runner.invoke(driver, [], input='9')
750 assert result.exit_code == 0, 'driver program failed'
751 assert result.stdout == '''\
752Our menu:
753[1] Egg and bacon
754[2] Egg, sausage and bacon
755[3] Egg and spam
756[4] Egg, bacon and spam
757[5] Egg, bacon, sausage and spam
758[6] Spam, bacon, sausage and spam
759[7] Spam, egg, spam, spam, bacon and spam
760[8] Spam, spam, spam, egg and spam
761[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
762[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam
763Your selection? (1-10, leave empty to abort): 9
764A fine choice: Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
765(Note: Vikings strictly optional.)
766''', 'driver program produced unexpected output'
767 result = runner.invoke(driver, ['--heading='], input='',
768 catch_exceptions=True)
769 assert result.exit_code > 0, 'driver program succeeded?!'
770 assert result.stdout == '''\
771[1] Egg and bacon
772[2] Egg, sausage and bacon
773[3] Egg and spam
774[4] Egg, bacon and spam
775[5] Egg, bacon, sausage and spam
776[6] Spam, bacon, sausage and spam
777[7] Spam, egg, spam, spam, bacon and spam
778[8] Spam, spam, spam, egg and spam
779[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
780[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam
781Your selection? (1-10, leave empty to abort): \n''', (
782 'driver program produced unexpected output'
783 )
784 assert isinstance(result.exception, IndexError), (
785 'driver program did not raise IndexError?!'
786 )
789 def test_102_prompt_for_selection_single(self, monkeypatch: Any) -> None:
790 @click.command()
791 @click.option('--item', default='baked beans')
792 @click.argument('prompt')
793 def driver(item, prompt):
794 try:
795 cli._prompt_for_selection([item], heading='',
796 single_choice_prompt=prompt)
797 except IndexError as e:
798 click.echo('Boo.')
799 raise e
800 else:
801 click.echo('Great!')
802 runner = click.testing.CliRunner(mix_stderr=True)
803 result = runner.invoke(driver, ['Will replace with spam. Confirm, y/n?'],
804 input='y')
805 assert result.exit_code == 0, 'driver program failed'
806 assert result.stdout == '''\
807[1] baked beans
808Will replace with spam. Confirm, y/n? y
809Great!
810''', 'driver program produced unexpected output'
811 result = runner.invoke(driver,
812 ['Will replace with spam, okay? ' +
813 '(Please say "y" or "n".)'],
814 input='')
815 assert result.exit_code > 0, 'driver program succeeded?!'
816 assert result.stdout == '''\
817[1] baked beans
818Will replace with spam, okay? (Please say "y" or "n".):
819Boo.
820''', 'driver program produced unexpected output'
821 assert isinstance(result.exception, IndexError), (
822 'driver program did not raise IndexError?!'
823 )
826 def test_103_prompt_for_passphrase(self, monkeypatch: Any) -> None:
827 monkeypatch.setattr(click, 'prompt',
828 lambda *a, **kw: json.dumps({'args': a, 'kwargs': kw}))
829 res = json.loads(cli._prompt_for_passphrase())
830 assert 'args' in res and 'kwargs' in res, (
831 'missing arguments to passphrase prompt'
832 )
833 assert res['args'][:1] == ['Passphrase'], (
834 'missing arguments to passphrase prompt'
835 )
836 assert (res['kwargs'].get('default') == ''
837 and not res['kwargs'].get('show_default', True)), (
838 'missing arguments to passphrase prompt'
839 )
840 assert res['kwargs'].get('err') and res['kwargs'].get('hide_input'), (
841 'missing arguments to passphrase prompt'
842 )
845 @pytest.mark.parametrize(['command_line', 'config', 'result_config'], [
846 (['--delete-globals'],
847 {'global': {'phrase': 'abc'}, 'services': {}}, {'services': {}}),
848 (['--delete', DUMMY_SERVICE],
849 {'global': {'phrase': 'abc'},
850 'services': {DUMMY_SERVICE: {'notes': '...'}}},
851 {'global': {'phrase': 'abc'}, 'services': {}}),
852 (['--clear'],
853 {'global': {'phrase': 'abc'},
854 'services': {DUMMY_SERVICE: {'notes': '...'}}},
855 {'services': {}}),
856 ])
857 def test_203_repeated_config_deletion(
858 self, monkeypatch: Any, command_line: list[str],
859 config: dpp.types.VaultConfig, result_config: dpp.types.VaultConfig,
860 ) -> None:
861 runner = click.testing.CliRunner(mix_stderr=False)
862 for start_config in [config, result_config]:
863 with tests.isolated_config(monkeypatch=monkeypatch,
864 runner=runner, config=start_config):
865 result = runner.invoke(cli.derivepassphrase, command_line,
866 catch_exceptions=False)
867 assert (result.exit_code, result.stderr_bytes) == (0, b''), (
868 'program exited with failure'
869 )
870 with open(cli._config_filename(), 'rt') as infile:
871 config_readback = json.load(infile)
872 assert config_readback == result_config
875 def test_204_phrase_from_key_manually(self) -> None:
876 assert (
877 dpp.Vault(phrase=DUMMY_PHRASE_FROM_KEY1, **DUMMY_CONFIG_SETTINGS)
878 .generate(DUMMY_SERVICE) == DUMMY_RESULT_KEY1
879 )
882 @pytest.mark.parametrize(['vfunc', 'input'], [
883 (cli._validate_occurrence_constraint, 20),
884 (cli._validate_length, 20),
885 ])
886 def test_210a_validate_constraints_manually(
887 self,
888 vfunc: Callable[[click.Context, click.Parameter, Any], int | None],
889 input: int,
890 ) -> None:
891 ctx = cli.derivepassphrase.make_context(cli.prog_name, [])
892 param = cli.derivepassphrase.params[0]
893 assert vfunc(ctx, param, input) == input
896 @tests.skip_if_no_agent
897 @pytest.mark.parametrize(['conn_hint'],
898 [('none',), ('socket',), ('client',)])
899 def test_227_get_suitable_ssh_keys(
900 self, monkeypatch: Any, conn_hint: str,
901 ) -> None:
902 monkeypatch.setattr(ssh_agent_client.SSHAgentClient,
903 'list_keys', tests.list_keys)
904 hint: ssh_agent_client.SSHAgentClient | socket.socket | None
905 match conn_hint:
906 case 'client': 906 ↛ 908line 906 didn't jump to line 908 because the pattern on line 906 always matched
907 hint = ssh_agent_client.SSHAgentClient()
908 case 'socket':
909 hint = socket.socket(family=socket.AF_UNIX)
910 hint.connect(os.environ['SSH_AUTH_SOCK'])
911 case _:
912 assert conn_hint == 'none'
913 hint = None
914 exception: Exception | None = None
915 try:
916 list(cli._get_suitable_ssh_keys(hint))
917 except RuntimeError: # pragma: no cover
918 pass
919 except Exception as e: # pragma: no cover
920 exception = e
921 finally:
922 assert exception == None, 'exception querying suitable SSH keys'