Close the residual gaps the 2026-09-18 audit left open

Re-checked every item of the audit's own outcome section against the tree.
Most held, but five items marked fixed had residual gaps — one of them a
live IDOR — and the deferred list was untouched.

Security:
- Analytics IDOR: /api/analytics/{ws}/overview and /projection took
  child_id as an unchecked query param, and get_overview called
  get_days_left_at_age_4 without workspace_id, so the ownership guard
  never ran. Any member could read another workspace's child projection.
  Fixed at both layers.
- Scraper: session ids no longer logged at INFO; a persist-callback
  failure's raw exception text and the FK maintenance banner's scraped
  text no longer reach the poll client.
- Email is now case-normalised at the API boundary, with migration 009
  lowercasing existing rows (aborts naming colliding rows rather than
  failing opaquely on the UNIQUE constraint).

Frontend correctness:
- usePlanProposal/usePlanSync gained the stale-response guard the other
  hooks already had.
- PlanSyncProvider no longer fires POST /plan/sync before auth resolves
  off a blindly-read localStorage workspaceId.
- Busy flags are cleared unconditionally across all 16 useLatestRequest
  consumers. setBusy(true) was unconditional while the clear was guarded
  by isCurrent, so a superseded call stranded its flag forever — under
  StrictMode this hung every in-app navigation behind the PageSkeleton.
  isCurrent decides which response's data wins, never whether a call
  releases the flag it raised.

Performance:
- Calendar no longer blanks on a mutation, and a multi-day save issues N
  writes plus one background refetch instead of 2N requests.
- take_pl_on_red_days moved onto GET /auth/context, dropping a redundant
  GET /workspace/:id from every cold load.

CI and repo hygiene: least-privilege permissions block, actions pinned to
commit SHAs, npm ci, uv sync --locked, failure-only artifact upload,
dependabot; dead frontend/out.js removed; CSP added to the nginx template
(repeated into the location blocks that set their own add_header, since
nginx does not merge them across levels).

Left deferred by decision: refresh-token revocation, the phase-2 contract
change, M4, M5, L2, and H12's cross-route cache. Details and the smaller
follow-ups browser verification turned up are in the audit report.

Backend 901 passed, coverage 92%. Frontend 1322 tests, coverage
98.17/94.79/92.14/98.17.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PngVF38MLpdkgXcByKfdW6
This commit is contained in:
2026-09-21 06:16:23 +02:00
parent fc90cf9a5f
commit 88197060de
44 changed files with 1401 additions and 218 deletions
+15 -1
View File
@@ -1,6 +1,6 @@
"""Pydantic schemas for auth endpoints."""
from pydantic import BaseModel, EmailStr, Field
from pydantic import BaseModel, EmailStr, Field, field_validator
class RegisterRequest(BaseModel):
@@ -13,11 +13,25 @@ class RegisterRequest(BaseModel):
# if one exists) instead of creating a new one.
invite_token: str | None = None
@field_validator("email")
@classmethod
def normalize_email(cls, v: str) -> str:
"""Lowercase at the API boundary — storage/lookup is case-sensitive
(`WHERE email = ?`), so Foo@x.se and foo@x.se must resolve to one
account rather than silently becoming two."""
return v.lower()
class LoginRequest(BaseModel):
email: EmailStr
password: str
@field_validator("email")
@classmethod
def normalize_email(cls, v: str) -> str:
"""Lowercase at the API boundary — see `RegisterRequest.normalize_email`."""
return v.lower()
class TokenResponse(BaseModel):
access_token: str
+4
View File
@@ -44,7 +44,9 @@ async def get_overview(
pd_repo: PersonalDataRepositoryDep,
workspace_repo: WorkspaceRepositoryDep,
vacation_repo: VacationDaysRepositoryDep,
scope: WorkspaceScopeDep,
) -> WorkspaceOverviewResponse:
await scope.check_child(child_id)
service = _make_service(
leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo
)
@@ -65,7 +67,9 @@ async def get_projection(
pd_repo: PersonalDataRepositoryDep,
workspace_repo: WorkspaceRepositoryDep,
vacation_repo: VacationDaysRepositoryDep,
scope: WorkspaceScopeDep,
) -> WorkspaceOverviewResponse:
await scope.check_child(child_id)
service = _make_service(
leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo
)
+11
View File
@@ -10,6 +10,7 @@ from app.dependencies import (
ChildRepositoryDep,
CurrentUserDep,
PersonalDataRepositoryDep,
WorkspaceRepositoryDep,
)
from app.rate_limit import limiter
from app.models.auth import LoginRequest, RegisterRequest, TokenResponse, UserMeResponse
@@ -105,6 +106,11 @@ class _UserContext(BaseModel):
workspace_id: str
parent_name: str
children: list[_ChildContext]
# Mirrors `WorkspaceResponse.take_pl_on_red_days` — included here so the
# frontend's cold-load `refreshContext()` can build its `workspace`
# state from this response alone, without a follow-up `GET
# /workspace/:id` (see AGENTS.md H12 step 1).
take_pl_on_red_days: bool
@router.get("/context", response_model=_UserContext)
@@ -112,12 +118,16 @@ async def get_context(
current_user: CurrentUserDep,
pd_repo: PersonalDataRepositoryDep,
child_repo: ChildRepositoryDep,
ws_repo: WorkspaceRepositoryDep,
) -> _UserContext:
"""Return workspace + children for the current user. Used by the dev harness."""
pd = await pd_repo.get_by_id(current_user.personal_data_uuid)
if pd is None:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "No parent profile found")
children = await child_repo.list_by_workspace(pd.workspace_id)
workspace = await ws_repo.get_by_id(pd.workspace_id)
if workspace is None:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "No workspace found")
return _UserContext(
personal_data_uuid=str(pd.uuid),
workspace_id=str(pd.workspace_id),
@@ -125,6 +135,7 @@ async def get_context(
children=[
_ChildContext(id=str(c.id), name=c.name, birth_date=str(c.birth_date)) for c in children
],
take_pl_on_red_days=workspace.take_pl_on_red_days,
)
+35 -27
View File
@@ -306,7 +306,7 @@ class FKScraperService(FetchDataService):
# Schedule TTL watchdog
asyncio.create_task(_ttl_watchdog(session_id))
logger.info("scraper session started [id=%s]", session_id)
logger.debug("scraper session started [id=%s]", session_id)
return session_id
async def poll_session(self, session_id: str, owner_user_id: UUID) -> dict:
@@ -345,7 +345,7 @@ class FKScraperService(FetchDataService):
state.qr_data = None
if state._task and not state._task.done():
state._task.cancel()
logger.info("scraper session cancelled [id=%s]", session_id)
logger.debug("scraper session cancelled [id=%s]", session_id)
# ---------------------------------------------------------------------------
@@ -390,7 +390,7 @@ async def _ttl_watchdog(session_id: str) -> None:
state.qr_data = None
if state._task and not state._task.done():
state._task.cancel()
logger.info("scraper session timed out [id=%s]", session_id)
logger.debug("scraper session timed out [id=%s]", session_id)
elif state.status in ("done", "error", "cancelled"):
_sessions.pop(session_id, None)
@@ -451,13 +451,13 @@ async def _save_debug_artifacts(page, session_id: str, console_messages: list[st
screenshot_path = prefix.with_suffix(".png")
await page.screenshot(path=str(screenshot_path), full_page=True)
logger.info(
logger.debug(
"scraper debug artifacts saved [id=%s] dir=%s",
session_id,
out.resolve(),
)
except Exception as artifact_exc:
logger.warning("failed to save debug artifacts [id=%s]: %s", session_id, artifact_exc)
logger.warning("failed to save debug artifacts: %s", artifact_exc)
async def _run_session(state: _SessionState) -> None:
@@ -509,9 +509,9 @@ async def _do_login(page, state: _SessionState) -> None:
# Check for maintenance message before proceeding
maintenance_msg = await _check_maintenance(page)
if maintenance_msg:
error_msg = f"FK is under maintenance: {maintenance_msg}"
_set_status(state, "error", error_msg)
logger.info("[%s] FK maintenance detected; aborting session", state.session_id)
_set_status(state, "error", "FK is currently under maintenance. Please try again later.")
logger.debug("[%s] FK maintenance banner text: %s", state.session_id, maintenance_msg)
logger.info("FK maintenance detected; aborting session")
return
# Accept cookie consent banner if present (only appears once per browser profile)
@@ -533,7 +533,7 @@ async def _do_login(page, state: _SessionState) -> None:
if is_disabled:
error_msg = "FK login temporarily unavailable (BankID button disabled). Please try again later."
_set_status(state, "error", error_msg)
logger.info("[%s] BankID button is disabled; aborting session", state.session_id)
logger.info("BankID button is disabled; aborting session")
return
except Exception:
pass # proceed anyway; may not be disabled
@@ -627,7 +627,9 @@ async def _do_extract(page, state: _SessionState) -> None:
if family is not None:
state.partner_deduction = _deduce_partner(data, family)
except Exception as exc:
logger.info("[%s] family-panel deduction skipped: %s", state.session_id, type(exc).__name__)
logger.debug(
"[%s] family-panel deduction skipped: %s", state.session_id, type(exc).__name__
)
state.family = None
state.partner_deduction = None
@@ -645,7 +647,7 @@ async def _do_extract(page, state: _SessionState) -> None:
state.partner_deduction, transferable_days_left=transferable.partner_total
)
except Exception as exc:
logger.info(
logger.debug(
"[%s] transferable-days parse skipped: %s", state.session_id, type(exc).__name__
)
@@ -656,14 +658,20 @@ async def _do_extract(page, state: _SessionState) -> None:
_set_status(
state,
"error",
f"Scrape succeeded but failed to persist data: {type(exc).__name__}: {exc}",
"Scrape succeeded but failed to persist the data. Please try again.",
)
logger.error(
"persist error [id=%s]: %s: %s",
state.session_id,
type(exc).__name__,
exc,
exc_info=False,
)
logger.error("persist error [id=%s]: %s", state.session_id, exc, exc_info=False)
return
_set_status(state, "done")
state.result = data
logger.info("scraper session done [id=%s]", state.session_id)
logger.info("scraper session done")
async def _extract_parental_leave(page, state: _SessionState) -> ParentalLeaveFetch:
@@ -746,19 +754,19 @@ async def _extract_family_summary(page, state: _SessionState) -> FamilySummary |
Defensive by design: the real FK DOM for this panel is unconfirmed (see
`_SEL.FAMILY*`'s comment), so any missing element or unexpected text is
treated as "no family data available" — logged at INFO and returned as
treated as "no family data available" — logged at DEBUG and returned as
None — never raised. The caller's own `data` (this parent's snapshot)
must succeed regardless of what happens here.
"""
try:
family_panel = page.locator(_SEL.FAMILY)
if await family_panel.count() == 0:
logger.info("[%s] family panel not present; skipping deduction", state.session_id)
logger.debug("[%s] family panel not present; skipping deduction", state.session_id)
return None
dagar_kvar_loc = page.locator(_SEL.FAMILY_DAGAR_KVAR)
if await dagar_kvar_loc.count() == 0:
logger.info(
logger.debug(
"[%s] family panel dagar-kvar element not found; skipping deduction",
state.session_id,
)
@@ -768,7 +776,7 @@ async def _extract_family_summary(page, state: _SessionState) -> FamilySummary |
discrete_loc = page.locator(_SEL.FAMILY_DISCRETE)
if await discrete_loc.count() == 0:
logger.info(
logger.debug(
"[%s] family panel discrete element not found; skipping deduction",
state.session_id,
)
@@ -776,7 +784,7 @@ async def _extract_family_summary(page, state: _SessionState) -> FamilySummary |
discrete_text = await discrete_loc.first.inner_text(timeout=2_000)
total_match = re.search(r"av\s+(\d+)\s+dagar", discrete_text)
if not total_match:
logger.info(
logger.debug(
"[%s] could not parse 'av N dagar' from family panel text; skipping deduction",
state.session_id,
)
@@ -806,7 +814,7 @@ async def _extract_family_summary(page, state: _SessionState) -> FamilySummary |
lagsta_niva_left=lagsta_niva_left,
)
except Exception as exc:
logger.info(
logger.debug(
"[%s] family panel parsing failed (%s); skipping deduction",
state.session_id,
type(exc).__name__,
@@ -894,7 +902,7 @@ async def _extract_transferable_days(
Fully defensive by design, mirroring `_extract_family_summary`: any
missing element, unmatched child, or unexpected text is "no transferable
data available" — logged at INFO and returned as None — never raised.
data available" — logged at DEBUG and returned as None — never raised.
The caller's own `data` (already validated) must be unaffected either way.
"""
try:
@@ -924,7 +932,7 @@ async def _extract_transferable_days(
sections = page.locator(_SEL.MIN_FP_BARN_SEKTION)
section_count = await sections.count()
if section_count == 0:
logger.info(
logger.debug(
"[%s] no barn-sektion found on min-foraldrapenning page; skipping "
"transferable-days parse",
state.session_id,
@@ -935,7 +943,7 @@ async def _extract_transferable_days(
chosen = sections.first
else:
if not child_name or not child_name.strip():
logger.info(
logger.debug(
"[%s] multiple children on min-foraldrapenning page and no "
"child_name to match; skipping transferable-days parse",
state.session_id,
@@ -953,7 +961,7 @@ async def _extract_transferable_days(
chosen = section
break
if chosen is None:
logger.info(
logger.debug(
"[%s] no barn-sektion matched child_name; skipping transferable-days parse",
state.session_id,
)
@@ -961,7 +969,7 @@ async def _extract_transferable_days(
panel = chosen.locator(_SEL.PANEL_FORA_OVER_DAGAR)
if await panel.count() == 0:
logger.info(
logger.debug(
"[%s] chosen barn-sektion has no transfer panel; skipping transferable-days parse",
state.session_id,
)
@@ -1014,7 +1022,7 @@ async def _extract_transferable_days(
partner_sjuk, partner_lagsta = sjuk, lagsta
if mine_sjuk is None or mine_lagsta is None:
logger.info(
logger.debug(
"[%s] 'Du kan föra över' group missing or incomplete; skipping "
"transferable-days parse",
state.session_id,
@@ -1028,7 +1036,7 @@ async def _extract_transferable_days(
partner_lagsta_niva=partner_lagsta,
)
except Exception as exc:
logger.info(
logger.debug(
"[%s] transferable-days panel parsing failed (%s); skipping",
state.session_id,
type(exc).__name__,
+1 -1
View File
@@ -552,7 +552,7 @@ class AnalyticsService:
projections = []
for pd in members:
proj = await self.get_days_left_at_age_4(pd.uuid, child_id)
proj = await self.get_days_left_at_age_4(pd.uuid, child_id, workspace_id=workspace_id)
projections.append(proj)
combined_used = sum(p.days_used + p.planned_days_before_b4 for p in projections)
@@ -0,0 +1,78 @@
"""Lowercase existing "user".email values.
Email lookup/storage is case-sensitive (`SQLiteUserRepository.get_by_email`
runs a bare `WHERE email = ?`), so `Foo@x.se` and `foo@x.se` used to register
as two distinct accounts. The API boundary now lowercases incoming email on
every register/login request (`app.models.auth.RegisterRequest`/
`LoginRequest`, `normalize_email` field validators), so all NEW rows are
already lowercase; this migration normalizes rows written before that fix.
If two existing rows would collapse to the same lowercased address (a
genuine pre-existing duplicate-account situation), blindly lowercasing both
would hit the `email` column's UNIQUE constraint and abort with an opaque
integrity error on whichever row happens to update second. Instead,
`upgrade()` scans for any such collision FIRST and raises a clear,
actionable `RuntimeError` naming the colliding rows before writing anything,
so an operator can merge or rename one of each colliding pair by hand and
re-run the migration, rather than facing a half-applied state.
`downgrade()` cannot restore the original casing (it was never recorded) —
it is a deliberate, documented no-op. Lowercasing is a one-way normalization.
Revision ID: 009
Revises: 008
Create Date: 2026-09-20
"""
from __future__ import annotations
from typing import Any
from alembic import op
revision: str = "009"
down_revision: str | None = "008"
branch_labels: str | None = None
depends_on: str | None = None
def _collect_rows(conn: Any) -> list[Any]:
from sqlalchemy import text
return conn.execute(text('SELECT id, email FROM "user"')).fetchall()
def upgrade() -> None:
from sqlalchemy import text
conn = op.get_bind()
rows = _collect_rows(conn)
by_lower: dict[str, list[tuple[str, str]]] = {}
for row in rows:
by_lower.setdefault(row.email.lower(), []).append((str(row.id), row.email))
collisions = {lower: entries for lower, entries in by_lower.items() if len(entries) > 1}
if collisions:
detail = "; ".join(
f"{lower!r} <- {[f'{id_} ({email})' for id_, email in entries]}"
for lower, entries in collisions.items()
)
raise RuntimeError(
'Cannot lowercase "user".email: the following addresses would collide '
f"under case-insensitive comparison and must be resolved by hand first "
f"(merge or rename one account in each pair, then re-run this migration): "
f"{detail}"
)
for lower, entries in by_lower.items():
(id_, email) = entries[0]
if email != lower:
conn.execute(
text('UPDATE "user" SET email = :email WHERE id = :id'),
{"email": lower, "id": id_},
)
def downgrade() -> None:
"""No-op: the original casing was never recorded, so it cannot be restored."""
+132
View File
@@ -0,0 +1,132 @@
"""R4: email case-normalisation at the auth API boundary.
Storage/lookup (`SQLiteUserRepository.get_by_email`) is case-sensitive, so
without normalisation `Foo@x.se` and `foo@x.se` would register as two
distinct accounts. `RegisterRequest`/`LoginRequest` (`app/models/auth.py`)
now lowercase `email` via a field validator — these tests drive the real
app over HTTP (same technique as `tests/integration/test_user_journey.py`)
to prove that boundary actually normalises, rather than only unit-testing
the validator in isolation.
"""
from __future__ import annotations
import aiosqlite
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from app.dependencies import get_connection
from app.main import app
from app.rate_limit import limiter
_PASSWORD = "correct horse battery staple"
@pytest.fixture(autouse=True)
def _reset_rate_limiter() -> None:
"""`/api/auth/register` and `/api/auth/login` are limited to 10/minute,
keyed on client IP (`app/rate_limit.py`). In the full suite, every test
file that POSTs to either endpoint shares that one in-memory bucket for
127.0.0.1 (see `tests/integration/test_user_journey.py`'s comment on the
same issue) — by the time this file runs, other files may have already
spent it. Reset the limiter's storage before each test here rather than
disabling enforcement, so these tests still exercise the real
middleware stack; this is exactly `Limiter.reset()`'s documented purpose.
"""
limiter.reset()
@pytest_asyncio.fixture
async def client(connection: aiosqlite.Connection) -> AsyncClient:
"""Real app over HTTP, with `get_connection` swapped for the in-memory
test connection — see `tests/integration/test_user_journey.py`'s fixture
of the same name."""
app.dependency_overrides[get_connection] = lambda: connection
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
yield ac
app.dependency_overrides.pop(get_connection, None)
async def test_register_lowercases_email(client: AsyncClient) -> None:
resp = await client.post(
"/api/auth/register",
json={"email": "Mixed.Case@Example.COM", "password": _PASSWORD, "display_name": "A"},
)
assert resp.status_code == 201, resp.text
async def test_login_with_different_case_resolves_to_same_account(client: AsyncClient) -> None:
register_resp = await client.post(
"/api/auth/register",
json={"email": "Case.Test@Example.com", "password": _PASSWORD, "display_name": "A"},
)
assert register_resp.status_code == 201, register_resp.text
login_resp = await client.post(
"/api/auth/login",
json={"email": "CASE.TEST@EXAMPLE.COM", "password": _PASSWORD},
)
assert login_resp.status_code == 200, login_resp.text
assert "access_token" in login_resp.json()
async def test_registering_same_address_in_different_case_is_rejected_as_duplicate(
client: AsyncClient,
) -> None:
first = await client.post(
"/api/auth/register",
json={"email": "dupe@example.com", "password": _PASSWORD, "display_name": "First"},
)
assert first.status_code == 201, first.text
second = await client.post(
"/api/auth/register",
json={"email": "DUPE@EXAMPLE.COM", "password": _PASSWORD, "display_name": "Second"},
)
assert second.status_code == 409, second.text
async def test_context_includes_take_pl_on_red_days_default_false(client: AsyncClient) -> None:
"""H12 step 1: `/auth/context` must carry the same red-days flag
`GET /workspace/:id` does, so the frontend can drop that second cold-load
call. A freshly registered workspace defaults to `False`."""
register_resp = await client.post(
"/api/auth/register",
json={"email": "red-days@example.com", "password": _PASSWORD, "display_name": "A"},
)
assert register_resp.status_code == 201, register_resp.text
headers = {"Authorization": f"Bearer {register_resp.json()['access_token']}"}
ctx_resp = await client.get("/api/auth/context", headers=headers)
assert ctx_resp.status_code == 200, ctx_resp.text
assert ctx_resp.json()["take_pl_on_red_days"] is False
async def test_context_reflects_take_pl_on_red_days_after_workspace_update(
client: AsyncClient,
) -> None:
"""Once the workspace's flag is flipped via `PUT /workspace/:id`, the
context endpoint must reflect that new value, not a stale default —
this is what `Settings.tsx`'s red-days toggle relies on after it saves."""
register_resp = await client.post(
"/api/auth/register",
json={"email": "red-days-2@example.com", "password": _PASSWORD, "display_name": "A"},
)
assert register_resp.status_code == 201, register_resp.text
headers = {"Authorization": f"Bearer {register_resp.json()['access_token']}"}
ctx_resp = await client.get("/api/auth/context", headers=headers)
assert ctx_resp.status_code == 200, ctx_resp.text
workspace_id = ctx_resp.json()["workspace_id"]
update_resp = await client.put(
f"/api/workspace/{workspace_id}",
headers=headers,
json={"take_pl_on_red_days": True},
)
assert update_resp.status_code == 200, update_resp.text
ctx_resp_2 = await client.get("/api/auth/context", headers=headers)
assert ctx_resp_2.status_code == 200, ctx_resp_2.text
assert ctx_resp_2.json()["take_pl_on_red_days"] is True
+23
View File
@@ -268,6 +268,29 @@ class TestForeignWorkspaceIdIs404:
)
assert resp.status_code == 404
async def test_analytics_overview_foreign_child_in_own_workspace(
self, client: AsyncClient, two_workspaces: _TwoWorkspaces
) -> None:
"""R1: `workspace_id` is the caller's own (workspace A), but
`child_id` belongs to workspace B — the IDOR the router-level
`scope.check_child` guard closes (see `analytics.get_overview`).
"""
resp = await client.get(
f"/api/analytics/{two_workspaces.ws_a.id}/overview",
params={"child_id": str(two_workspaces.child_b.id)},
)
assert resp.status_code == 404
async def test_analytics_projection_foreign_child_in_own_workspace(
self, client: AsyncClient, two_workspaces: _TwoWorkspaces
) -> None:
"""Same IDOR as above, on the sibling `/projection` route."""
resp = await client.get(
f"/api/analytics/{two_workspaces.ws_a.id}/projection",
params={"child_id": str(two_workspaces.child_b.id)},
)
assert resp.status_code == 404
async def test_analytics_age4_foreign_parent(
self, client: AsyncClient, two_workspaces: _TwoWorkspaces
) -> None:
+42
View File
@@ -31,6 +31,7 @@ from app.scrapers.fk_scraper import (
_apply_transferable,
_deduce_partner,
_do_extract,
_do_login,
_extract_family_summary,
_extract_transferable_days,
_SEL,
@@ -1218,6 +1219,45 @@ class TestDoExtractTransferableIntegration:
assert state.result.transferable_days_left == 49 # unchanged from the original estimate
# ---------------------------------------------------------------------------
# Maintenance detection — client-facing message must not echo scraped text
# ---------------------------------------------------------------------------
class TestMaintenanceDetection:
"""R3: `_do_login`'s maintenance branch must return a fixed message —
never the scraped FK banner text — to the poll client."""
async def test_maintenance_banner_text_not_leaked_to_client(self) -> None:
sid = str(uuid.uuid4())
state = _SessionState(
session_id=sid,
workspace_id=uuid.uuid4(),
personal_data_uuid=uuid.uuid4(),
child_id=uuid.uuid4(),
owner_user_id=uuid.uuid4(),
)
page = MagicMock()
page.goto = AsyncMock()
page.url = "https://www.forsakringskassan.se/privatperson/logga-in"
sensitive_banner = (
"Planerat underhall pagar for personer med personnummer som slutar pa "
"1-5, tjansten ar otillganglig till kl 23."
)
with patch(
"app.scrapers.fk_scraper._check_maintenance", new_callable=AsyncMock
) as mock_check:
mock_check.return_value = sensitive_banner
await _do_login(page, state)
assert state.status == "error"
assert state.message is not None
assert sensitive_banner not in state.message
assert "maintenance" in state.message.lower()
# ---------------------------------------------------------------------------
# Session state machine
# ---------------------------------------------------------------------------
@@ -1706,6 +1746,8 @@ class TestSessionStateMachine:
assert state.status == "error"
assert "persist" in (state.message or "").lower()
# R2/R3: the raw exception text must never reach the poll client.
assert "DB unavailable" not in (state.message or "")
async def test_do_extract_waits_for_hydration_before_extracting(self) -> None:
"""_do_extract calls wait_for_function to ensure data hydrates before extraction."""