115 lines
4.2 KiB
Python
115 lines
4.2 KiB
Python
|
|
"""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"]
|