2026-07-21 22:48:59 +00:00
|
|
|
"""SPA-shell pages (/calendar, /day, /score, /compare, /legend, /alerts) and
|
|
|
|
|
static-asset serving -- moved here from backend/tests/web/test_api.py
|
2026-07-23 00:40:18 +00:00
|
|
|
(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
|
2026-07-21 22:48:59 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
UI product-event instrumentation (design + flagged-off prototype)
Extends the existing /api/v2/event beacon into a small, typed, durable event
schema so the interactive views can answer product questions the request log
cannot: whether a visitor ever gets a location, whether search finds anything,
which controls and views earn their maintenance, and which dead ends people
actually hit.
Seven events with three enum dimension slots, stored as hourly aggregates in a
TimescaleDB hypertable plus one JSONL line per event for the 30-day Loki view.
No row per interaction and no column an identifier could go in: no cookie, no
session id, no user id, no IP, no coordinates, no free text, no URLs. The IP is
a per-minute rate-limit key and nothing else.
Abuse resistance for a public endpoint on a 60%-crawler site: closed allowlist
(unknown names collapse to one bucket), 4 KB streaming body cap, per-IP ceiling
raised to 120/min (30 was sized for four coarse events and would have silently
truncated a batched stream), soft Sec-Fetch-Site first-party check, uniform 204.
Ships inert -- THERMOGRAPH_EVENTS gates both the recorder and the flag stamp on
<html> that makes the client send anything, and is unset everywhere. See
UI-EVENTS.md for the schema, the rejected transport/storage alternatives, and
the privacy decisions that must be settled before the flag is turned on.
2026-07-23 22:49:55 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- 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
|