Skip to content

Commit d554f1e

Browse files
committed
test: add round-trip and serialization tests
Add 32 tests covering to_dict(), Decimal field serialize/deserialize, and round-trip (from_dict -> to_dict) for all models. Documents serde behavior where None-valued optional fields are omitted on serialization. Closes #24
1 parent 7077cc0 commit d554f1e

1 file changed

Lines changed: 395 additions & 0 deletions

File tree

tests/test_serialization.py

Lines changed: 395 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,395 @@
1+
"""
2+
Tests for serialization (to_dict) and round-trip (from_dict -> to_dict)
3+
behavior across all models.
4+
"""
5+
6+
import json
7+
from decimal import Decimal as PyDecimal
8+
from pathlib import Path
9+
10+
from l9format import (
11+
Certificate,
12+
DatasetSummary,
13+
GeoLocation,
14+
GeoPoint,
15+
L9Event,
16+
L9HttpEvent,
17+
L9LeakEvent,
18+
L9ServiceEvent,
19+
Network,
20+
Software,
21+
SoftwareModule,
22+
)
23+
from l9format.l9format import Decimal as DecimalField
24+
25+
TESTS_DIR = Path(__file__).parent
26+
27+
28+
class TestDecimalField:
29+
"""Test the custom Decimal field serialize/deserialize paths."""
30+
31+
def test_serialize_without_resolution(self) -> None:
32+
field = DecimalField()
33+
assert field.serialize(PyDecimal("1.5")) == "1.5"
34+
35+
def test_serialize_with_resolution(self) -> None:
36+
field = DecimalField(resolution=6)
37+
assert field.serialize(PyDecimal("1.5")) == "1.500000"
38+
39+
def test_serialize_rounds_to_resolution(self) -> None:
40+
field = DecimalField(resolution=2)
41+
assert field.serialize(PyDecimal("1.555")) == "1.56"
42+
43+
def test_deserialize_without_resolution(self) -> None:
44+
field = DecimalField()
45+
result = field.deserialize("1.5")
46+
assert result == PyDecimal("1.5")
47+
48+
def test_deserialize_with_resolution(self) -> None:
49+
field = DecimalField(resolution=6)
50+
result = field.deserialize("1.5")
51+
assert result == PyDecimal("1.500000")
52+
53+
def test_deserialize_from_int(self) -> None:
54+
field = DecimalField()
55+
result = field.deserialize(42)
56+
assert result == PyDecimal("42")
57+
58+
def test_deserialize_from_float(self) -> None:
59+
field = DecimalField()
60+
result = field.deserialize(1.5)
61+
assert result == PyDecimal("1.5")
62+
63+
def test_round_trip_with_resolution(self) -> None:
64+
field = DecimalField(resolution=6)
65+
original = "1.500000"
66+
deserialized = field.deserialize(original)
67+
serialized = field.serialize(deserialized)
68+
assert serialized == original
69+
70+
def test_round_trip_without_resolution(self) -> None:
71+
field = DecimalField()
72+
original = "3.14159"
73+
deserialized = field.deserialize(original)
74+
serialized = field.serialize(deserialized)
75+
assert serialized == original
76+
77+
78+
class TestGeoPointRoundTrip:
79+
"""Test GeoPoint serialization and round-trip."""
80+
81+
def test_to_dict(self) -> None:
82+
gp = GeoPoint.from_dict({"lat": "1.5", "lon": "2.5"})
83+
d = gp.to_dict()
84+
assert d["lat"] == "1.5"
85+
assert d["lon"] == "2.5"
86+
87+
def test_round_trip(self) -> None:
88+
data = {"lat": "48.8566", "lon": "2.3522"}
89+
assert GeoPoint.from_dict(data).to_dict() == data
90+
91+
def test_round_trip_zero(self) -> None:
92+
data = {"lat": "0", "lon": "0"}
93+
assert GeoPoint.from_dict(data).to_dict() == data
94+
95+
def test_round_trip_negative(self) -> None:
96+
data = {"lat": "-33.8688", "lon": "-151.2093"}
97+
assert GeoPoint.from_dict(data).to_dict() == data
98+
99+
100+
class TestNetworkRoundTrip:
101+
"""Test Network serialization and round-trip."""
102+
103+
def test_to_dict(self) -> None:
104+
net = Network.from_dict(
105+
{
106+
"organization_name": "Test Org",
107+
"asn": 12345,
108+
"network": "1.0.0.0/8",
109+
}
110+
)
111+
d = net.to_dict()
112+
assert d["organization_name"] == "Test Org"
113+
assert d["asn"] == 12345
114+
assert d["network"] == "1.0.0.0/8"
115+
116+
def test_round_trip(self) -> None:
117+
data = {
118+
"organization_name": "Test Org",
119+
"asn": 12345,
120+
"network": "1.0.0.0/8",
121+
}
122+
assert Network.from_dict(data).to_dict() == data
123+
124+
125+
class TestCertificateRoundTrip:
126+
"""Test Certificate serialization and round-trip."""
127+
128+
def test_to_dict(self) -> None:
129+
data = {
130+
"cn": "example.com",
131+
"domain": ["a.example.com", "b.example.com"],
132+
"fingerprint": "abc123",
133+
"key_algo": "RSA",
134+
"key_size": 2048,
135+
"issuer_name": "Test CA",
136+
"not_before": "2024-01-01T00:00:00+00:00",
137+
"not_after": "2024-12-31T23:59:59+00:00",
138+
"valid": True,
139+
}
140+
cert = Certificate.from_dict(data)
141+
d = cert.to_dict()
142+
assert d["cn"] == "example.com"
143+
assert d["domain"] == ["a.example.com", "b.example.com"]
144+
assert d["key_size"] == 2048
145+
assert d["valid"] is True
146+
147+
def test_round_trip(self) -> None:
148+
data = {
149+
"cn": "example.com",
150+
"domain": ["a.example.com"],
151+
"fingerprint": "abc123",
152+
"key_algo": "RSA",
153+
"key_size": 2048,
154+
"issuer_name": "Test CA",
155+
"not_before": "2024-01-01T00:00:00+00:00",
156+
"not_after": "2024-12-31T23:59:59+00:00",
157+
"valid": True,
158+
}
159+
assert Certificate.from_dict(data).to_dict() == data
160+
161+
def test_round_trip_null_domain_omitted(self) -> None:
162+
"""serde omits None-valued optional fields from to_dict()."""
163+
data = {
164+
"cn": "example.com",
165+
"domain": None,
166+
"fingerprint": "abc123",
167+
"key_algo": "RSA",
168+
"key_size": 2048,
169+
"issuer_name": "Test CA",
170+
"not_before": "2024-01-01T00:00:00+00:00",
171+
"not_after": "2024-12-31T23:59:59+00:00",
172+
"valid": True,
173+
}
174+
result = Certificate.from_dict(data).to_dict()
175+
assert "domain" not in result
176+
177+
178+
class TestHttpEventRoundTrip:
179+
"""Test L9HttpEvent serialization and round-trip."""
180+
181+
def test_round_trip(self) -> None:
182+
data = {
183+
"root": "/",
184+
"url": "/index.html",
185+
"status": 200,
186+
"length": 1024,
187+
"header": {"Content-Type": "text/html"},
188+
"title": "Welcome",
189+
"favicon_hash": "abc123",
190+
}
191+
assert L9HttpEvent.from_dict(data).to_dict() == data
192+
193+
def test_round_trip_null_header_omitted(self) -> None:
194+
"""serde omits None-valued optional fields from to_dict()."""
195+
data = {
196+
"root": "/",
197+
"url": "/",
198+
"status": 0,
199+
"length": 0,
200+
"header": None,
201+
"title": "",
202+
"favicon_hash": "",
203+
}
204+
result = L9HttpEvent.from_dict(data).to_dict()
205+
assert "header" not in result
206+
207+
208+
class TestSoftwareRoundTrip:
209+
"""Test Software and SoftwareModule serialization."""
210+
211+
def test_software_module_round_trip(self) -> None:
212+
data = {"name": "PHP", "version": "8.2.0", "fingerprint": "php-8-2-0"}
213+
assert SoftwareModule.from_dict(data).to_dict() == data
214+
215+
def test_software_with_modules_round_trip(self) -> None:
216+
data = {
217+
"name": "Apache",
218+
"version": "2.4.52",
219+
"os": "Ubuntu",
220+
"modules": [
221+
{"name": "PHP", "version": "8.2.0", "fingerprint": "php-8-2-0"}
222+
],
223+
"fingerprint": "apache-2-4-52",
224+
}
225+
assert Software.from_dict(data).to_dict() == data
226+
227+
def test_software_null_modules_omitted(self) -> None:
228+
"""serde omits None-valued optional fields from to_dict()."""
229+
data = {
230+
"name": "nginx",
231+
"version": "1.24.0",
232+
"os": "",
233+
"modules": None,
234+
"fingerprint": "nginx-1-24-0",
235+
}
236+
result = Software.from_dict(data).to_dict()
237+
assert "modules" not in result
238+
assert result["name"] == "nginx"
239+
240+
241+
class TestLeakEventRoundTrip:
242+
"""Test L9LeakEvent and DatasetSummary serialization."""
243+
244+
def test_dataset_summary_round_trip(self) -> None:
245+
data = {
246+
"rows": 100,
247+
"files": 5,
248+
"size": 65536,
249+
"collections": 3,
250+
"infected": False,
251+
"ransom_notes": ["Pay up"],
252+
}
253+
assert DatasetSummary.from_dict(data).to_dict() == data
254+
255+
def test_dataset_summary_null_ransom_notes_omitted(self) -> None:
256+
"""serde omits None-valued optional fields from to_dict()."""
257+
data = {
258+
"rows": 100,
259+
"files": 5,
260+
"size": 65536,
261+
"collections": 3,
262+
"infected": False,
263+
"ransom_notes": None,
264+
}
265+
result = DatasetSummary.from_dict(data).to_dict()
266+
assert "ransom_notes" not in result
267+
268+
def test_leak_event_round_trip(self) -> None:
269+
data = {
270+
"stage": "open",
271+
"type": "database",
272+
"severity": "high",
273+
"dataset": {
274+
"rows": 1000,
275+
"files": 0,
276+
"size": 1048576,
277+
"collections": 10,
278+
"infected": True,
279+
"ransom_notes": ["Pay up"],
280+
},
281+
}
282+
assert L9LeakEvent.from_dict(data).to_dict() == data
283+
284+
285+
class TestGeoLocationRoundTrip:
286+
"""Test GeoLocation serialization with nested GeoPoint."""
287+
288+
def test_round_trip_with_location(self) -> None:
289+
data = {
290+
"continent_name": "Europe",
291+
"region_iso_code": "FR-IDF",
292+
"city_name": "Paris",
293+
"country_iso_code": "FR",
294+
"country_name": "France",
295+
"region_name": "Ile-de-France",
296+
"location": {"lat": "48.8566", "lon": "2.3522"},
297+
}
298+
assert GeoLocation.from_dict(data).to_dict() == data
299+
300+
def test_round_trip_all_null_omitted(self) -> None:
301+
"""serde omits None-valued optional fields from to_dict()."""
302+
data = {
303+
"continent_name": None,
304+
"region_iso_code": None,
305+
"city_name": None,
306+
"country_iso_code": None,
307+
"country_name": None,
308+
"region_name": None,
309+
"location": None,
310+
}
311+
result = GeoLocation.from_dict(data).to_dict()
312+
assert len(result) == 0
313+
314+
315+
class TestServiceEventRoundTrip:
316+
"""Test L9ServiceEvent serialization."""
317+
318+
def test_round_trip(self) -> None:
319+
data = {
320+
"credentials": {
321+
"noauth": True,
322+
"username": "",
323+
"password": "",
324+
"key": "",
325+
"raw": "dGVzdA==",
326+
},
327+
"software": {
328+
"name": "Apache",
329+
"version": "2.4.52",
330+
"os": "Ubuntu",
331+
"modules": [
332+
{
333+
"name": "PHP",
334+
"version": "8.2.0",
335+
"fingerprint": "php-8-2-0",
336+
}
337+
],
338+
"fingerprint": "apache-2-4-52",
339+
},
340+
}
341+
assert L9ServiceEvent.from_dict(data).to_dict() == data
342+
343+
def test_round_trip_null_optionals_omitted(self) -> None:
344+
"""serde omits None-valued optional fields from to_dict()."""
345+
data = {
346+
"credentials": {
347+
"noauth": False,
348+
"username": "",
349+
"password": "",
350+
"key": "",
351+
"raw": None,
352+
},
353+
"software": {
354+
"name": "nginx",
355+
"version": "1.24.0",
356+
"os": "",
357+
"modules": None,
358+
"fingerprint": "nginx-1-24-0",
359+
},
360+
}
361+
result = L9ServiceEvent.from_dict(data).to_dict()
362+
assert "raw" not in result["credentials"]
363+
assert "modules" not in result["software"]
364+
365+
366+
class TestL9EventRoundTrip:
367+
"""Test full L9Event round-trip from reference JSON.
368+
369+
The reference JSON uses raw types (numeric lat/lon, Z-suffix
370+
datetimes) that differ from the serialized form (string decimals,
371+
+00:00 datetimes). A true round-trip asserts idempotency:
372+
serialize(deserialize(x)) produces a stable output that survives
373+
another cycle unchanged.
374+
"""
375+
376+
def test_round_trip_from_reference(self) -> None:
377+
path = TESTS_DIR / "l9event.json"
378+
with open(path) as f:
379+
data = json.load(f)
380+
first = L9Event.from_dict(data).to_dict()
381+
second = L9Event.from_dict(first).to_dict()
382+
assert first == second
383+
384+
def test_round_trip_ip4scout(self) -> None:
385+
ip4scout_files = [
386+
f
387+
for f in Path.iterdir(TESTS_DIR)
388+
if Path.is_file(f) and "ip4scout" in f.name
389+
]
390+
for path in ip4scout_files:
391+
with open(path) as f:
392+
data = json.load(f)
393+
first = L9Event.from_dict(data).to_dict()
394+
second = L9Event.from_dict(first).to_dict()
395+
assert first == second

0 commit comments

Comments
 (0)