diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a00551c84..c4e0e8e42 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -17,6 +17,10 @@ Unreleased ---------- * nothing unreleased +[6.8.5] - 2026-03-31 +--------------------- +* feat: add AccountSettingsEnterpriseReadOnlyFieldsStep pipeline step (ENT-11510) + [6.8.4] - 2026-03-31 -------------------- * fix: hard delete customer admin records from API diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 9acac40a0..497d77447 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "6.8.4" +__version__ = "6.8.5" diff --git a/enterprise/filters/__init__.py b/enterprise/filters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/enterprise/filters/accounts.py b/enterprise/filters/accounts.py new file mode 100644 index 000000000..6759778db --- /dev/null +++ b/enterprise/filters/accounts.py @@ -0,0 +1,105 @@ +""" +Pipeline step for determining read-only account settings fields. +""" +from openedx_filters.filters import PipelineStep +from social_django.models import UserSocialAuth + +from django.conf import settings + +try: + from common.djangoapps import third_party_auth +except ImportError: + third_party_auth = None + +from enterprise.models import EnterpriseCustomerIdentityProvider, EnterpriseCustomerUser + + +class AccountSettingsEnterpriseReadOnlyFieldsStep(PipelineStep): + """ + Adds SSO-managed fields to the read-only account settings fields set. + + This step is intended to be registered as a pipeline step for the + ``org.openedx.learning.account.settings.read_only_fields.requested.v1`` filter. + + When a user is linked to an enterprise customer whose SSO identity provider has + ``sync_learner_profile_data`` enabled, the fields listed in + ``settings.ENTERPRISE_READONLY_ACCOUNT_FIELDS`` are added to ``readonly_fields``. + The ``"name"`` field is only added when the user has an existing ``UserSocialAuth`` + record for the enterprise IdP backend. + """ + + def run_filter(self, readonly_fields, user): # pylint: disable=arguments-differ + """ + Add enterprise SSO-managed fields to the read-only fields set. + + The original code migrated from openedx-platform can be distilled into 3 logical branches: + + 1. If NO identify provider (IdP) has sync enabled → no readonly fields added. + 2. If one or more IdPs have sync enabled, AND user has social auth → append ALL readonly fields. + 3. If one or more IdPs have sync enabled, AND user has NO social auth → append readonly fields MINUS 'name'. + + Each return statement below is marked with the corresponding branch number. + + Arguments: + readonly_fields (set): current set of read-only account field names. + user (User): the Django User whose account settings are being updated. + + Returns: + dict: updated pipeline data with ``readonly_fields`` key. + """ + enterprise_customer_user = ( + EnterpriseCustomerUser.objects.filter(user_id=user.id) + .order_by('-active', '-modified') + .select_related('enterprise_customer') + .first() + ) + if not enterprise_customer_user: + # Logical branch #1 (early exit) + return {"readonly_fields": readonly_fields, "user": user} + + enterprise_customer = enterprise_customer_user.enterprise_customer + + idp_records = list( + EnterpriseCustomerIdentityProvider.objects + .filter(enterprise_customer=enterprise_customer) + ) + + # Track whether any IdP for the customer is configured to sync learner profile data. If none are, then we can + # safely allow all fields to be editable since they won't get overwritten by the sync process + sync_learner_profile_data = False + + # Accumulate a list of all identity providers for the customer. If the learner does NOT have any social auth + # account configured with these backends, then we can safely allow them to edit the 'name' field (full name) + provider_backend_names = [] + + for idp in idp_records: + identity_provider = third_party_auth.provider.Registry.get( + provider_id=idp.provider_id + ) + if identity_provider and getattr(identity_provider, 'sync_learner_profile_data', False): + sync_learner_profile_data = True + + backend_name = getattr(identity_provider, 'backend_name', None) + if backend_name: + provider_backend_names.append(backend_name) + + # If none of the IdPs for the customer are configured to sync, allow the fields to be editable + if not sync_learner_profile_data: + # Logical branch #1 + return {"readonly_fields": readonly_fields, "user": user} + + # Determine if the learner has social auth configured. + has_social_auth = False + if provider_backend_names: + has_social_auth = UserSocialAuth.objects.filter( + provider__in=provider_backend_names, user=user + ).exists() + + enterprise_readonly = set(getattr(settings, 'ENTERPRISE_READONLY_ACCOUNT_FIELDS', [])) + + # If the learner does NOT have social auth configured, then at least allow them to edit their name. + if not has_social_auth: + enterprise_readonly = enterprise_readonly - {'name'} + + # Logical branch #2 and #3 + return {"readonly_fields": readonly_fields | enterprise_readonly, "user": user} diff --git a/enterprise/management/commands/update_enterprise_social_auth_uids.py b/enterprise/management/commands/update_enterprise_social_auth_uids.py index 8c40bf67f..78365f5f2 100644 --- a/enterprise/management/commands/update_enterprise_social_auth_uids.py +++ b/enterprise/management/commands/update_enterprise_social_auth_uids.py @@ -5,15 +5,12 @@ import csv import logging +from social_django.models import UserSocialAuth + from django.core.exceptions import ValidationError from django.core.management.base import BaseCommand from django.db import transaction -try: - from social_django.models import UserSocialAuth -except ImportError: - UserSocialAuth = None - logger = logging.getLogger(__name__) diff --git a/enterprise/tpa_pipeline.py b/enterprise/tpa_pipeline.py index b2dcb502e..e10c89e4a 100644 --- a/enterprise/tpa_pipeline.py +++ b/enterprise/tpa_pipeline.py @@ -6,21 +6,14 @@ from datetime import datetime from logging import getLogger +from social_core.pipeline.partial import partial +from social_django.models import UserSocialAuth + from django.urls import reverse from enterprise.models import EnterpriseCustomer, EnterpriseCustomerUser from enterprise.utils import get_identity_provider, get_social_auth_from_idp -try: - from social_core.pipeline.partial import partial -except ImportError: - from enterprise.decorators import null_decorator as partial - -try: - from social_django.models import UserSocialAuth -except ImportError: - UserSocialAuth = None - try: from common.djangoapps.third_party_auth.provider import Registry except ImportError: diff --git a/enterprise/utils.py b/enterprise/utils.py index f24025fc0..de27fa1cf 100644 --- a/enterprise/utils.py +++ b/enterprise/utils.py @@ -17,6 +17,7 @@ from edx_django_utils.cache import TieredCache from edx_django_utils.cache import get_cache_key as get_django_cache_key from slumber.exceptions import HttpClientError +from social_django.models import UserSocialAuth from django.apps import apps from django.conf import settings @@ -88,11 +89,6 @@ except ImportError: get_url = None -try: - from social_django.models import UserSocialAuth -except ImportError: - UserSocialAuth = None - # Only create manual enrollments if running in edx-platform try: from common.djangoapps.student.api import ( diff --git a/requirements/base.in b/requirements/base.in index 1062e118a..091f3f6fd 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -32,6 +32,7 @@ jsondiff jsonfield openedx-atlas openedx-events +openedx-filters paramiko path.py pillow @@ -41,6 +42,7 @@ requests rules slumber snowflake-connector-python +social-auth-app-django stevedore testfixtures unicodecsv diff --git a/requirements/dev.txt b/requirements/dev.txt index a11c5e0cf..acd7899d1 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -204,6 +204,8 @@ defusedxml==0.7.1 # -r requirements/test-master.txt # -r requirements/test.txt # djangorestframework-xml + # python3-openid + # social-auth-core diff-cover==10.2.0 # via -r requirements/test.txt dill==0.4.1 @@ -239,6 +241,8 @@ django==5.2.12 # edx-toggles # jsonfield # openedx-events + # openedx-filters + # social-auth-app-django django-cache-memoize==0.2.1 # via # -r requirements/doc.txt @@ -413,6 +417,7 @@ edx-opaque-keys[django]==3.1.0 # edx-ccx-keys # edx-drf-extensions # openedx-events + # openedx-filters edx-rbac==3.0.0 # via # -r requirements/doc.txt @@ -671,6 +676,8 @@ oauthlib==3.3.1 # -r requirements/test-master.txt # -r requirements/test.txt # django-oauth-toolkit + # requests-oauthlib + # social-auth-core openedx-atlas==0.7.0 # via # -r requirements/doc.txt @@ -681,6 +688,11 @@ openedx-events==11.1.0 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt +openedx-filters==2.1.0 + # via + # -r requirements/doc.txt + # -r requirements/test-master.txt + # -r requirements/test.txt packaging==26.0 # via # -r requirements/doc.txt @@ -823,6 +835,7 @@ pyjwt[crypto]==2.12.1 # edx-rest-api-client # firebase-admin # snowflake-connector-python + # social-auth-core pylint==3.3.9 # via # -c requirements/constraints.txt @@ -898,6 +911,10 @@ python-slugify==8.0.4 # -r requirements/test-master.txt # -r requirements/test.txt # code-annotations +python3-openid==3.2.0 + # via + # -r requirements/test.txt + # social-auth-core pytz==2026.1.post1 # via # -r requirements/doc.txt @@ -930,13 +947,19 @@ requests==2.32.5 # edx-rest-api-client # google-api-core # google-cloud-storage + # requests-oauthlib # requests-toolbelt # responses # sailthru-client # slumber # snowflake-connector-python + # social-auth-core # sphinx # twine +requests-oauthlib==2.0.0 + # via + # -r requirements/test.txt + # social-auth-core requests-toolbelt==1.0.0 # via twine responses==0.26.0 @@ -1007,6 +1030,12 @@ snowflake-connector-python==4.3.0 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt +social-auth-app-django==5.7.0 + # via -r requirements/test.txt +social-auth-core==4.8.5 + # via + # -r requirements/test.txt + # social-auth-app-django sortedcontainers==2.4.0 # via # -r requirements/doc.txt diff --git a/requirements/doc.in b/requirements/doc.in index 5279905ca..c9f54bafe 100644 --- a/requirements/doc.in +++ b/requirements/doc.in @@ -4,10 +4,10 @@ -r test-master.txt doc8 # reStructuredText style checker -sphinx-book-theme # Common theme for all Open edX projects -readme_renderer # Validates README.rst for usage on PyPI -Sphinx # Documentation builder docutils -factory-boy +edx-braze-client pytest -edx-braze-client \ No newline at end of file +factory-boy +readme_renderer # Validates README.rst for usage on PyPI +Sphinx # Documentation builder +sphinx-book-theme # Common theme for all Open edX projects \ No newline at end of file diff --git a/requirements/doc.txt b/requirements/doc.txt index 29503117a..f9406445d 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -123,6 +123,8 @@ defusedxml==0.7.1 # via # -r requirements/test-master.txt # djangorestframework-xml + # python3-openid + # social-auth-core django==5.2.12 # via # -c requirements/common_constraints.txt @@ -149,6 +151,8 @@ django==5.2.12 # edx-toggles # jsonfield # openedx-events + # openedx-filters + # social-auth-app-django django-cache-memoize==0.2.1 # via -r requirements/test-master.txt django-config-models==2.9.0 @@ -255,6 +259,7 @@ edx-opaque-keys[django]==3.1.0 # edx-ccx-keys # edx-drf-extensions # openedx-events + # openedx-filters edx-rbac==3.0.0 # via -r requirements/test-master.txt edx-rest-api-client==6.2.0 @@ -409,10 +414,14 @@ oauthlib==3.3.1 # via # -r requirements/test-master.txt # django-oauth-toolkit + # requests-oauthlib + # social-auth-core openedx-atlas==0.7.0 # via -r requirements/test-master.txt openedx-events==11.1.0 # via -r requirements/test-master.txt +openedx-filters==2.1.0 + # via -r requirements/test-master.txt packaging==26.0 # via # -r requirements/test-master.txt @@ -491,6 +500,7 @@ pyjwt[crypto]==2.12.1 # edx-rest-api-client # firebase-admin # snowflake-connector-python + # social-auth-core pymongo==4.4.0 # via # -r requirements/test-master.txt @@ -520,6 +530,8 @@ python-slugify==8.0.4 # via # -r requirements/test-master.txt # code-annotations +python3-openid==3.2.0 + # via social-auth-core pytz==2026.1.post1 # via # -r requirements/test-master.txt @@ -544,10 +556,14 @@ requests==2.32.5 # edx-rest-api-client # google-api-core # google-cloud-storage + # requests-oauthlib # sailthru-client # slumber # snowflake-connector-python + # social-auth-core # sphinx +requests-oauthlib==2.0.0 + # via social-auth-core restructuredtext-lint==2.0.2 # via doc8 roman-numerals==4.1.0 @@ -585,6 +601,10 @@ snowballstemmer==3.0.1 # via sphinx snowflake-connector-python==4.3.0 # via -r requirements/test-master.txt +social-auth-app-django==5.7.0 + # via -r requirements/doc.in +social-auth-core==4.8.5 + # via social-auth-app-django sortedcontainers==2.4.0 # via # -r requirements/test-master.txt diff --git a/requirements/test-master.txt b/requirements/test-master.txt index 81f15634d..4814aa4d2 100644 --- a/requirements/test-master.txt +++ b/requirements/test-master.txt @@ -146,6 +146,7 @@ django==5.2.12 # edx-toggles # jsonfield # openedx-events + # openedx-filters django-cache-memoize==0.2.1 # via # -c requirements/edx-platform-constraints.txt @@ -271,6 +272,7 @@ edx-opaque-keys[django]==3.1.0 # edx-ccx-keys # edx-drf-extensions # openedx-events + # openedx-filters edx-rbac==3.0.0 # via # -c requirements/edx-platform-constraints.txt @@ -430,6 +432,10 @@ openedx-events==11.1.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in +openedx-filters==2.1.0 + # via + # -c requirements/edx-platform-constraints.txt + # -r requirements/base.in packaging==26.0 # via # -c requirements/edx-platform-constraints.txt diff --git a/requirements/test.txt b/requirements/test.txt index 6c28daa1d..c37c67b0b 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -119,8 +119,11 @@ defusedxml==0.7.1 # via # -r requirements/test-master.txt # djangorestframework-xml + # python3-openid + # social-auth-core diff-cover==10.2.0 # via -r requirements/test.in +django==5.2.12 # via # -c requirements/common_constraints.txt # -r requirements/test-master.txt @@ -146,6 +149,8 @@ diff-cover==10.2.0 # edx-toggles # jsonfield # openedx-events + # openedx-filters + # social-auth-app-django django-cache-memoize==0.2.1 # via -r requirements/test-master.txt django-config-models==2.9.0 @@ -241,6 +246,7 @@ edx-opaque-keys[django]==3.1.0 # edx-ccx-keys # edx-drf-extensions # openedx-events + # openedx-filters edx-rbac==3.0.0 # via -r requirements/test-master.txt edx-rest-api-client==6.2.0 @@ -395,10 +401,14 @@ oauthlib==3.3.1 # via # -r requirements/test-master.txt # django-oauth-toolkit + # requests-oauthlib + # social-auth-core openedx-atlas==0.7.0 # via -r requirements/test-master.txt openedx-events==11.1.0 # via -r requirements/test-master.txt +openedx-filters==2.1.0 + # via -r requirements/test-master.txt packaging==26.0 # via # -r requirements/test-master.txt @@ -473,6 +483,7 @@ pyjwt[crypto]==2.12.1 # edx-rest-api-client # firebase-admin # snowflake-connector-python + # social-auth-core pymongo==4.4.0 # via # -r requirements/test-master.txt @@ -509,6 +520,8 @@ python-slugify==8.0.4 # via # -r requirements/test-master.txt # code-annotations +python3-openid==3.2.0 + # via social-auth-core pytz==2026.1.post1 # via # -r requirements/test-master.txt @@ -532,10 +545,14 @@ requests==2.32.5 # edx-rest-api-client # google-api-core # google-cloud-storage + # requests-oauthlib # responses # sailthru-client # slumber # snowflake-connector-python + # social-auth-core +requests-oauthlib==2.0.0 + # via social-auth-core responses==0.26.0 # via -r requirements/test.in rules==3.5 @@ -567,6 +584,10 @@ slumber==0.7.1 # via -r requirements/test-master.txt snowflake-connector-python==4.3.0 # via -r requirements/test-master.txt +social-auth-app-django==5.7.0 + # via -r requirements/test.in +social-auth-core==4.8.5 + # via social-auth-app-django sortedcontainers==2.4.0 # via # -r requirements/test-master.txt diff --git a/tests/filters/__init__.py b/tests/filters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/filters/test_accounts.py b/tests/filters/test_accounts.py new file mode 100644 index 000000000..95889c4ea --- /dev/null +++ b/tests/filters/test_accounts.py @@ -0,0 +1,262 @@ +""" +Tests for enterprise.filters.accounts pipeline step. +""" +from unittest.mock import MagicMock, patch + +from django.test import TestCase, override_settings + +from enterprise.filters.accounts import AccountSettingsEnterpriseReadOnlyFieldsStep + + +class TestAccountSettingsEnterpriseReadOnlyFieldsStep(TestCase): + """ + Tests for AccountSettingsEnterpriseReadOnlyFieldsStep pipeline step. + """ + + def _make_step(self): + return AccountSettingsEnterpriseReadOnlyFieldsStep( + "org.openedx.learning.account.settings.read_only_fields.requested.v1", + [], + ) + + def _mock_user(self, user_id=42): + user = MagicMock() + user.id = user_id + return user + + @patch('enterprise.filters.accounts.EnterpriseCustomerUser.objects') + def test_returns_unchanged_readonly_fields_when_no_enterprise_user(self, mock_objects): + """ + When the user has no enterprise link, readonly_fields is returned unchanged. + """ + mock_objects.filter.return_value.order_by.return_value.select_related.return_value.first.return_value = None + step = self._make_step() + fields = set() + user = self._mock_user() + result = step.run_filter(readonly_fields=fields, user=user) + self.assertEqual(result, {"readonly_fields": fields, "user": user}) + + @patch('enterprise.filters.accounts.UserSocialAuth') + @patch('enterprise.filters.accounts.EnterpriseCustomerIdentityProvider.objects') + @patch('enterprise.filters.accounts.EnterpriseCustomerUser.objects') + @override_settings(ENTERPRISE_READONLY_ACCOUNT_FIELDS=['name', 'email', 'country']) + def test_adds_readonly_fields_when_sso_sync_enabled( + self, mock_ecu_objects, mock_idp_objects, mock_user_social_auth + ): + """ + When enterprise SSO sync is enabled and social auth record exists, + ENTERPRISE_READONLY_ACCOUNT_FIELDS are added to readonly_fields. + """ + user = self._mock_user() + mock_ecu = MagicMock() + ( + mock_ecu_objects.filter.return_value + .order_by.return_value + .select_related.return_value + .first.return_value + ) = mock_ecu + mock_idp_record = MagicMock() + mock_idp_record.provider_id = 'saml-ubc' + mock_idp_objects.filter.return_value = [mock_idp_record] + mock_identity_provider = MagicMock() + mock_identity_provider.sync_learner_profile_data = True + mock_identity_provider.backend_name = 'tpa-saml' + mock_user_social_auth.objects.filter.return_value.exists.return_value = True + + mock_tpa = MagicMock() + mock_tpa.provider.Registry.get.return_value = mock_identity_provider + with patch('enterprise.filters.accounts.third_party_auth', mock_tpa): + step = self._make_step() + result = step.run_filter( + readonly_fields=set(), + user=user, + ) + + self.assertEqual(result["readonly_fields"], {"name", "email", "country"}) + + @patch('enterprise.filters.accounts.UserSocialAuth') + @patch('enterprise.filters.accounts.EnterpriseCustomerIdentityProvider.objects') + @patch('enterprise.filters.accounts.EnterpriseCustomerUser.objects') + @override_settings(ENTERPRISE_READONLY_ACCOUNT_FIELDS=['name', 'email']) + def test_name_not_added_without_social_auth_record( + self, mock_ecu_objects, mock_idp_objects, mock_user_social_auth + ): + """ + The 'name' field is not added when the user has no UserSocialAuth record. + """ + user = self._mock_user() + ( + mock_ecu_objects.filter.return_value + .order_by.return_value + .select_related.return_value + .first.return_value + ) = MagicMock() + mock_idp_record = MagicMock() + mock_idp_record.provider_id = 'saml-ubc' + mock_idp_objects.filter.return_value = [mock_idp_record] + mock_identity_provider = MagicMock() + mock_identity_provider.sync_learner_profile_data = True + mock_identity_provider.backend_name = 'tpa-saml' + mock_user_social_auth.objects.filter.return_value.exists.return_value = False + + mock_tpa = MagicMock() + mock_tpa.provider.Registry.get.return_value = mock_identity_provider + with patch('enterprise.filters.accounts.third_party_auth', mock_tpa): + step = self._make_step() + result = step.run_filter( + readonly_fields=set(), + user=user, + ) + + self.assertNotIn("name", result["readonly_fields"]) + self.assertIn("email", result["readonly_fields"]) + + @patch('enterprise.filters.accounts.EnterpriseCustomerIdentityProvider.objects') + @patch('enterprise.filters.accounts.EnterpriseCustomerUser.objects') + def test_returns_unchanged_readonly_fields_when_no_idp_record( + self, mock_ecu_objects, mock_idp_objects + ): + """ + When the enterprise customer has no linked identity provider, + readonly_fields is returned unchanged. + """ + user = self._mock_user() + ( + mock_ecu_objects.filter.return_value + .order_by.return_value + .select_related.return_value + .first.return_value + ) = MagicMock() + mock_idp_objects.filter.return_value = [] + step = self._make_step() + fields = {'existing_field'} + result = step.run_filter(readonly_fields=fields, user=user) + self.assertEqual(result["readonly_fields"], fields) + + @patch('enterprise.filters.accounts.EnterpriseCustomerIdentityProvider.objects') + @patch('enterprise.filters.accounts.EnterpriseCustomerUser.objects') + def test_returns_unchanged_readonly_fields_when_sync_not_enabled( + self, mock_ecu_objects, mock_idp_objects + ): + """ + When the identity provider exists but sync_learner_profile_data is False, + readonly_fields is returned unchanged. + """ + user = self._mock_user() + ( + mock_ecu_objects.filter.return_value + .order_by.return_value + .select_related.return_value + .first.return_value + ) = MagicMock() + mock_idp_record = MagicMock(provider_id='saml-test') + mock_idp_objects.filter.return_value = [mock_idp_record] + mock_identity_provider = MagicMock() + mock_identity_provider.sync_learner_profile_data = False + mock_tpa = MagicMock() + mock_tpa.provider.Registry.get.return_value = mock_identity_provider + step = self._make_step() + fields = {'existing_field'} + with patch('enterprise.filters.accounts.third_party_auth', mock_tpa): + result = step.run_filter(readonly_fields=fields, user=user) + self.assertEqual(result["readonly_fields"], fields) + + @patch('enterprise.filters.accounts.UserSocialAuth') + @patch('enterprise.filters.accounts.EnterpriseCustomerIdentityProvider.objects') + @patch('enterprise.filters.accounts.EnterpriseCustomerUser.objects') + @override_settings(ENTERPRISE_READONLY_ACCOUNT_FIELDS=['name', 'email', 'country']) + def test_multiple_identity_providers_only_one_sync_enabled( + self, mock_ecu_objects, mock_idp_objects, mock_user_social_auth + ): + """ + When multiple IdPs exist and only one has sync enabled, readonly fields are still added. + """ + user = self._mock_user() + ( + mock_ecu_objects.filter.return_value + .order_by.return_value + .select_related.return_value + .first.return_value + ) = MagicMock() + + mock_idp_no_sync = MagicMock(provider_id='saml-no-sync') + mock_idp_with_sync = MagicMock(provider_id='saml-with-sync') + mock_idp_objects.filter.return_value = [mock_idp_no_sync, mock_idp_with_sync] + + mock_provider_no_sync = MagicMock(sync_learner_profile_data=False, backend_name='tpa-saml-no-sync') + mock_provider_with_sync = MagicMock(sync_learner_profile_data=True, backend_name='tpa-saml-sync') + + mock_tpa = MagicMock() + mock_tpa.provider.Registry.get.side_effect = lambda provider_id: ( + mock_provider_no_sync if provider_id == 'saml-no-sync' else mock_provider_with_sync + ) + mock_user_social_auth.objects.filter.return_value.exists.return_value = True + + with patch('enterprise.filters.accounts.third_party_auth', mock_tpa): + step = self._make_step() + result = step.run_filter(readonly_fields=set(), user=user) + + self.assertEqual(result["readonly_fields"], {"name", "email", "country"}) + + @patch('enterprise.filters.accounts.EnterpriseCustomerIdentityProvider.objects') + @patch('enterprise.filters.accounts.EnterpriseCustomerUser.objects') + def test_returns_unchanged_readonly_fields_when_registry_returns_none( + self, mock_ecu_objects, mock_idp_objects + ): + """ + When Registry.get returns None for an IdP, sync_learner_profile_data stays False + and readonly_fields is returned unchanged. + """ + user = self._mock_user() + ( + mock_ecu_objects.filter.return_value + .order_by.return_value + .select_related.return_value + .first.return_value + ) = MagicMock() + mock_idp_record = MagicMock(provider_id='saml-unknown') + mock_idp_objects.filter.return_value = [mock_idp_record] + + mock_tpa = MagicMock() + mock_tpa.provider.Registry.get.return_value = None + + fields = {'existing_field'} + with patch('enterprise.filters.accounts.third_party_auth', mock_tpa): + step = self._make_step() + result = step.run_filter(readonly_fields=fields, user=user) + + self.assertEqual(result["readonly_fields"], fields) + + @patch('enterprise.filters.accounts.UserSocialAuth') + @patch('enterprise.filters.accounts.EnterpriseCustomerIdentityProvider.objects') + @patch('enterprise.filters.accounts.EnterpriseCustomerUser.objects') + @override_settings(ENTERPRISE_READONLY_ACCOUNT_FIELDS=['name', 'email']) + def test_name_excluded_when_sync_enabled_but_no_backend_name( + self, mock_ecu_objects, mock_idp_objects, mock_user_social_auth + ): + """ + When sync is enabled but the provider has no backend_name, provider_backend_names + stays empty, UserSocialAuth is not queried, has_social_auth stays False, + and 'name' is excluded (branch #3 behavior). + """ + user = self._mock_user() + ( + mock_ecu_objects.filter.return_value + .order_by.return_value + .select_related.return_value + .first.return_value + ) = MagicMock() + mock_idp_record = MagicMock(provider_id='saml-ubc') + mock_idp_objects.filter.return_value = [mock_idp_record] + # sync is True but backend_name is falsy + mock_identity_provider = MagicMock(sync_learner_profile_data=True, backend_name=None) + mock_tpa = MagicMock() + mock_tpa.provider.Registry.get.return_value = mock_identity_provider + + with patch('enterprise.filters.accounts.third_party_auth', mock_tpa): + step = self._make_step() + result = step.run_filter(readonly_fields=set(), user=user) + + self.assertNotIn('name', result["readonly_fields"]) + self.assertIn('email', result["readonly_fields"]) + mock_user_social_auth.objects.filter.assert_not_called() diff --git a/tests/test_tpa_pipeline.py b/tests/test_tpa_pipeline.py index db135054b..c1e7bda8e 100644 --- a/tests/test_tpa_pipeline.py +++ b/tests/test_tpa_pipeline.py @@ -72,7 +72,7 @@ def test_handle_enterprise_logistration_user_linking( fake_get_sso_provider.return_value = provider_config.provider_id fake_get_ec.return_value = enterprise_customer - assert handle_enterprise_logistration(backend, self.user) is None + assert handle_enterprise_logistration.__wrapped__(backend, self.user) is None assert EnterpriseCustomerUser.objects.filter( enterprise_customer=enterprise_customer, user_id=self.user.id, @@ -92,7 +92,7 @@ def test_handle_enterprise_logistration_not_user_linking(self): ) fake_get_ec.return_value = None fake_get_sso_provider.return_value = None - assert handle_enterprise_logistration(backend, self.user) is None + assert handle_enterprise_logistration.__wrapped__(backend, self.user) is None assert EnterpriseCustomerUser.objects.filter( enterprise_customer=enterprise_customer, user_id=self.user.id, @@ -123,7 +123,7 @@ def test_handle_enterprise_logistration_user_multiple_enterprises_linking(self): ) fake_get_ec.return_value = enterprise_customer fake_get_sso_provider.return_value = 'test-sso-provider-id' - assert handle_enterprise_logistration(backend, self.user) is None + assert handle_enterprise_logistration.__wrapped__(backend, self.user) is None assert EnterpriseCustomerUser.objects.filter( enterprise_customer=enterprise_customer, user_id=self.user.id, @@ -175,7 +175,7 @@ def test_social_auth_user_login_associated_with_multiple_enterprise(self, with mock.patch('enterprise.tpa_pipeline.get_sso_provider') as fake_get_sso_provider: fake_get_sso_provider.return_value = None fake_get_ec.return_value = None - handle_enterprise_logistration(backend, self.user, **kwargs) + handle_enterprise_logistration.__wrapped__(backend, self.user, **kwargs) if new_association: ent_page_redirect.assert_not_called() else: @@ -210,7 +210,7 @@ def test_social_auth_user_login_associated_with_one_enterprise(self, new_associa with mock.patch('enterprise.tpa_pipeline.get_sso_provider') as fake_get_sso_provider: fake_get_sso_provider.return_value = None fake_get_ec.return_value = None - handle_enterprise_logistration(backend, self.user, **kwargs) + handle_enterprise_logistration.__wrapped__(backend, self.user, **kwargs) ent_page_redirect.assert_not_called() @ddt.data( @@ -262,7 +262,7 @@ def test_bypass_enterprise_selection_page_for_enrollment_url_login(self, with mock.patch('enterprise.tpa_pipeline.get_sso_provider') as fake_get_sso_provider: fake_get_sso_provider.return_value = None fake_get_ec.return_value = None - handle_enterprise_logistration(backend, self.user, **kwargs) + handle_enterprise_logistration.__wrapped__(backend, self.user, **kwargs) if new_association or using_enrollment_url: ent_page_redirect.assert_not_called() else: @@ -309,6 +309,6 @@ def test_enterprise_logistration_validates_sso_orchestration_config(self): fake_get_ec.return_value = enterprise_customer fake_get_sso_provider.return_value = 'test-sso-provider-id' - assert handle_enterprise_logistration(backend, self.user) is None + assert handle_enterprise_logistration.__wrapped__(backend, self.user) is None customer_sso_integration_config.refresh_from_db() assert customer_sso_integration_config.validated_at is not None