Merge branch 'worktree-check-security-audit'

Security and frontend-correctness hardening: analytics IDOR fix, scraper
log scrubbing, lowercase email normalisation (migration 009), stale-response
guards in the plan hooks, busy flags cleared unconditionally across all
useLatestRequest consumers, Calendar no longer blanks on mutation, plus
dependabot/CI config and removal of the stray frontend/out.js.

Conflicts came from main's later "day assertions" restructure, which made
usePlannedDays read-only and replaced the calendar's per-day add/update with
useDayAssertions.assertDay. Both intents kept:

- usePlannedDays: branch's fetch machinery (isRefreshing, in-flight dedup,
  unguarded busy clear); mutators dropped as on main.
- usePlanSync: branch's useLatestRequest guard also applied to main's new
  syncWithWish.
- useDayAssertions / useLatestBalance (new on main): busy flag now cleared
  unconditionally, as the branch did for every other consumer.
- useDayAssertions.assertDay gains the branch's `skipRefresh` option and a
  `refresh`, so Calendar's multi-day save keeps the batch shape (N writes,
  one trailing bumpPlanVersion + one assertions refresh) on main's
  assertDay path. Branch's M7 tests ported from the removed add/update API
  to assertDay.
- PlanSyncProvider: branch's isAuthenticated gate kept; eviction-preview
  state stays removed as on main.
- usePlannedDayExclusions.ts stays deleted (superseded by useDayAssertions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
This commit is contained in:
2026-09-21 07:40:09 +02:00
47 changed files with 1271 additions and 190 deletions
+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
@@ -285,6 +285,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."""