Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 94 additions & 1 deletion openedx/core/djangoapps/user_api/accounts/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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)
66 changes: 65 additions & 1 deletion openedx/core/djangoapps/user_api/accounts/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 _
Expand All @@ -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__)

Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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))
Original file line number Diff line number Diff line change
@@ -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}.')
Loading