feat(phase-4-05): PATCH /api/auth/me/preferences for pdf_open_mode (DOC-01)

- Add PreferencesUpdate Pydantic model with Literal['in_app', 'new_tab'] validation
- Add GET /api/auth/me/preferences — returns current pdf_open_mode
- Add PATCH /api/auth/me/preferences — validates + stores + returns updated value
- Both endpoints use get_current_user (admin can set own prefs, D-10)
- Add 7 preference tests: default GET, in_app, new_tab, invalid 422, persist,
  and two unauthenticated 401 tests
This commit is contained in:
curo1305
2026-05-25 18:50:52 +02:00
parent f89f787656
commit 2a0df32e92
2 changed files with 124 additions and 1 deletions
+51 -1
View File
@@ -21,7 +21,7 @@ from __future__ import annotations
import re import re
import uuid import uuid
from typing import Optional from typing import Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from pydantic import BaseModel, EmailStr 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) # Do NOT issue tokens (AUTH-05 — user must pass TOTP gate on next login)
return {"message": "Password updated. Please sign in."} 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}
+73
View File
@@ -423,3 +423,76 @@ async def test_login_totp_takes_precedence(authed_client, db_session: AsyncSessi
await db_session.refresh(backup_code_row) 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 backup_code_row.used_at is None, "Backup code should NOT have been consumed when totp_code is provided"
assert resp.status_code == 401 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