mirror of
https://github.com/Horizontal42/netsleuth.git
synced 2026-11-10 15:42:10 +03:00
New Phase 0 in diagnose(): three generate_204-style probes run before every other module, using detect-and-tag rather than short-circuit — a portal is rarely total (DNS/ICMP often pass while only HTTP is intercepted), and an aborted run would look identical to a dead link. On detection every later phase still runs, tagged with a crit finding and a banner at the top of the report. Caught live before it shipped: detectportal.firefox.com/success.txt (one of the original three default check URLs) returns 200 "success" on a genuinely clean connection, which the generate_204-shaped classifier misread as a portal redirect. Swapped for a second real generate_204 endpoint instead of teaching the classifier a second success shape. Also redesigned the speedtest server geo-sanity check after checking the bundled Ookla CLI's actual JSON output: its server object never carries lat/lon (documented schema, not a version gap), so speed.server_other_country compares server_country against the client's own country instead of a haversine distance that no tier can actually supply. Part of plans/zesty-zooming-wilkinson.md (gitignored).
921 lines
32 KiB
Python
921 lines
32 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import math
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from netsleuth.exporter import (
|
||
FORMAT_EXTENSIONS,
|
||
SCHEMA_VERSION,
|
||
SECTION_ORDER,
|
||
atomic_write,
|
||
build_report,
|
||
compact_timestamp,
|
||
dump_json,
|
||
egress_asn,
|
||
flatten_errors,
|
||
report_filename,
|
||
sanitize_name,
|
||
write_report,
|
||
)
|
||
from netsleuth.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) == {
|
||
"captive_portal",
|
||
"connection",
|
||
"ip_geo",
|
||
"vpn_assessment",
|
||
"bgp",
|
||
"reputation",
|
||
"dns_advanced",
|
||
"latency",
|
||
"tls",
|
||
"path",
|
||
"path_diversity",
|
||
"prefix_benchmark",
|
||
"dpi_check",
|
||
"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_write_report_names_the_file_after_the_target_in_target_mode(tmp_path: Path):
|
||
report = build_report({**meta(), "mode": "target", "target": "AS15169"}, modules(), [], {})
|
||
paths = write_report(
|
||
report,
|
||
{"json": "{}", "md": "# netsleuth report\n", "ru-md": "# netsleuth report\n"},
|
||
tmp_path,
|
||
)
|
||
names = {p.name for p in paths}
|
||
assert "report_AS15169_20260808T191200Z.json" in names
|
||
assert "report_AS15169_20260808T191200Z.md" in names
|
||
assert "report_AS15169_20260808T191200Z.ru.md" in names
|
||
|
||
|
||
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_all_requested_artifacts_with_matching_names(tmp_path: Path):
|
||
report = build_report(meta(), modules(), [], {})
|
||
paths = write_report(
|
||
report,
|
||
{"json": dump_json(report), "md": "# netsleuth report\n", "ru-md": "# netsleuth report\n"},
|
||
tmp_path,
|
||
)
|
||
by_ext = {p.name.rsplit(".", 1)[-1] if not p.name.endswith(".ru.md") else "ru.md": p for p in paths}
|
||
assert by_ext["json"].name == "report_AS64500_20260808T191200Z.json"
|
||
assert by_ext["md"].name == "report_AS64500_20260808T191200Z.md"
|
||
assert by_ext["ru.md"].name == "report_AS64500_20260808T191200Z.ru.md"
|
||
assert json.loads(by_ext["json"].read_text(encoding="utf-8"))["schema_version"] == SCHEMA_VERSION
|
||
assert by_ext["md"].read_text(encoding="utf-8").startswith("# netsleuth report")
|
||
assert by_ext["ru.md"].read_text(encoding="utf-8").startswith("# netsleuth report")
|
||
|
||
|
||
def test_write_report_writes_only_the_requested_formats(tmp_path: Path):
|
||
report = build_report(meta(), modules(), [], {})
|
||
paths = write_report(report, {"md": "# netsleuth report\n"}, tmp_path)
|
||
assert [p.name for p in paths] == ["report_AS64500_20260808T191200Z.md"]
|
||
assert list(tmp_path.iterdir()) == paths
|
||
|
||
|
||
def test_write_report_with_empty_mapping_writes_nothing(tmp_path: Path):
|
||
report = build_report(meta(), modules(), [], {})
|
||
paths = write_report(report, {}, tmp_path)
|
||
assert paths == []
|
||
assert list(tmp_path.iterdir()) == []
|
||
|
||
|
||
def test_write_report_returns_paths_in_input_order(tmp_path: Path):
|
||
report = build_report(meta(), modules(), [], {})
|
||
paths = write_report(report, {"json": "{}", "md": "# x\n"}, tmp_path)
|
||
assert paths[0].name.endswith(".json")
|
||
assert paths[1].name.endswith(".md")
|
||
|
||
|
||
def test_format_extensions_covers_the_three_known_formats():
|
||
assert FORMAT_EXTENSIONS == {"md": "md", "ru-md": "ru.md", "json": "json"}
|
||
|
||
|
||
from netsleuth.exporter import SPARK_CHARS, _forced_interface_rows, badge, first_loss_jump, render_markdown, sparkline
|
||
from netsleuth.models import (
|
||
AdapterLeakResult,
|
||
AnycastHop,
|
||
BgpIntel,
|
||
CfTrace,
|
||
DnsAdvanced,
|
||
DnsLeak,
|
||
DnsblHit,
|
||
DpiCheckResult,
|
||
InternetDbResult,
|
||
IxpPresence,
|
||
PathDiversity,
|
||
PortProbe,
|
||
PrefixBenchmark,
|
||
PrefixProbe,
|
||
Reputation,
|
||
ResolverProbe,
|
||
Signal,
|
||
TierAttempt,
|
||
TlsResult,
|
||
TraceHop,
|
||
TraceResult,
|
||
VpnAssessment,
|
||
)
|
||
|
||
|
||
def full_modules() -> dict[str, ModuleResult]:
|
||
return {
|
||
"connection": ModuleResult(
|
||
name="connection",
|
||
status="ok",
|
||
data=LocalNet(
|
||
iface_name="Wi-Fi 2",
|
||
local_ipv4="192.168.1.34",
|
||
iface_mtu=1500,
|
||
default_gateway_v4="192.168.1.1",
|
||
dns_servers_per_adapter={"Wi-Fi 2": ["192.168.1.1"]},
|
||
),
|
||
),
|
||
"ip_geo": ModuleResult(
|
||
name="ip_geo",
|
||
status="ok",
|
||
data={
|
||
"egress_v4": IpGeo(
|
||
ip="203.0.113.44",
|
||
asn="AS64500",
|
||
as_name="Example Telecom",
|
||
city="Amsterdam",
|
||
country="Netherlands",
|
||
country_code="NL",
|
||
ip_type="residential",
|
||
reverse_dns="host-203-0-113-44.example.net",
|
||
),
|
||
"egress_v6": IpGeo(ip="2001:db8::1", asn="AS64500"),
|
||
"cf_trace": CfTrace(ip="203.0.113.44", colo="AMS", warp="off"),
|
||
"dual_stack_note": None,
|
||
},
|
||
),
|
||
"vpn_assessment": ModuleResult(
|
||
name="vpn_assessment",
|
||
status="ok",
|
||
data=VpnAssessment(
|
||
verdict="likely",
|
||
confidence=0.55,
|
||
signals=[Signal(name="cf_warp", observed=True, weight=0.5, direction="vpn", note="on")],
|
||
tunnel_iface="wg0",
|
||
dns_leak=DnsLeak(
|
||
per_adapter=[
|
||
AdapterLeakResult(
|
||
adapter="Wi-Fi 2",
|
||
configured_resolvers=["192.168.1.1"],
|
||
echoed_ip="203.0.113.9",
|
||
echoed_asn="AS64501",
|
||
matches_egress_asn=False,
|
||
)
|
||
],
|
||
ecs_leaked=True,
|
||
note="ISP resolver still active on the Wi-Fi adapter.",
|
||
),
|
||
),
|
||
),
|
||
"bgp": ModuleResult(
|
||
name="bgp",
|
||
status="ok",
|
||
data=BgpIntel(
|
||
asn="AS64500",
|
||
holder="Example Telecom BV",
|
||
upstreams=["AS3356"],
|
||
prefix_count_v4=2,
|
||
prefix_count_v6=1,
|
||
stability="stable",
|
||
ixps=[IxpPresence(name="AMS-IX", city="Amsterdam", country="NL", speed_mbps=100000)],
|
||
asrank=1842,
|
||
cone_asns=37,
|
||
pdb_info_type="Cable/DSL/ISP",
|
||
),
|
||
),
|
||
"reputation": ModuleResult(
|
||
name="reputation",
|
||
status="ok",
|
||
data=Reputation(
|
||
internetdb=InternetDbResult(ip="203.0.113.44", ports=[80, 443], tags=["cdn"]),
|
||
firehol_hits=[],
|
||
dnsbl_hits=[DnsblHit(zone="bl.spamcop.net", codes=["127.0.0.2"])],
|
||
dnsbl_query_blocked=True,
|
||
captcha_risk="high",
|
||
rationale="listed on bl.spamcop.net",
|
||
),
|
||
),
|
||
"dns_advanced": ModuleResult(
|
||
name="dns_advanced",
|
||
status="ok",
|
||
data=DnsAdvanced(
|
||
probes=[
|
||
ResolverProbe(name="system", kind="system", query_name="cloudflare.com", answers=["1.1.1.1"], elapsed_ms=18.0),
|
||
ResolverProbe(name="cloudflare", kind="doh", query_name="cloudflare.com", answers=["104.16.132.229"], elapsed_ms=22.0),
|
||
],
|
||
system_avg_ms=18.0,
|
||
doh_avg_ms=22.0,
|
||
transparent_proxy=False,
|
||
transparent_proxy_detail="No response from the bogus resolver IP (timeout): transparent DNS proxy not detected.",
|
||
note="System resolver and DoH agree within normal CDN variance.",
|
||
note_ru="Системный резолвер и DoH согласуются в пределах обычной вариации CDN.",
|
||
),
|
||
),
|
||
"latency": ModuleResult(
|
||
name="latency",
|
||
status="ok",
|
||
data=[
|
||
PingResult(
|
||
label="cloudflare-dns",
|
||
host="1.1.1.1",
|
||
method="icmp_win",
|
||
sent=5,
|
||
received=5,
|
||
avg_ms=12.4,
|
||
min_ms=11.0,
|
||
max_ms=15.1,
|
||
jitter_ms=1.9,
|
||
samples=[11.0, 12.0, None, 15.1, 12.4],
|
||
),
|
||
PingResult(label="github", host="github.com", method="tcp", sent=5, received=4, loss_pct=20.0, avg_ms=42.0),
|
||
],
|
||
duration_ms=2400,
|
||
),
|
||
"tls": ModuleResult(
|
||
name="tls",
|
||
status="ok",
|
||
data=[
|
||
TlsResult(
|
||
label="cloudflare",
|
||
host="cloudflare.com",
|
||
port=443,
|
||
resolved_ip="104.16.132.229",
|
||
tcp_rtt_ms=12.0,
|
||
tls_handshake_ms=45.0,
|
||
ttfb_ms=60.0,
|
||
tls_version="TLSv1.3",
|
||
cipher="TLS_AES_256_GCM_SHA384",
|
||
)
|
||
],
|
||
),
|
||
"path": ModuleResult(
|
||
name="path",
|
||
status="ok",
|
||
data=[
|
||
TraceResult(
|
||
target="1.1.1.1",
|
||
backend="icmp_win",
|
||
completed=True,
|
||
hops=[
|
||
TraceHop(ttl=1, ip="192.168.1.1", avg_ms=1.1, loss_pct=0.0),
|
||
TraceHop(ttl=2, ip="10.64.0.1", avg_ms=9.0, loss_pct=60.0),
|
||
TraceHop(ttl=3, ip="1.1.1.1", avg_ms=12.4, loss_pct=55.0),
|
||
],
|
||
)
|
||
],
|
||
),
|
||
"path_diversity": ModuleResult(
|
||
name="path_diversity",
|
||
status="ok",
|
||
data=PathDiversity(
|
||
client_country="RU",
|
||
hops=[
|
||
AnycastHop(
|
||
target="cloudflare.com",
|
||
resolved_ip="104.16.132.229",
|
||
edge_colo="FRA",
|
||
edge_city="Frankfurt",
|
||
edge_country="DE",
|
||
edge_rtt_ms=15.0,
|
||
client_rtt_ms=45.0,
|
||
source="cf_ray",
|
||
)
|
||
],
|
||
international_loop=True,
|
||
detour_countries=["DE"],
|
||
note="Anycast routed traffic through DE even though the client is in RU.",
|
||
note_ru="Anycast завернул трафик через DE, хотя клиент находится в RU.",
|
||
),
|
||
),
|
||
"prefix_benchmark": ModuleResult(
|
||
name="prefix_benchmark",
|
||
status="ok",
|
||
data=PrefixBenchmark(
|
||
asn="AS64500",
|
||
prefixes_announced=50,
|
||
prefixes_probed=2,
|
||
method="icmp",
|
||
results=[
|
||
PrefixProbe(prefix="203.0.113.0/24", probe_ip="203.0.113.1", avg_ms=12.0, loss_pct=0.0, reachable=True),
|
||
PrefixProbe(prefix="198.51.100.0/24", probe_ip="198.51.100.1", avg_ms=None, loss_pct=100.0, reachable=False),
|
||
],
|
||
best="203.0.113.0/24",
|
||
worst="203.0.113.0/24",
|
||
),
|
||
),
|
||
"dpi_check": ModuleResult(
|
||
name="dpi_check",
|
||
status="ok",
|
||
data=DpiCheckResult(
|
||
target="203.0.113.9",
|
||
resolved_ip="203.0.113.9",
|
||
consented=True,
|
||
ports=[
|
||
PortProbe(port=443, state="open", rtt_ms=20.0),
|
||
PortProbe(port=8443, state="filtered"),
|
||
],
|
||
verdict="partial_filtering",
|
||
rationale="Some ports responded (open) while others were silently dropped (filtered).",
|
||
rationale_ru="Часть портов ответила (открыты), а часть тихо не ответила (отфильтрованы).",
|
||
),
|
||
),
|
||
"speed": ModuleResult(
|
||
name="speed",
|
||
status="ok",
|
||
data=SpeedResult(
|
||
method="cloudflare",
|
||
tier_attempts=[
|
||
TierAttempt(tier="ookla_bin", ok=False, reason="binary not on PATH"),
|
||
TierAttempt(tier="cloudflare", ok=True),
|
||
],
|
||
download_mbps=284.3,
|
||
upload_mbps=41.7,
|
||
server="speed.cloudflare.com",
|
||
idle_rtt_ms=12.0,
|
||
loaded_rtt_down_ms=48.0,
|
||
bufferbloat_down_ms=36.0,
|
||
bufferbloat_grade="C",
|
||
),
|
||
),
|
||
}
|
||
|
||
|
||
def full_report() -> dict:
|
||
findings = [
|
||
Finding(
|
||
id="latency.loss.github",
|
||
severity="warn",
|
||
title="Loss to github.com",
|
||
detail="20.0% connection failures over 5 probes via tcp.",
|
||
metric="loss_pct",
|
||
value=20.0,
|
||
threshold=2.0,
|
||
advice="Sustained loss on every host points at the local link.",
|
||
)
|
||
]
|
||
return build_report(meta(), full_modules(), findings, {"ip-api": {"status": "success"}})
|
||
|
||
|
||
def test_sparkline_maps_a_series_across_the_glyph_ramp():
|
||
line = sparkline([1.0, 2.0, 3.0, 4.0, 5.0])
|
||
assert len(line) == 5
|
||
assert line[0] == SPARK_CHARS[0]
|
||
assert line[-1] == SPARK_CHARS[-1]
|
||
assert SPARK_CHARS == "▁▂▃▅▇"
|
||
|
||
|
||
def test_sparkline_of_a_flat_series_is_all_baseline():
|
||
assert sparkline([7.0, 7.0, 7.0]) == SPARK_CHARS[0] * 3
|
||
|
||
|
||
def test_sparkline_renders_a_dropped_probe_as_a_gap():
|
||
line = sparkline([1.0, None, 5.0])
|
||
assert line[1] == " "
|
||
assert len(line) == 3
|
||
|
||
|
||
def test_sparkline_of_nothing_is_empty():
|
||
assert sparkline([]) == ""
|
||
assert sparkline([None, None]) == ""
|
||
|
||
|
||
def test_badge_is_gated_behind_the_emoji_setting():
|
||
assert badge("crit", emoji=True) == "🔴"
|
||
assert badge("warn", emoji=True) == "🟡"
|
||
assert badge("ok", emoji=True) == "🟢"
|
||
assert badge("crit", emoji=False) == "[crit]"
|
||
assert badge("warn", emoji=False) == "[warn]"
|
||
|
||
|
||
def test_first_loss_jump_needs_the_loss_to_persist_downstream():
|
||
hops = [
|
||
{"ttl": 1, "loss_pct": 0.0},
|
||
{"ttl": 2, "loss_pct": 60.0},
|
||
{"ttl": 3, "loss_pct": 55.0},
|
||
]
|
||
assert first_loss_jump(hops) == 2
|
||
|
||
|
||
def test_first_loss_jump_ignores_a_single_icmp_rate_limiting_hop():
|
||
hops = [{"ttl": 1, "loss_pct": 0.0}, {"ttl": 2, "loss_pct": 100.0}, {"ttl": 3, "loss_pct": 0.0}]
|
||
assert first_loss_jump(hops) is None
|
||
assert first_loss_jump([]) is None
|
||
|
||
|
||
def test_markdown_has_every_section_from_the_spec_in_order():
|
||
text = render_markdown(full_report())
|
||
headings = [line for line in text.splitlines() if line.startswith("## ")]
|
||
assert headings == [
|
||
"## TL;DR",
|
||
"## Captive portal check",
|
||
"## Connection & identity",
|
||
"## VPN / proxy assessment",
|
||
"## ASN & BGP intelligence",
|
||
"## Reputation",
|
||
"## DNS: system vs DoH",
|
||
"## Latency",
|
||
"## TLS handshake",
|
||
"## Path",
|
||
"## Path diversity (Anycast)",
|
||
"## AS prefix benchmark",
|
||
"## DPI / port check",
|
||
"## Speed",
|
||
"## Problems & recommendations",
|
||
"## Run diagnostics",
|
||
]
|
||
assert text.startswith("# netsleuth report")
|
||
|
||
|
||
def test_markdown_header_states_mode_timestamp_and_verdict():
|
||
text = render_markdown(full_report())
|
||
assert "auto" in text
|
||
assert "2026-08-08T19:12:00Z" in text
|
||
assert "90/100" in text
|
||
|
||
|
||
def test_markdown_reports_identity_and_both_stacks():
|
||
text = render_markdown(full_report())
|
||
assert "203.0.113.44" in text
|
||
assert "AS64500" in text
|
||
assert "2001:db8::1" in text
|
||
assert "Amsterdam" in text
|
||
assert "residential" in text
|
||
|
||
|
||
def test_markdown_vpn_section_lists_signals_and_the_dns_leak():
|
||
text = render_markdown(full_report())
|
||
assert "likely" in text
|
||
assert "cf_warp" in text
|
||
assert "Wi-Fi 2" in text
|
||
assert "AS64501" in text
|
||
assert "ISP resolver still active" in text
|
||
|
||
|
||
def test_markdown_dnsbl_error_range_is_never_shown_as_a_plain_listing():
|
||
text = render_markdown(full_report())
|
||
assert "bl.spamcop.net" in text
|
||
assert "not a listing" in text
|
||
|
||
|
||
def test_markdown_latency_table_carries_a_sparkline_in_a_fenced_block():
|
||
text = render_markdown(full_report())
|
||
assert "cloudflare-dns" in text
|
||
assert any(char in text for char in SPARK_CHARS)
|
||
assert text.count("```") >= 2
|
||
|
||
|
||
def test_markdown_flags_that_tcp_loss_is_a_different_metric():
|
||
text = render_markdown(full_report())
|
||
assert "failed TCP connections" in text
|
||
|
||
|
||
def test_markdown_path_section_marks_the_first_loss_jump():
|
||
text = render_markdown(full_report())
|
||
path_block = text.split("## Path")[1].split("## Speed")[0]
|
||
marked = [line for line in path_block.splitlines() if line.rstrip().endswith("<<")]
|
||
assert len(marked) == 1
|
||
assert "10.64.0.1" in marked[0]
|
||
|
||
|
||
def test_markdown_speed_section_explains_the_bufferbloat_grade_in_plain_language():
|
||
text = render_markdown(full_report())
|
||
assert "284.3" in text
|
||
assert "grade C" in text
|
||
assert "choppy" in text
|
||
assert "ookla_bin" in text
|
||
assert "binary not on PATH" in text
|
||
|
||
|
||
def test_markdown_run_diagnostics_lists_every_module_with_its_status():
|
||
text = render_markdown(full_report())
|
||
block = text.split("## Run diagnostics")[1]
|
||
for section in SECTION_ORDER:
|
||
assert section in block
|
||
|
||
|
||
def test_markdown_without_emoji_uses_text_badges():
|
||
text = render_markdown(full_report(), emoji=False)
|
||
assert "[warn]" in text
|
||
assert "🟡" not in text
|
||
|
||
|
||
def test_forced_interface_rows_are_empty_without_a_forced_interface():
|
||
assert _forced_interface_rows({}, "en") == []
|
||
|
||
|
||
def test_forced_interface_rows_show_the_resolved_adapter():
|
||
meta = {
|
||
"interface": {
|
||
"requested": "Ethernet",
|
||
"resolved_iface": "Ethernet",
|
||
"bind_ipv4": "192.168.3.72",
|
||
"os_default_iface": "Ethernet",
|
||
"os_default_ipv4": "192.168.3.72",
|
||
}
|
||
}
|
||
rows = _forced_interface_rows(meta, "en")
|
||
assert rows == [["Forced interface", "Ethernet — 192.168.3.72 (--interface)"]]
|
||
|
||
|
||
def test_forced_interface_rows_add_the_os_default_when_it_differs():
|
||
meta = {
|
||
"interface": {
|
||
"requested": "Tailscale",
|
||
"resolved_iface": "Tailscale",
|
||
"bind_ipv4": "100.84.46.36",
|
||
"os_default_iface": "Ethernet",
|
||
"os_default_ipv4": "192.168.3.72",
|
||
}
|
||
}
|
||
rows = _forced_interface_rows(meta, "en")
|
||
assert rows == [
|
||
["Forced interface", "Tailscale — 100.84.46.36 (--interface)"],
|
||
["OS default interface", "Ethernet — 192.168.3.72"],
|
||
]
|
||
|
||
|
||
def test_forced_interface_rows_are_translated_in_russian():
|
||
meta = {"interface": {"requested": "Ethernet", "resolved_iface": "Ethernet", "bind_ipv4": "192.168.3.72"}}
|
||
rows = _forced_interface_rows(meta, "ru")
|
||
assert rows[0][0] == "Принудительный интерфейс"
|
||
|
||
|
||
from netsleuth.exporter import unavailable
|
||
|
||
|
||
def dead_modules() -> dict[str, ModuleResult]:
|
||
return {
|
||
section: ModuleResult(
|
||
name=section,
|
||
status="failed",
|
||
data=None,
|
||
errors=[ProbeError(source=section, kind="unavailable", message="network unreachable", retryable=True)],
|
||
duration_ms=8000,
|
||
)
|
||
for section in SECTION_ORDER
|
||
}
|
||
|
||
|
||
def dead_report() -> dict:
|
||
return build_report(meta(), dead_modules(), [], {})
|
||
|
||
|
||
def test_unavailable_describes_a_failed_module_and_stays_quiet_on_a_healthy_one():
|
||
assert unavailable(full_report(), "bgp") is None
|
||
note = unavailable(dead_report(), "bgp")
|
||
assert note is not None
|
||
assert "failed" in note
|
||
assert "network unreachable" in note
|
||
|
||
|
||
def test_unavailable_explains_a_skipped_module_without_inventing_an_error():
|
||
report = build_report(meta(), {"speed": ModuleResult(name="speed", status="skipped")}, [], {})
|
||
note = unavailable(report, "speed")
|
||
assert note is not None
|
||
assert "skipped" in note
|
||
|
||
|
||
def test_unavailable_surfaces_the_skip_reason_from_warnings():
|
||
report = build_report(
|
||
meta(),
|
||
{"speed": ModuleResult(name="speed", status="skipped", warnings=["speedtest skipped in target mode"])},
|
||
[],
|
||
{},
|
||
)
|
||
note = unavailable(report, "speed")
|
||
assert note is not None
|
||
assert "speedtest skipped in target mode" in note
|
||
|
||
|
||
def test_unavailable_translates_the_skip_reason_into_russian():
|
||
report = build_report(
|
||
meta(),
|
||
{"speed": ModuleResult(name="speed", status="skipped", warnings=["speedtest skipped: --quick"])},
|
||
[],
|
||
{},
|
||
)
|
||
note = unavailable(report, "speed", lang="ru")
|
||
assert note is not None
|
||
assert "speedtest skipped: --quick" not in note
|
||
assert "speedtest пропущен: --quick" in note
|
||
|
||
|
||
def test_diagnostics_translates_module_warnings_into_russian():
|
||
from netsleuth.exporter import _diagnostics
|
||
|
||
report = build_report(
|
||
meta(),
|
||
{"reputation": ModuleResult(name="reputation", status="partial", warnings=["ABUSEIPDB_API_KEY not set — abuse score skipped"])},
|
||
[],
|
||
{},
|
||
)
|
||
lines = _diagnostics(report, lang="ru")
|
||
text = "\n".join(lines)
|
||
assert "ABUSEIPDB_API_KEY not set — abuse score skipped" not in text
|
||
assert "ABUSEIPDB_API_KEY не задан" in text
|
||
|
||
|
||
def test_an_all_modules_failed_run_still_renders_every_section():
|
||
text = render_markdown(dead_report())
|
||
headings = [line for line in text.splitlines() if line.startswith("## ")]
|
||
assert len(headings) == 16
|
||
assert text.count("Not available") == len(SECTION_ORDER)
|
||
|
||
|
||
def test_an_all_modules_failed_report_is_still_valid_strict_json():
|
||
report = dead_report()
|
||
back = json.loads(dump_json(report))
|
||
assert back["schema_version"] == SCHEMA_VERSION
|
||
assert len(back["errors"]) == len(SECTION_ORDER)
|
||
assert all(section in back for section in SECTION_ORDER)
|
||
assert back["interpretation"]["overall_status"] == "ok"
|
||
assert back["raw"] == {}
|
||
|
||
|
||
def test_an_all_modules_failed_run_writes_both_artifacts(tmp_path: Path):
|
||
report = dead_report()
|
||
paths = write_report(
|
||
report,
|
||
{"md": render_markdown(report), "ru-md": render_markdown(report, lang="ru")},
|
||
tmp_path,
|
||
)
|
||
md_path = next(p for p in paths if p.name.endswith(".md") and not p.name.endswith(".ru.md"))
|
||
ru_md_path = next(p for p in paths if p.name.endswith(".ru.md"))
|
||
assert md_path.name.startswith("report_unknown_")
|
||
assert ru_md_path.name.startswith("report_unknown_")
|
||
assert "## Run diagnostics" in md_path.read_text(encoding="utf-8")
|
||
assert "## Диагностика запуска" in ru_md_path.read_text(encoding="utf-8")
|
||
|
||
|
||
def test_the_diagnostics_table_still_names_every_failed_module():
|
||
block = render_markdown(dead_report()).split("## Run diagnostics")[1]
|
||
assert block.count("failed") == len(SECTION_ORDER)
|
||
assert "unavailable" in block
|
||
|
||
|
||
def test_a_partial_module_with_data_is_rendered_normally_not_as_a_placeholder():
|
||
report = build_report(meta(), modules(), [], {})
|
||
assert unavailable(report, "latency") is None
|
||
assert "cloudflare-dns" in render_markdown(report)
|
||
|
||
|
||
def _has_cyrillic(text: str) -> bool:
|
||
return any("Ѐ" <= ch <= "ӿ" for ch in text)
|
||
|
||
|
||
def test_russian_markdown_is_non_empty_and_contains_cyrillic():
|
||
text = render_markdown(full_report(), lang="ru")
|
||
assert text.strip()
|
||
assert _has_cyrillic(text)
|
||
assert text.startswith("# netsleuth report")
|
||
|
||
|
||
def _structural_shape(text: str) -> tuple[int, int, int]:
|
||
headings = sum(1 for line in text.splitlines() if line.startswith("## "))
|
||
table_rows = sum(1 for line in text.splitlines() if line.strip().startswith("|"))
|
||
fences = text.count("```")
|
||
return headings, table_rows, fences
|
||
|
||
|
||
def test_russian_report_has_the_same_structural_shape_as_english_for_a_full_report():
|
||
en = render_markdown(full_report(), lang="en")
|
||
ru = render_markdown(full_report(), lang="ru")
|
||
assert _structural_shape(en) == _structural_shape(ru)
|
||
|
||
|
||
def test_russian_report_has_the_same_structural_shape_as_english_for_a_dead_report():
|
||
en = render_markdown(dead_report(), lang="en")
|
||
ru = render_markdown(dead_report(), lang="ru")
|
||
assert _structural_shape(en) == _structural_shape(ru)
|
||
|
||
|
||
def test_write_report_creates_three_files_with_the_right_extensions(tmp_path: Path):
|
||
report = full_report()
|
||
en_name = "sibling.md"
|
||
ru_name = "sibling.ru.md"
|
||
markdown = render_markdown(report, lang="en", sibling=ru_name)
|
||
markdown_ru = render_markdown(report, lang="ru", sibling=en_name)
|
||
paths = write_report(report, {"json": dump_json(report), "md": markdown, "ru-md": markdown_ru}, tmp_path)
|
||
json_path = next(p for p in paths if p.suffix == ".json")
|
||
md_path = next(p for p in paths if p.name.endswith(".md") and not p.name.endswith(".ru.md"))
|
||
ru_md_path = next(p for p in paths if p.name.endswith(".ru.md"))
|
||
assert json_path.suffix == ".json"
|
||
|
||
ru_text = ru_md_path.read_text(encoding="utf-8")
|
||
assert _has_cyrillic(ru_text)
|
||
|
||
en_lines = md_path.read_text(encoding="utf-8").splitlines()
|
||
ru_lines = ru_text.splitlines()
|
||
assert en_lines[2] == f"[Русский]({ru_name})"
|
||
assert ru_lines[2] == f"[English]({en_name})"
|
||
|
||
|
||
def test_render_markdown_default_call_is_byte_identical_to_before_the_lang_feature():
|
||
text = render_markdown(full_report())
|
||
headings = [line for line in text.splitlines() if line.startswith("## ")]
|
||
assert headings == [
|
||
"## TL;DR",
|
||
"## Captive portal check",
|
||
"## Connection & identity",
|
||
"## VPN / proxy assessment",
|
||
"## ASN & BGP intelligence",
|
||
"## Reputation",
|
||
"## DNS: system vs DoH",
|
||
"## Latency",
|
||
"## TLS handshake",
|
||
"## Path",
|
||
"## Path diversity (Anycast)",
|
||
"## AS prefix benchmark",
|
||
"## DPI / port check",
|
||
"## Speed",
|
||
"## Problems & recommendations",
|
||
"## Run diagnostics",
|
||
]
|
||
assert text.startswith("# netsleuth report")
|
||
assert "[Русский]" not in text
|
||
assert "[English]" not in text
|
||
assert "cloudflare-dns" in text
|