Files
gnickensandClaude Opus 5 0526d34e42 Scaffold FastAPI + MySQL + Keycloak service with devcontainer
Sets up the project skeleton:

- FastAPI app factory with lifespan, request-id middleware, and RFC 9457
  problem+json error handlers
- Async SQLAlchemy 2.0 over MySQL (asyncmy), with a constraint naming
  convention in place before the first migration and async Alembic
- Keycloak as a pure resource server: OIDC discovery, cached JWKS with
  rotation-aware refresh, and require_roles dependencies
- Devcontainer running MySQL 8.4 and Keycloak 26.7 as compose siblings,
  with the realm (clients, roles, test users) imported on first boot
- Test suite covering the endpoints plus the token validator itself,
  exercised against a locally generated RSA keypair
- uv packaging, ruff, mypy --strict, pre-commit, Gitea CI, prod Dockerfile

Two Keycloak-in-containers traps are handled explicitly and documented in
the README: the issuer/internal-URL split (the browser sees localhost:8080,
the API sees keycloak:8080) and the audience mapper that stops Keycloak
issuing tokens with aud=account.

The devices resource is a placeholder proving the routing -> auth -> ORM ->
migration path end to end; replace it with the real domain.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-10 12:46:52 -04:00

151 lines
5.0 KiB
Python

"""Shared fixtures.
Tests run against a real MySQL database (`v2x_test`), never SQLite: this project
relies on MySQL types, collation and constraint behaviour, and a SQLite-backed
suite would happily pass while production broke.
Isolation strategy: each test runs inside an outer transaction that is rolled
back afterwards, so tests share one migrated schema without sharing data.
"""
from __future__ import annotations
import os
from collections.abc import AsyncIterator, Callable, Iterator
from contextlib import AbstractAsyncContextManager
from typing import Any
import pytest
from alembic import command
from alembic.config import Config
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession, async_sessionmaker
from v2x_server.auth.deps import get_current_principal
from v2x_server.auth.principal import Principal
from v2x_server.core.config import Settings
from v2x_server.db.session import create_engine, get_session
from v2x_server.main import create_app
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@pytest.fixture(scope="session")
def settings() -> Settings:
base = Settings()
return base.model_copy(
update={
"app_env": "test",
"database_url": base.test_database_url,
"cors_origins": [],
}
)
@pytest.fixture(scope="session")
def _migrated_database(settings: Settings) -> Iterator[None]:
"""Bring `v2x_test` to head once per session."""
config = Config(os.path.join(PROJECT_ROOT, "alembic.ini"))
config.set_main_option("script_location", os.path.join(PROJECT_ROOT, "migrations"))
os.environ["ALEMBIC_DATABASE_URL"] = settings.database_url
command.upgrade(config, "head")
yield
os.environ.pop("ALEMBIC_DATABASE_URL", None)
@pytest.fixture
async def db_connection(
settings: Settings, _migrated_database: None
) -> AsyncIterator[AsyncConnection]:
"""A connection with an open outer transaction, rolled back after the test."""
engine = create_engine(settings)
async with engine.connect() as connection:
transaction = await connection.begin()
try:
yield connection
finally:
await transaction.rollback()
await engine.dispose()
@pytest.fixture
async def db_session(db_connection: AsyncConnection) -> AsyncIterator[AsyncSession]:
"""Session bound to the test's connection, joined to its outer transaction.
`join_transaction_mode="create_savepoint"` lets application code call
commit() normally -- it releases a savepoint rather than committing the
outer transaction, so the rollback above still wipes everything.
"""
factory = async_sessionmaker(
bind=db_connection,
expire_on_commit=False,
join_transaction_mode="create_savepoint",
)
async with factory() as session:
yield session
class LifespanRunner:
"""Minimal async context manager that drives an app's lifespan events."""
def __init__(self, app: FastAPI) -> None:
self._app = app
self._context: AbstractAsyncContextManager[None] | None = None
async def __aenter__(self) -> None:
self._context = self._app.router.lifespan_context(self._app)
await self._context.__aenter__()
async def __aexit__(self, *exc_info: Any) -> None:
assert self._context is not None
await self._context.__aexit__(*exc_info)
def make_principal(*roles: str, username: str = "test-user") -> Principal:
return Principal(
subject="00000000-0000-0000-0000-000000000001",
username=username,
email=f"{username}@example.com",
realm_roles=frozenset(roles),
)
@pytest.fixture
async def app(settings: Settings, db_session: AsyncSession) -> AsyncIterator[FastAPI]:
"""App wired to the test session; auth left unauthenticated by default."""
application = create_app(settings)
async def _override_session() -> AsyncIterator[AsyncSession]:
yield db_session
application.dependency_overrides[get_session] = _override_session
yield application
application.dependency_overrides.clear()
@pytest.fixture
def as_user(app: FastAPI) -> Callable[..., Principal]:
"""Authenticate subsequent requests as a principal holding `roles`."""
def _apply(*roles: str, username: str = "test-user") -> Principal:
principal = make_principal(*roles, username=username)
app.dependency_overrides[get_current_principal] = lambda: principal
return principal
return _apply
@pytest.fixture
async def client(app: FastAPI) -> AsyncIterator[AsyncClient]:
"""In-process HTTP client. No live server, but the full middleware stack."""
transport = ASGITransport(app=app)
# LifespanRunner is what builds the engine and OIDC provider, so app.state
# matches production rather than being half-initialised.
async with (
LifespanRunner(app),
AsyncClient(transport=transport, base_url="http://test") as http_client,
):
yield http_client