Skip to content

Commit 012edea

Browse files
committed
test: add table-driven tests for Hasher class (59% → 98%)
1 parent d4c7565 commit 012edea

1 file changed

Lines changed: 347 additions & 0 deletions

File tree

tests/test_patterns/test_hasher.py

Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
"""Table-driven tests for the Hasher class."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from har_capture.patterns.hasher import Hasher
8+
9+
10+
# ┌─────────────────────────────────────────────────────────────────────────────┐
11+
# │ Hasher.create() test cases │
12+
# ├──────────────┬─────────────────────┬────────────────────────────────────────┤
13+
# │ salt_input │ expected_salt_type │ description │
14+
# ├──────────────┼─────────────────────┼────────────────────────────────────────┤
15+
# │ "auto" │ str (32 hex chars) │ generates random salt │
16+
# │ "random" │ str (32 hex chars) │ alias for auto │
17+
# │ None │ None │ static placeholders mode │
18+
# │ "my-salt" │ "my-salt" │ custom salt preserved │
19+
# │ "" │ "" │ empty string is valid salt │
20+
# └──────────────┴─────────────────────┴────────────────────────────────────────┘
21+
#
22+
# fmt: off
23+
CREATE_CASES = [
24+
("auto", "random_hex", "generates random salt"),
25+
("random", "random_hex", "alias for auto"),
26+
(None, None, "static placeholders mode"),
27+
("my-salt", "my-salt", "custom salt preserved"),
28+
("", "", "empty string is valid salt"),
29+
]
30+
# fmt: on
31+
32+
33+
@pytest.mark.parametrize("salt_input,expected,desc", CREATE_CASES)
34+
def test_hasher_create(salt_input: str | None, expected: str | None, desc: str) -> None:
35+
"""Test Hasher.create() with various salt options."""
36+
hasher = Hasher.create(salt=salt_input)
37+
38+
if expected == "random_hex":
39+
assert hasher.salt is not None
40+
assert len(hasher.salt) == 32 # 16 bytes = 32 hex chars
41+
assert all(c in "0123456789abcdef" for c in hasher.salt)
42+
else:
43+
assert hasher.salt == expected
44+
45+
46+
# ┌─────────────────────────────────────────────────────────────────────────────┐
47+
# │ hash_mac() test cases │
48+
# ├─────────────────────────┬───────────────────┬───────────────────────────────┤
49+
# │ input │ expected_prefix │ description │
50+
# ├─────────────────────────┼───────────────────┼───────────────────────────────┤
51+
# │ "AA:BB:CC:DD:EE:FF" │ "02:" │ standard colon format │
52+
# │ "aa:bb:cc:dd:ee:ff" │ "02:" │ lowercase │
53+
# │ "AA-BB-CC-DD-EE-FF" │ "02:" │ dash separator │
54+
# │ "AABBCCDDEEFF" │ "02:" │ no separator │
55+
# │ "aabbccddeeff" │ "02:" │ lowercase no separator │
56+
# └─────────────────────────┴───────────────────┴───────────────────────────────┘
57+
#
58+
# fmt: off
59+
MAC_CASES = [
60+
("AA:BB:CC:DD:EE:FF", "02:", "standard colon format"),
61+
("aa:bb:cc:dd:ee:ff", "02:", "lowercase"),
62+
("AA-BB-CC-DD-EE-FF", "02:", "dash separator"),
63+
("AABBCCDDEEFF", "02:", "no separator (normalized)"),
64+
("aabbccddeeff", "02:", "lowercase no separator"),
65+
]
66+
# fmt: on
67+
68+
69+
@pytest.mark.parametrize("mac_input,expected_prefix,desc", MAC_CASES)
70+
def test_hash_mac_with_salt(mac_input: str, expected_prefix: str, desc: str) -> None:
71+
"""Test hash_mac() produces format-preserving output with salt."""
72+
hasher = Hasher.create(salt="test-salt")
73+
result = hasher.hash_mac(mac_input)
74+
75+
assert result.startswith(expected_prefix), f"Expected prefix {expected_prefix}, got {result}"
76+
# Should be valid MAC format: 02:xx:xx:xx:xx:xx
77+
parts = result.split(":")
78+
assert len(parts) == 6
79+
assert all(len(p) == 2 for p in parts)
80+
81+
82+
def test_hash_mac_without_salt() -> None:
83+
"""Test hash_mac() returns static placeholder without salt."""
84+
hasher = Hasher.create(salt=None)
85+
result = hasher.hash_mac("AA:BB:CC:DD:EE:FF")
86+
assert result == "XX:XX:XX:XX:XX:XX"
87+
88+
89+
def test_hash_mac_consistency() -> None:
90+
"""Test same MAC produces same hash with same salt."""
91+
hasher = Hasher.create(salt="consistent")
92+
result1 = hasher.hash_mac("AA:BB:CC:DD:EE:FF")
93+
result2 = hasher.hash_mac("AA:BB:CC:DD:EE:FF")
94+
assert result1 == result2
95+
96+
97+
def test_hash_mac_different_values() -> None:
98+
"""Test different MACs produce different hashes."""
99+
hasher = Hasher.create(salt="test")
100+
result1 = hasher.hash_mac("AA:BB:CC:DD:EE:FF")
101+
result2 = hasher.hash_mac("11:22:33:44:55:66")
102+
assert result1 != result2
103+
104+
105+
def test_hash_mac_normalization() -> None:
106+
"""Test different formats of same MAC produce same hash."""
107+
hasher = Hasher.create(salt="normalize")
108+
result1 = hasher.hash_mac("AA:BB:CC:DD:EE:FF")
109+
result2 = hasher.hash_mac("aa:bb:cc:dd:ee:ff")
110+
result3 = hasher.hash_mac("AA-BB-CC-DD-EE-FF")
111+
assert result1 == result2 == result3
112+
113+
114+
# ┌─────────────────────────────────────────────────────────────────────────────┐
115+
# │ hash_ip() test cases │
116+
# ├─────────────────────────┬────────────┬──────────────┬───────────────────────┤
117+
# │ input │ is_private │ expected_pfx │ description │
118+
# ├─────────────────────────┼────────────┼──────────────┼───────────────────────┤
119+
# │ "192.168.1.1" │ True │ "10.255." │ private IP │
120+
# │ "10.0.0.1" │ True │ "10.255." │ private 10.x │
121+
# │ "172.16.0.1" │ True │ "10.255." │ private 172.x │
122+
# │ "8.8.8.8" │ False │ "192.0.2." │ public IP │
123+
# │ "1.1.1.1" │ False │ "192.0.2." │ cloudflare DNS │
124+
# └─────────────────────────┴────────────┴──────────────┴───────────────────────┘
125+
#
126+
# fmt: off
127+
IP_CASES = [
128+
("192.168.1.1", True, "10.255.", "private IP"),
129+
("10.0.0.1", True, "10.255.", "private 10.x"),
130+
("172.16.0.1", True, "10.255.", "private 172.x"),
131+
("8.8.8.8", False, "192.0.2.", "public IP (Google DNS)"),
132+
("1.1.1.1", False, "192.0.2.", "public IP (Cloudflare)"),
133+
]
134+
# fmt: on
135+
136+
137+
@pytest.mark.parametrize("ip_input,is_private,expected_prefix,desc", IP_CASES)
138+
def test_hash_ip_with_salt(ip_input: str, is_private: bool, expected_prefix: str, desc: str) -> None:
139+
"""Test hash_ip() produces format-preserving output."""
140+
hasher = Hasher.create(salt="test-salt")
141+
result = hasher.hash_ip(ip_input, is_private=is_private)
142+
143+
assert result.startswith(expected_prefix), f"Expected prefix {expected_prefix}, got {result}"
144+
# Should be valid IP format
145+
parts = result.split(".")
146+
assert len(parts) == 4
147+
assert all(p.isdigit() and 0 <= int(p) <= 255 for p in parts)
148+
149+
150+
def test_hash_ip_without_salt() -> None:
151+
"""Test hash_ip() returns static placeholder without salt."""
152+
hasher = Hasher.create(salt=None)
153+
assert hasher.hash_ip("192.168.1.1", is_private=True) == "0.0.0.0"
154+
assert hasher.hash_ip("8.8.8.8", is_private=False) == "0.0.0.0"
155+
156+
157+
def test_hash_ip_caching() -> None:
158+
"""Test IP hashing uses cache for same values."""
159+
hasher = Hasher.create(salt="cache-test")
160+
result1 = hasher.hash_ip("192.168.1.1", is_private=True)
161+
result2 = hasher.hash_ip("192.168.1.1", is_private=True)
162+
assert result1 == result2
163+
# Verify it's in the cache
164+
assert "PRIV_IP:192.168.1.1" in hasher._cache
165+
166+
167+
# ┌─────────────────────────────────────────────────────────────────────────────┐
168+
# │ hash_ipv6() test cases │
169+
# ├─────────────────────────────────────┬───────────────┬───────────────────────┤
170+
# │ input │ expected_pfx │ description │
171+
# ├─────────────────────────────────────┼───────────────┼───────────────────────┤
172+
# │ "fe80::1" │ "2001:db8::" │ link-local │
173+
# │ "2001:4860:4860::8888" │ "2001:db8::" │ Google DNS IPv6 │
174+
# │ "::1" │ "2001:db8::" │ localhost │
175+
# │ "fd00::1234:5678" │ "2001:db8::" │ unique local │
176+
# └─────────────────────────────────────┴───────────────┴───────────────────────┘
177+
#
178+
# fmt: off
179+
IPV6_CASES = [
180+
("fe80::1", "2001:db8::", "link-local address"),
181+
("2001:4860:4860::8888", "2001:db8::", "Google DNS IPv6"),
182+
("::1", "2001:db8::", "localhost"),
183+
("fd00::1234:5678", "2001:db8::", "unique local address"),
184+
]
185+
# fmt: on
186+
187+
188+
@pytest.mark.parametrize("ipv6_input,expected_prefix,desc", IPV6_CASES)
189+
def test_hash_ipv6_with_salt(ipv6_input: str, expected_prefix: str, desc: str) -> None:
190+
"""Test hash_ipv6() produces format-preserving output."""
191+
hasher = Hasher.create(salt="test-salt")
192+
result = hasher.hash_ipv6(ipv6_input)
193+
194+
assert result.startswith(expected_prefix), f"Expected prefix {expected_prefix}, got {result}"
195+
196+
197+
def test_hash_ipv6_without_salt() -> None:
198+
"""Test hash_ipv6() returns static placeholder without salt."""
199+
hasher = Hasher.create(salt=None)
200+
result = hasher.hash_ipv6("fe80::1")
201+
assert result == "::"
202+
203+
204+
def test_hash_ipv6_format() -> None:
205+
"""Test hash_ipv6() produces valid IPv6 format."""
206+
hasher = Hasher.create(salt="format-test")
207+
result = hasher.hash_ipv6("fe80::1")
208+
209+
# Should be in format 2001:db8::xxxx:xxxx
210+
assert result.startswith("2001:db8::")
211+
suffix = result.replace("2001:db8::", "")
212+
parts = suffix.split(":")
213+
assert len(parts) == 2
214+
assert all(len(p) == 4 for p in parts)
215+
216+
217+
# ┌─────────────────────────────────────────────────────────────────────────────┐
218+
# │ hash_email() test cases │
219+
# ├─────────────────────────────────────┬───────────────────────────────────────┤
220+
# │ input │ description │
221+
# ├─────────────────────────────────────┼───────────────────────────────────────┤
222+
# │ "user@example.com" │ standard email │
223+
# │ "USER@EXAMPLE.COM" │ uppercase (normalized) │
224+
# │ "test.user+tag@domain.co.uk" │ complex email │
225+
# │ "a@b.c" │ minimal valid email │
226+
# └─────────────────────────────────────┴───────────────────────────────────────┘
227+
#
228+
# fmt: off
229+
EMAIL_CASES = [
230+
("user@example.com", "standard email"),
231+
("USER@EXAMPLE.COM", "uppercase (normalized)"),
232+
("test.user+tag@domain.co.uk", "complex email"),
233+
("a@b.c", "minimal valid email"),
234+
]
235+
# fmt: on
236+
237+
238+
@pytest.mark.parametrize("email_input,desc", EMAIL_CASES)
239+
def test_hash_email_with_salt(email_input: str, desc: str) -> None:
240+
"""Test hash_email() produces format-preserving output."""
241+
hasher = Hasher.create(salt="test-salt")
242+
result = hasher.hash_email(email_input)
243+
244+
assert result.startswith("user_"), f"Expected user_ prefix, got {result}"
245+
assert result.endswith("@redacted.invalid"), f"Expected @redacted.invalid suffix, got {result}"
246+
247+
248+
def test_hash_email_without_salt() -> None:
249+
"""Test hash_email() returns static placeholder without salt."""
250+
hasher = Hasher.create(salt=None)
251+
result = hasher.hash_email("user@example.com")
252+
assert result == "x@x.invalid"
253+
254+
255+
def test_hash_email_normalization() -> None:
256+
"""Test emails are normalized to lowercase before hashing."""
257+
hasher = Hasher.create(salt="normalize")
258+
result1 = hasher.hash_email("User@Example.COM")
259+
result2 = hasher.hash_email("user@example.com")
260+
assert result1 == result2
261+
262+
263+
# ┌─────────────────────────────────────────────────────────────────────────────┐
264+
# │ hash_value() / hash_generic() test cases │
265+
# ├────────────────┬─────────────┬──────────────────────────────────────────────┤
266+
# │ value │ prefix │ description │
267+
# ├────────────────┼─────────────┼──────────────────────────────────────────────┤
268+
# │ "ABC123" │ "SERIAL" │ serial number │
269+
# │ "secret-token" │ "TOKEN" │ auth token │
270+
# │ "password123" │ "PASS" │ password │
271+
# │ "" │ "EMPTY" │ empty value │
272+
# └────────────────┴─────────────┴──────────────────────────────────────────────┘
273+
#
274+
# fmt: off
275+
GENERIC_CASES = [
276+
("ABC123", "SERIAL", "serial number"),
277+
("secret-token", "TOKEN", "auth token"),
278+
("password123", "PASS", "password"),
279+
("", "EMPTY", "empty value"),
280+
]
281+
# fmt: on
282+
283+
284+
@pytest.mark.parametrize("value,prefix,desc", GENERIC_CASES)
285+
def test_hash_value_with_salt(value: str, prefix: str, desc: str) -> None:
286+
"""Test hash_value() produces prefixed hashed output."""
287+
hasher = Hasher.create(salt="test-salt")
288+
result = hasher.hash_value(value, prefix)
289+
290+
assert result.startswith(f"{prefix}_"), f"Expected {prefix}_ prefix, got {result}"
291+
# Should have 8 hex chars after underscore
292+
hash_part = result.split("_")[1]
293+
assert len(hash_part) == 8
294+
assert all(c in "0123456789abcdef" for c in hash_part)
295+
296+
297+
@pytest.mark.parametrize("value,prefix,desc", GENERIC_CASES)
298+
def test_hash_value_without_salt(value: str, prefix: str, desc: str) -> None:
299+
"""Test hash_value() returns static placeholder without salt."""
300+
hasher = Hasher.create(salt=None)
301+
result = hasher.hash_value(value, prefix)
302+
assert result == f"***{prefix}***"
303+
304+
305+
def test_hash_generic_is_alias() -> None:
306+
"""Test hash_generic() is an alias for hash_value()."""
307+
hasher = Hasher.create(salt="test")
308+
result1 = hasher.hash_value("test", "PREFIX")
309+
result2 = hasher.hash_generic("test", "PREFIX")
310+
assert result1 == result2
311+
312+
313+
class TestHasherCaching:
314+
"""Tests for hasher internal caching behavior."""
315+
316+
def test_cache_populated_on_hash(self) -> None:
317+
"""Test cache is populated after hashing."""
318+
hasher = Hasher.create(salt="cache-test")
319+
assert len(hasher._cache) == 0
320+
321+
hasher.hash_mac("AA:BB:CC:DD:EE:FF")
322+
assert len(hasher._cache) == 1
323+
324+
hasher.hash_ip("192.168.1.1", is_private=True)
325+
assert len(hasher._cache) == 2
326+
327+
def test_cache_hit_returns_same_value(self) -> None:
328+
"""Test cache hit returns identical value."""
329+
hasher = Hasher.create(salt="cache-hit")
330+
331+
# First call populates cache
332+
result1 = hasher.hash_email("test@example.com")
333+
334+
# Second call should hit cache
335+
result2 = hasher.hash_email("test@example.com")
336+
337+
assert result1 is result2 # Same object, not just equal
338+
339+
def test_different_salts_produce_different_hashes(self) -> None:
340+
"""Test different salts produce different results."""
341+
hasher1 = Hasher.create(salt="salt1")
342+
hasher2 = Hasher.create(salt="salt2")
343+
344+
result1 = hasher1.hash_mac("AA:BB:CC:DD:EE:FF")
345+
result2 = hasher2.hash_mac("AA:BB:CC:DD:EE:FF")
346+
347+
assert result1 != result2

0 commit comments

Comments
 (0)