refactor(api): Pydantic request models for setup & admin routes (R6 S1b)

- setup: SetupAdminIn (KILLS the 3rd named 500 — a non-string password reaching
  argon2/len()), SetupTestEmailIn; setup_config stays a typed dict[str, Any] (a
  genuinely dynamic key→value settings map, not a fixed schema).
- admin: AddInviteIn / SetRoleIn / SetSuspendedIn / AddDemoWhitelistIn. The
  role/status business-rule 400s are unchanged; type errors are now 422.

All 3 ROADMAP-named 500-producers now fixed (channels priority + tag_id, setup
password). Gate: ruff clean, pytest 164 green.
This commit is contained in:
2026-07-26 04:56:55 +02:00
parent 99d65cea23
commit ab83aae8bb
2 changed files with 51 additions and 17 deletions
+24 -8
View File
@@ -5,7 +5,10 @@ mutating step goes through `require_setup`: it rejects once the instance is conf
the one-time setup token (printed to the container logs at first boot, carried by the wizard as the
X-Setup-Token header). The wizard steps themselves are added in epic 6c.
"""
from typing import Any
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -45,12 +48,20 @@ def setup_info(db: Session = Depends(require_setup)) -> dict:
return {"secrets_manageable": sysconfig.secrets_manageable()}
class SetupAdminIn(BaseModel):
# Both default to "" so a missing field stays the wizard's own 400 ("Enter a valid email" /
# password too short) rather than a 422. `password: str` also stops a non-string value from
# reaching argon2 (which would 500 in len()/hash_password) — it's now a clean 422.
email: str = ""
password: str = ""
@router.post("/admin")
def setup_admin(payload: dict, db: Session = Depends(require_setup)) -> dict:
def setup_admin(body: SetupAdminIn, db: Session = Depends(require_setup)) -> dict:
"""Create (or update) the first administrator. Active + verified immediately — the operator is
configuring the instance, so there's no email-verification / approval gate here."""
email = (payload.get("email") or "").strip().lower()
password = payload.get("password") or ""
email = body.email.strip().lower()
password = body.password
if "@" not in email:
raise HTTPException(status_code=400, detail="Enter a valid email address.")
if len(password) < PASSWORD_MIN_LEN:
@@ -70,11 +81,12 @@ def setup_admin(payload: dict, db: Session = Depends(require_setup)) -> dict:
@router.post("/config")
def setup_config(payload: dict, db: Session = Depends(require_setup)) -> dict:
def setup_config(settings_map: dict[str, Any], db: Session = Depends(require_setup)) -> dict:
"""Persist provided DB-backed settings (Google creds, SMTP, quota…). Keys must be in the
sysconfig registry; empty values are skipped (left at the default)."""
sysconfig registry; empty values are skipped (left at the default). Genuinely dynamic
key→value map, so it stays a typed dict rather than a fixed model."""
saved: list[str] = []
for key, value in (payload or {}).items():
for key, value in settings_map.items():
if sysconfig.spec(key) is None:
raise HTTPException(status_code=400, detail=f"Unknown setting: {key}")
if value is None or value == "":
@@ -87,10 +99,14 @@ def setup_config(payload: dict, db: Session = Depends(require_setup)) -> dict:
return {"saved": saved}
class SetupTestEmailIn(BaseModel):
to: str = ""
@router.post("/test-email")
def setup_test_email(payload: dict, db: Session = Depends(require_setup)) -> dict:
def setup_test_email(body: SetupTestEmailIn, db: Session = Depends(require_setup)) -> dict:
"""Send a test email to confirm the SMTP settings the wizard just saved."""
to = (payload.get("to") or "").strip()
to = body.to.strip()
if "@" not in to:
raise HTTPException(status_code=400, detail="Enter a valid email address.")
if not email_mod.send_test(to):