"""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"]