""" ©AngelaMos | 2026 test_hash_identifier.py Tests for hash_identifier — focused on the most-relied-on cases ──────────────────────────────────────────────────────────────────── What "tests" are and why we write them ──────────────────────────────────────────────────────────────────── A test is a tiny Python function that calls our real code with a known input and then ASSERTS that the result is what we expected. If the assertion fails, pytest prints a red FAIL message — which means we changed something and broke a behavior we cared about Tests are insurance. The first time you write the code, the test just confirms it works. But six months later when you refactor or add a new feature, the existing tests catch any accidental breakage. This is why every senior codebase has tests: not because the code is hard to write, but because the code is hard to keep WORKING over time ──────────────────────────────────────────────────────────────────── The shape of a pytest test ──────────────────────────────────────────────────────────────────── def test_() -> None: result = some_function(some_input) assert result == expected Three rules 1. The function name must start with `test_` — pytest only collects functions that match that pattern 2. The function takes no arguments (unless using fixtures) 3. Use the `assert` keyword to declare what should be true. If the condition is false, the test fails We follow the "Arrange-Act-Assert" structure inside each test - Arrange: set up inputs (the `sample = ...` line) - Act: call the real code (`candidates = identify(sample)`) - Assert: check the result (`assert candidates[0]...`) ──────────────────────────────────────────────────────────────────── Coverage strategy ──────────────────────────────────────────────────────────────────── We do NOT try to test every algorithm in the table — that would be hundreds of nearly identical tests. Instead we exercise each BRANCH of identify() at least once - prefix matches (one bcrypt, one Argon2id, one Django, one crypt) - special MySQL5 format - hex length matches (MD5, SHA-1, SHA-256 lengths) - the empty / garbage / whitespace fallbacks - HashCandidate's immutability Together these give us confidence that every code path runs without explosion, and that the most-common inputs produce the expected top-ranked candidate """ # Importing from `hash_identifier` (NOT `hash_identifier.py`) tells # Python to load the module that lives in this same directory. We # pull in three things: # - `identify` — the function under test # - `HashCandidate`— the return-type dataclass (used in the # immutability test) # - `PREFIX_RULES` — the prefix lookup table (used by the # parametrized "every row is covered" test # at the bottom of this file) # `pytest` is also imported so we can use the @pytest.mark.parametrize # decorator to expand one test function into many test cases # Third-party: the test runner itself. We also need it imported here # so we can use its `@pytest.mark.parametrize` decorator below. import pytest # Local: our own module. We pull in the public pieces under test — # the prefix-rule table, the result dataclass, and the entry function. from hash_identifier import PREFIX_RULES, HashCandidate, identify # ============================================================================= # Prefix matches (high confidence) # ============================================================================= # These tests verify Step 1 of identify(): when the input starts with a # known prefix, we report HIGH confidence. The exact hash payload after # the prefix does not matter to identify() — it only inspects the # leading characters def test_bcrypt_prefix_is_recognized() -> None: """ A real bcrypt hash starts with `$2b$` and should be reported as bcrypt """ # Sample: an actual bcrypt hash for the password "password" with cost 12. # The interesting part for our test is just `$2b$` — we never even # decode the rest sample = "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQNQy.uK4Of2T7G" # Call the function under test. `candidates` is a list of HashCandidate candidates = identify(sample) # First assert the list is non-empty. `assert ` fails when # is falsy — empty lists are falsy, so this catches the # "no candidates returned" bug assert candidates # Then check the FIRST candidate (highest-priority guess) is bcrypt. # `candidates[0]` is the first item; `.algorithm` is the field we # check. `==` compares for equality assert candidates[0].algorithm == "bcrypt" # And confidence must be "high" — prefix matches are definitive assert candidates[0].confidence == "high" def test_argon2id_prefix_is_recognized() -> None: """ Argon2id PHC strings begin with `$argon2id$` """ # PHC format for Argon2id: $argon2id$v=$m=...,t=...,p=...$$ sample = "$argon2id$v=19$m=65536,t=3,p=4$c2FsdHNhbHQ$aGFzaA" candidates = identify(sample) # `any(...)` returns True if at least one element of the iterable # makes the inner expression true. We check that AT LEAST ONE # candidate is Argon2id — using any() instead of [0] keeps the test # robust if we ever add a second guess to the same prefix assert any(c.algorithm == "Argon2id" for c in candidates) def test_sha512_crypt_prefix_is_recognized() -> None: """ `$6$` is the marker for SHA-512 crypt — what /etc/shadow uses on Linux """ sample = "$6$rounds=10000$salt$hashedpasswordhere" candidates = identify(sample) # `[0]` because we want the TOP candidate. If anything else was # ranked first, this assertion would fail loudly assert candidates[0].algorithm == "SHA-512 crypt" def test_django_pbkdf2_prefix_is_recognized() -> None: """ Django stores passwords as `pbkdf2_sha256$$$` """ sample = "pbkdf2_sha256$260000$salt$hash" candidates = identify(sample) assert candidates[0].algorithm == "Django PBKDF2-SHA256" def test_apr1_prefix_is_recognized() -> None: """ Apache `.htpasswd` MD5 hashes start with $apr1$ The htpasswd tool generates these by default with the `-m` flag. Same MD5 family as the Unix $1$ format but with Apache's own salt handling — and FAR more common in the wild because every Apache basic-auth tutorial ends with one of these in a .htpasswd file """ # Real-looking apr1 hash. The trailing payload after the second # `$` is the base64-flavored encoding of the MD5 digest + salt. # identify() never decodes it — only the leading `$apr1$` matters sample = "$apr1$rsalt$mp7TYYDvbgvNCJN3JTd6q1" candidates = identify(sample) assert candidates[0].algorithm == "Apache MD5-crypt" assert candidates[0].confidence == "high" # ============================================================================= # Special formats # ============================================================================= # Step 2 of identify(): formats that are NOT PHC strings but still have # unmistakable shapes. Today we recognize NetNTLMv1, NetNTLMv2, and # MySQL5 — three structurally-distinct records that all benefit from # the same "check the literal shape before falling back to length" path def test_mysql5_format_is_recognized() -> None: """ MySQL5 = literal `*` followed by 40 uppercase hex chars MySQL5 stores SHA-1(SHA-1(password)) printed in uppercase hex with a leading asterisk. So the whole hash is exactly 41 characters """ # The * matters — without it, this would just be 40 hex chars # and would fall through to the SHA-1 length rule sample = "*23AE809DDACAF96AF0FD78ED04B6A265E05AA257" candidates = identify(sample) # MySQL5 is a definitive shape, so we expect HIGH confidence assert candidates[0].algorithm == "MySQL5" assert candidates[0].confidence == "high" def test_mysql5_rejects_lowercase_body() -> None: """ Lowercase hex after the leading `*` is not real MySQL5 output MySQL emits uppercase via `%02X`, so a `*` followed by lowercase hex is almost certainly hand-edited junk rather than a real MySQL5 hash. We would rather return nothing than return a confident WRONG answer here """ # Lowercase version of the previous test's body. The leading `*` # is the only thing it shares with real MySQL5 output, and the # case mismatch alone should disqualify it lowercase_body = "23ae809ddacaf96af0fd78ed04b6a265e05aa257" candidates = identify("*" + lowercase_body) # Either the list is empty (preferred, what we expect today) OR # whatever did match must NOT be labeled MySQL5. The `if` # guard makes the test robust to future rules that might catch # this shape under a different (correct) label — what matters is # that we do not LIE and call it MySQL5 if candidates: assert candidates[0].algorithm != "MySQL5" def test_netntlmv2_format_is_recognized() -> None: """ NetNTLMv2 records from Responder look like `user::domain:challenge:hmac:blob` The hmac field is exactly 32 hex chars. The leading `::` (empty LM-hash slot) is the giveaway that this is an AD challenge- response record, not a stored password hash """ # Build a realistic NetNTLMv2 record: # alice :: CORP : <16-char challenge> : <32 hex hmac> : <64 hex blob> # The actual hash values do not matter — only the structural # shape (colon count + hex field lengths) is what identify() checks sample = "alice::CORP:1122334455667788:" + "a" * 32 + ":" + "b" * 64 candidates = identify(sample) # NetNTLMv2 is a definitive shape — HIGH confidence assert candidates[0].algorithm == "NetNTLMv2" assert candidates[0].confidence == "high" def test_netntlmv1_format_is_recognized() -> None: """ NetNTLMv1 records have 48-hex-char lmhash AND nthash before the challenge Layout: `user::domain:lm(48 hex):nt(48 hex):challenge`. We recognize this by looking at field index 3 — in NetNTLMv1 it is exactly 48 hex chars, while in NetNTLMv2 it is the (shorter) challenge field """ # Build a realistic NetNTLMv1 record. The 48-char lmhash and # nthash are the load-bearing signal; identify() never decodes # them — only their length and charset matter sample = "alice::CORP:" + "a" * 48 + ":" + "b" * 48 + ":1122334455667788" candidates = identify(sample) assert candidates[0].algorithm == "NetNTLMv1" assert candidates[0].confidence == "high" def test_descrypt_format_is_recognized() -> None: """ Traditional DES crypt has NO prefix — only length and charset identify it Legacy /etc/passwd from pre-shadow Unix systems used this format: 13 characters drawn from the alphabet `./0-9A-Za-z`. Rare today but still turns up in retro CTFs, and hashcat still ships a mode (1500) for cracking it """ # A realistic 13-char DES crypt output. Content does not matter # for the test — only the length and the all-valid-charset # property are what _is_descrypt checks sample = "kRq14pmccuMOA" candidates = identify(sample) assert candidates[0].algorithm == "DES crypt" # MEDIUM (not HIGH) confidence because a 13-char `./0-9A-Za-z` # string CAN technically be other things (session IDs, random # tokens). An honest medium beats a confident false-positive assert candidates[0].confidence == "medium" # ============================================================================= # Hex length matches (medium / low confidence) # ============================================================================= # Step 3 of identify(): when the input is pure hex, length narrows down # the algorithm. The FIRST listed algorithm for each length gets # medium confidence (the modern default); the rest are low def test_mysql323_length_returns_mysql323_first() -> None: """ 16 hex chars points at MySQL323 (legacy MySQL OLD_PASSWORD output) MySQL versions before 4.1 stored passwords as a 16-char hex string from a custom (now-broken) hash function. The OLD_PASSWORD() SQL function still produces this format on modern MySQL for legacy compatibility, so these still show up in CTFs and old MySQL breach dumps """ # A 16-hex-char string. The content does not matter — only the # length and the all-hex charset are what identify() checks sample = "5d2e19393cc5ef67" candidates = identify(sample) # MySQL323 ranks ABOVE CRC-64 because in a security context (a # CTF challenge, a breach dump, a password column) MySQL323 is # by far the more likely source. A 64-bit CRC almost never # arrives at a hash-identifier tool assert candidates[0].algorithm == "MySQL323" # MEDIUM confidence: length alone is suggestive but cannot rule # out CRC-64 with certainty without seeing the surrounding context assert candidates[0].confidence == "medium" def test_md5_length_returns_md5_first() -> None: """ 32 hex chars matches MD5, NTLM, MD4, RIPEMD-128 MD5 is BY FAR the most common 32-hex hash in the wild, so it must be the top candidate. NTLM (a Windows hash) is also 32 hex but far less common in pasted hash dumps, so it should appear later """ # The literal MD5 of the string "password". A useful sample because # any reader can verify it: `echo -n password | md5sum` sample = "5f4dcc3b5aa765d61d8327deb882cf99" candidates = identify(sample) # Top candidate is MD5 assert candidates[0].algorithm == "MD5" # And we report MEDIUM confidence — length alone is suggestive # but not definitive (no prefix to confirm) assert candidates[0].confidence == "medium" # NTLM should appear in the candidate list as a less-likely option. # We pull just the algorithm names into a list using a comprehension, # then check membership with `in` algorithms = [c.algorithm for c in candidates] assert "NTLM" in algorithms def test_sha256_length_returns_sha256_first() -> None: """ 64 hex chars points at SHA-256 first SHA3-256 and BLAKE2s also produce 64 hex chars but are rarer in real systems, so SHA-256 takes the top spot """ # `"a" * 64` is Python shorthand for "the character 'a' repeated 64 # times" — a quick way to make a 64-char string for length tests. # The actual content does not matter; only the length does sample = "a" * 64 candidates = identify(sample) assert candidates[0].algorithm == "SHA-256" def test_sha1_length_returns_sha1_first() -> None: """ 40 hex chars = SHA-1 (RIPEMD-160 as a backup guess) """ sample = "a" * 40 candidates = identify(sample) assert candidates[0].algorithm == "SHA-1" # ============================================================================= # No-match / edge cases # ============================================================================= # Always test the boring edge cases. Empty inputs, whitespace-only, # garbage. These caused real bugs in real codebases more than once def test_empty_input_returns_no_candidates() -> None: """ Empty string returns an empty list — never blows up """ # Two checks because both of these have caused crashes elsewhere: # truly empty AND whitespace-only (which strips down to empty) assert identify("") == [] assert identify(" ") == [] def test_garbage_returns_no_candidates() -> None: """ A string with neither a known prefix nor a hex shape returns [] """ # Sentence with spaces and punctuation: cannot be hex, has no # PHC prefix. identify() should return an empty list, not guess assert identify("hello, this is not a hash") == [] def test_input_is_trimmed_of_whitespace() -> None: """ Trailing newlines and leading spaces should not block recognition This matters because copy-paste from a terminal often picks up invisible whitespace. We strip it inside identify() """ # Note the leading spaces and trailing \n — escaped newline character sample = " 5f4dcc3b5aa765d61d8327deb882cf99\n" candidates = identify(sample) # If trim works, we still recognize MD5 despite the surrounding noise assert candidates[0].algorithm == "MD5" # ============================================================================= # Soft-match fallbacks (shape hints, LOW confidence) # ============================================================================= # Steps 4 and 5 of identify(): when nothing in the PREFIX_RULES table # or the special-formats step or the hex-length table fires, we still # try two soft matches — generic PHC string shape, then "this looks # like a JWT / base64 blob, not a hash." Both return LOW confidence # because shape alone is a weaker signal than a known prefix def test_unknown_phc_string_falls_back_to_generic() -> None: """ A PHC string from an algorithm we don't have a specific rule for is still reported as a PHC string with the extracted algorithm name This is a SOFT match (LOW confidence) but still beats the alternative of returning nothing on an obviously-PHC-shaped input """ # Passlib's pbkdf2-sha512 PHC encoding. We have no specific rule # for it in PREFIX_RULES — but the `$pbkdf2-sha512$...` shape is # unambiguous, so the generic fallback should pick it up sample = "$pbkdf2-sha512$25000$cnNhbHQ$aGFzaA" candidates = identify(sample) assert candidates # The algorithm column should say "PHC string (pbkdf2-sha512)" — # both the marker word "PHC" and the extracted algorithm name # should appear so the user knows WHAT kind of thing they pasted assert "PHC" in candidates[0].algorithm assert "pbkdf2-sha512" in candidates[0].algorithm # LOW confidence because we matched on shape only, not on a # specific rule that would let us confirm the algorithm assert candidates[0].confidence == "low" def test_jwt_input_is_called_out_as_not_a_hash() -> None: """ JWTs start with `eyJ` and should be called out as not-a-hash Beginners often paste JWTs into a hash identifier because they look hash-like. Saying "this is a JWT, not a hash" is more useful than silence — it teaches the user what they have """ # A short but real-shaped JWT (header.payload.signature). The # signature here is intentionally not real — identify() never # validates it, only the leading `eyJ` matters sample = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.sig" candidates = identify(sample) assert candidates # The algorithm column should contain "JWT" so the user knows # exactly what kind of thing they pasted assert "JWT" in candidates[0].algorithm # LOW confidence is honest: we are not 100% sure this is a JWT, # we are 100% sure it starts with `eyJ` and JWTs start with `eyJ` assert candidates[0].confidence == "low" def test_base64_blob_is_called_out_as_not_a_hash() -> None: """ A string containing base64-only chars (`+`, `/`, `=`) is not hex Hex hashes never contain those characters, so their presence is a strong signal that the input is base64-encoded data of some kind rather than a hash — even when we cannot say what it decodes to """ # A base64-looking blob with the `=` padding character sample = "VGhpcyBpcyBub3QgYSBoYXNoLCBpdHMgYmFzZTY0Lg==" candidates = identify(sample) assert candidates # "Base64 blob" should appear in the algorithm name so the user # knows we did identify SOMETHING, just not a hash assert "Base64" in candidates[0].algorithm # ============================================================================= # HashCandidate is immutable # ============================================================================= # We declared HashCandidate with @dataclass(frozen=True). Frozen means # you cannot reassign fields after construction — like a sealed envelope. # This test makes sure the seal actually holds, so future refactors do # not accidentally drop the `frozen` flag def test_hash_candidate_is_frozen() -> None: """ Attempting to mutate a HashCandidate must raise an error """ # First, construct a normal instance candidate = HashCandidate( algorithm = "MD5", confidence = "medium", reason = "test", ) # try/except is Python's "guard against an error" syntax. Inside # `try`, we attempt the operation we EXPECT to fail. The `except` # block runs ONLY if the listed exception types fire — and our # frozen dataclass raises one of these on assignment try: # `# type: ignore[misc]` tells mypy "I know this is a type error; # I am doing it on purpose to verify it actually fails at runtime" candidate.algorithm = "SHA-1" # type: ignore[misc] except (AttributeError, TypeError): # Got the expected exception — the test passes by returning return # If we reached this line, no exception was raised — frozen is # broken. Fail the test loudly with a clear message raise AssertionError( "HashCandidate should be frozen; assignment should have raised" ) # ============================================================================= # Comprehensive PREFIX_RULES table coverage # ============================================================================= # The individual prefix tests above (bcrypt, Argon2id, etc.) double as # readable EXAMPLES of how identify() handles known prefixes. This last # test plays a different role: it is the safety-net that guarantees # EVERY row of PREFIX_RULES is exercised, so a typo in any single row's # algorithm name or note string fails its own test case rather than # slipping through silently. # # `@pytest.mark.parametrize(name, values)` is pytest's mechanism for # expanding ONE test function into MANY test cases. Here we hand it # the entire PREFIX_RULES list. Pytest unpacks each `(prefix, algorithm, # note)` tuple into the three parameters of the test function and runs # the body once per row. The leading `_` in `_note` tells linters # (and the next reader) that we intentionally do not assert on the # note string — we accept whatever the table author wrote @pytest.mark.parametrize("prefix,algorithm,_note", PREFIX_RULES) def test_every_prefix_rule_is_recognized_with_high_confidence( prefix: str, algorithm: str, _note: str, ) -> None: """ Every entry in PREFIX_RULES produces a HIGH-confidence candidate with the matching algorithm when its prefix sits at the start of the input The body of the hash after the prefix does not matter to identify() — it only inspects the leading characters — so we just glue any junk onto the end to form a syntactically-plausible input """ sample = prefix + "fakebodydoesntmatter" candidates = identify(sample) # If identify() returned nothing, the prefix-loop branch is # broken — fail with a message that names the offending prefix # so the failure is debuggable at a glance assert candidates, f"no candidates returned for prefix `{prefix}`" assert candidates[0].algorithm == algorithm assert candidates[0].confidence == "high"