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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
173
server/app/adapters/gumroad.py
Normal file
173
server/app/adapters/gumroad.py
Normal 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
214
server/app/email.py
Normal 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("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace('"', """)
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
71
server/app/products.py
Normal 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()
|
||||||
121
server/app/routes/webhooks.py
Normal file
121
server/app/routes/webhooks.py
Normal 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}
|
||||||
@@ -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:
|
||||||
|
|||||||
34
server/config/products.yaml
Normal file
34
server/config/products.yaml
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
103
server/tests/test_email.py
Normal 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 "<script>" 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())
|
||||||
97
server/tests/test_gumroad_adapter.py
Normal file
97
server/tests/test_gumroad_adapter.py
Normal 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
|
||||||
32
server/tests/test_products.py
Normal file
32
server/tests/test_products.py
Normal 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"
|
||||||
143
server/tests/test_webhook.py
Normal file
143
server/tests/test_webhook.py
Normal 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"
|
||||||
Reference in New Issue
Block a user