Coverage for tests / test_derivepassphrase_cli / test_vault_cli_config_management.py: 100.000%
245 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: config management.
7This includes tests for importing, exporting and setting the
8configuration, whether validly or invalidly. It does not contain any
9configuration that is cross-subsystem or that doesn't pertain to
10a `vault`-specific CLI call; those are [basic and common subsystem
11tests][tests.test_derivepassphrase_cli.test_000_basic].
13"""
15from __future__ import annotations
17import contextlib
18import copy
19import errno
20import json
21import os
22import shutil
23import types
24from typing import TYPE_CHECKING
26import hypothesis
27import pytest
28from hypothesis import strategies
30from derivepassphrase import _types, cli, ssh_agent
31from derivepassphrase._internals import (
32 cli_helpers,
33)
34from tests import data, machinery
35from tests.data import callables
36from tests.machinery import hypothesis as hypothesis_machinery
37from tests.machinery import pytest as pytest_machinery
39if TYPE_CHECKING:
40 from collections.abc import Generator
41 from typing import NoReturn
43 from typing_extensions import Any
45DUMMY_SERVICE = data.DUMMY_SERVICE
47DUMMY_KEY1_B64 = data.DUMMY_KEY1_B64
50def is_harmless_config_import_warning(record: tuple[str, int, str]) -> bool:
51 """Return true if the warning is harmless, during config import."""
52 possible_warnings = [ 1gbc
53 "Replacing invalid value ",
54 "Removing ineffective setting ",
55 (
56 "Setting a global passphrase is ineffective "
57 "because a key is also set."
58 ),
59 (
60 "Setting a service passphrase is ineffective "
61 "because a key is also set:"
62 ),
63 ]
64 return any( 1gbc
65 machinery.warning_emitted(w, [record]) for w in possible_warnings
66 )
69def assert_vault_config_is_indented_and_line_broken(
70 config_txt: str,
71 /,
72) -> None:
73 """Return true if the vault configuration is indented and line broken.
75 Indented and rewrapped vault configurations as produced by
76 `json.dump` contain the closing '}' of the '$.services' object
77 on a separate, indented line:
79 ~~~~
80 {
81 "services": {
82 ...
83 } <-- this brace here
84 }
85 ~~~~
87 or, if there are no services, then the indented line
89 ~~~~
90 "services": {}
91 ~~~~
93 Both variations may end with a comma if there are more top-level
94 keys.
96 """
97 known_indented_lines = { 1dgebch
98 "}",
99 "},",
100 '"services": {}',
101 '"services": {},',
102 }
103 assert any([ 1dgebch
104 line.strip() in known_indented_lines and line.startswith((" ", "\t"))
105 for line in config_txt.splitlines()
106 ])
109class Parametrize(types.SimpleNamespace):
110 """Common test parametrizations."""
112 CONFIG_EDITING_VIA_CONFIG_FLAG_FAILURES = pytest.mark.parametrize(
113 ["command_line", "input", "err_text"],
114 [
115 pytest.param(
116 [],
117 "",
118 "Cannot update the global settings without any given settings",
119 id="None",
120 ),
121 pytest.param(
122 ["--", "sv"],
123 "",
124 "Cannot update the service-specific settings without any given settings",
125 id="None-sv",
126 ),
127 pytest.param(
128 ["--phrase", "--", "sv"],
129 "\n",
130 "No passphrase was given",
131 id="phrase-sv",
132 ),
133 pytest.param(
134 ["--phrase", "--", "sv"],
135 "",
136 "No passphrase was given",
137 id="phrase-sv-eof",
138 ),
139 pytest.param(
140 ["--key"],
141 "\n",
142 "No SSH key was selected",
143 id="key-sv",
144 ),
145 pytest.param(
146 ["--key"],
147 "",
148 "No SSH key was selected",
149 id="key-sv-eof",
150 ),
151 ],
152 )
153 CONFIG_EDITING_VIA_CONFIG_FLAG = hypothesis_machinery.explicit_examples(
154 ["command_line", "input", "starting_config", "result_config"],
155 [
156 pytest.param(
157 ["--phrase"],
158 "my passphrase\n",
159 {"global": {"phrase": "abc"}, "services": {}},
160 {"global": {"phrase": "my passphrase"}, "services": {}},
161 id="phrase",
162 ),
163 pytest.param(
164 ["--key"],
165 "1\n",
166 {"global": {"phrase": "abc"}, "services": {}},
167 {
168 "global": {"key": DUMMY_KEY1_B64, "phrase": "abc"},
169 "services": {},
170 },
171 id="key",
172 ),
173 pytest.param(
174 ["--phrase", "--", "sv"],
175 "my passphrase\n",
176 {"global": {"phrase": "abc"}, "services": {}},
177 {
178 "global": {"phrase": "abc"},
179 "services": {"sv": {"phrase": "my passphrase"}},
180 },
181 id="phrase-sv",
182 ),
183 pytest.param(
184 ["--key", "--", "sv"],
185 "1\n",
186 {"global": {"phrase": "abc"}, "services": {}},
187 {
188 "global": {"phrase": "abc"},
189 "services": {"sv": {"key": DUMMY_KEY1_B64}},
190 },
191 id="key-sv",
192 ),
193 pytest.param(
194 ["--key", "--length", "15", "--", "sv"],
195 "1\n",
196 {"global": {"phrase": "abc"}, "services": {}},
197 {
198 "global": {"phrase": "abc"},
199 "services": {"sv": {"key": DUMMY_KEY1_B64, "length": 15}},
200 },
201 id="key-length-sv",
202 ),
203 ],
204 )
205 VALID_TEST_CONFIGS = pytest.mark.parametrize(
206 "config",
207 [conf.config for conf in data.TEST_CONFIGS if conf.is_valid()],
208 )
209 EXPORT_FORMAT_OPTIONS = pytest.mark.parametrize(
210 "export_options",
211 [
212 [],
213 ["--export-as=sh"],
214 ],
215 ids=["json-format", "sh-format"],
216 )
217 TRY_RACE_FREE_IMPLEMENTATION = pytest.mark.parametrize(
218 "try_race_free_implementation",
219 [False, True],
220 ids=["racy", "maybe-race-free"],
221 )
224class TestImportConfigValid:
225 """Tests concerning `vault` configuration imports: valid imports."""
227 def _test(
228 self,
229 /,
230 *,
231 caplog: pytest.LogCaptureFixture,
232 config: _types.VaultConfig,
233 ) -> None:
234 config2 = copy.deepcopy(config) 1gc
235 _types.clean_up_falsy_vault_config_values(config2) 1gc
236 runner = machinery.CliRunner(mix_stderr=False) 1gc
237 # TODO(the-13th-letter): Rewrite using parenthesized
238 # with-statements.
239 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
240 with contextlib.ExitStack() as stack: 1gc
241 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1gc
242 stack.enter_context( 1gc
243 pytest_machinery.isolated_vault_config(
244 monkeypatch=monkeypatch,
245 runner=runner,
246 vault_config={"services": {}},
247 )
248 )
249 result = runner.invoke( 1gc
250 cli.derivepassphrase_vault,
251 ["--import", "-"],
252 input=json.dumps(config),
253 catch_exceptions=False,
254 )
255 config_txt = cli_helpers.config_filename( 1gc
256 subsystem="vault"
257 ).read_text(encoding="UTF-8")
258 config3 = json.loads(config_txt) 1gc
259 assert result.clean_exit(empty_stderr=False), "expected clean exit" 1gc
260 assert config3 == config2, "config not imported correctly" 1gc
261 assert not result.stderr or all( 1gc
262 map(is_harmless_config_import_warning, caplog.record_tuples)
263 ), "unexpected error output"
264 assert_vault_config_is_indented_and_line_broken(config_txt) 1gc
266 @Parametrize.VALID_TEST_CONFIGS
267 def test_normal_config(
268 self,
269 caplog: pytest.LogCaptureFixture,
270 config: Any,
271 ) -> None:
272 """Importing a configuration works."""
273 self._test(caplog=caplog, config=config) 1g
275 @hypothesis.settings(
276 suppress_health_check=[
277 *hypothesis.settings().suppress_health_check,
278 hypothesis.HealthCheck.function_scoped_fixture,
279 ],
280 )
281 @hypothesis.given(
282 conf=hypothesis_machinery.smudged_vault_test_config(
283 strategies.sampled_from([
284 conf for conf in data.TEST_CONFIGS if conf.is_valid()
285 ])
286 )
287 )
288 def test_smudged_config(
289 self,
290 caplog: pytest.LogCaptureFixture,
291 conf: data.VaultTestConfig,
292 ) -> None:
293 """Importing a smudged configuration works.
295 Tested via hypothesis.
297 """
298 # Reset caplog between hypothesis runs.
299 caplog.clear() 1c
300 self._test(caplog=caplog, config=conf.config) 1c
303class TestImportConfigInvalid:
304 """Tests concerning `vault` configuration imports: invalid imports."""
306 @contextlib.contextmanager
307 def _setup_environment(self) -> Generator[machinery.CliRunner, None, None]:
308 runner = machinery.CliRunner(mix_stderr=False) 1uwx
309 # TODO(the-13th-letter): Rewrite using parenthesized
310 # with-statements.
311 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
312 with contextlib.ExitStack() as stack: 1uwx
313 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1uwx
314 stack.enter_context( 1uwx
315 pytest_machinery.isolated_config(
316 monkeypatch=monkeypatch,
317 runner=runner,
318 )
319 )
320 yield runner 1uwx
322 def _test(
323 self,
324 command_line: list[str],
325 /,
326 *,
327 input: str | bytes | None = None,
328 ) -> machinery.ReadableResult:
329 with self._setup_environment() as runner: 1wx
330 return runner.invoke( 1wx
331 cli.derivepassphrase_vault,
332 command_line,
333 input=input,
334 catch_exceptions=False,
335 )
337 def test_not_a_vault_config(
338 self,
339 ) -> None:
340 """Importing an invalid config fails."""
341 result = self._test(["--import", "-"], input="null") 1w
342 assert result.error_exit(error="Invalid vault config"), ( 1w
343 "expected error exit and known error message"
344 )
346 def test_not_json_data(
347 self,
348 ) -> None:
349 """Importing an invalid config fails."""
350 result = self._test( 1x
351 ["--import", "-"], input="This string is not valid JSON."
352 )
353 assert result.error_exit(error="cannot decode JSON"), ( 1x
354 "expected error exit and known error message"
355 )
357 def test_not_a_file(
358 self,
359 ) -> None:
360 """Importing an invalid config fails."""
361 with self._setup_environment() as runner: 1u
362 # `_setup_environment` (via `isolated_vault_config`) ensures
363 # the configuration is valid JSON. So, to pass an actual
364 # broken configuration, we must open the configuration file
365 # ourselves afterwards, inside the context.
366 cli_helpers.config_filename(subsystem="vault").write_text( 1u
367 "This string is not valid JSON.\n", encoding="UTF-8"
368 )
369 dname = cli_helpers.config_filename(subsystem=None) 1u
370 result = runner.invoke( 1u
371 cli.derivepassphrase_vault,
372 ["--import", os.fsdecode(dname)],
373 catch_exceptions=False,
374 )
375 # The Annoying OS uses EACCES, other OSes use EISDIR.
376 assert result.error_exit( 1u
377 error=os.strerror(errno.EISDIR)
378 ) or result.error_exit(error=os.strerror(errno.EACCES)), (
379 "expected error exit and known error message"
380 )
383class TestExportConfigValid:
384 """Tests concerning `vault` configuration exports: valid exports."""
386 def _assert_result(
387 self,
388 result: machinery.ReadableResult,
389 /,
390 *,
391 caplog: pytest.LogCaptureFixture,
392 ) -> None:
393 assert result.clean_exit(empty_stderr=False), "expected clean exit" 1db
394 assert not result.stderr or all( 1db
395 map(is_harmless_config_import_warning, caplog.record_tuples)
396 ), "unexpected error output"
398 def _test(
399 self,
400 /,
401 *,
402 caplog: pytest.LogCaptureFixture,
403 config: _types.VaultConfig,
404 use_import: bool = False,
405 ) -> None:
406 config2 = copy.deepcopy(config) 1db
407 _types.clean_up_falsy_vault_config_values(config2) 1db
408 runner = machinery.CliRunner(mix_stderr=False) 1db
409 # TODO(the-13th-letter): Rewrite using parenthesized
410 # with-statements.
411 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
412 with contextlib.ExitStack() as stack: 1db
413 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1db
414 stack.enter_context( 1db
415 pytest_machinery.isolated_vault_config(
416 monkeypatch=monkeypatch,
417 runner=runner,
418 vault_config={"services": {}},
419 )
420 )
421 if use_import: 1db
422 result1 = runner.invoke( 1b
423 cli.derivepassphrase_vault,
424 ["--import", "-"],
425 input=json.dumps(config),
426 catch_exceptions=False,
427 )
428 self._assert_result(result1, caplog=caplog) 1b
429 else:
430 with cli_helpers.config_filename(subsystem="vault").open( 1d
431 "w", encoding="UTF-8"
432 ) as outfile:
433 # Ensure the config is written on one line.
434 json.dump(config, outfile, indent=None) 1d
435 result = runner.invoke( 1db
436 cli.derivepassphrase_vault,
437 ["--export", "-"],
438 catch_exceptions=False,
439 )
440 self._assert_result(result, caplog=caplog) 1db
441 config3 = json.loads(result.stdout) 1db
442 assert config3 == config2, "config not exported correctly" 1db
443 assert_vault_config_is_indented_and_line_broken(result.stdout) 1db
445 @Parametrize.VALID_TEST_CONFIGS
446 def test_normal_config(
447 self,
448 caplog: pytest.LogCaptureFixture,
449 config: Any,
450 ) -> None:
451 """Exporting a configuration works."""
452 self._test(caplog=caplog, config=config, use_import=False) 1d
454 @hypothesis.settings(
455 suppress_health_check=[
456 *hypothesis.settings().suppress_health_check,
457 hypothesis.HealthCheck.function_scoped_fixture,
458 ],
459 )
460 @hypothesis.given(
461 conf=hypothesis_machinery.smudged_vault_test_config(
462 strategies.sampled_from([
463 conf for conf in data.TEST_CONFIGS if conf.is_valid()
464 ])
465 )
466 )
467 def test_reexport_smudged_config(
468 self,
469 caplog: pytest.LogCaptureFixture,
470 conf: data.VaultTestConfig,
471 ) -> None:
472 """Re-exporting a smudged configuration works.
474 Tested via hypothesis.
476 """
477 # Reset caplog between hypothesis runs.
478 caplog.clear() 1b
479 self._test(caplog=caplog, config=conf.config, use_import=True) 1b
481 @Parametrize.EXPORT_FORMAT_OPTIONS
482 def test_no_stored_settings(
483 self,
484 export_options: list[str],
485 ) -> None:
486 """Exporting the default, empty config works."""
487 runner = machinery.CliRunner(mix_stderr=False) 1z
488 # TODO(the-13th-letter): Rewrite using parenthesized
489 # with-statements.
490 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
491 with contextlib.ExitStack() as stack: 1z
492 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1z
493 stack.enter_context( 1z
494 pytest_machinery.isolated_config(
495 monkeypatch=monkeypatch,
496 runner=runner,
497 )
498 )
499 cli_helpers.config_filename(subsystem="vault").unlink( 1z
500 missing_ok=True
501 )
502 result = runner.invoke( 1z
503 # Test parent context navigation by not calling
504 # `cli.derivepassphrase_vault` directly. Used e.g. in
505 # the `--export-as=sh` section to autoconstruct the
506 # program name correctly.
507 cli.derivepassphrase,
508 ["vault", "--export", "-", *export_options],
509 catch_exceptions=False,
510 )
511 assert result.clean_exit(empty_stderr=True), "expected clean exit" 1z
512 assert result.stdout.startswith("#!") or json.loads(result.stdout) == { 1z
513 "services": {}
514 }
517class TestExportConfigInvalid:
518 """Tests concerning `vault` configuration exports: invalid exports."""
520 @contextlib.contextmanager
521 def _test(
522 self,
523 command_line: list[str],
524 /,
525 *,
526 config: _types.VaultConfig = {"services": {}}, # noqa: B006
527 error_messages: tuple[str, ...] = (),
528 ) -> Generator[list[str], None, None]:
529 runner = machinery.CliRunner(mix_stderr=False) 1yonv
530 # TODO(the-13th-letter): Rewrite using parenthesized
531 # with-statements.
532 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
533 with contextlib.ExitStack() as stack: 1yonv
534 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1yonv
535 stack.enter_context( 1yonv
536 pytest_machinery.isolated_vault_config(
537 monkeypatch=monkeypatch,
538 runner=runner,
539 vault_config=config,
540 )
541 )
542 # If the setup blows up, bubble the exception.
543 yield command_line # noqa: RUF075 1yonv
544 result = runner.invoke( 1yonv
545 cli.derivepassphrase_vault,
546 command_line,
547 input="null",
548 catch_exceptions=False,
549 )
550 assert any([result.error_exit(error=msg) for msg in error_messages]), ( 1yonv
551 "expected error exit and known error message"
552 )
554 @Parametrize.EXPORT_FORMAT_OPTIONS
555 def test_bad_stored_config(
556 self,
557 export_options: list[str],
558 ) -> None:
559 """Exporting an invalid config fails."""
560 with self._test( 1y
561 ["--export", "-", *export_options],
562 config=None, # type: ignore[arg-type,typeddict-item]
563 error_messages=("Cannot load vault settings:",),
564 ):
565 pass 1y
567 @Parametrize.EXPORT_FORMAT_OPTIONS
568 def test_not_a_file(
569 self,
570 export_options: list[str],
571 ) -> None:
572 """Exporting an invalid config fails."""
573 with self._test( 1o
574 ["--export", "-", *export_options],
575 error_messages=("Cannot load vault settings:",),
576 ):
577 config_file = cli_helpers.config_filename(subsystem="vault") 1o
578 config_file.unlink(missing_ok=True) 1o
579 config_file.mkdir(parents=True, exist_ok=True) 1o
581 @Parametrize.EXPORT_FORMAT_OPTIONS
582 def test_target_not_a_file(
583 self,
584 export_options: list[str],
585 ) -> None:
586 """Exporting an invalid config fails."""
587 with self._test( 1v
588 [], error_messages=("Cannot export vault settings:",)
589 ) as command_line:
590 dname = cli_helpers.config_filename(subsystem=None) 1v
591 command_line[:] = ["--export", os.fsdecode(dname), *export_options] 1v
593 @pytest_machinery.skip_if_on_the_annoying_os
594 @Parametrize.EXPORT_FORMAT_OPTIONS
595 def test_settings_directory_not_a_directory(
596 self,
597 export_options: list[str],
598 ) -> None:
599 """Exporting an invalid config fails."""
600 with self._test( 1n
601 ["--export", "-", *export_options],
602 error_messages=(
603 "Cannot load vault settings:",
604 "Cannot load user config:",
605 ),
606 ):
607 config_dir = cli_helpers.config_filename(subsystem=None) 1n
608 with contextlib.suppress(FileNotFoundError): 1n
609 shutil.rmtree(config_dir) 1n
610 config_dir.write_text("Obstruction!!\n") 1n
613class TestStoringConfigurationSuccesses:
614 """Tests concerning storing the configuration: successes."""
616 def _test(
617 self,
618 command_line: list[str],
619 /,
620 *,
621 starting_config: _types.VaultConfig | None,
622 result_config: _types.VaultConfig,
623 input: str | bytes | None = None,
624 ) -> machinery.ReadableResult:
625 runner = machinery.CliRunner(mix_stderr=False) 1eh
626 # TODO(the-13th-letter): Rewrite using parenthesized
627 # with-statements.
628 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
629 with contextlib.ExitStack() as stack: 1eh
630 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1eh
631 stack.enter_context( 1eh
632 pytest_machinery.isolated_vault_config(
633 monkeypatch=monkeypatch,
634 runner=runner,
635 vault_config=starting_config,
636 )
637 )
638 if starting_config is None: 1eh
639 with contextlib.suppress(FileNotFoundError): 1e
640 shutil.rmtree(cli_helpers.config_filename(subsystem=None)) 1e
641 monkeypatch.setattr( 1eh
642 cli_helpers,
643 "get_suitable_ssh_keys",
644 callables.suitable_ssh_keys,
645 )
646 result = runner.invoke( 1eh
647 cli.derivepassphrase_vault,
648 ["--config", *command_line],
649 catch_exceptions=False,
650 input=input,
651 )
652 assert result.clean_exit(), "expected clean exit" 1eh
653 config_txt = cli_helpers.config_filename( 1eh
654 subsystem="vault"
655 ).read_text(encoding="UTF-8")
656 config = json.loads(config_txt) 1eh
657 assert config == result_config, ( 1eh
658 "stored config does not match expectation"
659 )
660 assert_vault_config_is_indented_and_line_broken(config_txt) 1eh
661 return result 1eh
663 @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG
664 def test_store_good_config( 1ah
665 self,
666 command_line: list[str],
667 input: str,
668 starting_config: Any,
669 result_config: Any,
670 ) -> None:
671 """Storing valid settings via `--config` works.
673 The format also contains embedded newlines and indentation to make
674 the config more readable.
676 """
677 self._test( 1h
678 command_line,
679 input=input,
680 starting_config=starting_config,
681 result_config=result_config,
682 )
684 def test_config_directory_nonexistant(
685 self,
686 ) -> None:
687 """Running without an existing config directory works.
689 This is a regression test; see [the "pretty-print-json"
690 issue][PRETTY_PRINT_JSON] for context. See also
691 [TestStoringConfigurationFailures.test_config_directory_not_a_file][]
692 for a related aspect of this.
694 [PRETTY_PRINT_JSON]: https://the13thletter.info/derivepassphrase/0.x/wishlist/pretty-print-json/
696 """
697 result = self._test( 1e
698 ["-p"],
699 starting_config=None,
700 result_config={"global": {"phrase": "abc"}, "services": {}},
701 input="abc\n",
702 )
703 # Some versions of click leave the space and/or the line
704 # terminator in stderr when running in the test runner.
705 assert result.stderr.strip() == "Passphrase:", ( 1e
706 "program unexpectedly failed?!"
707 )
710class TestStoringConfigurationFailures:
711 """Tests concerning storing the configuration: failures."""
713 @contextlib.contextmanager
714 def _test(
715 self,
716 command_line: list[str],
717 error_text: str,
718 input: str | bytes | None = None,
719 starting_config: _types.VaultConfig = { # noqa: B006
720 "global": {"phrase": "abc"},
721 "services": {},
722 },
723 patch_suitable_ssh_keys: bool = True,
724 ) -> Generator[pytest.MonkeyPatch, None, None]:
725 runner = machinery.CliRunner(mix_stderr=False) 1fpqjirkstlm
726 # TODO(the-13th-letter): Rewrite using parenthesized
727 # with-statements.
728 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
729 with contextlib.ExitStack() as stack: 1fpqjirkstlm
730 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1fpqjirkstlm
731 stack.enter_context( 1fpqjirkstlm
732 pytest_machinery.isolated_vault_config(
733 monkeypatch=monkeypatch,
734 runner=runner,
735 vault_config=starting_config,
736 )
737 )
738 # Patch the list of suitable SSH keys by default, lest we be
739 # at the mercy of whatever SSH agent may be running. (But
740 # allow a test to turn this off, if it would interfere with
741 # the testing target, e.g. because we are testing
742 # non-reachability of the agent.)
743 if patch_suitable_ssh_keys: 1fpqjirkstlm
744 monkeypatch.setattr( 1fjirst
745 cli_helpers,
746 "get_suitable_ssh_keys",
747 callables.suitable_ssh_keys,
748 )
749 # If the setup blows up, bubble the exception.
750 yield monkeypatch # noqa: RUF075 1fpqjirkstlm
751 result = runner.invoke( 1fpqjirkstlm
752 cli.derivepassphrase_vault,
753 ["--config", *command_line],
754 catch_exceptions=False,
755 input=input,
756 )
757 assert result.error_exit(error=error_text), ( 1fpqjirkstlm
758 "expected error exit and known error message"
759 )
761 @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG_FAILURES
762 def test_store_bad_config(
763 self,
764 command_line: list[str],
765 input: str,
766 err_text: str,
767 ) -> None:
768 """Storing invalid settings via `--config` fails."""
769 with self._test(command_line, error_text=err_text, input=input): 1t
770 pass 1t
772 def test_fail_because_no_ssh_key_selection(self) -> None:
773 """Not selecting an SSH key during `--config --key` fails.
775 (This test does not actually need a running agent; the agent's
776 response is mocked by the test harness.)
778 """
779 with self._test( 1j
780 ["--key"], error_text="the user aborted the request"
781 ) as monkeypatch:
783 def prompt_for_selection(*_args: Any, **_kwargs: Any) -> NoReturn: 1j
784 raise IndexError(cli_helpers.EMPTY_SELECTION) 1j
786 monkeypatch.setattr( 1j
787 cli_helpers, "prompt_for_selection", prompt_for_selection
788 )
790 def test_fail_because_no_ssh_agent(
791 self,
792 use_stub_agent_with_address: None,
793 ) -> None:
794 """Not running an SSH agent during `--config --key` fails.
796 (This test runs against the stub agent; it does not actually
797 need an agent installed on the user's system.)
799 """
800 del use_stub_agent_with_address 1q
801 with self._test( 1q
802 ["--key"],
803 error_text="Cannot find any running SSH agent",
804 patch_suitable_ssh_keys=False,
805 ) as monkeypatch:
806 monkeypatch.delenv("SSH_AUTH_SOCK", raising=False) 1q
808 def test_fail_because_bad_ssh_agent_connection(
809 self,
810 use_stub_agent_with_address: None,
811 ) -> None:
812 """Not running a reachable SSH agent during `--config --key` fails.
814 (This test runs against the stub agent; it does not actually
815 need an agent installed on the user's system.)
817 """
818 del use_stub_agent_with_address 1p
819 with self._test( 1p
820 ["--key"],
821 error_text="Cannot connect to the SSH agent",
822 patch_suitable_ssh_keys=False,
823 ) as monkeypatch:
824 monkeypatch.setenv("SSH_AUTH_SOCK", "invalid address format") 1p
826 @Parametrize.TRY_RACE_FREE_IMPLEMENTATION
827 def test_fail_because_read_only_file(
828 self, try_race_free_implementation: bool
829 ) -> None:
830 """Using a read-only configuration file with `--config` fails."""
831 with self._test( 1r
832 ["--length=15", "--", DUMMY_SERVICE],
833 error_text="Cannot store vault settings:",
834 ):
835 callables.make_file_readonly( 1r
836 cli_helpers.config_filename(subsystem="vault"),
837 try_race_free_implementation=try_race_free_implementation,
838 )
840 def test_fail_because_of_custom_error(self) -> None:
841 """Triggering internal errors during `--config` leads to failure."""
842 custom_error = "custom error message" 1i
843 with self._test( 1i
844 ["--length=15", "--", DUMMY_SERVICE], error_text=custom_error
845 ) as monkeypatch:
847 def raiser(config: Any) -> None: 1i
848 del config 1i
849 raise RuntimeError(custom_error) 1i
851 monkeypatch.setattr(cli_helpers, "save_config", raiser) 1i
853 def test_fail_because_unsetting_and_setting_same_settings(self) -> None:
854 """Issuing conflicting settings to `--config` fails."""
855 with self._test( 1s
856 ["--unset=length", "--length=15", "--", DUMMY_SERVICE],
857 error_text="Attempted to unset and set --length at the same time.",
858 ):
859 pass 1s
861 def test_fail_because_ssh_agent_has_no_keys_loaded(
862 self,
863 use_stub_agent: None,
864 ) -> None:
865 """Not holding any SSH keys during `--config --key` fails.
867 (This test does not actually need a running agent; the agent's
868 response is mocked by the test harness.)
870 """
871 del use_stub_agent 1k
872 with self._test( 1k
873 ["--key"],
874 error_text="no keys suitable",
875 patch_suitable_ssh_keys=False,
876 ) as monkeypatch:
878 def func( 1k
879 *_args: Any,
880 **_kwargs: Any,
881 ) -> list[_types.SSHKeyCommentPair]:
882 return [] 1k
884 monkeypatch.setattr(ssh_agent.SSHAgentClient, "list_keys", func) 1k
886 def test_store_config_fail_manual_ssh_agent_runtime_error(
887 self,
888 use_stub_agent: None,
889 ) -> None:
890 """Triggering an error in the SSH agent during `--config --key` leads to failure.
892 (This test does not actually need a running agent; the agent's
893 response is mocked by the test harness.)
895 """
896 del use_stub_agent 1m
897 with self._test( 1m
898 ["--key"],
899 error_text="violates the communication protocol",
900 patch_suitable_ssh_keys=False,
901 ) as monkeypatch:
903 def raiser(*_args: Any, **_kwargs: Any) -> None: 1m
904 raise ssh_agent.TrailingDataError() 1m
906 monkeypatch.setattr(ssh_agent.SSHAgentClient, "list_keys", raiser) 1m
908 def test_store_config_fail_manual_ssh_agent_refuses(
909 self,
910 use_stub_agent: None,
911 ) -> None:
912 """The SSH agent refusing during `--config --key` leads to failure.
914 (This test does not actually need a running agent; the agent's
915 response is mocked by the test harness.)
917 """
918 del use_stub_agent 1l
919 with self._test( 1l
920 ["--key"], error_text="refused to", patch_suitable_ssh_keys=False
921 ) as monkeypatch:
923 def func(*_args: Any, **_kwargs: Any) -> NoReturn: 1l
924 raise ssh_agent.SSHAgentFailedError( 1l
925 _types.SSH_AGENT.FAILURE, b""
926 )
928 monkeypatch.setattr(ssh_agent.SSHAgentClient, "list_keys", func) 1l
930 def test_config_directory_not_a_file(self) -> None:
931 """Erroring without an existing config directory errors normally.
933 That is, the missing configuration directory does not cause any
934 errors by itself.
936 This is a regression test; see [the "pretty-print-json"
937 issue][PRETTY_PRINT_JSON] for context. See also
938 [TestStoringConfigurationSuccesses.test_config_directory_nonexistant][]
939 for a related aspect of this.
941 [PRETTY_PRINT_JSON]: https://the13thletter.info/derivepassphrase/0.x/wishlist/pretty-print-json/
943 """
944 with self._test( 1f
945 ["--phrase"],
946 error_text="Cannot store vault settings:",
947 input="abc\n",
948 ) as monkeypatch:
949 save_config_ = cli_helpers.save_config 1f
951 def obstruct_config_saving(*args: Any, **kwargs: Any) -> Any: 1f
952 config_dir = cli_helpers.config_filename(subsystem=None) 1f
953 with contextlib.suppress(FileNotFoundError): 1f
954 shutil.rmtree(config_dir) 1f
955 config_dir.write_text("Obstruction!!\n") 1f
956 monkeypatch.setattr(cli_helpers, "save_config", save_config_) 1f
957 return save_config_(*args, **kwargs) 1f
959 monkeypatch.setattr( 1f
960 cli_helpers, "save_config", obstruct_config_saving
961 )