feat(server): mint API + Postgres schema + manual adapter (PR 1)

Source-agnostic license issuance service. FastAPI app fronts a
Postgres `licenses` table; the only currently-wired source is
`manual` (operator mints via /internal/mint). Gumroad webhook
adapter lands in PR 2.

Key design points:

- Signing reuses src/license/crypto.py via a COPY into the image
  (single source of truth — blobs minted server-side verify against
  the same embedded pubkey on the buyer's machine).
- Source adapter Protocol (app/adapters/base.py) is the seam for
  Gumroad / Lemon Squeezy / Stripe in later PRs; Mint API speaks
  only SaleEvent / RefundEvent.
- (source, source_order_id) UNIQUE composite gives idempotent
  webhook retries without double-mint.
- JSONB type uses with_variant(JSON, 'sqlite') so the same models
  drive both Postgres prod and SQLite tests (no testcontainers dep).
- Bearer-token auth on /internal/*; the IP-loopback guard was
  removed after the docker bridge made it fight legitimate prod
  traffic (nginx defense + Bearer remain).
- Secrets resolved via *_FILE env vars pointing at
  /run/secrets/<name>, so passwords never appear in `docker inspect`.

21 unit tests (SQLite in-memory, StaticPool) plus a real-Postgres
docker-compose smoke test in server/scripts/smoke.sh that builds the
image, runs the alembic migration, mints a license, verifies the
signature against the host dev pubkey, and checks the DB row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 00:46:54 +00:00
parent 4179cb5156
commit bab2c9468c
29 changed files with 1519 additions and 0 deletions

0
server/tests/__init__.py Normal file
View File

108
server/tests/conftest.py Normal file
View File

@@ -0,0 +1,108 @@
"""Shared pytest fixtures.
Tests run against in-memory SQLite — no docker, no Postgres install.
The cross-dialect type variants in :mod:`app.models` keep the schema
identical in behavior for everything PR 1 exercises (the JSONB
column on ``gumroad_events`` isn't touched until PR 2).
Auth: a fixed test token is wired into the settings cache before any
app modules import, and the ``client`` fixture overrides the
``require_localhost`` guard since Starlette's TestClient connects
from a synthetic ``testclient`` peer.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
# Set required env BEFORE importing anything from app.* so pydantic
# Settings (lru_cache'd) picks up these values on first access.
os.environ["DATATOOLS_ADMIN_TOKEN"] = "test-admin-token"
os.environ["DATABASE_URL"] = "sqlite+pysqlite:///:memory:"
# Make the desktop license module importable as `datatools_license`.
# In the Docker image this happens via `COPY src/license /app/datatools_license`;
# during local tests we simulate it by aliasing src.license.
_REPO_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(_REPO_ROOT))
import src.license as _dt_license_module
sys.modules.setdefault("datatools_license", _dt_license_module)
for _sub in ("crypto", "schema", "features", "_dev_keypair"):
sys.modules.setdefault(
f"datatools_license.{_sub}",
__import__(f"src.license.{_sub}", fromlist=[_sub]),
)
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
import app.db as app_db
from app.db import Base
from app.main import app
@pytest.fixture(scope="session")
def engine():
eng = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
future=True,
)
# Enforce foreign keys on SQLite (off by default).
@event.listens_for(eng, "connect")
def _fk_on(dbapi_conn, _):
dbapi_conn.execute("PRAGMA foreign_keys=ON")
Base.metadata.create_all(eng)
yield eng
eng.dispose()
@pytest.fixture(autouse=True)
def _bind_app_engine(engine, monkeypatch):
"""Point the app's session factory at the test engine and wipe rows
between tests so order-of-execution can't leak state."""
TestSession = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
monkeypatch.setattr(app_db, "engine", engine)
monkeypatch.setattr(app_db, "SessionLocal", TestSession)
yield
with engine.begin() as conn:
for tbl in reversed(Base.metadata.sorted_tables):
conn.execute(tbl.delete())
@pytest.fixture
def db_session(engine):
"""Per-test session with rollback isolation."""
Session = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
sess = Session()
try:
yield sess
finally:
# Clean rows between tests rather than transaction-rolling (the
# mint code flushes mid-transaction and we want each test to
# see a clean licenses table).
sess.rollback()
for tbl in reversed(Base.metadata.sorted_tables):
sess.execute(tbl.delete())
sess.commit()
sess.close()
@pytest.fixture
def client():
"""Plain TestClient. Bearer-token check is live."""
with TestClient(app) as c:
yield c
@pytest.fixture
def admin_headers() -> dict[str, str]:
return {"Authorization": "Bearer test-admin-token"}

View File

@@ -0,0 +1,52 @@
"""ManualAdapter — building a SaleEvent from CLI-style kwargs."""
from __future__ import annotations
from decimal import Decimal
from app.adapters.manual import ManualAdapter
def test_build_sale_minimal_defaults():
a = ManualAdapter()
sale = a.build_sale(name="Jane Doe", email="jane@example.com", tier="core")
assert sale.source == "manual"
assert sale.source_order_id is None
assert sale.buyer_name == "Jane Doe"
assert sale.buyer_email == "jane@example.com"
assert sale.tier == "core"
assert sale.years == 1
assert sale.currency == "USD"
assert sale.promotion is None
assert sale.amount_paid is None
assert sale.notes is None
def test_build_sale_full_metadata():
a = ManualAdapter()
sale = a.build_sale(
name="Acme",
email="ops@acme.example",
tier="pro",
years=2,
promotion="LAUNCH50",
amount_paid=Decimal("249.00"),
currency="EUR",
notes="comp for beta tester",
)
assert sale.years == 2
assert sale.promotion == "LAUNCH50"
assert sale.amount_paid == Decimal("249.00")
assert sale.currency == "EUR"
assert sale.notes == "comp for beta tester"
def test_verify_webhook_always_false():
"""Manual flow never originates from a webhook."""
a = ManualAdapter()
assert a.verify_webhook(body=b"{}", headers={}) is False
def test_parse_refund_returns_none():
a = ManualAdapter()
assert a.parse_refund({"any": "payload"}) is None

102
server/tests/test_mint.py Normal file
View File

@@ -0,0 +1,102 @@
"""Mint core — signing, persistence, idempotency, revoke."""
from __future__ import annotations
from decimal import Decimal
import pytest
from app.adapters.base import SaleEvent
from app.mint import mint_from_sale, revoke_license
from app.models import License
from datatools_license.crypto import decode_blob, verify
def _sale(**overrides) -> SaleEvent:
base = dict(
source="manual",
source_order_id=None,
buyer_name="Jane Doe",
buyer_email="jane@example.com",
tier="core",
years=1,
promotion=None,
amount_paid=None,
currency="USD",
notes=None,
)
base.update(overrides)
return SaleEvent(**base)
def test_mint_persists_and_signs_verifiably(db_session):
row = mint_from_sale(db_session, _sale())
db_session.commit()
assert row.license_key.startswith("DT1-CORE-")
assert row.tier == "core"
assert row.source == "manual"
assert row.blob.startswith("DTLIC2:")
assert row.revoked_at is None
payload = decode_blob(row.blob)
sig = payload.pop("signature")
assert verify(payload, sig), "minted blob must verify against the dev pubkey"
assert payload["name"] == "Jane Doe"
assert payload["email"] == "jane@example.com"
assert payload["tier"] == "core"
def test_mint_idempotent_on_source_order_id(db_session):
"""A second mint with the same (source, source_order_id) returns
the existing row — webhook retries cannot double-mint."""
first = mint_from_sale(
db_session,
_sale(source="gumroad", source_order_id="GUM-1001"),
)
db_session.commit()
second = mint_from_sale(
db_session,
_sale(source="gumroad", source_order_id="GUM-1001", buyer_name="Different Name"),
)
db_session.commit()
assert first.license_key == second.license_key
assert second.name == "Jane Doe", "existing row is returned unchanged"
def test_manual_mints_never_dedup(db_session):
"""source_order_id=None means each manual mint creates a new row."""
a = mint_from_sale(db_session, _sale())
db_session.commit()
b = mint_from_sale(db_session, _sale())
db_session.commit()
assert a.license_key != b.license_key
def test_mint_records_commercial_metadata(db_session):
row = mint_from_sale(
db_session,
_sale(promotion="LAUNCH50", amount_paid=Decimal("79.00"), currency="USD"),
)
db_session.commit()
assert row.promotion == "LAUNCH50"
assert Decimal(str(row.amount_paid)) == Decimal("79.00")
assert row.currency == "USD"
def test_revoke_marks_row(db_session):
row = mint_from_sale(db_session, _sale())
db_session.commit()
revoked = revoke_license(db_session, license_key=row.license_key, reason="refund")
db_session.commit()
assert revoked is not None
assert revoked.revoked_at is not None
assert "refund" in (revoked.notes or "")
def test_revoke_unknown_returns_none(db_session):
assert revoke_license(db_session, license_key="DT1-CORE-no-such-key") is None

130
server/tests/test_routes.py Normal file
View File

@@ -0,0 +1,130 @@
"""HTTP route tests — auth, mint, revoke, list, health."""
from __future__ import annotations
def test_health_is_public(client):
r = client.get("/health")
assert r.status_code == 200
assert r.json()["status"] == "ok"
def test_internal_requires_bearer(client):
r = client.get("/internal/ping")
assert r.status_code == 401
def test_internal_rejects_wrong_bearer(client):
r = client.get("/internal/ping", headers={"Authorization": "Bearer nope"})
assert r.status_code == 401
def test_internal_ping_ok_with_token(client, admin_headers):
r = client.get("/internal/ping", headers=admin_headers)
assert r.status_code == 200
assert r.json() == {"ok": True}
def test_mint_creates_license(client, admin_headers):
r = client.post(
"/internal/mint",
headers=admin_headers,
json={
"name": "Jane Doe",
"email": "jane@example.com",
"tier": "core",
"years": 1,
"source": "manual",
},
)
assert r.status_code == 201, r.text
body = r.json()
assert body["tier"] == "core"
assert body["source"] == "manual"
assert body["blob"].startswith("DTLIC2:")
def test_mint_rejects_non_manual_source(client, admin_headers):
r = client.post(
"/internal/mint",
headers=admin_headers,
json={
"name": "x", "email": "x@example.com", "tier": "core",
"source": "gumroad",
},
)
assert r.status_code == 400
assert "not wired" in r.json()["detail"]
def test_mint_rejects_bad_email(client, admin_headers):
r = client.post(
"/internal/mint",
headers=admin_headers,
json={"name": "x", "email": "not-an-email", "tier": "core"},
)
assert r.status_code == 422
def test_mint_rejects_unknown_tier(client, admin_headers):
r = client.post(
"/internal/mint",
headers=admin_headers,
json={"name": "x", "email": "x@example.com", "tier": "platinum"},
)
assert r.status_code == 422
def test_list_licenses_filters_email_case_insensitive(client, admin_headers):
# Pydantic EmailStr normalizes the domain to lowercase per RFC.
for email in ("alice@example.com", "Bob@Example.com", "carol@other.test"):
client.post(
"/internal/mint",
headers=admin_headers,
json={"name": "User", "email": email, "tier": "core"},
)
r = client.get(
"/internal/licenses?email=example.com",
headers=admin_headers,
)
assert r.status_code == 200
emails = {row["email"].lower() for row in r.json()}
assert "alice@example.com" in emails
assert "bob@example.com" in emails
assert "carol@other.test" not in emails
def test_revoke_then_excluded_by_default(client, admin_headers):
r = client.post(
"/internal/mint",
headers=admin_headers,
json={"name": "x", "email": "x@example.com", "tier": "lite"},
)
key = r.json()["license_key"]
r2 = client.post(
"/internal/revoke",
headers=admin_headers,
json={"license_key": key, "reason": "refund"},
)
assert r2.status_code == 200
assert r2.json()["revoked_at"] is not None
listed = client.get("/internal/licenses", headers=admin_headers).json()
assert all(row["license_key"] != key for row in listed)
listed_all = client.get(
"/internal/licenses?include_revoked=true",
headers=admin_headers,
).json()
assert any(row["license_key"] == key for row in listed_all)
def test_revoke_unknown_returns_404(client, admin_headers):
r = client.post(
"/internal/revoke",
headers=admin_headers,
json={"license_key": "DT1-CORE-doesnot-exist"},
)
assert r.status_code == 404