diff --git a/backend/api/auth.py b/backend/api/auth.py index 6e7f8de..16886e9 100644 --- a/backend/api/auth.py +++ b/backend/api/auth.py @@ -21,7 +21,7 @@ from __future__ import annotations import re import uuid -from typing import Optional +from typing import Literal, Optional from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from pydantic import BaseModel, EmailStr @@ -632,3 +632,53 @@ async def password_reset_confirm( # Do NOT issue tokens (AUTH-05 — user must pass TOTP gate on next login) return {"message": "Password updated. Please sign in."} + + +# ── Preferences models ──────────────────────────────────────────────────────── + +class PreferencesUpdate(BaseModel): + """Request body for PATCH /api/auth/me/preferences. + + Validates pdf_open_mode strictly via Literal (T-04-05-05 — no mass assignment). + """ + pdf_open_mode: Literal["in_app", "new_tab"] + + +# ── GET /api/auth/me/preferences ───────────────────────────────────────────── + +@router.get("/me/preferences") +async def get_my_preferences( + current_user: User = Depends(get_current_user), +): + """Return the current user's PDF open mode preference (D-10). + + Both regular users and admins can read their own preferences. + Falls back to 'in_app' if the column is absent (migration not yet run). + """ + try: + pdf_open_mode = current_user.pdf_open_mode + except AttributeError: + pdf_open_mode = "in_app" + return {"pdf_open_mode": pdf_open_mode} + + +# ── PATCH /api/auth/me/preferences ─────────────────────────────────────────── + +@router.patch("/me/preferences") +async def update_my_preferences( + body: PreferencesUpdate, + session: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Update the current user's PDF open mode preference (D-10). + + Both regular users and admins can update their own preferences. + Pydantic Literal["in_app", "new_tab"] enforces strict allowlist (T-04-05-05). + """ + user = await session.get(User, current_user.id) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + user.pdf_open_mode = body.pdf_open_mode + session.add(user) + await session.commit() + return {"pdf_open_mode": user.pdf_open_mode} diff --git a/backend/tests/test_auth_api.py b/backend/tests/test_auth_api.py index 7619baf..48cd352 100644 --- a/backend/tests/test_auth_api.py +++ b/backend/tests/test_auth_api.py @@ -423,3 +423,76 @@ async def test_login_totp_takes_precedence(authed_client, db_session: AsyncSessi await db_session.refresh(backup_code_row) assert backup_code_row.used_at is None, "Backup code should NOT have been consumed when totp_code is provided" assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# Phase 4 DOC-01 preferences endpoint tests +# --------------------------------------------------------------------------- + + +async def test_get_preferences_default(async_client, auth_user): + """GET /api/auth/me/preferences returns default pdf_open_mode='in_app'.""" + resp = await async_client.get("/api/auth/me/preferences", headers=auth_user["headers"]) + assert resp.status_code == 200 + data = resp.json() + assert "pdf_open_mode" in data + assert data["pdf_open_mode"] in ("in_app", "new_tab") # column default varies by env + + +async def test_patch_preferences_in_app(async_client, auth_user): + """PATCH /api/auth/me/preferences with pdf_open_mode='in_app' stores and returns value.""" + resp = await async_client.patch( + "/api/auth/me/preferences", + json={"pdf_open_mode": "in_app"}, + headers=auth_user["headers"], + ) + assert resp.status_code == 200 + assert resp.json()["pdf_open_mode"] == "in_app" + + +async def test_patch_preferences_new_tab(async_client, auth_user): + """PATCH /api/auth/me/preferences with pdf_open_mode='new_tab' stores and returns value.""" + resp = await async_client.patch( + "/api/auth/me/preferences", + json={"pdf_open_mode": "new_tab"}, + headers=auth_user["headers"], + ) + assert resp.status_code == 200 + assert resp.json()["pdf_open_mode"] == "new_tab" + + +async def test_patch_preferences_invalid_value(async_client, auth_user): + """PATCH /api/auth/me/preferences with invalid pdf_open_mode returns 422.""" + resp = await async_client.patch( + "/api/auth/me/preferences", + json={"pdf_open_mode": "browser"}, + headers=auth_user["headers"], + ) + assert resp.status_code == 422 + + +async def test_patch_preferences_persists(async_client, auth_user): + """PATCH then GET confirms persistence of pdf_open_mode.""" + await async_client.patch( + "/api/auth/me/preferences", + json={"pdf_open_mode": "new_tab"}, + headers=auth_user["headers"], + ) + resp = await async_client.get("/api/auth/me/preferences", headers=auth_user["headers"]) + assert resp.status_code == 200 + assert resp.json()["pdf_open_mode"] == "new_tab" + + +async def test_preferences_requires_auth(async_client): + """GET /api/auth/me/preferences returns 401 without a bearer token.""" + resp = await async_client.get("/api/auth/me/preferences") + assert resp.status_code == 401 + + +async def test_patch_preferences_requires_auth(async_client): + """PATCH /api/auth/me/preferences returns 401 without a bearer token.""" + resp = await async_client.patch( + "/api/auth/me/preferences", + json={"pdf_open_mode": "in_app"}, + ) + assert resp.status_code == 401