feat(server): Gumroad webhook receiver + Postmark email (PR 2)

Wires the second source-adapter (Gumroad) plus the email delivery
that lets the server fulfill a sale end-to-end without operator
intervention.

Auth model: Gumroad doesn't HMAC the body, so we use their
recommended URL-secret pattern (?secret=...). Wrong/missing secret
returns 404 — no signal to a prober that the endpoint exists.

Webhook flow (server/app/routes/webhooks.py):
  1. audit-log the raw payload (gumroad_events row) BEFORE anything
     else, so a later failure leaves us replayable
  2. parse via GumroadAdapter (server/app/adapters/gumroad.py)
  3. mint_from_sale — UNIQUE(source, source_order_id) dedups
     duplicate webhook retries
  4. send the license email
  5. mark gumroad_events.processed = true

Always returns 200 once auth passes. Non-2xx would trigger Gumroad's
3-day retry storm; we'd rather record the failure on the audit row
and replay manually after fixing whatever surfaced.

Product → tier mapping is per-source YAML at
server/config/products.yaml (lru_cached). Adding a SKU = edit yaml,
restart api. Unmapped product_id is an error on the audit row, not
a crash.

EmailService (server/app/email.py): provider-agnostic interface with
Postmark as the first implementation. When POSTMARK_TOKEN is unset
the factory returns LoggingEmailService instead, so the webhook
exercises end-to-end before Postmark is provisioned.

48 unit tests (was 21) including:
- Gumroad secret verify with constant-time compare
- Sale parsing: amount-in-cents, name fallback from email,
  test=true tagging, missing-required fields, offer codes
- Product mapping lookups
- Email rendering text + HTML, HTML-escapes user input
- Postmark client via httpx.MockTransport (success and 4xx)
- Webhook end-to-end: secret check, audit log, idempotency on
  retry, unmapped product, email failure keeps license

Smoke test (server/scripts/smoke.sh) extended to POST a synthetic
Ping payload, verify the row + audit log, prove wrong-secret is
rejected, prove duplicate sale_id stays one row.

SQLite-test compatibility:
- BigInteger primary key uses with_variant(Integer, "sqlite") since
  SQLite only autoincrements INTEGER PRIMARY KEY.
- python-multipart pulled in for FastAPI Form parsing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 01:33:43 +00:00
parent b5cd74d474
commit 2bbaba954b
15 changed files with 1066 additions and 2 deletions

View File

@@ -23,6 +23,7 @@ RUN pip install -r /app/requirements.txt
COPY src/license /app/datatools_license COPY src/license /app/datatools_license
COPY server/app /app/app COPY server/app /app/app
COPY server/config /app/config
COPY server/alembic /app/alembic COPY server/alembic /app/alembic
COPY server/alembic.ini /app/alembic.ini COPY server/alembic.ini /app/alembic.ini

View File

@@ -0,0 +1,173 @@
"""Gumroad adapter.
Receives "Ping" notifications from Gumroad — form-encoded POSTs sent
when a sale occurs. Gumroad's Ping URL is configured in the seller
dashboard (Settings → Advanced → Ping URL).
Authentication
--------------
Gumroad does not HMAC-sign the body. Their recommended pattern is
to put a secret in the URL itself::
https://licenses.datatools.unalogix.com/webhooks/gumroad?secret=...
The webhook receiver pulls the secret from the query string and
:meth:`GumroadAdapter.verify_webhook` constant-time-compares it
against the configured value. If they don't match, the request is
dropped with 404 (so a probing attacker can't tell whether the
endpoint exists, much less that it's the wrong secret).
The "test" field
----------------
Gumroad sends ``test=true`` on test pings fired from the dashboard.
We treat test pings as real sales (they create licenses just like
production sales), but tag them with ``notes='gumroad test ping'``
so the operator can filter / delete them later. Refusing test pings
would block the standard "Send Test Ping" verification flow.
Refunds, disputes, cancellations
--------------------------------
Stubbed for now (``parse_refund`` returns None). Gumroad doesn't
include refund signals in the standard sale Ping — refunds arrive
via the separate "Resource subscriptions" mechanism. Wiring that
in is PR 2.1; until then, refunds are handled by the operator
running ``datatools-admin revoke``.
"""
from __future__ import annotations
import hmac
from decimal import Decimal
from typing import Any, Optional
from app.adapters.base import RefundEvent, SaleEvent
from app.products import lookup as product_lookup
class GumroadAdapter:
source_name = "gumroad"
def __init__(self, secret: Optional[str]) -> None:
self._secret = secret
# --- Auth ----------------------------------------------------------------
def verify_webhook(self, *, body: bytes, headers: dict[str, str]) -> bool:
"""Not used — Gumroad authentication is via URL query param,
which only the route handler has direct access to. Call
:meth:`verify_secret` instead."""
return False
def verify_secret(self, presented: Optional[str]) -> bool:
"""Constant-time compare against the configured secret.
Returns False (not an exception) so the route handler can
decide the response code — we return 404 to avoid signaling
endpoint existence to an unauthenticated prober.
"""
if not self._secret or not presented:
return False
return hmac.compare_digest(presented, self._secret)
# --- Parsing -------------------------------------------------------------
def parse_sale(self, payload: dict[str, Any]) -> Optional[SaleEvent]:
"""Parse a Gumroad Ping form-encoded payload into a SaleEvent.
Returns None if the payload isn't a sale (e.g. some future
event type we don't yet handle). Returns None *with no row
side-effect* if the product_id is unmapped — the caller
should treat that as an error and record it in the audit
row, not silently drop.
"""
# Sale pings always include sale_id (the order ID) and email.
sale_id = payload.get("sale_id")
email = payload.get("email")
product_id = (
payload.get("product_id")
or payload.get("product_permalink")
or payload.get("permalink")
)
if not (sale_id and email and product_id):
return None
mapping = product_lookup(self.source_name, str(product_id))
if mapping is None:
# Unmapped — surface to caller as a SaleEvent with no tier.
# We deliberately don't raise here so the caller can
# log it to gumroad_events with error info and still
# return 200 (no Gumroad retry storm).
raise UnmappedProductError(
f"Gumroad product_id {product_id!r} has no entry in "
"config/products.yaml. Add a mapping and replay this "
f"sale (sale_id={sale_id})."
)
name = (payload.get("full_name") or "").strip() or _email_local(email)
price_cents = _to_int(payload.get("price"))
amount_paid = Decimal(price_cents) / Decimal(100) if price_cents is not None else None
currency = (payload.get("currency") or "USD").upper()
promotion = (payload.get("offer_code") or "").strip() or None
notes = None
if _is_truthy(payload.get("test")):
notes = "gumroad test ping"
return SaleEvent(
source=self.source_name,
source_order_id=str(sale_id),
buyer_name=name,
buyer_email=email.strip(),
tier=mapping.tier,
years=mapping.years,
promotion=promotion,
amount_paid=amount_paid,
currency=currency,
notes=notes,
raw_payload=dict(payload),
)
def parse_refund(self, payload: dict[str, Any]) -> Optional[RefundEvent]:
# PR 2.1.
return None
class UnmappedProductError(ValueError):
"""Raised when a sale arrives for a product not in products.yaml.
Caller catches and logs into ``gumroad_events.error`` so the
operator can fix the mapping and replay.
"""
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _email_local(email: str) -> str:
"""Fallback display name when ``full_name`` is missing — the part
of the email before the ``@``, capitalized. Better than 'Unknown'
for support tickets and the buyer's own delivery email."""
local = email.split("@", 1)[0]
return local.replace(".", " ").title()
def _to_int(v: Any) -> Optional[int]:
if v is None or v == "":
return None
try:
return int(v)
except (TypeError, ValueError):
return None
def _is_truthy(v: Any) -> bool:
if isinstance(v, bool):
return v
if v is None:
return False
return str(v).strip().lower() in {"1", "true", "yes", "on"}

214
server/app/email.py Normal file
View File

@@ -0,0 +1,214 @@
"""Transactional email delivery.
Provider: Postmark. Picked for its transactional-deliverability
reputation and a tiny, no-SDK-needed HTTP API.
Configuration
-------------
- ``POSTMARK_TOKEN`` / ``POSTMARK_TOKEN_FILE`` — server API token.
- ``EMAIL_FROM`` — verified sender address (default
``licenses@datatools.unalogix.com``).
- ``EMAIL_REPLY_TO`` — optional Reply-To (default same as From).
When ``POSTMARK_TOKEN`` is unset the service falls back to
:class:`LoggingEmailService`, which prints the email to stdout
instead of sending. Lets the webhook handler exercise the full
flow before the Postmark account is provisioned.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from typing import Optional, Protocol
import httpx
from app.config import get_settings
log = logging.getLogger(__name__)
@dataclass(frozen=True)
class LicenseEmail:
"""Inputs the renderer needs from the caller."""
to_name: str
to_email: str
tier: str
license_key: str
expires_at_iso: str
blob: str
class EmailService(Protocol):
"""Provider-agnostic email surface — keeps Postmark out of the
callers' import graph."""
def send_license(self, msg: LicenseEmail) -> str:
"""Deliver the license-delivery email. Returns a provider
message id (or ``"logged"`` for the dev fallback) so the
caller can record it on the licenses row for audit."""
...
class LoggingEmailService:
"""Stand-in when no real provider is configured. Logs the
rendered message body at INFO so it shows up in ``docker compose
logs api`` — useful during local dev and during the deploy
window before Postmark is wired up."""
def send_license(self, msg: LicenseEmail) -> str:
body = _render_text(msg)
log.info(
"[email-stub] would send to=%s subject=%r\n%s",
msg.to_email,
_subject(msg),
body,
)
return "logged"
class PostmarkEmailService:
"""Postmark transactional API client.
Single endpoint, ~3 fields, no SDK needed. We use a per-call
httpx Client with a tight timeout — webhook handlers run on
the request thread and we never want to block them on a flaky
upstream.
"""
API_URL = "https://api.postmarkapp.com/email"
TIMEOUT_S = 8.0
def __init__(
self,
token: str,
*,
sender: str,
reply_to: Optional[str] = None,
message_stream: str = "outbound",
) -> None:
self._token = token
self._sender = sender
self._reply_to = reply_to or sender
self._stream = message_stream
def send_license(self, msg: LicenseEmail) -> str:
body_text = _render_text(msg)
body_html = _render_html(msg)
payload = {
"From": self._sender,
"To": _rfc_addr(msg.to_name, msg.to_email),
"ReplyTo": self._reply_to,
"Subject": _subject(msg),
"TextBody": body_text,
"HtmlBody": body_html,
"MessageStream": self._stream,
}
headers = {
"Accept": "application/json",
"Content-Type": "application/json",
"X-Postmark-Server-Token": self._token,
}
with httpx.Client(timeout=self.TIMEOUT_S) as c:
r = c.post(self.API_URL, json=payload, headers=headers)
if r.status_code >= 400:
raise EmailDeliveryError(
f"Postmark rejected the request: HTTP {r.status_code} "
f"body={r.text[:300]!r}"
)
return str(r.json().get("MessageID", ""))
class EmailDeliveryError(RuntimeError):
"""Provider returned a non-2xx. Caller should record this on the
audit row so the operator can replay after fixing the provider
config (verified sender domain, paid plan, etc.)."""
# ---------------------------------------------------------------------------
# Factory
# ---------------------------------------------------------------------------
def get_email_service() -> EmailService:
"""Choose the real provider if a token is configured, else the
logger. Reads settings fresh — tests can flip env vars between
sends without restarting."""
settings = get_settings()
token = settings.resolve_postmark_token()
if not token:
return LoggingEmailService()
sender = os.environ.get("EMAIL_FROM", "licenses@datatools.unalogix.com")
reply_to = os.environ.get("EMAIL_REPLY_TO")
return PostmarkEmailService(token, sender=sender, reply_to=reply_to)
# ---------------------------------------------------------------------------
# Rendering
# ---------------------------------------------------------------------------
def _subject(msg: LicenseEmail) -> str:
return f"Your DataTools license ({msg.tier})"
def _render_text(msg: LicenseEmail) -> str:
return (
f"Hi {msg.to_name},\n\n"
f"Thanks for your DataTools purchase. Your license is below.\n\n"
f"License key: {msg.license_key}\n"
f"Tier: {msg.tier}\n"
f"Expires: {msg.expires_at_iso[:10]}\n\n"
f"To activate, paste the full blob (starting with DTLIC2:) into\n"
f"the Activate screen, or run:\n\n"
f" python -m src.license_cli activate \"{msg.blob}\" \\\n"
f" --name \"{msg.to_name}\" --email {msg.to_email}\n\n"
f"Your blob:\n\n"
f"{msg.blob}\n\n"
f"Keep this email — you'll need the blob if you move to a new\n"
f"computer. Questions: reply to this email.\n\n"
f"— DataTools\n"
)
def _render_html(msg: LicenseEmail) -> str:
return (
"<!doctype html><html><body style=\"font-family:system-ui,sans-serif;"
"max-width:560px;margin:auto;padding:24px;color:#222;\">"
f"<p>Hi {_html_escape(msg.to_name)},</p>"
"<p>Thanks for your DataTools purchase. Your license is below.</p>"
"<table cellpadding=\"4\" style=\"border-collapse:collapse;\">"
f"<tr><td><b>License key</b></td><td><code>{_html_escape(msg.license_key)}</code></td></tr>"
f"<tr><td><b>Tier</b></td><td>{_html_escape(msg.tier)}</td></tr>"
f"<tr><td><b>Expires</b></td><td>{_html_escape(msg.expires_at_iso[:10])}</td></tr>"
"</table>"
"<p>To activate, paste the blob below into the <em>Activate</em> "
"screen on first launch.</p>"
"<pre style=\"background:#f4f4f4;padding:12px;border-radius:6px;"
"white-space:pre-wrap;word-break:break-all;font-size:11px;\">"
f"{_html_escape(msg.blob)}</pre>"
"<p style=\"color:#666;font-size:13px;\">Keep this email — you'll "
"need the blob if you move to a new computer. Questions: just reply.</p>"
"<p>— DataTools</p></body></html>"
)
def _rfc_addr(name: str, email: str) -> str:
# Postmark accepts "Name <addr>" or just "addr". Quote names with
# special chars; otherwise keep it readable in the inbox.
if not name or "@" in name:
return email
if any(c in name for c in ',<>"'):
name = name.replace('"', "").replace(",", "")
return f"{name} <{email}>"
def _html_escape(s: str) -> str:
return (
s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from fastapi import FastAPI from fastapi import FastAPI
from app.routes import internal, public from app.routes import internal, public, webhooks
app = FastAPI( app = FastAPI(
title="DataTools License Server", title="DataTools License Server",
@@ -16,3 +16,4 @@ app = FastAPI(
app.include_router(public.router) app.include_router(public.router)
app.include_router(internal.router) app.include_router(internal.router)
app.include_router(webhooks.router)

View File

@@ -16,6 +16,7 @@ from sqlalchemy import (
BigInteger, BigInteger,
DateTime, DateTime,
Index, Index,
Integer,
Numeric, Numeric,
String, String,
UniqueConstraint, UniqueConstraint,
@@ -29,6 +30,11 @@ from sqlalchemy.orm import Mapped, mapped_column
# (SQLite for tests). Same Python interface either way. # (SQLite for tests). Same Python interface either way.
_JSON_TYPE = JSON().with_variant(JSONB(), "postgresql") _JSON_TYPE = JSON().with_variant(JSONB(), "postgresql")
# SQLite only auto-increments INTEGER PRIMARY KEY (not BIGINT).
# Postgres can autoincrement either, so the variant keeps the
# production migration on BigInteger while tests use Integer.
_PK_TYPE = BigInteger().with_variant(Integer(), "sqlite")
from app.db import Base from app.db import Base
@@ -77,7 +83,7 @@ class GumroadEvent(Base):
__tablename__ = "gumroad_events" __tablename__ = "gumroad_events"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(_PK_TYPE, primary_key=True, autoincrement=True)
received_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) received_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
event_type: Mapped[str] = mapped_column(String, nullable=False) event_type: Mapped[str] = mapped_column(String, nullable=False)
order_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) order_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)

71
server/app/products.py Normal file
View File

@@ -0,0 +1,71 @@
"""Storefront product → license tier mapping.
The mapping lives in ``server/config/products.yaml`` (gitignored
for secrets it isn't — it's a routine catalog file) so adding a
new SKU is one yaml edit plus a container restart. The lookup is
``(source, product_id) -> (tier, years)``.
Cached at module import. The runtime cost of reloading on every
webhook would be trivial, but caching keeps the hot path
allocation-free and makes the "edit yaml, restart api" idiom
explicit — operators always know exactly when their changes go
live.
"""
from __future__ import annotations
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Optional
import yaml
@dataclass(frozen=True)
class ProductMapping:
tier: str
years: int
def _config_path() -> Path:
"""Resolve the products config.
Container layout puts the config at ``/app/config/products.yaml``
(the Dockerfile COPYs ``server/config`` to ``/app/config``).
For local pytest runs we walk up from this file to ``server/``.
"""
in_container = Path("/app/config/products.yaml")
if in_container.exists():
return in_container
return Path(__file__).resolve().parent.parent / "config" / "products.yaml"
@lru_cache(maxsize=1)
def _table() -> dict[tuple[str, str], ProductMapping]:
raw = yaml.safe_load(_config_path().read_text(encoding="utf-8")) or {}
table: dict[tuple[str, str], ProductMapping] = {}
for source, entries in raw.items():
for entry in entries or []:
key = (source, str(entry["product_id"]))
table[key] = ProductMapping(
tier=entry["tier"],
years=int(entry.get("years", 1)),
)
return table
def lookup(source: str, product_id: str) -> Optional[ProductMapping]:
"""Return the mapping for *(source, product_id)*, or None if unmapped.
Returning None (rather than raising) lets the webhook layer
decide whether to surface the failure as an audit row vs a
user-visible error — we want unmapped sales to be logged, not
to crash the handler and trigger Gumroad retry storms.
"""
return _table().get((source, product_id))
def reload_for_tests() -> None:
"""Drop the cache. Tests that mutate the yaml call this."""
_table.cache_clear()

View File

@@ -0,0 +1,121 @@
"""Storefront webhook receivers.
PR 2 wires Gumroad. Future storefronts each get their own route
(``/webhooks/lemonsqueezy``, ``/webhooks/stripe``, ...). All share
the same downstream flow: audit-log the raw payload, parse via
adapter, mint, send email, mark processed.
Handler contract
----------------
We **always** return 200 once a request authenticates, even on
downstream failures. Gumroad retries non-2xx for ~3 days, which
would turn a single broken sale into hours of duplicate webhook
storms. Our idempotency keys (``UNIQUE(source, source_order_id)``)
make at-least-once handling safe; the storefront retries on
network errors only.
When something downstream fails (unmapped product, DB error, email
failure), we record the cause in ``gumroad_events.error`` so the
operator can fix and replay.
Unauthenticated requests return 404 — we don't want to signal
endpoint existence or "wrong secret" to a prober.
"""
from __future__ import annotations
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy.orm import Session
from app.adapters.gumroad import GumroadAdapter, UnmappedProductError
from app.config import get_settings
from app.db import get_session
from app.email import EmailDeliveryError, LicenseEmail, get_email_service
from app.mint import mint_from_sale
from app.models import GumroadEvent
router = APIRouter(prefix="/webhooks")
log = logging.getLogger(__name__)
def _gumroad_adapter() -> GumroadAdapter:
settings = get_settings()
return GumroadAdapter(secret=settings.resolve_gumroad_secret())
@router.post("/gumroad", status_code=200)
async def gumroad(
request: Request,
secret: Optional[str] = Query(default=None),
session: Session = Depends(get_session),
) -> dict:
adapter = _gumroad_adapter()
if not adapter.verify_secret(secret):
# 404 — no information leak about endpoint existence.
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
# Gumroad's Ping is form-encoded; FastAPI doesn't auto-parse
# without a Form() dependency, and we want the raw map for the
# audit log regardless of schema.
raw_form = await request.form()
payload = {k: str(v) for k, v in raw_form.items()}
# Audit row FIRST — any later failure leaves us a replayable record.
event = GumroadEvent(
event_type="sale",
order_id=payload.get("sale_id"),
raw_payload=payload,
)
session.add(event)
session.flush()
try:
sale = adapter.parse_sale(payload)
except UnmappedProductError as e:
event.error = str(e)
log.warning("Gumroad sale with unmapped product: %s", e)
return {"status": "logged-no-mint", "reason": "unmapped_product"}
except Exception as e: # pragma: no cover — defensive
event.error = f"parse error: {e!r}"
log.exception("Gumroad parse failure")
return {"status": "logged-no-mint", "reason": "parse_error"}
if sale is None:
event.error = "payload did not parse as a sale"
return {"status": "logged-no-mint", "reason": "not_a_sale"}
try:
row = mint_from_sale(session, sale)
session.flush()
except Exception as e: # pragma: no cover — defensive
event.error = f"mint error: {e!r}"
log.exception("mint_from_sale failed")
return {"status": "logged-no-mint", "reason": "mint_error"}
try:
get_email_service().send_license(
LicenseEmail(
to_name=row.name,
to_email=row.email,
tier=row.tier,
license_key=row.license_key,
expires_at_iso=row.expires_at.isoformat(),
blob=row.blob,
)
)
except EmailDeliveryError as e:
event.error = f"email error: {e}"
log.warning("Email delivery failed (license already minted): %s", e)
# The buyer can still be served from the DB via the renewal
# portal in PR 3 / a manual resend, so we don't fail the
# webhook over an email hiccup.
event.processed = True
return {"status": "minted-email-failed", "license_key": row.license_key}
event.processed = True
return {"status": "ok", "license_key": row.license_key}

View File

@@ -30,8 +30,10 @@ services:
environment: environment:
DATABASE_URL: postgresql+psycopg://dt_test:test_pw@postgres:5432/dt_test DATABASE_URL: postgresql+psycopg://dt_test:test_pw@postgres:5432/dt_test
DATATOOLS_ADMIN_TOKEN: test-admin-token DATATOOLS_ADMIN_TOKEN: test-admin-token
GUMROAD_WEBHOOK_SECRET: test-gumroad-secret
# No DATATOOLS_LICENSE_PRIVKEY — falls back to the in-tree # No DATATOOLS_LICENSE_PRIVKEY — falls back to the in-tree
# dev keypair, matching what the desktop dev build expects. # dev keypair, matching what the desktop dev build expects.
# No POSTMARK_TOKEN — falls back to LoggingEmailService.
ports: ports:
- "127.0.0.1:18090:8000" - "127.0.0.1:18090:8000"
healthcheck: healthcheck:

View File

@@ -0,0 +1,34 @@
# Storefront product → license tier mapping.
#
# Each storefront has its own product/variant IDs. The webhook
# handler looks up (source, product_id) in this file to decide
# what to mint. Unknown product IDs are an error (audit row gets
# error="unmapped product", no license created — the operator
# fixes the mapping and replays).
#
# After editing this file, `docker compose restart api` to reload.
gumroad:
# Fill in real Gumroad product_ids once SKUs exist. Until then the
# examples below are placeholders that the test suite uses.
- product_id: "datatools-lite"
tier: lite
years: 1
- product_id: "datatools-core"
tier: core
years: 1
- product_id: "datatools-pro"
tier: pro
years: 1
# Future storefronts slot in as siblings:
#
# lemonsqueezy:
# - product_id: "12345"
# tier: core
# years: 1
#
# stripe:
# - product_id: "prod_xxx"
# tier: pro
# years: 1

View File

@@ -1,5 +1,6 @@
fastapi>=0.115,<0.120 fastapi>=0.115,<0.120
uvicorn[standard]>=0.32,<0.40 uvicorn[standard]>=0.32,<0.40
python-multipart>=0.0.20,<1
sqlalchemy>=2.0,<3 sqlalchemy>=2.0,<3
psycopg[binary]>=3.2,<4 psycopg[binary]>=3.2,<4
alembic>=1.14,<2 alembic>=1.14,<2
@@ -8,3 +9,4 @@ pydantic-settings>=2.6,<3
email-validator>=2.2,<3 email-validator>=2.2,<3
cryptography>=43,<46 cryptography>=43,<46
httpx>=0.27,<1 httpx>=0.27,<1
pyyaml>=6,<7

View File

@@ -78,6 +78,70 @@ echo "--- Verifying DB row ---"
"SELECT license_key, email, tier, source FROM licenses;" \ "SELECT license_key, email, tier, source FROM licenses;" \
| grep -q smoke@example.com | grep -q smoke@example.com
echo "--- POST /webhooks/gumroad (synthetic Ping payload) ---"
WEBHOOK_RESP=$(curl -s -w "\nHTTP=%{http_code}" -X POST \
"http://127.0.0.1:18090/webhooks/gumroad?secret=test-gumroad-secret" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "sale_id=GUM-SMOKE-001" \
--data-urlencode "email=webhook@example.com" \
--data-urlencode "full_name=Webhook Tester" \
--data-urlencode "product_id=datatools-core" \
--data-urlencode "price=9900" \
--data-urlencode "currency=usd" \
--data-urlencode "test=true")
WEBHOOK_CODE=$(echo "$WEBHOOK_RESP" | tail -n1 | sed 's/HTTP=//')
WEBHOOK_BODY=$(echo "$WEBHOOK_RESP" | sed '$d')
echo "$WEBHOOK_BODY" | python3 -m json.tool
if [ "$WEBHOOK_CODE" != "200" ] || ! echo "$WEBHOOK_BODY" | grep -q '"status":"ok"'; then
echo "WEBHOOK FAILED (HTTP $WEBHOOK_CODE)"
"${COMPOSE[@]}" logs --tail 30 api
exit 1
fi
echo "--- Verifying Gumroad mint landed in DB ---"
"${COMPOSE[@]}" exec -T postgres \
psql -U dt_test -d dt_test -t -c \
"SELECT license_key, email, tier, source, source_order_id FROM licenses WHERE source='gumroad';" \
| tee /dev/stderr | grep -q GUM-SMOKE-001
echo "--- Verifying gumroad_events audit row ---"
PROCESSED=$("${COMPOSE[@]}" exec -T postgres \
psql -U dt_test -d dt_test -At -c \
"SELECT processed FROM gumroad_events WHERE order_id='GUM-SMOKE-001' ORDER BY id LIMIT 1;")
if [ "$PROCESSED" != "t" ]; then
echo "FAIL: gumroad_events.processed=$PROCESSED (expected 't')"
exit 1
fi
echo "audit row processed=true"
echo "--- Verifying wrong-secret returns 404 ---"
WRONG_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
"http://127.0.0.1:18090/webhooks/gumroad?secret=wrong-secret" \
--data-urlencode "sale_id=should-not-mint")
if [ "$WRONG_CODE" != "404" ]; then
echo "FAIL: wrong-secret should return 404, got $WRONG_CODE"
exit 1
fi
echo "wrong-secret correctly rejected"
echo "--- Verifying webhook idempotency on retry ---"
curl -s -o /dev/null -X POST \
"http://127.0.0.1:18090/webhooks/gumroad?secret=test-gumroad-secret" \
--data-urlencode "sale_id=GUM-SMOKE-001" \
--data-urlencode "email=webhook@example.com" \
--data-urlencode "full_name=Webhook Tester" \
--data-urlencode "product_id=datatools-core" \
--data-urlencode "price=9900" \
--data-urlencode "currency=usd"
ROW_COUNT=$("${COMPOSE[@]}" exec -T postgres psql -U dt_test -d dt_test -t -c \
"SELECT COUNT(*) FROM licenses WHERE source_order_id='GUM-SMOKE-001';" \
| tr -d ' ')
if [ "$ROW_COUNT" != "1" ]; then
echo "FAIL: duplicate webhook produced $ROW_COUNT rows (expected 1)"
exit 1
fi
echo "idempotency OK: still 1 license row after retry"
echo echo
echo "====================================" echo "===================================="
echo " SMOKE TEST PASSED" echo " SMOKE TEST PASSED"

103
server/tests/test_email.py Normal file
View File

@@ -0,0 +1,103 @@
"""EmailService — Postmark client + dev-mode logging fallback."""
from __future__ import annotations
import logging
from unittest.mock import patch
import httpx
import pytest
from app.email import (
EmailDeliveryError,
LicenseEmail,
LoggingEmailService,
PostmarkEmailService,
_render_html,
_render_text,
get_email_service,
)
def _msg() -> LicenseEmail:
return LicenseEmail(
to_name="Jane Doe",
to_email="jane@example.com",
tier="core",
license_key="DT1-CORE-aaaa-bbbb",
expires_at_iso="2027-05-14T01:00:00Z",
blob="DTLIC2:placeholder",
)
def test_render_text_contains_essentials():
body = _render_text(_msg())
assert "Jane Doe" in body
assert "DTLIC2:placeholder" in body
assert "DT1-CORE-aaaa-bbbb" in body
assert "core" in body
assert "2027-05-14" in body
def test_render_html_escapes_user_input():
msg = LicenseEmail(
to_name="<script>alert(1)</script>",
to_email="x@y.com",
tier="core",
license_key="K",
expires_at_iso="2030-01-01T00:00:00Z",
blob="DTLIC2:x",
)
html = _render_html(msg)
assert "<script>" not in html
assert "&lt;script&gt;" in html
def test_logging_service_writes_to_log(caplog):
caplog.set_level(logging.INFO)
svc = LoggingEmailService()
result = svc.send_license(_msg())
assert result == "logged"
log_text = "\n".join(r.message for r in caplog.records)
assert "would send" in log_text
assert "jane@example.com" in log_text
assert "DTLIC2:placeholder" in log_text
def test_factory_returns_logging_when_no_token(monkeypatch):
monkeypatch.delenv("POSTMARK_TOKEN", raising=False)
monkeypatch.delenv("POSTMARK_TOKEN_FILE", raising=False)
from app.config import get_settings
get_settings.cache_clear()
svc = get_email_service()
assert isinstance(svc, LoggingEmailService)
def test_postmark_send_success():
svc = PostmarkEmailService("test-token", sender="from@example.com")
def handler(request: httpx.Request) -> httpx.Response:
body = request.read().decode()
assert "from@example.com" in body
assert "jane@example.com" in body
assert "DTLIC2:placeholder" in body
return httpx.Response(200, json={"MessageID": "pm-12345"})
transport = httpx.MockTransport(handler)
_real_client = httpx.Client
with patch("app.email.httpx.Client", lambda **kw: _real_client(transport=transport, **kw)):
msg_id = svc.send_license(_msg())
assert msg_id == "pm-12345"
def test_postmark_send_raises_on_failure():
svc = PostmarkEmailService("test-token", sender="from@example.com")
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(422, json={"Message": "Invalid sender"})
transport = httpx.MockTransport(handler)
_real_client = httpx.Client
with patch("app.email.httpx.Client", lambda **kw: _real_client(transport=transport, **kw)):
with pytest.raises(EmailDeliveryError, match="422"):
svc.send_license(_msg())

View File

@@ -0,0 +1,97 @@
"""Gumroad adapter: secret check, Ping parsing, refunds stub."""
from __future__ import annotations
from decimal import Decimal
import pytest
from app.adapters.gumroad import GumroadAdapter, UnmappedProductError
def _sale_payload(**overrides) -> dict:
base = {
"sale_id": "GUM-1001",
"email": "jane@example.com",
"full_name": "Jane Doe",
"product_id": "datatools-core",
"price": "9900", # cents
"currency": "usd",
"offer_code": "",
"test": "false",
}
base.update(overrides)
return base
def test_verify_secret_correct():
a = GumroadAdapter(secret="abc123")
assert a.verify_secret("abc123") is True
def test_verify_secret_wrong():
a = GumroadAdapter(secret="abc123")
assert a.verify_secret("nope") is False
def test_verify_secret_unset_rejects_all():
a = GumroadAdapter(secret=None)
assert a.verify_secret("anything") is False
assert a.verify_secret(None) is False
def test_verify_secret_missing_presented_value():
a = GumroadAdapter(secret="abc123")
assert a.verify_secret(None) is False
assert a.verify_secret("") is False
def test_parse_sale_happy_path():
a = GumroadAdapter(secret="x")
sale = a.parse_sale(_sale_payload())
assert sale is not None
assert sale.source == "gumroad"
assert sale.source_order_id == "GUM-1001"
assert sale.buyer_email == "jane@example.com"
assert sale.buyer_name == "Jane Doe"
assert sale.tier == "core"
assert sale.years == 1
assert sale.amount_paid == Decimal("99.00")
assert sale.currency == "USD"
assert sale.promotion is None
def test_parse_sale_with_offer_code():
a = GumroadAdapter(secret="x")
sale = a.parse_sale(_sale_payload(offer_code="LAUNCH50"))
assert sale.promotion == "LAUNCH50"
def test_parse_sale_test_ping_tagged():
a = GumroadAdapter(secret="x")
sale = a.parse_sale(_sale_payload(test="true"))
assert sale.notes == "gumroad test ping"
def test_parse_sale_name_fallback_from_email():
a = GumroadAdapter(secret="x")
sale = a.parse_sale(_sale_payload(full_name="", email="john.doe@example.com"))
assert sale.buyer_name == "John Doe"
def test_parse_sale_missing_required_returns_none():
a = GumroadAdapter(secret="x")
assert a.parse_sale(_sale_payload(sale_id="")) is None
assert a.parse_sale(_sale_payload(email="")) is None
assert a.parse_sale(_sale_payload(product_id="")) is None
def test_parse_sale_unmapped_product_raises():
a = GumroadAdapter(secret="x")
with pytest.raises(UnmappedProductError):
a.parse_sale(_sale_payload(product_id="no-such-sku"))
def test_parse_refund_stub_returns_none():
a = GumroadAdapter(secret="x")
assert a.parse_refund({"any": "payload"}) is None

View File

@@ -0,0 +1,32 @@
"""Product → tier mapping lookup."""
from __future__ import annotations
from app.products import lookup, reload_for_tests
def setup_function(_):
# The yaml file is read once at import; reload to be safe if
# other tests mutate state in the future.
reload_for_tests()
def test_lookup_known_gumroad_product():
m = lookup("gumroad", "datatools-core")
assert m is not None
assert m.tier == "core"
assert m.years == 1
def test_lookup_unknown_product_returns_none():
assert lookup("gumroad", "no-such-product") is None
def test_lookup_unknown_source_returns_none():
assert lookup("paddle", "datatools-core") is None
def test_all_three_tiers_mapped():
assert lookup("gumroad", "datatools-lite").tier == "lite"
assert lookup("gumroad", "datatools-core").tier == "core"
assert lookup("gumroad", "datatools-pro").tier == "pro"

View File

@@ -0,0 +1,143 @@
"""End-to-end webhook tests — secret check, audit log, mint, email,
idempotency."""
from __future__ import annotations
import os
from unittest.mock import patch
import pytest
from app.email import LicenseEmail
from app.models import GumroadEvent, License
@pytest.fixture
def with_gumroad_secret(monkeypatch):
monkeypatch.setenv("GUMROAD_WEBHOOK_SECRET", "test-gumroad-secret")
from app.config import get_settings
get_settings.cache_clear()
yield
get_settings.cache_clear()
def _form(**overrides) -> dict:
base = {
"sale_id": "GUM-2001",
"email": "jane@example.com",
"full_name": "Jane Doe",
"product_id": "datatools-core",
"price": "9900",
"currency": "usd",
"offer_code": "",
"test": "false",
}
base.update(overrides)
return base
def test_webhook_rejects_missing_secret(client, with_gumroad_secret):
r = client.post("/webhooks/gumroad", data=_form())
assert r.status_code == 404
def test_webhook_rejects_wrong_secret(client, with_gumroad_secret):
r = client.post("/webhooks/gumroad?secret=wrong", data=_form())
assert r.status_code == 404
def test_webhook_mints_and_sends_email(client, with_gumroad_secret, db_session):
captured: list[LicenseEmail] = []
class CapturingEmail:
def send_license(self, msg):
captured.append(msg)
return "captured"
with patch("app.routes.webhooks.get_email_service", lambda: CapturingEmail()):
r = client.post(
"/webhooks/gumroad?secret=test-gumroad-secret",
data=_form(),
)
assert r.status_code == 200
body = r.json()
assert body["status"] == "ok"
assert body["license_key"].startswith("DT1-CORE-")
# DB row landed.
row = db_session.query(License).filter_by(source_order_id="GUM-2001").one()
assert row.source == "gumroad"
assert row.email == "jane@example.com"
assert row.tier == "core"
assert row.blob.startswith("DTLIC2:")
# Audit row processed.
event = db_session.query(GumroadEvent).filter_by(order_id="GUM-2001").one()
assert event.processed is True
assert event.error is None
# Email captured.
assert len(captured) == 1
assert captured[0].to_email == "jane@example.com"
assert captured[0].blob == row.blob
def test_webhook_idempotent_on_duplicate_sale_id(client, with_gumroad_secret, db_session):
"""Gumroad retries on transient failures. Same sale_id must
produce one license, not two."""
with patch("app.routes.webhooks.get_email_service", lambda: _NullEmail()):
client.post("/webhooks/gumroad?secret=test-gumroad-secret", data=_form())
client.post("/webhooks/gumroad?secret=test-gumroad-secret", data=_form())
rows = db_session.query(License).filter_by(source_order_id="GUM-2001").all()
assert len(rows) == 1
# But both webhook deliveries should be in the audit log.
events = db_session.query(GumroadEvent).filter_by(order_id="GUM-2001").all()
assert len(events) == 2
def test_webhook_unmapped_product_audits_and_returns_200(client, with_gumroad_secret, db_session):
"""An unknown product_id must NOT crash and must NOT trigger a
retry storm. Audit row gets the error reason."""
with patch("app.routes.webhooks.get_email_service", lambda: _NullEmail()):
r = client.post(
"/webhooks/gumroad?secret=test-gumroad-secret",
data=_form(product_id="not-a-real-sku", sale_id="GUM-666"),
)
assert r.status_code == 200
assert r.json()["status"] == "logged-no-mint"
# No license, but audit row with error.
assert db_session.query(License).filter_by(source_order_id="GUM-666").count() == 0
event = db_session.query(GumroadEvent).filter_by(order_id="GUM-666").one()
assert event.processed is False
assert "no entry in config/products.yaml" in (event.error or "")
def test_webhook_email_failure_keeps_license(client, with_gumroad_secret, db_session):
"""If Postmark hiccups, the buyer's license is still minted and
persists in the DB. They can be served from the renewal portal
(PR 3) or a manual resend."""
from app.email import EmailDeliveryError
class FailingEmail:
def send_license(self, msg):
raise EmailDeliveryError("Postmark 503")
with patch("app.routes.webhooks.get_email_service", lambda: FailingEmail()):
r = client.post(
"/webhooks/gumroad?secret=test-gumroad-secret",
data=_form(sale_id="GUM-3001"),
)
assert r.status_code == 200
assert r.json()["status"] == "minted-email-failed"
assert db_session.query(License).filter_by(source_order_id="GUM-3001").count() == 1
event = db_session.query(GumroadEvent).filter_by(order_id="GUM-3001").one()
assert event.processed is True # we count it processed so Gumroad doesn't retry
assert "email error" in (event.error or "")
class _NullEmail:
def send_license(self, msg):
return "null"