Coverage for tests / test_derivepassphrase_cli / test_vault_cli_basic_functionality.py: 100.000%
274 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-07-06 21:34 +0200
« prev ^ index » next coverage.py v7.13.1, created at 2026-07-06 21:34 +0200
1# SPDX-FileCopyrightText: 2026 Marco Ricci <software@the13thletter.info>
2#
3# SPDX-License-Identifier: Zlib
5"""Tests for the `derivepassphrase vault` command-line interface: basic functionality."""
7from __future__ import annotations
9import contextlib
10import json
11import textwrap
12import types
13from typing import TYPE_CHECKING
15import pytest
16from typing_extensions import Any, NamedTuple
18from derivepassphrase import _types, cli, ssh_agent, vault
19from derivepassphrase._internals import (
20 cli_helpers,
21)
22from derivepassphrase.ssh_agent import socketprovider
23from tests import data, machinery
24from tests.data import callables
25from tests.machinery import pytest as pytest_machinery
27if TYPE_CHECKING:
28 from collections.abc import Generator
29 from typing import NoReturn
31DUMMY_SERVICE = data.DUMMY_SERVICE
32DUMMY_PASSPHRASE = data.DUMMY_PASSPHRASE
33DUMMY_CONFIG_SETTINGS = data.DUMMY_CONFIG_SETTINGS
34DUMMY_RESULT_PASSPHRASE = data.DUMMY_RESULT_PASSPHRASE
35DUMMY_RESULT_KEY1 = data.DUMMY_RESULT_KEY1
37DUMMY_KEY1_B64 = data.DUMMY_KEY1_B64
38DUMMY_KEY2_B64 = data.DUMMY_KEY2_B64
41PROG_NAME = cli.PROG_NAME
44class IncompatibleConfiguration(NamedTuple):
45 other_options: list[tuple[str, ...]]
46 needs_service: bool | None
47 input: str | None
50class SingleConfiguration(NamedTuple):
51 needs_service: bool | None
52 input: str | None
53 check_success: bool
56class OptionCombination(NamedTuple):
57 options: list[str]
58 incompatible: bool
59 needs_service: bool | None
60 input: str | None
61 check_success: bool
64PASSPHRASE_GENERATION_OPTIONS: list[tuple[str, ...]] = [
65 ("--phrase",),
66 ("--key",),
67 ("--length", "20"),
68 ("--repeat", "20"),
69 ("--lower", "1"),
70 ("--upper", "1"),
71 ("--number", "1"),
72 ("--space", "1"),
73 ("--dash", "1"),
74 ("--symbol", "1"),
75]
76CONFIGURATION_OPTIONS: list[tuple[str, ...]] = [
77 ("--notes",),
78 ("--config",),
79 ("--delete",),
80 ("--delete-globals",),
81 ("--clear",),
82]
83CONFIGURATION_COMMANDS: list[tuple[str, ...]] = [
84 ("--delete",),
85 ("--delete-globals",),
86 ("--clear",),
87]
88STORAGE_OPTIONS: list[tuple[str, ...]] = [("--export", "-"), ("--import", "-")]
89INCOMPATIBLE: dict[tuple[str, ...], IncompatibleConfiguration] = {
90 ("--phrase",): IncompatibleConfiguration(
91 [("--key",), *CONFIGURATION_COMMANDS, *STORAGE_OPTIONS],
92 True,
93 DUMMY_PASSPHRASE,
94 ),
95 ("--key",): IncompatibleConfiguration(
96 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
97 ),
98 ("--length", "20"): IncompatibleConfiguration(
99 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
100 ),
101 ("--repeat", "20"): IncompatibleConfiguration(
102 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
103 ),
104 ("--lower", "1"): IncompatibleConfiguration(
105 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
106 ),
107 ("--upper", "1"): IncompatibleConfiguration(
108 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
109 ),
110 ("--number", "1"): IncompatibleConfiguration(
111 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
112 ),
113 ("--space", "1"): IncompatibleConfiguration(
114 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
115 ),
116 ("--dash", "1"): IncompatibleConfiguration(
117 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
118 ),
119 ("--symbol", "1"): IncompatibleConfiguration(
120 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
121 ),
122 ("--notes",): IncompatibleConfiguration(
123 CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, None
124 ),
125 ("--config", "-p"): IncompatibleConfiguration(
126 [("--delete",), ("--delete-globals",), ("--clear",), *STORAGE_OPTIONS],
127 None,
128 DUMMY_PASSPHRASE,
129 ),
130 ("--delete",): IncompatibleConfiguration(
131 [("--delete-globals",), ("--clear",), *STORAGE_OPTIONS], True, None
132 ),
133 ("--delete-globals",): IncompatibleConfiguration(
134 [("--clear",), *STORAGE_OPTIONS], False, None
135 ),
136 ("--clear",): IncompatibleConfiguration(STORAGE_OPTIONS, False, None),
137 ("--export", "-"): IncompatibleConfiguration(
138 [("--import", "-")], False, None
139 ),
140 ("--import", "-"): IncompatibleConfiguration([], False, None),
141}
142SINGLES: dict[tuple[str, ...], SingleConfiguration] = {
143 ("--phrase",): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
144 ("--key",): SingleConfiguration(True, None, False),
145 ("--length", "20"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
146 ("--repeat", "20"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
147 ("--lower", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
148 ("--upper", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
149 ("--number", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
150 ("--space", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
151 ("--dash", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
152 ("--symbol", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
153 ("--notes",): SingleConfiguration(True, None, False),
154 ("--config", "-p"): SingleConfiguration(None, DUMMY_PASSPHRASE, False),
155 ("--delete",): SingleConfiguration(True, None, False),
156 ("--delete-globals",): SingleConfiguration(False, None, True),
157 ("--clear",): SingleConfiguration(False, None, True),
158 ("--export", "-"): SingleConfiguration(False, None, True),
159 ("--import", "-"): SingleConfiguration(False, '{"services": {}}', True),
160}
161INTERESTING_OPTION_COMBINATIONS: list[OptionCombination] = []
162config: IncompatibleConfiguration | SingleConfiguration
163for opt, config in INCOMPATIBLE.items():
164 for opt2 in config.other_options:
165 INTERESTING_OPTION_COMBINATIONS.extend([
166 OptionCombination(
167 options=list(opt + opt2),
168 incompatible=True,
169 needs_service=config.needs_service,
170 input=config.input,
171 check_success=False,
172 ),
173 OptionCombination(
174 options=list(opt2 + opt),
175 incompatible=True,
176 needs_service=config.needs_service,
177 input=config.input,
178 check_success=False,
179 ),
180 ])
181for opt, config in SINGLES.items():
182 INTERESTING_OPTION_COMBINATIONS.append(
183 OptionCombination(
184 options=list(opt),
185 incompatible=False,
186 needs_service=config.needs_service,
187 input=config.input,
188 check_success=config.check_success,
189 )
190 )
193def is_warning_line(line: str) -> bool:
194 """Return true if the line is a warning line."""
195 return line.startswith(( 1c
196 f"{PROG_NAME}: Warning: ",
197 f"{PROG_NAME}: Deprecation warning: ",
198 ))
201def is_harmless_config_import_warning(record: tuple[str, int, str]) -> bool:
202 """Return true if the warning is harmless, during config import."""
203 possible_warnings = [ 1bc
204 "Replacing invalid value ",
205 "Removing ineffective setting ",
206 (
207 "Setting a global passphrase is ineffective "
208 "because a key is also set."
209 ),
210 (
211 "Setting a service passphrase is ineffective "
212 "because a key is also set:"
213 ),
214 ]
215 return any( 1bc
216 machinery.warning_emitted(w, [record]) for w in possible_warnings
217 )
220class Parametrize(types.SimpleNamespace):
221 """Common test parametrizations."""
223 AUTO_PROMPT = pytest.mark.parametrize(
224 "auto_prompt", [False, True], ids=["normal_prompt", "auto_prompt"]
225 )
226 CHARSET_NAME = pytest.mark.parametrize(
227 "charset_name", ["lower", "upper", "number", "space", "dash", "symbol"]
228 )
229 BASE_CONFIG_WITH_KEY_VARIATIONS = pytest.mark.parametrize(
230 "config",
231 [
232 pytest.param(
233 {
234 "global": {"key": DUMMY_KEY1_B64},
235 "services": {DUMMY_SERVICE: {}},
236 },
237 id="global_config",
238 ),
239 pytest.param(
240 {"services": {DUMMY_SERVICE: {"key": DUMMY_KEY2_B64}}},
241 id="service_config",
242 ),
243 pytest.param(
244 {
245 "global": {"key": DUMMY_KEY1_B64},
246 "services": {DUMMY_SERVICE: {"key": DUMMY_KEY2_B64}},
247 },
248 id="full_config",
249 ),
250 ],
251 )
252 CONFIG_WITH_KEY = pytest.mark.parametrize(
253 "config",
254 [
255 pytest.param(
256 {
257 "global": {"key": DUMMY_KEY1_B64},
258 "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS},
259 },
260 id="global",
261 ),
262 pytest.param(
263 {
264 "global": {"phrase": DUMMY_PASSPHRASE.rstrip("\n")},
265 "services": {
266 DUMMY_SERVICE: {
267 "key": DUMMY_KEY1_B64,
268 **DUMMY_CONFIG_SETTINGS,
269 }
270 },
271 },
272 id="service",
273 ),
274 ],
275 )
276 CONFIG_WITH_PHRASE = pytest.mark.parametrize(
277 "config",
278 [
279 pytest.param(
280 {
281 "global": {"phrase": DUMMY_PASSPHRASE.rstrip("\n")},
282 "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS},
283 },
284 id="global",
285 ),
286 pytest.param(
287 {
288 "global": {
289 "phrase": DUMMY_PASSPHRASE.rstrip("\n")
290 + "XXX"
291 + DUMMY_PASSPHRASE.rstrip("\n")
292 },
293 "services": {
294 DUMMY_SERVICE: {
295 "phrase": DUMMY_PASSPHRASE.rstrip("\n"),
296 **DUMMY_CONFIG_SETTINGS,
297 }
298 },
299 },
300 id="service",
301 ),
302 ],
303 )
304 KEY_OVERRIDING_IN_CONFIG = pytest.mark.parametrize(
305 ["config", "command_line"],
306 [
307 pytest.param(
308 {
309 "global": {"key": DUMMY_KEY1_B64},
310 "services": {},
311 },
312 ["--config", "-p"],
313 id="global",
314 ),
315 pytest.param(
316 {
317 "services": {
318 DUMMY_SERVICE: {
319 "key": DUMMY_KEY1_B64,
320 **DUMMY_CONFIG_SETTINGS,
321 },
322 },
323 },
324 ["--config", "-p", "--", DUMMY_SERVICE],
325 id="service",
326 ),
327 pytest.param(
328 {
329 "global": {"key": DUMMY_KEY1_B64},
330 "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()},
331 },
332 ["--config", "-p", "--", DUMMY_SERVICE],
333 id="service-over-global",
334 ),
335 ],
336 )
337 KEY_INDEX = pytest.mark.parametrize(
338 "key_index",
339 [
340 i
341 for i, x in enumerate(data.ALL_KEYS.values(), start=1)
342 if x.is_suitable()
343 ],
344 ids=lambda i: f"index{i}",
345 )
346 EXPLICIT_SSH_AGENT_SOCKET_PROVIDER = pytest.mark.parametrize(
347 ["on_command_line", "in_config"],
348 [
349 pytest.param(False, True, id="configuration_only"),
350 pytest.param(True, False, id="command_line_only"),
351 pytest.param(
352 True, True, id="command_line_overrides_configuration"
353 ),
354 ],
355 )
356 VAULT_CHARSET_OPTION = pytest.mark.parametrize(
357 "option",
358 [
359 "--lower",
360 "--upper",
361 "--number",
362 "--space",
363 "--dash",
364 "--symbol",
365 "--repeat",
366 "--length",
367 ],
368 )
369 OPTION_COMBINATIONS_INCOMPATIBLE = pytest.mark.parametrize(
370 ["options", "service"],
371 [
372 pytest.param(o.options, o.needs_service, id=" ".join(o.options))
373 for o in INTERESTING_OPTION_COMBINATIONS
374 if o.incompatible
375 ],
376 )
377 OPTION_COMBINATIONS_SERVICE_NEEDED = pytest.mark.parametrize(
378 ["options", "needs_service", "input", "check_success"],
379 [
380 pytest.param(
381 o.options,
382 o.needs_service,
383 o.input,
384 o.check_success,
385 id=" ".join(o.options),
386 )
387 for o in INTERESTING_OPTION_COMBINATIONS
388 if not o.incompatible
389 ],
390 )
393class TestHelp:
394 """Tests related to help output."""
396 def test_help_output(
397 self,
398 ) -> None:
399 """The `--help` option emits help text."""
400 runner = machinery.CliRunner(mix_stderr=False) 1s
401 # TODO(the-13th-letter): Rewrite using parenthesized
402 # with-statements.
403 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
404 with contextlib.ExitStack() as stack: 1s
405 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1s
406 stack.enter_context( 1s
407 pytest_machinery.isolated_config(
408 monkeypatch=monkeypatch,
409 runner=runner,
410 )
411 )
412 result = runner.invoke( 1s
413 cli.derivepassphrase_vault,
414 ["--help"],
415 catch_exceptions=False,
416 )
417 assert result.clean_exit( 1s
418 empty_stderr=True, output="Passphrase generation:\n"
419 ), "expected clean exit, and option groups in help text"
420 assert result.clean_exit( 1s
421 empty_stderr=True, output="Use $VISUAL or $EDITOR to configure"
422 ), "expected clean exit, and option group epilog in help text"
425class TestDerivedPassphraseConstraints:
426 """Tests for (derived) passphrase constraints."""
428 def _test(self, command_line: list[str]) -> machinery.ReadableResult:
429 runner = machinery.CliRunner(mix_stderr=False) 1mq
430 # TODO(the-13th-letter): Rewrite using parenthesized
431 # with-statements.
432 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
433 with contextlib.ExitStack() as stack: 1mq
434 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1mq
435 stack.enter_context( 1mq
436 pytest_machinery.isolated_config(
437 monkeypatch=monkeypatch,
438 runner=runner,
439 )
440 )
441 monkeypatch.setattr( 1mq
442 cli_helpers,
443 "prompt_for_passphrase",
444 callables.auto_prompt,
445 )
446 return runner.invoke( 1mq
447 cli.derivepassphrase_vault,
448 command_line,
449 input=DUMMY_PASSPHRASE,
450 catch_exceptions=False,
451 )
453 @Parametrize.CHARSET_NAME
454 def test_disable_character_set(
455 self,
456 charset_name: str,
457 ) -> None:
458 """Named character classes can be disabled on the command-line."""
459 option = f"--{charset_name}" 1m
460 charset = vault.Vault.CHARSETS[charset_name].decode("ascii") 1m
461 result = self._test([option, "0", "-p", "--", DUMMY_SERVICE]) 1m
462 assert result.clean_exit(empty_stderr=True), "expected clean exit:" 1m
463 for c in charset: 1m
464 assert c not in result.stdout, ( 1m
465 f"derived password contains forbidden character {c!r}"
466 )
468 def test_disable_repetition(
469 self,
470 ) -> None:
471 """Character repetition can be disabled on the command-line."""
472 result = self._test(["--repeat", "0", "-p", "--", DUMMY_SERVICE]) 1q
473 assert result.clean_exit(empty_stderr=True), ( 1q
474 "expected clean exit and empty stderr"
475 )
476 passphrase = result.stdout.rstrip("\r\n") 1q
477 for i in range(len(passphrase) - 1): 1q
478 assert passphrase[i : i + 1] != passphrase[i + 1 : i + 2], ( 1q
479 f"derived password contains repeated character "
480 f"at position {i}: {result.stdout!r}"
481 )
484class TestPhraseBasic:
485 """Tests for master passphrase configuration: basic."""
487 def _test(
488 self,
489 command_line: list[str],
490 /,
491 *,
492 config: _types.VaultConfig = { # noqa: B006
493 "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}
494 },
495 auto_prompt: bool = False,
496 multiline: bool = False,
497 ) -> None:
498 runner = machinery.CliRunner(mix_stderr=False) 1ik
499 # TODO(the-13th-letter): Rewrite using parenthesized
500 # with-statements.
501 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
502 with contextlib.ExitStack() as stack: 1ik
503 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1ik
504 stack.enter_context( 1ik
505 pytest_machinery.isolated_vault_config(
506 monkeypatch=monkeypatch,
507 runner=runner,
508 vault_config=config,
509 )
510 )
512 def phrase_from_key(*_args: Any, **_kwargs: Any) -> NoReturn: 1ik
513 pytest.fail("Attempted to use a key in a phrase-based test!")
515 monkeypatch.setattr( 1ik
516 vault.Vault, "phrase_from_key", phrase_from_key
517 )
518 if auto_prompt: 1ik
519 monkeypatch.setattr( 1i
520 cli_helpers,
521 "prompt_for_passphrase",
522 callables.auto_prompt,
523 )
524 result = runner.invoke( 1ik
525 cli.derivepassphrase_vault,
526 command_line,
527 input=None if auto_prompt else DUMMY_PASSPHRASE,
528 catch_exceptions=False,
529 )
530 if multiline: 1ik
531 assert result.clean_exit(), "expected clean exit" 1i
532 else:
533 assert result.clean_exit(empty_stderr=True), ( 1k
534 "expected clean exit and empty stderr"
535 )
536 assert result.stdout, "expected program output" 1ik
537 last_line = ( 1ik
538 result.stdout.splitlines(keepends=True)[-1]
539 if multiline
540 else result.stdout
541 )
542 assert ( 1ik
543 last_line.rstrip("\n").encode("UTF-8") == DUMMY_RESULT_PASSPHRASE
544 ), "expected known output"
546 @Parametrize.CONFIG_WITH_PHRASE
547 def test_phrase_from_config(
548 self,
549 config: _types.VaultConfig,
550 ) -> None:
551 """A stored configured master passphrase will be used."""
552 self._test(["--", DUMMY_SERVICE], config=config) 1k
554 @Parametrize.AUTO_PROMPT
555 def test_phrase_from_command_line(
556 self,
557 auto_prompt: bool,
558 ) -> None:
559 """A master passphrase requested on the command-line will be used."""
560 self._test( 1i
561 ["-p", "--", DUMMY_SERVICE],
562 auto_prompt=auto_prompt,
563 multiline=True,
564 )
567class TestKeyBasic:
568 """Tests for SSH key configuration: basic."""
570 @contextlib.contextmanager
571 def _setup_environment(
572 self,
573 /,
574 *,
575 config: _types.VaultConfig = { # noqa: B006
576 "services": {
577 DUMMY_SERVICE: {**DUMMY_CONFIG_SETTINGS},
578 },
579 },
580 main_config_str: str | None = None,
581 registry: dict[str, _types.SSHAgentSocketProvider | str | None]
582 | None = None,
583 ) -> Generator[machinery.CliRunner, None, None]:
584 runner = machinery.CliRunner(mix_stderr=False) 1efdh
585 # TODO(the-13th-letter): Rewrite using parenthesized
586 # with-statements.
587 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
588 with contextlib.ExitStack() as stack: 1efdh
589 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1efdh
590 stack.enter_context( 1efdh
591 pytest_machinery.isolated_vault_config(
592 monkeypatch=monkeypatch,
593 runner=runner,
594 vault_config=config,
595 main_config_str=main_config_str,
596 )
597 )
598 monkeypatch.setattr( 1efdh
599 cli_helpers,
600 "get_suitable_ssh_keys",
601 callables.suitable_ssh_keys,
602 )
603 monkeypatch.setattr( 1efdh
604 vault.Vault,
605 "phrase_from_key",
606 callables.phrase_from_key,
607 )
608 if registry is not None: 1efdh
609 monkeypatch.setattr( 1dh
610 socketprovider.SocketProvider, "registry", registry
611 )
612 monkeypatch.setattr( 1dh
613 ssh_agent.SSHAgentClient,
614 "SOCKET_PROVIDERS",
615 ("incorrect",),
616 )
617 yield runner 1efdh
619 def _check_result(
620 self, result: machinery.ReadableResult, /, *, multiline: bool = False
621 ) -> None:
622 if multiline: 1efd
623 assert result.clean_exit(), "expected clean exit" 1e
624 else:
625 assert result.clean_exit(empty_stderr=True), ( 1fd
626 "expected clean exit and empty stderr"
627 )
628 assert result.stdout, "expected program output" 1efd
629 last_line = ( 1efd
630 result.stdout.splitlines(keepends=True)[-1]
631 if multiline
632 else result.stdout
633 )
634 assert ( 1efd
635 last_line.rstrip("\n").encode("UTF-8") != DUMMY_RESULT_PASSPHRASE
636 ), "known false output: phrase-based instead of key-based"
637 assert last_line.rstrip("\n").encode("UTF-8") == DUMMY_RESULT_KEY1, ( 1efd
638 "expected known output"
639 )
641 def _test(
642 self,
643 command_line: list[str],
644 /,
645 *,
646 config: _types.VaultConfig = { # noqa: B006
647 "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}
648 },
649 main_config_str: str | None = None,
650 registry: dict[str, _types.SSHAgentSocketProvider | str | None]
651 | None = None,
652 multiline: bool = False,
653 input: str | bytes | None = None,
654 ) -> None:
655 with self._setup_environment( 1efd
656 config=config, main_config_str=main_config_str, registry=registry
657 ) as runner:
658 result = runner.invoke( 1efd
659 cli.derivepassphrase_vault,
660 command_line,
661 input=input,
662 catch_exceptions=False,
663 )
664 self._check_result(result, multiline=multiline) 1efd
666 @Parametrize.CONFIG_WITH_KEY
667 def test_key_from_config(
668 self,
669 running_ssh_agent: data.RunningSSHAgentInfo,
670 config: _types.VaultConfig,
671 ) -> None:
672 """A stored configured SSH key will be used."""
673 del running_ssh_agent 1f
674 self._test(["--", DUMMY_SERVICE], config=config) 1f
676 def test_key_from_command_line(
677 self,
678 running_ssh_agent: data.RunningSSHAgentInfo,
679 ) -> None:
680 """An SSH key requested on the command-line will be used."""
681 del running_ssh_agent 1e
682 self._test(["-k", "--", DUMMY_SERVICE], input="1\n", multiline=True) 1e
685@pytest.fixture
686def provider_registry() -> dict[
687 str, _types.SSHAgentSocketProvider | str | None
688]:
689 """Set up a controlled SSH agent socket provider registry."""
691 def err() -> _types.SSHAgentSocket:
692 pytest.fail("Attempting to use the wrong SSH agent socket provider!")
694 return {
695 "correct": machinery.StubbedSSHAgentSocket,
696 "incorrect": err,
697 }
700# TODO(the-13th-letter): Rewrite the parametrization to be somewhat more
701# transparent, but keep it this high-level if possible.
702class TestKeyExplicitSSHAgentSocketProvider(TestKeyBasic):
703 """Tests for SSH key configuration: explicit SSH agent socket providers."""
705 ARG = "--ssh-agent-socket-provider=correct"
706 ARG_NONEXISTANT = "--ssh-agent-socket-provider=nonexistant"
707 MAIN_CONFIG_STR = textwrap.dedent(r"""
708 [vault]
709 ssh-agent-socket-provider = "correct"
710 """)
711 WRONG_CONFIG_STR = textwrap.dedent(r"""
712 [vault]
713 ssh-agent-socket-provider = "incorrect"
714 """)
715 NONEXISTANT_CONFIG_STR = textwrap.dedent(r"""
716 [vault]
717 ssh-agent-socket-provider = "nonexistant"
718 """)
720 @Parametrize.EXPLICIT_SSH_AGENT_SOCKET_PROVIDER
721 @Parametrize.CONFIG_WITH_KEY
722 def test_explicit_ssh_agent_socket_provider(
723 self,
724 provider_registry: dict[
725 str, _types.SSHAgentSocketProvider | str | None
726 ],
727 config: _types.VaultConfig,
728 on_command_line: bool,
729 in_config: bool,
730 ) -> None:
731 args = [self.ARG] if on_command_line else [] 1d
732 main_config_str = ( 1d
733 self.WRONG_CONFIG_STR
734 if in_config and on_command_line
735 else self.MAIN_CONFIG_STR
736 if in_config
737 else None
738 )
739 self._test( 1d
740 [*args, "--", DUMMY_SERVICE],
741 config=config,
742 registry=provider_registry,
743 main_config_str=main_config_str,
744 )
746 @Parametrize.EXPLICIT_SSH_AGENT_SOCKET_PROVIDER
747 @Parametrize.CONFIG_WITH_KEY
748 def test_explicit_ssh_agent_socket_provider_not_found(
749 self,
750 provider_registry: dict[
751 str, _types.SSHAgentSocketProvider | str | None
752 ],
753 config: _types.VaultConfig,
754 on_command_line: bool,
755 in_config: bool,
756 ) -> None:
757 assert "nonexistant" not in provider_registry, ( 1h
758 '"nonexistant" name actually found in registry?!'
759 )
760 args = [self.ARG_NONEXISTANT] if on_command_line else [] 1h
761 main_config_str = ( 1h
762 self.MAIN_CONFIG_STR
763 if in_config and on_command_line
764 else self.NONEXISTANT_CONFIG_STR
765 if in_config
766 else None
767 )
768 with self._setup_environment( 1h
769 config=config,
770 registry=provider_registry,
771 main_config_str=main_config_str,
772 ) as runner:
773 result = runner.invoke( 1h
774 cli.derivepassphrase_vault,
775 [*args, "--", DUMMY_SERVICE],
776 catch_exceptions=False,
777 )
778 assert result.error_exit( 1h
779 error=" is not in derivepassphrase's provider registry."
780 )
783class TestPhraseAndKeyOverriding:
784 """Tests for master passphrase and SSH key configuration: overriding."""
786 def _test(
787 self,
788 command_line: list[str],
789 /,
790 *,
791 config: _types.VaultConfig = { # noqa: B006
792 "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}
793 },
794 input: str | bytes | None = None,
795 ) -> machinery.ReadableResult:
796 runner = machinery.CliRunner(mix_stderr=False) 1ljc
797 # TODO(the-13th-letter): Rewrite using parenthesized
798 # with-statements.
799 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
800 with contextlib.ExitStack() as stack: 1ljc
801 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1ljc
802 stack.enter_context( 1ljc
803 pytest_machinery.isolated_vault_config(
804 monkeypatch=monkeypatch,
805 runner=runner,
806 vault_config=config,
807 )
808 )
809 monkeypatch.setattr( 1ljc
810 ssh_agent.SSHAgentClient,
811 "list_keys",
812 callables.list_keys,
813 )
814 monkeypatch.setattr( 1ljc
815 ssh_agent.SSHAgentClient, "sign", callables.sign
816 )
817 result = runner.invoke( 1ljc
818 cli.derivepassphrase_vault,
819 command_line,
820 input=input,
821 catch_exceptions=False,
822 )
823 assert result.clean_exit(), "expected clean exit" 1ljc
824 return result 1ljc
826 @Parametrize.BASE_CONFIG_WITH_KEY_VARIATIONS
827 @Parametrize.KEY_INDEX
828 def test_key_override_on_command_line(
829 self,
830 use_stub_agent_with_address: None,
831 config: _types.VaultConfig,
832 key_index: int,
833 ) -> None:
834 """A command-line SSH key will override the configured key."""
835 del use_stub_agent_with_address 1l
836 result = self._test( 1l
837 ["-k", "--", DUMMY_SERVICE],
838 config=config,
839 input=f"{key_index}\n",
840 )
841 assert result.stdout, "expected program output" 1l
842 assert result.stderr, "expected stderr" 1l
843 assert "Error:" not in result.stderr, ( 1l
844 "expected no error messages on stderr"
845 )
847 def test_service_phrase_if_key_in_global_config(
848 self,
849 use_stub_agent_with_address: None,
850 ) -> None:
851 """A configured passphrase does not override a configured key."""
852 del use_stub_agent_with_address 1j
853 result = self._test( 1j
854 ["--", DUMMY_SERVICE],
855 config={
856 "global": {"key": DUMMY_KEY1_B64},
857 "services": {
858 DUMMY_SERVICE: {
859 "phrase": DUMMY_PASSPHRASE.rstrip("\n"),
860 **DUMMY_CONFIG_SETTINGS,
861 }
862 },
863 },
864 )
865 assert result.stdout, "expected program output" 1j
866 last_line = result.stdout.splitlines(True)[-1] 1j
867 assert ( 1j
868 last_line.rstrip("\n").encode("UTF-8") != DUMMY_RESULT_PASSPHRASE
869 ), "known false output: phrase-based instead of key-based"
870 assert last_line.rstrip("\n").encode("UTF-8") == DUMMY_RESULT_KEY1, ( 1j
871 "expected known output"
872 )
874 @Parametrize.KEY_OVERRIDING_IN_CONFIG
875 def test_setting_phrase_thus_overriding_key_in_config(
876 self,
877 use_stub_agent_with_address: None,
878 caplog: pytest.LogCaptureFixture,
879 config: _types.VaultConfig,
880 command_line: list[str],
881 ) -> None:
882 """Configuring a passphrase atop an SSH key works, but warns."""
883 del use_stub_agent_with_address 1c
884 result = self._test( 1c
885 command_line, config=config, input=DUMMY_PASSPHRASE
886 )
887 assert not result.stdout.strip(), "expected no program output" 1c
888 assert result.stderr, "expected known error output" 1c
889 # First line contains passphrase prompt. Depending on the click
890 # version, either this is a standalone line, or the first warning
891 # line is attached to the end. So it needs special treatment.
892 err_lines = result.stderr.splitlines(True) 1c
893 prompt_line = err_lines[0] 1c
894 assert prompt_line.startswith("Passphrase:") 1c
895 assert machinery.warning_emitted( 1c
896 "Setting a service passphrase is ineffective ",
897 caplog.record_tuples,
898 ) or machinery.warning_emitted(
899 "Setting a global passphrase is ineffective ",
900 caplog.record_tuples,
901 ), "expected known warning message"
902 remainder = prompt_line.removeprefix("Passphrase:").lstrip() 1c
903 other_lines = ( 1c
904 [remainder, *err_lines[1:]] if remainder else err_lines[1:]
905 )
906 assert all(map(is_warning_line, other_lines)) 1c
907 assert all( 1c
908 map(is_harmless_config_import_warning, caplog.record_tuples)
909 ), "unexpected error output"
912class TestInvalidCommandLines:
913 """Tests concerning invalid command-lines."""
915 @contextlib.contextmanager
916 def _setup_environment(
917 self,
918 /,
919 *,
920 auto_prompt: bool = False,
921 config: _types.VaultConfig = { # noqa: B006
922 "services": {
923 DUMMY_SERVICE: {**DUMMY_CONFIG_SETTINGS},
924 },
925 },
926 ) -> Generator[machinery.CliRunner, None, None]:
927 runner = machinery.CliRunner(mix_stderr=False) 1bnropg
928 # TODO(the-13th-letter): Rewrite using parenthesized
929 # with-statements.
930 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
931 with contextlib.ExitStack() as stack: 1bnropg
932 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1bnropg
933 stack.enter_context( 1bnropg
934 pytest_machinery.isolated_vault_config(
935 monkeypatch=monkeypatch,
936 runner=runner,
937 vault_config=config,
938 )
939 )
940 if auto_prompt: 1bnropg
941 monkeypatch.setattr( 1bnopg
942 cli_helpers,
943 "prompt_for_passphrase",
944 callables.auto_prompt,
945 )
946 yield runner 1bnropg
948 def _call(
949 self,
950 command_line: list[str],
951 /,
952 *,
953 config: _types.VaultConfig = { # noqa: B006
954 "services": {
955 DUMMY_SERVICE: {**DUMMY_CONFIG_SETTINGS},
956 },
957 },
958 input: str | bytes | None = None,
959 runner: machinery.CliRunner | None = None,
960 ) -> machinery.ReadableResult:
961 if runner: 1bnopg
962 return runner.invoke( 1b
963 cli.derivepassphrase_vault,
964 command_line,
965 input=input,
966 catch_exceptions=False,
967 )
968 with self._setup_environment( 1nopg
969 config=config, auto_prompt=input is not None
970 ) as runner2:
971 return runner2.invoke( 1nopg
972 cli.derivepassphrase_vault,
973 command_line,
974 input=input,
975 catch_exceptions=False,
976 )
978 @Parametrize.VAULT_CHARSET_OPTION
979 def test_invalid_argument_range(
980 self,
981 option: str,
982 ) -> None:
983 """Requesting invalidly many characters from a class fails."""
984 with self._setup_environment() as runner: 1r
985 for value in "-42", "invalid": 1r
986 result = runner.invoke( 1r
987 cli.derivepassphrase_vault,
988 [option, value, "-p", "--", DUMMY_SERVICE],
989 input=DUMMY_PASSPHRASE,
990 catch_exceptions=False,
991 )
992 assert result.error_exit(error="Invalid value"), ( 1r
993 "expected error exit and known error message"
994 )
996 @Parametrize.OPTION_COMBINATIONS_SERVICE_NEEDED
997 def test_service_needed(
998 self,
999 options: list[str],
1000 needs_service: bool | None,
1001 input: str | None,
1002 check_success: bool,
1003 ) -> None:
1004 """We require or forbid a service argument, depending on options."""
1005 config: _types.VaultConfig = { 1g
1006 "global": {"phrase": "abc"},
1007 "services": {},
1008 }
1009 result = self._call( 1g
1010 options if needs_service else [*options, "--", DUMMY_SERVICE],
1011 config=config,
1012 input=input,
1013 )
1014 if needs_service is not None: 1g
1015 err_msg = ( 1g
1016 " requires a SERVICE"
1017 if needs_service
1018 else " does not take a SERVICE argument"
1019 )
1020 assert result.error_exit(error=err_msg), ( 1g
1021 "expected error exit and known error message"
1022 )
1023 if check_success: 1g
1024 result = self._call( 1g
1025 [*options, "--", DUMMY_SERVICE]
1026 if needs_service
1027 else options,
1028 config=config,
1029 input=input,
1030 )
1031 assert result.clean_exit(empty_stderr=True), ( 1g
1032 "expected clean exit"
1033 )
1034 else:
1035 assert result.clean_exit(empty_stderr=True), "expected clean exit" 1g
1037 def test_empty_service_name_causes_warning(
1038 self,
1039 caplog: pytest.LogCaptureFixture,
1040 ) -> None:
1041 """Using an empty service name (where permissible) warns.
1043 Only the `--config` option can optionally take a service name.
1045 """
1047 def is_expected_warning(record: tuple[str, int, str]) -> bool: 1b
1048 return is_harmless_config_import_warning( 1b
1049 record
1050 ) or machinery.warning_emitted(
1051 "An empty SERVICE is not supported by vault(1)", [record]
1052 )
1054 def check_result(result: machinery.ReadableResult) -> None: 1b
1055 assert result.clean_exit(empty_stderr=False), "expected clean exit" 1b
1056 assert result.stderr is not None, "expected known error output" 1b
1057 assert all(map(is_expected_warning, caplog.record_tuples)), ( 1b
1058 "expected known error output"
1059 )
1061 with self._setup_environment( 1b
1062 config={"services": {}}, auto_prompt=True
1063 ) as runner:
1064 result = self._call( 1b
1065 ["--config", "--length=30", "--", ""], runner=runner
1066 )
1067 check_result(result) 1b
1068 assert cli_helpers.load_config() == { 1b
1069 "global": {"length": 30},
1070 "services": {},
1071 }, "requested configuration change was not applied"
1072 caplog.clear() 1b
1073 result = self._call( 1b
1074 ["--import", "-"],
1075 input=json.dumps({"services": {"": {"length": 40}}}),
1076 runner=runner,
1077 )
1078 check_result(result) 1b
1079 assert cli_helpers.load_config() == { 1b
1080 "global": {"length": 30},
1081 "services": {"": {"length": 40}},
1082 }, "requested configuration change was not applied"
1084 @Parametrize.OPTION_COMBINATIONS_INCOMPATIBLE
1085 def test_incompatible_options(
1086 self,
1087 options: list[str],
1088 service: bool | None,
1089 ) -> None:
1090 """Incompatible options are detected."""
1091 result = self._call( 1n
1092 [*options, "--", DUMMY_SERVICE] if service else options,
1093 input=DUMMY_PASSPHRASE,
1094 )
1095 assert result.error_exit(error="mutually exclusive with "), ( 1n
1096 "expected error exit and known error message"
1097 )
1099 def test_no_arguments(self) -> None:
1100 """Calling `derivepassphrase vault` without any arguments fails."""
1101 result = self._call([], input=DUMMY_PASSPHRASE) 1o
1102 assert result.error_exit( 1o
1103 error="Deriving a passphrase requires a SERVICE"
1104 ), "expected error exit and known error message"
1106 def test_no_passphrase_or_key(
1107 self,
1108 ) -> None:
1109 """Deriving a passphrase without a passphrase or key fails."""
1110 result = self._call(["--", DUMMY_SERVICE], input=DUMMY_PASSPHRASE) 1p
1111 assert result.error_exit(error="No passphrase or key was given"), ( 1p
1112 "expected error exit and known error message"
1113 )