diff --git a/openedx/core/djangoapps/user_api/accounts/serializers.py b/openedx/core/djangoapps/user_api/accounts/serializers.py index a23ba772986e..3c5aafa14c54 100644 --- a/openedx/core/djangoapps/user_api/accounts/serializers.py +++ b/openedx/core/djangoapps/user_api/accounts/serializers.py @@ -591,8 +591,19 @@ def get_extended_profile(user_profile): def get_profile_visibility(user_profile, user, configuration): """ Returns the visibility level for the specified user profile. - """ - if user_profile.requires_parental_consent(): + + When ENABLE_COPPA_COMPLIANCE is True, the platform deliberately does not + collect year_of_birth for any learner, so ``year_of_birth is None`` no + longer signals "unknown age, assume minor". In that mode, pass + ``default_requires_consent=False`` so that users whose year_of_birth is + None solely because the platform refused to collect it fall through to + their explicit account_privacy preference. When COPPA mode is off, the + historical conservative default (None => treat as minor) is preserved. + Learners with a real year_of_birth below PARENTAL_CONSENT_AGE_LIMIT are + still forced to PRIVATE regardless of the flag. See issue #37987. + """ + default_requires_consent = not getattr(settings, 'ENABLE_COPPA_COMPLIANCE', False) + if user_profile.requires_parental_consent(default_requires_consent=default_requires_consent): return PRIVATE_VISIBILITY # Calling UserPreference directly because the requesting user may be different from existing_user diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_serializers.py b/openedx/core/djangoapps/user_api/accounts/tests/test_serializers.py index d77ef74c234d..b15cf4cedd5b 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_serializers.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_serializers.py @@ -7,13 +7,25 @@ from django.test import TestCase from django.test.client import RequestFactory +from django.test.utils import override_settings +from django.utils.timezone import now from testfixtures import LogCapture from common.djangoapps.student.models import UserProfile from common.djangoapps.student.tests.factories import UserFactory -from openedx.core.djangoapps.user_api.accounts.serializers import UserReadOnlySerializer +from openedx.core.djangoapps.user_api.accounts import ( + ALL_USERS_VISIBILITY, + PRIVATE_VISIBILITY, +) +from openedx.core.djangoapps.user_api.accounts.serializers import ( + UserReadOnlySerializer, + get_profile_visibility, +) +from openedx.core.djangoapps.user_api.models import UserPreference +from openedx.core.djangoapps.user_api.preferences.api import set_user_preference LOGGER_NAME = "openedx.core.djangoapps.user_api.accounts.serializers" +ACCOUNT_VISIBILITY_PREF_KEY = 'account_privacy' class UserReadOnlySerializerTest(TestCase): # lint-amnesty, pylint: disable=missing-class-docstring @@ -52,3 +64,88 @@ def test_user_no_profile(self): assert data['username'] == self.user.username assert data['name'] is None + + +class GetProfileVisibilityCoppaTest(TestCase): + """ + Regression tests for Bug #37987. + + When ENABLE_COPPA_COMPLIANCE=True the platform scrubs year_of_birth for + every learner at registration. The 2015 ``requires_parental_consent()`` + default treats a missing year_of_birth as "unknown age, assume minor" + and ``get_profile_visibility`` therefore forced ``PRIVATE_VISIBILITY``, + silently overriding the user's explicit ``account_privacy`` preference. + """ + + VISIBILITY_CONFIGURATION = { + 'default_visibility': ALL_USERS_VISIBILITY, + 'public_fields': ['account_privacy', 'profile_image', 'username'], + } + + def setUp(self): + super().setUp() + self.user = UserFactory.create() + self.profile = UserProfile.objects.get(user=self.user) + self.profile.year_of_birth = None + self.profile.save() + + @override_settings(ENABLE_COPPA_COMPLIANCE=True) + def test_unit_coppa_mode_honors_all_users_preference(self): + """ + Unit test for Bug #37987: when COPPA mode is on and year_of_birth + is None, the user's explicit all_users preference must be honored. + """ + set_user_preference(self.user, ACCOUNT_VISIBILITY_PREF_KEY, ALL_USERS_VISIBILITY) + visibility = get_profile_visibility(self.profile, self.user, self.VISIBILITY_CONFIGURATION) + assert visibility == ALL_USERS_VISIBILITY + + @override_settings(ENABLE_COPPA_COMPLIANCE=True) + def test_unit_coppa_mode_honors_private_preference(self): + """Under COPPA mode, explicit private preference still works.""" + set_user_preference(self.user, ACCOUNT_VISIBILITY_PREF_KEY, PRIVATE_VISIBILITY) + visibility = get_profile_visibility(self.profile, self.user, self.VISIBILITY_CONFIGURATION) + assert visibility == PRIVATE_VISIBILITY + + @override_settings(ENABLE_COPPA_COMPLIANCE=True) + def test_integration_coppa_mode_falls_back_to_configuration_default(self): + """ + With no preference set under COPPA mode, fall back to the configured + default_visibility. Before the fix this returned PRIVATE_VISIBILITY. + """ + UserPreference.objects.filter(user=self.user, key=ACCOUNT_VISIBILITY_PREF_KEY).delete() + visibility = get_profile_visibility(self.profile, self.user, self.VISIBILITY_CONFIGURATION) + assert visibility == ALL_USERS_VISIBILITY + + @override_settings(ENABLE_COPPA_COMPLIANCE=False) + def test_non_coppa_mode_preserves_legacy_private_default(self): + """ + When COPPA mode is off and year_of_birth is genuinely unknown, the + pre-2021 contract MUST be preserved: force PRIVATE_VISIBILITY even + if the user chose all_users. + """ + set_user_preference(self.user, ACCOUNT_VISIBILITY_PREF_KEY, ALL_USERS_VISIBILITY) + visibility = get_profile_visibility(self.profile, self.user, self.VISIBILITY_CONFIGURATION) + assert visibility == PRIVATE_VISIBILITY + + @override_settings(ENABLE_COPPA_COMPLIANCE=True, PARENTAL_CONSENT_AGE_LIMIT=13) + def test_bug_37987_regression_coppa_still_blocks_real_minor(self): + """ + Regression for #37987: if a profile has a real year_of_birth that + puts the user under PARENTAL_CONSENT_AGE_LIMIT, visibility must + still be forced PRIVATE even under COPPA mode. The COPPA flag must + not become a backdoor to publish minor profiles. + """ + self.profile.year_of_birth = now().year - 8 + self.profile.save() + set_user_preference(self.user, ACCOUNT_VISIBILITY_PREF_KEY, ALL_USERS_VISIBILITY) + visibility = get_profile_visibility(self.profile, self.user, self.VISIBILITY_CONFIGURATION) + assert visibility == PRIVATE_VISIBILITY + + @override_settings(ENABLE_COPPA_COMPLIANCE=True, PARENTAL_CONSENT_AGE_LIMIT=13) + def test_coppa_mode_allows_adult_with_real_year_of_birth(self): + """Adult with real year_of_birth is public under COPPA mode + all_users.""" + self.profile.year_of_birth = now().year - 25 + self.profile.save() + set_user_preference(self.user, ACCOUNT_VISIBILITY_PREF_KEY, ALL_USERS_VISIBILITY) + visibility = get_profile_visibility(self.profile, self.user, self.VISIBILITY_CONFIGURATION) + assert visibility == ALL_USERS_VISIBILITY