Harden the API and the data layer after a security and performance audit
Security - Every route now requires a login and refuses ids outside the caller's workspace with 404: plan, leave-wishes, day-overrides and vacation-days were fully anonymous; every other router trusted client-supplied workspace/child/parent ids. Scraper sessions are bound to their owner. - Invitations can no longer be created for a foreign workspace, and a redeemer who is already a member (or a full workspace) is refused. - Production refuses the default JWT secret; API docs are hidden in production; a failed migration aborts startup instead of serving. - Login and register no longer reveal whether an email exists; inactive accounts are rejected; /auth/refresh is rate limited. - The FK scraper writes debug artefacts only when explicitly enabled, logs no scraped values or page text, clears the BankID QR as soon as it is scanned and purges finished sessions after five minutes. - Real FK page captures are removed from the tree (history rewrite follows); the parser tests use a synthetic fixture. Performance and correctness - Plan apply/sync run inside one locked transaction with batched writes instead of one commit per row, so a failure mid-batch leaves nothing half-applied and concurrent syncs no longer corrupt each other. - SQLite runs in WAL mode with a busy timeout; 14 indexes cover the filters that were full scans; argon2 hashing runs off the event loop; responses are gzipped. - Frontend: a 401 now refreshes the token once and retries, so sessions no longer die silently after 15 minutes; every data hook discards stale responses; logout clears state even when the server call fails. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsXCCcDfmV4Sps4ZQSvNEY
This commit is contained in:
@@ -13,11 +13,15 @@ from uuid import UUID, uuid4
|
||||
|
||||
import aiosqlite
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.db.connection import ensure_schema
|
||||
from app.dependencies import get_connection, get_current_user
|
||||
from app.entities.child import Child
|
||||
from app.entities.personal_data import PersonalData
|
||||
from app.entities.user import User
|
||||
from app.entities.workspace import Workspace
|
||||
from app.main import app
|
||||
from app.repositories_impl.sqlite.child_repository import SQLiteChildRepository
|
||||
from app.repositories_impl.sqlite.day_override_repository import SQLiteDayOverrideRepository
|
||||
from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository
|
||||
@@ -25,6 +29,7 @@ from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParenta
|
||||
from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository
|
||||
from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository
|
||||
from app.repositories_impl.sqlite.tip_dismissal_repository import SQLiteTipDismissalRepository
|
||||
from app.repositories_impl.sqlite.unit_of_work import SQLiteUnitOfWork
|
||||
from app.repositories_impl.sqlite.user_repository import SQLiteUserRepository
|
||||
from app.repositories_impl.sqlite.vacation_days_repository import (
|
||||
SQLiteVacationDaysRepository,
|
||||
@@ -183,6 +188,51 @@ async def seeded_ids(
|
||||
return SeededIds(workspace_id=ws.id, personal_data_uuid=pd.uuid, child_id=child.id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared authenticated HTTP client — bound to `seeded_ids`'s workspace.
|
||||
#
|
||||
# T1 authz work: every router now rejects ids outside the caller's workspace,
|
||||
# so a router test's `client` must be authenticated as a real member of
|
||||
# whatever workspace the test operates on. This fixture is that identity for
|
||||
# any router test built around `seeded_ids` (see `tests/unit/test_fetch_data_
|
||||
# router.py`). Router tests that seed their own bespoke workspace per test
|
||||
# (e.g. `test_plan_router.py`) still define their own local `client`/current-
|
||||
# user fixture bound to THAT workspace instead — a single global identity
|
||||
# can't satisfy both shapes.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def current_user(seeded_ids: SeededIds) -> User:
|
||||
"""A real, logged-in user whose PersonalData is `seeded_ids.personal_data_uuid`."""
|
||||
return User(
|
||||
id=uuid4(),
|
||||
personal_data_uuid=seeded_ids.personal_data_uuid,
|
||||
email="seeded-user@example.com",
|
||||
hashed_password="not-a-real-hash",
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def authed_client(
|
||||
connection: aiosqlite.Connection, current_user: User
|
||||
) -> AsyncIterator[AsyncClient]:
|
||||
"""An httpx client against the real app, authenticated as `current_user`
|
||||
and wired to the test's in-memory `connection` — the same technique
|
||||
every router test file uses, consolidated here for tests keyed off
|
||||
`seeded_ids`.
|
||||
"""
|
||||
app.dependency_overrides[get_connection] = lambda: connection
|
||||
app.dependency_overrides[get_current_user] = lambda: current_user
|
||||
try:
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
||||
yield ac
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_connection, None)
|
||||
app.dependency_overrides.pop(get_current_user, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Child & leave
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -313,6 +363,40 @@ async def planning_service(
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit of work — a real `SQLiteUnitOfWork` bound to the test's shared
|
||||
# `connection`, for tests that specifically exercise atomicity/locking (see
|
||||
# `tests/unit/test_db_unit_of_work.py`). `planning_service` above deliberately
|
||||
# keeps its default no-op unit of work so every other test's per-write-commit
|
||||
# behavior stays exactly as it is; use `planning_service_with_uow` instead
|
||||
# when a test needs the real thing.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def sqlite_uow(connection: aiosqlite.Connection) -> SQLiteUnitOfWork:
|
||||
return SQLiteUnitOfWork(connection)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def planning_service_with_uow(
|
||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||
analytics_service: AnalyticsService,
|
||||
parental_leave_repo: SQLiteParentalLeaveRepository,
|
||||
vacation_repo: SQLiteVacationDaysRepository,
|
||||
planning_engine_service: PlanningEngineService,
|
||||
sqlite_uow: SQLiteUnitOfWork,
|
||||
) -> PlanningService:
|
||||
return PlanningService(
|
||||
planned_leave_repo,
|
||||
analytics_service,
|
||||
parental_leave_repo,
|
||||
vacation_repo,
|
||||
planning_engine_service,
|
||||
uow=sqlite_uow,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tips
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user