# SQLite Repositories Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Implement SQLite-backed repository classes that satisfy the existing Protocol interfaces, with a migration runner, lifespan integration, and FastAPI dependency injection. **Architecture:** Three repository classes (`SQLiteUserRepository`, `SQLiteCredentialRepository`, `SQLiteMagicLinkRepository`) sharing a single `aiosqlite` connection, initialized via FastAPI lifespan. Schema managed by numbered SQL migration files applied at startup. **Tech Stack:** aiosqlite, SQLite WAL mode, pytest with in-memory SQLite **Quality gate:** `./scripts/check.sh` (ruff format, ruff check, ty check, pytest) --- ### Task 1: SQL Migration File **Files:** - Create: `src/fastapi_oidc_op/store/sqlite/migrations/001_initial.sql` **Step 1: Create the migration file** ```sql CREATE TABLE users ( userid TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, preferred_username TEXT, given_name TEXT, family_name TEXT, nickname TEXT, email TEXT, email_verified INTEGER NOT NULL DEFAULT 0, phone_number TEXT, phone_number_verified INTEGER NOT NULL DEFAULT 0, picture TEXT, locale TEXT, active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE user_groups ( userid TEXT NOT NULL REFERENCES users(userid) ON DELETE CASCADE, group_name TEXT NOT NULL, PRIMARY KEY (userid, group_name) ); CREATE TABLE webauthn_credentials ( user_id TEXT NOT NULL REFERENCES users(userid) ON DELETE CASCADE, credential_id BLOB NOT NULL, public_key BLOB NOT NULL, sign_count INTEGER NOT NULL DEFAULT 0, device_name TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, PRIMARY KEY (user_id, credential_id) ); CREATE TABLE password_credentials ( user_id TEXT PRIMARY KEY REFERENCES users(userid) ON DELETE CASCADE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE magic_links ( token TEXT PRIMARY KEY, username TEXT NOT NULL, expires_at TEXT NOT NULL, used INTEGER NOT NULL DEFAULT 0, created_by TEXT, note TEXT ); ``` **Step 2: Commit** ```bash git add src/fastapi_oidc_op/store/sqlite/migrations/001_initial.sql git commit -m "feat: add initial SQLite migration schema" ``` --- ### Task 2: Migration Runner **Files:** - Create: `src/fastapi_oidc_op/store/sqlite/migrations.py` - Test: `tests/test_store/test_migrations.py` **Step 1: Write the failing tests** ```python # tests/test_store/test_migrations.py from pathlib import Path import aiosqlite from fastapi_oidc_op.store.sqlite.migrations import run_migrations MIGRATIONS_DIR = Path(__file__).resolve().parent.parent.parent / "src" / "fastapi_oidc_op" / "store" / "sqlite" / "migrations" async def test_run_migrations_applies_initial() -> None: async with aiosqlite.connect(":memory:") as db: await db.execute("PRAGMA foreign_keys=ON") count = await run_migrations(db, MIGRATIONS_DIR) assert count == 1 # Verify users table exists async with db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='users'") as cursor: row = await cursor.fetchone() assert row is not None async def test_run_migrations_skips_already_applied() -> None: async with aiosqlite.connect(":memory:") as db: await db.execute("PRAGMA foreign_keys=ON") first_count = await run_migrations(db, MIGRATIONS_DIR) second_count = await run_migrations(db, MIGRATIONS_DIR) assert first_count == 1 assert second_count == 0 async def test_run_migrations_creates_all_tables() -> None: async with aiosqlite.connect(":memory:") as db: await db.execute("PRAGMA foreign_keys=ON") await run_migrations(db, MIGRATIONS_DIR) async with db.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") as cursor: tables = [row[0] async for row in cursor] assert "users" in tables assert "user_groups" in tables assert "webauthn_credentials" in tables assert "password_credentials" in tables assert "magic_links" in tables assert "_migrations" in tables ``` **Step 2: Run tests to verify they fail** Run: `pytest tests/test_store/test_migrations.py -v` Expected: FAIL — cannot import `run_migrations` **Step 3: Write the migration runner** ```python # src/fastapi_oidc_op/store/sqlite/migrations.py from pathlib import Path import aiosqlite async def run_migrations(db: aiosqlite.Connection, migrations_dir: Path) -> int: """Apply unapplied SQL migration files in order. Returns count of newly applied migrations.""" await db.execute( """ CREATE TABLE IF NOT EXISTS _migrations ( id INTEGER PRIMARY KEY, filename TEXT NOT NULL UNIQUE, applied_at TEXT NOT NULL DEFAULT (datetime('now')) ) """ ) await db.commit() # Get already-applied migrations async with db.execute("SELECT filename FROM _migrations") as cursor: applied = {row[0] async for row in cursor} # Find and sort migration files migration_files = sorted(migrations_dir.glob("*.sql")) count = 0 for migration_file in migration_files: if migration_file.name in applied: continue sql = migration_file.read_text() await db.executescript(sql) await db.execute("INSERT INTO _migrations (filename) VALUES (?)", (migration_file.name,)) await db.commit() count += 1 return count ``` **Step 4: Run tests to verify they pass** Run: `pytest tests/test_store/test_migrations.py -v` Expected: 3 PASSED **Step 5: Run quality gate** Run: `./scripts/check.sh` Expected: All green **Step 6: Commit** ```bash git add src/fastapi_oidc_op/store/sqlite/migrations.py tests/test_store/test_migrations.py git commit -m "feat: add SQLite migration runner" ``` --- ### Task 3: Domain Exception and Store Exports **Files:** - Create: `src/fastapi_oidc_op/store/exceptions.py` - Test: `tests/test_store/test_exceptions.py` **Step 1: Write the failing test** ```python # tests/test_store/test_exceptions.py from fastapi_oidc_op.store.exceptions import DuplicateError def test_duplicate_error_is_exception() -> None: error = DuplicateError("user already exists") assert isinstance(error, Exception) assert str(error) == "user already exists" ``` **Step 2: Run test to verify it fails** Run: `pytest tests/test_store/test_exceptions.py -v` Expected: FAIL — cannot import `DuplicateError` **Step 3: Write the exception** ```python # src/fastapi_oidc_op/store/exceptions.py class DuplicateError(Exception): """Raised when a create operation violates a uniqueness constraint.""" ``` **Step 4: Run test to verify it passes** Run: `pytest tests/test_store/test_exceptions.py -v` Expected: 1 PASSED **Step 5: Commit** ```bash git add src/fastapi_oidc_op/store/exceptions.py tests/test_store/test_exceptions.py git commit -m "feat: add DuplicateError domain exception" ``` --- ### Task 4: SQLiteUserRepository **Files:** - Create: `src/fastapi_oidc_op/store/sqlite/repositories.py` - Create: `tests/test_store/conftest.py` (shared fixtures) - Create: `tests/test_store/test_sqlite_user_repo.py` **Step 1: Write shared test fixtures** ```python # tests/test_store/conftest.py from pathlib import Path import aiosqlite import pytest from fastapi_oidc_op.store.sqlite.migrations import run_migrations MIGRATIONS_DIR = Path(__file__).resolve().parent.parent.parent / "src" / "fastapi_oidc_op" / "store" / "sqlite" / "migrations" @pytest.fixture async def db(): conn = await aiosqlite.connect(":memory:") conn.row_factory = aiosqlite.Row await conn.execute("PRAGMA foreign_keys=ON") await run_migrations(conn, MIGRATIONS_DIR) yield conn await conn.close() ``` **Step 2: Write the failing tests** ```python # tests/test_store/test_sqlite_user_repo.py import aiosqlite import pytest from fastapi_oidc_op.models import User from fastapi_oidc_op.store.exceptions import DuplicateError from fastapi_oidc_op.store.protocols import UserRepository from fastapi_oidc_op.store.sqlite.repositories import SQLiteUserRepository @pytest.fixture def user_repo(db: aiosqlite.Connection) -> SQLiteUserRepository: return SQLiteUserRepository(db) def _make_user(**overrides) -> User: defaults = {"userid": "lusab-bansen", "username": "alice"} defaults.update(overrides) return User(**defaults) async def test_implements_protocol(user_repo: SQLiteUserRepository) -> None: assert isinstance(user_repo, UserRepository) async def test_create_and_get_by_userid(user_repo: SQLiteUserRepository) -> None: user = _make_user() created = await user_repo.create(user) assert created.userid == "lusab-bansen" assert created.username == "alice" fetched = await user_repo.get_by_userid("lusab-bansen") assert fetched is not None assert fetched.userid == "lusab-bansen" assert fetched.username == "alice" async def test_get_by_username(user_repo: SQLiteUserRepository) -> None: user = _make_user() await user_repo.create(user) fetched = await user_repo.get_by_username("alice") assert fetched is not None assert fetched.username == "alice" async def test_get_by_userid_not_found(user_repo: SQLiteUserRepository) -> None: result = await user_repo.get_by_userid("nonexistent") assert result is None async def test_get_by_username_not_found(user_repo: SQLiteUserRepository) -> None: result = await user_repo.get_by_username("nonexistent") assert result is None async def test_create_with_groups(user_repo: SQLiteUserRepository) -> None: user = _make_user(groups=["admin", "users"]) await user_repo.create(user) fetched = await user_repo.get_by_userid("lusab-bansen") assert fetched is not None assert sorted(fetched.groups) == ["admin", "users"] async def test_update(user_repo: SQLiteUserRepository) -> None: user = _make_user() await user_repo.create(user) user.email = "alice@example.com" user.given_name = "Alice" updated = await user_repo.update(user) assert updated.email == "alice@example.com" assert updated.given_name == "Alice" fetched = await user_repo.get_by_userid("lusab-bansen") assert fetched is not None assert fetched.email == "alice@example.com" async def test_update_groups(user_repo: SQLiteUserRepository) -> None: user = _make_user(groups=["users"]) await user_repo.create(user) user.groups = ["admin", "editors"] await user_repo.update(user) fetched = await user_repo.get_by_userid("lusab-bansen") assert fetched is not None assert sorted(fetched.groups) == ["admin", "editors"] async def test_list_users(user_repo: SQLiteUserRepository) -> None: await user_repo.create(_make_user(userid="id-1", username="alice")) await user_repo.create(_make_user(userid="id-2", username="bob")) await user_repo.create(_make_user(userid="id-3", username="charlie")) users = await user_repo.list_users() assert len(users) == 3 async def test_list_users_pagination(user_repo: SQLiteUserRepository) -> None: for i in range(5): await user_repo.create(_make_user(userid=f"id-{i}", username=f"user-{i}")) page1 = await user_repo.list_users(offset=0, limit=2) page2 = await user_repo.list_users(offset=2, limit=2) page3 = await user_repo.list_users(offset=4, limit=2) assert len(page1) == 2 assert len(page2) == 2 assert len(page3) == 1 async def test_delete(user_repo: SQLiteUserRepository) -> None: user = _make_user() await user_repo.create(user) deleted = await user_repo.delete("lusab-bansen") assert deleted is True fetched = await user_repo.get_by_userid("lusab-bansen") assert fetched is None async def test_delete_not_found(user_repo: SQLiteUserRepository) -> None: deleted = await user_repo.delete("nonexistent") assert deleted is False async def test_delete_cascades_groups(user_repo: SQLiteUserRepository) -> None: user = _make_user(groups=["admin"]) await user_repo.create(user) await user_repo.delete("lusab-bansen") # Verify groups were cascaded async with user_repo._db.execute("SELECT COUNT(*) FROM user_groups WHERE userid = ?", ("lusab-bansen",)) as cursor: row = await cursor.fetchone() assert row[0] == 0 async def test_create_duplicate_username(user_repo: SQLiteUserRepository) -> None: await user_repo.create(_make_user()) with pytest.raises(DuplicateError): await user_repo.create(_make_user(userid="different-id", username="alice")) async def test_roundtrip_preserves_all_fields(user_repo: SQLiteUserRepository) -> None: user = _make_user( preferred_username="ally", given_name="Alice", family_name="Smith", nickname="Al", email="alice@example.com", email_verified=True, phone_number="+1234567890", phone_number_verified=True, picture="https://example.com/alice.jpg", locale="en-US", active=False, groups=["admin", "users"], ) await user_repo.create(user) fetched = await user_repo.get_by_userid("lusab-bansen") assert fetched is not None assert fetched.preferred_username == "ally" assert fetched.given_name == "Alice" assert fetched.family_name == "Smith" assert fetched.nickname == "Al" assert fetched.email == "alice@example.com" assert fetched.email_verified is True assert fetched.phone_number == "+1234567890" assert fetched.phone_number_verified is True assert fetched.picture == "https://example.com/alice.jpg" assert fetched.locale == "en-US" assert fetched.active is False assert sorted(fetched.groups) == ["admin", "users"] assert fetched.created_at == user.created_at assert fetched.updated_at == user.updated_at ``` **Step 3: Run tests to verify they fail** Run: `pytest tests/test_store/test_sqlite_user_repo.py -v` Expected: FAIL — cannot import `SQLiteUserRepository` **Step 4: Write the implementation** ```python # src/fastapi_oidc_op/store/sqlite/repositories.py from datetime import UTC, datetime import aiosqlite from fastapi_oidc_op.models import ( MagicLink, PasswordCredential, User, WebAuthnCredential, ) from fastapi_oidc_op.store.exceptions import DuplicateError class SQLiteUserRepository: def __init__(self, db: aiosqlite.Connection) -> None: self._db = db async def _get_groups(self, userid: str) -> list[str]: async with self._db.execute( "SELECT group_name FROM user_groups WHERE userid = ? ORDER BY group_name", (userid,) ) as cursor: return [row[0] async for row in cursor] async def _set_groups(self, userid: str, groups: list[str]) -> None: await self._db.execute("DELETE FROM user_groups WHERE userid = ?", (userid,)) for group in groups: await self._db.execute("INSERT INTO user_groups (userid, group_name) VALUES (?, ?)", (userid, group)) def _row_to_user(self, row: aiosqlite.Row, groups: list[str]) -> User: return User( userid=row["userid"], username=row["username"], preferred_username=row["preferred_username"], given_name=row["given_name"], family_name=row["family_name"], nickname=row["nickname"], email=row["email"], email_verified=bool(row["email_verified"]), phone_number=row["phone_number"], phone_number_verified=bool(row["phone_number_verified"]), picture=row["picture"], locale=row["locale"], active=bool(row["active"]), created_at=datetime.fromisoformat(row["created_at"]), updated_at=datetime.fromisoformat(row["updated_at"]), groups=groups, ) async def create(self, user: User) -> User: try: await self._db.execute( """ INSERT INTO users ( userid, username, preferred_username, given_name, family_name, nickname, email, email_verified, phone_number, phone_number_verified, picture, locale, active, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( user.userid, user.username, user.preferred_username, user.given_name, user.family_name, user.nickname, user.email, int(user.email_verified), user.phone_number, int(user.phone_number_verified), user.picture, user.locale, int(user.active), user.created_at.isoformat(), user.updated_at.isoformat(), ), ) await self._set_groups(user.userid, user.groups) await self._db.commit() except aiosqlite.IntegrityError as e: raise DuplicateError(str(e)) from e return user async def get_by_userid(self, userid: str) -> User | None: async with self._db.execute("SELECT * FROM users WHERE userid = ?", (userid,)) as cursor: row = await cursor.fetchone() if row is None: return None groups = await self._get_groups(userid) return self._row_to_user(row, groups) async def get_by_username(self, username: str) -> User | None: async with self._db.execute("SELECT * FROM users WHERE username = ?", (username,)) as cursor: row = await cursor.fetchone() if row is None: return None groups = await self._get_groups(row["userid"]) return self._row_to_user(row, groups) async def update(self, user: User) -> User: user.updated_at = datetime.now(UTC) await self._db.execute( """ UPDATE users SET username = ?, preferred_username = ?, given_name = ?, family_name = ?, nickname = ?, email = ?, email_verified = ?, phone_number = ?, phone_number_verified = ?, picture = ?, locale = ?, active = ?, updated_at = ? WHERE userid = ? """, ( user.username, user.preferred_username, user.given_name, user.family_name, user.nickname, user.email, int(user.email_verified), user.phone_number, int(user.phone_number_verified), user.picture, user.locale, int(user.active), user.updated_at.isoformat(), user.userid, ), ) await self._set_groups(user.userid, user.groups) await self._db.commit() return user async def list_users(self, offset: int = 0, limit: int = 100) -> list[User]: async with self._db.execute( "SELECT * FROM users ORDER BY username LIMIT ? OFFSET ?", (limit, offset) ) as cursor: rows = await cursor.fetchall() users = [] for row in rows: groups = await self._get_groups(row["userid"]) users.append(self._row_to_user(row, groups)) return users async def delete(self, userid: str) -> bool: cursor = await self._db.execute("DELETE FROM users WHERE userid = ?", (userid,)) await self._db.commit() return cursor.rowcount > 0 ``` **Step 5: Run tests to verify they pass** Run: `pytest tests/test_store/test_sqlite_user_repo.py -v` Expected: All PASSED **Step 6: Run quality gate** Run: `./scripts/check.sh` Expected: All green **Step 7: Commit** ```bash git add src/fastapi_oidc_op/store/sqlite/repositories.py tests/test_store/conftest.py tests/test_store/test_sqlite_user_repo.py git commit -m "feat: add SQLiteUserRepository with tests" ``` --- ### Task 5: SQLiteCredentialRepository **Files:** - Modify: `src/fastapi_oidc_op/store/sqlite/repositories.py` - Create: `tests/test_store/test_sqlite_credential_repo.py` **Step 1: Write the failing tests** ```python # tests/test_store/test_sqlite_credential_repo.py import aiosqlite import pytest from fastapi_oidc_op.models import PasswordCredential, User, WebAuthnCredential from fastapi_oidc_op.store.exceptions import DuplicateError from fastapi_oidc_op.store.protocols import CredentialRepository from fastapi_oidc_op.store.sqlite.repositories import ( SQLiteCredentialRepository, SQLiteUserRepository, ) @pytest.fixture def user_repo(db: aiosqlite.Connection) -> SQLiteUserRepository: return SQLiteUserRepository(db) @pytest.fixture def credential_repo(db: aiosqlite.Connection) -> SQLiteCredentialRepository: return SQLiteCredentialRepository(db) @pytest.fixture async def alice(user_repo: SQLiteUserRepository) -> User: return await user_repo.create(User(userid="lusab-bansen", username="alice")) async def test_implements_protocol(credential_repo: SQLiteCredentialRepository) -> None: assert isinstance(credential_repo, CredentialRepository) async def test_create_and_get_webauthn(credential_repo: SQLiteCredentialRepository, alice: User) -> None: cred = WebAuthnCredential( user_id=alice.userid, credential_id=b"\x01\x02\x03", public_key=b"\x04\x05\x06", device_name="YubiKey", ) created = await credential_repo.create_webauthn(cred) assert created.user_id == alice.userid creds = await credential_repo.get_webauthn_by_user(alice.userid) assert len(creds) == 1 assert creds[0].credential_id == b"\x01\x02\x03" assert creds[0].public_key == b"\x04\x05\x06" assert creds[0].device_name == "YubiKey" async def test_get_webauthn_by_credential_id(credential_repo: SQLiteCredentialRepository, alice: User) -> None: cred = WebAuthnCredential( user_id=alice.userid, credential_id=b"\x01\x02\x03", public_key=b"\x04\x05\x06", ) await credential_repo.create_webauthn(cred) fetched = await credential_repo.get_webauthn_by_credential_id(b"\x01\x02\x03") assert fetched is not None assert fetched.user_id == alice.userid async def test_get_webauthn_by_credential_id_not_found(credential_repo: SQLiteCredentialRepository) -> None: result = await credential_repo.get_webauthn_by_credential_id(b"\xff\xff") assert result is None async def test_multiple_webauthn_per_user(credential_repo: SQLiteCredentialRepository, alice: User) -> None: for i in range(3): cred = WebAuthnCredential( user_id=alice.userid, credential_id=bytes([i]), public_key=b"\x00", device_name=f"Key {i}", ) await credential_repo.create_webauthn(cred) creds = await credential_repo.get_webauthn_by_user(alice.userid) assert len(creds) == 3 async def test_update_webauthn(credential_repo: SQLiteCredentialRepository, alice: User) -> None: cred = WebAuthnCredential( user_id=alice.userid, credential_id=b"\x01\x02\x03", public_key=b"\x04\x05\x06", sign_count=0, device_name="Old Name", ) await credential_repo.create_webauthn(cred) cred.sign_count = 42 cred.device_name = "New Name" updated = await credential_repo.update_webauthn(cred) assert updated.sign_count == 42 assert updated.device_name == "New Name" fetched = await credential_repo.get_webauthn_by_credential_id(b"\x01\x02\x03") assert fetched is not None assert fetched.sign_count == 42 assert fetched.device_name == "New Name" async def test_delete_webauthn(credential_repo: SQLiteCredentialRepository, alice: User) -> None: cred = WebAuthnCredential( user_id=alice.userid, credential_id=b"\x01\x02\x03", public_key=b"\x04\x05\x06", ) await credential_repo.create_webauthn(cred) deleted = await credential_repo.delete_webauthn(alice.userid, b"\x01\x02\x03") assert deleted is True creds = await credential_repo.get_webauthn_by_user(alice.userid) assert len(creds) == 0 async def test_delete_webauthn_not_found(credential_repo: SQLiteCredentialRepository) -> None: deleted = await credential_repo.delete_webauthn("nobody", b"\xff") assert deleted is False async def test_create_and_get_password(credential_repo: SQLiteCredentialRepository, alice: User) -> None: cred = PasswordCredential( user_id=alice.userid, password_hash="$argon2id$v=19$m=65536,t=3,p=4$hash", ) created = await credential_repo.create_password(cred) assert created.user_id == alice.userid fetched = await credential_repo.get_password_by_user(alice.userid) assert fetched is not None assert fetched.password_hash == "$argon2id$v=19$m=65536,t=3,p=4$hash" async def test_get_password_not_found(credential_repo: SQLiteCredentialRepository) -> None: result = await credential_repo.get_password_by_user("nobody") assert result is None async def test_delete_password(credential_repo: SQLiteCredentialRepository, alice: User) -> None: cred = PasswordCredential( user_id=alice.userid, password_hash="$argon2id$v=19$hash", ) await credential_repo.create_password(cred) deleted = await credential_repo.delete_password(alice.userid) assert deleted is True fetched = await credential_repo.get_password_by_user(alice.userid) assert fetched is None async def test_delete_password_not_found(credential_repo: SQLiteCredentialRepository) -> None: deleted = await credential_repo.delete_password("nobody") assert deleted is False async def test_create_duplicate_password(credential_repo: SQLiteCredentialRepository, alice: User) -> None: cred = PasswordCredential(user_id=alice.userid, password_hash="hash1") await credential_repo.create_password(cred) with pytest.raises(DuplicateError): cred2 = PasswordCredential(user_id=alice.userid, password_hash="hash2") await credential_repo.create_password(cred2) async def test_create_duplicate_webauthn(credential_repo: SQLiteCredentialRepository, alice: User) -> None: cred = WebAuthnCredential(user_id=alice.userid, credential_id=b"\x01", public_key=b"\x02") await credential_repo.create_webauthn(cred) with pytest.raises(DuplicateError): await credential_repo.create_webauthn(cred) ``` **Step 2: Run tests to verify they fail** Run: `pytest tests/test_store/test_sqlite_credential_repo.py -v` Expected: FAIL — cannot import `SQLiteCredentialRepository` **Step 3: Add SQLiteCredentialRepository to `repositories.py`** Append to `src/fastapi_oidc_op/store/sqlite/repositories.py`: ```python class SQLiteCredentialRepository: def __init__(self, db: aiosqlite.Connection) -> None: self._db = db def _row_to_webauthn(self, row: aiosqlite.Row) -> WebAuthnCredential: return WebAuthnCredential( user_id=row["user_id"], credential_id=bytes(row["credential_id"]), public_key=bytes(row["public_key"]), sign_count=row["sign_count"], device_name=row["device_name"], created_at=datetime.fromisoformat(row["created_at"]), ) def _row_to_password(self, row: aiosqlite.Row) -> PasswordCredential: return PasswordCredential( user_id=row["user_id"], password_hash=row["password_hash"], created_at=datetime.fromisoformat(row["created_at"]), ) async def create_webauthn(self, credential: WebAuthnCredential) -> WebAuthnCredential: try: await self._db.execute( """ INSERT INTO webauthn_credentials (user_id, credential_id, public_key, sign_count, device_name, created_at) VALUES (?, ?, ?, ?, ?, ?) """, ( credential.user_id, credential.credential_id, credential.public_key, credential.sign_count, credential.device_name, credential.created_at.isoformat(), ), ) await self._db.commit() except aiosqlite.IntegrityError as e: raise DuplicateError(str(e)) from e return credential async def create_password(self, credential: PasswordCredential) -> PasswordCredential: try: await self._db.execute( "INSERT INTO password_credentials (user_id, password_hash, created_at) VALUES (?, ?, ?)", (credential.user_id, credential.password_hash, credential.created_at.isoformat()), ) await self._db.commit() except aiosqlite.IntegrityError as e: raise DuplicateError(str(e)) from e return credential async def get_webauthn_by_user(self, user_id: str) -> list[WebAuthnCredential]: async with self._db.execute( "SELECT * FROM webauthn_credentials WHERE user_id = ?", (user_id,) ) as cursor: rows = await cursor.fetchall() return [self._row_to_webauthn(row) for row in rows] async def get_webauthn_by_credential_id(self, credential_id: bytes) -> WebAuthnCredential | None: async with self._db.execute( "SELECT * FROM webauthn_credentials WHERE credential_id = ?", (credential_id,) ) as cursor: row = await cursor.fetchone() if row is None: return None return self._row_to_webauthn(row) async def get_password_by_user(self, user_id: str) -> PasswordCredential | None: async with self._db.execute( "SELECT * FROM password_credentials WHERE user_id = ?", (user_id,) ) as cursor: row = await cursor.fetchone() if row is None: return None return self._row_to_password(row) async def update_webauthn(self, credential: WebAuthnCredential) -> WebAuthnCredential: await self._db.execute( "UPDATE webauthn_credentials SET sign_count = ?, device_name = ? WHERE user_id = ? AND credential_id = ?", (credential.sign_count, credential.device_name, credential.user_id, credential.credential_id), ) await self._db.commit() return credential async def delete_webauthn(self, user_id: str, credential_id: bytes) -> bool: cursor = await self._db.execute( "DELETE FROM webauthn_credentials WHERE user_id = ? AND credential_id = ?", (user_id, credential_id), ) await self._db.commit() return cursor.rowcount > 0 async def delete_password(self, user_id: str) -> bool: cursor = await self._db.execute( "DELETE FROM password_credentials WHERE user_id = ?", (user_id,) ) await self._db.commit() return cursor.rowcount > 0 ``` **Step 4: Run tests to verify they pass** Run: `pytest tests/test_store/test_sqlite_credential_repo.py -v` Expected: All PASSED **Step 5: Run quality gate** Run: `./scripts/check.sh` Expected: All green **Step 6: Commit** ```bash git add src/fastapi_oidc_op/store/sqlite/repositories.py tests/test_store/test_sqlite_credential_repo.py git commit -m "feat: add SQLiteCredentialRepository with tests" ``` --- ### Task 6: SQLiteMagicLinkRepository **Files:** - Modify: `src/fastapi_oidc_op/store/sqlite/repositories.py` - Create: `tests/test_store/test_sqlite_magic_link_repo.py` **Step 1: Write the failing tests** ```python # tests/test_store/test_sqlite_magic_link_repo.py from datetime import UTC, datetime, timedelta import aiosqlite import pytest from fastapi_oidc_op.models import MagicLink from fastapi_oidc_op.store.exceptions import DuplicateError from fastapi_oidc_op.store.protocols import MagicLinkRepository from fastapi_oidc_op.store.sqlite.repositories import SQLiteMagicLinkRepository @pytest.fixture def magic_link_repo(db: aiosqlite.Connection) -> SQLiteMagicLinkRepository: return SQLiteMagicLinkRepository(db) def _make_link(**overrides) -> MagicLink: defaults = { "token": "abc123", "username": "alice", "expires_at": datetime.now(UTC) + timedelta(hours=24), } defaults.update(overrides) return MagicLink(**defaults) async def test_implements_protocol(magic_link_repo: SQLiteMagicLinkRepository) -> None: assert isinstance(magic_link_repo, MagicLinkRepository) async def test_create_and_get_by_token(magic_link_repo: SQLiteMagicLinkRepository) -> None: link = _make_link() created = await magic_link_repo.create(link) assert created.token == "abc123" fetched = await magic_link_repo.get_by_token("abc123") assert fetched is not None assert fetched.token == "abc123" assert fetched.username == "alice" assert fetched.used is False async def test_get_by_token_not_found(magic_link_repo: SQLiteMagicLinkRepository) -> None: result = await magic_link_repo.get_by_token("nonexistent") assert result is None async def test_mark_used(magic_link_repo: SQLiteMagicLinkRepository) -> None: link = _make_link() await magic_link_repo.create(link) marked = await magic_link_repo.mark_used("abc123") assert marked is True fetched = await magic_link_repo.get_by_token("abc123") assert fetched is not None assert fetched.used is True async def test_mark_used_not_found(magic_link_repo: SQLiteMagicLinkRepository) -> None: marked = await magic_link_repo.mark_used("nonexistent") assert marked is False async def test_delete_expired(magic_link_repo: SQLiteMagicLinkRepository) -> None: # Create an expired link expired = _make_link(token="expired", expires_at=datetime.now(UTC) - timedelta(hours=1)) await magic_link_repo.create(expired) # Create a valid link valid = _make_link(token="valid", expires_at=datetime.now(UTC) + timedelta(hours=24)) await magic_link_repo.create(valid) count = await magic_link_repo.delete_expired() assert count == 1 # Expired should be gone assert await magic_link_repo.get_by_token("expired") is None # Valid should remain assert await magic_link_repo.get_by_token("valid") is not None async def test_delete_expired_skips_used(magic_link_repo: SQLiteMagicLinkRepository) -> None: # Create an expired but used link link = _make_link(token="used-expired", expires_at=datetime.now(UTC) - timedelta(hours=1)) await magic_link_repo.create(link) await magic_link_repo.mark_used("used-expired") count = await magic_link_repo.delete_expired() assert count == 0 async def test_create_with_optional_fields(magic_link_repo: SQLiteMagicLinkRepository) -> None: link = _make_link(created_by="admin", note="Welcome aboard") await magic_link_repo.create(link) fetched = await magic_link_repo.get_by_token("abc123") assert fetched is not None assert fetched.created_by == "admin" assert fetched.note == "Welcome aboard" async def test_create_duplicate_token(magic_link_repo: SQLiteMagicLinkRepository) -> None: await magic_link_repo.create(_make_link()) with pytest.raises(DuplicateError): await magic_link_repo.create(_make_link()) ``` **Step 2: Run tests to verify they fail** Run: `pytest tests/test_store/test_sqlite_magic_link_repo.py -v` Expected: FAIL — cannot import `SQLiteMagicLinkRepository` **Step 3: Add SQLiteMagicLinkRepository to `repositories.py`** Append to `src/fastapi_oidc_op/store/sqlite/repositories.py`: ```python class SQLiteMagicLinkRepository: def __init__(self, db: aiosqlite.Connection) -> None: self._db = db def _row_to_magic_link(self, row: aiosqlite.Row) -> MagicLink: return MagicLink( token=row["token"], username=row["username"], expires_at=datetime.fromisoformat(row["expires_at"]), used=bool(row["used"]), created_by=row["created_by"], note=row["note"], ) async def create(self, link: MagicLink) -> MagicLink: try: await self._db.execute( "INSERT INTO magic_links (token, username, expires_at, used, created_by, note) VALUES (?, ?, ?, ?, ?, ?)", ( link.token, link.username, link.expires_at.isoformat(), int(link.used), link.created_by, link.note, ), ) await self._db.commit() except aiosqlite.IntegrityError as e: raise DuplicateError(str(e)) from e return link async def get_by_token(self, token: str) -> MagicLink | None: async with self._db.execute("SELECT * FROM magic_links WHERE token = ?", (token,)) as cursor: row = await cursor.fetchone() if row is None: return None return self._row_to_magic_link(row) async def mark_used(self, token: str) -> bool: cursor = await self._db.execute( "UPDATE magic_links SET used = 1 WHERE token = ?", (token,) ) await self._db.commit() return cursor.rowcount > 0 async def delete_expired(self) -> int: now = datetime.now(UTC).isoformat() cursor = await self._db.execute( "DELETE FROM magic_links WHERE expires_at < ? AND used = 0", (now,) ) await self._db.commit() return cursor.rowcount ``` **Step 4: Run tests to verify they pass** Run: `pytest tests/test_store/test_sqlite_magic_link_repo.py -v` Expected: All PASSED **Step 5: Run quality gate** Run: `./scripts/check.sh` Expected: All green **Step 6: Commit** ```bash git add src/fastapi_oidc_op/store/sqlite/repositories.py tests/test_store/test_sqlite_magic_link_repo.py git commit -m "feat: add SQLiteMagicLinkRepository with tests" ``` --- ### Task 7: Lifespan Integration and Dependencies **Files:** - Modify: `src/fastapi_oidc_op/app.py` - Create: `src/fastapi_oidc_op/dependencies.py` - Modify: `tests/conftest.py` - Modify: `tests/test_app.py` **Step 1: Write the failing tests** Add to `tests/test_app.py`: ```python # Add these tests to the existing file async def test_app_has_repos_on_state(client: AsyncClient) -> None: """Repos should be available on app.state after lifespan startup.""" from fastapi_oidc_op.store.protocols import ( CredentialRepository, MagicLinkRepository, UserRepository, ) app = client._transport.app # type: ignore[union-attr] assert isinstance(app.state.user_repo, UserRepository) assert isinstance(app.state.credential_repo, CredentialRepository) assert isinstance(app.state.magic_link_repo, MagicLinkRepository) async def test_dependency_functions() -> None: """Dependency functions should return Protocol-typed repos.""" from unittest.mock import MagicMock from fastapi_oidc_op.dependencies import ( get_credential_repo, get_magic_link_repo, get_user_repo, ) request = MagicMock() request.app.state.user_repo = "user_repo_sentinel" request.app.state.credential_repo = "credential_repo_sentinel" request.app.state.magic_link_repo = "magic_link_repo_sentinel" assert get_user_repo(request) == "user_repo_sentinel" assert get_credential_repo(request) == "credential_repo_sentinel" assert get_magic_link_repo(request) == "magic_link_repo_sentinel" ``` Update `tests/conftest.py` so the client fixture uses lifespan (the app needs to run its lifespan to initialize repos). The settings fixture should use `:memory:` for SQLite: ```python # tests/conftest.py from collections.abc import AsyncIterator import pytest from httpx import ASGITransport, AsyncClient from fastapi_oidc_op.app import create_app from fastapi_oidc_op.config import Settings @pytest.fixture def settings() -> Settings: return Settings(issuer="http://localhost:8000", sqlite_path=":memory:") @pytest.fixture async def client(settings: Settings) -> AsyncIterator[AsyncClient]: app = create_app(settings) transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url=settings.issuer) as ac: yield ac ``` **Step 2: Run tests to verify they fail** Run: `pytest tests/test_app.py -v` Expected: FAIL — app has no lifespan, no repos on state **Step 3: Implement the lifespan in `app.py`** ```python # src/fastapi_oidc_op/app.py from collections.abc import AsyncIterator from contextlib import asynccontextmanager from pathlib import Path import aiosqlite from fastapi import FastAPI from fastapi_oidc_op.config import Settings, StorageBackend from fastapi_oidc_op.store.sqlite.migrations import run_migrations from fastapi_oidc_op.store.sqlite.repositories import ( SQLiteCredentialRepository, SQLiteMagicLinkRepository, SQLiteUserRepository, ) MIGRATIONS_DIR = Path(__file__).parent / "store" / "sqlite" / "migrations" @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: settings: Settings = app.state.settings if settings.storage_backend == StorageBackend.SQLITE: # Ensure parent directory exists (skip for :memory:) if settings.sqlite_path != ":memory:": Path(settings.sqlite_path).parent.mkdir(parents=True, exist_ok=True) db = await aiosqlite.connect(settings.sqlite_path) db.row_factory = aiosqlite.Row await db.execute("PRAGMA journal_mode=WAL") await db.execute("PRAGMA foreign_keys=ON") await run_migrations(db, MIGRATIONS_DIR) app.state.user_repo = SQLiteUserRepository(db) app.state.credential_repo = SQLiteCredentialRepository(db) app.state.magic_link_repo = SQLiteMagicLinkRepository(db) yield await db.close() else: raise NotImplementedError("MongoDB backend not yet implemented") def create_app(settings: Settings | None = None) -> FastAPI: if settings is None: settings = Settings() # type: ignore[call-arg] app = FastAPI( title="FastAPI OIDC OP", version="0.1.0", docs_url="/docs" if settings.debug else None, redoc_url=None, lifespan=lifespan, ) app.state.settings = settings @app.get("/health") async def health() -> dict[str, str]: return {"status": "ok"} return app ``` **Step 4: Implement `dependencies.py`** ```python # src/fastapi_oidc_op/dependencies.py from fastapi import Request from fastapi_oidc_op.store.protocols import ( CredentialRepository, MagicLinkRepository, UserRepository, ) def get_user_repo(request: Request) -> UserRepository: return request.app.state.user_repo def get_credential_repo(request: Request) -> CredentialRepository: return request.app.state.credential_repo def get_magic_link_repo(request: Request) -> MagicLinkRepository: return request.app.state.magic_link_repo ``` **Step 5: Run tests to verify they pass** Run: `pytest tests/test_app.py -v` Expected: All PASSED **Step 6: Run full quality gate** Run: `./scripts/check.sh` Expected: All green (all existing tests still pass with the updated conftest) **Step 7: Commit** ```bash git add src/fastapi_oidc_op/app.py src/fastapi_oidc_op/dependencies.py tests/conftest.py tests/test_app.py git commit -m "feat: add lifespan integration and dependency injection" ``` --- ### Task 8: Update Design Document **Files:** - Modify: `docs/plans/2026-02-12-sqlite-repositories-design.md` **Step 1: Update the schema status and next steps** Change the schema status line from: > **Status:** Schema section was presented to user and NOT YET explicitly approved. User requested shutdown before responding. **Ask for confirmation before proceeding.** To: > **Status:** Schema approved. Implementation complete. Update the "Next Steps" section to reflect completion and point to the next phase (Authentication). **Step 2: Commit** ```bash git add docs/plans/2026-02-12-sqlite-repositories-design.md git commit -m "docs: update sqlite design doc to reflect completed implementation" ```