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:
parent
73bb100cd6
commit
2b33ea63af
11 changed files with 445 additions and 13 deletions
6
tests/fixtures/README.md
vendored
6
tests/fixtures/README.md
vendored
|
|
@ -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
|
||||
|
|
|
|||
60
tests/fixtures/synthetic_capture_diagnostics.json
vendored
Normal file
60
tests/fixtures/synthetic_capture_diagnostics.json
vendored
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
108
tests/test_capture_diagnostics.py
Normal file
108
tests/test_capture_diagnostics.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue