feat: add validation models (locale, username, groups, password) and error helper

Add BCP 47 locale validator to ProfileUpdate, UsernameInput model,
GroupListInput model, PasswordSet/PasswordChange with zxcvbn strength
checking, and shared format_validation_errors HTML helper.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Johan Lundberg 2026-03-31 15:18:24 +02:00
parent 2f8cca3f41
commit aff6ddb99b
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1
2 changed files with 333 additions and 2 deletions

View file

@ -1,8 +1,10 @@
import re
from typing import Annotated from typing import Annotated
from urllib.parse import urlparse from urllib.parse import urlparse
from pydantic import BaseModel, EmailStr, Field, field_validator from pydantic import BaseModel, EmailStr, Field, ValidationError, field_validator, model_validator
from pydantic_extra_types.phone_numbers import PhoneNumberValidator from pydantic_extra_types.phone_numbers import PhoneNumberValidator
from zxcvbn import zxcvbn
E164Phone = Annotated[str, PhoneNumberValidator(number_format="E164")] E164Phone = Annotated[str, PhoneNumberValidator(number_format="E164")]
@ -40,3 +42,131 @@ class ProfileUpdate(BaseModel):
if parsed.scheme not in ("http", "https") or not parsed.netloc: if parsed.scheme not in ("http", "https") or not parsed.netloc:
raise ValueError("Picture URL must be a valid HTTP or HTTPS URL") raise ValueError("Picture URL must be a valid HTTP or HTTPS URL")
return v return v
@field_validator("locale", mode="before")
@classmethod
def validate_locale(cls, v: str) -> str:
if isinstance(v, str):
v = v.strip()
if v == "":
return ""
if not re.match(r"^[a-z]{2,3}(-[A-Z][a-z]{3})?(-[A-Z]{2})?$", v):
raise ValueError("Locale must be a valid BCP 47 language tag (e.g. en, sv-SE, zh-Hans-CN)")
return v
class UsernameInput(BaseModel):
username: str = Field(max_length=255)
@field_validator("username", mode="before")
@classmethod
def validate_username(cls, v: str) -> str:
if isinstance(v, str):
v = v.strip()
if not v:
raise ValueError("Username is required")
if not re.match(r"^[a-zA-Z0-9_.@-]+$", v):
raise ValueError("Username may only contain letters, digits, dots, hyphens, underscores, and @")
return v
class GroupListInput(BaseModel):
groups: str = ""
@property
def group_list(self) -> list[str]:
"""Parse comma-separated groups into a deduplicated list."""
seen: set[str] = set()
result: list[str] = []
for g in (g.strip() for g in self.groups.split(",") if g.strip()):
if g not in seen:
seen.add(g)
result.append(g)
return result
@field_validator("groups", mode="before")
@classmethod
def validate_groups(cls, v: str) -> str:
if isinstance(v, str):
names = [g.strip() for g in v.split(",") if g.strip()]
for name in names:
if not re.match(r"^[a-z0-9_-]{1,64}$", name):
raise ValueError(
f"Invalid group name '{name}'. "
"Groups must be 1-64 lowercase letters, digits, hyphens, or underscores."
)
return v
class PasswordSet(BaseModel):
password: str = Field(min_length=8, max_length=256)
confirm: str
@model_validator(mode="after")
def validate_password(self) -> "PasswordSet":
if self.password != self.confirm:
raise ValueError("Passwords do not match")
result = zxcvbn(self.password)
if result["score"] < 2:
feedback = result.get("feedback", {})
warning = feedback.get("warning", "")
suggestions = feedback.get("suggestions", [])
msg = "Password is too easily guessed."
if warning:
msg += f" {warning}."
if suggestions:
msg += " " + " ".join(suggestions)
raise ValueError(msg)
return self
class PasswordChange(PasswordSet):
current_password: str
@field_validator("current_password", mode="before")
@classmethod
def validate_current_password(cls, v: str) -> str:
if isinstance(v, str) and v.strip() == "":
raise ValueError("Current password is required")
return v
FIELD_LABELS: dict[str, str] = {
"given_name": "Given name",
"family_name": "Family name",
"preferred_username": "Display name",
"email": "Email",
"phone_number": "Phone number",
"picture": "Picture URL",
"locale": "Locale",
"username": "Username",
"groups": "Groups",
"password": "Password",
"confirm": "Confirm password",
"current_password": "Current password",
}
def format_validation_errors(exc: ValidationError) -> str:
"""Format Pydantic ValidationError into user-friendly HTML."""
messages: list[str] = []
for error in exc.errors():
field = str(error["loc"][-1]) if error["loc"] else "input"
label = FIELD_LABELS.get(field, field)
msg = error["msg"]
if error["type"] == "value_error":
raw = msg.removeprefix("Value error, ")
# If the message already starts with the label, don't duplicate it
if raw.startswith(label):
display_msg = raw
else:
display_msg = f"{label}: {raw}"
else:
display_msg = f"{label}: {msg}"
messages.append(display_msg)
if len(messages) == 1:
return f'<div role="alert">{messages[0]}</div>'
items = "".join(f"<li>{m}</li>" for m in messages)
return f'<div role="alert"><ul>{items}</ul></div>'

View file

@ -1,7 +1,14 @@
import pytest import pytest
from pydantic import ValidationError from pydantic import ValidationError
from porchlight.validation import ProfileUpdate from porchlight.validation import (
GroupListInput,
PasswordChange,
PasswordSet,
ProfileUpdate,
UsernameInput,
format_validation_errors,
)
class TestProfileUpdateEmail: class TestProfileUpdateEmail:
@ -91,3 +98,197 @@ class TestProfileUpdateDefaults:
assert p.phone_number is None assert p.phone_number is None
assert p.picture is None assert p.picture is None
assert p.locale == "" assert p.locale == ""
class TestProfileUpdateLocale:
def test_valid_locale_language_only(self) -> None:
p = ProfileUpdate(locale="en")
assert p.locale == "en"
def test_valid_locale_language_region(self) -> None:
p = ProfileUpdate(locale="sv-SE")
assert p.locale == "sv-SE"
def test_valid_locale_language_script_region(self) -> None:
p = ProfileUpdate(locale="zh-Hans-CN")
assert p.locale == "zh-Hans-CN"
def test_valid_locale_three_letter(self) -> None:
p = ProfileUpdate(locale="gsw")
assert p.locale == "gsw"
def test_empty_locale_allowed(self) -> None:
p = ProfileUpdate(locale="")
assert p.locale == ""
def test_invalid_locale_rejected(self) -> None:
with pytest.raises(ValidationError, match="locale"):
ProfileUpdate(locale="not_a_locale!")
def test_invalid_locale_numbers(self) -> None:
with pytest.raises(ValidationError, match="locale"):
ProfileUpdate(locale="12345")
def test_whitespace_locale_becomes_empty(self) -> None:
p = ProfileUpdate(locale=" ")
assert p.locale == ""
class TestUsernameInput:
def test_valid_simple(self) -> None:
u = UsernameInput(username="alice")
assert u.username == "alice"
def test_valid_with_dots_dashes_underscores(self) -> None:
u = UsernameInput(username="alice.bob_charlie-1")
assert u.username == "alice.bob_charlie-1"
def test_valid_email_style(self) -> None:
u = UsernameInput(username="user@example.com")
assert u.username == "user@example.com"
def test_valid_uppercase(self) -> None:
u = UsernameInput(username="Alice")
assert u.username == "Alice"
def test_empty_rejected(self) -> None:
with pytest.raises(ValidationError, match="username"):
UsernameInput(username="")
def test_whitespace_only_rejected(self) -> None:
with pytest.raises(ValidationError, match="username"):
UsernameInput(username=" ")
def test_too_long_rejected(self) -> None:
with pytest.raises(ValidationError):
UsernameInput(username="a" * 256)
def test_spaces_rejected(self) -> None:
with pytest.raises(ValidationError, match="username"):
UsernameInput(username="alice bob")
def test_special_chars_rejected(self) -> None:
with pytest.raises(ValidationError, match="username"):
UsernameInput(username="alice<script>")
def test_strips_whitespace(self) -> None:
u = UsernameInput(username=" alice ")
assert u.username == "alice"
class TestGroupListInput:
def test_valid_single_group(self) -> None:
g = GroupListInput(groups="users")
assert g.group_list == ["users"]
def test_valid_multiple_groups(self) -> None:
g = GroupListInput(groups="users, admin, staff")
assert g.group_list == ["users", "admin", "staff"]
def test_empty_string_gives_empty_list(self) -> None:
g = GroupListInput(groups="")
assert g.group_list == []
def test_whitespace_only_gives_empty_list(self) -> None:
g = GroupListInput(groups=" , , ")
assert g.group_list == []
def test_valid_with_hyphens_underscores(self) -> None:
g = GroupListInput(groups="my-group, my_group")
assert g.group_list == ["my-group", "my_group"]
def test_invalid_group_name_spaces(self) -> None:
with pytest.raises(ValidationError, match="group"):
GroupListInput(groups="bad group")
def test_invalid_group_name_special_chars(self) -> None:
with pytest.raises(ValidationError, match="group"):
GroupListInput(groups="admin, bad!group")
def test_invalid_group_name_uppercase(self) -> None:
with pytest.raises(ValidationError, match="group"):
GroupListInput(groups="Admin")
def test_group_name_too_long(self) -> None:
with pytest.raises(ValidationError, match="group"):
GroupListInput(groups="a" * 65)
def test_deduplicates(self) -> None:
g = GroupListInput(groups="users, users, admin")
assert g.group_list == ["users", "admin"]
class TestPasswordSet:
def test_valid_password(self) -> None:
p = PasswordSet(password="strongP@ss123", confirm="strongP@ss123")
assert p.password == "strongP@ss123"
def test_mismatch_rejected(self) -> None:
with pytest.raises(ValidationError, match="match"):
PasswordSet(password="strongP@ss123", confirm="different")
def test_too_short_rejected(self) -> None:
with pytest.raises(ValidationError):
PasswordSet(password="short", confirm="short")
def test_too_long_rejected(self) -> None:
long_pw = "a" * 257
with pytest.raises(ValidationError):
PasswordSet(password=long_pw, confirm=long_pw)
def test_weak_password_rejected(self) -> None:
with pytest.raises(ValidationError, match="[Ww]eak\\b|[Ss]trength|easily guessed"):
PasswordSet(password="password", confirm="password")
def test_common_password_rejected(self) -> None:
with pytest.raises(ValidationError, match="[Ww]eak\\b|[Ss]trength|easily guessed"):
PasswordSet(password="12345678", confirm="12345678")
class TestPasswordChange:
def test_valid_change(self) -> None:
p = PasswordChange(
current_password="oldPassword1",
password="newStrongP@ss99",
confirm="newStrongP@ss99",
)
assert p.current_password == "oldPassword1"
assert p.password == "newStrongP@ss99"
def test_missing_current_password(self) -> None:
with pytest.raises(ValidationError, match="current"):
PasswordChange(
current_password="",
password="newStrongP@ss99",
confirm="newStrongP@ss99",
)
class TestFormatValidationErrors:
def test_single_error_no_list(self) -> None:
try:
ProfileUpdate(email="bad")
except ValidationError as exc:
result = format_validation_errors(exc)
assert '<div role="alert">' in result
assert "<ul>" not in result
assert "Email" in result
def test_multiple_errors_uses_list(self) -> None:
try:
ProfileUpdate(email="bad", phone_number="bad")
except ValidationError as exc:
result = format_validation_errors(exc)
assert '<div role="alert">' in result
assert "<ul>" in result
assert "<li>" in result
assert "Email" in result
assert "Phone" in result
def test_value_error_strips_prefix(self) -> None:
try:
ProfileUpdate(picture="ftp://bad.url")
except ValidationError as exc:
result = format_validation_errors(exc)
assert "Picture URL must be a valid HTTP or HTTPS URL" in result
assert "Value error" not in result