Split the basic command-line tests, again
Marco Ricci

Marco Ricci commited on 2025-11-26 20:46:42
Zeige 11 geänderte Dateien mit 2549 Einfügungen und 2377 Löschungen.


(This is part 11 of a series of refactorings for the test suite.)

Split the basic command-line tests into four thematic groups, in four
separate test files:

  * tests for common subsystems of the `derivepassphrase` CLI, plus
    miscellaneous basic tests

  * basic tests for the `derivepassphrase vault` CLI

  * tests for the configuration management options of the
    `derivepassphrase vault` CLI

  * tests for the note handling machinery of the `derivepassphrase
    vault` CLI

Also update the documentation to reflect the module split.
... ...
@@ -0,0 +1,4 @@
1
+::: tests.test_derivepassphrase_cli.test_vault_cli_basic_functionality
2
+    options:
3
+      heading_level: 1
4
+
... ...
@@ -0,0 +1,4 @@
1
+::: tests.test_derivepassphrase_cli.test_vault_cli_config_management
2
+    options:
3
+      heading_level: 1
4
+
... ...
@@ -0,0 +1,4 @@
1
+::: tests.test_derivepassphrase_cli.test_vault_cli_notes_handling
2
+    options:
3
+      heading_level: 1
4
+
... ...
@@ -0,0 +1,4 @@
1
+::: tests.test_derivepassphrase_types.test_000_basic
2
+    options:
3
+      heading_level: 1
4
+
... ...
@@ -0,0 +1,4 @@
1
+::: tests.test_derivepassphrase_types.test_heavy_duty
2
+    options:
3
+      heading_level: 1
4
+
... ...
@@ -44,12 +44,15 @@ nav:
44 44
       - derivepassphrase command-line:
45 45
         - cli module, helpers and machinery:
46 46
           - Overview: reference/tests.test_derivepassphrase_cli.md
47
-          - Basic tests: reference/tests.test_derivepassphrase_cli.test_000_basic.md
47
+          - Basic and common-subsystem tests: reference/tests.test_derivepassphrase_cli.test_000_basic.md
48 48
           - Tests concerning all CLIs: reference/tests.test_derivepassphrase_cli.test_all_cli.md
49 49
           - Heavy-duty tests: reference/tests.test_derivepassphrase_cli.test_heavy_duty.md
50 50
           - Shell completion tests: reference/tests.test_derivepassphrase_cli.test_shell_completion.md
51 51
           - CLI Transition tests: reference/tests.test_derivepassphrase_cli.test_transition.md
52 52
           - Tests for CLI utilities: reference/tests.test_derivepassphrase_cli.test_utils.md
53
+          - '"vault" CLI basic tests': reference/tests.test_derivepassphrase_cli.test_vault_cli_basic_functionality.md
54
+          - '"vault" CLI config management tests': reference/tests.test_derivepassphrase_cli.test_vault_cli_config_management.md
55
+          - '"vault" CLI notes handling tests': reference/tests.test_derivepassphrase_cli.test_vault_cli_notes_handling.md
53 56
         - '"export vault" subcommand tests': reference/tests.test_derivepassphrase_cli_export_vault.md
54 57
       - exporter module: reference/tests.test_derivepassphrase_exporter.md
55 58
       - sequin module: reference/tests.test_derivepassphrase_sequin.md
... ...
@@ -2,504 +2,43 @@
2 2
 #
3 3
 # SPDX-License-Identifier: Zlib
4 4
 
5
-"""Tests for the `derivepassphrase vault` command-line interface."""
5
+"""Tests for the `derivepassphrase` command-line interface: common subsystems.
6
+
7
+(Currently, these tests are performed on the `derivepassphrase vault`
8
+subcommand, because that is the main command using all these
9
+subsystems.)
10
+
11
+"""
6 12
 
7 13
 from __future__ import annotations
8 14
 
9 15
 import contextlib
10
-import copy
11
-import errno
12 16
 import json
13
-import os
14
-import pathlib
15
-import shutil
16 17
 import socket
17 18
 import textwrap
18 19
 import types
19 20
 from typing import TYPE_CHECKING, ClassVar
20 21
 
21
-import click.testing
22
-import hypothesis
23 22
 import pytest
24
-from hypothesis import strategies
25
-from typing_extensions import Any, NamedTuple, TypedDict
26 23
 
27
-from derivepassphrase import _types, cli, ssh_agent, vault
24
+from derivepassphrase import _types, cli, ssh_agent
28 25
 from derivepassphrase._internals import (
29 26
     cli_helpers,
30
-    cli_messages,
31 27
 )
32 28
 from tests import data, machinery
33
-from tests.data import callables
34
-from tests.machinery import hypothesis as hypothesis_machinery
35 29
 from tests.machinery import pytest as pytest_machinery
36 30
 
37 31
 if TYPE_CHECKING:
38 32
     from collections.abc import Iterator
39
-    from typing import NoReturn
40
-
41
-    from typing_extensions import Literal, NotRequired
42 33
 
43 34
 DUMMY_SERVICE = data.DUMMY_SERVICE
44 35
 DUMMY_PASSPHRASE = data.DUMMY_PASSPHRASE
45 36
 DUMMY_CONFIG_SETTINGS = data.DUMMY_CONFIG_SETTINGS
46
-DUMMY_RESULT_PASSPHRASE = data.DUMMY_RESULT_PASSPHRASE
47
-DUMMY_RESULT_KEY1 = data.DUMMY_RESULT_KEY1
48
-
49
-DUMMY_KEY1_B64 = data.DUMMY_KEY1_B64
50
-DUMMY_KEY2_B64 = data.DUMMY_KEY2_B64
51
-
52
-
53
-class IncompatibleConfiguration(NamedTuple):
54
-    other_options: list[tuple[str, ...]]
55
-    needs_service: bool | None
56
-    input: str | None
57
-
58
-
59
-class SingleConfiguration(NamedTuple):
60
-    needs_service: bool | None
61
-    input: str | None
62
-    check_success: bool
63
-
64
-
65
-class OptionCombination(NamedTuple):
66
-    options: list[str]
67
-    incompatible: bool
68
-    needs_service: bool | None
69
-    input: str | None
70
-    check_success: bool
71
-
72
-
73
-PASSPHRASE_GENERATION_OPTIONS: list[tuple[str, ...]] = [
74
-    ("--phrase",),
75
-    ("--key",),
76
-    ("--length", "20"),
77
-    ("--repeat", "20"),
78
-    ("--lower", "1"),
79
-    ("--upper", "1"),
80
-    ("--number", "1"),
81
-    ("--space", "1"),
82
-    ("--dash", "1"),
83
-    ("--symbol", "1"),
84
-]
85
-CONFIGURATION_OPTIONS: list[tuple[str, ...]] = [
86
-    ("--notes",),
87
-    ("--config",),
88
-    ("--delete",),
89
-    ("--delete-globals",),
90
-    ("--clear",),
91
-]
92
-CONFIGURATION_COMMANDS: list[tuple[str, ...]] = [
93
-    ("--delete",),
94
-    ("--delete-globals",),
95
-    ("--clear",),
96
-]
97
-STORAGE_OPTIONS: list[tuple[str, ...]] = [("--export", "-"), ("--import", "-")]
98
-INCOMPATIBLE: dict[tuple[str, ...], IncompatibleConfiguration] = {
99
-    ("--phrase",): IncompatibleConfiguration(
100
-        [("--key",), *CONFIGURATION_COMMANDS, *STORAGE_OPTIONS],
101
-        True,
102
-        DUMMY_PASSPHRASE,
103
-    ),
104
-    ("--key",): IncompatibleConfiguration(
105
-        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
106
-    ),
107
-    ("--length", "20"): IncompatibleConfiguration(
108
-        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
109
-    ),
110
-    ("--repeat", "20"): IncompatibleConfiguration(
111
-        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
112
-    ),
113
-    ("--lower", "1"): IncompatibleConfiguration(
114
-        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
115
-    ),
116
-    ("--upper", "1"): IncompatibleConfiguration(
117
-        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
118
-    ),
119
-    ("--number", "1"): IncompatibleConfiguration(
120
-        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
121
-    ),
122
-    ("--space", "1"): IncompatibleConfiguration(
123
-        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
124
-    ),
125
-    ("--dash", "1"): IncompatibleConfiguration(
126
-        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
127
-    ),
128
-    ("--symbol", "1"): IncompatibleConfiguration(
129
-        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
130
-    ),
131
-    ("--notes",): IncompatibleConfiguration(
132
-        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, None
133
-    ),
134
-    ("--config", "-p"): IncompatibleConfiguration(
135
-        [("--delete",), ("--delete-globals",), ("--clear",), *STORAGE_OPTIONS],
136
-        None,
137
-        DUMMY_PASSPHRASE,
138
-    ),
139
-    ("--delete",): IncompatibleConfiguration(
140
-        [("--delete-globals",), ("--clear",), *STORAGE_OPTIONS], True, None
141
-    ),
142
-    ("--delete-globals",): IncompatibleConfiguration(
143
-        [("--clear",), *STORAGE_OPTIONS], False, None
144
-    ),
145
-    ("--clear",): IncompatibleConfiguration(STORAGE_OPTIONS, False, None),
146
-    ("--export", "-"): IncompatibleConfiguration(
147
-        [("--import", "-")], False, None
148
-    ),
149
-    ("--import", "-"): IncompatibleConfiguration([], False, None),
150
-}
151
-SINGLES: dict[tuple[str, ...], SingleConfiguration] = {
152
-    ("--phrase",): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
153
-    ("--key",): SingleConfiguration(True, None, False),
154
-    ("--length", "20"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
155
-    ("--repeat", "20"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
156
-    ("--lower", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
157
-    ("--upper", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
158
-    ("--number", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
159
-    ("--space", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
160
-    ("--dash", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
161
-    ("--symbol", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
162
-    ("--notes",): SingleConfiguration(True, None, False),
163
-    ("--config", "-p"): SingleConfiguration(None, DUMMY_PASSPHRASE, False),
164
-    ("--delete",): SingleConfiguration(True, None, False),
165
-    ("--delete-globals",): SingleConfiguration(False, None, True),
166
-    ("--clear",): SingleConfiguration(False, None, True),
167
-    ("--export", "-"): SingleConfiguration(False, None, True),
168
-    ("--import", "-"): SingleConfiguration(False, '{"services": {}}', True),
169
-}
170
-INTERESTING_OPTION_COMBINATIONS: list[OptionCombination] = []
171
-config: IncompatibleConfiguration | SingleConfiguration
172
-for opt, config in INCOMPATIBLE.items():
173
-    for opt2 in config.other_options:
174
-        INTERESTING_OPTION_COMBINATIONS.extend([
175
-            OptionCombination(
176
-                options=list(opt + opt2),
177
-                incompatible=True,
178
-                needs_service=config.needs_service,
179
-                input=config.input,
180
-                check_success=False,
181
-            ),
182
-            OptionCombination(
183
-                options=list(opt2 + opt),
184
-                incompatible=True,
185
-                needs_service=config.needs_service,
186
-                input=config.input,
187
-                check_success=False,
188
-            ),
189
-        ])
190
-for opt, config in SINGLES.items():
191
-    INTERESTING_OPTION_COMBINATIONS.append(
192
-        OptionCombination(
193
-            options=list(opt),
194
-            incompatible=False,
195
-            needs_service=config.needs_service,
196
-            input=config.input,
197
-            check_success=config.check_success,
198
-        )
199
-    )
200
-
201
-
202
-def is_warning_line(line: str) -> bool:
203
-    """Return true if the line is a warning line."""
204
-    return " Warning: " in line or " Deprecation warning: " in line
205
-
206
-
207
-def is_harmless_config_import_warning(record: tuple[str, int, str]) -> bool:
208
-    """Return true if the warning is harmless, during config import."""
209
-    possible_warnings = [
210
-        "Replacing invalid value ",
211
-        "Removing ineffective setting ",
212
-        (
213
-            "Setting a global passphrase is ineffective "
214
-            "because a key is also set."
215
-        ),
216
-        (
217
-            "Setting a service passphrase is ineffective "
218
-            "because a key is also set:"
219
-        ),
220
-    ]
221
-    return any(
222
-        machinery.warning_emitted(w, [record]) for w in possible_warnings
223
-    )
224
-
225
-
226
-def assert_vault_config_is_indented_and_line_broken(
227
-    config_txt: str,
228
-    /,
229
-) -> None:
230
-    """Return true if the vault configuration is indented and line broken.
231
-
232
-    Indented and rewrapped vault configurations as produced by
233
-    `json.dump` contain the closing '}' of the '$.services' object
234
-    on a separate, indented line:
235
-
236
-    ~~~~
237
-    {
238
-      "services": {
239
-        ...
240
-      }  <-- this brace here
241
-    }
242
-    ~~~~
243
-
244
-    or, if there are no services, then the indented line
245
-
246
-    ~~~~
247
-      "services": {}
248
-    ~~~~
249
-
250
-    Both variations may end with a comma if there are more top-level
251
-    keys.
252
-
253
-    """
254
-    known_indented_lines = {
255
-        "}",
256
-        "},",
257
-        '"services": {}',
258
-        '"services": {},',
259
-    }
260
-    assert any([
261
-        line.strip() in known_indented_lines and line.startswith((" ", "\t"))
262
-        for line in config_txt.splitlines()
263
-    ])
264
-
265
-
266
-class Strategies:
267
-    @staticmethod
268
-    def notes(*, max_size: int = 512) -> strategies.SearchStrategy[str]:
269
-        return strategies.text(
270
-            strategies.characters(
271
-                min_codepoint=32, max_codepoint=126, include_characters="\n"
272
-            ),
273
-            min_size=1,
274
-            max_size=max_size,
275
-        )
276 37
 
277 38
 
278 39
 class Parametrize(types.SimpleNamespace):
279 40
     """Common test parametrizations."""
280 41
 
281
-    AUTO_PROMPT = pytest.mark.parametrize(
282
-        "auto_prompt", [False, True], ids=["normal_prompt", "auto_prompt"]
283
-    )
284
-    CHARSET_NAME = pytest.mark.parametrize(
285
-        "charset_name", ["lower", "upper", "number", "space", "dash", "symbol"]
286
-    )
287
-    CONFIG_EDITING_VIA_CONFIG_FLAG_FAILURES = pytest.mark.parametrize(
288
-        ["command_line", "input", "err_text"],
289
-        [
290
-            pytest.param(
291
-                [],
292
-                "",
293
-                "Cannot update the global settings without any given settings",
294
-                id="None",
295
-            ),
296
-            pytest.param(
297
-                ["--", "sv"],
298
-                "",
299
-                "Cannot update the service-specific settings without any given settings",
300
-                id="None-sv",
301
-            ),
302
-            pytest.param(
303
-                ["--phrase", "--", "sv"],
304
-                "\n",
305
-                "No passphrase was given",
306
-                id="phrase-sv",
307
-            ),
308
-            pytest.param(
309
-                ["--phrase", "--", "sv"],
310
-                "",
311
-                "No passphrase was given",
312
-                id="phrase-sv-eof",
313
-            ),
314
-            pytest.param(
315
-                ["--key"],
316
-                "\n",
317
-                "No SSH key was selected",
318
-                id="key-sv",
319
-            ),
320
-            pytest.param(
321
-                ["--key"],
322
-                "",
323
-                "No SSH key was selected",
324
-                id="key-sv-eof",
325
-            ),
326
-        ],
327
-    )
328
-    CONFIG_EDITING_VIA_CONFIG_FLAG = pytest.mark.parametrize(
329
-        ["command_line", "input", "starting_config", "result_config"],
330
-        [
331
-            pytest.param(
332
-                ["--phrase"],
333
-                "my passphrase\n",
334
-                {"global": {"phrase": "abc"}, "services": {}},
335
-                {"global": {"phrase": "my passphrase"}, "services": {}},
336
-                id="phrase",
337
-            ),
338
-            pytest.param(
339
-                ["--key"],
340
-                "1\n",
341
-                {"global": {"phrase": "abc"}, "services": {}},
342
-                {
343
-                    "global": {"key": DUMMY_KEY1_B64, "phrase": "abc"},
344
-                    "services": {},
345
-                },
346
-                id="key",
347
-            ),
348
-            pytest.param(
349
-                ["--phrase", "--", "sv"],
350
-                "my passphrase\n",
351
-                {"global": {"phrase": "abc"}, "services": {}},
352
-                {
353
-                    "global": {"phrase": "abc"},
354
-                    "services": {"sv": {"phrase": "my passphrase"}},
355
-                },
356
-                id="phrase-sv",
357
-            ),
358
-            pytest.param(
359
-                ["--key", "--", "sv"],
360
-                "1\n",
361
-                {"global": {"phrase": "abc"}, "services": {}},
362
-                {
363
-                    "global": {"phrase": "abc"},
364
-                    "services": {"sv": {"key": DUMMY_KEY1_B64}},
365
-                },
366
-                id="key-sv",
367
-            ),
368
-            pytest.param(
369
-                ["--key", "--length", "15", "--", "sv"],
370
-                "1\n",
371
-                {"global": {"phrase": "abc"}, "services": {}},
372
-                {
373
-                    "global": {"phrase": "abc"},
374
-                    "services": {"sv": {"key": DUMMY_KEY1_B64, "length": 15}},
375
-                },
376
-                id="key-length-sv",
377
-            ),
378
-        ],
379
-    )
380
-    BASE_CONFIG_WITH_KEY_VARIATIONS = pytest.mark.parametrize(
381
-        "config",
382
-        [
383
-            pytest.param(
384
-                {
385
-                    "global": {"key": DUMMY_KEY1_B64},
386
-                    "services": {DUMMY_SERVICE: {}},
387
-                },
388
-                id="global_config",
389
-            ),
390
-            pytest.param(
391
-                {"services": {DUMMY_SERVICE: {"key": DUMMY_KEY2_B64}}},
392
-                id="service_config",
393
-            ),
394
-            pytest.param(
395
-                {
396
-                    "global": {"key": DUMMY_KEY1_B64},
397
-                    "services": {DUMMY_SERVICE: {"key": DUMMY_KEY2_B64}},
398
-                },
399
-                id="full_config",
400
-            ),
401
-        ],
402
-    )
403
-    CONFIG_WITH_KEY = pytest.mark.parametrize(
404
-        "config",
405
-        [
406
-            pytest.param(
407
-                {
408
-                    "global": {"key": DUMMY_KEY1_B64},
409
-                    "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS},
410
-                },
411
-                id="global",
412
-            ),
413
-            pytest.param(
414
-                {
415
-                    "global": {"phrase": DUMMY_PASSPHRASE.rstrip("\n")},
416
-                    "services": {
417
-                        DUMMY_SERVICE: {
418
-                            "key": DUMMY_KEY1_B64,
419
-                            **DUMMY_CONFIG_SETTINGS,
420
-                        }
421
-                    },
422
-                },
423
-                id="service",
424
-            ),
425
-        ],
426
-    )
427
-    CONFIG_WITH_PHRASE = pytest.mark.parametrize(
428
-        "config",
429
-        [
430
-            pytest.param(
431
-                {
432
-                    "global": {"phrase": DUMMY_PASSPHRASE.rstrip("\n")},
433
-                    "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS},
434
-                },
435
-                id="global",
436
-            ),
437
-            pytest.param(
438
-                {
439
-                    "global": {
440
-                        "phrase": DUMMY_PASSPHRASE.rstrip("\n")
441
-                        + "XXX"
442
-                        + DUMMY_PASSPHRASE.rstrip("\n")
443
-                    },
444
-                    "services": {
445
-                        DUMMY_SERVICE: {
446
-                            "phrase": DUMMY_PASSPHRASE.rstrip("\n"),
447
-                            **DUMMY_CONFIG_SETTINGS,
448
-                        }
449
-                    },
450
-                },
451
-                id="service",
452
-            ),
453
-        ],
454
-    )
455
-    VALID_TEST_CONFIGS = pytest.mark.parametrize(
456
-        "config",
457
-        [conf.config for conf in data.TEST_CONFIGS if conf.is_valid()],
458
-    )
459
-    KEY_OVERRIDING_IN_CONFIG = pytest.mark.parametrize(
460
-        ["config", "command_line"],
461
-        [
462
-            pytest.param(
463
-                {
464
-                    "global": {"key": DUMMY_KEY1_B64},
465
-                    "services": {},
466
-                },
467
-                ["--config", "-p"],
468
-                id="global",
469
-            ),
470
-            pytest.param(
471
-                {
472
-                    "services": {
473
-                        DUMMY_SERVICE: {
474
-                            "key": DUMMY_KEY1_B64,
475
-                            **DUMMY_CONFIG_SETTINGS,
476
-                        },
477
-                    },
478
-                },
479
-                ["--config", "-p", "--", DUMMY_SERVICE],
480
-                id="service",
481
-            ),
482
-            pytest.param(
483
-                {
484
-                    "global": {"key": DUMMY_KEY1_B64},
485
-                    "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()},
486
-                },
487
-                ["--config", "-p", "--", DUMMY_SERVICE],
488
-                id="service-over-global",
489
-            ),
490
-        ],
491
-    )
492
-    EXPORT_FORMAT_OPTIONS = pytest.mark.parametrize(
493
-        "export_options",
494
-        [
495
-            [],
496
-            ["--export-as=sh"],
497
-        ],
498
-        ids=["json-format", "sh-format"],
499
-    )
500
-    KEY_INDEX = pytest.mark.parametrize(
501
-        "key_index", [1, 2, 3], ids=lambda i: f"index{i}"
502
-    )
503 42
     UNICODE_NORMALIZATION_ERROR_INPUTS = pytest.mark.parametrize(
504 43
         ["main_config", "command_line", "input", "error_message"],
505 44
         [
... ...
@@ -679,1911 +218,6 @@ class Parametrize(types.SimpleNamespace):
679 218
             ),
680 219
         ],
681 220
     )
682
-    MODERN_EDITOR_INTERFACE = pytest.mark.parametrize(
683
-        "modern_editor_interface", [False, True], ids=["legacy", "modern"]
684
-    )
685
-    NOTES_PLACEMENT = pytest.mark.parametrize(
686
-        ["notes_placement", "placement_args"],
687
-        [
688
-            pytest.param("after", ["--print-notes-after"], id="after"),
689
-            pytest.param("before", ["--print-notes-before"], id="before"),
690
-        ],
691
-    )
692
-    VAULT_CHARSET_OPTION = pytest.mark.parametrize(
693
-        "option",
694
-        [
695
-            "--lower",
696
-            "--upper",
697
-            "--number",
698
-            "--space",
699
-            "--dash",
700
-            "--symbol",
701
-            "--repeat",
702
-            "--length",
703
-        ],
704
-    )
705
-    OPTION_COMBINATIONS_INCOMPATIBLE = pytest.mark.parametrize(
706
-        ["options", "service"],
707
-        [
708
-            pytest.param(o.options, o.needs_service, id=" ".join(o.options))
709
-            for o in INTERESTING_OPTION_COMBINATIONS
710
-            if o.incompatible
711
-        ],
712
-    )
713
-    OPTION_COMBINATIONS_SERVICE_NEEDED = pytest.mark.parametrize(
714
-        ["options", "service", "input", "check_success"],
715
-        [
716
-            pytest.param(
717
-                o.options,
718
-                o.needs_service,
719
-                o.input,
720
-                o.check_success,
721
-                id=" ".join(o.options),
722
-            )
723
-            for o in INTERESTING_OPTION_COMBINATIONS
724
-            if not o.incompatible
725
-        ],
726
-    )
727
-    TRY_RACE_FREE_IMPLEMENTATION = pytest.mark.parametrize(
728
-        "try_race_free_implementation",
729
-        [False, True],
730
-        ids=["racy", "maybe-race-free"],
731
-    )
732
-
733
-
734
-class TestHelp:
735
-    """Tests related to help output."""
736
-
737
-    def test_help_output(
738
-        self,
739
-    ) -> None:
740
-        """The `--help` option emits help text."""
741
-        runner = machinery.CliRunner(mix_stderr=False)
742
-        # TODO(the-13th-letter): Rewrite using parenthesized
743
-        # with-statements.
744
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
745
-        with contextlib.ExitStack() as stack:
746
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
747
-            stack.enter_context(
748
-                pytest_machinery.isolated_config(
749
-                    monkeypatch=monkeypatch,
750
-                    runner=runner,
751
-                )
752
-            )
753
-            result = runner.invoke(
754
-                cli.derivepassphrase_vault,
755
-                ["--help"],
756
-                catch_exceptions=False,
757
-            )
758
-        assert result.clean_exit(
759
-            empty_stderr=True, output="Passphrase generation:\n"
760
-        ), "expected clean exit, and option groups in help text"
761
-        assert result.clean_exit(
762
-            empty_stderr=True, output="Use $VISUAL or $EDITOR to configure"
763
-        ), "expected clean exit, and option group epilog in help text"
764
-
765
-
766
-class TestDerivedPassphraseConstraints:
767
-    """Tests for (derived) passphrase constraints."""
768
-
769
-    def _test(self, command_line: list[str]) -> machinery.ReadableResult:
770
-        runner = machinery.CliRunner(mix_stderr=False)
771
-        # TODO(the-13th-letter): Rewrite using parenthesized
772
-        # with-statements.
773
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
774
-        with contextlib.ExitStack() as stack:
775
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
776
-            stack.enter_context(
777
-                pytest_machinery.isolated_config(
778
-                    monkeypatch=monkeypatch,
779
-                    runner=runner,
780
-                )
781
-            )
782
-            monkeypatch.setattr(
783
-                cli_helpers,
784
-                "prompt_for_passphrase",
785
-                callables.auto_prompt,
786
-            )
787
-            return runner.invoke(
788
-                cli.derivepassphrase_vault,
789
-                command_line,
790
-                input=DUMMY_PASSPHRASE,
791
-                catch_exceptions=False,
792
-            )
793
-
794
-    @Parametrize.CHARSET_NAME
795
-    def test_disable_character_set(
796
-        self,
797
-        charset_name: str,
798
-    ) -> None:
799
-        """Named character classes can be disabled on the command-line."""
800
-        option = f"--{charset_name}"
801
-        charset = vault.Vault.CHARSETS[charset_name].decode("ascii")
802
-        result = self._test([option, "0", "-p", "--", DUMMY_SERVICE])
803
-        assert result.clean_exit(empty_stderr=True), "expected clean exit:"
804
-        for c in charset:
805
-            assert c not in result.stdout, (
806
-                f"derived password contains forbidden character {c!r}"
807
-            )
808
-
809
-    def test_disable_repetition(
810
-        self,
811
-    ) -> None:
812
-        """Character repetition can be disabled on the command-line."""
813
-        result = self._test(["--repeat", "0", "-p", "--", DUMMY_SERVICE])
814
-        assert result.clean_exit(empty_stderr=True), (
815
-            "expected clean exit and empty stderr"
816
-        )
817
-        passphrase = result.stdout.rstrip("\r\n")
818
-        for i in range(len(passphrase) - 1):
819
-            assert passphrase[i : i + 1] != passphrase[i + 1 : i + 2], (
820
-                f"derived password contains repeated character "
821
-                f"at position {i}: {result.stdout!r}"
822
-            )
823
-
824
-
825
-class TestPhraseBasic:
826
-    """Tests for master passphrase configuration: basic."""
827
-
828
-    def _test(
829
-        self,
830
-        command_line: list[str],
831
-        /,
832
-        *,
833
-        config: _types.VaultConfig = {  # noqa: B006
834
-            "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}
835
-        },
836
-        auto_prompt: bool = False,
837
-        multiline: bool = False,
838
-    ) -> None:
839
-        runner = machinery.CliRunner(mix_stderr=False)
840
-        # TODO(the-13th-letter): Rewrite using parenthesized
841
-        # with-statements.
842
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
843
-        with contextlib.ExitStack() as stack:
844
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
845
-            stack.enter_context(
846
-                pytest_machinery.isolated_vault_config(
847
-                    monkeypatch=monkeypatch,
848
-                    runner=runner,
849
-                    vault_config=config,
850
-                )
851
-            )
852
-
853
-            def phrase_from_key(*_args: Any, **_kwargs: Any) -> NoReturn:
854
-                pytest.fail("Attempted to use a key in a phrase-based test!")
855
-
856
-            monkeypatch.setattr(
857
-                vault.Vault, "phrase_from_key", phrase_from_key
858
-            )
859
-            if auto_prompt:
860
-                monkeypatch.setattr(
861
-                    cli_helpers,
862
-                    "prompt_for_passphrase",
863
-                    callables.auto_prompt,
864
-                )
865
-            result = runner.invoke(
866
-                cli.derivepassphrase_vault,
867
-                command_line,
868
-                input=None if auto_prompt else DUMMY_PASSPHRASE,
869
-                catch_exceptions=False,
870
-            )
871
-        if multiline:
872
-            assert result.clean_exit(), "expected clean exit"
873
-        else:
874
-            assert result.clean_exit(empty_stderr=True), (
875
-                "expected clean exit and empty stderr"
876
-            )
877
-        assert result.stdout, "expected program output"
878
-        last_line = (
879
-            result.stdout.splitlines(keepends=True)[-1]
880
-            if multiline
881
-            else result.stdout
882
-        )
883
-        assert (
884
-            last_line.rstrip("\n").encode("UTF-8") == DUMMY_RESULT_PASSPHRASE
885
-        ), "expected known output"
886
-
887
-    @Parametrize.CONFIG_WITH_PHRASE
888
-    def test_phrase_from_config(
889
-        self,
890
-        config: _types.VaultConfig,
891
-    ) -> None:
892
-        """A stored configured master passphrase will be used."""
893
-        self._test(["--", DUMMY_SERVICE], config=config)
894
-
895
-    @Parametrize.AUTO_PROMPT
896
-    def test_phrase_from_command_line(
897
-        self,
898
-        auto_prompt: bool,
899
-    ) -> None:
900
-        """A master passphrase requested on the command-line will be used."""
901
-        self._test(
902
-            ["-p", "--", DUMMY_SERVICE],
903
-            auto_prompt=auto_prompt,
904
-            multiline=True,
905
-        )
906
-
907
-
908
-class TestKeyBasic:
909
-    """Tests for SSH key configuration: basic."""
910
-
911
-    def _test(
912
-        self,
913
-        command_line: list[str],
914
-        /,
915
-        *,
916
-        config: _types.VaultConfig = {  # noqa: B006
917
-            "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}
918
-        },
919
-        multiline: bool = False,
920
-        input: str | bytes | None = None,
921
-    ) -> None:
922
-        runner = machinery.CliRunner(mix_stderr=False)
923
-        # TODO(the-13th-letter): Rewrite using parenthesized
924
-        # with-statements.
925
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
926
-        with contextlib.ExitStack() as stack:
927
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
928
-            stack.enter_context(
929
-                pytest_machinery.isolated_vault_config(
930
-                    monkeypatch=monkeypatch,
931
-                    runner=runner,
932
-                    vault_config=config,
933
-                )
934
-            )
935
-            monkeypatch.setattr(
936
-                cli_helpers,
937
-                "get_suitable_ssh_keys",
938
-                callables.suitable_ssh_keys,
939
-            )
940
-            monkeypatch.setattr(
941
-                vault.Vault,
942
-                "phrase_from_key",
943
-                callables.phrase_from_key,
944
-            )
945
-            result = runner.invoke(
946
-                cli.derivepassphrase_vault,
947
-                command_line,
948
-                input=input,
949
-                catch_exceptions=False,
950
-            )
951
-        if multiline:
952
-            assert result.clean_exit(), "expected clean exit"
953
-        else:
954
-            assert result.clean_exit(empty_stderr=True), (
955
-                "expected clean exit and empty stderr"
956
-            )
957
-        assert result.stdout, "expected program output"
958
-        last_line = (
959
-            result.stdout.splitlines(keepends=True)[-1]
960
-            if multiline
961
-            else result.stdout
962
-        )
963
-        assert (
964
-            last_line.rstrip("\n").encode("UTF-8") != DUMMY_RESULT_PASSPHRASE
965
-        ), "known false output: phrase-based instead of key-based"
966
-        assert last_line.rstrip("\n").encode("UTF-8") == DUMMY_RESULT_KEY1, (
967
-            "expected known output"
968
-        )
969
-
970
-    @Parametrize.CONFIG_WITH_KEY
971
-    def test_key_from_config(
972
-        self,
973
-        running_ssh_agent: data.RunningSSHAgentInfo,
974
-        config: _types.VaultConfig,
975
-    ) -> None:
976
-        """A stored configured SSH key will be used."""
977
-        del running_ssh_agent
978
-        self._test(["--", DUMMY_SERVICE], config=config)
979
-
980
-    def test_key_from_command_line(
981
-        self,
982
-        running_ssh_agent: data.RunningSSHAgentInfo,
983
-    ) -> None:
984
-        """An SSH key requested on the command-line will be used."""
985
-        del running_ssh_agent
986
-        self._test(["-k", "--", DUMMY_SERVICE], input="1\n", multiline=True)
987
-
988
-
989
-class TestPhraseAndKeyOverriding:
990
-    """Tests for master passphrase and SSH key configuration: overriding."""
991
-
992
-    def _test(
993
-        self,
994
-        command_line: list[str],
995
-        /,
996
-        *,
997
-        config: _types.VaultConfig = {  # noqa: B006
998
-            "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}
999
-        },
1000
-        input: str | bytes | None = None,
1001
-    ) -> machinery.ReadableResult:
1002
-        runner = machinery.CliRunner(mix_stderr=False)
1003
-        # TODO(the-13th-letter): Rewrite using parenthesized
1004
-        # with-statements.
1005
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1006
-        with contextlib.ExitStack() as stack:
1007
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1008
-            stack.enter_context(
1009
-                pytest_machinery.isolated_vault_config(
1010
-                    monkeypatch=monkeypatch,
1011
-                    runner=runner,
1012
-                    vault_config=config,
1013
-                )
1014
-            )
1015
-            monkeypatch.setattr(
1016
-                ssh_agent.SSHAgentClient,
1017
-                "list_keys",
1018
-                callables.list_keys,
1019
-            )
1020
-            monkeypatch.setattr(
1021
-                ssh_agent.SSHAgentClient, "sign", callables.sign
1022
-            )
1023
-            result = runner.invoke(
1024
-                cli.derivepassphrase_vault,
1025
-                command_line,
1026
-                input=input,
1027
-                catch_exceptions=False,
1028
-            )
1029
-        assert result.clean_exit(), "expected clean exit"
1030
-        return result
1031
-
1032
-    @Parametrize.BASE_CONFIG_WITH_KEY_VARIATIONS
1033
-    @Parametrize.KEY_INDEX
1034
-    def test_key_override_on_command_line(
1035
-        self,
1036
-        running_ssh_agent: data.RunningSSHAgentInfo,
1037
-        config: _types.VaultConfig,
1038
-        key_index: int,
1039
-    ) -> None:
1040
-        """A command-line SSH key will override the configured key."""
1041
-        del running_ssh_agent
1042
-        result = self._test(
1043
-            ["-k", "--", DUMMY_SERVICE],
1044
-            config=config,
1045
-            input=f"{key_index}\n",
1046
-        )
1047
-        assert result.stdout, "expected program output"
1048
-        assert result.stderr, "expected stderr"
1049
-        assert "Error:" not in result.stderr, (
1050
-            "expected no error messages on stderr"
1051
-        )
1052
-
1053
-    def test_service_phrase_if_key_in_global_config(
1054
-        self,
1055
-        running_ssh_agent: data.RunningSSHAgentInfo,
1056
-    ) -> None:
1057
-        """A configured passphrase does not override a configured key."""
1058
-        del running_ssh_agent
1059
-        result = self._test(
1060
-            ["--", DUMMY_SERVICE],
1061
-            config={
1062
-                "global": {"key": DUMMY_KEY1_B64},
1063
-                "services": {
1064
-                    DUMMY_SERVICE: {
1065
-                        "phrase": DUMMY_PASSPHRASE.rstrip("\n"),
1066
-                        **DUMMY_CONFIG_SETTINGS,
1067
-                    }
1068
-                },
1069
-            },
1070
-        )
1071
-        assert result.stdout, "expected program output"
1072
-        last_line = result.stdout.splitlines(True)[-1]
1073
-        assert (
1074
-            last_line.rstrip("\n").encode("UTF-8") != DUMMY_RESULT_PASSPHRASE
1075
-        ), "known false output: phrase-based instead of key-based"
1076
-        assert last_line.rstrip("\n").encode("UTF-8") == DUMMY_RESULT_KEY1, (
1077
-            "expected known output"
1078
-        )
1079
-
1080
-    @Parametrize.KEY_OVERRIDING_IN_CONFIG
1081
-    def test_setting_phrase_thus_overriding_key_in_config(
1082
-        self,
1083
-        running_ssh_agent: data.RunningSSHAgentInfo,
1084
-        caplog: pytest.LogCaptureFixture,
1085
-        config: _types.VaultConfig,
1086
-        command_line: list[str],
1087
-    ) -> None:
1088
-        """Configuring a passphrase atop an SSH key works, but warns."""
1089
-        del running_ssh_agent
1090
-        result = self._test(
1091
-            command_line, config=config, input=DUMMY_PASSPHRASE
1092
-        )
1093
-        assert not result.stdout.strip(), "expected no program output"
1094
-        assert result.stderr, "expected known error output"
1095
-        err_lines = result.stderr.splitlines(False)
1096
-        assert err_lines[0].startswith("Passphrase:")
1097
-        assert machinery.warning_emitted(
1098
-            "Setting a service passphrase is ineffective ",
1099
-            caplog.record_tuples,
1100
-        ) or machinery.warning_emitted(
1101
-            "Setting a global passphrase is ineffective ",
1102
-            caplog.record_tuples,
1103
-        ), "expected known warning message"
1104
-        assert all(map(is_warning_line, result.stderr.splitlines(True)))
1105
-        assert all(
1106
-            map(is_harmless_config_import_warning, caplog.record_tuples)
1107
-        ), "unexpected error output"
1108
-
1109
-
1110
-class TestInvalidCommandLines:
1111
-    """Tests concerning invalid command-lines."""
1112
-
1113
-    @contextlib.contextmanager
1114
-    def _setup_environment(
1115
-        self,
1116
-        /,
1117
-        *,
1118
-        auto_prompt: bool = False,
1119
-        config: _types.VaultConfig = {  # noqa: B006
1120
-            "services": {
1121
-                DUMMY_SERVICE: {**DUMMY_CONFIG_SETTINGS},
1122
-            },
1123
-        },
1124
-    ) -> Iterator[machinery.CliRunner]:
1125
-        runner = machinery.CliRunner(mix_stderr=False)
1126
-        # TODO(the-13th-letter): Rewrite using parenthesized
1127
-        # with-statements.
1128
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1129
-        with contextlib.ExitStack() as stack:
1130
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1131
-            stack.enter_context(
1132
-                pytest_machinery.isolated_vault_config(
1133
-                    monkeypatch=monkeypatch,
1134
-                    runner=runner,
1135
-                    vault_config=config,
1136
-                )
1137
-            )
1138
-            if auto_prompt:
1139
-                monkeypatch.setattr(
1140
-                    cli_helpers,
1141
-                    "prompt_for_passphrase",
1142
-                    callables.auto_prompt,
1143
-                )
1144
-            yield runner
1145
-
1146
-    def _call(
1147
-        self,
1148
-        command_line: list[str],
1149
-        /,
1150
-        *,
1151
-        config: _types.VaultConfig = {  # noqa: B006
1152
-            "services": {
1153
-                DUMMY_SERVICE: {**DUMMY_CONFIG_SETTINGS},
1154
-            },
1155
-        },
1156
-        input: str | bytes | None = None,
1157
-        runner: machinery.CliRunner | None = None,
1158
-    ) -> machinery.ReadableResult:
1159
-        if runner:
1160
-            return runner.invoke(
1161
-                cli.derivepassphrase_vault,
1162
-                command_line,
1163
-                input=input,
1164
-                catch_exceptions=False,
1165
-            )
1166
-        with self._setup_environment(
1167
-            config=config, auto_prompt=input is not None
1168
-        ) as runner2:
1169
-            return runner2.invoke(
1170
-                cli.derivepassphrase_vault,
1171
-                command_line,
1172
-                input=input,
1173
-                catch_exceptions=False,
1174
-            )
1175
-
1176
-    @Parametrize.VAULT_CHARSET_OPTION
1177
-    def test_invalid_argument_range(
1178
-        self,
1179
-        option: str,
1180
-    ) -> None:
1181
-        """Requesting invalidly many characters from a class fails."""
1182
-        with self._setup_environment() as runner:
1183
-            for value in "-42", "invalid":
1184
-                result = runner.invoke(
1185
-                    cli.derivepassphrase_vault,
1186
-                    [option, value, "-p", "--", DUMMY_SERVICE],
1187
-                    input=DUMMY_PASSPHRASE,
1188
-                    catch_exceptions=False,
1189
-                )
1190
-                assert result.error_exit(error="Invalid value"), (
1191
-                    "expected error exit and known error message"
1192
-                )
1193
-
1194
-    @Parametrize.OPTION_COMBINATIONS_SERVICE_NEEDED
1195
-    def test_service_needed(
1196
-        self,
1197
-        options: list[str],
1198
-        service: bool | None,
1199
-        input: str | None,
1200
-        check_success: bool,
1201
-    ) -> None:
1202
-        """We require or forbid a service argument, depending on options."""
1203
-        config: _types.VaultConfig = {
1204
-            "global": {"phrase": "abc"},
1205
-            "services": {},
1206
-        }
1207
-        result = self._call(
1208
-            options if service else [*options, "--", DUMMY_SERVICE],
1209
-            config=config,
1210
-            input=input,
1211
-        )
1212
-        if service is not None:
1213
-            err_msg = (
1214
-                " requires a SERVICE"
1215
-                if service
1216
-                else " does not take a SERVICE argument"
1217
-            )
1218
-            assert result.error_exit(error=err_msg), (
1219
-                "expected error exit and known error message"
1220
-            )
1221
-            if check_success:
1222
-                result = self._call(
1223
-                    [*options, "--", DUMMY_SERVICE] if service else options,
1224
-                    config=config,
1225
-                    input=input,
1226
-                )
1227
-                assert result.clean_exit(empty_stderr=True), (
1228
-                    "expected clean exit"
1229
-                )
1230
-        else:
1231
-            assert result.clean_exit(empty_stderr=True), "expected clean exit"
1232
-
1233
-    def test_empty_service_name_causes_warning(
1234
-        self,
1235
-        caplog: pytest.LogCaptureFixture,
1236
-    ) -> None:
1237
-        """Using an empty service name (where permissible) warns.
1238
-
1239
-        Only the `--config` option can optionally take a service name.
1240
-
1241
-        """
1242
-
1243
-        def is_expected_warning(record: tuple[str, int, str]) -> bool:
1244
-            return is_harmless_config_import_warning(
1245
-                record
1246
-            ) or machinery.warning_emitted(
1247
-                "An empty SERVICE is not supported by vault(1)", [record]
1248
-            )
1249
-
1250
-        def check_result(result: machinery.ReadableResult) -> None:
1251
-            assert result.clean_exit(empty_stderr=False), "expected clean exit"
1252
-            assert result.stderr is not None, "expected known error output"
1253
-            assert all(map(is_expected_warning, caplog.record_tuples)), (
1254
-                "expected known error output"
1255
-            )
1256
-
1257
-        with self._setup_environment(
1258
-            config={"services": {}}, auto_prompt=True
1259
-        ) as runner:
1260
-            result = self._call(
1261
-                ["--config", "--length=30", "--", ""], runner=runner
1262
-            )
1263
-            check_result(result)
1264
-            assert cli_helpers.load_config() == {
1265
-                "global": {"length": 30},
1266
-                "services": {},
1267
-            }, "requested configuration change was not applied"
1268
-            caplog.clear()
1269
-            result = self._call(
1270
-                ["--import", "-"],
1271
-                input=json.dumps({"services": {"": {"length": 40}}}),
1272
-                runner=runner,
1273
-            )
1274
-            check_result(result)
1275
-            assert cli_helpers.load_config() == {
1276
-                "global": {"length": 30},
1277
-                "services": {"": {"length": 40}},
1278
-            }, "requested configuration change was not applied"
1279
-
1280
-    @Parametrize.OPTION_COMBINATIONS_INCOMPATIBLE
1281
-    def test_incompatible_options(
1282
-        self,
1283
-        options: list[str],
1284
-        service: bool | None,
1285
-    ) -> None:
1286
-        """Incompatible options are detected."""
1287
-        result = self._call(
1288
-            [*options, "--", DUMMY_SERVICE] if service else options,
1289
-            input=DUMMY_PASSPHRASE,
1290
-        )
1291
-        assert result.error_exit(error="mutually exclusive with "), (
1292
-            "expected error exit and known error message"
1293
-        )
1294
-
1295
-    def test_no_arguments(self) -> None:
1296
-        """Calling `derivepassphrase vault` without any arguments fails."""
1297
-        result = self._call([], input=DUMMY_PASSPHRASE)
1298
-        assert result.error_exit(
1299
-            error="Deriving a passphrase requires a SERVICE"
1300
-        ), "expected error exit and known error message"
1301
-
1302
-    def test_no_passphrase_or_key(
1303
-        self,
1304
-    ) -> None:
1305
-        """Deriving a passphrase without a passphrase or key fails."""
1306
-        result = self._call(["--", DUMMY_SERVICE], input=DUMMY_PASSPHRASE)
1307
-        assert result.error_exit(error="No passphrase or key was given"), (
1308
-            "expected error exit and known error message"
1309
-        )
1310
-
1311
-
1312
-class TestImportConfigValid:
1313
-    """Tests concerning `vault` configuration imports: valid imports."""
1314
-
1315
-    def _test(
1316
-        self,
1317
-        /,
1318
-        *,
1319
-        caplog: pytest.LogCaptureFixture,
1320
-        config: _types.VaultConfig,
1321
-    ) -> None:
1322
-        config2 = copy.deepcopy(config)
1323
-        _types.clean_up_falsy_vault_config_values(config2)
1324
-        runner = machinery.CliRunner(mix_stderr=False)
1325
-        # TODO(the-13th-letter): Rewrite using parenthesized
1326
-        # with-statements.
1327
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1328
-        with contextlib.ExitStack() as stack:
1329
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1330
-            stack.enter_context(
1331
-                pytest_machinery.isolated_vault_config(
1332
-                    monkeypatch=monkeypatch,
1333
-                    runner=runner,
1334
-                    vault_config={"services": {}},
1335
-                )
1336
-            )
1337
-            result = runner.invoke(
1338
-                cli.derivepassphrase_vault,
1339
-                ["--import", "-"],
1340
-                input=json.dumps(config),
1341
-                catch_exceptions=False,
1342
-            )
1343
-            config_txt = cli_helpers.config_filename(
1344
-                subsystem="vault"
1345
-            ).read_text(encoding="UTF-8")
1346
-            config3 = json.loads(config_txt)
1347
-        assert result.clean_exit(empty_stderr=False), "expected clean exit"
1348
-        assert config3 == config2, "config not imported correctly"
1349
-        assert not result.stderr or all(
1350
-            map(is_harmless_config_import_warning, caplog.record_tuples)
1351
-        ), "unexpected error output"
1352
-        assert_vault_config_is_indented_and_line_broken(config_txt)
1353
-
1354
-    @Parametrize.VALID_TEST_CONFIGS
1355
-    def test_normal_config(
1356
-        self,
1357
-        caplog: pytest.LogCaptureFixture,
1358
-        config: Any,
1359
-    ) -> None:
1360
-        """Importing a configuration works."""
1361
-        self._test(caplog=caplog, config=config)
1362
-
1363
-    @hypothesis.settings(
1364
-        suppress_health_check=[
1365
-            *hypothesis.settings().suppress_health_check,
1366
-            hypothesis.HealthCheck.function_scoped_fixture,
1367
-        ],
1368
-    )
1369
-    @hypothesis.given(
1370
-        conf=hypothesis_machinery.smudged_vault_test_config(
1371
-            strategies.sampled_from([
1372
-                conf for conf in data.TEST_CONFIGS if conf.is_valid()
1373
-            ])
1374
-        )
1375
-    )
1376
-    def test_smudged_config(
1377
-        self,
1378
-        caplog: pytest.LogCaptureFixture,
1379
-        conf: data.VaultTestConfig,
1380
-    ) -> None:
1381
-        """Importing a smudged configuration works.
1382
-
1383
-        Tested via hypothesis.
1384
-
1385
-        """
1386
-        # Reset caplog between hypothesis runs.
1387
-        caplog.clear()
1388
-        self._test(caplog=caplog, config=conf.config)
1389
-
1390
-
1391
-class TestImportConfigInvalid:
1392
-    """Tests concerning `vault` configuration imports: invalid imports."""
1393
-
1394
-    @contextlib.contextmanager
1395
-    def _setup_environment(self) -> Iterator[machinery.CliRunner]:
1396
-        runner = machinery.CliRunner(mix_stderr=False)
1397
-        # TODO(the-13th-letter): Rewrite using parenthesized
1398
-        # with-statements.
1399
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1400
-        with contextlib.ExitStack() as stack:
1401
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1402
-            stack.enter_context(
1403
-                pytest_machinery.isolated_config(
1404
-                    monkeypatch=monkeypatch,
1405
-                    runner=runner,
1406
-                )
1407
-            )
1408
-            yield runner
1409
-
1410
-    def _test(
1411
-        self,
1412
-        command_line: list[str],
1413
-        /,
1414
-        *,
1415
-        input: str | bytes | None = None,
1416
-    ) -> machinery.ReadableResult:
1417
-        with self._setup_environment() as runner:
1418
-            return runner.invoke(
1419
-                cli.derivepassphrase_vault,
1420
-                command_line,
1421
-                input=input,
1422
-                catch_exceptions=False,
1423
-            )
1424
-
1425
-    def test_not_a_vault_config(
1426
-        self,
1427
-    ) -> None:
1428
-        """Importing an invalid config fails."""
1429
-        result = self._test(["--import", "-"], input="null")
1430
-        assert result.error_exit(error="Invalid vault config"), (
1431
-            "expected error exit and known error message"
1432
-        )
1433
-
1434
-    def test_not_json_data(
1435
-        self,
1436
-    ) -> None:
1437
-        """Importing an invalid config fails."""
1438
-        result = self._test(
1439
-            ["--import", "-"], input="This string is not valid JSON."
1440
-        )
1441
-        assert result.error_exit(error="cannot decode JSON"), (
1442
-            "expected error exit and known error message"
1443
-        )
1444
-
1445
-    def test_not_a_file(
1446
-        self,
1447
-    ) -> None:
1448
-        """Importing an invalid config fails."""
1449
-        with self._setup_environment() as runner:
1450
-            # `_setup_environment` (via `isolated_vault_config`) ensures
1451
-            # the configuration is valid JSON.  So, to pass an actual
1452
-            # broken configuration, we must open the configuration file
1453
-            # ourselves afterwards, inside the context.
1454
-            cli_helpers.config_filename(subsystem="vault").write_text(
1455
-                "This string is not valid JSON.\n", encoding="UTF-8"
1456
-            )
1457
-            dname = cli_helpers.config_filename(subsystem=None)
1458
-            result = runner.invoke(
1459
-                cli.derivepassphrase_vault,
1460
-                ["--import", os.fsdecode(dname)],
1461
-                catch_exceptions=False,
1462
-            )
1463
-        # The Annoying OS uses EACCES, other OSes use EISDIR.
1464
-        assert result.error_exit(
1465
-            error=os.strerror(errno.EISDIR)
1466
-        ) or result.error_exit(error=os.strerror(errno.EACCES)), (
1467
-            "expected error exit and known error message"
1468
-        )
1469
-
1470
-
1471
-class TestExportConfigValid:
1472
-    """Tests concerning `vault` configuration exports: valid exports."""
1473
-
1474
-    def _assert_result(
1475
-        self,
1476
-        result: machinery.ReadableResult,
1477
-        /,
1478
-        *,
1479
-        caplog: pytest.LogCaptureFixture,
1480
-    ) -> None:
1481
-        assert result.clean_exit(empty_stderr=False), "expected clean exit"
1482
-        assert not result.stderr or all(
1483
-            map(is_harmless_config_import_warning, caplog.record_tuples)
1484
-        ), "unexpected error output"
1485
-
1486
-    def _test(
1487
-        self,
1488
-        /,
1489
-        *,
1490
-        caplog: pytest.LogCaptureFixture,
1491
-        config: _types.VaultConfig,
1492
-        use_import: bool = False,
1493
-    ) -> None:
1494
-        config2 = copy.deepcopy(config)
1495
-        _types.clean_up_falsy_vault_config_values(config2)
1496
-        runner = machinery.CliRunner(mix_stderr=False)
1497
-        # TODO(the-13th-letter): Rewrite using parenthesized
1498
-        # with-statements.
1499
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1500
-        with contextlib.ExitStack() as stack:
1501
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1502
-            stack.enter_context(
1503
-                pytest_machinery.isolated_vault_config(
1504
-                    monkeypatch=monkeypatch,
1505
-                    runner=runner,
1506
-                    vault_config={"services": {}},
1507
-                )
1508
-            )
1509
-            if use_import:
1510
-                result1 = runner.invoke(
1511
-                    cli.derivepassphrase_vault,
1512
-                    ["--import", "-"],
1513
-                    input=json.dumps(config),
1514
-                    catch_exceptions=False,
1515
-                )
1516
-                self._assert_result(result1, caplog=caplog)
1517
-            else:
1518
-                with cli_helpers.config_filename(subsystem="vault").open(
1519
-                    "w", encoding="UTF-8"
1520
-                ) as outfile:
1521
-                    # Ensure the config is written on one line.
1522
-                    json.dump(config, outfile, indent=None)
1523
-            result = runner.invoke(
1524
-                cli.derivepassphrase_vault,
1525
-                ["--export", "-"],
1526
-                catch_exceptions=False,
1527
-            )
1528
-        self._assert_result(result, caplog=caplog)
1529
-        config3 = json.loads(result.stdout)
1530
-        assert config3 == config2, "config not exported correctly"
1531
-        assert_vault_config_is_indented_and_line_broken(result.stdout)
1532
-
1533
-    @Parametrize.VALID_TEST_CONFIGS
1534
-    def test_normal_config(
1535
-        self,
1536
-        caplog: pytest.LogCaptureFixture,
1537
-        config: Any,
1538
-    ) -> None:
1539
-        """Exporting a configuration works."""
1540
-        self._test(caplog=caplog, config=config, use_import=False)
1541
-
1542
-    @hypothesis.settings(
1543
-        suppress_health_check=[
1544
-            *hypothesis.settings().suppress_health_check,
1545
-            hypothesis.HealthCheck.function_scoped_fixture,
1546
-        ],
1547
-    )
1548
-    @hypothesis.given(
1549
-        conf=hypothesis_machinery.smudged_vault_test_config(
1550
-            strategies.sampled_from([
1551
-                conf for conf in data.TEST_CONFIGS if conf.is_valid()
1552
-            ])
1553
-        )
1554
-    )
1555
-    def test_reexport_smudged_config(
1556
-        self,
1557
-        caplog: pytest.LogCaptureFixture,
1558
-        conf: data.VaultTestConfig,
1559
-    ) -> None:
1560
-        """Re-exporting a smudged configuration works.
1561
-
1562
-        Tested via hypothesis.
1563
-
1564
-        """
1565
-        # Reset caplog between hypothesis runs.
1566
-        caplog.clear()
1567
-        self._test(caplog=caplog, config=conf.config, use_import=True)
1568
-
1569
-    @Parametrize.EXPORT_FORMAT_OPTIONS
1570
-    def test_no_stored_settings(
1571
-        self,
1572
-        export_options: list[str],
1573
-    ) -> None:
1574
-        """Exporting the default, empty config works."""
1575
-        runner = machinery.CliRunner(mix_stderr=False)
1576
-        # TODO(the-13th-letter): Rewrite using parenthesized
1577
-        # with-statements.
1578
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1579
-        with contextlib.ExitStack() as stack:
1580
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1581
-            stack.enter_context(
1582
-                pytest_machinery.isolated_config(
1583
-                    monkeypatch=monkeypatch,
1584
-                    runner=runner,
1585
-                )
1586
-            )
1587
-            cli_helpers.config_filename(subsystem="vault").unlink(
1588
-                missing_ok=True
1589
-            )
1590
-            result = runner.invoke(
1591
-                # Test parent context navigation by not calling
1592
-                # `cli.derivepassphrase_vault` directly.  Used e.g. in
1593
-                # the `--export-as=sh` section to autoconstruct the
1594
-                # program name correctly.
1595
-                cli.derivepassphrase,
1596
-                ["vault", "--export", "-", *export_options],
1597
-                catch_exceptions=False,
1598
-            )
1599
-        assert result.clean_exit(empty_stderr=True), "expected clean exit"
1600
-        assert result.stdout.startswith("#!") or json.loads(result.stdout) == {
1601
-            "services": {}
1602
-        }
1603
-
1604
-
1605
-class TestExportConfigInvalid:
1606
-    """Tests concerning `vault` configuration exports: invalid exports."""
1607
-
1608
-    @contextlib.contextmanager
1609
-    def _test(
1610
-        self,
1611
-        command_line: list[str],
1612
-        /,
1613
-        *,
1614
-        config: _types.VaultConfig = {"services": {}},  # noqa: B006
1615
-        error_messages: tuple[str, ...] = (),
1616
-    ) -> Iterator[list[str]]:
1617
-        runner = machinery.CliRunner(mix_stderr=False)
1618
-        # TODO(the-13th-letter): Rewrite using parenthesized
1619
-        # with-statements.
1620
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1621
-        with contextlib.ExitStack() as stack:
1622
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1623
-            stack.enter_context(
1624
-                pytest_machinery.isolated_vault_config(
1625
-                    monkeypatch=monkeypatch,
1626
-                    runner=runner,
1627
-                    vault_config=config,
1628
-                )
1629
-            )
1630
-            yield command_line
1631
-            result = runner.invoke(
1632
-                cli.derivepassphrase_vault,
1633
-                command_line,
1634
-                input="null",
1635
-                catch_exceptions=False,
1636
-            )
1637
-        assert any([result.error_exit(error=msg) for msg in error_messages]), (
1638
-            "expected error exit and known error message"
1639
-        )
1640
-
1641
-    @Parametrize.EXPORT_FORMAT_OPTIONS
1642
-    def test_bad_stored_config(
1643
-        self,
1644
-        export_options: list[str],
1645
-    ) -> None:
1646
-        """Exporting an invalid config fails."""
1647
-        with self._test(
1648
-            ["--export", "-", *export_options],
1649
-            config=None,  # type: ignore[arg-type,typeddict-item]
1650
-            error_messages=("Cannot load vault settings:",),
1651
-        ):
1652
-            pass
1653
-
1654
-    @Parametrize.EXPORT_FORMAT_OPTIONS
1655
-    def test_not_a_file(
1656
-        self,
1657
-        export_options: list[str],
1658
-    ) -> None:
1659
-        """Exporting an invalid config fails."""
1660
-        with self._test(
1661
-            ["--export", "-", *export_options],
1662
-            error_messages=("Cannot load vault settings:",),
1663
-        ):
1664
-            config_file = cli_helpers.config_filename(subsystem="vault")
1665
-            config_file.unlink(missing_ok=True)
1666
-            config_file.mkdir(parents=True, exist_ok=True)
1667
-
1668
-    @Parametrize.EXPORT_FORMAT_OPTIONS
1669
-    def test_target_not_a_file(
1670
-        self,
1671
-        export_options: list[str],
1672
-    ) -> None:
1673
-        """Exporting an invalid config fails."""
1674
-        with self._test(
1675
-            [], error_messages=("Cannot export vault settings:",)
1676
-        ) as command_line:
1677
-            dname = cli_helpers.config_filename(subsystem=None)
1678
-            command_line[:] = ["--export", os.fsdecode(dname), *export_options]
1679
-
1680
-    @pytest_machinery.skip_if_on_the_annoying_os
1681
-    @Parametrize.EXPORT_FORMAT_OPTIONS
1682
-    def test_settings_directory_not_a_directory(
1683
-        self,
1684
-        export_options: list[str],
1685
-    ) -> None:
1686
-        """Exporting an invalid config fails."""
1687
-        with self._test(
1688
-            ["--export", "-", *export_options],
1689
-            error_messages=(
1690
-                "Cannot load vault settings:",
1691
-                "Cannot load user config:",
1692
-            ),
1693
-        ):
1694
-            config_dir = cli_helpers.config_filename(subsystem=None)
1695
-            with contextlib.suppress(FileNotFoundError):
1696
-                shutil.rmtree(config_dir)
1697
-            config_dir.write_text("Obstruction!!\n")
1698
-
1699
-
1700
-class TestNotesPrinting:
1701
-    """Tests concerning printing the service notes."""
1702
-
1703
-    def _test(
1704
-        self,
1705
-        notes: str,
1706
-        /,
1707
-        notes_placement: Literal["before", "after"] | None = None,
1708
-        placement_args: list[str] | tuple[str, ...] = (),
1709
-    ) -> None:
1710
-        notes_stripped = notes.strip()
1711
-        maybe_notes = {"notes": notes_stripped} if notes_stripped else {}
1712
-        vault_config = {
1713
-            "global": {"phrase": DUMMY_PASSPHRASE},
1714
-            "services": {
1715
-                DUMMY_SERVICE: {**maybe_notes, **DUMMY_CONFIG_SETTINGS}
1716
-            },
1717
-        }
1718
-        result_phrase = DUMMY_RESULT_PASSPHRASE.decode("ascii")
1719
-        expected = (
1720
-            f"{notes_stripped}\n\n{result_phrase}\n"
1721
-            if notes_placement == "before"
1722
-            else f"{result_phrase}\n\n{notes_stripped}\n\n"
1723
-            if notes_placement == "after"
1724
-            else None
1725
-        )
1726
-        runner = machinery.CliRunner(mix_stderr=notes_placement is not None)
1727
-        # TODO(the-13th-letter): Rewrite using parenthesized
1728
-        # with-statements.
1729
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1730
-        with contextlib.ExitStack() as stack:
1731
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1732
-            stack.enter_context(
1733
-                pytest_machinery.isolated_vault_config(
1734
-                    monkeypatch=monkeypatch,
1735
-                    runner=runner,
1736
-                    vault_config=vault_config,
1737
-                )
1738
-            )
1739
-            result = runner.invoke(
1740
-                cli.derivepassphrase_vault,
1741
-                [*placement_args, "--", DUMMY_SERVICE],
1742
-                catch_exceptions=False,
1743
-            )
1744
-            if expected is not None:
1745
-                assert result.clean_exit(output=expected), (
1746
-                    "expected clean exit"
1747
-                )
1748
-            else:
1749
-                assert result.clean_exit(), "expected clean exit"
1750
-                assert result.stdout, "expected program output"
1751
-                assert result.stdout.strip() == result_phrase, (
1752
-                    "expected known program output"
1753
-                )
1754
-                assert result.stderr or not notes_stripped, "expected stderr"
1755
-                assert "Error:" not in result.stderr, (
1756
-                    "expected no error messages on stderr"
1757
-                )
1758
-                assert result.stderr.strip() == notes_stripped, (
1759
-                    "expected known stderr contents"
1760
-                )
1761
-
1762
-    @hypothesis.given(notes=Strategies.notes().filter(str.strip))
1763
-    def test_service_with_notes_actually_prints_notes(
1764
-        self,
1765
-        notes: str,
1766
-    ) -> None:
1767
-        """Service notes are printed, if they exist."""
1768
-        hypothesis.assume("Error:" not in notes)
1769
-        self._test(notes, notes_placement=None, placement_args=())
1770
-
1771
-    @Parametrize.NOTES_PLACEMENT
1772
-    @hypothesis.given(notes=Strategies.notes().filter(str.strip))
1773
-    def test_notes_placement(
1774
-        self,
1775
-        notes_placement: Literal["before", "after"],
1776
-        placement_args: list[str],
1777
-        notes: str,
1778
-    ) -> None:
1779
-        self._test(
1780
-            notes,
1781
-            notes_placement=notes_placement,
1782
-            placement_args=placement_args,
1783
-        )
1784
-
1785
-
1786
-class TestNotesEditing:
1787
-    """Superclass for tests concerning editing service notes."""
1788
-
1789
-    CURRENT_NOTES = "Contents go here"
1790
-    OLD_NOTES_TEXT = (
1791
-        "These backup notes are left over from the previous session."
1792
-    )
1793
-
1794
-    def _calculate_expected_contents(
1795
-        self,
1796
-        final_notes: str,
1797
-        /,
1798
-        *,
1799
-        modern_editor_interface: bool,
1800
-        current_notes: str | None = CURRENT_NOTES,
1801
-        old_notes_text: str | None = OLD_NOTES_TEXT,
1802
-    ) -> tuple[str, str]:
1803
-        current_notes = current_notes or ""
1804
-        old_notes_text = old_notes_text or ""
1805
-        # For the modern editor interface, the notes change if and only
1806
-        # if the notes change to a different, non-empty string.  There
1807
-        # are no backup notes, so we return the old ones (which may be
1808
-        # synthetic) unchanged.
1809
-        if modern_editor_interface:
1810
-            return old_notes_text.strip(), (
1811
-                final_notes.strip()
1812
-                if final_notes.strip()
1813
-                and final_notes.strip() != current_notes.strip()
1814
-                else current_notes.strip()
1815
-            )
1816
-        # For the legacy editor interface, the notes and the backup
1817
-        # notes change if and only if the new notes differ from the
1818
-        # previous notes.
1819
-        return (
1820
-            (current_notes.strip(), final_notes.strip())
1821
-            if final_notes.strip() != current_notes.strip()
1822
-            else (old_notes_text.strip(), current_notes.strip())
1823
-        )
1824
-
1825
-    def _test(
1826
-        self,
1827
-        edit_result: str,
1828
-        /,
1829
-        *,
1830
-        modern_editor_interface: bool,
1831
-        current_notes: str | None = CURRENT_NOTES,
1832
-        old_notes_text: str | None = OLD_NOTES_TEXT,
1833
-    ) -> tuple[machinery.ReadableResult, str, _types.VaultConfig]:
1834
-        if hypothesis.currently_in_test_context():  # pragma: no branch
1835
-            hypothesis.note(f"{edit_result = }")
1836
-            hypothesis.note(f"{modern_editor_interface = }")
1837
-            hypothesis.note(
1838
-                f"vault_config = {self._vault_config(current_notes or '')}"
1839
-            )
1840
-        runner = machinery.CliRunner(mix_stderr=False)
1841
-        # TODO(the-13th-letter): Rewrite using parenthesized
1842
-        # with-statements.
1843
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1844
-        with contextlib.ExitStack() as stack:
1845
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1846
-            stack.enter_context(
1847
-                pytest_machinery.isolated_vault_config(
1848
-                    monkeypatch=monkeypatch,
1849
-                    runner=runner,
1850
-                    vault_config=self._vault_config(current_notes or ""),
1851
-                )
1852
-            )
1853
-            notes_backup_file = cli_helpers.config_filename(
1854
-                subsystem="notes backup"
1855
-            )
1856
-            if old_notes_text and old_notes_text.strip():  # pragma: no branch
1857
-                notes_backup_file.write_text(
1858
-                    old_notes_text.strip(), encoding="UTF-8"
1859
-                )
1860
-            monkeypatch.setattr(click, "edit", lambda *_a, **_kw: edit_result)
1861
-            result = runner.invoke(
1862
-                cli.derivepassphrase_vault,
1863
-                [
1864
-                    "--config",
1865
-                    "--notes",
1866
-                    "--modern-editor-interface"
1867
-                    if modern_editor_interface
1868
-                    else "--vault-legacy-editor-interface",
1869
-                    "--",
1870
-                    "sv",
1871
-                ],
1872
-                catch_exceptions=False,
1873
-            )
1874
-            backup_contents = notes_backup_file.read_text(encoding="UTF-8")
1875
-            with cli_helpers.config_filename(subsystem="vault").open(
1876
-                encoding="UTF-8"
1877
-            ) as infile:
1878
-                config = json.load(infile)
1879
-            if hypothesis.currently_in_test_context():  # pragma: no branch
1880
-                hypothesis.note(f"{result = }")
1881
-                hypothesis.note(f"{backup_contents = }")
1882
-                hypothesis.note(f"{config = }")
1883
-            return result, backup_contents, config
1884
-
1885
-    def _assert_noop_exit(
1886
-        self,
1887
-        result: machinery.ReadableResult,
1888
-        /,
1889
-        *,
1890
-        modern_editor_interface: bool = False,
1891
-    ) -> None:
1892
-        # We do not distinguish between aborts and no-op edits.  Aborts
1893
-        # are treated as failures (error exit), and thus tested
1894
-        # specifically in a different class.
1895
-        if modern_editor_interface:
1896
-            assert result.error_exit(
1897
-                error="the user aborted the request"
1898
-            ) or result.clean_exit(empty_stderr=True), "expected clean exit"
1899
-        else:
1900
-            assert result.clean_exit(empty_stderr=False), "expected clean exit"
1901
-
1902
-    def _assert_normal_exit(self, result: machinery.ReadableResult) -> None:
1903
-        assert result.clean_exit(), "expected clean exit"
1904
-        assert all(map(is_warning_line, result.stderr.splitlines(True)))
1905
-
1906
-    def _assert_notes_backup_warning(
1907
-        self,
1908
-        caplog: pytest.LogCaptureFixture,
1909
-        /,
1910
-        *,
1911
-        modern_editor_interface: bool,
1912
-        notes_unchanged: bool = False,
1913
-    ) -> None:
1914
-        assert (
1915
-            modern_editor_interface
1916
-            or notes_unchanged
1917
-            or machinery.warning_emitted(
1918
-                "A backup copy of the old notes was saved",
1919
-                caplog.record_tuples,
1920
-            )
1921
-        ), "expected known warning message on stderr"
1922
-
1923
-    def _assert_notes_and_backup_notes(
1924
-        self,
1925
-        /,
1926
-        *,
1927
-        final_notes: str,
1928
-        new_backup_notes: str,
1929
-        new_config: _types.VaultConfig,
1930
-        modern_editor_interface: bool,
1931
-        current_notes: str | None = CURRENT_NOTES,
1932
-        old_notes_text: str | None = OLD_NOTES_TEXT,
1933
-    ) -> None:
1934
-        if hypothesis.currently_in_test_context():  # pragma: no branch
1935
-            hypothesis.note(f"{final_notes = }")
1936
-            hypothesis.note(f"{current_notes = }")
1937
-        expected_backup_notes, expected_notes = (
1938
-            self._calculate_expected_contents(
1939
-                final_notes,
1940
-                modern_editor_interface=modern_editor_interface,
1941
-                current_notes=current_notes,
1942
-                old_notes_text=old_notes_text,
1943
-            )
1944
-        )
1945
-        expected_config = self._vault_config(expected_notes)
1946
-        assert new_config == expected_config
1947
-        assert new_backup_notes == expected_backup_notes
1948
-
1949
-    @staticmethod
1950
-    def _vault_config(
1951
-        starting_notes: str = CURRENT_NOTES, /
1952
-    ) -> _types.VaultConfig:
1953
-        return {
1954
-            "global": {"phrase": "abc"},
1955
-            "services": {
1956
-                "sv": {"notes": starting_notes.strip()}
1957
-                if starting_notes.strip()
1958
-                else {}
1959
-            },
1960
-        }
1961
-
1962
-    class ExtraArgs(TypedDict):
1963
-        modern_editor_interface: bool
1964
-        current_notes: NotRequired[str]
1965
-        old_notes_text: NotRequired[str]
1966
-
1967
-
1968
-class TestNotesEditingValid(TestNotesEditing):
1969
-    """Tests concerning editing service notes: valid calls."""
1970
-
1971
-    @Parametrize.MODERN_EDITOR_INTERFACE
1972
-    @hypothesis.settings(
1973
-        suppress_health_check=[
1974
-            *hypothesis.settings().suppress_health_check,
1975
-            hypothesis.HealthCheck.function_scoped_fixture,
1976
-        ],
1977
-    )
1978
-    @hypothesis.given(
1979
-        notes=Strategies.notes()
1980
-        .filter(str.strip)
1981
-        .filter(lambda notes: notes != TestNotesEditingValid.CURRENT_NOTES)
1982
-    )
1983
-    @hypothesis.example(TestNotesEditing.CURRENT_NOTES)
1984
-    def test_successful_edit(
1985
-        self,
1986
-        caplog: pytest.LogCaptureFixture,
1987
-        modern_editor_interface: bool,
1988
-        notes: str,
1989
-    ) -> None:
1990
-        """Editing notes works."""
1991
-        # Reset caplog between hypothesis runs.
1992
-        caplog.clear()
1993
-        marker = cli_messages.TranslatedString(
1994
-            cli_messages.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER
1995
-        )
1996
-        edit_result = (
1997
-            f"""
1998
-
1999
-{marker}
2000
-{notes}
2001
-"""
2002
-            if modern_editor_interface
2003
-            else notes.strip()
2004
-        )
2005
-
2006
-        extra_args: TestNotesEditing.ExtraArgs = {
2007
-            "modern_editor_interface": modern_editor_interface,
2008
-            "current_notes": self.CURRENT_NOTES,
2009
-            "old_notes_text": self.OLD_NOTES_TEXT,
2010
-        }
2011
-        notes_unchanged = notes.strip() == extra_args["current_notes"].strip()
2012
-
2013
-        result, new_backup_notes, new_config = self._test(
2014
-            edit_result, **extra_args
2015
-        )
2016
-        self._assert_normal_exit(result)
2017
-        self._assert_notes_and_backup_notes(
2018
-            final_notes=notes,
2019
-            new_backup_notes=new_backup_notes,
2020
-            new_config=new_config,
2021
-            **extra_args,
2022
-        )
2023
-        self._assert_notes_backup_warning(
2024
-            caplog,
2025
-            modern_editor_interface=modern_editor_interface,
2026
-            notes_unchanged=notes_unchanged,
2027
-        )
2028
-
2029
-    @Parametrize.MODERN_EDITOR_INTERFACE
2030
-    @hypothesis.settings(
2031
-        suppress_health_check=[
2032
-            *hypothesis.settings().suppress_health_check,
2033
-            hypothesis.HealthCheck.function_scoped_fixture,
2034
-        ],
2035
-    )
2036
-    @hypothesis.given(notes=Strategies.notes().filter(str.strip))
2037
-    @hypothesis.example(TestNotesEditing.CURRENT_NOTES)
2038
-    def test_noop_edit(
2039
-        self,
2040
-        caplog: pytest.LogCaptureFixture,
2041
-        modern_editor_interface: bool,
2042
-        notes: str,
2043
-    ) -> None:
2044
-        """No-op editing existing notes works.
2045
-
2046
-        The notes are unchanged, and the command-line interface does not
2047
-        report an abort.  For the legacy editor interface, the backup
2048
-        notes are unchanged as well.
2049
-
2050
-        """
2051
-        # Reset caplog between hypothesis runs.
2052
-        caplog.clear()
2053
-        marker = cli_messages.TranslatedString(
2054
-            cli_messages.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER
2055
-        )
2056
-        edit_result = (f"{marker}\n" if modern_editor_interface else "") + (
2057
-            " " * 6 + notes + "\n" * 6
2058
-        )
2059
-
2060
-        extra_args: TestNotesEditing.ExtraArgs = {
2061
-            "modern_editor_interface": modern_editor_interface,
2062
-            "current_notes": notes.strip(),
2063
-            "old_notes_text": self.OLD_NOTES_TEXT,
2064
-        }
2065
-
2066
-        result, new_backup_notes, new_config = self._test(
2067
-            edit_result, **extra_args
2068
-        )
2069
-        self._assert_noop_exit(
2070
-            result,
2071
-            modern_editor_interface=modern_editor_interface,
2072
-        )
2073
-        self._assert_notes_and_backup_notes(
2074
-            final_notes=notes,
2075
-            new_backup_notes=new_backup_notes,
2076
-            new_config=new_config,
2077
-            **extra_args,
2078
-        )
2079
-        self._assert_notes_backup_warning(
2080
-            caplog,
2081
-            modern_editor_interface=modern_editor_interface,
2082
-            notes_unchanged=True,
2083
-        )
2084
-
2085
-    # TODO(the-13th-letter): Keep this behavior or not, with or without
2086
-    # warning?
2087
-    @Parametrize.MODERN_EDITOR_INTERFACE
2088
-    @hypothesis.settings(
2089
-        suppress_health_check=[
2090
-            *hypothesis.settings().suppress_health_check,
2091
-            hypothesis.HealthCheck.function_scoped_fixture,
2092
-        ],
2093
-    )
2094
-    @hypothesis.given(notes=Strategies.notes().filter(str.strip))
2095
-    def test_marker_removed(
2096
-        self,
2097
-        caplog: pytest.LogCaptureFixture,
2098
-        modern_editor_interface: bool,
2099
-        notes: str,
2100
-    ) -> None:
2101
-        """Removing the notes marker still saves the notes.
2102
-
2103
-        TODO: Keep this behavior or not, with or without warning?
2104
-
2105
-        """
2106
-        notes_marker = cli_messages.TranslatedString(
2107
-            cli_messages.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER
2108
-        )
2109
-        hypothesis.assume(str(notes_marker) not in notes.strip())
2110
-        # Reset caplog between hypothesis runs.
2111
-        caplog.clear()
2112
-
2113
-        extra_args: TestNotesEditing.ExtraArgs = {
2114
-            "modern_editor_interface": modern_editor_interface,
2115
-            "current_notes": self.CURRENT_NOTES,
2116
-            "old_notes_text": self.OLD_NOTES_TEXT,
2117
-        }
2118
-        notes_unchanged = notes.strip() == extra_args["current_notes"].strip()
2119
-
2120
-        result, new_backup_notes, new_config = self._test(
2121
-            notes.strip(), **extra_args
2122
-        )
2123
-        self._assert_normal_exit(result)
2124
-        self._assert_notes_and_backup_notes(
2125
-            final_notes=notes,
2126
-            new_backup_notes=new_backup_notes,
2127
-            new_config=new_config,
2128
-            **extra_args,
2129
-        )
2130
-        self._assert_notes_backup_warning(
2131
-            caplog,
2132
-            modern_editor_interface=modern_editor_interface,
2133
-            notes_unchanged=notes_unchanged,
2134
-        )
2135
-
2136
-
2137
-class TestNotesEditingInvalid(TestNotesEditing):
2138
-    """Tests concerning editing service notes: invalid/error calls."""
2139
-
2140
-    @hypothesis.given(notes=Strategies.notes())
2141
-    @hypothesis.example("")
2142
-    def test_abort(
2143
-        self,
2144
-        notes: str,
2145
-    ) -> None:
2146
-        """Aborting editing notes works, even if no notes are stored yet.
2147
-
2148
-        Aborting is only supported with the modern editor interface.
2149
-
2150
-        """
2151
-        edit_result = ""
2152
-
2153
-        extra_args: TestNotesEditing.ExtraArgs = {
2154
-            "modern_editor_interface": True,
2155
-            "current_notes": notes.strip(),
2156
-            "old_notes_text": self.OLD_NOTES_TEXT,
2157
-        }
2158
-
2159
-        result, new_backup_notes, new_config = self._test(
2160
-            edit_result, **extra_args
2161
-        )
2162
-        assert result.error_exit(error="the user aborted the request"), (
2163
-            "expected error exit"
2164
-        )
2165
-        self._assert_notes_and_backup_notes(
2166
-            final_notes=notes.strip(),
2167
-            new_backup_notes=new_backup_notes,
2168
-            new_config=new_config,
2169
-            **extra_args,
2170
-        )
2171
-
2172
-    @Parametrize.MODERN_EDITOR_INTERFACE
2173
-    @hypothesis.settings(
2174
-        suppress_health_check=[
2175
-            *hypothesis.settings().suppress_health_check,
2176
-            hypothesis.HealthCheck.function_scoped_fixture,
2177
-        ],
2178
-    )
2179
-    @hypothesis.given(notes=Strategies.notes())
2180
-    @hypothesis.example("")
2181
-    def test_fail_on_config_option_missing(
2182
-        self,
2183
-        caplog: pytest.LogCaptureFixture,
2184
-        modern_editor_interface: bool,
2185
-        notes: str,
2186
-    ) -> None:
2187
-        """Editing notes fails (and warns) if `--config` is missing."""
2188
-        maybe_notes = {"notes": notes.strip()} if notes.strip() else {}
2189
-        vault_config = {
2190
-            "global": {"phrase": DUMMY_PASSPHRASE},
2191
-            "services": {
2192
-                DUMMY_SERVICE: {**maybe_notes, **DUMMY_CONFIG_SETTINGS}
2193
-            },
2194
-        }
2195
-        old_notes_text = (
2196
-            "These backup notes are left over from the previous session."
2197
-        )
2198
-        # Reset caplog between hypothesis runs.
2199
-        caplog.clear()
2200
-        runner = machinery.CliRunner(mix_stderr=False)
2201
-        # TODO(the-13th-letter): Rewrite using parenthesized
2202
-        # with-statements.
2203
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2204
-        with contextlib.ExitStack() as stack:
2205
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2206
-            stack.enter_context(
2207
-                pytest_machinery.isolated_vault_config(
2208
-                    monkeypatch=monkeypatch,
2209
-                    runner=runner,
2210
-                    vault_config=vault_config,
2211
-                )
2212
-            )
2213
-            EDIT_ATTEMPTED = "edit attempted!"  # noqa: N806
2214
-
2215
-            def raiser(*_args: Any, **_kwargs: Any) -> NoReturn:
2216
-                pytest.fail(EDIT_ATTEMPTED)
2217
-
2218
-            notes_backup_file = cli_helpers.config_filename(
2219
-                subsystem="notes backup"
2220
-            )
2221
-            notes_backup_file.write_text(old_notes_text, encoding="UTF-8")
2222
-            monkeypatch.setattr(click, "edit", raiser)
2223
-            result = runner.invoke(
2224
-                cli.derivepassphrase_vault,
2225
-                [
2226
-                    "--notes",
2227
-                    "--modern-editor-interface"
2228
-                    if modern_editor_interface
2229
-                    else "--vault-legacy-editor-interface",
2230
-                    "--",
2231
-                    DUMMY_SERVICE,
2232
-                ],
2233
-                catch_exceptions=False,
2234
-            )
2235
-            assert result.clean_exit(
2236
-                output=DUMMY_RESULT_PASSPHRASE.decode("ascii")
2237
-            ), "expected clean exit"
2238
-            assert result.stderr
2239
-            assert notes.strip() in result.stderr
2240
-            assert all(
2241
-                is_warning_line(line)
2242
-                for line in result.stderr.splitlines(True)
2243
-                if line.startswith(f"{cli.PROG_NAME}: ")
2244
-            )
2245
-            assert machinery.warning_emitted(
2246
-                "Specifying --notes without --config is ineffective.  "
2247
-                "No notes will be edited.",
2248
-                caplog.record_tuples,
2249
-            ), "expected known warning message in stderr"
2250
-            assert (
2251
-                modern_editor_interface
2252
-                or notes_backup_file.read_text(encoding="UTF-8")
2253
-                == old_notes_text
2254
-            )
2255
-            with cli_helpers.config_filename(subsystem="vault").open(
2256
-                encoding="UTF-8"
2257
-            ) as infile:
2258
-                config = json.load(infile)
2259
-            assert config == vault_config
2260
-
2261
-
2262
-class TestStoringConfigurationSuccesses:
2263
-    """Tests concerning storing the configuration: successes."""
2264
-
2265
-    def _test(
2266
-        self,
2267
-        command_line: list[str],
2268
-        /,
2269
-        *,
2270
-        starting_config: _types.VaultConfig | None,
2271
-        result_config: _types.VaultConfig,
2272
-        input: str | bytes | None = None,
2273
-    ) -> machinery.ReadableResult:
2274
-        runner = machinery.CliRunner(mix_stderr=False)
2275
-        # TODO(the-13th-letter): Rewrite using parenthesized
2276
-        # with-statements.
2277
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2278
-        with contextlib.ExitStack() as stack:
2279
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2280
-            stack.enter_context(
2281
-                pytest_machinery.isolated_vault_config(
2282
-                    monkeypatch=monkeypatch,
2283
-                    runner=runner,
2284
-                    vault_config=starting_config,
2285
-                )
2286
-            )
2287
-            if starting_config is None:
2288
-                with contextlib.suppress(FileNotFoundError):
2289
-                    shutil.rmtree(cli_helpers.config_filename(subsystem=None))
2290
-            monkeypatch.setattr(
2291
-                cli_helpers,
2292
-                "get_suitable_ssh_keys",
2293
-                callables.suitable_ssh_keys,
2294
-            )
2295
-            result = runner.invoke(
2296
-                cli.derivepassphrase_vault,
2297
-                ["--config", *command_line],
2298
-                catch_exceptions=False,
2299
-                input=input,
2300
-            )
2301
-            assert result.clean_exit(), "expected clean exit"
2302
-            config_txt = cli_helpers.config_filename(
2303
-                subsystem="vault"
2304
-            ).read_text(encoding="UTF-8")
2305
-            config = json.loads(config_txt)
2306
-            assert config == result_config, (
2307
-                "stored config does not match expectation"
2308
-            )
2309
-            assert_vault_config_is_indented_and_line_broken(config_txt)
2310
-            return result
2311
-
2312
-    @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG
2313
-    def test_store_good_config(
2314
-        self,
2315
-        command_line: list[str],
2316
-        input: str,
2317
-        starting_config: Any,
2318
-        result_config: Any,
2319
-    ) -> None:
2320
-        """Storing valid settings via `--config` works.
2321
-
2322
-        The format also contains embedded newlines and indentation to make
2323
-        the config more readable.
2324
-
2325
-        """
2326
-        self._test(
2327
-            command_line,
2328
-            input=input,
2329
-            starting_config=starting_config,
2330
-            result_config=result_config,
2331
-        )
2332
-
2333
-    def test_config_directory_nonexistant(
2334
-        self,
2335
-    ) -> None:
2336
-        """Running without an existing config directory works.
2337
-
2338
-        This is a regression test; see [the "pretty-print-json"
2339
-        issue][PRETTY_PRINT_JSON] for context.  See also
2340
-        [TestStoringConfigurationFailures.test_config_directory_not_a_file][]
2341
-        for a related aspect of this.
2342
-
2343
-        [PRETTY_PRINT_JSON]: https://the13thletter.info/derivepassphrase/0.x/wishlist/pretty-print-json/
2344
-
2345
-        """
2346
-        result = self._test(
2347
-            ["-p"],
2348
-            starting_config=None,
2349
-            result_config={"global": {"phrase": "abc"}, "services": {}},
2350
-            input="abc\n",
2351
-        )
2352
-        assert result.stderr == "Passphrase:", "program unexpectedly failed?!"
2353
-
2354
-
2355
-class TestStoringConfigurationFailures:
2356
-    """Tests concerning storing the configuration: failures."""
2357
-
2358
-    @contextlib.contextmanager
2359
-    def _test(
2360
-        self,
2361
-        command_line: list[str],
2362
-        error_text: str,
2363
-        input: str | bytes | None = None,
2364
-        starting_config: _types.VaultConfig = {  # noqa: B006
2365
-            "global": {"phrase": "abc"},
2366
-            "services": {},
2367
-        },
2368
-        patch_suitable_ssh_keys: bool = True,
2369
-    ) -> Iterator[pytest.MonkeyPatch]:
2370
-        runner = machinery.CliRunner(mix_stderr=False)
2371
-        # TODO(the-13th-letter): Rewrite using parenthesized
2372
-        # with-statements.
2373
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2374
-        with contextlib.ExitStack() as stack:
2375
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2376
-            stack.enter_context(
2377
-                pytest_machinery.isolated_vault_config(
2378
-                    monkeypatch=monkeypatch,
2379
-                    runner=runner,
2380
-                    vault_config=starting_config,
2381
-                )
2382
-            )
2383
-            # Patch the list of suitable SSH keys by default, lest we be
2384
-            # at the mercy of whatever SSH agent may be running. (But
2385
-            # allow a test to turn this off, if it would interfere with
2386
-            # the testing target, e.g. because we are testing
2387
-            # non-reachability of the agent.)
2388
-            if patch_suitable_ssh_keys:
2389
-                monkeypatch.setattr(
2390
-                    cli_helpers,
2391
-                    "get_suitable_ssh_keys",
2392
-                    callables.suitable_ssh_keys,
2393
-                )
2394
-            yield monkeypatch
2395
-            result = runner.invoke(
2396
-                cli.derivepassphrase_vault,
2397
-                ["--config", *command_line],
2398
-                catch_exceptions=False,
2399
-                input=input,
2400
-            )
2401
-        assert result.error_exit(error=error_text), (
2402
-            "expected error exit and known error message"
2403
-        )
2404
-
2405
-    @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG_FAILURES
2406
-    def test_store_bad_config(
2407
-        self,
2408
-        command_line: list[str],
2409
-        input: str,
2410
-        err_text: str,
2411
-    ) -> None:
2412
-        """Storing invalid settings via `--config` fails."""
2413
-        with self._test(command_line, error_text=err_text, input=input):
2414
-            pass
2415
-
2416
-    def test_fail_because_no_ssh_key_selection(self) -> None:
2417
-        """Not selecting an SSH key during `--config --key` fails.
2418
-
2419
-        (This test does not actually need a running agent; the agent's
2420
-        response is mocked by the test harness.)
2421
-
2422
-        """
2423
-        with self._test(
2424
-            ["--key"], error_text="the user aborted the request"
2425
-        ) as monkeypatch:
2426
-
2427
-            def prompt_for_selection(*_args: Any, **_kwargs: Any) -> NoReturn:
2428
-                raise IndexError(cli_helpers.EMPTY_SELECTION)
2429
-
2430
-            monkeypatch.setattr(
2431
-                cli_helpers, "prompt_for_selection", prompt_for_selection
2432
-            )
2433
-
2434
-    def test_fail_because_no_ssh_agent(self) -> None:
2435
-        """Not running an SSH agent during `--config --key` fails.
2436
-
2437
-        (This test does not actually need a running agent; the agent's
2438
-        response is mocked by the test harness.)
2439
-
2440
-        """
2441
-        with self._test(
2442
-            ["--key"],
2443
-            error_text="Cannot find any running SSH agent",
2444
-            patch_suitable_ssh_keys=False,
2445
-        ) as monkeypatch:
2446
-            monkeypatch.delenv("SSH_AUTH_SOCK", raising=False)
2447
-
2448
-    def test_fail_because_bad_ssh_agent_connection(self) -> None:
2449
-        """Not running a reachable SSH agent during `--config --key` fails.
2450
-
2451
-        (This test does not actually need a running agent; the agent's
2452
-        response is mocked by the test harness.)
2453
-
2454
-        """
2455
-        with self._test(
2456
-            ["--key"],
2457
-            error_text="Cannot connect to the SSH agent",
2458
-            patch_suitable_ssh_keys=False,
2459
-        ) as monkeypatch:
2460
-            cwd = pathlib.Path.cwd().resolve()
2461
-            monkeypatch.setenv("SSH_AUTH_SOCK", str(cwd))
2462
-
2463
-    @Parametrize.TRY_RACE_FREE_IMPLEMENTATION
2464
-    def test_fail_because_read_only_file(
2465
-        self, try_race_free_implementation: bool
2466
-    ) -> None:
2467
-        """Using a read-only configuration file with `--config` fails."""
2468
-        with self._test(
2469
-            ["--length=15", "--", DUMMY_SERVICE],
2470
-            error_text="Cannot store vault settings:",
2471
-        ):
2472
-            callables.make_file_readonly(
2473
-                cli_helpers.config_filename(subsystem="vault"),
2474
-                try_race_free_implementation=try_race_free_implementation,
2475
-            )
2476
-
2477
-    def test_fail_because_of_custom_error(self) -> None:
2478
-        """Triggering internal errors during `--config` leads to failure."""
2479
-        custom_error = "custom error message"
2480
-        with self._test(
2481
-            ["--length=15", "--", DUMMY_SERVICE], error_text=custom_error
2482
-        ) as monkeypatch:
2483
-
2484
-            def raiser(config: Any) -> None:
2485
-                del config
2486
-                raise RuntimeError(custom_error)
2487
-
2488
-            monkeypatch.setattr(cli_helpers, "save_config", raiser)
2489
-
2490
-    def test_fail_because_unsetting_and_setting_same_settings(self) -> None:
2491
-        """Issuing conflicting settings to `--config` fails."""
2492
-        with self._test(
2493
-            ["--unset=length", "--length=15", "--", DUMMY_SERVICE],
2494
-            error_text="Attempted to unset and set --length at the same time.",
2495
-        ):
2496
-            pass
2497
-
2498
-    def test_fail_because_ssh_agent_has_no_keys_loaded(self) -> None:
2499
-        """Not holding any SSH keys during `--config --key` fails.
2500
-
2501
-        (This test does not actually need a running agent; the agent's
2502
-        response is mocked by the test harness.)
2503
-
2504
-        """
2505
-        with self._test(
2506
-            ["--key"],
2507
-            error_text="no keys suitable",
2508
-            patch_suitable_ssh_keys=False,
2509
-        ) as monkeypatch:
2510
-
2511
-            def func(
2512
-                *_args: Any,
2513
-                **_kwargs: Any,
2514
-            ) -> list[_types.SSHKeyCommentPair]:
2515
-                return []
2516
-
2517
-            monkeypatch.setattr(ssh_agent.SSHAgentClient, "list_keys", func)
2518
-
2519
-    def test_store_config_fail_manual_ssh_agent_runtime_error(self) -> None:
2520
-        """Triggering an error in the SSH agent during `--config --key` leads to failure.
2521
-
2522
-        (This test does not actually need a running agent; the agent's
2523
-        response is mocked by the test harness.)
2524
-
2525
-        """
2526
-        with self._test(
2527
-            ["--key"],
2528
-            error_text="violates the communication protocol",
2529
-            patch_suitable_ssh_keys=False,
2530
-        ) as monkeypatch:
2531
-
2532
-            def raiser(*_args: Any, **_kwargs: Any) -> None:
2533
-                raise ssh_agent.TrailingDataError()
2534
-
2535
-            monkeypatch.setattr(ssh_agent.SSHAgentClient, "list_keys", raiser)
2536
-
2537
-    def test_store_config_fail_manual_ssh_agent_refuses(self) -> None:
2538
-        """The SSH agent refusing during `--config --key` leads to failure.
2539
-
2540
-        (This test does not actually need a running agent; the agent's
2541
-        response is mocked by the test harness.)
2542
-
2543
-        """
2544
-        with self._test(
2545
-            ["--key"], error_text="refused to", patch_suitable_ssh_keys=False
2546
-        ) as monkeypatch:
2547
-
2548
-            def func(*_args: Any, **_kwargs: Any) -> NoReturn:
2549
-                raise ssh_agent.SSHAgentFailedError(
2550
-                    _types.SSH_AGENT.FAILURE, b""
2551
-                )
2552
-
2553
-            monkeypatch.setattr(ssh_agent.SSHAgentClient, "list_keys", func)
2554
-
2555
-    def test_config_directory_not_a_file(self) -> None:
2556
-        """Erroring without an existing config directory errors normally.
2557
-
2558
-        That is, the missing configuration directory does not cause any
2559
-        errors by itself.
2560
-
2561
-        This is a regression test; see [the "pretty-print-json"
2562
-        issue][PRETTY_PRINT_JSON] for context.  See also
2563
-        [TestStoringConfigurationSuccesses.test_config_directory_nonexistant][]
2564
-        for a related aspect of this.
2565
-
2566
-        [PRETTY_PRINT_JSON]: https://the13thletter.info/derivepassphrase/0.x/wishlist/pretty-print-json/
2567
-
2568
-        """
2569
-        with self._test(
2570
-            ["--phrase"],
2571
-            error_text="Cannot store vault settings:",
2572
-            input="abc\n",
2573
-        ) as monkeypatch:
2574
-            save_config_ = cli_helpers.save_config
2575
-
2576
-            def obstruct_config_saving(*args: Any, **kwargs: Any) -> Any:
2577
-                config_dir = cli_helpers.config_filename(subsystem=None)
2578
-                with contextlib.suppress(FileNotFoundError):
2579
-                    shutil.rmtree(config_dir)
2580
-                config_dir.write_text("Obstruction!!\n")
2581
-                monkeypatch.setattr(cli_helpers, "save_config", save_config_)
2582
-                return save_config_(*args, **kwargs)
2583
-
2584
-            monkeypatch.setattr(
2585
-                cli_helpers, "save_config", obstruct_config_saving
2586
-            )
2587 221
 
2588 222
 
2589 223
 class TestPassphraseUnicodeNormalization:
... ...
@@ -25,14 +25,19 @@ from derivepassphrase._internals import (
25 25
 from tests import data, machinery
26 26
 from tests.data import callables
27 27
 from tests.machinery import pytest as pytest_machinery
28
-from tests.test_derivepassphrase_cli import test_000_basic, test_utils
28
+from tests.test_derivepassphrase_cli import (
29
+    test_utils,
30
+    test_vault_cli_basic_functionality,
31
+)
29 32
 
30 33
 DUMMY_SERVICE = data.DUMMY_SERVICE
31 34
 DUMMY_PASSPHRASE = data.DUMMY_PASSPHRASE
32 35
 DUMMY_CONFIG_SETTINGS = data.DUMMY_CONFIG_SETTINGS
33 36
 
34 37
 
35
-class Parametrize(test_000_basic.Parametrize, test_utils.Parametrize):
38
+class Parametrize(
39
+    test_vault_cli_basic_functionality.Parametrize, test_utils.Parametrize
40
+):
36 41
     """Common test parametrizations."""
37 42
 
38 43
     BAD_CONFIGS = pytest.mark.parametrize(
... ...
@@ -0,0 +1,944 @@
1
+# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info>
2
+#
3
+# SPDX-License-Identifier: Zlib
4
+
5
+"""Tests for the `derivepassphrase vault` command-line interface: basic functionality."""
6
+
7
+from __future__ import annotations
8
+
9
+import contextlib
10
+import json
11
+import types
12
+from typing import TYPE_CHECKING
13
+
14
+import pytest
15
+from typing_extensions import Any, NamedTuple
16
+
17
+from derivepassphrase import _types, cli, ssh_agent, vault
18
+from derivepassphrase._internals import (
19
+    cli_helpers,
20
+)
21
+from tests import data, machinery
22
+from tests.data import callables
23
+from tests.machinery import pytest as pytest_machinery
24
+
25
+if TYPE_CHECKING:
26
+    from collections.abc import Iterator
27
+    from typing import NoReturn
28
+
29
+DUMMY_SERVICE = data.DUMMY_SERVICE
30
+DUMMY_PASSPHRASE = data.DUMMY_PASSPHRASE
31
+DUMMY_CONFIG_SETTINGS = data.DUMMY_CONFIG_SETTINGS
32
+DUMMY_RESULT_PASSPHRASE = data.DUMMY_RESULT_PASSPHRASE
33
+DUMMY_RESULT_KEY1 = data.DUMMY_RESULT_KEY1
34
+
35
+DUMMY_KEY1_B64 = data.DUMMY_KEY1_B64
36
+DUMMY_KEY2_B64 = data.DUMMY_KEY2_B64
37
+
38
+
39
+class IncompatibleConfiguration(NamedTuple):
40
+    other_options: list[tuple[str, ...]]
41
+    needs_service: bool | None
42
+    input: str | None
43
+
44
+
45
+class SingleConfiguration(NamedTuple):
46
+    needs_service: bool | None
47
+    input: str | None
48
+    check_success: bool
49
+
50
+
51
+class OptionCombination(NamedTuple):
52
+    options: list[str]
53
+    incompatible: bool
54
+    needs_service: bool | None
55
+    input: str | None
56
+    check_success: bool
57
+
58
+
59
+PASSPHRASE_GENERATION_OPTIONS: list[tuple[str, ...]] = [
60
+    ("--phrase",),
61
+    ("--key",),
62
+    ("--length", "20"),
63
+    ("--repeat", "20"),
64
+    ("--lower", "1"),
65
+    ("--upper", "1"),
66
+    ("--number", "1"),
67
+    ("--space", "1"),
68
+    ("--dash", "1"),
69
+    ("--symbol", "1"),
70
+]
71
+CONFIGURATION_OPTIONS: list[tuple[str, ...]] = [
72
+    ("--notes",),
73
+    ("--config",),
74
+    ("--delete",),
75
+    ("--delete-globals",),
76
+    ("--clear",),
77
+]
78
+CONFIGURATION_COMMANDS: list[tuple[str, ...]] = [
79
+    ("--delete",),
80
+    ("--delete-globals",),
81
+    ("--clear",),
82
+]
83
+STORAGE_OPTIONS: list[tuple[str, ...]] = [("--export", "-"), ("--import", "-")]
84
+INCOMPATIBLE: dict[tuple[str, ...], IncompatibleConfiguration] = {
85
+    ("--phrase",): IncompatibleConfiguration(
86
+        [("--key",), *CONFIGURATION_COMMANDS, *STORAGE_OPTIONS],
87
+        True,
88
+        DUMMY_PASSPHRASE,
89
+    ),
90
+    ("--key",): IncompatibleConfiguration(
91
+        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
92
+    ),
93
+    ("--length", "20"): IncompatibleConfiguration(
94
+        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
95
+    ),
96
+    ("--repeat", "20"): IncompatibleConfiguration(
97
+        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
98
+    ),
99
+    ("--lower", "1"): IncompatibleConfiguration(
100
+        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
101
+    ),
102
+    ("--upper", "1"): IncompatibleConfiguration(
103
+        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
104
+    ),
105
+    ("--number", "1"): IncompatibleConfiguration(
106
+        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
107
+    ),
108
+    ("--space", "1"): IncompatibleConfiguration(
109
+        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
110
+    ),
111
+    ("--dash", "1"): IncompatibleConfiguration(
112
+        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
113
+    ),
114
+    ("--symbol", "1"): IncompatibleConfiguration(
115
+        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
116
+    ),
117
+    ("--notes",): IncompatibleConfiguration(
118
+        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, None
119
+    ),
120
+    ("--config", "-p"): IncompatibleConfiguration(
121
+        [("--delete",), ("--delete-globals",), ("--clear",), *STORAGE_OPTIONS],
122
+        None,
123
+        DUMMY_PASSPHRASE,
124
+    ),
125
+    ("--delete",): IncompatibleConfiguration(
126
+        [("--delete-globals",), ("--clear",), *STORAGE_OPTIONS], True, None
127
+    ),
128
+    ("--delete-globals",): IncompatibleConfiguration(
129
+        [("--clear",), *STORAGE_OPTIONS], False, None
130
+    ),
131
+    ("--clear",): IncompatibleConfiguration(STORAGE_OPTIONS, False, None),
132
+    ("--export", "-"): IncompatibleConfiguration(
133
+        [("--import", "-")], False, None
134
+    ),
135
+    ("--import", "-"): IncompatibleConfiguration([], False, None),
136
+}
137
+SINGLES: dict[tuple[str, ...], SingleConfiguration] = {
138
+    ("--phrase",): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
139
+    ("--key",): SingleConfiguration(True, None, False),
140
+    ("--length", "20"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
141
+    ("--repeat", "20"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
142
+    ("--lower", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
143
+    ("--upper", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
144
+    ("--number", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
145
+    ("--space", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
146
+    ("--dash", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
147
+    ("--symbol", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
148
+    ("--notes",): SingleConfiguration(True, None, False),
149
+    ("--config", "-p"): SingleConfiguration(None, DUMMY_PASSPHRASE, False),
150
+    ("--delete",): SingleConfiguration(True, None, False),
151
+    ("--delete-globals",): SingleConfiguration(False, None, True),
152
+    ("--clear",): SingleConfiguration(False, None, True),
153
+    ("--export", "-"): SingleConfiguration(False, None, True),
154
+    ("--import", "-"): SingleConfiguration(False, '{"services": {}}', True),
155
+}
156
+INTERESTING_OPTION_COMBINATIONS: list[OptionCombination] = []
157
+config: IncompatibleConfiguration | SingleConfiguration
158
+for opt, config in INCOMPATIBLE.items():
159
+    for opt2 in config.other_options:
160
+        INTERESTING_OPTION_COMBINATIONS.extend([
161
+            OptionCombination(
162
+                options=list(opt + opt2),
163
+                incompatible=True,
164
+                needs_service=config.needs_service,
165
+                input=config.input,
166
+                check_success=False,
167
+            ),
168
+            OptionCombination(
169
+                options=list(opt2 + opt),
170
+                incompatible=True,
171
+                needs_service=config.needs_service,
172
+                input=config.input,
173
+                check_success=False,
174
+            ),
175
+        ])
176
+for opt, config in SINGLES.items():
177
+    INTERESTING_OPTION_COMBINATIONS.append(
178
+        OptionCombination(
179
+            options=list(opt),
180
+            incompatible=False,
181
+            needs_service=config.needs_service,
182
+            input=config.input,
183
+            check_success=config.check_success,
184
+        )
185
+    )
186
+
187
+
188
+def is_warning_line(line: str) -> bool:
189
+    """Return true if the line is a warning line."""
190
+    return " Warning: " in line or " Deprecation warning: " in line
191
+
192
+
193
+def is_harmless_config_import_warning(record: tuple[str, int, str]) -> bool:
194
+    """Return true if the warning is harmless, during config import."""
195
+    possible_warnings = [
196
+        "Replacing invalid value ",
197
+        "Removing ineffective setting ",
198
+        (
199
+            "Setting a global passphrase is ineffective "
200
+            "because a key is also set."
201
+        ),
202
+        (
203
+            "Setting a service passphrase is ineffective "
204
+            "because a key is also set:"
205
+        ),
206
+    ]
207
+    return any(
208
+        machinery.warning_emitted(w, [record]) for w in possible_warnings
209
+    )
210
+
211
+
212
+class Parametrize(types.SimpleNamespace):
213
+    """Common test parametrizations."""
214
+
215
+    AUTO_PROMPT = pytest.mark.parametrize(
216
+        "auto_prompt", [False, True], ids=["normal_prompt", "auto_prompt"]
217
+    )
218
+    CHARSET_NAME = pytest.mark.parametrize(
219
+        "charset_name", ["lower", "upper", "number", "space", "dash", "symbol"]
220
+    )
221
+    BASE_CONFIG_WITH_KEY_VARIATIONS = pytest.mark.parametrize(
222
+        "config",
223
+        [
224
+            pytest.param(
225
+                {
226
+                    "global": {"key": DUMMY_KEY1_B64},
227
+                    "services": {DUMMY_SERVICE: {}},
228
+                },
229
+                id="global_config",
230
+            ),
231
+            pytest.param(
232
+                {"services": {DUMMY_SERVICE: {"key": DUMMY_KEY2_B64}}},
233
+                id="service_config",
234
+            ),
235
+            pytest.param(
236
+                {
237
+                    "global": {"key": DUMMY_KEY1_B64},
238
+                    "services": {DUMMY_SERVICE: {"key": DUMMY_KEY2_B64}},
239
+                },
240
+                id="full_config",
241
+            ),
242
+        ],
243
+    )
244
+    CONFIG_WITH_KEY = pytest.mark.parametrize(
245
+        "config",
246
+        [
247
+            pytest.param(
248
+                {
249
+                    "global": {"key": DUMMY_KEY1_B64},
250
+                    "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS},
251
+                },
252
+                id="global",
253
+            ),
254
+            pytest.param(
255
+                {
256
+                    "global": {"phrase": DUMMY_PASSPHRASE.rstrip("\n")},
257
+                    "services": {
258
+                        DUMMY_SERVICE: {
259
+                            "key": DUMMY_KEY1_B64,
260
+                            **DUMMY_CONFIG_SETTINGS,
261
+                        }
262
+                    },
263
+                },
264
+                id="service",
265
+            ),
266
+        ],
267
+    )
268
+    CONFIG_WITH_PHRASE = pytest.mark.parametrize(
269
+        "config",
270
+        [
271
+            pytest.param(
272
+                {
273
+                    "global": {"phrase": DUMMY_PASSPHRASE.rstrip("\n")},
274
+                    "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS},
275
+                },
276
+                id="global",
277
+            ),
278
+            pytest.param(
279
+                {
280
+                    "global": {
281
+                        "phrase": DUMMY_PASSPHRASE.rstrip("\n")
282
+                        + "XXX"
283
+                        + DUMMY_PASSPHRASE.rstrip("\n")
284
+                    },
285
+                    "services": {
286
+                        DUMMY_SERVICE: {
287
+                            "phrase": DUMMY_PASSPHRASE.rstrip("\n"),
288
+                            **DUMMY_CONFIG_SETTINGS,
289
+                        }
290
+                    },
291
+                },
292
+                id="service",
293
+            ),
294
+        ],
295
+    )
296
+    KEY_OVERRIDING_IN_CONFIG = pytest.mark.parametrize(
297
+        ["config", "command_line"],
298
+        [
299
+            pytest.param(
300
+                {
301
+                    "global": {"key": DUMMY_KEY1_B64},
302
+                    "services": {},
303
+                },
304
+                ["--config", "-p"],
305
+                id="global",
306
+            ),
307
+            pytest.param(
308
+                {
309
+                    "services": {
310
+                        DUMMY_SERVICE: {
311
+                            "key": DUMMY_KEY1_B64,
312
+                            **DUMMY_CONFIG_SETTINGS,
313
+                        },
314
+                    },
315
+                },
316
+                ["--config", "-p", "--", DUMMY_SERVICE],
317
+                id="service",
318
+            ),
319
+            pytest.param(
320
+                {
321
+                    "global": {"key": DUMMY_KEY1_B64},
322
+                    "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()},
323
+                },
324
+                ["--config", "-p", "--", DUMMY_SERVICE],
325
+                id="service-over-global",
326
+            ),
327
+        ],
328
+    )
329
+    KEY_INDEX = pytest.mark.parametrize(
330
+        "key_index", [1, 2, 3], ids=lambda i: f"index{i}"
331
+    )
332
+    VAULT_CHARSET_OPTION = pytest.mark.parametrize(
333
+        "option",
334
+        [
335
+            "--lower",
336
+            "--upper",
337
+            "--number",
338
+            "--space",
339
+            "--dash",
340
+            "--symbol",
341
+            "--repeat",
342
+            "--length",
343
+        ],
344
+    )
345
+    OPTION_COMBINATIONS_INCOMPATIBLE = pytest.mark.parametrize(
346
+        ["options", "service"],
347
+        [
348
+            pytest.param(o.options, o.needs_service, id=" ".join(o.options))
349
+            for o in INTERESTING_OPTION_COMBINATIONS
350
+            if o.incompatible
351
+        ],
352
+    )
353
+    OPTION_COMBINATIONS_SERVICE_NEEDED = pytest.mark.parametrize(
354
+        ["options", "service", "input", "check_success"],
355
+        [
356
+            pytest.param(
357
+                o.options,
358
+                o.needs_service,
359
+                o.input,
360
+                o.check_success,
361
+                id=" ".join(o.options),
362
+            )
363
+            for o in INTERESTING_OPTION_COMBINATIONS
364
+            if not o.incompatible
365
+        ],
366
+    )
367
+
368
+
369
+class TestHelp:
370
+    """Tests related to help output."""
371
+
372
+    def test_help_output(
373
+        self,
374
+    ) -> None:
375
+        """The `--help` option emits help text."""
376
+        runner = machinery.CliRunner(mix_stderr=False)
377
+        # TODO(the-13th-letter): Rewrite using parenthesized
378
+        # with-statements.
379
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
380
+        with contextlib.ExitStack() as stack:
381
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
382
+            stack.enter_context(
383
+                pytest_machinery.isolated_config(
384
+                    monkeypatch=monkeypatch,
385
+                    runner=runner,
386
+                )
387
+            )
388
+            result = runner.invoke(
389
+                cli.derivepassphrase_vault,
390
+                ["--help"],
391
+                catch_exceptions=False,
392
+            )
393
+        assert result.clean_exit(
394
+            empty_stderr=True, output="Passphrase generation:\n"
395
+        ), "expected clean exit, and option groups in help text"
396
+        assert result.clean_exit(
397
+            empty_stderr=True, output="Use $VISUAL or $EDITOR to configure"
398
+        ), "expected clean exit, and option group epilog in help text"
399
+
400
+
401
+class TestDerivedPassphraseConstraints:
402
+    """Tests for (derived) passphrase constraints."""
403
+
404
+    def _test(self, command_line: list[str]) -> machinery.ReadableResult:
405
+        runner = machinery.CliRunner(mix_stderr=False)
406
+        # TODO(the-13th-letter): Rewrite using parenthesized
407
+        # with-statements.
408
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
409
+        with contextlib.ExitStack() as stack:
410
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
411
+            stack.enter_context(
412
+                pytest_machinery.isolated_config(
413
+                    monkeypatch=monkeypatch,
414
+                    runner=runner,
415
+                )
416
+            )
417
+            monkeypatch.setattr(
418
+                cli_helpers,
419
+                "prompt_for_passphrase",
420
+                callables.auto_prompt,
421
+            )
422
+            return runner.invoke(
423
+                cli.derivepassphrase_vault,
424
+                command_line,
425
+                input=DUMMY_PASSPHRASE,
426
+                catch_exceptions=False,
427
+            )
428
+
429
+    @Parametrize.CHARSET_NAME
430
+    def test_disable_character_set(
431
+        self,
432
+        charset_name: str,
433
+    ) -> None:
434
+        """Named character classes can be disabled on the command-line."""
435
+        option = f"--{charset_name}"
436
+        charset = vault.Vault.CHARSETS[charset_name].decode("ascii")
437
+        result = self._test([option, "0", "-p", "--", DUMMY_SERVICE])
438
+        assert result.clean_exit(empty_stderr=True), "expected clean exit:"
439
+        for c in charset:
440
+            assert c not in result.stdout, (
441
+                f"derived password contains forbidden character {c!r}"
442
+            )
443
+
444
+    def test_disable_repetition(
445
+        self,
446
+    ) -> None:
447
+        """Character repetition can be disabled on the command-line."""
448
+        result = self._test(["--repeat", "0", "-p", "--", DUMMY_SERVICE])
449
+        assert result.clean_exit(empty_stderr=True), (
450
+            "expected clean exit and empty stderr"
451
+        )
452
+        passphrase = result.stdout.rstrip("\r\n")
453
+        for i in range(len(passphrase) - 1):
454
+            assert passphrase[i : i + 1] != passphrase[i + 1 : i + 2], (
455
+                f"derived password contains repeated character "
456
+                f"at position {i}: {result.stdout!r}"
457
+            )
458
+
459
+
460
+class TestPhraseBasic:
461
+    """Tests for master passphrase configuration: basic."""
462
+
463
+    def _test(
464
+        self,
465
+        command_line: list[str],
466
+        /,
467
+        *,
468
+        config: _types.VaultConfig = {  # noqa: B006
469
+            "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}
470
+        },
471
+        auto_prompt: bool = False,
472
+        multiline: bool = False,
473
+    ) -> None:
474
+        runner = machinery.CliRunner(mix_stderr=False)
475
+        # TODO(the-13th-letter): Rewrite using parenthesized
476
+        # with-statements.
477
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
478
+        with contextlib.ExitStack() as stack:
479
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
480
+            stack.enter_context(
481
+                pytest_machinery.isolated_vault_config(
482
+                    monkeypatch=monkeypatch,
483
+                    runner=runner,
484
+                    vault_config=config,
485
+                )
486
+            )
487
+
488
+            def phrase_from_key(*_args: Any, **_kwargs: Any) -> NoReturn:
489
+                pytest.fail("Attempted to use a key in a phrase-based test!")
490
+
491
+            monkeypatch.setattr(
492
+                vault.Vault, "phrase_from_key", phrase_from_key
493
+            )
494
+            if auto_prompt:
495
+                monkeypatch.setattr(
496
+                    cli_helpers,
497
+                    "prompt_for_passphrase",
498
+                    callables.auto_prompt,
499
+                )
500
+            result = runner.invoke(
501
+                cli.derivepassphrase_vault,
502
+                command_line,
503
+                input=None if auto_prompt else DUMMY_PASSPHRASE,
504
+                catch_exceptions=False,
505
+            )
506
+        if multiline:
507
+            assert result.clean_exit(), "expected clean exit"
508
+        else:
509
+            assert result.clean_exit(empty_stderr=True), (
510
+                "expected clean exit and empty stderr"
511
+            )
512
+        assert result.stdout, "expected program output"
513
+        last_line = (
514
+            result.stdout.splitlines(keepends=True)[-1]
515
+            if multiline
516
+            else result.stdout
517
+        )
518
+        assert (
519
+            last_line.rstrip("\n").encode("UTF-8") == DUMMY_RESULT_PASSPHRASE
520
+        ), "expected known output"
521
+
522
+    @Parametrize.CONFIG_WITH_PHRASE
523
+    def test_phrase_from_config(
524
+        self,
525
+        config: _types.VaultConfig,
526
+    ) -> None:
527
+        """A stored configured master passphrase will be used."""
528
+        self._test(["--", DUMMY_SERVICE], config=config)
529
+
530
+    @Parametrize.AUTO_PROMPT
531
+    def test_phrase_from_command_line(
532
+        self,
533
+        auto_prompt: bool,
534
+    ) -> None:
535
+        """A master passphrase requested on the command-line will be used."""
536
+        self._test(
537
+            ["-p", "--", DUMMY_SERVICE],
538
+            auto_prompt=auto_prompt,
539
+            multiline=True,
540
+        )
541
+
542
+
543
+class TestKeyBasic:
544
+    """Tests for SSH key configuration: basic."""
545
+
546
+    def _test(
547
+        self,
548
+        command_line: list[str],
549
+        /,
550
+        *,
551
+        config: _types.VaultConfig = {  # noqa: B006
552
+            "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}
553
+        },
554
+        multiline: bool = False,
555
+        input: str | bytes | None = None,
556
+    ) -> None:
557
+        runner = machinery.CliRunner(mix_stderr=False)
558
+        # TODO(the-13th-letter): Rewrite using parenthesized
559
+        # with-statements.
560
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
561
+        with contextlib.ExitStack() as stack:
562
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
563
+            stack.enter_context(
564
+                pytest_machinery.isolated_vault_config(
565
+                    monkeypatch=monkeypatch,
566
+                    runner=runner,
567
+                    vault_config=config,
568
+                )
569
+            )
570
+            monkeypatch.setattr(
571
+                cli_helpers,
572
+                "get_suitable_ssh_keys",
573
+                callables.suitable_ssh_keys,
574
+            )
575
+            monkeypatch.setattr(
576
+                vault.Vault,
577
+                "phrase_from_key",
578
+                callables.phrase_from_key,
579
+            )
580
+            result = runner.invoke(
581
+                cli.derivepassphrase_vault,
582
+                command_line,
583
+                input=input,
584
+                catch_exceptions=False,
585
+            )
586
+        if multiline:
587
+            assert result.clean_exit(), "expected clean exit"
588
+        else:
589
+            assert result.clean_exit(empty_stderr=True), (
590
+                "expected clean exit and empty stderr"
591
+            )
592
+        assert result.stdout, "expected program output"
593
+        last_line = (
594
+            result.stdout.splitlines(keepends=True)[-1]
595
+            if multiline
596
+            else result.stdout
597
+        )
598
+        assert (
599
+            last_line.rstrip("\n").encode("UTF-8") != DUMMY_RESULT_PASSPHRASE
600
+        ), "known false output: phrase-based instead of key-based"
601
+        assert last_line.rstrip("\n").encode("UTF-8") == DUMMY_RESULT_KEY1, (
602
+            "expected known output"
603
+        )
604
+
605
+    @Parametrize.CONFIG_WITH_KEY
606
+    def test_key_from_config(
607
+        self,
608
+        running_ssh_agent: data.RunningSSHAgentInfo,
609
+        config: _types.VaultConfig,
610
+    ) -> None:
611
+        """A stored configured SSH key will be used."""
612
+        del running_ssh_agent
613
+        self._test(["--", DUMMY_SERVICE], config=config)
614
+
615
+    def test_key_from_command_line(
616
+        self,
617
+        running_ssh_agent: data.RunningSSHAgentInfo,
618
+    ) -> None:
619
+        """An SSH key requested on the command-line will be used."""
620
+        del running_ssh_agent
621
+        self._test(["-k", "--", DUMMY_SERVICE], input="1\n", multiline=True)
622
+
623
+
624
+class TestPhraseAndKeyOverriding:
625
+    """Tests for master passphrase and SSH key configuration: overriding."""
626
+
627
+    def _test(
628
+        self,
629
+        command_line: list[str],
630
+        /,
631
+        *,
632
+        config: _types.VaultConfig = {  # noqa: B006
633
+            "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}
634
+        },
635
+        input: str | bytes | None = None,
636
+    ) -> machinery.ReadableResult:
637
+        runner = machinery.CliRunner(mix_stderr=False)
638
+        # TODO(the-13th-letter): Rewrite using parenthesized
639
+        # with-statements.
640
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
641
+        with contextlib.ExitStack() as stack:
642
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
643
+            stack.enter_context(
644
+                pytest_machinery.isolated_vault_config(
645
+                    monkeypatch=monkeypatch,
646
+                    runner=runner,
647
+                    vault_config=config,
648
+                )
649
+            )
650
+            monkeypatch.setattr(
651
+                ssh_agent.SSHAgentClient,
652
+                "list_keys",
653
+                callables.list_keys,
654
+            )
655
+            monkeypatch.setattr(
656
+                ssh_agent.SSHAgentClient, "sign", callables.sign
657
+            )
658
+            result = runner.invoke(
659
+                cli.derivepassphrase_vault,
660
+                command_line,
661
+                input=input,
662
+                catch_exceptions=False,
663
+            )
664
+        assert result.clean_exit(), "expected clean exit"
665
+        return result
666
+
667
+    @Parametrize.BASE_CONFIG_WITH_KEY_VARIATIONS
668
+    @Parametrize.KEY_INDEX
669
+    def test_key_override_on_command_line(
670
+        self,
671
+        running_ssh_agent: data.RunningSSHAgentInfo,
672
+        config: _types.VaultConfig,
673
+        key_index: int,
674
+    ) -> None:
675
+        """A command-line SSH key will override the configured key."""
676
+        del running_ssh_agent
677
+        result = self._test(
678
+            ["-k", "--", DUMMY_SERVICE],
679
+            config=config,
680
+            input=f"{key_index}\n",
681
+        )
682
+        assert result.stdout, "expected program output"
683
+        assert result.stderr, "expected stderr"
684
+        assert "Error:" not in result.stderr, (
685
+            "expected no error messages on stderr"
686
+        )
687
+
688
+    def test_service_phrase_if_key_in_global_config(
689
+        self,
690
+        running_ssh_agent: data.RunningSSHAgentInfo,
691
+    ) -> None:
692
+        """A configured passphrase does not override a configured key."""
693
+        del running_ssh_agent
694
+        result = self._test(
695
+            ["--", DUMMY_SERVICE],
696
+            config={
697
+                "global": {"key": DUMMY_KEY1_B64},
698
+                "services": {
699
+                    DUMMY_SERVICE: {
700
+                        "phrase": DUMMY_PASSPHRASE.rstrip("\n"),
701
+                        **DUMMY_CONFIG_SETTINGS,
702
+                    }
703
+                },
704
+            },
705
+        )
706
+        assert result.stdout, "expected program output"
707
+        last_line = result.stdout.splitlines(True)[-1]
708
+        assert (
709
+            last_line.rstrip("\n").encode("UTF-8") != DUMMY_RESULT_PASSPHRASE
710
+        ), "known false output: phrase-based instead of key-based"
711
+        assert last_line.rstrip("\n").encode("UTF-8") == DUMMY_RESULT_KEY1, (
712
+            "expected known output"
713
+        )
714
+
715
+    @Parametrize.KEY_OVERRIDING_IN_CONFIG
716
+    def test_setting_phrase_thus_overriding_key_in_config(
717
+        self,
718
+        running_ssh_agent: data.RunningSSHAgentInfo,
719
+        caplog: pytest.LogCaptureFixture,
720
+        config: _types.VaultConfig,
721
+        command_line: list[str],
722
+    ) -> None:
723
+        """Configuring a passphrase atop an SSH key works, but warns."""
724
+        del running_ssh_agent
725
+        result = self._test(
726
+            command_line, config=config, input=DUMMY_PASSPHRASE
727
+        )
728
+        assert not result.stdout.strip(), "expected no program output"
729
+        assert result.stderr, "expected known error output"
730
+        err_lines = result.stderr.splitlines(False)
731
+        assert err_lines[0].startswith("Passphrase:")
732
+        assert machinery.warning_emitted(
733
+            "Setting a service passphrase is ineffective ",
734
+            caplog.record_tuples,
735
+        ) or machinery.warning_emitted(
736
+            "Setting a global passphrase is ineffective ",
737
+            caplog.record_tuples,
738
+        ), "expected known warning message"
739
+        assert all(map(is_warning_line, result.stderr.splitlines(True)))
740
+        assert all(
741
+            map(is_harmless_config_import_warning, caplog.record_tuples)
742
+        ), "unexpected error output"
743
+
744
+
745
+class TestInvalidCommandLines:
746
+    """Tests concerning invalid command-lines."""
747
+
748
+    @contextlib.contextmanager
749
+    def _setup_environment(
750
+        self,
751
+        /,
752
+        *,
753
+        auto_prompt: bool = False,
754
+        config: _types.VaultConfig = {  # noqa: B006
755
+            "services": {
756
+                DUMMY_SERVICE: {**DUMMY_CONFIG_SETTINGS},
757
+            },
758
+        },
759
+    ) -> Iterator[machinery.CliRunner]:
760
+        runner = machinery.CliRunner(mix_stderr=False)
761
+        # TODO(the-13th-letter): Rewrite using parenthesized
762
+        # with-statements.
763
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
764
+        with contextlib.ExitStack() as stack:
765
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
766
+            stack.enter_context(
767
+                pytest_machinery.isolated_vault_config(
768
+                    monkeypatch=monkeypatch,
769
+                    runner=runner,
770
+                    vault_config=config,
771
+                )
772
+            )
773
+            if auto_prompt:
774
+                monkeypatch.setattr(
775
+                    cli_helpers,
776
+                    "prompt_for_passphrase",
777
+                    callables.auto_prompt,
778
+                )
779
+            yield runner
780
+
781
+    def _call(
782
+        self,
783
+        command_line: list[str],
784
+        /,
785
+        *,
786
+        config: _types.VaultConfig = {  # noqa: B006
787
+            "services": {
788
+                DUMMY_SERVICE: {**DUMMY_CONFIG_SETTINGS},
789
+            },
790
+        },
791
+        input: str | bytes | None = None,
792
+        runner: machinery.CliRunner | None = None,
793
+    ) -> machinery.ReadableResult:
794
+        if runner:
795
+            return runner.invoke(
796
+                cli.derivepassphrase_vault,
797
+                command_line,
798
+                input=input,
799
+                catch_exceptions=False,
800
+            )
801
+        with self._setup_environment(
802
+            config=config, auto_prompt=input is not None
803
+        ) as runner2:
804
+            return runner2.invoke(
805
+                cli.derivepassphrase_vault,
806
+                command_line,
807
+                input=input,
808
+                catch_exceptions=False,
809
+            )
810
+
811
+    @Parametrize.VAULT_CHARSET_OPTION
812
+    def test_invalid_argument_range(
813
+        self,
814
+        option: str,
815
+    ) -> None:
816
+        """Requesting invalidly many characters from a class fails."""
817
+        with self._setup_environment() as runner:
818
+            for value in "-42", "invalid":
819
+                result = runner.invoke(
820
+                    cli.derivepassphrase_vault,
821
+                    [option, value, "-p", "--", DUMMY_SERVICE],
822
+                    input=DUMMY_PASSPHRASE,
823
+                    catch_exceptions=False,
824
+                )
825
+                assert result.error_exit(error="Invalid value"), (
826
+                    "expected error exit and known error message"
827
+                )
828
+
829
+    @Parametrize.OPTION_COMBINATIONS_SERVICE_NEEDED
830
+    def test_service_needed(
831
+        self,
832
+        options: list[str],
833
+        service: bool | None,
834
+        input: str | None,
835
+        check_success: bool,
836
+    ) -> None:
837
+        """We require or forbid a service argument, depending on options."""
838
+        config: _types.VaultConfig = {
839
+            "global": {"phrase": "abc"},
840
+            "services": {},
841
+        }
842
+        result = self._call(
843
+            options if service else [*options, "--", DUMMY_SERVICE],
844
+            config=config,
845
+            input=input,
846
+        )
847
+        if service is not None:
848
+            err_msg = (
849
+                " requires a SERVICE"
850
+                if service
851
+                else " does not take a SERVICE argument"
852
+            )
853
+            assert result.error_exit(error=err_msg), (
854
+                "expected error exit and known error message"
855
+            )
856
+            if check_success:
857
+                result = self._call(
858
+                    [*options, "--", DUMMY_SERVICE] if service else options,
859
+                    config=config,
860
+                    input=input,
861
+                )
862
+                assert result.clean_exit(empty_stderr=True), (
863
+                    "expected clean exit"
864
+                )
865
+        else:
866
+            assert result.clean_exit(empty_stderr=True), "expected clean exit"
867
+
868
+    def test_empty_service_name_causes_warning(
869
+        self,
870
+        caplog: pytest.LogCaptureFixture,
871
+    ) -> None:
872
+        """Using an empty service name (where permissible) warns.
873
+
874
+        Only the `--config` option can optionally take a service name.
875
+
876
+        """
877
+
878
+        def is_expected_warning(record: tuple[str, int, str]) -> bool:
879
+            return is_harmless_config_import_warning(
880
+                record
881
+            ) or machinery.warning_emitted(
882
+                "An empty SERVICE is not supported by vault(1)", [record]
883
+            )
884
+
885
+        def check_result(result: machinery.ReadableResult) -> None:
886
+            assert result.clean_exit(empty_stderr=False), "expected clean exit"
887
+            assert result.stderr is not None, "expected known error output"
888
+            assert all(map(is_expected_warning, caplog.record_tuples)), (
889
+                "expected known error output"
890
+            )
891
+
892
+        with self._setup_environment(
893
+            config={"services": {}}, auto_prompt=True
894
+        ) as runner:
895
+            result = self._call(
896
+                ["--config", "--length=30", "--", ""], runner=runner
897
+            )
898
+            check_result(result)
899
+            assert cli_helpers.load_config() == {
900
+                "global": {"length": 30},
901
+                "services": {},
902
+            }, "requested configuration change was not applied"
903
+            caplog.clear()
904
+            result = self._call(
905
+                ["--import", "-"],
906
+                input=json.dumps({"services": {"": {"length": 40}}}),
907
+                runner=runner,
908
+            )
909
+            check_result(result)
910
+            assert cli_helpers.load_config() == {
911
+                "global": {"length": 30},
912
+                "services": {"": {"length": 40}},
913
+            }, "requested configuration change was not applied"
914
+
915
+    @Parametrize.OPTION_COMBINATIONS_INCOMPATIBLE
916
+    def test_incompatible_options(
917
+        self,
918
+        options: list[str],
919
+        service: bool | None,
920
+    ) -> None:
921
+        """Incompatible options are detected."""
922
+        result = self._call(
923
+            [*options, "--", DUMMY_SERVICE] if service else options,
924
+            input=DUMMY_PASSPHRASE,
925
+        )
926
+        assert result.error_exit(error="mutually exclusive with "), (
927
+            "expected error exit and known error message"
928
+        )
929
+
930
+    def test_no_arguments(self) -> None:
931
+        """Calling `derivepassphrase vault` without any arguments fails."""
932
+        result = self._call([], input=DUMMY_PASSPHRASE)
933
+        assert result.error_exit(
934
+            error="Deriving a passphrase requires a SERVICE"
935
+        ), "expected error exit and known error message"
936
+
937
+    def test_no_passphrase_or_key(
938
+        self,
939
+    ) -> None:
940
+        """Deriving a passphrase without a passphrase or key fails."""
941
+        result = self._call(["--", DUMMY_SERVICE], input=DUMMY_PASSPHRASE)
942
+        assert result.error_exit(error="No passphrase or key was given"), (
943
+            "expected error exit and known error message"
944
+        )
... ...
@@ -0,0 +1,936 @@
1
+# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info>
2
+#
3
+# SPDX-License-Identifier: Zlib
4
+
5
+"""Tests for the `derivepassphrase vault` command-line interface: config management.
6
+
7
+This includes tests for importing, exporting and setting the
8
+configuration, whether validly or invalidly.  It does not contain any
9
+configuration that is cross-subsystem or that doesn't pertain to
10
+a `vault`-specific CLI call; those are [basic and common subsystem
11
+tests][tests.test_derivepassphrase_cli.test_000_basic].
12
+
13
+"""
14
+
15
+from __future__ import annotations
16
+
17
+import contextlib
18
+import copy
19
+import errno
20
+import json
21
+import os
22
+import pathlib
23
+import shutil
24
+import types
25
+from typing import TYPE_CHECKING
26
+
27
+import hypothesis
28
+import pytest
29
+from hypothesis import strategies
30
+from typing_extensions import Any
31
+
32
+from derivepassphrase import _types, cli, ssh_agent
33
+from derivepassphrase._internals import (
34
+    cli_helpers,
35
+)
36
+from tests import data, machinery
37
+from tests.data import callables
38
+from tests.machinery import hypothesis as hypothesis_machinery
39
+from tests.machinery import pytest as pytest_machinery
40
+
41
+if TYPE_CHECKING:
42
+    from collections.abc import Iterator
43
+    from typing import NoReturn
44
+
45
+DUMMY_SERVICE = data.DUMMY_SERVICE
46
+
47
+DUMMY_KEY1_B64 = data.DUMMY_KEY1_B64
48
+
49
+
50
+def 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 = [
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(
65
+        machinery.warning_emitted(w, [record]) for w in possible_warnings
66
+    )
67
+
68
+
69
+def 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.
74
+
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:
78
+
79
+    ~~~~
80
+    {
81
+      "services": {
82
+        ...
83
+      }  <-- this brace here
84
+    }
85
+    ~~~~
86
+
87
+    or, if there are no services, then the indented line
88
+
89
+    ~~~~
90
+      "services": {}
91
+    ~~~~
92
+
93
+    Both variations may end with a comma if there are more top-level
94
+    keys.
95
+
96
+    """
97
+    known_indented_lines = {
98
+        "}",
99
+        "},",
100
+        '"services": {}',
101
+        '"services": {},',
102
+    }
103
+    assert any([
104
+        line.strip() in known_indented_lines and line.startswith((" ", "\t"))
105
+        for line in config_txt.splitlines()
106
+    ])
107
+
108
+
109
+class Parametrize(types.SimpleNamespace):
110
+    """Common test parametrizations."""
111
+
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 = pytest.mark.parametrize(
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
+    )
222
+
223
+
224
+class TestImportConfigValid:
225
+    """Tests concerning `vault` configuration imports: valid imports."""
226
+
227
+    def _test(
228
+        self,
229
+        /,
230
+        *,
231
+        caplog: pytest.LogCaptureFixture,
232
+        config: _types.VaultConfig,
233
+    ) -> None:
234
+        config2 = copy.deepcopy(config)
235
+        _types.clean_up_falsy_vault_config_values(config2)
236
+        runner = machinery.CliRunner(mix_stderr=False)
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:
241
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
242
+            stack.enter_context(
243
+                pytest_machinery.isolated_vault_config(
244
+                    monkeypatch=monkeypatch,
245
+                    runner=runner,
246
+                    vault_config={"services": {}},
247
+                )
248
+            )
249
+            result = runner.invoke(
250
+                cli.derivepassphrase_vault,
251
+                ["--import", "-"],
252
+                input=json.dumps(config),
253
+                catch_exceptions=False,
254
+            )
255
+            config_txt = cli_helpers.config_filename(
256
+                subsystem="vault"
257
+            ).read_text(encoding="UTF-8")
258
+            config3 = json.loads(config_txt)
259
+        assert result.clean_exit(empty_stderr=False), "expected clean exit"
260
+        assert config3 == config2, "config not imported correctly"
261
+        assert not result.stderr or all(
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)
265
+
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)
274
+
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.
294
+
295
+        Tested via hypothesis.
296
+
297
+        """
298
+        # Reset caplog between hypothesis runs.
299
+        caplog.clear()
300
+        self._test(caplog=caplog, config=conf.config)
301
+
302
+
303
+class TestImportConfigInvalid:
304
+    """Tests concerning `vault` configuration imports: invalid imports."""
305
+
306
+    @contextlib.contextmanager
307
+    def _setup_environment(self) -> Iterator[machinery.CliRunner]:
308
+        runner = machinery.CliRunner(mix_stderr=False)
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:
313
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
314
+            stack.enter_context(
315
+                pytest_machinery.isolated_config(
316
+                    monkeypatch=monkeypatch,
317
+                    runner=runner,
318
+                )
319
+            )
320
+            yield runner
321
+
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:
330
+            return runner.invoke(
331
+                cli.derivepassphrase_vault,
332
+                command_line,
333
+                input=input,
334
+                catch_exceptions=False,
335
+            )
336
+
337
+    def test_not_a_vault_config(
338
+        self,
339
+    ) -> None:
340
+        """Importing an invalid config fails."""
341
+        result = self._test(["--import", "-"], input="null")
342
+        assert result.error_exit(error="Invalid vault config"), (
343
+            "expected error exit and known error message"
344
+        )
345
+
346
+    def test_not_json_data(
347
+        self,
348
+    ) -> None:
349
+        """Importing an invalid config fails."""
350
+        result = self._test(
351
+            ["--import", "-"], input="This string is not valid JSON."
352
+        )
353
+        assert result.error_exit(error="cannot decode JSON"), (
354
+            "expected error exit and known error message"
355
+        )
356
+
357
+    def test_not_a_file(
358
+        self,
359
+    ) -> None:
360
+        """Importing an invalid config fails."""
361
+        with self._setup_environment() as runner:
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(
367
+                "This string is not valid JSON.\n", encoding="UTF-8"
368
+            )
369
+            dname = cli_helpers.config_filename(subsystem=None)
370
+            result = runner.invoke(
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(
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
+        )
381
+
382
+
383
+class TestExportConfigValid:
384
+    """Tests concerning `vault` configuration exports: valid exports."""
385
+
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"
394
+        assert not result.stderr or all(
395
+            map(is_harmless_config_import_warning, caplog.record_tuples)
396
+        ), "unexpected error output"
397
+
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)
407
+        _types.clean_up_falsy_vault_config_values(config2)
408
+        runner = machinery.CliRunner(mix_stderr=False)
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:
413
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
414
+            stack.enter_context(
415
+                pytest_machinery.isolated_vault_config(
416
+                    monkeypatch=monkeypatch,
417
+                    runner=runner,
418
+                    vault_config={"services": {}},
419
+                )
420
+            )
421
+            if use_import:
422
+                result1 = runner.invoke(
423
+                    cli.derivepassphrase_vault,
424
+                    ["--import", "-"],
425
+                    input=json.dumps(config),
426
+                    catch_exceptions=False,
427
+                )
428
+                self._assert_result(result1, caplog=caplog)
429
+            else:
430
+                with cli_helpers.config_filename(subsystem="vault").open(
431
+                    "w", encoding="UTF-8"
432
+                ) as outfile:
433
+                    # Ensure the config is written on one line.
434
+                    json.dump(config, outfile, indent=None)
435
+            result = runner.invoke(
436
+                cli.derivepassphrase_vault,
437
+                ["--export", "-"],
438
+                catch_exceptions=False,
439
+            )
440
+        self._assert_result(result, caplog=caplog)
441
+        config3 = json.loads(result.stdout)
442
+        assert config3 == config2, "config not exported correctly"
443
+        assert_vault_config_is_indented_and_line_broken(result.stdout)
444
+
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)
453
+
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.
473
+
474
+        Tested via hypothesis.
475
+
476
+        """
477
+        # Reset caplog between hypothesis runs.
478
+        caplog.clear()
479
+        self._test(caplog=caplog, config=conf.config, use_import=True)
480
+
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)
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:
492
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
493
+            stack.enter_context(
494
+                pytest_machinery.isolated_config(
495
+                    monkeypatch=monkeypatch,
496
+                    runner=runner,
497
+                )
498
+            )
499
+            cli_helpers.config_filename(subsystem="vault").unlink(
500
+                missing_ok=True
501
+            )
502
+            result = runner.invoke(
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"
512
+        assert result.stdout.startswith("#!") or json.loads(result.stdout) == {
513
+            "services": {}
514
+        }
515
+
516
+
517
+class TestExportConfigInvalid:
518
+    """Tests concerning `vault` configuration exports: invalid exports."""
519
+
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
+    ) -> Iterator[list[str]]:
529
+        runner = machinery.CliRunner(mix_stderr=False)
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:
534
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
535
+            stack.enter_context(
536
+                pytest_machinery.isolated_vault_config(
537
+                    monkeypatch=monkeypatch,
538
+                    runner=runner,
539
+                    vault_config=config,
540
+                )
541
+            )
542
+            yield command_line
543
+            result = runner.invoke(
544
+                cli.derivepassphrase_vault,
545
+                command_line,
546
+                input="null",
547
+                catch_exceptions=False,
548
+            )
549
+        assert any([result.error_exit(error=msg) for msg in error_messages]), (
550
+            "expected error exit and known error message"
551
+        )
552
+
553
+    @Parametrize.EXPORT_FORMAT_OPTIONS
554
+    def test_bad_stored_config(
555
+        self,
556
+        export_options: list[str],
557
+    ) -> None:
558
+        """Exporting an invalid config fails."""
559
+        with self._test(
560
+            ["--export", "-", *export_options],
561
+            config=None,  # type: ignore[arg-type,typeddict-item]
562
+            error_messages=("Cannot load vault settings:",),
563
+        ):
564
+            pass
565
+
566
+    @Parametrize.EXPORT_FORMAT_OPTIONS
567
+    def test_not_a_file(
568
+        self,
569
+        export_options: list[str],
570
+    ) -> None:
571
+        """Exporting an invalid config fails."""
572
+        with self._test(
573
+            ["--export", "-", *export_options],
574
+            error_messages=("Cannot load vault settings:",),
575
+        ):
576
+            config_file = cli_helpers.config_filename(subsystem="vault")
577
+            config_file.unlink(missing_ok=True)
578
+            config_file.mkdir(parents=True, exist_ok=True)
579
+
580
+    @Parametrize.EXPORT_FORMAT_OPTIONS
581
+    def test_target_not_a_file(
582
+        self,
583
+        export_options: list[str],
584
+    ) -> None:
585
+        """Exporting an invalid config fails."""
586
+        with self._test(
587
+            [], error_messages=("Cannot export vault settings:",)
588
+        ) as command_line:
589
+            dname = cli_helpers.config_filename(subsystem=None)
590
+            command_line[:] = ["--export", os.fsdecode(dname), *export_options]
591
+
592
+    @pytest_machinery.skip_if_on_the_annoying_os
593
+    @Parametrize.EXPORT_FORMAT_OPTIONS
594
+    def test_settings_directory_not_a_directory(
595
+        self,
596
+        export_options: list[str],
597
+    ) -> None:
598
+        """Exporting an invalid config fails."""
599
+        with self._test(
600
+            ["--export", "-", *export_options],
601
+            error_messages=(
602
+                "Cannot load vault settings:",
603
+                "Cannot load user config:",
604
+            ),
605
+        ):
606
+            config_dir = cli_helpers.config_filename(subsystem=None)
607
+            with contextlib.suppress(FileNotFoundError):
608
+                shutil.rmtree(config_dir)
609
+            config_dir.write_text("Obstruction!!\n")
610
+
611
+
612
+class TestStoringConfigurationSuccesses:
613
+    """Tests concerning storing the configuration: successes."""
614
+
615
+    def _test(
616
+        self,
617
+        command_line: list[str],
618
+        /,
619
+        *,
620
+        starting_config: _types.VaultConfig | None,
621
+        result_config: _types.VaultConfig,
622
+        input: str | bytes | None = None,
623
+    ) -> machinery.ReadableResult:
624
+        runner = machinery.CliRunner(mix_stderr=False)
625
+        # TODO(the-13th-letter): Rewrite using parenthesized
626
+        # with-statements.
627
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
628
+        with contextlib.ExitStack() as stack:
629
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
630
+            stack.enter_context(
631
+                pytest_machinery.isolated_vault_config(
632
+                    monkeypatch=monkeypatch,
633
+                    runner=runner,
634
+                    vault_config=starting_config,
635
+                )
636
+            )
637
+            if starting_config is None:
638
+                with contextlib.suppress(FileNotFoundError):
639
+                    shutil.rmtree(cli_helpers.config_filename(subsystem=None))
640
+            monkeypatch.setattr(
641
+                cli_helpers,
642
+                "get_suitable_ssh_keys",
643
+                callables.suitable_ssh_keys,
644
+            )
645
+            result = runner.invoke(
646
+                cli.derivepassphrase_vault,
647
+                ["--config", *command_line],
648
+                catch_exceptions=False,
649
+                input=input,
650
+            )
651
+            assert result.clean_exit(), "expected clean exit"
652
+            config_txt = cli_helpers.config_filename(
653
+                subsystem="vault"
654
+            ).read_text(encoding="UTF-8")
655
+            config = json.loads(config_txt)
656
+            assert config == result_config, (
657
+                "stored config does not match expectation"
658
+            )
659
+            assert_vault_config_is_indented_and_line_broken(config_txt)
660
+            return result
661
+
662
+    @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG
663
+    def test_store_good_config(
664
+        self,
665
+        command_line: list[str],
666
+        input: str,
667
+        starting_config: Any,
668
+        result_config: Any,
669
+    ) -> None:
670
+        """Storing valid settings via `--config` works.
671
+
672
+        The format also contains embedded newlines and indentation to make
673
+        the config more readable.
674
+
675
+        """
676
+        self._test(
677
+            command_line,
678
+            input=input,
679
+            starting_config=starting_config,
680
+            result_config=result_config,
681
+        )
682
+
683
+    def test_config_directory_nonexistant(
684
+        self,
685
+    ) -> None:
686
+        """Running without an existing config directory works.
687
+
688
+        This is a regression test; see [the "pretty-print-json"
689
+        issue][PRETTY_PRINT_JSON] for context.  See also
690
+        [TestStoringConfigurationFailures.test_config_directory_not_a_file][]
691
+        for a related aspect of this.
692
+
693
+        [PRETTY_PRINT_JSON]: https://the13thletter.info/derivepassphrase/0.x/wishlist/pretty-print-json/
694
+
695
+        """
696
+        result = self._test(
697
+            ["-p"],
698
+            starting_config=None,
699
+            result_config={"global": {"phrase": "abc"}, "services": {}},
700
+            input="abc\n",
701
+        )
702
+        assert result.stderr == "Passphrase:", "program unexpectedly failed?!"
703
+
704
+
705
+class TestStoringConfigurationFailures:
706
+    """Tests concerning storing the configuration: failures."""
707
+
708
+    @contextlib.contextmanager
709
+    def _test(
710
+        self,
711
+        command_line: list[str],
712
+        error_text: str,
713
+        input: str | bytes | None = None,
714
+        starting_config: _types.VaultConfig = {  # noqa: B006
715
+            "global": {"phrase": "abc"},
716
+            "services": {},
717
+        },
718
+        patch_suitable_ssh_keys: bool = True,
719
+    ) -> Iterator[pytest.MonkeyPatch]:
720
+        runner = machinery.CliRunner(mix_stderr=False)
721
+        # TODO(the-13th-letter): Rewrite using parenthesized
722
+        # with-statements.
723
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
724
+        with contextlib.ExitStack() as stack:
725
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
726
+            stack.enter_context(
727
+                pytest_machinery.isolated_vault_config(
728
+                    monkeypatch=monkeypatch,
729
+                    runner=runner,
730
+                    vault_config=starting_config,
731
+                )
732
+            )
733
+            # Patch the list of suitable SSH keys by default, lest we be
734
+            # at the mercy of whatever SSH agent may be running. (But
735
+            # allow a test to turn this off, if it would interfere with
736
+            # the testing target, e.g. because we are testing
737
+            # non-reachability of the agent.)
738
+            if patch_suitable_ssh_keys:
739
+                monkeypatch.setattr(
740
+                    cli_helpers,
741
+                    "get_suitable_ssh_keys",
742
+                    callables.suitable_ssh_keys,
743
+                )
744
+            yield monkeypatch
745
+            result = runner.invoke(
746
+                cli.derivepassphrase_vault,
747
+                ["--config", *command_line],
748
+                catch_exceptions=False,
749
+                input=input,
750
+            )
751
+        assert result.error_exit(error=error_text), (
752
+            "expected error exit and known error message"
753
+        )
754
+
755
+    @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG_FAILURES
756
+    def test_store_bad_config(
757
+        self,
758
+        command_line: list[str],
759
+        input: str,
760
+        err_text: str,
761
+    ) -> None:
762
+        """Storing invalid settings via `--config` fails."""
763
+        with self._test(command_line, error_text=err_text, input=input):
764
+            pass
765
+
766
+    def test_fail_because_no_ssh_key_selection(self) -> None:
767
+        """Not selecting an SSH key during `--config --key` fails.
768
+
769
+        (This test does not actually need a running agent; the agent's
770
+        response is mocked by the test harness.)
771
+
772
+        """
773
+        with self._test(
774
+            ["--key"], error_text="the user aborted the request"
775
+        ) as monkeypatch:
776
+
777
+            def prompt_for_selection(*_args: Any, **_kwargs: Any) -> NoReturn:
778
+                raise IndexError(cli_helpers.EMPTY_SELECTION)
779
+
780
+            monkeypatch.setattr(
781
+                cli_helpers, "prompt_for_selection", prompt_for_selection
782
+            )
783
+
784
+    def test_fail_because_no_ssh_agent(self) -> None:
785
+        """Not running an SSH agent during `--config --key` fails.
786
+
787
+        (This test does not actually need a running agent; the agent's
788
+        response is mocked by the test harness.)
789
+
790
+        """
791
+        with self._test(
792
+            ["--key"],
793
+            error_text="Cannot find any running SSH agent",
794
+            patch_suitable_ssh_keys=False,
795
+        ) as monkeypatch:
796
+            monkeypatch.delenv("SSH_AUTH_SOCK", raising=False)
797
+
798
+    def test_fail_because_bad_ssh_agent_connection(self) -> None:
799
+        """Not running a reachable SSH agent during `--config --key` fails.
800
+
801
+        (This test does not actually need a running agent; the agent's
802
+        response is mocked by the test harness.)
803
+
804
+        """
805
+        with self._test(
806
+            ["--key"],
807
+            error_text="Cannot connect to the SSH agent",
808
+            patch_suitable_ssh_keys=False,
809
+        ) as monkeypatch:
810
+            cwd = pathlib.Path.cwd().resolve()
811
+            monkeypatch.setenv("SSH_AUTH_SOCK", str(cwd))
812
+
813
+    @Parametrize.TRY_RACE_FREE_IMPLEMENTATION
814
+    def test_fail_because_read_only_file(
815
+        self, try_race_free_implementation: bool
816
+    ) -> None:
817
+        """Using a read-only configuration file with `--config` fails."""
818
+        with self._test(
819
+            ["--length=15", "--", DUMMY_SERVICE],
820
+            error_text="Cannot store vault settings:",
821
+        ):
822
+            callables.make_file_readonly(
823
+                cli_helpers.config_filename(subsystem="vault"),
824
+                try_race_free_implementation=try_race_free_implementation,
825
+            )
826
+
827
+    def test_fail_because_of_custom_error(self) -> None:
828
+        """Triggering internal errors during `--config` leads to failure."""
829
+        custom_error = "custom error message"
830
+        with self._test(
831
+            ["--length=15", "--", DUMMY_SERVICE], error_text=custom_error
832
+        ) as monkeypatch:
833
+
834
+            def raiser(config: Any) -> None:
835
+                del config
836
+                raise RuntimeError(custom_error)
837
+
838
+            monkeypatch.setattr(cli_helpers, "save_config", raiser)
839
+
840
+    def test_fail_because_unsetting_and_setting_same_settings(self) -> None:
841
+        """Issuing conflicting settings to `--config` fails."""
842
+        with self._test(
843
+            ["--unset=length", "--length=15", "--", DUMMY_SERVICE],
844
+            error_text="Attempted to unset and set --length at the same time.",
845
+        ):
846
+            pass
847
+
848
+    def test_fail_because_ssh_agent_has_no_keys_loaded(self) -> None:
849
+        """Not holding any SSH keys during `--config --key` fails.
850
+
851
+        (This test does not actually need a running agent; the agent's
852
+        response is mocked by the test harness.)
853
+
854
+        """
855
+        with self._test(
856
+            ["--key"],
857
+            error_text="no keys suitable",
858
+            patch_suitable_ssh_keys=False,
859
+        ) as monkeypatch:
860
+
861
+            def func(
862
+                *_args: Any,
863
+                **_kwargs: Any,
864
+            ) -> list[_types.SSHKeyCommentPair]:
865
+                return []
866
+
867
+            monkeypatch.setattr(ssh_agent.SSHAgentClient, "list_keys", func)
868
+
869
+    def test_store_config_fail_manual_ssh_agent_runtime_error(self) -> None:
870
+        """Triggering an error in the SSH agent during `--config --key` leads to failure.
871
+
872
+        (This test does not actually need a running agent; the agent's
873
+        response is mocked by the test harness.)
874
+
875
+        """
876
+        with self._test(
877
+            ["--key"],
878
+            error_text="violates the communication protocol",
879
+            patch_suitable_ssh_keys=False,
880
+        ) as monkeypatch:
881
+
882
+            def raiser(*_args: Any, **_kwargs: Any) -> None:
883
+                raise ssh_agent.TrailingDataError()
884
+
885
+            monkeypatch.setattr(ssh_agent.SSHAgentClient, "list_keys", raiser)
886
+
887
+    def test_store_config_fail_manual_ssh_agent_refuses(self) -> None:
888
+        """The SSH agent refusing during `--config --key` leads to failure.
889
+
890
+        (This test does not actually need a running agent; the agent's
891
+        response is mocked by the test harness.)
892
+
893
+        """
894
+        with self._test(
895
+            ["--key"], error_text="refused to", patch_suitable_ssh_keys=False
896
+        ) as monkeypatch:
897
+
898
+            def func(*_args: Any, **_kwargs: Any) -> NoReturn:
899
+                raise ssh_agent.SSHAgentFailedError(
900
+                    _types.SSH_AGENT.FAILURE, b""
901
+                )
902
+
903
+            monkeypatch.setattr(ssh_agent.SSHAgentClient, "list_keys", func)
904
+
905
+    def test_config_directory_not_a_file(self) -> None:
906
+        """Erroring without an existing config directory errors normally.
907
+
908
+        That is, the missing configuration directory does not cause any
909
+        errors by itself.
910
+
911
+        This is a regression test; see [the "pretty-print-json"
912
+        issue][PRETTY_PRINT_JSON] for context.  See also
913
+        [TestStoringConfigurationSuccesses.test_config_directory_nonexistant][]
914
+        for a related aspect of this.
915
+
916
+        [PRETTY_PRINT_JSON]: https://the13thletter.info/derivepassphrase/0.x/wishlist/pretty-print-json/
917
+
918
+        """
919
+        with self._test(
920
+            ["--phrase"],
921
+            error_text="Cannot store vault settings:",
922
+            input="abc\n",
923
+        ) as monkeypatch:
924
+            save_config_ = cli_helpers.save_config
925
+
926
+            def obstruct_config_saving(*args: Any, **kwargs: Any) -> Any:
927
+                config_dir = cli_helpers.config_filename(subsystem=None)
928
+                with contextlib.suppress(FileNotFoundError):
929
+                    shutil.rmtree(config_dir)
930
+                config_dir.write_text("Obstruction!!\n")
931
+                monkeypatch.setattr(cli_helpers, "save_config", save_config_)
932
+                return save_config_(*args, **kwargs)
933
+
934
+            monkeypatch.setattr(
935
+                cli_helpers, "save_config", obstruct_config_saving
936
+            )
... ...
@@ -0,0 +1,630 @@
1
+# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info>
2
+#
3
+# SPDX-License-Identifier: Zlib
4
+
5
+"""Tests for the `derivepassphrase vault` command-line interface."""
6
+
7
+from __future__ import annotations
8
+
9
+import contextlib
10
+import json
11
+import types
12
+from typing import TYPE_CHECKING
13
+
14
+import click.testing
15
+import hypothesis
16
+import pytest
17
+from hypothesis import strategies
18
+from typing_extensions import Any, TypedDict
19
+
20
+from derivepassphrase import _types, cli
21
+from derivepassphrase._internals import (
22
+    cli_helpers,
23
+    cli_messages,
24
+)
25
+from tests import data, machinery
26
+from tests.machinery import pytest as pytest_machinery
27
+
28
+if TYPE_CHECKING:
29
+    from typing import NoReturn
30
+
31
+    from typing_extensions import Literal, NotRequired
32
+
33
+DUMMY_SERVICE = data.DUMMY_SERVICE
34
+DUMMY_PASSPHRASE = data.DUMMY_PASSPHRASE
35
+DUMMY_CONFIG_SETTINGS = data.DUMMY_CONFIG_SETTINGS
36
+DUMMY_RESULT_PASSPHRASE = data.DUMMY_RESULT_PASSPHRASE
37
+
38
+
39
+def is_warning_line(line: str) -> bool:
40
+    """Return true if the line is a warning line."""
41
+    return " Warning: " in line or " Deprecation warning: " in line
42
+
43
+
44
+class Strategies:
45
+    @staticmethod
46
+    def notes(*, max_size: int = 512) -> strategies.SearchStrategy[str]:
47
+        return strategies.text(
48
+            strategies.characters(
49
+                min_codepoint=32, max_codepoint=126, include_characters="\n"
50
+            ),
51
+            min_size=1,
52
+            max_size=max_size,
53
+        )
54
+
55
+
56
+class Parametrize(types.SimpleNamespace):
57
+    """Common test parametrizations."""
58
+
59
+    MODERN_EDITOR_INTERFACE = pytest.mark.parametrize(
60
+        "modern_editor_interface", [False, True], ids=["legacy", "modern"]
61
+    )
62
+    NOTES_PLACEMENT = pytest.mark.parametrize(
63
+        ["notes_placement", "placement_args"],
64
+        [
65
+            pytest.param("after", ["--print-notes-after"], id="after"),
66
+            pytest.param("before", ["--print-notes-before"], id="before"),
67
+        ],
68
+    )
69
+
70
+
71
+class TestNotesPrinting:
72
+    """Tests concerning printing the service notes."""
73
+
74
+    def _test(
75
+        self,
76
+        notes: str,
77
+        /,
78
+        notes_placement: Literal["before", "after"] | None = None,
79
+        placement_args: list[str] | tuple[str, ...] = (),
80
+    ) -> None:
81
+        notes_stripped = notes.strip()
82
+        maybe_notes = {"notes": notes_stripped} if notes_stripped else {}
83
+        vault_config = {
84
+            "global": {"phrase": DUMMY_PASSPHRASE},
85
+            "services": {
86
+                DUMMY_SERVICE: {**maybe_notes, **DUMMY_CONFIG_SETTINGS}
87
+            },
88
+        }
89
+        result_phrase = DUMMY_RESULT_PASSPHRASE.decode("ascii")
90
+        expected = (
91
+            f"{notes_stripped}\n\n{result_phrase}\n"
92
+            if notes_placement == "before"
93
+            else f"{result_phrase}\n\n{notes_stripped}\n\n"
94
+            if notes_placement == "after"
95
+            else None
96
+        )
97
+        runner = machinery.CliRunner(mix_stderr=notes_placement is not None)
98
+        # TODO(the-13th-letter): Rewrite using parenthesized
99
+        # with-statements.
100
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
101
+        with contextlib.ExitStack() as stack:
102
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
103
+            stack.enter_context(
104
+                pytest_machinery.isolated_vault_config(
105
+                    monkeypatch=monkeypatch,
106
+                    runner=runner,
107
+                    vault_config=vault_config,
108
+                )
109
+            )
110
+            result = runner.invoke(
111
+                cli.derivepassphrase_vault,
112
+                [*placement_args, "--", DUMMY_SERVICE],
113
+                catch_exceptions=False,
114
+            )
115
+            if expected is not None:
116
+                assert result.clean_exit(output=expected), (
117
+                    "expected clean exit"
118
+                )
119
+            else:
120
+                assert result.clean_exit(), "expected clean exit"
121
+                assert result.stdout, "expected program output"
122
+                assert result.stdout.strip() == result_phrase, (
123
+                    "expected known program output"
124
+                )
125
+                assert result.stderr or not notes_stripped, "expected stderr"
126
+                assert "Error:" not in result.stderr, (
127
+                    "expected no error messages on stderr"
128
+                )
129
+                assert result.stderr.strip() == notes_stripped, (
130
+                    "expected known stderr contents"
131
+                )
132
+
133
+    @hypothesis.given(notes=Strategies.notes().filter(str.strip))
134
+    def test_service_with_notes_actually_prints_notes(
135
+        self,
136
+        notes: str,
137
+    ) -> None:
138
+        """Service notes are printed, if they exist."""
139
+        hypothesis.assume("Error:" not in notes)
140
+        self._test(notes, notes_placement=None, placement_args=())
141
+
142
+    @Parametrize.NOTES_PLACEMENT
143
+    @hypothesis.given(notes=Strategies.notes().filter(str.strip))
144
+    def test_notes_placement(
145
+        self,
146
+        notes_placement: Literal["before", "after"],
147
+        placement_args: list[str],
148
+        notes: str,
149
+    ) -> None:
150
+        self._test(
151
+            notes,
152
+            notes_placement=notes_placement,
153
+            placement_args=placement_args,
154
+        )
155
+
156
+
157
+class TestNotesEditing:
158
+    """Superclass for tests concerning editing service notes."""
159
+
160
+    CURRENT_NOTES = "Contents go here"
161
+    OLD_NOTES_TEXT = (
162
+        "These backup notes are left over from the previous session."
163
+    )
164
+
165
+    def _calculate_expected_contents(
166
+        self,
167
+        final_notes: str,
168
+        /,
169
+        *,
170
+        modern_editor_interface: bool,
171
+        current_notes: str | None = CURRENT_NOTES,
172
+        old_notes_text: str | None = OLD_NOTES_TEXT,
173
+    ) -> tuple[str, str]:
174
+        current_notes = current_notes or ""
175
+        old_notes_text = old_notes_text or ""
176
+        # For the modern editor interface, the notes change if and only
177
+        # if the notes change to a different, non-empty string.  There
178
+        # are no backup notes, so we return the old ones (which may be
179
+        # synthetic) unchanged.
180
+        if modern_editor_interface:
181
+            return old_notes_text.strip(), (
182
+                final_notes.strip()
183
+                if final_notes.strip()
184
+                and final_notes.strip() != current_notes.strip()
185
+                else current_notes.strip()
186
+            )
187
+        # For the legacy editor interface, the notes and the backup
188
+        # notes change if and only if the new notes differ from the
189
+        # previous notes.
190
+        return (
191
+            (current_notes.strip(), final_notes.strip())
192
+            if final_notes.strip() != current_notes.strip()
193
+            else (old_notes_text.strip(), current_notes.strip())
194
+        )
195
+
196
+    def _test(
197
+        self,
198
+        edit_result: str,
199
+        /,
200
+        *,
201
+        modern_editor_interface: bool,
202
+        current_notes: str | None = CURRENT_NOTES,
203
+        old_notes_text: str | None = OLD_NOTES_TEXT,
204
+    ) -> tuple[machinery.ReadableResult, str, _types.VaultConfig]:
205
+        if hypothesis.currently_in_test_context():  # pragma: no branch
206
+            hypothesis.note(f"{edit_result = }")
207
+            hypothesis.note(f"{modern_editor_interface = }")
208
+            hypothesis.note(
209
+                f"vault_config = {self._vault_config(current_notes or '')}"
210
+            )
211
+        runner = machinery.CliRunner(mix_stderr=False)
212
+        # TODO(the-13th-letter): Rewrite using parenthesized
213
+        # with-statements.
214
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
215
+        with contextlib.ExitStack() as stack:
216
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
217
+            stack.enter_context(
218
+                pytest_machinery.isolated_vault_config(
219
+                    monkeypatch=monkeypatch,
220
+                    runner=runner,
221
+                    vault_config=self._vault_config(current_notes or ""),
222
+                )
223
+            )
224
+            notes_backup_file = cli_helpers.config_filename(
225
+                subsystem="notes backup"
226
+            )
227
+            if old_notes_text and old_notes_text.strip():  # pragma: no branch
228
+                notes_backup_file.write_text(
229
+                    old_notes_text.strip(), encoding="UTF-8"
230
+                )
231
+            monkeypatch.setattr(click, "edit", lambda *_a, **_kw: edit_result)
232
+            result = runner.invoke(
233
+                cli.derivepassphrase_vault,
234
+                [
235
+                    "--config",
236
+                    "--notes",
237
+                    "--modern-editor-interface"
238
+                    if modern_editor_interface
239
+                    else "--vault-legacy-editor-interface",
240
+                    "--",
241
+                    "sv",
242
+                ],
243
+                catch_exceptions=False,
244
+            )
245
+            backup_contents = notes_backup_file.read_text(encoding="UTF-8")
246
+            with cli_helpers.config_filename(subsystem="vault").open(
247
+                encoding="UTF-8"
248
+            ) as infile:
249
+                config = json.load(infile)
250
+            if hypothesis.currently_in_test_context():  # pragma: no branch
251
+                hypothesis.note(f"{result = }")
252
+                hypothesis.note(f"{backup_contents = }")
253
+                hypothesis.note(f"{config = }")
254
+            return result, backup_contents, config
255
+
256
+    def _assert_noop_exit(
257
+        self,
258
+        result: machinery.ReadableResult,
259
+        /,
260
+        *,
261
+        modern_editor_interface: bool = False,
262
+    ) -> None:
263
+        # We do not distinguish between aborts and no-op edits.  Aborts
264
+        # are treated as failures (error exit), and thus tested
265
+        # specifically in a different class.
266
+        if modern_editor_interface:
267
+            assert result.error_exit(
268
+                error="the user aborted the request"
269
+            ) or result.clean_exit(empty_stderr=True), "expected clean exit"
270
+        else:
271
+            assert result.clean_exit(empty_stderr=False), "expected clean exit"
272
+
273
+    def _assert_normal_exit(self, result: machinery.ReadableResult) -> None:
274
+        assert result.clean_exit(), "expected clean exit"
275
+        assert all(map(is_warning_line, result.stderr.splitlines(True)))
276
+
277
+    def _assert_notes_backup_warning(
278
+        self,
279
+        caplog: pytest.LogCaptureFixture,
280
+        /,
281
+        *,
282
+        modern_editor_interface: bool,
283
+        notes_unchanged: bool = False,
284
+    ) -> None:
285
+        assert (
286
+            modern_editor_interface
287
+            or notes_unchanged
288
+            or machinery.warning_emitted(
289
+                "A backup copy of the old notes was saved",
290
+                caplog.record_tuples,
291
+            )
292
+        ), "expected known warning message on stderr"
293
+
294
+    def _assert_notes_and_backup_notes(
295
+        self,
296
+        /,
297
+        *,
298
+        final_notes: str,
299
+        new_backup_notes: str,
300
+        new_config: _types.VaultConfig,
301
+        modern_editor_interface: bool,
302
+        current_notes: str | None = CURRENT_NOTES,
303
+        old_notes_text: str | None = OLD_NOTES_TEXT,
304
+    ) -> None:
305
+        if hypothesis.currently_in_test_context():  # pragma: no branch
306
+            hypothesis.note(f"{final_notes = }")
307
+            hypothesis.note(f"{current_notes = }")
308
+        expected_backup_notes, expected_notes = (
309
+            self._calculate_expected_contents(
310
+                final_notes,
311
+                modern_editor_interface=modern_editor_interface,
312
+                current_notes=current_notes,
313
+                old_notes_text=old_notes_text,
314
+            )
315
+        )
316
+        expected_config = self._vault_config(expected_notes)
317
+        assert new_config == expected_config
318
+        assert new_backup_notes == expected_backup_notes
319
+
320
+    @staticmethod
321
+    def _vault_config(
322
+        starting_notes: str = CURRENT_NOTES, /
323
+    ) -> _types.VaultConfig:
324
+        return {
325
+            "global": {"phrase": "abc"},
326
+            "services": {
327
+                "sv": {"notes": starting_notes.strip()}
328
+                if starting_notes.strip()
329
+                else {}
330
+            },
331
+        }
332
+
333
+    class ExtraArgs(TypedDict):
334
+        modern_editor_interface: bool
335
+        current_notes: NotRequired[str]
336
+        old_notes_text: NotRequired[str]
337
+
338
+
339
+class TestNotesEditingValid(TestNotesEditing):
340
+    """Tests concerning editing service notes: valid calls."""
341
+
342
+    @Parametrize.MODERN_EDITOR_INTERFACE
343
+    @hypothesis.settings(
344
+        suppress_health_check=[
345
+            *hypothesis.settings().suppress_health_check,
346
+            hypothesis.HealthCheck.function_scoped_fixture,
347
+        ],
348
+    )
349
+    @hypothesis.given(
350
+        notes=Strategies.notes()
351
+        .filter(str.strip)
352
+        .filter(lambda notes: notes != TestNotesEditingValid.CURRENT_NOTES)
353
+    )
354
+    @hypothesis.example(TestNotesEditing.CURRENT_NOTES)
355
+    def test_successful_edit(
356
+        self,
357
+        caplog: pytest.LogCaptureFixture,
358
+        modern_editor_interface: bool,
359
+        notes: str,
360
+    ) -> None:
361
+        """Editing notes works."""
362
+        # Reset caplog between hypothesis runs.
363
+        caplog.clear()
364
+        marker = cli_messages.TranslatedString(
365
+            cli_messages.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER
366
+        )
367
+        edit_result = (
368
+            f"""
369
+
370
+{marker}
371
+{notes}
372
+"""
373
+            if modern_editor_interface
374
+            else notes.strip()
375
+        )
376
+
377
+        extra_args: TestNotesEditing.ExtraArgs = {
378
+            "modern_editor_interface": modern_editor_interface,
379
+            "current_notes": self.CURRENT_NOTES,
380
+            "old_notes_text": self.OLD_NOTES_TEXT,
381
+        }
382
+        notes_unchanged = notes.strip() == extra_args["current_notes"].strip()
383
+
384
+        result, new_backup_notes, new_config = self._test(
385
+            edit_result, **extra_args
386
+        )
387
+        self._assert_normal_exit(result)
388
+        self._assert_notes_and_backup_notes(
389
+            final_notes=notes,
390
+            new_backup_notes=new_backup_notes,
391
+            new_config=new_config,
392
+            **extra_args,
393
+        )
394
+        self._assert_notes_backup_warning(
395
+            caplog,
396
+            modern_editor_interface=modern_editor_interface,
397
+            notes_unchanged=notes_unchanged,
398
+        )
399
+
400
+    @Parametrize.MODERN_EDITOR_INTERFACE
401
+    @hypothesis.settings(
402
+        suppress_health_check=[
403
+            *hypothesis.settings().suppress_health_check,
404
+            hypothesis.HealthCheck.function_scoped_fixture,
405
+        ],
406
+    )
407
+    @hypothesis.given(notes=Strategies.notes().filter(str.strip))
408
+    @hypothesis.example(TestNotesEditing.CURRENT_NOTES)
409
+    def test_noop_edit(
410
+        self,
411
+        caplog: pytest.LogCaptureFixture,
412
+        modern_editor_interface: bool,
413
+        notes: str,
414
+    ) -> None:
415
+        """No-op editing existing notes works.
416
+
417
+        The notes are unchanged, and the command-line interface does not
418
+        report an abort.  For the legacy editor interface, the backup
419
+        notes are unchanged as well.
420
+
421
+        """
422
+        # Reset caplog between hypothesis runs.
423
+        caplog.clear()
424
+        marker = cli_messages.TranslatedString(
425
+            cli_messages.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER
426
+        )
427
+        edit_result = (f"{marker}\n" if modern_editor_interface else "") + (
428
+            " " * 6 + notes + "\n" * 6
429
+        )
430
+
431
+        extra_args: TestNotesEditing.ExtraArgs = {
432
+            "modern_editor_interface": modern_editor_interface,
433
+            "current_notes": notes.strip(),
434
+            "old_notes_text": self.OLD_NOTES_TEXT,
435
+        }
436
+
437
+        result, new_backup_notes, new_config = self._test(
438
+            edit_result, **extra_args
439
+        )
440
+        self._assert_noop_exit(
441
+            result,
442
+            modern_editor_interface=modern_editor_interface,
443
+        )
444
+        self._assert_notes_and_backup_notes(
445
+            final_notes=notes,
446
+            new_backup_notes=new_backup_notes,
447
+            new_config=new_config,
448
+            **extra_args,
449
+        )
450
+        self._assert_notes_backup_warning(
451
+            caplog,
452
+            modern_editor_interface=modern_editor_interface,
453
+            notes_unchanged=True,
454
+        )
455
+
456
+    # TODO(the-13th-letter): Keep this behavior or not, with or without
457
+    # warning?
458
+    @Parametrize.MODERN_EDITOR_INTERFACE
459
+    @hypothesis.settings(
460
+        suppress_health_check=[
461
+            *hypothesis.settings().suppress_health_check,
462
+            hypothesis.HealthCheck.function_scoped_fixture,
463
+        ],
464
+    )
465
+    @hypothesis.given(notes=Strategies.notes().filter(str.strip))
466
+    def test_marker_removed(
467
+        self,
468
+        caplog: pytest.LogCaptureFixture,
469
+        modern_editor_interface: bool,
470
+        notes: str,
471
+    ) -> None:
472
+        """Removing the notes marker still saves the notes.
473
+
474
+        TODO: Keep this behavior or not, with or without warning?
475
+
476
+        """
477
+        notes_marker = cli_messages.TranslatedString(
478
+            cli_messages.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER
479
+        )
480
+        hypothesis.assume(str(notes_marker) not in notes.strip())
481
+        # Reset caplog between hypothesis runs.
482
+        caplog.clear()
483
+
484
+        extra_args: TestNotesEditing.ExtraArgs = {
485
+            "modern_editor_interface": modern_editor_interface,
486
+            "current_notes": self.CURRENT_NOTES,
487
+            "old_notes_text": self.OLD_NOTES_TEXT,
488
+        }
489
+        notes_unchanged = notes.strip() == extra_args["current_notes"].strip()
490
+
491
+        result, new_backup_notes, new_config = self._test(
492
+            notes.strip(), **extra_args
493
+        )
494
+        self._assert_normal_exit(result)
495
+        self._assert_notes_and_backup_notes(
496
+            final_notes=notes,
497
+            new_backup_notes=new_backup_notes,
498
+            new_config=new_config,
499
+            **extra_args,
500
+        )
501
+        self._assert_notes_backup_warning(
502
+            caplog,
503
+            modern_editor_interface=modern_editor_interface,
504
+            notes_unchanged=notes_unchanged,
505
+        )
506
+
507
+
508
+class TestNotesEditingInvalid(TestNotesEditing):
509
+    """Tests concerning editing service notes: invalid/error calls."""
510
+
511
+    @hypothesis.given(notes=Strategies.notes())
512
+    @hypothesis.example("")
513
+    def test_abort(
514
+        self,
515
+        notes: str,
516
+    ) -> None:
517
+        """Aborting editing notes works, even if no notes are stored yet.
518
+
519
+        Aborting is only supported with the modern editor interface.
520
+
521
+        """
522
+        edit_result = ""
523
+
524
+        extra_args: TestNotesEditing.ExtraArgs = {
525
+            "modern_editor_interface": True,
526
+            "current_notes": notes.strip(),
527
+            "old_notes_text": self.OLD_NOTES_TEXT,
528
+        }
529
+
530
+        result, new_backup_notes, new_config = self._test(
531
+            edit_result, **extra_args
532
+        )
533
+        assert result.error_exit(error="the user aborted the request"), (
534
+            "expected error exit"
535
+        )
536
+        self._assert_notes_and_backup_notes(
537
+            final_notes=notes.strip(),
538
+            new_backup_notes=new_backup_notes,
539
+            new_config=new_config,
540
+            **extra_args,
541
+        )
542
+
543
+    @Parametrize.MODERN_EDITOR_INTERFACE
544
+    @hypothesis.settings(
545
+        suppress_health_check=[
546
+            *hypothesis.settings().suppress_health_check,
547
+            hypothesis.HealthCheck.function_scoped_fixture,
548
+        ],
549
+    )
550
+    @hypothesis.given(notes=Strategies.notes())
551
+    @hypothesis.example("")
552
+    def test_fail_on_config_option_missing(
553
+        self,
554
+        caplog: pytest.LogCaptureFixture,
555
+        modern_editor_interface: bool,
556
+        notes: str,
557
+    ) -> None:
558
+        """Editing notes fails (and warns) if `--config` is missing."""
559
+        maybe_notes = {"notes": notes.strip()} if notes.strip() else {}
560
+        vault_config = {
561
+            "global": {"phrase": DUMMY_PASSPHRASE},
562
+            "services": {
563
+                DUMMY_SERVICE: {**maybe_notes, **DUMMY_CONFIG_SETTINGS}
564
+            },
565
+        }
566
+        old_notes_text = (
567
+            "These backup notes are left over from the previous session."
568
+        )
569
+        # Reset caplog between hypothesis runs.
570
+        caplog.clear()
571
+        runner = machinery.CliRunner(mix_stderr=False)
572
+        # TODO(the-13th-letter): Rewrite using parenthesized
573
+        # with-statements.
574
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
575
+        with contextlib.ExitStack() as stack:
576
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
577
+            stack.enter_context(
578
+                pytest_machinery.isolated_vault_config(
579
+                    monkeypatch=monkeypatch,
580
+                    runner=runner,
581
+                    vault_config=vault_config,
582
+                )
583
+            )
584
+            EDIT_ATTEMPTED = "edit attempted!"  # noqa: N806
585
+
586
+            def raiser(*_args: Any, **_kwargs: Any) -> NoReturn:
587
+                pytest.fail(EDIT_ATTEMPTED)
588
+
589
+            notes_backup_file = cli_helpers.config_filename(
590
+                subsystem="notes backup"
591
+            )
592
+            notes_backup_file.write_text(old_notes_text, encoding="UTF-8")
593
+            monkeypatch.setattr(click, "edit", raiser)
594
+            result = runner.invoke(
595
+                cli.derivepassphrase_vault,
596
+                [
597
+                    "--notes",
598
+                    "--modern-editor-interface"
599
+                    if modern_editor_interface
600
+                    else "--vault-legacy-editor-interface",
601
+                    "--",
602
+                    DUMMY_SERVICE,
603
+                ],
604
+                catch_exceptions=False,
605
+            )
606
+            assert result.clean_exit(
607
+                output=DUMMY_RESULT_PASSPHRASE.decode("ascii")
608
+            ), "expected clean exit"
609
+            assert result.stderr
610
+            assert notes.strip() in result.stderr
611
+            assert all(
612
+                is_warning_line(line)
613
+                for line in result.stderr.splitlines(True)
614
+                if line.startswith(f"{cli.PROG_NAME}: ")
615
+            )
616
+            assert machinery.warning_emitted(
617
+                "Specifying --notes without --config is ineffective.  "
618
+                "No notes will be edited.",
619
+                caplog.record_tuples,
620
+            ), "expected known warning message in stderr"
621
+            assert (
622
+                modern_editor_interface
623
+                or notes_backup_file.read_text(encoding="UTF-8")
624
+                == old_notes_text
625
+            )
626
+            with cli_helpers.config_filename(subsystem="vault").open(
627
+                encoding="UTF-8"
628
+            ) as infile:
629
+                config = json.load(infile)
630
+            assert config == vault_config
0 631