feat: wire ProfileUpdate validation into manage profile route

This commit is contained in:
Johan Lundberg 2026-03-10 14:01:47 +01:00
parent 3cbf7cda5f
commit 5fd63d61ff
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1
2 changed files with 173 additions and 30 deletions

View file

@ -1,12 +1,13 @@
from base64 import urlsafe_b64decode from base64 import urlsafe_b64decode
from urllib.parse import urlparse
from fastapi import APIRouter, Form, Request, Response from fastapi import APIRouter, Form, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fido2.webauthn import PublicKeyCredentialDescriptor, PublicKeyCredentialType from fido2.webauthn import PublicKeyCredentialDescriptor, PublicKeyCredentialType
from pydantic import ValidationError
from porchlight.dependencies import get_session_user from porchlight.dependencies import get_session_user
from porchlight.models import PasswordCredential, WebAuthnCredential from porchlight.models import PasswordCredential, WebAuthnCredential
from porchlight.validation import ProfileUpdate
router = APIRouter(prefix="/manage", tags=["manage"]) router = APIRouter(prefix="/manage", tags=["manage"])
@ -213,41 +214,51 @@ async def update_profile(
userid, _username = session_user userid, _username = session_user
# Validate field lengths try:
for field_name, value, max_len in [ profile = ProfileUpdate(
("Given name", given_name, 255), given_name=given_name,
("Family name", family_name, 255), family_name=family_name,
("Display name", preferred_username, 255), preferred_username=preferred_username,
("Email", email, 255), email=email,
("Phone number", phone_number, 50), phone_number=phone_number,
("Picture URL", picture, 2048), picture=picture,
("Locale", locale, 20), locale=locale,
]: )
if len(value) > max_len: except ValidationError as exc:
return HTMLResponse(f'<div role="alert">{field_name} is too long</div>') error = exc.errors()[0]
field = error["loc"][-1] if error["loc"] else "input"
# Validate email format msg = error["msg"]
if email and "@" not in email: # Produce user-friendly field labels
return HTMLResponse('<div role="alert">Invalid email address</div>') labels = {
"given_name": "Given name",
# Validate picture URL format "family_name": "Family name",
if picture: "preferred_username": "Display name",
parsed = urlparse(picture) "email": "Email",
if parsed.scheme not in ("http", "https") or not parsed.netloc: "phone_number": "Phone number",
return HTMLResponse('<div role="alert">Picture URL must be a valid HTTP or HTTPS URL</div>') "picture": "Picture URL",
"locale": "Locale",
}
label = labels.get(str(field), str(field))
# Use custom message for value errors (e.g. picture URL), generic pydantic message otherwise
if error["type"] == "value_error":
# Strip Pydantic's "Value error, " prefix to get the original message
display_msg = msg.removeprefix("Value error, ")
else:
display_msg = f"{label}: {msg}"
return HTMLResponse(f'<div role="alert">{display_msg}</div>')
user_repo = request.app.state.user_repo user_repo = request.app.state.user_repo
user = await user_repo.get_by_userid(userid) user = await user_repo.get_by_userid(userid)
updated = user.model_copy( updated = user.model_copy(
update={ update={
"given_name": given_name or None, "given_name": profile.given_name or None,
"family_name": family_name or None, "family_name": profile.family_name or None,
"preferred_username": preferred_username or None, "preferred_username": profile.preferred_username or None,
"email": email or None, "email": profile.email,
"phone_number": phone_number or None, "phone_number": profile.phone_number,
"picture": picture or None, "picture": profile.picture,
"locale": locale or None, "locale": profile.locale or None,
} }
) )
await user_repo.update(updated) await user_repo.update(updated)

View file

@ -0,0 +1,132 @@
from datetime import UTC, datetime
from argon2 import PasswordHasher
from httpx import AsyncClient
from porchlight.authn.password import PasswordService
from porchlight.models import PasswordCredential, User
from tests.conftest import get_csrf_token
async def _login(client: AsyncClient, username: str = "alice", userid: str = "user-01") -> None:
app = client._transport.app # type: ignore[union-attr]
user_repo = app.state.user_repo
cred_repo = app.state.credential_repo
user = await user_repo.get_by_username(username)
if user is None:
user = User(
userid=userid,
username=username,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
await user_repo.create(user)
svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192))
existing = await cred_repo.get_password_by_user(user.userid)
if existing is None:
await cred_repo.create_password(PasswordCredential(user_id=user.userid, password_hash=svc.hash("password")))
token = await get_csrf_token(client)
await client.post(
"/login/password",
data={"username": username, "password": "password"},
headers={"HX-Request": "true", "X-CSRF-Token": token},
)
async def test_update_profile_success(client: AsyncClient) -> None:
await _login(client)
token = await get_csrf_token(client)
response = await client.post(
"/manage/profile",
data={
"given_name": "Alice",
"family_name": "Smith",
"preferred_username": "ally",
"email": "alice@example.com",
"phone_number": "+46701234567",
"picture": "https://example.com/alice.jpg",
"locale": "en",
},
headers={"X-CSRF-Token": token},
)
assert response.status_code == 200
assert "Profile updated" in response.text
app = client._transport.app # type: ignore[union-attr]
user = await app.state.user_repo.get_by_userid("user-01")
assert user is not None
assert user.given_name == "Alice"
assert user.email == "alice@example.com"
assert user.phone_number == "+46701234567"
async def test_update_profile_invalid_email(client: AsyncClient) -> None:
await _login(client)
token = await get_csrf_token(client)
response = await client.post(
"/manage/profile",
data={"email": "not-an-email"},
headers={"X-CSRF-Token": token},
)
assert response.status_code == 200
assert "alert" in response.text
assert "email" in response.text.lower()
async def test_update_profile_invalid_phone(client: AsyncClient) -> None:
await _login(client)
token = await get_csrf_token(client)
response = await client.post(
"/manage/profile",
data={"phone_number": "not-a-phone"},
headers={"X-CSRF-Token": token},
)
assert response.status_code == 200
assert "alert" in response.text
assert "phone" in response.text.lower()
async def test_update_profile_phone_normalized(client: AsyncClient) -> None:
await _login(client)
token = await get_csrf_token(client)
response = await client.post(
"/manage/profile",
data={"phone_number": "+46 70 123 45 67"},
headers={"X-CSRF-Token": token},
)
assert response.status_code == 200
assert "Profile updated" in response.text
app = client._transport.app # type: ignore[union-attr]
user = await app.state.user_repo.get_by_userid("user-01")
assert user is not None
assert user.phone_number == "+46701234567"
async def test_update_profile_invalid_picture(client: AsyncClient) -> None:
await _login(client)
token = await get_csrf_token(client)
response = await client.post(
"/manage/profile",
data={"picture": "not-a-url"},
headers={"X-CSRF-Token": token},
)
assert response.status_code == 200
assert "alert" in response.text
assert "Picture URL" in response.text
async def test_update_profile_field_too_long(client: AsyncClient) -> None:
await _login(client)
token = await get_csrf_token(client)
response = await client.post(
"/manage/profile",
data={"given_name": "x" * 256},
headers={"X-CSRF-Token": token},
)
assert response.status_code == 200
assert "alert" in response.text
assert "Given name" in response.text