diff --git a/src/porchlight/validation.py b/src/porchlight/validation.py index 512ad02..ebee9d6 100644 --- a/src/porchlight/validation.py +++ b/src/porchlight/validation.py @@ -1,8 +1,10 @@ +import re from typing import Annotated 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 zxcvbn import zxcvbn 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: raise ValueError("Picture URL must be a valid HTTP or HTTPS URL") 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'
{messages[0]}
' + + items = "".join(f"
  • {m}
  • " for m in messages) + return f'
    ' diff --git a/tests/test_validation.py b/tests/test_validation.py index cdf3353..74e97d5 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -1,7 +1,14 @@ import pytest from pydantic import ValidationError -from porchlight.validation import ProfileUpdate +from porchlight.validation import ( + GroupListInput, + PasswordChange, + PasswordSet, + ProfileUpdate, + UsernameInput, + format_validation_errors, +) class TestProfileUpdateEmail: @@ -91,3 +98,197 @@ class TestProfileUpdateDefaults: assert p.phone_number is None assert p.picture is None 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