Skip to content

Commit a7536db

Browse files
Backend: Enhance password reset logic in CustomPasswordResetSerializer (#5054)
* Enhance password reset logic in CustomPasswordResetSerializer to include checks for unverified emails and bounced email addresses. Update unit tests to cover new validation scenarios, ensuring robust error handling for password reset requests. * Refactor password reset logic in CustomPasswordResetSerializer to utilize get_user_by_email for case-insensitive email lookup. Update unit tests to ensure proper validation and error handling for email variations. Enhance get_user_by_email utility to support case-insensitive queries and add tests for handling duplicate emails with differing cases.
1 parent a80163e commit a7536db

File tree

5 files changed

+235
-14
lines changed

5 files changed

+235
-14
lines changed

apps/accounts/serializers.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -345,15 +345,30 @@ class CustomPasswordResetSerializer(PasswordResetSerializer):
345345
def get_email_options(self):
346346
try:
347347
user = get_user_by_email(self.data["email"])
348-
if not user.is_active:
349-
raise ValidationError(
350-
{
351-
"details": "Account is not active. Please contact the administrator."
352-
}
353-
)
354-
else:
355-
return super().get_email_options()
356348
except User.DoesNotExist:
357349
raise ValidationError(
358350
{"details": "User with the given email does not exist."}
359351
)
352+
if not user.is_active:
353+
raise ValidationError(
354+
{
355+
"details": "Account is not active. Please contact the administrator."
356+
}
357+
)
358+
if hasattr(user, "profile") and user.profile.email_bounced:
359+
raise ValidationError(
360+
{
361+
"details": "This email address has bounced and cannot receive password reset emails."
362+
}
363+
)
364+
if not EmailAddress.objects.filter(
365+
user=user,
366+
email__iexact=self.data["email"],
367+
verified=True,
368+
).exists():
369+
raise ValidationError(
370+
{
371+
"details": "Email address is not verified. Please verify your email before resetting password."
372+
}
373+
)
374+
return super().get_email_options()

apps/base/utils.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@ def get_user_by_email(email):
3232
If duplicate-email users still exist (pre-merge), the oldest account
3333
(earliest date_joined) is returned deterministically.
3434
"""
35-
user = User.objects.filter(email=email).order_by("date_joined").first()
35+
user = (
36+
User.objects.filter(email__iexact=email)
37+
.order_by("date_joined")
38+
.first()
39+
)
3640
if user is None:
3741
raise User.DoesNotExist("User matching query does not exist.")
3842
return user

tests/unit/accounts/test_serializers.py

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
CustomPasswordResetSerializer,
66
ProfileSerializer,
77
)
8+
from allauth.account.models import EmailAddress
89
from challenges.models import Challenge
910
from django.contrib.auth import get_user_model
1011
from django.test import TestCase
@@ -22,22 +23,46 @@ def setUp(self):
2223
email="active@example.com",
2324
password="password",
2425
)
26+
self.unverified_user = self.user_model.objects.create_user(
27+
username="unverified_user",
28+
email="unverified@example.com",
29+
password="password",
30+
)
2531
self.inactive_user = self.user_model.objects.create_user(
2632
username="inactive_user",
2733
email="inactive@example.com",
2834
password="password",
2935
)
3036
self.inactive_user.is_active = False
3137
self.inactive_user.save()
38+
EmailAddress.objects.create(
39+
user=self.active_user,
40+
email=self.active_user.email,
41+
primary=True,
42+
verified=True,
43+
)
44+
EmailAddress.objects.create(
45+
user=self.unverified_user,
46+
email=self.unverified_user.email,
47+
primary=True,
48+
verified=False,
49+
)
50+
EmailAddress.objects.create(
51+
user=self.inactive_user,
52+
email=self.inactive_user.email,
53+
primary=True,
54+
verified=True,
55+
)
3256

3357
def test_get_email_options_try_block(self):
3458
serializer = CustomPasswordResetSerializer(
3559
data={"email": self.active_user.email}
3660
)
3761
self.assertEqual(serializer.is_valid(), True)
38-
expected_email_options = super(
39-
CustomPasswordResetSerializer, serializer
40-
).get_email_options()
62+
base_class = serializer.__class__.__mro__[1]
63+
base_serializer = base_class(data={"email": self.active_user.email})
64+
self.assertEqual(base_serializer.is_valid(), True)
65+
expected_email_options = base_serializer.get_email_options()
4166
self.assertEqual(
4267
serializer.get_email_options(), expected_email_options
4368
)
@@ -68,6 +93,49 @@ def test_get_email_options_inactive_user(self):
6893
},
6994
)
7095

96+
def test_get_email_options_unverified_user(self):
97+
serializer = CustomPasswordResetSerializer(
98+
data={"email": self.unverified_user.email}
99+
)
100+
serializer.is_valid() # Ensure data is validated
101+
with self.assertRaises(ValidationError) as e:
102+
serializer.get_email_options()
103+
self.assertEqual(
104+
e.exception.detail,
105+
{
106+
"details": "Email address is not verified. Please verify your email before resetting password."
107+
},
108+
)
109+
110+
def test_get_email_options_bounced_email_user(self):
111+
self.active_user.profile.email_bounced = True
112+
self.active_user.profile.save(update_fields=["email_bounced"])
113+
serializer = CustomPasswordResetSerializer(
114+
data={"email": self.active_user.email}
115+
)
116+
serializer.is_valid() # Ensure data is validated
117+
with self.assertRaises(ValidationError) as e:
118+
serializer.get_email_options()
119+
self.assertEqual(
120+
e.exception.detail,
121+
{
122+
"details": "This email address has bounced and cannot receive password reset emails."
123+
},
124+
)
125+
126+
def test_get_email_options_case_insensitive_email_lookup(self):
127+
serializer = CustomPasswordResetSerializer(
128+
data={"email": "ACTIVE@EXAMPLE.COM"}
129+
)
130+
self.assertEqual(serializer.is_valid(), True)
131+
base_class = serializer.__class__.__mro__[1]
132+
base_serializer = base_class(data={"email": "ACTIVE@EXAMPLE.COM"})
133+
self.assertEqual(base_serializer.is_valid(), True)
134+
expected_email_options = base_serializer.get_email_options()
135+
self.assertEqual(
136+
serializer.get_email_options(), expected_email_options
137+
)
138+
71139

72140
class TestProfileSerializer(TestCase):
73141
def setUp(self):

tests/unit/accounts/test_views.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,3 +321,114 @@ def test_integrity_error_non_email_reraised(self):
321321
},
322322
format="json",
323323
)
324+
325+
326+
class PasswordResetViewTest(APITestCase):
327+
def setUp(self):
328+
self.client = APIClient(enforce_csrf_checks=True)
329+
self.url = "/api/auth/password/reset/"
330+
331+
self.verified_user = User.objects.create_user(
332+
username="verified_user",
333+
email="verified@example.com",
334+
password="password",
335+
)
336+
EmailAddress.objects.create(
337+
user=self.verified_user,
338+
email=self.verified_user.email,
339+
primary=True,
340+
verified=True,
341+
)
342+
343+
self.unverified_user = User.objects.create_user(
344+
username="unverified_user",
345+
email="unverified@example.com",
346+
password="password",
347+
)
348+
EmailAddress.objects.create(
349+
user=self.unverified_user,
350+
email=self.unverified_user.email,
351+
primary=True,
352+
verified=False,
353+
)
354+
355+
self.inactive_user = User.objects.create_user(
356+
username="inactive_user",
357+
email="inactive@example.com",
358+
password="password",
359+
is_active=False,
360+
)
361+
EmailAddress.objects.create(
362+
user=self.inactive_user,
363+
email=self.inactive_user.email,
364+
primary=True,
365+
verified=True,
366+
)
367+
368+
@patch("django.contrib.auth.forms.PasswordResetForm.save")
369+
def test_password_reset_verified_active_user_success(self, mock_save):
370+
response = self.client.post(
371+
self.url, {"email": self.verified_user.email}, format="json"
372+
)
373+
self.assertEqual(response.status_code, status.HTTP_200_OK)
374+
mock_save.assert_called_once()
375+
376+
@patch("django.contrib.auth.forms.PasswordResetForm.save")
377+
def test_password_reset_verified_active_user_case_insensitive_email(
378+
self, mock_save
379+
):
380+
response = self.client.post(
381+
self.url, {"email": "VERIFIED@EXAMPLE.COM"}, format="json"
382+
)
383+
self.assertEqual(response.status_code, status.HTTP_200_OK)
384+
mock_save.assert_called_once()
385+
386+
@patch("django.contrib.auth.forms.PasswordResetForm.save")
387+
def test_password_reset_unverified_user_returns_400(self, mock_save):
388+
response = self.client.post(
389+
self.url, {"email": self.unverified_user.email}, format="json"
390+
)
391+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
392+
self.assertEqual(
393+
response.data["details"],
394+
"Email address is not verified. Please verify your email before resetting password.",
395+
)
396+
mock_save.assert_not_called()
397+
398+
@patch("django.contrib.auth.forms.PasswordResetForm.save")
399+
def test_password_reset_bounced_email_returns_400(self, mock_save):
400+
self.verified_user.profile.email_bounced = True
401+
self.verified_user.profile.save(update_fields=["email_bounced"])
402+
response = self.client.post(
403+
self.url, {"email": self.verified_user.email}, format="json"
404+
)
405+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
406+
self.assertEqual(
407+
response.data["details"],
408+
"This email address has bounced and cannot receive password reset emails.",
409+
)
410+
mock_save.assert_not_called()
411+
412+
@patch("django.contrib.auth.forms.PasswordResetForm.save")
413+
def test_password_reset_inactive_user_returns_400(self, mock_save):
414+
response = self.client.post(
415+
self.url, {"email": self.inactive_user.email}, format="json"
416+
)
417+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
418+
self.assertEqual(
419+
response.data["details"],
420+
"Account is not active. Please contact the administrator.",
421+
)
422+
mock_save.assert_not_called()
423+
424+
@patch("django.contrib.auth.forms.PasswordResetForm.save")
425+
def test_password_reset_nonexistent_user_returns_400(self, mock_save):
426+
response = self.client.post(
427+
self.url, {"email": "nonexistent@example.com"}, format="json"
428+
)
429+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
430+
self.assertEqual(
431+
response.data["details"],
432+
"User with the given email does not exist.",
433+
)
434+
mock_save.assert_not_called()

tests/unit/base/test_get_user_by_email.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,18 @@ def test_returns_user_for_unique_email(self):
3333
result = get_user_by_email("alice@example.com")
3434
self.assertEqual(result.pk, user.pk)
3535

36+
def test_returns_user_case_insensitive(self):
37+
user = User.objects.create_user(
38+
username="carol", email="carol@example.com", password="password"
39+
)
40+
for variant in (
41+
"CAROL@EXAMPLE.COM",
42+
"Carol@Example.Com",
43+
"carol@EXAMPLE.com",
44+
):
45+
result = get_user_by_email(variant)
46+
self.assertEqual(result.pk, user.pk)
47+
3648
def test_raises_for_nonexistent_email(self):
3749
with self.assertRaises(User.DoesNotExist):
3850
get_user_by_email("ghost@example.com")
@@ -45,8 +57,9 @@ def setUp(self):
4557
_drop_email_unique_constraint()
4658

4759
def tearDown(self):
48-
# Remove duplicate users before re-adding unique constraint
49-
User.objects.filter(username__in=("bob_old", "bob_new")).delete()
60+
User.objects.filter(
61+
username__in=("bob_old", "bob_new", "eve_lower", "eve_upper")
62+
).delete()
5063
_add_email_unique_constraint()
5164

5265
def test_returns_oldest_when_duplicates_exist(self):
@@ -58,3 +71,13 @@ def test_returns_oldest_when_duplicates_exist(self):
5871
)
5972
result = get_user_by_email("bob@example.com")
6073
self.assertEqual(result.pk, older.pk)
74+
75+
def test_returns_oldest_when_case_differing_duplicates_exist(self):
76+
older = User.objects.create_user(
77+
username="eve_lower", email="eve@example.com", password="password"
78+
)
79+
User.objects.create_user(
80+
username="eve_upper", email="EVE@EXAMPLE.COM", password="password"
81+
)
82+
result = get_user_by_email("Eve@Example.com")
83+
self.assertEqual(result.pk, older.pk)

0 commit comments

Comments
 (0)