Skip to content

Commit 36192df

Browse files
fix: Redact SSO PII before deletion
1 parent 9aa4192 commit 36192df

3 files changed

Lines changed: 79 additions & 78 deletions

File tree

openedx/core/djangoapps/user_api/accounts/tests/test_signals.py

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,24 +42,24 @@ def test_get_redacted_social_auth_uid_format(self):
4242
assert get_redacted_social_auth_uid(42) == 'redacted-before-delete-42@safe.com'
4343
assert get_redacted_social_auth_uid(1) == 'redacted-before-delete-1@safe.com'
4444

45-
def test_signal_warns_and_redacts_when_not_already_redacted(self):
45+
@patch('openedx.core.djangoapps.user_api.accounts.signals.redact_and_delete_social_auth')
46+
def test_signal_warns_and_redacts_when_not_already_redacted(self, mock_redact):
4647
"""
4748
When a UserSocialAuth is deleted without prior redaction, the signal handler
4849
should log a warning and call redact_and_delete_social_auth with skip_delete=True.
4950
"""
5051
social_auth = self._create_social_auth()
5152

52-
with patch(
53-
'openedx.core.djangoapps.user_api.accounts.signals.redact_and_delete_social_auth'
54-
) as mock_redact, self.assertLogs(
53+
with self.assertLogs(
5554
'openedx.core.djangoapps.user_api.accounts.signals', level=logging.WARNING
5655
) as log_ctx:
5756
social_auth.delete()
5857

5958
mock_redact.assert_called_once_with(self.user.id, skip_delete=True)
6059
assert any('was deleted without first being redacted' in msg for msg in log_ctx.output)
6160

62-
def test_signal_skips_warning_and_redaction_when_already_redacted(self):
61+
@patch('openedx.core.djangoapps.user_api.accounts.signals.redact_and_delete_social_auth')
62+
def test_signal_skips_warning_and_redaction_when_already_redacted(self, mock_redact):
6363
"""
6464
When a UserSocialAuth is already redacted before deletion, the signal handler
6565
should not log a warning and should not call redact_and_delete_social_auth.
@@ -70,10 +70,7 @@ def test_signal_skips_warning_and_redaction_when_already_redacted(self):
7070
social_auth.save(update_fields=['uid', 'extra_data'])
7171
social_auth_id = social_auth.id
7272

73-
with patch(
74-
'openedx.core.djangoapps.user_api.accounts.signals.redact_and_delete_social_auth'
75-
) as mock_redact:
76-
social_auth.delete()
73+
social_auth.delete()
7774

7875
mock_redact.assert_not_called()
7976
assert not UserSocialAuth.objects.filter(id=social_auth_id).exists()

openedx/core/djangoapps/user_api/accounts/tests/test_utils.py

Lines changed: 67 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
""" Unit tests for custom UserProfile properties. """
22

3+
from contextlib import contextmanager
4+
35
import ddt
46
from completion import models
57
from completion.test_utils import CompletionWaffleTestMixin
@@ -28,6 +30,39 @@
2830
from ..utils import format_social_link, validate_social_link
2931

3032

33+
def assert_update_before_delete(sql_list, num_redact_delete_pairs=1, table='social_auth_usersocialauth'):
34+
"""
35+
Assert that UPDATE and DELETE queries for ``table`` occur in consecutive pairs.
36+
"""
37+
table_key = table.upper()
38+
expected_sql_list = [
39+
sql for sql in sql_list
40+
if table_key in sql.upper() and ('UPDATE' in sql.upper() or 'DELETE' in sql.upper())
41+
]
42+
assert len(expected_sql_list) == num_redact_delete_pairs * 2, (
43+
f'Expected {num_redact_delete_pairs * 2} UPDATE/DELETE queries on {table}, '
44+
f'got {len(expected_sql_list)}'
45+
)
46+
47+
for index in range(0, len(expected_sql_list), 2):
48+
update_sql = expected_sql_list[index]
49+
delete_sql = expected_sql_list[index + 1]
50+
assert 'UPDATE' in update_sql.upper(), f'Expected UPDATE at position {index} for {table}'
51+
assert 'DELETE' in delete_sql.upper(), f'Expected DELETE at position {index + 1} for {table}'
52+
53+
# Use a context manager to guarantee signal reconnection between tests.
54+
@contextmanager
55+
def disconnected_social_auth_redaction_signal():
56+
"""
57+
Temporarily disconnect the fallback signal so tests exercise the helper path.
58+
"""
59+
pre_delete.disconnect(redact_social_auth_pii_before_deletion, sender=UserSocialAuth)
60+
try:
61+
yield
62+
finally:
63+
pre_delete.connect(redact_social_auth_pii_before_deletion, sender=UserSocialAuth)
64+
65+
3166
@ddt.ddt
3267
class UserAccountSettingsTest(TestCase):
3368
"""Unit tests for setting Social Media Links."""
@@ -146,45 +181,12 @@ def test_retrieve_last_sitewide_block_completed(self):
146181
class RedactAndDeleteSocialAuthTest(TestCase):
147182
"""
148183
Tests for the redact_and_delete_social_auth utility function.
149-
150-
The safety-net pre_delete signal handler is disconnected for all tests in this class
151-
to verify that redact_and_delete_social_auth itself redacts before deleting,
152-
not the fallback signal.
153184
"""
154185

155-
@classmethod
156-
def setUpClass(cls):
157-
super().setUpClass()
158-
# Disconnect the safety-net signal so tests verify redact_and_delete_social_auth
159-
# itself issues the UPDATE before DELETE, not the signal.
160-
pre_delete.disconnect(redact_social_auth_pii_before_deletion, sender=UserSocialAuth)
161-
162-
@classmethod
163-
def tearDownClass(cls):
164-
super().tearDownClass()
165-
# Reconnect the signal after the test class completes.
166-
pre_delete.connect(redact_social_auth_pii_before_deletion, sender=UserSocialAuth)
167-
168186
def setUp(self):
169187
super().setUp()
170188
self.user = UserFactory.create(username='testuser', email='testuser@example.com')
171189

172-
def _assert_update_before_delete(self, sql_list, table='social_auth_usersocialauth'):
173-
"""
174-
Assert that an UPDATE on ``table`` runs before the DELETE on ``table``.
175-
176-
This verifies that redaction happens in the helper itself, not via the
177-
fallback pre_delete signal.
178-
"""
179-
table_key = table.upper()
180-
update_indices = [i for i, sql in enumerate(sql_list) if 'UPDATE' in sql.upper() and table_key in sql.upper()]
181-
delete_indices = [i for i, sql in enumerate(sql_list) if 'DELETE' in sql.upper() and table_key in sql.upper()]
182-
assert update_indices, f'Expected at least one UPDATE on {table}'
183-
assert delete_indices, f'Expected at least one DELETE on {table}'
184-
assert any(update_idx < delete_idx for update_idx in update_indices for delete_idx in delete_indices), (
185-
'Expected at least one UPDATE to precede at least one DELETE'
186-
)
187-
188190
def create_social_auth(self, provider='google-oauth2', uid='user@example.com', extra_data=None):
189191
"""
190192
Helper method to create UserSocialAuth instances for testing.
@@ -201,29 +203,42 @@ def create_social_auth(self, provider='google-oauth2', uid='user@example.com', e
201203
extra_data=extra_data,
202204
)
203205

204-
@ddt.data(
205-
{
206-
'provider': 'google-oauth2',
207-
'uid': 'google@example.com',
208-
'extra_data': {'email': 'google@example.com', 'name': 'Google User'}
209-
},
210-
{
211-
'provider': 'tpa-saml',
212-
'uid': 'saml@example.com',
213-
'extra_data': {'email': 'saml@example.com', 'name': 'SAML User', 'uid': 'saml-uid'}
214-
}
215-
)
216-
@ddt.unpack
217-
def test_redact_and_delete_redacts_multiple_sso_providers(self, provider, uid, extra_data):
206+
def test_redact_and_delete_redacts_single_sso_record(self):
218207
"""
219-
Test that redact_and_delete_social_auth redacts and deletes records for
220-
multiple SSO providers in a single call.
208+
Test that redact_and_delete_social_auth redacts and deletes a single SSO record.
221209
"""
222-
social_auth = self.create_social_auth(provider=provider, uid=uid, extra_data=extra_data)
210+
social_auth = self.create_social_auth(
211+
provider='google-oauth2',
212+
uid='google@example.com',
213+
extra_data={'email': 'google@example.com', 'name': 'Google User'},
214+
)
223215
social_auth_id = social_auth.pk
224216

225-
with CaptureQueriesContext(connection) as ctx:
217+
with disconnected_social_auth_redaction_signal(), CaptureQueriesContext(connection) as ctx:
226218
redact_and_delete_social_auth(self.user.id)
227219

228-
self._assert_update_before_delete([query['sql'] for query in ctx])
220+
assert_update_before_delete([query['sql'] for query in ctx])
229221
assert not UserSocialAuth.objects.filter(id=social_auth_id).exists()
222+
223+
def test_redact_and_delete_redacts_multiple_sso_records(self):
224+
"""
225+
Test that redact_and_delete_social_auth redacts and deletes all SSO records for a user.
226+
"""
227+
social_auth_ids = [
228+
self.create_social_auth(
229+
provider='google-oauth2',
230+
uid='google@example.com',
231+
extra_data={'email': 'google@example.com', 'name': 'Google User'},
232+
).pk,
233+
self.create_social_auth(
234+
provider='tpa-saml',
235+
uid='saml@example.com',
236+
extra_data={'email': 'saml@example.com', 'name': 'SAML User', 'uid': 'saml-uid'},
237+
).pk,
238+
]
239+
240+
with disconnected_social_auth_redaction_signal(), CaptureQueriesContext(connection) as ctx:
241+
redact_and_delete_social_auth(self.user.id)
242+
243+
assert_update_before_delete([query['sql'] for query in ctx])
244+
assert not UserSocialAuth.objects.filter(id__in=social_auth_ids).exists()

openedx/core/djangoapps/user_api/management/tests/test_retire_user.py

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,23 @@
1616
from social_django.models import UserSocialAuth
1717

1818
from common.djangoapps.student.tests.factories import UserFactory # lint-amnesty, pylint: disable=wrong-import-order
19-
from openedx.core.djangoapps.user_api.accounts.signals import ( # lint-amnesty, pylint: disable=wrong-import-order
19+
from openedx.core.djangoapps.user_api.accounts.signals import (
2020
redact_social_auth_pii_before_deletion,
2121
)
22-
from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import ( # lint-amnesty, pylint: disable=unused-import, wrong-import-order
22+
from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import (
2323
setup_retirement_states, # noqa: F401
2424
)
25+
from openedx.core.djangoapps.user_api.accounts.tests.test_utils import (
26+
assert_update_before_delete,
27+
)
2528
from openedx.core.djangolib.testing.utils import skip_unless_lms # pylint: disable=wrong-import-order
2629

2730
from ...models import UserRetirementStatus
2831

2932
pytestmark = pytest.mark.django_db
3033
user_file = 'userfile.csv'
3134

32-
35+
# Use a context manager to guarantee signal reconnection between tests.
3336
@contextmanager
3437
def disconnected_social_auth_redaction_signal():
3538
"""
@@ -42,20 +45,6 @@ def disconnected_social_auth_redaction_signal():
4245
pre_delete.connect(redact_social_auth_pii_before_deletion, sender=UserSocialAuth)
4346

4447

45-
def assert_update_before_delete(sql_list, table='social_auth_usersocialauth'):
46-
"""
47-
Assert that at least one social auth UPDATE occurs before a DELETE.
48-
"""
49-
table_key = table.upper()
50-
update_indices = [i for i, sql in enumerate(sql_list) if 'UPDATE' in sql.upper() and table_key in sql.upper()]
51-
delete_indices = [i for i, sql in enumerate(sql_list) if 'DELETE' in sql.upper() and table_key in sql.upper()]
52-
assert update_indices, f'Expected at least one UPDATE on {table}'
53-
assert delete_indices, f'Expected at least one DELETE on {table}'
54-
assert any(update_idx < delete_idx for update_idx in update_indices for delete_idx in delete_indices), (
55-
'Expected at least one UPDATE to precede at least one DELETE'
56-
)
57-
58-
5948
def generate_dummy_users():
6049
"""
6150
Function to generate dummy users that needs to be retired

0 commit comments

Comments
 (0)