88197060de
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
708 lines
28 KiB
Python
708 lines
28 KiB
Python
"""Cross-cutting authorization tests (T1 authz audit).
|
|
|
|
Two things every route in the ten scoped routers must satisfy (see
|
|
`app/dependencies.py`'s `WorkspaceScope`/`WorkspaceIdDep` and the audit at
|
|
`graphify`-adjacent scratch notes for the full route table):
|
|
|
|
1. No `Authorization` header -> 401 (F1: `plan`, `leave_wishes`,
|
|
`day_overrides`, `vacation_days` had NO auth at all before this task).
|
|
2. A real id belonging to a DIFFERENT workspace -> 404, never 403 (F3: IDOR
|
|
on every workspace-/child-/pd-keyed route) — a 403 would confirm the id
|
|
exists, which is itself information disclosure.
|
|
|
|
This file exercises the real app + dependency graph (httpx ASGITransport),
|
|
following the pattern in `tests/unit/test_plan_router.py`, rather than only
|
|
the service layer — the bug class here is specifically about what the
|
|
ROUTER does (or fails to do) before calling the service.
|
|
"""
|
|
|
|
from datetime import date, datetime, timedelta
|
|
from uuid import uuid4
|
|
|
|
import aiosqlite
|
|
import pytest_asyncio
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from app.dependencies import get_connection, get_current_user
|
|
from app.entities.child import Child
|
|
from app.entities.day_override import DayOverride
|
|
from app.entities.leave_wish import LeaveWish
|
|
from app.entities.personal_data import PersonalData
|
|
from app.entities.planned_leave import PlannedLeaveDay
|
|
from app.entities.user import User
|
|
from app.entities.vacation_days import VacationDays
|
|
from app.entities.workspace import Workspace
|
|
from app.entities.workspace_invitation import WorkspaceInvitation
|
|
from app.main import app
|
|
from app.repositories_impl.sqlite.child_repository import SQLiteChildRepository
|
|
from app.repositories_impl.sqlite.day_override_repository import SQLiteDayOverrideRepository
|
|
from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository
|
|
from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository
|
|
from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository
|
|
from app.repositories_impl.sqlite.user_repository import SQLiteUserRepository
|
|
from app.repositories_impl.sqlite.vacation_days_repository import SQLiteVacationDaysRepository
|
|
from app.repositories_impl.sqlite.workspace_repository import (
|
|
SQLiteWorkspaceInvitationRepository,
|
|
SQLiteWorkspaceRepository,
|
|
)
|
|
|
|
# The caller's fixed identity across this file's authenticated tests: a
|
|
# member of "workspace A" (seeded per-test by `two_workspaces`).
|
|
_CALLER_PD_UUID = uuid4()
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def anon_client(connection: aiosqlite.Connection) -> AsyncClient:
|
|
"""No `get_current_user` override at all — a real, unauthenticated
|
|
request (no Authorization header) must be rejected by the real JWT
|
|
dependency, not by a test double standing in for it.
|
|
"""
|
|
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)
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def client(connection: aiosqlite.Connection) -> AsyncClient:
|
|
"""Authenticated as `_CALLER_PD_UUID` — a member of whatever workspace
|
|
the test seeds for them (see `two_workspaces`).
|
|
"""
|
|
app.dependency_overrides[get_connection] = lambda: connection
|
|
app.dependency_overrides[get_current_user] = lambda: User(
|
|
id=uuid4(),
|
|
personal_data_uuid=_CALLER_PD_UUID,
|
|
email="caller@example.com",
|
|
hashed_password="not-a-real-hash",
|
|
created_at=datetime.now(),
|
|
)
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
|
yield ac
|
|
app.dependency_overrides.pop(get_connection, None)
|
|
app.dependency_overrides.pop(get_current_user, None)
|
|
|
|
|
|
class _TwoWorkspaces:
|
|
def __init__(
|
|
self,
|
|
ws_a: Workspace,
|
|
ws_b: Workspace,
|
|
child_a: Child,
|
|
child_b: Child,
|
|
pd_b: PersonalData,
|
|
) -> None:
|
|
self.ws_a = ws_a
|
|
self.ws_b = ws_b
|
|
self.child_a = child_a
|
|
self.child_b = child_b
|
|
self.pd_b = pd_b
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def two_workspaces(connection: aiosqlite.Connection) -> _TwoWorkspaces:
|
|
"""Seed workspace A (the caller's own) and workspace B (a foreign
|
|
tenant), each with its own child and member.
|
|
"""
|
|
now = datetime.now()
|
|
ws_repo = SQLiteWorkspaceRepository(connection)
|
|
pd_repo = SQLitePersonalDataRepository(connection)
|
|
child_repo = SQLiteChildRepository(connection)
|
|
|
|
ws_a = await ws_repo.create(Workspace(id=uuid4(), name="Workspace A", created_at=now))
|
|
await pd_repo.create(
|
|
PersonalData(uuid=_CALLER_PD_UUID, workspace_id=ws_a.id, name="Caller", created_at=now)
|
|
)
|
|
child_a = await child_repo.create(
|
|
Child(id=uuid4(), workspace_id=ws_a.id, name="Child A", birth_date=date(2025, 1, 1))
|
|
)
|
|
|
|
ws_b = await ws_repo.create(Workspace(id=uuid4(), name="Workspace B", created_at=now))
|
|
pd_b = await pd_repo.create(
|
|
PersonalData(uuid=uuid4(), workspace_id=ws_b.id, name="Victim", created_at=now)
|
|
)
|
|
child_b = await child_repo.create(
|
|
Child(id=uuid4(), workspace_id=ws_b.id, name="Child B", birth_date=date(2025, 1, 1))
|
|
)
|
|
|
|
return _TwoWorkspaces(ws_a, ws_b, child_a, child_b, pd_b)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# F1 — routes that had NO authentication at all before this task.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestNoTokenIsUnauthenticated:
|
|
async def test_plan_propose_requires_auth(self, anon_client: AsyncClient) -> None:
|
|
resp = await anon_client.post("/api/plan/propose", json={"workspace_id": str(uuid4())})
|
|
assert resp.status_code == 401
|
|
|
|
async def test_plan_apply_requires_auth(self, anon_client: AsyncClient) -> None:
|
|
resp = await anon_client.post(
|
|
"/api/plan/apply", json={"workspace_id": str(uuid4()), "days": []}
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
async def test_plan_sync_requires_auth(self, anon_client: AsyncClient) -> None:
|
|
resp = await anon_client.post("/api/plan/sync", json={"workspace_id": str(uuid4())})
|
|
assert resp.status_code == 401
|
|
|
|
async def test_leave_wishes_create_requires_auth(self, anon_client: AsyncClient) -> None:
|
|
resp = await anon_client.post(
|
|
"/api/leave-wishes",
|
|
json={
|
|
"workspace_id": str(uuid4()),
|
|
"name": "X",
|
|
"window_start_month": 1,
|
|
"window_start_day": 1,
|
|
"window_end_month": 1,
|
|
"window_end_day": 7,
|
|
"target_weeks": 1,
|
|
"first_year": 2030,
|
|
},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
async def test_leave_wishes_list_requires_auth(self, anon_client: AsyncClient) -> None:
|
|
resp = await anon_client.get(f"/api/leave-wishes/{uuid4()}")
|
|
assert resp.status_code == 401
|
|
|
|
async def test_day_overrides_create_requires_auth(self, anon_client: AsyncClient) -> None:
|
|
resp = await anon_client.post(
|
|
"/api/day-overrides",
|
|
json={"workspace_id": str(uuid4()), "date": "2030-01-01", "is_working_day": False},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
async def test_day_overrides_list_requires_auth(self, anon_client: AsyncClient) -> None:
|
|
resp = await anon_client.get(f"/api/day-overrides/workspace/{uuid4()}")
|
|
assert resp.status_code == 401
|
|
|
|
async def test_vacation_days_create_requires_auth(self, anon_client: AsyncClient) -> None:
|
|
resp = await anon_client.post(
|
|
"/api/vacation-days",
|
|
json={
|
|
"personal_data_uuid": str(uuid4()),
|
|
"workspace_id": str(uuid4()),
|
|
"year": 2030,
|
|
"total_days": 25,
|
|
},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
async def test_vacation_days_list_requires_auth(self, anon_client: AsyncClient) -> None:
|
|
resp = await anon_client.get(f"/api/vacation-days/workspace/{uuid4()}")
|
|
assert resp.status_code == 401
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# F3 — a real id from a DIFFERENT workspace must 404, never 403.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestForeignWorkspaceIdIs404:
|
|
async def test_workspace_get(self, client: AsyncClient, two_workspaces: _TwoWorkspaces) -> None:
|
|
resp = await client.get(f"/api/workspace/{two_workspaces.ws_b.id}")
|
|
assert resp.status_code == 404
|
|
|
|
async def test_workspace_members(
|
|
self, client: AsyncClient, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
resp = await client.get(f"/api/workspace/{two_workspaces.ws_b.id}/members")
|
|
assert resp.status_code == 404
|
|
|
|
async def test_tips_get(self, client: AsyncClient, two_workspaces: _TwoWorkspaces) -> None:
|
|
resp = await client.get(f"/api/tips/{two_workspaces.ws_b.id}")
|
|
assert resp.status_code == 404
|
|
|
|
async def test_children_by_workspace(
|
|
self, client: AsyncClient, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
resp = await client.get(f"/api/children/workspace/{two_workspaces.ws_b.id}")
|
|
assert resp.status_code == 404
|
|
|
|
async def test_children_get_foreign_child(
|
|
self, client: AsyncClient, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
resp = await client.get(f"/api/children/{two_workspaces.child_b.id}")
|
|
assert resp.status_code == 404
|
|
|
|
async def test_settings_update_foreign_child(
|
|
self, client: AsyncClient, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
resp = await client.put(
|
|
f"/api/settings/child/{two_workspaces.child_b.id}", json={"name": "Hijacked"}
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
async def test_settings_delete_foreign_child(
|
|
self, client: AsyncClient, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
resp = await client.delete(f"/api/settings/{two_workspaces.child_b.id}")
|
|
assert resp.status_code == 404
|
|
|
|
async def test_settings_update_foreign_parent(
|
|
self, client: AsyncClient, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
resp = await client.put(
|
|
f"/api/settings/parent/{two_workspaces.pd_b.uuid}",
|
|
json={"employment_status": "none"},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
async def test_calendar_days_foreign_workspace(
|
|
self, client: AsyncClient, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
resp = await client.get(
|
|
"/api/calendar/days",
|
|
params={"workspace_id": str(two_workspaces.ws_b.id), "start_year": 2030},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
async def test_analytics_overview_foreign_workspace(
|
|
self, client: AsyncClient, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
resp = await client.get(
|
|
f"/api/analytics/{two_workspaces.ws_b.id}/overview",
|
|
params={"child_id": str(two_workspaces.child_b.id)},
|
|
)
|
|
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:
|
|
resp = await client.get(
|
|
f"/api/analytics/parent/{two_workspaces.pd_b.uuid}"
|
|
f"/child/{two_workspaces.child_b.id}/age4"
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
async def test_plan_propose_foreign_workspace(
|
|
self, client: AsyncClient, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
resp = await client.post(
|
|
"/api/plan/propose", json={"workspace_id": str(two_workspaces.ws_b.id)}
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
async def test_plan_apply_foreign_child(
|
|
self, client: AsyncClient, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
resp = await client.post(
|
|
"/api/plan/apply",
|
|
json={
|
|
"workspace_id": str(two_workspaces.ws_a.id),
|
|
"days": [
|
|
{
|
|
"date": "2030-06-01",
|
|
"parent_personal_data_uuid": str(_CALLER_PD_UUID),
|
|
"child_id": str(two_workspaces.child_b.id),
|
|
"day_type": "vacation",
|
|
}
|
|
],
|
|
},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
async def test_plan_sync_foreign_workspace(
|
|
self, client: AsyncClient, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
resp = await client.post(
|
|
"/api/plan/sync", json={"workspace_id": str(two_workspaces.ws_b.id)}
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
async def test_leave_wishes_create_foreign_workspace(
|
|
self, client: AsyncClient, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
resp = await client.post(
|
|
"/api/leave-wishes",
|
|
json={
|
|
"workspace_id": str(two_workspaces.ws_b.id),
|
|
"name": "X",
|
|
"window_start_month": 1,
|
|
"window_start_day": 1,
|
|
"window_end_month": 1,
|
|
"window_end_day": 7,
|
|
"target_weeks": 1,
|
|
"first_year": 2030,
|
|
},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
async def test_leave_wishes_create_foreign_child(
|
|
self, client: AsyncClient, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
resp = await client.post(
|
|
"/api/leave-wishes",
|
|
json={
|
|
"workspace_id": str(two_workspaces.ws_a.id),
|
|
"child_id": str(two_workspaces.child_b.id),
|
|
"name": "X",
|
|
"window_start_month": 1,
|
|
"window_start_day": 1,
|
|
"window_end_month": 1,
|
|
"window_end_day": 7,
|
|
"target_weeks": 1,
|
|
"first_year": 2030,
|
|
},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
async def test_leave_wishes_list_foreign_workspace(
|
|
self, client: AsyncClient, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
resp = await client.get(f"/api/leave-wishes/{two_workspaces.ws_b.id}")
|
|
assert resp.status_code == 404
|
|
|
|
async def test_leave_wishes_update_foreign_wish(
|
|
self, client: AsyncClient, connection: aiosqlite.Connection, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
wish_repo = SQLiteLeaveWishRepository(connection)
|
|
wish = await wish_repo.create(
|
|
LeaveWish(
|
|
id=uuid4(),
|
|
workspace_id=two_workspaces.ws_b.id,
|
|
child_id=None,
|
|
name="Foreign",
|
|
window_start_month=1,
|
|
window_start_day=1,
|
|
window_end_month=1,
|
|
window_end_day=7,
|
|
target_weeks=1,
|
|
first_year=2030,
|
|
last_year=None,
|
|
priority=1,
|
|
parent_mode="either",
|
|
parent_personal_data_uuid=None,
|
|
)
|
|
)
|
|
resp = await client.put(f"/api/leave-wishes/{wish.id}", json={"name": "Hijacked"})
|
|
assert resp.status_code == 404
|
|
|
|
async def test_leave_wishes_delete_foreign_wish(
|
|
self, client: AsyncClient, connection: aiosqlite.Connection, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
wish_repo = SQLiteLeaveWishRepository(connection)
|
|
wish = await wish_repo.create(
|
|
LeaveWish(
|
|
id=uuid4(),
|
|
workspace_id=two_workspaces.ws_b.id,
|
|
child_id=None,
|
|
name="Foreign",
|
|
window_start_month=1,
|
|
window_start_day=1,
|
|
window_end_month=1,
|
|
window_end_day=7,
|
|
target_weeks=1,
|
|
first_year=2030,
|
|
last_year=None,
|
|
priority=1,
|
|
parent_mode="either",
|
|
parent_personal_data_uuid=None,
|
|
)
|
|
)
|
|
resp = await client.delete(f"/api/leave-wishes/{wish.id}")
|
|
assert resp.status_code == 404
|
|
|
|
async def test_day_overrides_create_foreign_workspace(
|
|
self, client: AsyncClient, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
resp = await client.post(
|
|
"/api/day-overrides",
|
|
json={
|
|
"workspace_id": str(two_workspaces.ws_b.id),
|
|
"date": "2030-01-01",
|
|
"is_working_day": False,
|
|
},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
async def test_day_overrides_get_foreign(
|
|
self, client: AsyncClient, connection: aiosqlite.Connection, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
repo = SQLiteDayOverrideRepository(connection)
|
|
row = await repo.create(
|
|
DayOverride(
|
|
id=uuid4(),
|
|
workspace_id=two_workspaces.ws_b.id,
|
|
date=date(2030, 1, 1),
|
|
is_working_day=False,
|
|
)
|
|
)
|
|
resp = await client.get(f"/api/day-overrides/{row.id}")
|
|
assert resp.status_code == 404
|
|
|
|
async def test_day_overrides_list_foreign_workspace(
|
|
self, client: AsyncClient, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
resp = await client.get(f"/api/day-overrides/workspace/{two_workspaces.ws_b.id}")
|
|
assert resp.status_code == 404
|
|
|
|
async def test_day_overrides_delete_foreign(
|
|
self, client: AsyncClient, connection: aiosqlite.Connection, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
repo = SQLiteDayOverrideRepository(connection)
|
|
row = await repo.create(
|
|
DayOverride(
|
|
id=uuid4(),
|
|
workspace_id=two_workspaces.ws_b.id,
|
|
date=date(2030, 1, 2),
|
|
is_working_day=False,
|
|
)
|
|
)
|
|
resp = await client.delete(f"/api/day-overrides/{row.id}")
|
|
assert resp.status_code == 404
|
|
|
|
async def test_vacation_days_create_foreign_workspace(
|
|
self, client: AsyncClient, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
resp = await client.post(
|
|
"/api/vacation-days",
|
|
json={
|
|
"personal_data_uuid": str(_CALLER_PD_UUID),
|
|
"workspace_id": str(two_workspaces.ws_b.id),
|
|
"year": 2030,
|
|
"total_days": 25,
|
|
},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
async def test_vacation_days_create_foreign_member(
|
|
self, client: AsyncClient, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
resp = await client.post(
|
|
"/api/vacation-days",
|
|
json={
|
|
"personal_data_uuid": str(two_workspaces.pd_b.uuid),
|
|
"workspace_id": str(two_workspaces.ws_a.id),
|
|
"year": 2030,
|
|
"total_days": 25,
|
|
},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
async def test_vacation_days_get_foreign(
|
|
self, client: AsyncClient, connection: aiosqlite.Connection, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
repo = SQLiteVacationDaysRepository(connection)
|
|
row = await repo.create(
|
|
VacationDays(
|
|
id=uuid4(),
|
|
personal_data_uuid=two_workspaces.pd_b.uuid,
|
|
workspace_id=two_workspaces.ws_b.id,
|
|
year=2030,
|
|
total_days=25,
|
|
)
|
|
)
|
|
resp = await client.get(f"/api/vacation-days/{row.id}")
|
|
assert resp.status_code == 404
|
|
|
|
async def test_vacation_days_list_foreign_workspace(
|
|
self, client: AsyncClient, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
resp = await client.get(f"/api/vacation-days/workspace/{two_workspaces.ws_b.id}")
|
|
assert resp.status_code == 404
|
|
|
|
async def test_planned_days_create_foreign_child(
|
|
self, client: AsyncClient, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
resp = await client.post(
|
|
"/api/planned-days",
|
|
json={
|
|
"workspace_id": str(two_workspaces.ws_a.id),
|
|
"child_id": str(two_workspaces.child_b.id),
|
|
"parent_personal_data_uuid": str(_CALLER_PD_UUID),
|
|
"date": "2030-06-01",
|
|
"day_type": "vacation",
|
|
},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
async def test_planned_days_list_by_child_foreign(
|
|
self, client: AsyncClient, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
resp = await client.get(
|
|
"/api/planned-days", params={"child_id": str(two_workspaces.child_b.id)}
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
async def test_planned_days_get_foreign(
|
|
self, client: AsyncClient, connection: aiosqlite.Connection, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
repo = SQLitePlannedLeaveRepository(connection)
|
|
row = await repo.create(
|
|
PlannedLeaveDay(
|
|
id=uuid4(),
|
|
workspace_id=two_workspaces.ws_b.id,
|
|
child_id=two_workspaces.child_b.id,
|
|
parent_personal_data_uuid=two_workspaces.pd_b.uuid,
|
|
date=date(2030, 6, 1),
|
|
day_type="vacation",
|
|
)
|
|
)
|
|
resp = await client.get(f"/api/planned-days/{row.id}")
|
|
assert resp.status_code == 404
|
|
|
|
async def test_planned_days_by_workspace_foreign(
|
|
self, client: AsyncClient, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
resp = await client.get(f"/api/planned-days/workspace/{two_workspaces.ws_b.id}")
|
|
assert resp.status_code == 404
|
|
|
|
async def test_planned_days_update_foreign(
|
|
self, client: AsyncClient, connection: aiosqlite.Connection, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
repo = SQLitePlannedLeaveRepository(connection)
|
|
row = await repo.create(
|
|
PlannedLeaveDay(
|
|
id=uuid4(),
|
|
workspace_id=two_workspaces.ws_b.id,
|
|
child_id=two_workspaces.child_b.id,
|
|
parent_personal_data_uuid=two_workspaces.pd_b.uuid,
|
|
date=date(2030, 6, 2),
|
|
day_type="vacation",
|
|
)
|
|
)
|
|
resp = await client.put(f"/api/planned-days/{row.id}", json={"day_type": "sjukpenning"})
|
|
assert resp.status_code == 404
|
|
|
|
async def test_planned_days_delete_foreign(
|
|
self, client: AsyncClient, connection: aiosqlite.Connection, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
repo = SQLitePlannedLeaveRepository(connection)
|
|
row = await repo.create(
|
|
PlannedLeaveDay(
|
|
id=uuid4(),
|
|
workspace_id=two_workspaces.ws_b.id,
|
|
child_id=two_workspaces.child_b.id,
|
|
parent_personal_data_uuid=two_workspaces.pd_b.uuid,
|
|
date=date(2030, 6, 3),
|
|
day_type="vacation",
|
|
)
|
|
)
|
|
resp = await client.delete(f"/api/planned-days/{row.id}")
|
|
assert resp.status_code == 404
|
|
|
|
async def test_fetch_data_manual_foreign_workspace(
|
|
self, client: AsyncClient, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
resp = await client.post(
|
|
"/api/parental-leave/manual",
|
|
json={
|
|
"personal_data_uuid": str(_CALLER_PD_UUID),
|
|
"workspace_id": str(two_workspaces.ws_b.id),
|
|
"child_id": str(two_workspaces.child_a.id),
|
|
"total_days": 480,
|
|
"sjukpenning_left": 390,
|
|
"lagsta_niva_left": 90,
|
|
"dubbeldagar_left": 60,
|
|
"reserved_days_left": 90,
|
|
},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
async def test_fetch_data_get_latest_foreign_pd(
|
|
self, client: AsyncClient, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
resp = await client.get(
|
|
f"/api/parental-leave/{two_workspaces.pd_b.uuid}/{two_workspaces.child_a.id}"
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
async def test_invitations_revoke_foreign_workspace(
|
|
self, client: AsyncClient, connection: aiosqlite.Connection, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
"""The invitation belongs to workspace B; the caller (workspace A)
|
|
must not be able to revoke it, even by guessing/enumerating its id.
|
|
"""
|
|
# `invited_by_user_id` FKs to a real `user` row.
|
|
user_repo = SQLiteUserRepository(connection)
|
|
inviter = await user_repo.create(
|
|
User(
|
|
id=uuid4(),
|
|
personal_data_uuid=two_workspaces.pd_b.uuid,
|
|
email="inviter-b@example.com",
|
|
hashed_password="not-a-real-hash",
|
|
created_at=datetime.now(),
|
|
)
|
|
)
|
|
inv_repo = SQLiteWorkspaceInvitationRepository(connection)
|
|
inv = await inv_repo.create(
|
|
WorkspaceInvitation(
|
|
id=uuid4(),
|
|
workspace_id=two_workspaces.ws_b.id,
|
|
token_hash="deadbeef",
|
|
invited_by_user_id=inviter.id,
|
|
status="pending",
|
|
expires_at=datetime.now() + timedelta(hours=1),
|
|
created_at=datetime.now(),
|
|
)
|
|
)
|
|
resp = await client.delete(f"/api/invitations/{inv.id}")
|
|
assert resp.status_code == 404
|
|
|
|
async def test_list_invitations_foreign_workspace(
|
|
self, client: AsyncClient, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
"""`GET /{workspace_id}/invitations` is the only way to discover an
|
|
id for `DELETE /api/invitations/{id}` -- it must not leak workspace
|
|
B's invitations (or their existence) to a caller in workspace A."""
|
|
resp = await client.get(f"/api/workspace/{two_workspaces.ws_b.id}/invitations")
|
|
assert resp.status_code == 404
|
|
|
|
async def test_list_invitations_never_returns_token_hash(
|
|
self, client: AsyncClient, connection: aiosqlite.Connection, two_workspaces: _TwoWorkspaces
|
|
) -> None:
|
|
user_repo = SQLiteUserRepository(connection)
|
|
inviter = await user_repo.create(
|
|
User(
|
|
id=uuid4(),
|
|
personal_data_uuid=_CALLER_PD_UUID,
|
|
email="inviter-a@example.com",
|
|
hashed_password="not-a-real-hash",
|
|
created_at=datetime.now(),
|
|
)
|
|
)
|
|
inv_repo = SQLiteWorkspaceInvitationRepository(connection)
|
|
await inv_repo.create(
|
|
WorkspaceInvitation(
|
|
id=uuid4(),
|
|
workspace_id=two_workspaces.ws_a.id,
|
|
token_hash="super-secret-hash",
|
|
invited_by_user_id=inviter.id,
|
|
status="pending",
|
|
expires_at=datetime.now() + timedelta(hours=1),
|
|
created_at=datetime.now(),
|
|
)
|
|
)
|
|
resp = await client.get(f"/api/workspace/{two_workspaces.ws_a.id}/invitations")
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert len(body) == 1
|
|
assert "token_hash" not in body[0]
|
|
assert "super-secret-hash" not in resp.text
|