"""Route-level tests for the public product-event beacon (POST /api/v2/event). The endpoint is unauthenticated and reachable by anything on the internet — on a site where ~60% of inbound traffic is crawlers and scanners — so these tests are mostly about what it REFUSES to do: grow storage, echo anything back, or accept a payload big enough to be worth abusing. """ import json import pytest from fastapi.testclient import TestClient from core import events from web import app as appmod EVENT_URL = "/thermograph/api/v2/event" # Browsers set this on fetch/sendBeacon; a bare scanner POST does not. FIRST_PARTY = {"sec-fetch-site": "same-origin"} @pytest.fixture(autouse=True) def _fresh_rate_limiter(): """TestClient always presents the same client IP, so without this one test's beacon traffic silently rate-limits the next one's.""" from core import metrics metrics.reset_rate_limiter() yield metrics.reset_rate_limiter() @pytest.fixture def client(): return TestClient(appmod.app) @pytest.fixture def recorded(monkeypatch): """Capture what reaches the recorder, with the flag forced on.""" seen = [] monkeypatch.setattr(events, "ENABLED", True) monkeypatch.setattr(events, "_upsert", lambda *a: None) monkeypatch.setattr(events.audit, "log_activity", lambda tag, rec: seen.append(rec)) return seen def test_beacon_always_answers_204_and_returns_no_body(client, recorded): """Counted, rejected, or rate-limited must be indistinguishable to a prober.""" for body in ({"event": "view.open", "props": {"view": "home"}}, {"event": "not-a-real-event"}, {"nonsense": True}, [1, 2, 3], "plain string"): r = client.post(EVENT_URL, content=json.dumps(body), headers=FIRST_PARTY) assert r.status_code == 204 assert r.content == b"" def test_valid_event_is_recorded_with_allowlisted_dimensions(client, recorded): client.post(EVENT_URL, json={"event": "view.control", "props": {"view": "compare", "prop": "unit", "value": "C"}}, headers=FIRST_PARTY) assert recorded == [{"event": "view.control", "view": "compare", "prop": "unit", "value": "C", "referrer": ""}] def test_batched_payload_is_accepted_and_capped(client, recorded): items = [{"event": "share", "props": {"prop": "link"}}] * (events.MAX_BATCH + 5) client.post(EVENT_URL, json={"events": items}, headers=FIRST_PARTY) assert len(recorded) == events.MAX_BATCH def test_a_full_batch_of_real_events_fits_under_the_byte_cap(client, recorded): """Regression: if MAX_BODY_BYTES ever drops below a full MAX_BATCH payload, the byte cap silently eats legitimate batches instead of abusive ones.""" items = [{"event": "view.control", "props": {"view": "compare", "prop": "chart_metric", "value": "precip"}} ] * events.MAX_BATCH assert len(json.dumps({"events": items})) < events.MAX_BODY_BYTES client.post(EVENT_URL, json={"events": items}, headers=FIRST_PARTY) assert len(recorded) == events.MAX_BATCH def test_oversized_body_is_dropped_without_being_parsed(client, recorded): """The endpoint must not become storage: a body past the cap is never read into a record at all.""" fat = {"event": "view.open", "props": {"view": "home"}, "junk": "x" * (events.MAX_BODY_BYTES * 4)} r = client.post(EVENT_URL, content=json.dumps(fat), headers=FIRST_PARTY) assert r.status_code == 204 assert recorded == [] def test_free_text_and_coordinates_never_survive(client, recorded): """Anything a client puts in a dimension slot is allowlisted or bucketed — a leaked query string or lat/lon cannot reach a sink.""" client.post(EVENT_URL, json={"event": "place.pick", "props": {"view": "home", "prop": "47.6062,-122.3321"}}, headers=FIRST_PARTY) client.post(EVENT_URL, json={"event": "view.control", "props": {"view": "home", "prop": "chart_metric", "value": "user@example.com"}}, headers=FIRST_PARTY) stored = {v for rec in recorded for v in rec.values()} assert not any("47.6" in s or "@" in s for s in stored) def test_cross_site_post_is_dropped(client, recorded): r = client.post(EVENT_URL, json={"event": "view.open", "props": {"view": "home"}}, headers={"sec-fetch-site": "cross-site"}) assert r.status_code == 204 assert recorded == [] def test_flag_off_records_nothing(client, monkeypatch): """The whole feature ships inert: with THERMOGRAPH_EVENTS unset the beacon still answers, and nothing reaches the durable sink.""" seen = [] monkeypatch.setattr(events, "ENABLED", False) monkeypatch.setattr(events, "_upsert", lambda *a: seen.append(a)) monkeypatch.setattr(events.audit, "log_activity", lambda tag, rec: seen.append(rec)) r = client.post(EVENT_URL, json={"event": "view.open", "props": {"view": "home"}}, headers=FIRST_PARTY) assert r.status_code == 204 assert seen == [] def test_legacy_single_event_shape_still_counts(client, monkeypatch): """The four home.* aggregate counters already in production keep working — they are unflagged and predate the schema.""" from core import metrics seen = [] monkeypatch.setattr(metrics, "_store", type("S", (), { "record_event": lambda self, *a: seen.append(a)})()) client.post(EVENT_URL, json={"event": "home.locate"}, headers=FIRST_PARTY) assert seen and seen[0][0] == "home.locate" def test_beacon_is_not_counted_as_inbound_traffic(client): """It reports traffic; counting it would double every interaction.""" from core import metrics assert metrics.classify_inbound(EVENT_URL, "/thermograph") == "event"