Skip to content

Commit a114bef

Browse files
committed
test: add table-driven tests for validation/secrets (63% → 72%)
1 parent 012edea commit a114bef

2 files changed

Lines changed: 327 additions & 0 deletions

File tree

tests/test_validation/__init__.py

Whitespace-only changes.
Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
"""Table-driven tests for the validation/secrets module."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from har_capture.validation.secrets import (
8+
Finding,
9+
check_headers,
10+
check_post_data,
11+
is_cookie_attributes_only,
12+
is_private_ip,
13+
is_redacted,
14+
truncate,
15+
)
16+
17+
18+
# ┌─────────────────────────────────────────────────────────────────────────────┐
19+
# │ is_redacted() test cases │
20+
# ├─────────────────────────────────────┬───────────┬───────────────────────────┤
21+
# │ value │ expected │ description │
22+
# ├─────────────────────────────────────┼───────────┼───────────────────────────┤
23+
# │ "[REDACTED]" │ True │ standard redaction │
24+
# │ "REDACTED" │ True │ plain REDACTED │
25+
# │ "xxx REDACTED xxx" │ True │ contains REDACTED │
26+
# │ "XXXX" │ True │ X placeholder │
27+
# │ "XXXXXXXXXX" │ True │ long X placeholder │
28+
# │ "000000" │ True │ all zeros (6+) │
29+
# │ "0000000000" │ True │ all zeros (10) │
30+
# │ "XX:XX:XX:XX:XX:XX" │ True │ redacted MAC │
31+
# │ "0.0.0.0" │ True │ zero IP (allowlisted) │
32+
# │ "::" │ True │ empty IPv6 (allowlisted) │
33+
# │ "x@x.invalid" │ True │ redacted email │
34+
# │ "real-secret-value" │ False │ actual secret │
35+
# │ "Bearer token123" │ False │ auth token │
36+
# │ "password123" │ False │ password │
37+
# │ "00000" │ False │ 5 zeros (not enough) │
38+
# │ "XX" │ False │ 2 X's (not enough) │
39+
# └─────────────────────────────────────┴───────────┴───────────────────────────┘
40+
#
41+
# fmt: off
42+
REDACTED_CASES = [
43+
("[REDACTED]", True, "standard redaction"),
44+
("REDACTED", True, "plain REDACTED"),
45+
("xxx REDACTED xxx", True, "contains REDACTED"),
46+
("XXXX", True, "X placeholder (4+)"),
47+
("XXXXXXXXXX", True, "long X placeholder"),
48+
("000000", True, "all zeros (6)"),
49+
("0000000000", True, "all zeros (10)"),
50+
("XX:XX:XX:XX:XX:XX", True, "redacted MAC"),
51+
("0.0.0.0", True, "zero IP (allowlisted)"),
52+
("::", True, "empty IPv6 (allowlisted)"),
53+
("x@x.invalid", True, "redacted email"),
54+
("real-secret-value", False, "actual secret"),
55+
("Bearer token123", False, "auth token"),
56+
("password123", False, "password"),
57+
("00000", False, "5 zeros (not enough)"),
58+
]
59+
# fmt: on
60+
61+
62+
@pytest.mark.parametrize("value,expected,desc", REDACTED_CASES)
63+
def test_is_redacted(value: str, expected: bool, desc: str) -> None:
64+
"""Test is_redacted() with various values."""
65+
result = is_redacted(value)
66+
assert result == expected, f"Failed for {desc}: {value}"
67+
68+
69+
# ┌─────────────────────────────────────────────────────────────────────────────┐
70+
# │ is_cookie_attributes_only() test cases │
71+
# ├─────────────────────────────────────┬───────────┬───────────────────────────┤
72+
# │ value │ expected │ description │
73+
# ├─────────────────────────────────────┼───────────┼───────────────────────────┤
74+
# │ "Secure" │ True │ just Secure │
75+
# │ "HttpOnly" │ True │ just HttpOnly │
76+
# │ "Secure; HttpOnly" │ True │ both attributes │
77+
# │ "HttpOnly; Secure" │ True │ both reversed │
78+
# │ "" │ True │ empty string │
79+
# │ " " │ True │ whitespace only │
80+
# │ "session=abc123" │ False │ actual session value │
81+
# │ "Secure; session=abc" │ False │ attributes + value │
82+
# │ "token=xyz; HttpOnly" │ False │ value + attributes │
83+
# └─────────────────────────────────────┴───────────┴───────────────────────────┘
84+
#
85+
# fmt: off
86+
COOKIE_ATTR_CASES = [
87+
("Secure", True, "just Secure"),
88+
("HttpOnly", True, "just HttpOnly"),
89+
("Secure; HttpOnly", True, "both attributes"),
90+
("HttpOnly; Secure", True, "both reversed"),
91+
("", True, "empty string"),
92+
(" ", True, "whitespace only"),
93+
("session=abc123", False, "actual session value"),
94+
("Secure; session=abc", False, "attributes + value"),
95+
("token=xyz; HttpOnly", False, "value + attributes"),
96+
]
97+
# fmt: on
98+
99+
100+
@pytest.mark.parametrize("value,expected,desc", COOKIE_ATTR_CASES)
101+
def test_is_cookie_attributes_only(value: str, expected: bool, desc: str) -> None:
102+
"""Test is_cookie_attributes_only() with various values."""
103+
result = is_cookie_attributes_only(value)
104+
assert result == expected, f"Failed for {desc}: {value}"
105+
106+
107+
# ┌─────────────────────────────────────────────────────────────────────────────┐
108+
# │ is_private_ip() test cases │
109+
# ├─────────────────────────────────────┬───────────┬───────────────────────────┤
110+
# │ ip │ expected │ description │
111+
# ├─────────────────────────────────────┼───────────┼───────────────────────────┤
112+
# │ "10.0.0.1" │ True │ 10.x.x.x range │
113+
# │ "10.255.255.255" │ True │ 10.x end │
114+
# │ "172.16.0.1" │ True │ 172.16.x start │
115+
# │ "172.31.255.255" │ True │ 172.31.x end │
116+
# │ "192.168.0.1" │ True │ 192.168.x.x │
117+
# │ "192.168.100.1" │ True │ 192.168.x.x │
118+
# │ "127.0.0.1" │ True │ localhost │
119+
# │ "0.0.0.0" │ True │ all zeros (redacted) │
120+
# │ "8.8.8.8" │ False │ Google DNS (public) │
121+
# │ "1.1.1.1" │ False │ Cloudflare (public) │
122+
# │ "172.15.0.1" │ False │ just outside 172.16-31 │
123+
# │ "172.32.0.1" │ False │ just outside 172.16-31 │
124+
# │ "192.167.0.1" │ False │ not 192.168 │
125+
# │ "invalid" │ False │ not an IP │
126+
# │ "256.0.0.1" │ False │ invalid octet │
127+
# │ "1.2.3" │ False │ too few octets │
128+
# └─────────────────────────────────────┴───────────┴───────────────────────────┘
129+
#
130+
# fmt: off
131+
PRIVATE_IP_CASES = [
132+
("10.0.0.1", True, "10.x.x.x range"),
133+
("10.255.255.255", True, "10.x end"),
134+
("172.16.0.1", True, "172.16.x start"),
135+
("172.31.255.255", True, "172.31.x end"),
136+
("192.168.0.1", True, "192.168.x.x"),
137+
("192.168.100.1", True, "192.168.x.x"),
138+
("127.0.0.1", True, "localhost"),
139+
("0.0.0.0", True, "all zeros (redacted)"),
140+
("8.8.8.8", False, "Google DNS (public)"),
141+
("1.1.1.1", False, "Cloudflare (public)"),
142+
("172.15.0.1", False, "just outside 172.16-31"),
143+
("172.32.0.1", False, "just outside 172.16-31"),
144+
("192.167.0.1", False, "not 192.168"),
145+
("invalid", False, "not an IP"),
146+
("256.0.0.1", False, "invalid octet"),
147+
("1.2.3", False, "too few octets"),
148+
]
149+
# fmt: on
150+
151+
152+
@pytest.mark.parametrize("ip,expected,desc", PRIVATE_IP_CASES)
153+
def test_is_private_ip(ip: str, expected: bool, desc: str) -> None:
154+
"""Test is_private_ip() with various IPs."""
155+
result = is_private_ip(ip)
156+
assert result == expected, f"Failed for {desc}: {ip}"
157+
158+
159+
# ┌─────────────────────────────────────────────────────────────────────────────┐
160+
# │ truncate() test cases │
161+
# ├─────────────────────────────────────┬───────────┬───────────────────────────┤
162+
# │ value │ max_len │ expected_suffix │
163+
# ├─────────────────────────────────────┼───────────┼───────────────────────────┤
164+
# │ "short" │ 40 │ "short" (unchanged) │
165+
# │ "x" * 40 │ 40 │ exact (unchanged) │
166+
# │ "x" * 50 │ 40 │ ends with "..." │
167+
# │ "abcdefghij" │ 5 │ "ab..." │
168+
# └─────────────────────────────────────┴───────────┴───────────────────────────┘
169+
#
170+
# fmt: off
171+
TRUNCATE_CASES = [
172+
("short", 40, "short", "under limit unchanged"),
173+
("x" * 40, 40, "x" * 40, "exact limit unchanged"),
174+
("x" * 50, 40, "x" * 37 + "...", "over limit truncated"),
175+
("abcdefghij", 5, "ab...", "custom limit"),
176+
]
177+
# fmt: on
178+
179+
180+
@pytest.mark.parametrize("value,max_len,expected,desc", TRUNCATE_CASES)
181+
def test_truncate(value: str, max_len: int, expected: str, desc: str) -> None:
182+
"""Test truncate() with various values."""
183+
result = truncate(value, max_len)
184+
assert result == expected, f"Failed for {desc}"
185+
186+
187+
class TestCheckHeaders:
188+
"""Tests for check_headers() function."""
189+
190+
def test_detects_authorization_header(self) -> None:
191+
"""Test detection of Authorization header with real value."""
192+
headers = [{"name": "Authorization", "value": "Bearer secret-token"}]
193+
findings: list[Finding] = []
194+
check_headers(headers, "request", findings)
195+
assert len(findings) == 1
196+
assert findings[0].severity == "error"
197+
assert "authorization" in findings[0].reason.lower()
198+
199+
def test_ignores_redacted_authorization(self) -> None:
200+
"""Test redacted Authorization header is not flagged."""
201+
headers = [{"name": "Authorization", "value": "[REDACTED]"}]
202+
findings: list[Finding] = []
203+
check_headers(headers, "request", findings)
204+
assert len(findings) == 0
205+
206+
def test_detects_cookie_header(self) -> None:
207+
"""Test detection of Cookie header with session data."""
208+
headers = [{"name": "Cookie", "value": "session=abc123xyz"}]
209+
findings: list[Finding] = []
210+
check_headers(headers, "request", findings)
211+
assert len(findings) == 1
212+
assert "Cookie" in findings[0].field or "cookie" in findings[0].reason.lower()
213+
214+
def test_ignores_cookie_attributes_only(self) -> None:
215+
"""Test Cookie with only attributes is not flagged."""
216+
headers = [{"name": "Cookie", "value": "Secure; HttpOnly"}]
217+
findings: list[Finding] = []
218+
check_headers(headers, "request", findings)
219+
assert len(findings) == 0
220+
221+
def test_ignores_empty_header_value(self) -> None:
222+
"""Test empty header value is not flagged."""
223+
headers = [{"name": "Authorization", "value": ""}]
224+
findings: list[Finding] = []
225+
check_headers(headers, "request", findings)
226+
assert len(findings) == 0
227+
228+
def test_detects_set_cookie_header(self) -> None:
229+
"""Test detection of Set-Cookie header."""
230+
headers = [{"name": "Set-Cookie", "value": "session=xyz; Path=/"}]
231+
findings: list[Finding] = []
232+
check_headers(headers, "response", findings)
233+
assert len(findings) == 1
234+
235+
def test_multiple_sensitive_headers(self) -> None:
236+
"""Test detection of multiple sensitive headers."""
237+
headers = [
238+
{"name": "Authorization", "value": "Bearer token"},
239+
{"name": "Cookie", "value": "session=abc"},
240+
]
241+
findings: list[Finding] = []
242+
check_headers(headers, "request", findings)
243+
assert len(findings) == 2
244+
245+
246+
class TestCheckPostData:
247+
"""Tests for check_post_data() function."""
248+
249+
def test_detects_password_in_params(self) -> None:
250+
"""Test detection of password field in form params."""
251+
post_data = {
252+
"params": [{"name": "password", "value": "mysecret123"}],
253+
"mimeType": "application/x-www-form-urlencoded",
254+
}
255+
findings: list[Finding] = []
256+
check_post_data(post_data, "request", findings)
257+
assert len(findings) == 1
258+
assert findings[0].severity == "error"
259+
assert "password" in findings[0].field.lower()
260+
261+
def test_ignores_redacted_password(self) -> None:
262+
"""Test redacted password is not flagged."""
263+
post_data = {
264+
"params": [{"name": "password", "value": "[REDACTED]"}],
265+
"mimeType": "application/x-www-form-urlencoded",
266+
}
267+
findings: list[Finding] = []
268+
check_post_data(post_data, "request", findings)
269+
assert len(findings) == 0
270+
271+
def test_handles_empty_post_data(self) -> None:
272+
"""Test empty post data doesn't cause errors."""
273+
findings: list[Finding] = []
274+
check_post_data(None, "request", findings)
275+
assert len(findings) == 0
276+
check_post_data({}, "request", findings)
277+
assert len(findings) == 0
278+
279+
def test_detects_token_field(self) -> None:
280+
"""Test detection of token field."""
281+
post_data = {
282+
"params": [{"name": "access_token", "value": "abc123xyz"}],
283+
"mimeType": "application/x-www-form-urlencoded",
284+
}
285+
findings: list[Finding] = []
286+
check_post_data(post_data, "request", findings)
287+
assert len(findings) == 1
288+
289+
def test_detects_credential_field(self) -> None:
290+
"""Test detection of credential field."""
291+
post_data = {
292+
"params": [{"name": "user_credential", "value": "secret"}],
293+
"mimeType": "application/x-www-form-urlencoded",
294+
}
295+
findings: list[Finding] = []
296+
check_post_data(post_data, "request", findings)
297+
assert len(findings) == 1
298+
299+
300+
class TestFindingDataclass:
301+
"""Tests for Finding dataclass."""
302+
303+
def test_finding_creation(self) -> None:
304+
"""Test Finding can be created with all fields."""
305+
finding = Finding(
306+
severity="error",
307+
location="request.headers",
308+
field="Authorization",
309+
value="Bearer xxx...",
310+
reason="Sensitive header",
311+
)
312+
assert finding.severity == "error"
313+
assert finding.location == "request.headers"
314+
assert finding.field == "Authorization"
315+
assert finding.value == "Bearer xxx..."
316+
assert finding.reason == "Sensitive header"
317+
318+
def test_finding_warning_severity(self) -> None:
319+
"""Test Finding with warning severity."""
320+
finding = Finding(
321+
severity="warning",
322+
location="response.content",
323+
field="email",
324+
value="user@example.com",
325+
reason="Potential email address",
326+
)
327+
assert finding.severity == "warning"

0 commit comments

Comments
 (0)