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:
0
server/tests/__init__.py
Normal file
0
server/tests/__init__.py
Normal file
108
server/tests/conftest.py
Normal file
108
server/tests/conftest.py
Normal 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"}
|
||||
52
server/tests/test_adapters.py
Normal file
52
server/tests/test_adapters.py
Normal 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
102
server/tests/test_mint.py
Normal 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
130
server/tests/test_routes.py
Normal 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
|
||||
Reference in New Issue
Block a user