Skip to content

Commit 94a3832

Browse files
authored
Add password checks for registration (#54)
## Notion ticket link No link ## Implementation description The backend user schema was modified to return an array of requirements that the password does not meet rather than just raising the first one, with updated requirements as well. The authApiClient was modified to handle 422 errors and parse this list of errors. The participant form frontend was modified check if there is an error before running `router.push()`, and to loop over the errors to display the requirement list. In the future we can make this change to admin page as well. ## Steps to test On the registration page enter a password which does not fit the requirements. Instead of allowing you to proceed, it should show a checklist of requirements the password must meet. This should match the figma design: https://www.figma.com/design/HhFPmxFewSCBLGkIt9RZen/LLSC-Main-Design-File?node-id=6082-14924&t=xlyQNnm2i6cO0ct6-0 ### What it looks like ![Image 2025-09-21 at 11 53 PM](https://github.com/user-attachments/assets/9c6b335b-00d6-44f3-83a5-27e03570124f) ### Backend tests Tests in `user_schema_test.py` handle password requirement logic and all of them should pass ![Image 2025-09-22 at 11 18 AM](https://github.com/user-attachments/assets/8c1c24a6-46d0-4512-89a5-88bef6081572) ## What should reviewers focus on? ## Checklist - [ ] My PR name is descriptive and in imperative tense - [ ] My commit messages are descriptive and in imperative tense. My commits are atomic and trivial commits are squashed or fixup'd into non-trivial commits - [ ] I have run the appropriate linter(s) - [ ] I have requested a review from the PL, as well as other devs who have background knowledge on this PR or who will be building on top of this PR
1 parent b7186bc commit 94a3832

File tree

4 files changed

+315
-34
lines changed

4 files changed

+315
-34
lines changed

backend/app/schemas/user.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ class UserCreateRequest(UserBase):
5353
Request schema for user creation with conditional password validation
5454
"""
5555

56-
password: Optional[str] = Field(None, min_length=8)
56+
password: Optional[str] = Field(None)
5757
auth_id: Optional[str] = Field(None)
5858
signup_method: SignUpMethod = Field(default=SignUpMethod.PASSWORD)
5959

@@ -65,12 +65,18 @@ def validate_password(cls, password: Optional[str], info):
6565
raise ValueError("Password is required for password signup")
6666

6767
if password:
68-
if not any(char.isdigit() for char in password):
69-
raise ValueError("Password must contain at least one digit")
68+
errors = []
69+
if len(password) < 8:
70+
errors.append("Password must be at least 8 characters long")
7071
if not any(char.isupper() for char in password):
71-
raise ValueError("Password must contain at least one uppercase letter")
72+
errors.append("Password must contain at least one uppercase letter")
7273
if not any(char.islower() for char in password):
73-
raise ValueError("Password must contain at least one lowercase letter")
74+
errors.append("Password must contain at least one lowercase letter")
75+
if not any(char in "!@#$%^&*" for char in password):
76+
errors.append("Password must contain at least one special character (!, @, #, $, %, ^, &, or *)")
77+
78+
if errors:
79+
raise ValueError(errors)
7480

7581
return password
7682

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import pytest
2+
from pydantic import ValidationError
3+
4+
from app.schemas.user import SignUpMethod, UserCreateRequest, UserRole
5+
6+
7+
class TestUserCreateRequestPasswordValidation:
8+
"""Test password validation in UserCreateRequest schema"""
9+
10+
def test_password_validation_success_valid_password(self):
11+
"""Test that a valid password passes validation"""
12+
user_data = {
13+
"first_name": "Test",
14+
"last_name": "User",
15+
"email": "[email protected]",
16+
"password": "ValidPass123!",
17+
"role": UserRole.PARTICIPANT,
18+
"signup_method": SignUpMethod.PASSWORD,
19+
}
20+
21+
# Should not raise any exception
22+
user_request = UserCreateRequest(**user_data)
23+
assert user_request.password == "ValidPass123!"
24+
25+
def test_password_validation_too_short(self):
26+
"""Test that password less than 8 characters fails validation"""
27+
user_data = {
28+
"first_name": "Test",
29+
"last_name": "User",
30+
"email": "[email protected]",
31+
"password": "short1!", # Only 7 characters
32+
"role": UserRole.PARTICIPANT,
33+
"signup_method": SignUpMethod.PASSWORD,
34+
}
35+
36+
with pytest.raises(ValidationError) as exc_info:
37+
UserCreateRequest(**user_data)
38+
39+
# Check that the error message contains the expected validation message
40+
error_msg = str(exc_info.value)
41+
assert "Password must be at least 8 characters long" in error_msg
42+
43+
def test_password_validation_missing_uppercase(self):
44+
"""Test that password without uppercase letter fails validation"""
45+
user_data = {
46+
"first_name": "Test",
47+
"last_name": "User",
48+
"email": "[email protected]",
49+
"password": "lowercase123!", # No uppercase
50+
"role": UserRole.PARTICIPANT,
51+
"signup_method": SignUpMethod.PASSWORD,
52+
}
53+
54+
with pytest.raises(ValidationError) as exc_info:
55+
UserCreateRequest(**user_data)
56+
57+
error_msg = str(exc_info.value)
58+
assert "Password must contain at least one uppercase letter" in error_msg
59+
60+
def test_password_validation_missing_lowercase(self):
61+
"""Test that password without lowercase letter fails validation"""
62+
user_data = {
63+
"first_name": "Test",
64+
"last_name": "User",
65+
"email": "[email protected]",
66+
"password": "UPPERCASE123!", # No lowercase
67+
"role": UserRole.PARTICIPANT,
68+
"signup_method": SignUpMethod.PASSWORD,
69+
}
70+
71+
with pytest.raises(ValidationError) as exc_info:
72+
UserCreateRequest(**user_data)
73+
74+
error_msg = str(exc_info.value)
75+
assert "Password must contain at least one lowercase letter" in error_msg
76+
77+
def test_password_validation_missing_special_character(self):
78+
"""Test that password without special character fails validation"""
79+
user_data = {
80+
"first_name": "Test",
81+
"last_name": "User",
82+
"email": "[email protected]",
83+
"password": "NoSpecial123", # No special characters
84+
"role": UserRole.PARTICIPANT,
85+
"signup_method": SignUpMethod.PASSWORD,
86+
}
87+
88+
with pytest.raises(ValidationError) as exc_info:
89+
UserCreateRequest(**user_data)
90+
91+
error_msg = str(exc_info.value)
92+
assert "Password must contain at least one special character" in error_msg
93+
94+
def test_password_validation_multiple_errors(self):
95+
"""Test that password with multiple issues returns all errors"""
96+
user_data = {
97+
"first_name": "Test",
98+
"last_name": "User",
99+
"email": "[email protected]",
100+
"password": "short", # Too short, no uppercase, no special char
101+
"role": UserRole.PARTICIPANT,
102+
"signup_method": SignUpMethod.PASSWORD,
103+
}
104+
105+
with pytest.raises(ValidationError) as exc_info:
106+
UserCreateRequest(**user_data)
107+
108+
error_msg = str(exc_info.value)
109+
# Should contain multiple validation errors
110+
assert "Password must be at least 8 characters long" in error_msg
111+
assert "Password must contain at least one uppercase letter" in error_msg
112+
assert "Password must contain at least one special character" in error_msg
113+
114+
def test_password_validation_google_signup_no_password_required(self):
115+
"""Test that Google signup doesn't require password validation"""
116+
user_data = {
117+
"first_name": "Test",
118+
"last_name": "User",
119+
"email": "[email protected]",
120+
# No password provided
121+
"role": UserRole.PARTICIPANT,
122+
"signup_method": SignUpMethod.GOOGLE,
123+
}
124+
125+
# Should not raise any exception for Google signup
126+
user_request = UserCreateRequest(**user_data)
127+
assert user_request.password is None
128+
129+
def test_password_validation_edge_cases(self):
130+
"""Test edge cases for password validation"""
131+
# Test with exactly 8 characters
132+
user_data = {
133+
"first_name": "Test",
134+
"last_name": "User",
135+
"email": "[email protected]",
136+
"password": "MinPass1!", # Exactly 8 chars, meets all requirements
137+
"role": UserRole.PARTICIPANT,
138+
"signup_method": SignUpMethod.PASSWORD,
139+
}
140+
141+
# Should not raise any exception
142+
user_request = UserCreateRequest(**user_data)
143+
assert user_request.password == "MinPass1!"
144+
145+
def test_password_validation_all_special_characters(self):
146+
"""Test that all allowed special characters work"""
147+
special_chars = ["!", "@", "#", "$", "%", "^", "&", "*"]
148+
149+
for char in special_chars:
150+
user_data = {
151+
"first_name": "Test",
152+
"last_name": "User",
153+
"email": "[email protected]",
154+
"password": f"ValidPass1{char}",
155+
"role": UserRole.PARTICIPANT,
156+
"signup_method": SignUpMethod.PASSWORD,
157+
}
158+
159+
# Should not raise any exception
160+
user_request = UserCreateRequest(**user_data)
161+
assert user_request.password == f"ValidPass1{char}"

frontend/src/APIClients/authAPIClient.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export interface AuthResult {
2828
user?: AuthenticatedUser;
2929
error?: string;
3030
errorCode?: string;
31+
validationErrors?: string[];
3132
}
3233

3334
const login = async (email: string, password: string): Promise<AuthResult> => {
@@ -223,9 +224,28 @@ export const register = async ({
223224
} else if (response?.status === 400) {
224225
const detail = response?.data?.detail || 'Invalid registration data';
225226
return { success: false, error: detail };
227+
} else if (response?.status === 422) {
228+
// Pydantic validation errors
229+
const validationErrors = response?.data?.detail;
230+
console.log('[REGISTER] Validation errors:', validationErrors);
231+
if (Array.isArray(validationErrors)) {
232+
// Extract password validation errors specifically
233+
const passwordErrors = validationErrors
234+
.filter((err) => err.loc && err.loc.includes('password'))
235+
.map((err) => err.msg);
236+
237+
if (passwordErrors.length > 0) {
238+
return {
239+
success: false,
240+
error: 'password_validation',
241+
validationErrors: passwordErrors,
242+
};
243+
}
244+
}
245+
// Fallback for other validation errors
246+
return { success: false, error: response?.data?.detail || 'Validation failed' };
226247
}
227248
}
228-
229249
return { success: false, error: 'Registration failed. Please try again.' };
230250
}
231251
};

0 commit comments

Comments
 (0)