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:
+51
-1
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user