142 lines
5.8 KiB
Python
142 lines
5.8 KiB
Python
|
|
"""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"
|