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 |