thermograph/backend/tests/web/test_event_beacon.py

142 lines
5.8 KiB
Python
Raw Normal View History

"""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"