From 2bbaba954bc1b3327226e54d4107a96550aca60e Mon Sep 17 00:00:00 2001 From: Michael Date: Thu, 14 May 2026 01:33:43 +0000 Subject: [PATCH] feat(server): Gumroad webhook receiver + Postmark email (PR 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- server/Dockerfile | 1 + server/app/adapters/gumroad.py | 173 ++++++++++++++++++++++ server/app/email.py | 214 +++++++++++++++++++++++++++ server/app/main.py | 3 +- server/app/models.py | 8 +- server/app/products.py | 71 +++++++++ server/app/routes/webhooks.py | 121 +++++++++++++++ server/compose.test.yml | 2 + server/config/products.yaml | 34 +++++ server/requirements.txt | 2 + server/scripts/smoke.sh | 64 ++++++++ server/tests/test_email.py | 103 +++++++++++++ server/tests/test_gumroad_adapter.py | 97 ++++++++++++ server/tests/test_products.py | 32 ++++ server/tests/test_webhook.py | 143 ++++++++++++++++++ 15 files changed, 1066 insertions(+), 2 deletions(-) create mode 100644 server/app/adapters/gumroad.py create mode 100644 server/app/email.py create mode 100644 server/app/products.py create mode 100644 server/app/routes/webhooks.py create mode 100644 server/config/products.yaml create mode 100644 server/tests/test_email.py create mode 100644 server/tests/test_gumroad_adapter.py create mode 100644 server/tests/test_products.py create mode 100644 server/tests/test_webhook.py diff --git a/server/Dockerfile b/server/Dockerfile index 8c718e5..5f936d7 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -23,6 +23,7 @@ RUN pip install -r /app/requirements.txt COPY src/license /app/datatools_license COPY server/app /app/app +COPY server/config /app/config COPY server/alembic /app/alembic COPY server/alembic.ini /app/alembic.ini diff --git a/server/app/adapters/gumroad.py b/server/app/adapters/gumroad.py new file mode 100644 index 0000000..17a0911 --- /dev/null +++ b/server/app/adapters/gumroad.py @@ -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"} diff --git a/server/app/email.py b/server/app/email.py new file mode 100644 index 0000000..93086fe --- /dev/null +++ b/server/app/email.py @@ -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 ( + "" + f"

Hi {_html_escape(msg.to_name)},

" + "

Thanks for your DataTools purchase. Your license is below.

" + "" + f"" + f"" + f"" + "
License key{_html_escape(msg.license_key)}
Tier{_html_escape(msg.tier)}
Expires{_html_escape(msg.expires_at_iso[:10])}
" + "

To activate, paste the blob below into the Activate " + "screen on first launch.

" + "
"
+        f"{_html_escape(msg.blob)}
" + "

Keep this email — you'll " + "need the blob if you move to a new computer. Questions: just reply.

" + "

— DataTools

" + ) + + +def _rfc_addr(name: str, email: str) -> str: + # Postmark accepts "Name " 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('"', """) + ) diff --git a/server/app/main.py b/server/app/main.py index 994949f..f40297f 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -4,7 +4,7 @@ from __future__ import annotations from fastapi import FastAPI -from app.routes import internal, public +from app.routes import internal, public, webhooks app = FastAPI( title="DataTools License Server", @@ -16,3 +16,4 @@ app = FastAPI( app.include_router(public.router) app.include_router(internal.router) +app.include_router(webhooks.router) diff --git a/server/app/models.py b/server/app/models.py index cc36e57..9fff4d1 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -16,6 +16,7 @@ from sqlalchemy import ( BigInteger, DateTime, Index, + Integer, Numeric, String, UniqueConstraint, @@ -29,6 +30,11 @@ from sqlalchemy.orm import Mapped, mapped_column # (SQLite for tests). Same Python interface either way. _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 @@ -77,7 +83,7 @@ class GumroadEvent(Base): __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()) event_type: Mapped[str] = mapped_column(String, nullable=False) order_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) diff --git a/server/app/products.py b/server/app/products.py new file mode 100644 index 0000000..ce17ac4 --- /dev/null +++ b/server/app/products.py @@ -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() diff --git a/server/app/routes/webhooks.py b/server/app/routes/webhooks.py new file mode 100644 index 0000000..ff90a32 --- /dev/null +++ b/server/app/routes/webhooks.py @@ -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} diff --git a/server/compose.test.yml b/server/compose.test.yml index 6fc5fdf..7eefde5 100644 --- a/server/compose.test.yml +++ b/server/compose.test.yml @@ -30,8 +30,10 @@ services: environment: DATABASE_URL: postgresql+psycopg://dt_test:test_pw@postgres:5432/dt_test DATATOOLS_ADMIN_TOKEN: test-admin-token + GUMROAD_WEBHOOK_SECRET: test-gumroad-secret # No DATATOOLS_LICENSE_PRIVKEY — falls back to the in-tree # dev keypair, matching what the desktop dev build expects. + # No POSTMARK_TOKEN — falls back to LoggingEmailService. ports: - "127.0.0.1:18090:8000" healthcheck: diff --git a/server/config/products.yaml b/server/config/products.yaml new file mode 100644 index 0000000..3049d59 --- /dev/null +++ b/server/config/products.yaml @@ -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 diff --git a/server/requirements.txt b/server/requirements.txt index 67bccef..4391dc1 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -1,5 +1,6 @@ fastapi>=0.115,<0.120 uvicorn[standard]>=0.32,<0.40 +python-multipart>=0.0.20,<1 sqlalchemy>=2.0,<3 psycopg[binary]>=3.2,<4 alembic>=1.14,<2 @@ -8,3 +9,4 @@ pydantic-settings>=2.6,<3 email-validator>=2.2,<3 cryptography>=43,<46 httpx>=0.27,<1 +pyyaml>=6,<7 diff --git a/server/scripts/smoke.sh b/server/scripts/smoke.sh index 4e310de..850b01e 100755 --- a/server/scripts/smoke.sh +++ b/server/scripts/smoke.sh @@ -78,6 +78,70 @@ echo "--- Verifying DB row ---" "SELECT license_key, email, tier, source FROM licenses;" \ | 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 " SMOKE TEST PASSED" diff --git a/server/tests/test_email.py b/server/tests/test_email.py new file mode 100644 index 0000000..245be4c --- /dev/null +++ b/server/tests/test_email.py @@ -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="", + 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 "