thermograph/frontend/tests/unit/test_geoip_ui.py

59 lines
2.4 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
"""Approximate-location fallback, frontend side.
Two things are worth locking down here, and both are about honesty rather than
mechanics: the server-rendered HTML must not vary by visitor (so no shared
cache can leak one person's city to another), and the privacy page's factual
claim about IP lookups must track the flag that decides whether they happen.
"""
import content
B = "/thermograph"
def test_home_ships_an_empty_hidden_container(client):
"""The suggestion is fetched client-side. If it were ever server-rendered
into the homepage, that page's HTML — and its ETag — would vary per
visitor, which is exactly the cache-poisoning shape this design avoids."""
r = client.get(f"{B}/")
assert r.status_code == 200
assert 'id="geo-approx"' in r.text
assert "hidden" in r.text.split('id="geo-approx"')[1].split(">")[0]
def test_home_html_is_identical_regardless_of_client_address(client):
"""Same bytes, same ETag, whatever the request claims about where it came
from the property that lets the homepage stay cacheable."""
a = client.get(f"{B}/", headers={"X-Forwarded-For": "8.8.8.8"})
b = client.get(f"{B}/", headers={"X-Forwarded-For": "1.1.1.1"})
assert a.text == b.text
assert a.headers["etag"] == b.headers["etag"]
def test_privacy_says_no_ip_lookup_while_the_flag_is_off(client, monkeypatch):
monkeypatch.delenv("THERMOGRAPH_GEOIP", raising=False)
r = client.get(f"{B}/privacy")
assert r.status_code == 200
assert "never looks up your location from your IP address" in r.text
def test_privacy_describes_the_fallback_when_the_flag_is_on(client, monkeypatch):
"""Turning the feature on must not leave a published claim behind that is
no longer true."""
monkeypatch.setenv("THERMOGRAPH_GEOIP", "1")
r = client.get(f"{B}/privacy")
assert r.status_code == 200
assert "never looks up your location from your IP address" not in r.text
assert "rough guess" in r.text
assert "not stored" in r.text
def test_geoip_enabled_reads_the_shared_flag(monkeypatch):
monkeypatch.delenv("THERMOGRAPH_GEOIP", raising=False)
assert content.geoip_enabled() is False
for on in ("1", "true", "YES", "on"):
monkeypatch.setenv("THERMOGRAPH_GEOIP", on)
assert content.geoip_enabled() is True, on
for off in ("0", "", "no", "maybe"):
monkeypatch.setenv("THERMOGRAPH_GEOIP", off)
assert content.geoip_enabled() is False, off