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>
This commit is contained in:
parent
2297f9b2ed
commit
6c13f855e9
6 changed files with 624 additions and 0 deletions
215
tests/test_polar_webhook.py
Normal file
215
tests/test_polar_webhook.py
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
"""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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue