Refactor the `vault` and `sequin` tests
Marco Ricci

Marco Ricci commited on 2025-08-17 16:21:27
Zeige 2 geänderte Dateien mit 100 Einfügungen und 134 Löschungen.


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

For the `sequin` tests, merge the two tests for bit extraction from
a big-endian number.  They both test testing infrastructure, and they
already both had the same test parametrization.

For the `vault` tests, factor out common test operations for the
`TestPhraseDependence` and `TestInterchangablePhrases` classes (which in
both cases amounts to the whole test body).  Rewrite the
`TestStringAndBinaryExchangability` tests to a more uniform style,
parametrizing over the specific binary class.  Also, for
`test_binary_service_name` (now `test_binary_service_name_and_phrase`),
cross-check that all combinations of phrase and service name classes
lead to identical results.

Finally, fix the explicit forbidden patterns in
`test_only_numbers_and_very_high_repetition_limit`, which were one
character too short, as well as a typo in the
`test_arbitrary_repetition_limit` docstring.
... ...
@@ -90,25 +90,18 @@ class TestStaticFunctionality:
90 90
         n = len(seq1)
91 91
         seq2 = bits(num, byte_width=8)
92 92
         m = len(seq2)
93
+        text1 = "".join(str(bit) for bit in seq1)
94
+        text2 = "".join(str(bit) for bit in seq2)
95
+        text3 = f"{num:064b}"
96
+        seq3 = bitseq(text3)
93 97
         assert m == 64
98
+        assert seq2 == seq3
94 99
         assert seq2[-n:] == seq1
95 100
         assert seq2[: m - n] == [0] * (m - n)
96
-        text1 = "".join(str(bit) for bit in seq1)
97
-        text2 = "".join(str(bit) for bit in seq2)
98 101
         assert text1.lstrip("0") == (f"{num:b}" if num else "")
99
-        assert text2 == f"{num:064b}"
100
-
101
-    @hypothesis.given(
102
-        num=strategies.integers(min_value=0, max_value=0xFFFFFFFFFFFFFFFF),
103
-    )
104
-    def test_bits_bitseq(self, num: int) -> None:
105
-        """Extract the bits from a number in big-endian format."""
106
-        text1 = f"{num:064b}"
107
-        seq1 = bitseq(text1)
108
-        seq2 = bits(num, byte_width=8)
109
-        assert seq1 == seq2
110
-        text2 = "".join(str(bit) for bit in seq1)
102
+        assert text2.endswith(text1)
111 103
         assert int(text2, 2) == num
104
+        assert text2 == text3
112 105
 
113 106
     class BigEndianNumberTest(NamedTuple):
114 107
         """Test data for
... ...
@@ -21,7 +21,7 @@ from derivepassphrase import vault
21 21
 from tests.machinery import hypothesis as hypothesis_machinery
22 22
 
23 23
 if TYPE_CHECKING:
24
-    from collections.abc import Callable
24
+    from collections.abc import Callable, Sequence
25 25
 
26 26
     from typing_extensions import Buffer
27 27
 
... ...
@@ -41,6 +41,13 @@ The standard derived passphrase for the "twitter" service, from
41 41
 <i>vault</i>(1)'s test suite.
42 42
 """
43 43
 
44
+buffer_types: dict[str, Callable[..., Buffer]] = {
45
+    "bytes": bytes,
46
+    "bytearray": bytearray,
47
+    "memoryview": memoryview,
48
+    "array.array": lambda data: array.array("B", data),
49
+}
50
+
44 51
 
45 52
 class Parametrize(types.SimpleNamespace):
46 53
     ENTROPY_RESULTS = pytest.mark.parametrize(
... ...
@@ -62,6 +69,11 @@ class Parametrize(types.SimpleNamespace):
62 69
             (1, {"upper": 0, "lower": 0, "number": 0, "symbol": 0}, 0.0),
63 70
         ],
64 71
     )
72
+    MASTER_PASSPHRASE_TYPES = pytest.mark.parametrize(
73
+        ["phrase1", "phrase2"],
74
+        [(PHRASE.decode("UTF-8"), f(PHRASE)) for f in buffer_types.values()],
75
+        ids=buffer_types.keys(),
76
+    )
65 77
     BINARY_STRINGS = pytest.mark.parametrize(
66 78
         "s",
67 79
         [
... ...
@@ -79,6 +91,12 @@ class Parametrize(types.SimpleNamespace):
79 91
             (b"google", GOOGLE_PHRASE),
80 92
             ("twitter", TWITTER_PHRASE),
81 93
         ],
94
+        ids=["google", "twitter"],
95
+    )
96
+    SERVICE_NAME_TYPES = pytest.mark.parametrize(
97
+        ["sv1", "sv2"],
98
+        [("email", f(b"email")) for f in buffer_types.values()],
99
+        ids=buffer_types.keys(),
82 100
     )
83 101
 
84 102
 
... ...
@@ -273,6 +291,11 @@ class TestVault:
273 291
 class TestPhraseDependence:
274 292
     """Test the dependence of the internal hash on the master passphrase."""
275 293
 
294
+    def _test(self, phrases: Sequence[bytes], service: str) -> None:
295
+        assert vault.Vault.create_hash(
296
+            phrase=phrases[0], service=service
297
+        ) != vault.Vault.create_hash(phrase=phrases[1], service=service)
298
+
276 299
     @hypothesis.given(
277 300
         phrases=Strategies.pair_of_binary_phrases_strategy(
278 301
             size=PhraseSize.SHORT
... ...
@@ -283,19 +306,13 @@ class TestPhraseDependence:
283 306
         reason="phrases are interchangable",
284 307
         raises=AssertionError,
285 308
     )
286
-    def test_small(
287
-        self,
288
-        phrases: list[bytes],
289
-        service: str,
290
-    ) -> None:
309
+    def test_small(self, phrases: Sequence[bytes], service: str) -> None:
291 310
         """The internal hash is dependent on the master passphrase.
292 311
 
293 312
         We filter out interchangable passphrases during generation.
294 313
 
295 314
         """
296
-        assert vault.Vault.create_hash(
297
-            phrase=phrases[0], service=service
298
-        ) != vault.Vault.create_hash(phrase=phrases[1], service=service)
315
+        self._test(phrases, service)
299 316
 
300 317
     @hypothesis.given(
301 318
         phrases=Strategies.pair_of_binary_phrases_strategy(
... ...
@@ -303,19 +320,13 @@ class TestPhraseDependence:
303 320
         ),
304 321
         service=Strategies.text_strategy(),
305 322
     )
306
-    def test_medium(
307
-        self,
308
-        phrases: list[bytes],
309
-        service: str,
310
-    ) -> None:
323
+    def test_medium(self, phrases: Sequence[bytes], service: str) -> None:
311 324
         """The internal hash is dependent on the master passphrase.
312 325
 
313 326
         We filter out interchangable passphrases during generation.
314 327
 
315 328
         """
316
-        assert vault.Vault.create_hash(
317
-            phrase=phrases[0], service=service
318
-        ) != vault.Vault.create_hash(phrase=phrases[1], service=service)
329
+        self._test(phrases, service)
319 330
 
320 331
     @hypothesis.given(
321 332
         phrases=Strategies.pair_of_binary_phrases_strategy(
... ...
@@ -323,19 +334,13 @@ class TestPhraseDependence:
323 334
         ),
324 335
         service=Strategies.text_strategy(),
325 336
     )
326
-    def test_large(
327
-        self,
328
-        phrases: tuple[bytes, bytes],
329
-        service: str,
330
-    ) -> None:
337
+    def test_large(self, phrases: Sequence[bytes], service: str) -> None:
331 338
         """The internal hash is dependent on the master passphrase.
332 339
 
333 340
         We filter out interchangable passphrases during generation.
334 341
 
335 342
         """
336
-        assert vault.Vault.create_hash(
337
-            phrase=phrases[0], service=service
338
-        ) != vault.Vault.create_hash(phrase=phrases[1], service=service)
343
+        self._test(phrases, service)
339 344
 
340 345
     @hypothesis.given(
341 346
         phrases=Strategies.pair_of_binary_phrases_strategy(
... ...
@@ -360,19 +365,13 @@ class TestPhraseDependence:
360 365
         ),
361 366
         raises=AssertionError,
362 367
     )
363
-    def test_mixed(
364
-        self,
365
-        phrases: list[bytes],
366
-        service: str,
367
-    ) -> None:
368
+    def test_mixed(self, phrases: Sequence[bytes], service: str) -> None:
368 369
         """The internal hash is dependent on the master passphrase.
369 370
 
370 371
         We filter out interchangable passphrases during generation.
371 372
 
372 373
         """
373
-        assert vault.Vault.create_hash(
374
-            phrase=phrases[0], service=service
375
-        ) != vault.Vault.create_hash(phrase=phrases[1], service=service)
374
+        self._test(phrases, service)
376 375
 
377 376
 
378 377
 class TestServiceNameDependence:
... ...
@@ -401,22 +400,21 @@ class TestServiceNameDependence:
401 400
 class TestInterchangablePhrases:
402 401
     """Test the interchangability of certain master passphrases."""
403 402
 
403
+    def _test(self, phrases: Sequence[bytes], service: str) -> None:
404
+        assert vault.Vault.phrases_are_interchangable(*phrases)
405
+        assert vault.Vault.create_hash(
406
+            phrase=phrases[0], service=service
407
+        ) == vault.Vault.create_hash(phrase=phrases[1], service=service)
408
+
404 409
     @hypothesis.given(
405 410
         phrases=Strategies.binary_phrase_strategy(
406 411
             size=PhraseSize.SHORT
407 412
         ).flatmap(Strategies.make_interchangable_phrases),
408 413
         service=Strategies.text_strategy(),
409 414
     )
410
-    def test_small(
411
-        self,
412
-        phrases: tuple[bytes, bytes],
413
-        service: str,
414
-    ) -> None:
415
+    def test_small(self, phrases: Sequence[bytes], service: str) -> None:
415 416
         """Claimed interchangable passphrases are actually interchangable."""
416
-        assert vault.Vault.phrases_are_interchangable(*phrases)
417
-        assert vault.Vault.create_hash(
418
-            phrase=phrases[0], service=service
419
-        ) == vault.Vault.create_hash(phrase=phrases[1], service=service)
417
+        self._test(phrases, service)
420 418
 
421 419
     @hypothesis.given(
422 420
         phrases=Strategies.binary_phrase_strategy(
... ...
@@ -424,16 +422,9 @@ class TestInterchangablePhrases:
424 422
         ).flatmap(Strategies.make_interchangable_phrases),
425 423
         service=Strategies.text_strategy(),
426 424
     )
427
-    def test_large(
428
-        self,
429
-        phrases: tuple[bytes, bytes],
430
-        service: str,
431
-    ) -> None:
425
+    def test_large(self, phrases: Sequence[bytes], service: str) -> None:
432 426
         """Claimed interchangable passphrases are actually interchangable."""
433
-        assert vault.Vault.phrases_are_interchangable(*phrases)
434
-        assert vault.Vault.create_hash(
435
-            phrase=phrases[0], service=service
436
-        ) == vault.Vault.create_hash(phrase=phrases[1], service=service)
427
+        self._test(phrases, service)
437 428
 
438 429
 
439 430
 class TestBasicFunctionalityFromUpstream(TestVault):
... ...
@@ -457,83 +448,65 @@ class TestBasicFunctionalityFromUpstream(TestVault):
457 448
 class TestStringAndBinaryExchangability(TestVault):
458 449
     """Test the exchangability of text and byte strings in the "vault" scheme.
459 450
 
460
-    This specifically refers to ASCII-cleanliness, and buffer-type
451
+    This specifically refers to UTF-8-cleanliness, and buffer-type
461 452
     independence.
462 453
 
463 454
     """
464 455
 
465
-    def test_bytes_service_name(self) -> None:
466
-        """Deriving a passphrase works equally for byte strings."""
467
-        assert vault.Vault(phrase=self.phrase).generate(
468
-            b"google"
469
-        ) == vault.Vault(phrase=self.phrase).generate("google")
470
-
471
-    def test_bytearray_service_name(self) -> None:
472
-        """Deriving a passphrase works equally for byte arrays."""
473
-        assert vault.Vault(phrase=self.phrase).generate(
474
-            b"google"
475
-        ) == vault.Vault(phrase=self.phrase).generate(bytearray(b"google"))
476
-
477
-    def test_buffer_like_service_name(self) -> None:
478
-        """Deriving a passphrase works equally for memory views."""
479
-        assert vault.Vault(phrase=self.phrase).generate(
480
-            b"google"
481
-        ) == vault.Vault(phrase=self.phrase).generate(memoryview(b"google"))
482
-
483
-    @hypothesis.given(
484
-        phrase=Strategies.text_strategy(),
485
-        service=Strategies.text_strategy(),
486
-    )
456
+    @Parametrize.SAMPLE_SERVICES_AND_PHRASES
457
+    @Parametrize.MASTER_PASSPHRASE_TYPES
487 458
     def test_binary_phrases(
488 459
         self,
489
-        phrase: str,
490
-        service: str,
460
+        phrase1: str,
461
+        phrase2: Buffer,
462
+        service: bytes | str,
463
+        expected: bytes,
491 464
     ) -> None:
492 465
         """Binary and text master passphrases generate the same passphrases."""
493
-        buffer_types: dict[str, Callable[..., Buffer]] = {
494
-            "bytes": bytes,
495
-            "bytearray": bytearray,
496
-            "memoryview": memoryview,
497
-            "array.array": lambda data: array.array("B", data),
498
-        }
499
-        for type_name, buffer_type in buffer_types.items():
500
-            str_phrase = phrase
501
-            bytes_phrase = phrase.encode("utf-8")
502
-            assert vault.Vault(phrase=str_phrase).generate(
503
-                service
504
-            ) == vault.Vault(phrase=buffer_type(bytes_phrase)).generate(
505
-                service
506
-            ), (
507
-                f"{str_phrase!r} and {type_name}({bytes_phrase!r}) "
508
-                "master passphrases generate different passphrases"
509
-            )
466
+        v1 = vault.Vault(phrase=phrase1)
467
+        v2 = vault.Vault(phrase=phrase2)
468
+        assert v1.generate(service) == expected
469
+        assert v2.generate(service) == expected
470
+
471
+    @Parametrize.SERVICE_NAME_TYPES
472
+    def test_binary_service_name(self, sv1: str, sv2: Buffer) -> None:
473
+        """Binary and text service names generate the same passphrases."""
474
+        v = vault.Vault(phrase=self.phrase)
475
+        assert v.generate(sv1) == v.generate(sv2)
510 476
 
511 477
     @hypothesis.given(
512 478
         phrase=Strategies.text_strategy(),
513 479
         service=Strategies.text_strategy(),
514 480
     )
515
-    def test_binary_service_name(
481
+    def test_binary_service_name_and_phrase(
516 482
         self,
517 483
         phrase: str,
518 484
         service: str,
519 485
     ) -> None:
520
-        """Binary and text service names generate the same passphrases."""
521
-        buffer_types: dict[str, Callable[..., Buffer]] = {
522
-            "bytes": bytes,
523
-            "bytearray": bytearray,
524
-            "memoryview": memoryview,
525
-            "array.array": lambda data: array.array("B", data),
526
-        }
527
-        for type_name, buffer_type in buffer_types.items():
486
+        """Binary and text inputs generate the same passphrases."""
487
+        v0 = vault.Vault(phrase=phrase)
528 488
         str_service = service
489
+        result = v0.generate(str_service)
529 490
         bytes_service = service.encode("utf-8")
530
-            assert vault.Vault(phrase=phrase).generate(
531
-                str_service
532
-            ) == vault.Vault(phrase=phrase).generate(
533
-                buffer_type(bytes_service)
534
-            ), (
535
-                f"{str_service!r} and {type_name}({bytes_service!r}) "
536
-                "service name generate different passphrases"
491
+
492
+        for type_name, buffer_type in buffer_types.items():
493
+            assert v0.generate(buffer_type(bytes_service)) == result, (
494
+                f"mismatched result when using the {type_name} service name"
495
+            )
496
+
497
+        for type_name, buffer_type in buffer_types.items():
498
+            v = vault.Vault(phrase=buffer_type(phrase.encode("utf-8")))
499
+            assert v.generate(str_service) == result, (
500
+                f"mismatched result when using the {type_name} "
501
+                "master passphrase"
502
+            )
503
+
504
+        for type_name, buffer_type in buffer_types.items():
505
+            v = vault.Vault(phrase=buffer_type(phrase.encode("utf-8")))
506
+            for type_name2, buffer_type2 in buffer_types.items():
507
+                assert v.generate(buffer_type2(bytes_service)) == result, (
508
+                    f"mismatched result when using the {type_name} "
509
+                    f"master passphrase and the {type_name2} service name"
537 510
                 )
538 511
 
539 512
 
... ...
@@ -627,16 +600,16 @@ class TestConstraintSatisfactionFromUpstream(TestVault):
627 600
             repeat=4,
628 601
         ).generate("abcdef")
629 602
         forbidden_substrings = {
630
-            b"0000",
631
-            b"1111",
632
-            b"2222",
633
-            b"3333",
634
-            b"4444",
635
-            b"5555",
636
-            b"6666",
637
-            b"7777",
638
-            b"8888",
639
-            b"9999",
603
+            b"00000",
604
+            b"11111",
605
+            b"22222",
606
+            b"33333",
607
+            b"44444",
608
+            b"55555",
609
+            b"66666",
610
+            b"77777",
611
+            b"88888",
612
+            b"99999",
640 613
         }
641 614
         for substring in forbidden_substrings:
642 615
             assert substring not in generated
... ...
@@ -772,7 +745,7 @@ class TestConstraintSatisfactionHeavyDuty(TestVault):
772 745
         config: dict[str, int],
773 746
         service: str,
774 747
     ) -> None:
775
-        """Derived passphrases obey character and occurrence restraints."""
748
+        """Derived passphrases obey character and occurrence constraints."""
776 749
         try:
777 750
             password = vault.Vault(phrase=phrase, **config).generate(service)
778 751
         except ValueError as exc:  # pragma: no cover
779 752