thermograph/frontend/tests/unit/test_pages.py

116 lines
5 KiB
Python
Raw Normal View History

"""SPA-shell pages (/calendar, /day, /score, /compare, /legend, /alerts) and
static-asset serving -- moved here from backend/tests/web/test_api.py
(repo-split Stage 7a: frontend now owns these, not backend). These routes are static
shells/assets that don't call the backend, so the hermetic `client` fixture serves them."""
B = "/thermograph" # matches THERMOGRAPH_BASE set in tests/conftest.py
def test_calendar_serves_with_origin_filled_in(client):
r = client.get(f"{B}/calendar")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
assert "__ORIGIN__" not in r.text
assert client.head(f"{B}/calendar").status_code == 200
def test_other_spa_shells_serve(client):
for path in ("/day", "/score", "/compare", "/legend", "/alerts"):
r = client.get(f"{B}{path}")
assert r.status_code == 200, path
assert "__ORIGIN__" not in r.text, path
def test_static_asset_serves(client):
r = client.get(f"{B}/app.js")
assert r.status_code == 200
assert "javascript" in r.headers["content-type"]
def test_old_static_index_is_gone(client):
"""index.html must not linger behind the static mount as indexable
duplicate content now that / is server-rendered (moved here from
backend/tests/web/test_homepage.py, repo-split Stage 7a -- frontend's own
StaticFiles mount is what actually answers this now)."""
assert client.get(f"{B}/index.html").status_code == 404
# --- product-event instrumentation flag ---------------------------------------
# track.js sends nothing unless it finds data-tg-events on <html>. The whole
# rollout story rests on that stamp being absent by default, so assert both
# halves: off unless THERMOGRAPH_EVENTS is set, and applied to BOTH HTML paths
# (the Jinja-rendered content pages and the static SPA shells).
def test_event_flag_is_off_by_default_on_every_html_path(client):
for path in ("/", "/calendar", "/day", "/score", "/compare", "/alerts"):
r = client.get(f"{B}{path}")
assert r.status_code == 200, path
assert "data-tg-events" not in r.text, path
def test_event_flag_stamps_both_html_paths_when_enabled(monkeypatch):
"""Rebuilt in-process rather than reusing `client`: the flag is read at
import and the SPA shells memoize their prepped template."""
import importlib
import content as content_mod
from fastapi.testclient import TestClient
monkeypatch.setenv("THERMOGRAPH_EVENTS", "1")
content_mod = importlib.reload(content_mod)
assert content_mod.EVENTS_ENABLED
import app as app_mod
app_mod = importlib.reload(app_mod)
flagged = TestClient(app_mod.app)
for path in ("/calendar", "/compare"):
assert 'data-tg-events="1"' in flagged.get(f"{B}{path}").text, path
# Leave the modules as the rest of the suite expects to find them.
monkeypatch.delenv("THERMOGRAPH_EVENTS")
importlib.reload(content_mod)
importlib.reload(app_mod)
UI events v2: consented session tier, failed-search capture, privacy rewrite Three operator decisions change v1's premises, so the design is now two tiers with separate flags: Tier A (THERMOGRAPH_EVENTS) is v1 unchanged -- anonymous hourly aggregates, no identifier, nothing stored on the device, no consent needed, runs for everyone. It stays the primary signal precisely so a poor consent rate cannot take the numbers down with it, and so Tier B's rates can be calibrated against it to measure the consent bias rather than ignoring it. Tier B (THERMOGRAPH_EVENT_SESSIONS) adds an ephemeral per-tab id -- 16 random bytes in sessionStorage, rotating on a 30-minute idle and a 2-hour absolute cap, capped at 200 events, never linked to another session, device, or to the account (api_event does not read the auth cookie and there is no user column). Rows land in a 30-day hypertable with minute-granularity timestamps and an in-session sequence number instead of precise clock times. Storing an identifier engages ePrivacy Art. 5(3) and analytics is not strictly necessary, so it is gated on opt-in consent: an equal-weight banner that mints nothing before "Allow", one-click withdrawal in every footer that drops the live id immediately, and GPC/DNT treated as a refusal already given. THERMOGRAPH_SEARCH_MISS captures zero-result search text server-side in api_suggest only -- normalised, rejected outright on any personal-data smell, stored as a per-day count, and pruned below a three-person floor after a week. The privacy page is rewritten rather than deferred: "no per-visitor identifier" becomes false the moment Tier B ships. Tests assert the load-bearing promises so the copy and the code cannot drift apart silently. Raw-IP truncation is NOT implemented here (the logging pipeline owns it) but UI-EVENTS.md states what the app side must do, and records that Caddy's default JSON log already stores full request URIs -- so every search query is in Loki with the client IP today, which the search-miss mitigations depend on fixing. UI-EVENTS.md carries the v1-to-v2 diff, the consent reasoning including why legitimate interest is not available, and an explicit argument that the session identifier is the wrong trade at this traffic volume.
2026-07-23 23:11:06 +00:00
def test_consent_control_is_present_on_every_page(client):
"""Withdrawal must be reachable from anywhere, so the footer carries the
control on every server-rendered page. consent.js leaves it empty when
instrumentation is disabled, so it costs nothing when off."""
for path in ("/", "/privacy", "/climate"):
r = client.get(f"{B}{path}")
assert r.status_code == 200, path
assert "data-consent-control" in r.text, path
def test_consent_script_loads_before_the_tracker(client):
"""consent.js owns the banner and the opt-in gate; track.js imports it. It is
listed first so the gate is installed even on a page whose tracker chain
never loads."""
for path, after in (("/", "digest.js"), ("/calendar", "track.js")):
body = client.get(f"{B}{path}").text
tag = lambda name: body.index(f'src="{B}/{name}"' if path == "/" else f'src="{name}"')
assert tag("consent.js") < tag(after), path
def test_privacy_page_describes_what_is_actually_collected(client):
"""The page is a public commitment; if this test has to change, the copy and
the code have drifted apart. Asserts the load-bearing promises only."""
body = client.get(f"{B}/privacy").text
for promise in (
"session storage", # where the id lives
"30 minutes", # idle rotation
"2 hours", # absolute cap
"never linked to your account",
"30 days", # session-row retention
"400 days", # anonymous aggregate retention
"90 days", # failed-search retention
"Global Privacy Control",
"only if you say yes", # opt-in, not opt-out
):
assert promise in body, promise
# The v1 claim that this change makes untrue must be gone.
assert "no per-visitor identifier" not in body
def test_footer_no_longer_claims_no_tracking(client):
"""A consented session id is an identifier; the blanket claim would be a lie."""
body = client.get(f"{B}/").text
assert "No tracking." not in body
assert "No third-party trackers." in body