netsleuth/tests/test_exporter.py

217 lines
7.2 KiB
Python

from __future__ import annotations
import json
import math
from pathlib import Path
import pytest
from netcheck.exporter import (
SCHEMA_VERSION,
SECTION_ORDER,
atomic_write,
build_report,
compact_timestamp,
dump_json,
egress_asn,
flatten_errors,
report_filename,
sanitize_name,
write_report,
)
from netcheck.models import (
Finding,
IpGeo,
LocalNet,
ModuleResult,
PingResult,
ProbeError,
SpeedResult,
)
def meta() -> dict:
return {
"run_id": "b7f1",
"started_at": "2026-08-08T19:12:00Z",
"finished_at": "2026-08-08T19:13:04Z",
"mode": "auto",
"target": None,
"flags": {"quick": True, "full": False, "dnsbl": False, "ndt7": False, "tcp_trace": False},
"host_os": "Windows",
"capabilities": {"os_name": "Windows", "chosen_latency_backend": "icmp_win"},
}
def modules() -> dict[str, ModuleResult]:
return {
"connection": ModuleResult(name="connection", status="ok", data=LocalNet(iface_name="Wi-Fi 2")),
"ip_geo": ModuleResult(
name="ip_geo",
status="ok",
data={
"egress_v4": IpGeo(ip="203.0.113.44", asn="AS64500", as_name="Example Telecom"),
"egress_v6": None,
"cf_trace": None,
"dual_stack_note": None,
},
),
"latency": ModuleResult(
name="latency",
status="partial",
data=[PingResult(label="cloudflare-dns", host="1.1.1.1", method="icmp_win", sent=5, received=5)],
errors=[ProbeError(source="quad9-dns", kind="timeout", message="2s", retryable=True)],
warnings=["quad9 did not answer"],
duration_ms=1500,
),
"speed": ModuleResult(name="speed", status="skipped", data=None),
}
def test_report_carries_every_top_level_key_from_the_spec():
report = build_report(meta(), modules(), [], {"ip-api": {"status": "success"}})
expected = {"schema_version", "meta", "interpretation", "errors", "raw", *SECTION_ORDER}
assert expected.issubset(report.keys())
assert report["schema_version"] == SCHEMA_VERSION
def test_missing_modules_are_rendered_as_skipped_sections_not_dropped():
report = build_report(meta(), modules(), [], {})
assert report["bgp"]["status"] == "skipped"
assert report["bgp"]["data"] is None
assert set(SECTION_ORDER) == {
"connection",
"ip_geo",
"vpn_assessment",
"bgp",
"reputation",
"latency",
"path",
"speed",
}
def test_interpretation_is_computed_from_the_findings():
findings = [Finding(id="a", severity="warn", title="Jitter high", detail="d")]
report = build_report(meta(), modules(), findings, {})
assert report["interpretation"]["overall_status"] == "warn"
assert report["interpretation"]["overall_score"] == 90
assert report["interpretation"]["findings"][0]["id"] == "a"
def test_errors_are_flattened_to_the_top_level_with_their_module():
flat = flatten_errors(modules())
assert flat == [
{
"source": "quad9-dns",
"kind": "timeout",
"message": "2s",
"retryable": True,
"module": "latency",
}
]
def test_raw_payloads_are_stored_verbatim_under_their_source_key():
raw = {"ip-api": {"status": "success", "as": "AS64500 Example"}, "cf-trace": "ip=203.0.113.44"}
report = build_report(meta(), modules(), [], raw)
assert report["raw"]["ip-api"]["as"] == "AS64500 Example"
assert report["raw"]["cf-trace"] == "ip=203.0.113.44"
def test_non_finite_numbers_that_reached_the_pipeline_serialize_as_null():
# Zero-duration timing math produces exactly this on the failure paths the
# report most needs to show; allow_nan=False would otherwise abort the write.
broken = modules()
broken["speed"] = ModuleResult(
name="speed",
status="partial",
data=SpeedResult(
method="cloudflare",
download_mbps=float("inf"),
upload_mbps=float("nan"),
idle_rtt_ms=float("-inf"),
bufferbloat_down_ms=12.5,
),
)
report = build_report(meta(), broken, [], {"cloudflare": {"rate": float("inf")}})
text = dump_json(report)
back = json.loads(text)
assert back["speed"]["data"]["download_mbps"] is None
assert back["speed"]["data"]["upload_mbps"] is None
assert back["speed"]["data"]["idle_rtt_ms"] is None
assert back["speed"]["data"]["bufferbloat_down_ms"] == 12.5
assert back["raw"]["cloudflare"]["rate"] is None
assert "Infinity" not in text
assert "NaN" not in text
def test_dump_json_is_strict_and_round_trips():
report = build_report(meta(), modules(), [], {})
back = json.loads(dump_json(report))
assert back["meta"]["run_id"] == "b7f1"
assert back["latency"]["data"][0]["label"] == "cloudflare-dns"
assert math.isfinite(back["latency"]["duration_ms"])
def test_compact_timestamp_strips_the_characters_windows_forbids():
assert compact_timestamp("2026-08-08T19:12:00Z") == "20260808T191200Z"
assert compact_timestamp("") == "unknown"
@pytest.mark.parametrize(
("value", "expected"),
[
("AS64500", "AS64500"),
("AS64500 Example Telecom", "AS64500_Example_Telecom"),
("AS64500/../etc", "AS64500_.._etc"),
("AS:64500", "AS_64500"),
("", "unknown"),
(None, "unknown"),
("///", "unknown"),
],
)
def test_sanitize_name_produces_windows_safe_fragments(value, expected):
assert sanitize_name(value) == expected
def test_report_filename_matches_the_documented_pattern():
assert (
report_filename("AS64500", "2026-08-08T19:12:00Z", "json")
== "report_AS64500_20260808T191200Z.json"
)
assert (
report_filename(None, "2026-08-08T19:12:00Z", "md")
== "report_unknown_20260808T191200Z.md"
)
def test_egress_asn_is_read_out_of_the_assembled_report():
report = build_report(meta(), modules(), [], {})
assert egress_asn(report) == "AS64500"
assert egress_asn({"ip_geo": {"data": None}}) is None
assert egress_asn({}) is None
def test_atomic_write_creates_the_directory_and_leaves_no_temp_file(tmp_path: Path):
target = tmp_path / "logs" / "report.json"
atomic_write(target, '{"a": 1}')
assert target.read_text(encoding="utf-8") == '{"a": 1}'
assert list((tmp_path / "logs").iterdir()) == [target]
def test_atomic_write_replaces_an_existing_file(tmp_path: Path):
target = tmp_path / "report.json"
atomic_write(target, "old")
atomic_write(target, "new")
assert target.read_text(encoding="utf-8") == "new"
assert not list(tmp_path.glob("*.tmp"))
def test_write_report_emits_both_artifacts_with_matching_names(tmp_path: Path):
report = build_report(meta(), modules(), [], {})
json_path, md_path = write_report(report, "# netcheck report\n", tmp_path)
assert json_path.name == "report_AS64500_20260808T191200Z.json"
assert md_path.name == "report_AS64500_20260808T191200Z.md"
assert json.loads(json_path.read_text(encoding="utf-8"))["schema_version"] == SCHEMA_VERSION
assert md_path.read_text(encoding="utf-8").startswith("# netcheck report")