From 52b4973175c0d87b880eb6bd62afc35999b2b0c1 Mon Sep 17 00:00:00 2001 From: jableader Date: Sat, 1 Nov 2025 19:23:26 +1100 Subject: [PATCH] test(auth, routing): add logout semantics and route-surface lockdown tests; update spec --- backend-spec.md | 7 +++- tests/test_logout_v2.py | 45 +++++++++++++++++++++++ tests/test_route_surface_lockdown.py | 53 ++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 tests/test_logout_v2.py create mode 100644 tests/test_route_surface_lockdown.py diff --git a/backend-spec.md b/backend-spec.md index 5283ad4..5826044 100644 --- a/backend-spec.md +++ b/backend-spec.md @@ -146,7 +146,7 @@ Refactor the backend from a single-tenant architecture to a robust, multi-tenant - `POST /api/v1/auth/register`: `{ email, password, displayName }` → creates User + LocalCredentials; returns access token; sets HttpOnly refresh cookie. - `POST /api/v1/auth/login`: `{ email, password }` → validates; returns access token; sets HttpOnly refresh cookie. - `POST /api/v1/auth/refresh`: returns a fresh access token (reads refresh cookie). - - `POST /api/v1/auth/logout`: clears refresh cookie. + - `POST /api/v1/auth/logout`: clears refresh cookie. After logout, subsequent `POST /api/v1/auth/refresh` returns 401. Notes: - `get_current_user` (JWT) is used by protected endpoints (households, shopping purchase, etc.). Legacy `cookie_person` remains only for historical v1 module references and will be removed with full v2 completion. @@ -170,6 +170,11 @@ Shopping requests parity (preserved in v2): Repositories accept `household_id` and filter by it across `meals`, `recipes`, and `shopping` domains. +Route surface lockdown: +- Legacy unscoped endpoints (e.g., `/api/v1/meals/*`, `/api/v1/shopping/*`) are not exposed; all data routes are under `/api/v1/households/{householdSlug}/...`. +- Protected household routes return 401 without Authorization; with Authorization, non-existent households yield 404, and non-membership yields 403. +- Tests: `tests/test_route_surface_lockdown.py` asserts these behaviors. + ### 3.3. Invitation API - **Add to `api/households.py`**: diff --git a/tests/test_logout_v2.py b/tests/test_logout_v2.py new file mode 100644 index 0000000..d72f867 --- /dev/null +++ b/tests/test_logout_v2.py @@ -0,0 +1,45 @@ +import unittest +from fastapi.testclient import TestClient + +import main +from db import connect, create + + +class TestLogoutV2(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.conn = await connect(":memory:") + await create(self.conn) + + async def override_get_db(): + try: + yield self.conn + finally: + pass + + main.app.dependency_overrides[main.get_db] = override_get_db + self.client = TestClient(main.app) + + async def asyncTearDown(self): + await self.conn.close() + main.app.dependency_overrides.clear() + + def test_logout_clears_refresh_cookie(self): + # Register to receive refresh cookie and access token + r = self.client.post( + "/api/v1/auth/register", + json={"email": "logout@test.com", "password": "pw", "displayName": "User"}, + ) + assert r.status_code == 200, r.text + + # Refresh should succeed (cookie automatically sent by TestClient) + r2 = self.client.post("/api/v1/auth/refresh") + assert r2.status_code == 200, r2.text + assert "accessToken" in r2.json() + + # Call logout to clear refresh cookie + r3 = self.client.post("/api/v1/auth/logout") + assert r3.status_code == 200, r3.text + + # Subsequent refresh should fail with 401 (cookie cleared) + r4 = self.client.post("/api/v1/auth/refresh") + assert r4.status_code == 401, r4.text diff --git a/tests/test_route_surface_lockdown.py b/tests/test_route_surface_lockdown.py new file mode 100644 index 0000000..46848ad --- /dev/null +++ b/tests/test_route_surface_lockdown.py @@ -0,0 +1,53 @@ +import unittest +from fastapi.testclient import TestClient + +import main +from db import connect, create + + +class TestRouteSurfaceLockdown(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.conn = await connect(":memory:") + await create(self.conn) + + async def override_get_db(): + try: + yield self.conn + finally: + pass + + main.app.dependency_overrides[main.get_db] = override_get_db + self.client = TestClient(main.app) + + # Register a user to obtain auth header for positive checks + r = self.client.post( + "/api/v1/auth/register", + json={"email": "lock@test.com", "password": "pw", "displayName": "Lock"}, + ) + assert r.status_code == 200, r.text + self.auth_headers = {"Authorization": f"Bearer {r.json()['accessToken']}"} + + async def asyncTearDown(self): + await self.conn.close() + main.app.dependency_overrides.clear() + + def test_unscoped_endpoints_not_exposed(self): + # These v1-style unscoped paths should not exist + for path in [ + "/api/v1/meals", + "/api/v1/meals/1", + "/api/v1/shopping/current", + "/api/v1/shopping/1", + ]: + r = self.client.get(path) + assert r.status_code in (404, 405) + + def test_protected_routes_require_auth(self): + # Household routes require Authorization (401 when missing) + # Use a slug that won't exist; should return 401 before 404 membership/not found checks + r = self.client.get("/api/v1/households/nope/whoami") + assert r.status_code == 401 + + # With auth, not found or forbidden are possible; whoami returns 404 for missing household + r2 = self.client.get("/api/v1/households/nope/whoami", headers=self.auth_headers) + assert r2.status_code == 404