read.markets/tests/test_polar_webhook.py
Giorgio Gilestro 6c13f855e9 polar: build /api/polar/webhook handler
Standalone router for inbound Polar (merchant-of-record) deliveries.
No bearer-token dep — authenticity comes from the Standard Webhooks
HMAC instead. Wired up so it's safe to deploy dark: empty
POLAR_WEBHOOK_SECRET makes the endpoint return 503 (loud) rather than
accept unsigned events.

Behaviour
- Standard Webhooks signature verification: HMAC-SHA256 over
  `{webhook-id}.{webhook-timestamp}.{body}`, base64 secret prefixed
  whsec_, ±5min replay window, constant-time compare against any of
  the space-separated v1 tokens.
- Idempotency via UNIQUE on polar_events.event_id — a replayed
  webhook-id short-circuits to 200 "duplicate" without re-running.
- Event dispatch table covers the 10 events we subscribed to:
  subscription.{created,active,updated,uncanceled} -> tier=paid +
  persist polar_customer_id / polar_subscription_id.
  subscription.revoked -> tier=free (customer id kept so a resub
  matches the same User row).
  canceled / past_due / order.* / refund.created -> audit only.
- Unknown event types are acked 200 + recorded; we don't want to 4xx
  on something Polar adds in the future and trigger their retry loop.

Schema (migration 0018)
- users.polar_customer_id, users.polar_subscription_id (both nullable
  String(64)); UNIQUE on polar_customer_id so two users can't claim
  the same Polar identity.
- polar_events table: event_id (unique), event_type, received_at,
  processed_at, error, raw payload (truncated to 16 KiB).

Tests
- 7 in tests/test_polar_webhook.py: bad signature -> 401, stale
  timestamp -> 401, missing headers -> 400, subscription.active flips
  tier to paid + stores IDs, subscription.revoked drops to free while
  keeping customer link, replayed webhook-id is no-op, unknown event
  is acked.
- Full suite: 212 passed, 5 skipped.

Operator next steps before saving the webhook in Polar
1. Pull this branch to prod and apply migration 0018.
2. Save the webhook in Polar pointing at
   https://read.markets/api/polar/webhook — Polar will accept the
   save even though our endpoint still 503s (no secret yet).
3. Copy the secret Polar reveals into the prod .env as
   POLAR_WEBHOOK_SECRET=whsec_... and restart the app.
4. Trigger a test event from Polar's dashboard to confirm 200 OK.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 17:42:41 +02:00

215 lines
7 KiB
Python

"""Polar (Standard Webhooks) endpoint: signature verification, idempotency,
and the subscription.active -> tier=paid handler.
Integration-style: real router + in-memory aiosqlite. Same scaffold as
test_news_window.py / test_chat_and_log_gates.py."""
from __future__ import annotations
import asyncio
import base64
import hashlib
import hmac
import json
import time
import pytest
_SECRET_RAW = b"this-is-a-deterministic-test-secret-32b!"
_SECRET = "whsec_" + base64.b64encode(_SECRET_RAW).decode("ascii")
def _sign(msg_id: str, ts: str, body: bytes) -> str:
"""Produce the `v1,<b64>` token Polar would send."""
signed = f"{msg_id}.{ts}.{body.decode('utf-8')}"
mac = hmac.new(_SECRET_RAW, signed.encode("utf-8"), hashlib.sha256).digest()
return "v1," + base64.b64encode(mac).decode("ascii")
def _build_app(tmp_path):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import db as db_mod
from app.config import get_settings
from app.db import Base
from app.models import User
from app.routers import polar_webhook as polar_router
# Inject the secret into the cached Settings. We override the
# field rather than monkeypatching env because the secret is read
# via get_settings() at request time.
s = get_settings()
s.POLAR_WEBHOOK_SECRET = _SECRET # type: ignore[misc]
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/polar.db")
factory = async_sessionmaker(engine, expire_on_commit=False)
db_mod._engine = engine
db_mod._session_factory = factory
async def _seed():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with factory() as session:
session.add(User(id=1, email="paying@x", tier="free"))
await session.commit()
asyncio.run(_seed())
app = FastAPI()
app.include_router(polar_router.router)
return TestClient(app), factory
def _post(client, *, body: dict, msg_id="msg_001", ts: str | None = None,
sig: str | None = None):
raw = json.dumps(body).encode("utf-8")
ts = ts or str(int(time.time()))
sig = sig or _sign(msg_id, ts, raw)
return client.post(
"/api/polar/webhook",
content=raw,
headers={
"webhook-id": msg_id,
"webhook-timestamp": ts,
"webhook-signature": sig,
"content-type": "application/json",
},
)
# --- signature gate --------------------------------------------------------
def test_rejects_bad_signature(tmp_path):
client, _ = _build_app(tmp_path)
raw = json.dumps({"type": "subscription.active", "data": {}}).encode("utf-8")
ts = str(int(time.time()))
r = client.post(
"/api/polar/webhook",
content=raw,
headers={
"webhook-id": "msg_bad",
"webhook-timestamp": ts,
"webhook-signature": "v1,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"content-type": "application/json",
},
)
assert r.status_code == 401, r.text
def test_rejects_stale_timestamp(tmp_path):
client, _ = _build_app(tmp_path)
body = {"type": "subscription.active", "data": {}}
# 10 minutes in the past — beyond the 5-minute tolerance window.
stale = str(int(time.time()) - 600)
r = _post(client, body=body, ts=stale, msg_id="msg_stale")
assert r.status_code == 401, r.text
def test_rejects_missing_headers(tmp_path):
client, _ = _build_app(tmp_path)
r = client.post("/api/polar/webhook", content=b"{}",
headers={"content-type": "application/json"})
assert r.status_code == 400, r.text
# --- happy paths -----------------------------------------------------------
def test_subscription_active_flips_tier_to_paid(tmp_path):
client, factory = _build_app(tmp_path)
body = {
"type": "subscription.active",
"data": {
"id": "sub_abc",
"customer": {"id": "cust_xyz", "email": "paying@x"},
},
}
r = _post(client, body=body, msg_id="msg_active")
assert r.status_code == 200, r.text
assert r.json()["status"] == "ok"
async def _check():
from sqlalchemy import select
from app.models import User
async with factory() as session:
u = (await session.execute(
select(User).where(User.id == 1)
)).scalar_one()
return u.tier, u.polar_customer_id, u.polar_subscription_id
tier, cid, sid = asyncio.run(_check())
assert tier == "paid"
assert cid == "cust_xyz"
assert sid == "sub_abc"
def test_subscription_revoked_drops_to_free(tmp_path):
client, factory = _build_app(tmp_path)
# First, activate.
_post(client, body={
"type": "subscription.active",
"data": {"id": "sub_abc",
"customer": {"id": "cust_xyz", "email": "paying@x"}},
}, msg_id="msg_act")
# Then, revoke.
r = _post(client, body={
"type": "subscription.revoked",
"data": {"id": "sub_abc",
"customer": {"id": "cust_xyz", "email": "paying@x"}},
}, msg_id="msg_rev")
assert r.status_code == 200, r.text
async def _check():
from sqlalchemy import select
from app.models import User
async with factory() as session:
u = (await session.execute(
select(User).where(User.id == 1)
)).scalar_one()
return u.tier, u.polar_customer_id, u.polar_subscription_id
tier, cid, sid = asyncio.run(_check())
assert tier == "free"
# Customer linkage preserved so a future resub matches the same row.
assert cid == "cust_xyz"
assert sid is None
# --- idempotency -----------------------------------------------------------
def test_replayed_event_id_is_a_noop(tmp_path):
client, factory = _build_app(tmp_path)
body = {
"type": "subscription.active",
"data": {"id": "sub_abc",
"customer": {"id": "cust_xyz", "email": "paying@x"}},
}
# Two POSTs with the same msg_id and body — second should be deduped.
r1 = _post(client, body=body, msg_id="msg_dup")
r2 = _post(client, body=body, msg_id="msg_dup")
assert r1.status_code == 200 and r1.json()["status"] == "ok"
assert r2.status_code == 200 and r2.json()["status"] == "duplicate"
async def _count():
from sqlalchemy import select, func
from app.models import PolarEvent
async with factory() as session:
n = (await session.execute(
select(func.count(PolarEvent.id))
.where(PolarEvent.event_id == "msg_dup")
)).scalar_one()
return n
assert asyncio.run(_count()) == 1
def test_unknown_event_type_is_acked(tmp_path):
client, _ = _build_app(tmp_path)
body = {"type": "benefit_grant.cycled", "data": {}}
r = _post(client, body=body, msg_id="msg_unknown")
assert r.status_code == 200
assert r.json()["status"] == "ignored"