Files
F-r-ldraledigplannerare/backend/tests/unit/test_db_unit_of_work.py
T
jacobh2 02db5430eb 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
2026-09-18 07:54:21 +02:00

244 lines
9.5 KiB
Python

"""Tests for the unit-of-work mechanism (`app.db.connection`) and the
batched `PlannedLeaveRepository` methods it exists to support.
See `scratchpad/audit/backend-perf.md` §3: the app runs on one shared
connection, so a multi-statement write needs (a) mutual exclusion for the
whole unit of work and (b) the per-row `commit()` calls suppressed while one
is in progress. These tests exercise that mechanism directly against an
in-memory connection, plus the `PlanningService.apply_proposed_days`
atomicity it is meant to guarantee end-to-end.
"""
import asyncio
from datetime import date, datetime
from uuid import uuid4
import aiosqlite
import pytest
from app.db.connection import commit, ensure_schema, unit_of_work
from app.entities.leave_wish import LeaveWish
from app.entities.planned_leave import PlannedLeaveDay
from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository
from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository
from app.services.planning_service import PlanningService, ProposedDayInput
from tests.conftest import SeededIds
@pytest.fixture
async def uow_connection() -> aiosqlite.Connection:
conn = await aiosqlite.connect(":memory:")
conn.row_factory = aiosqlite.Row
await conn.execute("PRAGMA foreign_keys = ON;")
await ensure_schema(conn)
try:
yield conn
finally:
await conn.close()
async def _workspace_count(conn: aiosqlite.Connection) -> int:
cursor = await conn.execute("SELECT COUNT(*) FROM workspace")
row = await cursor.fetchone()
return int(row[0])
async def _insert_workspace(conn: aiosqlite.Connection, workspace_id: str) -> None:
await conn.execute(
"INSERT INTO workspace (id, name, planning_mode, created_at) VALUES (?, ?, ?, ?)",
(workspace_id, "Test", "solo", datetime.now().isoformat()),
)
class TestUnitOfWork:
async def test_commits_on_success(self, uow_connection: aiosqlite.Connection) -> None:
async with unit_of_work(uow_connection):
await _insert_workspace(uow_connection, "ws-commit")
assert await _workspace_count(uow_connection) == 1
async def test_rolls_back_on_exception(self, uow_connection: aiosqlite.Connection) -> None:
with pytest.raises(ValueError):
async with unit_of_work(uow_connection):
await _insert_workspace(uow_connection, "ws-rollback")
raise ValueError("simulated failure")
assert await _workspace_count(uow_connection) == 0
async def test_commit_helper_is_noop_inside_uow(
self, uow_connection: aiosqlite.Connection
) -> None:
"""`commit()` must not durably commit inside a unit of work — otherwise
a later rollback in the same block could only undo writes made after
that no-op `commit()` call, not before it (see backend-perf audit §3,
"Is BEGIN/COMMIT ... safe on the shared connection? No, not alone.").
"""
with pytest.raises(RuntimeError):
async with unit_of_work(uow_connection):
await _insert_workspace(uow_connection, "ws-noop-commit")
await commit(uow_connection) # must be a no-op here
raise RuntimeError("still rolls back everything, including the row above")
assert await _workspace_count(uow_connection) == 0
async def test_concurrent_calls_serialize(self, uow_connection: aiosqlite.Connection) -> None:
"""Two concurrent `unit_of_work()` blocks on the same connection never
interleave — the second blocks at entry until the first fully exits.
"""
events: list[str] = []
async def worker(name: str, hold: float) -> None:
async with unit_of_work(uow_connection):
events.append(f"{name}-start")
await asyncio.sleep(hold)
events.append(f"{name}-end")
await asyncio.gather(worker("a", 0.05), worker("b", 0))
assert events in (
["a-start", "a-end", "b-start", "b-end"],
["b-start", "b-end", "a-start", "a-end"],
)
class TestPlannedLeaveRepositoryBatchMethods:
async def test_create_many_then_delete_many(
self,
planned_leave_repo: SQLitePlannedLeaveRepository,
seeded_ids: SeededIds,
) -> None:
now = datetime.now()
entities = [
PlannedLeaveDay(
id=uuid4(),
workspace_id=seeded_ids.workspace_id,
child_id=seeded_ids.child_id,
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
date=date(2026, 3, day),
day_type="sjukpenning",
created_at=now,
updated_at=now,
)
for day in (1, 2, 3)
]
await planned_leave_repo.create_many(entities)
rows = await planned_leave_repo.list(workspace_id=seeded_ids.workspace_id)
assert len(rows) == 3
deleted = await planned_leave_repo.delete_many([entities[0].id, entities[1].id])
assert deleted == 2
remaining = await planned_leave_repo.list(workspace_id=seeded_ids.workspace_id)
assert [r.id for r in remaining] == [entities[2].id]
async def test_create_many_empty_list_is_noop(
self,
planned_leave_repo: SQLitePlannedLeaveRepository,
seeded_ids: SeededIds,
) -> None:
await planned_leave_repo.create_many([])
assert await planned_leave_repo.list(workspace_id=seeded_ids.workspace_id) == []
async def test_delete_many_is_idempotent(
self, planned_leave_repo: SQLitePlannedLeaveRepository
) -> None:
"""Unlike `delete()`, a missing id is silently ignored rather than raising."""
deleted = await planned_leave_repo.delete_many([uuid4(), uuid4()])
assert deleted == 0
async def test_update_metadata_many(
self,
planned_leave_repo: SQLitePlannedLeaveRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
now = datetime.now()
entity = PlannedLeaveDay(
id=uuid4(),
workspace_id=seeded_ids.workspace_id,
child_id=seeded_ids.child_id,
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
date=date(2026, 4, 1),
day_type="vacation",
created_at=now,
updated_at=now,
)
await planned_leave_repo.create(entity)
wish = await leave_wish_repo.create(
LeaveWish(
id=uuid4(),
workspace_id=seeded_ids.workspace_id,
child_id=seeded_ids.child_id,
name="Test wish",
window_start_month=4,
window_start_day=1,
window_end_month=4,
window_end_day=30,
target_weeks=1,
recurrence="once",
first_year=2026,
last_year=None,
priority=1,
parent_mode="either",
parent_personal_data_uuid=None,
window_mode="fixed",
created_at=now,
updated_at=now,
)
)
await planned_leave_repo.update_metadata_many([(entity.id, wish.id, True)])
refreshed = await planned_leave_repo.get_by_id(entity.id)
assert refreshed is not None
assert refreshed.wish_id == wish.id
assert refreshed.is_pinned is True
# day_type/is_dubbeldag are untouched by the metadata-only update.
assert refreshed.day_type == "vacation"
class TestApplyProposedDaysAtomicity:
async def test_mid_batch_failure_leaves_nothing_persisted(
self,
planning_service_with_uow: PlanningService,
planned_leave_repo: SQLitePlannedLeaveRepository,
seeded_ids: SeededIds,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A batch that replaces an existing row (delete + create, both inside
stage 4) must not leave the delete applied if the create half fails —
the whole `async with self._uow():` block must roll back together.
Uses `planning_service_with_uow` (a real `SQLiteUnitOfWork`), not the
default `planning_service` fixture, which wires a no-op unit of work
and would (correctly, given a no-op) leave the delete committed.
"""
existing = await planning_service_with_uow.add_planned_day(
workspace_id=seeded_ids.workspace_id,
child_id=seeded_ids.child_id,
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
date=date(2026, 5, 1),
day_type="sjukpenning",
)
async def boom(_items: list[PlannedLeaveDay]) -> None:
raise RuntimeError("simulated mid-batch failure")
monkeypatch.setattr(planned_leave_repo, "create_many", boom)
days = [
ProposedDayInput(
date=date(2026, 5, 1),
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
child_id=seeded_ids.child_id,
day_type="lagsta", # differs from the existing row -> collision -> to_replace
)
]
with pytest.raises(RuntimeError):
await planning_service_with_uow.apply_proposed_days(
workspace_id=seeded_ids.workspace_id, days=days, replace=True
)
remaining = await planned_leave_repo.list(workspace_id=seeded_ids.workspace_id)
assert len(remaining) == 1
assert remaining[0].id == existing.id
assert remaining[0].day_type == "sjukpenning"