Refactor the basic command-line interface tests
Marco Ricci

Marco Ricci commited on 2025-08-29 19:45:20
Zeige 1 geänderte Dateien mit 994 Einfügungen und 1389 Löschungen.


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

In the basic tests, factor out the common test operation or the common
environment setup for each group of related tests, whichever is more
feasible.  For some groups of related tests, if there were similar tests
that differed only in details or in parametrization data, combine them
under a new parameter set, if necessary.

Both the test file and the commit should be further split up, but I am
not yet sure how.
... ...
@@ -16,13 +16,13 @@ import shutil
16 16
 import socket
17 17
 import textwrap
18 18
 import types
19
-from typing import TYPE_CHECKING
19
+from typing import TYPE_CHECKING, ClassVar
20 20
 
21 21
 import click.testing
22 22
 import hypothesis
23 23
 import pytest
24 24
 from hypothesis import strategies
25
-from typing_extensions import Any, NamedTuple
25
+from typing_extensions import Any, NamedTuple, TypedDict
26 26
 
27 27
 from derivepassphrase import _types, cli, ssh_agent, vault
28 28
 from derivepassphrase._internals import (
... ...
@@ -35,9 +35,10 @@ from tests.machinery import hypothesis as hypothesis_machinery
35 35
 from tests.machinery import pytest as pytest_machinery
36 36
 
37 37
 if TYPE_CHECKING:
38
+    from collections.abc import Iterator
38 39
     from typing import NoReturn
39 40
 
40
-    from typing_extensions import Literal
41
+    from typing_extensions import Literal, NotRequired
41 42
 
42 43
 DUMMY_SERVICE = data.DUMMY_SERVICE
43 44
 DUMMY_PASSPHRASE = data.DUMMY_PASSPHRASE
... ...
@@ -262,6 +263,18 @@ def assert_vault_config_is_indented_and_line_broken(
262 263
     ])
263 264
 
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
+
277
+
265 278
 class Parametrize(types.SimpleNamespace):
266 279
     """Common test parametrizations."""
267 280
 
... ...
@@ -271,23 +284,6 @@ class Parametrize(types.SimpleNamespace):
271 284
     CHARSET_NAME = pytest.mark.parametrize(
272 285
         "charset_name", ["lower", "upper", "number", "space", "dash", "symbol"]
273 286
     )
274
-    UNICODE_NORMALIZATION_COMMAND_LINES = pytest.mark.parametrize(
275
-        "command_line",
276
-        [
277
-            pytest.param(
278
-                ["--config", "--phrase"],
279
-                id="configure global passphrase",
280
-            ),
281
-            pytest.param(
282
-                ["--config", "--phrase", "--", "DUMMY_SERVICE"],
283
-                id="configure service passphrase",
284
-            ),
285
-            pytest.param(
286
-                ["--phrase", "--", DUMMY_SERVICE],
287
-                id="interactive passphrase",
288
-            ),
289
-        ],
290
-    )
291 287
     CONFIG_EDITING_VIA_CONFIG_FLAG_FAILURES = pytest.mark.parametrize(
292 288
         ["command_line", "input", "err_text"],
293 289
         [
... ...
@@ -330,17 +326,19 @@ class Parametrize(types.SimpleNamespace):
330 326
         ],
331 327
     )
332 328
     CONFIG_EDITING_VIA_CONFIG_FLAG = pytest.mark.parametrize(
333
-        ["command_line", "input", "result_config"],
329
+        ["command_line", "input", "starting_config", "result_config"],
334 330
         [
335 331
             pytest.param(
336 332
                 ["--phrase"],
337 333
                 "my passphrase\n",
334
+                {"global": {"phrase": "abc"}, "services": {}},
338 335
                 {"global": {"phrase": "my passphrase"}, "services": {}},
339 336
                 id="phrase",
340 337
             ),
341 338
             pytest.param(
342 339
                 ["--key"],
343 340
                 "1\n",
341
+                {"global": {"phrase": "abc"}, "services": {}},
344 342
                 {
345 343
                     "global": {"key": DUMMY_KEY1_B64, "phrase": "abc"},
346 344
                     "services": {},
... ...
@@ -350,6 +348,7 @@ class Parametrize(types.SimpleNamespace):
350 348
             pytest.param(
351 349
                 ["--phrase", "--", "sv"],
352 350
                 "my passphrase\n",
351
+                {"global": {"phrase": "abc"}, "services": {}},
353 352
                 {
354 353
                     "global": {"phrase": "abc"},
355 354
                     "services": {"sv": {"phrase": "my passphrase"}},
... ...
@@ -359,6 +358,7 @@ class Parametrize(types.SimpleNamespace):
359 358
             pytest.param(
360 359
                 ["--key", "--", "sv"],
361 360
                 "1\n",
361
+                {"global": {"phrase": "abc"}, "services": {}},
362 362
                 {
363 363
                     "global": {"phrase": "abc"},
364 364
                     "services": {"sv": {"key": DUMMY_KEY1_B64}},
... ...
@@ -368,6 +368,7 @@ class Parametrize(types.SimpleNamespace):
368 368
             pytest.param(
369 369
                 ["--key", "--length", "15", "--", "sv"],
370 370
                 "1\n",
371
+                {"global": {"phrase": "abc"}, "services": {}},
371 372
                 {
372 373
                     "global": {"phrase": "abc"},
373 374
                     "services": {"sv": {"key": DUMMY_KEY1_B64, "length": 15}},
... ...
@@ -488,20 +489,13 @@ class Parametrize(types.SimpleNamespace):
488 489
             ),
489 490
         ],
490 491
     )
491
-    NOOP_EDIT_FUNCS = pytest.mark.parametrize(
492
-        ["edit_func_name", "modern_editor_interface"],
493
-        [
494
-            pytest.param("empty", True, id="empty"),
495
-            pytest.param("space", False, id="space-legacy"),
496
-            pytest.param("space", True, id="space-modern"),
497
-        ],
498
-    )
499 492
     EXPORT_FORMAT_OPTIONS = pytest.mark.parametrize(
500 493
         "export_options",
501 494
         [
502 495
             [],
503 496
             ["--export-as=sh"],
504 497
         ],
498
+        ids=["json-format", "sh-format"],
505 499
     )
506 500
     KEY_INDEX = pytest.mark.parametrize(
507 501
         "key_index", [1, 2, 3], ids=lambda i: f"index{i}"
... ...
@@ -545,6 +539,45 @@ class Parametrize(types.SimpleNamespace):
545 539
                 ),
546 540
                 id="service",
547 541
             ),
542
+            pytest.param(
543
+                textwrap.dedent(r"""
544
+                [vault]
545
+                default-unicode-normalization-form = 'XXX'
546
+                """),
547
+                ["--config", "--phrase"],
548
+                DUMMY_PASSPHRASE,
549
+                (
550
+                    "Invalid value 'XXX' for config key "
551
+                    "vault.default-unicode-normalization-form"
552
+                ),
553
+                id="configure global passphrase",
554
+            ),
555
+            pytest.param(
556
+                textwrap.dedent(r"""
557
+                [vault]
558
+                default-unicode-normalization-form = 'XXX'
559
+                """),
560
+                ["--config", "--phrase", "--", DUMMY_SERVICE],
561
+                DUMMY_PASSPHRASE,
562
+                (
563
+                    "Invalid value 'XXX' for config key "
564
+                    "vault.default-unicode-normalization-form"
565
+                ),
566
+                id="configure service passphrase",
567
+            ),
568
+            pytest.param(
569
+                textwrap.dedent(r"""
570
+                [vault]
571
+                default-unicode-normalization-form = 'XXX'
572
+                """),
573
+                ["--phrase", "--", DUMMY_SERVICE],
574
+                DUMMY_PASSPHRASE,
575
+                (
576
+                    "Invalid value 'XXX' for config key "
577
+                    "vault.default-unicode-normalization-form"
578
+                ),
579
+                id="interactive passphrase",
580
+            ),
548 581
         ],
549 582
     )
550 583
     UNICODE_NORMALIZATION_WARNING_INPUTS = pytest.mark.parametrize(
... ...
@@ -733,14 +766,7 @@ class TestHelp:
733 766
 class TestDerivedPassphraseConstraints:
734 767
     """Tests for (derived) passphrase constraints."""
735 768
 
736
-    @Parametrize.CHARSET_NAME
737
-    def test_disable_character_set(
738
-        self,
739
-        charset_name: str,
740
-    ) -> None:
741
-        """Named character classes can be disabled on the command-line."""
742
-        option = f"--{charset_name}"
743
-        charset = vault.Vault.CHARSETS[charset_name].decode("ascii")
769
+    def _test(self, command_line: list[str]) -> machinery.ReadableResult:
744 770
         runner = machinery.CliRunner(mix_stderr=False)
745 771
         # TODO(the-13th-letter): Rewrite using parenthesized
746 772
         # with-statements.
... ...
@@ -758,12 +784,22 @@ class TestDerivedPassphraseConstraints:
758 784
                 "prompt_for_passphrase",
759 785
                 callables.auto_prompt,
760 786
             )
761
-            result = runner.invoke(
787
+            return runner.invoke(
762 788
                 cli.derivepassphrase_vault,
763
-                [option, "0", "-p", "--", DUMMY_SERVICE],
789
+                command_line,
764 790
                 input=DUMMY_PASSPHRASE,
765 791
                 catch_exceptions=False,
766 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])
767 803
         assert result.clean_exit(empty_stderr=True), "expected clean exit:"
768 804
         for c in charset:
769 805
             assert c not in result.stdout, (
... ...
@@ -774,29 +810,7 @@ class TestDerivedPassphraseConstraints:
774 810
         self,
775 811
     ) -> None:
776 812
         """Character repetition can be disabled on the command-line."""
777
-        runner = machinery.CliRunner(mix_stderr=False)
778
-        # TODO(the-13th-letter): Rewrite using parenthesized
779
-        # with-statements.
780
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
781
-        with contextlib.ExitStack() as stack:
782
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
783
-            stack.enter_context(
784
-                pytest_machinery.isolated_config(
785
-                    monkeypatch=monkeypatch,
786
-                    runner=runner,
787
-                )
788
-            )
789
-            monkeypatch.setattr(
790
-                cli_helpers,
791
-                "prompt_for_passphrase",
792
-                callables.auto_prompt,
793
-            )
794
-            result = runner.invoke(
795
-                cli.derivepassphrase_vault,
796
-                ["--repeat", "0", "-p", "--", DUMMY_SERVICE],
797
-                input=DUMMY_PASSPHRASE,
798
-                catch_exceptions=False,
799
-            )
813
+        result = self._test(["--repeat", "0", "-p", "--", DUMMY_SERVICE])
800 814
         assert result.clean_exit(empty_stderr=True), (
801 815
             "expected clean exit and empty stderr"
802 816
         )
... ...
@@ -811,12 +825,17 @@ class TestDerivedPassphraseConstraints:
811 825
 class TestPhraseBasic:
812 826
     """Tests for master passphrase configuration: basic."""
813 827
 
814
-    @Parametrize.CONFIG_WITH_PHRASE
815
-    def test_phrase_from_config(
828
+    def _test(
816 829
         self,
817
-        config: _types.VaultConfig,
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,
818 838
     ) -> None:
819
-        """A stored configured master passphrase will be used."""
820 839
         runner = machinery.CliRunner(mix_stderr=False)
821 840
         # TODO(the-13th-letter): Rewrite using parenthesized
822 841
         # with-statements.
... ...
@@ -837,72 +856,69 @@ class TestPhraseBasic:
837 856
             monkeypatch.setattr(
838 857
                 vault.Vault, "phrase_from_key", phrase_from_key
839 858
             )
859
+            if auto_prompt:
860
+                monkeypatch.setattr(
861
+                    cli_helpers,
862
+                    "prompt_for_passphrase",
863
+                    callables.auto_prompt,
864
+                )
840 865
             result = runner.invoke(
841 866
                 cli.derivepassphrase_vault,
842
-                ["--", DUMMY_SERVICE],
867
+                command_line,
868
+                input=None if auto_prompt else DUMMY_PASSPHRASE,
843 869
                 catch_exceptions=False,
844 870
             )
871
+        if multiline:
872
+            assert result.clean_exit(), "expected clean exit"
873
+        else:
845 874
             assert result.clean_exit(empty_stderr=True), (
846 875
                 "expected clean exit and empty stderr"
847 876
             )
848
-        assert result.stdout
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
+        )
849 883
         assert (
850
-            result.stdout.rstrip("\n").encode("UTF-8")
851
-            == DUMMY_RESULT_PASSPHRASE
884
+            last_line.rstrip("\n").encode("UTF-8") == DUMMY_RESULT_PASSPHRASE
852 885
         ), "expected known output"
853 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
+
854 895
     @Parametrize.AUTO_PROMPT
855 896
     def test_phrase_from_command_line(
856 897
         self,
857 898
         auto_prompt: bool,
858 899
     ) -> None:
859 900
         """A master passphrase requested on the command-line will be used."""
860
-        runner = machinery.CliRunner(mix_stderr=False)
861
-        # TODO(the-13th-letter): Rewrite using parenthesized
862
-        # with-statements.
863
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
864
-        with contextlib.ExitStack() as stack:
865
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
866
-            stack.enter_context(
867
-                pytest_machinery.isolated_vault_config(
868
-                    monkeypatch=monkeypatch,
869
-                    runner=runner,
870
-                    vault_config={
871
-                        "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}
872
-                    },
873
-                )
874
-            )
875
-            if auto_prompt:
876
-                monkeypatch.setattr(
877
-                    cli_helpers,
878
-                    "prompt_for_passphrase",
879
-                    callables.auto_prompt,
880
-                )
881
-            result = runner.invoke(
882
-                cli.derivepassphrase_vault,
901
+        self._test(
883 902
             ["-p", "--", DUMMY_SERVICE],
884
-                input=None if auto_prompt else DUMMY_PASSPHRASE,
885
-                catch_exceptions=False,
903
+            auto_prompt=auto_prompt,
904
+            multiline=True,
886 905
         )
887
-        assert result.clean_exit(), "expected clean exit"
888
-        assert result.stdout, "expected program output"
889
-        last_line = result.stdout.splitlines(True)[-1]
890
-        assert (
891
-            last_line.rstrip("\n").encode("UTF-8") == DUMMY_RESULT_PASSPHRASE
892
-        ), "expected known output"
893 906
 
894 907
 
895 908
 class TestKeyBasic:
896 909
     """Tests for SSH key configuration: basic."""
897 910
 
898
-    @Parametrize.CONFIG_WITH_KEY
899
-    def test_key_from_config(
911
+    def _test(
900 912
         self,
901
-        running_ssh_agent: data.RunningSSHAgentInfo,
902
-        config: _types.VaultConfig,
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,
903 921
     ) -> None:
904
-        """A stored configured SSH key will be used."""
905
-        del running_ssh_agent
906 922
         runner = machinery.CliRunner(mix_stderr=False)
907 923
         # TODO(the-13th-letter): Rewrite using parenthesized
908 924
         # with-statements.
... ...
@@ -916,49 +932,6 @@ class TestKeyBasic:
916 932
                     vault_config=config,
917 933
                 )
918 934
             )
919
-            monkeypatch.setattr(
920
-                vault.Vault,
921
-                "phrase_from_key",
922
-                callables.phrase_from_key,
923
-            )
924
-            result = runner.invoke(
925
-                cli.derivepassphrase_vault,
926
-                ["--", DUMMY_SERVICE],
927
-                catch_exceptions=False,
928
-            )
929
-        assert result.clean_exit(empty_stderr=True), (
930
-            "expected clean exit and empty stderr"
931
-        )
932
-        assert result.stdout
933
-        assert (
934
-            result.stdout.rstrip("\n").encode("UTF-8")
935
-            != DUMMY_RESULT_PASSPHRASE
936
-        ), "known false output: phrase-based instead of key-based"
937
-        assert (
938
-            result.stdout.rstrip("\n").encode("UTF-8") == DUMMY_RESULT_KEY1
939
-        ), "expected known output"
940
-
941
-    def test_key_from_command_line(
942
-        self,
943
-        running_ssh_agent: data.RunningSSHAgentInfo,
944
-    ) -> None:
945
-        """An SSH key requested on the command-line will be used."""
946
-        del running_ssh_agent
947
-        runner = machinery.CliRunner(mix_stderr=False)
948
-        # TODO(the-13th-letter): Rewrite using parenthesized
949
-        # with-statements.
950
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
951
-        with contextlib.ExitStack() as stack:
952
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
953
-            stack.enter_context(
954
-                pytest_machinery.isolated_vault_config(
955
-                    monkeypatch=monkeypatch,
956
-                    runner=runner,
957
-                    vault_config={
958
-                        "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}
959
-                    },
960
-                )
961
-            )
962 935
             monkeypatch.setattr(
963 936
                 cli_helpers,
964 937
                 "get_suitable_ssh_keys",
... ...
@@ -971,13 +944,22 @@ class TestKeyBasic:
971 944
             )
972 945
             result = runner.invoke(
973 946
                 cli.derivepassphrase_vault,
974
-                ["-k", "--", DUMMY_SERVICE],
975
-                input="1\n",
947
+                command_line,
948
+                input=input,
976 949
                 catch_exceptions=False,
977 950
             )
951
+        if multiline:
978 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
+            )
979 957
         assert result.stdout, "expected program output"
980
-        last_line = result.stdout.splitlines(True)[-1]
958
+        last_line = (
959
+            result.stdout.splitlines(keepends=True)[-1]
960
+            if multiline
961
+            else result.stdout
962
+        )
981 963
         assert (
982 964
             last_line.rstrip("\n").encode("UTF-8") != DUMMY_RESULT_PASSPHRASE
983 965
         ), "known false output: phrase-based instead of key-based"
... ...
@@ -985,20 +967,38 @@ class TestKeyBasic:
985 967
             "expected known output"
986 968
         )
987 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)
988 979
 
989
-class TestPhraseAndKeyOverriding:
990
-    """Tests for master passphrase and SSH key configuration: overriding."""
991
-
992
-    @Parametrize.BASE_CONFIG_WITH_KEY_VARIATIONS
993
-    @Parametrize.KEY_INDEX
994
-    def test_key_override_on_command_line(
980
+    def test_key_from_command_line(
995 981
         self,
996 982
         running_ssh_agent: data.RunningSSHAgentInfo,
997
-        config: dict[str, Any],
998
-        key_index: int,
999 983
     ) -> None:
1000
-        """A command-line SSH key will override the configured key."""
984
+        """An SSH key requested on the command-line will be used."""
1001 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 1002
         runner = machinery.CliRunner(mix_stderr=False)
1003 1003
         # TODO(the-13th-letter): Rewrite using parenthesized
1004 1004
         # with-statements.
... ...
@@ -1022,12 +1022,30 @@ class TestPhraseAndKeyOverriding:
1022 1022
             )
1023 1023
             result = runner.invoke(
1024 1024
                 cli.derivepassphrase_vault,
1025
-                ["-k", "--", DUMMY_SERVICE],
1026
-                input=f"{key_index}\n",
1025
+                command_line,
1026
+                input=input,
1027
+                catch_exceptions=False,
1027 1028
             )
1028 1029
         assert result.clean_exit(), "expected clean exit"
1029
-        assert result.stdout, "expected program output"
1030
-        assert result.stderr, "expected stderr"
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"
1031 1049
         assert "Error:" not in result.stderr, (
1032 1050
             "expected no error messages on stderr"
1033 1051
         )
... ...
@@ -1036,19 +1054,11 @@ class TestPhraseAndKeyOverriding:
1036 1054
         self,
1037 1055
         running_ssh_agent: data.RunningSSHAgentInfo,
1038 1056
     ) -> None:
1039
-        """A command-line passphrase will override the configured key."""
1057
+        """A configured passphrase does not override a configured key."""
1040 1058
         del running_ssh_agent
1041
-        runner = machinery.CliRunner(mix_stderr=False)
1042
-        # TODO(the-13th-letter): Rewrite using parenthesized
1043
-        # with-statements.
1044
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1045
-        with contextlib.ExitStack() as stack:
1046
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1047
-            stack.enter_context(
1048
-                pytest_machinery.isolated_vault_config(
1049
-                    monkeypatch=monkeypatch,
1050
-                    runner=runner,
1051
-                    vault_config={
1059
+        result = self._test(
1060
+            ["--", DUMMY_SERVICE],
1061
+            config={
1052 1062
                 "global": {"key": DUMMY_KEY1_B64},
1053 1063
                 "services": {
1054 1064
                     DUMMY_SERVICE: {
... ...
@@ -1058,21 +1068,6 @@ class TestPhraseAndKeyOverriding:
1058 1068
                 },
1059 1069
             },
1060 1070
         )
1061
-            )
1062
-            monkeypatch.setattr(
1063
-                ssh_agent.SSHAgentClient,
1064
-                "list_keys",
1065
-                callables.list_keys,
1066
-            )
1067
-            monkeypatch.setattr(
1068
-                ssh_agent.SSHAgentClient, "sign", callables.sign
1069
-            )
1070
-            result = runner.invoke(
1071
-                cli.derivepassphrase_vault,
1072
-                ["--", DUMMY_SERVICE],
1073
-                catch_exceptions=False,
1074
-            )
1075
-        assert result.clean_exit(), "expected clean exit"
1076 1071
         assert result.stdout, "expected program output"
1077 1072
         last_line = result.stdout.splitlines(True)[-1]
1078 1073
         assert (
... ...
@@ -1092,34 +1087,9 @@ class TestPhraseAndKeyOverriding:
1092 1087
     ) -> None:
1093 1088
         """Configuring a passphrase atop an SSH key works, but warns."""
1094 1089
         del running_ssh_agent
1095
-        runner = machinery.CliRunner(mix_stderr=False)
1096
-        # TODO(the-13th-letter): Rewrite using parenthesized
1097
-        # with-statements.
1098
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1099
-        with contextlib.ExitStack() as stack:
1100
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1101
-            stack.enter_context(
1102
-                pytest_machinery.isolated_vault_config(
1103
-                    monkeypatch=monkeypatch,
1104
-                    runner=runner,
1105
-                    vault_config=config,
1106
-                )
1107
-            )
1108
-            monkeypatch.setattr(
1109
-                ssh_agent.SSHAgentClient,
1110
-                "list_keys",
1111
-                callables.list_keys,
1112
-            )
1113
-            monkeypatch.setattr(
1114
-                ssh_agent.SSHAgentClient, "sign", callables.sign
1115
-            )
1116
-            result = runner.invoke(
1117
-                cli.derivepassphrase_vault,
1118
-                command_line,
1119
-                input=DUMMY_PASSPHRASE,
1120
-                catch_exceptions=False,
1090
+        result = self._test(
1091
+            command_line, config=config, input=DUMMY_PASSPHRASE
1121 1092
         )
1122
-        assert result.clean_exit(), "expected clean exit"
1123 1093
         assert not result.stdout.strip(), "expected no program output"
1124 1094
         assert result.stderr, "expected known error output"
1125 1095
         err_lines = result.stderr.splitlines(False)
... ...
@@ -1140,12 +1110,18 @@ class TestPhraseAndKeyOverriding:
1140 1110
 class TestInvalidCommandLines:
1141 1111
     """Tests concerning invalid command-lines."""
1142 1112
 
1143
-    @Parametrize.VAULT_CHARSET_OPTION
1144
-    def test_invalid_argument_range(
1113
+    @contextlib.contextmanager
1114
+    def _setup_environment(
1145 1115
         self,
1146
-        option: str,
1147
-    ) -> None:
1148
-        """Requesting invalidly many characters from a class fails."""
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]:
1149 1125
         runner = machinery.CliRunner(mix_stderr=False)
1150 1126
         # TODO(the-13th-letter): Rewrite using parenthesized
1151 1127
         # with-statements.
... ...
@@ -1153,11 +1129,57 @@ class TestInvalidCommandLines:
1153 1129
         with contextlib.ExitStack() as stack:
1154 1130
             monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1155 1131
             stack.enter_context(
1156
-                pytest_machinery.isolated_config(
1132
+                pytest_machinery.isolated_vault_config(
1157 1133
                     monkeypatch=monkeypatch,
1158 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,
1159 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,
1160 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:
1161 1183
             for value in "-42", "invalid":
1162 1184
                 result = runner.invoke(
1163 1185
                     cli.derivepassphrase_vault,
... ...
@@ -1178,29 +1200,14 @@ class TestInvalidCommandLines:
1178 1200
         check_success: bool,
1179 1201
     ) -> None:
1180 1202
         """We require or forbid a service argument, depending on options."""
1181
-        runner = machinery.CliRunner(mix_stderr=False)
1182
-        # TODO(the-13th-letter): Rewrite using parenthesized
1183
-        # with-statements.
1184
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1185
-        with contextlib.ExitStack() as stack:
1186
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1187
-            stack.enter_context(
1188
-                pytest_machinery.isolated_vault_config(
1189
-                    monkeypatch=monkeypatch,
1190
-                    runner=runner,
1191
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
1192
-                )
1193
-            )
1194
-            monkeypatch.setattr(
1195
-                cli_helpers,
1196
-                "prompt_for_passphrase",
1197
-                callables.auto_prompt,
1198
-            )
1199
-            result = runner.invoke(
1200
-                cli.derivepassphrase_vault,
1203
+        config: _types.VaultConfig = {
1204
+            "global": {"phrase": "abc"},
1205
+            "services": {},
1206
+        }
1207
+        result = self._call(
1201 1208
             options if service else [*options, "--", DUMMY_SERVICE],
1209
+            config=config,
1202 1210
             input=input,
1203
-                catch_exceptions=False,
1204 1211
         )
1205 1212
         if service is not None:
1206 1213
             err_msg = (
... ...
@@ -1211,37 +1218,16 @@ class TestInvalidCommandLines:
1211 1218
             assert result.error_exit(error=err_msg), (
1212 1219
                 "expected error exit and known error message"
1213 1220
             )
1214
-            else:
1215
-                assert result.clean_exit(empty_stderr=True), (
1216
-                    "expected clean exit"
1217
-                )
1218 1221
             if check_success:
1219
-            # TODO(the-13th-letter): Rewrite using parenthesized
1220
-            # with-statements.
1221
-            # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1222
-            with contextlib.ExitStack() as stack:
1223
-                monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1224
-                stack.enter_context(
1225
-                    pytest_machinery.isolated_vault_config(
1226
-                        monkeypatch=monkeypatch,
1227
-                        runner=runner,
1228
-                        vault_config={
1229
-                            "global": {"phrase": "abc"},
1230
-                            "services": {},
1231
-                        },
1232
-                    )
1233
-                )
1234
-                monkeypatch.setattr(
1235
-                    cli_helpers,
1236
-                    "prompt_for_passphrase",
1237
-                    callables.auto_prompt,
1238
-                )
1239
-                result = runner.invoke(
1240
-                    cli.derivepassphrase_vault,
1222
+                result = self._call(
1241 1223
                     [*options, "--", DUMMY_SERVICE] if service else options,
1224
+                    config=config,
1242 1225
                     input=input,
1243
-                    catch_exceptions=False,
1244 1226
                 )
1227
+                assert result.clean_exit(empty_stderr=True), (
1228
+                    "expected clean exit"
1229
+                )
1230
+        else:
1245 1231
             assert result.clean_exit(empty_stderr=True), "expected clean exit"
1246 1232
 
1247 1233
     def test_empty_service_name_causes_warning(
... ...
@@ -1261,50 +1247,31 @@ class TestInvalidCommandLines:
1261 1247
                 "An empty SERVICE is not supported by vault(1)", [record]
1262 1248
             )
1263 1249
 
1264
-        runner = machinery.CliRunner(mix_stderr=False)
1265
-        # TODO(the-13th-letter): Rewrite using parenthesized
1266
-        # with-statements.
1267
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1268
-        with contextlib.ExitStack() as stack:
1269
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1270
-            stack.enter_context(
1271
-                pytest_machinery.isolated_vault_config(
1272
-                    monkeypatch=monkeypatch,
1273
-                    runner=runner,
1274
-                    vault_config={"services": {}},
1275
-                )
1276
-            )
1277
-            monkeypatch.setattr(
1278
-                cli_helpers,
1279
-                "prompt_for_passphrase",
1280
-                callables.auto_prompt,
1281
-            )
1282
-            result = runner.invoke(
1283
-                cli.derivepassphrase_vault,
1284
-                ["--config", "--length=30", "--", ""],
1285
-                catch_exceptions=False,
1286
-            )
1250
+        def check_result(result: machinery.ReadableResult) -> None:
1287 1251
             assert result.clean_exit(empty_stderr=False), "expected clean exit"
1288 1252
             assert result.stderr is not None, "expected known error output"
1289 1253
             assert all(map(is_expected_warning, caplog.record_tuples)), (
1290 1254
                 "expected known error output"
1291 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)
1292 1264
             assert cli_helpers.load_config() == {
1293 1265
                 "global": {"length": 30},
1294 1266
                 "services": {},
1295 1267
             }, "requested configuration change was not applied"
1296 1268
             caplog.clear()
1297
-            result = runner.invoke(
1298
-                cli.derivepassphrase_vault,
1269
+            result = self._call(
1299 1270
                 ["--import", "-"],
1300 1271
                 input=json.dumps({"services": {"": {"length": 40}}}),
1301
-                catch_exceptions=False,
1302
-            )
1303
-            assert result.clean_exit(empty_stderr=False), "expected clean exit"
1304
-            assert result.stderr is not None, "expected known error output"
1305
-            assert all(map(is_expected_warning, caplog.record_tuples)), (
1306
-                "expected known error output"
1272
+                runner=runner,
1307 1273
             )
1274
+            check_result(result)
1308 1275
             assert cli_helpers.load_config() == {
1309 1276
                 "global": {"length": 30},
1310 1277
                 "services": {"": {"length": 40}},
... ...
@@ -1317,23 +1284,9 @@ class TestInvalidCommandLines:
1317 1284
         service: bool | None,
1318 1285
     ) -> None:
1319 1286
         """Incompatible options are detected."""
1320
-        runner = machinery.CliRunner(mix_stderr=False)
1321
-        # TODO(the-13th-letter): Rewrite using parenthesized
1322
-        # with-statements.
1323
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1324
-        with contextlib.ExitStack() as stack:
1325
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1326
-            stack.enter_context(
1327
-                pytest_machinery.isolated_config(
1328
-                    monkeypatch=monkeypatch,
1329
-                    runner=runner,
1330
-                )
1331
-            )
1332
-            result = runner.invoke(
1333
-                cli.derivepassphrase_vault,
1287
+        result = self._call(
1334 1288
             [*options, "--", DUMMY_SERVICE] if service else options,
1335 1289
             input=DUMMY_PASSPHRASE,
1336
-                catch_exceptions=False,
1337 1290
         )
1338 1291
         assert result.error_exit(error="mutually exclusive with "), (
1339 1292
             "expected error exit and known error message"
... ...
@@ -1341,21 +1294,7 @@ class TestInvalidCommandLines:
1341 1294
 
1342 1295
     def test_no_arguments(self) -> None:
1343 1296
         """Calling `derivepassphrase vault` without any arguments fails."""
1344
-        runner = machinery.CliRunner(mix_stderr=False)
1345
-        # TODO(the-13th-letter): Rewrite using parenthesized
1346
-        # with-statements.
1347
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1348
-        with contextlib.ExitStack() as stack:
1349
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1350
-            stack.enter_context(
1351
-                pytest_machinery.isolated_config(
1352
-                    monkeypatch=monkeypatch,
1353
-                    runner=runner,
1354
-                )
1355
-            )
1356
-            result = runner.invoke(
1357
-                cli.derivepassphrase_vault, [], catch_exceptions=False
1358
-            )
1297
+        result = self._call([], input=DUMMY_PASSPHRASE)
1359 1298
         assert result.error_exit(
1360 1299
             error="Deriving a passphrase requires a SERVICE"
1361 1300
         ), "expected error exit and known error message"
... ...
@@ -1364,23 +1303,7 @@ class TestInvalidCommandLines:
1364 1303
         self,
1365 1304
     ) -> None:
1366 1305
         """Deriving a passphrase without a passphrase or key fails."""
1367
-        runner = machinery.CliRunner(mix_stderr=False)
1368
-        # TODO(the-13th-letter): Rewrite using parenthesized
1369
-        # with-statements.
1370
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1371
-        with contextlib.ExitStack() as stack:
1372
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1373
-            stack.enter_context(
1374
-                pytest_machinery.isolated_config(
1375
-                    monkeypatch=monkeypatch,
1376
-                    runner=runner,
1377
-                )
1378
-            )
1379
-            result = runner.invoke(
1380
-                cli.derivepassphrase_vault,
1381
-                ["--", DUMMY_SERVICE],
1382
-                catch_exceptions=False,
1383
-            )
1306
+        result = self._call(["--", DUMMY_SERVICE], input=DUMMY_PASSPHRASE)
1384 1307
         assert result.error_exit(error="No passphrase or key was given"), (
1385 1308
             "expected error exit and known error message"
1386 1309
         )
... ...
@@ -1389,13 +1312,15 @@ class TestInvalidCommandLines:
1389 1312
 class TestImportConfigValid:
1390 1313
     """Tests concerning `vault` configuration imports: valid imports."""
1391 1314
 
1392
-    @Parametrize.VALID_TEST_CONFIGS
1393
-    def test_import_config(
1315
+    def _test(
1394 1316
         self,
1317
+        /,
1318
+        *,
1395 1319
         caplog: pytest.LogCaptureFixture,
1396
-        config: Any,
1320
+        config: _types.VaultConfig,
1397 1321
     ) -> None:
1398
-        """Importing a configuration works."""
1322
+        config2 = copy.deepcopy(config)
1323
+        _types.clean_up_falsy_vault_config_values(config2)
1399 1324
         runner = machinery.CliRunner(mix_stderr=False)
1400 1325
         # TODO(the-13th-letter): Rewrite using parenthesized
1401 1326
         # with-statements.
... ...
@@ -1418,14 +1343,23 @@ class TestImportConfigValid:
1418 1343
             config_txt = cli_helpers.config_filename(
1419 1344
                 subsystem="vault"
1420 1345
             ).read_text(encoding="UTF-8")
1421
-            config2 = json.loads(config_txt)
1346
+            config3 = json.loads(config_txt)
1422 1347
         assert result.clean_exit(empty_stderr=False), "expected clean exit"
1423
-        assert config2 == config, "config not imported correctly"
1424
-        assert not result.stderr or all(  # pragma: no branch
1348
+        assert config3 == config2, "config not imported correctly"
1349
+        assert not result.stderr or all(
1425 1350
             map(is_harmless_config_import_warning, caplog.record_tuples)
1426 1351
         ), "unexpected error output"
1427 1352
         assert_vault_config_is_indented_and_line_broken(config_txt)
1428 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
+
1429 1363
     @hypothesis.settings(
1430 1364
         suppress_health_check=[
1431 1365
             *hypothesis.settings().suppress_health_check,
... ...
@@ -1439,7 +1373,7 @@ class TestImportConfigValid:
1439 1373
             ])
1440 1374
         )
1441 1375
     )
1442
-    def test_import_smudged_config(
1376
+    def test_smudged_config(
1443 1377
         self,
1444 1378
         caplog: pytest.LogCaptureFixture,
1445 1379
         conf: data.VaultTestConfig,
... ...
@@ -1449,49 +1383,16 @@ class TestImportConfigValid:
1449 1383
         Tested via hypothesis.
1450 1384
 
1451 1385
         """
1452
-        config = conf.config
1453
-        config2 = copy.deepcopy(config)
1454
-        _types.clean_up_falsy_vault_config_values(config2)
1455 1386
         # Reset caplog between hypothesis runs.
1456 1387
         caplog.clear()
1457
-        runner = machinery.CliRunner(mix_stderr=False)
1458
-        # TODO(the-13th-letter): Rewrite using parenthesized
1459
-        # with-statements.
1460
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1461
-        with contextlib.ExitStack() as stack:
1462
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1463
-            stack.enter_context(
1464
-                pytest_machinery.isolated_vault_config(
1465
-                    monkeypatch=monkeypatch,
1466
-                    runner=runner,
1467
-                    vault_config={"services": {}},
1468
-                )
1469
-            )
1470
-            result = runner.invoke(
1471
-                cli.derivepassphrase_vault,
1472
-                ["--import", "-"],
1473
-                input=json.dumps(config),
1474
-                catch_exceptions=False,
1475
-            )
1476
-            config_txt = cli_helpers.config_filename(
1477
-                subsystem="vault"
1478
-            ).read_text(encoding="UTF-8")
1479
-            config3 = json.loads(config_txt)
1480
-        assert result.clean_exit(empty_stderr=False), "expected clean exit"
1481
-        assert config3 == config2, "config not imported correctly"
1482
-        assert not result.stderr or all(
1483
-            map(is_harmless_config_import_warning, caplog.record_tuples)
1484
-        ), "unexpected error output"
1485
-        assert_vault_config_is_indented_and_line_broken(config_txt)
1388
+        self._test(caplog=caplog, config=conf.config)
1486 1389
 
1487 1390
 
1488 1391
 class TestImportConfigInvalid:
1489 1392
     """Tests concerning `vault` configuration imports: invalid imports."""
1490 1393
 
1491
-    def test_import_config_not_a_vault_config(
1492
-        self,
1493
-    ) -> None:
1494
-        """Importing an invalid config fails."""
1394
+    @contextlib.contextmanager
1395
+    def _setup_environment(self) -> Iterator[machinery.CliRunner]:
1495 1396
         runner = machinery.CliRunner(mix_stderr=False)
1496 1397
         # TODO(the-13th-letter): Rewrite using parenthesized
1497 1398
         # with-statements.
... ...
@@ -1504,64 +1405,52 @@ class TestImportConfigInvalid:
1504 1405
                     runner=runner,
1505 1406
                 )
1506 1407
             )
1507
-            result = runner.invoke(
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(
1508 1419
                 cli.derivepassphrase_vault,
1509
-                ["--import", "-"],
1510
-                input="null",
1420
+                command_line,
1421
+                input=input,
1511 1422
                 catch_exceptions=False,
1512 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")
1513 1430
         assert result.error_exit(error="Invalid vault config"), (
1514 1431
             "expected error exit and known error message"
1515 1432
         )
1516 1433
 
1517
-    def test_import_config_not_json_data(
1434
+    def test_not_json_data(
1518 1435
         self,
1519 1436
     ) -> None:
1520 1437
         """Importing an invalid config fails."""
1521
-        runner = machinery.CliRunner(mix_stderr=False)
1522
-        # TODO(the-13th-letter): Rewrite using parenthesized
1523
-        # with-statements.
1524
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1525
-        with contextlib.ExitStack() as stack:
1526
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1527
-            stack.enter_context(
1528
-                pytest_machinery.isolated_config(
1529
-                    monkeypatch=monkeypatch,
1530
-                    runner=runner,
1531
-                )
1532
-            )
1533
-            result = runner.invoke(
1534
-                cli.derivepassphrase_vault,
1535
-                ["--import", "-"],
1536
-                input="This string is not valid JSON.",
1537
-                catch_exceptions=False,
1438
+        result = self._test(
1439
+            ["--import", "-"], input="This string is not valid JSON."
1538 1440
         )
1539 1441
         assert result.error_exit(error="cannot decode JSON"), (
1540 1442
             "expected error exit and known error message"
1541 1443
         )
1542 1444
 
1543
-    def test_import_config_not_a_file(
1445
+    def test_not_a_file(
1544 1446
         self,
1545 1447
     ) -> None:
1546 1448
         """Importing an invalid config fails."""
1547
-        runner = machinery.CliRunner(mix_stderr=False)
1548
-        # `isolated_vault_config` ensures the configuration is valid
1549
-        # JSON.  So, to pass an actual broken configuration, we must
1550
-        # open the configuration file ourselves afterwards, inside the
1551
-        # context.
1552
-        #
1553
-        # TODO(the-13th-letter): Rewrite using parenthesized
1554
-        # with-statements.
1555
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1556
-        with contextlib.ExitStack() as stack:
1557
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1558
-            stack.enter_context(
1559
-                pytest_machinery.isolated_vault_config(
1560
-                    monkeypatch=monkeypatch,
1561
-                    runner=runner,
1562
-                    vault_config={"services": {}},
1563
-                )
1564
-            )
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.
1565 1454
             cli_helpers.config_filename(subsystem="vault").write_text(
1566 1455
                 "This string is not valid JSON.\n", encoding="UTF-8"
1567 1456
             )
... ...
@@ -1582,13 +1471,28 @@ class TestImportConfigInvalid:
1582 1471
 class TestExportConfigValid:
1583 1472
     """Tests concerning `vault` configuration exports: valid exports."""
1584 1473
 
1585
-    @Parametrize.VALID_TEST_CONFIGS
1586
-    def test_export_config_success(
1474
+    def _assert_result(
1587 1475
         self,
1476
+        result: machinery.ReadableResult,
1477
+        /,
1478
+        *,
1588 1479
         caplog: pytest.LogCaptureFixture,
1589
-        config: Any,
1590 1480
     ) -> None:
1591
-        """Exporting a configuration works."""
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)
1592 1496
         runner = machinery.CliRunner(mix_stderr=False)
1593 1497
         # TODO(the-13th-letter): Rewrite using parenthesized
1594 1498
         # with-statements.
... ...
@@ -1599,9 +1503,18 @@ class TestExportConfigValid:
1599 1503
                 pytest_machinery.isolated_vault_config(
1600 1504
                     monkeypatch=monkeypatch,
1601 1505
                     runner=runner,
1602
-                    vault_config=config,
1506
+                    vault_config={"services": {}},
1603 1507
                 )
1604 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:
1605 1518
                 with cli_helpers.config_filename(subsystem="vault").open(
1606 1519
                     "w", encoding="UTF-8"
1607 1520
                 ) as outfile:
... ...
@@ -1612,17 +1525,20 @@ class TestExportConfigValid:
1612 1525
                 ["--export", "-"],
1613 1526
                 catch_exceptions=False,
1614 1527
             )
1615
-            with cli_helpers.config_filename(subsystem="vault").open(
1616
-                encoding="UTF-8"
1617
-            ) as infile:
1618
-                config2 = json.load(infile)
1619
-        assert result.clean_exit(empty_stderr=False), "expected clean exit"
1620
-        assert config2 == config, "config not imported correctly"
1621
-        assert not result.stderr or all(  # pragma: no branch
1622
-            map(is_harmless_config_import_warning, caplog.record_tuples)
1623
-        ), "unexpected error output"
1528
+        self._assert_result(result, caplog=caplog)
1529
+        config3 = json.loads(result.stdout)
1530
+        assert config3 == config2, "config not exported correctly"
1624 1531
         assert_vault_config_is_indented_and_line_broken(result.stdout)
1625 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
+
1626 1542
     @hypothesis.settings(
1627 1543
         suppress_health_check=[
1628 1544
             *hypothesis.settings().suppress_health_check,
... ...
@@ -1646,52 +1562,12 @@ class TestExportConfigValid:
1646 1562
         Tested via hypothesis.
1647 1563
 
1648 1564
         """
1649
-        config = conf.config
1650
-        config2 = copy.deepcopy(config)
1651
-        _types.clean_up_falsy_vault_config_values(config2)
1652 1565
         # Reset caplog between hypothesis runs.
1653 1566
         caplog.clear()
1654
-        runner = machinery.CliRunner(mix_stderr=False)
1655
-        # TODO(the-13th-letter): Rewrite using parenthesized
1656
-        # with-statements.
1657
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1658
-        with contextlib.ExitStack() as stack:
1659
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1660
-            stack.enter_context(
1661
-                pytest_machinery.isolated_vault_config(
1662
-                    monkeypatch=monkeypatch,
1663
-                    runner=runner,
1664
-                    vault_config={"services": {}},
1665
-                )
1666
-            )
1667
-            result1 = runner.invoke(
1668
-                cli.derivepassphrase_vault,
1669
-                ["--import", "-"],
1670
-                input=json.dumps(config),
1671
-                catch_exceptions=False,
1672
-            )
1673
-            assert result1.clean_exit(empty_stderr=False), (
1674
-                "expected clean exit"
1675
-            )
1676
-            assert not result1.stderr or all(
1677
-                map(is_harmless_config_import_warning, caplog.record_tuples)
1678
-            ), "unexpected error output"
1679
-            result2 = runner.invoke(
1680
-                cli.derivepassphrase_vault,
1681
-                ["--export", "-"],
1682
-                catch_exceptions=False,
1683
-            )
1684
-            assert result2.clean_exit(empty_stderr=False), (
1685
-                "expected clean exit"
1686
-            )
1687
-            assert not result2.stderr or all(
1688
-                map(is_harmless_config_import_warning, caplog.record_tuples)
1689
-            ), "unexpected error output"
1690
-            config3 = json.loads(result2.stdout)
1691
-            assert config3 == config2, "config not exported correctly"
1567
+        self._test(caplog=caplog, config=conf.config, use_import=True)
1692 1568
 
1693 1569
     @Parametrize.EXPORT_FORMAT_OPTIONS
1694
-    def test_export_config_no_stored_settings(
1570
+    def test_no_stored_settings(
1695 1571
         self,
1696 1572
         export_options: list[str],
1697 1573
     ) -> None:
... ...
@@ -1721,17 +1597,23 @@ class TestExportConfigValid:
1721 1597
                 catch_exceptions=False,
1722 1598
             )
1723 1599
         assert result.clean_exit(empty_stderr=True), "expected clean exit"
1600
+        assert result.stdout.startswith("#!") or json.loads(result.stdout) == {
1601
+            "services": {}
1602
+        }
1724 1603
 
1725 1604
 
1726 1605
 class TestExportConfigInvalid:
1727 1606
     """Tests concerning `vault` configuration exports: invalid exports."""
1728 1607
 
1729
-    @Parametrize.EXPORT_FORMAT_OPTIONS
1730
-    def test_export_config_bad_stored_config(
1608
+    @contextlib.contextmanager
1609
+    def _test(
1731 1610
         self,
1732
-        export_options: list[str],
1733
-    ) -> None:
1734
-        """Exporting an invalid config fails."""
1611
+        command_line: list[str],
1612
+        /,
1613
+        *,
1614
+        config: _types.VaultConfig = {"services": {}},  # noqa: B006
1615
+        error_messages: tuple[str, ...] = (),
1616
+    ) -> Iterator[list[str]]:
1735 1617
         runner = machinery.CliRunner(mix_stderr=False)
1736 1618
         # TODO(the-13th-letter): Rewrite using parenthesized
1737 1619
         # with-statements.
... ...
@@ -1742,261 +1624,219 @@ class TestExportConfigInvalid:
1742 1624
                 pytest_machinery.isolated_vault_config(
1743 1625
                     monkeypatch=monkeypatch,
1744 1626
                     runner=runner,
1745
-                    vault_config={},
1627
+                    vault_config=config,
1746 1628
                 )
1747 1629
             )
1630
+            yield command_line
1748 1631
             result = runner.invoke(
1749 1632
                 cli.derivepassphrase_vault,
1750
-                ["--export", "-", *export_options],
1633
+                command_line,
1751 1634
                 input="null",
1752 1635
                 catch_exceptions=False,
1753 1636
             )
1754
-        assert result.error_exit(error="Cannot load vault settings:"), (
1637
+        assert any([result.error_exit(error=msg) for msg in error_messages]), (
1755 1638
             "expected error exit and known error message"
1756 1639
         )
1757 1640
 
1758 1641
     @Parametrize.EXPORT_FORMAT_OPTIONS
1759
-    def test_export_config_not_a_file(
1642
+    def test_bad_stored_config(
1760 1643
         self,
1761 1644
         export_options: list[str],
1762 1645
     ) -> None:
1763 1646
         """Exporting an invalid config fails."""
1764
-        runner = machinery.CliRunner(mix_stderr=False)
1765
-        # TODO(the-13th-letter): Rewrite using parenthesized
1766
-        # with-statements.
1767
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1768
-        with contextlib.ExitStack() as stack:
1769
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1770
-            stack.enter_context(
1771
-                pytest_machinery.isolated_config(
1772
-                    monkeypatch=monkeypatch,
1773
-                    runner=runner,
1774
-                )
1775
-            )
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
+        ):
1776 1664
             config_file = cli_helpers.config_filename(subsystem="vault")
1777 1665
             config_file.unlink(missing_ok=True)
1778 1666
             config_file.mkdir(parents=True, exist_ok=True)
1779
-            result = runner.invoke(
1780
-                cli.derivepassphrase_vault,
1781
-                ["--export", "-", *export_options],
1782
-                input="null",
1783
-                catch_exceptions=False,
1784
-            )
1785
-        assert result.error_exit(error="Cannot load vault settings:"), (
1786
-            "expected error exit and known error message"
1787
-        )
1788 1667
 
1789 1668
     @Parametrize.EXPORT_FORMAT_OPTIONS
1790
-    def test_export_config_target_not_a_file(
1669
+    def test_target_not_a_file(
1791 1670
         self,
1792 1671
         export_options: list[str],
1793 1672
     ) -> None:
1794 1673
         """Exporting an invalid config fails."""
1795
-        runner = machinery.CliRunner(mix_stderr=False)
1796
-        # TODO(the-13th-letter): Rewrite using parenthesized
1797
-        # with-statements.
1798
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1799
-        with contextlib.ExitStack() as stack:
1800
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1801
-            stack.enter_context(
1802
-                pytest_machinery.isolated_config(
1803
-                    monkeypatch=monkeypatch,
1804
-                    runner=runner,
1805
-                )
1806
-            )
1674
+        with self._test(
1675
+            [], error_messages=("Cannot export vault settings:",)
1676
+        ) as command_line:
1807 1677
             dname = cli_helpers.config_filename(subsystem=None)
1808
-            result = runner.invoke(
1809
-                cli.derivepassphrase_vault,
1810
-                ["--export", os.fsdecode(dname), *export_options],
1811
-                input="null",
1812
-                catch_exceptions=False,
1813
-            )
1814
-        assert result.error_exit(error="Cannot export vault settings:"), (
1815
-            "expected error exit and known error message"
1816
-        )
1678
+            command_line[:] = ["--export", os.fsdecode(dname), *export_options]
1817 1679
 
1818 1680
     @pytest_machinery.skip_if_on_the_annoying_os
1819 1681
     @Parametrize.EXPORT_FORMAT_OPTIONS
1820
-    def test_export_config_settings_directory_not_a_directory(
1682
+    def test_settings_directory_not_a_directory(
1821 1683
         self,
1822 1684
         export_options: list[str],
1823 1685
     ) -> None:
1824 1686
         """Exporting an invalid config fails."""
1825
-        runner = machinery.CliRunner(mix_stderr=False)
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)
1826 1727
         # TODO(the-13th-letter): Rewrite using parenthesized
1827 1728
         # with-statements.
1828 1729
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1829 1730
         with contextlib.ExitStack() as stack:
1830 1731
             monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1831 1732
             stack.enter_context(
1832
-                pytest_machinery.isolated_config(
1733
+                pytest_machinery.isolated_vault_config(
1833 1734
                     monkeypatch=monkeypatch,
1834 1735
                     runner=runner,
1736
+                    vault_config=vault_config,
1835 1737
                 )
1836 1738
             )
1837
-            config_dir = cli_helpers.config_filename(subsystem=None)
1838
-            with contextlib.suppress(FileNotFoundError):
1839
-                shutil.rmtree(config_dir)
1840
-            config_dir.write_text("Obstruction!!\n")
1841 1739
             result = runner.invoke(
1842 1740
                 cli.derivepassphrase_vault,
1843
-                ["--export", "-", *export_options],
1844
-                input="null",
1741
+                [*placement_args, "--", DUMMY_SERVICE],
1845 1742
                 catch_exceptions=False,
1846 1743
             )
1847
-        assert result.error_exit(
1848
-            error="Cannot load vault settings:"
1849
-        ) or result.error_exit(error="Cannot load user config:"), (
1850
-            "expected error exit and known error message"
1851
-        )
1852
-
1853
-
1854
-class TestNotesPrinting:
1855
-    """Tests concerning printing the service notes."""
1856
-
1857
-    @hypothesis.given(
1858
-        notes=strategies.text(
1859
-            strategies.characters(
1860
-                min_codepoint=32,
1861
-                max_codepoint=126,
1862
-                include_characters="\n",
1863
-            ),
1864
-            max_size=256,
1865
-        ),
1866
-    )
1867
-    def test_service_with_notes_actually_prints_notes(
1868
-        self,
1869
-        notes: str,
1870
-    ) -> None:
1871
-        """Service notes are printed, if they exist."""
1872
-        hypothesis.assume("Error:" not in notes)
1873
-        runner = machinery.CliRunner(mix_stderr=False)
1874
-        # TODO(the-13th-letter): Rewrite using parenthesized
1875
-        # with-statements.
1876
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1877
-        with contextlib.ExitStack() as stack:
1878
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1879
-            stack.enter_context(
1880
-                pytest_machinery.isolated_vault_config(
1881
-                    monkeypatch=monkeypatch,
1882
-                    runner=runner,
1883
-                    vault_config={
1884
-                        "global": {
1885
-                            "phrase": DUMMY_PASSPHRASE,
1886
-                        },
1887
-                        "services": {
1888
-                            DUMMY_SERVICE: {
1889
-                                "notes": notes,
1890
-                                **DUMMY_CONFIG_SETTINGS,
1891
-                            },
1892
-                        },
1893
-                    },
1894
-                )
1895
-            )
1896
-            result = runner.invoke(
1897
-                cli.derivepassphrase_vault,
1898
-                ["--", DUMMY_SERVICE],
1744
+            if expected is not None:
1745
+                assert result.clean_exit(output=expected), (
1746
+                    "expected clean exit"
1899 1747
                 )
1748
+            else:
1900 1749
                 assert result.clean_exit(), "expected clean exit"
1901 1750
                 assert result.stdout, "expected program output"
1902
-        assert result.stdout.strip() == DUMMY_RESULT_PASSPHRASE.decode(
1903
-            "ascii"
1904
-        ), "expected known program output"
1905
-        assert result.stderr or not notes.strip(), "expected stderr"
1751
+                assert result.stdout.strip() == result_phrase, (
1752
+                    "expected known program output"
1753
+                )
1754
+                assert result.stderr or not notes_stripped, "expected stderr"
1906 1755
                 assert "Error:" not in result.stderr, (
1907 1756
                     "expected no error messages on stderr"
1908 1757
                 )
1909
-        assert result.stderr.strip() == notes.strip(), (
1758
+                assert result.stderr.strip() == notes_stripped, (
1910 1759
                     "expected known stderr contents"
1911 1760
                 )
1912 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
+
1913 1771
     @Parametrize.NOTES_PLACEMENT
1914
-    @hypothesis.given(
1915
-        notes=strategies.text(
1916
-            strategies.characters(
1917
-                min_codepoint=32, max_codepoint=126, include_characters="\n"
1918
-            ),
1919
-            min_size=1,
1920
-            max_size=512,
1921
-        ).filter(str.strip),
1922
-    )
1772
+    @hypothesis.given(notes=Strategies.notes().filter(str.strip))
1923 1773
     def test_notes_placement(
1924 1774
         self,
1925 1775
         notes_placement: Literal["before", "after"],
1926 1776
         placement_args: list[str],
1927 1777
         notes: str,
1928 1778
     ) -> None:
1929
-        notes = notes.strip()
1930
-        maybe_notes = {"notes": notes} if notes else {}
1931
-        vault_config = {
1932
-            "global": {"phrase": DUMMY_PASSPHRASE},
1933
-            "services": {
1934
-                DUMMY_SERVICE: {**maybe_notes, **DUMMY_CONFIG_SETTINGS}
1935
-            },
1936
-        }
1937
-        result_phrase = DUMMY_RESULT_PASSPHRASE.decode("ascii")
1938
-        expected = (
1939
-            f"{notes}\n\n{result_phrase}\n"
1940
-            if notes_placement == "before"
1941
-            else f"{result_phrase}\n\n{notes}\n\n"
1779
+        self._test(
1780
+            notes,
1781
+            notes_placement=notes_placement,
1782
+            placement_args=placement_args,
1942 1783
         )
1943
-        runner = machinery.CliRunner(mix_stderr=True)
1944
-        # TODO(the-13th-letter): Rewrite using parenthesized
1945
-        # with-statements.
1946
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1947
-        with contextlib.ExitStack() as stack:
1948
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1949
-            stack.enter_context(
1950
-                pytest_machinery.isolated_vault_config(
1951
-                    monkeypatch=monkeypatch,
1952
-                    runner=runner,
1953
-                    vault_config=vault_config,
1954
-                )
1955
-            )
1956
-            result = runner.invoke(
1957
-                cli.derivepassphrase_vault,
1958
-                [*placement_args, "--", DUMMY_SERVICE],
1959
-                catch_exceptions=False,
1960
-            )
1961
-            assert result.clean_exit(output=expected), "expected clean exit"
1962 1784
 
1963 1785
 
1964
-class TestNotesEditingValid:
1965
-    """Tests concerning editing service notes: valid calls."""
1786
+class TestNotesEditing:
1787
+    """Superclass for tests concerning editing service notes."""
1966 1788
 
1967
-    @Parametrize.MODERN_EDITOR_INTERFACE
1968
-    @hypothesis.settings(
1969
-        suppress_health_check=[
1970
-            *hypothesis.settings().suppress_health_check,
1971
-            hypothesis.HealthCheck.function_scoped_fixture,
1972
-        ],
1789
+    CURRENT_NOTES = "Contents go here"
1790
+    OLD_NOTES_TEXT = (
1791
+        "These backup notes are left over from the previous session."
1973 1792
     )
1974
-    @hypothesis.given(
1975
-        notes=strategies.text(
1976
-            strategies.characters(
1977
-                min_codepoint=32, max_codepoint=126, include_characters="\n"
1978
-            ),
1979
-            min_size=1,
1980
-            max_size=512,
1981
-        ).filter(str.strip),
1982
-    )
1983
-    def test_successful_edit(
1793
+
1794
+    def _calculate_expected_contents(
1984 1795
         self,
1985
-        caplog: pytest.LogCaptureFixture,
1796
+        final_notes: str,
1797
+        /,
1798
+        *,
1986 1799
         modern_editor_interface: bool,
1987
-        notes: str,
1988
-    ) -> None:
1989
-        """Editing notes works."""
1990
-        marker = cli_messages.TranslatedString(
1991
-            cli_messages.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER
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 '')}"
1992 1839
             )
1993
-        edit_result = f"""
1994
-
1995
-{marker}
1996
-{notes}
1997
-"""
1998
-        # Reset caplog between hypothesis runs.
1999
-        caplog.clear()
2000 1840
         runner = machinery.CliRunner(mix_stderr=False)
2001 1841
         # TODO(the-13th-letter): Rewrite using parenthesized
2002 1842
         # with-statements.
... ...
@@ -2007,18 +1847,15 @@ class TestNotesEditingValid:
2007 1847
                 pytest_machinery.isolated_vault_config(
2008 1848
                     monkeypatch=monkeypatch,
2009 1849
                     runner=runner,
2010
-                    vault_config={
2011
-                        "global": {"phrase": "abc"},
2012
-                        "services": {"sv": {"notes": "Contents go here"}},
2013
-                    },
1850
+                    vault_config=self._vault_config(current_notes or ""),
2014 1851
                 )
2015 1852
             )
2016 1853
             notes_backup_file = cli_helpers.config_filename(
2017 1854
                 subsystem="notes backup"
2018 1855
             )
1856
+            if old_notes_text and old_notes_text.strip():  # pragma: no branch
2019 1857
                 notes_backup_file.write_text(
2020
-                "These backup notes are left over from the previous session.",
2021
-                encoding="UTF-8",
1858
+                    old_notes_text.strip(), encoding="UTF-8"
2022 1859
                 )
2023 1860
             monkeypatch.setattr(click, "edit", lambda *_a, **_kw: edit_result)
2024 1861
             result = runner.invoke(
... ...
@@ -2034,113 +1871,217 @@ class TestNotesEditingValid:
2034 1871
                 ],
2035 1872
                 catch_exceptions=False,
2036 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:
2037 1903
         assert result.clean_exit(), "expected clean exit"
2038 1904
         assert all(map(is_warning_line, result.stderr.splitlines(True)))
2039
-            assert modern_editor_interface or machinery.warning_emitted(
2040
-                "A backup copy of the old notes was saved",
2041
-                caplog.record_tuples,
2042
-            ), "expected known warning message in stderr"
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:
2043 1914
         assert (
2044 1915
             modern_editor_interface
2045
-                or notes_backup_file.read_text(encoding="UTF-8")
2046
-                == "Contents go here"
1916
+            or notes_unchanged
1917
+            or machinery.warning_emitted(
1918
+                "A backup copy of the old notes was saved",
1919
+                caplog.record_tuples,
2047 1920
             )
2048
-            with cli_helpers.config_filename(subsystem="vault").open(
2049
-                encoding="UTF-8"
2050
-            ) as infile:
2051
-                config = json.load(infile)
2052
-            assert config == {
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 {
2053 1954
             "global": {"phrase": "abc"},
2054 1955
             "services": {
2055
-                    "sv": {
2056
-                        "notes": notes.strip()
2057
-                        if modern_editor_interface
2058
-                        else edit_result.strip()
2059
-                    }
1956
+                "sv": {"notes": starting_notes.strip()}
1957
+                if starting_notes.strip()
1958
+                else {}
2060 1959
             },
2061 1960
         }
2062 1961
 
2063
-    @Parametrize.NOOP_EDIT_FUNCS
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
+    )
2064 1978
     @hypothesis.given(
2065
-        notes=strategies.text(
2066
-            strategies.characters(
2067
-                min_codepoint=32, max_codepoint=126, include_characters="\n"
2068
-            ),
2069
-            min_size=1,
2070
-            max_size=512,
2071
-        ).filter(str.strip),
1979
+        notes=Strategies.notes()
1980
+        .filter(str.strip)
1981
+        .filter(lambda notes: notes != TestNotesEditingValid.CURRENT_NOTES)
2072 1982
     )
2073
-    def test_noop_edit(
1983
+    @hypothesis.example(TestNotesEditing.CURRENT_NOTES)
1984
+    def test_successful_edit(
2074 1985
         self,
2075
-        edit_func_name: Literal["empty", "space"],
1986
+        caplog: pytest.LogCaptureFixture,
2076 1987
         modern_editor_interface: bool,
2077 1988
         notes: str,
2078 1989
     ) -> None:
2079
-        """Abandoning edited notes works."""
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"""
2080 1998
 
2081
-        def empty(text: str, *_args: Any, **_kwargs: Any) -> str:
2082
-            del text
2083
-            return ""
1999
+{marker}
2000
+{notes}
2001
+"""
2002
+            if modern_editor_interface
2003
+            else notes.strip()
2004
+        )
2084 2005
 
2085
-        def space(text: str, *_args: Any, **_kwargs: Any) -> str:
2086
-            del text
2087
-            return "       " + notes.strip() + "\n\n\n\n\n\n"
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()
2088 2012
 
2089
-        edit_funcs = {"empty": empty, "space": space}
2090
-        runner = machinery.CliRunner(mix_stderr=False)
2091
-        # TODO(the-13th-letter): Rewrite using parenthesized
2092
-        # with-statements.
2093
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2094
-        with contextlib.ExitStack() as stack:
2095
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2096
-            stack.enter_context(
2097
-                pytest_machinery.isolated_vault_config(
2098
-                    monkeypatch=monkeypatch,
2099
-                    runner=runner,
2100
-                    vault_config={
2101
-                        "global": {"phrase": "abc"},
2102
-                        "services": {"sv": {"notes": notes.strip()}},
2103
-                    },
2013
+        result, new_backup_notes, new_config = self._test(
2014
+            edit_result, **extra_args
2104 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,
2105 2022
         )
2106
-            notes_backup_file = cli_helpers.config_filename(
2107
-                subsystem="notes backup"
2108
-            )
2109
-            notes_backup_file.write_text(
2110
-                "These backup notes are left over from the previous session.",
2111
-                encoding="UTF-8",
2023
+        self._assert_notes_backup_warning(
2024
+            caplog,
2025
+            modern_editor_interface=modern_editor_interface,
2026
+            notes_unchanged=notes_unchanged,
2112 2027
         )
2113
-            monkeypatch.setattr(click, "edit", edit_funcs[edit_func_name])
2114
-            result = runner.invoke(
2115
-                cli.derivepassphrase_vault,
2116
-                [
2117
-                    "--config",
2118
-                    "--notes",
2119
-                    "--modern-editor-interface"
2120
-                    if modern_editor_interface
2121
-                    else "--vault-legacy-editor-interface",
2122
-                    "--",
2123
-                    "sv",
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,
2124 2034
         ],
2125
-                catch_exceptions=False,
2126 2035
     )
2127
-            assert result.clean_exit(empty_stderr=True) or result.error_exit(
2128
-                error="the user aborted the request"
2129
-            ), "expected clean exit"
2130
-            assert (
2131
-                modern_editor_interface
2132
-                or notes_backup_file.read_text(encoding="UTF-8")
2133
-                == "These backup notes are left over from the previous session."
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
2134 2055
         )
2135
-            with cli_helpers.config_filename(subsystem="vault").open(
2136
-                encoding="UTF-8"
2137
-            ) as infile:
2138
-                config = json.load(infile)
2139
-            assert config == {
2140
-                "global": {"phrase": "abc"},
2141
-                "services": {"sv": {"notes": notes.strip()}},
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,
2142 2064
         }
2143 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
+
2144 2085
     # TODO(the-13th-letter): Keep this behavior or not, with or without
2145 2086
     # warning?
2146 2087
     @Parametrize.MODERN_EDITOR_INTERFACE
... ...
@@ -2150,15 +2091,7 @@ class TestNotesEditingValid:
2150 2091
             hypothesis.HealthCheck.function_scoped_fixture,
2151 2092
         ],
2152 2093
     )
2153
-    @hypothesis.given(
2154
-        notes=strategies.text(
2155
-            strategies.characters(
2156
-                min_codepoint=32, max_codepoint=126, include_characters="\n"
2157
-            ),
2158
-            min_size=1,
2159
-            max_size=512,
2160
-        ).filter(str.strip),
2161
-    )
2094
+    @hypothesis.given(notes=Strategies.notes().filter(str.strip))
2162 2095
     def test_marker_removed(
2163 2096
         self,
2164 2097
         caplog: pytest.LogCaptureFixture,
... ...
@@ -2176,175 +2109,66 @@ class TestNotesEditingValid:
2176 2109
         hypothesis.assume(str(notes_marker) not in notes.strip())
2177 2110
         # Reset caplog between hypothesis runs.
2178 2111
         caplog.clear()
2179
-        runner = machinery.CliRunner(mix_stderr=False)
2180
-        # TODO(the-13th-letter): Rewrite using parenthesized
2181
-        # with-statements.
2182
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2183
-        with contextlib.ExitStack() as stack:
2184
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2185
-            stack.enter_context(
2186
-                pytest_machinery.isolated_vault_config(
2187
-                    monkeypatch=monkeypatch,
2188
-                    runner=runner,
2189
-                    vault_config={
2190
-                        "global": {"phrase": "abc"},
2191
-                        "services": {"sv": {"notes": "Contents go here"}},
2192
-                    },
2193
-                )
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
2194 2122
         )
2195
-            notes_backup_file = cli_helpers.config_filename(
2196
-                subsystem="notes backup"
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,
2197 2129
         )
2198
-            notes_backup_file.write_text(
2199
-                "These backup notes are left over from the previous session.",
2200
-                encoding="UTF-8",
2130
+        self._assert_notes_backup_warning(
2131
+            caplog,
2132
+            modern_editor_interface=modern_editor_interface,
2133
+            notes_unchanged=notes_unchanged,
2201 2134
         )
2202
-            monkeypatch.setattr(click, "edit", lambda *_a, **_kw: notes)
2203
-            result = runner.invoke(
2204
-                cli.derivepassphrase_vault,
2205
-                [
2206
-                    "--config",
2207
-                    "--notes",
2208
-                    "--modern-editor-interface"
2209
-                    if modern_editor_interface
2210
-                    else "--vault-legacy-editor-interface",
2211
-                    "--",
2212
-                    "sv",
2213
-                ],
2214
-                catch_exceptions=False,
2215
-            )
2216
-            assert result.clean_exit(), "expected clean exit"
2217
-            assert not result.stderr or all(
2218
-                map(is_warning_line, result.stderr.splitlines(True))
2219
-            )
2220
-            assert not caplog.record_tuples or machinery.warning_emitted(
2221
-                "A backup copy of the old notes was saved",
2222
-                caplog.record_tuples,
2223
-            ), "expected known warning message in stderr"
2224
-            assert (
2225
-                modern_editor_interface
2226
-                or notes_backup_file.read_text(encoding="UTF-8")
2227
-                == "Contents go here"
2228
-            )
2229
-            with cli_helpers.config_filename(subsystem="vault").open(
2230
-                encoding="UTF-8"
2231
-            ) as infile:
2232
-                config = json.load(infile)
2233
-            assert config == {
2234
-                "global": {"phrase": "abc"},
2235
-                "services": {"sv": {"notes": notes.strip()}},
2236
-            }
2237 2135
 
2238 2136
 
2239
-class TestNotesEditingInvalid:
2137
+class TestNotesEditingInvalid(TestNotesEditing):
2240 2138
     """Tests concerning editing service notes: invalid/error calls."""
2241 2139
 
2242
-    @hypothesis.given(
2243
-        notes=strategies.text(
2244
-            strategies.characters(
2245
-                min_codepoint=32, max_codepoint=126, include_characters="\n"
2246
-            ),
2247
-            min_size=1,
2248
-            max_size=512,
2249
-        ).filter(str.strip),
2250
-    )
2140
+    @hypothesis.given(notes=Strategies.notes())
2141
+    @hypothesis.example("")
2251 2142
     def test_abort(
2252 2143
         self,
2253 2144
         notes: str,
2254 2145
     ) -> None:
2255
-        """Aborting editing notes works.
2146
+        """Aborting editing notes works, even if no notes are stored yet.
2256 2147
 
2257 2148
         Aborting is only supported with the modern editor interface.
2258 2149
 
2259 2150
         """
2260
-        runner = machinery.CliRunner(mix_stderr=False)
2261
-        # TODO(the-13th-letter): Rewrite using parenthesized
2262
-        # with-statements.
2263
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2264
-        with contextlib.ExitStack() as stack:
2265
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2266
-            stack.enter_context(
2267
-                pytest_machinery.isolated_vault_config(
2268
-                    monkeypatch=monkeypatch,
2269
-                    runner=runner,
2270
-                    vault_config={
2271
-                        "global": {"phrase": "abc"},
2272
-                        "services": {"sv": {"notes": notes.strip()}},
2273
-                    },
2274
-                )
2275
-            )
2276
-            monkeypatch.setattr(click, "edit", lambda *_a, **_kw: "")
2277
-            result = runner.invoke(
2278
-                cli.derivepassphrase_vault,
2279
-                [
2280
-                    "--config",
2281
-                    "--notes",
2282
-                    "--modern-editor-interface",
2283
-                    "--",
2284
-                    "sv",
2285
-                ],
2286
-                catch_exceptions=False,
2287
-            )
2288
-            assert result.error_exit(error="the user aborted the request"), (
2289
-                "expected known error message"
2290
-            )
2291
-            with cli_helpers.config_filename(subsystem="vault").open(
2292
-                encoding="UTF-8"
2293
-            ) as infile:
2294
-                config = json.load(infile)
2295
-            assert config == {
2296
-                "global": {"phrase": "abc"},
2297
-                "services": {"sv": {"notes": notes.strip()}},
2298
-            }
2151
+        edit_result = ""
2299 2152
 
2300
-    def test_abort_no_prior_notes(
2301
-        self,
2302
-    ) -> None:
2303
-        """Aborting editing notes works even if no notes are stored yet.
2304
-
2305
-        Aborting is only supported with the modern editor interface.
2306
-
2307
-        """
2308
-        runner = machinery.CliRunner(mix_stderr=False)
2309
-        # TODO(the-13th-letter): Rewrite using parenthesized
2310
-        # with-statements.
2311
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2312
-        with contextlib.ExitStack() as stack:
2313
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2314
-            stack.enter_context(
2315
-                pytest_machinery.isolated_vault_config(
2316
-                    monkeypatch=monkeypatch,
2317
-                    runner=runner,
2318
-                    vault_config={
2319
-                        "global": {"phrase": "abc"},
2320
-                        "services": {},
2321
-                    },
2322
-                )
2323
-            )
2324
-            monkeypatch.setattr(click, "edit", lambda *_a, **_kw: "")
2325
-            result = runner.invoke(
2326
-                cli.derivepassphrase_vault,
2327
-                [
2328
-                    "--config",
2329
-                    "--notes",
2330
-                    "--modern-editor-interface",
2331
-                    "--",
2332
-                    "sv",
2333
-                ],
2334
-                catch_exceptions=False,
2335
-            )
2336
-            assert result.error_exit(error="the user aborted the request"), (
2337
-                "expected known error message"
2338
-            )
2339
-            with cli_helpers.config_filename(subsystem="vault").open(
2340
-                encoding="UTF-8"
2341
-            ) as infile:
2342
-                config = json.load(infile)
2343
-            assert config == {
2344
-                "global": {"phrase": "abc"},
2345
-                "services": {},
2153
+        extra_args: TestNotesEditing.ExtraArgs = {
2154
+            "modern_editor_interface": True,
2155
+            "current_notes": notes.strip(),
2156
+            "old_notes_text": self.OLD_NOTES_TEXT,
2346 2157
         }
2347 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
+
2348 2172
     @Parametrize.MODERN_EDITOR_INTERFACE
2349 2173
     @hypothesis.settings(
2350 2174
         suppress_health_check=[
... ...
@@ -2352,14 +2176,8 @@ class TestNotesEditingInvalid:
2352 2176
             hypothesis.HealthCheck.function_scoped_fixture,
2353 2177
         ],
2354 2178
     )
2355
-    @hypothesis.given(
2356
-        notes=strategies.text(
2357
-            strategies.characters(
2358
-                min_codepoint=32, max_codepoint=126, include_characters="\n"
2359
-            ),
2360
-            max_size=512,
2361
-        ),
2362
-    )
2179
+    @hypothesis.given(notes=Strategies.notes())
2180
+    @hypothesis.example("")
2363 2181
     def test_fail_on_config_option_missing(
2364 2182
         self,
2365 2183
         caplog: pytest.LogCaptureFixture,
... ...
@@ -2374,6 +2192,9 @@ class TestNotesEditingInvalid:
2374 2192
                 DUMMY_SERVICE: {**maybe_notes, **DUMMY_CONFIG_SETTINGS}
2375 2193
             },
2376 2194
         }
2195
+        old_notes_text = (
2196
+            "These backup notes are left over from the previous session."
2197
+        )
2377 2198
         # Reset caplog between hypothesis runs.
2378 2199
         caplog.clear()
2379 2200
         runner = machinery.CliRunner(mix_stderr=False)
... ...
@@ -2397,10 +2218,7 @@ class TestNotesEditingInvalid:
2397 2218
             notes_backup_file = cli_helpers.config_filename(
2398 2219
                 subsystem="notes backup"
2399 2220
             )
2400
-            notes_backup_file.write_text(
2401
-                "These backup notes are left over from the previous session.",
2402
-                encoding="UTF-8",
2403
-            )
2221
+            notes_backup_file.write_text(old_notes_text, encoding="UTF-8")
2404 2222
             monkeypatch.setattr(click, "edit", raiser)
2405 2223
             result = runner.invoke(
2406 2224
                 cli.derivepassphrase_vault,
... ...
@@ -2432,7 +2250,7 @@ class TestNotesEditingInvalid:
2432 2250
             assert (
2433 2251
                 modern_editor_interface
2434 2252
                 or notes_backup_file.read_text(encoding="UTF-8")
2435
-                == "These backup notes are left over from the previous session."
2253
+                == old_notes_text
2436 2254
             )
2437 2255
             with cli_helpers.config_filename(subsystem="vault").open(
2438 2256
                 encoding="UTF-8"
... ...
@@ -2444,19 +2262,15 @@ class TestNotesEditingInvalid:
2444 2262
 class TestStoringConfigurationSuccesses:
2445 2263
     """Tests concerning storing the configuration: successes."""
2446 2264
 
2447
-    @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG
2448
-    def test_store_good_config(
2265
+    def _test(
2449 2266
         self,
2450 2267
         command_line: list[str],
2451
-        input: str,
2452
-        result_config: Any,
2453
-    ) -> None:
2454
-        """Storing valid settings via `--config` works.
2455
-
2456
-        The format also contains embedded newlines and indentation to make
2457
-        the config more readable.
2458
-
2459
-        """
2268
+        /,
2269
+        *,
2270
+        starting_config: _types.VaultConfig | None,
2271
+        result_config: _types.VaultConfig,
2272
+        input: str | bytes | None = None,
2273
+    ) -> machinery.ReadableResult:
2460 2274
         runner = machinery.CliRunner(mix_stderr=False)
2461 2275
         # TODO(the-13th-letter): Rewrite using parenthesized
2462 2276
         # with-statements.
... ...
@@ -2467,9 +2281,12 @@ class TestStoringConfigurationSuccesses:
2467 2281
                 pytest_machinery.isolated_vault_config(
2468 2282
                     monkeypatch=monkeypatch,
2469 2283
                     runner=runner,
2470
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2284
+                    vault_config=starting_config,
2471 2285
                 )
2472 2286
             )
2287
+            if starting_config is None:
2288
+                with contextlib.suppress(FileNotFoundError):
2289
+                    shutil.rmtree(cli_helpers.config_filename(subsystem=None))
2473 2290
             monkeypatch.setattr(
2474 2291
                 cli_helpers,
2475 2292
                 "get_suitable_ssh_keys",
... ...
@@ -2490,6 +2307,28 @@ class TestStoringConfigurationSuccesses:
2490 2307
                 "stored config does not match expectation"
2491 2308
             )
2492 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
+        )
2493 2332
 
2494 2333
     def test_config_directory_nonexistant(
2495 2334
         self,
... ...
@@ -2504,51 +2343,30 @@ class TestStoringConfigurationSuccesses:
2504 2343
         [PRETTY_PRINT_JSON]: https://the13thletter.info/derivepassphrase/0.x/wishlist/pretty-print-json/
2505 2344
 
2506 2345
         """
2507
-        runner = machinery.CliRunner(mix_stderr=False)
2508
-        # TODO(the-13th-letter): Rewrite using parenthesized
2509
-        # with-statements.
2510
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2511
-        with contextlib.ExitStack() as stack:
2512
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2513
-            stack.enter_context(
2514
-                pytest_machinery.isolated_config(
2515
-                    monkeypatch=monkeypatch,
2516
-                    runner=runner,
2517
-                )
2518
-            )
2519
-            with contextlib.suppress(FileNotFoundError):
2520
-                shutil.rmtree(cli_helpers.config_filename(subsystem=None))
2521
-            result = runner.invoke(
2522
-                cli.derivepassphrase_vault,
2523
-                ["--config", "-p"],
2524
-                catch_exceptions=False,
2346
+        result = self._test(
2347
+            ["-p"],
2348
+            starting_config=None,
2349
+            result_config={"global": {"phrase": "abc"}, "services": {}},
2525 2350
             input="abc\n",
2526 2351
         )
2527
-            assert result.clean_exit(), "expected clean exit"
2528
-            assert result.stderr == "Passphrase:", (
2529
-                "program unexpectedly failed?!"
2530
-            )
2531
-            with cli_helpers.config_filename(subsystem="vault").open(
2532
-                encoding="UTF-8"
2533
-            ) as infile:
2534
-                config_readback = json.load(infile)
2535
-            assert config_readback == {
2536
-                "global": {"phrase": "abc"},
2537
-                "services": {},
2538
-            }, "config mismatch"
2352
+        assert result.stderr == "Passphrase:", "program unexpectedly failed?!"
2539 2353
 
2540 2354
 
2541 2355
 class TestStoringConfigurationFailures:
2542 2356
     """Tests concerning storing the configuration: failures."""
2543 2357
 
2544
-    @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG_FAILURES
2545
-    def test_store_bad_config(
2358
+    @contextlib.contextmanager
2359
+    def _test(
2546 2360
         self,
2547 2361
         command_line: list[str],
2548
-        input: str,
2549
-        err_text: str,
2550
-    ) -> None:
2551
-        """Storing invalid settings via `--config` fails."""
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]:
2552 2370
         runner = machinery.CliRunner(mix_stderr=False)
2553 2371
         # TODO(the-13th-letter): Rewrite using parenthesized
2554 2372
         # with-statements.
... ...
@@ -2559,43 +2377,52 @@ class TestStoringConfigurationFailures:
2559 2377
                 pytest_machinery.isolated_vault_config(
2560 2378
                     monkeypatch=monkeypatch,
2561 2379
                     runner=runner,
2562
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2380
+                    vault_config=starting_config,
2563 2381
                 )
2564 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:
2565 2389
                 monkeypatch.setattr(
2566 2390
                     cli_helpers,
2567 2391
                     "get_suitable_ssh_keys",
2568 2392
                     callables.suitable_ssh_keys,
2569 2393
                 )
2394
+            yield monkeypatch
2570 2395
             result = runner.invoke(
2571 2396
                 cli.derivepassphrase_vault,
2572 2397
                 ["--config", *command_line],
2573 2398
                 catch_exceptions=False,
2574 2399
                 input=input,
2575 2400
             )
2576
-        assert result.error_exit(error=err_text), (
2401
+        assert result.error_exit(error=error_text), (
2577 2402
             "expected error exit and known error message"
2578 2403
         )
2579 2404
 
2580
-    def test_fail_because_no_ssh_key_selection(
2405
+    @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG_FAILURES
2406
+    def test_store_bad_config(
2581 2407
         self,
2582
-        running_ssh_agent: data.RunningSSHAgentInfo,
2408
+        command_line: list[str],
2409
+        input: str,
2410
+        err_text: str,
2583 2411
     ) -> None:
2584
-        """Not selecting an SSH key during `--config --key` fails."""
2585
-        del running_ssh_agent
2586
-        runner = machinery.CliRunner(mix_stderr=False)
2587
-        # TODO(the-13th-letter): Rewrite using parenthesized
2588
-        # with-statements.
2589
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2590
-        with contextlib.ExitStack() as stack:
2591
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2592
-            stack.enter_context(
2593
-                pytest_machinery.isolated_vault_config(
2594
-                    monkeypatch=monkeypatch,
2595
-                    runner=runner,
2596
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2597
-                )
2598
-            )
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:
2599 2426
 
2600 2427
             def prompt_for_selection(*_args: Any, **_kwargs: Any) -> NoReturn:
2601 2428
                 raise IndexError(cli_helpers.EMPTY_SELECTION)
... ...
@@ -2603,198 +2430,84 @@ class TestStoringConfigurationFailures:
2603 2430
             monkeypatch.setattr(
2604 2431
                 cli_helpers, "prompt_for_selection", prompt_for_selection
2605 2432
             )
2606
-            # Also patch the list of suitable SSH keys, lest we be at
2607
-            # the mercy of whatever SSH agent may be running.
2608
-            monkeypatch.setattr(
2609
-                cli_helpers,
2610
-                "get_suitable_ssh_keys",
2611
-                callables.suitable_ssh_keys,
2612
-            )
2613
-            result = runner.invoke(
2614
-                cli.derivepassphrase_vault,
2615
-                ["--key", "--config"],
2616
-                catch_exceptions=False,
2617
-            )
2618
-        assert result.error_exit(error="the user aborted the request"), (
2619
-            "expected error exit and known error message"
2620
-        )
2621
-
2622
-    def test_fail_because_no_ssh_agent(
2623
-        self,
2624
-        running_ssh_agent: data.RunningSSHAgentInfo,
2625
-    ) -> None:
2626
-        """Not running an SSH agent during `--config --key` fails."""
2627
-        del running_ssh_agent
2628
-        runner = machinery.CliRunner(mix_stderr=False)
2629
-        # TODO(the-13th-letter): Rewrite using parenthesized
2630
-        # with-statements.
2631
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2632
-        with contextlib.ExitStack() as stack:
2633
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2634
-            stack.enter_context(
2635
-                pytest_machinery.isolated_vault_config(
2636
-                    monkeypatch=monkeypatch,
2637
-                    runner=runner,
2638
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2639
-                )
2640
-            )
2641
-            monkeypatch.delenv("SSH_AUTH_SOCK", raising=False)
2642
-            result = runner.invoke(
2643
-                cli.derivepassphrase_vault,
2644
-                ["--key", "--config"],
2645
-                catch_exceptions=False,
2646
-            )
2647
-        assert result.error_exit(error="Cannot find any running SSH agent"), (
2648
-            "expected error exit and known error message"
2649
-        )
2650
-
2651
-    def test_fail_because_bad_ssh_agent_connection(
2652
-        self,
2653
-        running_ssh_agent: data.RunningSSHAgentInfo,
2654
-    ) -> None:
2655
-        """Not running a reachable SSH agent during `--config --key` fails."""
2656
-        running_ssh_agent.require_external_address()
2657
-        runner = machinery.CliRunner(mix_stderr=False)
2658
-        # TODO(the-13th-letter): Rewrite using parenthesized
2659
-        # with-statements.
2660
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2661
-        with contextlib.ExitStack() as stack:
2662
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2663
-            stack.enter_context(
2664
-                pytest_machinery.isolated_vault_config(
2665
-                    monkeypatch=monkeypatch,
2666
-                    runner=runner,
2667
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2668
-                )
2669
-            )
2670
-            cwd = pathlib.Path.cwd().resolve()
2671
-            monkeypatch.setenv("SSH_AUTH_SOCK", str(cwd))
2672
-            result = runner.invoke(
2673
-                cli.derivepassphrase_vault,
2674
-                ["--key", "--config"],
2675
-                catch_exceptions=False,
2676
-            )
2677
-        assert result.error_exit(error="Cannot connect to the SSH agent"), (
2678
-            "expected error exit and known error message"
2679
-        )
2680
-
2681
-    @Parametrize.TRY_RACE_FREE_IMPLEMENTATION
2682
-    def test_fail_because_read_only_file(
2683
-        self,
2684
-        try_race_free_implementation: bool,
2685
-    ) -> None:
2686
-        """Using a read-only configuration file with `--config` fails."""
2687
-        runner = machinery.CliRunner(mix_stderr=False)
2688
-        # TODO(the-13th-letter): Rewrite using parenthesized
2689
-        # with-statements.
2690
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2691
-        with contextlib.ExitStack() as stack:
2692
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2693
-            stack.enter_context(
2694
-                pytest_machinery.isolated_vault_config(
2695
-                    monkeypatch=monkeypatch,
2696
-                    runner=runner,
2697
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2698
-                )
2699
-            )
2700
-            callables.make_file_readonly(
2701
-                cli_helpers.config_filename(subsystem="vault"),
2702
-                try_race_free_implementation=try_race_free_implementation,
2703
-            )
2704
-            result = runner.invoke(
2705
-                cli.derivepassphrase_vault,
2706
-                ["--config", "--length=15", "--", DUMMY_SERVICE],
2707
-                catch_exceptions=False,
2708
-            )
2709
-        assert result.error_exit(error="Cannot store vault settings:"), (
2710
-            "expected error exit and known error message"
2711
-        )
2712
-
2713
-    def test_fail_because_of_custom_error(
2714
-        self,
2715
-    ) -> None:
2716
-        """Triggering internal errors during `--config` leads to failure."""
2717
-        runner = machinery.CliRunner(mix_stderr=False)
2718
-        # TODO(the-13th-letter): Rewrite using parenthesized
2719
-        # with-statements.
2720
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2721
-        with contextlib.ExitStack() as stack:
2722
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2723
-            stack.enter_context(
2724
-                pytest_machinery.isolated_vault_config(
2725
-                    monkeypatch=monkeypatch,
2726
-                    runner=runner,
2727
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2728
-                )
2729
-            )
2730
-            custom_error = "custom error message"
2731 2433
 
2732
-            def raiser(config: Any) -> None:
2733
-                del config
2734
-                raise RuntimeError(custom_error)
2434
+    def test_fail_because_no_ssh_agent(self) -> None:
2435
+        """Not running an SSH agent during `--config --key` fails.
2735 2436
 
2736
-            monkeypatch.setattr(cli_helpers, "save_config", raiser)
2737
-            result = runner.invoke(
2738
-                cli.derivepassphrase_vault,
2739
-                ["--config", "--length=15", "--", DUMMY_SERVICE],
2740
-                catch_exceptions=False,
2741
-            )
2742
-        assert result.error_exit(error=custom_error), (
2743
-            "expected error exit and known error message"
2744
-        )
2437
+        (This test does not actually need a running agent; the agent's
2438
+        response is mocked by the test harness.)
2745 2439
 
2746
-    def test_fail_because_unsetting_and_setting_same_settings(
2747
-        self,
2748
-    ) -> None:
2749
-        """Issuing conflicting settings to `--config` fails."""
2750
-        runner = machinery.CliRunner(mix_stderr=False)
2751
-        # TODO(the-13th-letter): Rewrite using parenthesized
2752
-        # with-statements.
2753
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2754
-        with contextlib.ExitStack() as stack:
2755
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2756
-            stack.enter_context(
2757
-                pytest_machinery.isolated_vault_config(
2758
-                    monkeypatch=monkeypatch,
2759
-                    runner=runner,
2760
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2761
-                )
2762
-            )
2763
-            result = runner.invoke(
2764
-                cli.derivepassphrase_vault,
2765
-                [
2766
-                    "--config",
2767
-                    "--unset=length",
2768
-                    "--length=15",
2769
-                    "--",
2770
-                    DUMMY_SERVICE,
2771
-                ],
2772
-                catch_exceptions=False,
2773
-            )
2774
-        assert result.error_exit(
2775
-            error="Attempted to unset and set --length at the same time."
2776
-        ), "expected error exit and known error message"
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)
2777 2447
 
2778
-    def test_fail_because_ssh_agent_has_no_keys_loaded(
2779
-        self,
2780
-        running_ssh_agent: data.RunningSSHAgentInfo,
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
2781 2466
     ) -> None:
2782
-        """Not holding any SSH keys during `--config --key` fails."""
2783
-        del running_ssh_agent
2784
-        runner = machinery.CliRunner(mix_stderr=False)
2785
-        # TODO(the-13th-letter): Rewrite using parenthesized
2786
-        # with-statements.
2787
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2788
-        with contextlib.ExitStack() as stack:
2789
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2790
-            stack.enter_context(
2791
-                pytest_machinery.isolated_vault_config(
2792
-                    monkeypatch=monkeypatch,
2793
-                    runner=runner,
2794
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2795
-                )
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,
2796 2475
             )
2797 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
+
2798 2511
             def func(
2799 2512
                 *_args: Any,
2800 2513
                 **_kwargs: Any,
... ...
@@ -2802,67 +2515,35 @@ class TestStoringConfigurationFailures:
2802 2515
                 return []
2803 2516
 
2804 2517
             monkeypatch.setattr(ssh_agent.SSHAgentClient, "list_keys", func)
2805
-            result = runner.invoke(
2806
-                cli.derivepassphrase_vault,
2807
-                ["--key", "--config"],
2808
-                catch_exceptions=False,
2809
-            )
2810
-        assert result.error_exit(error="no keys suitable"), (
2811
-            "expected error exit and known error message"
2812
-        )
2813 2518
 
2814
-    def test_store_config_fail_manual_ssh_agent_runtime_error(
2815
-        self,
2816
-        running_ssh_agent: data.RunningSSHAgentInfo,
2817
-    ) -> None:
2818
-        """Triggering an error in the SSH agent during `--config --key` leads to failure."""
2819
-        del running_ssh_agent
2820
-        runner = machinery.CliRunner(mix_stderr=False)
2821
-        # TODO(the-13th-letter): Rewrite using parenthesized
2822
-        # with-statements.
2823
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2824
-        with contextlib.ExitStack() as stack:
2825
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2826
-            stack.enter_context(
2827
-                pytest_machinery.isolated_vault_config(
2828
-                    monkeypatch=monkeypatch,
2829
-                    runner=runner,
2830
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2831
-                )
2832
-            )
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:
2833 2531
 
2834 2532
             def raiser(*_args: Any, **_kwargs: Any) -> None:
2835 2533
                 raise ssh_agent.TrailingDataError()
2836 2534
 
2837 2535
             monkeypatch.setattr(ssh_agent.SSHAgentClient, "list_keys", raiser)
2838
-            result = runner.invoke(
2839
-                cli.derivepassphrase_vault,
2840
-                ["--key", "--config"],
2841
-                catch_exceptions=False,
2842
-            )
2843
-        assert result.error_exit(
2844
-            error="violates the communication protocol."
2845
-        ), "expected error exit and known error message"
2846 2536
 
2847
-    def test_store_config_fail_manual_ssh_agent_refuses(
2848
-        self,
2849
-        running_ssh_agent: data.RunningSSHAgentInfo,
2850
-    ) -> None:
2851
-        """The SSH agent refusing during `--config --key` leads to failure."""
2852
-        del running_ssh_agent
2853
-        runner = machinery.CliRunner(mix_stderr=False)
2854
-        # TODO(the-13th-letter): Rewrite using parenthesized
2855
-        # with-statements.
2856
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2857
-        with contextlib.ExitStack() as stack:
2858
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2859
-            stack.enter_context(
2860
-                pytest_machinery.isolated_vault_config(
2861
-                    monkeypatch=monkeypatch,
2862
-                    runner=runner,
2863
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2864
-                )
2865
-            )
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:
2866 2547
 
2867 2548
             def func(*_args: Any, **_kwargs: Any) -> NoReturn:
2868 2549
                 raise ssh_agent.SSHAgentFailedError(
... ...
@@ -2870,18 +2551,8 @@ class TestStoringConfigurationFailures:
2870 2551
                 )
2871 2552
 
2872 2553
             monkeypatch.setattr(ssh_agent.SSHAgentClient, "list_keys", func)
2873
-            result = runner.invoke(
2874
-                cli.derivepassphrase_vault,
2875
-                ["--key", "--config"],
2876
-                catch_exceptions=False,
2877
-            )
2878
-        assert result.error_exit(error="refused to"), (
2879
-            "expected error exit and known error message"
2880
-        )
2881 2554
 
2882
-    def test_config_directory_not_a_file(
2883
-        self,
2884
-    ) -> None:
2555
+    def test_config_directory_not_a_file(self) -> None:
2885 2556
         """Erroring without an existing config directory errors normally.
2886 2557
 
2887 2558
         That is, the missing configuration directory does not cause any
... ...
@@ -2895,18 +2566,11 @@ class TestStoringConfigurationFailures:
2895 2566
         [PRETTY_PRINT_JSON]: https://the13thletter.info/derivepassphrase/0.x/wishlist/pretty-print-json/
2896 2567
 
2897 2568
         """
2898
-        runner = machinery.CliRunner(mix_stderr=False)
2899
-        # TODO(the-13th-letter): Rewrite using parenthesized
2900
-        # with-statements.
2901
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2902
-        with contextlib.ExitStack() as stack:
2903
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2904
-            stack.enter_context(
2905
-                pytest_machinery.isolated_config(
2906
-                    monkeypatch=monkeypatch,
2907
-                    runner=runner,
2908
-                )
2909
-            )
2569
+        with self._test(
2570
+            ["--phrase"],
2571
+            error_text="Cannot store vault settings:",
2572
+            input="abc\n",
2573
+        ) as monkeypatch:
2910 2574
             save_config_ = cli_helpers.save_config
2911 2575
 
2912 2576
             def obstruct_config_saving(*args: Any, **kwargs: Any) -> Any:
... ...
@@ -2920,30 +2584,25 @@ class TestStoringConfigurationFailures:
2920 2584
             monkeypatch.setattr(
2921 2585
                 cli_helpers, "save_config", obstruct_config_saving
2922 2586
             )
2923
-            result = runner.invoke(
2924
-                cli.derivepassphrase_vault,
2925
-                ["--config", "-p"],
2926
-                catch_exceptions=False,
2927
-                input="abc\n",
2928
-            )
2929
-            assert result.error_exit(error="Cannot store vault settings:"), (
2930
-                "expected error exit and known error message"
2931
-            )
2932 2587
 
2933 2588
 
2934 2589
 class TestPassphraseUnicodeNormalization:
2935 2590
     """Tests concerning the Unicode normalization of passphrases."""
2936 2591
 
2937
-    @Parametrize.UNICODE_NORMALIZATION_WARNING_INPUTS
2938
-    def test_warning(
2592
+    DEFAULT_VAULT_CONFIG: ClassVar[_types.VaultConfig] = {
2593
+        "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}
2594
+    }
2595
+
2596
+    def _test(
2939 2597
         self,
2940
-        caplog: pytest.LogCaptureFixture,
2941
-        main_config: str,
2942 2598
         command_line: list[str],
2943
-        input: str | None,
2944
-        warning_message: str,
2599
+        /,
2600
+        *,
2601
+        main_config: str,
2602
+        message: str,
2603
+        caplog: pytest.LogCaptureFixture | None = None,
2604
+        input: str | None = None,
2945 2605
     ) -> None:
2946
-        """Using unnormalized Unicode passphrases warns."""
2947 2606
         runner = machinery.CliRunner(mix_stderr=False)
2948 2607
         # TODO(the-13th-letter): Rewrite using parenthesized
2949 2608
         # with-statements.
... ...
@@ -2954,115 +2613,75 @@ class TestPassphraseUnicodeNormalization:
2954 2613
                 pytest_machinery.isolated_vault_config(
2955 2614
                     monkeypatch=monkeypatch,
2956 2615
                     runner=runner,
2957
-                    vault_config={
2958
-                        "services": {
2959
-                            DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()
2960
-                        }
2961
-                    },
2616
+                    vault_config=self.DEFAULT_VAULT_CONFIG,
2962 2617
                     main_config_str=main_config,
2963 2618
                 )
2964 2619
             )
2965 2620
             result = runner.invoke(
2966 2621
                 cli.derivepassphrase_vault,
2967
-                ["--debug", *command_line],
2622
+                ["--debug", *command_line]
2623
+                if caplog is not None
2624
+                else command_line,
2968 2625
                 catch_exceptions=False,
2969 2626
                 input=input,
2970 2627
             )
2628
+        if caplog is not None:
2971 2629
             assert result.clean_exit(), "expected clean exit"
2972
-        assert machinery.warning_emitted(
2973
-            warning_message, caplog.record_tuples
2974
-        ), "expected known warning message in stderr"
2630
+            assert machinery.warning_emitted(message, caplog.record_tuples), (
2631
+                "expected known warning message in stderr"
2632
+            )
2633
+        else:
2634
+            assert result.error_exit(
2635
+                error="The user configuration file is invalid."
2636
+            ), "expected error exit and known error message"
2637
+            assert result.error_exit(error=message), (
2638
+                "expected error exit and known error message"
2639
+            )
2975 2640
 
2976
-    @Parametrize.UNICODE_NORMALIZATION_ERROR_INPUTS
2977
-    def test_error(
2641
+    @Parametrize.UNICODE_NORMALIZATION_WARNING_INPUTS
2642
+    def test_warning(
2978 2643
         self,
2644
+        caplog: pytest.LogCaptureFixture,
2979 2645
         main_config: str,
2980 2646
         command_line: list[str],
2981 2647
         input: str | None,
2982
-        error_message: str,
2648
+        warning_message: str,
2983 2649
     ) -> None:
2984
-        """Using unknown Unicode normalization forms fails."""
2985
-        runner = machinery.CliRunner(mix_stderr=False)
2986
-        # TODO(the-13th-letter): Rewrite using parenthesized
2987
-        # with-statements.
2988
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2989
-        with contextlib.ExitStack() as stack:
2990
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2991
-            stack.enter_context(
2992
-                pytest_machinery.isolated_vault_config(
2993
-                    monkeypatch=monkeypatch,
2994
-                    runner=runner,
2995
-                    vault_config={
2996
-                        "services": {
2997
-                            DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()
2998
-                        }
2999
-                    },
3000
-                    main_config_str=main_config,
3001
-                )
3002
-            )
3003
-            result = runner.invoke(
3004
-                cli.derivepassphrase_vault,
2650
+        """Using unnormalized Unicode passphrases warns."""
2651
+        self._test(
3005 2652
             command_line,
3006
-                catch_exceptions=False,
2653
+            main_config=main_config,
2654
+            message=warning_message,
2655
+            caplog=caplog,
3007 2656
             input=input,
3008 2657
         )
3009
-        assert result.error_exit(
3010
-            error="The user configuration file is invalid."
3011
-        ), "expected error exit and known error message"
3012
-        assert result.error_exit(error=error_message), (
3013
-            "expected error exit and known error message"
3014
-        )
3015 2658
 
3016
-    @Parametrize.UNICODE_NORMALIZATION_COMMAND_LINES
3017
-    def test_error_from_stored_config(
2659
+    @Parametrize.UNICODE_NORMALIZATION_ERROR_INPUTS
2660
+    def test_error(
3018 2661
         self,
2662
+        main_config: str,
3019 2663
         command_line: list[str],
2664
+        input: str | None,
2665
+        error_message: str,
3020 2666
     ) -> None:
3021
-        """Using unknown Unicode normalization forms in the config fails."""
3022
-        runner = machinery.CliRunner(mix_stderr=False)
3023
-        # TODO(the-13th-letter): Rewrite using parenthesized
3024
-        # with-statements.
3025
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3026
-        with contextlib.ExitStack() as stack:
3027
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
3028
-            stack.enter_context(
3029
-                pytest_machinery.isolated_vault_config(
3030
-                    monkeypatch=monkeypatch,
3031
-                    runner=runner,
3032
-                    vault_config={
3033
-                        "services": {
3034
-                            DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()
3035
-                        }
3036
-                    },
3037
-                    main_config_str=(
3038
-                        "[vault]\ndefault-unicode-normalization-form = 'XXX'\n"
3039
-                    ),
3040
-                )
3041
-            )
3042
-            result = runner.invoke(
3043
-                cli.derivepassphrase_vault,
2667
+        """Using unknown Unicode normalization forms fails."""
2668
+        self._test(
3044 2669
             command_line,
3045
-                input=DUMMY_PASSPHRASE,
3046
-                catch_exceptions=False,
2670
+            main_config=main_config,
2671
+            message=error_message,
2672
+            input=input,
3047 2673
         )
3048
-            assert result.error_exit(
3049
-                error="The user configuration file is invalid."
3050
-            ), "expected error exit and known error message"
3051
-            assert result.error_exit(
3052
-                error=(
3053
-                    "Invalid value 'XXX' for config key "
3054
-                    "vault.default-unicode-normalization-form"
3055
-                ),
3056
-            ), "expected error exit and known error message"
3057 2674
 
3058 2675
 
3059 2676
 class TestUserConfigurationFileOther:
3060 2677
     """Other tests concerning the user configuration file."""
3061 2678
 
3062
-    def test_bad_user_config_file(
2679
+    @contextlib.contextmanager
2680
+    def _test(
3063 2681
         self,
3064
-    ) -> None:
3065
-        """Loading a user configuration file in an invalid format fails."""
2682
+        *,
2683
+        main_config: str = "",
2684
+    ) -> Iterator[pytest.MonkeyPatch]:
3066 2685
         runner = machinery.CliRunner(mix_stderr=False)
3067 2686
         # TODO(the-13th-letter): Rewrite using parenthesized
3068 2687
         # with-statements.
... ...
@@ -3074,9 +2693,10 @@ class TestUserConfigurationFileOther:
3074 2693
                     monkeypatch=monkeypatch,
3075 2694
                     runner=runner,
3076 2695
                     vault_config={"services": {}},
3077
-                    main_config_str="This file is not valid TOML.\n",
2696
+                    main_config_str=main_config,
3078 2697
                 )
3079 2698
             )
2699
+            yield monkeypatch
3080 2700
             result = runner.invoke(
3081 2701
                 cli.derivepassphrase_vault,
3082 2702
                 ["--phrase", "--", DUMMY_SERVICE],
... ...
@@ -3087,38 +2707,23 @@ class TestUserConfigurationFileOther:
3087 2707
                 "expected error exit and known error message"
3088 2708
             )
3089 2709
 
2710
+    def test_bad_user_config_file(
2711
+        self,
2712
+    ) -> None:
2713
+        """Loading a user configuration file in an invalid format fails."""
2714
+        with self._test(main_config="This file is not valid TOML.\n"):
2715
+            pass
2716
+
3090 2717
     def test_user_config_is_a_directory(
3091 2718
         self,
3092 2719
     ) -> None:
3093 2720
         """Loading a user configuration non-file fails."""
3094
-        runner = machinery.CliRunner(mix_stderr=False)
3095
-        # TODO(the-13th-letter): Rewrite using parenthesized
3096
-        # with-statements.
3097
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3098
-        with contextlib.ExitStack() as stack:
3099
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
3100
-            stack.enter_context(
3101
-                pytest_machinery.isolated_vault_config(
3102
-                    monkeypatch=monkeypatch,
3103
-                    runner=runner,
3104
-                    vault_config={"services": {}},
3105
-                    main_config_str="",
3106
-                )
3107
-            )
2721
+        with self._test():
3108 2722
             user_config = cli_helpers.config_filename(
3109 2723
                 subsystem="user configuration"
3110 2724
             )
3111 2725
             user_config.unlink()
3112 2726
             user_config.mkdir(parents=True, exist_ok=True)
3113
-            result = runner.invoke(
3114
-                cli.derivepassphrase_vault,
3115
-                ["--phrase", "--", DUMMY_SERVICE],
3116
-                input=DUMMY_PASSPHRASE,
3117
-                catch_exceptions=False,
3118
-            )
3119
-            assert result.error_exit(error="Cannot load user config:"), (
3120
-                "expected error exit and known error message"
3121
-            )
3122 2727
 
3123 2728
 
3124 2729
 class TestSSHAgentAvailability:
3125 2730