Files
F-r-ldraledigplannerare/backend/tests/unit/test_auth_router.py
T
jacobh2 88197060de 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
2026-09-21 06:16:23 +02:00

133 lines
5.3 KiB
Python

"""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