Add capture diagnostics and replay fixtures

Add privacy-safe capture diagnostics and sanitized replay fixtures without changing the existing export format, record order, or UID generation.
This commit is contained in:
Golumpa 2026-07-16 00:12:02 +01:00
parent 73bb100cd6
commit 2b33ea63af
11 changed files with 445 additions and 13 deletions

View file

@ -22,6 +22,12 @@ Do not replace this file with a real `.pcap`, `.flows`, or exported account
history. Add new cases by constructing the smallest relevant payload, replacing
all timestamps and endpoints, and extending the privacy assertions.
`synthetic_capture_diagnostics.json` is a smaller replay transcript for failure
paths. It deliberately contains a short response and a marker-free response so
the debug sidecar's reason codes and privacy contract can be tested without a
real capture. Repeated-byte payloads use `payload_byte` plus `payload_length` to
keep the fixture readable.
## Synthetic NTE_Assets fixture
`nte_assets/` mirrors only the six table paths and English localization file

View file

@ -0,0 +1,60 @@
{
"schema_version": 1,
"description": "Minimal synthetic replay for capture diagnostic reason codes.",
"privacy": {
"synthetic": true,
"contains_user_uid": false,
"contains_raw_account_session": false
},
"local_ip": "192.0.2.10",
"expected": {
"packets_seen": 3,
"history_requests_recognized": 1,
"response_candidates_rejected": 2,
"event_counts": {
"HISTORY_REQUEST_RECOGNIZED": 1,
"NO_HISTORY_MARKER": 1,
"RESPONSE_TOO_SHORT": 1
},
"reason_counts": {
"NO_HISTORY_MARKER": 1,
"RESPONSE_TOO_SHORT": 1
},
"pending_response_candidates": 2,
"pending_candidate_lengths": [80, 220]
},
"packets": [
{
"label": "permanent-page-1-request",
"timestamp": 1893456000.0,
"src_ip": "192.0.2.10",
"dst_ip": "198.51.100.20",
"src_port": 50000,
"dst_port": 40000,
"protocol": "udp",
"payload_hex": "00000000000000000000000000000000000000000000000000000000000000040000007c100000000400000000"
},
{
"label": "matching-response-too-short",
"timestamp": 1893456000.1,
"src_ip": "198.51.100.20",
"dst_ip": "192.0.2.10",
"src_port": 40000,
"dst_port": 50000,
"protocol": "udp",
"payload_byte": "00",
"payload_length": 80
},
{
"label": "matching-response-without-history-marker",
"timestamp": 1893456000.2,
"src_ip": "198.51.100.20",
"dst_ip": "192.0.2.10",
"src_port": 40000,
"dst_port": 50000,
"protocol": "udp",
"payload_byte": "00",
"payload_length": 220
}
]
}

View file

@ -60,6 +60,34 @@ def load_network_fixture():
return json.load(f)
def load_capture_diagnostics_fixture():
path = FIXTURES / "synthetic_capture_diagnostics.json"
with path.open(encoding="utf-8") as f:
return json.load(f)
def diagnostic_fixture_session():
fixture = load_capture_diagnostics_fixture()
session = LiveHistorySession(fixture["local_ip"])
for packet in fixture["packets"]:
if "payload_hex" in packet:
payload = bytes.fromhex(packet["payload_hex"])
else:
payload = bytes.fromhex(packet["payload_byte"]) * packet["payload_length"]
session.process_packet(
UdpPacket(
timestamp=packet["timestamp"],
src_ip=packet["src_ip"],
dst_ip=packet["dst_ip"],
src_port=packet["src_port"],
dst_port=packet["dst_port"],
payload=payload,
protocol=packet["protocol"],
)
)
return session
def fixture_packets(scenario=None):
fixture = load_network_fixture()
packets = fixture["packets"]

View file

@ -0,0 +1,108 @@
from tests.support import * # noqa: F401,F403
from nte_history_exporter.live_capture.diagnostics import (
new_diagnostics_path,
write_capture_diagnostics,
)
class CaptureDiagnosticsTests(unittest.TestCase):
def test_successful_network_replay_reports_capture_pipeline_counts(self):
report = fixture_session().diagnostic_report()
self.assertEqual(
report["counters"],
{
"history_requests_recognized": 10,
"history_responses_decoded": 10,
"packets_seen": 20,
"pages_matched": 10,
"udp_packets_seen": 20,
},
)
self.assertEqual(report["reason_counts"], {})
self.assertEqual(report["pending_requests"], [])
def test_synthetic_replay_reports_actionable_rejection_reasons(self):
fixture = load_capture_diagnostics_fixture()
report = diagnostic_fixture_session().diagnostic_report()
expected = fixture["expected"]
self.assertEqual(report["format"], "nte-capture-diagnostics")
self.assertEqual(report["format_version"], 1)
self.assertEqual(report["counters"]["packets_seen"], expected["packets_seen"])
self.assertEqual(
report["counters"]["history_requests_recognized"],
expected["history_requests_recognized"],
)
self.assertEqual(
report["counters"]["response_candidates_rejected"],
expected["response_candidates_rejected"],
)
self.assertEqual(report["event_counts"], expected["event_counts"])
self.assertEqual(report["reason_counts"], expected["reason_counts"])
self.assertEqual(report["pending_requests"][0]["response_candidates"], 2)
self.assertEqual(
report["pending_requests"][0]["response_candidate_lengths"],
expected["pending_candidate_lengths"],
)
def test_diagnostic_fixture_contains_only_synthetic_network_identity(self):
fixture = load_capture_diagnostics_fixture()
self.assertTrue(fixture["privacy"]["synthetic"])
self.assertFalse(fixture["privacy"]["contains_user_uid"])
self.assertFalse(fixture["privacy"]["contains_raw_account_session"])
allowed_ips = {"192.0.2.10", "198.51.100.20"}
for packet in fixture["packets"]:
with self.subTest(label=packet["label"]):
self.assertIn(packet["src_ip"], allowed_ips)
self.assertIn(packet["dst_ip"], allowed_ips)
if "payload_hex" in packet:
payload = bytes.fromhex(packet["payload_hex"])
else:
payload = bytes.fromhex(packet["payload_byte"]) * packet["payload_length"]
self.assertIsNone(extract_user_uid(payload))
def test_diagnostic_report_excludes_capture_identity_and_payload_data(self):
report = diagnostic_fixture_session().diagnostic_report()
serialized = json.dumps(report)
forbidden_keys = {
"src_ip",
"dst_ip",
"src_port",
"dst_port",
"timestamp",
"payload",
"payload_hex",
"user_uid",
}
def assert_safe(value):
if isinstance(value, dict):
self.assertTrue(forbidden_keys.isdisjoint(value))
for child in value.values():
assert_safe(child)
elif isinstance(value, list):
for child in value:
assert_safe(child)
assert_safe(report)
self.assertNotIn("192.0.2.10", serialized)
self.assertNotIn("198.51.100.20", serialized)
self.assertNotIn("1893456000", serialized)
def test_diagnostics_are_debug_sidecar_not_public_export_data(self):
session = diagnostic_fixture_session()
export = build_export_json([], [])
self.assertNotIn("diagnostics", export)
self.assertNotIn("capture_diagnostics", export)
with TemporaryDirectory() as temp_dir:
diagnostics_path = new_diagnostics_path(temp_dir)
write_capture_diagnostics(diagnostics_path, session.diagnostic_report())
written = json.loads(diagnostics_path.read_text(encoding="utf-8"))
self.assertEqual(written, session.diagnostic_report())
self.assertRegex(diagnostics_path.name, r"^Capture_\d{8}_\d{6}\.diagnostics\.json$")
self.assertNotIn("user", diagnostics_path.name.casefold())