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:
parent
2f8cca3f41
commit
aff6ddb99b
2 changed files with 333 additions and 2 deletions
|
|
@ -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<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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue