diff --git a/backend/src/modules/user/schemas.py b/backend/src/modules/user/schemas.py index 2565984b..b8e64fe9 100644 --- a/backend/src/modules/user/schemas.py +++ b/backend/src/modules/user/schemas.py @@ -1,7 +1,8 @@ +import re from datetime import datetime from typing import Annotated -from pydantic import BaseModel, ConfigDict, EmailStr, Field +from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator from ..common.schemas import PersistentDeletion, TimestampSchema from .constants import NAME_MAX_LENGTH, USERNAME_MAX_LENGTH, USERNAME_PATTERN @@ -68,7 +69,6 @@ class UserCreate(UserBase): "uppercase letter, lowercase letter, and special character" ), examples=["Str1ngst!"], - pattern=r"^.{8,}|[0-9]+|[A-Z]+|[a-z]+|[^a-zA-Z0-9]+$", ), ] google_id: str | None = None @@ -78,6 +78,23 @@ class UserCreate(UserBase): oauth_created_at: datetime | None = None oauth_updated_at: datetime | None = None + @field_validator("password") + def validate_password_strength(cls, v: str) -> str: + """Validate the password includes every character class in the description.""" + if not re.search(r"[a-z]", v): + raise ValueError("Password must include at least one lowercase letter") + + if not re.search(r"[A-Z]", v): + raise ValueError("Password must include at least one uppercase letter") + + if not re.search(r"[0-9]", v): + raise ValueError("Password must include at least one number") + + if not re.search(r"[^a-zA-Z0-9]", v): + raise ValueError("Password must include at least one special character") + + return v + model_config = ConfigDict(extra="forbid") diff --git a/backend/tests/unit/modules/user/test_schemas.py b/backend/tests/unit/modules/user/test_schemas.py new file mode 100644 index 00000000..09d93df2 --- /dev/null +++ b/backend/tests/unit/modules/user/test_schemas.py @@ -0,0 +1,47 @@ +"""Unit tests for the User schemas.""" + +import pytest +from pydantic import ValidationError + +from src.modules.user.schemas import UserCreate + + +def _user_data(password: str) -> dict[str, str]: + return { + "name": "Test User", + "username": "testuser", + "email": "user.userson@example.com", + "password": password, + } + + +def test_password_with_every_character_class_is_accepted(): + """The documented example must keep working.""" + user = UserCreate(**_user_data("Str1ngst!")) + + assert user.password == "Str1ngst!" + + +@pytest.mark.parametrize( + ("password", "missing"), + [ + ("str1ngst!", "uppercase letter"), + ("STR1NGST!", "lowercase letter"), + ("Stringst!", "number"), + ("Str1ngst", "special character"), + ("abcdefgh", "uppercase letter"), + ("aaaaaaaaaaaa", "uppercase letter"), + (" ", "lowercase letter"), + ], +) +def test_password_missing_a_character_class_is_rejected(password: str, missing: str): + """A password is rejected when any class from the description is missing.""" + with pytest.raises(ValidationError, match=missing): + UserCreate(**_user_data(password)) + + +@pytest.mark.parametrize("password", ["Str1ng!", "Ab1!cde"]) +def test_password_shorter_than_eight_characters_is_rejected(password: str): + """Length stays enforced separately from the character classes.""" + with pytest.raises(ValidationError, match="at least 8 characters"): + UserCreate(**_user_data(password))