diff --git a/README.md b/README.md index 960fabd..b4c6af1 100644 --- a/README.md +++ b/README.md @@ -128,42 +128,44 @@ This requires the API to be running locally (or set `OPENAPI_URL` to point to a ## API Endpoints +All application routes are served under the `/v1` prefix so the API surface can be versioned as a whole — when a breaking change is needed, mount the new routers under `/v2` alongside `/v1`. Infrastructure routes (`/`, `/health`, `/docs`, `/openapi.json`) stay unversioned. Routers are registered in the `ROUTERS` tuple in `app/main.py`, which loop-mounts each one with the `/v1` prefix. + ### Auth -| Method | Endpoint | Description | -| ------ | ------------------- | ----------------------------------- | -| POST | `/auth/register` | Create a new account | -| POST | `/auth/jwt/login` | Log in (sets access + refresh cookies) | -| POST | `/auth/jwt/logout` | Log out (revokes tokens, clears cookies) | -| POST | `/auth/refresh` | Rotate refresh token, reissue access token | -| GET | `/auth/me` | Get current authenticated user | +| Method | Endpoint | Description | +| ------ | ---------------------- | ------------------------------------------ | +| POST | `/v1/auth/register` | Create a new account | +| POST | `/v1/auth/jwt/login` | Log in (sets access + refresh cookies) | +| POST | `/v1/auth/jwt/logout` | Log out (revokes tokens, clears cookies) | +| POST | `/v1/auth/refresh` | Rotate refresh token, reissue access token | +| GET | `/v1/auth/me` | Get current authenticated user | ### Admin Admin endpoints require the `admin` role (superusers also have access). -| Method | Endpoint | Description | -| ------ | ------------------------------ | ------------------------ | -| PATCH | `/admin/users/{id}/role` | Update a user's role | +| Method | Endpoint | Description | +| ------ | ------------------------------ | -------------------- | +| PATCH | `/v1/admin/users/{id}/role` | Update a user's role | ### Notes (Example CRUD) All note endpoints require authentication. Users can only access their own notes. -| Method | Endpoint | Description | -| ------ | ------------- | ------------------------ | -| GET | `/notes` | List current user's notes | -| GET | `/notes/{id}` | Get a note by ID | -| POST | `/notes` | Create a note | -| PATCH | `/notes/{id}` | Update a note | -| DELETE | `/notes/{id}` | Delete a note | +| Method | Endpoint | Description | +| ------ | ---------------- | ------------------------- | +| GET | `/v1/notes` | List current user's notes | +| GET | `/v1/notes/{id}` | Get a note by ID | +| POST | `/v1/notes` | Create a note | +| PATCH | `/v1/notes/{id}` | Update a note | +| DELETE | `/v1/notes/{id}` | Delete a note | ## Authentication Authentication uses httpOnly cookies with short-lived access tokens and rotating refresh tokens. - **Access token**: 15-minute JWT stored in a `{COOKIE_PREFIX}_access` httpOnly cookie -- **Refresh token**: 7-day JWT stored in a `{COOKIE_PREFIX}_refresh` httpOnly cookie (scoped to `/auth/refresh`) +- **Refresh token**: 7-day JWT stored in a `{COOKIE_PREFIX}_refresh` httpOnly cookie (scoped to `/v1/auth/refresh` — the cookie's `path` tracks `API_V1_PREFIX` in `app/config.py`) - **Token rotation**: Each refresh issues a new token in the same family; reuse of an old token revokes the entire family (theft detection) - **Rate limiting**: Login (5/min), registration (3/min), refresh (30/min) @@ -176,7 +178,7 @@ Users have a `role` field (default: `user`). Roles are defined as a `StrEnum` in - **user** — default role for all registered users - **admin** — can access admin endpoints (e.g. updating user roles) -Superusers (`is_superuser=True`) bypass all role checks. Roles are read-only via `GET /auth/me` and can only be changed by admins via `PATCH /admin/users/{id}/role`. The `require_role()` dependency factory can be used to gate any route: +Superusers (`is_superuser=True`) bypass all role checks. Roles are read-only via `GET /v1/auth/me` and can only be changed by admins via `PATCH /v1/admin/users/{id}/role`. The `require_role()` dependency factory can be used to gate any route: ```python from app.auth import require_role @@ -219,7 +221,7 @@ Use the `get_analytics()` FastAPI dependency to access it in route handlers. Feature flags are read from `FEATURE_*` environment variables at startup (no database required). Set `FEATURE_=true` or `false` in your `.env`. -The `GET /flags` endpoint (requires authentication) returns all flags as a JSON object, consumed by the web-template's `FeatureFlagProvider`. +The `GET /v1/flags` endpoint (requires authentication) returns all flags as a JSON object, consumed by the web-template's `FeatureFlagProvider`. Use the `get_feature_flags()` dependency in route handlers to check flags server-side via `flags.is_enabled("flag_name")`. @@ -332,7 +334,7 @@ api-template/ │ │ └── user.py # User model (FastAPI-Users) │ ├── routers/ │ │ ├── admin.py # Admin endpoints (role management) -│ │ ├── auth_refresh.py # /auth/refresh and /auth/jwt/logout +│ │ ├── auth_refresh.py # /v1/auth/refresh and /v1/auth/jwt/logout │ │ └── notes.py # Notes CRUD (user-scoped) │ ├── schemas/ │ │ ├── note.py # Note request/response schemas diff --git a/app/auth/refresh.py b/app/auth/refresh.py index b900d4f..89d22db 100644 --- a/app/auth/refresh.py +++ b/app/auth/refresh.py @@ -7,11 +7,15 @@ from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings +from app.config import API_V1_PREFIX, settings from app.models.refresh_token import RefreshToken REFRESH_TOKEN_LIFETIME = timedelta(days=7) REFRESH_COOKIE_NAME = f"{settings.cookie_prefix}_refresh" +# Scoped to the refresh endpoint's URL so the browser only sends the refresh +# token there. Must track where auth_refresh_router is mounted in app/main.py +# (API_V1_PREFIX + "/auth/refresh") — a mismatch silently breaks token refresh. +REFRESH_COOKIE_PATH = f"{API_V1_PREFIX}/auth/refresh" REFRESH_AUDIENCE = ["app:refresh"] @@ -113,7 +117,7 @@ def set_refresh_cookie(response: Response, jwt: str) -> None: key=REFRESH_COOKIE_NAME, value=jwt, max_age=int(REFRESH_TOKEN_LIFETIME.total_seconds()), - path="/auth/refresh", + path=REFRESH_COOKIE_PATH, domain=settings.cookie_domain, secure=not settings.is_development, httponly=True, @@ -124,7 +128,7 @@ def set_refresh_cookie(response: Response, jwt: str) -> None: def clear_refresh_cookie(response: Response) -> None: response.delete_cookie( key=REFRESH_COOKIE_NAME, - path="/auth/refresh", + path=REFRESH_COOKIE_PATH, domain=settings.cookie_domain, secure=not settings.is_development, httponly=True, diff --git a/app/config.py b/app/config.py index f3e347e..ceb58e0 100644 --- a/app/config.py +++ b/app/config.py @@ -1,6 +1,12 @@ from pydantic import model_validator from pydantic_settings import BaseSettings, SettingsConfigDict +# Prefix under which every application route (auth + business resources) is +# mounted, so the whole API surface is versioned together. Infrastructure routes +# (/, /health, /docs, /openapi.json) stay unversioned. Not env-overridable on +# purpose — the path version is part of the code contract, not deployment config. +API_V1_PREFIX = "/v1" + class Settings(BaseSettings): """Application settings loaded from environment variables.""" diff --git a/app/main.py b/app/main.py index 1b23fd3..a02e467 100644 --- a/app/main.py +++ b/app/main.py @@ -13,7 +13,7 @@ from app.auth import auth_backend, current_active_user, fastapi_users from app.auth.security_logging import SecurityEvent, log_security_event -from app.config import settings +from app.config import API_V1_PREFIX, settings from app.features import router as features_router from app.logging import setup_logging from app.models.user import User @@ -62,32 +62,32 @@ async def limit_request_body_size(request: Request, call_next) -> Response: return await call_next(request) -# --- Auth routes --- -# Custom refresh/logout routes (included before FastAPI-Users so /auth/jwt/logout is shadowed) -app.include_router(auth_refresh_router) +# --- Auth routes (mounted under /v1) --- +# Custom refresh/logout routes (included before FastAPI-Users so /v1/auth/jwt/logout is shadowed) +app.include_router(auth_refresh_router, prefix=API_V1_PREFIX) app.include_router( fastapi_users.get_auth_router(auth_backend), - prefix="/auth/jwt", + prefix=f"{API_V1_PREFIX}/auth/jwt", tags=["auth"], ) app.include_router( fastapi_users.get_register_router(UserRead, UserCreate), - prefix="/auth", + prefix=f"{API_V1_PREFIX}/auth", tags=["auth"], ) # --- End auth routes --- -@app.get("/auth/me", response_model=UserRead, tags=["auth"]) +@app.get(f"{API_V1_PREFIX}/auth/me", response_model=UserRead, tags=["auth"]) async def get_current_user(user: User = Depends(current_active_user)): return user # Path-specific rate limits for auth endpoints _AUTH_RATE_LIMITS: dict[str, RateLimitItem] = { - "/auth/jwt/login": parse("5/minute"), - "/auth/register": parse("3/minute"), - "/auth/refresh": parse("30/minute"), + f"{API_V1_PREFIX}/auth/jwt/login": parse("5/minute"), + f"{API_V1_PREFIX}/auth/register": parse("3/minute"), + f"{API_V1_PREFIX}/auth/refresh": parse("30/minute"), } @@ -138,7 +138,7 @@ async def request_id_middleware(request: Request, call_next) -> Response: async def cache_control_middleware(request: Request, call_next) -> Response: """Set Cache-Control headers: no-store for auth paths, public caching for GETs.""" response = await call_next(request) - if request.url.path.startswith("/auth/"): + if request.url.path.startswith(f"{API_V1_PREFIX}/auth/"): response.headers["Cache-Control"] = "no-store" elif request.method == "GET" and response.status_code == 200: response.headers["Cache-Control"] = "public, max-age=3600" @@ -165,10 +165,16 @@ async def request_logging_middleware(request: Request, call_next) -> Response: return response -# API routes -app.include_router(admin_router) -app.include_router(notes_router) -app.include_router(features_router) +# Application routers, all mounted under /v1. Add new resource routers to this +# tuple — they're loop-mounted with the /v1 prefix automatically. (The auth +# routers above are mounted separately because their ordering matters.) +ROUTERS = ( + admin_router, + notes_router, + features_router, +) +for router in ROUTERS: + app.include_router(router, prefix=API_V1_PREFIX) @app.get("/docs", include_in_schema=False) diff --git a/tests/test_auth.py b/tests/test_auth.py index 3fbec13..b32408b 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -46,7 +46,7 @@ async def test_logout_revokes_token_family(self, client: AsyncClient, test_user: await session.commit() response = await client.post( - "/auth/jwt/logout", + "/v1/auth/jwt/logout", cookies={REFRESH_COOKIE_NAME: _make_refresh_jwt(test_user.id, jti, family)}, ) assert response.status_code == 204 @@ -77,7 +77,7 @@ async def test_logout_revokes_all_tokens_in_family( await session.commit() response = await client.post( - "/auth/jwt/logout", + "/v1/auth/jwt/logout", cookies={REFRESH_COOKIE_NAME: _make_refresh_jwt(test_user.id, tokens[-1].id, family)}, ) assert response.status_code == 204 @@ -95,13 +95,13 @@ async def test_logout_revokes_all_tokens_in_family( async def test_logout_with_invalid_token_returns_204(self, client: AsyncClient): """Malformed cookie: logout still succeeds (user-facing) and does not crash.""" response = await client.post( - "/auth/jwt/logout", + "/v1/auth/jwt/logout", cookies={REFRESH_COOKIE_NAME: "not-a-real-jwt"}, ) assert response.status_code == 204 async def test_logout_without_cookie_returns_204(self, client: AsyncClient): - response = await client.post("/auth/jwt/logout") + response = await client.post("/v1/auth/jwt/logout") assert response.status_code == 204 diff --git a/tests/test_notes.py b/tests/test_notes.py index 8572cb2..c51ea98 100644 --- a/tests/test_notes.py +++ b/tests/test_notes.py @@ -11,7 +11,7 @@ class TestListNotes: async def test_list_empty(self, client: AsyncClient, test_user: User): app.dependency_overrides[current_active_user] = lambda: test_user - response = await client.get("/notes") + response = await client.get("/v1/notes") assert response.status_code == 200 assert response.json() == [] @@ -21,7 +21,7 @@ async def test_list_with_data(self, client: AsyncClient, test_user: User, sessio session.add(note) await session.commit() - response = await client.get("/notes") + response = await client.get("/v1/notes") assert response.status_code == 200 data = response.json() assert len(data) == 1 @@ -36,7 +36,7 @@ async def test_user_isolation( session.add(note) await session.commit() - response = await client.get("/notes") + response = await client.get("/v1/notes") assert response.status_code == 200 assert response.json() == [] @@ -45,7 +45,7 @@ class TestCreateNote: async def test_create_success(self, client: AsyncClient, test_user: User): app.dependency_overrides[current_active_user] = lambda: test_user response = await client.post( - "/notes", + "/v1/notes", json={"title": "My note", "body": "Hello world"}, ) assert response.status_code == 201 @@ -57,13 +57,13 @@ async def test_create_success(self, client: AsyncClient, test_user: User): async def test_create_without_body(self, client: AsyncClient, test_user: User): app.dependency_overrides[current_active_user] = lambda: test_user - response = await client.post("/notes", json={"title": "Title only"}) + response = await client.post("/v1/notes", json={"title": "Title only"}) assert response.status_code == 201 assert response.json()["body"] is None async def test_create_invalid_title(self, client: AsyncClient, test_user: User): app.dependency_overrides[current_active_user] = lambda: test_user - response = await client.post("/notes", json={"title": ""}) + response = await client.post("/v1/notes", json={"title": ""}) assert response.status_code == 422 @@ -75,13 +75,13 @@ async def test_get_existing(self, client: AsyncClient, test_user: User, session) await session.commit() await session.refresh(note) - response = await client.get(f"/notes/{note.id}") + response = await client.get(f"/v1/notes/{note.id}") assert response.status_code == 200 assert response.json()["title"] == "Get me" async def test_get_not_found(self, client: AsyncClient, test_user: User): app.dependency_overrides[current_active_user] = lambda: test_user - response = await client.get(f"/notes/{uuid4()}") + response = await client.get(f"/v1/notes/{uuid4()}") assert response.status_code == 404 async def test_get_other_users_note( @@ -94,7 +94,7 @@ async def test_get_other_users_note( await session.commit() await session.refresh(note) - response = await client.get(f"/notes/{note.id}") + response = await client.get(f"/v1/notes/{note.id}") assert response.status_code == 404 @@ -106,7 +106,7 @@ async def test_update_partial(self, client: AsyncClient, test_user: User, sessio await session.commit() await session.refresh(note) - response = await client.patch(f"/notes/{note.id}", json={"title": "Updated"}) + response = await client.patch(f"/v1/notes/{note.id}", json={"title": "Updated"}) assert response.status_code == 200 data = response.json() assert data["title"] == "Updated" @@ -114,7 +114,7 @@ async def test_update_partial(self, client: AsyncClient, test_user: User, sessio async def test_update_not_found(self, client: AsyncClient, test_user: User): app.dependency_overrides[current_active_user] = lambda: test_user - response = await client.patch(f"/notes/{uuid4()}", json={"title": "Nope"}) + response = await client.patch(f"/v1/notes/{uuid4()}", json={"title": "Nope"}) assert response.status_code == 404 @@ -126,14 +126,33 @@ async def test_delete_success(self, client: AsyncClient, test_user: User, sessio await session.commit() await session.refresh(note) - response = await client.delete(f"/notes/{note.id}") + response = await client.delete(f"/v1/notes/{note.id}") assert response.status_code == 204 # Verify deleted - response = await client.get(f"/notes/{note.id}") + response = await client.get(f"/v1/notes/{note.id}") assert response.status_code == 404 async def test_delete_not_found(self, client: AsyncClient, test_user: User): app.dependency_overrides[current_active_user] = lambda: test_user - response = await client.delete(f"/notes/{uuid4()}") + response = await client.delete(f"/v1/notes/{uuid4()}") assert response.status_code == 404 + + +class TestRoutesAreVersioned: + """Guard: application routes live under /v1 only — the unversioned path 404s. + + Catches a router accidentally mounted at the root (e.g. a bare + `app.include_router(notes_router)` instead of appending to the ROUTERS tuple). + """ + + async def test_unversioned_route_not_served(self, client: AsyncClient, test_user: User): + app.dependency_overrides[current_active_user] = lambda: test_user + assert (await client.get("/notes")).status_code == 404 + # ...but the versioned path works. + assert (await client.get("/v1/notes")).status_code == 200 + + async def test_health_routes_stay_unversioned(self, client: AsyncClient): + # Infrastructure routes are deliberately not under /v1. + assert (await client.get("/health")).status_code == 200 + assert (await client.get("/v1/health")).status_code == 404 diff --git a/tests/test_roles.py b/tests/test_roles.py index 8d16dc7..e166fc3 100644 --- a/tests/test_roles.py +++ b/tests/test_roles.py @@ -20,7 +20,7 @@ async def test_admin_can_access_admin_route( await session.commit() response = await admin_client.patch( - f"/admin/users/{target.id}/role", + f"/v1/admin/users/{target.id}/role", json={"role": "admin"}, ) assert response.status_code == 200 @@ -28,7 +28,7 @@ async def test_admin_can_access_admin_route( async def test_regular_user_gets_403(self, auth_client: AsyncClient, test_user: User): """Regular users are forbidden from admin endpoints.""" response = await auth_client.patch( - f"/admin/users/{uuid4()}/role", + f"/v1/admin/users/{uuid4()}/role", json={"role": "admin"}, ) assert response.status_code == 403 @@ -45,7 +45,7 @@ async def test_superuser_bypasses_role_check( await session.commit() response = await client.patch( - f"/admin/users/{target.id}/role", + f"/v1/admin/users/{target.id}/role", json={"role": "admin"}, ) assert response.status_code == 200 @@ -54,7 +54,7 @@ async def test_superuser_bypasses_role_check( class TestRoleUpdate: - """Test the PATCH /admin/users/{user_id}/role endpoint.""" + """Test the PATCH /v1/admin/users/{user_id}/role endpoint.""" async def test_update_role_success(self, admin_client: AsyncClient, admin_user: User, session): """Successfully update a user's role.""" @@ -63,7 +63,7 @@ async def test_update_role_success(self, admin_client: AsyncClient, admin_user: await session.commit() response = await admin_client.patch( - f"/admin/users/{target.id}/role", + f"/v1/admin/users/{target.id}/role", json={"role": "admin"}, ) assert response.status_code == 200 @@ -80,7 +80,7 @@ async def test_invalid_role_returns_422( await session.commit() response = await admin_client.patch( - f"/admin/users/{target.id}/role", + f"/v1/admin/users/{target.id}/role", json={"role": "supervillain"}, ) assert response.status_code == 422 @@ -88,7 +88,7 @@ async def test_invalid_role_returns_422( async def test_user_not_found_returns_404(self, admin_client: AsyncClient, admin_user: User): """Updating a non-existent user returns 404.""" response = await admin_client.patch( - f"/admin/users/{uuid4()}/role", + f"/v1/admin/users/{uuid4()}/role", json={"role": "admin"}, ) assert response.status_code == 404 @@ -96,7 +96,7 @@ async def test_user_not_found_returns_404(self, admin_client: AsyncClient, admin class TestAuthMeIncludesRole: - """Test that /auth/me returns the role field.""" + """Test that /v1/auth/me returns the role field.""" async def test_me_returns_role_for_regular_user(self, client: AsyncClient): user = User( @@ -110,7 +110,7 @@ async def test_me_returns_role_for_regular_user(self, client: AsyncClient): ) app.dependency_overrides[current_active_user] = lambda: user try: - response = await client.get("/auth/me") + response = await client.get("/v1/auth/me") assert response.status_code == 200 data = response.json() assert data["role"] == "user" @@ -129,7 +129,7 @@ async def test_me_returns_role_for_admin(self, client: AsyncClient): ) app.dependency_overrides[current_active_user] = lambda: user try: - response = await client.get("/auth/me") + response = await client.get("/v1/auth/me") assert response.status_code == 200 data = response.json() assert data["role"] == "admin"