All checks were successful
PR build (required check) / changes (pull_request) Successful in 7s
secrets-guard / encrypted (pull_request) Successful in 6s
PR build (required check) / build-frontend (pull_request) Has been skipped
shell-lint / shellcheck (pull_request) Successful in 7s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-backend (pull_request) Successful in 58s
PR build (required check) / gate (pull_request) Successful in 2s
/healthz accounted for ~47% of prod's daily access log and the internal frontend_ssr->backend content-API hop for another ~32%, each costing a threadpool dispatch + lock + file write on the request path for traffic that isn't meaningful to retain. classify_inbound gains "health" and "internal" categories for these, both excluded from audit.log_access (alongside the existing static/metrics/event exclusions) so they're never dispatched to the logging threadpool at all. The access log's client IP is now truncated to /24 (IPv4) or /48 (IPv6) before being persisted -- coarse geo/abuse signal without keeping a full, joinable address in Loki for the 30-day retention window, matching the privacy page's claim that IPs aren't logged beyond normal request handling. The rate limiter keyed off the same IP (_rate_ok) is untouched and still sees full precision. uvicorn's own --no-access-log now suppresses its duplicate per-request line, since the app's own middleware already writes one.
87 lines
3.7 KiB
Python
87 lines
3.7 KiB
Python
"""The request-logging middleware (web/app.py's revalidate_static): what gets
|
|
counted, what gets written to the access log, and what gets truncated before
|
|
it's persisted. Companion to core/test_metrics.py's classify_inbound tests --
|
|
these exercise the actual ASGI request path, not just the classifier function.
|
|
"""
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from web import app as appmod
|
|
|
|
metrics = appmod.metrics # the exact module object app.py's own calls use --
|
|
audit = appmod.audit # see the note on module-reload isolation below.
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
return TestClient(appmod.app)
|
|
|
|
|
|
def _patch_log_access(monkeypatch):
|
|
calls = []
|
|
monkeypatch.setattr(audit, "log_access", lambda record: calls.append(record))
|
|
return calls
|
|
|
|
|
|
def test_healthz_is_never_audited(monkeypatch, client):
|
|
"""/healthz is ~47% of prod's daily access log; it must never even reach
|
|
audit.log_access (not just be cheap once there -- see classify_inbound's
|
|
"health" category and the exclusion in revalidate_static)."""
|
|
calls = _patch_log_access(monkeypatch)
|
|
r = client.get("/healthz")
|
|
assert r.status_code == 200
|
|
assert calls == []
|
|
|
|
|
|
def test_internal_category_is_never_audited(monkeypatch, client):
|
|
"""The SSR content API's own category ("internal") is excluded from the
|
|
access log the same way -- it's a server-to-server hop (frontend_ssr's
|
|
api_client.py calling backend), not a page view."""
|
|
calls = _patch_log_access(monkeypatch)
|
|
monkeypatch.setattr(metrics, "classify_inbound", lambda path, base: "internal")
|
|
r = client.get("/thermograph/api/version")
|
|
assert r.status_code == 200
|
|
assert calls == []
|
|
|
|
|
|
def test_access_log_ip_is_truncated_not_raw(monkeypatch, client):
|
|
calls = _patch_log_access(monkeypatch)
|
|
r = client.get("/thermograph/api/version",
|
|
headers={"X-Forwarded-For": "203.0.113.77, 10.0.0.1"})
|
|
assert r.status_code == 200
|
|
assert len(calls) == 1
|
|
assert calls[0]["ip"] == "203.0.113.0" # /24, not the exact address
|
|
assert calls[0]["ip"] != "203.0.113.77"
|
|
|
|
|
|
def test_loggable_ip_truncation():
|
|
assert appmod._loggable_ip("203.0.113.77") == "203.0.113.0"
|
|
assert appmod._loggable_ip("2001:db8:1234:5678::1") == "2001:db8:1234::"
|
|
assert appmod._loggable_ip(None) is None
|
|
assert appmod._loggable_ip("") == ""
|
|
assert appmod._loggable_ip("not-an-ip") == "not-an-ip" # best-effort, never raises
|
|
|
|
|
|
def test_rate_limiter_still_sees_the_full_precision_ip(monkeypatch, client):
|
|
"""_client_ip (which feeds both the access log and the event rate limiter)
|
|
is never itself truncated -- only what audit.log_access persists is. A
|
|
/24-truncated rate-limit key would let one abuser exhaust a whole NAT'd
|
|
office's quota, which is exactly what must NOT happen here."""
|
|
seen = {}
|
|
monkeypatch.setattr(metrics, "record_event", lambda name, **kw: seen.update(kw))
|
|
r = client.post("/thermograph/api/v2/event", json={"event": "home.locate"},
|
|
headers={"X-Forwarded-For": "203.0.113.77"})
|
|
assert r.status_code == 204
|
|
assert seen["ip"] == "203.0.113.77"
|
|
|
|
|
|
def test_event_route_records_end_to_end(client):
|
|
"""Full HTTP round trip through the real ASGI route -- confirms the write
|
|
path backend/api/event -> metrics.record_event -> the counters store works
|
|
end to end (the existing suite only ever called record_event directly)."""
|
|
before = metrics.snapshot()["events_total"]
|
|
r = client.post("/thermograph/api/v2/event", json={"event": "home.locate"})
|
|
assert r.status_code == 204
|
|
after = metrics.snapshot()
|
|
assert after["events_total"] == before + 1
|
|
assert after["events"]["home.locate"]["direct"] # no Referer sent -> "direct"
|