Refactor the `exporter` tests
Marco Ricci

Marco Ricci commited on 2025-08-17 16:42:24
Zeige 2 geänderte Dateien mit 299 Einfügungen und 393 Löschungen.


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

For the `export vault` command-line interface tests, split the tests
into tests for command-line argument support, tests for various
command-line or format-related errors, and the already existing groups
for "storeroom" and "vault v0.2"/"vault v0.3" format tests.  For the
former two groups, factor out the common test operation, which in both
cases is the whole test content, though with differing call conventions.
For the latter two groups, factor out the common environment setup
instead.

For the `exporter` subpackage tests, collect the data generation
strategies in a common class `Strategies` (similar to the `Parametrize`
class).  Split the existing `TestUtilities` class into
a `TestCLIUtilities` and a `TestExportVaultConfigDataHandlerRegistry`
class, and factor out the common environment setup for each respective
group.  Rename the `TestCLI` class into `TestGenericVaultCLIErrors`, and
factor out the common environment setup and the CLI call function.
... ...
@@ -34,7 +34,7 @@ from cryptography.hazmat.primitives.ciphers import (  # noqa: E402
34 34
 )
35 35
 
36 36
 if TYPE_CHECKING:
37
-    from collections.abc import Callable
37
+    from collections.abc import Callable, Iterator
38 38
     from typing import Any
39 39
 
40 40
     from typing_extensions import Buffer, Literal
... ...
@@ -175,17 +175,16 @@ class Parametrize(types.SimpleNamespace):
175 175
     )
176 176
 
177 177
 
178
-class TestCLI:
178
+class TestCLIParameters:
179 179
     """Test the command-line interface for `derivepassphrase export vault`."""
180 180
 
181
-    def test_path_parameter(self) -> None:
182
-        """The path `VAULT_PATH` is supported.
183
-
184
-        Using `VAULT_PATH` as the path looks up the actual path in the
185
-        `VAULT_PATH` environment variable.  See
186
-        [`exporter.get_vault_path`][] for details.
187
-
188
-        """
181
+    def _test(
182
+        self,
183
+        command_line: list[str],
184
+        *,
185
+        vault_config: str | bytes = data.VAULT_V03_CONFIG,
186
+        config_data: dict[str, Any] = data.VAULT_V03_CONFIG_DATA,
187
+    ) -> None:
189 188
         runner = machinery.CliRunner(mix_stderr=False)
190 189
         # TODO(the-13th-letter): Rewrite using parenthesized
191 190
         # with-statements.
... ...
@@ -196,42 +195,33 @@ class TestCLI:
196 195
                 pytest_machinery.isolated_vault_exporter_config(
197 196
                     monkeypatch=monkeypatch,
198 197
                     runner=runner,
199
-                    vault_config=data.VAULT_V03_CONFIG,
198
+                    vault_config=vault_config,
200 199
                     vault_key=data.VAULT_MASTER_KEY,
201 200
                 )
202 201
             )
203
-            monkeypatch.setenv("VAULT_KEY", data.VAULT_MASTER_KEY)
204 202
             result = runner.invoke(
205 203
                 cli.derivepassphrase_export_vault,
206
-                ["VAULT_PATH"],
204
+                command_line,
207 205
             )
208 206
         assert result.clean_exit(empty_stderr=True), "expected clean exit"
209
-        assert json.loads(result.stdout) == data.VAULT_V03_CONFIG_DATA
207
+        assert json.loads(result.stdout) == config_data
208
+
209
+    def test_path(self) -> None:
210
+        """The path `VAULT_PATH` is supported.
211
+
212
+        Using `VAULT_PATH` as the path looks up the actual path in the
213
+        `VAULT_PATH` environment variable.  See
214
+        [`exporter.get_vault_path`][] for details.
215
+
216
+        """
217
+        self._test(["VAULT_PATH"])
210 218
 
211
-    def test_key_parameter(self) -> None:
219
+    def test_key(self) -> None:
212 220
         """The `--key` option is supported."""
213
-        runner = machinery.CliRunner(mix_stderr=False)
214
-        # TODO(the-13th-letter): Rewrite using parenthesized
215
-        # with-statements.
216
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
217
-        with contextlib.ExitStack() as stack:
218
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
219
-            stack.enter_context(
220
-                pytest_machinery.isolated_vault_exporter_config(
221
-                    monkeypatch=monkeypatch,
222
-                    runner=runner,
223
-                    vault_config=data.VAULT_V03_CONFIG,
224
-                )
225
-            )
226
-            result = runner.invoke(
227
-                cli.derivepassphrase_export_vault,
228
-                ["-k", data.VAULT_MASTER_KEY, ".vault"],
229
-            )
230
-        assert result.clean_exit(empty_stderr=True), "expected clean exit"
231
-        assert json.loads(result.stdout) == data.VAULT_V03_CONFIG_DATA
221
+        self._test(["-k", data.VAULT_MASTER_KEY, ".vault"])
232 222
 
233 223
     @pytest_machinery.Parametrize.VAULT_CONFIG_FORMATS_DATA
234
-    def test_load_vault_v02_v03_storeroom(
224
+    def test_load_vault_specific_format(
235 225
         self,
236 226
         config: str | bytes,
237 227
         format: str,
... ...
@@ -243,37 +233,28 @@ class TestCLI:
243 233
         vault` to only attempt decoding in that named format.
244 234
 
245 235
         """
246
-        runner = machinery.CliRunner(mix_stderr=False)
247
-        # TODO(the-13th-letter): Rewrite using parenthesized
248
-        # with-statements.
249
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
250
-        with contextlib.ExitStack() as stack:
251
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
252
-            stack.enter_context(
253
-                pytest_machinery.isolated_vault_exporter_config(
254
-                    monkeypatch=monkeypatch,
255
-                    runner=runner,
236
+        self._test(
237
+            ["-f", format, "-k", data.VAULT_MASTER_KEY, "VAULT_PATH"],
256 238
             vault_config=config,
239
+            config_data=config_data,
257 240
         )
258
-            )
259
-            result = runner.invoke(
260
-                cli.derivepassphrase_export_vault,
261
-                [
262
-                    "-f",
263
-                    format,
264
-                    "-k",
265
-                    data.VAULT_MASTER_KEY,
266
-                    "VAULT_PATH",
267
-                ],
268
-            )
269
-        assert result.clean_exit(empty_stderr=True), "expected clean exit"
270
-        assert json.loads(result.stdout) == config_data
271 241
 
272
-    def test_vault_config_not_found(
242
+
243
+class TestCLIFailures:
244
+    """Test the command-line interface for `derivepassphrase export vault`."""
245
+
246
+    @contextlib.contextmanager
247
+    def _test(
273 248
         self,
274 249
         caplog: pytest.LogCaptureFixture,
275
-    ) -> None:
276
-        """Fail when trying to decode non-existant files/directories."""
250
+        command_line: list[str],
251
+        /,
252
+        error_message: str = "Cannot parse '.vault' "
253
+        "as a valid vault-native config",
254
+        *,
255
+        vault_config: str | bytes = data.VAULT_V03_CONFIG,
256
+        vault_key: str = data.VAULT_MASTER_KEY,
257
+    ) -> Iterator[pytest.MonkeyPatch]:
277 258
         runner = machinery.CliRunner(mix_stderr=False)
278 259
         # TODO(the-13th-letter): Rewrite using parenthesized
279 260
         # with-statements.
... ...
@@ -284,132 +265,72 @@ class TestCLI:
284 265
                 pytest_machinery.isolated_vault_exporter_config(
285 266
                     monkeypatch=monkeypatch,
286 267
                     runner=runner,
287
-                    vault_config=data.VAULT_V03_CONFIG,
288
-                    vault_key=data.VAULT_MASTER_KEY,
268
+                    vault_config=vault_config,
269
+                    vault_key=vault_key,
289 270
                 )
290 271
             )
272
+            yield monkeypatch
291 273
             result = runner.invoke(
292 274
                 cli.derivepassphrase_export_vault,
293
-                ["does-not-exist.txt"],
275
+                command_line,
294 276
             )
295 277
         assert result.error_exit(
296
-            error=(
297
-                "Cannot parse 'does-not-exist.txt' "
298
-                "as a valid vault-native config"
299
-            ),
278
+            error=error_message,
300 279
             record_tuples=caplog.record_tuples,
301 280
         ), "expected error exit and known error message"
302 281
         assert data.CANNOT_LOAD_CRYPTOGRAPHY not in result.stderr
303 282
 
304
-    def test_vault_config_invalid(
283
+    def test_file_not_found(
284
+        self,
285
+        caplog: pytest.LogCaptureFixture,
286
+    ) -> None:
287
+        """Fail when trying to decode non-existant files/directories."""
288
+        with self._test(
289
+            caplog,
290
+            ["does-not-exist.txt"],
291
+            error_message="Cannot parse 'does-not-exist.txt' "
292
+            "as a valid vault-native config",
293
+        ):
294
+            pass
295
+
296
+    def test_invalid_encrypted_contents(
305 297
         self,
306 298
         caplog: pytest.LogCaptureFixture,
307 299
     ) -> None:
308 300
         """Fail to parse invalid vault configurations (files)."""
309
-        runner = machinery.CliRunner(mix_stderr=False)
310
-        # TODO(the-13th-letter): Rewrite using parenthesized
311
-        # with-statements.
312
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
313
-        with contextlib.ExitStack() as stack:
314
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
315
-            stack.enter_context(
316
-                pytest_machinery.isolated_vault_exporter_config(
317
-                    monkeypatch=monkeypatch,
318
-                    runner=runner,
319
-                    vault_config="",
320
-                    vault_key=data.VAULT_MASTER_KEY,
321
-                )
322
-            )
323
-            result = runner.invoke(
324
-                cli.derivepassphrase_export_vault,
325
-                [".vault"],
326
-            )
327
-        assert result.error_exit(
328
-            error="Cannot parse '.vault' as a valid vault-native config",
329
-            record_tuples=caplog.record_tuples,
330
-        ), "expected error exit and known error message"
331
-        assert data.CANNOT_LOAD_CRYPTOGRAPHY not in result.stderr
301
+        with self._test(caplog, [".vault"], vault_config=""):
302
+            pass
332 303
 
333
-    def test_vault_config_invalid_just_a_directory(
304
+    def test_just_a_directory(
334 305
         self,
335 306
         caplog: pytest.LogCaptureFixture,
336 307
     ) -> None:
337 308
         """Fail to parse invalid vault configurations (directories)."""
338
-        runner = machinery.CliRunner(mix_stderr=False)
339
-        # TODO(the-13th-letter): Rewrite using parenthesized
340
-        # with-statements.
341
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
342
-        with contextlib.ExitStack() as stack:
343
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
344
-            stack.enter_context(
345
-                pytest_machinery.isolated_vault_exporter_config(
346
-                    monkeypatch=monkeypatch,
347
-                    runner=runner,
348
-                    vault_config="",
349
-                    vault_key=data.VAULT_MASTER_KEY,
350
-                )
351
-            )
309
+        with self._test(caplog, [".vault"], vault_config=""):
352 310
             p = pathlib.Path(".vault")
353 311
             p.unlink()
354 312
             p.mkdir()
355
-            result = runner.invoke(
356
-                cli.derivepassphrase_export_vault,
357
-                [str(p)],
358
-            )
359
-        assert result.error_exit(
360
-            error="Cannot parse '.vault' as a valid vault-native config",
361
-            record_tuples=caplog.record_tuples,
362
-        ), "expected error exit and known error message"
363
-        assert data.CANNOT_LOAD_CRYPTOGRAPHY not in result.stderr
364 313
 
365
-    def test_invalid_vault_config_bad_signature(
314
+    def test_bad_signature(
366 315
         self,
367 316
         caplog: pytest.LogCaptureFixture,
368 317
     ) -> None:
369 318
         """Fail to parse vault configurations with invalid integrity checks."""
370
-        runner = machinery.CliRunner(mix_stderr=False)
371
-        # TODO(the-13th-letter): Rewrite using parenthesized
372
-        # with-statements.
373
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
374
-        with contextlib.ExitStack() as stack:
375
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
376
-            stack.enter_context(
377
-                pytest_machinery.isolated_vault_exporter_config(
378
-                    monkeypatch=monkeypatch,
379
-                    runner=runner,
380
-                    vault_config=data.VAULT_V02_CONFIG,
381
-                    vault_key=data.VAULT_MASTER_KEY,
382
-                )
383
-            )
384
-            result = runner.invoke(
385
-                cli.derivepassphrase_export_vault,
319
+        with self._test(
320
+            caplog,
386 321
             ["-f", "v0.3", ".vault"],
387
-            )
388
-        assert result.error_exit(
389
-            error="Cannot parse '.vault' as a valid vault-native config",
390
-            record_tuples=caplog.record_tuples,
391
-        ), "expected error exit and known error message"
392
-        assert data.CANNOT_LOAD_CRYPTOGRAPHY not in result.stderr
322
+            vault_config=data.VAULT_V02_CONFIG,
323
+        ):
324
+            pass
393 325
 
394
-    def test_vault_config_invalid_internal(
326
+    def test_invalid_decrypted_contents(
395 327
         self,
396 328
         caplog: pytest.LogCaptureFixture,
397 329
     ) -> None:
398
-        """The decoded vault configuration data is valid."""
399
-        runner = machinery.CliRunner(mix_stderr=False)
400
-        # TODO(the-13th-letter): Rewrite using parenthesized
401
-        # with-statements.
402
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
403
-        with contextlib.ExitStack() as stack:
404
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
405
-            stack.enter_context(
406
-                pytest_machinery.isolated_vault_exporter_config(
407
-                    monkeypatch=monkeypatch,
408
-                    runner=runner,
409
-                    vault_config=data.VAULT_V03_CONFIG,
410
-                    vault_key=data.VAULT_MASTER_KEY,
411
-                )
412
-            )
330
+        """Fail to parse encrypted vault configurations with invalid plaintext."""
331
+        with self._test(
332
+            caplog, [".vault"], error_message="Invalid vault config: "
333
+        ) as monkeypatch:
413 334
 
414 335
             def export_vault_config_data(*_args: Any, **_kwargs: Any) -> None:
415 336
                 return None
... ...
@@ -419,20 +340,34 @@ class TestCLI:
419 340
                 "export_vault_config_data",
420 341
                 export_vault_config_data,
421 342
             )
422
-            result = runner.invoke(
423
-                cli.derivepassphrase_export_vault,
424
-                [".vault"],
425
-            )
426
-        assert result.error_exit(
427
-            error="Invalid vault config: ",
428
-            record_tuples=caplog.record_tuples,
429
-        ), "expected error exit and known error message"
430
-        assert data.CANNOT_LOAD_CRYPTOGRAPHY not in result.stderr
431 343
 
432 344
 
433 345
 class TestStoreroom:
434 346
     """Test the "storeroom" handler and handler machinery."""
435 347
 
348
+    @contextlib.contextmanager
349
+    def _setup_environment(
350
+        self,
351
+        *,
352
+        vault_config: str | bytes = data.VAULT_STOREROOM_CONFIG_ZIPPED,
353
+        vault_key: str = data.VAULT_MASTER_KEY,
354
+    ) -> Iterator[tuple[pytest.MonkeyPatch, machinery.CliRunner]]:
355
+        runner = machinery.CliRunner(mix_stderr=False)
356
+        # TODO(the-13th-letter): Rewrite using parenthesized
357
+        # with-statements.
358
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
359
+        with contextlib.ExitStack() as stack:
360
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
361
+            stack.enter_context(
362
+                pytest_machinery.isolated_vault_exporter_config(
363
+                    monkeypatch=monkeypatch,
364
+                    runner=runner,
365
+                    vault_config=vault_config,
366
+                    vault_key=vault_key,
367
+                )
368
+            )
369
+            yield monkeypatch, runner
370
+
436 371
     @Parametrize.PATH
437 372
     @Parametrize.KEY_FORMATS
438 373
     @Parametrize.STOREROOM_HANDLER
... ...
@@ -448,20 +383,7 @@ class TestStoreroom:
448 383
         them as well.
449 384
 
450 385
         """
451
-        runner = machinery.CliRunner(mix_stderr=False)
452
-        # TODO(the-13th-letter): Rewrite using parenthesized
453
-        # with-statements.
454
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
455
-        with contextlib.ExitStack() as stack:
456
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
457
-            stack.enter_context(
458
-                pytest_machinery.isolated_vault_exporter_config(
459
-                    monkeypatch=monkeypatch,
460
-                    runner=runner,
461
-                    vault_config=data.VAULT_STOREROOM_CONFIG_ZIPPED,
462
-                    vault_key=data.VAULT_MASTER_KEY,
463
-                )
464
-            )
386
+        with self._setup_environment():
465 387
             assert (
466 388
                 handler(path, key, format="storeroom")
467 389
                 == data.VAULT_STOREROOM_CONFIG_DATA
... ...
@@ -491,24 +413,12 @@ class TestStoreroom:
491 413
         wrong shape.
492 414
 
493 415
         """
494
-        runner = machinery.CliRunner(mix_stderr=False)
495 416
         master_keys = _types.StoreroomMasterKeys(
496 417
             encryption_key=bytes(storeroom.KEY_SIZE),
497 418
             signing_key=bytes(storeroom.KEY_SIZE),
498 419
             hashing_key=bytes(storeroom.KEY_SIZE),
499 420
         )
500
-        # TODO(the-13th-letter): Rewrite using parenthesized
501
-        # with-statements.
502
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
503
-        with contextlib.ExitStack() as stack:
504
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
505
-            stack.enter_context(
506
-                pytest_machinery.isolated_vault_exporter_config(
507
-                    monkeypatch=monkeypatch,
508
-                    runner=runner,
509
-                    vault_config=data.VAULT_STOREROOM_CONFIG_ZIPPED,
510
-                )
511
-            )
421
+        with self._setup_environment():
512 422
             p = pathlib.Path(".vault", "20")
513 423
             with p.open("w", encoding="UTF-8") as outfile:
514 424
                 print(config, file=outfile)
... ...
@@ -528,20 +438,7 @@ class TestStoreroom:
528 438
         These include unknown versions, and data of the wrong shape.
529 439
 
530 440
         """
531
-        runner = machinery.CliRunner(mix_stderr=False)
532
-        # TODO(the-13th-letter): Rewrite using parenthesized
533
-        # with-statements.
534
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
535
-        with contextlib.ExitStack() as stack:
536
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
537
-            stack.enter_context(
538
-                pytest_machinery.isolated_vault_exporter_config(
539
-                    monkeypatch=monkeypatch,
540
-                    runner=runner,
541
-                    vault_config=data.VAULT_STOREROOM_CONFIG_ZIPPED,
542
-                    vault_key=data.VAULT_MASTER_KEY,
543
-                )
544
-            )
441
+        with self._setup_environment():
545 442
             p = pathlib.Path(".vault", ".keys")
546 443
             with p.open("w", encoding="UTF-8") as outfile:
547 444
                 print(master_keys_data, file=outfile)
... ...
@@ -570,21 +467,11 @@ class TestStoreroom:
570 467
             subdirectories.
571 468
 
572 469
         """
573
-        runner = machinery.CliRunner(mix_stderr=False)
470
+        with self._setup_environment(vault_config=zipped_config):  # noqa: SIM117
574 471
             # TODO(the-13th-letter): Rewrite using parenthesized
575 472
             # with-statements.
576 473
             # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
577
-        with contextlib.ExitStack() as stack:
578
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
579
-            stack.enter_context(
580
-                pytest_machinery.isolated_vault_exporter_config(
581
-                    monkeypatch=monkeypatch,
582
-                    runner=runner,
583
-                    vault_config=zipped_config,
584
-                    vault_key=data.VAULT_MASTER_KEY,
585
-                )
586
-            )
587
-            stack.enter_context(pytest.raises(RuntimeError, match=error_text))
474
+            with pytest.raises(RuntimeError, match=error_text):
588 475
                 handler(format="storeroom")
589 476
 
590 477
     def test_decrypt_keys_wrong_data_length(self) -> None:
... ...
@@ -667,6 +554,29 @@ class TestStoreroom:
667 554
 class TestVaultNativeConfig:
668 555
     """Test the vault-native handler and handler machinery."""
669 556
 
557
+    @contextlib.contextmanager
558
+    def _setup_environment(
559
+        self,
560
+        *,
561
+        vault_config: str | bytes = data.VAULT_V03_CONFIG,
562
+        vault_key: str = data.VAULT_MASTER_KEY,
563
+    ) -> Iterator[tuple[pytest.MonkeyPatch, machinery.CliRunner]]:
564
+        runner = machinery.CliRunner(mix_stderr=False)
565
+        # TODO(the-13th-letter): Rewrite using parenthesized
566
+        # with-statements.
567
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
568
+        with contextlib.ExitStack() as stack:
569
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
570
+            stack.enter_context(
571
+                pytest_machinery.isolated_vault_exporter_config(
572
+                    monkeypatch=monkeypatch,
573
+                    runner=runner,
574
+                    vault_config=vault_config,
575
+                    vault_key=vault_key,
576
+                )
577
+            )
578
+            yield monkeypatch, runner
579
+
670 580
     @Parametrize.VAULT_NATIVE_PBKDF2_RESULT
671 581
     def test_pbkdf2_manually(self, iterations: int, result: bytes) -> None:
672 582
         """The PBKDF2 helper function works."""
... ...
@@ -696,20 +606,7 @@ class TestVaultNativeConfig:
696 606
             no longer does.
697 607
 
698 608
         """
699
-        runner = machinery.CliRunner(mix_stderr=False)
700
-        # TODO(the-13th-letter): Rewrite using parenthesized
701
-        # with-statements.
702
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
703
-        with contextlib.ExitStack() as stack:
704
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
705
-            stack.enter_context(
706
-                pytest_machinery.isolated_vault_exporter_config(
707
-                    monkeypatch=monkeypatch,
708
-                    runner=runner,
709
-                    vault_config=config,
710
-                    vault_key=data.VAULT_MASTER_KEY,
711
-                )
712
-            )
609
+        with self._setup_environment(vault_config=config):
713 610
             if isinstance(config_data, type):
714 611
                 with pytest.raises(config_data):
715 612
                     handler(None, format=format)
... ...
@@ -732,20 +629,7 @@ class TestVaultNativeConfig:
732 629
         them as well.
733 630
 
734 631
         """
735
-        runner = machinery.CliRunner(mix_stderr=False)
736
-        # TODO(the-13th-letter): Rewrite using parenthesized
737
-        # with-statements.
738
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
739
-        with contextlib.ExitStack() as stack:
740
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
741
-            stack.enter_context(
742
-                pytest_machinery.isolated_vault_exporter_config(
743
-                    monkeypatch=monkeypatch,
744
-                    runner=runner,
745
-                    vault_config=data.VAULT_V03_CONFIG,
746
-                    vault_key=data.VAULT_MASTER_KEY,
747
-                )
748
-            )
632
+        with self._setup_environment():
749 633
             assert (
750 634
                 handler(path, key, format="v0.3") == data.VAULT_V03_CONFIG_DATA
751 635
             )
... ...
@@ -766,19 +650,8 @@ class TestVaultNativeConfig:
766 650
 
767 651
             return func
768 652
 
769
-        runner = machinery.CliRunner(mix_stderr=False)
770
-        # TODO(the-13th-letter): Rewrite using parenthesized
771
-        # with-statements.
772
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
773
-        with contextlib.ExitStack() as stack:
774
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
775
-            stack.enter_context(
776
-                pytest_machinery.isolated_vault_exporter_config(
777
-                    monkeypatch=monkeypatch,
778
-                    runner=runner,
779
-                    vault_config=config,
780
-                )
781
-            )
653
+        with self._setup_environment(vault_config=config) as values:
654
+            monkeypatch, _ = values
782 655
             parser = parser_class(
783 656
                 base64.b64decode(config), data.VAULT_MASTER_KEY
784 657
             )
... ...
@@ -10,7 +10,7 @@ import os
10 10
 import pathlib
11 11
 import string
12 12
 import types
13
-from typing import TYPE_CHECKING, Any, NamedTuple
13
+from typing import TYPE_CHECKING, NamedTuple, TypeVar
14 14
 
15 15
 import hypothesis
16 16
 import pytest
... ...
@@ -21,7 +21,57 @@ from tests import data, machinery
21 21
 from tests.machinery import pytest as pytest_machinery
22 22
 
23 23
 if TYPE_CHECKING:
24
-    from typing_extensions import Buffer
24
+    from collections.abc import Callable, Iterator
25
+
26
+    from typing_extensions import Any, Buffer
27
+
28
+
29
+class Strategies:
30
+    @strategies.composite
31
+    @staticmethod
32
+    def names(draw: strategies.DrawFn) -> str:
33
+        """Return a strategy for identifier names."""
34
+        first_letter = draw(
35
+            strategies.text(string.ascii_letters, min_size=1, max_size=1),
36
+            label="first_letter",
37
+        )
38
+        rest = draw(
39
+            strategies.text(
40
+                string.ascii_letters + string.digits + "_-", max_size=23
41
+            ),
42
+            label="rest",
43
+        )
44
+        return first_letter + rest
45
+
46
+    _T = TypeVar("_T")
47
+
48
+    @strategies.composite
49
+    @staticmethod
50
+    def pairs_of_lists(
51
+        draw: strategies.DrawFn,
52
+        strat: strategies.SearchStrategy[_T],
53
+        min_size: int = 1,
54
+        max_size: int = 3,
55
+    ) -> tuple[list[_T], list[_T]]:
56
+        """Return a strategy for two short lists, with unique items."""
57
+        size1 = draw(
58
+            strategies.integers(min_value=min_size, max_value=max_size),
59
+            label="size1",
60
+        )
61
+        size2 = draw(
62
+            strategies.integers(min_value=min_size, max_value=max_size),
63
+            label="size2",
64
+        )
65
+        all_values = draw(
66
+            strategies.lists(
67
+                strat,
68
+                min_size=size1 + size2,
69
+                max_size=size1 + size2,
70
+                unique=True,
71
+            ),
72
+            label="all_values",
73
+        )
74
+        return all_values[:size1], all_values[size1:]
25 75
 
26 76
 
27 77
 class Parametrize(types.SimpleNamespace):
... ...
@@ -51,8 +101,8 @@ class Parametrize(types.SimpleNamespace):
51 101
     )
52 102
 
53 103
 
54
-class TestUtilities:
55
-    """Test the utility functions in the `exporter` subpackage."""
104
+class TestCLIUtilities:
105
+    """Test the command-line utility functions in the `exporter` subpackage."""
56 106
 
57 107
     class VaultKeyEnvironment(NamedTuple):
58 108
         """An environment configuration for vault key determination.
... ...
@@ -87,7 +137,7 @@ class TestUtilities:
87 137
         def strategy(
88 138
             draw: strategies.DrawFn,
89 139
             allow_missing: bool = False,
90
-        ) -> TestUtilities.VaultKeyEnvironment:
140
+        ) -> TestCLIUtilities.VaultKeyEnvironment:
91 141
             """Return a vault key environment configuration."""
92 142
             text_strategy = strategies.text(
93 143
                 strategies.characters(min_codepoint=32, max_codepoint=127),
... ...
@@ -100,7 +150,7 @@ class TestUtilities:
100 150
             )
101 151
             num_fields = sum(
102 152
                 1
103
-                for f in TestUtilities.VaultKeyEnvironment._fields
153
+                for f in TestCLIUtilities.VaultKeyEnvironment._fields
104 154
                 if f != "expected"
105 155
             )
106 156
             env_vars: list[str | None] = draw(
... ...
@@ -124,9 +174,22 @@ class TestUtilities:
124 174
             for value in reversed(env_vars):
125 175
                 if value is not None:
126 176
                     expected = value
127
-            return TestUtilities.VaultKeyEnvironment(
128
-                expected, *env_vars
177
+            return TestCLIUtilities.VaultKeyEnvironment(expected, *env_vars)
178
+
179
+    @contextlib.contextmanager
180
+    def _setup_environment(self) -> Iterator[pytest.MonkeyPatch]:
181
+        runner = machinery.CliRunner(mix_stderr=False)
182
+        # TODO(the-13th-letter): Rewrite using parenthesized
183
+        # with-statements.
184
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
185
+        with contextlib.ExitStack() as stack:
186
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
187
+            stack.enter_context(
188
+                pytest_machinery.isolated_vault_exporter_config(
189
+                    monkeypatch=monkeypatch, runner=runner
190
+                )
129 191
             )
192
+            yield monkeypatch
130 193
 
131 194
     @hypothesis.example(
132 195
         VaultKeyEnvironment("4username", None, None, None, "4username")
... ...
@@ -206,22 +269,22 @@ class TestUtilities:
206 269
             ("USER", user),
207 270
             ("USERNAME", username),
208 271
         ]
209
-        runner = machinery.CliRunner(mix_stderr=False)
210
-        # TODO(the-13th-letter): Rewrite using parenthesized
211
-        # with-statements.
212
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
213
-        with contextlib.ExitStack() as stack:
214
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
215
-            stack.enter_context(
216
-                pytest_machinery.isolated_vault_exporter_config(
217
-                    monkeypatch=monkeypatch, runner=runner
218
-                )
219
-            )
272
+        with self._setup_environment() as monkeypatch:
220 273
             for key, value in priority_list:
221 274
                 if value is not None:
222 275
                     monkeypatch.setenv(key, value)
223 276
             assert os.fsdecode(exporter.get_vault_key()) == expected
224 277
 
278
+    def test_get_vault_key_without_envs(self) -> None:
279
+        """Fail to look up the vault key in the empty environment."""
280
+        with pytest.MonkeyPatch.context() as monkeypatch:
281
+            monkeypatch.delenv("VAULT_KEY", raising=False)
282
+            monkeypatch.delenv("LOGNAME", raising=False)
283
+            monkeypatch.delenv("USER", raising=False)
284
+            monkeypatch.delenv("USERNAME", raising=False)
285
+            with pytest.raises(KeyError, match="VAULT_KEY"):
286
+                exporter.get_vault_key()
287
+
225 288
     @Parametrize.EXPECTED_VAULT_PATH
226 289
     def test_get_vault_path(
227 290
         self,
... ...
@@ -233,17 +296,7 @@ class TestUtilities:
233 296
         Handle relative paths, absolute paths, and missing paths.
234 297
 
235 298
         """
236
-        runner = machinery.CliRunner(mix_stderr=False)
237
-        # TODO(the-13th-letter): Rewrite using parenthesized
238
-        # with-statements.
239
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
240
-        with contextlib.ExitStack() as stack:
241
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
242
-            stack.enter_context(
243
-                pytest_machinery.isolated_vault_exporter_config(
244
-                    monkeypatch=monkeypatch, runner=runner
245
-                )
246
-            )
299
+        with self._setup_environment() as monkeypatch:
247 300
             if path:
248 301
                 monkeypatch.setenv(
249 302
                     "VAULT_PATH", os.fspath(path) if path is not None else None
... ...
@@ -253,64 +306,6 @@ class TestUtilities:
253 306
                 == expected.expanduser().resolve()
254 307
             )
255 308
 
256
-    @hypothesis.given(
257
-        name_data=strategies.lists(
258
-            strategies.integers(min_value=1, max_value=3),
259
-            min_size=2,
260
-            max_size=2,
261
-        ).flatmap(
262
-            lambda nm: strategies.lists(
263
-                strategies.builds(
264
-                    operator.add,
265
-                    strategies.sampled_from(string.ascii_letters),
266
-                    strategies.text(
267
-                        string.ascii_letters + string.digits + "_-",
268
-                        max_size=23,
269
-                    ),
270
-                ),
271
-                min_size=sum(nm),
272
-                max_size=sum(nm),
273
-                unique=True,
274
-            ).flatmap(
275
-                lambda list_: strategies.just((list_[nm[0] :], list_[: nm[0]]))
276
-            )
277
-        ),
278
-    )
279
-    def test_register_export_vault_config_data_handler(
280
-        self, name_data: tuple[list[str], list[str]]
281
-    ) -> None:
282
-        """Register vault config data export handlers."""
283
-
284
-        def handler(  # pragma: no cover
285
-            path: str | bytes | os.PathLike | None = None,
286
-            key: str | Buffer | None = None,
287
-            *,
288
-            format: str,
289
-        ) -> Any:
290
-            del path, key
291
-            raise ValueError(format)
292
-
293
-        names1, names2 = name_data
294
-
295
-        with pytest.MonkeyPatch.context() as monkeypatch:
296
-            registry = dict.fromkeys(names1, handler)
297
-            monkeypatch.setattr(
298
-                exporter, "_export_vault_config_data_registry", registry
299
-            )
300
-            dec = exporter.register_export_vault_config_data_handler(*names2)
301
-            assert dec(handler) == handler
302
-            assert registry == dict.fromkeys(names1 + names2, handler)
303
-
304
-    def test_get_vault_key_without_envs(self) -> None:
305
-        """Fail to look up the vault key in the empty environment."""
306
-        with pytest.MonkeyPatch.context() as monkeypatch:
307
-            monkeypatch.delenv("VAULT_KEY", raising=False)
308
-            monkeypatch.delenv("LOGNAME", raising=False)
309
-            monkeypatch.delenv("USER", raising=False)
310
-            monkeypatch.delenv("USERNAME", raising=False)
311
-            with pytest.raises(KeyError, match="VAULT_KEY"):
312
-                exporter.get_vault_key()
313
-
314 309
     def test_get_vault_path_without_home(self) -> None:
315 310
         """Fail to look up the vault path without `HOME`."""
316 311
 
... ...
@@ -325,6 +320,52 @@ class TestUtilities:
325 320
             ):
326 321
                 exporter.get_vault_path()
327 322
 
323
+
324
+class TestExportVaultConfigDataHandlerRegistry:
325
+    """Test the registry of `vault` config data exporters."""
326
+
327
+    @contextlib.contextmanager
328
+    def _setup_environment(
329
+        self,
330
+        *,
331
+        registry: dict[str, Any] | None = None,
332
+        find_handlers: Callable | None = None,
333
+    ) -> Iterator[pytest.MonkeyPatch]:
334
+        with pytest.MonkeyPatch.context() as monkeypatch:
335
+            if registry is not None:  # pragma: no branch
336
+                monkeypatch.setattr(
337
+                    exporter, "_export_vault_config_data_registry", registry
338
+                )
339
+            if find_handlers is not None:  # pragma: no branch
340
+                monkeypatch.setattr(
341
+                    exporter, "find_vault_config_data_handlers", find_handlers
342
+                )
343
+            yield monkeypatch
344
+
345
+    @staticmethod
346
+    def dummy_handler(  # pragma: no cover
347
+        path: str | bytes | os.PathLike | None = None,
348
+        key: str | Buffer | None = None,
349
+        *,
350
+        format: str,
351
+    ) -> Any:
352
+        del path, key
353
+        raise ValueError(format)
354
+
355
+    @hypothesis.given(name_data=Strategies.pairs_of_lists(Strategies.names()))
356
+    def test_register_export_vault_config_data_handler(
357
+        self, name_data: tuple[list[str], list[str]]
358
+    ) -> None:
359
+        """Register vault config data export handlers."""
360
+        names1, names2 = name_data
361
+        registry = dict.fromkeys(names1, self.dummy_handler)
362
+        with self._setup_environment(registry=registry):
363
+            dec = exporter.register_export_vault_config_data_handler(*names2)
364
+            assert dec(self.dummy_handler) == self.dummy_handler
365
+            assert registry == dict.fromkeys(
366
+                names1 + names2, self.dummy_handler
367
+            )
368
+
328 369
     @Parametrize.EXPORT_VAULT_CONFIG_DATA_HANDLER_NAMELISTS
329 370
     def test_register_export_vault_config_data_handler_errors(
330 371
         self,
... ...
@@ -338,34 +379,21 @@ class TestUtilities:
338 379
 
339 380
         """
340 381
 
341
-        def handler(  # pragma: no cover
342
-            path: str | bytes | os.PathLike | None = None,
343
-            key: str | Buffer | None = None,
344
-            *,
345
-            format: str,
346
-        ) -> Any:
347
-            del path, key
348
-            raise ValueError(format)
349
-
350
-        with pytest.MonkeyPatch.context() as monkeypatch:
351
-            registry = {"dummy": handler}
352
-            monkeypatch.setattr(
353
-                exporter, "_export_vault_config_data_registry", registry
354
-            )
382
+        with self._setup_environment(registry={"dummy": self.dummy_handler}):  # noqa: SIM117
383
+            # TODO(the-13th-letter): Rewrite using parenthesized
384
+            # with-statements.
385
+            # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
355 386
             with pytest.raises(ValueError, match=err_pat):
356 387
                 exporter.register_export_vault_config_data_handler(*namelist)(
357
-                    handler
388
+                    self.dummy_handler
358 389
                 )
359 390
 
360 391
     def test_export_vault_config_data_bad_handler(self) -> None:
361 392
         """Fail to export vault config data without known handlers."""
362
-        with pytest.MonkeyPatch.context() as monkeypatch:
363
-            monkeypatch.setattr(
364
-                exporter, "_export_vault_config_data_registry", {}
365
-            )
366
-            monkeypatch.setattr(
367
-                exporter, "find_vault_config_data_handlers", lambda: None
368
-            )
393
+        with self._setup_environment(registry={}, find_handlers=lambda: None):  # noqa: SIM117
394
+            # TODO(the-13th-letter): Rewrite using parenthesized
395
+            # with-statements.
396
+            # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
369 397
             with pytest.raises(
370 398
                 ValueError,
371 399
                 match=r"Invalid vault native configuration format",
... ...
@@ -373,11 +401,18 @@ class TestUtilities:
373 401
                 exporter.export_vault_config_data(format="v0.3")
374 402
 
375 403
 
376
-class TestCLI:
377
-    """Test the command-line functionality of the `exporter` subpackage."""
404
+class TestGenericVaultCLIErrors:
405
+    """Test errors in the `derivepassphrase export vault` subpackage.
378 406
 
379
-    def test_invalid_format(self) -> None:
380
-        """Reject invalid vault configuration format names."""
407
+    These errors are always possible, even if the `export` extra is not
408
+    available.
409
+
410
+    """
411
+
412
+    @contextlib.contextmanager
413
+    def _setup_environment(
414
+        self, *, vault_config: str | bytes = data.VAULT_V03_CONFIG
415
+    ) -> Iterator[tuple[pytest.MonkeyPatch, machinery.CliRunner]]:
381 416
         runner = machinery.CliRunner(mix_stderr=False)
382 417
         # TODO(the-13th-letter): Rewrite using parenthesized
383 418
         # with-statements.
... ...
@@ -388,15 +423,29 @@ class TestCLI:
388 423
                 pytest_machinery.isolated_vault_exporter_config(
389 424
                     monkeypatch=monkeypatch,
390 425
                     runner=runner,
391
-                    vault_config=data.VAULT_V03_CONFIG,
426
+                    vault_config=vault_config,
392 427
                     vault_key=data.VAULT_MASTER_KEY,
393 428
                 )
394 429
             )
395
-            result = runner.invoke(
430
+            yield monkeypatch, runner
431
+
432
+    def _call_cli(
433
+        self,
434
+        command_line: list[str],
435
+        *,
436
+        vault_config: str | bytes = data.VAULT_V03_CONFIG,
437
+    ) -> machinery.ReadableResult:
438
+        with self._setup_environment(vault_config=vault_config) as contexts:
439
+            _, runner = contexts
440
+            return runner.invoke(
396 441
                 cli.derivepassphrase_export_vault,
397
-                ["-f", "INVALID", "VAULT_PATH"],
442
+                command_line,
398 443
                 catch_exceptions=False,
399 444
             )
445
+
446
+    def test_invalid_format(self) -> None:
447
+        """Reject invalid vault configuration format names."""
448
+        result = self._call_cli(["-f", "INVALID", "VAULT_PATH"])
400 449
         for snippet in ("Invalid value for", "-f", "--format", "INVALID"):
401 450
             assert result.error_exit(error=snippet), (
402 451
                 "expected error exit and known error message"
... ...
@@ -413,24 +462,8 @@ class TestCLI:
413 462
     ) -> None:
414 463
         """Abort export call if no cryptography is available."""
415 464
         del config_data
416
-        runner = machinery.CliRunner(mix_stderr=False)
417
-        # TODO(the-13th-letter): Rewrite using parenthesized
418
-        # with-statements.
419
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
420
-        with contextlib.ExitStack() as stack:
421
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
422
-            stack.enter_context(
423
-                pytest_machinery.isolated_vault_exporter_config(
424
-                    monkeypatch=monkeypatch,
425
-                    runner=runner,
426
-                    vault_config=config,
427
-                    vault_key=data.VAULT_MASTER_KEY,
428
-                )
429
-            )
430
-            result = runner.invoke(
431
-                cli.derivepassphrase_export_vault,
432
-                ["-f", format, "VAULT_PATH"],
433
-                catch_exceptions=False,
465
+        result = self._call_cli(
466
+            ["-f", format, "VAULT_PATH"], vault_config=config
434 467
         )
435 468
         assert result.error_exit(
436 469
             error=data.CANNOT_LOAD_CRYPTOGRAPHY,
437 470