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>
109 lines
3.5 KiB
Python
109 lines
3.5 KiB
Python
"""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"}
|