diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py index e8cac9c524ff..808bcbb0f31b 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py @@ -7,6 +7,7 @@ from contextlib import contextmanager import ddt +import pytest from completion import models from completion.test_utils import CompletionWaffleTestMixin from django.db import connection @@ -16,15 +17,17 @@ from django.utils import timezone from social_django.models import UserSocialAuth -from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.student.models import CourseEnrollment, is_email_retired from common.djangoapps.student.tests.factories import UserFactory from openedx.core.djangoapps.user_api.accounts.signals import redact_social_auth_pii_before_deletion from openedx.core.djangoapps.user_api.accounts.utils import ( REDACTED_SOCIAL_AUTH_UID_PREFIX, redact_and_delete_historical_social_auth, redact_and_delete_social_auth, + release_retired_learner_email, retrieve_last_sitewide_block_completed, ) +from openedx.core.djangoapps.user_api.models import RetirementState, RetirementStateError, UserRetirementStatus from openedx.core.djangolib.testing.utils import assert_redact_before_delete, skip_unless_lms from xmodule.modulestore.tests.django_utils import ( SharedModuleStoreTestCase, # pylint: disable=wrong-import-order @@ -35,6 +38,11 @@ ) from ..utils import format_social_link, validate_social_link +from .retirement_helpers import ( # noqa: F401 # pylint: disable=unused-import + RetirementTestCase, + create_retirement_status, + setup_retirement_states, +) # Use a context manager to guarantee signal reconnection between tests. @@ -296,3 +304,88 @@ def test_historical_social_auth_redact_before_delete(self): ) assert not self.historical_social_auth_model.objects.filter(user=self.user).exists() assert self.historical_social_auth_model.objects.filter(user=other_user).exists() + + +class ReleaseRetiredLearnerEmailTest(RetirementTestCase): + """ + Tests for release_retired_learner_email(). + """ + + def _retire_user_to_state(self, user, state_name): + return create_retirement_status(user, state=RetirementState.objects.get(state_name=state_name)) + + def test_releases_email_when_retirement_complete(self): + user = UserFactory(email='retired__user_abc123@retired.invalid') + self._retire_user_to_state(user, 'COMPLETE') + + release_retired_learner_email(user) + + user.refresh_from_db() + assert user.email == f'retired__uid_{user.id}@retired.invalid' + + def test_raises_when_retirement_still_in_progress(self): + user = UserFactory(email='retired__user_abc123@retired.invalid') + self._retire_user_to_state(user, 'RETIRING_LMS') + + with pytest.raises(RetirementStateError, match=r"retirement is in state 'RETIRING_LMS', not COMPLETE"): + release_retired_learner_email(user) + + # A rejected release must not touch the email. + user.refresh_from_db() + assert user.email == 'retired__user_abc123@retired.invalid' + + def test_is_idempotent(self): + user = UserFactory(email='retired__user_abc123@retired.invalid') + self._retire_user_to_state(user, 'COMPLETE') + + release_retired_learner_email(user) + user.refresh_from_db() + released_email = user.email + + release_retired_learner_email(user) + user.refresh_from_db() + assert user.email == released_email + + def test_releases_email_when_status_row_archived(self): + user = UserFactory(email='retired__user_abc123@retired.invalid') + + release_retired_learner_email(user) + + user.refresh_from_db() + assert user.email == f'retired__uid_{user.id}@retired.invalid' + + def test_raises_when_user_does_not_appear_retired(self): + user = UserFactory(email='still.active@example.com') + + with pytest.raises(RetirementStateError, match=r'does not appear to be a retired user') as exc_info: + release_retired_learner_email(user) + + # Raised via "raise ... from exc" off the DoesNotExist - confirm the chain, not just the message. + assert isinstance(exc_info.value.__cause__, UserRetirementStatus.DoesNotExist) + + # A rejected release must not touch the email. + user.refresh_from_db() + assert user.email == 'still.active@example.com' + + def test_releases_email_unblocks_reregistration_with_original_email(self): + """ + Regression coverage for the actual point of this feature: is_email_retired() + is what the registration path (student.signals.receivers.on_user_updated) + checks to block signup with a previously-retired address, and it works by + looking for a User row whose email still equals a salted hash of the + candidate address. Releasing the email must clear that match, or learners + stay locked out of re-registering with their original email forever. + """ + original_email = 'learner@example.com' + user = UserFactory(email=original_email) + retirement = self._retire_user_to_state(user, 'COMPLETE') + + # Mimic what the real retirement pipeline does to the auth_user row: + # swap the email for its retired-hash value. + user.email = retirement.retired_email + user.save(update_fields=['email']) + assert is_email_retired(original_email) + + release_retired_learner_email(user) + + assert not is_email_retired(original_email) diff --git a/openedx/core/djangoapps/user_api/accounts/utils.py b/openedx/core/djangoapps/user_api/accounts/utils.py index 5ba37a4ba05c..be6717c61d26 100644 --- a/openedx/core/djangoapps/user_api/accounts/utils.py +++ b/openedx/core/djangoapps/user_api/accounts/utils.py @@ -11,6 +11,8 @@ from completion.models import BlockCompletion from completion.waffle import ENABLE_COMPLETION_TRACKING_SWITCH from django.conf import settings +from django.contrib.auth.models import User # pylint: disable=imported-auth-user +from django.db import transaction from django.db.models import CharField, TextField, Value from django.db.models.functions import Cast, Concat from django.utils.translation import gettext as _ @@ -27,12 +29,19 @@ from openedx.core.djangolib.oauth2_retirement_utils import retire_dot_oauth2_models from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order -from ..models import UserRetirementStatus +from ..models import RetirementStateError, UserRetirementStatus # Prefix and suffix used to build a per-record redacted uid for UserSocialAuth. REDACTED_SOCIAL_AUTH_UID_PREFIX = 'redacted-before-delete-' REDACTED_SOCIAL_AUTH_UID_SUFFIX = '@safe.com' +# Format for the placeholder email a learner's retired email is replaced with by +# release_retired_learner_email(). Kept private to this feature - deliberately not shared +# with the RETIRED_EMAIL_* settings/format used by the retirement pipeline itself. The +# domain is filled in from settings.RETIRED_EMAIL_DOMAIN so it matches whatever domain +# _is_retired_email_format() considers "retired" on this deployment. +RELEASED_LEARNER_EMAIL_FORMAT = 'retired__uid_{}@{}' + ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH = 'enable_secondary_email_feature' LOGGER = logging.getLogger(__name__) @@ -311,3 +320,58 @@ def handle_retirement_cancellation(retirement, email_address=None): retirement.user.save() retirement.delete() + + +def _is_retired_email_format(email): + """ + Returns True if the given email address is in the retired-email domain + used by settings.RETIRED_EMAIL_DOMAIN. + """ + return email.endswith(f'@{settings.RETIRED_EMAIL_DOMAIN}') + + +def release_retired_learner_email(user): + """ + Lets a fully-retired learner reuse their original email address by replacing + the retired-hash email currently on their auth_user row with a stable, + human-readable placeholder keyed on their user id (RELEASED_LEARNER_EMAIL_FORMAT, + e.g. "retired__uid_42@retired.invalid"). This only mutates that one column - + the row and its retirement history are kept for compliance, and + is_email_retired() will no longer match the new value. + + Raises RetirementStateError if the user's retirement isn't in a state where + it's safe to release the email (still in progress, or doesn't look retired at all). + + Locks the user row for the duration of the check, so this can't interleave with a + concurrent handle_retirement_cancellation() (which restores the original email on + the same row) or with another release_retired_learner_email() call for this user. + The check itself runs against a freshly re-fetched copy of the row rather than the + `user` passed in, but on success `user.email` is updated in place to match. + """ + released_email = RELEASED_LEARNER_EMAIL_FORMAT.format(user.id, settings.RETIRED_EMAIL_DOMAIN) + + with transaction.atomic(): + locked_user = User.objects.select_for_update().get(pk=user.pk) + + if locked_user.email == released_email: + LOGGER.info(f"Email for user {locked_user.id} was already released, nothing to do.") + else: + try: + retirement = UserRetirementStatus.objects.select_related('current_state').get(user=locked_user) + if retirement.current_state.state_name != 'COMPLETE': + raise RetirementStateError( + f"Cannot release email for user {locked_user.id}: retirement is in state " + f"'{retirement.current_state.state_name}', not COMPLETE." + ) + except UserRetirementStatus.DoesNotExist as exc: + if not _is_retired_email_format(locked_user.email): + raise RetirementStateError(f"User {locked_user.id} does not appear to be a retired user.") from exc + + locked_user.email = released_email + locked_user.save(update_fields=['email']) + LOGGER.info(f"Released retired email for user {locked_user.id}.") + + # Keep the caller's in-memory object in sync with what was actually persisted - + # we mutated a separately-fetched `locked_user`, not `user` itself. Runs on both + # the freshly-released and already-released paths (an early return here would skip it). + user.email = released_email diff --git a/openedx/core/djangoapps/user_api/management/commands/release_retired_user_email.py b/openedx/core/djangoapps/user_api/management/commands/release_retired_user_email.py new file mode 100644 index 000000000000..b27c0a19a5e8 --- /dev/null +++ b/openedx/core/djangoapps/user_api/management/commands/release_retired_user_email.py @@ -0,0 +1,44 @@ +""" +Releases a retired learner's email address so it can be reused for a new +registration, without touching the archived UserRetirementStatus row. +""" +import logging + +from django.contrib.auth.models import User # pylint: disable=imported-auth-user +from django.core.management.base import BaseCommand, CommandError + +from openedx.core.djangoapps.user_api.accounts.utils import release_retired_learner_email +from openedx.core.djangoapps.user_api.models import RetirementStateError + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + Releases a single retired learner's original email address for reuse. + + The learner must already be fully retired (UserRetirementStatus is in the + COMPLETE state) - see release_retired_learner_email() for the exact rules. + """ + help = "Releases a retired learner's email address so it can be reused for a new registration." + + def add_arguments(self, parser): + # user_id only - avoids taking a username (PII) as input for a retired-learner tool. + parser.add_argument('--user_id', type=int, required=True, help='User ID of the retired learner to release.') + + def handle(self, *args, **options): + user_id = options['user_id'] + + try: + user = User.objects.get(id=user_id) + except User.DoesNotExist as exc: + raise CommandError(f'No user found for the given user_id={user_id!r}.') from exc + + try: + release_retired_learner_email(user) + except RetirementStateError as exc: + raise CommandError(str(exc)) from exc + + message = f'Successfully released email for user {user.id}.' + logger.info(message) + self.stdout.write(self.style.SUCCESS(message)) diff --git a/openedx/core/djangoapps/user_api/management/tests/test_release_retired_user_email.py b/openedx/core/djangoapps/user_api/management/tests/test_release_retired_user_email.py new file mode 100644 index 000000000000..ab0701687761 --- /dev/null +++ b/openedx/core/djangoapps/user_api/management/tests/test_release_retired_user_email.py @@ -0,0 +1,107 @@ +""" +Test the release_retired_user_email management command +""" + + +from unittest.mock import patch + +import pytest +from django.contrib.auth.models import User # pylint: disable=imported-auth-user +from django.core.management import CommandError, call_command + +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import ( # noqa: F401 # pylint: disable=unused-import + create_retirement_status, + setup_retirement_states, +) +from openedx.core.djangoapps.user_api.models import RetirementState + +pytestmark = pytest.mark.django_db + + +def _retire_user(user, state_name): + return create_retirement_status(user, state=RetirementState.objects.get(state_name=state_name)) + + +@patch('openedx.core.djangoapps.user_api.management.commands.release_retired_user_email.logger') +@pytest.mark.usefixtures('setup_retirement_states') +def test_releases_email_by_user_id(mock_logger): + user = UserFactory(email='retired__user_abc123@retired.invalid') + _retire_user(user, 'COMPLETE') + + call_command('release_retired_user_email', user_id=user.id) + + user.refresh_from_db() + assert user.email == f'retired__uid_{user.id}@retired.invalid' + mock_logger.info.assert_called_with(f'Successfully released email for user {user.id}.') + + +def test_requires_user_id(): + with pytest.raises(CommandError, match=r'the following arguments are required: --user_id'): + call_command('release_retired_user_email') + + +def test_unknown_user_id(): + with pytest.raises(CommandError, match=r'No user found for the given user_id=999999'): + call_command('release_retired_user_email', user_id=999999) + + +@pytest.mark.usefixtures('setup_retirement_states') +def test_blocked_while_retirement_in_progress(): + user = UserFactory(email='retired__user_abc123@retired.invalid') + _retire_user(user, 'RETIRING_LMS') + + with pytest.raises(CommandError, match=r'not COMPLETE'): + call_command('release_retired_user_email', user_id=user.id) + + user.refresh_from_db() + assert User.objects.get(id=user.id).email == 'retired__user_abc123@retired.invalid' + + +def test_releases_email_when_status_row_archived(): + """ + No UserRetirementStatus row exists for this user (e.g. it was redacted and + deleted by the partner-report cleanup endpoint), but the email is still in + the retired-domain format - the command should fall back to that and succeed. + """ + user = UserFactory(email='retired__user_abc123@retired.invalid') + + call_command('release_retired_user_email', user_id=user.id) + + user.refresh_from_db() + assert user.email == f'retired__uid_{user.id}@retired.invalid' + + +def test_raises_when_user_does_not_appear_retired(): + """ + No UserRetirementStatus row and a non-retired-looking email - the command + should refuse via CommandError rather than release the email. + """ + user = UserFactory(email='still.active@example.com') + + with pytest.raises(CommandError, match=r'does not appear to be a retired user'): + call_command('release_retired_user_email', user_id=user.id) + + user.refresh_from_db() + assert user.email == 'still.active@example.com' + + +@patch('openedx.core.djangoapps.user_api.management.commands.release_retired_user_email.logger') +@pytest.mark.usefixtures('setup_retirement_states') +def test_running_twice_is_idempotent(mock_logger): + """ + A second run against an already-released user must not error - it should + hit release_retired_learner_email()'s early return and still report success. + """ + user = UserFactory(email='retired__user_abc123@retired.invalid') + _retire_user(user, 'COMPLETE') + + call_command('release_retired_user_email', user_id=user.id) + user.refresh_from_db() + released_email = user.email + + call_command('release_retired_user_email', user_id=user.id) + + user.refresh_from_db() + assert user.email == released_email + mock_logger.info.assert_called_with(f'Successfully released email for user {user.id}.')