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 |