thermograph/backend/tests/web/test_geoip_api.py

115 lines
4.2 KiB
Python
Raw Normal View History

Approximate-location fallback from the client IP (feature-flagged off) A visitor who declines browser geolocation currently has no location at all — the copy sends them to the map picker. This adds an opt-in fallback that suggests a coarse city from the request's own IP, presented as a guess with a one-tap correction, so the dead end has a way out. backend/data/geoip.py does the lookup against a local MMDB file (DB-IP IP to City Lite or GeoLite2 City — same format, either works, attribution derived from the file's metadata). Off unless THERMOGRAPH_GEOIP is truthy AND the database exists AND maxminddb imports; every degraded case — private/reserved/ CGNAT/IPv6-ULA addresses, unparseable input, no record, a country-centroid record with no city, a record wider than the accuracy limit, a corrupt file — returns None, which the route answers as 204 and the client treats exactly as today. The IP is read in memory and dropped. GET /api/v2/geoip runs no RunAudit, records no metric dimension, and is excluded from the access log (its own "geoip" traffic category) so no stored, joinable (IP, location) pair is ever written. The response is no-store/Vary:* and takes no parameters. Client-side rather than server-rendered on purpose: the homepage's HTML and weak ETag must stay byte-identical for every visitor, or any cache added in front of it can serve one visitor's city to another. The suggestion is fetched only after a declined/unavailable prompt, and only when nothing is remembered. A guess is never persisted — no localStorage write, no URL hash — and the hero and results headings say "roughly near"/"near … approximate" rather than letting the reverse-geocoded cell name a neighbourhood the lookup never knew. The /privacy page's "never looks up your location from your IP address" paragraph now follows the same flag out of the same env file, so the published statement and the behaviour flip together. Database lifecycle is a host-side systemd timer (infra/deploy/geoip-refresh.*) that verifies a download opens and answers before swapping it in atomically, bind-mounted read-only; geoip.py re-opens on mtime change, so a refresh needs no restart. GEOIP-APPROX-LOCATION.md carries the database comparison, the SSR-vs-client argument, the privacy analysis, and the open decisions.
2026-07-23 23:22:37 +00:00
"""GET /api/v2/geoip — the approximate-location fallback endpoint.
The two properties worth locking down are behavioural, not cosmetic: it is
inert when the flag is off (so shipping it disabled changes nothing), and it
never writes the client IP anywhere.
"""
import glob
import json
import os
import pytest
from fastapi.testclient import TestClient
from core import audit
from data import geoip
from web import app as appmod
URL = "/thermograph/api/v2/geoip"
_SUGGESTION = {
"approximate": True, "source": "ip", "label": "Rotterdam, South Holland",
"city": "Rotterdam", "region": "South Holland", "country": "Netherlands",
"country_code": "NL", "lat": 51.92028, "lon": 4.48339,
"accuracy_radius_km": 20,
"attribution": {"text": "IP Geolocation by DB-IP", "url": "https://db-ip.com"},
}
@pytest.fixture
def client():
return TestClient(appmod.app)
def _access_lines():
lines = []
for path in glob.glob(os.path.join(audit.ACCESS_DIR, "access-*.jsonl")):
with open(path, encoding="utf-8") as fh:
lines += [json.loads(ln) for ln in fh if ln.strip()]
return lines
def test_disabled_answers_204_with_no_body(client, monkeypatch):
monkeypatch.delenv(geoip.FLAG_ENV, raising=False)
r = client.get(URL, headers={"X-Forwarded-For": "8.8.8.8"})
assert r.status_code == 204
assert r.content == b""
def test_enabled_hit_returns_the_suggestion(client, monkeypatch):
monkeypatch.setattr(geoip, "lookup", lambda ip: dict(_SUGGESTION))
r = client.get(URL, headers={"X-Forwarded-For": "8.8.8.8"})
assert r.status_code == 200
body = r.json()
assert body["approximate"] is True and body["label"] == "Rotterdam, South Holland"
assert body["attribution"]["url"] == "https://db-ip.com"
def test_response_is_never_cacheable_by_a_shared_cache(client, monkeypatch):
"""This response is per-visitor by construction. A CDN or proxy that reused
it would show one visitor another's city — the exact hazard that keeps this
lookup out of the server-rendered homepage."""
monkeypatch.setattr(geoip, "lookup", lambda ip: dict(_SUGGESTION))
r = client.get(URL, headers={"X-Forwarded-For": "8.8.8.8"})
assert "no-store" in r.headers["cache-control"]
assert r.headers["vary"] == "*"
def test_miss_answers_204(client, monkeypatch):
monkeypatch.setattr(geoip, "lookup", lambda ip: None)
r = client.get(URL, headers={"X-Forwarded-For": "8.8.8.8"})
assert r.status_code == 204
assert "no-store" in r.headers["cache-control"]
def test_the_left_most_forwarded_hop_is_what_gets_looked_up(client, monkeypatch):
seen = []
monkeypatch.setattr(geoip, "lookup", lambda ip: seen.append(ip) or None)
client.get(URL, headers={"X-Forwarded-For": "8.8.8.8, 10.0.0.1, 10.0.0.2"})
assert seen == ["8.8.8.8"]
def test_missing_forwarded_header_still_answers_cleanly(client, monkeypatch):
"""No XFF (direct hit, LAN dev) falls through to the peer address, which is
the loopback TestClient address private, so no suggestion, no error."""
monkeypatch.setenv(geoip.FLAG_ENV, "1")
r = client.get(URL)
assert r.status_code == 204
def test_the_client_ip_is_not_written_to_the_access_log(client, monkeypatch):
"""The whole point: reading an IP to derive a location must not leave a
stored, joinable (IP, location) pair behind."""
monkeypatch.setattr(geoip, "lookup", lambda ip: dict(_SUGGESTION))
before = len(_access_lines())
client.get(URL, headers={"X-Forwarded-For": "8.8.8.8"})
after = _access_lines()
assert len(after) == before
assert not any(ln.get("ip") == "8.8.8.8" for ln in after)
def test_a_normal_route_still_is_logged(client):
"""Guards the exclusion above from silently widening to everything."""
before = len(_access_lines())
client.get("/thermograph/api/version")
assert len(_access_lines()) > before
def test_the_route_takes_no_parameters(client, monkeypatch):
"""You cannot ask this endpoint about someone else's address: there is no
input but the connection itself, and a supplied one is ignored."""
seen = []
monkeypatch.setattr(geoip, "lookup", lambda ip: seen.append(ip) or None)
client.get(f"{URL}?ip=1.2.3.4", headers={"X-Forwarded-For": "8.8.8.8"})
assert seen == ["8.8.8.8"]