porchlight/tests/test_validation.py
Johan Lundberg 01e3382aaf
fix: resolve all ruff lint errors and type checker warnings
- Use Annotated[str, Form()] for FastAPI dependencies (FAST002)
- Add missing type annotations across src/ and tests/ (ANN001/003/201/202)
- Reduce function arguments via request.form() reads (PLR0913)
- Combine return paths to reduce return statements (PLR0911)
- Use anyio.Path for async-safe filesystem operations (ASYNC240)
- Extract constants, helpers, and dict comprehensions for clarity
- Move inline imports to top-level (PLC0415)
- Use raw strings for regex match patterns (RUF043)
- Fix redundant get_session_user call in delete_user (not-iterable)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:48:46 +02:00

294 lines
10 KiB
Python

import pytest
from pydantic import ValidationError
from porchlight.validation import (
GroupListInput,
PasswordChange,
PasswordSet,
ProfileUpdate,
UsernameInput,
format_validation_errors,
)
class TestProfileUpdateEmail:
def test_valid_email(self) -> None:
p = ProfileUpdate(email="user@example.com")
assert p.email == "user@example.com"
def test_empty_email_becomes_none(self) -> None:
p = ProfileUpdate(email="")
assert p.email is None
def test_invalid_email_no_at(self) -> None:
with pytest.raises(ValidationError, match="email"):
ProfileUpdate(email="not-an-email")
def test_invalid_email_no_domain(self) -> None:
with pytest.raises(ValidationError, match="email"):
ProfileUpdate(email="user@")
class TestProfileUpdatePhone:
def test_valid_e164(self) -> None:
p = ProfileUpdate(phone_number="+46701234567")
assert p.phone_number == "+46701234567"
def test_valid_e164_with_spaces_normalized(self) -> None:
p = ProfileUpdate(phone_number="+46 70 123 45 67")
assert p.phone_number == "+46701234567"
def test_empty_phone_becomes_none(self) -> None:
p = ProfileUpdate(phone_number="")
assert p.phone_number is None
def test_invalid_phone(self) -> None:
with pytest.raises(ValidationError, match="phone"):
ProfileUpdate(phone_number="not-a-phone")
def test_invalid_phone_no_plus(self) -> None:
with pytest.raises(ValidationError, match="phone"):
ProfileUpdate(phone_number="46701234567")
class TestProfileUpdatePicture:
def test_valid_https_url(self) -> None:
p = ProfileUpdate(picture="https://example.com/pic.jpg")
assert p.picture == "https://example.com/pic.jpg"
def test_valid_http_url(self) -> None:
p = ProfileUpdate(picture="http://example.com/pic.jpg")
assert p.picture == "http://example.com/pic.jpg"
def test_empty_picture_becomes_none(self) -> None:
p = ProfileUpdate(picture="")
assert p.picture is None
def test_invalid_picture_not_url(self) -> None:
with pytest.raises(ValidationError, match="picture"):
ProfileUpdate(picture="not-a-url")
def test_invalid_picture_ftp_scheme(self) -> None:
with pytest.raises(ValidationError, match="picture"):
ProfileUpdate(picture="ftp://example.com/pic.jpg")
class TestProfileUpdateFieldLengths:
def test_given_name_too_long(self) -> None:
with pytest.raises(ValidationError, match="given_name"):
ProfileUpdate(given_name="x" * 256)
def test_phone_number_max_length(self) -> None:
"""E.164 max is 15 digits + plus sign = 16 chars."""
p = ProfileUpdate(phone_number="+431234567890123")
assert p.phone_number == "+431234567890123"
def test_locale_too_long(self) -> None:
with pytest.raises(ValidationError, match="locale"):
ProfileUpdate(locale="x" * 21)
class TestProfileUpdateDefaults:
def test_all_defaults(self) -> None:
p = ProfileUpdate()
assert p.given_name == ""
assert p.family_name == ""
assert p.preferred_username == ""
assert p.email is None
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=r"[Ww]eak\b|[Ss]trength|easily guessed"):
PasswordSet(password="password", confirm="password")
def test_common_password_rejected(self) -> None:
with pytest.raises(ValidationError, match=r"[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