Files
datatools-dev/server/tests/conftest.py
Michael bab2c9468c 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>
2026-05-14 00:46:54 +00:00

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"}