Skip to content

Commit c097370

Browse files
feat: add tests for password reset functionality for retired users (#38737)
Addresses follow-up feedback from PR #38427 by replacing the retirement TODO with a permanent comment explaining why social accounts are retained during the cooling-off period, and by adding tests to verify that retired users cannot initiate or complete password reset flows.
1 parent bbe1e8b commit c097370

2 files changed

Lines changed: 59 additions & 13 deletions

File tree

openedx/core/djangoapps/user_api/accounts/utils.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,12 @@ def create_retirement_request_and_deactivate_account(user):
263263
user.set_unusable_password()
264264
user.save()
265265

266-
# TODO: Unlink social accounts & change password on each IDA.
266+
# Do not unlink/redact social accounts during the initial retirement request.
267+
# If the user cancels retirement during the cool-off period, they must
268+
# still be able to authenticate using their existing social account.
269+
# Deleting the social account at this stage would permanently break that
270+
# login path. Therefore, social account unlinking should only occur
271+
# when retirement is finalized after the cool-off period has elapsed.
267272
# Remove the activation keys sent by email to the user for account activation.
268273
Registration.objects.filter(user=user).delete()
269274

openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@
3535
from openedx.core.djangoapps.oauth_dispatch.tests import factories as dot_factories
3636
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
3737
from openedx.core.djangoapps.user_api.accounts import EMAIL_MAX_LENGTH, EMAIL_MIN_LENGTH
38-
from openedx.core.djangoapps.user_api.models import UserRetirementRequest
38+
from openedx.core.djangoapps.user_api.accounts.utils import create_retirement_request_and_deactivate_account
39+
from openedx.core.djangoapps.user_api.models import RetirementState
3940
from openedx.core.djangoapps.user_api.tests.test_views import UserAPITestCase
4041
from openedx.core.djangoapps.user_authn.views.password_reset import (
4142
PASSWORD_RESET_INITIATED,
@@ -79,6 +80,14 @@ def setUp(self): # pylint: disable=arguments-differ
7980
self.user_bad_passwd.password = UNUSABLE_PASSWORD_PREFIX
8081
self.user_bad_passwd.save()
8182

83+
# Create PENDING retirement state for tests that need it
84+
RetirementState.objects.create(
85+
state_name='PENDING',
86+
state_execution_order=1,
87+
is_dead_end_state=False,
88+
required=True,
89+
)
90+
8291
def setup_request_session_with_token(self, request):
8392
"""
8493
Internal helper to setup request session and add token in session.
@@ -318,6 +327,7 @@ def test_reset_password_email(self, body_type, expected_output):
318327
obj = json.loads(good_resp.content.decode('utf-8'))
319328
assert obj['success']
320329
assert 'e-mailed you instructions for setting your password' in obj['value']
330+
assert len(mail.outbox) > 0
321331

322332
from_email = configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)
323333
sent_message = mail.outbox[0]
@@ -536,23 +546,54 @@ def test_password_reset_fail(self):
536546
assert resp.status_code == 200
537547
assert not User.objects.get(pk=self.user.pk).is_active
538548

539-
def test_password_reset_retired_user_fail(self):
549+
def test_password_reset_initiation_fails_for_retired_user(self):
540550
"""
541-
Tests that if a retired user attempts to reset their password, it fails.
551+
Tests that a retired user cannot initiate a password reset.
542552
"""
553+
create_retirement_request_and_deactivate_account(self.user)
554+
self.user.refresh_from_db()
543555
assert not self.user.is_active
556+
assert not self.user.has_usable_password()
557+
558+
reset_request = self.request_factory.post('/password_reset/', {'email': self.user.email})
559+
reset_request.user = AnonymousUser()
560+
response = password_reset(reset_request)
544561

545-
# Retire the user.
546-
UserRetirementRequest.create_retirement_request(self.user)
562+
# Always return 200 OK to prevent user enumeration while leaving the password unchanged and unusable.
563+
assert response.status_code == 200
564+
response_data = json.loads(response.content.decode('utf-8'))
565+
assert response_data['success'] is True
566+
assert len(mail.outbox) == 0
567+
self.user.refresh_from_db()
568+
assert not self.user.is_active
547569

548-
reset_req = self.request_factory.get(self.password_reset_confirm_url)
549-
reset_req.user = self.user
550-
resp = PasswordResetConfirmWrapper.as_view()(reset_req, uidb36=self.uidb36, token=self.token)
570+
def test_password_reset_completion_fails_for_retired_user(self):
571+
"""
572+
Tests that password reset completion fails if retirement happens after reset initiation.
551573
552-
# Verify the response status code is: 200 with password reset fail and also verify that
553-
# the user is not marked as active.
554-
assert resp.status_code == 200
555-
assert not User.objects.get(pk=self.user.pk).is_active
574+
This simulates a user who initiated password reset before retirement
575+
and then attempts to submit a completed reset form after retirement.
576+
"""
577+
# Retire the user after they have initiated a reset (using the token set up in setUp).
578+
create_retirement_request_and_deactivate_account(self.user)
579+
self.user.refresh_from_db()
580+
assert not self.user.is_active
581+
assert not self.user.has_usable_password()
582+
old_password_hash = self.user.password
583+
584+
request_params = {'new_password1': 'new_password1', 'new_password2': 'new_password1'}
585+
confirm_request = self.request_factory.post(self.password_reset_confirm_url, data=request_params)
586+
self.setup_request_session_with_token(confirm_request)
587+
confirm_request.user = self.user
588+
589+
response = PasswordResetConfirmWrapper.as_view()(confirm_request, uidb36=self.uidb36, token=self.token)
590+
591+
# Always return 200 OK to prevent user enumeration while leaving the password unchanged and unusable.
592+
assert response.status_code == 200
593+
self.user.refresh_from_db()
594+
assert not self.user.is_active
595+
assert not self.user.has_usable_password()
596+
assert self.user.password == old_password_hash
556597

557598
def test_password_reset_normalize_password(self):
558599
# pylint: disable=anomalous-unicode-escape-in-string

0 commit comments

Comments
 (0)