Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
b593861
fix: Redact uid field and delete records for retired users from suppo…
Akanshu-2u May 19, 2026
caf150a
fix: fixed tests and utility lookup error
Akanshu-2u May 19, 2026
bf1db7b
fix: Potential fix for pull request finding
Akanshu-2u May 19, 2026
721cd94
fix: modified annotations
Akanshu-2u May 19, 2026
6563246
fix: Potential fix for pull request finding
Akanshu-2u May 19, 2026
7d01b68
fix: Remove logging for missing support app
Akanshu-2u May 19, 2026
8b9de00
fix: added logger and dynamic testing
Akanshu-2u May 19, 2026
c19fe2f
fix: added skip test lookup error
Akanshu-2u May 19, 2026
a411cb5
fix: added extra_data none check
Akanshu-2u May 19, 2026
f813c0a
fix: added sql tests
Akanshu-2u May 20, 2026
632871c
fix: corrected annotations
Akanshu-2u May 20, 2026
c726942
fix: added textfield instead of charfield
Akanshu-2u May 20, 2026
0decc17
fix: added the valid docstring
Akanshu-2u May 21, 2026
a5f35d8
fix: added implementation in retre user management command
Akanshu-2u May 21, 2026
f82889d
fix: fixed lint error for docstring
Akanshu-2u May 21, 2026
e0ddc2f
fix: added test case to retire user tests
Akanshu-2u May 22, 2026
dbb5c15
fix: removed unwanted comment
Akanshu-2u May 22, 2026
73e482b
fix: fixed the logger and getattr caling
Akanshu-2u May 22, 2026
798d6e5
fix: optimized tests
Akanshu-2u May 25, 2026
ff8050d
fix: removed unwanted tests and handling
Akanshu-2u May 27, 2026
29d3f71
fix: removed extra spaces from retire user file
Akanshu-2u May 27, 2026
14c3906
fix: fixed the docstring and annotations
Akanshu-2u May 27, 2026
164b22b
Merge branch 'master' into aaich/BOMS-577
Akanshu-2u Jun 1, 2026
0d76944
Merge branch 'openedx:master' into aaich/BOMS-577
Akanshu-2u Jun 1, 2026
4142802
fix: used the helper function for tests
Akanshu-2u Jun 1, 2026
9e9d359
Merge branch 'openedx:master' into aaich/BOMS-577
Akanshu-2u Jun 1, 2026
319e507
fix: fixed the quality checks
Akanshu-2u Jun 1, 2026
ac78971
Merge branch 'master' into aaich/BOMS-577
Akanshu-2u Jun 2, 2026
d5ab640
fix: merged one and multiple sso
Akanshu-2u Jun 2, 2026
96dffa9
fix: removed the unnecessary variable usage
Akanshu-2u Jun 2, 2026
193910e
Merge branch 'master' into aaich/BOMS-577
Akanshu-2u Jun 2, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -1707,3 +1707,42 @@ def test_retire_user_twice_idempotent(self):
self.post_and_assert_status(data)
fake_completed_retirement(self.test_user)
self.post_and_assert_status(data)

@mock.patch('openedx.core.djangoapps.user_api.accounts.views.USER_RETIRE_MAILINGS')
@mock.patch('openedx.core.djangoapps.user_api.accounts.views.USER_RETIRE_LMS_MISC')
@mock.patch('openedx.core.djangoapps.user_api.accounts.views.redact_and_delete_historical_social_auth')
@mock.patch('openedx.core.djangoapps.user_api.accounts.views.CreditRequirementStatus.retire_user')
@mock.patch('openedx.core.djangoapps.user_api.accounts.views.ApiAccessRequest.retire_user')
@mock.patch('openedx.core.djangoapps.user_api.accounts.views.CreditRequest.retire_user')
@mock.patch('openedx.core.djangoapps.user_api.accounts.views.ManualEnrollmentAudit.retire_manual_enrollments')
@mock.patch('openedx.core.djangoapps.user_api.accounts.views.PendingNameChange.delete_by_user_value')
@mock.patch('openedx.core.djangoapps.user_api.accounts.views.ArticleRevision.retire_user')
@mock.patch('openedx.core.djangoapps.user_api.accounts.views.RevisionPluginRevision.retire_user')
def test_retire_misc_calls_all_retirement_steps(
self,
mock_revision_plugin,
mock_article_revision,
mock_pending_name,
mock_manual_enroll,
mock_credit_request,
mock_api_access,
mock_credit_req_status,
mock_redact_historical,
mock_lms_misc_signal,
mock_mailings_signal,
):
"""
Ensure that all retirement steps in the retire_misc view are invoked.
"""
self.post_and_assert_status({'username': self.original_username})

mock_revision_plugin.assert_called_once()
mock_article_revision.assert_called_once()
mock_pending_name.assert_called_once()
mock_manual_enroll.assert_called_once()
mock_credit_request.assert_called_once()
mock_api_access.assert_called_once()
mock_credit_req_status.assert_called_once()
mock_redact_historical.assert_called_once()
mock_lms_misc_signal.send.assert_called_once()
mock_mailings_signal.send.assert_called_once()
118 changes: 85 additions & 33 deletions openedx/core/djangoapps/user_api/accounts/tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
""" Unit tests for custom UserProfile properties. """
"""
Unit tests for user account utility functions, including social links, completion, and social-auth PII redaction.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I made minor changes. It'd either leave out the "Includes..." line, or add the "etc." (like I did), to not make it sound comprehensive. It would be easy to add tests without updating this comment, and comments can easily get out of sync.

Suggested change
Unit tests for user account utility functions, including social links, completion, and social-auth PII redaction.
Unit tests for user account utility functions.
Includes tests for social links, social-auth PII redaction, completion, etc.

"""

from contextlib import contextmanager

Expand All @@ -9,16 +11,19 @@
from django.db.models.signals import pre_delete
from django.test import TestCase
from django.test.utils import CaptureQueriesContext, override_settings
from django.utils import timezone
from social_django.models import UserSocialAuth

from common.djangoapps.student.models import CourseEnrollment
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,
retrieve_last_sitewide_block_completed,
)
from openedx.core.djangolib.testing.utils import skip_unless_lms
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 @@ -30,26 +35,6 @@
from ..utils import format_social_link, validate_social_link


def assert_update_before_delete(sql_list, num_redact_delete_pairs=1, table='social_auth_usersocialauth'):
"""
Assert that UPDATE and DELETE queries for ``table`` occur in consecutive pairs.
"""
table_key = table.upper()
expected_sql_list = [
sql for sql in sql_list
if table_key in sql.upper() and ('UPDATE' in sql.upper() or 'DELETE' in sql.upper())
]
assert len(expected_sql_list) == num_redact_delete_pairs * 2, (
f'Expected {num_redact_delete_pairs * 2} UPDATE/DELETE queries on {table}, '
f'got {len(expected_sql_list)}'
)

for index in range(0, len(expected_sql_list), 2):
update_sql = expected_sql_list[index]
delete_sql = expected_sql_list[index + 1]
assert 'UPDATE' in update_sql.upper(), f'Expected UPDATE at position {index} for {table}'
assert 'DELETE' in delete_sql.upper(), f'Expected DELETE at position {index + 1} for {table}'

# Use a context manager to guarantee signal reconnection between tests.
@contextmanager
def disconnected_social_auth_redaction_signal():
Expand Down Expand Up @@ -203,6 +188,21 @@ def create_social_auth(self, provider='google-oauth2', uid='user@example.com', e
extra_data=extra_data,
)

def _assert_redact_and_delete_social_auth(self, social_auth_ids):
"""
Test redact_and_delete_social_auth and assert that all given records were
redacted before deletion.
"""
with disconnected_social_auth_redaction_signal(), CaptureQueriesContext(connection) as ctx:
redact_and_delete_social_auth(self.user.id)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't part of the assertion, but the heart of what is getting tested. It don't make sense for this to be in the helper.

I think a better solution would be a single test with no helper. You could use ddt to set up the list of users (1 and 2) for the two tests.


assert_redact_before_delete(
[query['sql'] for query in ctx],
table='social_auth_usersocialauth',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We've been getting the table names from the model.

expected_redacted_value_list=[REDACTED_SOCIAL_AUTH_UID_PREFIX],
)
assert not UserSocialAuth.objects.filter(id__in=social_auth_ids).exists()

def test_redact_and_delete_redacts_single_sso_record(self):
"""
Test that redact_and_delete_social_auth redacts and deletes a single SSO record.
Expand All @@ -212,13 +212,7 @@ def test_redact_and_delete_redacts_single_sso_record(self):
uid='google@example.com',
extra_data={'email': 'google@example.com', 'name': 'Google User'},
)
social_auth_id = social_auth.pk

with disconnected_social_auth_redaction_signal(), CaptureQueriesContext(connection) as ctx:
redact_and_delete_social_auth(self.user.id)

assert_update_before_delete([query['sql'] for query in ctx])
assert not UserSocialAuth.objects.filter(id=social_auth_id).exists()
self._assert_redact_and_delete_social_auth([social_auth.pk])

def test_redact_and_delete_redacts_multiple_sso_records(self):

This comment was marked as resolved.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented.

"""
Expand All @@ -236,9 +230,67 @@ def test_redact_and_delete_redacts_multiple_sso_records(self):
extra_data={'email': 'saml@example.com', 'name': 'SAML User', 'uid': 'saml-uid'},
).pk,
]
self._assert_redact_and_delete_social_auth(social_auth_ids)

with disconnected_social_auth_redaction_signal(), CaptureQueriesContext(connection) as ctx:
redact_and_delete_social_auth(self.user.id)

assert_update_before_delete([query['sql'] for query in ctx])
assert not UserSocialAuth.objects.filter(id__in=social_auth_ids).exists()
@skip_unless_lms
class RedactAndDeleteHistoricalSocialAuthTest(TestCase):
"""
Tests for the redact_and_delete_historical_social_auth utility function.
"""

def setUp(self):
super().setUp()
self.user = UserFactory.create(username='testuser', email='testuser@example.com')
self.historical_social_auth_model = UserSocialAuth.history.model

def _create_historical_record(self, provider='google-oauth2', uid='user@example.com', extra_data=None, source_id=1):
"""
Create a HistoricalUserSocialAuth record directly for test setup.
"""
if extra_data is None:
extra_data = {'email': uid, 'name': 'Test User'}
return self.historical_social_auth_model.objects.create(
user=self.user,
id=source_id,
provider=provider,
uid=uid,
extra_data=extra_data,
created=timezone.now(),
modified=timezone.now(),
history_date=timezone.now(),
history_type='+',
)

def test_historical_social_auth_redact_before_delete(self):
"""
Ensure HistoricalUserSocialAuth records are properly redacted and deleted for retirement.

The fields uid (email format) and extra_data must be redacted before delete.
"""
self._create_historical_record(provider='google-oauth2', uid='google@example.com', source_id=1)
self._create_historical_record(provider='tpa-saml', uid='saml@example.com', source_id=2)

other_user = UserFactory.create(username='otheruser', email='other@example.com')
other_record = self.historical_social_auth_model.objects.create(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You have _create_historical_record. Why not just use it and add any missing arguments to the function?

user=other_user,
id=3,
provider='google-oauth2',
uid='other@example.com',
extra_data={},
created=timezone.now(),
modified=timezone.now(),
history_date=timezone.now(),
history_type='+',
)

with CaptureQueriesContext(connection) as ctx:
redact_and_delete_historical_social_auth(self.user.id)

assert_redact_before_delete(
[query['sql'] for query in ctx],
table=self.historical_social_auth_model._meta.db_table,
expected_redacted_value_list=[REDACTED_SOCIAL_AUTH_UID_PREFIX],
)
assert not self.historical_social_auth_model.objects.filter(user=self.user).exists()
assert self.historical_social_auth_model.objects.filter(history_id=other_record.history_id).exists()
22 changes: 21 additions & 1 deletion openedx/core/djangoapps/user_api/accounts/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from completion.models import BlockCompletion
from completion.waffle import ENABLE_COMPLETION_TRACKING_SWITCH
from django.conf import settings
from django.db.models import CharField, Value
from django.db.models import CharField, TextField, Value
from django.db.models.functions import Cast, Concat
from django.utils.translation import gettext as _
from edx_django_utils.user import generate_password
Expand Down Expand Up @@ -226,6 +226,26 @@ def redact_and_delete_social_auth(user_id, skip_delete=False):
social_auth_queryset.delete()


def redact_and_delete_historical_social_auth(user_id):
"""
Redact PII from all HistoricalUserSocialAuth records for the given user, then delete them.

Downstream copies of data may use soft-deletes, and redacting before deleting
ensures PII for retired users (or future retirements) is not retained.
"""
historical_social_auth_model = UserSocialAuth.history.model
historical_queryset = historical_social_auth_model.objects.filter(user_id=user_id)
historical_queryset.update(
uid=Concat(
Value(REDACTED_SOCIAL_AUTH_UID_PREFIX),
Cast('history_id', output_field=TextField()),
Value(REDACTED_SOCIAL_AUTH_UID_SUFFIX),
),
Comment thread
Akanshu-2u marked this conversation as resolved.
extra_data={},
)
historical_queryset.delete()


def create_retirement_request_and_deactivate_account(user):
"""
Adds user to retirement queue, unlinks social auth accounts, changes user passwords
Expand Down
6 changes: 5 additions & 1 deletion openedx/core/djangoapps/user_api/accounts/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.user_api import accounts
from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_names, set_has_profile_image
from openedx.core.djangoapps.user_api.accounts.utils import handle_retirement_cancellation
from openedx.core.djangoapps.user_api.accounts.utils import (
handle_retirement_cancellation,
redact_and_delete_historical_social_auth,
)
from openedx.core.djangoapps.user_authn.exceptions import AuthFailedError
from openedx.core.lib.api.authentication import BearerAuthentication, BearerAuthenticationAllowInactiveUser
from openedx.core.lib.api.parsers import MergePatchParser
Expand Down Expand Up @@ -1104,6 +1107,7 @@ def post(self, request):
CreditRequest.retire_user(retirement)
ApiAccessRequest.retire_user(retirement.user)
CreditRequirementStatus.retire_user(retirement)
redact_and_delete_historical_social_auth(retirement.user.id)
Comment thread
Akanshu-2u marked this conversation as resolved.
Comment thread
robrap marked this conversation as resolved.

# This signal allows code in higher points of LMS to retire the user as necessary
USER_RETIRE_LMS_MISC.send(sender=self.__class__, user=retirement.user)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@
from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import (
setup_retirement_states, # noqa: F401
)
from openedx.core.djangoapps.user_api.accounts.tests.test_utils import (
assert_update_before_delete,
from openedx.core.djangoapps.user_api.accounts.utils import REDACTED_SOCIAL_AUTH_UID_PREFIX
from openedx.core.djangolib.testing.utils import ( # pylint: disable=wrong-import-order
assert_redact_before_delete,
skip_unless_lms,
)
from openedx.core.djangolib.testing.utils import skip_unless_lms # pylint: disable=wrong-import-order

from ...models import UserRetirementStatus

Expand Down Expand Up @@ -164,7 +165,11 @@ def test_retire_user_redacts_sso_pii_before_deletion(setup_retirement_states, so
with disconnected_social_auth_redaction_signal(), CaptureQueriesContext(connection) as ctx:
call_command('retire_user', username=user.username, user_email=user.email)

assert_update_before_delete([query['sql'] for query in ctx])
assert_redact_before_delete(
[query['sql'] for query in ctx],
table='social_auth_usersocialauth',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, we've been getting the table names from the model.

expected_redacted_value_list=[REDACTED_SOCIAL_AUTH_UID_PREFIX],
)
for auth_id in auth_ids:
assert not UserSocialAuth.objects.filter(id=auth_id).exists()

Expand Down
Loading