Admin step A: privacy-respecting first-party event logging
- events table (kind, article_id, visitor_hash, day) with a UNIQUE key that dedups to one row per visitor-day — caps volume and makes counts mean distinct visitor-days. NO ip/ua/referrer/url. Groupings derived from article_id at query time, never stored. - POST /api/events (public): whitelisted kinds (visit/open/share_ub/copy_source/ native_share/source_click); visitor token hashed server-side (never raw). - Frontend analytics.js: random localStorage visitor token; track() via sendBeacon; visit once/day; open on article click; share_ub/copy_source/native_share from the share menu; /a landing pages fire source_click. 127 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_db(tmp_path, monkeypatch):
|
||||
db = tmp_path / "t.sqlite3"
|
||||
monkeypatch.setenv("GOODNEWS_DB", str(db))
|
||||
monkeypatch.setenv("GOODNEWS_SESSION_SECRET", "test-secret")
|
||||
import importlib
|
||||
import goodnews.api as api
|
||||
importlib.reload(api)
|
||||
from goodnews.db import connect, init_db
|
||||
connect(str(db)).close() # creates schema lazily? ensure init
|
||||
c = connect(str(db)); init_db(c); c.close()
|
||||
return api.create_app(), db
|
||||
|
||||
|
||||
def _count(db, **where):
|
||||
from goodnews.db import connect
|
||||
c = connect(str(db))
|
||||
clause = " AND ".join(f"{k}=?" for k in where)
|
||||
sql = "SELECT COUNT(*) FROM events" + (f" WHERE {clause}" if where else "")
|
||||
n = c.execute(sql, tuple(where.values())).fetchone()[0]
|
||||
c.close()
|
||||
return n
|
||||
|
||||
|
||||
def test_event_recorded_and_deduped(app_db):
|
||||
app, db = app_db
|
||||
tc = TestClient(app)
|
||||
for _ in range(3): # same (kind, article, visitor, day) → one row
|
||||
assert tc.post("/api/events", json={"kind": "open", "article_id": 5, "visitor": "tok"}).json() == {"ok": True}
|
||||
assert _count(db, kind="open", article_id=5) == 1
|
||||
# a different visitor is a distinct row
|
||||
tc.post("/api/events", json={"kind": "open", "article_id": 5, "visitor": "other"})
|
||||
assert _count(db, kind="open", article_id=5) == 2
|
||||
|
||||
|
||||
def test_visitor_token_is_hashed_not_stored_raw(app_db):
|
||||
app, db = app_db
|
||||
TestClient(app).post("/api/events", json={"kind": "visit", "visitor": "secret-token"})
|
||||
from goodnews.db import connect
|
||||
c = connect(str(db))
|
||||
vh = c.execute("SELECT visitor_hash FROM events").fetchone()[0]
|
||||
c.close()
|
||||
assert vh and vh != "secret-token" and len(vh) == 64 # sha256 hex
|
||||
|
||||
|
||||
def test_unknown_kind_is_ignored(app_db):
|
||||
app, db = app_db
|
||||
assert TestClient(app).post("/api/events", json={"kind": "evil", "visitor": "x"}).json() == {"ok": True}
|
||||
assert _count(db) == 0
|
||||
Reference in New Issue
Block a user