diff --git a/openedx/core/djangoapps/user_api/accounts/utils.py b/openedx/core/djangoapps/user_api/accounts/utils.py index 343cddb93ddc..3da126a81704 100644 --- a/openedx/core/djangoapps/user_api/accounts/utils.py +++ b/openedx/core/djangoapps/user_api/accounts/utils.py @@ -263,7 +263,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..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 @@ -35,7 +35,8 @@ 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.models import UserRetirementRequest +from openedx.core.djangoapps.user_api.accounts.utils import create_retirement_request_and_deactivate_account +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, @@ -79,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. @@ -554,6 +563,47 @@ 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) + 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': self.user.email}) + reset_request.user = AnonymousUser() + response = password_reset(reset_request) + + # 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 + 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) + 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 = self.user + + response = PasswordResetConfirmWrapper.as_view()(confirm_request, uidb36=self.uidb36, token=self.token) + + # 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() + assert self.user.password == old_password_hash + def test_password_reset_normalize_password(self): # pylint: disable=anomalous-unicode-escape-in-string """