netsleuth/tests/test_captive_portal.py
Andrew Pimenov 84c3dffbce detect captive portals up front, flag off-country speedtest servers
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).
2026-08-12 21:36:54 +03:00

89 lines
3.3 KiB
Python

from __future__ import annotations
import httpx
from netsleuth.interpret import captive_portal_findings
from netsleuth.models import CaptivePortal
from netsleuth.probes.captive_portal import check_captive_portal, classify_portal_response
URL = "http://cp.cloudflare.com/generate_204"
def test_classify_204_with_empty_body_is_clean():
assert classify_portal_response(URL, 204, "", None) == ("clean", "204 with an empty body, as expected")
def test_classify_204_with_a_body_is_suspect():
verdict, _ = classify_portal_response(URL, 204, "unexpected", None)
assert verdict == "suspect"
def test_classify_200_with_a_body_is_portal():
verdict, _ = classify_portal_response(URL, 200, "<html>login</html>", None)
assert verdict == "portal"
def test_classify_302_to_the_same_host_is_still_a_redirect_status_portal():
verdict, evidence = classify_portal_response(URL, 302, "", "http://cp.cloudflare.com/login")
assert verdict == "portal"
assert "302" in evidence
def test_classify_302_to_a_different_host_is_portal():
verdict, evidence = classify_portal_response(URL, 302, "", "http://portal.example.net/login")
assert verdict == "portal"
def test_classify_511_is_portal():
assert classify_portal_response(URL, 511, "", None)[0] == "portal"
def test_classify_500_is_suspect():
assert classify_portal_response(URL, 500, "", None)[0] == "suspect"
def test_classify_no_response_is_error():
assert classify_portal_response(URL, None, "", None) == ("error", "no response")
async def test_check_captive_portal_detects_a_redirect(httpx_mock):
httpx_mock.add_response(url=URL, status_code=302, headers={"location": "http://portal.example.net/login"})
async with httpx.AsyncClient() as client:
result = await check_captive_portal(client, [URL], timeout=5.0)
assert result.detected is True
assert result.verdict == "portal"
assert result.portal_url == URL
async def test_check_captive_portal_clean_connection(httpx_mock):
httpx_mock.add_response(url=URL, status_code=204)
async with httpx.AsyncClient() as client:
result = await check_captive_portal(client, [URL], timeout=5.0)
assert result.detected is False
assert result.verdict == "clean"
async def test_check_captive_portal_all_ambiguous_is_suspect(httpx_mock):
httpx_mock.add_response(url=URL, status_code=500)
async with httpx.AsyncClient() as client:
result = await check_captive_portal(client, [URL], timeout=5.0)
assert result.detected is False
assert result.verdict == "suspect"
def test_captive_portal_findings_fires_crit_when_detected():
cp = CaptivePortal(detected=True, verdict="portal", portal_url=URL, note="redirected", note_ru="редирект")
findings = captive_portal_findings(cp)
assert [f.id for f in findings] == ["net.captive_portal"]
assert findings[0].severity == "crit"
def test_captive_portal_findings_fires_warn_when_suspect():
cp = CaptivePortal(detected=False, verdict="suspect", note="ambiguous", note_ru="неоднозначно")
findings = captive_portal_findings(cp)
assert [f.id for f in findings] == ["net.captive_portal_suspect"]
assert findings[0].severity == "warn"
def test_captive_portal_findings_silent_when_clean():
assert captive_portal_findings(CaptivePortal(detected=False, verdict="clean")) == []