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:
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user