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:
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"}
|
||||
Reference in New Issue
Block a user