From 8cbbdbd11e096543845a85f87cc7874638058d59 Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Wed, 10 Jun 2026 07:35:13 +0000 Subject: [PATCH 1/7] feat: add tests for password reset functionality for retired users --- .../djangoapps/user_api/accounts/utils.py | 7 +++- .../views/tests/test_reset_password.py | 35 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/user_api/accounts/utils.py b/openedx/core/djangoapps/user_api/accounts/utils.py index cf00dc3a9f93..7194a94db52c 100644 --- a/openedx/core/djangoapps/user_api/accounts/utils.py +++ b/openedx/core/djangoapps/user_api/accounts/utils.py @@ -262,7 +262,12 @@ def create_retirement_request_and_deactivate_account(user): user.set_unusable_password() user.save() - # TODO: Unlink social accounts & change password on each IDA. + # Do not unlink/redact social accounts during the initial retirement request. + # If the user cancels retirement during the 14-day cool-off period, they must + # still be able to authenticate using their existing social account. Redacting + # or deleting the social account at this stage would permanently break that + # login path. Therefore, social account unlinking/redaction should only occur + # when retirement is finalized after the cool-off period has elapsed. # Remove the activation keys sent by email to the user for account activation. Registration.objects.filter(user=user).delete() diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py b/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py index 7388e51edddc..be3963f22495 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py @@ -35,6 +35,7 @@ from openedx.core.djangoapps.oauth_dispatch.tests import factories as dot_factories from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.user_api.accounts import EMAIL_MAX_LENGTH, EMAIL_MIN_LENGTH +from openedx.core.djangoapps.user_api.accounts.utils import create_retirement_request_and_deactivate_account from openedx.core.djangoapps.user_api.models import UserRetirementRequest from openedx.core.djangoapps.user_api.tests.test_views import UserAPITestCase from openedx.core.djangoapps.user_authn.views.password_reset import ( @@ -554,6 +555,40 @@ def test_password_reset_retired_user_fail(self): assert resp.status_code == 200 assert not User.objects.get(pk=self.user.pk).is_active + def test_password_reset_retired_user_initiation_fail(self): + """ + Tests that a retired user cannot initiate a password reset. + """ + create_retirement_request_and_deactivate_account(self.user) + retired_user = User.objects.get(pk=self.user.pk) + + reset_request = self.request_factory.post('/password_reset/', {'email': retired_user.email}) + reset_request.user = AnonymousUser() + response = password_reset(reset_request) + + # We intentionally return a generic success response, but no reset email should be sent. + assert response.status_code == 200 + response_data = json.loads(response.content.decode('utf-8')) + assert response_data['success'] is True + assert len(mail.outbox) == 0 + + def test_password_reset_retired_user_complete_fail(self): + """ + Tests that a retired user cannot complete password reset even with a submitted form. + """ + create_retirement_request_and_deactivate_account(self.user) + retired_user = User.objects.get(pk=self.user.pk) + + request_params = {'new_password1': 'new_password1', 'new_password2': 'new_password1'} + confirm_request = self.request_factory.post(self.password_reset_confirm_url, data=request_params) + self.setup_request_session_with_token(confirm_request) + confirm_request.user = retired_user + + response = PasswordResetConfirmWrapper.as_view()(confirm_request, uidb36=self.uidb36, token=self.token) + + assert response.status_code == 200 + assert not User.objects.get(pk=self.user.pk).has_usable_password() + def test_password_reset_normalize_password(self): # pylint: disable=anomalous-unicode-escape-in-string """ From 48d1633e98311406f5ec0e8b8abbebcbf6972b1f Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Mon, 15 Jun 2026 07:21:10 +0000 Subject: [PATCH 2/7] feat: add tests for password reset functionality for retired users --- .../user_authn/views/tests/test_reset_password.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py b/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py index be3963f22495..f40f35d4dfcd 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py @@ -36,7 +36,7 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.user_api.accounts import EMAIL_MAX_LENGTH, EMAIL_MIN_LENGTH from openedx.core.djangoapps.user_api.accounts.utils import create_retirement_request_and_deactivate_account -from openedx.core.djangoapps.user_api.models import UserRetirementRequest +from openedx.core.djangoapps.user_api.models import RetirementState, UserRetirementRequest from openedx.core.djangoapps.user_api.tests.test_views import UserAPITestCase from openedx.core.djangoapps.user_authn.views.password_reset import ( PASSWORD_RESET_INITIATED, @@ -559,6 +559,12 @@ def test_password_reset_retired_user_initiation_fail(self): """ Tests that a retired user cannot initiate a password reset. """ + RetirementState.objects.create( + state_name='PENDING', + state_execution_order=1, + is_dead_end_state=False, + required=True, + ) create_retirement_request_and_deactivate_account(self.user) retired_user = User.objects.get(pk=self.user.pk) @@ -576,6 +582,12 @@ def test_password_reset_retired_user_complete_fail(self): """ Tests that a retired user cannot complete password reset even with a submitted form. """ + RetirementState.objects.create( + state_name='PENDING', + state_execution_order=1, + is_dead_end_state=False, + required=True, + ) create_retirement_request_and_deactivate_account(self.user) retired_user = User.objects.get(pk=self.user.pk) From 67bd1f503ec28cefdfabc73946c2d5597637add2 Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Mon, 15 Jun 2026 07:36:30 +0000 Subject: [PATCH 3/7] feat: add tests for password reset functionality for retired users --- .../views/tests/test_reset_password.py | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py b/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py index f40f35d4dfcd..3017e85e3ab6 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py @@ -80,6 +80,14 @@ def setUp(self): # pylint: disable=arguments-differ self.user_bad_passwd.password = UNUSABLE_PASSWORD_PREFIX self.user_bad_passwd.save() + # Create PENDING retirement state for tests that need it + RetirementState.objects.create( + state_name='PENDING', + state_execution_order=1, + is_dead_end_state=False, + required=True, + ) + def setup_request_session_with_token(self, request): """ Internal helper to setup request session and add token in session. @@ -559,12 +567,6 @@ def test_password_reset_retired_user_initiation_fail(self): """ Tests that a retired user cannot initiate a password reset. """ - RetirementState.objects.create( - state_name='PENDING', - state_execution_order=1, - is_dead_end_state=False, - required=True, - ) create_retirement_request_and_deactivate_account(self.user) retired_user = User.objects.get(pk=self.user.pk) @@ -582,12 +584,6 @@ def test_password_reset_retired_user_complete_fail(self): """ Tests that a retired user cannot complete password reset even with a submitted form. """ - RetirementState.objects.create( - state_name='PENDING', - state_execution_order=1, - is_dead_end_state=False, - required=True, - ) create_retirement_request_and_deactivate_account(self.user) retired_user = User.objects.get(pk=self.user.pk) From d921ff4052d46c022c0b6d3f308e477a24b000ee Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Mon, 15 Jun 2026 09:12:20 +0000 Subject: [PATCH 4/7] feat: add tests for password reset functionality for retired users --- .../views/tests/test_reset_password.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py b/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py index 3017e85e3ab6..983f7cff537b 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py @@ -568,9 +568,11 @@ def test_password_reset_retired_user_initiation_fail(self): Tests that a retired user cannot initiate a password reset. """ create_retirement_request_and_deactivate_account(self.user) - retired_user = User.objects.get(pk=self.user.pk) + self.user.refresh_from_db() + assert not self.user.is_active + assert not self.user.has_usable_password() - reset_request = self.request_factory.post('/password_reset/', {'email': retired_user.email}) + reset_request = self.request_factory.post('/password_reset/', {'email': self.user.email}) reset_request.user = AnonymousUser() response = password_reset(reset_request) @@ -585,17 +587,21 @@ def test_password_reset_retired_user_complete_fail(self): Tests that a retired user cannot complete password reset even with a submitted form. """ create_retirement_request_and_deactivate_account(self.user) - retired_user = User.objects.get(pk=self.user.pk) + self.user.refresh_from_db() + assert not self.user.is_active + old_password_hash = self.user.password request_params = {'new_password1': 'new_password1', 'new_password2': 'new_password1'} confirm_request = self.request_factory.post(self.password_reset_confirm_url, data=request_params) self.setup_request_session_with_token(confirm_request) - confirm_request.user = retired_user + confirm_request.user = self.user response = PasswordResetConfirmWrapper.as_view()(confirm_request, uidb36=self.uidb36, token=self.token) assert response.status_code == 200 - assert not User.objects.get(pk=self.user.pk).has_usable_password() + self.user.refresh_from_db() + assert not self.user.has_usable_password() + assert self.user.password == old_password_hash def test_password_reset_normalize_password(self): # pylint: disable=anomalous-unicode-escape-in-string From d206f2a3136564e1a59dc11caec9585cd85b3c11 Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Mon, 15 Jun 2026 11:44:16 +0000 Subject: [PATCH 5/7] feat: add tests for password reset functionality for retired users --- .../user_authn/views/tests/test_reset_password.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py b/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py index 983f7cff537b..55b4257b36b6 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py @@ -576,7 +576,8 @@ def test_password_reset_retired_user_initiation_fail(self): reset_request.user = AnonymousUser() response = password_reset(reset_request) - # We intentionally return a generic success response, but no reset email should be sent. + # Security design: always return 200 OK to avoid user enumeration, + # but ensure no reset email is sent and the password remains unchanged. assert response.status_code == 200 response_data = json.loads(response.content.decode('utf-8')) assert response_data['success'] is True @@ -598,8 +599,9 @@ def test_password_reset_retired_user_complete_fail(self): response = PasswordResetConfirmWrapper.as_view()(confirm_request, uidb36=self.uidb36, token=self.token) + # Security design: always return 200 OK to avoid user enumeration, + # but ensure the password remains unchanged and unusable. assert response.status_code == 200 - self.user.refresh_from_db() assert not self.user.has_usable_password() assert self.user.password == old_password_hash From 703592834af3065fb1ec43276536b5517e5f13ee Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Mon, 15 Jun 2026 11:50:17 +0000 Subject: [PATCH 6/7] feat: add tests for password reset functionality for retired users --- .../djangoapps/user_authn/views/tests/test_reset_password.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py b/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py index 55b4257b36b6..c4d11fed46f4 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py @@ -602,6 +602,7 @@ def test_password_reset_retired_user_complete_fail(self): # Security design: always return 200 OK to avoid user enumeration, # but ensure the password remains unchanged and unusable. assert response.status_code == 200 + self.user.refresh_from_db() assert not self.user.has_usable_password() assert self.user.password == old_password_hash From f817552e0070eaf0399ae7f9412ff764e6901d95 Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Tue, 16 Jun 2026 09:33:41 +0000 Subject: [PATCH 7/7] feat: add tests for password reset functionality for retired users --- .../user_authn/views/tests/test_reset_password.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py b/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py index c4d11fed46f4..ac722f4b3b5b 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py @@ -576,8 +576,7 @@ def test_password_reset_retired_user_initiation_fail(self): reset_request.user = AnonymousUser() response = password_reset(reset_request) - # Security design: always return 200 OK to avoid user enumeration, - # but ensure no reset email is sent and the password remains unchanged. + # Always return 200 OK to prevent user enumeration while leaving the password unchanged and unusable. assert response.status_code == 200 response_data = json.loads(response.content.decode('utf-8')) assert response_data['success'] is True @@ -599,8 +598,7 @@ def test_password_reset_retired_user_complete_fail(self): response = PasswordResetConfirmWrapper.as_view()(confirm_request, uidb36=self.uidb36, token=self.token) - # Security design: always return 200 OK to avoid user enumeration, - # but ensure the password remains unchanged and unusable. + # Always return 200 OK to prevent user enumeration while leaving the password unchanged and unusable. assert response.status_code == 200 self.user.refresh_from_db() assert not self.user.has_usable_password()