Skip to content

Commit 3a3901b

Browse files
authored
Merge pull request #38 from LeakIX/copilot/add-negative-edge-case-tests
tests: add edge-case and validation tests
2 parents f8a9e20 + b6bdf6b commit 3a3901b

2 files changed

Lines changed: 364 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ and this project adheres to
88

99
## [Unreleased]
1010

11+
### Added
12+
13+
- Add 34 edge-case and validation tests covering missing fields, null values,
14+
empty strings, boundary integers, malformed datetimes/decimals, and nested
15+
validation ([1ca6e4d], [#33])
16+
1117
### Changed
1218

1319
- Re-export all public models from `__init__.py` and define `__all__`
@@ -142,6 +148,7 @@ and this project adheres to
142148

143149
<!-- Commit links -->
144150

151+
[1ca6e4d]: https://github.com/LeakIX/l9format-python/commit/1ca6e4d
145152
[0d8736e]: https://github.com/LeakIX/l9format-python/commit/0d8736e
146153
[d30efd2]: https://github.com/LeakIX/l9format-python/commit/d30efd2
147154
[1dcfbef]: https://github.com/LeakIX/l9format-python/commit/1dcfbef
@@ -206,4 +213,5 @@ and this project adheres to
206213
[#18]: https://github.com/LeakIX/l9format-python/pull/18
207214
[#21]: https://github.com/LeakIX/l9format-python/issues/21
208215
[#27]: https://github.com/LeakIX/l9format-python/issues/27
216+
[#33]: https://github.com/LeakIX/l9format-python/issues/33
209217
[#35]: https://github.com/LeakIX/l9format-python/issues/35

tests/test_validation.py

Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
1+
"""
2+
Tests for validation behavior on malformed input, missing fields,
3+
and edge cases.
4+
5+
These tests document and verify the expected behavior when the schema
6+
encounters invalid or edge-case input data.
7+
"""
8+
9+
import json
10+
from pathlib import Path
11+
12+
import pytest
13+
from serde import ValidationError
14+
15+
from l9format import (
16+
Certificate,
17+
DatasetSummary,
18+
GeoLocation,
19+
GeoPoint,
20+
L9Event,
21+
L9HttpEvent,
22+
Network,
23+
)
24+
25+
TESTS_DIR = Path(__file__).parent
26+
27+
28+
class TestMissingRequiredFields:
29+
"""Test behavior when required fields are missing."""
30+
31+
def test_geopoint_missing_lat(self) -> None:
32+
with pytest.raises(ValidationError):
33+
GeoPoint.from_dict({"lon": "1.0"})
34+
35+
def test_geopoint_missing_lon(self) -> None:
36+
with pytest.raises(ValidationError):
37+
GeoPoint.from_dict({"lat": "1.0"})
38+
39+
def test_network_missing_organization_name(self) -> None:
40+
with pytest.raises(ValidationError):
41+
Network.from_dict({"asn": 12345, "network": "1.0.0.0/8"})
42+
43+
def test_network_missing_asn(self) -> None:
44+
with pytest.raises(ValidationError):
45+
Network.from_dict(
46+
{"organization_name": "Test Org", "network": "1.0.0.0/8"}
47+
)
48+
49+
def test_network_missing_network(self) -> None:
50+
with pytest.raises(ValidationError):
51+
Network.from_dict({"organization_name": "Test Org", "asn": 12345})
52+
53+
def test_certificate_missing_cn(self) -> None:
54+
with pytest.raises(ValidationError):
55+
Certificate.from_dict(
56+
{
57+
"fingerprint": "abc123",
58+
"key_algo": "RSA",
59+
"key_size": 2048,
60+
"issuer_name": "Test CA",
61+
"not_before": "2024-01-01T00:00:00Z",
62+
"not_after": "2024-12-31T23:59:59Z",
63+
"valid": True,
64+
}
65+
)
66+
67+
def test_l9event_missing_required_fields(self) -> None:
68+
with pytest.raises(ValidationError):
69+
L9Event.from_dict(
70+
{
71+
"event_source": "test",
72+
"ip": "127.0.0.1",
73+
"port": "80",
74+
"host": "example.com",
75+
"reverse": "ptr.example.com",
76+
"protocol": "http",
77+
"summary": "test",
78+
"time": "2024-01-01T00:00:00Z",
79+
}
80+
)
81+
82+
83+
class TestExtraUnknownFields:
84+
"""Extra fields are silently ignored (default serde behavior)."""
85+
86+
def test_geopoint_extra_field_ignored(self) -> None:
87+
gp = GeoPoint.from_dict(
88+
{"lat": "1.5", "lon": "2.5", "unknown_field": "value"}
89+
)
90+
assert gp.lat == 1.5
91+
assert gp.lon == 2.5
92+
assert not hasattr(gp, "unknown_field")
93+
94+
def test_network_extra_field_ignored(self) -> None:
95+
net = Network.from_dict(
96+
{
97+
"organization_name": "Test Org",
98+
"asn": 12345,
99+
"network": "1.0.0.0/8",
100+
"extra_field": "should be ignored",
101+
}
102+
)
103+
assert net.organization_name == "Test Org"
104+
assert net.asn == 12345
105+
assert not hasattr(net, "extra_field")
106+
107+
108+
class TestNullValues:
109+
"""Test behavior when null values are provided."""
110+
111+
def test_geopoint_null_lat(self) -> None:
112+
with pytest.raises(ValueError, match="invalid decimal"):
113+
GeoPoint.from_dict({"lat": None, "lon": "1.0"})
114+
115+
def test_geopoint_null_lon(self) -> None:
116+
with pytest.raises(ValueError, match="invalid decimal"):
117+
GeoPoint.from_dict({"lat": "1.0", "lon": None})
118+
119+
def test_network_null_organization_name(self) -> None:
120+
with pytest.raises(ValidationError):
121+
Network.from_dict(
122+
{
123+
"organization_name": None,
124+
"asn": 12345,
125+
"network": "1.0.0.0/8",
126+
}
127+
)
128+
129+
def test_network_null_asn(self) -> None:
130+
with pytest.raises(ValidationError):
131+
Network.from_dict(
132+
{
133+
"organization_name": "Test Org",
134+
"asn": None,
135+
"network": "1.0.0.0/8",
136+
}
137+
)
138+
139+
def test_optional_field_allows_null(self) -> None:
140+
geo = GeoLocation.from_dict(
141+
{
142+
"continent_name": None,
143+
"region_iso_code": None,
144+
"city_name": None,
145+
"country_iso_code": None,
146+
"country_name": None,
147+
"region_name": None,
148+
"location": None,
149+
}
150+
)
151+
assert geo.continent_name is None
152+
assert geo.location is None
153+
154+
155+
class TestEmptyStrings:
156+
"""Test behavior when empty strings are provided."""
157+
158+
def test_geopoint_empty_string_lat(self) -> None:
159+
with pytest.raises(ValueError, match="invalid decimal"):
160+
GeoPoint.from_dict({"lat": "", "lon": "1.0"})
161+
162+
def test_geopoint_empty_string_lon(self) -> None:
163+
with pytest.raises(ValueError, match="invalid decimal"):
164+
GeoPoint.from_dict({"lat": "1.0", "lon": ""})
165+
166+
def test_network_accepts_empty_strings(self) -> None:
167+
net = Network.from_dict(
168+
{"organization_name": "", "asn": 12345, "network": ""}
169+
)
170+
assert net.organization_name == ""
171+
assert net.network == ""
172+
173+
174+
class TestBoundaryIntegers:
175+
"""Test behavior with boundary integer values.
176+
177+
The schema performs no range validation on integers.
178+
"""
179+
180+
def test_network_zero_asn(self) -> None:
181+
net = Network.from_dict(
182+
{"organization_name": "Test", "asn": 0, "network": "1.0.0.0/8"}
183+
)
184+
assert net.asn == 0
185+
186+
def test_network_negative_asn(self) -> None:
187+
net = Network.from_dict(
188+
{"organization_name": "Test", "asn": -1, "network": "1.0.0.0/8"}
189+
)
190+
assert net.asn == -1
191+
192+
def test_network_large_asn(self) -> None:
193+
net = Network.from_dict(
194+
{
195+
"organization_name": "Test",
196+
"asn": 2**31 - 1,
197+
"network": "1.0.0.0/8",
198+
}
199+
)
200+
assert net.asn == 2147483647
201+
202+
def test_http_event_negative_status(self) -> None:
203+
http = L9HttpEvent.from_dict(
204+
{
205+
"root": "/",
206+
"url": "/test",
207+
"status": -1,
208+
"length": 0,
209+
"title": "",
210+
"favicon_hash": "",
211+
}
212+
)
213+
assert http.status == -1
214+
215+
def test_dataset_summary_negative_values(self) -> None:
216+
ds = DatasetSummary.from_dict(
217+
{
218+
"rows": -1,
219+
"files": -1,
220+
"size": -1,
221+
"collections": -1,
222+
"infected": False,
223+
}
224+
)
225+
assert ds.rows == -1
226+
assert ds.files == -1
227+
assert ds.size == -1
228+
229+
230+
class TestMalformedDatetimes:
231+
"""Test behavior with malformed datetime strings."""
232+
233+
def test_certificate_invalid_datetime(self) -> None:
234+
with pytest.raises(ValidationError):
235+
Certificate.from_dict(
236+
{
237+
"cn": "example.com",
238+
"fingerprint": "abc123",
239+
"key_algo": "RSA",
240+
"key_size": 2048,
241+
"issuer_name": "Test CA",
242+
"not_before": "invalid-datetime",
243+
"not_after": "2024-12-31T23:59:59Z",
244+
"valid": True,
245+
}
246+
)
247+
248+
def test_certificate_empty_datetime(self) -> None:
249+
with pytest.raises(ValidationError):
250+
Certificate.from_dict(
251+
{
252+
"cn": "example.com",
253+
"fingerprint": "abc123",
254+
"key_algo": "RSA",
255+
"key_size": 2048,
256+
"issuer_name": "Test CA",
257+
"not_before": "",
258+
"not_after": "2024-12-31T23:59:59Z",
259+
"valid": True,
260+
}
261+
)
262+
263+
def test_certificate_date_only(self) -> None:
264+
cert = Certificate.from_dict(
265+
{
266+
"cn": "example.com",
267+
"fingerprint": "abc123",
268+
"key_algo": "RSA",
269+
"key_size": 2048,
270+
"issuer_name": "Test CA",
271+
"not_before": "2024-01-01",
272+
"not_after": "2024-12-31T23:59:59Z",
273+
"valid": True,
274+
}
275+
)
276+
assert cert.not_before.year == 2024
277+
assert cert.not_before.month == 1
278+
assert cert.not_before.day == 1
279+
280+
281+
class TestMalformedDecimals:
282+
"""Test behavior with malformed decimal values."""
283+
284+
def test_geopoint_non_numeric(self) -> None:
285+
with pytest.raises(ValueError, match="invalid decimal"):
286+
GeoPoint.from_dict({"lat": "not-a-number", "lon": "1.0"})
287+
288+
def test_geopoint_infinity(self) -> None:
289+
gp = GeoPoint.from_dict({"lat": "Infinity", "lon": "1.0"})
290+
assert str(gp.lat) == "Infinity"
291+
292+
def test_geopoint_nan(self) -> None:
293+
gp = GeoPoint.from_dict({"lat": "NaN", "lon": "1.0"})
294+
assert str(gp.lat) == "NaN"
295+
296+
def test_geopoint_scientific_notation(self) -> None:
297+
gp = GeoPoint.from_dict({"lat": "1.5e2", "lon": "2.5E-1"})
298+
assert gp.lat == 150
299+
assert gp.lon == 0.25
300+
301+
def test_geopoint_negative_values(self) -> None:
302+
gp = GeoPoint.from_dict({"lat": "-1.5", "lon": "-2.5"})
303+
assert gp.lat == -1.5
304+
assert gp.lon == -2.5
305+
306+
307+
class TestComplexNestedValidation:
308+
"""Test validation behavior with complex nested structures."""
309+
310+
def test_l9event_invalid_nested_decimal(self) -> None:
311+
path = TESTS_DIR / "l9event.json"
312+
with open(path) as f:
313+
data = json.load(f)
314+
data["geoip"]["location"] = {"lat": "invalid", "lon": "1.0"}
315+
with pytest.raises(ValueError, match="invalid decimal"):
316+
L9Event.from_dict(data)
317+
318+
def test_l9event_missing_nested_required_field(self) -> None:
319+
path = TESTS_DIR / "l9event.json"
320+
with open(path) as f:
321+
data = json.load(f)
322+
del data["network"]["asn"]
323+
with pytest.raises(ValidationError):
324+
L9Event.from_dict(data)
325+
326+
def test_certificate_with_domain_list(self) -> None:
327+
cert = Certificate.from_dict(
328+
{
329+
"cn": "example.com",
330+
"domain": ["site1.example.com", "site2.example.com"],
331+
"fingerprint": "abc123",
332+
"key_algo": "RSA",
333+
"key_size": 2048,
334+
"issuer_name": "Test CA",
335+
"not_before": "2024-01-01T00:00:00Z",
336+
"not_after": "2024-12-31T23:59:59Z",
337+
"valid": True,
338+
}
339+
)
340+
assert cert.domain == ["site1.example.com", "site2.example.com"]
341+
342+
def test_certificate_with_empty_domain_list(self) -> None:
343+
cert = Certificate.from_dict(
344+
{
345+
"cn": "example.com",
346+
"domain": [],
347+
"fingerprint": "abc123",
348+
"key_algo": "RSA",
349+
"key_size": 2048,
350+
"issuer_name": "Test CA",
351+
"not_before": "2024-01-01T00:00:00Z",
352+
"not_after": "2024-12-31T23:59:59Z",
353+
"valid": True,
354+
}
355+
)
356+
assert cert.domain == []

0 commit comments

Comments
 (0)