02db5430eb
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
244 lines
9.5 KiB
Python
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"
|