From 1ca6e4d6e1c6a7efcbfbb4ea8054e298785b21eb Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sat, 7 Feb 2026 22:20:13 -0300 Subject: [PATCH 1/2] tests: add edge-case and validation tests Add 34 tests covering missing fields, null values, empty strings, boundary integers, malformed datetimes, malformed decimals, extra unknown fields, and nested validation errors. Closes #33 --- tests/test_validation.py | 356 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 356 insertions(+) create mode 100644 tests/test_validation.py diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..69bbb80 --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,356 @@ +""" +Tests for validation behavior on malformed input, missing fields, +and edge cases. + +These tests document and verify the expected behavior when the schema +encounters invalid or edge-case input data. +""" + +import json +from pathlib import Path + +import pytest +from serde import ValidationError + +from l9format import ( + Certificate, + DatasetSummary, + GeoLocation, + GeoPoint, + L9Event, + L9HttpEvent, + Network, +) + +TESTS_DIR = Path(__file__).parent + + +class TestMissingRequiredFields: + """Test behavior when required fields are missing.""" + + def test_geopoint_missing_lat(self) -> None: + with pytest.raises(ValidationError): + GeoPoint.from_dict({"lon": "1.0"}) + + def test_geopoint_missing_lon(self) -> None: + with pytest.raises(ValidationError): + GeoPoint.from_dict({"lat": "1.0"}) + + def test_network_missing_organization_name(self) -> None: + with pytest.raises(ValidationError): + Network.from_dict({"asn": 12345, "network": "1.0.0.0/8"}) + + def test_network_missing_asn(self) -> None: + with pytest.raises(ValidationError): + Network.from_dict( + {"organization_name": "Test Org", "network": "1.0.0.0/8"} + ) + + def test_network_missing_network(self) -> None: + with pytest.raises(ValidationError): + Network.from_dict({"organization_name": "Test Org", "asn": 12345}) + + def test_certificate_missing_cn(self) -> None: + with pytest.raises(ValidationError): + Certificate.from_dict( + { + "fingerprint": "abc123", + "key_algo": "RSA", + "key_size": 2048, + "issuer_name": "Test CA", + "not_before": "2024-01-01T00:00:00Z", + "not_after": "2024-12-31T23:59:59Z", + "valid": True, + } + ) + + def test_l9event_missing_required_fields(self) -> None: + with pytest.raises(ValidationError): + L9Event.from_dict( + { + "event_source": "test", + "ip": "127.0.0.1", + "port": "80", + "host": "example.com", + "reverse": "ptr.example.com", + "protocol": "http", + "summary": "test", + "time": "2024-01-01T00:00:00Z", + } + ) + + +class TestExtraUnknownFields: + """Extra fields are silently ignored (default serde behavior).""" + + def test_geopoint_extra_field_ignored(self) -> None: + gp = GeoPoint.from_dict( + {"lat": "1.5", "lon": "2.5", "unknown_field": "value"} + ) + assert gp.lat == 1.5 + assert gp.lon == 2.5 + assert not hasattr(gp, "unknown_field") + + def test_network_extra_field_ignored(self) -> None: + net = Network.from_dict( + { + "organization_name": "Test Org", + "asn": 12345, + "network": "1.0.0.0/8", + "extra_field": "should be ignored", + } + ) + assert net.organization_name == "Test Org" + assert net.asn == 12345 + assert not hasattr(net, "extra_field") + + +class TestNullValues: + """Test behavior when null values are provided.""" + + def test_geopoint_null_lat(self) -> None: + with pytest.raises(ValueError, match="invalid decimal"): + GeoPoint.from_dict({"lat": None, "lon": "1.0"}) + + def test_geopoint_null_lon(self) -> None: + with pytest.raises(ValueError, match="invalid decimal"): + GeoPoint.from_dict({"lat": "1.0", "lon": None}) + + def test_network_null_organization_name(self) -> None: + with pytest.raises(ValidationError): + Network.from_dict( + { + "organization_name": None, + "asn": 12345, + "network": "1.0.0.0/8", + } + ) + + def test_network_null_asn(self) -> None: + with pytest.raises(ValidationError): + Network.from_dict( + { + "organization_name": "Test Org", + "asn": None, + "network": "1.0.0.0/8", + } + ) + + def test_optional_field_allows_null(self) -> None: + geo = GeoLocation.from_dict( + { + "continent_name": None, + "region_iso_code": None, + "city_name": None, + "country_iso_code": None, + "country_name": None, + "region_name": None, + "location": None, + } + ) + assert geo.continent_name is None + assert geo.location is None + + +class TestEmptyStrings: + """Test behavior when empty strings are provided.""" + + def test_geopoint_empty_string_lat(self) -> None: + with pytest.raises(ValueError, match="invalid decimal"): + GeoPoint.from_dict({"lat": "", "lon": "1.0"}) + + def test_geopoint_empty_string_lon(self) -> None: + with pytest.raises(ValueError, match="invalid decimal"): + GeoPoint.from_dict({"lat": "1.0", "lon": ""}) + + def test_network_accepts_empty_strings(self) -> None: + net = Network.from_dict( + {"organization_name": "", "asn": 12345, "network": ""} + ) + assert net.organization_name == "" + assert net.network == "" + + +class TestBoundaryIntegers: + """Test behavior with boundary integer values. + + The schema performs no range validation on integers. + """ + + def test_network_zero_asn(self) -> None: + net = Network.from_dict( + {"organization_name": "Test", "asn": 0, "network": "1.0.0.0/8"} + ) + assert net.asn == 0 + + def test_network_negative_asn(self) -> None: + net = Network.from_dict( + {"organization_name": "Test", "asn": -1, "network": "1.0.0.0/8"} + ) + assert net.asn == -1 + + def test_network_large_asn(self) -> None: + net = Network.from_dict( + { + "organization_name": "Test", + "asn": 2**31 - 1, + "network": "1.0.0.0/8", + } + ) + assert net.asn == 2147483647 + + def test_http_event_negative_status(self) -> None: + http = L9HttpEvent.from_dict( + { + "root": "/", + "url": "/test", + "status": -1, + "length": 0, + "title": "", + "favicon_hash": "", + } + ) + assert http.status == -1 + + def test_dataset_summary_negative_values(self) -> None: + ds = DatasetSummary.from_dict( + { + "rows": -1, + "files": -1, + "size": -1, + "collections": -1, + "infected": False, + } + ) + assert ds.rows == -1 + assert ds.files == -1 + assert ds.size == -1 + + +class TestMalformedDatetimes: + """Test behavior with malformed datetime strings.""" + + def test_certificate_invalid_datetime(self) -> None: + with pytest.raises(ValidationError): + Certificate.from_dict( + { + "cn": "example.com", + "fingerprint": "abc123", + "key_algo": "RSA", + "key_size": 2048, + "issuer_name": "Test CA", + "not_before": "invalid-datetime", + "not_after": "2024-12-31T23:59:59Z", + "valid": True, + } + ) + + def test_certificate_empty_datetime(self) -> None: + with pytest.raises(ValidationError): + Certificate.from_dict( + { + "cn": "example.com", + "fingerprint": "abc123", + "key_algo": "RSA", + "key_size": 2048, + "issuer_name": "Test CA", + "not_before": "", + "not_after": "2024-12-31T23:59:59Z", + "valid": True, + } + ) + + def test_certificate_date_only(self) -> None: + cert = Certificate.from_dict( + { + "cn": "example.com", + "fingerprint": "abc123", + "key_algo": "RSA", + "key_size": 2048, + "issuer_name": "Test CA", + "not_before": "2024-01-01", + "not_after": "2024-12-31T23:59:59Z", + "valid": True, + } + ) + assert cert.not_before.year == 2024 + assert cert.not_before.month == 1 + assert cert.not_before.day == 1 + + +class TestMalformedDecimals: + """Test behavior with malformed decimal values.""" + + def test_geopoint_non_numeric(self) -> None: + with pytest.raises(ValueError, match="invalid decimal"): + GeoPoint.from_dict({"lat": "not-a-number", "lon": "1.0"}) + + def test_geopoint_infinity(self) -> None: + gp = GeoPoint.from_dict({"lat": "Infinity", "lon": "1.0"}) + assert str(gp.lat) == "Infinity" + + def test_geopoint_nan(self) -> None: + gp = GeoPoint.from_dict({"lat": "NaN", "lon": "1.0"}) + assert str(gp.lat) == "NaN" + + def test_geopoint_scientific_notation(self) -> None: + gp = GeoPoint.from_dict({"lat": "1.5e2", "lon": "2.5E-1"}) + assert gp.lat == 150 + assert gp.lon == 0.25 + + def test_geopoint_negative_values(self) -> None: + gp = GeoPoint.from_dict({"lat": "-1.5", "lon": "-2.5"}) + assert gp.lat == -1.5 + assert gp.lon == -2.5 + + +class TestComplexNestedValidation: + """Test validation behavior with complex nested structures.""" + + def test_l9event_invalid_nested_decimal(self) -> None: + path = TESTS_DIR / "l9event.json" + with open(path) as f: + data = json.load(f) + data["geoip"]["location"] = {"lat": "invalid", "lon": "1.0"} + with pytest.raises(ValueError, match="invalid decimal"): + L9Event.from_dict(data) + + def test_l9event_missing_nested_required_field(self) -> None: + path = TESTS_DIR / "l9event.json" + with open(path) as f: + data = json.load(f) + del data["network"]["asn"] + with pytest.raises(ValidationError): + L9Event.from_dict(data) + + def test_certificate_with_domain_list(self) -> None: + cert = Certificate.from_dict( + { + "cn": "example.com", + "domain": ["site1.example.com", "site2.example.com"], + "fingerprint": "abc123", + "key_algo": "RSA", + "key_size": 2048, + "issuer_name": "Test CA", + "not_before": "2024-01-01T00:00:00Z", + "not_after": "2024-12-31T23:59:59Z", + "valid": True, + } + ) + assert cert.domain == ["site1.example.com", "site2.example.com"] + + def test_certificate_with_empty_domain_list(self) -> None: + cert = Certificate.from_dict( + { + "cn": "example.com", + "domain": [], + "fingerprint": "abc123", + "key_algo": "RSA", + "key_size": 2048, + "issuer_name": "Test CA", + "not_before": "2024-01-01T00:00:00Z", + "not_after": "2024-12-31T23:59:59Z", + "valid": True, + } + ) + assert cert.domain == [] From b6bdf6b1cbda54f6a4d898977e85905939fc9d5c Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sat, 7 Feb 2026 22:21:11 -0300 Subject: [PATCH 2/2] CHANGELOG: add entry for edge-case validation tests (#33) --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5adfe9..8c41021 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to ## [Unreleased] +### Added + +- Add 34 edge-case and validation tests covering missing fields, null values, + empty strings, boundary integers, malformed datetimes/decimals, and nested + validation ([1ca6e4d], [#33]) + ### Changed - Re-export all public models from `__init__.py` and define `__all__` @@ -142,6 +148,7 @@ and this project adheres to +[1ca6e4d]: https://github.com/LeakIX/l9format-python/commit/1ca6e4d [0d8736e]: https://github.com/LeakIX/l9format-python/commit/0d8736e [d30efd2]: https://github.com/LeakIX/l9format-python/commit/d30efd2 [1dcfbef]: https://github.com/LeakIX/l9format-python/commit/1dcfbef @@ -206,4 +213,5 @@ and this project adheres to [#18]: https://github.com/LeakIX/l9format-python/pull/18 [#21]: https://github.com/LeakIX/l9format-python/issues/21 [#27]: https://github.com/LeakIX/l9format-python/issues/27 +[#33]: https://github.com/LeakIX/l9format-python/issues/33 [#35]: https://github.com/LeakIX/l9format-python/issues/35