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
290 lines
12 KiB
Python
290 lines
12 KiB
Python
"""API-level tests for `/api/parental-leave` (manual entry, always available)
|
|
and `/api/fetch-data` (scraper session, gated by `settings.scraper_enabled`).
|
|
|
|
`git grep -n "fetch_data_service\\|FetchDataService" backend/tests/` found no
|
|
coverage for either router before this file, despite manual entry being the
|
|
primary onboarding action. Follows the `test_calendar_router.py` /
|
|
`test_leave_wish_router.py` pattern: exercise the real app + dependency
|
|
graph, not just the service, so status codes and wire-shape regressions are
|
|
caught.
|
|
|
|
`settings.scraper_enabled` defaults to `True` in `app/config.py` (the FK
|
|
legal/ToS review completed 2026-06; see the field's docstring) but a
|
|
contributor's untracked, gitignored `backend/.env` can still override it
|
|
either way -- so `settings.scraper_enabled` at *import time* is genuinely
|
|
ambient and must not be asserted on directly.
|
|
|
|
The scraper-session tests below therefore use `monkeypatch` to pin
|
|
`app.routers.fetch_data.settings.scraper_enabled` to a known value for the
|
|
duration of each test, rather than asserting on (or branching on) whatever a
|
|
contributor's local `.env` happens to set. This is deliberately more robust
|
|
than reading the live value once and assuming it holds — the disabled path
|
|
is exercised here to prove the guard still works for an environment that
|
|
opts back out (e.g. one without Playwright chromium installed); the enabled
|
|
path is exercised only far enough to prove the guard flips (`start_session`
|
|
is faked out, never a real Playwright session -- see the module docstring in
|
|
`app/scrapers/fk_scraper.py`; actually driving it here would launch a real,
|
|
non-headless browser as a fire-and-forget background task, which is unsafe
|
|
to do from a router test).
|
|
"""
|
|
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from httpx import AsyncClient
|
|
|
|
from app.routers import fetch_data as fetch_data_router
|
|
from tests.conftest import SeededIds
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def client(authed_client: AsyncClient) -> AsyncClient:
|
|
"""The shared `seeded_ids`-bound authenticated client (see
|
|
`tests/conftest.py`) — required now that the routes reject ids outside
|
|
the caller's workspace. See CLAUDE.md T1 authz work / audit §4.
|
|
"""
|
|
return authed_client
|
|
|
|
|
|
def _manual_payload(seeded_ids: SeededIds, **overrides: object) -> dict:
|
|
base: dict[str, object] = {
|
|
"personal_data_uuid": str(seeded_ids.personal_data_uuid),
|
|
"workspace_id": str(seeded_ids.workspace_id),
|
|
"child_id": str(seeded_ids.child_id),
|
|
"total_days": 480,
|
|
"days_used": 100,
|
|
"reserved_days_total": 90,
|
|
"reserved_days_left": 90,
|
|
"transferable_days_left": 150,
|
|
"dubbeldagar_left": 60,
|
|
"sjukpenning_total": 390,
|
|
"sjukpenning_used": 80,
|
|
"lagsta_niva_total": 90,
|
|
"lagsta_niva_used": 20,
|
|
}
|
|
base.update(overrides)
|
|
return base
|
|
|
|
|
|
class TestSaveManualEntry:
|
|
async def test_valid_payload_returns_201_with_persisted_shape(
|
|
self, client: AsyncClient, seeded_ids: SeededIds
|
|
) -> None:
|
|
response = await client.post("/api/parental-leave/manual", json=_manual_payload(seeded_ids))
|
|
|
|
assert response.status_code == 201
|
|
body = response.json()
|
|
assert body["source"] == "manual"
|
|
assert body["days_left"] == 380
|
|
assert body["tier_data"]["sjukpenning_left"] == 310
|
|
assert body["tier_data"]["lagsta_niva_left"] == 70
|
|
assert body["child_id"] == str(seeded_ids.child_id)
|
|
|
|
async def test_reserved_days_left_exceeding_total_returns_422(
|
|
self, client: AsyncClient, seeded_ids: SeededIds
|
|
) -> None:
|
|
response = await client.post(
|
|
"/api/parental-leave/manual",
|
|
json=_manual_payload(seeded_ids, reserved_days_total=90, reserved_days_left=91),
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
assert "reserved_days_left" in response.json()["detail"]
|
|
|
|
async def test_days_used_exceeding_total_days_returns_422(
|
|
self, client: AsyncClient, seeded_ids: SeededIds
|
|
) -> None:
|
|
response = await client.post(
|
|
"/api/parental-leave/manual",
|
|
json=_manual_payload(seeded_ids, total_days=100, days_used=150),
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
assert "days_used" in response.json()["detail"]
|
|
|
|
async def test_out_of_range_field_is_rejected_by_schema_before_the_service(
|
|
self, client: AsyncClient, seeded_ids: SeededIds
|
|
) -> None:
|
|
"""`dubbeldagar_left` is capped at 60 in `ManualFetchRequest` itself
|
|
(Pydantic, not the service) -- a value above that never reaches
|
|
`ManualFetchService.save_manual_entry` at all."""
|
|
response = await client.post(
|
|
"/api/parental-leave/manual",
|
|
json=_manual_payload(seeded_ids, dubbeldagar_left=61),
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
|
|
|
|
class TestGetLatestAndHistory:
|
|
async def test_get_latest_with_no_snapshot_returns_null(
|
|
self, client: AsyncClient, seeded_ids: SeededIds
|
|
) -> None:
|
|
response = await client.get(
|
|
f"/api/parental-leave/{seeded_ids.personal_data_uuid}/{seeded_ids.child_id}"
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json() is None
|
|
|
|
async def test_get_latest_after_manual_save_returns_the_snapshot(
|
|
self, client: AsyncClient, seeded_ids: SeededIds
|
|
) -> None:
|
|
create_response = await client.post(
|
|
"/api/parental-leave/manual", json=_manual_payload(seeded_ids)
|
|
)
|
|
assert create_response.status_code == 201
|
|
created_id = create_response.json()["id"]
|
|
|
|
response = await client.get(
|
|
f"/api/parental-leave/{seeded_ids.personal_data_uuid}/{seeded_ids.child_id}"
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["id"] == created_id
|
|
|
|
async def test_get_history_returns_all_snapshots_newest_first(
|
|
self, client: AsyncClient, seeded_ids: SeededIds
|
|
) -> None:
|
|
await client.post(
|
|
"/api/parental-leave/manual", json=_manual_payload(seeded_ids, days_used=50)
|
|
)
|
|
await client.post(
|
|
"/api/parental-leave/manual", json=_manual_payload(seeded_ids, days_used=60)
|
|
)
|
|
|
|
response = await client.get(
|
|
f"/api/parental-leave/{seeded_ids.personal_data_uuid}/{seeded_ids.child_id}/history"
|
|
)
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert len(body) == 2
|
|
assert [row["days_used"] for row in body] == [60, 50]
|
|
|
|
|
|
class TestScraperEndpointsDisabled:
|
|
"""Covers the disabled path (an environment that opts out via
|
|
`APP_SCRAPER_ENABLED=false`, e.g. one without Playwright chromium
|
|
installed) — see the module docstring for why this is pinned via
|
|
`monkeypatch` rather than asserted on ambiently.
|
|
"""
|
|
|
|
async def test_start_returns_501_when_scraper_disabled(
|
|
self, client: AsyncClient, seeded_ids: SeededIds, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(fetch_data_router.settings, "scraper_enabled", False)
|
|
response = await client.post(
|
|
"/api/fetch-data/start",
|
|
json={
|
|
"workspace_id": str(seeded_ids.workspace_id),
|
|
"personal_data_uuid": str(seeded_ids.personal_data_uuid),
|
|
"child_id": str(seeded_ids.child_id),
|
|
},
|
|
)
|
|
assert response.status_code == 501
|
|
assert "APP_SCRAPER_ENABLED" in response.json()["detail"]
|
|
|
|
async def test_poll_returns_501_when_scraper_disabled(
|
|
self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(fetch_data_router.settings, "scraper_enabled", False)
|
|
response = await client.get("/api/fetch-data/poll", params={"session_id": "does-not-exist"})
|
|
assert response.status_code == 501
|
|
|
|
async def test_cancel_returns_501_when_scraper_disabled(
|
|
self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(fetch_data_router.settings, "scraper_enabled", False)
|
|
response = await client.post(
|
|
"/api/fetch-data/cancel", json={"session_id": "does-not-exist"}
|
|
)
|
|
assert response.status_code == 501
|
|
|
|
|
|
class TestScraperEndpointsEnabled:
|
|
"""With the guard open, the routes must delegate to `_scraper_service`
|
|
and return its result -- `_scraper_service.start_session` is replaced
|
|
with a fake so no real Playwright session (a real, possibly non-headless
|
|
browser launched as a fire-and-forget background task) is ever started
|
|
from a test.
|
|
"""
|
|
|
|
async def test_start_returns_200_and_delegates_to_the_scraper_service(
|
|
self, client: AsyncClient, seeded_ids: SeededIds, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(fetch_data_router.settings, "scraper_enabled", True)
|
|
|
|
captured: dict[str, Any] = {}
|
|
|
|
async def fake_start_session(
|
|
*,
|
|
workspace_id: UUID,
|
|
personal_data_uuid: UUID,
|
|
child_id: UUID,
|
|
persist_callback: Any,
|
|
owner_user_id: UUID,
|
|
) -> str:
|
|
captured["workspace_id"] = workspace_id
|
|
captured["personal_data_uuid"] = personal_data_uuid
|
|
captured["child_id"] = child_id
|
|
captured["owner_user_id"] = owner_user_id
|
|
return "fake-session-id"
|
|
|
|
monkeypatch.setattr(fetch_data_router._scraper_service, "start_session", fake_start_session)
|
|
|
|
response = await client.post(
|
|
"/api/fetch-data/start",
|
|
json={
|
|
"workspace_id": str(seeded_ids.workspace_id),
|
|
"personal_data_uuid": str(seeded_ids.personal_data_uuid),
|
|
"child_id": str(seeded_ids.child_id),
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["session_id"] == "fake-session-id"
|
|
assert body["status"] == "launching"
|
|
assert body["workspace_id"] == str(seeded_ids.workspace_id)
|
|
assert captured["child_id"] == seeded_ids.child_id
|
|
|
|
async def test_poll_returns_200_with_the_scraper_services_result(
|
|
self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(fetch_data_router.settings, "scraper_enabled", True)
|
|
|
|
async def fake_poll_session(session_id: str, owner_user_id: UUID) -> dict:
|
|
assert session_id == "some-session-id"
|
|
return {"status": "awaiting_scan"}
|
|
|
|
monkeypatch.setattr(fetch_data_router._scraper_service, "poll_session", fake_poll_session)
|
|
|
|
response = await client.get(
|
|
"/api/fetch-data/poll", params={"session_id": "some-session-id"}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {"status": "awaiting_scan"}
|
|
|
|
async def test_cancel_returns_200_and_delegates_to_the_scraper_service(
|
|
self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(fetch_data_router.settings, "scraper_enabled", True)
|
|
|
|
cancelled: dict[str, Any] = {}
|
|
|
|
async def fake_cancel_session(session_id: str, owner_user_id: UUID) -> None:
|
|
cancelled["session_id"] = session_id
|
|
|
|
monkeypatch.setattr(
|
|
fetch_data_router._scraper_service, "cancel_session", fake_cancel_session
|
|
)
|
|
|
|
response = await client.post(
|
|
"/api/fetch-data/cancel", json={"session_id": "some-session-id"}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {"status": "cancelled"}
|
|
assert cancelled["session_id"] == "some-session-id"
|