diff --git a/cms/lib/spectacular.py b/cms/lib/spectacular.py index 662cf80c65f0..323b405f2cd9 100644 --- a/cms/lib/spectacular.py +++ b/cms/lib/spectacular.py @@ -4,8 +4,8 @@ from drf_spectacular.openapi import AutoSchema -# Legacy operations superseded by a conforming address under /api/authoring/. -# Each is listed in the truncated spelling as well as the full one: the routes +# Legacy operations superseded by a conforming address. Each contentstore one +# is listed in the truncated spelling as well as the full one: the routes # make the final character optional, so both addresses resolve, and which one # the generator emits depends on how it renders that regex. SUPERSEDED_PATHS = ( @@ -13,6 +13,36 @@ "/api/contentstore/v0/youtube_transcripts/{course_id}/check", "/api/contentstore/v0/youtube_transcripts/{course_id}/uploa", "/api/contentstore/v0/youtube_transcripts/{course_id}/upload", + # The user API this service mounts as the LMS does; the LMS serves the replacements. + # The preference addresses are left out: this service's own pages change a + # preference through them, and it serves no replacement for them. + "/api/user/v1/me", + "/api/user/v1/accounts", + "/api/user/v1/accounts/search_emails", + "/api/user/v1/accounts/{username}", + "/api/user/v1/accounts/{username}/deactivate/", + "/api/user/v1/accounts/{username}/image", + "/api/user/v1/accounts/{username}/verification_status/", + "/api/user/v1/accounts/{username}/verifications/", + "/api/user/v1/accounts/name_change/", + "/api/user/v1/accounts/name_change/{username}/confirm/", + "/api/user/v1/users/", + "/api/user/v1/users/{id}/", + "/api/user/v1/user_prefs/", + "/api/user/v1/user_prefs/{id}/", + "/api/user/v1/preferences/{pref_key}/users/", + "/api/user/v1/forum_roles/{name}/users/", + "/api/user/v1/preferences/time_zones/", + "/api/user/v1/accounts/{username}/retirement_status/", + "/api/user/v1/accounts/retirement_queue/", + "/api/user/v1/accounts/retirements_by_status_and_date/", + "/api/user/v1/accounts/update_retirement_status/", + "/api/user/v1/accounts/retirement_cleanup/", + "/api/user/v1/accounts/retirement_partner_report/", + "/api/user/v1/accounts/retirement_partner_report_cleanup/", + "/api/user/v1/accounts/cancel_retirement/", + "/api/user/v1/accounts/retire/", + "/api/user/v1/accounts/retire_misc/", ) # Legacy addresses of APIs migrated to /api/authoring/, marked deprecated for diff --git a/cms/lib/tests/test_spectacular.py b/cms/lib/tests/test_spectacular.py index 193f3c98c448..b9496e56aef7 100644 --- a/cms/lib/tests/test_spectacular.py +++ b/cms/lib/tests/test_spectacular.py @@ -1,14 +1,17 @@ """Tests for the CMS drf-spectacular hooks and schema class.""" import re +from importlib import import_module from pathlib import Path from unittest import TestCase, mock from django.conf import settings from django.test import SimpleTestCase +from django.test import TestCase as DjangoTestCase from django.urls import path as url_path from drf_spectacular.generators import SchemaGenerator -from drf_spectacular.settings import spectacular_settings +from drf_spectacular.openapi import AutoSchema +from drf_spectacular.settings import patched_settings, spectacular_settings from rest_framework import serializers, viewsets from rest_framework.response import Response from rest_framework.settings import api_settings @@ -20,6 +23,8 @@ cms_mark_migrated_paths, cms_mark_superseded_paths, ) +from openedx.core.apidocs import get_api_docs_settings +from openedx.core.djangoapps.user_api.schema_components import PATCHED_USER_RETIREMENT_STATUS def _endpoint(path): @@ -73,7 +78,7 @@ def test_every_operation_of_a_superseded_path_is_marked(self): assert result["paths"][path]["get"]["deprecated"] is True def test_both_spellings_of_each_superseded_path_are_listed(self): - assert set(SUPERSEDED_PATHS) == { + assert {path for path in SUPERSEDED_PATHS if path.startswith("/api/contentstore/")} == { "/api/contentstore/v0/youtube_transcripts/{course_id}/chec", "/api/contentstore/v0/youtube_transcripts/{course_id}/check", "/api/contentstore/v0/youtube_transcripts/{course_id}/uploa", @@ -156,6 +161,14 @@ def test_both_hooks_are_configured(self): assert "cms.lib.spectacular.cms_mark_superseded_paths" in hooks assert "cms.lib.spectacular.cms_mark_migrated_paths" in hooks + def test_no_marking_hook_runs_beside_the_two_this_module_defines(self): + assert settings.SPECTACULAR_SETTINGS["POSTPROCESSING_HOOKS"] == [ + "drf_spectacular.hooks.postprocess_schema_enums", + "cms.lib.spectacular.cms_mark_superseded_paths", + "cms.lib.spectacular.cms_mark_migrated_paths", + "openedx.core.apidocs.untag_deprecated_operations", + ] + def test_the_default_enum_hook_is_kept_alongside_the_new_one(self): """Setting the key replaces drf-spectacular's default list, so it is restated.""" assert "drf_spectacular.hooks.postprocess_schema_enums" in settings.SPECTACULAR_SETTINGS["POSTPROCESSING_HOOKS"] @@ -200,6 +213,9 @@ def test_is_the_default_schema_class(self): """Views without their own ``schema`` pick this up via REST_FRAMEWORK settings.""" assert api_settings.DEFAULT_SCHEMA_CLASS is CmsAutoSchema + def test_every_operation_is_described_by_this_class_alone(self): + assert CmsAutoSchema.__bases__ == (AutoSchema,) + def test_only_the_colliding_legacy_address_is_suffixed(self): patterns = [ url_path("api/contentstore/v3/home/", _HomeViewSet.as_view({"get": "list"})), @@ -215,3 +231,140 @@ def test_only_the_colliding_legacy_address_is_suffixed(self): # Already distinct from its conforming twin's id, so it keeps its name. assert ids["/api/authoring/v4/courses/"] == "v4_courses_list" assert ids["/api/contentstore/v4/home/courses/"] == "v4_home_courses_list" + + +#: Every operation of a superseded user address this service serves, with the +#: name it is published under. The names are the ones both documents have +#: always carried: a client imports a function per name, so an operation that +#: comes back under a different one has been taken away from it. +SUPERSEDED_USER_OPERATION_IDS = { + ("/api/user/v1/accounts", "get"): "api_user_v1_accounts_retrieve", + ("/api/user/v1/accounts/name_change/", "post"): "api_user_v1_accounts_name_change_create", + ("/api/user/v1/accounts/name_change/{username}/confirm/", "post"): ( + "api_user_v1_accounts_name_change_confirm_create" + ), + ("/api/user/v1/accounts/search_emails", "post"): "api_user_v1_accounts_search_emails_create", + ("/api/user/v1/accounts/{username}", "get"): "api_user_v1_accounts_retrieve_2", + ("/api/user/v1/accounts/{username}", "patch"): "api_user_v1_accounts_partial_update", + ("/api/user/v1/accounts/{username}/deactivate/", "post"): "api_user_v1_accounts_deactivate_create", + ("/api/user/v1/accounts/{username}/image", "delete"): "api_user_v1_accounts_image_destroy", + ("/api/user/v1/accounts/{username}/image", "post"): "api_user_v1_accounts_image_create", + ("/api/user/v1/accounts/{username}/verification_status/", "get"): ( + "api_user_v1_accounts_verification_status_retrieve" + ), + ("/api/user/v1/accounts/{username}/verifications/", "get"): "api_user_v1_accounts_verifications_list", + ("/api/user/v1/forum_roles/{name}/users/", "get"): "api_user_v1_forum_roles_users_list", + ("/api/user/v1/me", "get"): "api_user_v1_me_retrieve", + ("/api/user/v1/preferences/email_opt_in/", "post"): "api_user_v1_preferences_email_opt_in_create", + ("/api/user/v1/preferences/time_zones/", "get"): "api_user_v1_preferences_time_zones_list", + ("/api/user/v1/preferences/{pref_key}/users/", "get"): "api_user_v1_preferences_users_list", + ("/api/user/v1/preferences/{username}", "get"): "api_user_v1_preferences_retrieve", + ("/api/user/v1/preferences/{username}", "patch"): "api_user_v1_preferences_partial_update", + ("/api/user/v1/preferences/{username}/{preference_key}", "delete"): "api_user_v1_preferences_destroy", + ("/api/user/v1/preferences/{username}/{preference_key}", "get"): "api_user_v1_preferences_retrieve_2", + ("/api/user/v1/preferences/{username}/{preference_key}", "put"): "api_user_v1_preferences_update", + ("/api/user/v1/user_prefs/", "get"): "api_user_v1_user_prefs_list", + ("/api/user/v1/user_prefs/{id}/", "get"): "api_user_v1_user_prefs_retrieve", + ("/api/user/v1/users/", "get"): "api_user_v1_users_list", + ("/api/user/v1/users/{id}/", "get"): "api_user_v1_users_retrieve", + ("/api/user/v1/accounts/cancel_retirement/", "post"): "api_user_v1_accounts_cancel_retirement_create", + ("/api/user/v1/accounts/retire/", "post"): "api_user_v1_accounts_retire_create", + ("/api/user/v1/accounts/retire_misc/", "post"): "api_user_v1_accounts_retire_misc_create", + ("/api/user/v1/accounts/retirement_cleanup/", "post"): "api_user_v1_accounts_retirement_cleanup_create", + ("/api/user/v1/accounts/retirement_partner_report/", "post"): ( + "api_user_v1_accounts_retirement_partner_report_create" + ), + ("/api/user/v1/accounts/retirement_partner_report/", "put"): ( + "api_user_v1_accounts_retirement_partner_report_update" + ), + ("/api/user/v1/accounts/retirement_partner_report_cleanup/", "post"): ( + "api_user_v1_accounts_retirement_partner_report_cleanup_create" + ), + ("/api/user/v1/accounts/retirement_queue/", "get"): "api_user_v1_accounts_retirement_queue_retrieve", + ("/api/user/v1/accounts/retirements_by_status_and_date/", "get"): ( + "api_user_v1_accounts_retirements_by_status_and_date_retrieve" + ), + ("/api/user/v1/accounts/update_retirement_status/", "patch"): ( + "api_user_v1_accounts_update_retirement_status_partial_update" + ), + ("/api/user/v1/accounts/{username}/retirement_status/", "get"): "api_user_v1_accounts_retirement_status_retrieve", +} + + +#: The user addresses this service marks superseded. +SUPERSEDED_USER_ADDRESSES = tuple(path for path in SUPERSEDED_PATHS if path.startswith("/api/user/")) + +#: The preference operations this service's own pages call. It serves no +#: replacement for them, so its document publishes them as current. +CURRENT_PREFERENCE_OPERATIONS = frozenset({ + ("/api/user/v1/preferences/{username}", "get"), + ("/api/user/v1/preferences/{username}", "patch"), + ("/api/user/v1/preferences/{username}/{preference_key}", "delete"), + ("/api/user/v1/preferences/{username}/{preference_key}", "get"), + ("/api/user/v1/preferences/{username}/{preference_key}", "put"), + ("/api/user/v1/preferences/email_opt_in/", "post"), +}) + + +class SupersededUserOperationTest(DjangoTestCase): + """ + The user addresses this service serves beside the LMS, which serves their replacements. + + Both services mount that API at the same addresses and both publish an + unauthenticated document of everything they serve. This one marks the + addresses it lists as superseded and keeps the name each operation is + published under. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + patterns = [ + entry for entry in import_module(settings.ROOT_URLCONF).urlpatterns + if str(entry.pattern).startswith("api/user/") + ] + # The whole document derives its path prefix from every address the + # service serves, which share nothing past the root. + with patched_settings({**get_api_docs_settings(), "SCHEMA_PATH_PREFIX": "/"}): + cls.schema = SchemaGenerator(patterns=patterns).get_schema(request=None, public=True) + + def _operations(self): + """Every operation of the user addresses that have a replacement, as this document carries it.""" + return { + (address, method): self.schema["paths"][address][method] + for address, method in SUPERSEDED_USER_OPERATION_IDS + } + + def test_this_service_serves_every_address_it_marks(self): + """The mark is matched on the exact address, so one this document spells otherwise is never marked.""" + assert [address for address in SUPERSEDED_USER_ADDRESSES if address not in self.schema["paths"]] == [] + + def test_every_operation_of_the_addresses_it_lists_is_marked_superseded(self): + marked = { + key: operation.get("deprecated") for key, operation in self._operations().items() + if key not in CURRENT_PREFERENCE_OPERATIONS + } + assert marked == dict.fromkeys(set(SUPERSEDED_USER_OPERATION_IDS) - CURRENT_PREFERENCE_OPERATIONS, True) + + def test_the_preference_addresses_its_own_pages_call_are_published_as_current(self): + operations = self._operations() + marked = {key: "deprecated" in operations[key] for key in CURRENT_PREFERENCE_OPERATIONS} + assert marked == dict.fromkeys(CURRENT_PREFERENCE_OPERATIONS, False) + + def test_every_operation_keeps_the_name_it_is_published_under(self): + published = {key: operation["operationId"] for key, operation in self._operations().items()} + assert published == SUPERSEDED_USER_OPERATION_IDS + + def test_the_retirement_status_model_is_published_as_the_other_service_appends_it(self): + """ + A client generated from this document names a class after each component in it. + + The partial update of a version 1 retirement refers to the + retirement-status model here, as it always has. The other service + publishes that operation's real body instead and appends the model so + that the class keeps its name, and the two describe it identically. + """ + operation = self.schema["paths"]["/api/user/v1/accounts/update_retirement_status/"]["patch"] + bodies = {content["schema"]["$ref"] for content in operation["requestBody"]["content"].values()} + assert bodies == {"#/components/schemas/PatchedUserRetirementStatus"} + assert self.schema["components"]["schemas"]["PatchedUserRetirementStatus"] == PATCHED_USER_RETIREMENT_STATUS diff --git a/cms/tests.py b/cms/tests.py index 6bebd0f5f52d..7f6cf5e9cb92 100644 --- a/cms/tests.py +++ b/cms/tests.py @@ -5,6 +5,7 @@ from django.urls import path from openedx.core.apidocs import cached_schema_view +from openedx.core.djangolib.testing.utils import unresolved_references class CmsModuleTests(TestCase): @@ -29,6 +30,20 @@ def test_api_docs_schema(self): response = self.client.get('/api-docs/schema/') assert response.status_code == 200 + def test_every_reference_in_each_served_document_resolves(self): + """ + Each document this service serves defines every component it refers to. + + A component appended through the settings is copied in as written, so + one pointing at a model that only another document defines leaves a + reference that a client generator refuses. + """ + for address in ('/api-docs/schema/', '/authoring-api/schema/'): + response = self.client.get(address, HTTP_ACCEPT='application/vnd.oai.openapi+json') + assert response.status_code == 200, address + unresolved = unresolved_references(response.json()) + assert not unresolved, (address, unresolved) + @override_settings( OPENAPI_CACHE_TIMEOUT=60, CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}, diff --git a/docs/docs_settings.py b/docs/docs_settings.py index 48ec4a5cc63e..c7d8b4d92afa 100644 --- a/docs/docs_settings.py +++ b/docs/docs_settings.py @@ -82,7 +82,9 @@ # OpenAPI 3 security schemes, consumed by drf-spectacular. "Basic" uses the # OpenAPI 3 http/basic form rather than Swagger 2.0's type: basic. +# Components the service settings already append are kept. SPECTACULAR_SETTINGS["APPEND_COMPONENTS"] = { # noqa: F405 + **SPECTACULAR_SETTINGS.get("APPEND_COMPONENTS", {}), # noqa: F405 "securitySchemes": { "Basic": { "type": "http", diff --git a/lms/envs/common.py b/lms/envs/common.py index 7ce78ae83ae0..0182d95bec16 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -64,6 +64,7 @@ from path import Path as path from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType +from openedx.core.djangoapps.user_api.schema_components import KEPT_COMPONENT_SCHEMAS from openedx.core.lib.derived import Derived from openedx.core.lib.features_setting_proxy import FeaturesProxy from openedx.envs.common import * # pylint: disable=wildcard-import # noqa: F403 @@ -2179,23 +2180,35 @@ add_optional_apps(OPTIONAL_APPS, INSTALLED_APPS) # noqa: F405 -###################### drf-spectacular (LMS enrollment schema) ###################### +###################### drf-spectacular (LMS API schema) ###################### SPECTACULAR_SETTINGS = { - 'TITLE': 'LMS Enrollment API', + 'TITLE': 'LMS API', 'VERSION': '0.1.0', 'SERVE_INCLUDE_SCHEMA': False, 'PREPROCESSING_HOOKS': ['lms.lib.spectacular.lms_api_filter'], - # Mark legacy slashless enrollment addresses deprecated. The enum hook is - # drf-spectacular's default, restated because setting this key replaces - # the default list. + # Mark superseded addresses deprecated. The enum hook is drf-spectacular's + # default, restated because setting this key replaces the default list. 'POSTPROCESSING_HOOKS': [ 'drf_spectacular.hooks.postprocess_schema_enums', 'lms.lib.spectacular.lms_mark_legacy_paths_deprecated', + 'openedx.core.djangoapps.user_api.schema.mark_superseded_user_paths', 'openedx.core.apidocs.untag_deprecated_operations', ], + # Carried by every document built from these settings; Studio generates the + # identical model from its own description of the operation. + 'APPEND_COMPONENTS': { + 'schemas': dict(KEPT_COMPONENT_SCHEMAS), + }, + # Pinned so a second same-named choice field cannot rename these sets; each + # names its model so it never claims a plain name another field may need. + 'ENUM_NAME_OVERRIDES': { + 'RetirementErasureScopeEnum': 'openedx.core.djangoapps.user_api.v2.serializers.ERASURE_SCOPE_CHOICES', + 'RetirementBatchResultOutcomeEnum': + 'openedx.core.djangoapps.user_api.v2.serializers.BATCH_OUTCOME_CHOICES', + }, # Stripped from each path to derive its tags and operationId, so changing # it renames every operationId. Paths themselves are emitted in full. - 'SCHEMA_PATH_PREFIX': '/api/enrollment', + 'SCHEMA_PATH_PREFIX': r'/api/(enrollment|user)', # Serve the Swagger UI and ReDoc assets from drf-spectacular-sidecar rather # than drf-spectacular's default unpinned jsdelivr CDN URLs, keeping them # self-hosted and version-pinned as the drf-yasg bundles were. diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index 789945a32c40..e74a18aaf8ce 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -583,7 +583,7 @@ def should_show_debug_toolbar(request): # pylint: disable=missing-function-docs 'COMPLETE', ] -###################### drf-spectacular (LMS enrollment schema) ###################### +###################### drf-spectacular (LMS API schema) ###################### SPECTACULAR_SETTINGS['SERVERS'] = [ # noqa: F405 {'url': LMS_ROOT_URL, 'description': 'Local'}, # noqa: F405 ] diff --git a/lms/envs/production.py b/lms/envs/production.py index 24350824c391..1defebe32992 100644 --- a/lms/envs/production.py +++ b/lms/envs/production.py @@ -398,7 +398,7 @@ def get_env_setting(setting): MIDDLEWARE.extend(_YAML_TOKENS.get('EXTRA_MIDDLEWARE_CLASSES', [])) # noqa: F405 -###################### drf-spectacular (LMS enrollment schema) ###################### +###################### drf-spectacular (LMS API schema) ###################### SPECTACULAR_SETTINGS['SERVERS'] = [ # noqa: F405 {'url': LMS_ROOT_URL, 'description': 'Local'}, # noqa: F405 ] diff --git a/lms/lib/spectacular.py b/lms/lib/spectacular.py index 942199deac69..5ca5a02b8cc3 100644 --- a/lms/lib/spectacular.py +++ b/lms/lib/spectacular.py @@ -4,6 +4,8 @@ from drf_spectacular.openapi import AutoSchema +from openedx.core.djangoapps.user_api.schema import SupersededUserOperations, is_superseded_user_path + LEGACY_V2_PREFIX = "/api/enrollment/v2/" # The one legacy address that differs from its conforming twin only by the @@ -11,27 +13,51 @@ # addresses already have distinct ids and keep them. COLLIDING_LEGACY_PATH = "/api/enrollment/v2/enrollments" +PUBLISHED_PATH_PATTERN = re.compile(r"^/api/enrollment/v\d+/") + +# Older apps share this prefix, so the view's package decides what is published. +PUBLISHED_USER_PREFIX = "/api/user/v2/" +PUBLISHED_USER_VIEW_MODULES = ( + "openedx.core.djangoapps.user_api.v2.", + "openedx.core.djangoapps.profile_images.v2.", +) + +# User operations share one tag where the derived tag would only name a version. +USER_PATH_PREFIX = "/api/user/" +USER_TAG = "user" + +_VERSION_TAG = re.compile(r"v\d+") + def _is_legacy_path(path): """Conforming routes always end in a slash, so a slashless v2 path is a legacy address.""" return path.startswith(LEGACY_V2_PREFIX) and not path.endswith("/") +def _is_published_user_path(path, callback): + """A user address is documented when this version serves it, or when this version replaces it.""" + if is_superseded_user_path(path): + return True + if not path.startswith(PUBLISHED_USER_PREFIX): + return False + view = getattr(callback, "cls", None) + return getattr(view, "__module__", "").startswith(PUBLISHED_USER_VIEW_MODULES) + + def lms_api_filter(endpoints): """ - Pre-processing hook: keep only enrollment v2 endpoints tagged for the SDK. + Pre-processing hook: keep only the endpoints this schema documents. """ filtered = [] - ENROLLMENT_PATH_PATTERN = re.compile(r"^/api/enrollment/v\d+/") for path, path_regex, method, callback in endpoints: - if ENROLLMENT_PATH_PATTERN.match(path): + if PUBLISHED_PATH_PATTERN.match(path) or _is_published_user_path(path, callback): filtered.append((path, path_regex, method, callback)) return filtered -class LmsAutoSchema(AutoSchema): +class LmsAutoSchema(SupersededUserOperations, AutoSchema): """ Give the colliding legacy Enrollment v2 address a distinct operationId. @@ -39,6 +65,8 @@ class LmsAutoSchema(AutoSchema): drf-spectacular would otherwise break the tie with a numeral suffix in registration order. The conforming address keeps the clean id; the deprecated one is suffixed ``_legacy``. + + A user operation whose derived tag would name a version is tagged ``user``. """ def get_operation_id(self): @@ -48,6 +76,13 @@ def get_operation_id(self): return f"{operation_id}_legacy" return operation_id + def get_tags(self): + """Name the area whenever the tag derived for a user address would name a version.""" + tags = super().get_tags() + if self.path.startswith(USER_PATH_PREFIX) and tags and _VERSION_TAG.fullmatch(tags[0]): + return [USER_TAG] + return tags + def lms_mark_legacy_paths_deprecated(result, generator, request, public): # pylint: disable=unused-argument """Mark the legacy slashless Enrollment v2 addresses ``deprecated: true``.""" diff --git a/lms/lib/tests/test_spectacular.py b/lms/lib/tests/test_spectacular.py index b611c8ad7776..5522a732b0dd 100644 --- a/lms/lib/tests/test_spectacular.py +++ b/lms/lib/tests/test_spectacular.py @@ -1,28 +1,68 @@ """Tests for the LMS drf-spectacular hooks and schema class.""" +import json +import re +from collections import defaultdict +from functools import cache +from importlib import import_module +from types import SimpleNamespace from unittest import mock -from django.test import SimpleTestCase -from django.urls import path +import jsonschema +from django.conf import settings +from django.test import Client, SimpleTestCase, TestCase +from django.test.client import FakePayload +from django.urls import path, resolve from drf_spectacular.generators import SchemaGenerator -from drf_spectacular.settings import spectacular_settings +from drf_spectacular.settings import patched_settings, spectacular_settings from rest_framework import serializers, viewsets +from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.settings import api_settings +from common.djangoapps.student.models_api import do_name_change_request +from common.djangoapps.student.tests.factories import TEST_PASSWORD, UserFactory from lms.lib.spectacular import LmsAutoSchema, lms_api_filter, lms_mark_legacy_paths_deprecated +from openedx.core.apidocs import get_api_docs_settings +from openedx.core.djangoapps.profile_images.tests.helpers import make_image_file +from openedx.core.djangoapps.user_api.accounts.image_helpers import ( + get_profile_image_names, + get_profile_image_storage, +) +from openedx.core.djangoapps.user_api.models import UserPreference +from openedx.core.djangoapps.user_api.schema import ( + RENAMED_OPERATION_IDS, + SUPERSEDED_USER_PATHS, + mark_superseded_user_paths, + superseded_bodies, +) + +ENROLLMENT_VIEWS = "openedx.core.djangoapps.enrollments.views" +USER_API_V1_VIEWS = "openedx.core.djangoapps.user_api.views" +USER_API_V2_VIEWS = "openedx.core.djangoapps.user_api.v2.views" +PROFILE_IMAGE_V1_VIEWS = "openedx.core.djangoapps.profile_images.views" +PROFILE_IMAGE_V2_VIEWS = "openedx.core.djangoapps.profile_images.v2.views" +USER_AUTHN_VIEWS = "openedx.core.djangoapps.user_authn.views.register" +USER_MOUNT = "api/user/" +COURSE_VIEWS = "lms.djangoapps.course_api.views" def _endpoint(route): return (route, route, "GET", None) +def _served_endpoint(route, view_module=COURSE_VIEWS): + """An endpoint tuple as the generator hands it to the hook, served from ``view_module``.""" + callback = SimpleNamespace(cls=type("_View", (), {"__module__": view_module})) + return (route, route, "GET", callback) + + def _schema(*routes): return {"paths": {route: {"get": {"operationId": route}} for route in routes}} class LmsApiFilterTest(SimpleTestCase): - """The pre-processing hook keeps only enrollment endpoints.""" + """The pre-processing hook keeps enrollment endpoints and drops unrelated ones.""" def test_keeps_enrollment_drops_the_rest(self): kept = lms_api_filter([ @@ -36,6 +76,67 @@ def test_keeps_enrollment_drops_the_rest(self): ] +class LmsApiFilterUserAddressTest(SimpleTestCase): + """The hook keeps the current user mounts and the addresses they supersede, beside enrollment.""" + + def test_keeps_enrollment_this_version_and_the_addresses_it_supersedes(self): + kept = lms_api_filter([ + _served_endpoint("/api/enrollment/v2/enrollments/", ENROLLMENT_VIEWS), + _served_endpoint("/api/enrollment/v1/enrollment", ENROLLMENT_VIEWS), + _served_endpoint("/api/user/v2/users/", USER_API_V2_VIEWS), + _served_endpoint("/api/user/v2/user_preferences/{username},{key}/", USER_API_V2_VIEWS), + # Superseded, and spelled with the parameter name the generator has + # not renamed to ``{id}`` yet. + _served_endpoint("/api/user/v1/users/{pk}/", USER_API_V1_VIEWS), + _served_endpoint("/api/user/v1/preferences/time_zones/", USER_API_V1_VIEWS), + _served_endpoint("/api/user/v1/accounts", USER_API_V1_VIEWS), + _served_endpoint("/api/user/v1/accounts/{username}", USER_API_V1_VIEWS), + _served_endpoint("/api/user/v1/me", USER_API_V1_VIEWS), + _served_endpoint("/api/user/v1/accounts/{username}/image", USER_API_V1_VIEWS), + _served_endpoint("/api/user/v1/accounts/name_change/", USER_API_V1_VIEWS), + _served_endpoint("/api/user/v1/preferences/{username}", USER_API_V1_VIEWS), + _served_endpoint( + "/api/user/v1/preferences/{username}/{preference_key}", USER_API_V1_VIEWS, + ), + _served_endpoint("/api/user/v1/preferences/email_opt_in/", USER_API_V1_VIEWS), + # Served by this version from the app that owns the resource. + _served_endpoint("/api/user/v2/accounts/{username}/profile_image/", PROFILE_IMAGE_V2_VIEWS), + # Neither served nor superseded by this version. + _served_endpoint("/api/user/v2/account/registration/", USER_AUTHN_VIEWS), + _served_endpoint("/api/profile_images/v1/{username}/upload", PROFILE_IMAGE_V1_VIEWS), + _served_endpoint("/api/courses/v1/courses/", COURSE_VIEWS), + _served_endpoint("/api/user_tours/v1/tours/"), + _served_endpoint("/user_api/v1/users/", USER_API_V1_VIEWS), + _served_endpoint("/user_api/v1/preferences/email_opt_in/", USER_API_V1_VIEWS), + ]) + assert [path for path, *_ in kept] == [ + "/api/enrollment/v2/enrollments/", + "/api/enrollment/v1/enrollment", + "/api/user/v2/users/", + "/api/user/v2/user_preferences/{username},{key}/", + "/api/user/v1/users/{pk}/", + "/api/user/v1/preferences/time_zones/", + "/api/user/v1/accounts", + "/api/user/v1/accounts/{username}", + "/api/user/v1/me", + "/api/user/v1/accounts/{username}/image", + "/api/user/v1/accounts/name_change/", + "/api/user/v1/preferences/{username}", + "/api/user/v1/preferences/{username}/{preference_key}", + "/api/user/v1/preferences/email_opt_in/", + "/api/user/v2/accounts/{username}/profile_image/", + ] + + def test_under_the_version_prefix_the_view_package_decides(self): + """Other apps are mounted under the same prefix, so the path alone cannot admit an address.""" + route = "/api/user/v2/account/registration/" + admitted = { + view_module: [path for path, *_ in lms_api_filter([_served_endpoint(route, view_module)])] + for view_module in (USER_AUTHN_VIEWS, USER_API_V2_VIEWS) + } + assert admitted == {USER_AUTHN_VIEWS: [], USER_API_V2_VIEWS: [route]} + + class LmsMarkLegacyPathsDeprecatedTest(SimpleTestCase): """Slashless v2 paths are legacy and deprecated; slashed ones are conforming.""" @@ -60,6 +161,65 @@ def test_paths_are_left_as_full_urls(self): assert list(result["paths"]) == ["/api/enrollment/v2/enrollments/"] +class MarkSupersededUserPathsTest(SimpleTestCase): + """Only the addresses the shared list names are marked, wherever a service publishes them.""" + + def test_only_the_listed_user_addresses_are_deprecated(self): + result = mark_superseded_user_paths(_schema( + "/api/user/v1/users/", + "/api/user/v1/users/{id}/", + "/api/user/v1/preferences/time_zones/", + "/api/user/v1/accounts", + "/api/user/v1/accounts/{username}", + "/api/user/v1/accounts/{username}/deactivate/", + "/api/user/v1/me", + "/api/user/v1/accounts/{username}/image", + "/api/user/v1/accounts/name_change/", + "/api/user/v1/accounts/name_change/{username}/confirm/", + "/api/user/v1/accounts/{username}/verification_status/", + "/api/user/v1/accounts/{username}/verifications/", + "/api/user/v1/preferences/{username}", + "/api/user/v1/preferences/{username}/{preference_key}", + "/api/user/v1/preferences/email_opt_in/", + "/api/profile_images/v1/{username}/upload", + "/api/user/v2/users/", + "/api/user/v2/accounts/{username}/profile_image/", + "/api/user/v2/accounts/{username}/preferences/", + "/user_api/v1/users/", + "/user_api/v1/accounts/{username}", + "/user_api/v1/preferences/email_opt_in/", + ), None, None, False) + paths = result["paths"] + for marked in ( + "/api/user/v1/users/", + "/api/user/v1/users/{id}/", + "/api/user/v1/preferences/time_zones/", + "/api/user/v1/accounts", + "/api/user/v1/accounts/{username}", + "/api/user/v1/accounts/{username}/deactivate/", + "/api/user/v1/me", + "/api/user/v1/accounts/{username}/image", + "/api/user/v1/accounts/name_change/", + "/api/user/v1/accounts/name_change/{username}/confirm/", + "/api/user/v1/accounts/{username}/verification_status/", + "/api/user/v1/accounts/{username}/verifications/", + "/api/user/v1/preferences/{username}", + "/api/user/v1/preferences/{username}/{preference_key}", + "/api/user/v1/preferences/email_opt_in/", + ): + assert paths[marked]["get"]["deprecated"] is True, marked + for untouched in ( + "/api/profile_images/v1/{username}/upload", + "/api/user/v2/users/", + "/api/user/v2/accounts/{username}/profile_image/", + "/api/user/v2/accounts/{username}/preferences/", + "/user_api/v1/users/", + "/user_api/v1/accounts/{username}", + "/user_api/v1/preferences/email_opt_in/", + ): + assert "deprecated" not in paths[untouched]["get"], untouched + + class _EmptySerializer(serializers.Serializer): # pylint: disable=abstract-method pass @@ -78,7 +238,7 @@ def retrieve(self, request, pk=None): class LmsAutoSchemaTest(SimpleTestCase): - """Dual mounts get deterministic operationIds instead of registration-order numeral suffixes.""" + """Dual mounts get deterministic operationIds instead of numeral suffixes.""" def test_is_the_default_schema_class(self): """Views without their own ``schema`` pick this up via REST_FRAMEWORK settings.""" @@ -99,3 +259,907 @@ def test_only_the_colliding_legacy_address_is_suffixed(self): assert ids["/api/enrollment/v2/courses/{id}/"] == "v2_courses_retrieve" # Already distinct from every conforming id, so it keeps its name. assert ids["/api/enrollment/v2/enrollment/{id}"] == "v2_enrollment_retrieve" + + +class _UserApiViewSet(viewsets.GenericViewSet): + """Stand-in for a user view read both as a collection and as a member.""" + + serializer_class = _EmptySerializer + permission_classes = (AllowAny,) + + def list(self, request): + return Response([]) + + def retrieve(self, request, **kwargs): + return Response({}) + + +def _user_routes(schema_class): + """User, enrollment and non-``/api/`` addresses, inspected by ``schema_class``.""" + view = type("_InspectedViewSet", (_UserApiViewSet,), {"schema": schema_class()}) + return [ + # The published collection answers with a list of accounts, which is + # what the generator derives a list operation id from. + path("api/user/v1/accounts", view.as_view({"get": "list"})), + path("api/user/v1/accounts/", view.as_view({"get": "retrieve"})), + path("api/user/v1/preferences/", view.as_view({"get": "retrieve"})), + path( + "api/user/v1/preferences//", + view.as_view({"get": "retrieve"}), + ), + path("api/user/v1/users/", view.as_view({"get": "list"})), + # Two reads of the retirement queue that answer with an array, which + # is what the generator derives a list operation id from, and which + # the document that trims nothing off the front has published under + # the name derived for a single record. + path("api/user/v1/accounts/retirement_queue/", view.as_view({"get": "list"})), + path( + "api/user/v1/accounts/retirements_by_status_and_date/", + view.as_view({"get": "list"}), + ), + path("api/user/v2/accounts//preferences/", view.as_view({"get": "retrieve"})), + path( + "api/user/v2/accounts//preferences//", + view.as_view({"get": "retrieve"}), + ), + path("api/user/v2/users/", view.as_view({"get": "list"})), + # A stored preference is addressed by its owner and its name together, + # and the comma between them is a token of the address like any other, + # so the generator leaves it in the name it derives. + path( + "api/user/v2/user_preferences/,/", + view.as_view({"get": "retrieve"}), + ), + path("api/enrollment/v1/enrollments/", view.as_view({"get": "list"})), + # Outside ``/api/``, so a document that derives its prefix from the + # routes it covers derives the one the service-wide document has. + path("user_api/v1/preferences/", view.as_view({"get": "retrieve"})), + ] + + +def _derived_operations(schema_class, patches): + """ + The names ``schema_class`` produces with nothing pinned, which is what the generator derives. + + The paths are read as they are assembled, which is before the numeral + telling two addresses that derived one name apart is handed out: a name + carrying that numeral was derived without it, and is looked up without it. + """ + with mock.patch.dict("openedx.core.djangoapps.user_api.schema.RENAMED_OPERATION_IDS", {}, clear=True): + with patched_settings({"PREPROCESSING_HOOKS": [], **patches}): + generator = SchemaGenerator(patterns=_user_routes(schema_class)) + return _by_address(generator.parse(None, public=True)) + + +def _named_operations(schema_class, patches): + """The ``{address: (operationId, tags)}`` ``schema_class`` produces under ``patches``.""" + with patched_settings({"PREPROCESSING_HOOKS": [], **patches}): + schema = SchemaGenerator(patterns=_user_routes(schema_class)).get_schema(request=None, public=True) + return _by_address(schema["paths"]) + + +def _by_address(paths): + """The ``{address: (operationId, tags)}`` the paths of a document carry.""" + return { + route: (operation["operationId"], tuple(operation["tags"])) + for route, path_item in paths.items() + for operation in path_item.values() + } + + +class UserOperationIdTest(SimpleTestCase): + """A user address keeps the name the generator derives unless one is pinned for it.""" + + def test_the_service_wide_document_keeps_every_name_the_generator_derives(self): + """ + Each of these is the name that document publishes, and a client imports it by name. + + An address that document has published for years keeps the name it + derived there, numeral and all; an address of a version it has not + published yet carries the name pinned for it. + """ + assert _named_operations(LmsAutoSchema, get_api_docs_settings()) == { + "/api/user/v1/accounts": ("api_user_v1_accounts_retrieve", ("api",)), + "/api/user/v1/accounts/{username}": ("api_user_v1_accounts_retrieve_2", ("api",)), + "/api/user/v1/preferences/{username}": ("api_user_v1_preferences_retrieve", ("api",)), + "/api/user/v1/preferences/{username}/{preference_key}": + ("api_user_v1_preferences_retrieve_2", ("api",)), + "/api/user/v1/users/": ("api_user_v1_users_list", ("api",)), + "/api/user/v1/accounts/retirement_queue/": + ("api_user_v1_accounts_retirement_queue_retrieve", ("api",)), + "/api/user/v1/accounts/retirements_by_status_and_date/": + ("api_user_v1_accounts_retirements_by_status_and_date_retrieve", ("api",)), + "/api/user/v2/accounts/{username}/preferences/": + ("api_user_v2_accounts_preferences_retrieve", ("api",)), + "/api/user/v2/accounts/{username}/preferences/{key}/": + ("api_user_v2_accounts_preferences_retrieve_one", ("api",)), + "/api/user/v2/users/": ("api_user_v2_users_list", ("api",)), + "/api/user/v2/user_preferences/{username},{key}/": + ("api_user_v2_user_preferences_retrieve", ("api",)), + "/api/enrollment/v1/enrollments/": ("api_enrollment_v1_enrollments_list", ("api",)), + "/user_api/v1/preferences/{username}": ("user_api_v1_preferences_retrieve", ("user_api",)), + } + + def test_every_pinned_name_replaces_one_a_document_derives(self): + """An entry keyed on an id no document derives is never reached, and pins nothing.""" + derived = { + (address, "GET", operation_id) + for patches in ({}, get_api_docs_settings()) + for address, (operation_id, _tags) in _derived_operations(LmsAutoSchema, patches).items() + } + assert set(RENAMED_OPERATION_IDS) - derived == set() + + def test_a_user_address_is_grouped_by_area_where_the_derived_tag_would_name_a_version(self): + """In the document that trims the area off, the derived tag is the version, and ids are untouched.""" + assert _named_operations(LmsAutoSchema, {}) == { + "/api/user/v1/accounts": ("v1_accounts_list", ("user",)), + "/api/user/v1/accounts/{username}": ("v1_accounts_retrieve", ("user",)), + "/api/user/v1/preferences/{username}": ("v1_preferences_retrieve", ("user",)), + "/api/user/v1/preferences/{username}/{preference_key}": ( + "v1_preferences_retrieve_one", ("user",), + ), + "/api/user/v1/users/": ("v1_users_list", ("user",)), + "/api/user/v1/accounts/retirement_queue/": ("v1_accounts_retirement_queue_list", ("user",)), + "/api/user/v1/accounts/retirements_by_status_and_date/": ( + "v1_accounts_retirements_by_status_and_date_list", ("user",), + ), + "/api/user/v2/accounts/{username}/preferences/": ( + "v2_accounts_preferences_retrieve", ("user",), + ), + "/api/user/v2/accounts/{username}/preferences/{key}/": ( + "v2_accounts_preferences_retrieve_one", ("user",), + ), + "/api/user/v2/users/": ("v2_users_list", ("user",)), + "/api/user/v2/user_preferences/{username},{key}/": ( + "v2_user_preferences_retrieve", ("user",), + ), + "/api/enrollment/v1/enrollments/": ("v1_enrollments_list", ("v1",)), + "/user_api/v1/preferences/{username}": ("user_api_v1_preferences_retrieve", ("user_api",)), + } + + +#: The preference document a superseded read answers with, spelled out here +#: rather than read from the module that publishes it: an edit to the shape +#: that module publishes is then an edit this test sees. +PREFERENCE_DOCUMENT_BODY = { + "type": "object", + "additionalProperties": {"type": "string"}, + "description": ( + "Preferences of one account, as an object of preference name to stored value. Every " + "value is text. Which names an account carries depends on the features it uses." + ), +} + +#: What each superseded operation the schema describes is expected to exchange: +#: the media types of the body it reads, and the success code with the body it +#: answers with. ``None`` is a response code carrying no body at all. +SUPERSEDED_EXCHANGES = { + ("/api/user/v1/accounts", "get"): ([], {"200": "Account[]"}), + ("/api/user/v1/accounts/{username}", "get"): ([], {"200": "Account"}), + ("/api/user/v1/accounts/{username}", "patch"): (["application/merge-patch+json"], {"200": "Account"}), + ("/api/user/v1/accounts/search_emails", "post"): ( + ["application/json", "application/merge-patch+json"], {"200": "EmailSearchResult[]"}, + ), + ("/api/user/v1/accounts/{username}/deactivate/", "post"): ([], {"200": "Account"}), + ("/api/user/v1/accounts/name_change/", "post"): ( + ["application/json", "application/x-www-form-urlencoded", "multipart/form-data"], + {"201": None}, + ), + ("/api/user/v1/accounts/name_change/{username}/confirm/", "post"): ([], {"204": None}), + ("/api/user/v1/accounts/{username}/image", "post"): ( + ["image/gif", "image/jpeg", "image/pjpeg", "image/png", "multipart/form-data"], + {"204": None}, + ), + ("/api/user/v1/accounts/{username}/image", "delete"): ([], {"204": None}), + ("/api/user/v1/accounts/{username}/verification_status/", "get"): ([], {"200": "VerificationStatus"}), + ("/api/user/v1/preferences/{username}", "get"): ([], {"200": PREFERENCE_DOCUMENT_BODY}), + ("/api/user/v1/preferences/{username}", "patch"): (["application/merge-patch+json"], {"204": None}), + ("/api/user/v1/preferences/{username}/{preference_key}", "get"): ([], {"200": {"type": "string"}}), + ("/api/user/v1/preferences/{username}/{preference_key}", "put"): (["application/json"], {"204": None}), + ("/api/user/v1/preferences/{username}/{preference_key}", "delete"): ([], {"204": None}), + ("/api/user/v1/preferences/email_opt_in/", "post"): ( + ["application/x-www-form-urlencoded"], {"200": None}, + ), +} + + +def _named_body(response): + """ + Return what a response carries: the component it names, or the shape itself. + + A component is named by its own name, suffixed ``[]`` where the body is an + array of it; a body described in place comes back as that description, and + a response with no body at all as ``None``. + """ + content = response.get("content") + if not content: + return None + body = content["application/json"]["schema"] + if body.get("type") == "array": + return f"{body['items']['$ref'].rsplit('/', 1)[-1]}[]" + if "$ref" not in body: + return body + return body["$ref"].rsplit("/", 1)[-1] + + +@cache +def _user_documents(): + """ + Return the two documents this service publishes, generated once and from the user mounts only. + + Each is generated under the settings it is published with, so what it says + about a user address is what the whole document says about it. + """ + patterns = [ + entry for entry in import_module(settings.ROOT_URLCONF).urlpatterns + if str(entry.pattern).startswith(USER_MOUNT) + ] + published = SchemaGenerator(patterns=patterns).get_schema(request=None, public=True) + # The whole document derives its path prefix from every address the + # service serves, which share nothing past the root. + with patched_settings({**get_api_docs_settings(), "SCHEMA_PATH_PREFIX": "/"}): + service_wide = SchemaGenerator(patterns=patterns).get_schema(request=None, public=True) + return {"published": published, "service-wide": service_wide} + + +#: The unversioned twin of the version 1 email choice address, as published. +OPT_IN_TWIN = "/user_api/v1/preferences/email_opt_in/" + + +def _opt_in_twin_operation(): + """ + Return the operation the service-wide document publishes at the email choice's unversioned twin. + + The twin is mounted outside the user mounts, so it is generated from its + own route, under the settings that document is published with. + """ + routes = import_module("openedx.core.djangoapps.user_api.legacy_urls").urlpatterns + twin = [route for route in routes if getattr(route, "name", None) == "preferences_email_opt_in_legacy"] + with patched_settings({**get_api_docs_settings(), "SCHEMA_PATH_PREFIX": "/"}): + schema = SchemaGenerator(patterns=twin).get_schema(request=None, public=True) + return schema["paths"][OPT_IN_TWIN]["post"] + + +class SupersededBodyTest(TestCase): + """Every address published as superseded carries the bodies it exchanges.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.schema = _user_documents()["published"] + + def test_the_described_addresses_are_ones_this_schema_marks_superseded(self): + described = {address for address, _method in superseded_bodies()} + marked = {re.sub(r"\{[\w-]+\}", "{}", address) for address in SUPERSEDED_USER_PATHS} + assert described <= marked, described - marked + + def test_each_superseded_operation_publishes_what_it_reads_and_what_it_answers_with(self): + published = {} + for (address, method), _expected in SUPERSEDED_EXCHANGES.items(): + operation = self.schema["paths"][address][method] + request_media_types = sorted((operation.get("requestBody") or {}).get("content", {})) + published[(address, method)] = ( + request_media_types, + {code: _named_body(response) for code, response in operation["responses"].items()}, + ) + assert published == SUPERSEDED_EXCHANGES + + def test_a_body_one_of_the_published_forms_requires_is_published_as_required(self): + """The forms an image may be sent in are alternatives, and one of them is a form part.""" + image = self.schema["paths"]["/api/user/v1/accounts/{username}/image"]["post"] + assert image["requestBody"]["required"] is True + assert sorted(image["requestBody"]["content"]) == [ + "image/gif", "image/jpeg", "image/pjpeg", "image/png", "multipart/form-data", + ] + + def test_no_superseded_operation_answers_with_a_body_it_does_not_describe(self): + """A published success body with no schema is the half-documented state this avoids.""" + undescribed = [ + (address, method, code) + for (address, method), (_request, responses) in SUPERSEDED_EXCHANGES.items() + for code, body in responses.items() + if body is None and "content" in self.schema["paths"][address][method]["responses"][code] + ] + assert undescribed == [] + + +#: A request each superseded address answers with success that the body the +#: replacing address reads would refuse: more addresses than a search there +#: takes, a longer name than a change there takes, and a number where a change +#: there takes only text. +ACCEPTED_ONLY_BY_THE_SUPERSEDED_ADDRESS = { + ("/api/user/v1/accounts/search_emails", "post"): { + "emails": [f"crowd-{index:03d}@example.com" for index in range(101)], + }, + ("/api/user/v1/accounts/name_change/", "post"): {"name": "N" * 256}, + ("/api/user/v1/accounts/{username}", "patch"): {"bio": 5}, +} + +#: What a published body may say only where the handler reading it holds a +#: request to it. +BOUNDING_KEYWORDS = {"$ref", "format", "maxItems", "maxLength", "minItems", "minLength", "pattern"} + + +def _keywords(node): + """Return every key used anywhere inside a JSON schema.""" + if isinstance(node, dict): + return set(node).union(*(_keywords(value) for value in node.values())) + if isinstance(node, list): + return set().union(*(_keywords(value) for value in node)) + return set() + + +class SupersededRequestBodyTest(TestCase): + """ + A superseded address publishes no bound or type its handler does not hold a request to. + + Its replacement reads the same members with bounds of its own. A client + generated from either document enforces what the body it reads publishes, + so a bound borrowed from the replacement would have that client refuse, + on its own side, a request the superseded handler answers with success. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.documents = _user_documents() + + def setUp(self): + super().setUp() + self.owner = UserFactory.create(username="bodyowner", password=TEST_PASSWORD) + self.staff = UserFactory.create(username="bodystaff", password=TEST_PASSWORD, is_staff=True) + + def _published_bodies(self, address, method): + """Yield each document's name, a media type and the body published for it at one operation.""" + for name, document in self.documents.items(): + for media_type, content in document["paths"][address][method]["requestBody"]["content"].items(): + yield name, media_type, content["schema"] + + def test_each_body_publishes_the_members_it_reads_and_nothing_that_bounds_them(self): + published = {} + for address, method in ACCEPTED_ONLY_BY_THE_SUPERSEDED_ADDRESS: + for name, media_type, body in self._published_bodies(address, method): + published[(name, address, media_type)] = body + for key, body in published.items(): + assert _keywords(body) & BOUNDING_KEYWORDS == set(), key + assert published[("published", "/api/user/v1/accounts/search_emails", "application/json")] == { + "type": "object", + "properties": {"emails": {"type": "array", "items": {}}}, + "required": ["emails"], + } + assert published[("service-wide", "/api/user/v1/accounts/name_change/", "multipart/form-data")] == { + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + } + change = published[("published", "/api/user/v1/accounts/{username}", "application/merge-patch+json")] + assert {"bio", "email", "name", "social_links", "year_of_birth"} <= set(change["properties"]) + assert set(map(json.dumps, change["properties"].values())) == {"{}"} + + def test_each_request_the_handler_accepts_is_one_its_published_body_allows(self): + for (address, method), request in ACCEPTED_ONLY_BY_THE_SUPERSEDED_ADDRESS.items(): + for name, media_type, body in self._published_bodies(address, method): + with self.subTest(document=name, address=address, media_type=media_type): + jsonschema.Draft202012Validator(body).validate(request) + + def test_each_handler_answers_those_requests_with_success(self): + owner, staff = Client(), Client() + assert owner.login(username=self.owner.username, password=TEST_PASSWORD) + assert staff.login(username=self.staff.username, password=TEST_PASSWORD) + requests = ACCEPTED_ONLY_BY_THE_SUPERSEDED_ADDRESS + answered = { + "search": staff.post( + "/api/user/v1/accounts/search_emails", + requests[("/api/user/v1/accounts/search_emails", "post")], + content_type="application/json", + ).status_code, + "name change": owner.post( + "/api/user/v1/accounts/name_change/", + requests[("/api/user/v1/accounts/name_change/", "post")], + content_type="application/json", + ).status_code, + "account change": owner.patch( + f"/api/user/v1/accounts/{self.owner.username}", + requests[("/api/user/v1/accounts/{username}", "patch")], + content_type="application/merge-patch+json", + ).status_code, + } + assert answered == {"search": 200, "name change": 201, "account change": 200} + + def test_the_replacing_addresses_publish_the_bounds_they_hold_a_request_to(self): + for name, document in self.documents.items(): + components = document["components"]["schemas"] + with self.subTest(document=name): + assert components["EmailSearch"]["properties"]["emails"]["maxItems"] == 100 + assert components["NameChange"]["properties"]["name"]["maxLength"] == 255 + assert components["PatchedAccountUpdate"]["properties"]["bio"]["type"] == "string" + paths = document["paths"] + assert paths["/api/user/v2/email_searches/"]["post"]["requestBody"]["content"][ + "application/json" + ]["schema"] == {"$ref": "#/components/schemas/EmailSearch"} + assert paths["/api/user/v2/name_changes/"]["post"]["requestBody"]["content"][ + "application/json" + ]["schema"] == {"$ref": "#/components/schemas/NameChange"} + assert paths["/api/user/v2/accounts/{username}/"]["patch"]["requestBody"]["content"][ + "application/merge-patch+json" + ]["schema"] == {"$ref": "#/components/schemas/PatchedAccountUpdate"} + + def test_a_body_that_names_a_member_it_must_carry_is_published_as_required(self): + required = { + (name, address): document["paths"][address][method]["requestBody"].get("required", False) + for name, document in self.documents.items() + for address, method in ACCEPTED_ONLY_BY_THE_SUPERSEDED_ADDRESS + } + assert required == { + (name, address): address != "/api/user/v1/accounts/{username}" + for name in self.documents + for address, _method in ACCEPTED_ONLY_BY_THE_SUPERSEDED_ADDRESS + } + + +#: A value each superseded preference write stores as its text that the body +#: the replacing address reads would refuse, because that body takes only text. +PREFERENCE_VALUES_ACCEPTED_ONLY_BY_THE_SUPERSEDED_ADDRESS = { + ("/api/user/v1/preferences/{username}", "patch"): {"num_pref": 5}, + ("/api/user/v1/preferences/{username}/{preference_key}", "put"): 7, +} + + +class SupersededPreferenceRequestBodyTest(TestCase): + """ + A superseded preference write publishes values of any type, as its handler stores them. + + Both handlers store the text of whatever value they are sent, where the + replacing addresses take text alone. A client generated from a body that + borrowed that type would refuse, on its own side, a value the superseded + handler stores. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.documents = _user_documents() + + def _published_bodies(self, address, method): + """Yield each document's name, a media type and the body published for it at one operation.""" + for name, document in self.documents.items(): + for media_type, content in document["paths"][address][method]["requestBody"]["content"].items(): + yield name, media_type, content["schema"] + + def test_each_body_publishes_a_value_of_any_type(self): + published = { + (name, address, media_type): {key: value for key, value in body.items() if key != "description"} + for address, method in PREFERENCE_VALUES_ACCEPTED_ONLY_BY_THE_SUPERSEDED_ADDRESS + for name, media_type, body in self._published_bodies(address, method) + } + assert published == { + **{ + (name, "/api/user/v1/preferences/{username}", "application/merge-patch+json"): { + "type": "object", "minProperties": 1, "additionalProperties": {}, + } + for name in ("published", "service-wide") + }, + **{ + (name, "/api/user/v1/preferences/{username}/{preference_key}", "application/json"): {} + for name in ("published", "service-wide") + }, + } + + def test_each_value_the_handler_stores_is_one_its_published_body_allows(self): + for (address, method), request in PREFERENCE_VALUES_ACCEPTED_ONLY_BY_THE_SUPERSEDED_ADDRESS.items(): + for name, media_type, body in self._published_bodies(address, method): + with self.subTest(document=name, address=address, media_type=media_type): + jsonschema.Draft202012Validator(body).validate(request) + + def test_each_handler_stores_those_values_as_their_text(self): + owner = UserFactory.create(username="prefbodyowner", password=TEST_PASSWORD) + client = Client() + assert client.login(username=owner.username, password=TEST_PASSWORD) + values = PREFERENCE_VALUES_ACCEPTED_ONLY_BY_THE_SUPERSEDED_ADDRESS + answered = { + "change": client.patch( + f"/api/user/v1/preferences/{owner.username}", + values[("/api/user/v1/preferences/{username}", "patch")], + content_type="application/merge-patch+json", + ).status_code, + "write": client.put( + f"/api/user/v1/preferences/{owner.username}/other_pref", + json.dumps(values[("/api/user/v1/preferences/{username}/{preference_key}", "put")]), + content_type="application/json", + ).status_code, + } + assert answered == {"change": 204, "write": 204} + assert dict(UserPreference.objects.filter(user=owner).values_list("key", "value")) == { + "num_pref": "5", "other_pref": "7", + } + + def test_the_replacing_addresses_publish_values_as_text(self): + for name, document in self.documents.items(): + paths = document["paths"] + with self.subTest(document=name): + change = paths["/api/user/v2/accounts/{username}/preferences/"]["patch"]["requestBody"] + assert change["content"]["application/merge-patch+json"]["schema"]["additionalProperties"] == { + "type": "string", "nullable": True, + } + value = document["components"]["schemas"]["AccountPreferenceValue"]["properties"]["value"] + assert value["type"] == "string" + + def test_a_change_is_published_as_needing_a_body_and_a_write_as_not(self): + required = { + (name, address): document["paths"][address][method]["requestBody"].get("required", False) + for name, document in self.documents.items() + for address, method in PREFERENCE_VALUES_ACCEPTED_ONLY_BY_THE_SUPERSEDED_ADDRESS + } + assert required == { + (name, address): address == "/api/user/v1/preferences/{username}" + for name in ("published", "service-wide") + for address, _method in PREFERENCE_VALUES_ACCEPTED_ONLY_BY_THE_SUPERSEDED_ADDRESS + } + + +#: The preference operations this service serves replacements for. Studio +#: mounts the same addresses and serves no replacement, so only this service +#: marks them. +SUPERSEDED_PREFERENCE_OPERATIONS = ( + ("/api/user/v1/preferences/{username}", "get"), + ("/api/user/v1/preferences/{username}", "patch"), + ("/api/user/v1/preferences/{username}/{preference_key}", "delete"), + ("/api/user/v1/preferences/{username}/{preference_key}", "get"), + ("/api/user/v1/preferences/{username}/{preference_key}", "put"), + ("/api/user/v1/preferences/email_opt_in/", "post"), +) + + +class SupersededPreferenceMarkTest(TestCase): + """Both documents of this service mark each preference operation it serves a replacement for.""" + + def test_every_preference_operation_is_marked_superseded_in_both_documents(self): + marked = { + (name, address, method): document["paths"][address][method].get("deprecated") + for name, document in _user_documents().items() + for address, method in SUPERSEDED_PREFERENCE_OPERATIONS + } + assert marked == { + (name, address, method): True + for name in ("published", "service-wide") + for address, method in SUPERSEDED_PREFERENCE_OPERATIONS + } + + +#: The exchanges of the preference and opt-in addresses this change describes. +PREFERENCE_EXCHANGES = { + operation: exchange for operation, exchange in SUPERSEDED_EXCHANGES.items() + if operation[0].startswith("/api/user/v1/preferences/") +} + + +class SupersededBodyInTheServiceWideDocumentTest(TestCase): + """ + The same bodies describe these addresses in the document that covers the whole service. + + That document publishes every endpoint rather than this area alone, and it + already carried these addresses before they were described, so the table + changes what they publish there instead of adding them. Two of them + answered with no body all along and are published with the code they + really answer with. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.schema = _user_documents()["service-wide"] + + def test_each_superseded_operation_publishes_the_same_exchange_there(self): + published = {} + for (address, method), _expected in PREFERENCE_EXCHANGES.items(): + operation = self.schema["paths"][address][method] + request_media_types = sorted((operation.get("requestBody") or {}).get("content", {})) + published[(address, method)] = ( + request_media_types, + {code: _named_body(response) for code, response in operation["responses"].items()}, + ) + assert published == PREFERENCE_EXCHANGES + + def test_the_twin_of_the_opt_in_is_left_as_that_document_always_published_it(self): + """It keeps resolving, is described by nothing here, and is not marked superseded.""" + assert resolve(OPT_IN_TWIN).url_name == "preferences_email_opt_in_legacy" + twin = _opt_in_twin_operation() + assert "requestBody" not in twin + assert sorted(twin["responses"]) == ["200"] + assert "content" not in twin["responses"]["200"] + assert twin.get("deprecated", False) is False + + +#: Each address this document already published before its body was described, +#: and the success code its handler really answers. That document carried a 200 +#: with no body for all four, which no handler answers; the code each is +#: published under now is driven below. +CORRECTED_SUCCESS_CODES = ( + ("post", "/api/user/v1/accounts/name_change/{username}/confirm/", "204"), + ("post", "/api/user/v1/accounts/{username}/image", "204"), + ("patch", "/api/user/v1/preferences/{username}", "204"), + ("put", "/api/user/v1/preferences/{username}/{preference_key}", "204"), +) + + +class CorrectedSuccessCodeTest(TestCase): + """ + Four addresses both documents already published answer a success code neither carried. + + These are not new addresses: a client generated before this change branches + on the code the document gave them, so each is pinned here in both + documents and each handler is driven, so that the published code can only + change again together with the handler that answers it. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.documents = _user_documents() + + def setUp(self): + super().setUp() + self.owner = UserFactory.create(username="codeowner", password=TEST_PASSWORD) + self.owner.profile.year_of_birth = 1980 + self.owner.profile.save() + self.staff = UserFactory.create(username="codestaff", password=TEST_PASSWORD, is_staff=True) + self.storage = get_profile_image_storage() + + def tearDown(self): + for name in get_profile_image_names(self.owner.username).values(): + self.storage.delete(name) + super().tearDown() + + def _as(self, user): + """A client signed in as that account.""" + client = Client() + assert client.login(username=user.username, password=TEST_PASSWORD) + return client + + def _answered(self): + """Drive each of the four on its success path and return the code it answers.""" + owner, staff = self._as(self.owner), self._as(self.staff) + do_name_change_request(self.owner, "Another Name", "a reason") + answered = { + ("post", "/api/user/v1/accounts/name_change/{username}/confirm/"): staff.post( + f"/api/user/v1/accounts/name_change/{self.owner.username}/confirm/", + ).status_code, + ("patch", "/api/user/v1/preferences/{username}"): owner.patch( + f"/api/user/v1/preferences/{self.owner.username}", + data='{"pref-lang": "en"}', + content_type=MERGE_PATCH, + ).status_code, + ("put", "/api/user/v1/preferences/{username}/{preference_key}"): owner.put( + f"/api/user/v1/preferences/{self.owner.username}/account_privacy", + data='"private"', + content_type="application/json", + ).status_code, + } + with make_image_file() as image_file: + answered[("post", "/api/user/v1/accounts/{username}/image")] = owner.post( + f"/api/user/v1/accounts/{self.owner.username}/image", {"file": image_file}, + ).status_code + return answered + + def test_each_handler_answers_the_code_it_is_published_under(self): + assert self._answered() == { + (method, address): int(code) for method, address, code in CORRECTED_SUCCESS_CODES + } + + def test_every_document_publishes_that_code_and_nothing_beside_it(self): + published = {} + for name, document in self.documents.items(): + for method, address, _code in CORRECTED_SUCCESS_CODES: + responses = document["paths"][address][method]["responses"] + published[(name, method, address)] = ( + sorted(responses), + ["content" in response for response in responses.values()], + ) + assert published == { + (name, method, address): ([code], [False]) + for name in self.documents + for method, address, code in CORRECTED_SUCCESS_CODES + } + + +MERGE_PATCH = "application/merge-patch+json" + +#: Each address that describes its body in place, the form it reads it in, and +#: what the handler answers a request carrying no body at all. +BODIES_SENT_AS_NOTHING = ( + ("PATCH", "/api/user/v1/preferences/{}", "/api/user/v1/preferences/bodyowner", MERGE_PATCH, 400), + ("POST", "/api/user/v1/preferences/email_opt_in/", "/api/user/v1/preferences/email_opt_in/", + "application/x-www-form-urlencoded", 400), + ("PATCH", "/api/user/v2/accounts/{}/preferences/", "/api/user/v2/accounts/bodyowner/preferences/", + MERGE_PATCH, 400), + ("PUT", "/api/user/v1/preferences/{}/{}", "/api/user/v1/preferences/bodyowner/account_privacy", + "application/json", 204), +) + + +class RequiredRequestBodyTest(TestCase): + """ + A body no request may leave out is published as one the operation needs. + + Every address below describes its body in place rather than through a + serializer, which leaves the generator no field to read that from, so it + publishes no mark at all and a client makes the body optional. Each is + driven here with no body, in both documents, so the mark one carries is + held to what the handler answers. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.documents = _user_documents() + + def setUp(self): + super().setUp() + self.owner = UserFactory.create(username="bodyowner", password=TEST_PASSWORD) + assert self.client.login(username=self.owner.username, password=TEST_PASSWORD) + + @staticmethod + def _published_address(shape): + """The address as a document spells it, from the shape its parameters are removed from.""" + return { + "/api/user/v1/preferences/{}": "/api/user/v1/preferences/{username}", + "/api/user/v1/preferences/{}/{}": "/api/user/v1/preferences/{username}/{preference_key}", + "/api/user/v2/accounts/{}/preferences/": "/api/user/v2/accounts/{username}/preferences/", + }.get(shape, shape) + + def _send_nothing(self, method, address, media_type): + """Send the request with its form named and no body behind it.""" + return self.client.generic( + method, + address, + data=b"", + content_type=media_type, + CONTENT_TYPE=media_type, + CONTENT_LENGTH="0", + **{"wsgi.input": FakePayload(b"")}, + ) + + def test_a_handler_that_refuses_a_request_carrying_no_body_is_the_reason_for_the_mark(self): + """What each handler answers, which is what the mark below is held to.""" + answered = { + (method, address): self._send_nothing(method, address, media_type).status_code + for method, _shape, address, media_type, _expected in BODIES_SENT_AS_NOTHING + } + assert answered == { + (method, address): expected + for method, _shape, address, media_type, expected in BODIES_SENT_AS_NOTHING + } + + def test_every_document_marks_a_body_the_handler_refuses_to_work_without(self): + marks = {} + for name, document in self.documents.items(): + for method, shape, _address, _media_type, _answer in BODIES_SENT_AS_NOTHING: + address = self._published_address(shape) + operation = document["paths"][address][method.lower()] + marks[(name, method, address)] = operation["requestBody"].get("required", "") + assert marks == { + (name, method, self._published_address(shape)): (True if answer == 400 else "") + for name in self.documents + for method, shape, _address, _media_type, answer in BODIES_SENT_AS_NOTHING + } + + +class ServiceWideDocumentSettingsTest(TestCase): + """ + The document covering the whole service keeps the settings it is generated under. + + Its title, its version and its description are given to the generator for + the length of the generation rather than configured for the deployment, + and a part of the document is built under a setting held for that part + alone. What the document says about itself is read here beside that part, + because a hold let go of too widely takes the rest with it and the + document would describe itself as something else. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.given = get_api_docs_settings() + cls.schema = _user_documents()["service-wide"] + + def test_the_document_says_of_itself_what_it_was_generated_with(self): + expected = { + "title": self.given["TITLE"], + "version": self.given["VERSION"], + "description": self.given["DESCRIPTION"], + } + if self.given.get("CONTACT"): + expected["contact"] = self.given["CONTACT"] + assert self.schema["info"] == expected + + def test_the_document_names_no_server_to_send_callers_to(self): + """It is generated with none named, and a document naming one points at that host.""" + assert "servers" not in self.schema + + def test_the_body_read_whole_is_published_whole_in_that_document_too(self): + """The body is read whole under the settings of every document, not only the service's own.""" + body = self.schema["paths"]["/api/user/v2/retirements/{username}/"]["patch"]["requestBody"] + assert body["required"] is True + assert body["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/RetirementUpdate", + } + assert self.schema["components"]["schemas"]["RetirementUpdate"]["required"] == [ + "new_state", "response", + ] + assert "PatchedRetirementUpdate" not in self.schema["components"]["schemas"] + + + + +class PublishedDocumentTest(TestCase): + """What the service publishes for the user addresses: distinct ids, and one area per tag group.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.schema = _user_documents()["published"] + + @classmethod + def operations(cls): + """Yield every (path, operation) the published document carries.""" + for route, path_item in cls.schema["paths"].items(): + for operation in path_item.values(): + if isinstance(operation, dict) and "operationId" in operation: + yield route, operation + + def test_every_operation_id_is_unique(self): + ids = [operation["operationId"] for _route, operation in self.operations()] + assert sorted(ids) == sorted(set(ids)) + + def test_no_operation_is_told_apart_by_a_numeral_suffix(self): + """A numeral suffix is assigned in route order, so it is not a stable name.""" + suffixed = { + route: operation["operationId"] + for route, operation in self.operations() + if re.fullmatch(r".+_\d+", operation["operationId"]) + } + assert suffixed == {} + + def test_a_read_of_one_preference_is_named_for_the_one_preference_it_reads(self): + """ + The read of a whole preference document and the read of one of its names are named apart. + + Both addresses derive one name, which the assembled document would + tell apart with a numeral, so the name a client imports for the read + of one preference would say which of the two addresses sorts first + rather than what it answers with. Each version names that read for + what it reads, and the read of the whole document keeps the name both + addresses derive. + """ + ids = { + (route, method): operation["operationId"] + for route, path_item in self.schema["paths"].items() + for method, operation in path_item.items() + if isinstance(operation, dict) and "operationId" in operation + } + assert ids[("/api/user/v1/preferences/{username}", "get")] == "v1_preferences_retrieve" + assert ids[("/api/user/v1/preferences/{username}/{preference_key}", "get")] == ( + "v1_preferences_retrieve_one" + ) + assert ids[("/api/user/v2/accounts/{username}/preferences/", "get")] == ( + "v2_accounts_preferences_retrieve" + ) + assert ids[("/api/user/v2/accounts/{username}/preferences/{key}/", "get")] == ( + "v2_accounts_preferences_retrieve_one" + ) + + def test_a_tag_group_holds_one_area_unless_it_marks_the_client_library(self): + """ + The group a client library is generated from marks operations in any area. + + Every other group names an area, and gathering two of them under one + name would move operations in every client generated from the document. + """ + areas = defaultdict(set) + for route, operation in self.operations(): + for tag in operation.get("tags", []): + areas[tag].add(route.split("/")[2]) + assert {tag: sorted(members) for tag, members in areas.items()} == { + "openedx-platform-sdk": ["user"], + "user": ["user"], + } diff --git a/lms/tests.py b/lms/tests.py index 4a45b86a2699..8387a0d024b2 100644 --- a/lms/tests.py +++ b/lms/tests.py @@ -10,6 +10,7 @@ from django.urls import path from openedx.core.apidocs import cached_schema_view +from openedx.core.djangolib.testing.utils import unresolved_references log = logging.getLogger(__name__) @@ -42,6 +43,20 @@ def test_api_docs_schema(self): response = self.client.get('/api-docs/schema/') assert response.status_code == 200 + def test_every_reference_in_each_served_document_resolves(self): + """ + Each document this service serves defines every component it refers to. + + A component appended through the settings is copied in as written, so + one pointing at a model that only another document defines leaves a + reference that a client generator refuses. + """ + for address in ('/api-docs/schema/', '/lms-api/schema/'): + response = self.client.get(address, HTTP_ACCEPT='application/vnd.oai.openapi+json') + assert response.status_code == 200, address + unresolved = unresolved_references(response.json()) + assert not unresolved, (address, unresolved) + @override_settings( OPENAPI_CACHE_TIMEOUT=60, CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}, diff --git a/lms/urls.py b/lms/urls.py index 0e624d44981a..8a4c1cc929ae 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -142,6 +142,7 @@ path('api/courses/', include('lms.djangoapps.course_api.urls')), # User API endpoints + path('api/user/v2/', include('openedx.core.djangoapps.user_api.v2.urls')), path('api/user/', include('openedx.core.djangoapps.user_api.urls')), # Note: these are older versions of the User API that will eventually be # subsumed by api/user listed above. diff --git a/openedx/core/apidocs.py b/openedx/core/apidocs.py index 838c61b6bb56..05f8f59888f0 100644 --- a/openedx/core/apidocs.py +++ b/openedx/core/apidocs.py @@ -21,7 +21,7 @@ # These are passed as ``custom_settings`` to SpectacularAPIView rather than # living in ``SPECTACULAR_SETTINGS``, because that global is already claimed by # a deliberately narrow schema in each service -- the Authoring API -# (``/authoring-api/``) in CMS and the Enrollment API (``/lms-api/``) in LMS. +# (``/authoring-api/``) in CMS and the LMS API (``/lms-api/``) in LMS. # Both filter the surface down via ``PREPROCESSING_HOOKS`` and trim a path # prefix, so ``/api-docs`` must switch that filtering off explicitly to cover # the whole service. diff --git a/openedx/core/djangoapps/profile_images/v2/__init__.py b/openedx/core/djangoapps/profile_images/v2/__init__.py new file mode 100644 index 000000000000..1b89a5f06f07 --- /dev/null +++ b/openedx/core/djangoapps/profile_images/v2/__init__.py @@ -0,0 +1 @@ +"""Version 2 of the profile image endpoints.""" diff --git a/openedx/core/djangoapps/profile_images/v2/serializers.py b/openedx/core/djangoapps/profile_images/v2/serializers.py new file mode 100644 index 000000000000..73683e29256e --- /dev/null +++ b/openedx/core/djangoapps/profile_images/v2/serializers.py @@ -0,0 +1,43 @@ +"""Serializers for version 2 of the profile image endpoints.""" + +import itertools + +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from ..images import IMAGE_TYPES + + +# A file field is published as the address a stored file is served from, +# because that is what a response carries; this body carries the file itself. +@extend_schema_field(OpenApiTypes.BINARY) +class UploadedImageField(serializers.FileField): + """A file sent as part of the request rather than named by its address.""" + + +class ProfileImageUploadSerializer(serializers.Serializer): # pylint: disable=abstract-method + """The image a user sends to be shown beside their name.""" + + file = UploadedImageField( + help_text=( + "Image to show beside the user's name. It is stored in each size the site renders, " + "cropped to a square, and must be a JPEG, PNG or GIF within the size limits this " + "site configures." + ), + ) + + +#: The media types an image sent as the whole request body may be sent in, +#: which are the ones the site stores a picture from. +UPLOAD_MEDIA_TYPES = tuple(sorted(set( + itertools.chain(*(image_type.mimetypes for image_type in IMAGE_TYPES.values())) +))) + +#: What an upload carries, by media type: the image as a named part of a form, +#: or the image alone as the whole body sent in the media type of the image. +#: Every address that takes an image reads both forms and publishes this. +IMAGE_UPLOAD_REQUEST = { + "multipart/form-data": ProfileImageUploadSerializer, + **{media_type: OpenApiTypes.BINARY for media_type in UPLOAD_MEDIA_TYPES}, +} diff --git a/openedx/core/djangoapps/profile_images/v2/tests/__init__.py b/openedx/core/djangoapps/profile_images/v2/tests/__init__.py new file mode 100644 index 000000000000..898afe25a32b --- /dev/null +++ b/openedx/core/djangoapps/profile_images/v2/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for version 2 of the profile image endpoints.""" diff --git a/openedx/core/djangoapps/profile_images/v2/tests/test_views.py b/openedx/core/djangoapps/profile_images/v2/tests/test_views.py new file mode 100644 index 000000000000..49321ea00090 --- /dev/null +++ b/openedx/core/djangoapps/profile_images/v2/tests/test_views.py @@ -0,0 +1,1011 @@ +"""Tests for the version 2 profile image views.""" + +import datetime +import os +import struct +import zlib +from contextlib import closing +from io import BytesIO +from unittest import mock +from zoneinfo import ZoneInfo + +import freezegun +import pytest +from django.contrib.auth.models import User # pylint: disable=imported-auth-user +from django.core.files.uploadedfile import SimpleUploadedFile +from django.urls import reverse +from edx_rest_framework_extensions.errors import classify_error +from edx_rest_framework_extensions.testing import assert_error_envelope +from PIL import Image +from rest_framework import status +from rest_framework.exceptions import MethodNotAllowed, ParseError, UnsupportedMediaType +from rest_framework.test import APITestCase + +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user +from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_names, get_profile_image_storage +from openedx.core.djangolib.testing.utils import skip_unless_lms + +from ...tests.helpers import make_image_file + +PASSWORD = "Password1234" + +#: What the shared catalog calls each failure a request meets before a handler +#: runs; this endpoint answers them as every other standardized API does. +REFUSED_METHOD_SLUG = classify_error(MethodNotAllowed("GET"))[0] +UNSUPPORTED_MEDIA_TYPE_SLUG = classify_error(UnsupportedMediaType("text/plain"))[0] +MALFORMED_BODY_SLUG, MALFORMED_BODY_TITLE = classify_error(ParseError()) + +#: Where the view records what it refused, which is where an operator reads +#: why a caller was told their image could not be used. +VIEWS_LOGGER = "openedx.core.djangoapps.profile_images.v2.views" + +#: Where the classes the platform declares record that they read a request. +DEFAULT_AUTH_RECORDER = "openedx.core.djangolib.default_auth_classes.set_custom_attribute" + +def earlier_address(username): + """Return the version 1 address of one user's image, which takes both methods.""" + return f"/api/user/v1/accounts/{username}/image" + + +class ProfileImageTestCase(APITestCase): + """Fixtures shared by the version 2 profile image tests.""" + + def setUp(self): + super().setUp() + self.owner = UserFactory.create( + username="image-owner", email="image-owner@example.com", password=PASSWORD, + ) + self.other = UserFactory.create( + username="image-other", email="image-other@example.com", password=PASSWORD, + ) + self.staff = UserFactory.create( + username="image-staff", email="image-staff@example.com", password=PASSWORD, is_staff=True, + ) + self.url = self.image_url(self.owner.username) + self.storage = get_profile_image_storage() + self.addCleanup(self.remove_stored_images, self.owner.username) + self.addCleanup(self.remove_stored_images, self.other.username) + + def remove_stored_images(self, username): + """Take whatever this user has stored out of the image store.""" + for name in get_profile_image_names(username).values(): + self.storage.delete(name) + + def image_url(self, username): + """Return the address of one user's image.""" + return reverse("user_v2:account_profile_image", kwargs={"username": username}) + + def login(self, user): + """Sign the given user in through the session these views accept.""" + assert self.client.login(username=user.username, password=PASSWORD) + + def token_headers(self, user): + """Return the request headers that carry a token identifying the given user.""" + return {"HTTP_AUTHORIZATION": f"JWT {create_jwt_for_user(user)}"} + + def sign_in_as_a_case_variant_of(self, user): + """ + Sign in a caller whose own name is the given user's, in upper case. + + The variant is set in memory only, since a site that stores usernames + case-insensitively has no room for both names at once. + """ + caller = UserFactory.create( + username="image-variant", email="image-variant@example.com", password=PASSWORD, + ) + caller.username = user.username.upper() + self.client.force_authenticate(user=caller) + return caller + + def upload(self, address=None, **kwargs): + """Send an image as a form field, the way a browser does.""" + with make_image_file(**kwargs) as image_file: + return self.client.post(address or self.url, {"file": image_file}, format="multipart") + + def not_an_image(self): + """Return a file the site stores nothing from, long enough to pass the size check.""" + return SimpleUploadedFile("notes.txt", b"x" * 200, content_type="text/plain") + + def unreadable_image(self): + """ + Return a file that answers every check the site makes and still holds no image. + + The name, the media type and the leading bytes are a PNG's, so the size, + extension, media-type and leading-byte checks all pass and the file is + only found to be unreadable when the site tries to draw it. + """ + return SimpleUploadedFile( + "picture.png", bytes.fromhex("89504e470d0a1a0a") + b"\x00" * 400, content_type="image/png", + ) + + def noisy_image(self, image_format="JPEG", dimensions=(320, 240), **options): + """ + Return the bytes of an image of random pixels, in the given format. + + Random pixels compress to little less than their own size, so half of + such a file is still larger than the smallest file the site takes, + which is what makes a part of one stand in for an upload that stopped + in transfer. An image of one colour shrinks to a few hundred bytes and + would be refused for its size before anything tried to draw it. + """ + image = Image.frombytes("RGB", dimensions, os.urandom(dimensions[0] * dimensions[1] * 3)) + body = BytesIO() + image.save(body, format=image_format, **options) + return body.getvalue() + + def cut_short(self, data): + """Return the leading half of the given bytes, as an upload that stopped partway.""" + return data[: len(data) // 2] + + def image_with_unreadable_metadata(self): + """Return a JPEG carrying a metadata block that is not the metadata it announces.""" + return self.noisy_image(exif=b"Exif\x00\x00" + b"\xff" * 64) + + def image_with_metadata_entries(self, *entries): + """ + Return a JPEG whose metadata holds one directory of the given raw entries. + + Each entry is a tag, a value type, a count and the four bytes that + carry the value, written as they stand, so an entry can say something + about itself that its value does not bear out. + """ + directory = struct.pack("I", len(payload)) + kind + payload + + struct.pack(">I", zlib.crc32(kind + payload)) + ) + + header = struct.pack(">IIBBBBB", dimensions[0], dimensions[1], 8, 2, 0, 0, 0) + return ( + b"\x89PNG\r\n\x1a\x0a" + + chunk(b"IHDR", header) + + chunk(b"IDAT", zlib.compress(b"\x00" * 64)) + + chunk(b"tEXt", b"padding" + b"\x00" * 150) + + chunk(b"IEND", b"") + ) + + def stored_sizes(self, username): + """Return the sizes this user has an image stored in.""" + return { + size for size, name in get_profile_image_names(username).items() + if self.storage.exists(name) + } + + def all_sizes(self, username): + """Return every size the site stores an image in.""" + return set(get_profile_image_names(username)) + + def has_image(self, user): + """Return whether the account records an image of its own.""" + user.profile.refresh_from_db() + return user.profile.has_profile_image + + +@skip_unless_lms +class ProfileImageUploadTests(ProfileImageTestCase): + """POST /api/user/v2/accounts/{username}/profile_image/""" + + def test_a_user_sets_the_image_on_their_own_account(self): + self.login(self.owner) + response = self.upload() + assert response.status_code == status.HTTP_204_NO_CONTENT + assert response.data is None + assert self.stored_sizes(self.owner.username) == self.all_sizes(self.owner.username) + assert self.has_image(self.owner) is True + + def test_each_stored_size_is_a_square_of_that_size(self): + self.login(self.owner) + self.upload(dimensions=(320, 240)) + for size, name in get_profile_image_names(self.owner.username).items(): + with closing(Image.open(self.storage.path(name))) as stored: + assert stored.size == (size, size), size + assert stored.format == "JPEG", size + + def test_the_account_records_when_the_image_was_set(self): + self.login(self.owner) + self.upload() + self.owner.profile.refresh_from_db() + assert self.owner.profile.profile_image_uploaded_at is not None + + def test_an_image_sent_as_the_whole_body_is_stored(self): + """The mobile apps send the image bytes with the media type of the image.""" + self.login(self.owner) + with make_image_file(extension=".jpg") as image_file: + response = self.client.post( + self.url, + image_file.read(), + content_type="image/jpeg", + HTTP_CONTENT_DISPOSITION='attachment; filename="image.jpg"', + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert self.stored_sizes(self.owner.username) == self.all_sizes(self.owner.username) + + def test_a_second_image_replaces_the_first(self): + self.login(self.owner) + with freezegun.freeze_time("2024-01-01 10:00:00"): + assert self.upload(dimensions=(320, 240)).status_code == status.HTTP_204_NO_CONTENT + self.owner.profile.refresh_from_db() + assert self.owner.profile.profile_image_uploaded_at == datetime.datetime( + 2024, 1, 1, 10, 0, tzinfo=ZoneInfo("UTC"), + ) + + with freezegun.freeze_time("2024-01-01 11:30:00"): + assert self.upload(dimensions=(640, 640), prefix="second").status_code == ( + status.HTTP_204_NO_CONTENT + ) + self.owner.profile.refresh_from_db() + assert self.owner.profile.profile_image_uploaded_at == datetime.datetime( + 2024, 1, 1, 11, 30, tzinfo=ZoneInfo("UTC"), + ) + assert self.stored_sizes(self.owner.username) == self.all_sizes(self.owner.username) + assert self.has_image(self.owner) is True + + def test_a_body_carrying_no_image_is_refused(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.client.post(self.url, {}, format="multipart"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"file": ["No file was submitted."]} + assert self.stored_sizes(self.owner.username) == set() + assert self.has_image(self.owner) is False + + def test_an_image_the_site_will_not_take_is_refused_by_what_is_wrong_with_it(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.client.post(self.url, {"file": self.not_an_image()}, format="multipart"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "file": ["The file must be one of the following types: .jpeg, .jpg, .png, .gif."], + } + assert self.stored_sizes(self.owner.username) == set() + assert self.has_image(self.owner) is False + + def test_an_image_whose_media_type_does_not_match_its_name_is_refused(self): + self.login(self.owner) + with make_image_file(extension=".png") as image_file: + mislabelled = SimpleUploadedFile( + "picture.png", image_file.read(), content_type="image/jpeg", + ) + envelope = assert_error_envelope( + self.client.post(self.url, {"file": mislabelled}, format="multipart"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "file": [ + "The Content-Type header for this file does not match the file data. " + "The file may be corrupted.", + ], + } + assert self.stored_sizes(self.owner.username) == set() + + def test_an_image_larger_than_the_site_takes_is_refused(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.upload(force_size=1024 * 1024 + 1), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"file": ["The file must be smaller than 1 MB in size."]} + assert self.stored_sizes(self.owner.username) == set() + + def test_an_image_smaller_than_the_site_takes_is_refused(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.upload(dimensions=(1, 1), extension=".png", force_size=99), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"file": ["The file must be at least 100 bytes in size."]} + assert self.stored_sizes(self.owner.username) == set() + + def test_a_file_shaped_like_an_image_that_holds_none_is_refused(self): + """A file whose leading bytes are a PNG's is one nothing can be drawn from.""" + self.login(self.owner) + envelope = assert_error_envelope( + self.client.post(self.url, {"file": self.unreadable_image()}, format="multipart"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "file": ["The image could not be read. The file may be incomplete or corrupted."], + } + assert self.stored_sizes(self.owner.username) == set() + assert self.has_image(self.owner) is False + + def test_an_image_whose_bytes_stop_early_is_refused(self): + """An upload cut off partway answers every check its bytes can answer.""" + self.login(self.owner) + for image_format, name, media_type in ( + ("JPEG", "picture.jpg", "image/jpeg"), + ("PNG", "picture.png", "image/png"), + ("GIF", "picture.gif", "image/gif"), + ): + with self.subTest(image_format=image_format): + cut_off = SimpleUploadedFile( + name, self.cut_short(self.noisy_image(image_format=image_format)), + content_type=media_type, + ) + envelope = assert_error_envelope( + self.client.post(self.url, {"file": cut_off}, format="multipart"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "file": ["The image could not be read. The file may be incomplete or corrupted."], + } + assert self.stored_sizes(self.owner.username) == set() + assert self.has_image(self.owner) is False + + def test_an_image_sent_as_the_whole_body_whose_bytes_stop_early_is_refused(self): + """A body cut off in transfer is as much the caller's fault as a form part is.""" + self.login(self.owner) + with self.assertLogs(VIEWS_LOGGER, level="INFO") as logged: + response = self.client.post( + self.url, + self.cut_short(self.noisy_image(image_format="PNG")), + content_type="image/png", + HTTP_CONTENT_DISPOSITION='attachment; filename="picture.png"', + ) + assert [ + line for line in logged.output + if self.owner.username in line and "OSError" in line + ], logged.output + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "file": ["The image could not be read. The file may be incomplete or corrupted."], + } + assert self.stored_sizes(self.owner.username) == set() + assert self.has_image(self.owner) is False + + def test_an_image_whose_metadata_cannot_be_read_is_refused(self): + """Each stored size carries the metadata of the file it was drawn from.""" + self.login(self.owner) + unreadable = SimpleUploadedFile( + "picture.jpg", self.image_with_unreadable_metadata(), content_type="image/jpeg", + ) + envelope = assert_error_envelope( + self.client.post(self.url, {"file": unreadable}, format="multipart"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "file": ["The image could not be read. The file may be incomplete or corrupted."], + } + assert self.stored_sizes(self.owner.username) == set() + assert self.has_image(self.owner) is False + + def assert_refused_as_unreadable(self, data): + """Send ``data`` as the owner's image and check it is refused and nothing is stored.""" + self.login(self.owner) + upload = SimpleUploadedFile("picture.jpg", data, content_type="image/jpeg") + envelope = assert_error_envelope( + self.client.post(self.url, {"file": upload}, format="multipart"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "file": ["The image could not be read. The file may be incomplete or corrupted."], + } + assert self.stored_sizes(self.owner.username) == set() + assert self.has_image(self.owner) is False + + def test_metadata_naming_a_value_type_that_does_not_exist_is_refused(self): + """The manufacturer entry claims value type 99, which the metadata library cannot decode.""" + self.assert_refused_as_unreadable(self.image_with_metadata_entries((0x010F, 99, 1, b"\0\0\0\0"))) + + def test_metadata_that_reads_but_cannot_be_written_back_is_refused(self): + """ + A resolution held as a short with no values is read, and fails when each size writes it back. + + Only writing the metadata back finds the fault, so reading it alone + would let the image through to fail once the sizes were being stored. + """ + self.assert_refused_as_unreadable(self.image_with_metadata_entries((0x011A, 3, 0, b"\0\0\0\0"))) + + def test_metadata_whose_pointer_claims_two_places_is_refused(self): + """The pointer to the second directory claims two offsets, which the library cannot follow.""" + self.assert_refused_as_unreadable(self.image_with_metadata_entries((0x8769, 4, 2, b"\0\0\0\0"))) + + def test_an_image_whose_metadata_reads_and_writes_back_is_stored(self): + self.login(self.owner) + with make_image_file(orientation=8) as image_file: + assert Image.open(image_file).info.get("exif") is not None + image_file.seek(0) + response = self.client.post(self.url, {"file": image_file}, format="multipart") + assert response.status_code == status.HTTP_204_NO_CONTENT, response.content + assert self.stored_sizes(self.owner.username) == self.all_sizes(self.owner.username) + assert self.has_image(self.owner) is True + + def test_an_image_declaring_more_pixels_than_the_site_draws_is_refused(self): + """The refusal names the declaration, which is not the same fault as a file that stops early.""" + self.login(self.owner) + declared = SimpleUploadedFile( + "picture.png", self.image_declaring_more_pixels_than_it_holds(), content_type="image/png", + ) + envelope = assert_error_envelope( + self.client.post(self.url, {"file": declared}, format="multipart"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "file": ["The image declares more pixels than this site draws."], + } + assert self.stored_sizes(self.owner.username) == set() + assert self.has_image(self.owner) is False + + def test_an_image_sent_as_the_whole_body_without_a_name_is_refused(self): + """The name the body is sent under carries the extension, and nothing else does.""" + self.login(self.owner) + with make_image_file(extension=".jpg") as image_file: + response = self.client.post(self.url, image_file.read(), content_type="image/jpeg") + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "file": [ + "Missing filename. Request should include a Content-Disposition header " + "with a filename parameter.", + ], + } + assert self.stored_sizes(self.owner.username) == set() + assert self.has_image(self.owner) is False + + def test_an_image_sent_as_the_whole_body_under_a_name_of_another_type_is_refused(self): + """The extension of the name and the media type of the body must agree.""" + self.login(self.owner) + with make_image_file(extension=".jpg") as image_file: + response = self.client.post( + self.url, + image_file.read(), + content_type="image/jpeg", + HTTP_CONTENT_DISPOSITION='attachment; filename="image.png"', + ) + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug=MALFORMED_BODY_SLUG, + ) + assert envelope["title"] == MALFORMED_BODY_TITLE + assert self.stored_sizes(self.owner.username) == set() + assert self.has_image(self.owner) is False + + def test_a_form_body_that_is_not_well_formed_is_refused_as_the_callers_fault(self): + """A multipart body sent without the boundary that separates its parts cannot be read.""" + self.login(self.owner) + response = self.client.post( + self.url, b"--nothing-here--", content_type="multipart/form-data", + ) + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug=MALFORMED_BODY_SLUG, + ) + assert envelope["title"] == MALFORMED_BODY_TITLE + assert self.stored_sizes(self.owner.username) == set() + assert self.has_image(self.owner) is False + + def test_a_learner_may_not_set_another_learners_image(self): + self.login(self.other) + assert_error_envelope( + self.upload(address=self.url), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + assert self.stored_sizes(self.owner.username) == set() + assert self.has_image(self.owner) is False + + def test_a_caller_named_for_the_address_in_case_alone_may_not_set_the_image(self): + """The address names one account, and a name that differs in case is another name.""" + self.sign_in_as_a_case_variant_of(self.owner) + assert_error_envelope( + self.upload(address=self.url), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + assert self.stored_sizes(self.owner.username) == set() + assert self.has_image(self.owner) is False + + def test_a_name_no_account_holds_is_refused_as_somebody_elses_image(self): + """ + A name nobody holds is not the caller's own, so it is refused rather than reported missing. + + The refusal a caller meets at a name nobody holds is the one they meet + at any other account's address, which is what the published refusals + say happens here. + """ + self.login(self.owner) + assert_error_envelope( + self.upload(address=self.image_url("nobody-at-all")), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + assert self.stored_sizes("nobody-at-all") == set() + + def test_an_account_carrying_no_profile_is_not_found(self): + """The account is read when the image is recorded, and the refusal says so.""" + self.login(self.owner) + self.owner.profile.delete() + assert_error_envelope( + self.upload(), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_a_staff_caller_may_not_set_another_account_s_image_either(self): + """Staff access is not an exception here: the image is the owner's to set.""" + self.login(self.staff) + assert_error_envelope( + self.upload(address=self.url), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + assert self.stored_sizes(self.owner.username) == set() + + def test_a_refused_upload_stays_refused_whatever_else_the_request_carries(self): + """Who may set this image is settled from the address, before the query string or the form is read.""" + self.login(self.other) + for address in ( + f"{self.url}?username={self.other.username}", + f"{self.url}?user={self.other.username}", + ): + with self.subTest(address=address): + assert_error_envelope( + self.upload(address=address), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + for field in ("username", "user"): + with self.subTest(field=field): + with make_image_file() as image_file: + response = self.client.post( + self.url, {"file": image_file, field: self.other.username}, format="multipart", + ) + assert_error_envelope( + response, + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + assert self.stored_sizes(self.owner.username) == set() + assert self.stored_sizes(self.other.username) == set() + assert self.has_image(self.owner) is False + assert self.has_image(self.other) is False + + def test_a_name_sent_with_the_image_does_not_move_where_it_is_stored(self): + """The sizes are stored under the name the address carries, whatever the form carries.""" + self.login(self.owner) + with make_image_file() as image_file: + response = self.client.post( + self.url, {"file": image_file, "username": self.other.username}, format="multipart", + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert self.stored_sizes(self.owner.username) == self.all_sizes(self.owner.username) + assert self.stored_sizes(self.other.username) == set() + assert self.has_image(self.owner) is True + assert self.has_image(self.other) is False + + def test_an_anonymous_caller_is_unauthenticated(self): + assert_error_envelope( + self.upload(), + expected_status=status.HTTP_401_UNAUTHORIZED, + expected_type_slug="authn", + ) + assert self.stored_sizes(self.owner.username) == set() + + def test_a_caller_who_has_not_confirmed_their_email_address_may_still_set_an_image(self): + self.owner.is_active = False + self.owner.save() + with make_image_file() as image_file: + response = self.client.post( + self.url, {"file": image_file}, format="multipart", **self.token_headers(self.owner), + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert self.stored_sizes(self.owner.username) == self.all_sizes(self.owner.username) + + def test_a_token_is_read_here_without_recording_the_address_as_one_on_the_defaults(self): + """ + This address names its own classes, so it is not one the default pair serves. + + The pair the platform configures records every request it reads a + token for, so that an operator can list the addresses it serves. This + one replaces the session half of it and is therefore not among them, + and nothing here is recorded as if it were. The address read afterwards + names no classes of its own and is recorded, which is what shows the + record is reachable from a request made this way at all. + """ + with mock.patch(DEFAULT_AUTH_RECORDER) as recorded: + with make_image_file() as image_file: + response = self.client.post( + self.url, {"file": image_file}, format="multipart", **self.token_headers(self.owner), + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert self.stored_sizes(self.owner.username) == self.all_sizes(self.owner.username) + assert recorded.call_args_list == [] + + with mock.patch(DEFAULT_AUTH_RECORDER) as recorded: + on_the_default_pair = self.client.get( + reverse("user_v2:time_zone_list"), **self.token_headers(self.staff), + ) + assert on_the_default_pair.status_code == status.HTTP_200_OK + assert [call.args for call in recorded.call_args_list] == [("using_default_auth_classes", True)] + + def test_a_body_in_a_media_type_this_endpoint_cannot_read_is_refused(self): + self.login(self.owner) + assert_error_envelope( + self.client.post(self.url, {"file": "not an image"}, format="json"), + expected_status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + expected_type_slug=UNSUPPORTED_MEDIA_TYPE_SLUG, + ) + assert self.stored_sizes(self.owner.username) == set() + + def test_a_query_parameter_this_endpoint_does_not_read_is_refused(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.upload(address=f"{self.url}?foo=1"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"foo": ["No such query parameter. Send any of: format."]} + assert self.stored_sizes(self.owner.username) == set() + assert self.has_image(self.owner) is False + + def test_the_address_is_not_read_with_a_get(self): + self.login(self.owner) + assert_error_envelope( + self.client.get(self.url), + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug=REFUSED_METHOD_SLUG, + ) + + +@skip_unless_lms +class ProfileImageRemovalTests(ProfileImageTestCase): + """DELETE /api/user/v2/accounts/{username}/profile_image/""" + + def setUp(self): + super().setUp() + self.login(self.owner) + assert self.upload().status_code == status.HTTP_204_NO_CONTENT + + def test_a_user_clears_the_image_on_their_own_account(self): + response = self.client.delete(self.url) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert response.data is None + assert self.stored_sizes(self.owner.username) == set() + assert self.has_image(self.owner) is False + + def test_clearing_an_account_that_carries_no_image_is_not_an_error(self): + assert self.client.delete(self.url).status_code == status.HTTP_204_NO_CONTENT + assert self.client.delete(self.url).status_code == status.HTTP_204_NO_CONTENT + assert self.has_image(self.owner) is False + + def test_a_query_parameter_this_endpoint_does_not_read_is_refused(self): + """A caller that guards a destructive request with a parameter is told it is not read.""" + envelope = assert_error_envelope( + self.client.delete(f"{self.url}?confirm=no&foo=1"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "confirm": ["No such query parameter. Send any of: format."], + "foo": ["No such query parameter. Send any of: format."], + } + assert self.stored_sizes(self.owner.username) == self.all_sizes(self.owner.username) + assert self.has_image(self.owner) is True + + def test_a_learner_may_not_clear_another_learners_image(self): + self.client.logout() + self.login(self.other) + assert_error_envelope( + self.client.delete(self.url), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + assert self.stored_sizes(self.owner.username) == self.all_sizes(self.owner.username) + assert self.has_image(self.owner) is True + + def test_a_caller_named_for_the_address_in_case_alone_may_not_clear_the_image(self): + """The address names one account, and a name that differs in case is another name.""" + self.client.logout() + self.sign_in_as_a_case_variant_of(self.owner) + assert_error_envelope( + self.client.delete(self.url), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + assert self.stored_sizes(self.owner.username) == self.all_sizes(self.owner.username) + assert self.has_image(self.owner) is True + + def test_a_staff_caller_may_not_clear_another_account_s_image_either(self): + self.client.logout() + self.login(self.staff) + assert_error_envelope( + self.client.delete(self.url), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + assert self.stored_sizes(self.owner.username) == self.all_sizes(self.owner.username) + assert self.has_image(self.owner) is True + + def test_a_refused_removal_stays_refused_whatever_the_query_string_carries(self): + """Who may clear this image is settled from the address, before the query string is read.""" + self.client.logout() + self.login(self.other) + for address in ( + f"{self.url}?username={self.other.username}", + f"{self.url}?user={self.other.username}", + ): + with self.subTest(address=address): + assert_error_envelope( + self.client.delete(address), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + assert self.stored_sizes(self.owner.username) == self.all_sizes(self.owner.username) + assert self.has_image(self.owner) is True + + def test_an_anonymous_caller_is_unauthenticated(self): + self.client.logout() + assert_error_envelope( + self.client.delete(self.url), + expected_status=status.HTTP_401_UNAUTHORIZED, + expected_type_slug="authn", + ) + assert self.stored_sizes(self.owner.username) == self.all_sizes(self.owner.username) + + def test_an_account_that_no_longer_exists_is_not_found(self): + """The account is read when the image is cleared, and the refusal says so.""" + address = self.image_url(self.owner.username) + self.owner.profile.delete() + assert_error_envelope( + self.client.delete(address), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_a_name_no_account_holds_is_refused_as_somebody_elses_image(self): + """ + A name nobody holds is not the caller's own, so it is refused rather than reported missing. + + The refusal a caller meets at a name nobody holds is the one they meet + at any other account's address, which is what the published refusals + say happens here. + """ + assert_error_envelope( + self.client.delete(self.image_url("nobody-at-all")), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + +@skip_unless_lms +class ProfileImageAddressedNameTests(ProfileImageTestCase): + """ + This address carries the name an account is stored under. + + The account collection reads ``me`` as the account of the caller; the + addresses beneath an account read the name they carry as it stands, so a + caller that sends ``me`` to this one is answered as a caller naming any + other account is. + """ + + def setUp(self): + super().setUp() + self.login(self.owner) + self.collection_name_url = self.image_url("me") + + def test_an_image_is_not_set_at_the_collections_own_name(self): + assert_error_envelope( + self.upload(address=self.collection_name_url), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + assert self.stored_sizes(self.owner.username) == set() + assert self.has_image(self.owner) is False + + def test_an_image_is_not_cleared_at_the_collections_own_name(self): + assert self.upload().status_code == status.HTTP_204_NO_CONTENT + assert_error_envelope( + self.client.delete(self.collection_name_url), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + assert self.stored_sizes(self.owner.username) == self.all_sizes(self.owner.username) + assert self.has_image(self.owner) is True + + def test_the_account_holding_that_name_is_served_at_it(self): + """A user may register the name, and sets their own image where it is addressed.""" + holder = UserFactory.create(username="me", email="me@example.com", password=PASSWORD) + self.addCleanup(self.remove_stored_images, holder.username) + self.client.logout() + self.login(holder) + assert self.upload(address=self.collection_name_url).status_code == status.HTTP_204_NO_CONTENT + assert self.stored_sizes(holder.username) == self.all_sizes(holder.username) + assert self.has_image(holder) is True + + +@skip_unless_lms +class ProfileImageParityTests(ProfileImageTestCase): + """The same requests through the version 1 and the version 2 addresses.""" + + def test_both_versions_store_the_same_sizes(self): + self.login(self.owner) + earlier = self.upload(address=earlier_address(self.owner.username)) + assert earlier.status_code == status.HTTP_204_NO_CONTENT + earlier_sizes = self.stored_sizes(self.owner.username) + assert self.has_image(self.owner) is True + self.client.delete(earlier_address(self.owner.username)) + + later = self.upload() + assert later.status_code == earlier.status_code + assert later.data == earlier.data + assert self.stored_sizes(self.owner.username) == earlier_sizes + assert self.has_image(self.owner) is True + + def test_both_versions_clear_the_image_the_same_way(self): + self.login(self.owner) + self.upload() + earlier = self.client.delete(earlier_address(self.owner.username)) + assert earlier.status_code == status.HTTP_204_NO_CONTENT + assert self.stored_sizes(self.owner.username) == set() + + self.upload() + later = self.client.delete(self.url) + assert later.status_code == earlier.status_code + assert self.stored_sizes(self.owner.username) == set() + assert self.has_image(self.owner) is False + + def test_both_versions_refuse_an_image_the_site_will_not_take(self): + self.login(self.owner) + earlier = self.client.post( + earlier_address(self.owner.username), {"file": self.not_an_image()}, format="multipart", + ) + assert earlier.status_code == status.HTTP_400_BAD_REQUEST + assert earlier.data == { + "developer_message": "The file must be one of the following types: .jpeg, .jpg, .png, .gif.", + "user_message": "The file must be one of the following types: .jpeg, .jpg, .png, .gif.", + } + + later = self.client.post(self.url, {"file": self.not_an_image()}, format="multipart") + assert later.status_code == earlier.status_code + # The one declared difference: the refusal is the standard body, and + # the message version 1 carried as ``user_message`` names the field. + assert later.data["errors"] == { + "file": ["The file must be one of the following types: .jpeg, .jpg, .png, .gif."], + } + + def test_both_versions_refuse_a_body_carrying_no_image(self): + self.login(self.owner) + earlier = self.client.post(earlier_address(self.owner.username), {}, format="multipart") + assert earlier.status_code == status.HTTP_400_BAD_REQUEST + assert earlier.data == { + "developer_message": "No file provided for profile image", + "user_message": "No file provided for profile image", + } + + later = self.client.post(self.url, {}, format="multipart") + assert later.status_code == earlier.status_code + assert later.data["errors"] == {"file": ["No file was submitted."]} + + def test_both_versions_refuse_another_learners_image(self): + self.login(self.other) + earlier = self.upload(address=earlier_address(self.owner.username)) + assert earlier.status_code == status.HTTP_403_FORBIDDEN + + later = self.upload() + assert later.status_code == earlier.status_code + assert_error_envelope(later, expected_status=403, expected_type_slug="authz") + assert self.stored_sizes(self.owner.username) == set() + + +@skip_unless_lms +class ImageRecordingTests(ProfileImageTestCase): + """ + What an image is recorded on is the account the request was admitted for. + + The profile written is reached from that account, not looked up again by + the name the account is stored under. + """ + + def renaming_the_owner_before_the_image_is_recorded(self): + """Store the owner under another name once the sizes are written and before the image is recorded.""" + def rename(*args, **kwargs): # pylint: disable=unused-argument + User.objects.filter(pk=self.owner.pk).update(username="image-owner-elsewhere") + + return mock.patch( + "openedx.core.djangoapps.profile_images.v2.views.create_profile_images", side_effect=rename, + ) + + def test_an_upload_is_recorded_on_the_admitted_account_whatever_name_it_is_stored_under(self): + self.login(self.owner) + with self.renaming_the_owner_before_the_image_is_recorded(): + response = self.upload() + assert response.status_code == status.HTTP_204_NO_CONTENT + assert self.has_image(self.owner) is True + + def test_an_admitted_account_carrying_no_profile_is_told_there_is_nothing_here(self): + self.login(self.owner) + self.owner.profile.delete() + assert_error_envelope( + self.client.delete(self.url), expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + + +@pytest.fixture(name="image_owner") +@skip_unless_lms +def image_owner_fixture(client, django_user_model): # pylint: disable=unused-argument + """Sign a learner in and hand back the addresses of their image in both versions.""" + owner = UserFactory.create(username="count-image-owner", password=PASSWORD) + assert client.login(username=owner.username, password=PASSWORD) + # The first authenticated request of a session populates caches the rest + # of the session reuses; the counts below are the steady-state ones. + client.delete(reverse("user_v2:account_profile_image", kwargs={"username": owner.username})) + return owner + + +#: What setting and clearing an image cost, measured on the version 1 address +#: first and asserted on both. Neither version reads anything per stored size. +UPLOAD_QUERIES = 17 +REMOVAL_QUERIES = 17 + + +@pytest.mark.django_db +@skip_unless_lms +def test_setting_an_image_costs_what_the_earlier_address_costs( + client, image_owner, django_assert_num_queries, +): + storage = get_profile_image_storage() + try: + with make_image_file() as image_file, django_assert_num_queries(UPLOAD_QUERIES): + earlier = client.post( + f"/api/user/v1/accounts/{image_owner.username}/image", {"file": image_file}, + ) + assert earlier.status_code == status.HTTP_204_NO_CONTENT + + address = reverse("user_v2:account_profile_image", kwargs={"username": image_owner.username}) + with make_image_file() as image_file, django_assert_num_queries(UPLOAD_QUERIES): + later = client.post(address, {"file": image_file}) + assert later.status_code == status.HTTP_204_NO_CONTENT + finally: + for name in get_profile_image_names(image_owner.username).values(): + storage.delete(name) + + +@pytest.mark.django_db +@skip_unless_lms +def test_clearing_an_image_costs_what_the_earlier_address_costs( + client, image_owner, django_assert_num_queries, +): + with django_assert_num_queries(REMOVAL_QUERIES): + earlier = client.delete(f"/api/user/v1/accounts/{image_owner.username}/image") + assert earlier.status_code == status.HTTP_204_NO_CONTENT + + address = reverse("user_v2:account_profile_image", kwargs={"username": image_owner.username}) + with django_assert_num_queries(REMOVAL_QUERIES): + later = client.delete(address) + assert later.status_code == status.HTTP_204_NO_CONTENT diff --git a/openedx/core/djangoapps/profile_images/v2/views.py b/openedx/core/djangoapps/profile_images/v2/views.py new file mode 100644 index 000000000000..ee8be165d38f --- /dev/null +++ b/openedx/core/djangoapps/profile_images/v2/views.py @@ -0,0 +1,268 @@ +"""Views for version 2 of the profile image endpoints.""" + +import datetime +import logging +import struct +from contextlib import closing +from zoneinfo import ZoneInfo + +import piexif +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser +from edx_rest_framework_extensions.mixins import StandardizedErrorMixin +from PIL import Image +from rest_framework import permissions, status +from rest_framework.exceptions import NotFound, ValidationError +from rest_framework.parsers import FormParser, MultiPartParser +from rest_framework.response import Response +from rest_framework.views import APIView + +from common.djangoapps.student.models import UserProfile +from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_names +from openedx.core.djangoapps.user_api.v2.errors import ReadableErrorDetailMixin +from openedx.core.djangoapps.user_api.v2.permissions import IsAddressedUser, RestrictedApplicationGuardMixin +from openedx.core.djangoapps.user_api.v2.serializers import ErrorResponse +from openedx.core.djangoapps.user_api.v2.view_utils import ( + RESP_UNAUTHENTICATED, + RESP_UNREADABLE_BODY, + SDK_TAG, + USERNAME_PATTERN, + StrictQueryParametersMixin, + path_param, +) +from openedx.core.djangoapps.user_api.v2.views import RESP_NOT_ACCEPTABLE, AccountResourceMixin +from openedx.core.lib.api.parsers import TypedFileUploadParser + +from ..exceptions import ImageValidationError +from ..images import create_profile_images, remove_profile_images, validate_uploaded_image +from .serializers import IMAGE_UPLOAD_REQUEST, UPLOAD_MEDIA_TYPES, ProfileImageUploadSerializer + +log = logging.getLogger(__name__) + +# A stored username; the account collection's shorthand for the caller is not translated here. +_USERNAME_PATH_PARAM = path_param( + "username", + "Unique name of the user whose image is addressed. The name is read as it is stored, so " + "`me` here names an account holding that name rather than the account of the caller " + "making the request.", + USERNAME_PATTERN, +) + +_RESP_ANOTHER_USERS_IMAGE = OpenApiResponse( + ErrorResponse, description="The image belongs to another user, which nobody else may change.", +) +_RESP_NO_PROFILE = OpenApiResponse( + ErrorResponse, + description=( + "The account at this address carries no profile to record an image on. A name no account " + "holds is refused as somebody else's image instead of being reported here." + ), +) + +_CONTENT_DISPOSITION_HEADER_PARAM = OpenApiParameter( + name="Content-Disposition", + description=( + "Name the image is sent under, as `attachment; filename=\"picture.png\"`. Required of a " + "request that sends the image as the whole body, because the extension of that name is " + "what is held against the media type the body was sent in. A form part carries its own " + "name and needs no header." + ), + required=False, + location=OpenApiParameter.HEADER, + type=str, +) + +#: What a caller is told about a file that is shaped like an image the site +#: takes but holds nothing the site can draw. +UNREADABLE_IMAGE_MESSAGE = "The image could not be read. The file may be incomplete or corrupted." + +#: What a caller is told about a file whose header declares a picture far +#: larger than any this site draws, which is never decoded. +OVERSIZED_IMAGE_MESSAGE = "The image declares more pixels than this site draws." + +#: What the imaging library raises when the bytes handed to it hold no picture +#: it can draw: a file whose bytes stop early, or metadata that does not parse. +UNREADABLE_IMAGE_ERRORS = (OSError, struct.error) + +#: What the metadata library raises on metadata it cannot take apart, or +#: cannot write back once taken apart, which each stored size does with it. +UNREADABLE_METADATA_ERRORS = (ValueError, IndexError, TypeError) + + +class NamedImageUploadParser(TypedFileUploadParser): + """ + Reads an image sent as the whole request body, and refuses one sent without a name. + + The name a body is sent under carries the extension that is compared with + the media type, and it is read from the ``Content-Disposition`` header. A + body sent without that header has no name to compare, which is a fault in + the request, so it is refused here rather than left to fail further in. + """ + + def get_filename(self, stream, media_type, parser_context): + """Return the name the body was sent under, refusing a body sent without one.""" + filename = super().get_filename(stream, media_type, parser_context) + if not filename: + raise ValidationError({"file": [self.errors["no_filename"]]}) + return filename + + +@extend_schema(tags=[SDK_TAG]) +class ProfileImageView( + StandardizedErrorMixin, + RestrictedApplicationGuardMixin, + ReadableErrorDetailMixin, + AccountResourceMixin, + StrictQueryParametersMixin, + APIView, +): + """ + The image shown beside a user's name, in each size the site renders. + + A user sets and clears the image on their own account and nobody else's, + staff included. Setting an image replaces one already set, and clearing an + account that carries no image of its own is not an error. The addresses + the stored sizes are served from are carried by the account itself. + """ + + # A view of its own: only the owner is admitted, unlike the account collection. + + # Admits the session of an account whose email is not yet confirmed, which + # the platform default refuses; tokens are read as the platform reads them. + authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser) + permission_classes = (permissions.IsAuthenticated, IsAddressedUser) + parser_classes = (MultiPartParser, FormParser, NamedImageUploadParser) + serializer_class = ProfileImageUploadSerializer + upload_media_types = UPLOAD_MEDIA_TYPES + + @extend_schema( + summary="Set the profile image", + description=( + "Stores the uploaded image in each size the site renders, cropped to a square, and " + "records on the account that it has one. An image already set is replaced. The image " + "may be sent as a form field named `file` or as the whole body, with the media type " + "of the image." + ), + parameters=[_USERNAME_PATH_PARAM, _CONTENT_DISPOSITION_HEADER_PARAM], + request=IMAGE_UPLOAD_REQUEST, + responses={ + 204: OpenApiResponse(description="The account now carries this image."), + 400: OpenApiResponse( + ErrorResponse, + description=( + "The request carries no usable image: none was sent, the body is not well " + "formed in the media type it was sent in, one sent as the whole body was sent " + "without a name or under a name whose extension names another media type, the " + "image is of a type or a size this site does not take, or no picture can be " + "drawn from the file, whose bytes may stop early, carry metadata that does " + "not parse, or declare more pixels than this site draws. A query parameter " + "this endpoint does not read is refused here too." + ), + ), + 401: RESP_UNAUTHENTICATED, + 403: _RESP_ANOTHER_USERS_IMAGE, + 404: _RESP_NO_PROFILE, + 406: RESP_NOT_ACCEPTABLE, + 415: RESP_UNREADABLE_BODY, + }, + ) + def post(self, request, username): + """Store the uploaded image in each size the site renders.""" + upload = ProfileImageUploadSerializer(data=request.data) + upload.is_valid(raise_exception=True) + uploaded_file = upload.validated_data["file"] + with closing(uploaded_file): + try: + validate_uploaded_image(uploaded_file) + except ImageValidationError as error: + raise self._unusable_image(username, error.user_message, error) from error + self._read_image(username, uploaded_file) + image_names = get_profile_image_names(username) + create_profile_images(uploaded_file, image_names) + self._record_image(request.user, True) + log.info( + "Generated and uploaded images %(image_names)s for user %(user_id)s", + {"image_names": list(image_names.values()), "user_id": request.user.id}, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + @extend_schema( + summary="Clear the profile image", + description=( + "Removes the stored sizes of the user's image and records on the account that it has " + "none, which is what the default image is shown for. Clearing an account that has no " + "image of its own is not an error." + ), + parameters=[_USERNAME_PATH_PARAM], + request=None, + responses={ + 204: OpenApiResponse(description="The account carries no image of its own."), + 400: OpenApiResponse( + ErrorResponse, + description="The request carries a query parameter this endpoint does not read.", + ), + 401: RESP_UNAUTHENTICATED, + 403: _RESP_ANOTHER_USERS_IMAGE, + 404: _RESP_NO_PROFILE, + 406: RESP_NOT_ACCEPTABLE, + }, + ) + def delete(self, request, username): + """Remove the stored sizes of the user's image.""" + self._record_image(request.user, False) + image_names = get_profile_image_names(username) + remove_profile_images(image_names) + log.info( + "Deleted images %(image_names)s for user %(user_id)s", + {"image_names": list(image_names.values()), "user_id": request.user.id}, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + def _read_image(self, username, uploaded_file): + """ + Read the uploaded image once, refusing bytes no picture can be drawn from. + + Every stored size is drawn from these bytes and carries the metadata + found in them, taken apart and written back, so a file whose bytes stop + early, whose metadata cannot be taken apart or written back, or whose + header declares a picture far larger than any this site draws fails + partway through that work. Reading it here, metadata included, tells a + fault in the request apart from a failure to store what was drawn from + it, which is the site's own. + """ + try: + image = Image.open(uploaded_file) + image.load() + metadata = image.info.get("exif") + if metadata is not None: + try: + piexif.dump(piexif.load(metadata)) + except UNREADABLE_METADATA_ERRORS as error: + raise self._unusable_image(username, UNREADABLE_IMAGE_MESSAGE, error) from error + except Image.DecompressionBombError as error: + raise self._unusable_image(username, OVERSIZED_IMAGE_MESSAGE, error) from error + except UNREADABLE_IMAGE_ERRORS as error: + raise self._unusable_image(username, UNREADABLE_IMAGE_MESSAGE, error) from error + finally: + uploaded_file.seek(0) + + def _record_image(self, user, is_uploaded): + """ + Record on the account whether it carries an image of its own. + + The profile written is reached from the account the request was + admitted for rather than looked up again by name, so what is recorded + lands on that account and on no other. + """ + try: + profile = UserProfile.objects.get(user=user) + except UserProfile.DoesNotExist as exc: + raise NotFound from exc + profile.profile_image_uploaded_at = datetime.datetime.now(ZoneInfo("UTC")) if is_uploaded else None + profile.save() + + def _unusable_image(self, username, message, reason): + """Return the refusal telling the caller what the site cannot do with this image.""" + log.info("Profile image refused for %r: %r", username, reason) + return ValidationError({"file": [message]}) diff --git a/openedx/core/djangoapps/user_api/accounts/utils.py b/openedx/core/djangoapps/user_api/accounts/utils.py index be6717c61d26..0afc2195c123 100644 --- a/openedx/core/djangoapps/user_api/accounts/utils.py +++ b/openedx/core/djangoapps/user_api/accounts/utils.py @@ -322,6 +322,20 @@ def handle_retirement_cancellation(retirement, email_address=None): retirement.delete() +def get_orgs_for_user(user): + """ + Returns a set of orgs that the user has enrollments with + """ + orgs = set() + for enrollment in user.courseenrollment_set.all(): + org = enrollment.course_id.org + + # Org can conceivably be blank or this bogus default value + if org and org != "outdated_entry": + orgs.add(org) + return orgs + + def _is_retired_email_format(email): """ Returns True if the given email address is in the retired-email domain diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py index 61c7fb3c4b47..7443f57dcf45 100644 --- a/openedx/core/djangoapps/user_api/accounts/views.py +++ b/openedx/core/djangoapps/user_api/accounts/views.py @@ -95,7 +95,7 @@ UserSearchEmailSerializer, ) from .signals import USER_RETIRE_LMS_CRITICAL, USER_RETIRE_LMS_MISC, USER_RETIRE_MAILINGS -from .utils import create_retirement_request_and_deactivate_account, username_suffix_generator +from .utils import create_retirement_request_and_deactivate_account, get_orgs_for_user, username_suffix_generator log = logging.getLogger(__name__) @@ -681,14 +681,7 @@ def _get_orgs_for_user(user): """ Returns a set of orgs that the user has enrollments with """ - orgs = set() - for enrollment in user.courseenrollment_set.all(): - org = enrollment.course_id.org - - # Org can conceivably be blank or this bogus default value - if org and org != "outdated_entry": - orgs.add(org) - return orgs + return get_orgs_for_user(user) def retirement_partner_report(self, request): # pylint: disable=unused-argument """ diff --git a/openedx/core/djangoapps/user_api/schema.py b/openedx/core/djangoapps/user_api/schema.py new file mode 100644 index 000000000000..78a44bbef783 --- /dev/null +++ b/openedx/core/djangoapps/user_api/schema.py @@ -0,0 +1,481 @@ +"""How a document describes the addresses of this API that a conforming one replaces.""" + +import re +from collections import namedtuple +from functools import cache + +from drf_spectacular.utils import OpenApiResponse +from rest_framework.parsers import FormParser, JSONParser, MultiPartParser +from rest_framework.settings import api_settings + +# Addresses superseded by a conforming address this schema also publishes, spelled +# out rather than matched by prefix so an address with no replacement is never marked. +SUPERSEDED_USER_PATHS = ( + "/api/user/v1/me", + "/api/user/v1/accounts", + "/api/user/v1/accounts/search_emails", + "/api/user/v1/accounts/{username}", + "/api/user/v1/accounts/{username}/deactivate/", + "/api/user/v1/accounts/{username}/image", + "/api/user/v1/accounts/{username}/verification_status/", + "/api/user/v1/accounts/{username}/verifications/", + "/api/user/v1/accounts/name_change/", + "/api/user/v1/accounts/name_change/{username}/confirm/", + "/api/user/v1/users/", + "/api/user/v1/users/{id}/", + "/api/user/v1/user_prefs/", + "/api/user/v1/user_prefs/{id}/", + "/api/user/v1/preferences/{username}", + "/api/user/v1/preferences/{username}/{preference_key}", + "/api/user/v1/preferences/email_opt_in/", + "/api/user/v1/preferences/{pref_key}/users/", + "/api/user/v1/forum_roles/{name}/users/", + "/api/user/v1/preferences/time_zones/", + "/api/user/v1/accounts/{username}/retirement_status/", + "/api/user/v1/accounts/retirement_queue/", + "/api/user/v1/accounts/retirements_by_status_and_date/", + "/api/user/v1/accounts/update_retirement_status/", + "/api/user/v1/accounts/retirement_cleanup/", + "/api/user/v1/accounts/retirement_partner_report/", + "/api/user/v1/accounts/retirement_partner_report_cleanup/", + "/api/user/v1/accounts/cancel_retirement/", + "/api/user/v1/accounts/retire/", + "/api/user/v1/accounts/retire_misc/", +) + +# Ids pinned over the one a document derives, keyed by (path, method, derived id): the +# v1 account collection keeps the id it is already published under, and the preference +# member read drops the bare "," token its two path parameters leave. The read of one +# account preference is named for what it reads rather than for the numeral its sort +# position earns it, in each document that has not already published that numeral. +# The two v1 retirement queue reads keep the id they are already published under. +RENAMED_OPERATION_IDS = { + ("/api/user/v1/accounts", "GET", "api_user_v1_accounts_list"): "api_user_v1_accounts_retrieve", + ( + "/api/user/v1/accounts/retirement_queue/", + "GET", + "api_user_v1_accounts_retirement_queue_list", + ): "api_user_v1_accounts_retirement_queue_retrieve", + ( + "/api/user/v1/accounts/retirements_by_status_and_date/", + "GET", + "api_user_v1_accounts_retirements_by_status_and_date_list", + ): "api_user_v1_accounts_retirements_by_status_and_date_retrieve", + ("/api/user/v1/preferences/{username}/{preference_key}", "GET", "v1_preferences_retrieve"): ( + "v1_preferences_retrieve_one" + ), + ( + "/api/user/v2/accounts/{username}/preferences/{key}/", + "GET", + "v2_accounts_preferences_retrieve", + ): "v2_accounts_preferences_retrieve_one", + ( + "/api/user/v2/accounts/{username}/preferences/{key}/", + "GET", + "api_user_v2_accounts_preferences_retrieve", + ): "api_user_v2_accounts_preferences_retrieve_one", + ( + "/api/user/v2/user_preferences/{username},{key}/", + "GET", + "v2_user_preferences_,_retrieve", + ): "v2_user_preferences_retrieve", + ( + "/api/user/v2/user_preferences/{username},{key}/", + "GET", + "api_user_v2_user_preferences_,_retrieve", + ): "api_user_v2_user_preferences_retrieve", +} + +# Operations whose handler answers a request carrying no body with a 400. The +# body of each is described in place rather than through a serializer, and a +# shape given in place carries no field the generator can read requiredness +# from, so it publishes no mark at all and a client generated from the document +# makes the body an argument the caller may leave out. Each address is spelled +# with its parameter names removed, as the table of bodies below is. +# +# An address belongs here only once its handler has been sent a request with +# the form named and no body behind it and has refused it. The write of one +# stored preference is deliberately absent: that handler takes a request with +# no body and answers that the preference now stands as the request asked. +REQUIRED_REQUEST_BODIES = frozenset({ + ("/api/user/v1/preferences/{}", "PATCH"), + ("/api/user/v1/preferences/email_opt_in/", "POST"), + ("/api/user/v2/accounts/{}/preferences/", "PATCH"), +}) + + +_PATH_PARAMETER = re.compile(r"\{([\w-]+)\}") + +# Compared without parameter names: the generator renames ``{pk}`` to ``{id}`` after filtering. +_SUPERSEDED_USER_SHAPES = frozenset(_PATH_PARAMETER.sub("{}", path) for path in SUPERSEDED_USER_PATHS) + + +def is_superseded_user_path(path): + """Say whether this address is one of those a conforming address replaces.""" + return _PATH_PARAMETER.sub("{}", path) in _SUPERSEDED_USER_SHAPES + +#: What one superseded operation reads and what it answers with on success. +_SupersededBodies = namedtuple("_SupersededBodies", ("request", "response")) + + +@cache +def superseded_bodies(): + """ + Return the request and success bodies of each superseded operation, keyed by address and method. + + The v1 views name no serializer. A response is described by the v2 serializer + of the same shape; a request by the members the v1 handler reads, without the + v2 bounds, under the media types that handler reads; ``None`` reads no body. + Where a v1 view names a serializer describing neither, the shapes its handler + exchanges are named here instead. + """ + # Imported here because the serializers need loaded models. + # pylint: disable=import-outside-toplevel + from openedx.core.djangoapps.profile_images.v2.serializers import IMAGE_UPLOAD_REQUEST + from openedx.core.djangoapps.user_api.accounts import serializers as retirement + from openedx.core.djangoapps.user_api.v2 import serializers as replacing + from openedx.core.lib.api.parsers import MergePatchParser + + account = {200: replacing.AccountSerializer} + account_change = { + "type": "object", + "properties": {name: {} for name in replacing.AccountUpdateSerializer().fields}, + } + email_search = { + "type": "object", + "properties": {"emails": {"type": "array", "items": {}}}, + "required": ["emails"], + } + name_change = { + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + } + json_type = "application/json" + + def in_every_form(shape): + """Name a shape under each media type a view that declares no parsers reads a body in.""" + return {parser.media_type: shape for parser in api_settings.DEFAULT_PARSER_CLASSES} + + retirement_erased = { + 204: OpenApiResponse(description="The data this step erases is gone."), + } + #: The name of one account, which is how the retirement pipeline's steps + #: say which account they are acting on. + named_account = { + "type": "object", + "properties": { + "username": { + "type": "string", + "description": "Name the account being retired was registered under.", + }, + }, + "required": ["username"], + } + #: The names of several accounts, which is how a step acting on a batch of + #: retirement records says which ones. + named_accounts = { + "type": "object", + "properties": { + "usernames": { + "type": "array", + "items": {"type": "string"}, + "description": "Names the accounts were registered under.", + }, + }, + "required": ["usernames"], + } + # One preference is answered as the bare value it is stored as. The writes + # store the text of a value of any type, so their values carry no type. + preference_value = {"type": "string"} + preference_changes = { + "type": "object", + "minProperties": 1, + "additionalProperties": {}, + "description": ( + "Preferences to change, as an object of preference name to the value to store under it. " + "A value that is not text is stored as its text, and a null removes the preference. Names " + "the body leaves out are left as they are. At least one name must be given." + ), + } + preference_change = { + "description": "Value to store under the name. A value that is not text is stored as its text.", + } + preferences_changed = { + 204: OpenApiResponse(description="The preferences now stand as the body asked."), + } + described = { + ("/api/user/v1/accounts", "GET"): _SupersededBodies(None, {200: replacing.AccountSerializer(many=True)}), + ("/api/user/v1/accounts/{username}", "GET"): _SupersededBodies(None, account), + ("/api/user/v1/accounts/{username}", "PATCH"): _SupersededBodies( + {MergePatchParser.media_type: account_change}, account, + ), + ("/api/user/v1/accounts/search_emails", "POST"): _SupersededBodies( + {JSONParser.media_type: email_search, MergePatchParser.media_type: email_search}, + {200: replacing.EmailSearchResultSerializer(many=True)}, + ), + ("/api/user/v1/accounts/{username}/deactivate/", "POST"): _SupersededBodies(None, account), + ("/api/user/v1/accounts/name_change/", "POST"): _SupersededBodies( + dict.fromkeys( + (JSONParser.media_type, FormParser.media_type, MultiPartParser.media_type), name_change, + ), + {201: OpenApiResponse(description="The change was recorded and waits to be confirmed.")}, + ), + ("/api/user/v1/accounts/name_change/{username}/confirm/", "POST"): _SupersededBodies( + None, {204: OpenApiResponse(description="The profile carries the name that was asked for.")}, + ), + ("/api/user/v1/accounts/{username}/image", "POST"): _SupersededBodies( + IMAGE_UPLOAD_REQUEST, + {204: OpenApiResponse(description="The account now carries this image.")}, + ), + ("/api/user/v1/accounts/{username}/image", "DELETE"): _SupersededBodies( + None, {204: OpenApiResponse(description="The account carries no image of its own.")}, + ), + ("/api/user/v1/accounts/{username}/verification_status/", "GET"): _SupersededBodies( + None, {200: replacing.VerificationStatusSerializer}, + ), + ("/api/user/v1/preferences/{username}", "GET"): _SupersededBodies( + None, + { + 200: OpenApiResponse( + replacing.ACCOUNT_PREFERENCES_SCHEMA, description="The preferences this account stores.", + ), + }, + ), + ("/api/user/v1/preferences/{username}", "PATCH"): _SupersededBodies( + {MergePatchParser.media_type: preference_changes}, preferences_changed, + ), + ("/api/user/v1/preferences/{username}/{preference_key}", "GET"): _SupersededBodies( + None, {200: preference_value}, + ), + ("/api/user/v1/preferences/{username}/{preference_key}", "PUT"): _SupersededBodies( + {"application/json": preference_change}, preferences_changed, + ), + ("/api/user/v1/preferences/{username}/{preference_key}", "DELETE"): _SupersededBodies( + None, {204: OpenApiResponse(description="The account stores no value under that name.")}, + ), + # The handler reads the choice from the submitted form rather than from + # a body of its own, and reads each value as the text a form carries. + # The queue and the by-date list both answer with an array of the + # shape their view names, which is published for one of them. + ("/api/user/v1/accounts/retirement_queue/", "GET"): _SupersededBodies( + None, {200: retirement.UserRetirementStatusSerializer(many=True)}, + ), + ("/api/user/v1/accounts/retirements_by_status_and_date/", "GET"): _SupersededBodies( + None, {200: retirement.UserRetirementStatusSerializer(many=True)}, + ), + # The move reads the name, step and log entry, refuses anything else, and + # answers with nothing. + ("/api/user/v1/accounts/update_retirement_status/", "PATCH"): _SupersededBodies( + { + json_type: { + "type": "object", + "properties": { + "username": { + "type": "string", + "description": "Name the account being retired was registered under.", + }, + "new_state": { + "type": "string", + "description": "Name of the pipeline step to move this retirement to.", + }, + "response": { + "type": "string", + "description": "What happened at the step being left, appended to the log.", + }, + "force": { + "type": "boolean", + "description": "Move the retirement even where the step asked for is not a later one.", + }, + }, + "required": ["username", "new_state", "response"], + }, + }, + {204: OpenApiResponse(description="The retirement now stands at the step asked for.")}, + ), + ("/api/user/v1/accounts/retirement_cleanup/", "POST"): _SupersededBodies( + {json_type: named_accounts}, + {204: OpenApiResponse(description="The retirement records named are gone.")}, + ), + # The claim reads no body and answers with the entries it took. + ("/api/user/v1/accounts/retirement_partner_report/", "POST"): _SupersededBodies( + None, {200: retirement.UserRetirementPartnerReportSerializer(many=True)}, + ), + ("/api/user/v1/accounts/retirement_partner_report/", "PUT"): _SupersededBodies( + {json_type: named_account}, + { + 204: OpenApiResponse( + description=( + "The account is queued for reporting to the organizations it studied " + "with, or was enrolled with none and so needs no report." + ), + ), + }, + ), + # This batch is named by a bare array rather than by an object. + ("/api/user/v1/accounts/retirement_partner_report_cleanup/", "POST"): _SupersededBodies( + { + json_type: { + "type": "array", + "items": { + "type": "object", + "properties": { + "original_username": { + "type": "string", + "description": "Name the account was registered under.", + }, + }, + "required": ["original_username"], + }, + }, + }, + {204: OpenApiResponse(description="The partner report entries named are gone.")}, + ), + # The cancellation's view declares no parsers, so every platform media type + # is read. + ("/api/user/v1/accounts/cancel_retirement/", "POST"): _SupersededBodies( + in_every_form({ + "type": "object", + "properties": { + "retirement_id": { + "type": "integer", + "description": "Identifier of the retirement to cancel.", + }, + }, + "required": ["retirement_id"], + }), + { + 200: { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "True once the retirement request has been undone.", + }, + }, + "required": ["success"], + }, + }, + ), + ("/api/user/v1/accounts/retire/", "POST"): _SupersededBodies( + {json_type: named_account}, retirement_erased, + ), + ("/api/user/v1/accounts/retire_misc/", "POST"): _SupersededBodies( + {json_type: named_account}, retirement_erased, + ), + ("/api/user/v1/preferences/email_opt_in/", "POST"): _SupersededBodies( + { + "application/x-www-form-urlencoded": { + "type": "object", + "properties": { + "course_id": { + "type": "string", + "description": ( + "Course the organization runs, which is how the organization the " + "choice applies to is identified." + ), + }, + "email_opt_in": { + "type": "string", + "description": ( + "`True` if this user wants messages from that organization. Any " + "other value is read as not wanting them." + ), + }, + }, + "required": ["course_id", "email_opt_in"], + }, + }, + {200: OpenApiResponse(description="The choice was recorded.")}, + ), + } + # Parameter names are spelled differently at different points of + # generation, so an address is keyed by its shape alone. + return { + (_PATH_PARAMETER.sub("{}", path), method): bodies + for (path, method), bodies in described.items() + } + + +def _names_what_it_needs(shape): + """ + Say whether a body written out as a shape names something it cannot be sent without. + + A shape is published as it is written rather than built from a + serializer's fields, so nothing has judged whether the operation can be + called with no body at all. One that requires a member, or that is a list + whose members require one, cannot be satisfied by an absent body. + """ + if not isinstance(shape, dict): + return False + if shape.get("required"): + return True + return _names_what_it_needs(shape.get("items")) + + +class SupersededUserOperations: + """ + Describe each superseded user address by what it reads and answers with, under the name it keeps. + + Ids not named in ``RENAMED_OPERATION_IDS`` stay as the generator derives them. + """ + + def get_operation_id(self): + """Keep the name an address is published under where describing its bodies would move it.""" + operation_id = super().get_operation_id() + return RENAMED_OPERATION_IDS.get((self.path, self.method, operation_id), operation_id) + + def _get_request_body(self, direction="request"): + """ + Mark the body required when any of its media types requires it. + + Overrides a private method of drf-spectacular 0.30, which requires a body + only when every media type does and never requires an inline shape. An + operation named in ``REQUIRED_REQUEST_BODIES`` is marked required outright. + An inline shape is required when it, or the items of a list it describes, name a required member. + """ + body = super()._get_request_body(direction) + if not body: + return body + if self._operation_shape() in REQUIRED_REQUEST_BODIES: + body["required"] = True + return body + shapes = self.get_request_serializer() + if body.get("required") or not isinstance(shapes, dict): + return body + if any(self._requires_a_body(shape, direction) for shape in shapes.values()): + body["required"] = True + return body + + def _requires_a_body(self, shape, direction): + """Say whether this form of the body names a member the request must carry.""" + return _names_what_it_needs(shape) or self._get_request_for_media_type(shape, direction)[1] + + def get_request_serializer(self): + """Return the body this operation reads, named here for the addresses this one supersedes.""" + bodies = self._superseded_bodies() + return super().get_request_serializer() if bodies is None else bodies.request + + def get_response_serializers(self): + """Return the body this operation answers with, named here for the addresses this one supersedes.""" + bodies = self._superseded_bodies() + return super().get_response_serializers() if bodies is None else bodies.response + + def _superseded_bodies(self): + """Return the bodies named for this operation, or ``None`` where none are named.""" + return superseded_bodies().get(self._operation_shape()) + + def _operation_shape(self): + """This operation as the tables above key one: its address without parameter names, and its method.""" + return (_PATH_PARAMETER.sub("{}", self.path), self.method) + + +def mark_superseded_user_paths(result, generator, request, public): # pylint: disable=unused-argument + """Post-processing hook: mark every address a conforming one replaces ``deprecated: true``.""" + for path, path_item in result.get("paths", {}).items(): + if path not in SUPERSEDED_USER_PATHS: + continue + for operation in path_item.values(): + if isinstance(operation, dict): + operation["deprecated"] = True + return result diff --git a/openedx/core/djangoapps/user_api/schema_components.py b/openedx/core/djangoapps/user_api/schema_components.py new file mode 100644 index 000000000000..4852b02db0df --- /dev/null +++ b/openedx/core/djangoapps/user_api/schema_components.py @@ -0,0 +1,28 @@ +"""Components of this API that a document carries although nothing in it refers to them.""" + +# No imports: a settings module reads this while settings are still being built. + +# The model the v1 retirement-status PATCH was published as reading, kept by +# name so generated clients keep the class once the operation no longer refers to it. +PATCHED_USER_RETIREMENT_STATUS = { + 'type': 'object', + 'description': 'Perform serialization for the RetirementStatus model', + 'properties': { + 'id': {'type': 'integer', 'readOnly': True}, + 'user': {'allOf': [{'$ref': '#/components/schemas/RetirementUser'}], 'readOnly': True}, + 'current_state': {'allOf': [{'$ref': '#/components/schemas/RetirementState'}], 'readOnly': True}, + 'last_state': {'allOf': [{'$ref': '#/components/schemas/RetirementState'}], 'readOnly': True}, + 'created': {'type': 'string', 'format': 'date-time', 'readOnly': True}, + 'modified': {'type': 'string', 'format': 'date-time', 'readOnly': True}, + 'original_username': {'type': 'string', 'maxLength': 150}, + 'original_email': {'type': 'string', 'format': 'email', 'maxLength': 254}, + 'original_name': {'type': 'string', 'maxLength': 255}, + 'retired_username': {'type': 'string', 'maxLength': 150}, + 'retired_email': {'type': 'string', 'format': 'email', 'maxLength': 254}, + }, +} + +#: Keyed by the name each component is published under, for a service to append. +KEPT_COMPONENT_SCHEMAS = { + 'PatchedUserRetirementStatus': PATCHED_USER_RETIREMENT_STATUS, +} diff --git a/openedx/core/djangoapps/user_api/v2/__init__.py b/openedx/core/djangoapps/user_api/v2/__init__.py new file mode 100644 index 000000000000..333c1a588afe --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/__init__.py @@ -0,0 +1 @@ +"""Version 2 of the User API.""" diff --git a/openedx/core/djangoapps/user_api/v2/errors.py b/openedx/core/djangoapps/user_api/v2/errors.py new file mode 100644 index 000000000000..45a6253e26f3 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/errors.py @@ -0,0 +1,200 @@ +"""Error types this version of the User API classifies for itself.""" + +from edx_rest_framework_extensions.errors import Conflict, register_error_type +from rest_framework.exceptions import NotFound, ValidationError +from rest_framework.settings import api_settings + + +class RetiredAccount(NotFound): + """ + An account that has been retired, addressed by something it used to hold. + + The account is gone, so the answer is the same as for an address that was + never held; what is left of it is the retirement record, which the members + below name so that a caller able to reverse a retirement can find it. + """ + + def __init__(self, detail=None, can_cancel_retirement=False, retirement_id=None): + super().__init__(detail) + self.extension_members = { + "can_cancel_retirement": can_cancel_retirement, + "retirement_id": retirement_id, + } + + +class _NamesTheRecordsAtFault: + """ + Carries the records a refusal is about, each with what was wrong with it. + + A caller acting on several records at once learns which of them to look + at, rather than being told only that the request as a whole was refused. + Nothing is changed when any one record is refused. + """ + + def __init__(self, detail=None, errors=None): + super().__init__(detail) + if errors: + self.extension_members = {"errors": errors} + + +class RetirementStateConflict(_NamesTheRecordsAtFault, Conflict): + """ + An operation asked of a retirement whose state does not admit it. + + Which operation a retirement admits depends on how far through the + pipeline it is, and the order of those steps is configured by the + operator rather than fixed here. + """ + + +class AmbiguousAccountName(_NamesTheRecordsAtFault, ValidationError): + """ + An address or a name answered by more than one account, naming no one of them. + + The name an account was registered under is kept on the records of this + area without being unique to one of them, so a request naming such a name + reaches several records at once and acting on any of them would act on an + account the caller did not ask about. Nothing is done. The refusal is one + about what the request asked for, so it keeps the status of the refusals + beside it; what it adds is a name for this one condition, which a caller + can tell apart from a value it sent being wrong and can act on by naming + an account as it is stored. + """ + + +register_error_type(RetiredAccount, "user/retired-account", "Retired Account") +register_error_type(AmbiguousAccountName, "user/ambiguous-account-name", "Ambiguous Account Name") +register_error_type(RetirementStateConflict, "user/retirement-state-conflict", "Retirement State Conflict") + + +def refusal_sentence(refusals): + """ + Return one sentence naming what each parameter or property was refused for. + + A refusal that names nothing in particular is spelled on its own, so the + sentence reads the same whether or not the thing refused has a name. + """ + return " ".join( + reason if name == api_settings.NON_FIELD_ERRORS_KEY else f"{name}: {reason}" + for name, reasons in refusals.items() + for reason in reasons + ) + + +def _holds_a_structure(value): + """Whether a refusal's value is a mapping, or a list holding anything but messages.""" + return isinstance(value, dict) or ( + isinstance(value, list) and any(isinstance(item, (dict, list)) for item in value) + ) + + +def _refusals_by_item(detail, name): + """ + Yield each message of a refusal with the name of the thing it is about. + + An item of a list is named after its position, counted from 0, and a + property of an object after the property: ``social_links[0]``, + ``social_links[0].social_link``. A refusal of an object as a whole keeps the + object's name. + """ + if isinstance(detail, dict): + for key, inner in detail.items(): + if isinstance(key, int): + yield from _refusals_by_item(inner, f"{name}[{key}]") + elif key == api_settings.NON_FIELD_ERRORS_KEY: + yield from _refusals_by_item(inner, name) + else: + yield from _refusals_by_item(inner, f"{name}.{key}") + elif _holds_a_structure(detail): + for index, inner in enumerate(detail): + yield from _refusals_by_item(inner, f"{name}[{index}]") + else: + yield from ((name, message) for message in (detail if isinstance(detail, list) else [detail])) + + +def _named_by_item(detail): + """ + Return a refusal with each refused item of a list listed under a name of its own. + + Nothing changes for a refusal naming only parameters or properties; one + that names nothing in particular is never nested. + """ + if not isinstance(detail, dict) or not any(_holds_a_structure(value) for value in detail.values()): + return detail + named = {} + for field, refusal in detail.items(): + for name, message in _refusals_by_item(refusal, field): + named.setdefault(name, []).append(message) + return named + + +def _came_with_the_records_at_fault(exc): + """ + Whether this refusal arrived carrying the records at fault, and a line of its own. + + Such a refusal was raised with both: a sentence about the request as a + whole, and the record-by-record list that goes beside it. A refusal that + arrived with a line alone has its list derived from that line, and the + line written back from the list says the same thing. + """ + return isinstance(getattr(exc, "extension_members", {}).get("errors"), dict) + + +class ReadableErrorDetailMixin: + """ + Writes the one-line message of a refused request as a sentence. + + The standard error body carries a line for a person to read beside the + list of what each parameter or property was refused for, and where the + refusal names them that line was produced by printing the list itself. + Everything that puts the line in front of a person then shows the + brackets, the quoting and the class names that write a data structure + out: the message of the exception a generated client raises, a line in an + integration's log, the notification a page puts on the screen. The line is + rewritten from the values in the list. + + A list a body carries is refused item by item, which the standard body + would print, positions and class names included, as one message under the + list's name. Each refused item is listed under a name of its own instead + -- ``social_links[0].social_link`` -- so that the list and the line both say in words + which item was refused and why. + + A refusal that names the records at fault wrote that line itself, as a + sentence about the request as a whole -- that a set of records was left + alone, and nothing deleted -- which the list of records cannot be read to + say. That line is kept, and the records stay in the list beside it. + """ + + def handle_exception(self, exc): + """Answer with the standard body, its one-line message written in words.""" + if isinstance(exc, ValidationError): + exc.detail = _named_by_item(exc.detail) + response = super().handle_exception(exc) + if _came_with_the_records_at_fault(exc): + return response + body = getattr(response, "data", None) + if isinstance(body, dict) and isinstance(body.get("errors"), dict): + sentence = refusal_sentence(body["errors"]) + if sentence: + body["detail"] = sentence + return response + + +class ErrorExtensionsMixin: + """ + Adds the members an exception declares to the error body it produces. + + The shared envelope describes a failure with a fixed set of members, so a + failure that has more to say about the resource -- what can still be done + about it, which record holds the rest -- has nowhere to put it. An error + body may carry members beside the standard ones, and the ones an exception + declares are put there. + """ + + def handle_exception(self, exc): + """Answer with the standard body, extended by the members the exception declares.""" + response = super().handle_exception(exc) + members = getattr(exc, "extension_members", None) + if members and isinstance(getattr(response, "data", None), dict): + response.data.update(members) + return response diff --git a/openedx/core/djangoapps/user_api/v2/filters.py b/openedx/core/djangoapps/user_api/v2/filters.py new file mode 100644 index 000000000000..aa0837f9c751 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/filters.py @@ -0,0 +1,306 @@ +"""Filter sets for version 2 of the User API.""" + +import datetime +import logging +from zoneinfo import ZoneInfo + +from django import forms +from django.contrib.auth.models import User # pylint: disable=imported-auth-user +from django.core.exceptions import ValidationError as FormValidationError +from django_filters import rest_framework as filters +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey +from rest_framework.exceptions import ValidationError +from rest_framework.filters import OrderingFilter + +from openedx.core.djangoapps.user_api.models import RetirementState, UserPreference, UserRetirementStatus + +from .view_utils import names_in + +log = logging.getLogger(__name__) + + +class StrictOrderingFilter(OrderingFilter): + """ + Refuses a sort field the view does not offer, and completes the sort it accepts. + + The default backend drops an unusable field silently and answers 200 in + the default order, which a caller reads as a page sorted the way it asked. + It also replaces the view's own order rather than adding to it, so a sort + on a column several rows share leaves their relative order to the database + and the pages of one listing need not agree on it; the fields named by the + view's ``ordering_tiebreak`` are appended to settle those rows. + """ + + def get_ordering(self, request, queryset, view): + """Return the requested sort, followed by the fields that make it total.""" + ordering = super().get_ordering(request, queryset, view) + if not ordering: + return ordering + chosen = {field.lstrip("-") for field in ordering} + tiebreak = [field for field in getattr(view, "ordering_tiebreak", ()) if field not in chosen] + return [*ordering, *tiebreak] + + def remove_invalid_fields(self, queryset, fields, view, request): + """Return the requested sort fields, refusing the request when one is not offered.""" + allowed = super().remove_invalid_fields(queryset, fields, view, request) + rejected = [field for field in fields if field not in allowed] + if rejected: + log.info("Sort refused on %s: %r", type(view).__name__, rejected) + offered = sorted( + name for name, _label in self.get_valid_fields(queryset, view, {"request": request}) + ) + raise ValidationError({ + "ordering": [ + f"Sort by any of: {', '.join(offered)}, each optionally prefixed with '-'.", + ], + }) + return allowed + + +class _RepeatedValuesWidget(forms.TextInput): + """ + Reads every value sent under one parameter name, not only the last of them. + + A query string names several values either as one comma-separated list or + by repeating the parameter, and both idioms are in use by the callers of + this area. The default widget keeps the last value sent and drops the + rest, so a caller naming several by repetition is answered with the rows + of one of them and told nothing about the others. + """ + + def value_from_datadict(self, data, files, name): + """Return every value sent under this name, as the list the filter reads.""" + if not hasattr(data, "getlist"): + return super().value_from_datadict(data, files, name) + sent = data.getlist(name) + return ",".join(sent) if sent else None + + +class _BooleanQueryField(forms.NullBooleanField): + """Reads a boolean out of the query string, refusing a value that names neither side.""" + + def to_python(self, value): + """Return the boolean this value names, or refuse the request.""" + if value in self.empty_values: + return None + parsed = super().to_python(value) + if parsed is None: + raise FormValidationError("Enter true or false.", code="invalid") + return parsed + + +class StrictBooleanFilter(filters.BooleanFilter): + """ + Refuses a value that names neither true nor false instead of ignoring it. + + The default field reads an unrecognised value as "no preference" and + answers 200 with the filter dropped, handing the caller the rows it asked + to leave out, which is the opposite of what it asked for. + """ + + field_class = _BooleanQueryField + + def __init__(self, *args, **kwargs): + """Pass the raw value through to the field, which is what judges it.""" + # The default widget maps anything it does not recognise to "no + # preference" before the field ever sees it; a text widget hands the + # value over untouched. + kwargs.setdefault("widget", forms.TextInput) + super().__init__(*args, **kwargs) + + +class UserFilterSet(filters.FilterSet): + """ + Narrows the user directory. + + ``forum_role`` and ``course_key`` name a role within one course and are + only meaningful together, so either on its own is rejected. A role that no + course has defined matches no users and is not created. + """ + + username = filters.CharFilter( + method="filter_usernames", + strip=False, + help_text=( + "Comma-separated usernames. Only these users are returned, and a name no user holds " + "is left out of the page rather than refused." + ), + ) + email = filters.CharFilter(help_text="Return the user with exactly this email address.") + is_active = StrictBooleanFilter( + help_text="Return only users whose account is active, or only inactive ones. true or false.", + ) + preference_key = filters.CharFilter( + method="filter_preference_key", + help_text="Return only users who have stored a preference with this key.", + ) + forum_role = filters.CharFilter( + method="filter_forum_role", + help_text="Return only users holding this course forum role. Requires course_key.", + ) + course_key = filters.CharFilter( + method="filter_course_key", + help_text=( + "Course the forum role belongs to, percent-encoded, for example " + "course-v1:edX%2BDemoX%2BDemo. A plus sign that is not encoded arrives as a " + "space and the key is refused. Requires forum_role." + ), + ) + + class Meta: + model = User + fields = ("username", "email", "is_active", "preference_key", "forum_role", "course_key") + + def filter_queryset(self, queryset): + """Reject a forum role without its course, or a course without its role, before filtering.""" + cleaned = self.form.cleaned_data + forum_role = cleaned.get("forum_role") + course_key = cleaned.get("course_key") + if forum_role and not course_key: + raise ValidationError({"course_key": ["This parameter is required when forum_role is supplied."]}) + if course_key and not forum_role: + raise ValidationError({"forum_role": ["This parameter is required when course_key is supplied."]}) + return super().filter_queryset(queryset) + + def filter_usernames(self, queryset, name, value): + """Keep the users the value names, which it carries as a comma-separated list.""" + return queryset.filter(**{f"{name}__in": names_in(value)}) + + def filter_preference_key(self, queryset, name, value): # pylint: disable=unused-argument + """Keep users who have stored a preference under ``value``.""" + return queryset.filter(preferences__key=value) + + def filter_forum_role(self, queryset, name, value): # pylint: disable=unused-argument + """ + Keep users holding the role ``value`` in the course named by ``course_key``. + + Nothing stops a course from carrying the same role under several rows, + and a user may hold any number of them, so the join is collapsed to one + entry per user. + """ + raw_course_key = self.form.cleaned_data.get("course_key") + try: + course_key = CourseKey.from_string(raw_course_key) + except InvalidKeyError as exc: + log.info("User directory filtered with an unparsable course key: %r", raw_course_key) + raise ValidationError({"course_key": ["Enter a valid course key."]}) from exc + return queryset.filter(roles__name=value, roles__course_id=course_key).distinct() + + def filter_course_key(self, queryset, name, value): # pylint: disable=unused-argument + """Narrowing happens in ``filter_forum_role``, which needs both values at once.""" + return queryset + + +class UserPreferenceFilterSet(filters.FilterSet): + """ + Narrows the stored-preference list by preference name or by owner. + + An owner is named by username: the list is keyed the way the addresses of + its members are, and publishes no database identifier for a caller to + filter on. + """ + + key = filters.CharFilter(help_text="Return only preferences stored under exactly this key.") + username = filters.CharFilter( + field_name="user__username", + method="filter_usernames", + strip=False, + help_text=( + "Comma-separated usernames. Only these users' preferences are returned, and a name " + "no user holds is left out of the page rather than refused." + ), + ) + + class Meta: + model = UserPreference + fields = ("key", "username") + + def filter_usernames(self, queryset, name, value): + """Keep the preferences of the users the value names, read as a comma-separated list.""" + return queryset.filter(**{f"{name}__in": names_in(value)}) + + +class _WholeNumberFilter(filters.NumberFilter): + """Reads a count of whole units, refusing a fraction.""" + + field_class = forms.IntegerField + + +#: The longest cooling-off period a caller may ask for: a century, which reaches +#: further back than any retirement a deployment holds. +MAX_COOL_OFF_DAYS = 36500 + + +class RetirementFilterSet(filters.FilterSet): + """ + Narrows the retirement pipeline's queue. + + ``cool_off_days`` is kept as a filter of its own rather than expressed as + a point in time, because it is the operator concept the pipeline is + configured in and a caller computing the equivalent timestamp would have + to agree with this server's clock to the second. The two date bounds say + which end they include, which the pair they replace left to the reader. + ``state`` takes several steps sent either as one comma-separated list or + as the parameter repeated, because the list that named them before this + one read them as repeated parameters. + """ + + state = filters.CharFilter( + method="filter_states", + widget=_RepeatedValuesWidget, + help_text=( + "Names of pipeline steps, as one comma-separated list or by repeating the parameter " + "once per step. Only retirements standing at one of them are returned, and a name no " + "configured step holds is refused." + ), + ) + cool_off_days = _WholeNumberFilter( + method="filter_cool_off_days", + min_value=0, + max_value=MAX_COOL_OFF_DAYS, + help_text=( + "Return only retirements requested at least this many days ago, from 0 to " + f"{MAX_COOL_OFF_DAYS}. The pipeline uses this to leave a learner time to change their mind." + ), + ) + created_after = filters.DateTimeFilter( + field_name="created", + lookup_expr="gte", + help_text=( + "Return only retirements requested at or after this moment, as a date or a date and " + "time. A date alone means the start of that day." + ), + ) + created_before = filters.DateTimeFilter( + field_name="created", + lookup_expr="lt", + help_text=( + "Return only retirements requested before this moment, as a date or a date and time. " + "A date alone means the start of that day, so that day is left out." + ), + ) + + class Meta: + model = UserRetirementStatus + fields = ("state", "cool_off_days", "created_after", "created_before") + + def filter_states(self, queryset, name, value): # pylint: disable=unused-argument + """Keep the retirements standing at one of the steps the value names.""" + names = names_in(value) + configured = set(RetirementState.get_state_names_list()) + unknown = [name for name in names if name not in configured] + if unknown: + log.info("Retirement queue filtered by unconfigured steps: %r", unknown) + raise ValidationError({ + "state": [ + f"No such pipeline step: {', '.join(unknown)}. " + f"This deployment runs: {', '.join(sorted(configured))}.", + ], + }) + return queryset.filter(current_state__state_name__in=names) + + def filter_cool_off_days(self, queryset, name, value): # pylint: disable=unused-argument + """Keep the retirements requested at least ``value`` days ago.""" + requested_before = datetime.datetime.now(ZoneInfo("UTC")) - datetime.timedelta(days=value) + return queryset.filter(created__lt=requested_before) diff --git a/openedx/core/djangoapps/user_api/v2/paginators.py b/openedx/core/djangoapps/user_api/v2/paginators.py new file mode 100644 index 000000000000..3a715a2c6c37 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/paginators.py @@ -0,0 +1,96 @@ +"""Pagination for version 2 of the User API.""" + +import logging + +from edx_rest_framework_extensions.paginators import DefaultPagination +from rest_framework.exceptions import ValidationError + +log = logging.getLogger(__name__) + + +class UserApiPagination(DefaultPagination): + """ + Page-number pagination whose published schema matches the body it returns, + and which refuses a number of rows no page of it can carry. + + ``DefaultPagination`` answers with ``count``, ``num_pages``, + ``current_page``, ``start``, ``next``, ``previous`` and ``results`` but + inherits the four-member schema of DRF's ``PageNumberPagination``, so a + generated client would not know about the three page-position members. + The response schema below only restates them; the body is unchanged. + """ + + def get_page_size(self, request): + """ + Return how many rows a page carries, refusing a number no page can have. + + The number asked for is otherwise dropped where it cannot be honoured + and the default page answered in its place, which a caller reads as + the page it asked for; worse, the links on that page carry the value + back, so a client that follows them keeps sending a number that is + never read. A number above what a page carries is still answered with + as many rows as it carries, because asking for more rows than a page + holds is asking for a full one. + """ + asked = request.query_params.get(self.page_size_query_param) + if asked is None or asked == "": + return self.page_size + try: + rows = int(asked) + except ValueError: + rows = 0 + if rows < 1: + log.info("Page size refused at %s: %r", request.path, asked) + raise ValidationError({ + self.page_size_query_param: [ + "Enter a whole number of rows of at least 1. A larger number than " + f"{self.max_page_size} returns {self.max_page_size} rows.", + ], + }) + return min(rows, self.max_page_size) + + def get_paginated_response_schema(self, schema): + """ + Describe the full envelope the paginator returns. + + Remove this override once ``DefaultPagination`` in edx-drf-extensions describes all seven members. + """ + return { + "type": "object", + "required": ["count", "num_pages", "current_page", "start", "next", "previous", "results"], + "properties": { + "count": { + "type": "integer", + "example": 123, + "description": "Total number of items across every page.", + }, + "num_pages": { + "type": "integer", + "example": 13, + "description": "Total number of pages.", + }, + "current_page": { + "type": "integer", + "example": 2, + "description": "Number of the page in this response, counting from one.", + }, + "start": { + "type": "integer", + "example": 10, + "description": "Index of the first item of this page within the full result set.", + }, + "next": { + "type": "string", + "nullable": True, + "format": "uri", + "description": "URL of the next page, or null on the last page.", + }, + "previous": { + "type": "string", + "nullable": True, + "format": "uri", + "description": "URL of the previous page, or null on the first page.", + }, + "results": schema, + }, + } diff --git a/openedx/core/djangoapps/user_api/v2/permissions.py b/openedx/core/djangoapps/user_api/v2/permissions.py new file mode 100644 index 000000000000..1454be5f17ed --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/permissions.py @@ -0,0 +1,100 @@ +"""Permission classes for version 2 of the User API.""" + +from edx_rest_framework_extensions.permissions import NotJwtRestrictedApplication +from rest_framework.exceptions import NotFound +from rest_framework.permissions import BasePermission + + +class IsActiveUser(BasePermission): + """ + Admits only a caller whose account is still active. + + Neither the authenticated check nor the administrator check reads + ``is_active``, so a view whose answer turns on the state of the caller's + own account declares this class alongside them. edx-drf-extensions ships + no equivalent class, so this one stands in for it. + """ + + def has_permission(self, request, view): + """Return whether the caller's account is still active.""" + return bool(request.user and request.user.is_active) + + +#: Query parameters that pick an account out by something about its owner +#: that the owner has not published. +ACCOUNT_CONTACT_SELECTORS = ("email", "lms_user_id") + + +class CanSelectAccountsByContact(BasePermission): + """ + Admits a caller selecting accounts by an owner's address or identifier while their account is active. + + An account selected by the email address or the numeric identifier of its + owner is reached without its owner having published either, which is a + privilege, so it is refused to a caller whose own account is no longer + active. A request that selects accounts no such way is admitted whatever + state the caller's account is in, because a user reaches their own account + before they have confirmed its address. + """ + + def has_permission(self, request, view): + """Return whether this request selects no account by contact, or comes from an active account.""" + if all(request.query_params.get(name) is None for name in ACCOUNT_CONTACT_SELECTORS): + return True + return bool(request.user and request.user.is_active) + + +class IsAddressedUser(BasePermission): + """ + Admits a caller whose username is exactly the one the address carries. + + The handlers behind this class select a record by the username in the + address, so that is the value compared here, character for character, and + the address is the only place a username is read from. Where usernames are + stored case-sensitively, two names differing in case alone are two + different names, so the comparison is exact. + """ + + def has_permission(self, request, view): + """Return whether the address names this caller, and nobody else.""" + user = request.user + if not (user and user.is_authenticated): + return False + return getattr(view, "kwargs", {}).get("username") == user.username + + +class IsStaffOrAbsent(BasePermission): + """ + Admits a platform administrator and reports the address as absent to anyone else. + + The endpoints this guards answer questions about other people's accounts. + Refusing a caller who may not ask them with a refusal would confirm that + the address exists and what it is for, so they are told instead that there + is nothing at it. + """ + + def has_permission(self, request, view): + """Admit an administrator, and report the address as absent to every other caller.""" + if request.user and request.user.is_staff: + return True + raise NotFound + + +class RestrictedApplicationGuardMixin: + """ + Refuses a token issued to a restricted application on every operation of the view. + + ``EnsureJWTAuthSettingsMiddleware`` adds this refusal to a view's + ``permission_classes`` attribute and nowhere else, so an operation whose + classes come from somewhere else, such as the ``permission_classes`` of an + extra action, would be left without it. Appending it to whatever + ``get_permissions`` resolves keeps it on every operation however that + operation composes its classes. A request that carries no token passes it. + """ + + def get_permissions(self): + """Return the classes this operation resolves, ending with the restricted-application refusal.""" + resolved = list(super().get_permissions()) + if not any(isinstance(permission, NotJwtRestrictedApplication) for permission in resolved): + resolved.append(NotJwtRestrictedApplication()) + return resolved diff --git a/openedx/core/djangoapps/user_api/v2/schema.py b/openedx/core/djangoapps/user_api/v2/schema.py new file mode 100644 index 000000000000..1db961213b16 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/schema.py @@ -0,0 +1,31 @@ +"""Schema helpers for version 2 of the User API.""" + +from drf_spectacular.serializers import PolymorphicProxySerializerExtension +from drf_spectacular.utils import PolymorphicProxySerializer + + +class ResponseShapes(PolymorphicProxySerializer): + """ + The set of bodies one operation returns for the same status code. + + An endpoint whose caller can reduce the response answers with the default + object, with a preset, or with a selection of properties. A reduced body + satisfies more than one of those descriptions, so they are published as + alternatives a body may match in any combination. + """ + + def __init__(self, component_name, serializers, **kwargs): + # The generator re-instantiates a declaration from the arguments it + # retained, so the discriminator is only supplied when it is missing. + kwargs.setdefault("resource_type_field_name", None) + super().__init__(component_name=component_name, serializers=serializers, **kwargs) + + +class ResponseShapesExtension(PolymorphicProxySerializerExtension): + """Publishes the alternatives of a ``ResponseShapes`` declaration.""" + + target_class = ResponseShapes + + def map_serializer(self, auto_schema, direction): + """Return the alternatives as a set a body may match more than one of.""" + return {"anyOf": super().map_serializer(auto_schema, direction)["oneOf"]} diff --git a/openedx/core/djangoapps/user_api/v2/selectors.py b/openedx/core/djangoapps/user_api/v2/selectors.py new file mode 100644 index 000000000000..2d7567b9f48f --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/selectors.py @@ -0,0 +1,176 @@ +"""Selections over the user records version 2 of the User API answers from.""" + +import datetime +from zoneinfo import ZoneInfo + +from django.conf import settings +from django.contrib.auth.models import User # pylint: disable=imported-auth-user + +from common.djangoapps.student.models import get_potentially_retired_user_by_username +from openedx.core.djangoapps.user_api.models import ( + UserRetirementPartnerReportingStatus, + UserRetirementStatus, +) + + +def accounts_holding_emails(emails): + """Return the accounts holding any of these email addresses, by name.""" + return User.objects.filter(email__in=emails).order_by("username") + + +def accounts_named(name): + """ + Return the accounts this name addresses. + + The database settles which stored names an address answers to, and it need + not be only the one written exactly as the address writes it, so a name + can bring back more than one account. + """ + return list(User.objects.filter(username=name)) + + +def usernames_among(names, ordering): + """ + Return the names these accounts are stored under, in the order asked for. + + The accounts an address or a query string names are the only ones the + answer carries, and a name no account holds matches nothing. The order is + settled here because it decides which rows fall on which page, and the + database compares and sorts the stored names: an account can therefore + come back under a spelling other than the one asked for. + """ + return ( + User.objects + .filter(username__in=names) + .order_by(ordering) + .values_list("username", flat=True) + ) + + +def usernames_holding_email(email): + """ + Return the names of the accounts holding this address. + + Nothing keeps one email address to one account, so an address can be held + by more than one of them. Every account holding it is named, and nothing + is named for an address no account holds. + """ + return list(accounts_holding_emails([email]).values_list("username", flat=True)) + + +def username_holding_id(identifier): + """Return the name of the account holding this numeric identifier, or ``None``.""" + try: + return User.objects.get(id=identifier).username + except User.DoesNotExist: + return None + + +def cancellable_retirement_for_email(email): + """ + Return the retirement of the account that held this address, while it can still be cancelled. + + A retirement is cancellable for as long as the platform's cooling-off + period runs, so a retirement older than that is not reported here: the + address is gone either way, and only a retirement inside the period can be + undone by asking the platform to undo it. + + One address can have been held and left more than once, so more than one + retirement of it can sit inside the period. The most recent is the one + reported, being the only one still open to being undone. This is the one + place where the answer differs from the superseded version, which reads + the same window with a lookup that takes a single record and so fails + outright on the second. + """ + now = datetime.datetime.now(ZoneInfo("UTC")) + return ( + UserRetirementStatus.objects + .filter( + created__gt=now - datetime.timedelta(days=settings.COOL_OFF_DAYS), + created__lt=now, + original_email=email, + ) + .order_by("-created") + .first() + ) + + +def retirements(): + """Return every retirement, with the account and the steps a retirement record reports.""" + return UserRetirementStatus.objects.select_related( + "user", "user__profile", "current_state", "last_state", + ) + + +def retirement_of_the_account_named(records, username): + """ + Return the retirement of the account this name addresses, or ``None``. + + A retired account is stored under a hashed name, so the name a caller + holds may be either the one the account was registered under or the one it + was moved to; both address the same account and so the same retirement. + The record is taken from the selection given rather than from every record + held, so a record outside it is reported as absent. + """ + try: + user = get_potentially_retired_user_by_username(username) + except User.DoesNotExist: + return None + return records.filter(user=user).first() + + +def _grouped_by_requested_name(rows, usernames): + """ + Return these rows under the name each was requested with, every row kept. + + Stored names are compared without regard to case in some deployments, + which once let a learner register a retired name again by changing its + capitalisation, so the rows the database offers are compared again here + character for character. The name a row keeps of the account it is for is + indexed but not unique, so a name may carry more than one row; every one + of them is returned, because a caller that acted on one of several would + act on a record it did not ask about. A name no row was requested under is + absent from the result. + """ + asked_for = set(usernames) + grouped = {} + for row in rows: + if row.original_username in asked_for: + grouped.setdefault(row.original_username, []).append(row) + return grouped + + +def retirements_requested_as(records, usernames): + """Return the records of this selection requested under exactly these names, grouped by name.""" + return _grouped_by_requested_name( + records.filter(original_username__in=usernames), usernames, + ) + + +def unclaimed_partner_reports(): + """ + Return the partner reports no caller has taken for processing yet. + + The organizations of each are read from the user's enrolments, which are + fetched with the rows so that the cost of the answer does not grow with + the number of rows in it. + """ + return ( + UserRetirementPartnerReportingStatus.objects + .filter(is_being_processed=False) + .select_related("user") + .prefetch_related("user__courseenrollment_set") + .order_by("id") + ) + + +def partner_reports(): + """Return every partner report entry, whether or not it has been taken for processing.""" + return UserRetirementPartnerReportingStatus.objects.all() + + +def partner_reports_requested_as(entries, usernames): + """Return the entries of this selection registered under exactly these names, grouped by name.""" + return _grouped_by_requested_name( + entries.filter(original_username__in=usernames), usernames, + ) diff --git a/openedx/core/djangoapps/user_api/v2/serializers.py b/openedx/core/djangoapps/user_api/v2/serializers.py new file mode 100644 index 000000000000..d4759175d6af --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/serializers.py @@ -0,0 +1,1411 @@ +"""Serializers for version 2 of the User API.""" + +import re + +from django.contrib.auth.models import User # pylint: disable=imported-auth-user +from django.urls import reverse +from drf_spectacular.utils import extend_schema_field +from edx_rest_framework_extensions.errors import ErrorResponseSerializer +from rest_framework import serializers +from rest_framework.fields import empty +from rest_framework.settings import api_settings +from rest_framework.validators import ProhibitSurrogateCharactersValidator + +from lms.djangoapps.verify_student.models import ManualVerification, SoftwareSecurePhotoVerification +from openedx.core.djangoapps.user_api.models import RetirementState, UserOrgTag, UserPreference +from openedx.core.djangoapps.user_authn.views.registration_form import contains_html, contains_url +from openedx.core.lib.api.serializers import CourseKeyField + +from .paginators import UserApiPagination + +#: Value of the ``view`` query parameter that embeds the owning user in each preference. +FULL_VIEW = "full" + +#: Value of the ``view`` query parameter that reduces a user to its identifying fields. +MINIMAL_VIEW = "minimal" + +#: Properties the ``minimal`` preset keeps of a directory entry. +USER_MINIMAL_FIELDS = ("id", "username", "email") + +#: Properties the ``minimal`` preset keeps of a stored preference. +PREFERENCE_MINIMAL_FIELDS = ("username", "key", "value") + +#: Context member naming the users whose whole directory entry a response may +#: carry, by id. A directory entry belongs to the user collection, so which +#: entries a caller may be given is that collection's decision; the view +#: settles it for a whole page and leaves the answer here, so that reading it +#: costs no query per row. +EMBEDDABLE_OWNER_IDS = "embeddable_owner_ids" + + +class ErrorResponse(ErrorResponseSerializer): # pylint: disable=abstract-method + """ + The body of every unsuccessful response. + + ``type`` is a URI naming the kind of failure, ``title`` names it in one + line, ``status`` repeats the HTTP status code and ``detail`` explains what + happened; ``errors`` lists the per-field messages of a rejected parameter. + """ + + +class RetiredAccountErrorResponse(ErrorResponse): # pylint: disable=abstract-method + """ + The body of a refusal that also reports what is left of a retired account. + + The two members below are carried only when the account was selected by an + email address it held before being retired; every other refusal at this + status carries the standard members alone. + """ + + can_cancel_retirement = serializers.BooleanField( + required=False, + help_text=( + "Whether the retirement can still be reversed, which it can only while it is inside " + "the cooling-off period." + ), + ) + retirement_id = serializers.IntegerField( + required=False, + allow_null=True, + help_text=( + "Identifies the retirement to a caller able to reverse it, or null when there is " + "nothing left to reverse." + ), + ) + + +class _KeptFieldsMixin: + """ + Describes an object reduced to the properties named in ``kept_fields``. + + The response presets reduce an already serialized object, so a preset's + shape has a describing serializer of its own rather than a serializing one. + """ + + #: Names of the properties the preset returns. + kept_fields = () + + def get_fields(self): + """Return the declared fields narrowed to the ones the preset returns.""" + fields = super().get_fields() + return {name: field for name, field in fields.items() if name in self.kept_fields} + + +class _OptionalFieldsMixin: + """ + Describes an object whose every property is optional. + + A caller that selects properties with ``fields`` decides which ones come + back, so none of them is guaranteed to be present. + """ + + def get_fields(self): + """Return the declared fields, none of them mandatory.""" + fields = super().get_fields() + for field in fields.values(): + field.required = False + field.read_only = False + return fields + + +def full_view_requested(request): + """Return True when the caller asked for the embedded-user response preset.""" + if request is None: + return False + params = getattr(request, "query_params", None) + if params is None: + params = getattr(request, "GET", {}) + return params.get("view") == FULL_VIEW + + +class UserDirectorySerializer(serializers.ModelSerializer): + """A user of the platform directory, addressed by username.""" + + id = serializers.IntegerField( + read_only=True, + help_text="Database identifier of the user. Addresses in this API use the username instead.", + ) + url = serializers.HyperlinkedIdentityField( + view_name="user_v2:user_detail", + lookup_field="username", + help_text="Absolute URL of this user's directory entry.", + ) + email = serializers.EmailField(read_only=True, help_text="Registered email address of the user.") + username = serializers.CharField(read_only=True, help_text="Unique name the user is addressed by.") + name = serializers.SerializerMethodField( + help_text="Full name recorded on the user's profile, or null when the user has no profile.", + ) + preferences = serializers.SerializerMethodField( + help_text="The user's stored preferences, as a mapping of preference key to value.", + ) + + class Meta: + model = User + fields = ("id", "url", "email", "name", "username", "preferences") + read_only_fields = fields + + @extend_schema_field(serializers.CharField(allow_null=True)) + def get_name(self, user): + """Return the profile name, or None for a user who has no profile row.""" + profile = getattr(user, "profile", None) + return profile.name if profile is not None else None + + @extend_schema_field(serializers.DictField(child=serializers.CharField())) + def get_preferences(self, user): + """Return the user's preferences as a key-to-value mapping.""" + return UserPreference.get_all_preferences(user) + + +class UserDirectoryMinimalSerializer(_KeptFieldsMixin, UserDirectorySerializer): + """A directory entry reduced to the properties that identify the user.""" + + kept_fields = USER_MINIMAL_FIELDS + + +class UserDirectoryProjectionSerializer(_OptionalFieldsMixin, UserDirectorySerializer): + """A directory entry reduced to the properties the caller selected.""" + + +class UserPreferenceRecordSerializer(serializers.ModelSerializer): + """ + A stored preference, identified by the user that owns it and its key. + + ``user`` carries the owner's whole directory entry and is included only + when the caller asks for the ``full`` response preset and is one the + caller may be given; by default the owner appears as the ``username`` + string alone. + """ + + url = serializers.SerializerMethodField(help_text="Absolute URL of this preference.") + username = serializers.CharField( + source="user.username", + read_only=True, + help_text="Username of the user this preference belongs to.", + ) + key = serializers.CharField(read_only=True, help_text="Name of the preference.") + value = serializers.CharField(read_only=True, help_text="Stored value of the preference.") + user = serializers.SerializerMethodField( + help_text=( + "Directory entry of the owning user; null unless the full response preset is asked " + "for and the entry is one the caller may be given." + ), + ) + + class Meta: + model = UserPreference + fields = ("username", "key", "value", "url", "user") + read_only_fields = fields + + @extend_schema_field(serializers.URLField()) + def get_url(self, preference): + """Return the member address of this preference.""" + path = reverse( + "user_v2:user_preference_detail", + kwargs={"username": preference.user.username, "key": preference.key}, + ) + request = self.context.get("request") + return request.build_absolute_uri(path) if request is not None else path + + @extend_schema_field(UserDirectorySerializer(allow_null=True)) + def get_user(self, preference): + """Embed the owner's entry when the caller asked for the full preset and may be given it.""" + if not full_view_requested(self.context.get("request")): + return None + if preference.user_id not in (self.context.get(EMBEDDABLE_OWNER_IDS) or ()): + return None + return UserDirectorySerializer(preference.user, context=self.context).data + + +class UserPreferenceRecordMinimalSerializer(_KeptFieldsMixin, UserPreferenceRecordSerializer): + """A stored preference reduced to its owner, its name and its value.""" + + kept_fields = PREFERENCE_MINIMAL_FIELDS + + +class UserPreferenceRecordProjectionSerializer(_OptionalFieldsMixin, UserPreferenceRecordSerializer): + """A stored preference reduced to the properties the caller selected.""" + + +class TimeZoneSerializer(serializers.Serializer): # pylint: disable=abstract-method + """A time zone and the way it is displayed to a learner.""" + + time_zone = serializers.CharField(help_text="Name of the time zone, for example ``US/Pacific``.") + description = serializers.CharField( + help_text="Display form of the time zone, for example ``US/Pacific (PST, UTC-0800)``.", + ) + + +class TimeZoneQuerySerializer(serializers.Serializer): # pylint: disable=abstract-method + """Query parameters accepted by the time-zone list.""" + + country_code = serializers.RegexField( + r"^[A-Za-z]{2}$", + required=False, + # An empty value narrows nothing, the way an empty value of any other + # parameter does, and is accepted rather than held to the pattern. + allow_blank=True, + error_messages={ + "invalid": "Enter a two-letter ISO 3166-1 alpha-2 country code, for example FR.", + }, + help_text=( + "Two-letter ISO 3166-1 alpha-2 country code, case-insensitive. " + "Every time zone is returned when it is omitted or left empty, and when " + "the code names a country with no time zones of its own." + ), + ) + + +#: Value of the ``view`` query parameter that returns only the fields the +#: account's owner shares with everybody. +SHARED_VIEW = "shared" + +#: Properties the ``minimal`` preset keeps of an account. +ACCOUNT_MINIMAL_FIELDS = ("username", "email", "name", "profile_image") + +#: Properties the ``ordering`` parameter of the account list accepts. +ACCOUNT_SORT_FIELDS = ("username",) + +#: The order the account list uses when it is asked for none. Usernames are +#: unique, so sorting by one settles every row: the same selection asked for +#: twice is cut into the same pages. +ACCOUNT_DEFAULT_SORT = ACCOUNT_SORT_FIELDS[0] + + +class AccountProfileImageSerializer(serializers.Serializer): # pylint: disable=abstract-method + """Where a user's profile image can be fetched, in each size the site renders.""" + + has_image = serializers.BooleanField( + help_text="Whether the user has uploaded an image. The URLs below are a default image when it is false.", + ) + image_url_full = serializers.URLField(required=False, help_text="Address of the image at its largest size.") + image_url_large = serializers.URLField(required=False, help_text="Address of the image at its large size.") + image_url_medium = serializers.URLField(required=False, help_text="Address of the image at its medium size.") + image_url_small = serializers.URLField(required=False, help_text="Address of the image at its smallest size.") + + +class AccountLanguageSerializer(serializers.Serializer): # pylint: disable=abstract-method + """A language the user has told the site they speak.""" + + code = serializers.CharField(help_text="Language code, for example ``en`` or ``pt-br``.") + + +class AccountSocialLinkSerializer(serializers.Serializer): # pylint: disable=abstract-method + """A link to one of the user's profiles on another site.""" + + platform = serializers.CharField(help_text="Name of the site, for example ``facebook`` or ``linkedin``.") + social_link = serializers.CharField( + allow_blank=True, help_text="Address of the user's profile on that site.", + ) + + +class AccountExtendedFieldSerializer(serializers.Serializer): # pylint: disable=abstract-method + """One of the extra profile fields this site collects.""" + + field_name = serializers.CharField(help_text="Name the site configured the field under.") + field_value = serializers.JSONField( + help_text=( + "Value the user gave, in the shape this site stores it in: a string for a field " + "collected as text, and whatever the field holds otherwise. It is an empty string " + "when the field is unanswered and null where the site stores no value at all." + ), + ) + + +class AccountSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + A user's account and profile. + + Which properties a response carries depends on who asks: a user reading + their own account, and a staff caller reading anyone's, receive every + property below; anyone else receives the ones the account's owner shares, + which is why all but ``username``, ``profile_image`` and ``account_privacy`` + are marked optional here. + """ + + username = serializers.CharField(help_text="Unique name the user is addressed by.") + profile_image = AccountProfileImageSerializer( + allow_null=True, + help_text="Where the user's profile image can be fetched, null for an account without a profile.", + ) + account_privacy = serializers.CharField( + help_text=( + "How much of this account other learners may see: `all_users`, `private`, or `custom` " + "when the owner chose field by field." + ), + ) + email = serializers.EmailField(required=False, help_text="Address the site writes to the user at.") + id = serializers.IntegerField( + required=False, + help_text="Numeric identifier of the user. Addresses in this API use the username instead.", + ) + name = serializers.CharField( + required=False, allow_null=True, help_text="Full name the user gave, as it appears on certificates.", + ) + bio = serializers.CharField(required=False, allow_null=True, help_text="Short self-description.") + country = serializers.CharField( + required=False, allow_null=True, help_text="Two-letter ISO 3166-1 alpha-2 code of the user's country.", + ) + state = serializers.CharField( + required=False, allow_null=True, help_text="Code of the state or territory, recorded for the United States.", + ) + date_joined = serializers.DateTimeField(required=False, help_text="When the account was created.") + last_login = serializers.DateTimeField( + required=False, allow_null=True, help_text="When the user last signed in, null if they never have.", + ) + is_active = serializers.BooleanField( + required=False, help_text="Whether the user has confirmed their email address.", + ) + gender = serializers.CharField( + required=False, + allow_null=True, + help_text="Gender the user selected: `m`, `f`, `o`, or null when unanswered.", + ) + year_of_birth = serializers.IntegerField( + required=False, allow_null=True, help_text="Year the user was born, null when unanswered.", + ) + level_of_education = serializers.CharField( + required=False, + allow_null=True, + help_text=( + "Highest level of education the user selected: `p` for a doctorate, `m` for a " + "master's or professional degree, `b` for a bachelor's degree, `a` for an associate " + "degree, `hs` for secondary or high school, `jhs` for junior secondary or middle " + "school, `el` for elementary or primary school, `none` for no formal education, or " + "`other`. Null when unanswered, and an account registered long ago may carry a code " + "the site no longer offers." + ), + ) + goals = serializers.CharField( + required=False, allow_null=True, help_text="What the user said they want from the site.", + ) + mailing_address = serializers.CharField(required=False, allow_null=True, help_text="Postal address the user gave.") + phone_number = serializers.CharField( + required=False, allow_null=True, help_text="Digits, optionally led by a `+`, or null when unanswered.", + ) + language_proficiencies = AccountLanguageSerializer( + many=True, + required=False, + allow_null=True, + help_text=( + "Languages the user speaks, ordered by language code, null for an account without a profile." + ), + ) + social_links = AccountSocialLinkSerializer( + many=True, + required=False, + allow_null=True, + help_text=( + "The user's profiles on other sites, ordered by platform, null for an account without a profile." + ), + ) + extended_profile = AccountExtendedFieldSerializer( + many=True, + required=False, + allow_null=True, + help_text=( + "The extra profile fields this site collects, in configured order, null for an " + "account without a profile." + ), + ) + requires_parental_consent = serializers.BooleanField( + required=False, + allow_null=True, + help_text=( + "Whether the user is too young to share a profile without a guardian's consent, null " + "for an account without a profile." + ), + ) + pending_name_change = serializers.CharField( + required=False, allow_null=True, help_text="Name the user has asked to change to, null when none is pending.", + ) + verified_name = serializers.CharField( + required=False, allow_null=True, help_text="Name verified against the user's photo identification.", + ) + secondary_email = serializers.EmailField( + required=False, allow_null=True, help_text="Recovery address, present only where the site collects one.", + ) + secondary_email_enabled = serializers.BooleanField( + required=False, + allow_null=True, + help_text="Whether this site collects a recovery address, null where it collects none.", + ) + course_certificates = serializers.DictField( + required=False, + allow_null=True, + help_text="Always null here; certificates are served by the certificates API.", + ) + time_zone = serializers.CharField( + required=False, + allow_null=True, + help_text="Always null here; the time-zone preference is served by the preferences API.", + ) + + +class AccountMinimalSerializer(_KeptFieldsMixin, AccountSerializer): + """An account reduced to the properties that identify its owner.""" + + kept_fields = ACCOUNT_MINIMAL_FIELDS + + +class AccountProjectionSerializer(_OptionalFieldsMixin, AccountSerializer): + """An account reduced to the properties the caller selected.""" + + +class AccountQuerySerializer(serializers.Serializer): # pylint: disable=abstract-method + """Query parameters accepted by the account list.""" + + username = serializers.CharField( + required=False, + allow_blank=True, + trim_whitespace=False, + help_text=( + "Comma-separated usernames. A name no account holds is left out of the page rather than refused. " + "Omit this and the other selectors to receive your own account." + ), + ) + email = serializers.EmailField( + required=False, + allow_blank=True, + trim_whitespace=False, + help_text="Address of the one account to return. Only staff may select an account this way.", + ) + lms_user_id = serializers.IntegerField( + required=False, + help_text="Numeric identifier of the one account to return. Only staff may select an account this way.", + ) + view = serializers.ChoiceField( + choices=[ + (MINIMAL_VIEW, "The properties that identify the account's owner"), + (SHARED_VIEW, "The properties the owner shares with everybody"), + ], + required=False, + allow_blank=True, + help_text=( + "Response preset. `minimal` reduces each account to the properties that identify its owner; " + "`shared` returns only the properties the owner shares with everybody, even to a caller who " + "may see more. Omit it, or send it empty, to receive everything the caller may see." + ), + ) + ordering = serializers.ChoiceField( + choices=[ + *((field, f"{field}, ascending") for field in ACCOUNT_SORT_FIELDS), + *((f"-{field}", f"{field}, descending") for field in ACCOUNT_SORT_FIELDS), + ], + required=False, + allow_blank=True, + default=ACCOUNT_DEFAULT_SORT, + error_messages={ + "invalid_choice": ( + f"Sort by any of: {', '.join(ACCOUNT_SORT_FIELDS)}, each optionally prefixed with '-'." + ), + }, + help_text=( + "Property to sort the page by, prefixed with `-` to sort descending. Omit it, or " + f"send it empty, to sort by ascending `{ACCOUNT_DEFAULT_SORT}`. Usernames are unique, " + "so a selection asked for twice is cut into the same pages." + ), + ) + + +class _TextField(serializers.CharField): + """ + Text that is only taken as text. + + The base field spells out a number or a decimal as the digits that write + it, so a property sent as ``5`` arrives as ``"5"`` and is stored. Here a + value of any other type is refused, naming the property that carried it. + """ + + def to_internal_value(self, data): + """Return the text sent, or refuse a value that is not text.""" + if not isinstance(data, str): + self.fail("invalid") + return super().to_internal_value(data) + + +class AccountUpdateSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + The properties of an account a user may change. + + The request body is a merge patch, so every property is optional and only + the ones present are changed; sending null clears a property that allows it. + An empty text is taken for each property that accepts one, because what it + means there -- clearing the property, or a value the property cannot hold + -- is settled where the change is carried out. + """ + + email = serializers.EmailField( + required=False, + help_text="New address. The change takes effect only once the user confirms it from a message sent there.", + ) + secondary_email = serializers.EmailField( + required=False, help_text="New recovery address, confirmed the same way as the primary one.", + ) + name = _TextField( + required=False, + allow_blank=True, + help_text="Full name. A change may need identity verification before it takes effect.", + ) + bio = _TextField( + required=False, allow_blank=True, help_text="Short self-description. Send an empty text to clear it.", + ) + country = _TextField( + required=False, + allow_null=True, + allow_blank=True, + help_text="Two-letter ISO 3166-1 alpha-2 code of the user's country.", + ) + state = _TextField( + required=False, + allow_null=True, + allow_blank=True, + help_text="Code of the state or territory, recorded for the United States.", + ) + city = _TextField(required=False, allow_null=True, allow_blank=True, help_text="City the user lives in.") + gender = _TextField( + required=False, allow_null=True, allow_blank=True, help_text="Gender the user selects: `m`, `f` or `o`.", + ) + year_of_birth = serializers.IntegerField(required=False, allow_null=True, help_text="Year the user was born.") + level_of_education = _TextField( + required=False, + allow_null=True, + allow_blank=True, + help_text=( + "Highest level of education: `p` for a doctorate, `m` for a master's or professional " + "degree, `b` for a bachelor's degree, `a` for an associate degree, `hs` for secondary " + "or high school, `jhs` for junior secondary or middle school, `el` for elementary or " + "primary school, `none` for no formal education, or `other`." + ), + ) + goals = _TextField( + required=False, allow_null=True, allow_blank=True, help_text="What the user wants from the site.", + ) + mailing_address = _TextField( + required=False, allow_null=True, allow_blank=True, help_text="Postal address.", + ) + phone_number = _TextField( + required=False, + allow_blank=True, + help_text="Digits, optionally led by a `+`. Send an empty text to clear it.", + ) + language_proficiencies = AccountLanguageSerializer( + many=True, required=False, help_text="The whole list of languages the user speaks, replacing the stored one.", + ) + social_links = AccountSocialLinkSerializer( + many=True, + required=False, + help_text=( + "Links to add or replace, merged into the stored list by platform. " + "Send an empty address to drop a platform." + ), + ) + extended_profile = AccountExtendedFieldSerializer( + many=True, required=False, help_text="Values for the extra profile fields this site collects.", + ) + account_privacy = _TextField( + required=False, + allow_null=True, + allow_blank=True, + help_text="How much of the account other learners may see: `all_users`, `private` or `custom`.", + ) + + +class _TextListField(serializers.ListField): + """ + A list of text whose refusal is one message naming the entries at fault. + + The base field reports an entry it will not take under the position that + entry holds, which reaches a caller as a mapping of positions nested + inside the member naming the list, where nothing can display it. The + refusal here names those positions in a sentence instead. + """ + + def to_internal_value(self, data): + """Return the entries sent, refusing the whole list when one of them is not text.""" + try: + return super().to_internal_value(data) + except serializers.ValidationError as refusal: + if not isinstance(refusal.detail, dict): + raise + positions = ", ".join(str(position) for position in sorted(refusal.detail)) + raise serializers.ValidationError( + f"Every entry must be text, and the entries at these positions are not: {positions}.", + ) from refusal + + +#: Most addresses one search may name. A search answers with at most one +#: account per address, so holding the request to the largest page a caller +#: may ask for keeps the answer within a page a caller may read. +EMAIL_SEARCH_MAX_ADDRESSES = UserApiPagination.max_page_size + + +class EmailSearchSerializer(serializers.Serializer): # pylint: disable=abstract-method + """The addresses an account search is asked to resolve.""" + + emails = _TextListField( + child=serializers.CharField(allow_blank=True, trim_whitespace=False), + allow_empty=True, + max_length=EMAIL_SEARCH_MAX_ADDRESSES, + help_text=( + f"Email addresses to look up, at most {EMAIL_SEARCH_MAX_ADDRESSES} of them. An " + "address no account holds is left out of the results." + ), + ) + + +class EmailSearchResultSerializer(serializers.ModelSerializer): + """The account that holds one of the addresses searched for.""" + + email = serializers.EmailField(read_only=True, help_text="Registered email address of the user.") + id = serializers.IntegerField(read_only=True, help_text="Numeric identifier of the user.") + username = serializers.CharField(read_only=True, help_text="Unique name the user is addressed by.") + + class Meta: + model = User + fields = ("email", "id", "username") + read_only_fields = fields + + +#: Longest name the column holding a pending change can store. +NAME_MAX_LENGTH = 255 + + +class NameChangeSerializer(serializers.Serializer): # pylint: disable=abstract-method + """A request to put a different full name on a user's profile, stored as it is sent.""" + + name = serializers.CharField( + max_length=NAME_MAX_LENGTH, + trim_whitespace=False, + help_text="Full name to put on the profile once the request is confirmed.", + ) + + def validate_name(self, name): + """Refuse a name of spaces alone, or one carrying markup or a web address.""" + if not name.strip(): + self.fields["name"].fail("blank") + if contains_html(name): + raise serializers.ValidationError("Name cannot contain the following characters: < >") + if contains_url(name): + raise serializers.ValidationError("Name cannot contain a URL") + return name + + +#: Property the verification list is sorted by, and the order it uses when +#: asked for none: the most recently changed attempt first. +VERIFICATION_SORT_FIELD = "updated_at" +VERIFICATION_DEFAULT_SORT = f"-{VERIFICATION_SORT_FIELD}" + +#: Shape of the day a summary reports an accepted proof as running out on: +#: ``MM/DD/YYYY``, or empty while the day is not reported at all. +EXPIRATION_DAY_PATTERN = r"^(\d{2}/\d{2}/\d{4})?$" + + +class VerificationStatusSerializer(serializers.Serializer): # pylint: disable=abstract-method + """Where a user stands on having proved their identity.""" + + is_verified = serializers.BooleanField( + help_text="Whether an accepted proof of identity is on record and has not expired.", + ) + status = serializers.CharField( + help_text=( + "`none` until a proof has been sent in, `approved` once one has been accepted, " + "`pending` while one is being looked at, `must_reverify` once one has been turned " + "down, and `expired` once an accepted proof has run out." + ), + ) + expiration_datetime = serializers.RegexField( + EXPIRATION_DAY_PATTERN, + help_text=( + "Calendar day the accepted proof runs out, written as `MM/DD/YYYY`: the day alone, " + "where the attempt that carries the proof records the moment. It is empty until that " + "day is near enough for the user to be asked to prove their identity again." + ), + ) + + +class VerificationSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + One attempt a user has made at proving their identity. + + An attempt is stored by the service that carried out the check, in a table + of its own per kind of check, so the kind, what was said about the attempt + and the identifier it was filed under are read from the record rather than + held on it. + """ + + type = serializers.SerializerMethodField( + help_text=( + "How the identity was checked: `Software Secure` for a photo check, `Manual` for one " + "recorded by hand, and `SSO` for one recorded when the user signed in elsewhere, " + "which is also what a check carried out by an installed verification service is " + "reported as." + ), + ) + status = serializers.CharField( + help_text=( + "Where this attempt stands: `created` before it has been started, `ready` and " + "`submitted` while a photo check is on its way, `must_retry` once sending one on has " + "failed, `pending` while a check carried out elsewhere is being looked at, `approved` " + "once the attempt has been accepted and `denied` once it has been turned down." + ), + ) + expiration_datetime = serializers.DateTimeField( + allow_null=True, + help_text=( + "Moment this attempt stops counting as proof, to the second. It is null for an " + "attempt that never expires." + ), + ) + message = serializers.SerializerMethodField( + help_text="Why the attempt was turned down, or the reason it was accepted by hand. Empty otherwise.", + ) + updated_at = serializers.DateTimeField(help_text="When this attempt last changed.") + receipt_id = serializers.SerializerMethodField( + help_text="Identifier the photo verification service filed this attempt under, null for other kinds.", + ) + + def get_type(self, obj) -> str: + """Return how the identity was checked.""" + if isinstance(obj, SoftwareSecurePhotoVerification): + return "Software Secure" + if isinstance(obj, ManualVerification): + return "Manual" + return "SSO" + + def get_message(self, obj) -> str: + """Return what was said about this attempt: why it was turned down, or why it was accepted by hand.""" + if isinstance(obj, SoftwareSecurePhotoVerification): + return obj.error_msg + if isinstance(obj, ManualVerification): + return obj.reason + return "" + + def get_receipt_id(self, obj) -> str | None: + """Return the identifier the photo verification service filed this attempt under.""" + if isinstance(obj, SoftwareSecurePhotoVerification): + return obj.receipt_id + return None + + +class VerificationQuerySerializer(serializers.Serializer): # pylint: disable=abstract-method + """Query parameters accepted by the verification list.""" + + ordering = serializers.ChoiceField( + choices=[ + (VERIFICATION_SORT_FIELD, "Least recently changed attempt first"), + (VERIFICATION_DEFAULT_SORT, "Most recently changed attempt first"), + ], + required=False, + allow_blank=True, + default=VERIFICATION_DEFAULT_SORT, + error_messages={ + "invalid_choice": ( + f"Sort by any of: {VERIFICATION_SORT_FIELD}, each optionally prefixed with '-'." + ), + }, + help_text=( + "Property to sort the page by, prefixed with `-` to sort descending. Omit it, or send " + f"it empty, to sort by `{VERIFICATION_DEFAULT_SORT}`. Attempts changed at the same " + "moment are ordered by the receipt identifier, so a page is always in the same order." + ), + ) + + +#: Longest name a preference can be stored under, taken from the column that +#: stores it. +PREFERENCE_NAME_MAX_LENGTH = UserPreference._meta.get_field("key").max_length + +#: Refusal of a name longer than a preference can be stored under. +PREFERENCE_NAME_TOO_LONG = ( + f"A preference name is at most {PREFERENCE_NAME_MAX_LENGTH} characters long." +) + +#: What a name must carry to be stored under, taken from the column that stores +#: it. The column looks for this anywhere in the name rather than over the +#: whole of it, so the same search is made here and the names refused are the +#: names the store refuses. +PREFERENCE_NAME_PATTERN = UserPreference.KEY_REGEX + +#: Refusal of a name nothing can be stored under at all. +PREFERENCE_NAME_UNUSABLE = ( + "A preference name carries at least one letter, digit, hyphen or underscore." +) + + +def why_a_name_cannot_be_stored(name): + """Return why nothing can be stored under this name, or nothing if something can.""" + if len(name) > PREFERENCE_NAME_MAX_LENGTH: + return [PREFERENCE_NAME_TOO_LONG] + if not re.search(PREFERENCE_NAME_PATTERN, name): + return [PREFERENCE_NAME_UNUSABLE] + return [] + +#: Refuses text that has no encoding of its own, which is text that can neither +#: be stored nor be carried back in an answer. +_REFUSE_UNENCODABLE_TEXT = ProhibitSurrogateCharactersValidator() + + +def _members_at_fault(refusal): + """Return a refusal keyed by the body member it is about, or against the body itself.""" + if isinstance(refusal, dict): + return refusal + return {api_settings.NON_FIELD_ERRORS_KEY: refusal} + + +def _why_text_cannot_be_carried(text): + """Return why this text can be neither stored nor answered with, or nothing if it can.""" + try: + _REFUSE_UNENCODABLE_TEXT(text) + except serializers.ValidationError as refused: + return list(refused.detail) + return [] + + +#: What the preference document of one account looks like: an object whose +#: every member is a preference name and the text stored under it. The +#: document has no fixed set of members -- any name an account's own features +#: store a value under is one of them -- so the shape of its members is named +#: here rather than declared as properties, and the fields below carry it: +#: a body named by a field alone is published as an object of no stated shape. +ACCOUNT_PREFERENCES_SCHEMA = { + "type": "object", + "additionalProperties": {"type": "string"}, + "description": ( + "Preferences of one account, as an object of preference name to stored value. Every " + "value is text. Which names an account carries depends on the features it uses." + ), +} + + +#: What a merge patch of the preference document carries: the same object as +#: the document, except that a member may also be null, and that it has to +#: name something -- a body naming nothing to change is refused as it is read, +#: so the document says so too. +ACCOUNT_PREFERENCE_CHANGES_SCHEMA = { + "type": "object", + "minProperties": 1, + "additionalProperties": {"type": "string", "nullable": True}, + "description": ( + "Preferences to change, as an object of preference name to the value to store under it. " + "Every value is text, and a null removes the preference. Names the body leaves out are " + "left as they are. At least one name must be given." + ), +} + + +@extend_schema_field(ACCOUNT_PREFERENCES_SCHEMA) +class AccountPreferenceDocumentField(serializers.DictField): + """ + The preferences of one account, as the body they are answered in. + + Every stored value is text and the names are answered sorted by name, + character by character and by the code of each character, rather than in + the order they were stored. Both are settled here so that the body cannot + say something the shape above does not. + """ + + child = serializers.CharField( + help_text="Value stored under the preference name this member is, which is always text.", + ) + + def to_representation(self, value): + """Return the stored preferences as the document, by name.""" + return super().to_representation(dict(sorted(value.items()))) + + +class _PreferenceChangeField(_TextField): + """The value to store under one preference name, which a null removes instead.""" + + default_error_messages = {"invalid": "Send a value as text, or null to remove the preference."} + + +@extend_schema_field(ACCOUNT_PREFERENCE_CHANGES_SCHEMA) +class AccountPreferenceChangesField(serializers.DictField): + """ + The members a merge patch of a preference document carries. + + Each value is read as the text to store under the name carrying it, and a + null is read as a removal. An empty value is read rather than refused, + because what a preference does with one is the preference service's to + decide and it answers for every address storing a value. + + A body that is not an object, and one that names nothing to change, are + refused by the field as part of reading it: only the wording of those two + refusals is settled here, and it is the wording this version answers with + for the other body read as a merge patch. + """ + + default_error_messages = { + "not_a_dict": "Send the preferences to change as an object.", + "empty": "Name at least one preference to change.", + } + + # The value reaches the preference service as it was sent: the service + # checks some values, a time zone among them, before it trims what it + # stores, so trimming here would store a value that check refuses. + child = _PreferenceChangeField( + allow_null=True, + allow_blank=True, + trim_whitespace=False, + help_text="Value to store under the preference name this member is, or null to remove it.", + ) + + +#: Formats the preference document of one account into the body it is answered in. +ACCOUNT_PREFERENCE_DOCUMENT = AccountPreferenceDocumentField() + +#: Reads the members a merge patch of a preference document carries. A body +#: naming nothing is refused rather than read as a change that does nothing, +#: since a caller who sent one has asked for something that did not arrive. +ACCOUNT_PREFERENCE_CHANGES = AccountPreferenceChangesField(allow_empty=False) + + +class AccountPreferencesUpdateSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + The changes a merge patch of an account's preference document carries. + + The document has no fixed set of members, so the body is read member by + member against the field describing one change instead of against + declared fields. Refusals are reported under the preference name that + carried the value at fault, which is the member of the body a caller + would correct; a name that could not itself be carried back is reported + against the body, since the answer could not name it. + """ + + def to_internal_value(self, data): + """Return the changes sent, refusing a body, a name or a value that cannot be stored.""" + names = data.keys() if isinstance(data, dict) else () + unnameable = sorted({ + message for name in names for message in _why_text_cannot_be_carried(name) + }) + if unnameable: + raise serializers.ValidationError({api_settings.NON_FIELD_ERRORS_KEY: unnameable}) + # A name longer than one can be stored under is reported under itself, + # ahead of whatever the value carried by it is refused for: the name is + # the part of that member a caller has to change either way. + too_long = { + name: [PREFERENCE_NAME_TOO_LONG] + for name in names + if len(name) > PREFERENCE_NAME_MAX_LENGTH + } + try: + changes = ACCOUNT_PREFERENCE_CHANGES.run_validation(data) + except serializers.ValidationError as refused: + raise serializers.ValidationError({ + **_members_at_fault(refused.detail), **too_long, + }) from refused + if too_long: + raise serializers.ValidationError(too_long) + return changes + + +class AccountPreferenceSerializer(serializers.Serializer): # pylint: disable=abstract-method + """One preference of one account.""" + + key = serializers.CharField(help_text="Name the preference is stored under.") + value = serializers.CharField(help_text="Value stored under that name, which is always text.") + + +class AccountPreferenceValueSerializer(serializers.Serializer): # pylint: disable=abstract-method + """The value to store under one preference of one account.""" + + # Read as sent, for the reason the document's values are. + value = _TextField( + trim_whitespace=False, + help_text=( + "Value to store, as text. A preference cannot hold an empty value; remove the " + "preference instead." + ), + ) + + +@extend_schema_field(serializers.CharField) +class _CourseKeyField(CourseKeyField): + """ + A course, read from and published as the text that writes its key. + + The platform's field turns that text into the key object the services + behind these endpoints take, and describes itself to no document, so the + text form is named here. It reads a value of any other type by splitting + it, which no value but text can be, so one is refused instead. + """ + + default_error_messages = {"invalid": "Send the course key as the text that writes it."} + + def to_internal_value(self, data): + """Return the course the text names, refusing a value that is not text.""" + if not isinstance(data, str): + self.fail("invalid") + return super().to_internal_value(data) + + +#: Longest organization name a choice can be recorded against, taken from the +#: column the choice is recorded in. +ORGANIZATION_MAX_LENGTH = UserOrgTag._meta.get_field("org").max_length + +#: Refusal of a key that names something other than a course. Some keys of +#: other kinds are read by the platform's course key reader for historical +#: reasons and say themselves that they name no course. +NOT_A_COURSE = ( + "This key names something other than a course, and the organization a choice is recorded " + "against is the one running a course." +) + +#: Refusal of a course whose organization no choice can be recorded against. +ORGANIZATION_TOO_LONG = ( + f"The organization running this course is named by more than {ORGANIZATION_MAX_LENGTH} " + "characters, which is more than a choice can be recorded against." +) + +#: What the choice itself is published and read as. +EMAIL_OPT_IN_HELP = ( + "Whether this user wants messages from that organization, sent as true or false. A value " + "that says neither is refused rather than read as one of them." +) + + +class _EmailOptInChoiceField(serializers.BooleanField): + """ + A yes or no about messages, read from a boolean or from the word for one. + + The value records consent, so it is read strictly: a boolean, or the text + ``true`` or ``false`` in any case. The further spellings a boolean field + admits (``1``, ``on``, ``yes``) are refused rather than read, because a + caller who sends one of them has not said which choice they mean and a + choice recorded the wrong way round is not something they can see. For the + same reason a body that leaves the choice out is refused instead of being + read as an unticked checkbox, which is what an absent form member would + otherwise mean. + """ + + default_empty_html = empty + default_error_messages = {"invalid": "Send the choice as true or false."} + + def to_internal_value(self, data): + """Return the choice sent, refusing a value that does not say which choice it is.""" + if isinstance(data, bool): + return data + if isinstance(data, str) and data.lower() in ("true", "false"): + return data.lower() == "true" + self.fail("invalid") + + +class EmailOptInSerializer(serializers.Serializer): # pylint: disable=abstract-method + """A user's choice about messages from the organization running a course.""" + + course_key = _CourseKeyField( + help_text=( + "Course the organization runs, which is how the organization the choice applies to " + "is identified." + ), + ) + email_opt_in = _EmailOptInChoiceField(help_text=EMAIL_OPT_IN_HELP) + + def validate_course_key(self, value): + """Return the course, refusing a key that names none and one whose organization is too long.""" + if not value.is_course: + raise serializers.ValidationError(NOT_A_COURSE) + if len(value.org) > ORGANIZATION_MAX_LENGTH: + raise serializers.ValidationError(ORGANIZATION_TOO_LONG) + return value + + +#: Properties the ``minimal`` preset keeps of a retirement; none is personal data. +RETIREMENT_MINIMAL_FIELDS = ("id", "original_username", "current_state") + +#: The personal data each erasure scope clears: core for the platform, misc for features on top. +ERASURE_CORE_SCOPE = "lms_core" +ERASURE_MISC_SCOPE = "lms_misc" +ERASURE_SCOPES = (ERASURE_CORE_SCOPE, ERASURE_MISC_SCOPE) + +#: Each erasure scope with what it clears. Declared here so ENUM_NAME_OVERRIDES +#: can name the set from the same values (it matches values and labels together). +ERASURE_SCOPE_CHOICES = ( + (ERASURE_CORE_SCOPE, "Profile, images, certificates, entitlements and the account itself."), + (ERASURE_MISC_SCOPE, "Wiki revisions, enrollment audits, credit and API access records."), +) + +#: What a batch operation reports about each name it was given. +OUTCOME_DELETED = "deleted" +OUTCOME_ALREADY_ABSENT = "already_absent" +BATCH_OUTCOMES = (OUTCOME_DELETED, OUTCOME_ALREADY_ABSENT) + +#: Each outcome with what it means, offered and named as the erasure scopes are. +BATCH_OUTCOME_CHOICES = ( + (OUTCOME_DELETED, "The record was deleted by this request."), + (OUTCOME_ALREADY_ABSENT, "There was no such record, so this request left nothing to do."), +) + + +def why_a_state_is_not_configured(name): + """Return why this deployment has no such pipeline step, or ``None`` when it has one.""" + if RetirementState.objects.filter(state_name=name).exists(): + return None + configured = ", ".join(sorted(RetirementState.get_state_names_list())) + return f"No such pipeline step. This deployment runs: {configured}." + + +class RetirementAccountProfileSerializer(serializers.Serializer): # pylint: disable=abstract-method + """The part of a retiring user's profile a retirement record carries.""" + + id = serializers.IntegerField(read_only=True, help_text="Database identifier of the profile.") + name = serializers.CharField( + read_only=True, + allow_blank=True, + help_text="Full name recorded on the profile at the time it is read.", + ) + + +class RetirementAccountSerializer(serializers.Serializer): # pylint: disable=abstract-method + """The part of a retiring user's account a retirement record carries.""" + + id = serializers.IntegerField(read_only=True, help_text="Database identifier of the user.") + username = serializers.CharField( + read_only=True, + help_text=( + "Name the account is stored under now, which is the hashed replacement once the " + "erasure of core data has run." + ), + ) + email = serializers.EmailField( + read_only=True, + help_text=( + "Address the account is stored under now, which is the hashed replacement once the " + "account has been locked." + ), + ) + profile = RetirementAccountProfileSerializer( + read_only=True, + # An account may have no profile; answer null rather than drop the property. + allow_null=True, + help_text="The profile of the account, or null for an account that has none.", + ) + + +class RetirementSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + One account's progress through the retirement pipeline, addressed by the name it held. + + ``current_state`` and ``last_state`` are the names of the steps rather + than objects: a step's execution order and identifier are configured by + the operator and say nothing a caller of this API can act on. The log of + the transitions a retirement has been through grows without bound and is + left out for that reason. + """ + + id = serializers.IntegerField( + read_only=True, + help_text="Database identifier of the retirement. Addresses in this API use the username instead.", + ) + user = RetirementAccountSerializer(read_only=True, help_text="The account being retired, as it stands now.") + original_username = serializers.CharField( + read_only=True, + help_text="Name the account was stored under when retirement was requested.", + ) + original_email = serializers.EmailField( + read_only=True, + help_text="Address the account was stored under when retirement was requested.", + ) + original_name = serializers.CharField( + read_only=True, + allow_blank=True, + help_text="Full name on the profile when retirement was requested.", + ) + retired_username = serializers.CharField( + read_only=True, + allow_blank=True, + help_text="Hashed name the account is moved to once its core data is erased.", + ) + retired_email = serializers.EmailField( + read_only=True, + allow_blank=True, + help_text="Hashed address the account is moved to once it is locked.", + ) + current_state = serializers.CharField( + source="current_state.state_name", + read_only=True, + help_text="Name of the pipeline step this retirement stands at.", + ) + last_state = serializers.CharField( + source="last_state.state_name", + read_only=True, + help_text="Name of the pipeline step this retirement stood at before the one it is at now.", + ) + created = serializers.DateTimeField(read_only=True, help_text="When retirement was requested.") + modified = serializers.DateTimeField(read_only=True, help_text="When this retirement last moved.") + + +class RetirementMinimalSerializer(_KeptFieldsMixin, RetirementSerializer): + """A retirement reduced to which account it is for and where it stands.""" + + kept_fields = RETIREMENT_MINIMAL_FIELDS + + +class RetirementProjectionSerializer(_OptionalFieldsMixin, RetirementSerializer): + """A retirement reduced to the properties the caller selected.""" + + +class RetirementUpdateSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + A move of one retirement to another step of the pipeline. + + Which steps exist and in what order is configured by the operator, so + ``new_state`` is checked against the configured steps rather than against + a fixed list. + + The body is an instruction rather than a set of changes to the record it + addresses: the step to move to and what happened at the step being left + are both needed to carry the move out and to keep the retirement's log + whole, so it is read whole and a body missing either is refused. + """ + + @property + def partial(self): + """ + Always false: this body is read whole, whichever method carries it. + + DRF makes the serializer of a ``PATCH`` partial, which would let either + required field be left out, and a schema generated from a partial + serializer publishes it as a set of changes with nothing required. + Neither is true of this body. + """ + return False + + @partial.setter + def partial(self, _value): + """Leave this body read whole when it is asked to be read partially.""" + + new_state = serializers.CharField(help_text="Name of the pipeline step to move this retirement to.") + response = serializers.CharField( + allow_blank=True, + help_text="What happened at the step being left, appended to the retirement's log.", + ) + force = serializers.BooleanField( + required=False, + default=False, + help_text=( + "Move the retirement even where the step asked for is not a later one. Operators use " + "this to send a retirement back through a step that has to be repeated." + ), + ) + + def validate_new_state(self, value): + """Return the step named, refusing a name no configured step holds.""" + reason = why_a_state_is_not_configured(value) + if reason is not None: + raise serializers.ValidationError(reason) + return value + + +class RetirementErasureSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + Which set of a retiring user's personal data to erase. + + The erasure cannot be undone, and each scope clears a different set, so + the scope is named in the body rather than defaulted. + """ + + scope = serializers.ChoiceField( + choices=ERASURE_SCOPE_CHOICES, + help_text=( + f"`{ERASURE_CORE_SCOPE}` erases the account's own data: its profile, its images, the " + "name on its certificates and entitlement notes, and the username and address on the " + f"account. `{ERASURE_MISC_SCOPE}` erases what the features around it hold: wiki " + "revisions, pending name changes, enrollment audits, credit records and API access " + "requests." + ), + ) + + +class RetirementBatchSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + The accounts a batch operation acts on, named as they were before retirement. + + A name repeated in the list names one account, so it is acted on and + reported once. + """ + + # As many names as the caller sends: the pipeline disposes of everything it + # has finished with, a whole queue at a time, in one request. + usernames = serializers.ListField( + child=serializers.CharField(trim_whitespace=False), + allow_empty=False, + help_text=( + "Names the accounts were stored under before retirement was requested, compared " + "exactly as spelled. There is no limit on how many one request names." + ), + ) + + def validate_usernames(self, value): + """Return the names given, each once, in the order they were first given.""" + return list(dict.fromkeys(value)) + + +class RetirementBatchResultSerializer(serializers.Serializer): # pylint: disable=abstract-method + """What a batch operation did about one of the names it was given.""" + + username = serializers.CharField(help_text="The name this result is about, as it was given.") + outcome = serializers.ChoiceField( + choices=BATCH_OUTCOME_CHOICES, + help_text=( + f"`{OUTCOME_DELETED}` where this request deleted the record, and " + f"`{OUTCOME_ALREADY_ABSENT}` where there was none to delete, which is what a repeat " + "of a request that has already been carried out reports for every name." + ), + ) + + +class RetirementBatchOutcomeSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + What a batch operation did, reported for each name it was given. + + Every name is reported, so a caller repeating a request that has already + been carried out can tell that from a request that did the work. + """ + + results = RetirementBatchResultSerializer( + many=True, + help_text="One result per name given, in the order the names were given.", + ) + + +class RetirementPartnerReportRecordSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + What a partner needs in order to forget one retired user. + + Every property is answered for every entry: the record is built from the + queue entry and from the enrolments of the account it names, so there is + nothing in it that a deployment may or may not supply. + """ + + user_id = serializers.IntegerField(help_text="Database identifier of the retired user.") + original_username = serializers.CharField(help_text="Name the account was stored under before retirement.") + original_email = serializers.EmailField(help_text="Address the account was stored under before retirement.") + original_name = serializers.CharField( + allow_blank=True, + help_text="Full name on the profile before retirement.", + ) + orgs = serializers.ListField( + child=serializers.CharField(), + help_text="Short names of the organizations running the courses this user was enrolled in.", + ) + created = serializers.DateTimeField(help_text="When this user was added to the partner reporting queue.") + + +#: The most entries one claim can be held to (a page); no limit takes every entry. +PARTNER_REPORT_CLAIM_MAX = UserApiPagination.max_page_size + + +class RetirementPartnerReportQuerySerializer(serializers.Serializer): # pylint: disable=abstract-method + """The query string of a request that takes partner reports for processing.""" + + limit = serializers.IntegerField( + required=False, + min_value=1, + max_value=PARTNER_REPORT_CLAIM_MAX, + help_text=( + f"Take at most this many entries, from 1 to {PARTNER_REPORT_CLAIM_MAX}. Omit it to take " + "every entry waiting, which is what the pipeline does: nothing releases an entry once " + "it is taken, so a caller that takes fewer than it processes cannot hand the rest back." + ), + ) diff --git a/openedx/core/djangoapps/user_api/v2/tests/__init__.py b/openedx/core/djangoapps/user_api/v2/tests/__init__.py new file mode 100644 index 000000000000..61ab32b09c15 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for version 2 of the User API.""" diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_account_parity.py b/openedx/core/djangoapps/user_api/v2/tests/test_account_parity.py new file mode 100644 index 000000000000..0618584fd285 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_account_parity.py @@ -0,0 +1,685 @@ +"""Behaviour parity between the version 1 and version 2 account addresses.""" + +import json + +from django.urls import reverse +from edx_rest_framework_extensions.testing import assert_error_envelope +from rest_framework import status +from rest_framework.test import APITestCase + +from common.djangoapps.student.models import get_retired_email_by_email +from common.djangoapps.student.tests.factories import ( + ContentTypeFactory, + PermissionFactory, + UserFactory, +) +from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user +from openedx.core.djangolib.testing.utils import skip_unless_lms + +from .test_parity import diff, unexpected +from .test_views import UNSUPPORTED_MEDIA_TYPE_SLUG + +PASSWORD = "Password1234" +MERGE_PATCH = "application/merge-patch+json" + +EARLIER_ME = "/api/user/v1/me" +EARLIER_LIST = "/api/user/v1/accounts" +EARLIER_SEARCH = "/api/user/v1/accounts/search_emails" + + +def earlier_detail(username): + """Return the version 1 member address of one account.""" + return f"/api/user/v1/accounts/{username}" + + +def by_username(rows): + """ + Return the given rows in username order. + + The earlier addresses answer in whatever order the database returns the + accounts, so rows are brought into one order before their properties are + compared with this version's. The order a page comes back in is a + difference of its own and is pinned by the tests named for it. + """ + return sorted(rows, key=lambda row: row["username"]) + + +#: Differences each case is expected to show, as (json path, reason) pairs: the +#: diff must carry all of them and nothing else. A difference in the shape of a +#: body, or in the order of the rows it carries, is not a path and is pinned by +#: the test named for it instead. +INTENTIONAL_DIFFERENCES = { + "account_list": [], + "account_detail": [], + "shared_account": [], + "another_account": [], + "changed_account": [], + "email_search": [], +} + +#: Names the member address of this version reads as something other than a +#: username, with what each one names instead and what that address answers for +#: an account holding it. The earlier member address answers every one of them +#: with that account; here each is read through the collection, with +#: ``username=`` naming it. +RESERVED_MEMBER_IDENTIFIERS = ( + ("me", "the account of the caller", status.HTTP_200_OK), +) + + +@skip_unless_lms +class AccountParityTests(APITestCase): + """The same fixtures through the version 1 and the version 2 account addresses.""" + + def setUp(self): + super().setUp() + self.staff = UserFactory.create( + username="parity-staff", email="parity-staff@example.com", password=PASSWORD, is_staff=True, + ) + self.owner = UserFactory.create( + username="parity-owner", email="parity-owner@example.com", password=PASSWORD, + ) + self.other = UserFactory.create( + username="parity-other", email="parity-other@example.com", password=PASSWORD, + ) + + def login(self, user): + """Sign the given user in, which both versions authenticate the same way.""" + assert self.client.login(username=user.username, password=PASSWORD) + + def bodies(self, user, earlier_address, address, params=None): + """ + Return the decoded body each version answers the same request with. + + Both are read inside one session, because signing in again would stamp + a new last-login time on the account and so on the second body. + """ + self.login(user) + earlier = self.client.get(earlier_address, params or {}) + current = self.client.get(address, params or {}) + self.client.logout() + assert earlier.status_code == status.HTTP_200_OK, earlier.content + assert current.status_code == status.HTTP_200_OK, current.content + return json.loads(earlier.content), json.loads(current.content) + + def assert_parity(self, earlier, current, case): + """Assert the two bodies differ only where the case declares they do.""" + changed = diff(earlier, current) + intentional = INTENTIONAL_DIFFERENCES[case] + assert not unexpected(changed, intentional), ( + f"undeclared differences: {sorted(unexpected(changed, intentional))}\n" + f"earlier={earlier}\ncurrent={current}" + ) + for path, reason in intentional: + assert any( + key == path or key.startswith(path + ".") or key.startswith(path + "[") + for key in changed + ), f"declared difference did not occur: {path} ({reason})" + + def test_the_callers_own_address_answers_with_the_whole_account(self): + """Declared difference: the earlier address answers with the username alone.""" + self.login(self.owner) + earlier = json.loads(self.client.get(EARLIER_ME).content) + current = json.loads( + self.client.get(reverse("user_v2:account_detail", kwargs={"username": "me"})).content, + ) + named = json.loads( + self.client.get( + reverse("user_v2:account_detail", kwargs={"username": "parity-owner"}), + ).content, + ) + self.client.logout() + + assert earlier == {"username": "parity-owner"} + assert current["username"] == earlier["username"] + assert current["email"] == "parity-owner@example.com" + assert current == named + assert set(current) > set(earlier) + + def test_the_earlier_own_address_body_is_the_selection_of_one_property(self): + """The property the earlier address answered with is one selection of this one.""" + earlier, current = self.bodies( + self.owner, + EARLIER_ME, + reverse("user_v2:account_detail", kwargs={"username": "me"}), + {"fields": "username"}, + ) + assert earlier == current == {"username": "parity-owner"} + + def test_account_list_parity(self): + earlier, current = self.bodies( + self.staff, EARLIER_LIST, reverse("user_v2:account_list"), + {"username": "parity-other,parity-owner"}, + ) + assert isinstance(earlier, list) + assert len(earlier) == 2 + self.assert_parity(earlier, current["results"], "account_list") + + def test_the_page_carries_the_rows_the_earlier_list_returned_unbounded(self): + """ + Differences this version declares: the rows come in a page, and in username order. + + The earlier address answered with a bare array of every account named, + in the order the database returned them; here they are the page of the + envelope each list of this version answers with, ordered by username, + so the same selection asked for twice is cut into the same pages. + """ + earlier, current = self.bodies( + self.staff, EARLIER_LIST, reverse("user_v2:account_list"), + {"username": "parity-other,parity-owner"}, + ) + assert isinstance(earlier, list) + assert {account["username"] for account in earlier} == {"parity-other", "parity-owner"} + assert set(current) == { + "count", "num_pages", "current_page", "start", "next", "previous", "results", + } + assert current["count"] == len(earlier) + assert [account["username"] for account in current["results"]] == [ + "parity-other", "parity-owner", + ] + + def test_an_account_whose_name_this_version_reserves_is_read_through_the_collection(self): + """Declared difference: a reserved name addresses something else here.""" + self.login(self.staff) + for name, _names_instead, member_status in RESERVED_MEMBER_IDENTIFIERS: + held = UserFactory.create( + username=name, email=f"{name}@example.com", password=PASSWORD, + ) + earlier = self.client.get(earlier_detail(name)) + assert earlier.status_code == status.HTTP_200_OK, earlier.content + assert json.loads(earlier.content)["username"] == name + + member = self.client.get(reverse("user_v2:account_detail", kwargs={"username": name})) + assert member.status_code == member_status + assert json.loads(member.content).get("username") != name + + page = self.client.get(reverse("user_v2:account_list"), {"username": name}) + assert page.status_code == status.HTTP_200_OK + assert [row["username"] for row in json.loads(page.content)["results"]] == [name] + assert json.loads(page.content)["results"][0]["email"] == held.email + self.client.logout() + + def test_a_selection_that_matches_no_account_is_an_empty_page(self): + """Declared difference: a collection that matched nothing is empty rather than absent.""" + self.login(self.staff) + earlier = self.client.get(EARLIER_LIST, {"username": "nobody"}) + current = self.client.get(reverse("user_v2:account_list"), {"username": "nobody"}) + self.client.logout() + + assert earlier.status_code == status.HTTP_404_NOT_FOUND + assert earlier.content == b"" + assert current.status_code == status.HTTP_200_OK + assert json.loads(current.content)["count"] == 0 + assert json.loads(current.content)["results"] == [] + + def test_account_detail_parity_for_the_owner(self): + earlier, current = self.bodies( + self.owner, + earlier_detail("parity-owner"), + reverse("user_v2:account_detail", kwargs={"username": "parity-owner"}), + ) + self.assert_parity(earlier, current, "account_detail") + assert current["email"] == "parity-owner@example.com" + + def test_account_detail_parity_for_a_staff_caller(self): + earlier, current = self.bodies( + self.staff, + earlier_detail("parity-owner"), + reverse("user_v2:account_detail", kwargs={"username": "parity-owner"}), + ) + self.assert_parity(earlier, current, "account_detail") + + def test_the_properties_another_learner_may_see_are_unchanged(self): + """The field-level visibility is the whole of the record protection on this resource.""" + earlier, current = self.bodies( + self.owner, + earlier_detail("parity-other"), + reverse("user_v2:account_detail", kwargs={"username": "parity-other"}), + ) + assert set(earlier) == {"account_privacy", "profile_image", "username"} + self.assert_parity(earlier, current, "another_account") + + def test_the_shared_preset_is_unchanged(self): + earlier, current = self.bodies( + self.owner, + earlier_detail("parity-owner"), + reverse("user_v2:account_detail", kwargs={"username": "parity-owner"}), + {"view": "shared"}, + ) + assert set(earlier) == {"account_privacy", "profile_image", "username"} + self.assert_parity(earlier, current, "shared_account") + + def test_the_account_a_change_answers_with_is_unchanged(self): + self.login(self.owner) + earlier = self.client.patch( + earlier_detail("parity-owner"), json.dumps({"bio": "parity bio"}), content_type=MERGE_PATCH, + ) + current = self.client.patch( + reverse("user_v2:account_detail", kwargs={"username": "parity-owner"}), + json.dumps({"bio": "parity bio"}), + content_type=MERGE_PATCH, + ) + assert earlier.status_code == status.HTTP_200_OK, earlier.content + assert current.status_code == status.HTTP_200_OK, current.content + self.assert_parity(json.loads(earlier.content), json.loads(current.content), "changed_account") + assert json.loads(current.content)["bio"] == "parity bio" + + def test_email_search_parity(self): + emails = {"emails": ["parity-other@example.com", "parity-owner@example.com"]} + self.login(self.staff) + earlier = self.client.post(EARLIER_SEARCH, emails, format="json") + current = self.client.post(reverse("user_v2:email_search_list"), emails, format="json") + assert earlier.status_code == status.HTTP_200_OK, earlier.content + assert current.status_code == status.HTTP_200_OK, current.content + earlier_rows = by_username(json.loads(earlier.content)) + self.assert_parity(earlier_rows, json.loads(current.content)["results"], "email_search") + assert earlier_rows[0]["email"] == "parity-other@example.com" + + def test_the_search_page_carries_the_rows_the_earlier_search_returned_unbounded(self): + """ + Differences this version declares: the rows come in a page, and in username order. + + The earlier address answered with a bare array of every account it + found, in the order the database returned them; here they are the page + of the envelope each list of this version answers with, ordered by + username, so the same search asked for twice is cut into the same + pages. + """ + emails = {"emails": [ + "parity-other@example.com", "parity-owner@example.com", "parity-staff@example.com", + ]} + self.login(self.staff) + earlier = json.loads(self.client.post(EARLIER_SEARCH, emails, format="json").content) + current = json.loads( + self.client.post(reverse("user_v2:email_search_list"), emails, format="json").content, + ) + self.client.logout() + + assert isinstance(earlier, list) + assert {row["username"] for row in earlier} == { + "parity-other", "parity-owner", "parity-staff", + } + assert set(current) == { + "count", "num_pages", "current_page", "start", "next", "previous", "results", + } + assert current["count"] == len(earlier) + assert [row["username"] for row in current["results"]] == [ + "parity-other", "parity-owner", "parity-staff", + ] + assert current["next"] is None + + +@skip_unless_lms +class AccountRefusalParityTests(APITestCase): + """The refusals each version answers with, recorded beside each other.""" + + def setUp(self): + super().setUp() + self.staff = UserFactory.create( + username="refusal-staff", email="refusal-staff@example.com", password=PASSWORD, is_staff=True, + ) + self.owner = UserFactory.create( + username="refusal-owner", email="refusal-owner@example.com", password=PASSWORD, + ) + self.other = UserFactory.create( + username="refusal-other", email="refusal-other@example.com", password=PASSWORD, + ) + self.member = reverse("user_v2:account_detail", kwargs={"username": "refusal-owner"}) + + def login(self, user): + """Sign the given user in.""" + assert self.client.login(username=user.username, password=PASSWORD) + + def test_an_unauthenticated_refusal_keeps_its_status_and_changes_its_body(self): + earlier = self.client.get(EARLIER_LIST) + assert earlier.status_code == status.HTTP_401_UNAUTHORIZED + assert json.loads(earlier.content) == { + "detail": "Authentication credentials were not provided.", + } + + current = self.client.get(reverse("user_v2:account_list")) + envelope = assert_error_envelope( + current, expected_status=status.HTTP_401_UNAUTHORIZED, expected_type_slug="authn", + ) + assert envelope["title"] == "Authentication Required" + assert envelope["instance"] == "/api/user/v2/accounts/" + + def test_a_selection_no_account_matches_is_a_page_here_and_was_a_refusal_before(self): + """ + Selecting by an address nobody holds narrows the page to nothing instead of refusing. + + The earlier address answered one collection two ways: a name it found + nothing behind left the page empty, while an address it found nothing + behind was refused. Here every selector narrows the same way, and the + refusal is kept for an address an account held before it was retired. + """ + self.login(self.staff) + selection = {"email": "nobody@example.com"} + earlier = self.client.get(EARLIER_LIST, selection) + assert earlier.status_code == status.HTTP_404_NOT_FOUND + + current = self.client.get(reverse("user_v2:account_list"), selection) + assert current.status_code == status.HTTP_200_OK + body = json.loads(current.content) + assert body["count"] == 0 + assert body["results"] == [] + + def test_a_name_no_account_holds_keeps_its_status_and_gains_a_body(self): + self.login(self.staff) + earlier = self.client.get(earlier_detail("nobody")) + assert earlier.status_code == status.HTTP_404_NOT_FOUND + assert earlier.content == b"" + + current = self.client.get(reverse("user_v2:account_detail", kwargs={"username": "nobody"})) + envelope = assert_error_envelope( + current, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + assert envelope["detail"] == "Not found." + + def test_changing_another_account_keeps_its_status_and_gains_a_body(self): + self.login(self.owner) + earlier = self.client.patch( + earlier_detail("refusal-other"), json.dumps({"bio": "x"}), content_type=MERGE_PATCH, + ) + assert earlier.status_code == status.HTTP_403_FORBIDDEN + assert earlier.content == b"" + + current = self.client.patch( + reverse("user_v2:account_detail", kwargs={"username": "refusal-other"}), + json.dumps({"bio": "x"}), + content_type=MERGE_PATCH, + ) + assert_error_envelope( + current, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + + def test_a_refused_change_names_the_same_property_in_a_body_of_another_shape(self): + self.login(self.owner) + body = json.dumps({"year_of_birth": "recently"}) + earlier = self.client.patch(earlier_detail("refusal-owner"), body, content_type=MERGE_PATCH) + assert earlier.status_code == status.HTTP_400_BAD_REQUEST + assert json.loads(earlier.content) == { + "field_errors": { + "year_of_birth": { + "developer_message": "Value 'recently' is not valid for field 'year_of_birth': " + "['A valid integer is required.']", + "user_message": "This value is invalid.", + }, + }, + } + + current = self.client.patch(self.member, body, content_type=MERGE_PATCH) + envelope = assert_error_envelope( + current, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + # Both answers name the property and say the value is not a year. The + # earlier one says why inside its developer message and keeps a fixed + # sentence for the reader; this one says it once, where a reader looks. + assert envelope["errors"] == {"year_of_birth": ["A valid integer is required."]} + + def test_a_body_in_another_media_type_keeps_its_status_and_gains_a_body(self): + self.login(self.owner) + earlier = self.client.patch( + earlier_detail("refusal-owner"), json.dumps({"bio": "x"}), content_type="application/json", + ) + assert earlier.status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE + + current = self.client.patch(self.member, json.dumps({"bio": "x"}), content_type="application/json") + assert_error_envelope( + current, + expected_status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + expected_type_slug=UNSUPPORTED_MEDIA_TYPE_SLUG, + ) + + def test_the_search_hides_itself_from_a_learner_in_both_versions(self): + self.login(self.owner) + emails = {"emails": ["refusal-owner@example.com"]} + earlier = self.client.post(EARLIER_SEARCH, emails, format="json") + assert earlier.status_code == status.HTTP_404_NOT_FOUND + assert json.loads(earlier.content) == { + "developer_message": "not_found", "user_message": "Not Found", + } + + current = self.client.post(reverse("user_v2:email_search_list"), emails, format="json") + assert_error_envelope( + current, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + + def test_the_search_hides_itself_from_an_unconfirmed_learner_in_both_versions(self): + """The answer is the same whether the account signs in with a session or a token.""" + self.owner.is_active = False + self.owner.save() + emails = {"emails": ["refusal-other@example.com"]} + token = {"HTTP_AUTHORIZATION": f"JWT {create_jwt_for_user(self.owner)}"} + for transport in ("session", "token"): + with self.subTest(transport=transport): + headers = token if transport == "token" else {} + if transport == "session": + self.login(self.owner) + earlier = self.client.post(EARLIER_SEARCH, emails, format="json", **headers) + assert earlier.status_code == status.HTTP_404_NOT_FOUND, earlier.content + current = self.client.post(reverse("user_v2:email_search_list"), emails, format="json", **headers) + assert_error_envelope( + current, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + self.client.logout() + + def test_a_search_without_addresses_keeps_its_status_and_names_the_property(self): + self.login(self.staff) + earlier = self.client.post(EARLIER_SEARCH, {}, format="json") + assert earlier.status_code == status.HTTP_400_BAD_REQUEST + assert json.loads(earlier.content) == { + "developer_message": "'emails' field is required", + "user_message": "'emails' field is required", + } + + current = self.client.post(reverse("user_v2:email_search_list"), {}, format="json") + envelope = assert_error_envelope( + current, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == {"emails": ["This field is required."]} + + def test_a_search_longer_than_a_page_is_refused_here_and_was_answered_before(self): + """ + A difference this version declares: a search names at most a hundred addresses. + + The earlier address took as many addresses as a body could carry and + read them in one query; here a search is held to what the largest page + a caller may ask for can answer with. + """ + self.login(self.staff) + emails = {"emails": [f"crowd-{index:03d}@example.com" for index in range(101)]} + + earlier = self.client.post(EARLIER_SEARCH, emails, format="json") + assert earlier.status_code == status.HTTP_200_OK, earlier.content + assert json.loads(earlier.content) == [] + + current = self.client.post(reverse("user_v2:email_search_list"), emails, format="json") + envelope = assert_error_envelope( + current, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "emails": ["Ensure this field has no more than 100 elements."], + } + + def test_a_name_with_spaces_before_it_selects_no_account_in_either_version(self): + """Both versions match a name exactly as it was sent.""" + self.login(self.staff) + for asked in (" refusal-owner", "refusal-other, refusal-owner"): + with self.subTest(username=asked): + earlier = self.client.get(EARLIER_LIST, {"username": asked}) + current = self.client.get(reverse("user_v2:account_list"), {"username": asked}) + assert current.status_code == status.HTTP_200_OK + selected = [row["username"] for row in json.loads(current.content)["results"]] + if "," in asked: + assert earlier.status_code == status.HTTP_200_OK + assert [row["username"] for row in json.loads(earlier.content)] == ["refusal-other"] + assert selected == ["refusal-other"] + else: + assert earlier.status_code == status.HTTP_404_NOT_FOUND + assert selected == [] + + def test_an_address_with_spaces_before_it_selects_no_account_in_either_version(self): + """ + Both versions read the address exactly as it was sent. + + The earlier address looked it up and found nothing behind it; this + version refuses it as an address that is not well formed. + """ + self.login(self.staff) + asked = {"email": " refusal-owner@example.com"} + earlier = self.client.get(EARLIER_LIST, asked) + assert earlier.status_code == status.HTTP_404_NOT_FOUND + + current = self.client.get(reverse("user_v2:account_list"), asked) + envelope = assert_error_envelope( + current, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == {"email": ["Enter a valid email address."]} + + def test_a_searched_address_with_spaces_before_it_matches_no_account_in_either_version(self): + self.login(self.staff) + emails = {"emails": ["refusal-other@example.com", " refusal-owner@example.com"]} + earlier = self.client.post(EARLIER_SEARCH, emails, format="json") + assert earlier.status_code == status.HTTP_200_OK + assert [row["username"] for row in json.loads(earlier.content)] == ["refusal-other"] + + current = self.client.post(reverse("user_v2:email_search_list"), emails, format="json") + assert current.status_code == status.HTTP_200_OK + assert [row["username"] for row in json.loads(current.content)["results"]] == ["refusal-other"] + + def test_an_address_that_is_not_well_formed_is_refused_here_and_found_nothing_before(self): + """A difference this version declares: a malformed address is refused rather than looked up.""" + self.login(self.staff) + asked = {"email": "notanemail"} + earlier = self.client.get(EARLIER_LIST, asked) + assert earlier.status_code == status.HTTP_404_NOT_FOUND + assert earlier.content == b"" + + current = self.client.get(reverse("user_v2:account_list"), asked) + envelope = assert_error_envelope( + current, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == {"email": ["Enter a valid email address."]} + + def test_a_preset_this_version_does_not_offer_is_refused_here_and_ignored_before(self): + """ + A difference this version declares: an unknown ``view`` is refused rather than ignored. + + The earlier addresses answered any value other than ``shared`` with the + whole account, so a mistyped preset returned more than was asked for. + """ + self.login(self.owner) + refusal = { + "view": ["Ask for any of: minimal, shared, or omit this parameter for the default response."], + } + for earlier_address, address in ( + (EARLIER_LIST, reverse("user_v2:account_list")), + (earlier_detail("refusal-owner"), self.member), + ): + with self.subTest(address=address): + earlier = self.client.get(earlier_address, {"view": "bogus"}) + assert earlier.status_code == status.HTTP_200_OK + rows = json.loads(earlier.content) + rows = rows if isinstance(rows, list) else [rows] + assert [(row["username"], row["email"]) for row in rows] == [ + ("refusal-owner", "refusal-owner@example.com"), + ] + + current = self.client.get(address, {"view": "bogus"}) + envelope = assert_error_envelope( + current, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == refusal + + def test_the_address_of_a_retired_account_keeps_what_the_earlier_body_carried(self): + self.login(self.staff) + retired_email = self.other.email + self.other.email = get_retired_email_by_email(retired_email) + self.other.save() + + earlier = self.client.get(EARLIER_LIST, {"email": retired_email}) + assert earlier.status_code == status.HTTP_404_NOT_FOUND + earlier_body = json.loads(earlier.content) + assert set(earlier_body) == {"error_msg", "can_cancel_retirement", "retirement_id"} + + current = self.client.get(reverse("user_v2:account_list"), {"email": retired_email}) + envelope = assert_error_envelope( + current, + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="user/retired-account", + ) + assert envelope["detail"] == earlier_body["error_msg"] + assert envelope["can_cancel_retirement"] == earlier_body["can_cancel_retirement"] + assert envelope["retirement_id"] == earlier_body["retirement_id"] + + +@skip_unless_lms +class DeactivationParityTests(APITestCase): + """The deactivation of an account, through each version.""" + + def setUp(self): + super().setUp() + self.deactivator = UserFactory.create(username="parity-deactivator", password=PASSWORD) + self.deactivator.user_permissions.add( + PermissionFactory( + codename="can_deactivate_users", + content_type=ContentTypeFactory(app_label="student"), + ), + ) + self.earlier_target = UserFactory.create(username="parity-target-one", password=PASSWORD) + self.target = UserFactory.create(username="parity-target-two", password=PASSWORD) + assert self.client.login(username=self.deactivator.username, password=PASSWORD) + + def test_both_versions_make_the_password_unusable(self): + earlier = self.client.post(f"/api/user/v1/accounts/{self.earlier_target.username}/deactivate/") + assert earlier.status_code == status.HTTP_200_OK + assert json.loads(earlier.content)["username"] == "parity-target-one" + self.earlier_target.refresh_from_db() + assert not self.earlier_target.has_usable_password() + + current = self.client.post( + reverse("user_v2:account_deactivation", kwargs={"username": self.target.username}), + ) + assert current.status_code == status.HTTP_204_NO_CONTENT + assert current.content == b"" + self.target.refresh_from_db() + assert not self.target.has_usable_password() + + def test_the_reserved_identifier_names_the_same_account_on_both_addresses(self): + """``me`` is a stored username on this action in both versions, not a name for the caller.""" + held = UserFactory.create(username="me", password=PASSWORD) + earlier = self.client.post("/api/user/v1/accounts/me/deactivate/") + assert earlier.status_code == status.HTTP_200_OK + assert json.loads(earlier.content)["username"] == "me" + held.refresh_from_db() + assert not held.has_usable_password() + + held.set_password(PASSWORD) + held.save() + current = self.client.post(reverse("user_v2:account_deactivation", kwargs={"username": "me"})) + assert current.status_code == status.HTTP_204_NO_CONTENT + held.refresh_from_db() + assert not held.has_usable_password() + self.deactivator.refresh_from_db() + assert self.deactivator.has_usable_password() + + def test_the_account_is_readable_after_it_is_deactivated(self): + """The account the earlier version answered with is one read away.""" + self.client.post( + reverse("user_v2:account_deactivation", kwargs={"username": self.target.username}), + ) + account = self.client.get( + reverse("user_v2:account_detail", kwargs={"username": self.target.username}), + ) + assert account.status_code == status.HTTP_200_OK + assert json.loads(account.content)["username"] == "parity-target-two" + + def test_a_name_no_account_holds_is_not_found(self): + """An address naming no account is refused with the envelope every refusal carries.""" + current = self.client.post( + reverse("user_v2:account_deactivation", kwargs={"username": "nobody"}), + ) + assert_error_envelope( + current, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_account_query_counts.py b/openedx/core/djangoapps/user_api/v2/tests/test_account_query_counts.py new file mode 100644 index 000000000000..ab67874f3bb1 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_account_query_counts.py @@ -0,0 +1,410 @@ +"""Query-count guards for the version 2 User API account addresses.""" + +import json + +import pytest +from django.urls import reverse + +from common.djangoapps.student.tests.factories import ( + ContentTypeFactory, + PermissionFactory, + UserFactory, +) +from openedx.core.djangoapps.user_api.accounts import ( + ACCOUNT_VISIBILITY_PREF_KEY, + ALL_USERS_VISIBILITY, + CUSTOM_VISIBILITY, + VISIBILITY_PREFIX, +) +from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory +from openedx.core.djangolib.testing.utils import skip_unless_lms + +PASSWORD = "Password1234" +MERGE_PATCH = "application/merge-patch+json" + +#: Collection sizes a per-account query would show up at. +ROW_COUNTS = [1, 5, 25] + +#: Page sizes, so that what a page costs can be told apart from what the +#: collection behind it costs. +PAGE_SIZES = [1, 5, 10] + +#: Year of birth the measured accounts carry. An account that records none +#: counts as a minor, and what a caller may see of it is settled by that +#: before any preference of its owner is read, which is fewer reads than a +#: populated account costs. +MEASURED_YEAR_OF_BIRTH = 1990 + +#: What one page of accounts costs a staff caller, by the number of accounts +#: on it: a constant part of sixteen, and six reads for each account the page +#: carries. The account service selects the accounts in one query and then +#: builds each one on its own, reading its recovery address, its languages, +#: its links, its pending name change, one stored preference and the switch +#: that says whether a recovery address is offered at all. The page pays none +#: of that for the accounts it leaves out, which is what these numbers pin. +ACCOUNT_LIST_QUERIES = {1: 22, 5: 46, 10: 76} + +#: The same page read by a learner, who may see of each account only what its +#: owner shares: the six reads above and the stored preference that says how +#: much that is, seven an account. +LEARNER_LIST_QUERIES = {1: 23, 5: 51, 10: 86} + +#: The same page where every owner has chosen property by property what they +#: share: the seven reads above and the preferences that choice is written in, +#: read once for the account rather than once for each property, eight an +#: account. A page carries at most a hundred accounts, so this is the dearest +#: page the list answers with. +CHOSEN_VISIBILITY_LIST_QUERIES = {1: 24, 5: 56, 10: 96} + +ACCOUNT_DETAIL_QUERIES = 20 +OWN_ACCOUNT_QUERIES = 20 + +#: Address of the one this version answers with the caller's own account. It +#: answered with the username alone and read nothing of the account to do it, +#: so what the whole account costs is what this version's answer costs more. +#: Selecting the one property the earlier body carried shapes that answer; it +#: does not make it cheaper, because the account is built before it is shaped. +#: The earlier address is read first below, so the reads a request pays once +#: are counted against it and the difference measured is the smaller one. +EARLIER_OWN_ACCOUNT = "/api/user/v1/me" +EARLIER_OWN_ACCOUNT_QUERIES = 13 +#: A search is answered a page at a time, which costs one read for the page of +#: rows and one for the number of rows there are to page through, whatever the +#: addresses asked about. +EMAIL_SEARCH_QUERIES = 15 + +#: Address of the account list this version replaces, which has no pages: it +#: serves every account named in one unbounded response and pays the per-account +#: cost above for all of them. +EARLIER_ACCOUNT_LIST = "/api/user/v1/accounts" +EARLIER_ACCOUNT_LIST_QUERIES = {1: 20, 5: 44, 25: 164} + +#: What a change of one property costs, measured on the version 1 address +#: first. Both versions do the same work and answer with the account as it now +#: stands, so the one number is asserted on both addresses. +CHANGE_QUERIES = 30 + +#: What a deactivation costs on each address. The earlier one answers with the +#: account it deactivated and pays for reading it back; this one answers with +#: nothing, so it is cheaper by exactly those reads. +EARLIER_DEACTIVATION_QUERIES = 24 +DEACTIVATION_QUERIES = 18 + + +def measured_accounts(count): + """Return ``count`` accounts carrying the profile data a populated account holds.""" + return [ + UserFactory.create( + username=f"counted{index:03d}", + email=f"counted{index:03d}@example.com", + profile__year_of_birth=MEASURED_YEAR_OF_BIRTH, + ) + for index in range(count) + ] + + +def warm_up(client): + """ + Spend what a session pays once, so the counts are the steady-state ones. + + The first authenticated request of a session populates caches the rest of + the session reuses. + """ + client.get(reverse("user_v2:account_list")) + + +@pytest.fixture(name="accounts") +@skip_unless_lms +def accounts_fixture(request, client, django_user_model): # pylint: disable=unused-argument + """Sign a staff caller in and give the site ``request.param`` other accounts.""" + staff = UserFactory.create( + username="count-staff", + password=PASSWORD, + is_staff=True, + profile__year_of_birth=MEASURED_YEAR_OF_BIRTH, + ) + users = measured_accounts(request.param) + assert client.login(username=staff.username, password=PASSWORD) + warm_up(client) + return users + + +@pytest.fixture(name="accounts_read_by_a_learner") +@skip_unless_lms +def learner_accounts_fixture(request, client, django_user_model): # pylint: disable=unused-argument + """Sign a learner in beside ``request.param`` accounts belonging to other people.""" + learner = UserFactory.create( + username="count-learner", + password=PASSWORD, + profile__year_of_birth=MEASURED_YEAR_OF_BIRTH, + ) + users = measured_accounts(request.param) + assert client.login(username=learner.username, password=PASSWORD) + warm_up(client) + return users + + +@pytest.fixture(name="accounts_shared_by_choice") +@skip_unless_lms +def chosen_visibility_fixture(request, client, django_user_model): # pylint: disable=unused-argument + """Sign a learner in beside accounts whose owners chose property by property what they share.""" + learner = UserFactory.create( + username="count-learner", + password=PASSWORD, + profile__year_of_birth=MEASURED_YEAR_OF_BIRTH, + ) + users = measured_accounts(request.param) + for user in users: + UserPreferenceFactory.create( + user=user, key=ACCOUNT_VISIBILITY_PREF_KEY, value=CUSTOM_VISIBILITY, + ) + UserPreferenceFactory.create( + user=user, key=f"{VISIBILITY_PREFIX}bio", value=ALL_USERS_VISIBILITY, + ) + assert client.login(username=learner.username, password=PASSWORD) + warm_up(client) + return users + + +def selected(users): + """Return the selector naming every account of the fixture.""" + return ",".join(user.username for user in users) + + +@pytest.mark.django_db +@pytest.mark.parametrize("accounts", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_account_list_query_count_does_not_grow_with_the_accounts_selected( + client, accounts, django_assert_num_queries, +): + url = reverse("user_v2:account_list") + with django_assert_num_queries(ACCOUNT_LIST_QUERIES[1]): + response = client.get(url, {"username": selected(accounts), "page_size": 1}) + assert response.json()["count"] == len(accounts) + assert len(response.json()["results"]) == 1 + + +@pytest.mark.django_db +@pytest.mark.parametrize("page_size", PAGE_SIZES) +@pytest.mark.parametrize("accounts", [25], indirect=True) +@skip_unless_lms +def test_the_page_costs_what_it_carries_and_not_what_the_collection_holds( + client, accounts, page_size, django_assert_num_queries, +): + url = reverse("user_v2:account_list") + with django_assert_num_queries(ACCOUNT_LIST_QUERIES[page_size]): + response = client.get(url, {"username": selected(accounts), "page_size": page_size}) + assert response.json()["count"] == 25 + assert len(response.json()["results"]) == page_size + + +@pytest.mark.django_db +@pytest.mark.parametrize("page_size", PAGE_SIZES) +@pytest.mark.parametrize("accounts_read_by_a_learner", [25], indirect=True) +@skip_unless_lms +def test_a_learner_reading_other_accounts_pays_for_the_page_and_not_the_collection( + client, accounts_read_by_a_learner, page_size, django_assert_num_queries, +): + """What a caller may see of an account is worked out for the accounts of the page alone.""" + url = reverse("user_v2:account_list") + with django_assert_num_queries(LEARNER_LIST_QUERIES[page_size]): + response = client.get( + url, {"username": selected(accounts_read_by_a_learner), "page_size": page_size}, + ) + assert response.json()["count"] == 25 + assert len(response.json()["results"]) == page_size + assert "email" not in response.json()["results"][0] + + +@pytest.mark.django_db +@pytest.mark.parametrize("page_size", PAGE_SIZES) +@pytest.mark.parametrize("accounts_shared_by_choice", [25], indirect=True) +@skip_unless_lms +def test_an_account_shared_property_by_property_costs_one_read_more( + client, accounts_shared_by_choice, page_size, django_assert_num_queries, +): + """An owner who chose what they share is asked for that choice once, not once per property.""" + url = reverse("user_v2:account_list") + with django_assert_num_queries(CHOSEN_VISIBILITY_LIST_QUERIES[page_size]): + response = client.get( + url, {"username": selected(accounts_shared_by_choice), "page_size": page_size}, + ) + assert response.json()["count"] == 25 + assert len(response.json()["results"]) == page_size + assert "bio" in response.json()["results"][0] + + +@pytest.mark.django_db +@pytest.mark.parametrize("accounts", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_the_earlier_account_list_pays_for_every_account_named( + client, accounts, django_assert_num_queries, +): + """ + Both lists are measured over the same accounts, so the saving cannot drift apart. + + The earlier address serves every account named in one response and pays + the per-account cost for all of them; this one pays it for the accounts of + the page alone. + """ + # The two pins together say that the earlier cost grows with the accounts + # named while the page's does not, so neither can be changed on its own. + assert EARLIER_ACCOUNT_LIST_QUERIES[25] > EARLIER_ACCOUNT_LIST_QUERIES[1] + assert ACCOUNT_LIST_QUERIES[1] < EARLIER_ACCOUNT_LIST_QUERIES[25] + + with django_assert_num_queries(EARLIER_ACCOUNT_LIST_QUERIES[len(accounts)]): + earlier = client.get(EARLIER_ACCOUNT_LIST, {"username": selected(accounts)}) + assert len(earlier.json()) == len(accounts) + + with django_assert_num_queries(ACCOUNT_LIST_QUERIES[1]): + current = client.get( + reverse("user_v2:account_list"), {"username": selected(accounts), "page_size": 1}, + ) + assert current.json()["count"] == len(accounts) + + +@pytest.mark.django_db +@pytest.mark.parametrize("accounts", [5], indirect=True) +@skip_unless_lms +def test_member_query_counts(client, accounts, django_assert_num_queries): + with django_assert_num_queries(ACCOUNT_DETAIL_QUERIES): + response = client.get(reverse("user_v2:account_detail", kwargs={"username": "counted000"})) + assert response.json()["username"] == "counted000" + + with django_assert_num_queries(OWN_ACCOUNT_QUERIES): + own = client.get(reverse("user_v2:account_detail", kwargs={"username": "me"})) + assert own.json()["username"] == "count-staff" + + +@pytest.mark.django_db +@pytest.mark.parametrize("accounts", [1], indirect=True) +@skip_unless_lms +def test_the_callers_own_account_costs_more_here_than_the_name_alone_did( + client, accounts, django_assert_num_queries, +): + """ + Declared difference: this address answers with the whole account. + + The earlier address answered with the username, which it had already, and + asking this one for that property alone shapes the answer rather than the + work behind it: the account is built and then reduced, so the selection + costs what the whole account costs. + """ + with django_assert_num_queries(EARLIER_OWN_ACCOUNT_QUERIES): + earlier = client.get(EARLIER_OWN_ACCOUNT) + assert earlier.json() == {"username": "count-staff"} + + with django_assert_num_queries(OWN_ACCOUNT_QUERIES): + whole = client.get(reverse("user_v2:account_detail", kwargs={"username": "me"})) + assert whole.json()["username"] == "count-staff" + assert set(whole.json()) > set(earlier.json()) + + with django_assert_num_queries(OWN_ACCOUNT_QUERIES): + one_property = client.get( + reverse("user_v2:account_detail", kwargs={"username": "me"}), {"fields": "username"}, + ) + assert one_property.json() == earlier.json() + assert OWN_ACCOUNT_QUERIES > EARLIER_OWN_ACCOUNT_QUERIES + + +@pytest.mark.django_db +@pytest.mark.parametrize("accounts", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_email_search_query_count_does_not_grow_with_the_addresses_searched( + client, accounts, django_assert_num_queries, +): + with django_assert_num_queries(EMAIL_SEARCH_QUERIES): + response = client.post( + reverse("user_v2:email_search_list"), + {"emails": [user.email for user in accounts]}, + content_type="application/json", + ) + assert response.json()["count"] == len(accounts) + + +@pytest.fixture(name="account_owner") +@skip_unless_lms +def account_owner_fixture(client, django_user_model): # pylint: disable=unused-argument + """Sign a learner in whose own account is the one changed.""" + owner = UserFactory.create( + username="count-change-owner", + password=PASSWORD, + profile__year_of_birth=MEASURED_YEAR_OF_BIRTH, + ) + assert client.login(username=owner.username, password=PASSWORD) + warm_up(client) + return owner + + +@pytest.fixture(name="deactivation_targets") +@skip_unless_lms +def deactivation_fixture(client, django_user_model): # pylint: disable=unused-argument + """Sign a caller in who may deactivate accounts, beside one account per version to deactivate.""" + deactivator = UserFactory.create(username="count-deactivator", password=PASSWORD) + deactivator.user_permissions.add( + PermissionFactory( + codename="can_deactivate_users", + content_type=ContentTypeFactory(app_label="student"), + ), + ) + targets = ( + UserFactory.create(username="count-target-one", password=PASSWORD), + UserFactory.create(username="count-target-two", password=PASSWORD), + ) + assert client.login(username=deactivator.username, password=PASSWORD) + warm_up(client) + return targets + + +@pytest.mark.django_db +@skip_unless_lms +def test_a_change_costs_what_the_earlier_address_costs( + client, account_owner, django_assert_num_queries, +): + """A property is changed for the same reads on both addresses, and each answers with the account.""" + with django_assert_num_queries(CHANGE_QUERIES): + earlier = client.patch( + f"/api/user/v1/accounts/{account_owner.username}", + json.dumps({"bio": "Earlier bio"}), + content_type=MERGE_PATCH, + ) + assert earlier.json()["bio"] == "Earlier bio" + + with django_assert_num_queries(CHANGE_QUERIES): + later = client.patch( + reverse("user_v2:account_detail", kwargs={"username": account_owner.username}), + json.dumps({"bio": "Later bio"}), + content_type=MERGE_PATCH, + ) + assert later.json()["bio"] == "Later bio" + + +@pytest.mark.django_db +@skip_unless_lms +def test_a_deactivation_costs_what_the_earlier_address_costs( + client, deactivation_targets, django_assert_num_queries, +): + """ + Each version is measured over an account of its own, so neither pays for the other's work. + + The earlier address answers with the account it deactivated; this one + answers with nothing, which is the difference between the two numbers. + """ + earlier_target, target = deactivation_targets + # The saving is the account the earlier address reads back to answer with, + # so neither number can be changed without the other. + assert DEACTIVATION_QUERIES < EARLIER_DEACTIVATION_QUERIES + + with django_assert_num_queries(EARLIER_DEACTIVATION_QUERIES): + earlier = client.post(f"/api/user/v1/accounts/{earlier_target.username}/deactivate/") + assert earlier.status_code == 200 + earlier_target.refresh_from_db() + assert not earlier_target.has_usable_password() + + with django_assert_num_queries(DEACTIVATION_QUERIES): + later = client.post( + reverse("user_v2:account_deactivation", kwargs={"username": target.username}), + ) + assert later.status_code == 204 + target.refresh_from_db() + assert not target.has_usable_password() diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py new file mode 100644 index 000000000000..91d10d6fe1c7 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py @@ -0,0 +1,3349 @@ +"""Tests for the version 2 User API account views.""" + +import datetime +import json +from unittest import mock +from urllib.parse import parse_qs, urlparse + +from django.conf import settings +from django.contrib.auth.models import User # pylint: disable=imported-auth-user +from django.db import DatabaseError, connection +from django.test.utils import CaptureQueriesContext, override_settings +from django.urls import reverse +from edx_rest_framework_extensions.scoping import FullScopePolicy +from edx_rest_framework_extensions.testing import assert_error_envelope +from rest_framework import status +from rest_framework.permissions import BasePermission +from rest_framework.test import APITestCase +from waffle.testutils import override_switch + +from common.djangoapps.student.models import PendingNameChange, UserProfile, get_retired_email_by_email +from common.djangoapps.student.tests.factories import ( + ContentTypeFactory, + PermissionFactory, + SuperuserFactory, + UserFactory, +) +from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user +from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration_context +from openedx.core.djangoapps.user_api.accounts.utils import ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH +from openedx.core.djangoapps.user_api.models import RetirementState, UserRetirementStatus +from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory +from openedx.core.djangoapps.user_api.v2 import views +from openedx.core.djangoapps.user_api.v2.permissions import ACCOUNT_CONTACT_SELECTORS +from openedx.core.djangoapps.user_api.v2.serializers import ACCOUNT_DEFAULT_SORT, AccountQuerySerializer +from openedx.core.djangoapps.user_api.v2.views import AMBIGUOUS_ACCOUNT_ADDRESS, EmailSearchViewSet +from openedx.core.djangoapps.user_authn.views.password_reset import PasswordResetFormNoActive +from openedx.core.djangolib.testing.utils import skip_unless_lms + +from .test_schema import name_affirmation_answering +from .test_views import ( + MALFORMED_BODY_SLUG, + MALFORMED_BODY_TITLE, + PAGINATION_KEYS, + REFUSED_METHOD_SLUG, + UNSUPPORTED_MEDIA_TYPE_SLUG, + write_queries, + write_target, +) + +PASSWORD = "Password1234" + +#: The properties every caller may see, whoever they are. +PUBLIC_FIELDS = {"account_privacy", "profile_image", "username"} + +#: ``ACCOUNT_VISIBILITY_CONFIGURATION`` is an LMS setting, and this package is +#: shared with the CMS. Every test below that reads these sets is +#: ``@skip_unless_lms``, but a module body runs before any skip can apply, so a +#: missing setting is tolerated here instead of breaking collection under the +#: CMS settings. The same guard is used for the same setting in +#: ``user_api/accounts/tests/test_views.py``. +_VISIBILITY = getattr(settings, "ACCOUNT_VISIBILITY_CONFIGURATION", {}) + +#: The properties a user sees of their own account, and a staff caller of any. +FULL_FIELDS = set(_VISIBILITY.get("admin_fields", ())) + +#: The properties a learner sees of an account whose owner shares it with everybody. +SHARED_FIELDS = set(_VISIBILITY.get("bulk_shareable_fields", ())) + +#: The properties an owner is able to share, and so the only ones another caller +#: may select. A refusal about somebody else's account names none of them; one +#: about an answer the caller narrowed itself offers them in this order. +SHAREABLE_FIELDS = set(_VISIBILITY.get("custom_shareable_fields", ())) +SHAREABLE_FIELDS_OFFERED = ", ".join(sorted(SHAREABLE_FIELDS)) + +#: The identifying properties the ``minimal`` preset keeps. +MINIMAL_FIELDS = {"username", "email", "name", "profile_image"} + +#: The query parameters each account action reads, in the order its refusal lists them. +LIST_QUERY_PARAMS = "email, fields, format, lms_user_id, ordering, page, page_size, username, view" +MEMBER_QUERY_PARAMS = "fields, format, view" +ACTION_QUERY_PARAMS = "format, page, page_size" + +#: A deactivation carries no page, so it reads only the representation parameter. +DEACTIVATION_QUERY_PARAMS = "format" + +MERGE_PATCH = "application/merge-patch+json" + +#: Where the classes the platform declares record that they read a request. +DEFAULT_AUTH_RECORDER = "openedx.core.djangolib.default_auth_classes.set_custom_attribute" + +#: Where the account views look an account up, which the database answers. A +#: test that stands in for the database answers it from here. +ACCOUNT_LOOKUP = "openedx.core.djangoapps.user_api.v2.views.get_account_settings" + +class RefusesEveryCaller(BasePermission): + """A rule that admits nobody, added to a declaration to see how far it reaches.""" + + def has_permission(self, request, view): + """Refuse every caller.""" + return False + + +def refusing_account_resource(): + """Add a rule admitting nobody to the classes the account resource is read with.""" + return mock.patch.object( + views, + "ACCOUNT_RESOURCE_PERMISSIONS", + (*views.ACCOUNT_RESOURCE_PERMISSIONS, RefusesEveryCaller), + ) + + +def counting_cache(location): + """ + Return a cache that counts what it is given, under a name of its own. + + The cache the test settings configure discards everything, so a rate limit + never counts past its first request and the path it guards is never + reached. Each test names its own cache, because the counts outlive the + test that made them. + """ + return { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": location, + }, + } + + +class AccountApiV2TestCase(APITestCase): + """Fixtures shared by the version 2 account tests.""" + + def setUp(self): + super().setUp() + self.staff = UserFactory.create( + username="staff-caller", email="staff-caller@example.com", password=PASSWORD, is_staff=True, + ) + self.owner = UserFactory.create( + username="owner-caller", email="owner-caller@example.com", password=PASSWORD, + ) + self.other = UserFactory.create( + username="other-caller", email="other-caller@example.com", password=PASSWORD, + ) + self.list_url = reverse("user_v2:account_list") + self.own_url = reverse("user_v2:account_detail", kwargs={"username": "me"}) + + def login(self, user): + """Sign the given user in through the session these views accept.""" + assert self.client.login(username=user.username, password=PASSWORD) + + def detail_url(self, username): + """Return the member address of one account.""" + return reverse("user_v2:account_detail", kwargs={"username": username}) + + def token_headers(self, user): + """Return the request headers that carry a token identifying the given user.""" + return {"HTTP_AUTHORIZATION": f"JWT {create_jwt_for_user(user)}"} + + def usernames_in(self, response): + """Return the usernames carried by a page of accounts.""" + return [row["username"] for row in response.data["results"]] + + def patch(self, address, body, **kwargs): + """Send a merge patch, the only body these views read.""" + return self.client.patch(address, body, content_type=MERGE_PATCH, **kwargs) + + +@skip_unless_lms +class AccountListViewTests(AccountApiV2TestCase): + """GET /api/user/v2/accounts/""" + + def test_a_caller_with_no_selector_receives_their_own_account(self): + self.login(self.owner) + response = self.client.get(self.list_url) + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] == 1 + assert self.usernames_in(response) == ["owner-caller"] + assert set(response.data["results"][0]) == FULL_FIELDS + + def test_the_page_carries_the_whole_envelope(self): + self.login(self.staff) + response = self.client.get(self.list_url) + assert set(response.data) == PAGINATION_KEYS + assert response.data["count"] == 1 + assert response.data["num_pages"] == 1 + assert response.data["current_page"] == 1 + assert response.data["start"] == 0 + assert response.data["next"] is None + assert response.data["previous"] is None + + def test_named_accounts_come_back_in_one_page(self): + self.login(self.staff) + response = self.client.get(self.list_url, {"username": "other-caller,owner-caller"}) + assert response.status_code == status.HTTP_200_OK + assert self.usernames_in(response) == ["other-caller", "owner-caller"] + + def test_the_page_is_ordered_by_username_and_the_order_can_be_reversed(self): + self.login(self.staff) + selection = {"username": "owner-caller,other-caller,staff-caller"} + ascending = self.client.get(self.list_url, selection) + assert self.usernames_in(ascending) == ["other-caller", "owner-caller", "staff-caller"] + + descending = self.client.get(self.list_url, {**selection, "ordering": "-username"}) + assert self.usernames_in(descending) == ["staff-caller", "owner-caller", "other-caller"] + + def test_the_page_is_sorted_by_username_when_no_order_is_asked_for(self): + """The order the parameter publishes as its default is the one a page comes back in.""" + self.login(self.staff) + selection = {"username": "owner-caller,other-caller,staff-caller"} + omitted = self.client.get(self.list_url, selection) + named = self.client.get(self.list_url, {**selection, "ordering": ACCOUNT_DEFAULT_SORT}) + blank = self.client.get(self.list_url, {**selection, "ordering": ""}) + assert self.usernames_in(omitted) == ["other-caller", "owner-caller", "staff-caller"] + assert self.usernames_in(named) == self.usernames_in(omitted) + assert self.usernames_in(blank) == self.usernames_in(omitted) + + def test_a_property_the_list_does_not_sort_by_is_refused(self): + """ + A sort the list does not offer is refused rather than quietly applied. + + Any other column of the user table would otherwise order the page, so + a caller who may read nothing but usernames could read the order of a + column they may not be answered with. + """ + self.login(self.staff) + for asked in ("bogus", "password", "date_joined", "-date_joined", "username,-username"): + with self.subTest(ordering=asked): + response = self.client.get( + self.list_url, {"username": "owner-caller,other-caller", "ordering": asked}, + ) + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "ordering": ["Sort by any of: username, each optionally prefixed with '-'."], + } + assert "results" not in envelope + + def test_a_name_sent_with_spaces_around_it_is_read_as_sent(self): + """A name is matched exactly as it was sent, so spaces around it name no account.""" + self.login(self.staff) + exact = self.client.get(self.list_url, {"username": "owner-caller"}) + assert self.usernames_in(exact) == ["owner-caller"] + for asked in (" owner-caller", "other-caller, owner-caller"): + with self.subTest(username=asked): + response = self.client.get(self.list_url, {"username": asked}) + assert response.status_code == status.HTTP_200_OK, response.content + assert self.usernames_in(response) == (["other-caller"] if "," in asked else []) + + def test_a_name_no_account_holds_is_left_out_rather_than_refused(self): + self.login(self.staff) + response = self.client.get(self.list_url, {"username": "owner-caller,nobody"}) + assert response.status_code == status.HTTP_200_OK + assert self.usernames_in(response) == ["owner-caller"] + + def test_an_account_the_lookup_resolved_a_name_to_is_carried_by_the_page(self): + """ + A page carries every account its lookup came back with. + + The rows are put back in the order the names were asked for by the name + each account is stored under. An account stored under a name none of + them is written as is carried at the end of the page rather than + dropped from it in silence. + """ + def one_account_written_apart(request, usernames=None, **kwargs): # pylint: disable=unused-argument + assert usernames == ["owner-caller"] + return [{"username": "owner-caller ", "email": "owner-caller@example.com"}] + + self.login(self.staff) + with mock.patch(ACCOUNT_LOOKUP, one_account_written_apart): + response = self.client.get(self.list_url, {"username": "owner-caller"}) + assert response.status_code == status.HTTP_200_OK + assert response.data["results"] == [ + {"username": "owner-caller ", "email": "owner-caller@example.com"}, + ] + + def test_a_selector_that_names_no_account_answers_an_empty_page(self): + """A collection that matched nothing is an empty page, not an absent collection.""" + self.login(self.staff) + response = self.client.get(self.list_url, {"username": "nobody"}) + assert response.status_code == status.HTTP_200_OK + assert set(response.data) == PAGINATION_KEYS + assert response.data["count"] == 0 + assert response.data["results"] == [] + assert response.data["num_pages"] == 1 + assert response.data["next"] is None + assert response.data["previous"] is None + + def test_an_empty_page_reads_no_account(self): + """Nothing was selected, so no account is read to fill the page.""" + self.login(self.owner) + with CaptureQueriesContext(connection) as captured: + response = self.client.get(self.list_url, {"username": "nobody"}) + assert response.data["results"] == [] + assert [query for query in captured.captured_queries if "auth_userprofile" in query["sql"]] == [] + + def test_a_learner_sees_only_the_shared_properties_of_another_account(self): + self.login(self.owner) + response = self.client.get(self.list_url, {"username": "other-caller"}) + assert response.status_code == status.HTTP_200_OK + assert set(response.data["results"][0]) == PUBLIC_FIELDS + assert response.data["results"][0]["username"] == "other-caller" + + def test_a_staff_caller_sees_every_property_of_another_account(self): + self.login(self.staff) + response = self.client.get(self.list_url, {"username": "other-caller"}) + assert set(response.data["results"][0]) == FULL_FIELDS + assert response.data["results"][0]["email"] == "other-caller@example.com" + + def test_a_page_mixing_the_caller_and_others_shapes_each_row_for_its_own_owner(self): + self.login(self.owner) + response = self.client.get(self.list_url, {"username": "owner-caller,other-caller"}) + rows = {row["username"]: set(row) for row in response.data["results"]} + assert rows == {"other-caller": PUBLIC_FIELDS, "owner-caller": FULL_FIELDS} + + def test_the_shared_preset_narrows_the_callers_own_account(self): + self.login(self.owner) + response = self.client.get(self.list_url, {"view": "shared"}) + assert set(response.data["results"][0]) == PUBLIC_FIELDS + + def test_the_minimal_preset_keeps_the_identifying_properties(self): + self.login(self.owner) + response = self.client.get(self.list_url, {"view": "minimal"}) + assert set(response.data["results"][0]) == {"username", "email", "name", "profile_image"} + + def test_the_minimal_preset_cannot_hand_out_a_property_the_caller_may_not_see(self): + self.login(self.owner) + response = self.client.get(self.list_url, {"username": "other-caller", "view": "minimal"}) + assert set(response.data["results"][0]) == {"username", "profile_image"} + + def test_a_learner_selecting_an_identifying_property_they_may_not_see_is_refused(self): + """The preset keeps the identifying properties; this caller may be answered with some of them.""" + self.login(self.owner) + response = self.client.get( + self.list_url, {"username": "other-caller", "view": "minimal", "fields": "email"}, + ) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"]["fields"] == [ + "No such field: email. An account another user holds carries only the " + "properties its owner shares.", + ] + + def test_a_caller_selects_an_identifying_property_of_their_own_account(self): + self.login(self.owner) + response = self.client.get(self.list_url, {"view": "minimal", "fields": "email"}) + assert response.status_code == status.HTTP_200_OK + assert response.data["results"] == [{"email": "owner-caller@example.com"}] + + def test_selected_fields_narrow_each_row(self): + self.login(self.owner) + response = self.client.get(self.list_url, {"fields": "username,email"}) + assert response.data["results"] == [ + {"username": "owner-caller", "email": "owner-caller@example.com"}, + ] + + def test_a_field_the_response_cannot_carry_is_refused(self): + self.login(self.owner) + response = self.client.get(self.list_url, {"fields": "username,wallet"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"]["fields"][0].startswith("No such field: wallet.") + + def test_a_learner_selecting_a_property_of_another_account_they_may_not_see_is_refused(self): + """A property this caller's page cannot carry is refused, not answered as an empty object.""" + self.login(self.owner) + response = self.client.get(self.list_url, {"username": "other-caller", "fields": "email"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"]["fields"] == [ + "No such field: email. An account another user holds carries only the " + "properties its owner shares.", + ] + + def test_a_learner_selecting_a_property_this_owner_does_not_share_receives_the_rest(self): + """ + A property an owner is able to share can be selected; whether they share it is their own choice. + + This owner shares nothing beyond the properties every caller may see, + so the row carries the rest of the selection rather than the property + asked for. + """ + self.login(self.owner) + response = self.client.get(self.list_url, {"username": "other-caller", "fields": "bio,username"}) + assert response.status_code == status.HTTP_200_OK + assert response.data["results"] == [{"username": "other-caller"}] + + def test_a_staff_caller_still_selects_a_property_only_they_may_see(self): + self.login(self.staff) + response = self.client.get(self.list_url, {"username": "other-caller", "fields": "email"}) + assert response.status_code == status.HTTP_200_OK + assert response.data["results"] == [{"email": "other-caller@example.com"}] + + def test_a_caller_still_selects_a_property_only_they_may_see_of_their_own_account(self): + self.login(self.owner) + response = self.client.get(self.list_url, {"username": "owner-caller", "fields": "email"}) + assert response.status_code == status.HTTP_200_OK + assert response.data["results"] == [{"email": "owner-caller@example.com"}] + + def test_a_property_the_shared_preset_leaves_out_is_refused_with_the_preset_asked_for(self): + """``shared`` answers with what the owner shares, so only those properties can be selected.""" + self.login(self.staff) + response = self.client.get( + self.list_url, {"username": "other-caller", "view": "shared", "fields": "email"}, + ) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"]["fields"] == [ + f"No such field: email. Select any of: {SHAREABLE_FIELDS_OFFERED}.", + ] + + def test_a_preset_the_endpoint_does_not_offer_is_refused(self): + self.login(self.owner) + response = self.client.get(self.list_url, {"view": "everything"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "view": ["Ask for any of: minimal, shared, or omit this parameter for the default response."], + } + + def test_a_query_parameter_the_endpoint_does_not_read_is_refused(self): + self.login(self.owner) + response = self.client.get(self.list_url, {"usernames": "owner-caller"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "usernames": [f"No such query parameter. Send any of: {LIST_QUERY_PARAMS}."], + } + + def test_a_page_that_does_not_exist_is_not_found(self): + self.login(self.staff) + response = self.client.get(self.list_url, {"page": 7}) + assert_error_envelope( + response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + + def test_the_collection_is_split_into_pages(self): + self.login(self.staff) + selection = {"username": "owner-caller,other-caller,staff-caller", "page_size": 2} + first = self.client.get(self.list_url, selection) + assert first.data["count"] == 3 + assert first.data["num_pages"] == 2 + assert self.usernames_in(first) == ["other-caller", "owner-caller"] + assert "page=2" in first.data["next"] + + second = self.client.get(self.list_url, {**selection, "page": 2}) + assert self.usernames_in(second) == ["staff-caller"] + assert second.data["start"] == 2 + + def test_an_anonymous_caller_is_unauthenticated(self): + response = self.client.get(self.list_url) + assert_error_envelope( + response, expected_status=status.HTTP_401_UNAUTHORIZED, expected_type_slug="authn", + ) + + +@skip_unless_lms +class AccountListSelectorTests(AccountApiV2TestCase): + """The selectors of GET /api/user/v2/accounts/ that only staff may use.""" + + def test_a_staff_caller_selects_an_account_by_email_address(self): + self.login(self.staff) + response = self.client.get(self.list_url, {"email": "other-caller@example.com"}) + assert response.status_code == status.HTTP_200_OK + assert self.usernames_in(response) == ["other-caller"] + + def test_a_staff_caller_selects_an_account_by_identifier(self): + self.login(self.staff) + response = self.client.get(self.list_url, {"lms_user_id": self.other.id}) + assert response.status_code == status.HTTP_200_OK + assert self.usernames_in(response) == ["other-caller"] + + def test_the_selectors_a_learner_may_not_use_are_the_ones_the_privilege_is_asked_for(self): + """Every selector the list documents as staff-only is one the permission classes gate.""" + documented = { + name for name, field in AccountQuerySerializer().fields.items() + if "Only staff" in (field.help_text or "") + } + assert documented == {"email", "lms_user_id"} + assert set(ACCOUNT_CONTACT_SELECTORS) == documented + + def test_a_staff_caller_selecting_an_address_more_than_one_account_holds_receives_them_all(self): + """ + A page carries every account holding the address it was given. + + Nothing keeps one email address to one account, so an address can be + held by more than one of them. The page of accounts holding an address + is what the search over addresses answers with as well, and it is the + answer here whether the address is held once or several times. + """ + UserFactory.create( + username="owner-second", email="owner-caller@example.com", password=PASSWORD, + ) + self.login(self.staff) + response = self.client.get(self.list_url, {"email": "owner-caller@example.com"}) + assert response.status_code == status.HTTP_200_OK, response.content + assert response.data["count"] == 2 + assert self.usernames_in(response) == ["owner-caller", "owner-second"] + + def test_a_learner_may_not_select_by_email_address(self): + self.login(self.owner) + response = self.client.get(self.list_url, {"email": "other-caller@example.com"}) + assert_error_envelope( + response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + + def test_a_learner_may_not_select_by_identifier(self): + self.login(self.owner) + response = self.client.get(self.list_url, {"lms_user_id": self.other.id}) + assert_error_envelope( + response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + + def test_a_learner_may_not_select_their_own_account_by_email_address_either(self): + """The selector is the staff-only part, whoever the account it names belongs to.""" + self.login(self.owner) + response = self.client.get(self.list_url, {"email": "owner-caller@example.com"}) + assert_error_envelope( + response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + + def test_every_selector_that_finds_no_account_answers_the_same_empty_page(self): + """ + A selection no account matches is an empty page, whichever selector was given. + + A caller filtering this collection reads one answer for a selection + that matched nothing, instead of having to tell an empty page from a + refusal by which parameter it sent. + """ + self.login(self.staff) + selections = ( + {"username": "nobody"}, + {"email": "nobody@example.com"}, + {"lms_user_id": 999999}, + ) + for selection in selections: + with self.subTest(**selection): + response = self.client.get(self.list_url, selection) + assert response.status_code == status.HTTP_200_OK, response.content + assert set(response.data) == PAGINATION_KEYS + assert response.data["count"] == 0 + assert response.data["results"] == [] + assert response.data["next"] is None + + def test_an_identifier_that_is_not_a_number_is_refused(self): + self.login(self.staff) + response = self.client.get(self.list_url, {"lms_user_id": "nope"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == {"lms_user_id": ["A valid integer is required."]} + + def test_an_address_that_is_not_an_email_address_is_refused(self): + self.login(self.staff) + response = self.client.get(self.list_url, {"email": "not-an-address"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == {"email": ["Enter a valid email address."]} + + def test_names_are_read_before_the_other_selectors(self): + self.login(self.staff) + response = self.client.get( + self.list_url, {"username": "owner-caller", "email": "other-caller@example.com"}, + ) + assert self.usernames_in(response) == ["owner-caller"] + + +@skip_unless_lms +class RetiredAccountTests(AccountApiV2TestCase): + """An email address an account held before it was retired.""" + + def setUp(self): + super().setUp() + self.login(self.staff) + self.retired_email = self.other.email + + def retire(self): + """Replace the account's address with its retired form, as retirement does.""" + self.other.email = get_retired_email_by_email(self.retired_email) + self.other.save() + + def test_the_address_of_a_retired_account_is_not_found(self): + self.retire() + response = self.client.get(self.list_url, {"email": self.retired_email}) + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="user/retired-account", + ) + assert envelope["title"] == "Retired Account" + assert envelope["can_cancel_retirement"] is False + assert envelope["retirement_id"] is None + + def test_reading_the_address_of_a_retired_account_writes_nothing(self): + """The record the refusal names is read, and the refusal leaves it as it was.""" + RetirementState.objects.create(state_name="PENDING", state_execution_order=1) + UserRetirementStatus.create_retirement(self.other) + self.retire() + with CaptureQueriesContext(connection) as captured: + with self.captureOnCommitCallbacks(execute=True): + response = self.client.get(self.list_url, {"email": self.retired_email}) + assert response.status_code == status.HTTP_404_NOT_FOUND + assert write_queries(captured.captured_queries) == [] + + def test_a_retirement_still_inside_the_cooling_off_period_can_be_cancelled(self): + RetirementState.objects.create(state_name="PENDING", state_execution_order=1) + retirement = UserRetirementStatus.create_retirement(self.other) + self.retire() + response = self.client.get(self.list_url, {"email": self.retired_email}) + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="user/retired-account", + ) + assert envelope["can_cancel_retirement"] is True + assert envelope["retirement_id"] == retirement.id + + def test_an_address_held_and_left_more_than_once_reports_the_most_recent_retirement(self): + """ + Where one address has been held and left more than once, the retirement reported is the last. + + Only the most recent retirement of an address is still open to being + undone, so that is the one the refusal names. The second account is + given the address outright rather than registered with it, because an + address an account held before it was retired is refused to a new one. + Each account here is this test's own: what retirement leaves behind of + an account cannot be shared with another test. + """ + RetirementState.objects.create(state_name="PENDING", state_execution_order=1) + address = "held-and-left-twice@example.com" + first = UserFactory.create(username="first-holder", email=address, password=PASSWORD) + earliest = UserRetirementStatus.create_retirement(first) + first.email = get_retired_email_by_email(address) + first.save() + second = UserFactory.create( + username="second-holder", email="second-holder@example.com", password=PASSWORD, + ) + User.objects.filter(pk=second.pk).update(email=address) + second.refresh_from_db() + latest = UserRetirementStatus.create_retirement(second) + second.email = get_retired_email_by_email(address) + second.save() + assert sorted( + UserRetirementStatus.objects.filter(original_email=address).values_list("id", flat=True), + ) == sorted([earliest.id, latest.id]) + + envelope = assert_error_envelope( + self.client.get(self.list_url, {"email": address}), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="user/retired-account", + ) + assert envelope["can_cancel_retirement"] is True + assert envelope["retirement_id"] == latest.id + + def test_a_retirement_older_than_the_cooling_off_period_can_no_longer_be_cancelled(self): + RetirementState.objects.create(state_name="PENDING", state_execution_order=1) + retirement = UserRetirementStatus.create_retirement(self.other) + long_ago = datetime.datetime.now(datetime.UTC) - datetime.timedelta( + days=settings.COOL_OFF_DAYS + 1, + ) + UserRetirementStatus.objects.filter(pk=retirement.pk).update(created=long_ago) + self.retire() + envelope = assert_error_envelope( + self.client.get(self.list_url, {"email": self.retired_email}), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="user/retired-account", + ) + assert envelope["can_cancel_retirement"] is False + assert envelope["retirement_id"] is None + + +@skip_unless_lms +class AccountDetailViewTests(AccountApiV2TestCase): + """GET /api/user/v2/accounts/{username}/ and its ``me`` form.""" + + def test_a_caller_reads_their_own_account_through_the_reserved_identifier(self): + self.login(self.owner) + response = self.client.get(self.own_url) + assert response.status_code == status.HTTP_200_OK + assert response.data["username"] == "owner-caller" + assert set(response.data) == FULL_FIELDS + + def test_the_reserved_identifier_names_the_caller_and_not_the_account_holding_it(self): + """``me`` addresses the caller, so an account holding that name is read through the list.""" + UserFactory.create(username="me", email="me@example.com", password=PASSWORD) + self.login(self.staff) + assert self.client.get(self.own_url).data["username"] == "staff-caller" + + named = self.client.get(self.list_url, {"username": "me"}) + assert named.status_code == status.HTTP_200_OK + assert self.usernames_in(named) == ["me"] + assert named.data["results"][0]["email"] == "me@example.com" + + def test_an_account_named_after_another_collection_is_served_at_its_own_address(self): + """ + A username is whatever its owner registered, and the collections beside this one take none away. + + ``email_searches`` is a name a user may hold, so the member address + carrying it belongs to that account: its owner reads and changes it + there, a staff caller reads it there, and another learner is answered + the properties that account shares rather than told it is absent. + """ + held = UserFactory.create( + username="email_searches", email="email-searches@example.com", password=PASSWORD, + ) + address = self.detail_url("email_searches") + + self.login(held) + own = self.client.get(address) + assert own.status_code == status.HTTP_200_OK + assert own.data["username"] == "email_searches" + assert own.data["email"] == "email-searches@example.com" + changed = self.patch(address, json.dumps({"bio": "named for a search"})) + assert changed.status_code == status.HTTP_200_OK + assert changed.data["bio"] == "named for a search" + self.client.logout() + + self.login(self.staff) + by_staff = self.client.get(address) + assert by_staff.status_code == status.HTTP_200_OK + assert by_staff.data["username"] == "email_searches" + named = self.client.get(self.list_url, {"username": "email_searches"}) + assert self.usernames_in(named) == ["email_searches"] + assert named.data["results"][0]["email"] == "email-searches@example.com" + self.client.logout() + + self.login(self.other) + by_a_learner = self.client.get(address) + assert by_a_learner.status_code == status.HTTP_200_OK + assert set(by_a_learner.data) == PUBLIC_FIELDS + assert by_a_learner.data["username"] == "email_searches" + + def test_a_member_address_a_stored_account_answers_to_carries_that_account(self): + """ + An address the lookup resolves to a stored account is answered with that account. + + Which stored names an address resolves to is the database's decision, + and the name it resolves to can be written differently from the one the + address carried. The test database resolves a name only to the record + stored under exactly it, so the account service is answered here the + way it answers where an address resolves more widely. + """ + def stored_under_another_spelling(request, usernames=None, **kwargs): # pylint: disable=unused-argument + assert usernames == ["Owner-Caller"] + return [{"username": "owner-caller", "email": "owner-caller@example.com"}] + + self.login(self.staff) + with mock.patch(ACCOUNT_LOOKUP, stored_under_another_spelling): + response = self.client.get(self.detail_url("Owner-Caller")) + assert response.status_code == status.HTTP_200_OK + assert response.data == {"username": "owner-caller", "email": "owner-caller@example.com"} + + def test_a_member_address_resolving_to_a_name_written_further_apart_carries_that_account(self): + """ + An address resolving to a record whose name shares less with it is answered with that record. + + The name asked for is matched to the record the lookup came back with + so that a page carries its rows in the order they were asked for. At a + member address the record the lookup resolved to is the answer whatever + it is stored under, which is what the superseded version answers. + """ + def stored_under_a_name_written_further_apart(request, usernames=None, **kwargs): # pylint: disable=unused-argument + assert usernames == ["owner-caller "] + return [{"username": "owner-caller", "email": "owner-caller@example.com"}] + + self.login(self.staff) + with mock.patch(ACCOUNT_LOOKUP, stored_under_a_name_written_further_apart): + response = self.client.get(self.detail_url("owner-caller ")) + assert response.status_code == status.HTTP_200_OK + assert response.data == {"username": "owner-caller", "email": "owner-caller@example.com"} + + def test_a_member_address_more_than_one_account_answers_to_is_refused(self): + """ + An address resolving to several accounts names none of them, so it is refused. + + Answering with one of them would hand the caller an account they did + not address, and which one it was would be settled by the order the + records came back in. + """ + def two_accounts(request, usernames=None, **kwargs): # pylint: disable=unused-argument + return [ + {"username": "owner-caller", "email": "owner-caller@example.com"}, + {"username": "Owner-Caller", "email": "second@example.com"}, + ] + + self.login(self.staff) + with mock.patch(ACCOUNT_LOOKUP, two_accounts): + response = self.client.get(self.detail_url("owner-caller ")) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="user/ambiguous-account-name", + ) + assert envelope["title"] == "Ambiguous Account Name" + assert envelope["errors"] == {"username": [AMBIGUOUS_ACCOUNT_ADDRESS]} + + def test_a_member_address_one_of_several_accounts_is_stored_under_carries_that_one(self): + """The account stored under exactly the name addressed is the answer, in whatever order they come.""" + def two_accounts(request, usernames=None, **kwargs): # pylint: disable=unused-argument + return [ + {"username": "Owner-Caller", "email": "second@example.com"}, + {"username": "owner-caller", "email": "owner-caller@example.com"}, + ] + + self.login(self.staff) + with mock.patch(ACCOUNT_LOOKUP, two_accounts): + response = self.client.get(self.detail_url("owner-caller")) + assert response.status_code == status.HTTP_200_OK + assert response.data == {"username": "owner-caller", "email": "owner-caller@example.com"} + + def test_a_caller_reads_their_own_account_by_name(self): + self.login(self.owner) + response = self.client.get(self.detail_url("owner-caller")) + assert set(response.data) == FULL_FIELDS + assert response.data["email"] == "owner-caller@example.com" + + def test_a_learner_sees_only_the_shared_properties_of_another_account(self): + self.login(self.owner) + response = self.client.get(self.detail_url("other-caller")) + assert response.status_code == status.HTTP_200_OK + assert set(response.data) == PUBLIC_FIELDS + + def test_a_staff_caller_sees_every_property_of_another_account(self): + self.login(self.staff) + response = self.client.get(self.detail_url("owner-caller")) + assert set(response.data) == FULL_FIELDS + assert response.data["email"] == "owner-caller@example.com" + + def test_the_shared_preset_narrows_the_callers_own_account(self): + self.login(self.owner) + response = self.client.get(self.detail_url("owner-caller"), {"view": "shared"}) + assert set(response.data) == PUBLIC_FIELDS + + def test_the_minimal_preset_keeps_the_identifying_properties(self): + self.login(self.owner) + response = self.client.get(self.own_url, {"view": "minimal"}) + assert set(response.data) == {"username", "email", "name", "profile_image"} + + def test_the_empty_preset_narrows_nothing_at_any_address_that_takes_one(self): + """ + A query string built from an empty variable carries a preset with no + value, which is answered with everything the caller may see. + """ + self.login(self.owner) + assert set(self.client.get(self.own_url, {"view": ""}).data) == FULL_FIELDS + assert set(self.client.get(self.list_url, {"view": ""}).data["results"][0]) == FULL_FIELDS + changed = self.patch(self.own_url, json.dumps({"bio": "unnarrowed"}), QUERY_STRING="view=") + assert changed.status_code == status.HTTP_200_OK + assert set(changed.data) == FULL_FIELDS + + def test_a_learner_selecting_an_identifying_property_they_may_not_see_is_refused(self): + """The preset keeps the identifying properties; this caller may be answered with some of them.""" + self.login(self.owner) + response = self.client.get( + self.detail_url("other-caller"), {"view": "minimal", "fields": "email"}, + ) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"]["fields"] == [ + "No such field: email. An account another user holds carries only the " + "properties its owner shares.", + ] + + def test_selected_fields_narrow_the_account(self): + self.login(self.owner) + response = self.client.get(self.own_url, {"fields": "username,email"}) + assert response.data == {"username": "owner-caller", "email": "owner-caller@example.com"} + + def test_a_learner_selecting_a_property_of_another_account_they_may_not_see_is_refused(self): + """A property this caller's response cannot carry is refused, not answered as an empty object.""" + self.login(self.owner) + response = self.client.get(self.detail_url("other-caller"), {"fields": "email"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"]["fields"] == [ + "No such field: email. An account another user holds carries only the " + "properties its owner shares.", + ] + + def test_a_learner_selecting_a_property_this_owner_does_not_share_receives_the_rest(self): + """ + A property an owner is able to share can be selected; whether they share it is their own choice. + + This owner shares nothing beyond the properties every caller may see, + so the account carries the rest of the selection rather than the + property asked for. + """ + self.login(self.owner) + response = self.client.get(self.detail_url("other-caller"), {"fields": "bio,username"}) + assert response.status_code == status.HTTP_200_OK + assert response.data == {"username": "other-caller"} + + def test_a_staff_caller_still_selects_a_property_only_they_may_see(self): + self.login(self.staff) + response = self.client.get(self.detail_url("other-caller"), {"fields": "email"}) + assert response.data == {"email": "other-caller@example.com"} + + def test_the_reserved_identifier_selects_the_properties_of_the_callers_own_account(self): + self.login(self.owner) + response = self.client.get(self.own_url, {"fields": "email"}) + assert response.data == {"email": "owner-caller@example.com"} + + def test_a_name_no_account_holds_is_not_found(self): + self.login(self.staff) + response = self.client.get(self.detail_url("nobody")) + assert_error_envelope( + response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + + def test_an_anonymous_caller_is_unauthenticated(self): + response = self.client.get(self.detail_url("owner-caller")) + assert_error_envelope( + response, expected_status=status.HTTP_401_UNAUTHORIZED, expected_type_slug="authn", + ) + + def test_a_selector_of_the_list_is_not_a_parameter_of_a_member(self): + self.login(self.staff) + response = self.client.get(self.detail_url("owner-caller"), {"ordering": "username"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "ordering": [f"No such query parameter. Send any of: {MEMBER_QUERY_PARAMS}."], + } + + def test_a_member_carries_no_page_so_the_parameters_that_cut_one_are_refused(self): + """ + One account is not a page of them, and a parameter that cuts a page is refused here. + + A caller that mistyped a parameter of the collection and sent it to a + member would otherwise be answered the whole account as though the + parameter had been read. + """ + self.login(self.owner) + for address in (self.own_url, self.detail_url("owner-caller")): + envelope = assert_error_envelope( + self.client.get(address, {"page": 9, "page_size": 3}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "page": [f"No such query parameter. Send any of: {MEMBER_QUERY_PARAMS}."], + "page_size": [f"No such query parameter. Send any of: {MEMBER_QUERY_PARAMS}."], + } + + def test_a_change_carries_no_page_either(self): + """A change answers with the one account it changed, so it reads no page parameter.""" + self.login(self.owner) + envelope = assert_error_envelope( + self.patch(f"{self.own_url}?page=2", json.dumps({"bio": "paged"})), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "page": [f"No such query parameter. Send any of: {MEMBER_QUERY_PARAMS}."], + } + self.owner.profile.refresh_from_db() + assert self.owner.profile.bio is None + + +@skip_unless_lms +class UnconfirmedAccountTests(AccountApiV2TestCase): + """ + A user who has not confirmed their email address still reaches their own account. + + The site sends them to account settings to change the address, so the + session class these views accept is the one that admits them. + """ + + def setUp(self): + super().setUp() + self.owner.is_active = False + self.owner.save() + + def test_an_unconfirmed_account_is_readable_by_its_owner(self): + self.login(self.owner) + response = self.client.get(self.own_url) + assert response.status_code == status.HTTP_200_OK + assert response.data["username"] == "owner-caller" + assert response.data["is_active"] is False + + def test_an_unconfirmed_account_is_changeable_by_its_owner(self): + self.login(self.owner) + response = self.patch(self.own_url, json.dumps({"bio": "still unconfirmed"})) + assert response.status_code == status.HTTP_200_OK + assert response.data["bio"] == "still unconfirmed" + + def test_a_token_held_by_a_disabled_staff_account_may_not_select_accounts_by_contact(self): + """A privilege does not survive the deactivation of the account that held it.""" + headers = self.token_headers(self.staff) + for query in ({"email": "other-caller@example.com"}, {"lms_user_id": self.other.id}): + assert self.client.get(self.list_url, query, **headers).status_code == status.HTTP_200_OK + self.staff.is_active = False + self.staff.save() + for query in ({"email": "other-caller@example.com"}, {"lms_user_id": self.other.id}): + assert_error_envelope( + self.client.get(self.list_url, query, **headers), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_a_disabled_staff_account_still_reads_the_accounts_it_names(self): + """Naming an account is not the privilege a disabled account loses.""" + self.staff.is_active = False + self.staff.save() + headers = self.token_headers(self.staff) + page = self.client.get(self.list_url, {"username": "other-caller"}, **headers) + assert page.status_code == status.HTTP_200_OK + assert self.usernames_in(page) == ["other-caller"] + + def test_a_token_is_read_here_without_recording_the_address_as_one_on_the_defaults(self): + """ + This address names its own classes, so it is not one the default pair serves. + + The pair the platform configures records every request it reads a + token for, so that an operator can list the addresses it serves. This + one replaces the session half of it and is therefore not among them, + and nothing here is recorded as if it were. The address read afterwards + names no classes of its own and is recorded, which is what shows the + record is reachable from a request made this way at all. + """ + with mock.patch(DEFAULT_AUTH_RECORDER) as recorded: + answer = self.client.get(self.detail_url("other-caller"), **self.token_headers(self.other)) + assert answer.status_code == status.HTTP_200_OK + assert answer.json()["username"] == "other-caller" + assert recorded.call_args_list == [] + + with mock.patch(DEFAULT_AUTH_RECORDER) as recorded: + on_the_default_pair = self.client.get( + reverse("user_v2:time_zone_list"), **self.token_headers(self.staff), + ) + assert on_the_default_pair.status_code == status.HTTP_200_OK + assert [call.args for call in recorded.call_args_list] == [("using_default_auth_classes", True)] + + def test_an_unconfirmed_account_still_may_not_search_for_others(self): + self.staff.is_active = False + self.staff.save() + response = self.client.post( + reverse("user_v2:email_search_list"), + {"emails": ["other-caller@example.com"]}, + format="json", + **self.token_headers(self.staff), + ) + assert_error_envelope( + response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + + +@skip_unless_lms +class AccountChangeTests(AccountApiV2TestCase): + """PATCH /api/user/v2/accounts/{username}/""" + + def test_an_owner_changes_their_own_account(self): + self.login(self.owner) + response = self.patch(self.detail_url("owner-caller"), json.dumps({"bio": "a new bio"})) + assert response.status_code == status.HTTP_200_OK + assert response.data["bio"] == "a new bio" + self.owner.profile.refresh_from_db() + assert self.owner.profile.bio == "a new bio" + + def test_the_reserved_identifier_changes_the_callers_own_account(self): + self.login(self.owner) + response = self.patch(self.own_url, json.dumps({"goals": "learn everything"})) + assert response.status_code == status.HTTP_200_OK + assert response.data["goals"] == "learn everything" + + def test_every_level_of_education_a_profile_stores_is_taken_as_it_is_sent(self): + """The codes are not all one letter, and each is stored as the caller sent it.""" + self.login(self.owner) + offered = [code for code, _label in UserProfile.LEVEL_OF_EDUCATION_CHOICES] + assert offered == ["p", "m", "b", "a", "hs", "jhs", "el", "none", "other"] + for code in offered: + with self.subTest(level_of_education=code): + response = self.patch(self.own_url, json.dumps({"level_of_education": code})) + assert response.status_code == status.HTTP_200_OK, response.content + assert response.data["level_of_education"] == code + self.owner.profile.refresh_from_db() + assert self.owner.profile.level_of_education == code + + def test_the_changed_account_is_shaped_like_a_read_of_it(self): + self.login(self.owner) + response = self.patch(self.own_url, json.dumps({"bio": "x"}), QUERY_STRING="view=minimal") + assert set(response.data) == {"username", "email", "name", "profile_image"} + + def test_the_shared_preset_narrows_the_changed_account_as_it_narrows_a_read_of_it(self): + """ + The preset asked for shapes what a change answers with, as it shapes what a read answers with. + + A caller asking for the shared form of their account after changing it + is answered the properties their account shares with everybody, not + the ones only they may see. + """ + self.login(self.owner) + response = self.patch( + self.own_url, json.dumps({"bio": "shared bio"}), QUERY_STRING="view=shared", + ) + assert response.status_code == status.HTTP_200_OK + assert set(response.data) == PUBLIC_FIELDS + assert response.data["username"] == "owner-caller" + read = self.client.get(self.own_url, {"view": "shared"}) + assert set(read.data) == set(response.data) + + self.owner.profile.refresh_from_db() + assert self.owner.profile.bio == "shared bio" + assert self.client.get(self.own_url).data["bio"] == "shared bio" + + def test_a_property_the_shared_preset_leaves_out_cannot_be_selected_from_a_change(self): + self.login(self.owner) + response = self.patch( + self.own_url, json.dumps({"bio": "x"}), QUERY_STRING="view=shared&fields=email", + ) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"]["fields"] == [ + f"No such field: email. Select any of: {SHAREABLE_FIELDS_OFFERED}.", + ] + self.owner.profile.refresh_from_db() + assert self.owner.profile.bio is None + + def test_a_learner_may_not_change_another_account(self): + self.login(self.owner) + response = self.patch(self.detail_url("other-caller"), json.dumps({"bio": "not mine"})) + assert_error_envelope( + response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + self.other.profile.refresh_from_db() + assert self.other.profile.bio is None + + def test_a_staff_caller_may_not_change_another_account_either(self): + """Reading somebody else's account is a staff privilege; changing it is nobody's.""" + self.login(self.staff) + response = self.patch(self.detail_url("owner-caller"), json.dumps({"bio": "not mine"})) + assert_error_envelope( + response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + + def test_a_name_no_account_holds_is_refused_before_it_is_looked_up(self): + """Only the caller's own account can be changed, so no other name is reached.""" + self.login(self.owner) + response = self.patch(self.detail_url("nobody"), json.dumps({"bio": "x"})) + assert_error_envelope( + response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + + def test_an_anonymous_caller_is_unauthenticated(self): + response = self.patch(self.detail_url("owner-caller"), json.dumps({"bio": "x"})) + assert_error_envelope( + response, expected_status=status.HTTP_401_UNAUTHORIZED, expected_type_slug="authn", + ) + + def test_an_account_removed_while_its_change_is_being_made_is_reported_as_absent(self): + """ + The account is looked up again when the change is made, and is gone by then. + + The change is made on the row the caller's own name is stored under, + so where that row has been removed since the caller was admitted the + service finds no account to change, and the address is absent. + """ + change_account = views.update_account_settings + + def removed_first(requesting_user, update, username=None): + User.objects.filter(pk=requesting_user.pk).delete() + return change_account(requesting_user, update, username=username) + + self.login(self.owner) + with mock.patch.object(views, "update_account_settings", removed_first): + response = self.patch(self.own_url, json.dumps({"bio": "x"})) + assert_error_envelope( + response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + + def test_a_body_in_another_media_type_is_refused(self): + self.login(self.owner) + response = self.client.patch( + self.detail_url("owner-caller"), json.dumps({"bio": "x"}), content_type="application/json", + ) + assert_error_envelope( + response, + expected_status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + expected_type_slug=UNSUPPORTED_MEDIA_TYPE_SLUG, + ) + + def test_a_body_that_is_not_well_formed_is_refused_as_the_callers_fault(self): + self.login(self.owner) + response = self.client.patch(self.own_url, "{not json", content_type=MERGE_PATCH) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug=MALFORMED_BODY_SLUG, + ) + assert envelope["title"] == MALFORMED_BODY_TITLE + + def test_a_body_that_is_not_an_object_is_refused(self): + self.login(self.owner) + response = self.patch(self.detail_url("owner-caller"), json.dumps(["bio"])) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "non_field_errors": ["Send the properties to change as an object."], + } + + def test_a_value_the_property_cannot_take_is_refused_with_the_property_named(self): + self.login(self.owner) + response = self.patch(self.own_url, json.dumps({"year_of_birth": "recently"})) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == {"year_of_birth": ["A valid integer is required."]} + + def test_a_value_of_the_right_type_the_account_will_not_hold_is_refused_by_the_account(self): + """ + What a property may hold is still settled where the change is carried out. + + The body is measured against the published shape first, and that is + about types and nothing else: a value of the right type that this + property cannot hold comes back refused in the account's own words. + """ + self.login(self.owner) + response = self.patch(self.own_url, json.dumps({"country": "Neverland", "bio": "x" * 4000})) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "country": ["This value is invalid."], + "bio": ["The about me field must be at most 300 characters long."], + } + self.owner.profile.refresh_from_db() + assert self.owner.profile.country is None + assert self.owner.profile.bio is None + + def test_a_property_this_version_does_not_publish_still_reaches_the_account(self): + """ + The body is checked against the published shape and then carried out as it was sent. + + A property the published shape does not carry is left for the account + to deal with, which either changes something by it or ignores it; what + it must not do is disappear here, because the account is where the + properties that may not be changed are refused. + """ + self.login(self.owner) + ignored = self.patch(self.own_url, json.dumps({"time_zone": "Asia/Tokyo"})) + assert ignored.status_code == status.HTTP_200_OK + + refused = self.patch(self.own_url, json.dumps({"is_active": False})) + envelope = assert_error_envelope( + refused, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == {"is_active": ["The 'is_active' field cannot be edited."]} + self.owner.refresh_from_db() + assert self.owner.is_active is True + + def test_a_property_that_cannot_be_changed_is_refused_with_the_property_named(self): + self.login(self.owner) + response = self.patch(self.own_url, json.dumps({"username": "renamed"})) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == {"username": ["The 'username' field cannot be edited."]} + self.owner.refresh_from_db() + assert self.owner.username == "owner-caller" + + def test_no_change_is_kept_when_one_property_of_the_body_is_refused(self): + self.login(self.owner) + response = self.patch( + self.own_url, json.dumps({"bio": "kept?", "year_of_birth": "recently"}), + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + self.owner.profile.refresh_from_db() + assert self.owner.profile.bio is None + + @override_settings(EMAIL_CHANGE_RATE_LIMIT="1/d", CACHES=counting_cache("email-change-refused")) + def test_changing_an_email_address_too_often_is_refused(self): + self.login(self.owner) + first = self.patch(self.own_url, json.dumps({"email": "owner-first@example.com"})) + assert first.status_code == status.HTTP_200_OK + + second = self.patch(self.own_url, json.dumps({"email": "owner-second@example.com"})) + envelope = assert_error_envelope( + second, expected_status=status.HTTP_429_TOO_MANY_REQUESTS, expected_type_slug="rate-limited", + ) + assert envelope["detail"] == "Wait before changing email again." + + @override_settings(EMAIL_CHANGE_RATE_LIMIT="1/d", CACHES=counting_cache("email-change-allowed")) + def test_the_rate_limit_leaves_a_change_of_another_property_alone(self): + self.login(self.owner) + assert self.patch(self.own_url, json.dumps({"email": "owner-first@example.com"})).status_code == ( + status.HTTP_200_OK + ) + later = self.patch(self.own_url, json.dumps({"bio": "unrelated"})) + assert later.status_code == status.HTTP_200_OK + assert later.data["bio"] == "unrelated" + + def test_a_property_the_body_cannot_carry_a_null_for_is_cleared_with_an_empty_text(self): + """Neither property takes a null, so the change that empties each one is pinned here.""" + self.login(self.owner) + assert self.patch( + self.own_url, json.dumps({"bio": "a bio", "phone_number": "+15550000"}), + ).status_code == status.HTTP_200_OK + + cleared = self.patch(self.own_url, json.dumps({"bio": "", "phone_number": ""})) + assert cleared.status_code == status.HTTP_200_OK + assert cleared.data["bio"] is None + assert cleared.data["phone_number"] is None + + def test_an_email_change_waits_for_the_owner_to_confirm_it(self): + self.login(self.owner) + response = self.patch(self.own_url, json.dumps({"email": "owner-new@example.com"})) + assert response.status_code == status.HTTP_200_OK + self.owner.refresh_from_db() + assert self.owner.email == "owner-caller@example.com" + + +#: Not text, so not a name: the refusals below quote these back. +NOT_TEXT = "Not a valid string." +#: What a property that carries a list of objects says about anything else. +NOT_A_LIST = 'Expected a list of items but got type "{}".' +#: Nothing at all, where the property carries something or is left out. +NOT_SENDABLE_AS_NULL = "This field may not be null." +#: What a property holding an email address says about anything else. +NOT_AN_ADDRESS = "Enter a valid email address." + +#: A property of an account, a value of a type it is not sent as, and what the +#: caller is told. Each pair reached the account service unmeasured before the +#: body was checked against the shape this endpoint publishes, and several were +#: answered as a failure of the server. +WRONGLY_TYPED_CHANGES = ( + ("name", None, NOT_SENDABLE_AS_NULL), + ("name", 5, NOT_TEXT), + ("name", [], NOT_TEXT), + ("name", {}, NOT_TEXT), + ("name", True, NOT_TEXT), + ("bio", None, NOT_SENDABLE_AS_NULL), + ("bio", 5, NOT_TEXT), + ("email", None, NOT_SENDABLE_AS_NULL), + ("email", 5, NOT_AN_ADDRESS), + ("email", True, NOT_AN_ADDRESS), + ("secondary_email", 5, NOT_AN_ADDRESS), + ("country", [], NOT_TEXT), + ("country", {}, NOT_TEXT), + ("phone_number", 5, NOT_TEXT), + ("phone_number", [], NOT_TEXT), + ("phone_number", {}, NOT_TEXT), + ("phone_number", True, NOT_TEXT), + ("year_of_birth", [], "A valid integer is required."), + ("account_privacy", [], NOT_TEXT), + ("social_links", None, NOT_SENDABLE_AS_NULL), +) + + +@skip_unless_lms +class AccountChangeBodyShapeTests(AccountApiV2TestCase): + """ + A change is measured against the shape this endpoint publishes before it is carried out. + + The account service reads a body of its own shape and its helpers assume a + property arrives as the type the document gives it, so a value of another + type used to reach them unmeasured. Here each one is the caller's fault, + named property by property, and the account is left as it was. + """ + + def setUp(self): + super().setUp() + self.owner.profile.bio = "A bio that stays." + self.owner.profile.name = "Owner Caller" + self.owner.profile.country = "GB" + self.owner.profile.phone_number = "+15550000" + self.owner.profile.save() + self.login(self.owner) + + def stored(self): + """Return the stored values of the properties these bodies try to change.""" + self.owner.refresh_from_db() + self.owner.profile.refresh_from_db() + return { + "email": self.owner.email, + "name": self.owner.profile.name, + "bio": self.owner.profile.bio, + "country": self.owner.profile.country.code, + "phone_number": self.owner.profile.phone_number, + } + + def test_a_property_sent_as_another_type_is_refused_with_the_property_named(self): + """Each pair is answered 400, naming the property and what it will not take.""" + before = self.stored() + for name, value, reason in WRONGLY_TYPED_CHANGES: + with self.subTest(property=name, value=value): + response = self.patch(self.own_url, json.dumps({name: value})) + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {name: [reason]} + assert self.stored() == before + + def test_a_null_for_a_property_the_body_publishes_no_null_for_is_refused(self): + """``bio`` and ``name`` carry text and no null, and each says so rather than failing.""" + for name in ("bio", "name"): + with self.subTest(property=name): + response = self.patch(self.own_url, json.dumps({name: None})) + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {name: ["This field may not be null."]} + assert self.stored()["bio"] == "A bio that stays." + assert self.stored()["name"] == "Owner Caller" + + def test_a_number_is_not_read_as_the_digits_that_spell_it(self): + """A property that carries text takes text, and the account keeps the text it had.""" + response = self.patch(self.own_url, json.dumps({"bio": 5})) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == {"bio": ["Not a valid string."]} + assert self.stored()["bio"] == "A bio that stays." + + def test_a_property_that_carries_a_list_is_refused_when_it_is_not_one(self): + """ + The refusal names the property and says a list is what belongs there. + + It is quoted rather than matched whole because the envelope carries one + line of text per property, and a refusal that came from inside a list + is written into that line as it stood. + """ + for name, sent, kind in ( + ("language_proficiencies", 5, "int"), + ("extended_profile", {}, "dict"), + ("social_links", "https://www.facebook.com/owner", "str"), + ): + with self.subTest(property=name): + response = self.patch(self.own_url, json.dumps({name: sent})) + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert list(envelope["errors"]) == [name] + assert NOT_A_LIST.format(kind) in envelope["errors"][name][0] + + def test_an_incomplete_object_inside_the_body_is_refused(self): + """A property that is an object of its own is sent whole, not a part at a time.""" + response = self.patch( + self.own_url, json.dumps({"social_links": [{"platform": "facebook"}]}), + ) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + # A refusal from inside a list is listed under the item it is about. + assert envelope["errors"] == {"social_links[0].social_link": ["This field is required."]} + assert envelope["detail"] == "social_links[0].social_link: This field is required." + assert self.owner.profile.social_links.count() == 0 + + def test_an_empty_address_still_drops_a_platform(self): + """The body publishes an empty address as the way to drop a link, and it is taken.""" + assert self.patch( + self.own_url, + json.dumps({"social_links": [{"platform": "facebook", "social_link": "https://www.facebook.com/owner"}]}), + ).status_code == status.HTTP_200_OK + assert self.owner.profile.social_links.count() == 1 + + dropped = self.patch( + self.own_url, json.dumps({"social_links": [{"platform": "facebook", "social_link": ""}]}), + ) + assert dropped.status_code == status.HTTP_200_OK + assert dropped.data["social_links"] == [] + assert self.owner.profile.social_links.count() == 0 + + def test_the_change_a_refused_body_asked_for_alongside_is_not_carried_out(self): + """One unusable value refuses the whole body, as one the account rejects does.""" + self.owner.profile.goals = "Finish the course." + self.owner.profile.save() + + response = self.patch(self.own_url, json.dumps({"goals": "learn", "name": 5})) + assert response.status_code == status.HTTP_400_BAD_REQUEST + self.owner.profile.refresh_from_db() + assert self.owner.profile.goals == "Finish the course." + + +@skip_unless_lms +class AccountChangeFailureTests(AccountApiV2TestCase): + """ + A change that was asked for correctly and could not be carried out is this server's fault. + + What is left once the body has been measured and the account has had its + say is the work itself failing, and a caller cannot mend that by sending + something else: it is reported as a failure here, not as an unusable + request. + """ + + def setUp(self): + super().setUp() + self.login(self.owner) + + def test_a_change_that_cannot_be_stored_is_answered_as_this_servers_failure(self): + with mock.patch( + "openedx.core.djangoapps.user_api.accounts.api.AccountLegacyProfileSerializer.save", + side_effect=DatabaseError("the rows would not take it"), + ): + response = self.patch(self.own_url, json.dumps({"bio": "a bio"})) + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_500_INTERNAL_SERVER_ERROR, + expected_type_slug="internal", + ) + assert envelope["detail"] == "The change could not be carried out." + self.owner.profile.refresh_from_db() + assert self.owner.profile.bio is None + + def test_a_message_that_cannot_be_sent_is_answered_as_this_servers_failure(self): + """The address is confirmed from a message, so a change of it fails when none can be sent.""" + with mock.patch( + "openedx.core.djangoapps.user_api.accounts.api.student_views.do_email_change_request", + side_effect=ValueError("Unable to send email activation link. Please try again later."), + ): + response = self.patch(self.own_url, json.dumps({"email": "owner-new@example.com"})) + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_500_INTERNAL_SERVER_ERROR, + expected_type_slug="internal", + ) + assert envelope["detail"] == "The change could not be carried out." + self.owner.refresh_from_db() + assert self.owner.email == "owner-caller@example.com" + + def test_what_the_failure_says_is_the_same_whatever_went_wrong(self): + """Neither answer carries what the account service said, which names rows and addresses.""" + with mock.patch( + "openedx.core.djangoapps.user_api.accounts.api.AccountLegacyProfileSerializer.save", + side_effect=DatabaseError("relation user_profile does not exist"), + ): + response = self.patch(self.own_url, json.dumps({"bio": "a bio"})) + assert "relation" not in json.dumps(response.data) + assert response.data["title"] == "Internal Server Error" + + @override_settings(ALLOW_EMAIL_ADDRESS_CHANGE=False) + def test_a_site_that_does_not_change_addresses_here_says_so_about_the_address(self): + """ + The refusal names ``email``, so a form can show it where the caller typed. + + A site that does not take an address change through this endpoint is + not a failure of the request and not a failure of the server; it is the + one property this endpoint will not change, and it is reported as that. + """ + response = self.patch(self.own_url, json.dumps({"email": "owner-new@example.com"})) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "email": ["This site does not let an email address be changed here."], + } + self.owner.refresh_from_db() + assert self.owner.email == "owner-caller@example.com" + + @override_settings(ALLOW_EMAIL_ADDRESS_CHANGE=False) + def test_a_site_that_does_not_change_addresses_here_still_changes_the_rest(self): + response = self.patch(self.own_url, json.dumps({"bio": "still changeable"})) + assert response.status_code == status.HTTP_200_OK + assert response.data["bio"] == "still changeable" + + +@skip_unless_lms +class SelectionIsSettledBeforeTheAddressIsReadTests(AccountApiV2TestCase): + """ + A selection this endpoint cannot answer is refused before the account is read or written. + + Which properties an answer can carry depends on the caller and on the + address, so a selection naming a property this caller cannot be given at + this address is refused whoever holds the name and whether or not anybody + does. The refusal names the selection and nothing else, so it says no more + about who holds a name than the refusal that would follow it, and the + account the address names is neither read nor changed on the way to it. + """ + + def setUp(self): + super().setUp() + self.login(self.owner) + + def assert_selection_refused(self, response, missing): + """Assert the answer is the refusal of the selection, naming the property asked for.""" + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"]["fields"][0].startswith(f"No such field: {missing}.") + return envelope + + def test_a_selection_the_answer_cannot_carry_leaves_the_account_as_it_was(self): + response = self.patch( + f"{self.own_url}?fields=nosuchfield", json.dumps({"bio": "a bio"}), + ) + self.assert_selection_refused(response, "nosuchfield") + self.owner.profile.refresh_from_db() + assert self.owner.profile.bio is None + + def test_a_property_another_owner_keeps_is_refused_at_their_address(self): + """``email`` is a property of an account that only its owner and staff read.""" + refused = self.patch( + f"{self.detail_url('other-caller')}?fields=email", json.dumps({"bio": "a bio"}), + ) + self.assert_selection_refused(refused, "email") + self.other.profile.refresh_from_db() + assert self.other.profile.bio is None + + def test_the_same_selection_is_refused_the_same_way_at_a_name_nobody_holds(self): + """What comes back names the selection, so it tells nothing about who holds the name.""" + held = self.patch( + f"{self.detail_url('other-caller')}?fields=email", json.dumps({"bio": "a bio"}), + ) + unheld = self.patch( + f"{self.detail_url('nobody-at-all')}?fields=email", json.dumps({"bio": "a bio"}), + ) + assert held.status_code == unheld.status_code == status.HTTP_400_BAD_REQUEST + assert held.data["errors"] == unheld.data["errors"] + + def test_a_request_that_names_no_selection_is_answered_by_the_address(self): + """Without a selection there is nothing to settle, and the address answers as it does.""" + for address in (self.detail_url("other-caller"), self.detail_url("nobody-at-all")): + response = self.patch(address, json.dumps({"bio": "a bio"})) + assert_error_envelope( + response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + + +@skip_unless_lms +class OtherApiClassificationTests(AccountApiV2TestCase): + """ + A body that does not parse, or arrives in a media type nothing reads, is + typed here as at a standardized address of another API. + + The catalog of error types is shared by every API in the process, so a type + registered for one of these failures here would rename it for all of them. + """ + + #: Standardized, served by another API, and reads a request body. + OTHER_API_WRITE_URL = "/api/enrollment/v2/enrollment/" + + def setUp(self): + super().setUp() + self.login(self.staff) + + def assert_unnamed(self, response, expected_status): + """Assert the failure is reported as one the shared catalog does not name.""" + envelope = assert_error_envelope( + response, expected_status=expected_status, expected_type_slug="internal", + ) + assert envelope["title"] == "Internal Server Error" + + def test_another_apis_malformed_body_keeps_the_name_the_shared_catalog_gives_it(self): + response = self.client.post( + self.OTHER_API_WRITE_URL, "{not json", content_type="application/json", + ) + self.assert_unnamed(response, status.HTTP_400_BAD_REQUEST) + + def test_another_apis_unreadable_body_keeps_the_name_the_shared_catalog_gives_it(self): + response = self.client.post( + self.OTHER_API_WRITE_URL, "course_id=course-v1:edX+DemoX+Demo", content_type="text/plain", + ) + self.assert_unnamed(response, status.HTTP_415_UNSUPPORTED_MEDIA_TYPE) + + def test_the_same_two_failures_are_typed_alike_at_an_address_of_this_api(self): + malformed = self.client.patch(self.own_url, "{not json", content_type=MERGE_PATCH) + self.assert_unnamed(malformed, status.HTTP_400_BAD_REQUEST) + unreadable = self.client.patch(self.own_url, "bio=x", content_type="text/plain") + self.assert_unnamed(unreadable, status.HTTP_415_UNSUPPORTED_MEDIA_TYPE) + + +@skip_unless_lms +class EmailSearchTests(AccountApiV2TestCase): + """POST /api/user/v2/email_searches/""" + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:email_search_list") + + def search(self, emails, **kwargs): + """Ask for the accounts holding the given addresses.""" + return self.client.post(self.url, {"emails": emails}, format="json", **kwargs) + + def test_a_staff_caller_resolves_addresses_to_accounts(self): + self.login(self.staff) + response = self.search(["owner-caller@example.com", "other-caller@example.com"]) + assert response.status_code == status.HTTP_200_OK + assert response.data["results"] == [ + {"email": "other-caller@example.com", "id": self.other.id, "username": "other-caller"}, + {"email": "owner-caller@example.com", "id": self.owner.id, "username": "owner-caller"}, + ] + + def test_the_page_carries_the_whole_envelope(self): + self.login(self.staff) + response = self.search(["owner-caller@example.com"]) + assert set(response.data) == PAGINATION_KEYS + assert response.data["count"] == 1 + + def test_an_address_no_account_holds_is_left_out_of_the_page(self): + self.login(self.staff) + response = self.search(["owner-caller@example.com", "nobody@example.com"]) + assert [row["username"] for row in response.data["results"]] == ["owner-caller"] + assert response.data["count"] == 1 + + def test_the_page_carries_the_accounts_the_visibility_layer_leaves(self): + """ + The page is answered from a selection the view's policy has narrowed. + + The policy this view declares keeps every matched account, and the + only caller admitted may read them all, so what is held here is the + layer rather than a narrowing: a policy that keeps a row back is one + the page is answered under, not one the search reads past. The rows + come from the same table the collections of this version narrow, so + the same policy applies to the search as to them. + """ + class _WithoutTheOtherAccount: + """Keeps one account back, standing for a policy narrower than the declared one.""" + + def scope(self, queryset, subject): # pylint: disable=unused-argument + """Return the accounts of this selection other than the other caller's.""" + return queryset.exclude(username="other-caller") + + self.login(self.staff) + with mock.patch.object(EmailSearchViewSet, "scoping_policy", _WithoutTheOtherAccount()): + response = self.search(["owner-caller@example.com", "other-caller@example.com"]) + assert response.status_code == status.HTTP_200_OK + assert [row["username"] for row in response.data["results"]] == ["owner-caller"] + assert response.data["count"] == 1 + + def test_the_search_declares_which_of_the_accounts_it_finds_a_caller_may_see(self): + """Every account the addresses match is answered, because that is what the policy keeps.""" + assert isinstance(EmailSearchViewSet.scoping_policy, FullScopePolicy) + self.login(self.staff) + response = self.search(["owner-caller@example.com", "other-caller@example.com"]) + assert [row["username"] for row in response.data["results"]] == ["other-caller", "owner-caller"] + + def test_an_empty_search_finds_nothing(self): + self.login(self.staff) + response = self.search([]) + assert response.status_code == status.HTTP_200_OK + assert response.data["results"] == [] + assert response.data["count"] == 0 + + def test_a_body_without_the_addresses_is_refused(self): + self.login(self.staff) + response = self.client.post(self.url, {}, format="json") + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == {"emails": ["This field is required."]} + + def test_an_entry_that_is_not_an_address_is_refused_with_a_message(self): + """ + A refusal names the entries at fault in a sentence a client can show. + + An entry reported under the position it holds would reach the caller + as a mapping nested inside the member naming the list, which nothing + can display. + """ + self.login(self.staff) + response = self.client.post( + self.url, {"emails": [None, "owner-caller@example.com", {"address": "x"}]}, format="json", + ) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "emails": ["Every entry must be text, and the entries at these positions are not: 0, 2."], + } + + def test_addresses_that_are_not_a_list_are_refused(self): + self.login(self.staff) + response = self.client.post(self.url, {"emails": "owner-caller@example.com"}, format="json") + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == {"emails": ['Expected a list of items but got type "str".']} + + def test_a_body_that_is_not_well_formed_is_refused_as_the_callers_fault(self): + self.login(self.staff) + response = self.client.post(self.url, "{not json", content_type="application/json") + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug=MALFORMED_BODY_SLUG, + ) + assert envelope["title"] == MALFORMED_BODY_TITLE + + def test_a_learner_is_told_there_is_nothing_at_this_address(self): + """The search is hidden rather than refused, so it does not confirm what it is for.""" + self.login(self.owner) + response = self.search(["owner-caller@example.com"]) + assert_error_envelope( + response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + + def test_an_anonymous_caller_is_unauthenticated_rather_than_hidden_from(self): + response = self.search(["owner-caller@example.com"]) + assert_error_envelope( + response, expected_status=status.HTTP_401_UNAUTHORIZED, expected_type_slug="authn", + ) + + def test_a_staff_token_reaches_the_search(self): + response = self.search(["owner-caller@example.com"], **self.token_headers(self.staff)) + assert response.status_code == status.HTTP_200_OK + assert [row["username"] for row in response.data["results"]] == ["owner-caller"] + + def test_a_token_held_by_a_deactivated_staff_account_is_refused(self): + headers = self.token_headers(self.staff) + assert self.search(["owner-caller@example.com"], **headers).status_code == status.HTTP_200_OK + self.staff.is_active = False + self.staff.save() + assert_error_envelope( + self.search(["owner-caller@example.com"], **headers), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_a_session_whose_owner_has_not_confirmed_their_address_is_refused_as_a_token_is(self): + """ + Searching other people's accounts is not done from an account that has not been confirmed. + + The session is read, as it is at the account addresses, so the refusal + is the one a token of the same account gets, and the same session + still reads the account it belongs to. + """ + self.staff.is_active = False + self.staff.save() + self.login(self.staff) + assert_error_envelope( + self.search(["owner-caller@example.com"]), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + own = self.client.get(self.own_url) + assert own.status_code == status.HTTP_200_OK + assert own.data["username"] == "staff-caller" + + def test_an_unconfirmed_staff_account_gets_one_refusal_whichever_way_it_signs_in(self): + self.staff.is_active = False + self.staff.save() + by_token = assert_error_envelope( + self.search(["owner-caller@example.com"], **self.token_headers(self.staff)), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + self.login(self.staff) + by_session = assert_error_envelope( + self.search(["owner-caller@example.com"]), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + assert by_session["detail"] == by_token["detail"] + + def test_an_unconfirmed_learner_is_told_there_is_nothing_here_whichever_way_it_signs_in(self): + """A caller who may not search is answered as an active one is, so the refusal does not confirm the search.""" + self.owner.is_active = False + self.owner.save() + by_token = assert_error_envelope( + self.search(["other-caller@example.com"], **self.token_headers(self.owner)), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + self.login(self.owner) + by_session = assert_error_envelope( + self.search(["other-caller@example.com"]), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + self.client.logout() + self.owner.is_active = True + self.owner.save() + self.login(self.other) + active = assert_error_envelope( + self.search(["owner-caller@example.com"]), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + assert by_session["detail"] == by_token["detail"] == active["detail"] + + def test_the_search_is_not_read_with_a_get(self): + self.login(self.staff) + response = self.client.get(self.url) + assert_error_envelope( + response, + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug=REFUSED_METHOD_SLUG, + ) + + def test_the_rest_of_a_search_is_read_by_sending_the_body_to_the_link_again(self): + """ + A caller who asked for pages reads the rest by sending the same body to the link. + + The link is an address like any other, and this collection is only + read by sending the addresses to it, so a caller follows the link the + way they made the first request rather than by fetching it. + """ + self.login(self.staff) + addresses = [f"paged-{index:02d}@example.com" for index in range(12)] + User.objects.bulk_create( + User(username=f"paged-{index:02d}", email=address) + for index, address in enumerate(addresses) + ) + + first = self.client.post(f"{self.url}?page_size=10", {"emails": addresses}, format="json") + assert first.status_code == status.HTTP_200_OK + assert first.data["count"] == 12 + assert first.data["num_pages"] == 2 + assert [row["username"] for row in first.data["results"]] == [ + f"paged-{index:02d}" for index in range(10) + ] + + link = first.data["next"] + assert urlparse(link).path == "/api/user/v2/email_searches/" + assert parse_qs(urlparse(link).query) == {"page": ["2"], "page_size": ["10"]} + rest = self.client.post(link, {"emails": addresses}, format="json") + assert rest.status_code == status.HTTP_200_OK + assert [row["username"] for row in rest.data["results"]] == ["paged-10", "paged-11"] + assert rest.data["next"] is None + assert urlparse(rest.data["previous"]).path == "/api/user/v2/email_searches/" + assert parse_qs(urlparse(rest.data["previous"]).query) == {"page_size": ["10"]} + + def test_a_search_is_answered_a_page_at_a_time_when_the_caller_asks_for_no_size(self): + """ + A search that found more accounts than a page carries answers with the first page of them. + + The answer is a list like every other list of this version: it carries + the rows one page holds and a link to the rest, rather than every + account found however many that is. + """ + self.login(self.staff) + addresses = [f"whole-{index:02d}@example.com" for index in range(12)] + User.objects.bulk_create( + User(username=f"whole-{index:02d}", email=address) + for index, address in enumerate(addresses) + ) + + answer = self.search(addresses) + assert answer.status_code == status.HTTP_200_OK + assert answer.data["count"] == 12 + assert answer.data["num_pages"] == 2 + assert [row["username"] for row in answer.data["results"]] == [ + f"whole-{index:02d}" for index in range(10) + ] + assert parse_qs(urlparse(answer.data["next"]).query) == {"page": ["2"]} + assert answer.data["previous"] is None + + def test_a_search_may_name_as_many_addresses_as_the_largest_page_carries(self): + """A hundred addresses is the most one search takes, and it is answered.""" + self.login(self.staff) + addresses = [f"bound-{index:03d}@example.com" for index in range(100)] + User.objects.bulk_create( + User(username=f"bound-{index:03d}", email=address) + for index, address in enumerate(addresses) + ) + + answer = self.search(addresses) + assert answer.status_code == status.HTTP_200_OK + assert answer.data["count"] == 100 + assert answer.data["num_pages"] == 10 + assert [row["username"] for row in answer.data["results"]] == [ + f"bound-{index:03d}" for index in range(10) + ] + + def test_a_search_naming_more_addresses_than_that_is_refused(self): + """ + A search is held to the addresses the largest page could answer for. + + An unbounded list would be read in one database query as long as the + body, and could ask for more accounts than any page may carry. + """ + self.login(self.staff) + response = self.search([f"bound-{index:03d}@example.com" for index in range(101)]) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "emails": ["Ensure this field has no more than 100 elements."], + } + + def test_fetching_the_link_to_the_rest_of_a_search_is_not_how_it_is_read(self): + """The link addresses this collection, which is read by sending the addresses to it.""" + self.login(self.staff) + response = self.client.get(f"{self.url}?page=2") + assert_error_envelope( + response, + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug=REFUSED_METHOD_SLUG, + ) + + def test_a_page_the_search_does_not_have_is_not_found(self): + self.login(self.staff) + response = self.client.post( + f"{self.url}?page=7", {"emails": ["owner-caller@example.com"]}, format="json", + ) + assert_error_envelope( + response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + + def test_a_query_parameter_is_not_read_by_the_search(self): + self.login(self.staff) + response = self.client.post( + f"{self.url}?view=minimal", {"emails": []}, format="json", + ) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "view": [f"No such query parameter. Send any of: {ACTION_QUERY_PARAMS}."], + } + + +@skip_unless_lms +class AccountPageSizeTests(AccountApiV2TestCase): + """ + How many accounts a page carries when the caller does not say, and the most it will carry. + + Both lists are asked for more rows than they will give, against rows that + really exist, so what is pinned is the page the endpoint answers with + rather than the numbers a paginator class holds. + """ + + DEFAULT_PAGE_SIZE = 10 + MAX_PAGE_SIZE = 100 + + def extra_accounts(self, count): + """Create the given number of accounts and return their names in page order.""" + User.objects.bulk_create( + User(username=f"paged-{index:03d}", email=f"paged-{index:03d}@example.com") + for index in range(count) + ) + return [f"paged-{index:03d}" for index in range(count)] + + def test_a_page_of_accounts_carries_ten_of_them_when_the_caller_asks_for_no_size(self): + names = self.extra_accounts(self.DEFAULT_PAGE_SIZE + 1) + self.login(self.staff) + response = self.client.get(self.list_url, {"username": ",".join(names)}) + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] == self.DEFAULT_PAGE_SIZE + 1 + assert self.usernames_in(response) == names[:self.DEFAULT_PAGE_SIZE] + assert response.data["num_pages"] == 2 + assert "page=2" in response.data["next"] + + def test_a_page_of_accounts_carries_a_hundred_of_them_however_many_are_asked_for(self): + names = self.extra_accounts(self.MAX_PAGE_SIZE + 1) + self.login(self.staff) + response = self.client.get( + self.list_url, {"username": ",".join(names), "page_size": 1000}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] == self.MAX_PAGE_SIZE + 1 + assert self.usernames_in(response) == names[:self.MAX_PAGE_SIZE] + assert response.data["num_pages"] == 2 + assert response.data["next"] is not None + + def test_a_page_of_found_addresses_carries_ten_of_them_when_the_caller_asks_for_no_size(self): + names = self.extra_accounts(self.DEFAULT_PAGE_SIZE + 1) + self.login(self.staff) + response = self.client.post( + reverse("user_v2:email_search_list"), + {"emails": [f"{name}@example.com" for name in names]}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] == self.DEFAULT_PAGE_SIZE + 1 + assert [row["username"] for row in response.data["results"]] == names[:self.DEFAULT_PAGE_SIZE] + assert response.data["num_pages"] == 2 + assert "page=2" in response.data["next"] + + def test_the_longest_search_allowed_fits_in_the_largest_page_a_caller_may_ask_for(self): + """ + What a search may ask about and what a page may carry are the same number. + + A caller that names the most addresses a search takes and asks for a + page larger than the cap is answered with all of them at once, so no + search can find more accounts than one page will hold. + """ + names = self.extra_accounts(self.MAX_PAGE_SIZE) + self.login(self.staff) + response = self.client.post( + f"{reverse('user_v2:email_search_list')}?page_size=1000", + {"emails": [f"{name}@example.com" for name in names]}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] == self.MAX_PAGE_SIZE + assert [row["username"] for row in response.data["results"]] == names + assert response.data["num_pages"] == 1 + assert response.data["next"] is None + + +@skip_unless_lms +class AccountPreferenceAddressDescriptionTests(AccountApiV2TestCase): + """What OPTIONS answers about the preference addresses beneath one account.""" + + def setUp(self): + super().setUp() + self.document_url = reverse( + "user_v2:account_preference_list", kwargs={"username": self.owner.username}, + ) + UserPreferenceFactory.create(user=self.owner, key="time_zone", value="Asia/Tokyo") + self.member_url = reverse( + "user_v2:account_preference_detail", + kwargs={"username": self.owner.username, "key": "time_zone"}, + ) + + def test_the_document_address_names_the_media_type_a_change_to_it_is_sent_in(self): + """ + The media types the answer names are the ones the address reads. + + A change to the whole document is a merge patch and a body in any + other media type is refused, so naming the media types read everywhere + else would tell a caller to send three the address refuses and leave + out the one it takes. + """ + self.login(self.owner) + answer = self.client.options(self.document_url) + assert answer.status_code == status.HTTP_200_OK + assert answer.json()["parses"] == [MERGE_PATCH] + + def test_the_member_address_names_the_media_types_a_change_to_one_preference_is_sent_in(self): + """One preference is sent as the platform reads a body everywhere, which is what is named.""" + self.login(self.owner) + answer = self.client.options(self.member_url) + assert answer.status_code == status.HTTP_200_OK + assert answer.json()["parses"] == [ + "application/json", + "application/x-www-form-urlencoded", + "multipart/form-data", + ] + + def test_a_change_built_from_the_described_body_is_carried_out(self): + """The description is taken at its word: what it names is a body the address reads.""" + self.login(self.owner) + described = self.client.options(self.member_url).json()["actions"]["PUT"] + response = self.client.put( + self.member_url, json.dumps({name: "Europe/Berlin" for name in described}), + content_type="application/json", + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert self.owner.preferences.get(key="time_zone").value == "Europe/Berlin" + + def test_a_change_sent_in_a_media_type_the_answer_names_is_read(self): + """ + The answer is taken at its word, which is the only mechanism offered for it. + + A caller that reads the media types from the description and sends one + of them is answered by the change being carried out rather than by a + refusal of what it was told to send. + """ + self.login(self.owner) + for media_type in self.client.options(self.document_url).json()["parses"]: + response = self.client.patch( + self.document_url, json.dumps({"time_zone": "Asia/Tokyo"}), content_type=media_type, + ) + assert response.status_code == status.HTTP_204_NO_CONTENT, media_type + + +@skip_unless_lms +class AccountWriteTargetTests(AccountApiV2TestCase): + """ + A change whose address names no one account is refused rather than written somewhere. + + What an address resolves to is the database's answer, and more than one + account can come back for one name. The record the work lands on is + settled from that answer rather than taken as the first of it, so a change + either lands on the account the address names or on nothing at all. The + test database resolves a name only to the record stored under exactly it, + so the lookups are answered here the way they answer where a name resolves + more widely. + """ + + def setUp(self): + super().setUp() + self.deactivator = UserFactory.create(username="target-deactivator", password=PASSWORD) + self.deactivator.user_permissions.add( + PermissionFactory( + codename="can_deactivate_users", + content_type=ContentTypeFactory(app_label="student"), + ), + ) + self.owner.profile.profile_image_uploaded_at = datetime.datetime.now(datetime.UTC) + self.owner.profile.save() + + def test_a_deactivation_whose_address_names_more_than_one_account_deactivates_none(self): + address = reverse("user_v2:account_deactivation", kwargs={"username": self.owner.username}) + self.login(self.deactivator) + with mock.patch( + "openedx.core.djangoapps.user_api.v2.views.accounts_named", + return_value=[self.other, self.staff], + ): + envelope = assert_error_envelope( + self.client.post(address), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="user/ambiguous-account-name", + ) + assert envelope["errors"] == {"username": [AMBIGUOUS_ACCOUNT_ADDRESS]} + for user in (self.owner, self.other, self.staff): + user.refresh_from_db() + assert user.has_usable_password(), user.username + + def test_a_deactivation_lands_on_the_account_stored_under_the_name_addressed(self): + address = reverse("user_v2:account_deactivation", kwargs={"username": self.owner.username}) + self.login(self.deactivator) + with mock.patch( + "openedx.core.djangoapps.user_api.v2.views.accounts_named", + return_value=[self.other, self.owner], + ): + assert self.client.post(address).status_code == status.HTTP_204_NO_CONTENT + self.owner.refresh_from_db() + self.other.refresh_from_db() + assert not self.owner.has_usable_password() + assert self.other.has_usable_password() + + def test_an_image_is_recorded_on_the_account_the_request_was_admitted_for(self): + """ + What the image is recorded on is the account the request was admitted for. + + The profile written is reached from that account rather than looked up + again by name, so a name that answers for more than one account never + comes into it. + """ + address = reverse( + "user_v2:account_profile_image", kwargs={"username": self.owner.username}, + ) + self.login(self.owner) + found = UserProfile.objects.get + + def found_by_account_only(*args, **kwargs): + if "user__username" in kwargs: + raise UserProfile.MultipleObjectsReturned + return found(*args, **kwargs) + + with mock.patch.object(UserProfile.objects, "get", side_effect=found_by_account_only): + assert self.client.delete(address).status_code == status.HTTP_204_NO_CONTENT + self.owner.profile.refresh_from_db() + assert self.owner.profile.has_profile_image is False + + +@skip_unless_lms +class AccountResourceGateTests(AccountApiV2TestCase): + """ + A rule the account resource admits a caller by reaches every operation on an account. + + The accounts are one collection however many addresses reach into them, so + a rule declared for the resource has to be applied by every operation that + reads or changes one of its accounts, wherever that operation is served + from. Each one is measured twice: once as it stands, and once with a rule + that admits nobody added to the resource's declaration, which every one of + them must then refuse. Measuring both ways is what makes the second + measurement an answer about the rule rather than about the address. + """ + + def setUp(self): + super().setUp() + UserPreferenceFactory.create(user=self.owner, key="time_zone", value="Asia/Tokyo") + self.owner.profile.profile_image_uploaded_at = datetime.datetime.now(datetime.UTC) + self.owner.profile.save() + self.deactivator = UserFactory.create(username="gate-deactivator", password=PASSWORD) + self.deactivator.user_permissions.add( + PermissionFactory( + codename="can_deactivate_users", + content_type=ContentTypeFactory(app_label="student"), + ), + ) + + def reads(self): + """Return each read of one account, by the address it is sent to.""" + name = self.owner.username + return { + "the account": lambda: self.client.get(self.detail_url(name)), + "the verification summary": lambda: self.client.get( + reverse("user_v2:account_verification_status", kwargs={"username": name}), + ), + "the verification attempts": lambda: self.client.get( + reverse("user_v2:account_verification_list", kwargs={"username": name}), + ), + "the preferences": lambda: self.client.get( + reverse("user_v2:account_preference_list", kwargs={"username": name}), + ), + "one preference": lambda: self.client.get( + reverse( + "user_v2:account_preference_detail", + kwargs={"username": name, "key": "time_zone"}, + ), + ), + } + + def writes(self): + """Return each change to one account, by the address it is sent to.""" + name = self.owner.username + return { + "the preferences": lambda: self.client.patch( + reverse("user_v2:account_preference_list", kwargs={"username": name}), + json.dumps({"time_zone": "Asia/Tokyo"}), + content_type=MERGE_PATCH, + ), + "the image": lambda: self.client.post( + reverse("user_v2:account_profile_image", kwargs={"username": name}), + ), + "the stored image": lambda: self.client.delete( + reverse("user_v2:account_profile_image", kwargs={"username": name}), + ), + } + + def has_image(self): + """Return whether the account carries an image of its own.""" + self.owner.profile.refresh_from_db() + return self.owner.profile.has_profile_image + + def test_every_read_of_one_account_is_answered_while_the_resource_admits_the_caller(self): + self.login(self.owner) + for case, send in self.reads().items(): + assert send().status_code == status.HTTP_200_OK, case + + def test_a_rule_the_resource_refuses_every_caller_by_refuses_every_read_of_one_account(self): + self.login(self.owner) + with refusing_account_resource(): + for case, send in self.reads().items(): + with self.subTest(case=case): + assert_error_envelope( + send(), expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + + def test_every_change_to_one_account_reaches_its_handler_while_the_resource_admits_the_caller(self): + """ + The handler is reached, which is what the refusal below is measured against. + + A change is sent here as a caller sends it wrong, so that what is + measured is the request reaching the handler rather than the change + itself: an empty merge patch and an upload carrying no image are + refused by the handler, and clearing the image is carried out. + """ + self.login(self.owner) + answers = {case: send() for case, send in self.writes().items()} + assert answers["the preferences"].status_code == status.HTTP_204_NO_CONTENT + assert answers["the image"].status_code == status.HTTP_400_BAD_REQUEST + assert answers["the stored image"].status_code == status.HTTP_204_NO_CONTENT + assert self.has_image() is False + + def test_a_rule_the_resource_refuses_every_caller_by_refuses_every_change_to_one_account(self): + """A change refused by the resource leaves the account as it stands.""" + self.login(self.owner) + with refusing_account_resource(): + for case, send in self.writes().items(): + with self.subTest(case=case): + assert_error_envelope( + send(), expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + assert self.has_image() is True + + def test_a_rule_the_resource_refuses_every_caller_by_refuses_a_deactivation(self): + """ + The privilege to deactivate is held and the resource still decides. + + The caller holds the privilege this address asks for, so the refusal + measured here is the resource's rather than that privilege's, and the + password is still usable afterwards. + """ + address = reverse("user_v2:account_deactivation", kwargs={"username": self.owner.username}) + self.login(self.deactivator) + with refusing_account_resource(): + assert_error_envelope( + self.client.post(address), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + self.owner.refresh_from_db() + assert self.owner.has_usable_password() + + assert self.client.post(address).status_code == status.HTTP_204_NO_CONTENT + self.owner.refresh_from_db() + assert not self.owner.has_usable_password() + + +@skip_unless_lms +class AccountDeactivationTests(AccountApiV2TestCase): + """POST /api/user/v2/accounts/{username}/deactivation/""" + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:account_deactivation", kwargs={"username": "other-caller"}) + self.deactivator = UserFactory.create(username="deactivator", password=PASSWORD) + self.deactivator.user_permissions.add( + PermissionFactory( + codename="can_deactivate_users", + content_type=ContentTypeFactory(app_label="student"), + ), + ) + + def test_a_caller_holding_the_privilege_deactivates_an_account(self): + self.login(self.deactivator) + assert self.other.has_usable_password() + response = self.client.post(self.url) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not response.content + self.other.refresh_from_db() + assert not self.other.has_usable_password() + + def test_asking_for_a_password_reset_does_not_bring_a_deactivated_account_back(self): + """ + The reset flow refuses an account whose password is unusable, which is what this leaves. + + The description of this operation tells a support caller what a + deactivation can and cannot be undone by, so the refusal it names is + pinned here: a reset asked for on the deactivated address is refused, + and the account still has no usable password afterwards. + """ + self.login(self.deactivator) + assert self.client.post(self.url).status_code == status.HTTP_204_NO_CONTENT + form = PasswordResetFormNoActive(data={"email": self.other.email}) + assert form.is_valid() is False + assert form.errors["email"] == [ + "The user account associated with this e-mail address cannot reset the password.", + ] + self.other.refresh_from_db() + assert not self.other.has_usable_password() + + def test_a_deactivated_account_keeps_its_data(self): + self.login(self.deactivator) + self.client.post(self.url) + self.other.refresh_from_db() + assert self.other.is_active is True + assert self.other.username == "other-caller" + assert self.other.email == "other-caller@example.com" + + def test_a_superuser_deactivates_an_account(self): + superuser = SuperuserFactory(username="super-caller", password=PASSWORD) + self.login(superuser) + assert self.client.post(self.url).status_code == status.HTTP_204_NO_CONTENT + + def test_a_staff_caller_without_the_privilege_is_refused(self): + self.login(self.staff) + assert_error_envelope( + self.client.post(self.url), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + self.other.refresh_from_db() + assert self.other.has_usable_password() + + def test_a_learner_is_refused(self): + self.login(self.owner) + assert_error_envelope( + self.client.post(self.url), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_a_learner_may_not_deactivate_their_own_account_either(self): + self.login(self.owner) + response = self.client.post( + reverse("user_v2:account_deactivation", kwargs={"username": "owner-caller"}), + ) + assert_error_envelope( + response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + self.owner.refresh_from_db() + assert self.owner.has_usable_password() + + def test_the_reserved_identifier_names_the_account_holding_it_and_not_the_caller(self): + """This action names the account to disable, so ``me`` is the username it looks like.""" + held = UserFactory.create(username="me", password=PASSWORD) + self.login(self.deactivator) + response = self.client.post( + reverse("user_v2:account_deactivation", kwargs={"username": "me"}), + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + held.refresh_from_db() + assert not held.has_usable_password() + self.deactivator.refresh_from_db() + assert self.deactivator.has_usable_password() + + def test_the_reserved_identifier_is_not_found_when_no_account_holds_it(self): + """Nobody is named ``me`` here, so there is no account at that address to disable.""" + self.login(self.deactivator) + response = self.client.post( + reverse("user_v2:account_deactivation", kwargs={"username": "me"}), + ) + assert_error_envelope( + response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + self.deactivator.refresh_from_db() + assert self.deactivator.has_usable_password() + + def test_an_anonymous_caller_is_unauthenticated(self): + assert_error_envelope( + self.client.post(self.url), + expected_status=status.HTTP_401_UNAUTHORIZED, + expected_type_slug="authn", + ) + + def test_a_query_parameter_is_not_read_by_a_deactivation(self): + """A parameter this action reads nothing from is refused before the account is touched.""" + self.login(self.deactivator) + response = self.client.post(f"{self.url}?view=minimal") + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "view": [f"No such query parameter. Send any of: {DEACTIVATION_QUERY_PARAMS}."], + } + self.other.refresh_from_db() + assert self.other.has_usable_password() + + def test_a_name_no_account_holds_is_not_found(self): + self.login(self.deactivator) + response = self.client.post( + reverse("user_v2:account_deactivation", kwargs={"username": "nobody"}), + ) + assert_error_envelope( + response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + + def test_a_token_held_by_a_deactivated_caller_is_refused(self): + headers = self.token_headers(self.deactivator) + self.deactivator.is_active = False + self.deactivator.save() + assert_error_envelope( + self.client.post(self.url, **headers), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + self.other.refresh_from_db() + assert self.other.has_usable_password() + + def test_a_token_held_by_an_active_caller_reaches_the_action(self): + response = self.client.post(self.url, **self.token_headers(self.deactivator)) + assert response.status_code == status.HTTP_204_NO_CONTENT + + def test_the_account_is_not_deactivated_by_reading_it(self): + self.login(self.deactivator) + response = self.client.get(self.url) + assert_error_envelope( + response, + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug=REFUSED_METHOD_SLUG, + ) + self.other.refresh_from_db() + assert self.other.has_usable_password() + + +@skip_unless_lms +class ProfilelessAccountTests(AccountApiV2TestCase): + """An account whose user has no profile row is served rather than failing.""" + + def setUp(self): + super().setUp() + self.profileless = UserFactory.create(username="no-profile", password=PASSWORD) + self.profileless.profile.delete() + + def test_a_learner_reading_an_account_without_a_profile_sees_the_shared_properties(self): + self.login(self.owner) + response = self.client.get(self.detail_url("no-profile")) + assert response.status_code == status.HTTP_200_OK + assert set(response.data) == PUBLIC_FIELDS + assert response.data["username"] == "no-profile" + + def test_a_staff_caller_reading_an_account_without_a_profile_sees_empty_properties(self): + self.login(self.staff) + response = self.client.get(self.detail_url("no-profile")) + assert response.status_code == status.HTTP_200_OK + assert set(response.data) == FULL_FIELDS + assert response.data["name"] is None + assert response.data["country"] is None + + def test_the_owner_of_an_account_without_a_profile_reads_it_too(self): + self.login(self.profileless) + response = self.client.get(self.own_url) + assert response.status_code == status.HTTP_200_OK + assert response.data["username"] == "no-profile" + + +@skip_unless_lms +@override_switch(ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH, active=True) +class SecondaryEmailEnabledTests(AccountApiV2TestCase): + """The recovery address is collected on this site.""" + + def test_the_account_says_the_site_collects_a_recovery_address(self): + self.login(self.owner) + response = self.client.get(self.own_url) + assert response.data["secondary_email_enabled"] is True + assert response.data["secondary_email"] is None + + +@skip_unless_lms +@override_switch(ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH, active=False) +class SecondaryEmailDisabledTests(AccountApiV2TestCase): + """The recovery address is not collected on this site.""" + + def test_the_account_says_nothing_about_a_recovery_address(self): + self.login(self.owner) + response = self.client.get(self.own_url) + assert response.data["secondary_email_enabled"] is None + assert response.data["secondary_email"] is None + + +@skip_unless_lms +class ExtendedProfileConfiguredTests(AccountApiV2TestCase): + """A site that collects extra profile fields.""" + + def setUp(self): + super().setUp() + # The configuration is entered per test. The helper that applies it + # wraps a function, so a class given to it comes back as a function and + # the runner finds no tests inside it at all. + self.enterContext( + with_site_configuration_context(configuration={"extended_profile_fields": ["department"]}), + ) + + def test_the_account_carries_a_value_for_each_configured_field(self): + self.login(self.owner) + response = self.client.get(self.own_url) + assert response.data["extended_profile"] == [{"field_name": "department", "field_value": ""}] + + def test_an_extra_field_is_changed_by_its_owner(self): + self.login(self.owner) + response = self.patch( + self.own_url, + json.dumps({"extended_profile": [{"field_name": "department", "field_value": "physics"}]}), + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["extended_profile"] == [ + {"field_name": "department", "field_value": "physics"}, + ] + + def test_an_extra_field_keeps_the_shape_the_site_stored_it_in(self): + """A field a site collects as something other than text comes back as that, not as its text.""" + self.login(self.owner) + response = self.patch( + self.own_url, + json.dumps({"extended_profile": [{"field_name": "department", "field_value": 41}]}), + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["extended_profile"] == [{"field_name": "department", "field_value": 41}] + reread = self.client.get(self.own_url) + assert reread.data["extended_profile"] == [{"field_name": "department", "field_value": 41}] + + def test_a_field_the_site_does_not_collect_is_left_out_of_the_account(self): + """Only the fields the site asks for are carried, whatever else the profile holds.""" + self.owner.profile.meta = json.dumps({"department": "physics", "shoe_size": 44}) + self.owner.profile.save() + self.login(self.owner) + response = self.client.get(self.own_url) + assert response.data["extended_profile"] == [ + {"field_name": "department", "field_value": "physics"}, + ] + + +@skip_unless_lms +class ExtendedProfileUnconfiguredTests(AccountApiV2TestCase): + """A site that collects no extra profile fields.""" + + def test_the_account_carries_no_extra_fields(self): + self.login(self.owner) + response = self.client.get(self.own_url) + assert response.data["extended_profile"] == [] + + +@skip_unless_lms +class AccountVisibilityTests(AccountApiV2TestCase): + """What one learner sees of another account is what that account's owner shares.""" + + def setUp(self): + super().setUp() + # A profile that does not say how old its owner is counts as a child's, + # and a child shares nothing, so the account being read is an adult's + # wherever the owner's own choice is what decides. + self.other.profile.year_of_birth = 1985 + self.other.profile.bio = "Teaches mathematics." + self.other.profile.save() + self.login(self.owner) + + def prefer(self, key, value): + """Record one of the account owner's visibility choices.""" + UserPreferenceFactory.create(user=self.other, key=key, value=value) + + def test_an_account_shared_with_everybody_carries_what_its_owner_shares(self): + response = self.client.get(self.detail_url("other-caller")) + assert response.status_code == status.HTTP_200_OK + assert set(response.data) == SHARED_FIELDS + assert response.data["account_privacy"] == "all_users" + assert response.data["bio"] == "Teaches mathematics." + + def test_a_page_carries_the_same_shared_properties_as_the_account_itself(self): + response = self.client.get(self.list_url, {"username": "other-caller"}) + assert response.status_code == status.HTTP_200_OK + assert set(response.data["results"][0]) == SHARED_FIELDS + assert response.data["results"][0]["bio"] == "Teaches mathematics." + + def test_an_account_shared_property_by_property_carries_the_ones_chosen(self): + self.prefer("account_privacy", "custom") + self.prefer("visibility.bio", "all_users") + response = self.client.get(self.detail_url("other-caller")) + assert response.status_code == status.HTTP_200_OK + assert set(response.data) == PUBLIC_FIELDS | {"bio"} + assert response.data["account_privacy"] == "custom" + assert response.data["bio"] == "Teaches mathematics." + + def test_an_account_kept_private_carries_only_what_everybody_may_see(self): + self.prefer("account_privacy", "private") + response = self.client.get(self.detail_url("other-caller")) + assert response.status_code == status.HTTP_200_OK + assert set(response.data) == PUBLIC_FIELDS + assert response.data["account_privacy"] == "private" + + def test_an_account_whose_profile_does_not_say_how_old_its_owner_is_shares_nothing(self): + self.other.profile.year_of_birth = None + self.other.profile.save() + self.prefer("account_privacy", "all_users") + response = self.client.get(self.detail_url("other-caller")) + assert response.status_code == status.HTTP_200_OK + assert set(response.data) == PUBLIC_FIELDS + assert response.data["account_privacy"] == "private" + + def test_the_owner_of_a_shared_account_still_sees_every_property_of_it(self): + self.login(self.other) + response = self.client.get(self.own_url) + assert response.status_code == status.HTTP_200_OK + assert set(response.data) == FULL_FIELDS + + def test_a_selected_property_an_owner_shares_with_everybody_is_answered(self): + response = self.client.get(self.detail_url("other-caller"), {"fields": "bio"}) + assert response.status_code == status.HTTP_200_OK + assert response.data == {"bio": "Teaches mathematics."} + + def test_a_selected_property_an_owner_shares_by_choice_is_answered(self): + self.prefer("account_privacy", "custom") + self.prefer("visibility.bio", "all_users") + response = self.client.get(self.detail_url("other-caller"), {"fields": "bio"}) + assert response.status_code == status.HTTP_200_OK + assert response.data == {"bio": "Teaches mathematics."} + + def test_a_selected_property_an_owner_chose_not_to_share_is_left_out(self): + """ + Which properties an owner shares is theirs to decide, so a selection can come back empty. + + What can be selected is settled from the properties an owner is able + to share, which is the same question for every account; whether this + owner shares this one is a different question, and it is answered + where the account is read. + """ + self.prefer("account_privacy", "custom") + self.prefer("visibility.country", "all_users") + response = self.client.get(self.detail_url("other-caller"), {"fields": "bio"}) + assert response.status_code == status.HTTP_200_OK + assert response.data == {} + + def test_a_selected_property_of_an_account_kept_private_is_left_out(self): + self.prefer("account_privacy", "private") + response = self.client.get(self.detail_url("other-caller"), {"fields": "bio"}) + assert response.status_code == status.HTTP_200_OK + assert response.data == {} + + def test_a_property_only_a_chosen_few_share_is_left_out_of_an_account_shared_with_everybody(self): + """Sharing with everybody shares a set the site fixes, and the full name is outside it.""" + response = self.client.get(self.detail_url("other-caller"), {"fields": "name"}) + assert response.status_code == status.HTTP_200_OK + assert response.data == {} + + def test_a_staff_caller_selecting_the_same_property_is_answered_it(self): + """The empty answer above is the owner's choice and not a property the endpoint cannot carry.""" + self.login(self.staff) + self.prefer("account_privacy", "private") + response = self.client.get(self.detail_url("other-caller"), {"fields": "bio"}) + assert response.status_code == status.HTTP_200_OK + assert response.data == {"bio": "Teaches mathematics."} + + def test_a_page_answers_each_row_by_the_choice_of_the_owner_it_belongs_to(self): + """One selection over two accounts answers what each owner shares, row by row.""" + self.prefer("account_privacy", "private") + self.owner.profile.bio = "Learning mathematics." + self.owner.profile.save() + response = self.client.get( + self.list_url, {"username": "other-caller,owner-caller", "fields": "bio"}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["results"] == [{}, {"bio": "Learning mathematics."}] + + +class AccountReadsDoNotWriteMixin: + """ + Reading an account changes nothing, on every branch a read can take. + + The capture runs the callbacks a request defers to the end of its + transaction, because a write made from one is a write the request made. + """ + + def captured_writes(self, method, address, params=None, expect=status.HTTP_200_OK, **headers): + """ + Return the statements a request made that would change stored data. + + The body the request answered with is left on ``self.answer``, so a + test can show which branch of the read the capture went through + instead of asserting that an unidentified read wrote nothing. Request + headers given here reach the client, which is how a read presented + with a token is captured alongside one made from a session. + """ + with CaptureQueriesContext(connection) as captured: + with self.captureOnCommitCallbacks(execute=True): + self.answer = getattr(self.client, method)(address, params or {}, **headers) + assert self.answer.status_code == expect, self.answer.content + return write_queries(captured.captured_queries) + + def populate(self, user): + """Give an account a value for each kind of data a read of it branches on.""" + user.profile.year_of_birth = 1985 + user.profile.bio = "Teaches mathematics." + user.profile.country = "US" + user.profile.state = "MA" + user.profile.profile_image_uploaded_at = datetime.datetime( + 2020, 1, 1, tzinfo=datetime.UTC, + ) + user.profile.meta = json.dumps({"department": "physics"}) + user.profile.save() + user.profile.language_proficiencies.create(code="fr") + user.profile.social_links.create( + platform="twitter", social_link="https://www.twitter.com/example", + ) + return user + + def test_a_staff_caller_reading_the_collection_writes_nothing(self): + self.login(self.staff) + assert self.captured_writes("get", self.list_url, {"username": "owner-caller,other-caller"}) == [] + + def test_a_learner_reading_the_collection_writes_nothing(self): + self.login(self.owner) + assert self.captured_writes("get", self.list_url) == [] + assert self.captured_writes("get", self.list_url, {"username": "other-caller"}) == [] + + def test_reading_a_member_writes_nothing(self): + self.login(self.owner) + assert self.captured_writes("get", self.own_url) == [] + assert self.captured_writes("get", self.detail_url("other-caller")) == [] + + def test_a_staff_caller_reading_a_member_writes_nothing(self): + self.login(self.staff) + assert self.captured_writes("get", self.detail_url("owner-caller")) == [] + + def test_the_shared_preset_writes_nothing(self): + self.login(self.owner) + assert self.captured_writes("get", self.own_url, {"view": "shared"}) == [] + + def test_heading_the_collection_and_a_member_writes_nothing(self): + self.login(self.owner) + assert self.captured_writes("head", self.list_url) == [] + assert self.captured_writes("head", self.own_url) == [] + + def test_a_search_writes_nothing(self): + self.login(self.staff) + with CaptureQueriesContext(connection) as captured: + with self.captureOnCommitCallbacks(execute=True): + response = self.client.post( + reverse("user_v2:email_search_list"), + {"emails": ["owner-caller@example.com"]}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + assert write_queries(captured.captured_queries) == [] + + def test_narrowing_what_a_read_carries_writes_nothing(self): + self.login(self.owner) + assert self.captured_writes("get", self.list_url, {"view": "minimal"}) == [] + assert self.captured_writes("get", self.own_url, {"view": "minimal"}) == [] + assert self.captured_writes("get", self.list_url, {"fields": "username,email"}) == [] + assert self.captured_writes("get", self.own_url, {"fields": "username,email"}) == [] + + def test_selecting_an_account_by_something_other_than_its_name_writes_nothing(self): + self.login(self.staff) + assert self.captured_writes("get", self.list_url, {"email": "owner-caller@example.com"}) == [] + assert self.captured_writes("get", self.list_url, {"lms_user_id": str(self.owner.id)}) == [] + + def test_reading_an_account_with_a_name_change_outstanding_writes_nothing(self): + PendingNameChange.objects.create( + user=self.owner, new_name="Changed Name", rationale="Asked for in a test", + ) + self.login(self.owner) + assert self.captured_writes("get", self.own_url) == [] + + def test_a_read_that_finds_no_account_writes_nothing(self): + self.login(self.staff) + assert self.captured_writes( + "get", self.detail_url("nobody-at-all"), expect=status.HTTP_404_NOT_FOUND, + ) == [] + assert self.captured_writes( + "get", self.list_url, {"email": "nobody@example.com"}, + ) == [] + assert self.answer.data["results"] == [] + + def test_a_read_a_token_identifies_its_caller_by_writes_nothing(self): + """ + A caller resolved from a token reads as a signed-in one does. + + A token is how a service or a micro-frontend reaches these addresses, + and the layer that resolves one runs before the handler, so each read + is captured under both ways of identifying the caller. + """ + self.populate(self.owner) + headers = self.token_headers(self.owner) + assert self.captured_writes("get", self.own_url, **headers) == [] + assert self.answer.data["username"] == "owner-caller" + assert self.captured_writes("get", self.detail_url("other-caller"), **headers) == [] + assert set(self.answer.data) == PUBLIC_FIELDS + assert self.captured_writes("get", self.list_url, **headers) == [] + assert self.usernames_in(self.answer) == ["owner-caller"] + + def test_a_read_a_staff_token_identifies_its_caller_by_writes_nothing(self): + self.populate(self.other) + headers = self.token_headers(self.staff) + assert self.captured_writes( + "get", self.list_url, {"username": "other-caller,owner-caller"}, **headers, + ) == [] + assert self.usernames_in(self.answer) == ["other-caller", "owner-caller"] + assert self.captured_writes("get", self.detail_url("other-caller"), **headers) == [] + assert self.answer.data["bio"] == "Teaches mathematics." + + def test_a_search_a_token_identifies_its_caller_by_writes_nothing(self): + headers = self.token_headers(self.staff) + with CaptureQueriesContext(connection) as captured: + with self.captureOnCommitCallbacks(execute=True): + response = self.client.post( + reverse("user_v2:email_search_list"), + {"emails": ["owner-caller@example.com"]}, + format="json", + **headers, + ) + assert response.status_code == status.HTTP_200_OK, response.content + assert [row["username"] for row in response.data["results"]] == ["owner-caller"] + assert write_queries(captured.captured_queries) == [] + + def test_reading_an_account_whose_name_was_checked_against_an_identity_document_writes_nothing(self): + """The service holding a checked name is consulted by every read, and the read still writes nothing.""" + self.login(self.owner) + with name_affirmation_answering("Checked Name"): + assert self.captured_writes("get", self.own_url) == [] + assert self.answer.data["verified_name"] == "Checked Name" + assert self.captured_writes("get", self.list_url) == [] + assert self.answer.data["results"][0]["verified_name"] == "Checked Name" + + def test_reading_an_account_the_name_service_holds_no_checked_name_for_writes_nothing(self): + """The same read on an account the service has nothing for carries no name and writes nothing.""" + self.login(self.owner) + with name_affirmation_answering(None) as service: + service.return_value.get_verified_name.return_value = None + assert self.captured_writes("get", self.own_url) == [] + assert self.answer.data["verified_name"] is None + + def test_reading_an_account_that_holds_every_kind_of_data_writes_nothing(self): + """The read has a branch per kind of stored data, and none of them writes.""" + self.populate(self.owner) + self.login(self.owner) + assert self.captured_writes("get", self.own_url) == [] + assert self.answer.data["bio"] == "Teaches mathematics." + assert self.answer.data["country"] == "US" + assert self.answer.data["language_proficiencies"] == [{"code": "fr"}] + assert self.answer.data["social_links"] == [ + {"platform": "twitter", "social_link": "https://www.twitter.com/example"}, + ] + assert self.answer.data["profile_image"]["has_image"] is True + assert self.captured_writes("get", self.list_url) == [] + assert self.answer.data["results"][0]["bio"] == "Teaches mathematics." + + def test_a_staff_caller_reading_an_account_that_holds_every_kind_of_data_writes_nothing(self): + self.populate(self.other) + self.login(self.staff) + assert self.captured_writes("get", self.detail_url("other-caller")) == [] + assert self.answer.data["bio"] == "Teaches mathematics." + assert self.answer.data["profile_image"]["has_image"] is True + + def test_reading_an_account_whose_site_collects_extra_profile_fields_writes_nothing(self): + self.populate(self.owner) + self.login(self.owner) + with with_site_configuration_context( + configuration={"extended_profile_fields": ["department"]}, + ): + assert self.captured_writes("get", self.own_url) == [] + assert self.answer.data["extended_profile"] == [ + {"field_name": "department", "field_value": "physics"}, + ] + + def test_reading_an_account_that_has_no_profile_writes_nothing(self): + profileless = UserFactory.create(username="unprofiled-caller", password=PASSWORD) + profileless.profile.delete() + self.login(self.staff) + assert self.captured_writes("get", self.detail_url("unprofiled-caller")) == [] + assert self.answer.data["name"] is None + self.login(self.owner) + assert self.captured_writes("get", self.detail_url("unprofiled-caller")) == [] + assert set(self.answer.data) == PUBLIC_FIELDS + + def test_a_learner_reading_an_account_kept_private_writes_nothing(self): + """The read that answers with the properties everybody may see writes nothing either.""" + self.populate(self.other) + UserPreferenceFactory.create(user=self.other, key="account_privacy", value="private") + self.login(self.owner) + assert self.captured_writes("get", self.detail_url("other-caller")) == [] + assert set(self.answer.data) == PUBLIC_FIELDS + assert self.answer.data["account_privacy"] == "private" + + def test_the_shared_preset_over_another_account_writes_nothing(self): + self.populate(self.other) + self.login(self.staff) + assert self.captured_writes( + "get", self.detail_url("other-caller"), {"view": "shared"}, + ) == [] + assert set(self.answer.data) == SHARED_FIELDS + assert self.captured_writes( + "get", self.list_url, {"username": "other-caller", "view": "shared"}, + ) == [] + assert set(self.answer.data["results"][0]) == SHARED_FIELDS + + def test_reading_the_collection_a_page_at_a_time_writes_nothing(self): + self.populate(self.owner) + self.login(self.staff) + selection = {"username": "owner-caller,other-caller,staff-caller", "page_size": 2} + assert self.captured_writes("get", self.list_url, selection) == [] + assert self.usernames_in(self.answer) == ["other-caller", "owner-caller"] + assert self.captured_writes("get", self.list_url, {**selection, "page": 2}) == [] + assert self.usernames_in(self.answer) == ["staff-caller"] + + def test_asking_for_a_page_the_collection_does_not_have_writes_nothing(self): + self.login(self.staff) + assert self.captured_writes( + "get", self.list_url, {"page": 7}, expect=status.HTTP_404_NOT_FOUND, + ) == [] + + def test_reading_the_collection_in_either_order_writes_nothing(self): + self.populate(self.owner) + self.login(self.staff) + selection = {"username": "owner-caller,other-caller,staff-caller"} + assert self.captured_writes( + "get", self.list_url, {**selection, "ordering": ACCOUNT_DEFAULT_SORT}, + ) == [] + assert self.usernames_in(self.answer) == ["other-caller", "owner-caller", "staff-caller"] + assert self.captured_writes( + "get", self.list_url, {**selection, "ordering": f"-{ACCOUNT_DEFAULT_SORT}"}, + ) == [] + assert self.usernames_in(self.answer) == ["staff-caller", "owner-caller", "other-caller"] + + def test_a_learner_reading_an_account_shared_with_everybody_writes_nothing(self): + """The visibility a read consults is stored as a preference, which is read and not written.""" + self.other.profile.year_of_birth = 1985 + self.other.profile.save() + UserPreferenceFactory.create(user=self.other, key="account_privacy", value="custom") + UserPreferenceFactory.create(user=self.other, key="visibility.bio", value="all_users") + self.login(self.owner) + assert self.captured_writes("get", self.detail_url("other-caller")) == [] + self.other.preferences.filter(key="account_privacy").delete() + assert self.captured_writes("get", self.detail_url("other-caller")) == [] + + +@skip_unless_lms +class TokenProvisionedAccountTests(AccountApiV2TestCase): + """What a token naming an account no row holds leaves behind.""" + + #: The tables written the first time a token names an account that does + #: not exist: the account itself, and the two rows the platform gives + #: every new account through the receivers that run when one is created. + PROVISIONED_TABLES = ["auth_user", "notifications_notificationpreference", "user_tours_usertour"] + + #: The body of the read most recently captured. + answer = None + + def token_for_a_name_no_account_holds(self): + """Return request headers carrying a token for a name no row holds.""" + user = UserFactory.create(username="token-only-caller", email="token-only@example.com") + headers = self.token_headers(user) + User.objects.filter(pk=user.pk).delete() + return headers + + def tables_written(self, address, headers, expect=status.HTTP_200_OK): + """Return the tables one read changed, and leave its body on ``self.answer``.""" + with CaptureQueriesContext(connection) as captured: + with self.captureOnCommitCallbacks(execute=True): + self.answer = self.client.get(address, **headers) + assert self.answer.status_code == expect, self.answer.content + return sorted({write_target(sql) for sql in write_queries(captured.captured_queries)}) + + def test_the_first_read_a_token_for_an_unknown_name_makes_creates_that_account(self): + """ + A token carrying a name no account holds signs that name up. + + The authentication layer the whole platform shares resolves a token by + the name in it and creates the account when it finds none; the + receivers that run on a new account then add the rows belonging with + it. All of that happens before any handler of this API is reached, and + it is declared here rather than left out of the captures, because it + is the one way a read of these addresses is followed by new rows. + """ + headers = self.token_for_a_name_no_account_holds() + assert self.tables_written(self.own_url, headers) == self.PROVISIONED_TABLES + assert self.answer.data["username"] == "token-only-caller" + assert User.objects.filter(username="token-only-caller").count() == 1 + + def test_a_later_read_the_same_token_makes_writes_nothing(self): + """Once the account exists, the reads behind that token write nothing at all.""" + headers = self.token_for_a_name_no_account_holds() + assert self.client.get(self.own_url, **headers).status_code == status.HTTP_200_OK + assert self.tables_written(self.own_url, headers) == [] + assert self.answer.data["username"] == "token-only-caller" + assert self.tables_written(self.list_url, headers) == [] + assert self.usernames_in(self.answer) == ["token-only-caller"] + + def test_the_earlier_address_answers_such_a_token_the_same_way(self): + """Both versions sign the name up, because the layer in front of both does it.""" + headers = self.token_for_a_name_no_account_holds() + assert self.tables_written("/api/user/v1/me", headers) == self.PROVISIONED_TABLES + assert self.answer.data["username"] == "token-only-caller" + + +@skip_unless_lms +@override_switch(ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH, active=False) +class AccountReadsDoNotWriteTests(AccountReadsDoNotWriteMixin, AccountApiV2TestCase): + """Reading an account on a site that collects no recovery address writes nothing.""" + + +@skip_unless_lms +@override_switch(ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH, active=True) +class AccountReadsWithARecoveryAddressDoNotWriteTests(AccountReadsDoNotWriteMixin, AccountApiV2TestCase): + """The same reads on a site that collects a recovery address, which is another row to read.""" + + +@skip_unless_lms +class AccountSelectedPropertyNameTests(AccountApiV2TestCase): + """ + What a selected property name may be at the account addresses. + + An account carries properties that are objects of their own. A name + written with a dot in it reads as a name of no property the account + carries, because the account carries the object whole and the parameter + selects the properties of the account rather than the properties of those + objects. + """ + + def test_a_name_reaching_into_a_property_is_refused_on_one_account(self): + self.login(self.owner) + response = self.client.get(f"{self.own_url}?fields=profile_image.has_image") + assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert response.json()["errors"]["fields"][0].startswith( + "No such field: profile_image.has_image. Select any of: ", + ) + + def test_a_name_reaching_into_a_property_is_refused_on_the_account_list(self): + self.login(self.owner) + response = self.client.get(f"{self.list_url}?fields=profile_image.image_url_full") + assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert response.json()["errors"]["fields"][0].startswith( + "No such field: profile_image.image_url_full. Select any of: ", + ) + + def test_a_name_reaching_into_a_property_is_refused_on_a_change(self): + self.login(self.owner) + response = self.patch(f"{self.own_url}?fields=profile_image.has_image", json.dumps({"bio": "a bio"})) + assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert UserProfile.objects.get(user=self.owner).bio != "a bio" + + def test_naming_the_property_itself_returns_the_whole_object(self): + """The object is what the account carries, so naming it brings all of it back.""" + self.login(self.owner) + response = self.client.get(f"{self.own_url}?fields=profile_image") + assert response.status_code == status.HTTP_200_OK + assert sorted(response.json()) == ["profile_image"] + assert sorted(response.json()["profile_image"]) == [ + "has_image", "image_url_full", "image_url_large", "image_url_medium", "image_url_small", + ] + + +@skip_unless_lms +@override_switch(ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH, active=True) +class RecoveryAddressRateLimitedTests(AccountApiV2TestCase): + """A site that limits how often a recovery address may be changed.""" + + @override_settings( + SECONDARY_EMAIL_RATE_LIMIT="1/d", CACHES=counting_cache("recovery-address-refused"), + ) + def test_changing_a_recovery_address_too_often_is_refused(self): + self.login(self.owner) + first = self.patch(self.own_url, json.dumps({"secondary_email": "owner-recovery@example.com"})) + assert first.status_code == status.HTTP_200_OK + + second = self.patch(self.own_url, json.dumps({"secondary_email": "owner-other@example.com"})) + envelope = assert_error_envelope( + second, expected_status=status.HTTP_429_TOO_MANY_REQUESTS, expected_type_slug="rate-limited", + ) + assert envelope["detail"] == "Wait before changing secondary_email again." + + @override_settings( + SECONDARY_EMAIL_RATE_LIMIT="1/d", CACHES=counting_cache("recovery-address-allowed"), + ) + def test_the_limit_leaves_a_change_of_the_sign_in_address_alone(self): + """Each property is counted under a name of its own, so one limit does not spend another.""" + self.login(self.owner) + assert self.patch( + self.own_url, json.dumps({"secondary_email": "owner-recovery@example.com"}), + ).status_code == status.HTTP_200_OK + later = self.patch(self.own_url, json.dumps({"email": "owner-signin@example.com"})) + assert later.status_code == status.HTTP_200_OK + + +@skip_unless_lms +@override_switch(ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH, active=True) +@override_settings(CACHES=counting_cache("recovery-address-unlimited")) +class RecoveryAddressUnlimitedTests(AccountApiV2TestCase): + """ + A site that has given no limit, which is how the platform ships. + + Neither of the two properties that can be limited carries a limit out of + the box, so the refusal this endpoint publishes for changing one too often + is answered only by a deployment that has given the matching setting a + value. That is recorded here so that the published refusal is not read as + something every deployment produces. + """ + + def test_the_platform_default_gives_no_limit(self): + assert settings.SECONDARY_EMAIL_RATE_LIMIT == "" + assert settings.EMAIL_CHANGE_RATE_LIMIT == "" + + def test_a_recovery_address_may_be_changed_again_and_again(self): + self.login(self.owner) + answered = [] + for attempt in range(3): + response = self.patch( + self.own_url, json.dumps({"secondary_email": f"owner-recovery-{attempt}@example.com"}), + ) + answered.append(response.status_code) + assert answered == [status.HTTP_200_OK] * 3 + + +@skip_unless_lms +class EmailSearchAddressDescriptionTests(AccountApiV2TestCase): + """What OPTIONS answers about the body a search is asked for in.""" + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:email_search_list") + + def test_the_address_names_the_body_a_search_is_sent_in(self): + """ + The body the answer describes is the one the address reads, not the one it returns. + + A search is asked for by sending the addresses to look for, and is + answered with an account per address found. Describing the answer here + would have a caller build its request out of the shape it is going to + receive, which this address does not read. + """ + self.login(self.staff) + answer = self.client.options(self.url) + assert answer.status_code == status.HTTP_200_OK + assert sorted(answer.json()["actions"]["POST"]) == ["emails"] + + def test_every_name_the_described_body_carries_is_one_the_search_needs(self): + """Leaving any one of the described names out is refused, so the description is the body.""" + self.login(self.staff) + described = self.client.options(self.url).json()["actions"]["POST"] + assert described + for omitted in described: + body = {name: ["owner-caller@example.com"] for name in described if name != omitted} + response = self.client.post(self.url, body, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST, omitted + + def test_a_search_built_from_the_described_body_is_answered(self): + """The description is taken at its word: what it names is a body the address reads.""" + self.login(self.staff) + described = self.client.options(self.url).json()["actions"]["POST"] + response = self.client.post( + self.url, {name: ["owner-caller@example.com"] for name in described}, format="json", + ) + assert response.status_code == status.HTTP_200_OK + assert [row["username"] for row in response.data["results"]] == ["owner-caller"] + + +@skip_unless_lms +class RefusalDisclosureTests(AccountApiV2TestCase): + """ + What a refusal about somebody else's account gives away. + + A caller that selects a property of another user's account it may not see + is told that it cannot. Which properties an owner shares is that owner's + to decide, and the set the site offers them to choose from is the site's + own configuration, so a refusal about an account the caller does not hold + names neither: it would otherwise hand every signed-in caller the whole + configured list, including names nobody on the site shares. + """ + + REFUSAL_ABOUT_ANOTHER_ACCOUNT = ( + "No such field: email. An account another user holds carries only the properties " + "its owner shares." + ) + + def test_a_refusal_about_another_account_names_nothing_the_site_configures(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.client.get(self.detail_url("other-caller"), {"fields": "email"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + message = envelope["errors"]["fields"][0] + assert message == self.REFUSAL_ABOUT_ANOTHER_ACCOUNT + assert sorted(name for name in SHAREABLE_FIELDS if name in message) == [] + + def test_a_refusal_about_another_account_on_the_list_names_nothing_either(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.client.get(self.list_url, {"username": "other-caller", "fields": "email"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + message = envelope["errors"]["fields"][0] + assert message == self.REFUSAL_ABOUT_ANOTHER_ACCOUNT + assert sorted(name for name in SHAREABLE_FIELDS if name in message) == [] + + def test_a_refusal_about_another_account_under_a_preset_names_nothing_either(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.client.get(self.detail_url("other-caller"), {"view": "minimal", "fields": "email"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"]["fields"][0] == self.REFUSAL_ABOUT_ANOTHER_ACCOUNT + + def test_a_refusal_about_the_callers_own_account_still_says_what_may_be_selected(self): + """The caller sees every property of their own account, so naming them gives nothing away.""" + self.login(self.owner) + envelope = assert_error_envelope( + self.client.get(self.own_url, {"fields": "wallet"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + message = envelope["errors"]["fields"][0] + assert message.startswith("No such field: wallet. Select any of: ") + assert "username" in message + + def test_a_refusal_to_a_staff_caller_still_says_what_may_be_selected(self): + """A staff caller reads every property of every account, so the list tells them nothing new.""" + self.login(self.staff) + envelope = assert_error_envelope( + self.client.get(self.detail_url("other-caller"), {"fields": "wallet"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"]["fields"][0].startswith("No such field: wallet. Select any of: ") diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_email_opt_in_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_email_opt_in_views.py new file mode 100644 index 000000000000..ccb47e6cb3ae --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_email_opt_in_views.py @@ -0,0 +1,666 @@ +"""Tests for the version 2 User API email opt-in address.""" + +import datetime +from unittest import mock +from urllib.parse import urlencode + +from django.conf import settings +from django.urls import reverse +from edx_rest_framework_extensions.auth.jwt.tests.utils import generate_jwt +from edx_rest_framework_extensions.testing import assert_error_envelope +from rest_framework import status +from rest_framework.test import APIClient, APITestCase + +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user +from openedx.core.djangoapps.user_api.errors import UserAPIInternalError +from openedx.core.djangoapps.user_api.models import UserOrgTag +from openedx.core.djangoapps.user_api.v2.serializers import ( + NOT_A_COURSE, + ORGANIZATION_MAX_LENGTH, + ORGANIZATION_TOO_LONG, +) +from openedx.core.djangolib.testing.utils import skip_unless_lms + +from .test_preference_views import DEFAULT_AUTH_RECORDER, requests_served_outside_a_transaction +from .test_views import MALFORMED_BODY_SLUG, REFUSED_METHOD_SLUG, UNSUPPORTED_MEDIA_TYPE_SLUG + +PASSWORD = "Password1234" + +#: The address the choice is recorded at, and the one it supersedes. +EARLIER_ADDRESS = "/api/user/v1/preferences/email_opt_in/" +UNVERSIONED_EARLIER_ADDRESS = "/user_api/v1/preferences/email_opt_in/" + +COURSE_KEY = "course-v1:edX+DemoX+Demo" +OTHER_COURSE_KEY = "course-v1:edX+OtherX+Other" +OTHER_ORG_COURSE_KEY = "course-v1:MITx+6.002x+2026" + +#: The form a course key was written in before the current one, which the +#: platform still reads. +EARLIER_COURSE_KEY = "edX/DemoX/Demo" + +#: A key the platform's course key reader reads, and that names a library +#: rather than a course. It carries an organization like a course key does, +#: which is the whole of what this address takes a key for. +LIBRARY_KEY = "library-v1:edX+lib" + +#: Where the choice is stored: one row for each user and organization. +OPT_IN_KEY = "email-optin" + +#: What such a row holds when it was opened and no choice was written into it. +NO_CHOICE_AT_ALL = "" + +#: An age below the one a guardian's consent is needed under. +MINOR_AGE = 10 + +#: Values that suggest a choice without saying which one, each of which the +#: earlier address recorded as a choice not to receive messages. +SUGGESTED_CHOICES = ("1", "0", "on", "off", "yes", "no", "t", "f", "y", "n", 1, 0) + +#: Refusal of a value that does not say which choice it is. +CHOICE_REFUSAL = "Send the choice as true or false." + +#: A course whose organization is as long as the column that records a choice +#: against it holds, and one whose organization is a character longer. +LONGEST_ORG = "o" * ORGANIZATION_MAX_LENGTH +TOO_LONG_ORG = "o" * (ORGANIZATION_MAX_LENGTH + 1) + + +@skip_unless_lms +class EmailOptInTestCase(APITestCase): + """Fixtures shared by the version 2 email opt-in tests.""" + + def setUp(self): + super().setUp() + self.user = UserFactory.create( + username="optin-user", email="optin-user@example.com", password=PASSWORD, + ) + self.other = UserFactory.create( + username="optin-other", email="optin-other@example.com", password=PASSWORD, + ) + self.url = reverse("user_v2:email_opt_in_list") + + def login(self, user): + """Sign the given user in through the session this view accepts.""" + assert self.client.login(username=user.username, password=PASSWORD) + + def token_headers(self, user): + """Return the request headers that carry a token identifying the given user.""" + return {"HTTP_AUTHORIZATION": f"JWT {create_jwt_for_user(user)}"} + + def choose(self, course_key=COURSE_KEY, opt_in=True, address=None, **kwargs): + """Record a choice at the conforming address.""" + return self.client.post( + address or self.url, + {"course_key": course_key, "email_opt_in": opt_in}, + format="json", + **kwargs, + ) + + def choices(self, user=None): + """Return the choices one account has made, by organization.""" + return { + tag.org: tag.value + for tag in UserOrgTag.objects.filter(user=user or self.user, key=OPT_IN_KEY) + } + + def make_minor(self, user): + """Record the given account as belonging to someone a guardian answers for.""" + user.profile.year_of_birth = datetime.date.today().year - MINOR_AGE + user.profile.save() + return user + + +class EmailOptInViewTests(EmailOptInTestCase): + """POST /api/user/v2/email_opt_ins/""" + + def test_a_choice_to_receive_messages_is_recorded_for_the_organization(self): + self.login(self.user) + response = self.choose() + assert response.status_code == status.HTTP_204_NO_CONTENT + assert response.content == b"" + assert self.choices() == {"edX": "True"} + + def test_a_choice_not_to_receive_them_is_recorded_too(self): + self.login(self.user) + assert self.choose(opt_in=False).status_code == status.HTTP_204_NO_CONTENT + assert self.choices() == {"edX": "False"} + + def test_a_choice_replaces_the_one_that_account_made_before(self): + self.login(self.user) + self.choose(opt_in=True) + assert self.choose(opt_in=False).status_code == status.HTTP_204_NO_CONTENT + assert self.choices() == {"edX": "False"} + assert UserOrgTag.objects.filter(user=self.user, key=OPT_IN_KEY).count() == 1 + + def test_the_choice_covers_every_course_the_organization_runs(self): + self.login(self.user) + self.choose(course_key=COURSE_KEY, opt_in=True) + assert self.choose( + course_key=OTHER_COURSE_KEY, opt_in=False, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.choices() == {"edX": "False"} + + def test_a_choice_is_kept_for_each_organization_on_its_own(self): + self.login(self.user) + self.choose(course_key=COURSE_KEY, opt_in=True) + self.choose(course_key=OTHER_ORG_COURSE_KEY, opt_in=False) + assert self.choices() == {"edX": "True", "MITx": "False"} + + def test_a_course_key_written_in_the_earlier_form_is_still_read(self): + self.login(self.user) + assert self.choose( + course_key=EARLIER_COURSE_KEY, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.choices() == {"edX": "True"} + + def test_a_choice_submitted_as_a_form_is_read(self): + """The page a learner makes this choice on submits a form.""" + self.login(self.user) + response = self.client.post( + self.url, {"course_key": COURSE_KEY, "email_opt_in": "true"}, format="multipart", + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert self.choices() == {"edX": "True"} + + def test_an_account_a_guardian_answers_for_is_recorded_as_not_receiving_them(self): + """Whatever the body asks for, consent decides, as it does at the earlier address.""" + self.make_minor(self.user) + self.login(self.user) + assert self.choose(opt_in=True).status_code == status.HTTP_204_NO_CONTENT + assert self.choices() == {"edX": "False"} + + def test_the_choice_is_recorded_for_the_caller_and_nobody_else(self): + """No account is named in the address or read from the body.""" + self.login(self.user) + response = self.client.post( + self.url, + { + "course_key": COURSE_KEY, + "email_opt_in": True, + "username": self.other.username, + "user": self.other.id, + }, + format="json", + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert self.choices() == {"edX": "True"} + assert self.choices(self.other) == {} + + def test_a_value_that_is_neither_true_nor_false_is_refused(self): + """The earlier address read anything other than ``True`` as a choice not to receive them.""" + self.login(self.user) + envelope = assert_error_envelope( + self.choose(opt_in="Banana"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"email_opt_in": [CHOICE_REFUSAL]} + assert self.choices() == {} + + def test_the_choice_is_read_from_the_word_for_it_in_any_case(self): + self.login(self.user) + for sent, recorded in ( + ("true", "True"), ("True", "True"), ("TRUE", "True"), + ("false", "False"), ("False", "False"), ("FALSE", "False"), + ): + assert self.choose(opt_in=sent).status_code == status.HTTP_204_NO_CONTENT, sent + assert self.choices() == {"edX": recorded}, sent + + def test_a_value_that_only_suggests_a_choice_is_refused_rather_than_read(self): + """ + A value a boolean field would read one way and the earlier address the + other records nothing: it is refused, and a choice already made stands. + """ + self.login(self.user) + assert self.choose(opt_in=True).status_code == status.HTTP_204_NO_CONTENT + for suggestion in SUGGESTED_CHOICES: + envelope = assert_error_envelope( + self.choose(opt_in=suggestion), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"email_opt_in": [CHOICE_REFUSAL]}, suggestion + assert self.choices() == {"edX": "True"}, suggestion + + def test_a_form_that_leaves_the_choice_out_is_refused(self): + """An absent form member is a missing choice here, not an unticked checkbox.""" + self.login(self.user) + assert self.choose(opt_in=True).status_code == status.HTTP_204_NO_CONTENT + bodies = ( + self.client.post(self.url, {"course_key": COURSE_KEY}, format="multipart"), + self.client.post( + self.url, + urlencode({"course_key": COURSE_KEY}), + content_type="application/x-www-form-urlencoded", + ), + ) + for answer in bodies: + envelope = assert_error_envelope( + answer, + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"email_opt_in": ["This field is required."]} + assert self.choices() == {"edX": "True"} + + def test_a_form_carrying_the_choice_empty_is_refused(self): + self.login(self.user) + envelope = assert_error_envelope( + self.client.post( + self.url, {"course_key": COURSE_KEY, "email_opt_in": ""}, format="multipart", + ), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"email_opt_in": [CHOICE_REFUSAL]} + assert self.choices() == {} + + def test_a_course_whose_organization_fills_the_column_is_recorded(self): + self.login(self.user) + assert self.choose( + course_key=f"course-v1:{LONGEST_ORG}+DemoX+Demo", + ).status_code == status.HTTP_204_NO_CONTENT + assert self.choices() == {LONGEST_ORG: "True"} + + def test_a_course_whose_organization_is_longer_than_the_column_is_refused(self): + """The organization is the one part of the body no key reader bounds.""" + self.login(self.user) + envelope = assert_error_envelope( + self.choose(course_key=f"course-v1:{TOO_LONG_ORG}+DemoX+Demo"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"course_key": [ORGANIZATION_TOO_LONG]} + assert str(ORGANIZATION_MAX_LENGTH) in envelope["errors"]["course_key"][0] + assert self.choices() == {} + + def test_a_course_key_that_cannot_be_read_is_refused(self): + self.login(self.user) + envelope = assert_error_envelope( + self.choose(course_key="not a key"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"course_key": ["Invalid course key"]} + assert self.choices() == {} + + def test_a_key_that_names_something_other_than_a_course_is_refused(self): + """A choice is recorded against the organization running a course, so a key names one.""" + self.login(self.user) + envelope = assert_error_envelope( + self.choose(course_key=LIBRARY_KEY), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"course_key": [NOT_A_COURSE]} + assert self.choices() == {} + + def test_a_course_named_by_something_other_than_text_is_refused(self): + """A key is read by splitting it, which only text can be, so it is refused as a body fault.""" + self.login(self.user) + for named_by in (5, 2.5, True, [COURSE_KEY], {"id": COURSE_KEY}): + envelope = assert_error_envelope( + self.choose(course_key=named_by), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "course_key": ["Send the course key as the text that writes it."], + }, named_by + assert self.choices() == {} + + def test_a_course_named_by_nothing_at_all_is_refused(self): + self.login(self.user) + envelope = assert_error_envelope( + self.choose(course_key=None), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"course_key": ["This field may not be null."]} + assert self.choices() == {} + + def test_a_body_missing_either_half_of_the_choice_is_refused(self): + self.login(self.user) + envelope = assert_error_envelope( + self.client.post(self.url, {}, format="json"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "course_key": ["This field is required."], + "email_opt_in": ["This field is required."], + } + + def test_a_body_in_a_media_type_this_address_does_not_read_is_refused(self): + self.login(self.user) + assert_error_envelope( + self.client.post( + self.url, "{}", content_type="application/merge-patch+json", + ), + expected_status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + expected_type_slug=UNSUPPORTED_MEDIA_TYPE_SLUG, + ) + + def test_a_body_that_is_not_well_formed_is_refused(self): + self.login(self.user) + assert_error_envelope( + self.client.post(self.url, "{not json", content_type="application/json"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug=MALFORMED_BODY_SLUG, + ) + + def test_the_choices_made_are_not_listed_here(self): + """This collection records a choice; nothing reads it back.""" + self.login(self.user) + for method in ("get", "put", "patch", "delete"): + assert_error_envelope( + getattr(self.client, method)(self.url), + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug=REFUSED_METHOD_SLUG, + ) + + def test_a_query_parameter_this_address_does_not_read_is_refused(self): + self.login(self.user) + envelope = assert_error_envelope( + self.choose(address=f"{self.url}?username={self.other.username}"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "username": ["No such query parameter. Send any of: format."], + } + assert self.choices() == {} + assert self.choices(self.other) == {} + + def test_an_account_that_carries_no_profile_is_reported_absent(self): + """ + The consent rule is read from the profile, and there is nothing to read it from. + + The row a choice is kept in is opened before that rule is read, and + the refusal undoes it: nothing is stored where each request is served + in a transaction, which is how the platform serves them. + """ + self.login(self.user) + self.user.profile.delete() + assert_error_envelope( + self.choose(), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + assert self.choices() == {} + + def test_the_row_a_refusal_opened_stands_where_a_statement_stands_on_its_own(self): + """ + A refused choice leaves a row behind on a database that commits each statement. + + The row is opened before the consent rule is read, so where the + request is not served in a transaction there is nothing to undo it and + the account is left holding a row carrying no choice at all. What such + a row holds is pinned because a reader of it compares it against the + word for wanting the messages and so reads it as not wanting them. + """ + self.login(self.user) + self.user.profile.delete() + with requests_served_outside_a_transaction(): + assert_error_envelope( + self.choose(), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + assert self.choices() == {"edX": NO_CHOICE_AT_ALL} + + def test_a_choice_that_could_not_be_recorded_is_a_server_fault(self): + self.login(self.user) + with mock.patch( + "openedx.core.djangoapps.user_api.v2.views.update_email_opt_in", + side_effect=UserAPIInternalError("the tag table is gone"), + ): + response = self.choose() + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_500_INTERNAL_SERVER_ERROR, + expected_type_slug="internal", + ) + assert envelope["detail"] == "The choice could not be recorded." + assert "tag table is gone" not in response.content.decode() + + +class EmailOptInAccessTests(EmailOptInTestCase): + """Who may record a choice, and whose choice is recorded.""" + + def test_an_anonymous_caller_is_refused_with_the_standard_body(self): + """The earlier address refuses them as somebody else's request instead.""" + assert_error_envelope( + self.choose(), + expected_status=status.HTTP_401_UNAUTHORIZED, + expected_type_slug="authn", + ) + assert self.choices() == {} + + def test_a_choice_presented_with_a_token_is_recorded_for_the_account_it_names(self): + assert self.choose(**self.token_headers(self.other)).status_code == ( + status.HTTP_204_NO_CONTENT + ) + assert self.choices(self.other) == {"edX": "True"} + assert self.choices() == {} + + def test_a_token_is_read_here_without_recording_the_address_as_one_on_the_defaults(self): + """ + This address names its own classes, so it is not one the default pair serves. + + The pair the platform configures records every request it reads a + token for, so that an operator can list the addresses it serves. This + one replaces the session half of it and is therefore not among them, + and nothing here is recorded as if it were. The address read afterwards + names no classes of its own and is recorded, which is what shows the + record is reachable from a request made this way at all. + """ + with mock.patch(DEFAULT_AUTH_RECORDER) as recorded: + answer = self.choose(**self.token_headers(self.other)) + assert answer.status_code == status.HTTP_204_NO_CONTENT + assert self.choices(self.other) == {"edX": "True"} + assert recorded.call_args_list == [] + + administrator = UserFactory.create( + username="optin-admin", email="optin-admin@example.com", password=PASSWORD, is_staff=True, + ) + with mock.patch(DEFAULT_AUTH_RECORDER) as recorded: + on_the_default_pair = self.client.get( + reverse("user_v2:time_zone_list"), **self.token_headers(administrator), + ) + assert on_the_default_pair.status_code == status.HTTP_200_OK + assert [call.args for call in recorded.call_args_list] == [ + ("using_default_auth_classes", True), + ] + + def test_an_account_with_an_unconfirmed_address_records_a_choice(self): + """A learner makes this choice while enrolling, before confirming their address.""" + self.user.is_active = False + self.user.save() + self.login(self.user) + assert self.choose().status_code == status.HTTP_204_NO_CONTENT + assert self.choices() == {"edX": "True"} + + def test_a_session_without_the_token_that_goes_with_it_is_refused(self): + """The session class still holds a request to the token the site hands out with it.""" + client = APIClient(enforce_csrf_checks=True) + assert client.login(username=self.user.username, password=PASSWORD) + assert_error_envelope( + client.post( + self.url, {"course_key": COURSE_KEY, "email_opt_in": True}, format="json", + ), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + assert self.choices() == {} + + def test_a_session_carrying_the_token_the_sign_in_handed_out_records_the_choice(self): + """ + The token a session is held to comes from signing in, not from this address. + + A page reaches this address with a session it got by signing in, so the + sign-in is what hands out the token the session is held to. The whole + way round is driven here, because nothing this version serves sets that + cookie and a caller presenting only a session would otherwise be + refused at every write. + """ + client = APIClient(enforce_csrf_checks=True) + sign_in_address = reverse("user_api_login_session", kwargs={"api_version": "v2"}) + form = client.get(sign_in_address) + assert form.status_code == status.HTTP_200_OK, form.content + assert settings.CSRF_COOKIE_NAME in form.cookies + signed_in = client.post( + sign_in_address, + {"email_or_username": self.user.email, "password": PASSWORD}, + HTTP_X_CSRFTOKEN=client.cookies[settings.CSRF_COOKIE_NAME].value, + ) + assert signed_in.status_code == status.HTTP_200_OK, signed_in.content + recorded = client.post( + self.url, + {"course_key": COURSE_KEY, "email_opt_in": True}, + format="json", + HTTP_X_CSRFTOKEN=client.cookies[settings.CSRF_COOKIE_NAME].value, + ) + assert recorded.status_code == status.HTTP_204_NO_CONTENT, recorded.content + assert self.choices() == {"edX": "True"} + + +class EmailOptInParityTests(EmailOptInTestCase): + """The same choice through the earlier address and through this one.""" + + def earlier(self, course_id=COURSE_KEY, email_opt_in="True", address=EARLIER_ADDRESS): + """Record a choice at the address this version supersedes, as its clients do.""" + return self.client.post( + address, {"course_id": course_id, "email_opt_in": email_opt_in}, + ) + + def test_both_addresses_record_the_same_row(self): + self.login(self.user) + assert self.earlier().status_code == status.HTTP_200_OK + earlier = self.choices() + UserOrgTag.objects.filter(user=self.user, key=OPT_IN_KEY).delete() + assert self.choose().status_code == status.HTTP_204_NO_CONTENT + assert self.choices() == earlier == {"edX": "True"} + + def test_both_addresses_record_the_same_row_for_an_account_a_guardian_answers_for(self): + self.make_minor(self.user) + self.login(self.user) + self.earlier(email_opt_in="True") + earlier = self.choices() + UserOrgTag.objects.filter(user=self.user, key=OPT_IN_KEY).delete() + self.choose(opt_in=True) + assert self.choices() == earlier == {"edX": "False"} + + def test_both_addresses_read_a_course_key_written_in_the_earlier_form(self): + self.login(self.user) + self.earlier(course_id=EARLIER_COURSE_KEY) + earlier = self.choices() + UserOrgTag.objects.filter(user=self.user, key=OPT_IN_KEY).delete() + self.choose(course_key=EARLIER_COURSE_KEY) + assert self.choices() == earlier == {"edX": "True"} + + def test_the_earlier_address_answers_with_an_empty_body_and_this_one_with_no_body(self): + """Declared difference: the choice is recorded, and there is nothing to return.""" + self.login(self.user) + earlier = self.earlier() + assert earlier.status_code == status.HTTP_200_OK + assert earlier.content == b"" + current = self.choose() + assert current.status_code == status.HTTP_204_NO_CONTENT + assert current.content == b"" + + def test_a_value_the_earlier_address_read_as_a_refusal_is_refused_here(self): + """Declared difference: a value that is neither true nor false is not read as false.""" + self.login(self.user) + assert self.earlier(email_opt_in="Banana").status_code == status.HTTP_200_OK + assert self.choices() == {"edX": "False"} + UserOrgTag.objects.filter(user=self.user, key=OPT_IN_KEY).delete() + assert self.choose(opt_in="Banana").status_code == status.HTTP_400_BAD_REQUEST + assert self.choices() == {} + + def test_a_value_that_only_suggests_a_choice_is_refused_here_and_not_read_as_one(self): + """ + Declared difference: the earlier address recorded a choice not to + receive messages for each of these, and a boolean field would have + recorded the opposite for half of them, so they are refused instead. + """ + self.login(self.user) + for suggestion in SUGGESTED_CHOICES: + assert self.earlier(email_opt_in=suggestion).status_code == status.HTTP_200_OK + assert self.choices() == {"edX": "False"}, suggestion + UserOrgTag.objects.filter(user=self.user, key=OPT_IN_KEY).delete() + assert self.choose(opt_in=suggestion).status_code == ( + status.HTTP_400_BAD_REQUEST + ), suggestion + assert self.choices() == {}, suggestion + + def test_a_form_that_leaves_the_choice_out_is_refused_by_both_addresses(self): + """Both answer a form body that names a course and no choice as a fault to correct.""" + self.login(self.user) + self.choose(opt_in=True) + earlier = self.client.post(EARLIER_ADDRESS, {"course_id": COURSE_KEY}) + assert earlier.status_code == status.HTTP_400_BAD_REQUEST + assert self.choices() == {"edX": "True"} + current = self.client.post(self.url, {"course_key": COURSE_KEY}, format="multipart") + assert current.status_code == status.HTTP_400_BAD_REQUEST + assert self.choices() == {"edX": "True"} + + def test_a_key_that_names_no_course_is_refused_by_both_addresses(self): + """Neither address records a choice for the organization of something that is not a course.""" + self.login(self.user) + earlier = self.earlier(course_id=LIBRARY_KEY) + assert earlier.status_code == status.HTTP_400_BAD_REQUEST + assert earlier.content == f"No course '{LIBRARY_KEY}' found".encode() + assert self.choices() == {} + current = self.choose(course_key=LIBRARY_KEY) + assert current.status_code == status.HTTP_400_BAD_REQUEST + assert self.choices() == {} + + def test_neither_address_records_a_choice_for_an_account_carrying_no_profile(self): + """ + Declared difference: the earlier address fails outright where this one reports it absent. + + Neither address writes a choice, and each leaves the same thing behind + on the same database: nothing where the request is served in a + transaction, and a row carrying no choice where each statement stands + on its own. The row is opened before the consent rule is read, which + is why it is there to be left at all. + """ + self.login(self.user) + self.user.profile.delete() + self.client.raise_request_exception = False + + assert self.earlier().status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + assert self.choices() == {} + assert self.choose().status_code == status.HTTP_404_NOT_FOUND + assert self.choices() == {} + + with requests_served_outside_a_transaction(): + assert self.earlier().status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + left_by_the_earlier_address = self.choices() + UserOrgTag.objects.filter(user=self.user, key=OPT_IN_KEY).delete() + assert self.choose().status_code == status.HTTP_404_NOT_FOUND + assert self.choices() == left_by_the_earlier_address == {"edX": NO_CHOICE_AT_ALL} + + def test_every_address_refuses_a_restricted_applications_token(self): + """The earlier addresses read no token at all, and this one reads it and refuses the application.""" + restricted = {"HTTP_AUTHORIZATION": f"JWT {generate_jwt(self.user, is_restricted=True)}"} + earlier = self.client.post(EARLIER_ADDRESS, {"course_id": COURSE_KEY, "email_opt_in": "True"}, **restricted) + unversioned = self.client.post( + UNVERSIONED_EARLIER_ADDRESS, {"course_id": COURSE_KEY, "email_opt_in": "True"}, **restricted, + ) + current = self.choose(**restricted) + assert (earlier.status_code, unversioned.status_code) == ( + status.HTTP_403_FORBIDDEN, status.HTTP_403_FORBIDDEN, + ) + assert_error_envelope(current, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz") + assert self.choices() == {} + + def test_the_unversioned_earlier_address_records_the_same_row(self): + """It keeps serving the clients that call it, and this version adds no third address.""" + self.login(self.user) + assert self.earlier( + address=UNVERSIONED_EARLIER_ADDRESS, + ).status_code == status.HTTP_200_OK + assert self.choices() == {"edX": "True"} diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_errors.py b/openedx/core/djangoapps/user_api/v2/tests/test_errors.py new file mode 100644 index 000000000000..96672c81280b --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_errors.py @@ -0,0 +1,58 @@ +"""Tests for the error bodies this version of the User API writes.""" + +from django.test import SimpleTestCase +from edx_rest_framework_extensions.mixins import StandardizedErrorMixin +from rest_framework.exceptions import ValidationError +from rest_framework.permissions import AllowAny +from rest_framework.test import APIRequestFactory +from rest_framework.views import APIView + +from openedx.core.djangoapps.user_api.v2.errors import ReadableErrorDetailMixin + + +class _RefusingView(StandardizedErrorMixin, ReadableErrorDetailMixin, APIView): + """Refuses every request with the refusal it is built with.""" + + permission_classes = (AllowAny,) + refusal = None + + def post(self, request): # pylint: disable=unused-argument + raise ValidationError(self.refusal) + + +def _refused(refusal): + """Return the body a view refusing a request this way answers with.""" + view = _RefusingView.as_view(refusal=refusal) + return view(APIRequestFactory().post("/refused/", {}, format="json")).data + + +class NestedRefusalTests(SimpleTestCase): + """A refusal of one item of a list, however deep, is listed under the name of that item.""" + + def test_a_property_of_one_object_in_a_list_is_named_by_position_and_property(self): + body = _refused({"username_mappings": {1: {"c": ["Not a name."]}, 0: ["Names several."]}}) + assert body["errors"] == { + "username_mappings[1].c": ["Not a name."], + "username_mappings[0]": ["Names several."], + } + assert body["detail"] == "username_mappings[1].c: Not a name. username_mappings[0]: Names several." + + def test_a_list_of_objects_names_only_the_objects_refused(self): + body = _refused({"items": [{}, {"name": ["This field is required."]}]}) + assert body["errors"] == {"items[1].name": ["This field is required."]} + assert body["detail"] == "items[1].name: This field is required." + + def test_a_refusal_of_an_object_as_a_whole_keeps_the_name_of_the_object(self): + body = _refused({"profile": {"non_field_errors": ["The two values disagree."]}, "items": {0: "Not text."}}) + assert body["errors"] == {"profile": ["The two values disagree."], "items[0]": ["Not text."]} + assert body["detail"] == "profile: The two values disagree. items[0]: Not text." + + def test_a_refusal_naming_only_parameters_is_left_as_it_was(self): + body = _refused({"limit": ["Not a whole number."], "non_field_errors": ["Nothing was changed."]}) + assert body["errors"] == {"limit": ["Not a whole number."], "non_field_errors": ["Nothing was changed."]} + assert body["detail"] == "limit: Not a whole number. Nothing was changed." + + def test_a_refusal_naming_nothing_in_particular_is_left_as_it_was(self): + body = _refused(["Nothing may be sent here."]) + assert body["errors"] == {"non_field_errors": ["Nothing may be sent here."]} + assert body["detail"] == "Nothing may be sent here." diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_name_change_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_name_change_views.py new file mode 100644 index 000000000000..5dc37cd0a7a3 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_name_change_views.py @@ -0,0 +1,697 @@ +"""Tests for the version 2 User API profile name change views.""" + +from unittest import mock + +import pytest +from django.urls import reverse +from edx_rest_framework_extensions.testing import assert_error_envelope +from rest_framework import status +from rest_framework.test import APITestCase + +from common.djangoapps.student.models import PendingNameChange +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user +from openedx.core.djangoapps.user_api.v2.views import AMBIGUOUS_ACCOUNT_ADDRESS +from openedx.core.djangolib.testing.utils import skip_unless_lms + +from .test_views import MALFORMED_BODY_SLUG, MALFORMED_BODY_TITLE, REFUSED_METHOD_SLUG, UNSUPPORTED_MEDIA_TYPE_SLUG + +PASSWORD = "Password1234" + +#: The version 1 addresses these views supersede. +EARLIER_REQUEST = "/api/user/v1/accounts/name_change/" + + +def earlier_confirmation(username): + """Return the version 1 address that confirms one user's requested name.""" + return f"/api/user/v1/accounts/name_change/{username}/confirm/" + + +class NameChangeTestCase(APITestCase): + """Fixtures shared by the version 2 name change tests.""" + + def setUp(self): + super().setUp() + self.staff = UserFactory.create( + username="name-staff", email="name-staff@example.com", password=PASSWORD, is_staff=True, + ) + self.owner = UserFactory.create( + username="name-owner", email="name-owner@example.com", password=PASSWORD, + ) + self.other = UserFactory.create( + username="name-other", email="name-other@example.com", password=PASSWORD, + ) + self.owner.profile.name = "Original Name" + self.owner.profile.save() + self.request_url = reverse("user_v2:name_change_list") + + def login(self, user): + """Sign the given user in through the session these views accept.""" + assert self.client.login(username=user.username, password=PASSWORD) + + def token_headers(self, user): + """Return the request headers that carry a token identifying the given user.""" + return {"HTTP_AUTHORIZATION": f"JWT {create_jwt_for_user(user)}"} + + def confirmation_url(self, username): + """Return the address that confirms one user's requested name.""" + return reverse("user_v2:name_change_confirmation", kwargs={"username": username}) + + def request_name(self, name, **kwargs): + """Ask for a profile name as the signed-in caller.""" + return self.client.post(self.request_url, {"name": name}, format="json", **kwargs) + + def pending_name(self, user): + """Return the name this user has asked for, or None when they have asked for none.""" + pending = PendingNameChange.objects.filter(user=user).first() + return pending.new_name if pending else None + + def profile_name(self, user): + """Return the name the user's profile carries right now.""" + user.profile.refresh_from_db() + return user.profile.name + + +@skip_unless_lms +class NameChangeRequestTests(NameChangeTestCase): + """POST /api/user/v2/name_changes/""" + + def test_a_caller_asks_for_a_name_on_their_own_profile(self): + self.login(self.owner) + response = self.request_name("Jon Doe") + assert response.status_code == status.HTTP_201_CREATED + assert response.data == {"name": "Jon Doe"} + assert self.pending_name(self.owner) == "Jon Doe" + assert self.profile_name(self.owner) == "Original Name" + + def test_the_request_records_who_asked_for_it(self): + self.login(self.owner) + self.request_name("Jon Doe") + pending = PendingNameChange.objects.get(user=self.owner) + assert pending.rationale == "Name change requested through account API by name-owner" + + def test_a_second_request_replaces_the_first(self): + self.login(self.owner) + self.request_name("First Try") + response = self.request_name("Second Try") + assert response.status_code == status.HTTP_201_CREATED + assert PendingNameChange.objects.filter(user=self.owner).count() == 1 + assert self.pending_name(self.owner) == "Second Try" + + def test_a_caller_may_only_ask_for_a_name_on_their_own_profile(self): + """The address carries no username, so a request is about the caller and nobody else.""" + self.login(self.other) + self.request_name("Jon Doe") + assert self.pending_name(self.other) == "Jon Doe" + assert self.pending_name(self.owner) is None + + def test_the_name_the_profile_already_carries_is_refused(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.request_name("Original Name"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"name": ["The profile already carries this name."]} + assert self.pending_name(self.owner) is None + + def test_a_name_carrying_markup_is_refused(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.request_name("Jon Doe"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "name": ["Name cannot contain the following characters: < >"], + } + assert self.pending_name(self.owner) is None + + def test_a_name_carrying_a_web_address_is_refused(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.request_name("Jon Doe https://example.com"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"name": ["Name cannot contain a URL"]} + assert self.pending_name(self.owner) is None + + def test_a_body_carrying_no_name_is_refused(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.client.post(self.request_url, {}, format="json"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"name": ["This field is required."]} + + def test_an_empty_name_is_refused(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.request_name(""), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"name": ["This field may not be blank."]} + + def test_a_name_longer_than_the_column_that_stores_it_is_refused(self): + """The stored column holds 255 characters, and a longer one would fail at the database.""" + self.login(self.owner) + envelope = assert_error_envelope( + self.request_name("N" * 256), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "name": ["Ensure this field has no more than 255 characters."], + } + assert self.pending_name(self.owner) is None + assert PendingNameChange.objects.count() == 0 + + def test_a_name_exactly_as_long_as_the_column_is_accepted(self): + self.login(self.owner) + assert self.request_name("N" * 255).status_code == status.HTTP_201_CREATED + assert self.pending_name(self.owner) == "N" * 255 + + def test_an_account_carrying_no_profile_has_no_name_to_change(self): + self.login(self.other) + self.other.profile.delete() + assert_error_envelope( + self.request_name("Jon Doe"), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + assert PendingNameChange.objects.count() == 0 + + def test_an_anonymous_caller_is_unauthenticated(self): + assert_error_envelope( + self.request_name("Jon Doe"), + expected_status=status.HTTP_401_UNAUTHORIZED, + expected_type_slug="authn", + ) + assert PendingNameChange.objects.count() == 0 + + def test_a_token_held_by_a_caller_who_has_not_confirmed_their_address_asks_for_a_name(self): + """Asking for a name is about the caller's own profile, which a token reaches while unconfirmed.""" + self.owner.is_active = False + self.owner.save() + response = self.request_name("Jon Doe", **self.token_headers(self.owner)) + assert response.status_code == status.HTTP_201_CREATED + assert self.pending_name(self.owner) == "Jon Doe" + + def test_a_session_held_by_such_a_caller_identifies_nobody_here(self): + """A session here is read the way the platform reads one, and that is a token's work.""" + self.login(self.owner) + self.owner.is_active = False + self.owner.save() + assert_error_envelope( + self.request_name("Jon Doe"), + expected_status=status.HTTP_401_UNAUTHORIZED, + expected_type_slug="authn", + ) + assert PendingNameChange.objects.count() == 0 + + def test_an_unknown_query_parameter_is_refused(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.client.post(f"{self.request_url}?username=name-other", {"name": "Jon Doe"}, format="json"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "username": ["No such query parameter. Send any of: format."], + } + assert PendingNameChange.objects.count() == 0 + + def test_a_body_that_is_not_well_formed_is_refused_as_the_callers_fault(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.client.post(self.request_url, "{not json", content_type="application/json"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug=MALFORMED_BODY_SLUG, + ) + assert envelope["title"] == MALFORMED_BODY_TITLE + assert PendingNameChange.objects.count() == 0 + + def test_a_body_in_a_media_type_this_endpoint_cannot_read_is_refused(self): + self.login(self.owner) + assert_error_envelope( + self.client.post(self.request_url, "Jon Doe", content_type="application/xml"), + expected_status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + expected_type_slug=UNSUPPORTED_MEDIA_TYPE_SLUG, + ) + assert PendingNameChange.objects.count() == 0 + + def test_a_name_sent_as_a_web_form_is_recorded_the_way_one_sent_as_json_is(self): + self.login(self.owner) + encoded = self.client.post( + self.request_url, "name=Jon+Doe", content_type="application/x-www-form-urlencoded", + ) + assert encoded.status_code == status.HTTP_201_CREATED + assert encoded.data == {"name": "Jon Doe"} + assert self.pending_name(self.owner) == "Jon Doe" + + uploaded = self.client.post(self.request_url, {"name": "Jane Doe"}, format="multipart") + assert uploaded.status_code == status.HTTP_201_CREATED + assert uploaded.data == {"name": "Jane Doe"} + assert self.pending_name(self.owner) == "Jane Doe" + + def test_the_collection_is_not_read_with_a_get(self): + self.login(self.owner) + assert_error_envelope( + self.client.get(self.request_url), + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug=REFUSED_METHOD_SLUG, + ) + + +@skip_unless_lms +class NameChangeConfirmationTests(NameChangeTestCase): + """POST /api/user/v2/name_changes/{username}/confirmations/""" + + def setUp(self): + super().setUp() + PendingNameChange.objects.create( + user=self.owner, new_name="Jon Doe", rationale="asked for in a test", + ) + self.url = self.confirmation_url(self.owner.username) + + def test_a_staff_caller_puts_the_requested_name_on_the_profile(self): + self.login(self.staff) + response = self.client.post(self.url) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert response.data is None + assert self.profile_name(self.owner) == "Jon Doe" + assert self.pending_name(self.owner) is None + + def test_the_name_the_profile_carried_is_kept_in_its_history(self): + self.login(self.staff) + self.client.post(self.url) + self.owner.profile.refresh_from_db() + recorded = self.owner.profile.get_meta()["old_names"] + assert len(recorded) == 1 + assert recorded[0][0] == "Original Name" + assert recorded[0][1] == "asked for in a test" + + def test_the_user_who_asked_may_not_confirm_their_own_request(self): + self.login(self.owner) + assert_error_envelope( + self.client.post(self.url), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + assert self.profile_name(self.owner) == "Original Name" + assert self.pending_name(self.owner) == "Jon Doe" + + def test_another_learner_may_not_confirm_a_request_either(self): + self.login(self.other) + assert_error_envelope( + self.client.post(self.url), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + assert self.profile_name(self.owner) == "Original Name" + + def test_an_anonymous_caller_is_unauthenticated(self): + assert_error_envelope( + self.client.post(self.url), + expected_status=status.HTTP_401_UNAUTHORIZED, + expected_type_slug="authn", + ) + assert self.profile_name(self.owner) == "Original Name" + + def test_a_token_held_by_a_deactivated_staff_account_is_refused(self): + headers = self.token_headers(self.staff) + self.staff.is_active = False + self.staff.save() + assert_error_envelope( + self.client.post(self.url, **headers), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + assert self.profile_name(self.owner) == "Original Name" + assert self.pending_name(self.owner) == "Jon Doe" + + def test_a_token_held_by_an_active_staff_account_reaches_the_action(self): + response = self.client.post(self.url, **self.token_headers(self.staff)) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert self.profile_name(self.owner) == "Jon Doe" + + def test_a_user_who_has_asked_for_nothing_has_nothing_to_confirm(self): + self.login(self.staff) + assert_error_envelope( + self.client.post(self.confirmation_url(self.other.username)), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + assert self.profile_name(self.other) == self.other.profile.name + + def test_the_reserved_identifier_is_read_as_a_stored_name_here(self): + """``me`` names an account holding that name at this address, as the parameter says.""" + self.login(self.staff) + assert_error_envelope( + self.client.post(self.confirmation_url("me")), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + named = UserFactory.create(username="me", email="me@example.com", password=PASSWORD) + PendingNameChange.objects.create( + user=named, new_name="Named Me", rationale="asked for in a test", + ) + assert self.client.post(self.confirmation_url("me")).status_code == status.HTTP_204_NO_CONTENT + assert self.profile_name(named) == "Named Me" + assert self.pending_name(self.owner) == "Jon Doe" + + def test_an_unknown_username_is_not_found(self): + self.login(self.staff) + assert_error_envelope( + self.client.post(self.confirmation_url("nobody-at-all")), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_an_account_carrying_no_profile_has_no_name_to_change(self): + self.login(self.staff) + self.owner.profile.delete() + assert_error_envelope( + self.client.post(self.url), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + assert self.pending_name(self.owner) == "Jon Doe" + + def test_the_address_is_not_read_with_a_get(self): + self.login(self.staff) + assert_error_envelope( + self.client.get(self.url), + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug=REFUSED_METHOD_SLUG, + ) + assert self.profile_name(self.owner) == "Original Name" + + def test_an_unknown_query_parameter_is_refused(self): + self.login(self.staff) + envelope = assert_error_envelope( + self.client.post(f"{self.url}?confirm=true"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "confirm": ["No such query parameter. Send any of: format."], + } + assert self.profile_name(self.owner) == "Original Name" + + +@skip_unless_lms +class NameChangeAddressDescriptionTests(NameChangeTestCase): + """What OPTIONS answers about the addresses that ask for and confirm a name.""" + + def setUp(self): + super().setUp() + PendingNameChange.objects.create( + user=self.owner, new_name="Jon Doe", rationale="asked for in a test", + ) + self.url = self.confirmation_url(self.owner.username) + + def test_the_confirmation_address_describes_no_body(self): + """ + A confirmation carries out the change already asked for, so there is nothing to send. + + A client or a form built from this description would otherwise ask the + operator for a name before confirming the change, and send a body the + address does not read. + """ + self.login(self.staff) + answer = self.client.options(self.url) + assert answer.status_code == status.HTTP_200_OK + assert "actions" not in answer.json() + + def test_asking_for_a_name_is_still_described_by_the_body_it_takes(self): + """The collection reads a body, and the description of it is not what the above removes.""" + self.login(self.owner) + answer = self.client.options(self.request_url) + assert answer.status_code == status.HTTP_200_OK + assert set(answer.json()["actions"]["POST"]) == {"name"} + assert answer.json()["actions"]["POST"]["name"]["required"] is True + + +@skip_unless_lms +class NameChangeAmbiguousTargetTests(NameChangeTestCase): + """A confirmation whose address names no one account changes no profile.""" + + def setUp(self): + super().setUp() + PendingNameChange.objects.create( + user=self.owner, new_name="Jon Doe", rationale="asked for in a test", + ) + self.url = self.confirmation_url(self.owner.username) + + def test_an_address_naming_more_than_one_account_changes_nothing(self): + """ + The profile the work lands on is the one the address names, or none at all. + + What the address resolves to is the database's answer, and more than + one account can come back for one name. Writing to whichever came back + first would put a name on an account nobody addressed, so the request + is refused and both profiles are left as they stand. The test database + resolves a name only to the record stored under exactly it, so the + lookup is answered here the way it answers where a name resolves more + widely. + """ + sibling = UserFactory.create( + username="name-sibling", email="name-sibling@example.com", password=PASSWORD, + ) + sibling.profile.name = "Sibling Name" + sibling.profile.save() + self.login(self.staff) + with mock.patch( + "openedx.core.djangoapps.user_api.v2.views.accounts_named", + return_value=[sibling, self.other], + ): + envelope = assert_error_envelope( + self.client.post(self.url), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="user/ambiguous-account-name", + ) + assert envelope["errors"] == {"username": [AMBIGUOUS_ACCOUNT_ADDRESS]} + assert self.profile_name(self.owner) == "Original Name" + assert self.pending_name(self.owner) == "Jon Doe" + sibling.profile.refresh_from_db() + assert sibling.profile.name == "Sibling Name" + + def test_the_account_stored_under_the_name_addressed_is_the_one_changed(self): + """Among several, the account stored under exactly the name addressed is the one written to.""" + sibling = UserFactory.create( + username="name-sibling", email="name-sibling@example.com", password=PASSWORD, + ) + sibling.profile.name = "Sibling Name" + sibling.profile.save() + self.login(self.staff) + with mock.patch( + "openedx.core.djangoapps.user_api.v2.views.accounts_named", + return_value=[sibling, self.owner], + ): + assert self.client.post(self.url).status_code == status.HTTP_204_NO_CONTENT + assert self.profile_name(self.owner) == "Jon Doe" + sibling.profile.refresh_from_db() + assert sibling.profile.name == "Sibling Name" + + +@skip_unless_lms +class NameChangeParityTests(NameChangeTestCase): + """The same requests through the version 1 and the version 2 addresses.""" + + def test_both_versions_record_the_same_request(self): + self.login(self.owner) + earlier = self.client.post(EARLIER_REQUEST, {"name": "Jon Doe"}, format="json") + assert earlier.status_code == status.HTTP_201_CREATED + assert earlier.data is None + earlier_pending = PendingNameChange.objects.get(user=self.owner) + earlier_name, earlier_rationale = earlier_pending.new_name, earlier_pending.rationale + earlier_pending.delete() + + later = self.request_name("Jon Doe") + later_pending = PendingNameChange.objects.get(user=self.owner) + assert (later_pending.new_name, later_pending.rationale) == (earlier_name, earlier_rationale) + assert later.status_code == earlier.status_code + # The one declared difference: the created request is returned as the + # body where version 1 answered with none. + assert later.data == {"name": "Jon Doe"} + + def test_neither_version_records_a_request_from_an_unconfirmed_session(self): + """Both addresses read a session the way the platform reads one, and so refuse the same ones.""" + self.login(self.owner) + self.owner.is_active = False + self.owner.save() + earlier = self.client.post(EARLIER_REQUEST, {"name": "Jon Doe"}, format="json") + later = self.request_name("Jon Doe") + assert earlier.status_code == status.HTTP_401_UNAUTHORIZED + assert later.status_code == earlier.status_code + assert_error_envelope(later, expected_status=401, expected_type_slug="authn") + assert PendingNameChange.objects.count() == 0 + + def test_both_versions_read_a_name_sent_as_a_web_form(self): + """A form posts its fields encoded rather than as JSON, and both addresses take them.""" + self.login(self.owner) + body, encoded = "name=Jon+Doe", "application/x-www-form-urlencoded" + earlier = self.client.post(EARLIER_REQUEST, body, content_type=encoded) + assert earlier.status_code == status.HTTP_201_CREATED + assert self.pending_name(self.owner) == "Jon Doe" + PendingNameChange.objects.get(user=self.owner).delete() + + later = self.client.post(self.request_url, body, content_type=encoded) + assert later.status_code == earlier.status_code + assert self.pending_name(self.owner) == "Jon Doe" + + def test_both_versions_refuse_the_name_the_profile_already_carries(self): + self.login(self.owner) + earlier = self.client.post(EARLIER_REQUEST, {"name": "Original Name"}, format="json") + assert earlier.status_code == status.HTTP_400_BAD_REQUEST + assert earlier.data == { + "new_name": "The profile name given was identical to the current name.", + } + + later = self.request_name("Original Name") + assert later.status_code == earlier.status_code + assert later.data["errors"] == {"name": ["The profile already carries this name."]} + + def test_both_versions_refuse_a_name_carrying_markup(self): + self.login(self.owner) + earlier = self.client.post(EARLIER_REQUEST, {"name": "Jon Doe"}, format="json") + assert earlier.status_code == status.HTTP_400_BAD_REQUEST + assert earlier.data == { + "new_name": ["Name cannot contain the following characters: < >"], + } + + later = self.request_name("Jon Doe") + assert later.status_code == earlier.status_code + assert later.data["errors"] == { + "name": ["Name cannot contain the following characters: < >"], + } + assert PendingNameChange.objects.count() == 0 + + def test_both_versions_store_a_name_as_it_was_sent(self): + """Spaces around a name are kept, so the profile's name with one in front is another name.""" + self.login(self.owner) + for sent in (" Jon Doe ", " Original Name"): + with self.subTest(name=sent): + earlier = self.client.post(EARLIER_REQUEST, {"name": sent}, format="json") + assert earlier.status_code == status.HTTP_201_CREATED + assert self.pending_name(self.owner) == sent + PendingNameChange.objects.filter(user=self.owner).delete() + + later = self.request_name(sent) + assert later.status_code == earlier.status_code + assert later.data == {"name": sent} + assert self.pending_name(self.owner) == sent + PendingNameChange.objects.filter(user=self.owner).delete() + + def test_both_versions_refuse_a_name_of_spaces_alone(self): + self.login(self.owner) + earlier = self.client.post(EARLIER_REQUEST, {"name": " "}, format="json") + assert earlier.status_code == status.HTTP_400_BAD_REQUEST + assert earlier.data == {"new_name": ["This field may not be blank."]} + + envelope = assert_error_envelope( + self.request_name(" "), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"name": ["This field may not be blank."]} + assert PendingNameChange.objects.count() == 0 + + def test_both_versions_confirm_a_request_the_same_way(self): + PendingNameChange.objects.create(user=self.owner, new_name="Jon Doe", rationale="a test") + self.login(self.staff) + earlier = self.client.post(earlier_confirmation(self.owner.username)) + assert earlier.status_code == status.HTTP_204_NO_CONTENT + assert self.profile_name(self.owner) == "Jon Doe" + earlier_history = self.owner.profile.get_meta()["old_names"] + + self.owner.profile.name = "Original Name" + self.owner.profile.save() + PendingNameChange.objects.create(user=self.owner, new_name="Jon Doe", rationale="a test") + later = self.client.post(self.confirmation_url(self.owner.username)) + assert later.status_code == earlier.status_code + assert self.profile_name(self.owner) == "Jon Doe" + later_history = self.owner.profile.get_meta()["old_names"] + assert [entry[:2] for entry in later_history] == [entry[:2] for entry in earlier_history] * 2 + + def test_both_versions_refuse_a_confirmation_from_a_caller_who_is_not_staff(self): + PendingNameChange.objects.create(user=self.owner, new_name="Jon Doe", rationale="a test") + self.login(self.other) + earlier = self.client.post(earlier_confirmation(self.owner.username)) + assert earlier.status_code == status.HTTP_403_FORBIDDEN + assert earlier.data is None + + later = self.client.post(self.confirmation_url(self.owner.username)) + assert later.status_code == earlier.status_code + # The one declared difference: version 1 answers with an empty body. + assert_error_envelope(later, expected_status=403, expected_type_slug="authz") + assert self.profile_name(self.owner) == "Original Name" + + def test_a_name_no_account_holds_is_not_found(self): + """A confirmation addressed to no account is refused before anything is looked up.""" + self.login(self.staff) + later = self.client.post(self.confirmation_url("nobody-at-all")) + assert_error_envelope( + later, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + + +#: What each address costs, measured on the version 1 address first and +#: asserted on both. Neither version reads anything per row: the request reads +#: the caller's profile and stores the name asked for, and the confirmation +#: reads the user, their request and their profile before writing the new name +#: and deleting the request. +REQUEST_QUERIES = 20 +CONFIRMATION_QUERIES = 20 + + +@pytest.fixture(name="name_change") +@skip_unless_lms +def name_change_fixture(client, django_user_model): # pylint: disable=unused-argument + """Sign a staff caller in beside the learner whose profile is renamed.""" + staff = UserFactory.create(username="count-name-staff", password=PASSWORD, is_staff=True) + owner = UserFactory.create(username="count-name-owner", password=PASSWORD) + owner.profile.name = "Original Name" + owner.profile.save() + assert client.login(username=staff.username, password=PASSWORD) + # The first authenticated request of a session populates caches the rest + # of the session reuses; the counts below are the steady-state ones. + client.post(reverse("user_v2:name_change_list"), {"name": "Warm Up"}, content_type="application/json") + PendingNameChange.objects.all().delete() + return staff, owner + + +@pytest.mark.django_db +@skip_unless_lms +def test_a_request_costs_what_the_earlier_address_costs(client, name_change, django_assert_num_queries): + body = {"name": "Jon Doe"} + with django_assert_num_queries(REQUEST_QUERIES): + earlier = client.post(EARLIER_REQUEST, body, content_type="application/json") + assert earlier.status_code == status.HTTP_201_CREATED + PendingNameChange.objects.all().delete() + + with django_assert_num_queries(REQUEST_QUERIES): + later = client.post(reverse("user_v2:name_change_list"), body, content_type="application/json") + assert later.status_code == status.HTTP_201_CREATED + + +@pytest.mark.django_db +@skip_unless_lms +def test_a_confirmation_costs_what_the_earlier_address_costs(client, name_change, django_assert_num_queries): + _staff, owner = name_change + + PendingNameChange.objects.create(user=owner, new_name="Jon Doe", rationale="a test") + with django_assert_num_queries(CONFIRMATION_QUERIES): + earlier = client.post(earlier_confirmation(owner.username)) + assert earlier.status_code == status.HTTP_204_NO_CONTENT + + PendingNameChange.objects.create(user=owner, new_name="Jon Doe Again", rationale="a test") + with django_assert_num_queries(CONFIRMATION_QUERIES): + later = client.post( + reverse("user_v2:name_change_confirmation", kwargs={"username": owner.username}), + ) + assert later.status_code == status.HTTP_204_NO_CONTENT diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_pagination.py b/openedx/core/djangoapps/user_api/v2/tests/test_pagination.py new file mode 100644 index 000000000000..3baf8f3c2276 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_pagination.py @@ -0,0 +1,167 @@ +"""Page-size handling on the paginated collections of version 2 of the User API.""" + +import inspect + +from django.test.utils import override_settings +from django.urls import reverse +from edx_rest_framework_extensions.testing import assert_error_envelope +from rest_framework import status +from rest_framework.test import APITestCase + +from common.djangoapps.student.tests.factories import AdminFactory, SuperuserFactory, UserFactory +from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory +from openedx.core.djangoapps.user_api.v2 import views as user_api_views +from openedx.core.djangoapps.user_api.v2.paginators import UserApiPagination +from openedx.core.djangolib.testing.utils import skip_unless_lms + +PASSWORD = "Password1234" +API_KEY = "page-test-api-key" + +#: Stands for the caller the directory admits: a request carrying the site's API key. +KEY_HOLDER = object() + +#: Values naming a number of rows no page can carry: none at all, fewer than +#: none, and values that are not a whole number of rows in the first place. +UNHONOURED_PAGE_SIZES = ("0", "-1", "abc", "1.5", "0x10", "%20") + +#: What a caller is told when it asks for one of them. +REFUSAL = ( + "Enter a whole number of rows of at least 1. A larger number than " + f"{UserApiPagination.max_page_size} returns {UserApiPagination.max_page_size} rows." +) + + +@skip_unless_lms +@override_settings(EDX_API_KEY=API_KEY) +class PageSizeTests(APITestCase): + """ + A page size the collection will not give is refused rather than answered. + + The size asked for is dropped by the framework when it cannot be honoured + and the default page is answered in its place, which a caller reads as the + page it asked for; the links on that page carry the value back, so a + client following them keeps sending a number that is never read. Each + collection below is checked, because the number is read by the paginator + they share and a collection that stopped sharing it would stop refusing. + """ + + def setUp(self): + super().setUp() + self.admin = AdminFactory.create(username="page-admin", password=PASSWORD) + self.learner = UserFactory.create(username="page-learner", password=PASSWORD) + # The retirement queue is read by a caller holding the capability the + # pipeline runs with, which a superuser holds along with every other. + self.pipeline = SuperuserFactory.create(username="page-pipeline", password=PASSWORD) + UserPreferenceFactory.create(user=self.learner, key="time_zone", value="UTC") + + def collections(self): + """Return each paginated collection, the caller it admits and how it is asked.""" + return { + "user_list": (KEY_HOLDER, reverse("user_v2:user_list"), {}), + "user_preference_list": (KEY_HOLDER, reverse("user_v2:user_preference_list"), {}), + "time_zone_list": (None, reverse("user_v2:time_zone_list"), {}), + "account_list": (self.learner, reverse("user_v2:account_list"), {}), + "account_verification_list": ( + self.learner, + reverse( + "user_v2:account_verification_list", + kwargs={"username": self.learner.username}, + ), + {}, + ), + "email_search_list": ( + self.admin, + reverse("user_v2:email_search_list"), + {"body": {"emails": [self.learner.email]}}, + ), + "retirement_list": (self.pipeline, reverse("user_v2:retirement_list"), {}), + } + + def ask(self, caller, address, page_size, body=None): + """Ask one collection for a page of ``page_size`` rows as ``caller``.""" + self.client.logout() + self.client.credentials() + if caller is KEY_HOLDER: + self.client.credentials(HTTP_X_EDX_API_KEY=API_KEY) + elif caller is not None: + assert self.client.login(username=caller.username, password=PASSWORD) + query = "" if page_size is None else f"?page_size={page_size}" + if body is None: + return self.client.get(f"{address}{query}") + return self.client.post(f"{address}{query}", body, format="json") + + def test_a_number_of_rows_no_page_can_carry_is_refused(self): + for name, (caller, address, extra) in self.collections().items(): + for page_size in UNHONOURED_PAGE_SIZES: + with self.subTest(collection=name, page_size=page_size): + response = self.ask(caller, address, page_size, **extra) + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"page_size": [REFUSAL]} + + def test_a_value_carrying_nothing_asks_for_nothing_and_is_answered(self): + """An empty value leaves the size alone, as an empty selector leaves the rows alone.""" + for name, (caller, address, extra) in self.collections().items(): + with self.subTest(collection=name): + response = self.ask(caller, address, "", **extra) + assert response.status_code in ( + status.HTTP_200_OK, status.HTTP_201_CREATED, + ), response.content + assert len(response.data["results"]) <= UserApiPagination.page_size + + def test_more_rows_than_a_page_carries_is_answered_with_as_many_as_it_carries(self): + """A number above the cap keeps being answered with the cap rather than refused.""" + for index in range(UserApiPagination.max_page_size + 5): + UserFactory.create(username=f"page-filler{index:03d}") + response = self.client.get( + reverse("user_v2:user_list"), {"page_size": 100000}, HTTP_X_EDX_API_KEY=API_KEY, + ) + assert response.status_code == status.HTTP_200_OK, response.content + assert len(response.data["results"]) == UserApiPagination.max_page_size + + def test_the_link_to_the_next_page_carries_a_size_the_next_page_will_give(self): + """ + A refused size cannot reach the links, and an honoured one is carried back unchanged. + + A link carrying a size the collection drops sends the client back with + the same value on every page it follows, and every one of those pages + is a different size from the one it asked for. + """ + for index in range(5): + UserFactory.create(username=f"page-walked{index:03d}") + self.client.credentials(HTTP_X_EDX_API_KEY=API_KEY) + response = self.client.get(reverse("user_v2:user_list"), {"page_size": 2}) + assert response.status_code == status.HTTP_200_OK, response.content + assert len(response.data["results"]) == 2 + assert response.data["next"].endswith("?page=2&page_size=2") + + followed = self.client.get(response.data["next"]) + assert followed.status_code == status.HTTP_200_OK, followed.content + assert len(followed.data["results"]) == 2 + + def test_every_collection_of_this_version_that_pages_is_checked_here(self): + """ + The refusal lives in one paginator, so the check is only worth what it covers. + + A collection added later that pages its rows with the same paginator + is listed above or this fails, rather than the refusal quietly + applying to an address nothing exercises. + """ + paging = { + name + for name, member in inspect.getmembers(user_api_views, inspect.isclass) + if getattr(member, "pagination_class", None) is UserApiPagination + } + assert paging == { + "AccountViewSet", + "CountryTimeZoneViewSet", + "EmailSearchViewSet", + "IDVerificationViewSet", + "RetirementViewSet", + "UserPreferenceViewSet", + "UserViewSet", + } + assert len(self.collections()) == len(paging) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_parity.py b/openedx/core/djangoapps/user_api/v2/tests/test_parity.py new file mode 100644 index 000000000000..b3f846b747f5 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_parity.py @@ -0,0 +1,551 @@ +"""Behaviour parity between the version 1 and version 2 User API addresses.""" + +import json + +import pytest +from django.contrib.auth.models import User # pylint: disable=imported-auth-user +from django.core.exceptions import ObjectDoesNotExist +from django.test.utils import override_settings +from django.urls import reverse +from edx_rest_framework_extensions.testing import assert_error_envelope +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey +from rest_framework import status +from rest_framework.test import APITestCase + +from common.djangoapps.student.tests.factories import AdminFactory, UserFactory +from openedx.core.djangoapps.django_comment_common.models import Role +from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory +from openedx.core.djangolib.testing.utils import skip_unless_lms + +PASSWORD = "Password1234" +API_KEY = "parity-test-api-key" +COURSE_KEY = CourseKey.from_string("course-v1:edX+DemoX+Demo") + +#: Fields that legitimately differ per request and are ignored in the diff. +VOLATILE_FIELDS = {"instance", "next", "previous"} + +#: Differences the plan declares intentional, per case. Each entry is a +#: (json path, reason) pair and the diff must contain all of them and nothing else. +INTENTIONAL_DIFFERENCES = { + "user_list": [ + ("results[0].url", "the member address is keyed by username instead of database id"), + ("results[1].url", "the member address is keyed by username instead of database id"), + ], + "user_detail": [ + ("url", "the member address is keyed by username instead of database id"), + ], + "preference_list": [ + ( + "results[0].user", + "the embedded owner is a member of every row and is null unless the full preset is asked for", + ), + ("results[0].username", "the owner's username is carried in place of the embedded user object"), + ("results[0].url", "the member address is keyed by username and preference key"), + ( + "results[1].user", + "the embedded owner is a member of every row and is null unless the full preset is asked for", + ), + ("results[1].username", "the owner's username is carried in place of the embedded user object"), + ("results[1].url", "the member address is keyed by username and preference key"), + ], + "preference_detail": [ + ("user", "the embedded owner is a member of every row and is null unless the full preset is asked for"), + ("username", "the owner's username is carried in place of the embedded user object"), + ("url", "the member address is keyed by username and preference key"), + ], + "preference_key_filter": [ + ("results[0].url", "the member address is keyed by username instead of database id"), + ], + "forum_role_filter": [ + ("results[0].url", "the member address is keyed by username instead of database id"), + ], +} + + +def normalize(obj, path=""): + """Sort keys, drop volatile fields, and collect a {json path: value} map for diffing.""" + flat = {} + if isinstance(obj, dict): + for key in sorted(obj): + if key in VOLATILE_FIELDS: + continue + flat.update(normalize(obj[key], f"{path}.{key}" if path else key)) + elif isinstance(obj, list): + for index, item in enumerate(obj): + flat.update(normalize(item, f"{path}[{index}]")) + else: + flat[path] = obj + return flat + + +def diff(legacy, new): + """Return (every changed path, the changed paths no declared difference covers).""" + left, right = normalize(legacy), normalize(new) + return {key for key in set(left) | set(right) if left.get(key, "") != right.get(key, "")} + + +def unexpected(changed, intentional): + """Return the changed paths that no declared intentional difference covers.""" + declared = {path for path, _reason in intentional} + return { + key for key in changed + if not any(key == path or key.startswith(path + ".") or key.startswith(path + "[") for path in declared) + } + + +@skip_unless_lms +@override_settings(EDX_API_KEY=API_KEY) +class ParityTests(APITestCase): + """The same fixtures through the legacy and the conforming address.""" + + def setUp(self): + super().setUp() + self.admin = AdminFactory.create(username="parity-admin", password=PASSWORD) + self.user = UserFactory.create( + username="parity-user", email="parity-user@example.com", password=PASSWORD, + ) + self.preference = UserPreferenceFactory.create(user=self.user, key="pref-lang", value="en") + role = Role.objects.create(course_id=COURSE_KEY, name="Moderator") + role.users.add(self.user) + + def legacy(self, address, params=None): + """Call a version 1 address with the API key it is gated by.""" + return self.client.get(address, params or {}, HTTP_X_EDX_API_KEY=API_KEY) + + def conforming(self, address, params=None): + """Call a version 2 address with the same API key.""" + return self.client.get(address, params or {}, HTTP_X_EDX_API_KEY=API_KEY) + + def assert_parity(self, legacy, new, case, expected_status=status.HTTP_200_OK): + """ + Assert the two bodies differ only where the case declares they do. + + Rows are compared where each version put them, so a page whose rows + move is a difference like any other and has to be declared. + """ + assert legacy.status_code == expected_status, legacy.content + assert new.status_code == expected_status, new.content + legacy_body = json.loads(legacy.content) + new_body = json.loads(new.content) + changed = diff(legacy_body, new_body) + intentional = INTENTIONAL_DIFFERENCES[case] + assert not unexpected(changed, intentional), ( + f"undeclared differences: {sorted(unexpected(changed, intentional))}\n" + f"legacy={legacy_body}\nnew={new_body}" + ) + for path, reason in intentional: + assert any( + key == path or key.startswith(path + ".") or key.startswith(path + "[") + for key in changed + ), f"declared difference did not occur: {path} ({reason})" + + def test_user_list_parity(self): + legacy = self.legacy("/api/user/v1/users/", {"page_size": 100}) + new = self.conforming(reverse("user_v2:user_list"), {"page_size": 100}) + self.assert_parity(legacy, new, "user_list") + + def test_the_conforming_page_is_ordered_by_username_whatever_the_creation_order(self): + """Version 1 applies no ordering, so only the conforming side can be pinned to a sequence.""" + for username in ("parity-zulu", "parity-alpha"): + UserFactory.create(username=username, email=f"{username}@example.com", password=PASSWORD) + + new = self.conforming(reverse("user_v2:user_list"), {"page_size": 100}) + usernames = [row["username"] for row in json.loads(new.content)["results"]] + assert usernames == ["parity-admin", "parity-alpha", "parity-user", "parity-zulu"] + + legacy = self.legacy("/api/user/v1/users/", {"page_size": 100}) + legacy_usernames = [row["username"] for row in json.loads(legacy.content)["results"]] + assert sorted(legacy_usernames) == usernames + + def directory_addresses(self): + """Return each directory address of version 1 beside the version 2 address replacing it.""" + return { + "user_list": ("/api/user/v1/users/", reverse("user_v2:user_list")), + "user_detail": ( + f"/api/user/v1/users/{self.user.id}/", + reverse("user_v2:user_detail", kwargs={"username": "parity-user"}), + ), + "preference_list": ("/api/user/v1/user_prefs/", reverse("user_v2:user_preference_list")), + "preference_detail": ( + f"/api/user/v1/user_prefs/{self.preference.id}/", + reverse( + "user_v2:user_preference_detail", + kwargs={"username": "parity-user", "key": "pref-lang"}, + ), + ), + } + + def test_the_api_key_alone_admits_at_both_versions(self): + """The header the version 1 addresses are read with is what admits a caller to version 2.""" + for name, (legacy_address, address) in self.directory_addresses().items(): + with self.subTest(address=name): + legacy = self.legacy(legacy_address) + assert legacy.status_code == status.HTTP_200_OK, legacy.content + new = self.conforming(address) + assert new.status_code == status.HTTP_200_OK, new.content + + def test_a_staff_account_without_the_key_is_refused_at_both_versions(self): + assert self.client.login(username=self.admin.username, password=PASSWORD) + for name, (legacy_address, address) in self.directory_addresses().items(): + with self.subTest(address=name): + legacy = self.client.get(legacy_address) + assert legacy.status_code == status.HTTP_403_FORBIDDEN, legacy.content + envelope = assert_error_envelope( + self.client.get(address), expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + assert envelope["detail"] == json.loads(legacy.content)["detail"] + + def test_a_key_other_than_the_sites_is_refused_at_both_versions(self): + for name, (legacy_address, address) in self.directory_addresses().items(): + with self.subTest(address=name): + legacy = self.client.get(legacy_address, HTTP_X_EDX_API_KEY=f"{API_KEY}-other") + assert legacy.status_code == status.HTTP_401_UNAUTHORIZED, legacy.content + assert_error_envelope( + self.client.get(address, HTTP_X_EDX_API_KEY=f"{API_KEY}-other"), + expected_status=status.HTTP_401_UNAUTHORIZED, expected_type_slug="authn", + ) + + def test_a_name_with_spaces_before_it_selects_nothing_in_either_version(self): + """ + Both versions match a username exactly as it was sent. + + The directory addresses of version 1 take no username, so its account + list, the one place it selects by name, is the reading compared here. + """ + assert self.client.login(username=self.admin.username, password=PASSWORD) + exact = self.client.get("/api/user/v1/accounts", {"username": "parity-user"}) + padded = self.client.get("/api/user/v1/accounts", {"username": " parity-user"}) + self.client.logout() + assert exact.status_code == status.HTTP_200_OK + assert [account["username"] for account in json.loads(exact.content)] == ["parity-user"] + assert padded.status_code == status.HTTP_404_NOT_FOUND + + for name in ("user_v2:user_list", "user_v2:user_preference_list"): + with self.subTest(address=name): + selected = self.conforming(reverse(name), {"username": "parity-user"}) + assert [row["username"] for row in json.loads(selected.content)["results"]] == ["parity-user"] + padded = self.conforming(reverse(name), {"username": " parity-user"}) + assert padded.status_code == status.HTTP_200_OK, padded.content + assert json.loads(padded.content)["results"] == [] + + def test_user_detail_parity(self): + legacy = self.legacy(f"/api/user/v1/users/{self.user.id}/") + new = self.conforming(reverse("user_v2:user_detail", kwargs={"username": "parity-user"})) + self.assert_parity(legacy, new, "user_detail") + + def test_preference_list_parity(self): + """More than one row, so the comparison pairs rows rather than reading a single one.""" + UserPreferenceFactory.create(user=self.user, key="pref-time", value="UTC") + legacy = self.legacy("/api/user/v1/user_prefs/", {"page_size": 100}) + new = self.conforming(reverse("user_v2:user_preference_list"), {"page_size": 100}) + assert len(json.loads(new.content)["results"]) == 2 + self.assert_parity(legacy, new, "preference_list") + + def test_the_conforming_preference_page_is_ordered_by_owner_and_key(self): + """Version 1 applies no ordering, so only the conforming side can be pinned to a sequence.""" + UserPreferenceFactory.create(user=self.user, key="pref-time", value="UTC") + UserPreferenceFactory.create(user=self.admin, key="pref-zone", value="UTC") + + new = self.conforming(reverse("user_v2:user_preference_list"), {"page_size": 100}) + rows = [(row["username"], row["key"]) for row in json.loads(new.content)["results"]] + assert rows == [ + ("parity-admin", "pref-zone"), + ("parity-user", "pref-lang"), + ("parity-user", "pref-time"), + ] + + legacy = self.legacy("/api/user/v1/user_prefs/", {"page_size": 100}) + legacy_rows = [ + (row["user"]["username"], row["key"]) for row in json.loads(legacy.content)["results"] + ] + assert legacy_rows == [ + ("parity-user", "pref-lang"), + ("parity-user", "pref-time"), + ("parity-admin", "pref-zone"), + ] + assert sorted(legacy_rows) == rows + + def test_preference_detail_parity(self): + legacy = self.legacy(f"/api/user/v1/user_prefs/{self.preference.id}/") + new = self.conforming( + reverse( + "user_v2:user_preference_detail", + kwargs={"username": "parity-user", "key": "pref-lang"}, + ), + ) + self.assert_parity(legacy, new, "preference_detail") + + def test_preference_key_filter_parity(self): + legacy = self.legacy("/api/user/v1/preferences/pref-lang/users/", {"page_size": 100}) + new = self.conforming( + reverse("user_v2:user_list"), {"preference_key": "pref-lang", "page_size": 100}, + ) + self.assert_parity(legacy, new, "preference_key_filter") + + def test_forum_role_filter_parity(self): + legacy = self.legacy( + "/api/user/v1/forum_roles/Moderator/users/", + {"course_id": str(COURSE_KEY), "page_size": 100}, + ) + new = self.conforming( + reverse("user_v2:user_list"), + {"forum_role": "Moderator", "course_key": str(COURSE_KEY), "page_size": 100}, + ) + self.assert_parity(legacy, new, "forum_role_filter") + + def test_full_preset_reproduces_the_legacy_embedded_user(self): + legacy = self.legacy(f"/api/user/v1/user_prefs/{self.preference.id}/") + new = self.conforming( + reverse( + "user_v2:user_preference_detail", + kwargs={"username": "parity-user", "key": "pref-lang"}, + ), + {"view": "full"}, + ) + legacy_user = json.loads(legacy.content)["user"] + new_user = json.loads(new.content)["user"] + assert set(legacy_user) == set(new_user) + for field in ("id", "email", "name", "username", "preferences"): + assert legacy_user[field] == new_user[field], field + assert legacy_user["url"].endswith(f"/user_api/v1/users/{self.user.id}/") + assert new_user["url"].endswith("/api/user/v2/users/parity-user/") + + def test_a_user_without_a_profile_is_a_row_here_and_a_failure_at_version_one(self): + """Declared difference: the version 1 entry cannot render a user who has no profile row.""" + profileless = User.objects.create_user("parity-profileless", "pp@example.com", PASSWORD) + with pytest.raises(ObjectDoesNotExist): + self.legacy(f"/api/user/v1/users/{profileless.id}/") + + new = self.conforming( + reverse("user_v2:user_detail", kwargs={"username": "parity-profileless"}), + ) + assert new.status_code == status.HTTP_200_OK + body = json.loads(new.content) + assert body["name"] is None + assert body["username"] == "parity-profileless" + + def test_time_zone_rows_are_unchanged_inside_the_new_envelope(self): + legacy = json.loads(self.legacy("/api/user/v1/preferences/time_zones/").content) + new = json.loads(self.conforming(reverse("user_v2:time_zone_list"), {"page_size": 100}).content) + assert isinstance(legacy, list) + assert new["count"] == len(legacy) + assert new["results"] == legacy[:100] + + def test_country_filtered_time_zones_are_unchanged(self): + legacy = json.loads( + self.legacy("/api/user/v1/preferences/time_zones/", {"country_code": "FR"}).content, + ) + new = json.loads( + self.conforming(reverse("user_v2:time_zone_list"), {"country_code": "FR"}).content, + ) + assert new["results"] == legacy + + def test_unauthenticated_bodies_differ_as_declared(self): + legacy = self.client.get("/api/user/v1/users/") + assert legacy.status_code == status.HTTP_401_UNAUTHORIZED + assert json.loads(legacy.content) == { + "detail": "Authentication credentials were not provided.", + } + + new = self.client.get(reverse("user_v2:user_list")) + envelope = assert_error_envelope( + new, expected_status=status.HTTP_401_UNAUTHORIZED, expected_type_slug="authn", + ) + assert envelope["title"] == "Authentication Required" + assert envelope["detail"] == "Authentication credentials were not provided." + assert envelope["instance"] == "/api/user/v2/users/" + + def test_forbidden_bodies_differ_as_declared(self): + assert self.client.login(username=self.user.username, password=PASSWORD) + legacy = self.client.get("/api/user/v1/users/") + assert legacy.status_code == status.HTTP_403_FORBIDDEN + assert json.loads(legacy.content) == { + "detail": "You do not have permission to perform this action.", + } + + new = self.client.get(reverse("user_v2:user_list")) + envelope = assert_error_envelope( + new, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + assert envelope["title"] == "Permission Denied" + assert envelope["detail"] == "You do not have permission to perform this action." + + def test_a_missing_course_parameter_keeps_its_status_and_changes_its_body(self): + """The legacy address already refused this caller; only the shape of the refusal moves.""" + legacy = self.legacy("/api/user/v1/forum_roles/Moderator/users/") + assert legacy.status_code == status.HTTP_400_BAD_REQUEST + assert json.loads(legacy.content) == {"detail": "course_id must be specified"} + + new = self.conforming(reverse("user_v2:user_list"), {"forum_role": "Moderator"}) + envelope = assert_error_envelope( + new, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "course_key": ["This parameter is required when forum_role is supplied."], + } + + def test_an_unparsable_course_parameter_is_answered_rather_than_raised(self): + """Declared difference: the version 1 address lets the key error escape the handler.""" + with pytest.raises(InvalidKeyError): + self.legacy("/api/user/v1/forum_roles/Moderator/users/", {"course_id": "not a course"}) + + new = self.conforming( + reverse("user_v2:user_list"), {"forum_role": "Moderator", "course_key": "not a course"}, + ) + envelope = assert_error_envelope( + new, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == {"course_key": ["Enter a valid course key."]} + + def test_a_database_identifier_is_refused_instead_of_narrowing_nothing(self): + """The directory is keyed by username, and says so rather than answering an unfiltered page.""" + refused = self.conforming(reverse("user_v2:user_list"), {"id": self.user.id, "page_size": 100}) + envelope = assert_error_envelope( + refused, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert sorted(envelope["errors"]) == ["id"] + + def test_an_identifier_held_by_a_caller_resolves_to_the_username_the_entry_is_keyed_by(self): + """The step an integration holding identifiers takes before addressing an entry.""" + assert self.client.login(username=self.admin.username, password=PASSWORD) + resolved = self.client.get("/api/user/v1/accounts", {"lms_user_id": self.user.id}) + self.client.logout() + assert resolved.status_code == status.HTTP_200_OK + assert [account["username"] for account in json.loads(resolved.content)] == ["parity-user"] + + entry = self.conforming(reverse("user_v2:user_detail", kwargs={"username": "parity-user"})) + assert entry.status_code == status.HTTP_200_OK + assert json.loads(entry.content)["id"] == self.user.id + + +@skip_unless_lms +@override_settings(EDX_API_KEY=API_KEY) +class RefusedRequestMessageTests(APITestCase): + """ + What a person reads when a request of either version is refused. + + The earlier addresses answer a refusal with one sentence, and the + standardized body of this version keeps a member for that sentence beside + the list of what each parameter was refused for. Where the refusal names + parameters, that sentence was being built by printing the list itself, so + the quoting, the bracketing and the class names of the data structure + holding it reached everything that shows the sentence to a person: the + message of an exception a generated client raises, a line in an + integration's log, the notification a page puts on the screen. + """ + + def setUp(self): + super().setUp() + self.admin = AdminFactory.create(username="refusal-admin", password=PASSWORD) + + def refused(self, address, params): + """Call a version 2 address as an administrator carrying the site's API key.""" + assert self.client.login(username=self.admin.username, password=PASSWORD) + response = self.client.get(address, params, HTTP_X_EDX_API_KEY=API_KEY) + self.client.logout() + return assert_error_envelope( + response, + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + + def test_the_sentence_of_a_refusal_naming_one_parameter_is_the_sentence_that_parameter_got(self): + cases = [ + ( + reverse("user_v2:user_list"), + {"forum_role": "Moderator"}, + {"course_key": ["This parameter is required when forum_role is supplied."]}, + "course_key: This parameter is required when forum_role is supplied.", + ), + ( + reverse("user_v2:user_list"), + {"is_active": "maybe"}, + {"is_active": ["Enter true or false."]}, + "is_active: Enter true or false.", + ), + ( + reverse("user_v2:user_preference_list"), + {"page_size": "0"}, + { + "page_size": [ + "Enter a whole number of rows of at least 1. " + "A larger number than 100 returns 100 rows.", + ], + }, + "page_size: Enter a whole number of rows of at least 1. " + "A larger number than 100 returns 100 rows.", + ), + ] + for address, params, expected_errors, expected_sentence in cases: + with self.subTest(address=address, params=params): + envelope = self.refused(address, params) + assert envelope["errors"] == expected_errors + assert envelope["detail"] == expected_sentence + + def test_the_sentence_of_a_refusal_naming_several_parameters_names_each_of_them(self): + """Every parameter refused is in the sentence, in the order the list carries them.""" + envelope = self.refused(reverse("user_v2:user_list"), {"nope": "1", "alsonope": "2"}) + assert sorted(envelope["errors"]) == ["alsonope", "nope"] + assert envelope["detail"] == " ".join( + f"{name}: {message}" + for name, messages in envelope["errors"].items() + for message in messages + ) + assert envelope["detail"].startswith("alsonope: No such query parameter.") + + def test_a_refusal_that_names_nothing_in_particular_is_spelled_on_its_own(self): + """A body of the wrong shape is refused with no property to hang the refusal on.""" + assert self.client.login(username=self.admin.username, password=PASSWORD) + response = self.client.post(reverse("user_v2:email_search_list"), [], format="json") + self.client.logout() + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "non_field_errors": ["Invalid data. Expected a dictionary, but got list."], + } + assert envelope["detail"] == "Invalid data. Expected a dictionary, but got list." + + def test_a_refusal_naming_no_parameter_says_what_it_has_always_said(self): + """Only the refusals built from a list of parameters change; the rest are untouched.""" + assert self.client.login(username=self.admin.username, password=PASSWORD) + missing = self.client.get( + reverse("user_v2:user_detail", kwargs={"username": "nobody"}), HTTP_X_EDX_API_KEY=API_KEY, + ) + envelope = assert_error_envelope( + missing, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + assert envelope["detail"] == "Not found." + assert "errors" not in envelope + + def test_no_sentence_of_a_refusal_carries_the_printed_form_of_a_list_or_a_mapping(self): + """ + One sweep over the refusals this version produces, rather than a case each. + + A sentence built by printing a data structure carries the brackets, + the quotes and the class names that write it, none of which belong in + something a person reads. + """ + refusals = [ + (reverse("user_v2:user_list"), {"is_active": "maybe"}), + (reverse("user_v2:user_list"), {"forum_role": "Moderator"}), + (reverse("user_v2:user_list"), {"course_key": "course-v1:edX+DemoX+Demo"}), + (reverse("user_v2:user_list"), {"ordering": "password"}), + (reverse("user_v2:user_list"), {"nope": "1"}), + (reverse("user_v2:user_list"), {"page_size": "0"}), + (reverse("user_v2:user_preference_list"), {"ordering": "nope"}), + (reverse("user_v2:user_preference_list"), {"page_size": "-1"}), + (reverse("user_v2:time_zone_list"), {"country_code": "nowhere"}), + ] + for address, params in refusals: + with self.subTest(address=address, params=params): + envelope = self.refused(address, params) + sentence = envelope["detail"] + assert isinstance(sentence, str) + assert not any(mark in sentence for mark in ("{", "}", "[", "]", "ErrorDetail(")), sentence + assert sentence == " ".join( + f"{name}: {message}" + for name, messages in envelope["errors"].items() + for message in messages + ) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_preference_parity.py b/openedx/core/djangoapps/user_api/v2/tests/test_preference_parity.py new file mode 100644 index 000000000000..d06273bfa4fd --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_preference_parity.py @@ -0,0 +1,614 @@ +"""Behaviour parity between the version 1 and version 2 account preference addresses.""" + +import json +from urllib.parse import quote + +from edx_rest_framework_extensions.auth.jwt.tests.utils import generate_jwt +from edx_rest_framework_extensions.testing import assert_error_envelope +from rest_framework import status + +from openedx.core.djangoapps.user_api.models import UserPreference +from openedx.core.djangolib.testing.utils import skip_unless_lms + +from .test_email_opt_in_views import COURSE_KEY, EARLIER_ADDRESS, UNVERSIONED_EARLIER_ADDRESS +from .test_parity import diff, unexpected +from .test_preference_views import ( + MERGE_PATCH, + SORTED_NAMES, + UNREACHABLE_KEYS, + UNSORTED_NAMES, + PreferenceApiTestCase, + earlier_document_address, + earlier_member_address, +) + +#: Preference names a filled-in account stores, in the numbers the document is +#: compared at: none, one, and enough that a body would be paged if it were a +#: list of them. +STORED_COUNTS = (0, 1, 25) + +#: Time zones the preference holds, sent with whitespace around them, which +#: makes each of them a value it does not hold. +TIME_ZONES_WITH_WHITESPACE = (" Asia/Tokyo", "Asia/Tokyo ", "\tEurope/Paris\n") + +#: Preference names with whitespace at one end or both: one the account already +#: stores without it, one whose value the service checks, and one it holds +#: nothing under at all. +NAMES_WITH_WHITESPACE = (" account_privacy", "account_privacy ", "\taccount_privacy", " time_zone", " fresh ") + +#: The members every refusal of this version carries, which the earlier +#: addresses answer with bodies of their own instead. +ENVELOPE_MEMBERS = {"type", "title", "status", "detail", "instance"} + +#: Differences each case is expected to show, as (json path, reason) pairs: the +#: body compared must carry all of them and nothing else. The empty path is the +#: whole body, which is a value rather than an object at one of the addresses +#: compared. A difference in the order of the names a body carries is not a +#: path and is pinned by the test named for it instead. +INTENTIONAL_DIFFERENCES = { + "document": [], + "staff_document": [], + "sorted_document": [], + "member": [ + ("", "the earlier address answers with the stored value and nothing else"), + ("key", "the body names the preference the value belongs to"), + ("value", "the value is carried under a property of an object"), + ], + "anonymous_refusal": [ + ("type", "a refusal names the kind of fault it reports"), + ("title", "a refusal carries a summary of the kind of fault it reports"), + ("status", "a refusal carries the status it was answered with"), + ], +} + + +def assert_parity(earlier, current, case): + """Assert the two bodies differ only where the case declares they do.""" + changed = diff(earlier, current) + intentional = INTENTIONAL_DIFFERENCES[case] + assert not unexpected(changed, intentional), ( + f"undeclared differences: {sorted(unexpected(changed, intentional))}\n" + f"earlier={earlier}\ncurrent={current}" + ) + for path, reason in intentional: + assert any( + key == path or key.startswith(path + ".") or key.startswith(path + "[") + for key in changed + ), f"declared difference did not occur: {path} ({reason})" + + +@skip_unless_lms +class PreferenceDocumentParityTests(PreferenceApiTestCase): + """The document each version answers with for the same account.""" + + def documents(self, user, username=None): + """Return the document each version answers, read in one session.""" + username = username or user.username + self.login(user) + earlier = self.client.get(earlier_document_address(username)) + current = self.client.get(self.document_address(username)) + assert earlier.status_code == status.HTTP_200_OK, earlier.content + assert current.status_code == status.HTTP_200_OK, current.content + return json.loads(earlier.content), json.loads(current.content) + + def test_the_document_carries_the_same_names_and_values_however_many_are_stored(self): + for count in STORED_COUNTS: + UserPreference.objects.filter(user=self.owner).delete() + for index in range(count): + self.prefer(f"counted_{index:03d}", f"value {index}") + earlier, current = self.documents(self.owner) + assert_parity(earlier, current, "document") + assert current == { + f"counted_{index:03d}": f"value {index}" for index in range(count) + }, count + + def test_a_staff_caller_reads_the_same_document_through_either_address(self): + earlier, current = self.documents(self.staff, username=self.owner.username) + assert_parity(earlier, current, "staff_document") + assert current == {"time_zone": "Asia/Tokyo"} + + def test_the_document_this_version_answers_is_sorted_and_carries_the_same_names(self): + """ + Declared difference: the names come back in one order rather than the stored order. + + The names stored include ones that sort differently by the code of + each character than they would with the case folded, so the order + pinned here is the one order the document promises rather than any + order that happens to agree with it. + """ + for key in UNSORTED_NAMES: + self.prefer(key, "stored") + earlier, current = self.documents(self.owner) + assert_parity(earlier, current, "sorted_document") + assert list(current) == sorted(earlier) == [*SORTED_NAMES, "time_zone"] + assert current == { + **{name: "stored" for name in UNSORTED_NAMES}, "time_zone": "Asia/Tokyo", + } + + def test_the_same_merge_patch_leaves_both_versions_storing_the_same_rows(self): + self.login(self.owner) + changes = json.dumps({"account_privacy": "private", "time_zone": None}) + assert self.client.patch( + earlier_document_address(self.owner.username), changes, content_type=MERGE_PATCH, + ).status_code == status.HTTP_204_NO_CONTENT + after_earlier = self.stored() + + UserPreference.objects.filter(user=self.owner).delete() + self.prefer("time_zone", "Asia/Tokyo") + assert self.client.patch( + self.document_url, changes, content_type=MERGE_PATCH, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == after_earlier == {"account_privacy": "private"} + + def test_both_versions_refuse_a_change_sent_in_another_media_type(self): + self.login(self.owner) + body = json.dumps({"time_zone": "Europe/Paris"}) + for address in (earlier_document_address(self.owner.username), self.document_url): + assert self.client.patch( + address, body, content_type="application/json", + ).status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, address + + def test_both_versions_refuse_a_value_the_preference_cannot_hold(self): + """The message a user is shown comes from the same place in both.""" + self.login(self.owner) + body = json.dumps({"time_zone": "Asia/Africa"}) + earlier = self.client.patch( + earlier_document_address(self.owner.username), body, content_type=MERGE_PATCH, + ) + current = self.client.patch(self.document_url, body, content_type=MERGE_PATCH) + assert earlier.status_code == current.status_code == status.HTTP_400_BAD_REQUEST + message = "Value 'Asia/Africa' is not a valid time zone selection." + assert json.loads(earlier.content)["field_errors"]["time_zone"]["user_message"] == message + assert json.loads(current.content)["errors"] == {"time_zone": [message]} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_both_versions_check_a_time_zone_as_it_was_sent(self): + """A zone with whitespace around it is not one of the zones the preference holds.""" + self.login(self.owner) + for sent in TIME_ZONES_WITH_WHITESPACE: + body = json.dumps({"time_zone": sent}) + earlier = self.client.patch( + earlier_document_address(self.owner.username), body, content_type=MERGE_PATCH, + ) + current = self.client.patch(self.document_url, body, content_type=MERGE_PATCH) + assert (earlier.status_code, current.status_code) == ( + status.HTTP_400_BAD_REQUEST, status.HTTP_400_BAD_REQUEST, + ), sent + message = f"Value '{sent}' is not a valid time zone selection." + assert json.loads(earlier.content)["field_errors"]["time_zone"]["user_message"] == message + assert json.loads(current.content)["errors"] == {"time_zone": [message]} + assert self.stored() == {"time_zone": "Asia/Tokyo"}, sent + + def test_both_versions_store_any_other_value_without_the_whitespace_around_it(self): + self.login(self.owner) + body = json.dumps({"account_privacy": " private ", "pref-lang": "\tfr\n"}) + assert self.client.patch( + earlier_document_address(self.owner.username), body, content_type=MERGE_PATCH, + ).status_code == status.HTTP_204_NO_CONTENT + after_earlier = self.stored() + + UserPreference.objects.filter(user=self.owner).exclude(key="time_zone").delete() + assert self.client.patch( + self.document_url, body, content_type=MERGE_PATCH, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == after_earlier == { + "time_zone": "Asia/Tokyo", "account_privacy": "private", "pref-lang": "fr", + } + + def test_both_versions_store_a_name_sent_in_the_document_without_the_whitespace_around_it(self): + self.login(self.owner) + body = json.dumps({" notes ": "draft"}) + assert self.client.patch( + earlier_document_address(self.owner.username), body, content_type=MERGE_PATCH, + ).status_code == status.HTTP_204_NO_CONTENT + after_earlier = self.stored() + + UserPreference.objects.filter(user=self.owner, key="notes").delete() + assert self.client.patch( + self.document_url, body, content_type=MERGE_PATCH, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == after_earlier == {"time_zone": "Asia/Tokyo", "notes": "draft"} + + def test_both_versions_refuse_a_value_of_whitespace_alone(self): + self.login(self.owner) + body = json.dumps({"account_privacy": " "}) + earlier = self.client.patch( + earlier_document_address(self.owner.username), body, content_type=MERGE_PATCH, + ) + current = self.client.patch(self.document_url, body, content_type=MERGE_PATCH) + assert (earlier.status_code, current.status_code) == ( + status.HTTP_400_BAD_REQUEST, status.HTTP_400_BAD_REQUEST, + ) + message = "Preference 'account_privacy' cannot be set to an empty value." + assert json.loads(earlier.content)["field_errors"]["account_privacy"]["user_message"] == message + assert json.loads(current.content)["errors"] == {"account_privacy": [message]} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_value_that_is_not_text_was_spelled_out_and_stored_and_is_now_refused(self): + """Declared difference: a value of another type is the caller's mistake, not a string.""" + self.login(self.owner) + body = json.dumps({"account_privacy": 5}) + assert self.client.patch( + earlier_document_address(self.owner.username), body, content_type=MERGE_PATCH, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored()["account_privacy"] == "5" + + UserPreference.objects.filter(user=self.owner, key="account_privacy").delete() + assert self.client.patch( + self.document_url, body, content_type=MERGE_PATCH, + ).status_code == status.HTTP_400_BAD_REQUEST + assert "account_privacy" not in self.stored() + + def test_a_body_naming_nothing_is_refused_by_both_versions(self): + self.login(self.owner) + for address in (earlier_document_address(self.owner.username), self.document_url): + assert self.client.patch( + address, json.dumps({}), content_type=MERGE_PATCH, + ).status_code == status.HTTP_400_BAD_REQUEST, address + + +@skip_unless_lms +class PreferenceMemberParityTests(PreferenceApiTestCase): + """One preference through either version's member address.""" + + def test_the_value_is_the_same_and_this_version_names_it(self): + """Declared difference: the body is the name and the value rather than the value alone.""" + self.login(self.owner) + earlier = self.client.get(earlier_member_address(self.owner.username, "time_zone")) + current = self.client.get(self.member_url) + assert earlier.status_code == current.status_code == status.HTTP_200_OK + assert_parity(json.loads(earlier.content), json.loads(current.content), "member") + assert json.loads(earlier.content) == "Asia/Tokyo" + assert json.loads(current.content) == {"key": "time_zone", "value": "Asia/Tokyo"} + + def test_both_versions_report_a_name_the_account_stores_no_value_under_as_absent(self): + self.login(self.owner) + for address in ( + earlier_member_address(self.owner.username, "account_privacy"), + self.member_address(self.owner.username, "account_privacy"), + ): + assert self.client.get(address).status_code == status.HTTP_404_NOT_FOUND, address + assert self.client.delete(address).status_code == status.HTTP_404_NOT_FOUND, address + + def test_the_names_the_earlier_member_route_cannot_carry_are_addressed_here(self): + """Declared difference: the key half of the address takes the whole segment.""" + self.login(self.owner) + for key in UNREACHABLE_KEYS: + self.prefer(key, "stored") + earlier = self.client.get(earlier_member_address(self.owner.username, key)) + assert earlier.status_code == status.HTTP_404_NOT_FOUND, key + assert earlier["Content-Type"].startswith("text/html"), key + current = self.client.get(self.member_address(self.owner.username, key)) + assert current.status_code == status.HTTP_200_OK, key + assert json.loads(current.content) == {"key": key, "value": "stored"}, key + + def test_the_value_sent_is_stored_by_both_versions(self): + self.login(self.owner) + assert self.client.put( + earlier_member_address(self.owner.username, "time_zone"), + json.dumps("Europe/Paris"), + content_type="application/json", + ).status_code == status.HTTP_204_NO_CONTENT + after_earlier = self.stored() + + assert self.put_value( + self.member_url, {"value": "Europe/Paris"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == after_earlier == {"time_zone": "Europe/Paris"} + + def test_a_value_sent_as_a_bare_string_is_no_longer_read(self): + """Declared difference: the value travels as a property of an object.""" + self.login(self.owner) + refused = self.client.put( + self.member_url, json.dumps("Europe/Paris"), content_type="application/json", + ) + assert refused.status_code == status.HTTP_400_BAD_REQUEST + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_both_versions_refuse_an_empty_value_with_the_same_message(self): + self.login(self.owner) + earlier = self.client.put( + earlier_member_address(self.owner.username, "account_privacy"), + json.dumps(""), + content_type="application/json", + ) + assert earlier.status_code == status.HTTP_400_BAD_REQUEST + assert json.loads(earlier.content)["user_message"] == ( + "Preference 'account_privacy' cannot be set to an empty value." + ) + current = self.put_value( + self.member_address(self.owner.username, "account_privacy"), {"value": ""}, + ) + assert current.status_code == status.HTTP_400_BAD_REQUEST + assert json.loads(current.content)["errors"] == {"value": ["This field may not be blank."]} + + def test_both_versions_check_a_time_zone_sent_to_the_member_address_as_it_was_sent(self): + self.login(self.owner) + for sent in TIME_ZONES_WITH_WHITESPACE: + earlier = self.client.put( + earlier_member_address(self.owner.username, "time_zone"), + json.dumps(sent), + content_type="application/json", + ) + current = self.put_value(self.member_url, {"value": sent}) + assert (earlier.status_code, current.status_code) == ( + status.HTTP_400_BAD_REQUEST, status.HTTP_400_BAD_REQUEST, + ), sent + message = f"Value '{sent}' is not a valid time zone selection." + assert json.loads(earlier.content)["user_message"] == message + assert json.loads(current.content)["errors"] == {"value": [message]} + assert self.stored() == {"time_zone": "Asia/Tokyo"}, sent + + def test_both_versions_store_another_value_sent_to_the_member_address_without_the_whitespace_around_it(self): + self.login(self.owner) + assert self.client.put( + earlier_member_address(self.owner.username, "account_privacy"), + json.dumps(" private "), + content_type="application/json", + ).status_code == status.HTTP_204_NO_CONTENT + after_earlier = self.stored() + + UserPreference.objects.filter(user=self.owner, key="account_privacy").delete() + assert self.put_value( + self.member_address(self.owner.username, "account_privacy"), {"value": " private "}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == after_earlier == {"time_zone": "Asia/Tokyo", "account_privacy": "private"} + + def test_both_versions_refuse_a_value_of_whitespace_alone_with_the_same_message(self): + self.login(self.owner) + earlier = self.client.put( + earlier_member_address(self.owner.username, "account_privacy"), + json.dumps(" "), + content_type="application/json", + ) + current = self.put_value( + self.member_address(self.owner.username, "account_privacy"), {"value": " "}, + ) + assert (earlier.status_code, current.status_code) == ( + status.HTTP_400_BAD_REQUEST, status.HTTP_400_BAD_REQUEST, + ) + message = "Preference 'account_privacy' cannot be set to an empty value." + assert json.loads(earlier.content)["user_message"] == message + assert json.loads(current.content)["errors"] == {"value": [message]} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_both_versions_answer_a_name_with_whitespace_around_it_as_absent(self): + """ + Neither version stores, reads or removes anything at such an address. + + The earlier member address carries no whitespace at all. The service + stores a name without the whitespace around it, so a value stored from + such an address here could not be read back or removed there. + """ + self.login(self.owner) + self.prefer("account_privacy", "private") + for name in NAMES_WITH_WHITESPACE: + earlier = earlier_member_address(self.owner.username, quote(name)) + current = self.member_address(self.owner.username, name) + answered = { + "earlier store": self.client.put(earlier, json.dumps("Not/AZone"), content_type="application/json"), + "earlier read": self.client.get(earlier), + "earlier removal": self.client.delete(earlier), + "current store": self.put_value(current, {"value": "Not/AZone"}), + "current read": self.client.get(current), + "current removal": self.client.delete(current), + } + assert {case: answer.status_code for case, answer in answered.items()} == dict.fromkeys( + answered, status.HTTP_404_NOT_FOUND, + ), name + assert self.stored() == {"time_zone": "Asia/Tokyo", "account_privacy": "private"}, name + + def test_a_preference_is_removed_by_both_versions(self): + self.login(self.owner) + assert self.client.delete( + earlier_member_address(self.owner.username, "time_zone"), + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {} + self.prefer("time_zone", "Asia/Tokyo") + assert self.client.delete(self.member_url).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {} + + +@skip_unless_lms +class PreferenceRefusalParityTests(PreferenceApiTestCase): + """How each version refuses the same caller.""" + + def addresses(self, username): + """Return the document and member address of one account in each version.""" + return ( + (earlier_document_address(username), self.document_address(username)), + ( + earlier_member_address(username, "time_zone"), + self.member_address(username, "time_zone"), + ), + ) + + def test_both_versions_refuse_a_learner_reading_another_account(self): + self.login(self.other) + for earlier_address, address in self.addresses(self.owner.username): + earlier = self.client.get(earlier_address) + current = self.client.get(address) + assert earlier.status_code == current.status_code == status.HTTP_403_FORBIDDEN + assert earlier.content == b"" + assert set(json.loads(current.content)) >= ENVELOPE_MEMBERS + + def test_both_versions_refuse_a_learner_changing_another_account(self): + self.login(self.other) + body = json.dumps({"time_zone": "Europe/Paris"}) + earlier_document, document = self.addresses(self.owner.username)[0] + for address in (earlier_document, document): + assert self.client.patch( + address, body, content_type=MERGE_PATCH, + ).status_code == status.HTTP_403_FORBIDDEN, address + earlier_member, member = self.addresses(self.owner.username)[1] + for address in (earlier_member, member): + assert self.client.delete(address).status_code == status.HTTP_403_FORBIDDEN, address + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_both_versions_refuse_a_staff_caller_changing_another_account(self): + self.login(self.staff) + earlier = self.client.patch( + earlier_document_address(self.owner.username), + json.dumps({"time_zone": "Europe/Paris"}), + content_type=MERGE_PATCH, + ) + current = self.client.patch( + self.document_url, json.dumps({"time_zone": "Europe/Paris"}), content_type=MERGE_PATCH, + ) + assert earlier.status_code == current.status_code == status.HTTP_403_FORBIDDEN + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_both_versions_refuse_an_address_that_is_not_exactly_the_callers_name(self): + caller = self.sign_in_as_a_case_variant_of(self.owner) + assert caller.username == self.owner.username.upper() + for earlier_address, address in self.addresses(self.owner.username): + assert self.client.get(earlier_address).status_code == status.HTTP_403_FORBIDDEN + assert self.client.get(address).status_code == status.HTTP_403_FORBIDDEN + + def test_a_staff_caller_is_told_about_a_name_nobody_holds_by_both_versions(self): + self.login(self.staff) + for earlier_address, address in self.addresses("nobody"): + assert self.client.get(earlier_address).status_code == status.HTTP_404_NOT_FOUND + assert self.client.get(address).status_code == status.HTTP_404_NOT_FOUND + + def test_a_learner_is_refused_a_name_nobody_holds_by_both_versions(self): + """Neither version lets a caller learn whether a name is held from these addresses.""" + self.login(self.other) + for earlier_address, address in self.addresses("nobody"): + assert self.client.get(earlier_address).status_code == status.HTTP_403_FORBIDDEN + assert self.client.get(address).status_code == status.HTTP_403_FORBIDDEN + + def test_an_anonymous_caller_is_refused_by_both_versions(self): + """Declared difference: the refusal carries the standard body.""" + for earlier_address, address in self.addresses(self.owner.username): + earlier = self.client.get(earlier_address) + current = self.client.get(address) + assert earlier.status_code == current.status_code == status.HTTP_401_UNAUTHORIZED + assert_parity( + json.loads(earlier.content), json.loads(current.content), "anonymous_refusal", + ) + assert set(json.loads(earlier.content)) == {"detail"} + assert set(json.loads(current.content)) >= ENVELOPE_MEMBERS + + def test_both_versions_refuse_a_restricted_applications_token(self): + """ + Declared difference: the earlier member address reads no token of this kind at all. + + It answers such a token as it answers a request carrying no credentials, + while the member address of this version reads the token and refuses the + application it was issued to, as both document addresses do. + """ + (earlier_document, document), (earlier_member, member) = self.addresses(self.owner.username) + change = json.dumps({"time_zone": "Europe/Paris"}) + + def restricted(user): + return {"HTTP_AUTHORIZATION": f"JWT {generate_jwt(user, is_restricted=True)}"} + + def answers(document_address, member_address, value_body): + owner, staff = restricted(self.owner), restricted(self.staff) + return { + "owner document read": self.client.get(document_address, **owner), + "owner document description": self.client.options(document_address, **owner), + "owner document change": self.client.patch( + document_address, change, content_type=MERGE_PATCH, **owner, + ), + "owner member read": self.client.get(member_address, **owner), + "owner member description": self.client.options(member_address, **owner), + "owner member store": self.client.put( + member_address, value_body, content_type="application/json", **owner, + ), + "owner member removal": self.client.delete(member_address, **owner), + "staff document read": self.client.get(document_address, **staff), + "staff document description": self.client.options(document_address, **staff), + "staff member read": self.client.get(member_address, **staff), + "staff member description": self.client.options(member_address, **staff), + } + + earlier = answers(earlier_document, earlier_member, json.dumps("Europe/Paris")) + current = answers(document, member, json.dumps({"value": "Europe/Paris"})) + refused, unread = status.HTTP_403_FORBIDDEN, status.HTTP_401_UNAUTHORIZED + assert {case: answer.status_code for case, answer in earlier.items()} == { + "owner document read": refused, + "owner document description": refused, + "owner document change": refused, + "owner member read": unread, + "owner member description": unread, + "owner member store": unread, + "owner member removal": unread, + "staff document read": refused, + "staff document description": refused, + "staff member read": unread, + "staff member description": unread, + } + assert {case: answer.status_code for case, answer in current.items()} == dict.fromkeys(earlier, refused) + for answer in current.values(): + assert_error_envelope(answer, expected_status=refused, expected_type_slug="authz") + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_query_parameter_neither_version_reads_is_ignored_by_one_and_refused_by_the_other(self): + """Declared difference: a parameter this version does not read is refused.""" + self.login(self.owner) + for earlier_address, address in self.addresses(self.owner.username): + assert self.client.get(earlier_address, {"page": "2"}).status_code == ( + status.HTTP_200_OK + ) + assert self.client.get(address, {"page": "2"}).status_code == ( + status.HTTP_400_BAD_REQUEST + ) + + +@skip_unless_lms +class SupersededAddressAnnouncementTests(PreferenceApiTestCase): + """ + What the superseded addresses say about being superseded: nothing, in any header. + + Which addresses are superseded is published in the document and carried by + the removal ticket. The addresses themselves answer exactly as they always + have, because a response header would be a change to a version already in + use. That is pinned here so the declaration and the running code cannot + drift apart. + """ + + #: Headers an answer would carry an announcement of its own removal in. + ANNOUNCING_HEADERS = ("Deprecation", "Sunset", "Warning", "Link") + + def assert_nothing_was_announced(self, response, expected_status, address): + """Assert the answer is the one that address gives, and announces nothing.""" + assert response.status_code == expected_status, (address, response.content) + announced = [header for header in self.ANNOUNCING_HEADERS if header in response] + assert announced == [], address + + def test_no_superseded_preference_address_announces_its_own_removal(self): + self.login(self.owner) + document = earlier_document_address(self.owner.username) + member = earlier_member_address(self.owner.username, "time_zone") + exchanges = ( + (f"GET {document}", self.client.get(document), status.HTTP_200_OK), + ( + f"PATCH {document}", + self.client.patch( + document, json.dumps({"zoneinfo": "Europe/Paris"}), content_type=MERGE_PATCH, + ), + status.HTTP_204_NO_CONTENT, + ), + (f"GET {member}", self.client.get(member), status.HTTP_200_OK), + ( + f"PUT {member}", + self.client.put(member, json.dumps("Asia/Tokyo"), content_type="application/json"), + status.HTTP_204_NO_CONTENT, + ), + (f"DELETE {member}", self.client.delete(member), status.HTTP_204_NO_CONTENT), + ) + for address, response, expected_status in exchanges: + with self.subTest(address=address): + self.assert_nothing_was_announced(response, expected_status, address) + + def test_neither_address_of_the_superseded_email_choice_announces_one(self): + self.login(self.owner) + for address in (EARLIER_ADDRESS, UNVERSIONED_EARLIER_ADDRESS): + with self.subTest(address=address): + self.assert_nothing_was_announced( + self.client.post(address, {"course_id": COURSE_KEY, "email_opt_in": "True"}), + status.HTTP_200_OK, + address, + ) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_preference_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_preference_views.py new file mode 100644 index 000000000000..233deb79d3c3 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_preference_views.py @@ -0,0 +1,1760 @@ +"""Tests for the version 2 User API account preference addresses.""" + +import contextlib +import json +from unittest import mock +from urllib.parse import quote + +from django.conf import settings +from django.contrib.auth.models import AnonymousUser +from django.db import DatabaseError, connection +from django.test.utils import CaptureQueriesContext +from django.urls import resolve, reverse +from edx_rest_framework_extensions.testing import assert_error_envelope +from rest_framework import status +from rest_framework.request import Request +from rest_framework.test import APIClient, APIRequestFactory, APITestCase, force_authenticate + +from common.djangoapps.student.tests.factories import UserFactory +from common.djangoapps.util import model_utils +from common.djangoapps.util.model_utils import USER_FIELDS_CHANGED, USER_SETTINGS_CHANGED_EVENT_NAME +from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user +from openedx.core.djangoapps.user_api.errors import ( + PreferenceUpdateError, + PreferenceValidationError, + UserAPIInternalError, +) +from openedx.core.djangoapps.user_api.models import UserPreference +from openedx.core.djangoapps.user_api.preferences.api import set_user_preference +from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory +from openedx.core.djangoapps.user_api.v2.serializers import ( + PREFERENCE_NAME_MAX_LENGTH, + PREFERENCE_NAME_TOO_LONG, + PREFERENCE_NAME_UNUSABLE, +) +from openedx.core.djangoapps.user_api.v2.views import DESCRIBE_ADDRESS_ACTION, AccountPreferenceViewSet +from openedx.core.djangolib.testing.utils import skip_unless_lms + +from .test_views import MALFORMED_BODY_SLUG, REFUSED_METHOD_SLUG, UNSUPPORTED_MEDIA_TYPE_SLUG, write_queries + +PASSWORD = "Password1234" +MERGE_PATCH = "application/merge-patch+json" + +#: The query parameters these addresses read, in the order their refusal lists them. +PREFERENCE_QUERY_PARAMS = "format" + +#: The table the preferences of an account are stored in. +PREFERENCE_TABLE = "user_api_userpreference" + +#: Where the classes the platform configures record that they read a request. +DEFAULT_AUTH_RECORDER = "openedx.core.djangolib.default_auth_classes.set_custom_attribute" + +#: What the platform's own record of a request carries for a read of one of +#: these addresses: the query string it was made with and the form body it +#: carried, both empty. +REQUEST_RECORD_DATA = '{"GET": {}, "POST": {}}' + +#: Where the language a user picked is carried between requests, and the name +#: the platform stores it under once it is carried. +LANGUAGE_COOKIE = settings.LANGUAGE_COOKIE_NAME +LANGUAGE_PREFERENCE = "pref-lang" + +#: Names a preference may be stored under that the version 1 member address +#: cannot carry: the language the platform itself stores, the names the profile +#: pages store a sharing choice under, and a name ending in a separator. +UNREACHABLE_KEYS = (LANGUAGE_PREFERENCE, "visibility.bio", "visibility.") + +#: Names a preference may be stored under that no member address can carry, +#: because the name is read from one segment and a segment ends at a slash. +SLASHED_NAMES = ("a/b", "x/", "/x", "a//b") + +#: Names that address another collection at version 1 and are ordinary +#: preference names here. +COLLECTION_NAMES = ("email_opt_in", "time_zones") + +#: The longest name a preference can be stored under, and one character more. +LONGEST_NAME = "k" * PREFERENCE_NAME_MAX_LENGTH +TOO_LONG_NAME = "k" * (PREFERENCE_NAME_MAX_LENGTH + 1) + +#: Names that differ in more than their letters, stored in an order none of the +#: orders they could come back in would put them in. +UNSORTED_NAMES = ("Zeta", "alpha", "Beta", "_under", "9nine") + +#: Those names in the order they are answered in: by the code of each +#: character, which puts digits, capitals and the underscore ahead of lower +#: case. A sort that folded case would answer them in another order. +SORTED_NAMES = ["9nine", "Beta", "Zeta", "_under", "alpha"] + +#: Text carrying a character that stands for half of a pair, which has no +#: encoding of its own: it can be neither stored nor carried back in an answer. +UNENCODABLE_TEXT = "x\ud800y" + +#: How such a character is reported, by the code point that was sent. +UNENCODABLE_REFUSAL = "Surrogate characters are not allowed: U+D800." + + +def earlier_document_address(username): + """Return the version 1 address of every preference of one account.""" + return f"/api/user/v1/preferences/{username}" + + +def earlier_member_address(username, key): + """Return the version 1 address of one preference of one account.""" + return f"/api/user/v1/preferences/{username}/{key}" + + +@contextlib.contextmanager +def requests_served_outside_a_transaction(): + """Serve requests the way a database that commits each statement on its own would.""" + connected_as = connection.settings_dict + each_request_in_a_transaction = connected_as["ATOMIC_REQUESTS"] + connected_as["ATOMIC_REQUESTS"] = False + try: + yield + finally: + connected_as["ATOMIC_REQUESTS"] = each_request_in_a_transaction + + +class PreferenceApiTestCase(APITestCase): + """Fixtures shared by the version 2 account preference tests.""" + + def setUp(self): + super().setUp() + self.owner = UserFactory.create( + username="pref-owner", email="pref-owner@example.com", password=PASSWORD, + ) + self.other = UserFactory.create( + username="pref-other", email="pref-other@example.com", password=PASSWORD, + ) + self.staff = UserFactory.create( + username="pref-staff", email="pref-staff@example.com", password=PASSWORD, is_staff=True, + ) + self.prefer("time_zone", "Asia/Tokyo") + self.document_url = self.document_address(self.owner.username) + self.member_url = self.member_address(self.owner.username, "time_zone") + + def document_address(self, username): + """Return the address of every preference of one account.""" + return reverse("user_v2:account_preference_list", kwargs={"username": username}) + + def member_address(self, username, key): + """Return the address of one preference of one account.""" + return reverse( + "user_v2:account_preference_detail", + kwargs={"username": username, "key": key}, + ) + + def login(self, user): + """Sign the given user in through the session these views accept.""" + assert self.client.login(username=user.username, password=PASSWORD) + + def token_headers(self, user): + """Return the request headers that carry a token identifying the given user.""" + return {"HTTP_AUTHORIZATION": f"JWT {create_jwt_for_user(user)}"} + + def prefer(self, key, value, user=None): + """Store one preference for the given account, the owner's by default.""" + return UserPreferenceFactory.create(user=user or self.owner, key=key, value=value) + + def stored(self, user=None): + """Return the preferences an account holds, as the names and values stored.""" + return { + preference.key: preference.value + for preference in UserPreference.objects.filter(user=user or self.owner) + } + + def sign_in_as_a_case_variant_of(self, user): + """ + Sign in a caller whose own name is the given user's, in upper case. + + The variant is set in memory only, since a site that stores usernames + case-insensitively has no room for both names at once. + """ + caller = UserFactory.create( + username="pref-variant", email="pref-variant@example.com", password=PASSWORD, + ) + caller.username = user.username.upper() + self.client.force_authenticate(user=caller) + return caller + + def patch_document(self, address, body, content_type=MERGE_PATCH, client=None, **kwargs): + """Send a merge patch of the preference document.""" + return (client or self.client).patch( + address, json.dumps(body), content_type=content_type, **kwargs, + ) + + def put_value(self, address, body, client=None, **kwargs): + """Send the value one preference is to hold.""" + return (client or self.client).put( + address, json.dumps(body), content_type="application/json", **kwargs, + ) + + def read_requests(self, username, client=None, **headers): + """Return the requests that read the preferences of one account, by name.""" + sender = client or self.client + return { + "document": lambda: sender.get(self.document_address(username), **headers), + "member": lambda: sender.get( + self.member_address(username, "time_zone"), **headers, + ), + } + + def write_requests(self, username, client=None, **headers): + """Return the requests that change the preferences of one account, by name.""" + sender = client or self.client + return { + "document": lambda: self.patch_document( + self.document_address(username), + {"time_zone": "Europe/Paris"}, + client=sender, + **headers, + ), + "member": lambda: self.put_value( + self.member_address(username, "time_zone"), + {"value": "Europe/Paris"}, + client=sender, + **headers, + ), + "member removal": lambda: sender.delete( + self.member_address(username, "time_zone"), **headers, + ), + } + + def assert_all_refused(self, requests, expected_status, expected_type_slug): + """ + Assert every request named is refused the same way. + + Each request is named in the failure as well as in the subtest it is + run under, because a subtest name reaches the report only where + something reads it, and the name of the one refusal that stopped + happening is the first thing a reader of the failure needs. + """ + for case, send in dict(requests).items(): + with self.subTest(case=case): + refused = send() + assert refused.status_code == expected_status, (case, refused.content) + assert_error_envelope( + refused, + expected_status=expected_status, + expected_type_slug=expected_type_slug, + ) + + +@skip_unless_lms +class PreferenceDocumentTests(PreferenceApiTestCase): + """GET and PATCH /api/user/v2/accounts/{username}/preferences/""" + + def test_an_owner_reads_every_preference_the_account_stores(self): + self.prefer("account_privacy", "private") + self.login(self.owner) + response = self.client.get(self.document_url) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"account_privacy": "private", "time_zone": "Asia/Tokyo"} + + def test_an_account_that_stores_nothing_is_answered_with_an_empty_document(self): + self.login(self.other) + response = self.client.get(self.document_address(self.other.username)) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {} + + def test_the_names_come_back_in_one_order_whatever_order_they_were_stored_in(self): + for key in ("zoneinfo", "account_privacy", LANGUAGE_PREFERENCE, "visibility.bio"): + self.prefer(key, "stored") + self.login(self.owner) + names = list(self.client.get(self.document_url).json()) + assert names == ["account_privacy", LANGUAGE_PREFERENCE, "time_zone", "visibility.bio", "zoneinfo"] + + def test_the_order_the_names_come_back_in_is_the_code_of_each_character(self): + """Which is what the document says, and is not the order a reader of English expects.""" + for key in UNSORTED_NAMES: + self.prefer(key, "stored") + self.login(self.owner) + assert list(self.client.get(self.document_url).json()) == [*SORTED_NAMES, "time_zone"] + + def test_a_staff_caller_reads_another_accounts_preferences(self): + self.login(self.staff) + response = self.client.get(self.document_url) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"time_zone": "Asia/Tokyo"} + + def test_an_owner_changes_the_names_the_body_carries_and_leaves_the_rest(self): + self.prefer("account_privacy", "private") + self.login(self.owner) + response = self.patch_document(self.document_url, {"time_zone": "Europe/Paris"}) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert response.content == b"" + assert self.stored() == {"account_privacy": "private", "time_zone": "Europe/Paris"} + + def test_a_name_the_account_does_not_store_is_added(self): + self.login(self.owner) + assert self.patch_document( + self.document_url, {"account_privacy": "private"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {"account_privacy": "private", "time_zone": "Asia/Tokyo"} + + def test_a_null_removes_the_name_from_the_account_that_was_addressed(self): + self.prefer("time_zone", "Europe/Paris", user=self.other) + self.login(self.owner) + assert self.patch_document( + self.document_url, {"time_zone": None}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {} + assert self.stored(self.other) == {"time_zone": "Europe/Paris"} + + def test_the_document_read_back_and_sent_again_changes_nothing(self): + self.prefer("account_privacy", "private") + self.login(self.owner) + document = self.client.get(self.document_url).json() + assert self.patch_document( + self.document_url, document, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.client.get(self.document_url).json() == document + + def test_the_names_the_earlier_member_address_cannot_carry_are_changed_here(self): + self.login(self.owner) + changes = {key: "stored" for key in UNREACHABLE_KEYS} + assert self.patch_document( + self.document_url, changes, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.client.get(self.document_url).json() == { + "time_zone": "Asia/Tokyo", **changes, + } + + def test_a_value_that_is_not_text_is_refused_under_the_name_that_carried_it(self): + """The earlier address spelled a number out and stored it.""" + self.login(self.owner) + response = self.patch_document(self.document_url, {"time_zone": 5, "account_privacy": "private"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "time_zone": ["Send a value as text, or null to remove the preference."], + } + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_what_a_value_of_spaces_alone_means_is_settled_where_it_is_stored(self): + """A value arrives as text; trimming it and refusing an empty one are the service's.""" + self.login(self.owner) + assert self.patch_document( + self.document_url, {"notes": " draft "}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {"notes": "draft", "time_zone": "Asia/Tokyo"} + envelope = assert_error_envelope( + self.patch_document(self.document_url, {"other": " "}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "other": ["Preference 'other' cannot be set to an empty value."], + } + assert self.stored() == {"notes": "draft", "time_zone": "Asia/Tokyo"} + + def test_an_empty_value_is_refused_under_the_name_that_carried_it(self): + self.login(self.owner) + response = self.patch_document(self.document_url, {"account_privacy": ""}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "account_privacy": ["Preference 'account_privacy' cannot be set to an empty value."], + } + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_nothing_is_stored_unless_every_name_in_the_body_can_be(self): + self.login(self.owner) + response = self.patch_document( + self.document_url, {"account_privacy": "private", "time_zone": "Asia/Africa"}, + ) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "time_zone": ["Value 'Asia/Africa' is not a valid time zone selection."], + } + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_name_as_long_as_the_column_holds_is_stored(self): + self.login(self.owner) + assert self.patch_document( + self.document_url, {LONGEST_NAME: "stored"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {"time_zone": "Asia/Tokyo", LONGEST_NAME: "stored"} + + def test_a_name_longer_than_the_column_holds_is_refused_under_itself(self): + self.login(self.owner) + response = self.patch_document(self.document_url, {TOO_LONG_NAME: "stored"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == {TOO_LONG_NAME: [PREFERENCE_NAME_TOO_LONG]} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_value_that_cannot_be_encoded_is_refused_under_the_name_that_carried_it(self): + self.login(self.owner) + response = self.patch_document(self.document_url, {"account_privacy": UNENCODABLE_TEXT}) + assert response["Content-Type"] == "application/json" + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == {"account_privacy": [UNENCODABLE_REFUSAL]} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_name_that_cannot_be_encoded_is_refused_against_the_body(self): + """The answer could not carry the name back, so it names the body instead of the member.""" + self.login(self.owner) + for body in ( + {"account_privacy\ud800": "private"}, + {"\ud800": "private"}, + {"account_privacy\ud800": 5}, + {"account_privacy\ud800": None}, + ): + response = self.patch_document(self.document_url, body) + assert response["Content-Type"] == "application/json" + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"non_field_errors": [UNENCODABLE_REFUSAL]}, body + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_body_that_is_not_an_object_is_refused(self): + self.login(self.owner) + for body in ([], "time_zone", 5): + envelope = assert_error_envelope( + self.patch_document(self.document_url, body), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "non_field_errors": ["Send the preferences to change as an object."], + }, body + + def test_a_body_that_names_no_preference_is_refused(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.patch_document(self.document_url, {}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "non_field_errors": ["Name at least one preference to change."], + } + + def test_a_body_that_is_not_well_formed_is_refused(self): + self.login(self.owner) + assert_error_envelope( + self.client.patch(self.document_url, "{not json", content_type=MERGE_PATCH), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug=MALFORMED_BODY_SLUG, + ) + + def test_a_change_sent_in_another_media_type_is_refused(self): + """The document is changed with a merge patch, in which a null removes a name.""" + self.login(self.owner) + for content_type in ("application/json", "application/x-www-form-urlencoded"): + assert_error_envelope( + self.patch_document( + self.document_url, {"time_zone": "Europe/Paris"}, content_type=content_type, + ), + expected_status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + expected_type_slug=UNSUPPORTED_MEDIA_TYPE_SLUG, + ) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_the_document_is_not_written_whole_or_added_to(self): + self.login(self.owner) + for method in ("put", "post", "delete"): + response = getattr(self.client, method)( + self.document_url, json.dumps({}), content_type="application/json", + ) + assert_error_envelope( + response, + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug=REFUSED_METHOD_SLUG, + ) + + +@skip_unless_lms +class PreferenceMemberTests(PreferenceApiTestCase): + """GET, PUT and DELETE /api/user/v2/accounts/{username}/preferences/{key}/""" + + def test_an_owner_reads_one_preference_as_its_name_and_its_value(self): + self.login(self.owner) + response = self.client.get(self.member_url) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"key": "time_zone", "value": "Asia/Tokyo"} + + def test_a_staff_caller_reads_one_preference_of_another_account(self): + self.login(self.staff) + assert self.client.get(self.member_url).json() == { + "key": "time_zone", "value": "Asia/Tokyo", + } + + def test_a_name_the_account_stores_no_value_under_is_absent(self): + self.login(self.owner) + assert_error_envelope( + self.client.get(self.member_address(self.owner.username, "account_privacy")), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_every_name_one_segment_can_carry_is_readable_and_writable(self): + self.login(self.owner) + for key in UNREACHABLE_KEYS + COLLECTION_NAMES: + address = self.member_address(self.owner.username, key) + assert self.put_value(address, {"value": "stored"}).status_code == ( + status.HTTP_204_NO_CONTENT + ), key + assert self.client.get(address).json() == {"key": key, "value": "stored"}, key + assert self.client.delete(address).status_code == status.HTTP_204_NO_CONTENT, key + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_name_as_long_as_the_column_holds_is_given_a_value(self): + self.login(self.owner) + assert self.put_value( + self.member_address(self.owner.username, LONGEST_NAME), {"value": "stored"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {"time_zone": "Asia/Tokyo", LONGEST_NAME: "stored"} + + def test_a_longer_name_is_refused_under_the_part_of_the_address_that_carried_it(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.put_value( + self.member_address(self.owner.username, TOO_LONG_NAME), {"value": "stored"}, + ), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"key": [PREFERENCE_NAME_TOO_LONG]} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_name_nothing_can_be_stored_under_is_refused_under_that_part_too(self): + """The fault is in the address, so changing the value sent would not mend it.""" + self.login(self.owner) + envelope = assert_error_envelope( + self.put_value( + self.member_address(self.owner.username, "!!!"), {"value": "stored"}, + ), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"key": [PREFERENCE_NAME_UNUSABLE]} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_the_names_this_address_refuses_are_the_ones_the_store_refuses(self): + """ + Which names are held is the store's to decide, so each is offered to it as well. + + A name is checked before the value is read, against what the store + holds rather than against a list kept here, so a change to what it + holds shows up as this address keeping a name the store would refuse or + refusing one it would keep. + """ + self.login(self.owner) + for name in ("a!!!", "!!!", "...", "9", "visibility.bio", "pref-lang"): + UserPreference.objects.filter(user=self.owner, key=name).delete() + try: + set_user_preference(self.owner, name, "stored") + except PreferenceValidationError: + the_store_refuses_it = True + else: + the_store_refuses_it = False + UserPreference.objects.filter(user=self.owner, key=name).delete() + answered = self.put_value( + self.member_address(self.owner.username, name), {"value": "stored"}, + ) + assert (answered.status_code == status.HTTP_400_BAD_REQUEST) == the_store_refuses_it, ( + f"{name}: answered {answered.status_code}" + ) + assert (name in self.stored()) == (not the_store_refuses_it), name + + def test_a_name_no_value_can_be_stored_under_is_read_and_removed_as_absent(self): + """Nothing is stored under a name that long, so both report it missing.""" + self.login(self.owner) + address = self.member_address(self.owner.username, TOO_LONG_NAME) + for response in (self.client.get(address), self.client.delete(address)): + assert_error_envelope( + response, + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_a_value_that_cannot_be_encoded_is_refused_under_the_property_that_carried_it(self): + self.login(self.owner) + response = self.put_value(self.member_url, {"value": UNENCODABLE_TEXT}) + assert response["Content-Type"] == "application/json" + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == {"value": [UNENCODABLE_REFUSAL]} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_name_carrying_a_slash_is_stored_read_and_removed_through_the_document(self): + """ + A name is read from one segment, which ends at the slash, so no member address serves it. + + Encoding the slash does not reach one either: an address is read in its + decoded form, which puts the slash back. The name the document stores + is therefore left as it was by every method the member address serves, + and the document is the only place it is read and removed. + """ + self.login(self.owner) + for name in SLASHED_NAMES: + with self.subTest(name=name): + held = {name: "stored", "time_zone": "Asia/Tokyo"} + assert self.patch_document( + self.document_url, {name: "stored"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == held + assert self.client.get(self.document_url).json() == held + for spelling in (name, quote(name, safe="")): + address = f"{self.document_url}{spelling}/" + attempts = ( + ("get", self.client.get(address)), + ("put", self.put_value(address, {"value": "other"})), + ("delete", self.client.delete(address)), + ) + for method, response in attempts: + assert response.status_code == status.HTTP_404_NOT_FOUND, (spelling, method) + assert self.stored() == held + assert self.patch_document( + self.document_url, {name: None}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_value_is_stored_whether_or_not_the_name_already_held_one(self): + self.login(self.owner) + new = self.member_address(self.owner.username, "account_privacy") + assert self.put_value(new, {"value": "private"}).status_code == status.HTTP_204_NO_CONTENT + assert self.put_value( + self.member_url, {"value": "Europe/Paris"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {"account_privacy": "private", "time_zone": "Europe/Paris"} + + def test_a_value_that_is_not_text_is_refused_under_the_property_that_carried_it(self): + """The earlier address spelled a number out and stored it.""" + self.login(self.owner) + envelope = assert_error_envelope( + self.put_value(self.member_url, {"value": 5}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"value": ["Not a valid string."]} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_name_with_whitespace_around_it_is_absent_to_its_owner_and_refused_to_anyone_else(self): + """The caller is admitted first, so the address tells nobody else anything about it.""" + address = self.member_address(self.owner.username, " time_zone") + self.login(self.other) + assert_error_envelope( + self.put_value(address, {"value": "Europe/Paris"}), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + self.client.logout() + assert_error_envelope( + self.put_value(address, {"value": "Europe/Paris"}), + expected_status=status.HTTP_401_UNAUTHORIZED, + expected_type_slug="authn", + ) + self.login(self.owner) + for case, send in { + "store": lambda: self.put_value(address, {"value": "Europe/Paris"}), + "read": lambda: self.client.get(address), + "removal": lambda: self.client.delete(address), + "description": lambda: self.client.options(address), + }.items(): + with self.subTest(case=case): + assert_error_envelope( + send(), expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_an_empty_value_is_refused(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.put_value(self.member_url, {"value": ""}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"value": ["This field may not be blank."]} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_body_carrying_no_value_is_refused(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.put_value(self.member_url, {}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"value": ["This field is required."]} + + def test_a_value_the_preference_cannot_hold_is_refused_with_the_reason(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.put_value(self.member_url, {"value": "Asia/Africa"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "value": ["Value 'Asia/Africa' is not a valid time zone selection."], + } + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_time_zone_the_zone_database_carries_is_stored(self): + """Which zones there are is decided by the database of them, not by a list kept here.""" + self.login(self.owner) + assert self.put_value( + self.member_url, {"value": "America/Argentina/Buenos_Aires"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {"time_zone": "America/Argentina/Buenos_Aires"} + + def test_a_preference_is_removed(self): + self.login(self.owner) + assert self.client.delete(self.member_url).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {} + + def test_removing_a_name_the_account_stores_no_value_under_reports_it_absent(self): + self.login(self.owner) + assert_error_envelope( + self.client.delete(self.member_address(self.owner.username, "account_privacy")), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_one_preference_is_not_merge_patched(self): + self.login(self.owner) + assert_error_envelope( + self.client.patch( + self.member_url, json.dumps({"value": "Europe/Paris"}), content_type=MERGE_PATCH, + ), + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug=REFUSED_METHOD_SLUG, + ) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_value_sent_in_a_media_type_this_address_does_not_read_is_refused(self): + self.login(self.owner) + assert_error_envelope( + self.client.put( + self.member_url, json.dumps({"value": "Europe/Paris"}), content_type=MERGE_PATCH, + ), + expected_status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + expected_type_slug=UNSUPPORTED_MEDIA_TYPE_SLUG, + ) + + def test_a_value_submitted_as_a_form_is_read(self): + """These addresses read the media types the platform reads everywhere.""" + self.login(self.owner) + assert self.client.put( + self.member_url, "value=Europe/Paris", + content_type="application/x-www-form-urlencoded", + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {"time_zone": "Europe/Paris"} + + +@skip_unless_lms +class PreferenceAccessTests(PreferenceApiTestCase): + """Who may read and who may change the preferences of the account an address names.""" + + def test_an_anonymous_caller_is_refused_with_the_standard_body(self): + self.assert_all_refused( + {**self.read_requests(self.owner.username), **self.write_requests(self.owner.username)}, + status.HTTP_401_UNAUTHORIZED, + "authn", + ) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_learner_may_not_read_another_accounts_preferences(self): + self.login(self.other) + self.assert_all_refused( + self.read_requests(self.owner.username), status.HTTP_403_FORBIDDEN, "authz", + ) + + def test_a_learner_may_not_change_another_accounts_preferences(self): + self.login(self.other) + self.assert_all_refused( + self.write_requests(self.owner.username), status.HTTP_403_FORBIDDEN, "authz", + ) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_staff_caller_reads_but_may_not_change_another_accounts_preferences(self): + self.login(self.staff) + for case, send in self.read_requests(self.owner.username).items(): + assert send().status_code == status.HTTP_200_OK, case + self.assert_all_refused( + self.write_requests(self.owner.username), status.HTTP_403_FORBIDDEN, "authz", + ) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_staff_caller_reads_and_changes_their_own_preferences(self): + self.prefer("time_zone", "Europe/Paris", user=self.staff) + self.login(self.staff) + own = self.staff.username + for case, send in self.read_requests(own).items(): + assert send().status_code == status.HTTP_200_OK, case + assert self.write_requests(own)["document"]().status_code == status.HTTP_204_NO_CONTENT + assert self.write_requests(own)["member"]().status_code == status.HTTP_204_NO_CONTENT + assert self.write_requests(own)["member removal"]().status_code == ( + status.HTTP_204_NO_CONTENT + ) + + def test_a_token_held_by_a_disabled_staff_account_may_not_read_another_accounts_preferences(self): + """A privilege does not survive the deactivation of the account that held it.""" + headers = self.token_headers(self.staff) + assert self.client.get(self.document_url, **headers).status_code == status.HTTP_200_OK + assert self.client.get(self.member_url, **headers).status_code == status.HTTP_200_OK + self.staff.is_active = False + self.staff.save() + for address in (self.document_url, self.member_url): + assert_error_envelope( + self.client.get(address, **headers), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_a_disabled_account_still_reads_and_changes_its_own_preferences(self): + """The staff half of the check is the one a disabled account loses.""" + self.owner.is_active = False + self.owner.save() + headers = self.token_headers(self.owner) + assert self.client.get(self.document_url, **headers).json() == {"time_zone": "Asia/Tokyo"} + assert self.patch_document( + self.document_url, {"time_zone": "Europe/Paris"}, **headers, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {"time_zone": "Europe/Paris"} + + def test_a_caller_named_for_the_address_in_case_alone_is_refused(self): + """Two names differing in case alone are two names, and only one of them is addressed.""" + self.sign_in_as_a_case_variant_of(self.owner) + self.assert_all_refused( + { + **self.read_requests(self.owner.username), + **self.write_requests(self.owner.username), + }, + status.HTTP_403_FORBIDDEN, + "authz", + ) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_learner_naming_an_account_that_does_not_exist_is_refused_without_learning_that(self): + self.login(self.other) + self.assert_all_refused( + {**self.read_requests("nobody"), **self.write_requests("nobody")}, + status.HTTP_403_FORBIDDEN, + "authz", + ) + + def test_an_administrator_naming_an_account_that_does_not_exist_is_told_so(self): + self.login(self.staff) + self.assert_all_refused( + self.read_requests("nobody"), status.HTTP_404_NOT_FOUND, "not-found", + ) + + def test_an_administrator_may_not_change_the_preferences_of_a_name_nobody_holds(self): + """The refusal is the one every change to another account gets, missing name or not.""" + self.login(self.staff) + self.assert_all_refused( + self.write_requests("nobody"), status.HTTP_403_FORBIDDEN, "authz", + ) + + def test_an_account_with_an_unconfirmed_address_reads_and_changes_its_own_preferences(self): + """The site sends such a user to the pages that read and write these preferences.""" + self.owner.is_active = False + self.owner.save() + self.login(self.owner) + assert self.client.get(self.document_url).json() == {"time_zone": "Asia/Tokyo"} + assert self.client.get(self.member_url).status_code == status.HTTP_200_OK + assert self.patch_document( + self.document_url, {"account_privacy": "private"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.put_value( + self.member_url, {"value": "Europe/Paris"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.client.delete(self.member_url).status_code == status.HTTP_204_NO_CONTENT + + def test_a_session_without_the_token_that_goes_with_it_is_refused_every_change(self): + """ + The session class still holds a change to the token the site hands out with it. + + A read is not held to it, because only a change can be provoked from + another site. The pages that keep a user's settings reach these + addresses with the session they signed in with, so the refusal a + browser meets here is the one pinned. + """ + held_to_its_token = APIClient(enforce_csrf_checks=True) + assert held_to_its_token.login(username=self.owner.username, password=PASSWORD) + self.assert_all_refused( + self.write_requests(self.owner.username, client=held_to_its_token), + status.HTTP_403_FORBIDDEN, + "authz", + ) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + for case, send in self.read_requests( + self.owner.username, client=held_to_its_token, + ).items(): + assert send().status_code == status.HTTP_200_OK, case + + def test_an_administrator_asking_what_another_accounts_addresses_answer_is_told(self): + """ + What an address does with an account is answered to whoever may read it. + + The description of a change the answer carries is built behind a check + of its own, which is made with the method of that change, so a caller + who may not make it is not told what it would carry. + """ + self.login(self.staff) + for address in (self.document_url, self.member_url): + answer = self.client.options(address) + assert answer.status_code == status.HTTP_200_OK, address + assert answer.json()["name"] == "Account Preference", address + assert "actions" not in answer.json(), address + + def test_the_addressed_account_is_told_what_a_change_at_their_own_address_carries(self): + """ + The answer describes the body this address serves, which carries the name as well. + + What a change carries is named in the published document, which gives + the value alone; the description assembled here is built from the body + the address answers with, so it names both halves of a stored + preference. + """ + self.login(self.owner) + answer = self.client.options(self.member_url) + assert answer.status_code == status.HTTP_200_OK + assert set(answer.json()["actions"]["PUT"]) == {"key", "value"} + + def test_a_learner_asking_what_another_accounts_addresses_answer_is_refused(self): + self.login(self.other) + for address in (self.document_url, self.member_url): + assert_error_envelope( + self.client.options(address), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_an_anonymous_caller_asking_what_these_addresses_answer_is_refused(self): + for address in (self.document_url, self.member_url): + assert_error_envelope( + self.client.options(address), + expected_status=status.HTTP_401_UNAUTHORIZED, + expected_type_slug="authn", + ) + + def test_a_token_is_read_here_without_recording_the_address_as_one_on_the_defaults(self): + """ + These addresses name their own classes, so they are not the ones the default pair serves. + + The pair the platform configures records every request it reads a + token for, so that an operator can list the addresses it serves. These + replace the session half of it and are therefore not among them, and + nothing here is recorded as if they were. The address read afterwards + names no classes of its own and is recorded, which is what shows the + record is reachable from a request made this way at all. + """ + with mock.patch(DEFAULT_AUTH_RECORDER) as recorded: + answer = self.client.get(self.document_url, **self.token_headers(self.owner)) + assert answer.status_code == status.HTTP_200_OK + assert answer.json() == {"time_zone": "Asia/Tokyo"} + assert recorded.call_args_list == [] + + with mock.patch(DEFAULT_AUTH_RECORDER) as recorded: + on_the_default_pair = self.client.get( + reverse("user_v2:time_zone_list"), **self.token_headers(self.staff), + ) + assert on_the_default_pair.status_code == status.HTTP_200_OK + assert [call.args for call in recorded.call_args_list] == [ + ("using_default_auth_classes", True), + ] + + +@skip_unless_lms +class PreferenceRefusedWithNothingBehindThemTests(PreferenceApiTestCase): + """ + Who these addresses refuse when nothing behind them refuses anybody. + + The preference service applies the same owner-or-administrator rule a + second time, one frame below the handler, so an answer produced with it in + place does not say which of the two refused a caller. Every request here is + made with the service calls replaced by stand-ins that serve whoever asks, + which leaves the classes guarding the actions as the only thing that can + refuse: each refusal below is one of theirs, and a caller they started + admitting would be answered here rather than refused. + """ + + #: What every preference the stand-ins serve is stored under, and its value. + SERVED = {"time_zone": "Asia/Tokyo"} + + def setUp(self): + super().setUp() + self.disabled_staff = UserFactory.create( + username="pref-disabled-staff", + email="pref-disabled-staff@example.com", + password=PASSWORD, + is_staff=True, + is_active=False, + ) + + @contextlib.contextmanager + def nothing_refusing_behind_the_handler(self): + """ + Serve every preference call, so only the classes guarding an action can refuse. + + The stand-ins are handed to the caller of this so a refusal can be + held to having happened in front of them: a request they were never + asked to serve was refused before anything behind the handler saw it. + """ + stand_ins = { + "get_user_preferences": mock.Mock(return_value=dict(self.SERVED)), + "get_user_preference": mock.Mock(return_value=self.SERVED["time_zone"]), + "update_user_preferences": mock.Mock(return_value=None), + "set_user_preference": mock.Mock(return_value=None), + "delete_user_preference": mock.Mock(return_value=True), + } + with mock.patch.multiple("openedx.core.djangoapps.user_api.v2.views", **stand_ins): + yield stand_ins + + def assert_nothing_behind_the_handler_was_asked(self, stand_ins): + """Assert no preference call was reached, so the refusal was the classes' own.""" + assert [name for name, stand_in in stand_ins.items() if stand_in.called] == [] + + def test_the_addressed_account_is_served_at_every_address(self): + """The stand-ins refuse nobody, which is what makes each refusal below the classes' own.""" + self.login(self.owner) + with self.nothing_refusing_behind_the_handler() as stand_ins: + reads = self.read_requests(self.owner.username) + assert reads["document"]().json() == self.SERVED + assert reads["member"]().json() == {"key": "time_zone", "value": "Asia/Tokyo"} + for case, send in self.write_requests(self.owner.username).items(): + assert send().status_code == status.HTTP_204_NO_CONTENT, case + assert sorted(name for name, stand_in in stand_ins.items() if stand_in.called) == [ + "delete_user_preference", + "get_user_preference", + "get_user_preferences", + "set_user_preference", + "update_user_preferences", + ] + + def test_a_learner_reaching_another_account_is_refused_at_every_address(self): + self.login(self.other) + with self.nothing_refusing_behind_the_handler() as stand_ins: + self.assert_all_refused( + { + **self.read_requests(self.owner.username), + **self.write_requests(self.owner.username), + }, + status.HTTP_403_FORBIDDEN, + "authz", + ) + self.assert_nothing_behind_the_handler_was_asked(stand_ins) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_an_administrator_reads_another_account_and_is_refused_every_change_to_it(self): + self.login(self.staff) + with self.nothing_refusing_behind_the_handler() as stand_ins: + for case, send in self.read_requests(self.owner.username).items(): + assert send().status_code == status.HTTP_200_OK, case + self.assert_all_refused( + self.write_requests(self.owner.username), status.HTTP_403_FORBIDDEN, "authz", + ) + assert sorted(name for name, stand_in in stand_ins.items() if stand_in.called) == [ + "get_user_preference", + "get_user_preferences", + ] + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_caller_named_for_the_address_in_case_alone_is_refused_at_every_address(self): + self.sign_in_as_a_case_variant_of(self.owner) + with self.nothing_refusing_behind_the_handler() as stand_ins: + self.assert_all_refused( + { + **self.read_requests(self.owner.username), + **self.write_requests(self.owner.username), + }, + status.HTTP_403_FORBIDDEN, + "authz", + ) + self.assert_nothing_behind_the_handler_was_asked(stand_ins) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_an_administrator_whose_account_is_no_longer_active_is_refused_every_address(self): + headers = self.token_headers(self.disabled_staff) + with self.nothing_refusing_behind_the_handler() as stand_ins: + self.assert_all_refused( + { + **self.read_requests(self.owner.username, **headers), + **self.write_requests(self.owner.username, **headers), + }, + status.HTTP_403_FORBIDDEN, + "authz", + ) + self.assert_nothing_behind_the_handler_was_asked(stand_ins) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + +@skip_unless_lms +class PreferenceGuardTests(PreferenceApiTestCase): + """ + Which callers the classes guarding each action admit, read from the view itself. + + The preference service refuses the same callers a second time, from behind + these classes, so a request refused at either point is refused the same + way. What each action is declared to admit is therefore asserted here, on + the declarations themselves, as well as through the answers these + addresses give when nothing behind them refuses anybody. + """ + + def setUp(self): + super().setUp() + self.disabled_staff = UserFactory.create( + username="pref-disabled-staff", + email="pref-disabled-staff@example.com", + password=PASSWORD, + is_staff=True, + is_active=False, + ) + + def routed_actions(self): + """Return every action the two preference addresses route a method to.""" + routed = set() + for address in (self.document_url, self.member_url): + routed.update( + action + for method, action in resolve(address).func.actions.items() + if method != "head" + ) + return routed + + def admits(self, action, caller, username, method="get"): + """ + Return whether every class guarding an action admits one caller at one address. + + The classes are handed the request as the view is handed it: the + wrapped form, carrying the caller as the one the request was read as, + since a class may ask either how the request was read or who made it. + """ + sent = getattr(APIRequestFactory(), method)("/") + force_authenticate(sent, user=caller) + request = Request(sent) + view = AccountPreferenceViewSet() + view.action = action + view.kwargs = {"username": username} + view.request = request + return all( + permission.has_permission(request, view) for permission in view.get_permissions() + ) + + def test_the_reads_admit_the_addressed_account_and_an_active_administrator(self): + for action in ("retrieve_all", "retrieve"): + assert self.admits(action, self.owner, self.owner.username), action + assert self.admits(action, self.staff, self.owner.username), action + assert not self.admits(action, self.other, self.owner.username), action + assert not self.admits(action, self.disabled_staff, self.owner.username), action + assert not self.admits(action, AnonymousUser(), self.owner.username), action + + def test_a_change_admits_the_addressed_account_and_nobody_else(self): + for action in ("partial_update_all", "update", "destroy"): + assert self.admits(action, self.owner, self.owner.username), action + assert not self.admits(action, self.staff, self.owner.username), action + assert not self.admits(action, self.other, self.owner.username), action + assert not self.admits(action, self.disabled_staff, self.owner.username), action + assert not self.admits(action, AnonymousUser(), self.owner.username), action + + def test_the_address_is_the_only_place_a_name_is_read_from(self): + """A name differing in case alone is a different name, whatever the action.""" + for action in self.routed_actions(): + assert not self.admits(action, self.owner, self.owner.username.upper()), action + assert not self.admits(action, self.owner, self.other.username), action + assert not self.admits(action, self.owner, "nobody"), action + + def test_the_actions_an_administrator_may_call_against_another_account_are_the_two_reads(self): + """ + Read from the actions the addresses route, so a new one is not admitted unnoticed. + + The actions come from the mounts rather than from a list of their own, + which is what makes this an answer about who is admitted rather than a + restatement of the names the view holds them under. + """ + admitted = { + action + for action in self.routed_actions() + if self.admits(action, self.staff, self.owner.username) + } + assert admitted == {"retrieve_all", "retrieve"} + assert self.routed_actions() - admitted == {"partial_update_all", "update", "destroy"} + + def test_asking_what_an_address_answers_is_guarded_as_a_read_of_it(self): + """ + The action recorded for that question is named by no mount, so it is asserted apart. + + The same action is recorded again while the description of a change is + built, with the method of that change rather than the one the caller + sent, and is guarded as the change itself. + """ + for caller, admitted in ( + (self.owner, True), + (self.staff, True), + (self.other, False), + (self.disabled_staff, False), + (AnonymousUser(), False), + ): + assert self.admits( + DESCRIBE_ADDRESS_ACTION, caller, self.owner.username, method="options", + ) is admitted, caller + assert self.admits( + DESCRIBE_ADDRESS_ACTION, self.owner, self.owner.username, method="put", + ) + assert not self.admits( + DESCRIBE_ADDRESS_ACTION, self.staff, self.owner.username, method="put", + ) + + +@skip_unless_lms +class PreferenceAddressedByMeTests(PreferenceApiTestCase): + """``me`` beneath an account is read as a stored name on these addresses.""" + + def setUp(self): + super().setUp() + self.me_document = self.document_address("me") + self.me_member = self.member_address("me", "time_zone") + + def changes(self): + """Return each change these addresses serve, as what it changes and how it is sent.""" + return ( + ("the whole document", lambda: self.patch_document( + self.me_document, {"time_zone": "Europe/Paris"}, + )), + ("one preference", lambda: self.put_value(self.me_member, {"value": "Europe/Paris"})), + ("the removal of one preference", lambda: self.client.delete(self.me_member)), + ) + + def reads(self): + """Return each read these addresses serve, as what it reads and how it is sent.""" + return ( + ("the whole document", lambda: self.client.get(self.me_document)), + ("one preference", lambda: self.client.get(self.me_member)), + ) + + def test_a_learner_addressing_me_is_refused(self): + self.login(self.owner) + self.assert_all_refused(self.reads(), status.HTTP_403_FORBIDDEN, "authz") + + def test_an_administrator_addressing_me_is_told_no_account_holds_that_name(self): + self.login(self.staff) + self.assert_all_refused(self.reads(), status.HTTP_404_NOT_FOUND, "not-found") + + def test_a_learner_changing_the_preferences_at_me_is_refused(self): + self.login(self.owner) + self.assert_all_refused(self.changes(), status.HTTP_403_FORBIDDEN, "authz") + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_an_administrator_changing_them_is_refused_rather_than_told_the_name_is_free(self): + self.login(self.staff) + self.assert_all_refused(self.changes(), status.HTTP_403_FORBIDDEN, "authz") + assert self.stored() == {"time_zone": "Asia/Tokyo"} + assert self.stored(self.staff) == {} + + def test_the_account_holding_that_name_is_served(self): + held = UserFactory.create(username="me", email="me@example.com", password=PASSWORD) + self.prefer("time_zone", "Europe/Paris", user=held) + self.login(held) + assert self.client.get(self.me_document).json() == {"time_zone": "Europe/Paris"} + assert self.client.get(self.me_member).json() == { + "key": "time_zone", "value": "Europe/Paris", + } + assert self.put_value( + self.me_member, {"value": "Asia/Tokyo"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored(held) == {"time_zone": "Asia/Tokyo"} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + assert self.patch_document( + self.me_document, {"time_zone": "America/Lima"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored(held) == {"time_zone": "America/Lima"} + assert self.client.delete(self.me_member).status_code == status.HTTP_204_NO_CONTENT + assert self.stored(held) == {} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + +@skip_unless_lms +class PreferenceQueryParameterTests(PreferenceApiTestCase): + """These addresses read no query parameter, and refuse the ones they are sent.""" + + def test_a_parameter_none_of_these_addresses_reads_is_refused(self): + self.login(self.owner) + for address in (self.document_url, self.member_url): + envelope = assert_error_envelope( + self.client.get(address, {"foo": "1"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "foo": [f"No such query parameter. Send any of: {PREFERENCE_QUERY_PARAMS}."], + }, address + + def test_the_document_carries_no_pages(self): + """It is one account's preferences, read and changed whole.""" + self.login(self.owner) + for parameter in ("page", "page_size"): + envelope = assert_error_envelope( + self.client.get(self.document_url, {parameter: "1"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert list(envelope["errors"]) == [parameter], parameter + + def test_no_preference_address_shapes_what_it_answers_with(self): + """A caller wanting one name has the address of it.""" + self.login(self.owner) + for address in (self.document_url, self.member_url): + for parameter in ("view", "fields"): + assert_error_envelope( + self.client.get(address, {parameter: "minimal"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + + def test_a_change_carrying_a_parameter_it_does_not_read_is_refused(self): + self.login(self.owner) + assert_error_envelope( + self.patch_document( + f"{self.document_url}?foo=1", {"time_zone": "Europe/Paris"}, + ), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + +@skip_unless_lms +class PreferenceServiceFailureTests(PreferenceApiTestCase): + """Work that was asked for correctly and failed anyway is not the caller's fault.""" + + def test_a_change_that_could_not_be_carried_out_is_a_server_fault(self): + """The earlier address answered a failed save as a bad request.""" + self.login(self.owner) + failure = PreferenceUpdateError( + developer_message="Save failed for user preference 'time_zone': disk full", + user_message="Save failed.", + ) + with mock.patch( + "openedx.core.djangoapps.user_api.v2.views.update_user_preferences", side_effect=failure, + ): + response = self.patch_document(self.document_url, {"time_zone": "Europe/Paris"}) + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_500_INTERNAL_SERVER_ERROR, + expected_type_slug="internal", + ) + assert envelope["detail"] == "The request could not be carried out." + assert "disk full" not in json.dumps(envelope) + + def test_a_change_the_database_refused_is_a_server_fault(self): + """ + A failure the caller cannot correct stays a server fault, whatever the body carried. + + The failure here is one the service did not make anything of, which is + answered by the standard handling rather than by what this endpoint + makes of the failures the service reports. + """ + self.login(self.owner) + with mock.patch( + "openedx.core.djangoapps.user_api.v2.views.update_user_preferences", + side_effect=DatabaseError("the table is gone"), + ): + response = self.patch_document(self.document_url, {"time_zone": "Europe/Paris"}) + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_500_INTERNAL_SERVER_ERROR, + expected_type_slug="internal", + ) + assert "the table is gone" not in json.dumps(envelope) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def send_a_change_whose_removal_fails(self): + """Send a merge patch whose first name is stored and whose second cannot be removed.""" + with mock.patch( + "openedx.core.djangoapps.user_api.models.UserPreference.delete", + side_effect=DatabaseError("the row is locked"), + ): + return self.patch_document( + self.document_url, + {"account_privacy": "all_users", "time_zone": None, "pref-lang": "eo"}, + ) + + def test_a_change_that_fails_part_way_through_stores_none_of_it(self): + """A merge patch is one change: a name that cannot be changed leaves every other alone.""" + self.login(self.owner) + self.prefer("account_privacy", "private") + self.prefer("pref-lang", "en") + envelope = assert_error_envelope( + self.send_a_change_whose_removal_fails(), + expected_status=status.HTTP_500_INTERNAL_SERVER_ERROR, + expected_type_slug="internal", + ) + assert "the row is locked" not in json.dumps(envelope) + assert self.stored() == { + "account_privacy": "private", "pref-lang": "en", "time_zone": "Asia/Tokyo", + } + + def test_a_change_that_fails_stores_none_of_it_where_a_statement_stands_on_its_own(self): + """ + The change is carried out as one piece by the endpoint itself. + + The platform serves every request inside a transaction, which would + undo the names stored before the one that failed on its own, so the + promise is held here to a database configured without one. + """ + self.login(self.owner) + self.prefer("account_privacy", "private") + self.prefer("pref-lang", "en") + with requests_served_outside_a_transaction(): + response = self.send_a_change_whose_removal_fails() + assert_error_envelope( + response, + expected_status=status.HTTP_500_INTERNAL_SERVER_ERROR, + expected_type_slug="internal", + ) + assert self.stored() == { + "account_privacy": "private", "pref-lang": "en", "time_zone": "Asia/Tokyo", + } + + def test_a_read_that_could_not_be_served_is_a_server_fault(self): + self.login(self.owner) + with mock.patch( + "openedx.core.djangoapps.user_api.v2.views.get_user_preferences", + side_effect=UserAPIInternalError("the preference table is gone"), + ): + response = self.client.get(self.document_url) + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_500_INTERNAL_SERVER_ERROR, + expected_type_slug="internal", + ) + assert "preference table is gone" not in json.dumps(envelope) + + def test_a_removal_that_could_not_be_carried_out_is_a_server_fault(self): + """ + The failure is provoked at the row, so it arrives as the service reports it. + + What the service makes of a failed removal is the thing this endpoint + turns into an answer, so the failure is raised where the row is + removed rather than handed to the handler ready-made. + """ + self.login(self.owner) + with mock.patch( + "openedx.core.djangoapps.user_api.models.UserPreference.delete", + side_effect=DatabaseError("the row is locked"), + ): + response = self.client.delete(self.member_url) + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_500_INTERNAL_SERVER_ERROR, + expected_type_slug="internal", + ) + assert envelope["detail"] == "The request could not be carried out." + assert "the row is locked" not in json.dumps(envelope) + assert "Delete failed" not in json.dumps(envelope) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + +class PreferenceReadWriteCaptureCase(PreferenceApiTestCase): + """ + What a read of these addresses does, measured statement by statement. + + Three channels are captured, because a read that changes stored data need + not do so itself: the statements it ran, the callbacks it deferred to the + end of its transaction, and what it announced. An announcement is captured + because the platform's own listeners act on one, and a change announced + from a read reaches those listeners whether or not the read wrote + anything: the field-change signal is captured wherever it is sent from, + and the analytics channel it is sent alongside with it. + """ + + def setUp(self): + super().setUp() + self.deferred = [] + self.announced = [] + self.tracked = [] + + def captured_writes(self, method, address, expect=status.HTTP_200_OK, **headers): + """Return the statements a request made that would change stored data.""" + with CaptureQueriesContext(connection) as captured, \ + mock.patch.object(USER_FIELDS_CHANGED, "send") as announced, \ + mock.patch.object(model_utils.tracker, "emit") as tracked, \ + self.captureOnCommitCallbacks(execute=True) as callbacks: + answer = getattr(self.client, method)(address, **headers) + assert answer.status_code == expect, answer.content + self.deferred = callbacks + self.announced = [call.kwargs for call in announced.call_args_list] + self.tracked = [self.emitted(call) for call in tracked.call_args_list] + return write_queries(captured.captured_queries) + + @staticmethod + def emitted(call): + """Return the name and the payload of one analytics event, however it was passed.""" + passed = (*call.args, None, None) + return call.kwargs.get("name", passed[0]), call.kwargs.get("data", passed[1]) + + def targets(self, statements): + """Return the table each statement writes to.""" + return [statement.split('"')[1] for statement in statements] + + def assert_only_the_request_itself_was_recorded(self, address): + """Assert the one event the request emitted was the platform's record of the request.""" + assert self.tracked == [(address, REQUEST_RECORD_DATA)] + + def assert_nothing_was_changed_or_announced(self, statements, address): + """Assert the request wrote nothing, deferred nothing and announced no change.""" + assert (statements, self.deferred, self.announced) == ([], [], []) + self.assert_only_the_request_itself_was_recorded(address) + + def assert_the_language_change_was_announced(self, address, caller=None): + """Assert the one change announced was the language row the middleware stored.""" + caller = caller or self.owner + assert self.announced == [{ + "sender": None, + "user": caller, + "table": PREFERENCE_TABLE, + "changed_fields": {LANGUAGE_PREFERENCE: (None, "eo")}, + }] + assert self.tracked == [ + (address, REQUEST_RECORD_DATA), + ( + USER_SETTINGS_CHANGED_EVENT_NAME, + { + "old": None, + "new": "eo", + "truncated": [], + "setting": LANGUAGE_PREFERENCE, + "user_id": caller.id, + "table": PREFERENCE_TABLE, + }, + ), + ] + + +@skip_unless_lms +class PreferenceReadsWithoutTheLanguageCookieTests(PreferenceReadWriteCaptureCase): + """A read of these addresses changes nothing of what they serve, and announces nothing.""" + + def assert_the_read_changed_nothing(self, method, address, expect=status.HTTP_200_OK, **headers): + """Assert a read of this address wrote nothing, deferred nothing and announced nothing.""" + self.assert_nothing_was_changed_or_announced( + self.captured_writes(method, address, expect=expect, **headers), address, + ) + + def test_reading_the_document_writes_nothing(self): + self.login(self.owner) + self.assert_the_read_changed_nothing("get", self.document_url) + + def test_reading_one_preference_writes_nothing(self): + self.login(self.owner) + self.assert_the_read_changed_nothing("get", self.member_url) + + def test_a_staff_caller_reading_another_account_writes_nothing(self): + self.login(self.staff) + for address in (self.document_url, self.member_url): + self.assert_the_read_changed_nothing("get", address) + + def test_a_read_presented_with_a_token_writes_nothing(self): + for address in (self.document_url, self.member_url): + self.assert_the_read_changed_nothing("get", address, **self.token_headers(self.owner)) + + def test_heading_either_address_writes_nothing(self): + self.login(self.owner) + for address in (self.document_url, self.member_url): + self.assert_the_read_changed_nothing("head", address) + + def test_the_earlier_addresses_write_nothing_either(self): + self.login(self.owner) + for address in ( + earlier_document_address(self.owner.username), + earlier_member_address(self.owner.username, "time_zone"), + ): + self.assert_the_read_changed_nothing("get", address) + + def test_reading_a_name_the_account_stores_no_value_under_writes_nothing(self): + """Being told nothing is stored under a name is no reason to store anything.""" + self.login(self.owner) + self.assert_the_read_changed_nothing( + "get", + self.member_address(self.owner.username, "account_privacy"), + expect=status.HTTP_404_NOT_FOUND, + ) + + def test_a_staff_caller_told_another_account_stores_no_such_name_writes_nothing(self): + self.login(self.staff) + self.assert_the_read_changed_nothing( + "get", + self.member_address(self.other.username, "account_privacy"), + expect=status.HTTP_404_NOT_FOUND, + ) + + def test_a_read_of_an_account_nobody_holds_writes_nothing(self): + """The other branch that answers nothing: no such account, rather than no such name.""" + self.login(self.staff) + for address in ( + self.document_address("pref-nobody"), + self.member_address("pref-nobody", "time_zone"), + ): + self.assert_the_read_changed_nothing( + "get", address, expect=status.HTTP_404_NOT_FOUND, + ) + + def test_a_read_leaves_the_stored_preferences_as_they_were(self): + self.login(self.owner) + before = self.stored() + self.client.get(self.document_url) + self.client.get(self.member_url) + assert self.stored() == before == {"time_zone": "Asia/Tokyo"} + + +@skip_unless_lms +class PreferenceReadsWithTheLanguageCookieTests(PreferenceReadWriteCaptureCase): + """ + A request carrying the language cookie stores the language it carries. + + The platform keeps the language a user picked in a cookie and syncs it into + that user's preferences from middleware, which runs before any view and on + every method, so it is reached by a read of these addresses as it is by a + read of any other. The capture below pins what it writes: the one row, by + the table it is stored in, and nothing else. The same request to the + address this version supersedes writes the same row, which is what shows + the write belongs to the platform rather than to either version. + """ + + def setUp(self): + super().setUp() + self.client.cookies[LANGUAGE_COOKIE] = "eo" + + def language_rows(self): + """Return the stored language of every account, by account.""" + return { + preference.user.username: preference.value + for preference in UserPreference.objects.filter(key=LANGUAGE_PREFERENCE) + } + + def assert_only_the_language_was_stored( + self, method, address, expect=status.HTTP_200_OK, **headers, + ): + """Assert the one write made, and the one change announced, was the caller's language.""" + statements = self.captured_writes(method, address, expect=expect, **headers) + assert (self.targets(statements), self.deferred) == ([PREFERENCE_TABLE], []), statements + assert self.language_rows() == {self.owner.username: "eo"} + self.assert_the_language_change_was_announced(address) + + def test_reading_the_document_stores_the_language_the_cookie_carries(self): + self.login(self.owner) + assert self.language_rows() == {} + self.assert_only_the_language_was_stored("get", self.document_url) + + def test_reading_one_preference_stores_it_too(self): + self.login(self.owner) + self.assert_only_the_language_was_stored("get", self.member_url) + + def test_the_address_this_version_supersedes_stores_the_same_row(self): + self.login(self.owner) + self.assert_only_the_language_was_stored( + "get", earlier_document_address(self.owner.username), + ) + + def test_the_earlier_member_address_stores_the_same_row(self): + self.login(self.owner) + self.assert_only_the_language_was_stored( + "get", earlier_member_address(self.owner.username, "time_zone"), + ) + + def test_heading_the_document_stores_it_as_well(self): + """A write one frame below the handler is reached by every method, this one included.""" + self.login(self.owner) + self.assert_only_the_language_was_stored("head", self.document_url) + + def test_a_read_presented_with_a_token_stores_it_as_well(self): + self.assert_only_the_language_was_stored( + "get", self.document_url, **self.token_headers(self.owner), + ) + + def test_reading_a_name_the_account_stores_no_value_under_stores_only_the_language(self): + """The refusal writes nothing of its own: the row below it is still the only write.""" + self.login(self.owner) + self.assert_only_the_language_was_stored( + "get", + self.member_address(self.owner.username, "account_privacy"), + expect=status.HTTP_404_NOT_FOUND, + ) + + def test_the_row_is_written_once_and_not_once_a_request(self): + """It is the language being synced, not the read recording anything.""" + self.login(self.owner) + self.assert_only_the_language_was_stored("get", self.document_url) + for address in (self.document_url, self.member_url): + self.assert_nothing_was_changed_or_announced( + self.captured_writes("get", address), address, + ) + + def test_the_language_a_staff_caller_carries_is_stored_against_their_own_account(self): + """The row is the caller's, whichever account the address names.""" + self.login(self.staff) + statements = self.captured_writes("get", self.document_url) + assert self.targets(statements) == [PREFERENCE_TABLE], statements + assert self.language_rows() == {self.staff.username: "eo"} + self.assert_the_language_change_was_announced(self.document_url, self.staff) + + def test_removing_the_language_removes_the_row_the_middleware_had_just_stored(self): + """ + The row below the handler is written first and removed by the handler itself. + + A client that removes the language it is carrying is told the account + stores no value under that name, and none does: the write one frame + below is not a second row and does not outlast the request that made + it. + """ + self.login(self.owner) + statements = self.captured_writes( + "delete", + self.member_address(self.owner.username, LANGUAGE_PREFERENCE), + expect=status.HTTP_204_NO_CONTENT, + ) + assert self.targets(statements) == [PREFERENCE_TABLE, PREFERENCE_TABLE], statements + assert self.language_rows() == {} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_the_document_removing_the_language_leaves_it_removed_as_well(self): + self.login(self.owner) + assert self.patch_document( + self.document_url, {LANGUAGE_PREFERENCE: None}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.language_rows() == {} + + def test_removing_another_name_stores_the_language_the_cookie_carries(self): + """The write one frame below is made whatever the method, a removal included.""" + self.login(self.owner) + statements = self.captured_writes( + "delete", self.member_url, expect=status.HTTP_204_NO_CONTENT, + ) + assert self.targets(statements) == [PREFERENCE_TABLE, PREFERENCE_TABLE], statements + assert self.language_rows() == {self.owner.username: "eo"} + assert self.stored() == {LANGUAGE_PREFERENCE: "eo"} + + def test_a_name_stored_only_by_the_middleware_is_removed_rather_than_reported_absent(self): + """ + The row is there to remove by the time the handler looks, so the removal is answered. + + Without the cookie the same request is answered with the refusal a + name no value is stored under gets, which is what the two assertions + below stand on either side of. + """ + self.login(self.owner) + address = self.member_address(self.owner.username, LANGUAGE_PREFERENCE) + assert self.language_rows() == {} + assert self.client.delete(address).status_code == status.HTTP_204_NO_CONTENT + del self.client.cookies[LANGUAGE_COOKIE] + assert self.client.delete(address).status_code == status.HTTP_404_NOT_FOUND + + def test_the_document_answered_carries_the_language_that_was_stored(self): + self.login(self.owner) + assert self.client.get(self.document_url).json() == { + LANGUAGE_PREFERENCE: "eo", "time_zone": "Asia/Tokyo", + } diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_query_counts.py b/openedx/core/djangoapps/user_api/v2/tests/test_query_counts.py new file mode 100644 index 000000000000..1833ccf4e654 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_query_counts.py @@ -0,0 +1,344 @@ +"""Query-count guards for the version 2 User API list and member addresses.""" + +import pytest +from django.db import connection +from django.test.utils import CaptureQueriesContext +from django.urls import reverse + +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.django_comment_common.models import Role +from openedx.core.djangoapps.user_api.models import UserOrgTag +from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory +from openedx.core.djangolib.testing.utils import skip_unless_lms + +PASSWORD = "Password1234" +COURSE_KEY = "course-v1:edX+DemoX+Demo" + +#: Row counts a per-row query would show up at. +ROW_COUNTS = [1, 5, 25] + +#: Page sizes a per-item query would show up at, for the list whose rows are +#: not database rows and whose size is therefore set by the page alone. +PAGE_SIZES = [1, 10, 100] + +USER_LIST_QUERIES = 11 +USER_DETAIL_QUERIES = 10 +PREFERENCE_LIST_QUERIES = 10 +#: One more than the list costs without the preset: the owners whose entry +#: a page may carry are resolved for the whole page in a single query, +#: whatever the number of rows. +PREFERENCE_LIST_FULL_VIEW_QUERIES = 12 +PREFERENCE_DETAIL_QUERIES = 9 +#: What the same address costs with the owner's entry asked for: the entry's +#: own preferences, and the one query that settles which owners' entries may +#: be carried. +PREFERENCE_DETAIL_FULL_VIEW_QUERIES = 11 +TIME_ZONE_LIST_QUERIES = 8 + +#: Address of the stored-preference list this version replaces, measured with +#: the same key so that the cost of the two is compared in one measurement. +EARLIER_PREFERENCE_LIST = "/api/user/v1/user_prefs/" + +#: The site's API key, which both versions of the directory are read with. +API_KEY = "query-count-api-key" + +#: What that list costs for a page holding two rows per user, measured the same +#: way as the counts above: a constant part plus five queries for every user on +#: the page, because each row fetches its owner, the owner's profile and the +#: owner's own preferences again. +EARLIER_PREFERENCE_LIST_QUERIES = {1: 15, 5: 35, 25: 135} + + +@pytest.fixture(name="directory") +@skip_unless_lms +def directory_fixture(request, client, settings, django_user_model): # pylint: disable=unused-argument + """Send the site's API key with every request and populate the directory with ``request.param`` users.""" + settings.EDX_API_KEY = API_KEY + client.defaults["HTTP_X_EDX_API_KEY"] = API_KEY + users = [UserFactory.create(username=f"counted{index}") for index in range(request.param)] + for user in users: + UserPreferenceFactory.create(user=user, key="pref-lang", value="en") + UserPreferenceFactory.create(user=user, key="time_zone", value="UTC") + role = Role.objects.create(course_id=COURSE_KEY, name="Moderator") + role.users.add(*users) + # The first request populates caches the later ones reuse; the counts + # below are the steady-state ones. + client.get(reverse("user_v2:user_list")) + return users + + +@pytest.mark.django_db +@pytest.mark.parametrize("directory", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_user_list_query_count_does_not_grow_with_rows(client, directory, django_assert_num_queries): + url = reverse("user_v2:user_list") + with django_assert_num_queries(USER_LIST_QUERIES): + response = client.get(url, {"page_size": 100}) + assert len(response.json()["results"]) == len(directory) + + +@pytest.mark.django_db +@pytest.mark.parametrize("directory", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_preference_key_filter_query_count_does_not_grow_with_rows( + client, directory, django_assert_num_queries, +): + url = reverse("user_v2:user_list") + with django_assert_num_queries(USER_LIST_QUERIES): + response = client.get(url, {"preference_key": "pref-lang", "page_size": 100}) + assert len(response.json()["results"]) == len(directory) + + +@pytest.mark.django_db +@pytest.mark.parametrize("directory", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_forum_role_filter_query_count_does_not_grow_with_rows( + client, directory, django_assert_num_queries, +): + url = reverse("user_v2:user_list") + with django_assert_num_queries(USER_LIST_QUERIES): + response = client.get( + url, {"forum_role": "Moderator", "course_key": COURSE_KEY, "page_size": 100}, + ) + assert len(response.json()["results"]) == len(directory) + + +@pytest.mark.django_db +@pytest.mark.parametrize("directory", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_preference_list_query_count_does_not_grow_with_rows( + client, directory, django_assert_num_queries, +): + url = reverse("user_v2:user_preference_list") + with django_assert_num_queries(PREFERENCE_LIST_QUERIES): + response = client.get(url, {"page_size": 100}) + assert len(response.json()["results"]) == 2 * len(directory) + + +@pytest.mark.django_db +@pytest.mark.parametrize("directory", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_preference_list_full_view_query_count_does_not_grow_with_rows( + client, directory, django_assert_num_queries, +): + url = reverse("user_v2:user_preference_list") + with django_assert_num_queries(PREFERENCE_LIST_FULL_VIEW_QUERIES): + response = client.get(url, {"page_size": 100, "view": "full"}) + rows = response.json()["results"] + assert len(rows) == 2 * len(directory) + assert rows[0]["user"]["preferences"] == {"pref-lang": "en", "time_zone": "UTC"} + + +@pytest.mark.django_db +@pytest.mark.parametrize("page_size", PAGE_SIZES) +@pytest.mark.parametrize("directory", [1], indirect=True) +@skip_unless_lms +def test_time_zone_list_query_count_does_not_grow_with_page_size( + client, directory, django_assert_num_queries, page_size, +): # pylint: disable=unused-argument + """The rows come from the time-zone database, so only the page size varies them.""" + url = reverse("user_v2:time_zone_list") + with django_assert_num_queries(TIME_ZONE_LIST_QUERIES): + response = client.get(url, {"page_size": page_size}) + assert len(response.json()["results"]) == page_size + + +@pytest.mark.django_db +@pytest.mark.parametrize("directory", [5], indirect=True) +@skip_unless_lms +def test_member_query_counts(client, directory, django_assert_num_queries): + with django_assert_num_queries(USER_DETAIL_QUERIES): + response = client.get(reverse("user_v2:user_detail", kwargs={"username": "counted0"})) + assert response.json()["username"] == "counted0" + + with django_assert_num_queries(PREFERENCE_DETAIL_QUERIES): + response = client.get( + reverse( + "user_v2:user_preference_detail", + kwargs={"username": "counted0", "key": "pref-lang"}, + ), + ) + assert response.json()["value"] == "en" + + +@pytest.mark.django_db +@pytest.mark.parametrize("directory", [5], indirect=True) +@skip_unless_lms +def test_the_member_address_pays_a_flat_cost_for_the_entry_it_carries( + client, directory, django_assert_num_queries, +): # pylint: disable=unused-argument + """ + Asking for one preference whole costs the same whatever else is stored. + + The entry the row carries brings the owner's own preferences with it and + the owners whose entry may be carried are resolved once, so the cost is + the member cost plus a fixed amount and does not move with the number of + rows the collection holds. + """ + address = reverse( + "user_v2:user_preference_detail", kwargs={"username": "counted0", "key": "pref-lang"}, + ) + with django_assert_num_queries(PREFERENCE_DETAIL_FULL_VIEW_QUERIES): + response = client.get(address, {"view": "full"}) + body = response.json() + assert body["value"] == "en" + assert body["user"]["username"] == "counted0" + assert body["user"]["preferences"] == {"pref-lang": "en", "time_zone": "UTC"} + + +@pytest.mark.django_db +@pytest.mark.parametrize("directory", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_the_earlier_preference_list_pays_per_row_where_this_one_does_not( + client, directory, django_assert_num_queries, +): + """ + Both lists are measured over the same rows, so the saving cannot drift apart. + + The earlier row embeds its owner without a join and re-reads that owner's + own preferences, so a page of it costs five queries per user; this one + reads the same page at a count that does not move with it. + """ + with django_assert_num_queries(PREFERENCE_LIST_QUERIES): + current = client.get(reverse("user_v2:user_preference_list"), {"page_size": 100}) + assert len(current.json()["results"]) == 2 * len(directory) + + with django_assert_num_queries(EARLIER_PREFERENCE_LIST_QUERIES[len(directory)]): + earlier = client.get(EARLIER_PREFERENCE_LIST, {"page_size": 100}) + assert len(earlier.json()["results"]) == 2 * len(directory) + + +#: Address of one account's preferences in the version this one supersedes. +#: Both versions call the same service over the same rows, so what each of the +#: addresses below costs is measured at the superseded address in the same run +#: and this version is held to that measurement rather than to a number +#: written down here. A change in what every request of the site costs then +#: moves both measurements together instead of turning these red. +EARLIER_PREFERENCE_DOCUMENT = "/api/user/v1/preferences/{username}" + +#: How many more preferences are stored before a cost is measured again, so +#: that a query paid for each preference stored shows up as a rise. +MORE_PREFERENCES = 5 + + +def cost_of(request): + """Return what one request cost in queries, and what it answered with.""" + with CaptureQueriesContext(connection) as queries: + answer = request() + return len(queries), answer + + +@pytest.fixture(name="account_with_preferences") +@skip_unless_lms +def account_with_preferences_fixture(request, client, django_user_model): # pylint: disable=unused-argument + """Sign a learner in and give their own account ``request.param`` preferences besides a time zone.""" + owner = UserFactory.create(username="count-owner", password=PASSWORD) + UserPreferenceFactory.create(user=owner, key="time_zone", value="UTC") + for index in range(request.param): + UserPreferenceFactory.create(user=owner, key=f"counted_{index:03d}", value="stored") + assert client.login(username=owner.username, password=PASSWORD) + # The first authenticated request of a session populates caches the rest + # of the session reuses; the counts below are the steady-state ones. + client.get(reverse("user_v2:account_preference_list", kwargs={"username": owner.username})) + return owner, request.param + + +@pytest.mark.django_db +@pytest.mark.parametrize("account_with_preferences", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_the_preference_document_costs_the_same_at_either_address( + client, account_with_preferences, +): + """Neither version pays per preference: only the body grows with them.""" + owner, stored = account_with_preferences + address = reverse("user_v2:account_preference_list", kwargs={"username": owner.username}) + earlier_cost, earlier = cost_of( + lambda: client.get(EARLIER_PREFERENCE_DOCUMENT.format(username=owner.username)), + ) + current_cost, current = cost_of(lambda: client.get(address)) + assert current_cost == earlier_cost + assert earlier.json() == current.json() + assert set(current.json()) == {"time_zone"} | { + f"counted_{index:03d}" for index in range(stored) + } + + for index in range(stored, stored + MORE_PREFERENCES): + UserPreferenceFactory.create(user=owner, key=f"counted_{index:03d}", value="stored") + with_more_cost, with_more = cost_of(lambda: client.get(address)) + assert with_more_cost == current_cost + assert len(with_more.json()) == len(current.json()) + MORE_PREFERENCES + + +@pytest.mark.django_db +@pytest.mark.parametrize("account_with_preferences", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_one_preference_costs_the_same_at_either_address(client, account_with_preferences): + """Reading one preference costs what it costs at the superseded address, stored rows aside.""" + owner, stored = account_with_preferences + address = reverse( + "user_v2:account_preference_detail", + kwargs={"username": owner.username, "key": "time_zone"}, + ) + earlier_cost, earlier = cost_of( + lambda: client.get( + f"{EARLIER_PREFERENCE_DOCUMENT.format(username=owner.username)}/time_zone", + ), + ) + current_cost, current = cost_of(lambda: client.get(address)) + assert current_cost == earlier_cost + assert earlier.json() == "UTC" + assert current.json() == {"key": "time_zone", "value": "UTC"} + + for index in range(stored, stored + MORE_PREFERENCES): + UserPreferenceFactory.create(user=owner, key=f"counted_{index:03d}", value="stored") + with_more_cost, with_more = cost_of(lambda: client.get(address)) + assert with_more_cost == current_cost + assert with_more.json() == {"key": "time_zone", "value": "UTC"} + + +@pytest.mark.django_db +@pytest.mark.parametrize("account_with_preferences", [1], indirect=True) +@skip_unless_lms +def test_recording_an_email_choice_costs_what_the_earlier_address_costs( + client, account_with_preferences, +): + """ + Each address is measured from the same starting state as the other. + + Recording the first choice creates the row it is kept in and recording a + later one updates it, so a count taken after the other address has already + recorded the choice would be the cheaper path measured against the dearer + one. Both are measured here, and the rise between them is what makes the + starting state matter. + """ + owner, _stored = account_with_preferences + choices = UserOrgTag.objects.filter(user=owner, key="email-optin") + earlier_body = {"course_id": COURSE_KEY, "email_opt_in": "True"} + current_body = {"course_key": COURSE_KEY, "email_opt_in": True} + + def record_at_the_earlier_address(): + """Record the choice the way the pages calling the earlier address do.""" + return client.post("/api/user/v1/preferences/email_opt_in/", earlier_body) + + def record(): + """Record the choice at this version's address.""" + return client.post( + reverse("user_v2:email_opt_in_list"), current_body, content_type="application/json", + ) + + costs = {} + for address, record_choice, expected_status in ( + ("the earlier address", record_at_the_earlier_address, 200), + ("this version", record, 204), + ): + choices.delete() + opening_cost, opened = cost_of(record_choice) + assert opened.status_code == expected_status, address + changing_cost, changed = cost_of(record_choice) + assert changed.status_code == expected_status, address + assert choices.count() == 1, address + costs[address] = (opening_cost, changing_cost) + + assert costs["this version"] == costs["the earlier address"] + opening_cost, changing_cost = costs["this version"] + assert opening_cost > changing_cost diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_restricted_applications.py b/openedx/core/djangoapps/user_api/v2/tests/test_restricted_applications.py new file mode 100644 index 000000000000..271f9c26b1d4 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_restricted_applications.py @@ -0,0 +1,373 @@ +"""Tests of the refusal every address of version 2 of the User API gives a restricted application's token.""" + +import json +from datetime import UTC, datetime +from unittest import mock + +import ddt +from django.conf import settings +from django.contrib.auth.models import User # pylint: disable=imported-auth-user +from django.test import SimpleTestCase +from django.test.utils import override_settings +from django.urls import reverse +from edx_rest_framework_extensions.auth.jwt.tests.utils import generate_jwt +from edx_rest_framework_extensions.permissions import NotJwtRestrictedApplication +from edx_rest_framework_extensions.testing import assert_error_envelope +from rest_framework import status +from rest_framework.test import APITestCase + +from common.djangoapps.student.models import PendingNameChange, UserProfile +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.profile_images.images import get_profile_image_storage +from openedx.core.djangoapps.profile_images.tests.helpers import make_image_file +from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_names +from openedx.core.djangoapps.user_api.models import UserOrgTag, UserPreference +from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory +from openedx.core.djangoapps.user_api.v2 import urls as v2_urls +from openedx.core.djangoapps.user_api.v2.permissions import RestrictedApplicationGuardMixin +from openedx.core.djangoapps.user_api.v2.views import AccountPreferenceViewSet, EmailOptInViewSet +from openedx.core.djangolib.testing.utils import skip_unless_lms + +PASSWORD = "Password1234" +MERGE_PATCH = "application/merge-patch+json" +API_KEY = "restricted-token-test-api-key" +COURSE_KEY = "course-v1:edX+DemoX+Demo" + +#: The middleware that adds the restricted-application refusal to the classes a +#: view declares, once that view has served a request. +JWT_SETTINGS_MIDDLEWARE = "edx_rest_framework_extensions.auth.jwt.middleware.EnsureJWTAuthSettingsMiddleware" + +#: The classes the preference and email choice views declare, read when this +#: module is imported, which is before any request has been served and so before +#: the middleware has added anything to them. +DECLARED_PERMISSION_CLASSES = { + view_class: view_class.permission_classes + for view_class in (AccountPreferenceViewSet, EmailOptInViewSet) +} + + +def routed_views(): + """ + Yield the view class, the route name and the operations of every route of this version. + + The operations are the handler names a route dispatches to: the actions of + a viewset, or the methods an ``APIView`` implements. + """ + for pattern in v2_urls.urlpatterns: + callback = pattern.callback + view_class = getattr(callback, "cls", None) or callback.view_class + if getattr(callback, "actions", None): + operations = sorted(set(callback.actions.values())) + else: + operations = sorted( + method for method in view_class.http_method_names + if method not in ("options", "head") and hasattr(view_class, method) + ) + yield view_class, pattern.name, callback.initkwargs, operations + + +@skip_unless_lms +class EveryOperationCarriesTheRefusalTests(SimpleTestCase): + """ + Each operation resolves the restricted-application refusal among its own classes. + + These read the classes an operation resolves without a request reaching + it, so the platform middleware, which adds the refusal only once a view + has served a request, plays no part in what they see. + """ + + def test_every_routed_view_inherits_the_guard(self): + unguarded = sorted( + {view_class.__name__ for view_class, _, _, _ in routed_views() + if not issubclass(view_class, RestrictedApplicationGuardMixin)} + ) + assert unguarded == [] + + def test_every_operation_resolves_the_refusal(self): + missing = [] + for view_class, name, initkwargs, operations in routed_views(): + for operation in operations: + view = view_class(**initkwargs) + view.action = operation + view.kwargs = {} + resolved = view.get_permissions() + if not any(isinstance(permission, NotJwtRestrictedApplication) for permission in resolved): + missing.append((name, operation)) + assert not missing, missing + + def test_the_name_change_confirmation_keeps_its_own_classes_and_gains_the_refusal(self): + pattern = next(p for p in v2_urls.urlpatterns if p.name == "name_change_confirmation") + view = pattern.callback.cls(**pattern.callback.initkwargs) + view.action = "confirmations" + assert [type(permission).__name__ for permission in view.get_permissions()] == [ + "IsAuthenticated", "IsActiveUser", "IsStaff", "NotJwtRestrictedApplication", + ] + + +@skip_unless_lms +@ddt.ddt +@override_settings(EDX_API_KEY=API_KEY) +class RestrictedApplicationTokenTests(APITestCase): + """ + A token issued to a restricted application is refused at every operation that reads a token. + + Each operation is called twice by the same staff account, once with a + token issued to an ordinary application, which the operation answers, and + once with the same token flagged as issued to a restricted application, + which it refuses before changing anything. The directory addresses are + sent the site's API key as well, since that is what admits a caller there, + so the token is the only thing that can refuse the request. + """ + + def setUp(self): + super().setUp() + self.staff = UserFactory.create( + username="token-staff", email="token-staff@example.com", password=PASSWORD, + is_staff=True, is_superuser=True, + ) + self.learner = UserFactory.create( + username="token-learner", email="token-learner@example.com", password=PASSWORD, + ) + UserPreferenceFactory.create(user=self.learner, key="pref-lang", value="en") + PendingNameChange.objects.create( + user=self.learner, new_name="Renamed Learner", rationale="asked for in a test", + ) + self.addCleanup(self.remove_stored_images, self.staff.username) + + def remove_stored_images(self, username): + """Take whatever this user has stored out of the image store.""" + storage = get_profile_image_storage() + for name in get_profile_image_names(username).values(): + storage.delete(name) + + def token_headers(self, is_restricted): + """Return headers carrying the staff account's token, flagged restricted or not.""" + return {"HTTP_AUTHORIZATION": f"JWT {generate_jwt(self.staff, is_restricted=is_restricted)}"} + + def prepare(self, operation): + """Put in place what ``operation`` acts on beyond the fixtures every operation shares.""" + if operation == "profile_image_clear": + UserProfile.objects.filter(user=self.staff).update(profile_image_uploaded_at=datetime.now(UTC)) + + def call(self, operation, headers): + """Send the request that exercises ``operation``.""" + staff, learner = self.staff.username, self.learner.username + client = self.client + directory = {**headers, "HTTP_X_EDX_API_KEY": API_KEY} + if operation == "user_list": + return client.get(reverse("user_v2:user_list"), **directory) + if operation == "user_detail": + return client.get(reverse("user_v2:user_detail", kwargs={"username": learner}), **directory) + if operation == "user_preference_list": + return client.get(reverse("user_v2:user_preference_list"), **directory) + if operation == "user_preference_detail": + return client.get( + reverse("user_v2:user_preference_detail", kwargs={"username": learner, "key": "pref-lang"}), + **directory, + ) + if operation == "time_zone_list": + return client.get(reverse("user_v2:time_zone_list"), **headers) + if operation == "account_list": + return client.get(reverse("user_v2:account_list"), **headers) + if operation == "account_detail": + return client.get(reverse("user_v2:account_detail", kwargs={"username": staff}), **headers) + if operation == "account_partial_update": + return client.patch( + reverse("user_v2:account_detail", kwargs={"username": staff}), + json.dumps({"bio": "Changed by a token"}), content_type=MERGE_PATCH, **headers, + ) + if operation == "account_deactivation": + return client.post(reverse("user_v2:account_deactivation", kwargs={"username": learner}), **headers) + if operation == "email_search": + return client.post( + reverse("user_v2:email_search_list"), {"emails": [self.learner.email]}, format="json", **headers, + ) + if operation == "name_change_request": + return client.post( + reverse("user_v2:name_change_list"), {"name": "Renamed Staff"}, format="json", **headers, + ) + if operation == "name_change_confirmation": + return client.post( + reverse("user_v2:name_change_confirmation", kwargs={"username": learner}), **headers, + ) + if operation == "verification_status": + return client.get( + reverse("user_v2:account_verification_status", kwargs={"username": learner}), **headers, + ) + if operation == "verification_list": + return client.get( + reverse("user_v2:account_verification_list", kwargs={"username": learner}), **headers, + ) + if operation == "profile_image_set": + with make_image_file() as image_file: + return client.post( + reverse("user_v2:account_profile_image", kwargs={"username": staff}), + {"file": image_file}, format="multipart", **headers, + ) + if operation == "profile_image_clear": + return client.delete(reverse("user_v2:account_profile_image", kwargs={"username": staff}), **headers) + raise AssertionError(f"No request is defined for {operation}") + + def state(self): + """Return what any of these operations could change.""" + staff = UserProfile.objects.get(user=self.staff) + learner = User.objects.get(pk=self.learner.pk) + return { + "staff_bio": staff.bio, + "staff_has_profile_image": staff.has_profile_image, + "staff_pending_name": PendingNameChange.objects.filter(user=self.staff).count(), + "learner_name": UserProfile.objects.get(user=self.learner).name, + "learner_password_usable": learner.has_usable_password(), + } + + OPERATIONS = { + "user_list": status.HTTP_200_OK, + "user_detail": status.HTTP_200_OK, + "user_preference_list": status.HTTP_200_OK, + "user_preference_detail": status.HTTP_200_OK, + "time_zone_list": status.HTTP_200_OK, + "account_list": status.HTTP_200_OK, + "account_detail": status.HTTP_200_OK, + "account_partial_update": status.HTTP_200_OK, + "account_deactivation": status.HTTP_204_NO_CONTENT, + "email_search": status.HTTP_200_OK, + "name_change_request": status.HTTP_201_CREATED, + "name_change_confirmation": status.HTTP_204_NO_CONTENT, + "verification_status": status.HTTP_200_OK, + "verification_list": status.HTTP_200_OK, + "profile_image_set": status.HTTP_204_NO_CONTENT, + "profile_image_clear": status.HTTP_204_NO_CONTENT, + } + + @ddt.data(*OPERATIONS) + def test_a_restricted_applications_token_is_refused(self, operation): + self.prepare(operation) + before = self.state() + response = self.call(operation, self.token_headers(is_restricted=True)) + assert_error_envelope(response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz") + assert self.state() == before + + @ddt.data(*OPERATIONS.items()) + @ddt.unpack + def test_the_same_token_from_an_ordinary_application_is_answered(self, operation, expected_status): + self.prepare(operation) + response = self.call(operation, self.token_headers(is_restricted=False)) + assert response.status_code == expected_status, response.content + + def test_a_restricted_token_leaves_a_requested_name_unconfirmed(self): + self.call("name_change_confirmation", self.token_headers(is_restricted=True)) + assert UserProfile.objects.get(user=self.learner).name != "Renamed Learner" + assert PendingNameChange.objects.filter(user=self.learner, new_name="Renamed Learner").exists() + + def test_an_ordinary_token_confirms_the_requested_name(self): + response = self.call("name_change_confirmation", self.token_headers(is_restricted=False)) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert UserProfile.objects.get(user=self.learner).name == "Renamed Learner" + assert not PendingNameChange.objects.filter(user=self.learner).exists() + + +@skip_unless_lms +@ddt.ddt +@override_settings(MIDDLEWARE=[entry for entry in settings.MIDDLEWARE if entry != JWT_SETTINGS_MIDDLEWARE]) +class PreferenceTokenRefusalTests(APITestCase): + """ + The preference and email choice addresses refuse a restricted application's token themselves. + + The platform middleware adds the same refusal to the classes a view + declares once that view has served a request. It is left out of these + requests, and whatever it added to these views earlier in the run is set + aside, so the refusal seen here is the one each operation resolves for + itself. Each operation is called with the owner's token, or a staff + member's for what staff may do with another account, once as issued to an + ordinary application, which the operation answers, and once as issued to a + restricted one, which it refuses before reading or changing anything. + """ + + def setUp(self): + super().setUp() + for view_class, declared in DECLARED_PERMISSION_CLASSES.items(): + declared_only = mock.patch.object(view_class, "permission_classes", declared) + declared_only.start() + self.addCleanup(declared_only.stop) + self.owner = UserFactory.create( + username="token-owner", email="token-owner@example.com", password=PASSWORD, + ) + self.staff = UserFactory.create( + username="token-reader", email="token-reader@example.com", password=PASSWORD, is_staff=True, + ) + UserPreferenceFactory.create(user=self.owner, key="time_zone", value="Asia/Tokyo") + + def call(self, operation, is_restricted): + """Send the request that exercises ``operation`` with a token flagged restricted or not.""" + caller = self.staff if operation.startswith("staff_") else self.owner + headers = {"HTTP_AUTHORIZATION": f"JWT {generate_jwt(caller, is_restricted=is_restricted)}"} + owner = self.owner.username + document = reverse("user_v2:account_preference_list", kwargs={"username": owner}) + member = reverse("user_v2:account_preference_detail", kwargs={"username": owner, "key": "time_zone"}) + client = self.client + requests = { + "document_read": lambda: client.get(document, **headers), + "document_description": lambda: client.options(document, **headers), + "document_change": lambda: client.patch( + document, json.dumps({"time_zone": "Europe/Paris"}), content_type=MERGE_PATCH, **headers, + ), + "preference_read": lambda: client.get(member, **headers), + "preference_description": lambda: client.options(member, **headers), + "preference_store": lambda: client.put( + member, json.dumps({"value": "Europe/Paris"}), content_type="application/json", **headers, + ), + "preference_removal": lambda: client.delete(member, **headers), + "staff_document_read": lambda: client.get(document, **headers), + "staff_document_description": lambda: client.options(document, **headers), + "staff_preference_read": lambda: client.get(member, **headers), + "staff_preference_description": lambda: client.options(member, **headers), + "email_opt_in": lambda: client.post( + reverse("user_v2:email_opt_in_list"), + {"course_key": COURSE_KEY, "email_opt_in": True}, + format="json", + **headers, + ), + } + return requests[operation]() + + def state(self): + """Return what any of these operations could change.""" + return { + "preferences": sorted(UserPreference.objects.values_list("user__username", "key", "value")), + "email_choices": sorted(UserOrgTag.objects.values_list("user__username", "org", "value")), + } + + OPERATIONS = { + "document_read": status.HTTP_200_OK, + "document_description": status.HTTP_200_OK, + "document_change": status.HTTP_204_NO_CONTENT, + "preference_read": status.HTTP_200_OK, + "preference_description": status.HTTP_200_OK, + "preference_store": status.HTTP_204_NO_CONTENT, + "preference_removal": status.HTTP_204_NO_CONTENT, + "staff_document_read": status.HTTP_200_OK, + "staff_document_description": status.HTTP_200_OK, + "staff_preference_read": status.HTTP_200_OK, + "staff_preference_description": status.HTTP_200_OK, + "email_opt_in": status.HTTP_204_NO_CONTENT, + } + + @ddt.data(*OPERATIONS) + def test_a_restricted_applications_token_is_refused(self, operation): + before = self.state() + response = self.call(operation, is_restricted=True) + assert_error_envelope(response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz") + assert self.state() == before + + @ddt.data(*OPERATIONS.items()) + @ddt.unpack + def test_the_same_token_from_an_ordinary_application_is_answered(self, operation, expected_status): + response = self.call(operation, is_restricted=False) + assert response.status_code == expected_status, response.content + + def test_the_reads_answer_the_ordinary_token_with_what_the_account_stores(self): + """What the restricted token is refused is the stored preference itself.""" + assert self.call("document_read", is_restricted=False).json() == {"time_zone": "Asia/Tokyo"} + assert self.call("staff_preference_read", is_restricted=False).json() == { + "key": "time_zone", "value": "Asia/Tokyo", + } diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_parity.py b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_parity.py new file mode 100644 index 000000000000..5e72bebf3a80 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_parity.py @@ -0,0 +1,942 @@ +"""Behaviour parity between the version 1 and version 2 retirement pipeline addresses.""" + +import contextlib +import datetime +import json +from unittest import mock +from zoneinfo import ZoneInfo + +import pytest +from django.core.cache import cache +from django.test import SimpleTestCase, override_settings +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase +from social_django.models import UserSocialAuth +from wiki.models import ArticleRevision +from wiki.models.pluginbase import RevisionPluginRevision + +from common.djangoapps.student.models import ( + AccountRecovery, + CourseEnrollmentAllowed, + ManualEnrollmentAudit, + PendingEmailChange, + PendingNameChange, + PendingSecondaryEmailChange, + SocialLink, + UserProfile, +) +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.api_admin.models import ApiAccessRequest +from openedx.core.djangoapps.course_groups.models import UnregisteredLearnerCohortAssignments +from openedx.core.djangoapps.credit.models import CreditRequest, CreditRequirementStatus +from openedx.core.djangoapps.user_api.accounts import views as legacy_views +from openedx.core.djangoapps.user_api.accounts.serializers import UserRetirementStatusSerializer +from openedx.core.djangoapps.user_api.accounts.signals import ( + USER_RETIRE_LMS_CRITICAL, + USER_RETIRE_LMS_MISC, + USER_RETIRE_MAILINGS, +) +from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import ( # pylint: disable=unused-import + create_retirement_status, + setup_retirement_states, # noqa: F401 +) +from openedx.core.djangoapps.user_api.accounts.views import ( + USER_PROFILE_PII, + AccountRetirementView, + LMSAccountRetirementView, +) +from openedx.core.djangoapps.user_api.models import ( + RetirementState, + RetirementStateError, + UserOrgTag, + UserRetirementPartnerReportingStatus, + UserRetirementStatus, +) +from openedx.core.djangoapps.user_api.v2 import view_services +from openedx.core.djangoapps.user_api.v2.errors import RetirementStateConflict +from openedx.core.djangoapps.user_api.v2.paginators import UserApiPagination +from openedx.core.djangoapps.user_api.v2.views import ( + RetirementCleanupViewSet, + RetirementPartnerReportCleanupViewSet, + RetirementPartnerReportViewSet, + RetirementViewSet, +) +from openedx.core.djangolib.testing.utils import skip_unless_lms + +PASSWORD = "Password1234" +WORKER_USERNAME = "parity-retirement-worker" + +#: The step of the pipeline at which the erasures are run. +WORKING_STATE = "RETIRING_LMS" + +#: The step a record is moved on to, so that the step it stands at and the +#: step it came from are different steps and a record naming one of them +#: twice is not read as naming both. +MOVED_TO_STATE = "LMS_COMPLETE" + +#: Each step of the erasure of the account's own data, named by what it clears +#: and by where both versions reach it. ``None`` as the holder means each +#: version calls the step through its own module, so both are recorded. +CORE_STEPS = ( + ("profile", AccountRetirementView, "clear_pii_from_userprofile"), + ("profile_images", AccountRetirementView, "delete_users_profile_images"), + ("country_cache", AccountRetirementView, "delete_users_country_cache"), + ("certificate_names", AccountRetirementView, "clear_pii_from_certificate_records"), + ("entitlement_notes", AccountRetirementView, "retire_entitlement_support_detail"), + ("pending_email_change", PendingEmailChange, "delete_by_user_value"), + ("organization_tags", UserOrgTag, "delete_by_user_value"), + ("pending_secondary_email", PendingSecondaryEmailChange, "redact_and_delete_pending_secondary_email"), + ("recovery_email", AccountRecovery, "retire_recovery_email"), + ("enrollment_invitations", CourseEnrollmentAllowed, "delete_by_user_value"), + ("cohort_assignments", UnregisteredLearnerCohortAssignments, "delete_by_user_value"), +) + +#: Each step of the erasure of what the features around the account hold. +MISC_STEPS = ( + ("wiki_plugin_revisions", RevisionPluginRevision, "retire_user"), + ("wiki_article_revisions", ArticleRevision, "retire_user"), + ("pending_name_change", PendingNameChange, "delete_by_user_value"), + ("manual_enrolments", ManualEnrollmentAudit, "retire_manual_enrollments"), + ("credit_requests", CreditRequest, "retire_user"), + ("api_access_requests", ApiAccessRequest, "retire_user"), + ("credit_requirements", CreditRequirementStatus, "retire_user"), +) + +#: The step each version reaches through its own module rather than through a +#: shared object, so it is patched in both. +HISTORICAL_SOCIAL_AUTH_STEP = ("historical_social_auth", "redact_and_delete_historical_social_auth") + +#: The declared differences between a version 1 retirement record and a +#: version 2 one. Each entry is a (property, reason) pair, and the comparison +#: must find all of them and nothing else. +RECORD_DIFFERENCES = ( + ("current_state", "the step is the name of the step rather than an object carrying its order and its id"), + ("last_state", "the step is the name of the step rather than an object carrying its order and its id"), +) + +#: The declared differences between the filters of the two version 1 queue +#: reads and the version 2 list that answers for both. Each entry is a +#: (parameter, reason) pair, and the tests below measure every one of them on +#: both versions, so a parameter that silently selects other rows than the one +#: it is read in place of is either here or failing. +QUEUE_FILTER_DIFFERENCES = ( + ( + "created_before", + "the bound is exclusive, where the end_date it is read in place of took in the whole of " + "the day named; the same rows are asked for by naming the day after", + ), + ( + "cool_off_days", + "a period longer than a century is refused, where the queue read answered it, and failed " + "with a server error once the period reached back past the first year of the calendar", + ), +) + +#: Each view of this version with the view whose operations it takes over, for +#: the media types a body may be sent in. The disposals are part of the +#: earlier version's two pipeline views and are compared against those. +MEDIA_TYPE_PAIRS = ( + (RetirementViewSet, legacy_views.AccountRetirementStatusView), + (RetirementCleanupViewSet, legacy_views.AccountRetirementStatusView), + (RetirementPartnerReportViewSet, legacy_views.AccountRetirementPartnerReportView), + (RetirementPartnerReportCleanupViewSet, legacy_views.AccountRetirementPartnerReportView), +) + + +@pytest.mark.usefixtures("setup_retirement_states") +@override_settings(RETIREMENT_SERVICE_WORKER_USERNAME=WORKER_USERNAME) +class RetirementParityTestCase(APITestCase): + """The caller both versions of the pipeline's addresses admit.""" + + def setUp(self): + super().setUp() + self.worker = UserFactory.create(username=WORKER_USERNAME, password=PASSWORD) + assert self.client.login(username=WORKER_USERNAME, password=PASSWORD) + + def retirement_for(self, user): + """Return a retirement of this account, standing where the erasures run.""" + return create_retirement_status(user, state=RetirementState.objects.get(state_name=WORKING_STATE)) + + +@skip_unless_lms +class ErasureStepParityTests(RetirementParityTestCase): + """Both versions clear the same things in the same order.""" + + def steps_taken(self, steps, call, own_module_step=None): + """Return the steps a call takes, in the order it takes them.""" + taken = [] + + def record(label): + def step(*args, **kwargs): # pylint: disable=unused-argument + taken.append(label) + return step + + with contextlib.ExitStack() as patches: + for label, holder, attribute in steps: + patches.enter_context( + mock.patch.object(holder, attribute, side_effect=record(label)), + ) + if own_module_step is not None: + label, attribute = own_module_step + for module in (legacy_views, view_services): + patches.enter_context( + mock.patch.object(module, attribute, side_effect=record(label)), + ) + response = call() + assert response.status_code == status.HTTP_204_NO_CONTENT, response.content + return taken + + def legacy_erasure(self, address_name, user): + """Run a version 1 erasure of this account.""" + return lambda: self.client.post( + reverse(address_name), {"username": user.username}, format="json", + ) + + def conforming_erasure(self, scope, user): + """Run a version 2 erasure of this account.""" + return lambda: self.client.post( + reverse("user_v2:retirement_erasure", kwargs={"username": user.username}), + {"scope": scope}, + format="json", + ) + + def test_the_core_scope_takes_the_same_steps_in_the_same_order(self): + legacy_user = UserFactory.create() + self.retirement_for(legacy_user) + conforming_user = UserFactory.create() + self.retirement_for(conforming_user) + + legacy = self.steps_taken(CORE_STEPS, self.legacy_erasure("accounts_retire", legacy_user)) + conforming = self.steps_taken(CORE_STEPS, self.conforming_erasure("lms_core", conforming_user)) + + assert legacy == [label for label, _holder, _attribute in CORE_STEPS] + assert conforming == legacy + + def test_the_miscellaneous_scope_takes_the_same_steps_in_the_same_order(self): + legacy_user = UserFactory.create() + self.retirement_for(legacy_user) + conforming_user = UserFactory.create() + self.retirement_for(conforming_user) + + legacy = self.steps_taken( + MISC_STEPS, + self.legacy_erasure("accounts_retire_misc", legacy_user), + own_module_step=HISTORICAL_SOCIAL_AUTH_STEP, + ) + conforming = self.steps_taken( + MISC_STEPS, + self.conforming_erasure("lms_misc", conforming_user), + own_module_step=HISTORICAL_SOCIAL_AUTH_STEP, + ) + + assert legacy == [ + *[label for label, _holder, _attribute in MISC_STEPS], + HISTORICAL_SOCIAL_AUTH_STEP[0], + ] + assert conforming == legacy + + def test_neither_scope_takes_a_step_belonging_to_the_other(self): + core_user = UserFactory.create() + self.retirement_for(core_user) + misc_user = UserFactory.create() + self.retirement_for(misc_user) + + assert not self.steps_taken(MISC_STEPS, self.conforming_erasure("lms_core", core_user)) + assert not self.steps_taken(CORE_STEPS, self.conforming_erasure("lms_misc", misc_user)) + + +@skip_unless_lms +class ErasureStateParityTests(RetirementParityTestCase): + """Both versions leave the stored data in the same state.""" + + def furnished_account(self, name): + """Return an account carrying one of everything the erasures clear.""" + user = UserFactory.create(username=name, email=f"{name}@example.com", first_name="A", last_name="B") + user.profile.name = "Learner Name" + user.profile.city = "Somewhere" + user.profile.bio = "A biography." + user.profile.year_of_birth = 1990 + user.profile.save() + SocialLink.objects.create( + user_profile=user.profile, platform="facebook", social_link="https://facebook.com/x", + ) + PendingEmailChange.objects.create( + user=user, new_email=f"new-{name}@example.com", activation_key=f"email-{name}", + ) + UserOrgTag.objects.create(user=user, key="foo", value="bar", org="edX") + PendingSecondaryEmailChange.objects.create( + user=user, new_secondary_email=f"second-{name}@example.com", activation_key=f"second-{name}", + ) + AccountRecovery.objects.create( + user=user, secondary_email=f"recovery-{name}@example.com", is_active=True, + ) + PendingNameChange.objects.create(user=user, new_name="Another Name", rationale="because") + UserSocialAuth.objects.create(user=user, provider="google-oauth2", uid=f"{name}@example.com") + cache.set(UserProfile.country_cache_key_name(user.id), "Timor-leste") + retirement = self.retirement_for(user) + CourseEnrollmentAllowed.objects.create( + email=retirement.original_email, course_id="course-v1:edX+DemoX+Demo", + ) + return user, retirement + + def stored_state(self, user, retirement): + """Return what is stored about this account, described without naming the account.""" + user.refresh_from_db() + profile = UserProfile.objects.get(user=user) + return { + "username_is_the_hashed_one": user.username == retirement.retired_username, + "email_is_the_hashed_one": user.email == retirement.retired_email, + "first_name": user.first_name, + "last_name": user.last_name, + "is_active": user.is_active, + "profile": {field: getattr(profile, field) for field in USER_PROFILE_PII}, + "social_links": profile.social_links.count(), + "pending_email_changes": PendingEmailChange.objects.filter(user=user).count(), + "organization_tags": UserOrgTag.objects.filter(user=user).count(), + "pending_secondary_email_changes": PendingSecondaryEmailChange.objects.filter(user=user).count(), + "recovery_emails": AccountRecovery.objects.filter(user=user).count(), + "enrollment_invitations": CourseEnrollmentAllowed.objects.filter( + email=retirement.original_email, + ).count(), + "cohort_assignments": UnregisteredLearnerCohortAssignments.objects.filter( + email=retirement.original_email, + ).count(), + "country_cache": cache.get(UserProfile.country_cache_key_name(user.id)), + "pending_name_changes": PendingNameChange.objects.filter(user=user).count(), + "social_auths": UserSocialAuth.objects.filter(user=user).count(), + "historical_social_auths": UserSocialAuth.history.model.objects.filter(user_id=user.id).count(), + } + + def test_the_core_scope_leaves_the_same_state_behind(self): + legacy_user, legacy_retirement = self.furnished_account("parity-core-legacy") + conforming_user, conforming_retirement = self.furnished_account("parity-core-conforming") + + assert self.client.post( + reverse("accounts_retire"), {"username": legacy_user.username}, format="json", + ).status_code == status.HTTP_204_NO_CONTENT + assert self.client.post( + reverse("user_v2:retirement_erasure", kwargs={"username": conforming_user.username}), + {"scope": "lms_core"}, + format="json", + ).status_code == status.HTTP_204_NO_CONTENT + + legacy_state = self.stored_state(legacy_user, legacy_retirement) + assert legacy_state == self.stored_state(conforming_user, conforming_retirement) + # The comparison is only worth anything if the erasure really ran. + assert legacy_state["username_is_the_hashed_one"] is True + assert legacy_state["is_active"] is False + assert legacy_state["profile"]["name"] == "" + assert legacy_state["social_links"] == 0 + assert legacy_state["pending_email_changes"] == 0 + assert legacy_state["organization_tags"] == 0 + assert legacy_state["recovery_emails"] == 0 + assert legacy_state["enrollment_invitations"] == 0 + assert legacy_state["country_cache"] is None + + def test_the_miscellaneous_scope_leaves_the_same_state_behind(self): + legacy_user, legacy_retirement = self.furnished_account("parity-misc-legacy") + conforming_user, conforming_retirement = self.furnished_account("parity-misc-conforming") + + assert self.client.post( + reverse("accounts_retire_misc"), {"username": legacy_user.username}, format="json", + ).status_code == status.HTTP_204_NO_CONTENT + assert self.client.post( + reverse("user_v2:retirement_erasure", kwargs={"username": conforming_user.username}), + {"scope": "lms_misc"}, + format="json", + ).status_code == status.HTTP_204_NO_CONTENT + + legacy_state = self.stored_state(legacy_user, legacy_retirement) + assert legacy_state == self.stored_state(conforming_user, conforming_retirement) + assert legacy_state["pending_name_changes"] == 0 + assert legacy_state["historical_social_auths"] == 0 + # This scope clears the history of a sign-in method, not the method itself. + assert legacy_state["social_auths"] == 1 + # The account itself is left alone by this scope. + assert legacy_state["username_is_the_hashed_one"] is False + assert legacy_state["is_active"] is True + + +@skip_unless_lms +class ErasureSignalParityTests(RetirementParityTestCase): + """Both versions announce the same things to the same listeners.""" + + def announcements(self, signal, call, user, retirement): + """Return what a call announced through this signal, described without naming the account.""" + heard = [] + + def listen(sender, **kwargs): + kwargs.pop("signal", None) + described = {"sender": sender} + for key, value in sorted(kwargs.items()): + if getattr(value, "pk", None) == user.pk: + described[key] = "" + elif value == retirement.retired_username: + described[key] = "" + elif value == retirement.retired_email: + described[key] = "" + elif value == retirement.original_email: + described[key] = "" + else: + described[key] = value + heard.append(described) + + signal.connect(listen) + try: + response = call() + finally: + signal.disconnect(listen) + assert response.status_code == status.HTTP_204_NO_CONTENT, response.content + return heard + + def test_the_core_scope_announces_the_same_thing(self): + legacy_user = UserFactory.create() + legacy_retirement = self.retirement_for(legacy_user) + conforming_user = UserFactory.create() + conforming_retirement = self.retirement_for(conforming_user) + + legacy = self.announcements( + USER_RETIRE_LMS_CRITICAL, + lambda: self.client.post( + reverse("accounts_retire"), {"username": legacy_user.username}, format="json", + ), + legacy_user, + legacy_retirement, + ) + conforming = self.announcements( + USER_RETIRE_LMS_CRITICAL, + lambda: self.client.post( + reverse("user_v2:retirement_erasure", kwargs={"username": conforming_user.username}), + {"scope": "lms_core"}, + format="json", + ), + conforming_user, + conforming_retirement, + ) + + assert legacy == [{ + "sender": AccountRetirementView, + "retired_email": "", + "retired_username": "", + "user": "", + }] + assert conforming == legacy + + def test_the_miscellaneous_scope_announces_the_same_two_things(self): + legacy_user = UserFactory.create() + legacy_retirement = self.retirement_for(legacy_user) + conforming_user = UserFactory.create() + conforming_retirement = self.retirement_for(conforming_user) + + for signal, expected in ( + (USER_RETIRE_LMS_MISC, [{"sender": LMSAccountRetirementView, "user": ""}]), + ( + USER_RETIRE_MAILINGS, + [{ + "sender": LMSAccountRetirementView, + "email": "", + "new_email": "", + "user": "", + }], + ), + ): + with self.subTest(signal=signal): + legacy = self.announcements( + signal, + lambda: self.client.post( + reverse("accounts_retire_misc"), + {"username": legacy_user.username}, + format="json", + ), + legacy_user, + legacy_retirement, + ) + conforming = self.announcements( + signal, + lambda: self.client.post( + reverse( + "user_v2:retirement_erasure", + kwargs={"username": conforming_user.username}, + ), + {"scope": "lms_misc"}, + format="json", + ), + conforming_user, + conforming_retirement, + ) + assert legacy == expected + assert conforming == legacy + + +@skip_unless_lms +class StateMachineParityTests(RetirementParityTestCase): + """The moves the pipeline allows and refuses are the same on both versions.""" + + #: Each case is the step a retirement stands at, the step asked for, + #: whether the move is forced, and whether each version carries it out. + MOVES = ( + ("LOCKING_ACCOUNT", "PENDING", False, False), + ("LOCKING_ACCOUNT", "PENDING", True, True), + ("COMPLETE", "PENDING", False, False), + ("COMPLETE", "PENDING", True, True), + ("LOCKING_ACCOUNT", "LOCKING_ACCOUNT", False, False), + ("LOCKING_ACCOUNT", "LOCKING_ACCOUNT", True, True), + ) + + def ask_for_the_move(self, address, body, legacy): + """Ask one of the two versions for a move, and return the status it answered with.""" + if legacy: + return self.client.patch(reverse("accounts_retirement_update"), body, format="json") + return self.client.patch(address, body, format="json") + + def test_both_versions_carry_out_and_refuse_the_same_moves(self): + for standing_at, asked_for, force, moves in self.MOVES: + with self.subTest(standing_at=standing_at, asked_for=asked_for, force=force): + legacy_retirement = self.retirement_for(UserFactory.create()) + conforming_retirement = self.retirement_for(UserFactory.create()) + for retirement in (legacy_retirement, conforming_retirement): + retirement.current_state = RetirementState.objects.get(state_name=standing_at) + retirement.save() + + body = {"new_state": asked_for, "response": "foo"} + if force: + body["force"] = True + + legacy = self.client.patch( + reverse("accounts_retirement_update"), + {**body, "username": legacy_retirement.original_username}, + format="json", + ) + conforming = self.client.patch( + reverse( + "user_v2:retirement_detail", + kwargs={"username": conforming_retirement.original_username}, + ), + body, + format="json", + ) + + if moves: + assert legacy.status_code == status.HTTP_204_NO_CONTENT + assert conforming.status_code == status.HTTP_204_NO_CONTENT + else: + assert legacy.status_code == status.HTTP_400_BAD_REQUEST + assert conforming.status_code == status.HTTP_409_CONFLICT + + legacy_retirement.refresh_from_db() + conforming_retirement.refresh_from_db() + assert ( + conforming_retirement.current_state.state_name + == legacy_retirement.current_state.state_name + ) + assert ( + conforming_retirement.last_state.state_name + == legacy_retirement.last_state.state_name + ) + + +@skip_unless_lms +class ActionGateParityTests(RetirementParityTestCase): + """Both versions refuse pipeline work on a retirement at the same steps, and admit it at the rest.""" + + #: The configured steps neither version admits work at: the steps the + #: pipeline requires, and every step recording a finished part of it. + REFUSED_STEPS = { + "PENDING", + "LOCKING_COMPLETE", + "CREDENTIALS_COMPLETE", + "ECOM_COMPLETE", + "FORUMS_COMPLETE", + "EMAIL_LISTS_COMPLETE", + "ENROLLMENTS_COMPLETE", + "NOTES_COMPLETE", + "LMS_COMPLETE", + "PARTNER_QUEUE_COMPLETE", + "ERRORED", + "ABORTED", + "COMPLETE", + } + + @staticmethod + def refused_by_version_1(username): + """Whether the version 1 gate refuses work on the retirement requested under this name.""" + try: + UserRetirementStatus.get_retirement_for_retirement_action(username) + except RetirementStateError: + return True + return False + + @staticmethod + def refused_by_version_2(username): + """Whether the version 2 gate refuses work on the retirement requested under this name.""" + try: + view_services.retirement_open_to_action(UserRetirementStatus.objects.all(), username) + except RetirementStateConflict: + return True + return False + + def test_both_versions_refuse_work_at_the_same_steps(self): + steps = set(RetirementState.objects.values_list("state_name", flat=True)) + refused = {"version 1": set(), "version 2": set()} + for state in RetirementState.objects.all(): + username = create_retirement_status(UserFactory.create(), state=state).original_username + if self.refused_by_version_1(username): + refused["version 1"].add(state.state_name) + if self.refused_by_version_2(username): + refused["version 2"].add(state.state_name) + assert refused == {"version 1": self.REFUSED_STEPS, "version 2": self.REFUSED_STEPS} + # Steps that admit work are configured too, so each gate's admitting side ran. + assert steps - self.REFUSED_STEPS == { + "LOCKING_ACCOUNT", + "RETIRING_CREDENTIALS", + "RETIRING_ECOM", + "RETIRING_FORUMS", + "RETIRING_EMAIL_LISTS", + "RETIRING_ENROLLMENTS", + "RETIRING_NOTES", + "RETIRING_LMS", + "ADDING_TO_PARTNER_QUEUE", + } + + +@skip_unless_lms +class RetirementRecordParityTests(RetirementParityTestCase): + """A version 2 retirement record carries the values a version 1 one carries.""" + + def setUp(self): + super().setUp() + self.retirement = self.retirement_for( + UserFactory.create(username="parity-subject", email="parity-subject@example.com"), + ) + # Moved on once, so the step the record stands at and the step it came + # from are different steps. + self.retirement.update_state({ + "username": self.retirement.original_username, + "new_state": MOVED_TO_STATE, + "response": "moved on", + }) + assert self.retirement.current_state.state_name != self.retirement.last_state.state_name + + def test_the_record_carries_the_same_values_bar_the_declared_differences(self): + legacy = UserRetirementStatusSerializer(instance=self.retirement).data + conforming = json.loads( + self.client.get( + reverse("user_v2:retirement_detail", kwargs={"username": "parity-subject"}), + ).content, + ) + + assert set(conforming) == set(legacy) + changed = sorted( + name for name in legacy + if json.loads(json.dumps(legacy[name], default=str)) != conforming[name] + ) + assert changed == sorted(name for name, _reason in RECORD_DIFFERENCES) + + def test_the_declared_differences_are_the_step_names(self): + conforming = self.client.get( + reverse("user_v2:retirement_detail", kwargs={"username": "parity-subject"}), + ).data + legacy = UserRetirementStatusSerializer(instance=self.retirement).data + assert conforming["current_state"] == legacy["current_state"]["state_name"] == MOVED_TO_STATE + assert conforming["last_state"] == legacy["last_state"]["state_name"] == WORKING_STATE + + def test_neither_version_carries_the_log_of_the_moves_a_retirement_has_made(self): + conforming = self.client.get( + reverse("user_v2:retirement_detail", kwargs={"username": "parity-subject"}), + ).data + assert "responses" not in conforming + assert "responses" not in UserRetirementStatusSerializer(instance=self.retirement).data + assert UserRetirementStatus.objects.get(id=self.retirement.id).responses != "" + + +@skip_unless_lms +class QueueFilterParityTests(RetirementParityTestCase): + """ + The filters of the merged list against the two version 1 reads they answer for. + + The callers of these addresses are drivers outside this repository, which + reach the merged list by renaming the parameters they already send. A + parameter that selects other rows than the one it is renamed from is + answered 200 and so cannot be noticed by such a caller, which is why each + is measured on both versions here rather than reasoned about. + """ + + #: A day with retirements requested before it, during it, and after it, so + #: that a bound naming it is measured from both sides. + BOUND = datetime.datetime(2020, 6, 10, tzinfo=ZoneInfo("UTC")) + + def setUp(self): + super().setUp() + self.moments = { + "the-day-before": self.BOUND - datetime.timedelta(days=1), + "the-day-itself": self.BOUND + datetime.timedelta(hours=12), + "the-day-after": self.BOUND + datetime.timedelta(days=1, hours=1), + } + self.requested = { + name: create_retirement_status( + UserFactory.create(username=name), + state=RetirementState.objects.get(state_name="COMPLETE"), + create_datetime=moment, + ) + for name, moment in self.moments.items() + } + + def legacy_by_date(self, start, end): + """Return the names the version 1 by-date read answers for this range.""" + response = self.client.get( + reverse("accounts_retirements_by_status_and_date"), + {"start_date": start, "end_date": end, "state": "COMPLETE"}, + ) + assert response.status_code == status.HTTP_200_OK + return [row["original_username"] for row in response.json()] + + def conforming_by_date(self, after, before): + """Return the names the merged list answers for this range.""" + response = self.client.get( + reverse("user_v2:retirement_list"), + {"state": "COMPLETE", "created_after": after, "created_before": before, "page_size": 100}, + ) + assert response.status_code == status.HTTP_200_OK + return [row["original_username"] for row in response.json()["results"]] + + def test_the_declared_difference_is_the_day_the_earlier_bound_names(self): + """Measured both ways: the day named is in the version 1 answer and out of this one.""" + assert self.legacy_by_date("2020-06-01", "2020-06-10") == ["the-day-before", "the-day-itself"] + assert self.conforming_by_date("2020-06-01", "2020-06-10") == ["the-day-before"] + assert [name for name, _reason in QUEUE_FILTER_DIFFERENCES] == ["created_before", "cool_off_days"] + + def legacy_queue(self, cool_off_days): + """Ask the version 1 queue read for the finished retirements this many days old.""" + return self.client.get( + reverse("accounts_retirement_queue"), {"cool_off_days": cool_off_days, "states": "COMPLETE"}, + ) + + def conforming_queue(self, cool_off_days): + """Ask the merged list for the finished retirements this many days old.""" + return self.client.get( + reverse("user_v2:retirement_list"), {"cool_off_days": cool_off_days, "state": "COMPLETE", "page_size": 100}, + ) + + def test_a_cooling_off_period_of_a_century_answers_the_same_rows_on_both_versions(self): + create_retirement_status( + UserFactory.create(username="requested-long-ago"), + state=RetirementState.objects.get(state_name="COMPLETE"), + create_datetime=datetime.datetime(1900, 1, 1, tzinfo=ZoneInfo("UTC")), + ) + legacy = self.legacy_queue(36500) + conforming = self.conforming_queue(36500) + assert (legacy.status_code, conforming.status_code) == (status.HTTP_200_OK, status.HTTP_200_OK) + assert [row["original_username"] for row in legacy.json()] == ["requested-long-ago"] + assert [row["original_username"] for row in conforming.json()["results"]] == ["requested-long-ago"] + + def test_the_declared_difference_is_a_cooling_off_period_longer_than_a_century(self): + """Measured both ways: refused here, where version 1 answered it or failed outright.""" + longer = self.legacy_queue(36501) + assert longer.status_code == status.HTTP_200_OK + assert longer.json() == [] + with pytest.raises(OverflowError): + self.legacy_queue(800000) + for value in (36501, 800000): + with self.subTest(value=value): + refused = self.conforming_queue(value) + assert refused.status_code == status.HTTP_400_BAD_REQUEST + assert refused.json()["errors"] == { + "cool_off_days": ["Ensure this value is less than or equal to 36500."], + } + + def test_the_rows_the_declared_difference_leaves_out_are_asked_for_by_naming_the_day_after(self): + assert self.conforming_by_date("2020-06-01", "2020-06-11") == [ + "the-day-before", "the-day-itself", + ] + assert self.legacy_by_date("2020-06-01", "2020-06-10") == self.conforming_by_date( + "2020-06-01", "2020-06-11", + ) + + def test_the_later_bound_takes_in_the_day_it_names_on_both_versions(self): + """``start_date`` and ``created_after`` are the one pair that means the same thing.""" + assert self.legacy_by_date("2020-06-10", "2020-06-10") == ["the-day-itself"] + assert self.conforming_by_date("2020-06-10", "2020-06-11") == ["the-day-itself"] + # One day asked for on the earlier version is that day named at both + # ends; on this one it is that day and the day after it. + assert self.legacy_by_date("2020-06-09", "2020-06-09") == ["the-day-before"] + assert self.conforming_by_date("2020-06-09", "2020-06-10") == ["the-day-before"] + + def test_steps_named_by_repeating_the_parameter_answer_the_same_rows_on_both_versions(self): + """The version 1 queue read its steps as repeated parameters, and this list reads them too.""" + create_retirement_status( + UserFactory.create(username="still-pending"), + state=RetirementState.objects.get(state_name="PENDING"), + create_datetime=self.BOUND, + ) + legacy = self.client.get( + reverse("accounts_retirement_queue") + + "?cool_off_days=0&states=COMPLETE&states=PENDING", + ) + assert legacy.status_code == status.HTTP_200_OK + conforming = self.client.get( + reverse("user_v2:retirement_list") + + "?cool_off_days=0&state=COMPLETE&state=PENDING&page_size=100", + ) + assert conforming.status_code == status.HTTP_200_OK + answered = [row["original_username"] for row in legacy.json()] + assert answered == [ + "the-day-before", "the-day-itself", "the-day-after", "still-pending", + ] + assert [row["original_username"] for row in conforming.json()["results"]] == answered + + def test_one_step_named_once_answers_the_same_rows_on_both_versions(self): + """The comma-separated idiom of this list is not the only one it reads.""" + legacy = self.client.get( + reverse("accounts_retirement_queue") + "?cool_off_days=0&states=COMPLETE", + ) + conforming = self.client.get( + reverse("user_v2:retirement_list") + "?cool_off_days=0&state=COMPLETE&page_size=100", + ) + assert [row["original_username"] for row in legacy.json()] == [ + "the-day-before", "the-day-itself", "the-day-after", + ] + assert [row["original_username"] for row in conforming.json()["results"]] == [ + row["original_username"] for row in legacy.json() + ] + + +#: More names than a page of any collection of this version holds, which is +#: what the pipeline sends when it disposes of a whole queue in one request. +WHOLE_QUEUE = UserApiPagination.max_page_size + 1 + + +@skip_unless_lms +class BatchDisposalParityTests(RetirementParityTestCase): + """ + The two disposals of a set of records named in the body, on both versions. + + The pipeline's jobs take every entry waiting and then dispose of all of + them in a single request, so neither version bounds how many names that + request carries. Each name is matched exactly as it was sent. + """ + + def finished_retirements(self, prefix): + """Return a whole queue of retirements the pipeline has finished with.""" + complete = RetirementState.objects.get(state_name="COMPLETE") + return [ + create_retirement_status(UserFactory.create(username=f"{prefix}-{index}"), state=complete) + for index in range(WHOLE_QUEUE) + ] + + def queue_partner_reports(self, prefix): + """Queue a whole queue of partner report entries, none of them taken yet.""" + for index in range(WHOLE_QUEUE): + user = UserFactory.create(username=f"{prefix}-{index}") + UserRetirementPartnerReportingStatus.objects.create( + user=user, original_username=user.username, original_email=user.email, + ) + + def test_a_whole_queue_of_finished_retirements_is_disposed_of_in_one_request_on_both_versions(self): + legacy = self.finished_retirements("legacy") + conforming = self.finished_retirements("conforming") + + legacy_answer = self.client.post( + reverse("accounts_retirement_cleanup"), + {"usernames": [record.original_username for record in legacy]}, + format="json", + ) + conforming_answer = self.client.post( + reverse("user_v2:retirement_cleanup_list"), + {"usernames": [record.original_username for record in conforming]}, + format="json", + ) + + assert legacy_answer.status_code == status.HTTP_204_NO_CONTENT + assert conforming_answer.status_code == status.HTTP_200_OK + assert [result["outcome"] for result in conforming_answer.data["results"]] == ["deleted"] * WHOLE_QUEUE + left = { + version: UserRetirementStatus.objects.filter(id__in=[record.id for record in records]).count() + for version, records in (("legacy", legacy), ("conforming", conforming)) + } + assert left == {"legacy": 0, "conforming": 0} + + def test_a_whole_queue_of_partner_reports_is_taken_and_then_disposed_of_on_both_versions(self): + """The partner report job disposes of every entry it took, in the request after the reports go out.""" + self.queue_partner_reports("legacy") + taken = self.client.post(reverse("accounts_retirement_partner_report")).json() + legacy_answer = self.client.post( + reverse("accounts_retirement_partner_report_cleanup"), + [{"original_username": entry["original_username"]} for entry in taken], + format="json", + ) + legacy_left = UserRetirementPartnerReportingStatus.objects.count() + + self.queue_partner_reports("conforming") + claimed = self.client.post(reverse("user_v2:retirement_partner_report_list")).json() + conforming_answer = self.client.post( + reverse("user_v2:retirement_partner_report_cleanup_list"), + {"usernames": [entry["original_username"] for entry in claimed]}, + format="json", + ) + conforming_left = UserRetirementPartnerReportingStatus.objects.count() + + assert (len(taken), len(claimed)) == (WHOLE_QUEUE, WHOLE_QUEUE) + assert (legacy_answer.status_code, conforming_answer.status_code) == ( + status.HTTP_204_NO_CONTENT, status.HTTP_200_OK, + ) + assert (legacy_left, conforming_left) == (0, 0) + + def test_a_name_sent_with_spaces_around_it_disposes_of_no_retirement_on_either_version(self): + complete = RetirementState.objects.get(state_name="COMPLETE") + legacy = create_retirement_status(UserFactory.create(username="padded-legacy"), state=complete) + conforming = create_retirement_status(UserFactory.create(username="padded-conforming"), state=complete) + + legacy_answer = self.client.post( + reverse("accounts_retirement_cleanup"), {"usernames": [" padded-legacy "]}, format="json", + ) + conforming_answer = self.client.post( + reverse("user_v2:retirement_cleanup_list"), {"usernames": [" padded-conforming "]}, format="json", + ) + + assert legacy_answer.status_code == status.HTTP_400_BAD_REQUEST + # No record was requested under the name as it was sent, so it is + # reported as absent, under that name, rather than refused. + assert conforming_answer.data == { + "results": [{"username": " padded-conforming ", "outcome": "already_absent"}], + } + kept = set(UserRetirementStatus.objects.filter(id__in=(legacy.id, conforming.id)).values_list("id", flat=True)) + assert kept == {legacy.id, conforming.id} + + def test_a_name_sent_with_spaces_around_it_disposes_of_no_partner_report_on_either_version(self): + for name in ("padded-legacy", "padded-conforming"): + user = UserFactory.create(username=name) + UserRetirementPartnerReportingStatus.objects.create( + user=user, original_username=name, original_email=user.email, is_being_processed=True, + ) + + legacy_answer = self.client.post( + reverse("accounts_retirement_partner_report_cleanup"), + [{"original_username": " padded-legacy "}], + format="json", + ) + conforming_answer = self.client.post( + reverse("user_v2:retirement_partner_report_cleanup_list"), + {"usernames": [" padded-conforming "]}, + format="json", + ) + + assert legacy_answer.status_code == status.HTTP_400_BAD_REQUEST + assert conforming_answer.data == { + "results": [{"username": " padded-conforming ", "outcome": "already_absent"}], + } + kept = set(UserRetirementPartnerReportingStatus.objects.values_list("original_username", flat=True)) + assert kept == {"padded-legacy", "padded-conforming"} + + +class MediaTypeParityTests(SimpleTestCase): + """Each operation reads a body in the media types the operation it replaces reads.""" + + def test_each_view_reads_what_the_view_it_takes_over_from_reads(self): + for conforming, legacy in MEDIA_TYPE_PAIRS: + with self.subTest(view=conforming.__name__): + assert tuple(conforming.parser_classes) == tuple(legacy.parser_classes) + + def test_the_cancellation_is_the_one_operation_whose_view_narrowed_nothing(self): + """It reads no body on either version, so there is nothing for a media type to decide.""" + assert "parser_classes" not in vars(legacy_views.CancelAccountRetirementStatusView) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_query_counts.py b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_query_counts.py new file mode 100644 index 000000000000..6e0e0945e4aa --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_query_counts.py @@ -0,0 +1,167 @@ +"""Query-count guards for the version 2 retirement pipeline addresses.""" + +import pytest +from django.urls import reverse + +from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory +from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import ( # pylint: disable=unused-import + create_retirement_status, + setup_retirement_states, # noqa: F401 +) +from openedx.core.djangoapps.user_api.models import ( + RetirementState, + UserRetirementPartnerReportingStatus, +) +from openedx.core.djangolib.testing.utils import skip_unless_lms + +PASSWORD = "Password1234" +WORKER_USERNAME = "count-retirement-worker" + +#: Row counts a per-row query would show up at. +ROW_COUNTS = [1, 5, 25] + +#: How many courses each account in the partner reporting queue studied, which +#: is what the legacy claim reads one query at a time. +ENROLMENTS_PER_ROW = 3 + +#: The step the rows of the queue stand at, which is one the pipeline works. +WORKING_STATE = "LOCKING_ACCOUNT" + +RETIREMENT_LIST_QUERIES = 15 +RETIREMENT_DETAIL_QUERIES = 15 +PARTNER_REPORT_CLAIM_QUERIES = 16 + +#: Addresses of the pipeline operations this version replaces, so the cost of +#: the two can be compared. +EARLIER_QUEUE = "/api/user/v1/accounts/retirement_queue/" +EARLIER_CLAIM = "/api/user/v1/accounts/retirement_partner_report/" + +#: What the earlier claim costs, measured the same way as the counts above: a +#: constant part plus two queries for every account on it, because both the +#: account and the courses it studied are read one account at a time. +EARLIER_CLAIM_QUERIES = {1: 17, 5: 25, 25: 65} + +#: What the earlier queue costs: a constant part plus one query for every +#: retirement on it, because the profile holding each account's full name is +#: read one retirement at a time. +EARLIER_QUEUE_QUERIES = {1: 16, 5: 20, 25: 40} + + +def sign_the_worker_in(client, settings): + """Bind the pipeline's capability to a caller and sign that caller in.""" + settings.RETIREMENT_SERVICE_WORKER_USERNAME = WORKER_USERNAME + worker = UserFactory.create(username=WORKER_USERNAME, password=PASSWORD) + assert client.login(username=WORKER_USERNAME, password=PASSWORD) + return worker + + +@pytest.fixture(name="queue") +@skip_unless_lms +def queue_fixture(request, client, settings): + """Sign the pipeline's caller in and put ``request.param`` retirements in the queue.""" + request.getfixturevalue("setup_retirement_states") + sign_the_worker_in(client, settings) + state = RetirementState.objects.get(state_name=WORKING_STATE) + rows = [ + create_retirement_status(UserFactory.create(username=f"queued{index}"), state=state) + for index in range(request.param) + ] + # The first authenticated request of a session populates caches the rest of + # the session reuses; the counts below are the steady-state ones. + client.get(reverse("user_v2:retirement_list")) + return rows + + +@pytest.fixture(name="partner_queue") +@skip_unless_lms +def partner_queue_fixture(request, client, settings): + """Sign the pipeline's caller in and queue ``request.param`` accounts for partner reporting.""" + request.getfixturevalue("setup_retirement_states") + sign_the_worker_in(client, settings) + rows = [] + for index in range(request.param): + user = UserFactory.create(username=f"reported{index}") + for course in range(ENROLMENTS_PER_ROW): + CourseEnrollmentFactory.create( + user=user, course_id=f"course-v1:Org{course}+Course{index}+Run", + ) + rows.append( + UserRetirementPartnerReportingStatus.objects.create( + user=user, + original_username=user.username, + original_email=user.email, + original_name=user.profile.name, + ), + ) + client.get(reverse("user_v2:retirement_list")) + return rows + + +@pytest.mark.django_db +@pytest.mark.parametrize("queue", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_the_queue_costs_the_same_however_many_retirements_are_in_it( + client, queue, django_assert_num_queries, +): + with django_assert_num_queries(RETIREMENT_LIST_QUERIES): + response = client.get(reverse("user_v2:retirement_list"), {"page_size": 100}) + assert len(response.json()["results"]) == len(queue) + + +@pytest.mark.django_db +@pytest.mark.parametrize("queue", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_the_minimal_queue_costs_the_same_however_many_retirements_are_in_it( + client, queue, django_assert_num_queries, +): + with django_assert_num_queries(RETIREMENT_LIST_QUERIES): + response = client.get( + reverse("user_v2:retirement_list"), {"page_size": 100, "view": "minimal"}, + ) + assert len(response.json()["results"]) == len(queue) + + +@pytest.mark.django_db +@pytest.mark.parametrize("queue", [1], indirect=True) +@skip_unless_lms +def test_one_retirement_costs_a_fixed_number_of_queries(client, queue, django_assert_num_queries): + address = reverse("user_v2:retirement_detail", kwargs={"username": queue[0].original_username}) + with django_assert_num_queries(RETIREMENT_DETAIL_QUERIES): + response = client.get(address) + assert response.json()["original_username"] == queue[0].original_username + + +@pytest.mark.django_db +@pytest.mark.parametrize("partner_queue", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_the_claim_costs_the_same_however_many_accounts_are_queued( + client, partner_queue, django_assert_num_queries, +): + """The courses each account studied are read with the entries rather than one entry at a time.""" + with django_assert_num_queries(PARTNER_REPORT_CLAIM_QUERIES): + response = client.post(reverse("user_v2:retirement_partner_report_list"), {}, format="json") + body = response.json() + assert len(body) == len(partner_queue) + assert all(len(entry["orgs"]) == ENROLMENTS_PER_ROW for entry in body) + + +@pytest.mark.django_db +@pytest.mark.parametrize("partner_queue", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_the_earlier_claim_reads_the_courses_one_account_at_a_time( + client, partner_queue, django_assert_num_queries, +): + """The baseline the count above is measured against, on the address this version replaces.""" + with django_assert_num_queries(EARLIER_CLAIM_QUERIES[len(partner_queue)]): + response = client.post(EARLIER_CLAIM, {}, format="json", content_type="application/json") + assert len(response.json()) == len(partner_queue) + + +@pytest.mark.django_db +@pytest.mark.parametrize("queue", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_the_earlier_queue_costs_what_it_costs(client, queue, django_assert_num_queries): + """The baseline the queue count is measured against, on the address this version replaces.""" + with django_assert_num_queries(EARLIER_QUEUE_QUERIES[len(queue)]): + response = client.get(EARLIER_QUEUE, {"cool_off_days": 0, "states": WORKING_STATE}) + assert len(response.json()) == len(queue) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py new file mode 100644 index 000000000000..1e65f999dc72 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py @@ -0,0 +1,3614 @@ +"""Tests for the version 2 User API views over the account retirement pipeline.""" + +import contextlib +import datetime +from unittest import mock +from zoneinfo import ZoneInfo + +import pytest +from crum.signals import current_user_setter +from django.conf import settings +from django.contrib.auth.models import Permission +from django.contrib.sessions.models import Session +from django.core.cache import cache +from django.core.signals import got_request_exception, request_finished, request_started +from django.db import connection +from django.db.backends.signals import connection_created +from django.db.models.signals import class_prepared, post_init, post_save, pre_init, pre_save +from django.dispatch import Signal +from django.test import SimpleTestCase, override_settings +from django.test.signals import setting_changed +from django.test.utils import CaptureQueriesContext +from django.urls import reverse +from edx_django_utils.monitoring.signals import ( + monitoring_support_process_request, + monitoring_support_process_response, +) +from edx_rest_framework_extensions.errors import ( + INTERNAL_ERROR_SLUG, + INTERNAL_ERROR_TITLE, + Conflict, + classify_error, + error_type_uri, +) +from edx_rest_framework_extensions.permissions import NotJwtRestrictedApplication +from edx_rest_framework_extensions.scoping import FullScopePolicy +from edx_rest_framework_extensions.testing import assert_error_envelope +from opaque_keys.edx.keys import CourseKey +from rest_framework import permissions, status +from rest_framework.exceptions import ValidationError +from rest_framework.test import APITestCase + +from common.djangoapps.student.models import ( + AccountRecovery, + CourseEnrollmentAllowed, + PendingEmailChange, + PendingNameChange, + UserProfile, + get_retired_username_by_username, +) +from common.djangoapps.student.tests.factories import ( + AdminFactory, + CourseEnrollmentFactory, + SuperuserFactory, + UserFactory, +) +from common.djangoapps.util.model_utils import USER_FIELDS_CHANGED +from openedx.core.djangoapps.user_api.accounts.permissions import CanCancelUserRetirement, CanRetireUser +from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_LMS_CRITICAL +from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import ( # pylint: disable=unused-import + create_retirement_status, + setup_retirement_states, # noqa: F401 +) +from openedx.core.djangoapps.user_api.models import ( + RetirementState, + UserOrgTag, + UserPreference, + UserRetirementPartnerReportingStatus, + UserRetirementRequest, + UserRetirementStatus, +) +from openedx.core.djangoapps.user_api.v2 import urls as v2_urls +from openedx.core.djangoapps.user_api.v2 import view_services +from openedx.core.djangoapps.user_api.v2.errors import AmbiguousAccountName, RetirementStateConflict +from openedx.core.djangoapps.user_api.v2.paginators import UserApiPagination +from openedx.core.djangoapps.user_api.v2.serializers import ( + RETIREMENT_MINIMAL_FIELDS, + RetirementSerializer, + RetirementUpdateSerializer, +) +from openedx.core.djangoapps.user_api.v2.views import ( + RETIREMENT_ACTION_PERMISSIONS, + RetirementCleanupViewSet, + RetirementPartnerReportCleanupViewSet, + RetirementPartnerReportViewSet, + RetirementViewSet, + _PartnerReportAccessMixin, + _RetirementRecordAccessMixin, + _RetirementRecordsMixin, +) +from openedx.core.djangolib.testing.utils import skip_unless_lms + +from .test_views import ( + MALFORMED_BODY_SLUG, + NOT_ACCEPTABLE_SLUG, + REFUSED_METHOD_SLUG, + UNSUPPORTED_MEDIA_TYPE_SLUG, + write_queries, +) + +PASSWORD = "Password1234" + +#: The caller the pipeline runs as, the caller that replaces usernames, and the +#: names the deployment binds each capability to. +WORKER_USERNAME = "retirement-worker" +REPLACEMENT_WORKER_USERNAME = "replacement-worker" + +#: The callers that may carry out pipeline work, and the callers that may not. +PIPELINE_CALLERS = ("worker", "requester", "superuser") +PIPELINE_REFUSED = ("learner", "subject", "staff", "canceller", "replacer") + +#: The callers that may undo a retirement request, and the callers that may +#: not. The pipeline itself is among the refused: carrying a retirement out and +#: calling it off are different capabilities, and one request cannot hold both. +CANCEL_CALLERS = ("canceller", "superuser") +CANCEL_REFUSED = ("learner", "subject", "staff", "requester", "worker", "replacer") + +#: The query parameters each retirement address reads, in the order its refusal +#: lists them. +LIST_QUERY_PARAMS = ( + "cool_off_days, created_after, created_before, fields, format, ordering, page, page_size, state, view" +) +DETAIL_QUERY_PARAMS = "fields, format, view" +WRITE_QUERY_PARAMS = "format" +CLAIM_QUERY_PARAMS = "format, limit" + +#: What a move of a retirement cannot be asked for without, which is what the +#: published body of the request says it requires. +MOVE_REQUIRED_VALUES = ["new_state", "response"] + +#: What the shortest and the longest page of any collection of this version holds. +DEFAULT_PAGE_SIZE = 10 +MAXIMUM_PAGE_SIZE = 100 + +PAGINATION_KEYS = {"count", "num_pages", "current_page", "start", "next", "previous", "results"} + +#: Every property a retirement record carries. +RETIREMENT_FIELDS = { + "id", + "user", + "original_username", + "original_email", + "original_name", + "retired_username", + "retired_email", + "current_state", + "last_state", + "created", + "modified", +} + +#: The properties of a retiring account's own data, which the ``minimal`` +#: preset must not carry. +RETIREMENT_PII_FIELDS = {"original_email", "original_name", "user"} + +STATE_CONFLICT_SLUG = "user/retirement-state-conflict" +AMBIGUOUS_NAME_SLUG = "user/ambiguous-account-name" + +#: Where the language a user picked is carried between requests, the name it is +#: stored under, and the table it is stored in. +LANGUAGE_COOKIE = settings.LANGUAGE_COOKIE_NAME +LANGUAGE_PREFERENCE = "pref-lang" +PREFERENCE_TABLE = "user_api_userpreference" + +#: The signals every request raises because the platform raises them: a row +#: being instantiated, the request beginning and ending, a connection being +#: opened, a model class being prepared, a setting being overridden by a test, +#: the telemetry each request is measured through, and the caller being put +#: where the code below the view reads it from. None of them says anything +#: about what the request did. +FRAMEWORK_SIGNALS = ( + pre_init, + post_init, + class_prepared, + connection_created, + request_started, + request_finished, + got_request_exception, + setting_changed, + monitoring_support_process_request, + monitoring_support_process_response, + current_user_setter, +) + +#: What syncing the language from the cookie announces: the row being stored, +#: and the platform's own announcement that a field of the account changed. +LANGUAGE_SYNC_SIGNALS = [ + (pre_save, UserPreference), + (post_save, UserPreference), + (USER_FIELDS_CHANGED, None), +] + +#: The addresses this version supersedes, used where the point is that both +#: versions behave alike. +EARLIER_RETIREMENT_QUEUE = "/api/user/v1/accounts/retirement_queue/" +EARLIER_RETIREMENT_CLEANUP = "/api/user/v1/accounts/retirement_cleanup/" +EARLIER_PARTNER_REPORT_CLEANUP = "/api/user/v1/accounts/retirement_partner_report_cleanup/" +EARLIER_PARTNER_REPORT = "/api/user/v1/accounts/retirement_partner_report/" +EARLIER_CORE_ERASURE = "/api/user/v1/accounts/retire/" + + +@contextlib.contextmanager +def captured_signals(): + """ + Record the signals sent while this block runs, bar the ones the framework sends itself. + + A signal is recorded by the signal object and the sender it was sent + under, so that a test names the signals it expects rather than counting + them. + """ + sent = [] + originals = {name: getattr(Signal, name) for name in ("send", "send_robust")} + + def recording(original): + """Return a replacement for one of the two ways a signal is sent.""" + def send(self, sender=None, **named): + # The session row is left out for the same reason its statements + # are: it is the platform's record of the caller being served, + # not data this API answers from. + if self not in FRAMEWORK_SIGNALS and sender is not Session: + sent.append((self, sender)) + return original(self, sender=sender, **named) + return send + + with mock.patch.object(Signal, "send", recording(originals["send"])), \ + mock.patch.object(Signal, "send_robust", recording(originals["send_robust"])): + yield sent + +COURSE_KEY = CourseKey.from_string("course-v1:edX+DemoX+Demo") +OTHER_COURSE_KEY = CourseKey.from_string("course-v1:OtherOrg+OtherX+Other") +#: A course under the placeholder organization name, which is never reported to a partner. +PLACEHOLDER_ORG_COURSE_KEY = CourseKey.from_string("course-v1:outdated_entry+OldX+Old") + + +def grant(user, codename, app_label="user_api"): + """Give this user the named Django model permission.""" + user.user_permissions.add( + Permission.objects.get(codename=codename, content_type__app_label=app_label), + ) + + +@override_settings( + RETIREMENT_SERVICE_WORKER_USERNAME=WORKER_USERNAME, + USERNAME_REPLACEMENT_WORKER=REPLACEMENT_WORKER_USERNAME, +) +@pytest.mark.usefixtures("setup_retirement_states") +class RetirementApiTestCase(APITestCase): + """The callers and the fixtures the retirement pipeline tests share.""" + + def setUp(self): + super().setUp() + self.worker = UserFactory.create(username=WORKER_USERNAME, password=PASSWORD) + self.replacer = UserFactory.create(username=REPLACEMENT_WORKER_USERNAME, password=PASSWORD) + self.superuser = SuperuserFactory.create(username="super-caller", password=PASSWORD) + self.staff = AdminFactory.create(username="plain-staff", password=PASSWORD) + self.requester = AdminFactory.create(username="staff-who-requests", password=PASSWORD) + self.canceller = AdminFactory.create(username="staff-who-cancels", password=PASSWORD) + self.learner = UserFactory.create(username="learner-caller", password=PASSWORD) + self.subject = UserFactory.create( + username="subject", email="subject@example.com", password=PASSWORD, + ) + grant(self.requester, "add_userretirementrequest") + grant(self.canceller, "change_userretirementstatus") + + def login(self, caller): + """Sign the named caller in through the session the platform default accepts.""" + user = getattr(self, caller) + assert self.client.login(username=user.username, password=PASSWORD) + + def state(self, state_name): + """Return the configured pipeline step of this name.""" + return RetirementState.objects.get(state_name=state_name) + + def retirement_in(self, state_name, user=None, created=None): + """Return a retirement standing at this pipeline step.""" + return create_retirement_status( + user or UserFactory.create(), state=self.state(state_name), create_datetime=created, + ) + + +class _AccessMatrixMixin: + """ + Every caller the address admits is served, and every other caller is refused. + + The matrix is the proof that the three capabilities of this area are + carried over as they stand: the caller the pipeline runs as, the staff + caller who may add a retirement request and a superuser work the pipeline; + only the staff caller who may change a retirement status and a superuser + call one off; and the caller that replaces usernames reaches neither. + """ + + #: The callers the address admits, and the ones it refuses. + admitted = () + refused = () + + def send(self): + """Make the request this address serves, building whatever it acts on.""" + raise NotImplementedError + + def test_every_admitted_caller_is_served(self): + for caller in self.admitted: + with self.subTest(caller=caller): + self.client.logout() + self.login(caller) + response = self.send() + assert response.status_code in ( + status.HTTP_200_OK, + status.HTTP_204_NO_CONTENT, + ), f"{caller} got {response.status_code}: {getattr(response, 'data', None)!r}" + + def test_every_other_caller_is_forbidden(self): + for caller in self.refused: + with self.subTest(caller=caller): + self.client.logout() + self.login(caller) + assert_error_envelope( + self.send(), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_an_anonymous_caller_is_unauthenticated(self): + self.client.logout() + assert_error_envelope( + self.send(), + expected_status=status.HTTP_401_UNAUTHORIZED, + expected_type_slug="authn", + ) + + +@skip_unless_lms +class RetirementListTests(_AccessMatrixMixin, RetirementApiTestCase): + """GET /api/user/v2/retirements/""" + + admitted = PIPELINE_CALLERS + refused = PIPELINE_REFUSED + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:retirement_list") + day = datetime.datetime(2026, 3, 1, tzinfo=ZoneInfo("UTC")) + self.pending = self.retirement_in("PENDING", created=day) + self.locking = self.retirement_in("LOCKING_ACCOUNT", created=day + datetime.timedelta(days=1)) + self.complete = self.retirement_in("COMPLETE", created=day + datetime.timedelta(days=2)) + self.day = day + + def send(self): + return self.client.get(self.url) + + def names_in(self, response): + """Return the registered names the page carries, in the order it carries them.""" + return [row["original_username"] for row in response.data["results"]] + + def test_the_page_carries_every_retirement_in_the_order_they_were_requested(self): + self.login("worker") + response = self.client.get(self.url) + assert response.status_code == status.HTTP_200_OK + assert set(response.data) == PAGINATION_KEYS + assert self.names_in(response) == [ + self.pending.original_username, + self.locking.original_username, + self.complete.original_username, + ] + assert response.data["count"] == 3 + assert response.data["num_pages"] == 1 + assert response.data["current_page"] == 1 + assert response.data["start"] == 0 + + def test_a_row_carries_the_values_the_record_holds(self): + self.login("worker") + response = self.client.get(self.url, {"state": "LOCKING_ACCOUNT"}) + row = response.data["results"][0] + assert set(row) == RETIREMENT_FIELDS + assert row["id"] == self.locking.id + assert row["original_username"] == self.locking.original_username + assert row["original_email"] == self.locking.original_email + assert row["original_name"] == self.locking.original_name + assert row["retired_username"] == self.locking.retired_username + assert row["retired_email"] == self.locking.retired_email + assert row["current_state"] == "LOCKING_ACCOUNT" + assert row["last_state"] == "LOCKING_ACCOUNT" + assert row["user"] == { + "id": self.locking.user.id, + "username": self.locking.user.username, + "email": self.locking.user.email, + "profile": {"id": self.locking.user.profile.id, "name": self.locking.user.profile.name}, + } + + def test_the_page_holds_ten_rows_unless_a_size_is_asked_for(self): + for _ in range(9): + self.retirement_in("PENDING") + self.login("worker") + response = self.client.get(self.url) + assert len(response.data["results"]) == DEFAULT_PAGE_SIZE + assert response.data["count"] == 12 + assert response.data["num_pages"] == 2 + + def test_a_page_larger_than_the_maximum_holds_the_maximum(self): + for _ in range(MAXIMUM_PAGE_SIZE + 2): + self.retirement_in("PENDING") + self.login("worker") + response = self.client.get(self.url, {"page_size": 500}) + assert len(response.data["results"]) == MAXIMUM_PAGE_SIZE + assert response.data["count"] == MAXIMUM_PAGE_SIZE + 5 + + def test_a_page_beyond_the_last_one_is_not_found(self): + self.login("worker") + assert_error_envelope( + self.client.get(self.url, {"page": 9999}), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_one_step_can_be_asked_for(self): + self.login("worker") + response = self.client.get(self.url, {"state": "COMPLETE"}) + assert self.names_in(response) == [self.complete.original_username] + + def test_several_steps_can_be_asked_for_at_once(self): + self.login("worker") + response = self.client.get(self.url, {"state": "PENDING,COMPLETE"}) + assert self.names_in(response) == [ + self.pending.original_username, + self.complete.original_username, + ] + + def test_several_steps_can_be_asked_for_by_repeating_the_parameter(self): + """The list these rows were read from before named its steps that way.""" + self.login("worker") + response = self.client.get(f"{self.url}?state=PENDING&state=COMPLETE") + assert self.names_in(response) == [ + self.pending.original_username, + self.complete.original_username, + ] + + def test_a_list_and_a_repetition_name_their_steps_together(self): + self.login("worker") + response = self.client.get(f"{self.url}?state=PENDING,COMPLETE&state=LOCKING_ACCOUNT") + assert self.names_in(response) == [ + self.pending.original_username, + self.locking.original_username, + self.complete.original_username, + ] + + def test_naming_no_step_at_all_leaves_every_retirement_in(self): + self.login("worker") + response = self.client.get(f"{self.url}?state=") + assert self.names_in(response) == [ + self.pending.original_username, + self.locking.original_username, + self.complete.original_username, + ] + + def test_a_step_this_deployment_does_not_run_is_refused(self): + self.login("worker") + response = self.client.get(self.url, {"state": "NOPE"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert "No such pipeline step: NOPE." in envelope["errors"]["state"][0] + + def test_a_step_this_deployment_does_not_run_is_refused_among_repeated_ones(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.get(f"{self.url}?state=PENDING&state=NOPE"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "No such pipeline step: NOPE." in envelope["errors"]["state"][0] + + def test_retirements_requested_recently_can_be_left_out(self): + recent = self.retirement_in( + "PENDING", created=datetime.datetime.now(ZoneInfo("UTC")) - datetime.timedelta(days=1), + ) + self.login("worker") + response = self.client.get(self.url, {"cool_off_days": 7, "page_size": 100}) + assert recent.original_username not in self.names_in(response) + assert self.pending.original_username in self.names_in(response) + + def test_no_cooling_off_period_leaves_every_retirement_in(self): + recent = self.retirement_in( + "PENDING", created=datetime.datetime.now(ZoneInfo("UTC")) - datetime.timedelta(days=1), + ) + self.login("worker") + response = self.client.get(self.url, {"cool_off_days": 0, "page_size": 100}) + assert recent.original_username in self.names_in(response) + + def test_a_cooling_off_period_that_is_not_a_whole_number_of_days_is_refused(self): + self.login("worker") + for value in ("1.5", "nope"): + with self.subTest(value=value): + envelope = assert_error_envelope( + self.client.get(self.url, {"cool_off_days": value}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "cool_off_days" in envelope["errors"] + + def test_a_negative_cooling_off_period_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.get(self.url, {"cool_off_days": -1}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "cool_off_days" in envelope["errors"] + + def test_a_cooling_off_period_of_a_century_is_taken(self): + self.login("worker") + response = self.client.get(self.url, {"cool_off_days": 36500, "page_size": 100}) + assert response.status_code == status.HTTP_200_OK + assert self.names_in(response) == [] + + def test_a_cooling_off_period_longer_than_a_century_is_refused(self): + """Up to a count of days that reaches back past the first year of the calendar, and beyond.""" + self.login("worker") + for value in (36501, 800000): + with self.subTest(value=value): + envelope = assert_error_envelope( + self.client.get(self.url, {"cool_off_days": value}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"cool_off_days": ["Ensure this value is less than or equal to 36500."]} + + def test_the_later_bound_includes_the_moment_it_names(self): + self.login("worker") + response = self.client.get( + self.url, {"created_after": self.locking.created.isoformat(), "page_size": 100}, + ) + assert self.names_in(response) == [ + self.locking.original_username, + self.complete.original_username, + ] + + def test_the_earlier_bound_leaves_out_the_moment_it_names(self): + self.login("worker") + response = self.client.get( + self.url, {"created_before": self.locking.created.isoformat(), "page_size": 100}, + ) + assert self.names_in(response) == [self.pending.original_username] + + def test_a_day_as_the_earlier_bound_leaves_that_whole_day_out(self): + """ + A date alone is the first moment of that day, which the bound excludes. + + The retirements requested during the day named are therefore not in the + page, which is the one reading of the bound that holds whether the + value names a day or a moment within one. + """ + midday = self.retirement_in( + "PENDING", created=self.locking.created + datetime.timedelta(hours=12), + ) + self.login("worker") + response = self.client.get( + self.url, + {"created_before": self.locking.created.date().isoformat(), "page_size": 100}, + ) + assert self.names_in(response) == [self.pending.original_username] + assert midday.original_username not in self.names_in(response) + later = self.client.get( + self.url, + { + "created_before": (self.locking.created + datetime.timedelta(days=1)).date().isoformat(), + "page_size": 100, + }, + ) + assert self.names_in(later) == [ + self.pending.original_username, + self.locking.original_username, + midday.original_username, + ] + + def test_a_bound_that_is_not_a_moment_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.get(self.url, {"created_after": "the day before"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "created_after" in envelope["errors"] + + def test_the_page_can_be_sorted_by_every_field_it_offers(self): + self.login("worker") + expected = { + "original_username": sorted( + [r.original_username for r in (self.pending, self.locking, self.complete)], + ), + "created": [ + self.pending.original_username, + self.locking.original_username, + self.complete.original_username, + ], + "modified": [ + self.pending.original_username, + self.locking.original_username, + self.complete.original_username, + ], + "current_state": [ + self.pending.original_username, + self.locking.original_username, + self.complete.original_username, + ], + } + for field, order in expected.items(): + with self.subTest(field=field): + response = self.client.get(self.url, {"ordering": field, "page_size": 100}) + assert self.names_in(response) == order + reversed_response = self.client.get( + self.url, {"ordering": f"-{field}", "page_size": 100}, + ) + assert self.names_in(reversed_response) == list(reversed(order)) + + def test_a_sort_field_the_page_does_not_offer_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.get(self.url, {"ordering": "id"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "ordering" in envelope["errors"] + + def test_a_query_parameter_the_page_does_not_read_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.get(self.url, {"states": "PENDING"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"]["states"] == [ + f"No such query parameter. Send any of: {LIST_QUERY_PARAMS}.", + ] + + def test_the_minimal_preset_carries_no_data_of_the_account_owner(self): + self.login("worker") + response = self.client.get(self.url, {"view": "minimal"}) + row = response.data["results"][0] + assert set(row) == set(RETIREMENT_MINIMAL_FIELDS) + assert set(row) & RETIREMENT_PII_FIELDS == set() + assert row["original_username"] == self.pending.original_username + assert row["current_state"] == "PENDING" + + def test_a_preset_the_page_does_not_answer_to_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.get(self.url, {"view": "everything"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"]["view"] == [ + "Ask for any of: minimal, or omit this parameter for the default response.", + ] + + def test_properties_can_be_selected(self): + self.login("worker") + response = self.client.get(self.url, {"fields": "original_username,current_state"}) + assert response.data["results"][0] == { + "original_username": self.pending.original_username, + "current_state": "PENDING", + } + + def test_a_property_a_row_cannot_carry_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.get(self.url, {"fields": "responses"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"]["fields"][0].startswith("No such field: responses.") + + def test_every_admitted_caller_sees_the_same_rows(self): + """The visibility layer narrows nothing for a caller holding the capability.""" + seen = {} + for caller in PIPELINE_CALLERS: + self.client.logout() + self.login(caller) + seen[caller] = self.names_in(self.client.get(self.url, {"page_size": 100})) + assert seen == { + caller: [ + self.pending.original_username, + self.locking.original_username, + self.complete.original_username, + ] + for caller in PIPELINE_CALLERS + } + + def test_the_page_is_readable_without_a_body(self): + self.login("worker") + response = self.client.head(self.url) + assert response.status_code == status.HTTP_200_OK + + +class _NarrowingPolicy: + """ + A visibility policy that keeps the rows named and records what it was given. + + It stands in for a deployment's own policy, so that the tests below read + what an address answers with when the policy narrows rather than when it + leaves the selection whole. + """ + + def __init__(self, column, *keep): + self.column = column + self.keep = keep + #: The caller each application was made for, in order. + self.subjects = [] + #: The values the selection held each time, before it was narrowed. + self.offered = [] + + def scope(self, queryset, subject): + """Return the rows named, recording the caller and what was on offer.""" + self.subjects.append(subject) + self.offered.append(sorted(queryset.values_list(self.column, flat=True))) + return queryset.filter(**{f"{self.column}__in": self.keep}) + + +#: Stands for a view class that declares no permission classes of its own. +NOT_DECLARED = object() + + +def _copied_by_the_middleware(view): + """ + Return whether the classes on this view class are the shared ones with the middleware's added. + + The platform's own middleware appends its class to whatever it finds on + every view class it serves, which leaves a copy on the view class itself. + That copy is this and nothing else, so a view that declares classes of its + own is told apart from one that has merely answered a request. + """ + own = vars(view).get("permission_classes") + if own is None: + return False + inherited = next( + vars(base)["permission_classes"] + for base in view.__mro__[1:] + if "permission_classes" in vars(base) + ) + return tuple(own) == (*inherited, NotJwtRestrictedApplication) + + +@contextlib.contextmanager +def layers_read_from_their_own_declaration(*views): + """ + Serve these views with the permission classes the layer they share declares. + + The middleware's copy is dropped here and put back afterwards, so that what + the views read is what is declared for them, as it would be on a service + that has just started. A view declaring classes of its own keeps them, + because that declaration is what these tests are here to see. + """ + on_entry = {view: vars(view).get("permission_classes", NOT_DECLARED) for view in views} + try: + for view in views: + if _copied_by_the_middleware(view): + del view.permission_classes + yield + finally: + # Each view class is left exactly as it was found, whatever the + # middleware wrote onto it while these requests were being served. + for view, declared in on_entry.items(): + if "permission_classes" in vars(view): + del view.permission_classes + if declared is not NOT_DECLARED: + view.permission_classes = declared + + +class _OnlyASuperuser(permissions.BasePermission): + """ + A rule a deployment might add to a resource, admitting one caller and refusing the rest. + + It stands in for whatever a deployment adds to the layer the operations on + a resource share, so the tests below read which of those operations is + asked for it. + """ + + def has_permission(self, request, view): + """Return whether this caller is a superuser.""" + return bool(request.user and request.user.is_superuser) + + +@skip_unless_lms +class RetirementVisibilityTests(RetirementApiTestCase): + """ + Which retirements a caller may see is decided by the view's own visibility policy. + + The policy this deployment runs keeps the queue whole, because every + caller these addresses admit holds the capability to work the whole + pipeline. A deployment that replaces it with one that narrows needs it + applied where the selection is read, so the policy is replaced here with + one that keeps two of three retirements and the page is read. + """ + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:retirement_list") + self.pending = self.retirement_in("PENDING") + self.locking = self.retirement_in("LOCKING_ACCOUNT") + self.hidden = self.retirement_in("COMPLETE") + self.policy = _NarrowingPolicy( + "original_username", self.pending.original_username, self.locking.original_username, + ) + + def names_in(self, response): + """Return the registered names the page carries.""" + return sorted(row["original_username"] for row in response.data["results"]) + + def test_the_page_carries_the_retirements_the_policy_keeps_and_no_others(self): + self.login("worker") + with mock.patch.object(RetirementViewSet, "scoping_policy", self.policy): + response = self.client.get(self.url, {"page_size": 100}) + assert response.status_code == status.HTTP_200_OK + assert self.names_in(response) == sorted( + [self.pending.original_username, self.locking.original_username], + ) + assert response.data["count"] == 2 + + def test_the_policy_is_given_the_caller_and_every_retirement_held(self): + self.login("worker") + with mock.patch.object(RetirementViewSet, "scoping_policy", self.policy): + self.client.get(self.url, {"page_size": 100}) + assert [subject.username for subject in self.policy.subjects] == [WORKER_USERNAME] + assert self.policy.offered == [sorted( + [ + self.pending.original_username, + self.locking.original_username, + self.hidden.original_username, + ], + )] + + def test_what_the_policy_leaves_out_is_left_out_of_a_narrowed_page_too(self): + """A filter narrows what the policy left; it cannot put a row back in.""" + self.login("worker") + with mock.patch.object(RetirementViewSet, "scoping_policy", self.policy): + response = self.client.get(self.url, {"state": "COMPLETE", "page_size": 100}) + assert self.names_in(response) == [] + assert response.data["count"] == 0 + + def test_the_policy_this_deployment_runs_keeps_the_whole_queue(self): + self.login("worker") + response = self.client.get(self.url, {"page_size": 100}) + assert self.names_in(response) == sorted( + [ + self.pending.original_username, + self.locking.original_username, + self.hidden.original_username, + ], + ) + + +@skip_unless_lms +class PartnerReportVisibilityTests(RetirementApiTestCase): + """The entries a caller may take from the partner-report queue are narrowed the same way.""" + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:retirement_partner_report_list") + self.taken = self.queued(self.subject) + self.hidden = self.queued(self.learner) + self.policy = _NarrowingPolicy("original_username", self.subject.username) + + def queued(self, user): + """Return a partner report entry waiting to be taken, for this user.""" + return UserRetirementPartnerReportingStatus.objects.create( + user=user, + original_username=user.username, + original_email=user.email, + original_name=user.profile.name, + ) + + def test_only_the_entries_the_policy_keeps_are_answered_with(self): + self.login("worker") + with mock.patch.object(RetirementPartnerReportViewSet, "scoping_policy", self.policy): + response = self.client.post(self.url, {}, format="json") + assert response.status_code == status.HTTP_200_OK + assert [entry["original_username"] for entry in response.data] == [self.subject.username] + + def test_an_entry_the_policy_leaves_out_is_not_marked_as_taken(self): + self.login("worker") + with mock.patch.object(RetirementPartnerReportViewSet, "scoping_policy", self.policy): + self.client.post(self.url, {}, format="json") + marked = set( + UserRetirementPartnerReportingStatus.objects + .filter(is_being_processed=True) + .values_list("original_username", flat=True), + ) + assert marked == {self.subject.username} + + def test_the_policy_is_given_the_caller_and_the_entries_nobody_has_taken(self): + self.login("worker") + with mock.patch.object(RetirementPartnerReportViewSet, "scoping_policy", self.policy): + self.client.post(self.url, {}, format="json") + assert [subject.username for subject in self.policy.subjects] == [WORKER_USERNAME] + assert self.policy.offered == [ + sorted([self.subject.username, self.learner.username]), + ] + + def test_the_policy_this_deployment_runs_leaves_every_queued_entry_takeable(self): + self.login("worker") + response = self.client.post(self.url, {}, format="json") + assert sorted(entry["original_username"] for entry in response.data) == sorted( + [self.subject.username, self.learner.username], + ) + + +@skip_unless_lms +class RetirementQueueOrderTests(RetirementApiTestCase): + """ + The queue is read in a total order, so that paging through it sees each retirement once. + + Retirements that compare equal on the column a caller sorts by are left + in whatever order the database answers with, and two requests need not + agree on it, so the order the queue is worked in is appended to the sort + asked for. That order is the retirement's own identifier, which rises as + retirements are requested: it settles ties without being distinguishable + from an unsettled read of rows stored in that order, so what these tests + read is the order the database is asked for. + """ + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:retirement_list") + day = datetime.datetime(2026, 3, 1, tzinfo=ZoneInfo("UTC")) + self.shared = [self.retirement_in("PENDING", created=day) for _ in range(3)] + self.login("worker") + + def ordering_asked_for(self, **params): + """Return the order the page of retirements was read in.""" + with CaptureQueriesContext(connection) as captured: + response = self.client.get(self.url, params) + assert response.status_code == status.HTTP_200_OK, response.content + table = UserRetirementStatus._meta.db_table + reads = [ + query["sql"] for query in captured.captured_queries + if query["sql"].startswith("SELECT") and "ORDER BY" in query["sql"] + and connection.ops.quote_name(table) in query["sql"] + ] + assert len(reads) == 1, reads + return reads[0].split("ORDER BY")[1].split("LIMIT")[0].strip() + + def column(self, name, direction="ASC"): + """Return the one column of the retirement table, as a sort names it.""" + quoted = connection.ops.quote_name + table = UserRetirementStatus._meta.db_table + return f"{quoted(table)}.{quoted(name)} {direction}" + + def test_a_sort_a_caller_asks_for_is_completed_by_the_order_the_queue_is_worked_in(self): + assert self.ordering_asked_for(ordering="created", page_size=100) == ", ".join( + [self.column("created"), self.column("id")], + ) + + def test_the_order_the_queue_is_worked_in_is_added_to_a_reversed_sort_as_well(self): + assert self.ordering_asked_for(ordering="-created", page_size=100) == ", ".join( + [self.column("created", "DESC"), self.column("id")], + ) + + def test_a_page_nobody_asked_a_sort_for_is_read_in_the_order_the_queue_is_worked_in(self): + """The order a page falls back to is the tie-break, so it is not asked for twice.""" + assert self.ordering_asked_for(page_size=100) == self.column("id") + + def test_paging_through_retirements_requested_at_one_moment_sees_each_once(self): + listed = [] + for page in (1, 2, 3): + response = self.client.get( + self.url, {"ordering": "created", "page_size": 1, "page": page}, + ) + listed.extend(row["original_username"] for row in response.data["results"]) + assert listed == [retirement.original_username for retirement in self.shared] + + +@skip_unless_lms +class RetirementDetailTests(_AccessMatrixMixin, RetirementApiTestCase): + """GET /api/user/v2/retirements/{username}/""" + + admitted = PIPELINE_CALLERS + refused = PIPELINE_REFUSED + + def setUp(self): + super().setUp() + self.retirement = self.retirement_in("LOCKING_ACCOUNT", user=self.subject) + self.url = reverse("user_v2:retirement_detail", kwargs={"username": "subject"}) + + def send(self): + return self.client.get(self.url) + + def test_the_record_comes_back_whole(self): + self.login("worker") + response = self.client.get(self.url) + assert response.status_code == status.HTTP_200_OK + assert set(response.data) == RETIREMENT_FIELDS + assert response.data["id"] == self.retirement.id + assert response.data["original_username"] == "subject" + assert response.data["original_email"] == "subject@example.com" + assert response.data["current_state"] == "LOCKING_ACCOUNT" + + def test_an_account_holding_no_profile_is_answered_with_a_null_profile(self): + """The property is answered under its own name either way, as null where there is none.""" + UserProfile.objects.filter(user=self.subject).delete() + self.login("worker") + response = self.client.get(self.url) + assert response.status_code == status.HTTP_200_OK + assert response.data["user"] == { + "id": self.subject.id, + "username": "subject", + "email": "subject@example.com", + "profile": None, + } + page = self.client.get(reverse("user_v2:retirement_list")) + assert [row["user"]["profile"] for row in page.data["results"]] == [None] + + def test_the_hashed_name_a_retired_account_is_moved_to_addresses_the_same_record(self): + hashed = get_retired_username_by_username("subject") + self.subject.username = hashed + self.subject.save() + self.login("worker") + response = self.client.get( + reverse("user_v2:retirement_detail", kwargs={"username": "subject"}), + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["id"] == self.retirement.id + by_hash = self.client.get( + reverse("user_v2:retirement_detail", kwargs={"username": hashed}), + ) + assert by_hash.status_code == status.HTTP_200_OK + assert by_hash.data["id"] == self.retirement.id + + def test_a_name_no_retirement_is_held_for_is_not_found(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.get(reverse("user_v2:retirement_detail", kwargs={"username": "nobody"})), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + assert envelope["detail"] == view_services.NO_SUCH_RETIREMENT + + def test_an_account_with_no_retirement_behind_it_is_not_found(self): + self.login("worker") + assert_error_envelope( + self.client.get( + reverse("user_v2:retirement_detail", kwargs={"username": "learner-caller"}), + ), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_the_minimal_preset_carries_no_data_of_the_account_owner(self): + self.login("worker") + response = self.client.get(self.url, {"view": "minimal"}) + assert set(response.data) == set(RETIREMENT_MINIMAL_FIELDS) + assert set(response.data) & RETIREMENT_PII_FIELDS == set() + + def test_properties_can_be_selected(self): + self.login("worker") + response = self.client.get(self.url, {"fields": "current_state"}) + assert response.data == {"current_state": "LOCKING_ACCOUNT"} + + def test_a_property_asked_for_as_a_path_is_refused(self): + """ + Selection reaches the top level only, which is what the parameter's description says. + + A name written with a dot in it names no property of the record, so it + is refused rather than read as the property it starts with: answering + that would hand back the whole of the embedded account to a caller + that asked for one property of it, which here is personal data of the + account being retired. + """ + self.login("worker") + response = self.client.get(self.url, {"fields": "user.email"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"]["fields"] == [ + "No such field: user.email. Select any of: " + f"{', '.join(sorted(RetirementSerializer().fields))}.", + ] + assert "user" not in response.data + + def test_the_parameters_that_cut_a_page_are_not_read_at_a_member_address(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.get(self.url, {"page": 2, "state": "PENDING"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert sorted(envelope["errors"]) == ["page", "state"] + assert envelope["errors"]["page"] == [ + f"No such query parameter. Send any of: {DETAIL_QUERY_PARAMS}.", + ] + + def test_a_refusal_is_answered_as_json_rather_than_as_a_page(self): + self.login("worker") + response = self.client.get(reverse("user_v2:retirement_detail", kwargs={"username": "nobody"})) + assert response["Content-Type"] == "application/json" + + def test_the_record_is_readable_without_a_body(self): + self.login("worker") + assert self.client.head(self.url).status_code == status.HTTP_200_OK + + +@skip_unless_lms +class RetirementUpdateTests(_AccessMatrixMixin, RetirementApiTestCase): + """PATCH /api/user/v2/retirements/{username}/""" + + admitted = PIPELINE_CALLERS + refused = PIPELINE_REFUSED + + def setUp(self): + super().setUp() + self.retirement = self.retirement_in("LOCKING_ACCOUNT", user=self.subject) + self.url = reverse("user_v2:retirement_detail", kwargs={"username": "subject"}) + + def send(self): + """Move a retirement of its own, so that one caller's move does not decide the next one's.""" + retirement = self.retirement_in("LOCKING_ACCOUNT") + return self.client.patch( + reverse("user_v2:retirement_detail", kwargs={"username": retirement.original_username}), + {"new_state": "LOCKING_COMPLETE", "response": "done"}, + format="json", + ) + + def patch_subject(self, body, **kwargs): + """Ask for a move of the retirement this address names.""" + return self.client.patch(self.url, body, format="json", **kwargs) + + def test_a_later_step_moves_the_retirement_and_is_logged(self): + self.login("worker") + response = self.patch_subject({"new_state": "LOCKING_COMPLETE", "response": "account locked"}) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert response.data is None + self.retirement.refresh_from_db() + assert self.retirement.current_state.state_name == "LOCKING_COMPLETE" + assert self.retirement.last_state.state_name == "LOCKING_ACCOUNT" + assert "account locked" in self.retirement.responses + + def test_an_earlier_step_is_refused_and_the_retirement_does_not_move(self): + self.login("worker") + envelope = assert_error_envelope( + self.patch_subject({"new_state": "PENDING", "response": "rewind"}), + expected_status=status.HTTP_409_CONFLICT, + expected_type_slug=STATE_CONFLICT_SLUG, + ) + assert envelope["detail"] == view_services.CANNOT_MOVE_TO_STEP + self.retirement.refresh_from_db() + assert self.retirement.current_state.state_name == "LOCKING_ACCOUNT" + + def test_an_earlier_step_moves_the_retirement_when_the_move_is_forced(self): + self.login("worker") + response = self.patch_subject({"new_state": "PENDING", "response": "rewind", "force": True}) + assert response.status_code == status.HTTP_204_NO_CONTENT + self.retirement.refresh_from_db() + assert self.retirement.current_state.state_name == "PENDING" + + def test_a_retirement_that_errored_can_be_moved_on_to_a_later_step(self): + """An operator rewinds and re-runs a retirement that failed, which is what this allows.""" + self.retirement.current_state = self.state("ERRORED") + self.retirement.save() + self.login("worker") + assert self.patch_subject( + {"new_state": "COMPLETE", "response": "finished by hand"}, + ).status_code == status.HTTP_204_NO_CONTENT + self.retirement.refresh_from_db() + assert self.retirement.current_state.state_name == "COMPLETE" + + def test_a_retirement_that_errored_is_not_moved_back_to_an_earlier_step_unless_forced(self): + self.retirement.current_state = self.state("ERRORED") + self.retirement.save() + self.login("worker") + assert_error_envelope( + self.patch_subject({"new_state": "PENDING", "response": "rewind"}), + expected_status=status.HTTP_409_CONFLICT, + expected_type_slug=STATE_CONFLICT_SLUG, + ) + self.retirement.refresh_from_db() + assert self.retirement.current_state.state_name == "ERRORED" + + def test_a_step_this_deployment_does_not_run_is_refused_before_anything_moves(self): + self.login("worker") + envelope = assert_error_envelope( + self.patch_subject({"new_state": "NOPE", "response": "done"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"]["new_state"][0].startswith("No such pipeline step.") + self.retirement.refresh_from_db() + assert self.retirement.current_state.state_name == "LOCKING_ACCOUNT" + + def test_a_forced_move_to_a_step_this_deployment_does_not_run_is_refused_too(self): + self.login("worker") + assert_error_envelope( + self.patch_subject({"new_state": "NOPE", "response": "done", "force": True}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + + def test_a_body_that_names_no_step_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.patch_subject({"response": "done"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "new_state" in envelope["errors"] + + def test_a_body_that_says_nothing_about_the_step_being_left_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.patch_subject({"new_state": "LOCKING_COMPLETE"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "response" in envelope["errors"] + + def test_an_empty_body_is_refused_for_both_of_the_values_a_move_needs(self): + """The two it names here are the two the published body says it cannot be sent without.""" + self.login("worker") + envelope = assert_error_envelope( + self.patch_subject({}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert sorted(envelope["errors"]) == MOVE_REQUIRED_VALUES + self.retirement.refresh_from_db() + assert self.retirement.current_state.state_name == "LOCKING_ACCOUNT" + + def test_the_move_is_read_whole_even_when_built_to_read_a_set_of_changes(self): + """What a partial body would let a caller leave out, this body still refuses.""" + move = RetirementUpdateSerializer(data={"new_state": "LOCKING_COMPLETE"}, partial=True) + assert move.partial is False + assert move.is_valid() is False + assert sorted(move.errors) == ["response"] + + def test_a_name_spelled_differently_from_the_registered_one_is_not_found(self): + self.login("worker") + assert_error_envelope( + self.client.patch( + reverse("user_v2:retirement_detail", kwargs={"username": "Subject"}), + {"new_state": "LOCKING_COMPLETE", "response": "done"}, + format="json", + ), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + self.retirement.refresh_from_db() + assert self.retirement.current_state.state_name == "LOCKING_ACCOUNT" + + def test_the_hashed_name_does_not_address_a_write(self): + hashed = get_retired_username_by_username("subject") + self.login("worker") + assert_error_envelope( + self.client.patch( + reverse("user_v2:retirement_detail", kwargs={"username": hashed}), + {"new_state": "LOCKING_COMPLETE", "response": "done"}, + format="json", + ), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_a_body_in_a_media_type_this_address_cannot_read_is_refused(self): + self.login("worker") + assert_error_envelope( + self.client.patch(self.url, "new_state=LOCKING_COMPLETE", content_type="text/plain"), + expected_status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + expected_type_slug=UNSUPPORTED_MEDIA_TYPE_SLUG, + ) + + def test_a_body_that_is_not_well_formed_is_refused(self): + self.login("worker") + assert_error_envelope( + self.client.patch(self.url, "{not json", content_type="application/json"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug=MALFORMED_BODY_SLUG, + ) + + def test_a_query_parameter_a_write_does_not_read_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.patch_subject({"new_state": "LOCKING_COMPLETE", "response": "done"}, QUERY_STRING="view=minimal"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"]["view"] == [ + f"No such query parameter. Send any of: {WRITE_QUERY_PARAMS}.", + ] + + +@skip_unless_lms +class MovedRetirementRecordTests(RetirementApiTestCase): + """ + A record that has moved names the step it stands at and the step it came from. + + They are two different steps once a retirement has moved, and a caller + driving the pipeline reads the second to tell what has already been done, + so the record is read here after a move rather than before one, when both + name the step the retirement was requested at. + """ + + def setUp(self): + super().setUp() + self.retirement = self.retirement_in("LOCKING_ACCOUNT", user=self.subject) + self.detail_url = reverse("user_v2:retirement_detail", kwargs={"username": "subject"}) + self.list_url = reverse("user_v2:retirement_list") + self.login("worker") + moved = self.client.patch( + self.detail_url, + {"new_state": "LOCKING_COMPLETE", "response": "the account was locked"}, + format="json", + ) + assert moved.status_code == status.HTTP_204_NO_CONTENT, moved.content + self.retirement.refresh_from_db() + + def test_the_record_names_the_step_it_stands_at_and_the_one_it_came_from(self): + response = self.client.get(self.detail_url) + assert response.status_code == status.HTTP_200_OK + assert response.data["current_state"] == "LOCKING_COMPLETE" + assert response.data["last_state"] == "LOCKING_ACCOUNT" + + def test_the_row_in_the_queue_names_both_steps_as_the_record_does(self): + response = self.client.get(self.list_url, {"page_size": 100}) + row = next( + row for row in response.data["results"] + if row["original_username"] == self.retirement.original_username + ) + assert row["current_state"] == "LOCKING_COMPLETE" + assert row["last_state"] == "LOCKING_ACCOUNT" + + def test_the_step_a_record_came_from_can_be_asked_for_on_its_own(self): + response = self.client.get(self.detail_url, {"fields": "last_state"}) + assert response.data == {"last_state": "LOCKING_ACCOUNT"} + + def test_the_minimal_record_names_the_step_it_stands_at(self): + """What a reader of the queue needs is where the retirement is now.""" + response = self.client.get(self.detail_url, {"view": "minimal"}) + assert response.data["current_state"] == "LOCKING_COMPLETE" + assert "last_state" not in response.data + + def test_the_queue_is_narrowed_by_the_step_a_retirement_stands_at(self): + """A retirement is found under the step it is at, not under the one it left.""" + by_current = self.client.get(self.list_url, {"state": "LOCKING_COMPLETE", "page_size": 100}) + assert [row["original_username"] for row in by_current.data["results"]] == [ + self.retirement.original_username, + ] + by_previous = self.client.get(self.list_url, {"state": "LOCKING_ACCOUNT", "page_size": 100}) + assert by_previous.data["results"] == [] + + +@skip_unless_lms +class RetirementCancelTests(_AccessMatrixMixin, RetirementApiTestCase): + """DELETE /api/user/v2/retirements/{username}/""" + + admitted = CANCEL_CALLERS + refused = CANCEL_REFUSED + + def send(self): + user = UserFactory.create(email="to-cancel@example.com") + create_retirement_status(user) + return self.client.delete( + reverse("user_v2:retirement_detail", kwargs={"username": user.username}), + ) + + def test_the_request_is_gone_and_the_account_is_usable_again(self): + retirement = create_retirement_status(self.subject) + self.subject.email = retirement.retired_email + self.subject.save() + self.login("canceller") + response = self.client.delete( + reverse("user_v2:retirement_detail", kwargs={"username": "subject"}), + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not UserRetirementStatus.objects.filter(id=retirement.id).exists() + assert not UserRetirementRequest.objects.filter(user=self.subject).exists() + self.subject.refresh_from_db() + assert self.subject.email == "subject@example.com" + assert not self.subject.check_password(PASSWORD) + + def test_a_retirement_the_pipeline_has_started_on_is_not_cancelled(self): + retirement = self.retirement_in("LOCKING_ACCOUNT", user=self.subject) + self.login("canceller") + envelope = assert_error_envelope( + self.client.delete(reverse("user_v2:retirement_detail", kwargs={"username": "subject"})), + expected_status=status.HTTP_409_CONFLICT, + expected_type_slug=STATE_CONFLICT_SLUG, + ) + assert envelope["detail"] == view_services.ONLY_PENDING_IS_CANCELLED + assert UserRetirementStatus.objects.filter(id=retirement.id).exists() + + def test_a_name_no_retirement_is_requested_under_is_not_found(self): + self.login("canceller") + assert_error_envelope( + self.client.delete(reverse("user_v2:retirement_detail", kwargs={"username": "nobody"})), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_a_name_spelled_differently_from_the_registered_one_is_not_found(self): + retirement = create_retirement_status(self.subject) + self.login("canceller") + assert_error_envelope( + self.client.delete(reverse("user_v2:retirement_detail", kwargs={"username": "SUBJECT"})), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + assert UserRetirementStatus.objects.filter(id=retirement.id).exists() + + +@skip_unless_lms +class RetirementErasureTests(_AccessMatrixMixin, RetirementApiTestCase): + """POST /api/user/v2/retirements/{username}/erasures/""" + + admitted = PIPELINE_CALLERS + refused = PIPELINE_REFUSED + + def setUp(self): + super().setUp() + self.retirement = self.retirement_in("RETIRING_LMS", user=self.subject) + self.url = reverse("user_v2:retirement_erasure", kwargs={"username": "subject"}) + + def send(self, scope="lms_core"): + return self.client.post(self.url, {"scope": scope}, format="json") + + def test_the_core_scope_erases_the_account_and_its_profile(self): + self.subject.profile.name = "Subject Name" + self.subject.profile.city = "Somewhere" + self.subject.profile.save() + PendingEmailChange.objects.create( + user=self.subject, new_email="new@example.com", activation_key="k" * 32, + ) + UserOrgTag.objects.create(user=self.subject, key="foo", value="bar", org="edX") + AccountRecovery.objects.create( + user=self.subject, secondary_email="recovery@example.com", is_active=True, + ) + CourseEnrollmentAllowed.objects.create(email="subject@example.com", course_id=COURSE_KEY) + cache.set(UserProfile.country_cache_key_name(self.subject.id), "Timor-leste") + + self.login("worker") + assert self.send("lms_core").status_code == status.HTTP_204_NO_CONTENT + + self.subject.refresh_from_db() + assert self.subject.username == self.retirement.retired_username + assert self.subject.email == self.retirement.retired_email + assert self.subject.first_name == "" + assert self.subject.last_name == "" + assert self.subject.is_active is False + assert self.subject.profile.name == "" + assert self.subject.profile.city is None + assert not PendingEmailChange.objects.filter(user=self.subject).exists() + assert not UserOrgTag.objects.filter(user=self.subject).exists() + assert not AccountRecovery.objects.filter(user=self.subject).exists() + assert not CourseEnrollmentAllowed.objects.filter(email="subject@example.com").exists() + assert cache.get(UserProfile.country_cache_key_name(self.subject.id)) is None + + def test_the_miscellaneous_scope_erases_what_the_features_around_the_account_hold(self): + PendingNameChange.objects.create(user=self.subject, new_name="Another Name", rationale="x") + self.login("worker") + assert self.send("lms_misc").status_code == status.HTTP_204_NO_CONTENT + assert not PendingNameChange.objects.filter(user=self.subject).exists() + self.subject.refresh_from_db() + # The miscellaneous scope leaves the account itself alone. + assert self.subject.username == "subject" + assert self.subject.is_active is True + + def test_a_second_run_works_the_replacement_name_out_from_the_registered_name(self): + """ + The record holds no replacement name of its own, so the step works one out. + + It works it out from the name the account was registered under, which the record keeps, + rather than from the name the account holds, which this very step has already replaced. + """ + UserRetirementStatus.objects.filter(id=self.retirement.id).update(retired_username="") + announced = [] + USER_RETIRE_LMS_CRITICAL.connect( + lambda sender, **kwargs: announced.append(kwargs["retired_username"]), + weak=False, + dispatch_uid="replacement-name-of-each-run", + ) + self.addCleanup( + USER_RETIRE_LMS_CRITICAL.disconnect, dispatch_uid="replacement-name-of-each-run", + ) + self.login("worker") + assert self.send("lms_core").status_code == status.HTTP_204_NO_CONTENT + self.subject.refresh_from_db() + written = self.subject.username + assert written == get_retired_username_by_username(self.retirement.original_username) + assert self.send("lms_core").status_code == status.HTTP_204_NO_CONTENT + self.subject.refresh_from_db() + assert self.subject.username == written + assert announced == [written, written] + + def test_the_erasure_does_not_move_the_retirement(self): + self.login("worker") + self.send("lms_core") + self.retirement.refresh_from_db() + assert self.retirement.current_state.state_name == "RETIRING_LMS" + + def test_a_body_that_names_no_scope_is_refused_and_nothing_is_erased(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.post(self.url, {}, format="json"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "scope" in envelope["errors"] + self.subject.refresh_from_db() + assert self.subject.username == "subject" + + def test_a_scope_this_address_does_not_erase_is_refused_and_nothing_is_erased(self): + self.login("worker") + envelope = assert_error_envelope( + self.send("everything"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "scope" in envelope["errors"] + self.subject.refresh_from_db() + assert self.subject.username == "subject" + + def test_a_retirement_waiting_for_the_learner_is_not_erased(self): + self.retirement.current_state = self.state("PENDING") + self.retirement.save() + self.login("worker") + envelope = assert_error_envelope( + self.send(), + expected_status=status.HTTP_409_CONFLICT, + expected_type_slug=STATE_CONFLICT_SLUG, + ) + assert envelope["detail"] == view_services.NOT_OPEN_TO_ACTION + self.subject.refresh_from_db() + assert self.subject.username == "subject" + + def test_a_retirement_at_a_step_already_recorded_as_finished_is_not_erased(self): + self.retirement.current_state = self.state("LMS_COMPLETE") + self.retirement.save() + self.login("worker") + assert_error_envelope( + self.send(), + expected_status=status.HTTP_409_CONFLICT, + expected_type_slug=STATE_CONFLICT_SLUG, + ) + + def test_a_retirement_at_a_dead_end_step_is_not_erased(self): + self.retirement.current_state = self.state("ERRORED") + self.retirement.save() + self.login("worker") + assert_error_envelope( + self.send(), + expected_status=status.HTTP_409_CONFLICT, + expected_type_slug=STATE_CONFLICT_SLUG, + ) + + def test_a_name_no_retirement_is_requested_under_is_not_found(self): + self.login("worker") + assert_error_envelope( + self.client.post( + reverse("user_v2:retirement_erasure", kwargs={"username": "nobody"}), + {"scope": "lms_core"}, + format="json", + ), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_a_step_that_fails_partway_through_says_nothing_about_why(self): + self.login("worker") + with mock.patch( + "openedx.core.djangoapps.user_api.v2.view_services.AccountRetirementView" + ".clear_pii_from_certificate_records", + side_effect=ValueError("the certificate store is on fire"), + ): + response = self.client.post(self.url, {"scope": "lms_core"}, format="json") + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + body = response.json() + # The answer carries the same envelope as every other refusal of this + # address, which is what the document publishes for it. + assert body == { + "type": error_type_uri(INTERNAL_ERROR_SLUG), + "title": INTERNAL_ERROR_TITLE, + "status": status.HTTP_500_INTERNAL_SERVER_ERROR, + "detail": "An unexpected error occurred. Please try again later.", + "instance": self.url, + } + assert "fire" not in str(body) + + +@skip_unless_lms +class PartnerReportClaimTests(_AccessMatrixMixin, RetirementApiTestCase): + """POST /api/user/v2/retirement_partner_reports/""" + + admitted = PIPELINE_CALLERS + refused = PIPELINE_REFUSED + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:retirement_partner_report_list") + self.entry = self.queued(self.subject) + + def queued(self, user): + """Return a partner report entry waiting to be taken, for this user.""" + return UserRetirementPartnerReportingStatus.objects.create( + user=user, + original_username=user.username, + original_email=user.email, + original_name=user.profile.name, + ) + + def send(self): + return self.client.post(self.url, {}, format="json") + + def test_the_entry_carries_what_a_partner_needs(self): + CourseEnrollmentFactory.create(user=self.subject, course_id=COURSE_KEY) + CourseEnrollmentFactory.create(user=self.subject, course_id=OTHER_COURSE_KEY) + self.login("worker") + response = self.client.post(self.url, {}, format="json") + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 1 + entry = response.data[0] + assert set(entry) == { + "user_id", "original_username", "original_email", "original_name", "orgs", "created", + } + assert entry["user_id"] == self.subject.id + assert entry["original_username"] == "subject" + assert entry["original_email"] == "subject@example.com" + assert entry["orgs"] == ["OtherOrg", "edX"] + + def test_an_account_enrolled_with_nobody_carries_no_organizations(self): + self.login("worker") + response = self.client.post(self.url, {}, format="json") + assert response.data[0]["orgs"] == [] + + def test_the_placeholder_organization_is_not_named_to_partners(self): + CourseEnrollmentFactory.create(user=self.subject, course_id=COURSE_KEY) + CourseEnrollmentFactory.create(user=self.subject, course_id=PLACEHOLDER_ORG_COURSE_KEY) + self.login("worker") + response = self.client.post(self.url, {}, format="json") + assert response.data[0]["orgs"] == ["edX"] + + def test_the_entries_returned_are_exactly_the_entries_taken(self): + other = self.queued(self.learner) + self.login("worker") + response = self.client.post(self.url, {}, format="json") + returned = {entry["original_username"] for entry in response.data} + assert returned == {"subject", "learner-caller"} + taken = set( + UserRetirementPartnerReportingStatus.objects + .filter(is_being_processed=True) + .values_list("original_username", flat=True), + ) + assert taken == returned + assert UserRetirementPartnerReportingStatus.objects.get(id=other.id).is_being_processed + + def test_a_second_request_takes_nothing(self): + self.login("worker") + self.client.post(self.url, {}, format="json") + again = self.client.post(self.url, {}, format="json") + assert again.status_code == status.HTTP_200_OK + assert again.data == [] + + def test_an_entry_added_while_the_answer_is_built_is_left_for_the_next_caller(self): + added = [] + + def add_one_entry_behind_the_reader(report): + if not added: + added.append(self.queued(self.learner)) + return original(report) + + original = view_services._partner_report_of # pylint: disable=protected-access + self.login("worker") + with mock.patch.object( + view_services, "_partner_report_of", side_effect=add_one_entry_behind_the_reader, + ): + response = self.client.post(self.url, {}, format="json") + + assert [entry["original_username"] for entry in response.data] == ["subject"] + latecomer = UserRetirementPartnerReportingStatus.objects.get(id=added[0].id) + assert latecomer.is_being_processed is False + assert UserRetirementPartnerReportingStatus.objects.get(id=self.entry.id).is_being_processed + + def test_at_most_the_number_of_entries_asked_for_is_taken(self): + self.queued(self.learner) + self.login("worker") + response = self.client.post(f"{self.url}?limit=1", {}, format="json") + assert len(response.data) == 1 + assert UserRetirementPartnerReportingStatus.objects.filter(is_being_processed=True).count() == 1 + + def test_taking_no_entries_at_all_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.post(f"{self.url}?limit=0", {}, format="json"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "limit" in envelope["errors"] + assert UserRetirementPartnerReportingStatus.objects.filter(is_being_processed=True).count() == 0 + + def test_a_claim_held_to_a_page_of_entries_is_taken(self): + self.queued(self.learner) + self.login("worker") + response = self.client.post(f"{self.url}?limit=100", {}, format="json") + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 2 + assert UserRetirementPartnerReportingStatus.objects.filter(is_being_processed=False).count() == 0 + + def test_a_claim_held_to_more_than_a_page_of_entries_is_refused(self): + self.login("worker") + for value in ("101", "9" * 30): + with self.subTest(value=value): + envelope = assert_error_envelope( + self.client.post(f"{self.url}?limit={value}", {}, format="json"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"limit": ["Ensure this value is less than or equal to 100."]} + assert UserRetirementPartnerReportingStatus.objects.filter(is_being_processed=True).count() == 0 + + def test_a_query_parameter_this_address_does_not_read_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.post(f"{self.url}?count=1", {}, format="json"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"]["count"] == [ + f"No such query parameter. Send any of: {CLAIM_QUERY_PARAMS}.", + ] + + +@skip_unless_lms +class PartnerReportQueueTests(_AccessMatrixMixin, RetirementApiTestCase): + """PUT /api/user/v2/retirement_partner_reports/{username}/""" + + admitted = PIPELINE_CALLERS + refused = PIPELINE_REFUSED + + def setUp(self): + super().setUp() + self.retirement = self.retirement_in("ADDING_TO_PARTNER_QUEUE", user=self.subject) + CourseEnrollmentFactory.create(user=self.subject, course_id=COURSE_KEY) + self.url = reverse("user_v2:retirement_partner_report_detail", kwargs={"username": "subject"}) + + def send(self): + return self.client.put(self.url, {}, format="json") + + def test_an_account_that_studied_with_an_organization_is_queued(self): + self.login("worker") + assert self.send().status_code == status.HTTP_204_NO_CONTENT + entry = UserRetirementPartnerReportingStatus.objects.get(user=self.subject) + assert entry.original_username == "subject" + assert entry.original_email == "subject@example.com" + assert entry.original_name == self.subject.profile.name + assert entry.is_being_processed is False + + def test_an_account_that_studied_with_nobody_is_not_queued(self): + learner_retirement = self.retirement_in("ADDING_TO_PARTNER_QUEUE", user=self.learner) + self.login("worker") + response = self.client.put( + reverse( + "user_v2:retirement_partner_report_detail", + kwargs={"username": learner_retirement.original_username}, + ), + {}, + format="json", + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not UserRetirementPartnerReportingStatus.objects.filter(user=self.learner).exists() + + def test_an_account_enrolled_only_under_the_placeholder_organization_is_not_queued(self): + learner_retirement = self.retirement_in("ADDING_TO_PARTNER_QUEUE", user=self.learner) + CourseEnrollmentFactory.create(user=self.learner, course_id=PLACEHOLDER_ORG_COURSE_KEY) + self.login("worker") + response = self.client.put( + reverse( + "user_v2:retirement_partner_report_detail", + kwargs={"username": learner_retirement.original_username}, + ), + {}, + format="json", + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not UserRetirementPartnerReportingStatus.objects.filter(user=self.learner).exists() + + def test_queueing_the_same_account_twice_leaves_one_entry(self): + self.login("worker") + assert self.send().status_code == status.HTTP_204_NO_CONTENT + assert self.send().status_code == status.HTTP_204_NO_CONTENT + assert UserRetirementPartnerReportingStatus.objects.filter(user=self.subject).count() == 1 + + def test_a_name_no_retirement_is_requested_under_is_not_found(self): + self.login("worker") + assert_error_envelope( + self.client.put( + reverse("user_v2:retirement_partner_report_detail", kwargs={"username": "nobody"}), + {}, + format="json", + ), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_a_retirement_at_a_step_that_admits_no_work_is_refused(self): + self.retirement.current_state = self.state("PENDING") + self.retirement.save() + self.login("worker") + assert_error_envelope( + self.send(), + expected_status=status.HTTP_409_CONFLICT, + expected_type_slug=STATE_CONFLICT_SLUG, + ) + assert not UserRetirementPartnerReportingStatus.objects.filter(user=self.subject).exists() + + def test_a_name_spelled_differently_from_the_registered_one_is_not_found(self): + self.login("worker") + assert_error_envelope( + self.client.put( + reverse("user_v2:retirement_partner_report_detail", kwargs={"username": "Subject"}), + {}, + format="json", + ), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_how_many_entries_to_take_is_not_read_where_none_are_taken(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.put(f"{self.url}?limit=1", {}, format="json"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"]["limit"] == [ + f"No such query parameter. Send any of: {WRITE_QUERY_PARAMS}.", + ] + + +class _BatchTestsMixin(_AccessMatrixMixin): + """What both batch disposals answer, whichever records they act on.""" + + admitted = PIPELINE_CALLERS + refused = PIPELINE_REFUSED + + def post(self, usernames): + """Name these accounts to the address under test.""" + return self.client.post(self.url, {"usernames": usernames}, format="json") + + def test_a_body_naming_no_account_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.post([]), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "usernames" in envelope["errors"] + + def test_a_body_naming_more_accounts_than_a_page_holds_is_answered_for_every_name(self): + self.login("worker") + names = [f"name-{index}" for index in range(MAXIMUM_PAGE_SIZE + 1)] + response = self.post(names) + assert response.status_code == status.HTTP_200_OK + assert response.data == {"results": [{"username": name, "outcome": "already_absent"} for name in names]} + + def test_each_refused_name_is_listed_by_its_position(self): + self.login("worker") + envelope = assert_error_envelope( + self.post(["subject", None, ""]), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "usernames[1]": ["This field may not be null."], + "usernames[2]": ["This field may not be blank."], + } + assert envelope["detail"] == ( + "usernames[1]: This field may not be null. usernames[2]: This field may not be blank." + ) + + def test_no_refusal_of_a_body_is_written_out_as_program_text(self): + """Whatever is wrong with the body, neither the line nor the list prints a data structure.""" + self.login("worker") + bodies = ( + {"usernames": [None]}, + {"usernames": ["subject", {"name": "subject"}]}, + {"usernames": [["subject"]]}, + {"usernames": "subject"}, + {"usernames": []}, + {}, + ) + for body in bodies: + with self.subTest(body=body): + response = self.client.post(self.url, body, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + text = response.content.decode() + printed = [mark for mark in ("ErrorDetail(", "string=") if mark in text] + assert not printed, text + + def test_a_body_that_is_not_an_object_is_refused(self): + self.login("worker") + assert_error_envelope( + self.client.post(self.url, [{"original_username": "subject"}], format="json"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + + def test_a_body_in_a_media_type_this_address_cannot_read_is_refused(self): + self.login("worker") + assert_error_envelope( + self.client.post(self.url, "usernames=subject", content_type="text/plain"), + expected_status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + expected_type_slug=UNSUPPORTED_MEDIA_TYPE_SLUG, + ) + + def test_a_query_parameter_this_address_does_not_read_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.post(f"{self.url}?limit=1", {"usernames": ["subject"]}, format="json"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"]["limit"] == [ + f"No such query parameter. Send any of: {WRITE_QUERY_PARAMS}.", + ] + + +@skip_unless_lms +class RetirementCleanupTests(_BatchTestsMixin, RetirementApiTestCase): + """POST /api/user/v2/retirement_cleanups/""" + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:retirement_cleanup_list") + self.finished = self.retirement_in("COMPLETE", user=self.subject) + + def send(self): + retirement = self.retirement_in("COMPLETE") + return self.post([retirement.original_username]) + + def test_a_finished_record_is_redacted_and_then_deleted(self): + self.login("worker") + response = self.post(["subject"]) + assert response.status_code == status.HTTP_200_OK + assert response.data == {"results": [{"username": "subject", "outcome": "deleted"}]} + assert not UserRetirementStatus.objects.filter(id=self.finished.id).exists() + + def test_the_record_carries_the_replacement_before_it_goes(self): + """The redaction and the delete are separate statements, so the replacement is written.""" + self.login("worker") + seen = [] + original = UserRetirementStatus.objects.filter + + def remember_the_values_the_row_holds(*args, **kwargs): + selection = original(*args, **kwargs) + seen.append(list(selection.values_list("original_username", "original_email"))) + return selection + + with mock.patch.object( + UserRetirementStatus.objects, "filter", side_effect=remember_the_values_the_row_holds, + ): + self.post(["subject"]) + # The last selection is the delete, which sees the written-over values. + assert seen[-1] == [(view_services.REDACTED_VALUE, view_services.REDACTED_EMAIL)] + + def test_a_record_moved_off_that_step_between_the_two_statements_is_left(self): + """The caller was answered about a finished record, so only a finished record goes.""" + self.login("worker") + selections = [] + original = UserRetirementStatus.objects.filter + + def move_the_record_before_the_delete_runs(*args, **kwargs): + selection = original(*args, **kwargs) + selections.append(selection) + if len(selections) == 2: + forced = UserRetirementStatus.objects.get(id=self.finished.id) + forced.current_state = self.state("ERRORED") + forced.save() + return selection + + with mock.patch.object( + UserRetirementStatus.objects, "filter", + side_effect=move_the_record_before_the_delete_runs, + ): + answered = self.post(["subject"]) + assert answered.status_code == status.HTTP_200_OK + left = UserRetirementStatus.objects.get(id=self.finished.id) + assert left.current_state.state_name == "ERRORED" + + def test_a_repeat_of_a_request_already_carried_out_is_answered(self): + self.login("worker") + self.post(["subject"]) + again = self.post(["subject"]) + assert again.status_code == status.HTTP_200_OK + assert again.data == {"results": [{"username": "subject", "outcome": "already_absent"}]} + + def test_each_name_is_reported_in_the_order_it_was_given(self): + other = self.retirement_in("COMPLETE") + self.login("worker") + response = self.post([other.original_username, "nobody", "subject"]) + assert response.data == { + "results": [ + {"username": other.original_username, "outcome": "deleted"}, + {"username": "nobody", "outcome": "already_absent"}, + {"username": "subject", "outcome": "deleted"}, + ], + } + + def test_a_name_given_twice_is_acted_on_and_reported_once(self): + self.login("worker") + response = self.post(["subject", "subject"]) + assert response.data == {"results": [{"username": "subject", "outcome": "deleted"}]} + + def test_an_unfinished_record_refuses_the_whole_batch_and_nothing_is_deleted(self): + unfinished = self.retirement_in("LOCKING_ACCOUNT") + self.login("worker") + envelope = assert_error_envelope( + self.post(["subject", unfinished.original_username]), + expected_status=status.HTTP_409_CONFLICT, + expected_type_slug=STATE_CONFLICT_SLUG, + ) + assert envelope["detail"] == view_services.BATCH_LEFT_ALONE + assert envelope["errors"] == { + unfinished.original_username: [ + "This retirement stands at LOCKING_ACCOUNT; only a retirement at COMPLETE is deleted here.", + ], + } + assert UserRetirementStatus.objects.filter(id=self.finished.id).exists() + assert UserRetirementStatus.objects.filter(id=unfinished.id).exists() + + def test_a_name_spelled_differently_from_the_registered_one_leaves_the_record_alone(self): + self.login("worker") + response = self.post(["SUBJECT"]) + assert response.data == {"results": [{"username": "SUBJECT", "outcome": "already_absent"}]} + assert UserRetirementStatus.objects.filter(id=self.finished.id).exists() + + +@skip_unless_lms +class PartnerReportCleanupTests(_BatchTestsMixin, RetirementApiTestCase): + """POST /api/user/v2/retirement_partner_report_cleanups/""" + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:retirement_partner_report_cleanup_list") + self.reported = self.queued(self.subject, taken=True) + + def queued(self, user, taken=False): + """Return a partner report entry for this user, taken for processing or not.""" + return UserRetirementPartnerReportingStatus.objects.create( + user=user, + original_username=user.username, + original_email=user.email, + original_name=user.profile.name, + is_being_processed=taken, + ) + + def send(self): + entry = self.queued(UserFactory.create(), taken=True) + return self.post([entry.original_username]) + + def test_an_entry_that_has_been_reported_on_is_deleted(self): + self.login("worker") + response = self.post(["subject"]) + assert response.status_code == status.HTTP_200_OK + assert response.data == {"results": [{"username": "subject", "outcome": "deleted"}]} + assert not UserRetirementPartnerReportingStatus.objects.filter(id=self.reported.id).exists() + + def test_a_repeat_of_a_request_already_carried_out_is_answered(self): + self.login("worker") + self.post(["subject"]) + again = self.post(["subject"]) + assert again.data == {"results": [{"username": "subject", "outcome": "already_absent"}]} + + def test_an_entry_nobody_has_taken_refuses_the_whole_batch_and_nothing_is_deleted(self): + waiting = self.queued(self.learner) + self.login("worker") + envelope = assert_error_envelope( + self.post(["subject", waiting.original_username]), + expected_status=status.HTTP_409_CONFLICT, + expected_type_slug=STATE_CONFLICT_SLUG, + ) + assert envelope["errors"] == { + waiting.original_username: [ + "This partner report has not been taken for processing, so it has not been reported on yet.", + ], + } + assert UserRetirementPartnerReportingStatus.objects.filter(id=self.reported.id).exists() + assert UserRetirementPartnerReportingStatus.objects.filter(id=waiting.id).exists() + + def test_each_name_is_reported_in_the_order_it_was_given(self): + other = self.queued(UserFactory.create(username="other-subject"), taken=True) + self.login("worker") + response = self.post(["other-subject", "nobody", "subject"]) + assert response.data == { + "results": [ + {"username": "other-subject", "outcome": "deleted"}, + {"username": "nobody", "outcome": "already_absent"}, + {"username": "subject", "outcome": "deleted"}, + ], + } + assert not UserRetirementPartnerReportingStatus.objects.filter(id=other.id).exists() + + +@skip_unless_lms +class RetirementAccessModelTests(RetirementApiTestCase): + """How the capabilities of this area are combined, and what happens with none of them.""" + + def test_an_action_the_mapping_does_not_name_reaches_no_capability(self): + """A later action cannot inherit the capability every named action shares.""" + # Read from the declaration, so the answer does not depend on whether a + # request to this view earlier in the run let the middleware copy it. + with layers_read_from_their_own_declaration(RetirementViewSet): + viewset = RetirementViewSet() + viewset.action = "an_action_added_later" + granted = [ + (type(permission), permission.has_permission(mock.Mock(user=self.superuser), viewset)) + for permission in viewset.get_permissions() + ] + assert granted == [ + (permissions.IsAuthenticated, True), + (permissions.NOT, False), + (NotJwtRestrictedApplication, True), + ] + + def test_each_named_action_carries_the_capability_it_is_mapped_to(self): + """The classes are read from the view rather than from the mapping, so the mapping can be wrong.""" + expected = { + "list": CanRetireUser, + "retrieve": CanRetireUser, + "partial_update": CanRetireUser, + "erasures": CanRetireUser, + "destroy": CanCancelUserRetirement, + "metadata": CanRetireUser, + } + assert set(expected) == set(RETIREMENT_ACTION_PERMISSIONS) + # Read from the declaration, so the answer does not depend on whether a + # request to this view earlier in the run let the middleware copy it. + with layers_read_from_their_own_declaration(RetirementViewSet): + for action, capability in expected.items(): + with self.subTest(action=action): + viewset = RetirementViewSet() + viewset.action = action + built = [type(permission) for permission in viewset.get_permissions()] + assert built == [permissions.IsAuthenticated, capability, NotJwtRestrictedApplication] + + def test_the_classes_of_a_view_must_all_admit_a_caller(self): + """Being authenticated is not enough, and holding the capability without being signed in is not either.""" + self.login("staff") + assert_error_envelope( + self.client.get(reverse("user_v2:retirement_list")), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + self.client.logout() + assert_error_envelope( + self.client.get(reverse("user_v2:retirement_list")), + expected_status=status.HTTP_401_UNAUTHORIZED, + expected_type_slug="authn", + ) + + def test_no_retirement_view_declares_its_own_authentication(self): + for view in ( + RetirementViewSet, + RetirementPartnerReportViewSet, + RetirementCleanupViewSet, + RetirementPartnerReportCleanupViewSet, + _RetirementRecordAccessMixin, + _PartnerReportAccessMixin, + ): + with self.subTest(view=view.__name__): + assert "authentication_classes" not in vars(view) + + def test_no_retirement_view_declares_its_own_throttles(self): + """The pipeline is unthrottled on both versions alike, which is left as it stands.""" + for view in ( + RetirementViewSet, + RetirementPartnerReportViewSet, + RetirementCleanupViewSet, + RetirementPartnerReportCleanupViewSet, + _RetirementRecordAccessMixin, + _PartnerReportAccessMixin, + ): + with self.subTest(view=view.__name__): + assert "throttle_classes" not in vars(view) + + +@skip_unless_lms +class RetirementRecordAccessReachTests(RetirementApiTestCase): + """ + What is asked of the operations on the retirement records is asked of their disposal. + + The records the collection lists, moves and cancels are the records the + batch disposal deletes, so both resolve who may act on them and which of + them a caller acts on through one layer. A rule added to that layer is + read here from the answers each of them gives, rather than from where the + rule is written. + """ + + def setUp(self): + super().setUp() + self.list_url = reverse("user_v2:retirement_list") + self.disposal_url = reverse("user_v2:retirement_cleanup_list") + self.finished = self.retirement_in("COMPLETE", user=self.subject) + self.other = self.retirement_in("COMPLETE") + + def dispose_of(self, usernames): + """Ask the disposal to delete the records of these accounts.""" + return self.client.post(self.disposal_url, {"usernames": usernames}, format="json") + + @contextlib.contextmanager + def a_rule_added_to_the_records(self): + """Add a rule to the layer the operations on these records share.""" + with layers_read_from_their_own_declaration(RetirementViewSet, RetirementCleanupViewSet): + with mock.patch.object( + _RetirementRecordAccessMixin, "permission_classes", + (permissions.IsAuthenticated, _OnlyASuperuser), + ): + yield + + def test_a_rule_added_to_the_records_is_asked_of_their_disposal(self): + self.login("worker") + with self.a_rule_added_to_the_records(): + refused = self.dispose_of(["subject"]) + assert_error_envelope( + refused, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + assert UserRetirementStatus.objects.filter(id=self.finished.id).exists() + + def test_the_same_rule_is_asked_of_the_collection_those_records_are_listed_by(self): + """The rule is added in one place, so what obeys it is what resolves it there.""" + self.login("worker") + with self.a_rule_added_to_the_records(): + assert_error_envelope( + self.client.get(self.list_url), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_the_caller_the_rule_admits_is_served_by_both(self): + """The refusals above are the rule's doing, and the rule admits somebody.""" + self.login("superuser") + with self.a_rule_added_to_the_records(): + assert self.client.get(self.list_url).status_code == status.HTTP_200_OK + assert self.dispose_of(["subject"]).status_code == status.HTTP_200_OK + + def test_the_same_caller_is_served_by_the_disposal_without_that_rule(self): + """The refusal above is the rule's doing and not the caller's.""" + self.login("worker") + answered = self.dispose_of(["subject"]) + assert answered.status_code == status.HTTP_200_OK + assert answered.data == {"results": [{"username": "subject", "outcome": "deleted"}]} + + def test_a_record_the_visibility_policy_leaves_out_is_not_deleted_by_the_disposal(self): + self.login("worker") + policy = _NarrowingPolicy("original_username", self.other.original_username) + with mock.patch.object(_RetirementRecordAccessMixin, "scoping_policy", policy): + answered = self.dispose_of(["subject", self.other.original_username]) + assert answered.data == { + "results": [ + {"username": "subject", "outcome": "already_absent"}, + {"username": self.other.original_username, "outcome": "deleted"}, + ], + } + assert UserRetirementStatus.objects.filter(id=self.finished.id).exists() + assert not UserRetirementStatus.objects.filter(id=self.other.id).exists() + + def test_the_policy_is_given_the_caller_and_every_record_the_disposal_could_delete(self): + self.login("worker") + policy = _NarrowingPolicy("original_username", self.other.original_username) + with mock.patch.object(_RetirementRecordAccessMixin, "scoping_policy", policy): + self.dispose_of(["subject"]) + assert [subject.username for subject in policy.subjects] == [WORKER_USERNAME] + assert policy.offered == [sorted(["subject", self.other.original_username])] + + def test_the_disposal_declares_none_of_these_layers_itself(self): + """Declaring one again here is what would leave a rule added to the records behind.""" + with layers_read_from_their_own_declaration(RetirementViewSet, RetirementCleanupViewSet): + for name, declared_by in ( + ("permission_classes", _RetirementRecordAccessMixin), + ("scoping_policy", _RetirementRecordAccessMixin), + ("get_permissions", _RetirementRecordAccessMixin), + ("get_queryset", _RetirementRecordsMixin), + ): + with self.subTest(declaration=name): + assert name not in vars(RetirementCleanupViewSet) + assert name not in vars(RetirementViewSet) + assert name in vars(declared_by) + + def test_the_classes_each_view_resolves_are_the_ones_the_shared_layer_declares(self): + """Neither view declares them itself, so these are the classes the layer resolves to.""" + resolved = {} + with layers_read_from_their_own_declaration(RetirementViewSet, RetirementCleanupViewSet): + for view, action in ( + (RetirementViewSet, "list"), + (RetirementViewSet, "destroy"), + (RetirementCleanupViewSet, "create"), + (RetirementCleanupViewSet, "metadata"), + ): + viewset = view() + viewset.action = action + resolved[(view.__name__, action)] = [ + type(permission) for permission in viewset.get_permissions() + ] + assert resolved == { + ("RetirementViewSet", "list"): [permissions.IsAuthenticated, CanRetireUser, NotJwtRestrictedApplication], + ("RetirementViewSet", "destroy"): [ + permissions.IsAuthenticated, CanCancelUserRetirement, NotJwtRestrictedApplication, + ], + ("RetirementCleanupViewSet", "create"): [ + permissions.IsAuthenticated, CanRetireUser, NotJwtRestrictedApplication, + ], + ("RetirementCleanupViewSet", "metadata"): [ + permissions.IsAuthenticated, CanRetireUser, NotJwtRestrictedApplication, + ], + } + + def test_the_policy_declared_for_these_records_is_the_one_each_view_resolves(self): + """The library mixin declares the same attribute, so where it stands in the order matters.""" + for view in (RetirementViewSet, RetirementCleanupViewSet): + with self.subTest(view=view.__name__): + assert isinstance(view().scoping_policy, FullScopePolicy) + + def test_the_disposal_asks_for_the_capability_the_rest_of_the_pipeline_work_asks_for(self): + assert RetirementCleanupViewSet.action_permissions == { + "create": (CanRetireUser,), + "metadata": (CanRetireUser,), + } + # Read from the declaration, so the answer does not depend on whether a + # request to this view earlier in the run let the middleware copy it. + with layers_read_from_their_own_declaration(RetirementCleanupViewSet): + for action in RetirementCleanupViewSet.action_permissions: + with self.subTest(action=action): + viewset = RetirementCleanupViewSet() + viewset.action = action + built = [type(permission) for permission in viewset.get_permissions()] + assert built == [permissions.IsAuthenticated, CanRetireUser, NotJwtRestrictedApplication] + + def test_asking_the_disposal_what_it_serves_asks_for_the_same_capability(self): + """Describing an address is reading it, and this address is read by whoever may work it.""" + self.login("worker") + described = self.client.options(self.disposal_url) + assert described.status_code == status.HTTP_200_OK + assert described.data["renders"] == ["application/json"] + self.client.logout() + self.login("learner") + assert_error_envelope( + self.client.options(self.disposal_url), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_an_action_added_to_the_disposal_later_reaches_no_capability(self): + # Read from the declaration, so the answer does not depend on whether a + # request to this view earlier in the run let the middleware copy it. + with layers_read_from_their_own_declaration(RetirementCleanupViewSet): + viewset = RetirementCleanupViewSet() + viewset.action = "an_action_added_later" + granted = [ + (type(permission), permission.has_permission(mock.Mock(user=self.superuser), viewset)) + for permission in viewset.get_permissions() + ] + assert granted == [ + (permissions.IsAuthenticated, True), + (permissions.NOT, False), + (NotJwtRestrictedApplication, True), + ] + + +@skip_unless_lms +class PartnerReportAccessReachTests(RetirementApiTestCase): + """What is asked of the operations on the partner report entries is asked of their disposal.""" + + def setUp(self): + super().setUp() + self.queue_url = reverse("user_v2:retirement_partner_report_list") + self.disposal_url = reverse("user_v2:retirement_partner_report_cleanup_list") + self.reported = self.queued(self.subject, taken=True) + self.also_reported = self.queued(self.learner, taken=True) + + def queued(self, user, taken=False): + """Return a partner report entry for this user, taken for processing or not.""" + return UserRetirementPartnerReportingStatus.objects.create( + user=user, + original_username=user.username, + original_email=user.email, + original_name=user.profile.name, + is_being_processed=taken, + ) + + def dispose_of(self, usernames): + """Ask the disposal to delete the entries of these accounts.""" + return self.client.post(self.disposal_url, {"usernames": usernames}, format="json") + + def test_a_rule_added_to_the_entries_is_asked_of_their_disposal(self): + self.login("worker") + with layers_read_from_their_own_declaration( + RetirementPartnerReportViewSet, RetirementPartnerReportCleanupViewSet, + ), mock.patch.object( + _PartnerReportAccessMixin, "permission_classes", + (permissions.IsAuthenticated, _OnlyASuperuser), + ): + refused = self.dispose_of(["subject"]) + taking = self.client.post(self.queue_url, {}, format="json") + for response in (refused, taking): + assert_error_envelope( + response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + assert UserRetirementPartnerReportingStatus.objects.filter(id=self.reported.id).exists() + + def test_the_same_caller_is_served_by_the_disposal_without_that_rule(self): + self.login("worker") + answered = self.dispose_of(["subject"]) + assert answered.status_code == status.HTTP_200_OK + assert answered.data == {"results": [{"username": "subject", "outcome": "deleted"}]} + + def test_an_entry_the_visibility_policy_leaves_out_is_not_deleted_by_the_disposal(self): + self.login("worker") + policy = _NarrowingPolicy("original_username", self.learner.username) + with mock.patch.object(_PartnerReportAccessMixin, "scoping_policy", policy): + answered = self.dispose_of(["subject", self.learner.username]) + assert answered.data == { + "results": [ + {"username": "subject", "outcome": "already_absent"}, + {"username": self.learner.username, "outcome": "deleted"}, + ], + } + assert UserRetirementPartnerReportingStatus.objects.filter(id=self.reported.id).exists() + assert not UserRetirementPartnerReportingStatus.objects.filter( + id=self.also_reported.id, + ).exists() + + def test_the_disposal_resolves_the_entries_the_queue_leaves_out(self): + """An entry is disposed of once it has been taken, which is when the queue stops offering it.""" + self.login("worker") + policy = _NarrowingPolicy("original_username", "nobody") + with mock.patch.object(_PartnerReportAccessMixin, "scoping_policy", policy): + self.dispose_of(["subject"]) + self.client.post(self.queue_url, {}, format="json") + assert policy.offered == [ + sorted([self.subject.username, self.learner.username]), + [], + ] + + def test_the_disposal_declares_neither_layer_itself(self): + with layers_read_from_their_own_declaration( + RetirementPartnerReportViewSet, RetirementPartnerReportCleanupViewSet, + ): + for name in ("permission_classes", "scoping_policy"): + with self.subTest(declaration=name): + assert name not in vars(RetirementPartnerReportCleanupViewSet) + assert name not in vars(RetirementPartnerReportViewSet) + assert name in vars(_PartnerReportAccessMixin) + + def test_the_classes_each_view_resolves_are_the_ones_the_shared_layer_declares(self): + """Neither view declares them itself, so these are the classes the layer resolves to.""" + with layers_read_from_their_own_declaration( + RetirementPartnerReportViewSet, RetirementPartnerReportCleanupViewSet, + ): + resolved = { + view.__name__: [type(permission) for permission in view().get_permissions()] + for view in ( + RetirementPartnerReportViewSet, RetirementPartnerReportCleanupViewSet, + ) + } + assert resolved == { + "RetirementPartnerReportViewSet": [ + permissions.IsAuthenticated, CanRetireUser, NotJwtRestrictedApplication, + ], + "RetirementPartnerReportCleanupViewSet": [ + permissions.IsAuthenticated, CanRetireUser, NotJwtRestrictedApplication, + ], + } + + def test_the_policy_declared_for_these_entries_is_the_one_each_view_resolves(self): + """The library mixin declares the same attribute, so where it stands in the order matters.""" + for view in (RetirementPartnerReportViewSet, RetirementPartnerReportCleanupViewSet): + with self.subTest(view=view.__name__): + assert isinstance(view().scoping_policy, FullScopePolicy) + + +@skip_unless_lms +class RetirementMemberVisibilityTests(RetirementApiTestCase): + """ + A retirement the visibility policy leaves out is absent from every member address too. + + The policy this deployment runs keeps the records whole, because every + caller these addresses admit holds the capability to work the whole + pipeline, so it is replaced here with one that keeps one record of two and + each member address is asked about the other. + """ + + def setUp(self): + super().setUp() + self.kept = self.retirement_in("RETIRING_LMS", user=self.learner) + self.left_out = self.retirement_in("RETIRING_LMS", user=self.subject) + self.policy = _NarrowingPolicy("original_username", self.learner.username) + self.left_out_url = reverse("user_v2:retirement_detail", kwargs={"username": "subject"}) + self.kept_url = reverse( + "user_v2:retirement_detail", kwargs={"username": self.learner.username}, + ) + self.erasure_url = reverse("user_v2:retirement_erasure", kwargs={"username": "subject"}) + + @contextlib.contextmanager + def policy_in_force(self): + """Serve the next requests under a policy that keeps one of the two records.""" + with mock.patch.object(_RetirementRecordAccessMixin, "scoping_policy", self.policy): + yield + + def assert_reported_absent(self, response): + """Assert the record was reported as absent rather than the caller refused.""" + envelope = assert_error_envelope( + response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + assert envelope["detail"] == view_services.NO_SUCH_RETIREMENT + + def test_a_record_the_policy_leaves_out_is_absent_from_its_member_address(self): + self.login("worker") + with self.policy_in_force(): + self.assert_reported_absent(self.client.get(self.left_out_url)) + + def test_the_record_the_policy_keeps_is_answered_at_its_own_address(self): + self.login("worker") + with self.policy_in_force(): + answered = self.client.get(self.kept_url) + assert answered.status_code == status.HTTP_200_OK + assert answered.data["original_username"] == self.learner.username + + def test_a_record_the_policy_leaves_out_cannot_be_moved(self): + self.login("worker") + with self.policy_in_force(): + self.assert_reported_absent( + self.client.patch( + self.left_out_url, + {"new_state": "LMS_COMPLETE", "response": "done"}, + format="json", + ), + ) + self.left_out.refresh_from_db() + assert self.left_out.current_state.state_name == "RETIRING_LMS" + + def test_a_record_the_policy_leaves_out_cannot_be_cancelled(self): + pending = self.retirement_in("PENDING", user=UserFactory.create(username="pending-one")) + self.login("canceller") + with mock.patch.object( + _RetirementRecordAccessMixin, "scoping_policy", + _NarrowingPolicy("original_username", self.learner.username), + ): + self.assert_reported_absent( + self.client.delete( + reverse("user_v2:retirement_detail", kwargs={"username": "pending-one"}), + ), + ) + assert UserRetirementStatus.objects.filter(id=pending.id).exists() + + def test_a_record_the_policy_leaves_out_has_nothing_erased(self): + self.subject.profile.city = "Somewhere" + self.subject.profile.save() + self.login("worker") + with self.policy_in_force(): + self.assert_reported_absent( + self.client.post(self.erasure_url, {"scope": "lms_core"}, format="json"), + ) + self.subject.profile.refresh_from_db() + assert self.subject.profile.city == "Somewhere" + self.subject.refresh_from_db() + assert self.subject.username == "subject" + + def test_the_policy_is_given_the_caller_and_every_record_held(self): + self.login("worker") + with self.policy_in_force(): + self.client.get(self.kept_url) + assert [subject.username for subject in self.policy.subjects] == [WORKER_USERNAME] + assert self.policy.offered == [ + sorted([self.learner.username, self.left_out.original_username]), + ] + + def test_a_name_more_than_one_record_is_held_under_is_still_refused(self): + """The policy narrows what the name is resolved against; it does not resolve the name.""" + twin = self.retirement_in("RETIRING_LMS", user=UserFactory.create(username="learner-twin")) + UserRetirementStatus.objects.filter(id=twin.id).update( + original_username=self.learner.username, + ) + self.login("worker") + with self.policy_in_force(): + envelope = assert_error_envelope( + self.client.patch( + self.kept_url, + {"new_state": "LMS_COMPLETE", "response": "done"}, + format="json", + ), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug=AMBIGUOUS_NAME_SLUG, + ) + assert envelope["detail"] == view_services.NAME_IS_AMBIGUOUS + for record in (self.kept, twin): + record.refresh_from_db() + assert record.current_state.state_name == "RETIRING_LMS" + + def test_a_policy_leaving_one_record_of_such_a_pair_leaves_one_record_for_the_name(self): + """The record left out belongs to an account this caller may not act on, so it is not weighed.""" + twin = self.retirement_in("RETIRING_LMS", user=UserFactory.create(username="learner-twin")) + UserRetirementStatus.objects.filter(id=twin.id).update( + original_username=self.learner.username, + ) + self.login("worker") + with mock.patch.object( + _RetirementRecordAccessMixin, "scoping_policy", + _NarrowingPolicy("id", self.kept.id), + ): + answered = self.client.patch( + self.kept_url, + {"new_state": "LMS_COMPLETE", "response": "done"}, + format="json", + ) + assert answered.status_code == status.HTTP_204_NO_CONTENT + self.kept.refresh_from_db() + twin.refresh_from_db() + assert self.kept.current_state.state_name == "LMS_COMPLETE" + assert twin.current_state.state_name == "RETIRING_LMS" + + +@skip_unless_lms +class SharedNameUnderANarrowingPolicyTests(RetirementApiTestCase): + """ + Which of two records requested under one name an operation weighs, and which it acts on. + + The name a record keeps of the account it was requested for is not unique, + so two records can stand under one name. A policy that keeps one of them + leaves the name addressing a single record, and that record is the one the + operation is weighed against: the record the policy left out belongs to an + account this caller does not act on, so the step it stands at decides + nothing about the account that does. + """ + + SHARED_NAME = "sharedname" + + def setUp(self): + super().setUp() + self.in_scope = UserFactory.create( + username="in-scope-holder", email="in-scope@example.com", + ) + self.out_of_scope = UserFactory.create( + username="out-of-scope-holder", email="out-of-scope@example.com", + ) + for user, name in ((self.in_scope, "In Scope"), (self.out_of_scope, "Out Of Scope")): + user.profile.name = name + user.profile.city = f"City of {user.username}" + user.profile.save() + self.url = reverse("user_v2:retirement_erasure", kwargs={"username": self.SHARED_NAME}) + # Declared here so the attributes exist before pair() fills them in. + self.out_of_scope_record = None + self.in_scope_record = None + self.in_scope_before = None + self.out_of_scope_before = None + + def pair(self, in_scope_step, out_of_scope_step): + """Request a retirement for each account under the one name, standing at these steps.""" + # The record the policy leaves out is requested first, so that it is + # the one a resolution that reads every record held settles on. + self.out_of_scope_record = self.retirement_in(out_of_scope_step, user=self.out_of_scope) + self.in_scope_record = self.retirement_in(in_scope_step, user=self.in_scope) + UserRetirementStatus.objects.filter( + id__in=[self.out_of_scope_record.id, self.in_scope_record.id], + ).update(original_username=self.SHARED_NAME) + self.in_scope_before = self.account(self.in_scope) + self.out_of_scope_before = self.account(self.out_of_scope) + + def account(self, user): + """Return the stored values the core erasure writes over.""" + user.refresh_from_db() + profile = UserProfile.objects.get(user=user) + return { + "username": user.username, + "email": user.email, + "is_active": user.is_active, + "profile_name": profile.name, + "profile_city": profile.city, + } + + @contextlib.contextmanager + def policy_keeping(self, record): + """Serve the next requests under a policy that keeps this record alone.""" + with mock.patch.object( + _RetirementRecordAccessMixin, "scoping_policy", _NarrowingPolicy("id", record.id), + ): + yield + + def erase(self): + """Ask for the core personal data of the account this name addresses to be erased.""" + return self.client.post(self.url, {"scope": "lms_core"}, format="json") + + def test_the_step_the_record_the_policy_keeps_stands_at_refuses_the_erasure(self): + """The other record admits work, which says nothing about the account being acted on.""" + self.pair(in_scope_step="PENDING", out_of_scope_step="RETIRING_LMS") + self.login("worker") + with self.policy_keeping(self.in_scope_record): + answered = self.erase() + # What survives is read first, so that a failure says what was destroyed. + assert self.account(self.in_scope) == self.in_scope_before + assert self.account(self.out_of_scope) == self.out_of_scope_before + envelope = assert_error_envelope( + answered, + expected_status=status.HTTP_409_CONFLICT, + expected_type_slug=STATE_CONFLICT_SLUG, + ) + assert envelope["detail"] == view_services.NOT_OPEN_TO_ACTION + + def test_the_step_the_record_the_policy_keeps_stands_at_admits_the_erasure(self): + """The other record admits no work, which says nothing about the account being acted on.""" + self.pair(in_scope_step="RETIRING_LMS", out_of_scope_step="COMPLETE") + self.login("worker") + with self.policy_keeping(self.in_scope_record): + answered = self.erase() + # What was erased is read first, so that a failure says what was left. + assert self.account(self.in_scope) == { + "username": self.in_scope_record.retired_username, + "email": self.in_scope_record.retired_email, + "is_active": False, + "profile_name": "", + "profile_city": None, + } + assert self.account(self.out_of_scope) == self.out_of_scope_before + assert answered.status_code == status.HTTP_204_NO_CONTENT + + def test_the_account_erased_is_the_one_whose_record_the_policy_keeps(self): + """Both records admit work, so what decides is which of them the name addresses here.""" + self.pair(in_scope_step="RETIRING_LMS", out_of_scope_step="RETIRING_LMS") + self.login("worker") + with self.policy_keeping(self.in_scope_record): + assert self.erase().status_code == status.HTTP_204_NO_CONTENT + assert self.account(self.in_scope)["username"] == self.in_scope_record.retired_username + assert self.account(self.out_of_scope) == self.out_of_scope_before + + def test_the_policy_this_deployment_runs_refuses_the_name_and_erases_nothing(self): + """It keeps the records whole, so the name is carried by two of them and addresses neither.""" + self.pair(in_scope_step="PENDING", out_of_scope_step="RETIRING_LMS") + self.login("worker") + envelope = assert_error_envelope( + self.erase(), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug=AMBIGUOUS_NAME_SLUG, + ) + assert envelope["detail"] == view_services.NAME_IS_AMBIGUOUS + assert self.account(self.in_scope) == self.in_scope_before + assert self.account(self.out_of_scope) == self.out_of_scope_before + + +@skip_unless_lms +class PartnerQueueRecordVisibilityTests(RetirementApiTestCase): + """The retirement a queue write resolves is narrowed by the policy this collection declares.""" + + def setUp(self): + super().setUp() + self.in_scope = self.retirement_in("ADDING_TO_PARTNER_QUEUE", user=self.subject) + self.out_of_scope = self.retirement_in("ADDING_TO_PARTNER_QUEUE", user=self.learner) + for user in (self.subject, self.learner): + CourseEnrollmentFactory.create(user=user, course_id=COURSE_KEY) + self.policy = _NarrowingPolicy("original_username", self.subject.username) + + def queue(self, username): + """Ask for the retiring account this name addresses to be queued for partner reporting.""" + return self.client.put( + reverse("user_v2:retirement_partner_report_detail", kwargs={"username": username}), + {}, + format="json", + ) + + def queued_names(self): + """Return the names every entry of the partner report queue carries.""" + return set( + UserRetirementPartnerReportingStatus.objects.values_list("original_username", flat=True), + ) + + def test_a_record_the_policy_leaves_out_is_not_queued_for_partner_reporting(self): + self.login("worker") + with mock.patch.object(_PartnerReportAccessMixin, "scoping_policy", self.policy): + answered = self.queue(self.learner.username) + # What the queue holds is read first, so that a failure names whose + # personal data a partner would have been handed. + assert self.queued_names() == set() + envelope = assert_error_envelope( + answered, + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + assert envelope["detail"] == view_services.NO_SUCH_RETIREMENT + + def test_the_record_the_policy_keeps_is_queued(self): + self.login("worker") + with mock.patch.object(_PartnerReportAccessMixin, "scoping_policy", self.policy): + answered = self.queue(self.subject.username) + assert answered.status_code == status.HTTP_204_NO_CONTENT + assert self.queued_names() == {self.subject.username} + + def test_the_policy_is_given_the_caller_and_the_retirement_records_it_resolves_from(self): + self.login("worker") + with mock.patch.object(_PartnerReportAccessMixin, "scoping_policy", self.policy): + self.queue(self.subject.username) + assert [caller.username for caller in self.policy.subjects] == [WORKER_USERNAME] + assert self.policy.offered == [ + sorted([self.subject.username, self.learner.username]), + ] + + def test_the_policy_this_deployment_runs_leaves_every_record_queueable(self): + self.login("worker") + assert self.queue(self.learner.username).status_code == status.HTTP_204_NO_CONTENT + assert self.queued_names() == {self.learner.username} + + +class RetirementErrorCatalogTests(SimpleTestCase): + """What this area adds to the shared error catalog, and what it leaves alone.""" + + def test_the_state_refusal_of_this_area_is_named_for_this_area(self): + assert classify_error(RetirementStateConflict()) == ( + "user/retirement-state-conflict", "Retirement State Conflict", + ) + + def test_another_area_refusing_a_conflict_is_classified_as_it_was_before(self): + """Registering this area's refusal reclassifies nothing for any other view in the process.""" + assert classify_error(Conflict()) == ("conflict", "Conflict") + + def test_the_refusal_of_an_ambiguous_name_is_named_for_this_area(self): + assert classify_error(AmbiguousAccountName()) == ( + "user/ambiguous-account-name", "Ambiguous Account Name", + ) + + def test_another_area_refusing_an_invalid_request_is_classified_as_it_was_before(self): + """The registry is shared, and this area registers a subclass of a classified error.""" + assert classify_error(ValidationError("anything")) == ("validation", "Validation Error") + + def test_a_refusal_naming_no_records_carries_no_per_record_member(self): + assert not hasattr(RetirementStateConflict("refused"), "extension_members") + + +class RetirementPageSizeTests(SimpleTestCase): + """The page sizes the collections of this version are cut to.""" + + def test_the_default_and_the_maximum_page_are_the_sizes_this_version_publishes(self): + assert UserApiPagination.page_size == DEFAULT_PAGE_SIZE + assert UserApiPagination.max_page_size == MAXIMUM_PAGE_SIZE + assert RetirementViewSet.pagination_class is UserApiPagination + + +class RetirementReadCaptureCase(RetirementApiTestCase): + """ + What a read of the retirement addresses does, measured statement by statement. + + Three channels are captured, because a read that changes stored data need + not do so itself: the statements it ran, the callbacks it deferred to the + end of its transaction, and the signals it sent. A signal is captured + because the platform's own listeners act on one whether or not the read + that sent it wrote anything, and the pipeline announces each of its steps + that way. + """ + + def setUp(self): + super().setUp() + self.retirement = self.retirement_in("LOCKING_ACCOUNT", user=self.subject) + self.list_url = reverse("user_v2:retirement_list") + self.detail_url = reverse("user_v2:retirement_detail", kwargs={"username": "subject"}) + self.deferred = [] + self.signalled = [] + + def captured_writes(self, method, url, **params): + """Return the statements a read makes that would change the data this API serves.""" + self.login("worker") + with CaptureQueriesContext(connection) as captured, \ + captured_signals() as signalled, \ + self.captureOnCommitCallbacks(execute=True) as callbacks: + response = getattr(self.client, method)(url, params) + assert response.status_code == status.HTTP_200_OK, response.content + self.deferred = callbacks + self.signalled = signalled + return write_queries(captured.captured_queries) + + def targets(self, statements): + """Return the table each statement writes to.""" + return [statement.split('"')[1] for statement in statements] + + +@skip_unless_lms +class RetirementReadsAreNotWritesTests(RetirementReadCaptureCase): + """Reading the queue or one record changes nothing, whichever way it is read.""" + + def assert_nothing_was_changed(self, method, url, **params): + """Assert the read wrote nothing, deferred nothing and announced nothing.""" + statements = self.captured_writes(method, url, **params) + assert (statements, self.deferred, self.signalled) == ([], [], []) + + def test_reading_the_queue_announces_nothing_and_defers_nothing(self): + self.assert_nothing_was_changed("get", self.list_url) + + def test_reading_one_record_announces_nothing_and_defers_nothing(self): + self.assert_nothing_was_changed("get", self.detail_url) + + def test_reading_the_queue_without_a_body_announces_nothing_either(self): + self.assert_nothing_was_changed("head", self.list_url) + + def test_reading_one_record_without_a_body_announces_nothing_either(self): + self.assert_nothing_was_changed("head", self.detail_url) + + def test_reading_the_queue_writes_nothing(self): + assert self.captured_writes("get", self.list_url) == [] + + def test_reading_the_queue_without_a_body_writes_nothing(self): + assert self.captured_writes("head", self.list_url) == [] + + def test_reading_one_record_writes_nothing(self): + assert self.captured_writes("get", self.detail_url) == [] + + def test_reading_one_record_without_a_body_writes_nothing(self): + assert self.captured_writes("head", self.detail_url) == [] + + def test_reading_the_queue_with_every_filter_applied_writes_nothing(self): + assert self.captured_writes( + "get", + self.list_url, + state="LOCKING_ACCOUNT", + cool_off_days=0, + created_after="2020-01-01", + created_before="2099-01-01", + ordering="created", + view="minimal", + ) == [] + + +@skip_unless_lms +class RetirementReadsWithTheLanguageCookieTests(RetirementReadCaptureCase): + """ + A read carrying the language cookie stores the language it carries. + + The platform keeps the language a user picked in a cookie and syncs it into + that user's preferences from middleware, which runs before any view and on + every method, so it is reached by a read of these addresses as it is by a + read of any other. It is the one write a read of this area makes, and it + belongs to the platform rather than to this version: the addresses this + version supersedes make the same write, under the same cookie. The capture + below pins it -- the one row, by the table it is stored in, the one change + it announces, and nothing else. + """ + + def setUp(self): + super().setUp() + self.client.cookies[LANGUAGE_COOKIE] = "eo" + + def language_rows(self): + """Return the stored language of every account, by account.""" + return { + preference.user.username: preference.value + for preference in UserPreference.objects.filter(key=LANGUAGE_PREFERENCE) + } + + def assert_only_the_language_was_stored(self, method, url, **params): + """Assert the one write made, and the one change announced, was the caller's language.""" + statements = self.captured_writes(method, url, **params) + assert (self.targets(statements), self.deferred) == ([PREFERENCE_TABLE], []), statements + assert self.language_rows() == {WORKER_USERNAME: "eo"} + assert self.signalled == LANGUAGE_SYNC_SIGNALS + + def test_reading_the_queue_stores_the_language_the_cookie_carries(self): + assert self.language_rows() == {} + self.assert_only_the_language_was_stored("get", self.list_url) + + def test_reading_one_record_stores_it_too(self): + self.assert_only_the_language_was_stored("get", self.detail_url) + + def test_reading_the_queue_without_a_body_stores_it_as_well(self): + """A write one frame below the handler is reached by every method, this one included.""" + self.assert_only_the_language_was_stored("head", self.list_url) + + def test_reading_one_record_without_a_body_stores_it_as_well(self): + self.assert_only_the_language_was_stored("head", self.detail_url) + + def test_reading_the_queue_with_every_filter_applied_stores_only_that(self): + self.assert_only_the_language_was_stored( + "get", + self.list_url, + state="LOCKING_ACCOUNT", + cool_off_days=0, + created_after="2020-01-01", + created_before="2099-01-01", + ordering="created", + view="minimal", + ) + + def test_the_address_this_version_supersedes_stores_the_same_row(self): + """The write belongs to the platform rather than to either version of this area.""" + self.login("worker") + with CaptureQueriesContext(connection) as captured: + response = self.client.get( + EARLIER_RETIREMENT_QUEUE, {"cool_off_days": 0, "states": "LOCKING_ACCOUNT"}, + ) + assert response.status_code == status.HTTP_200_OK, response.content + assert self.targets(write_queries(captured.captured_queries)) == [PREFERENCE_TABLE] + assert self.language_rows() == {WORKER_USERNAME: "eo"} + + def test_the_row_is_written_once_and_not_once_a_request(self): + """It is the language being synced, not the read recording anything.""" + self.assert_only_the_language_was_stored("get", self.list_url) + statements = self.captured_writes("get", self.list_url) + assert (statements, self.deferred, self.signalled) == ([], [], []) + + +@skip_unless_lms +class NameRequestedTwiceTests(RetirementApiTestCase): + """ + A name more than one record was requested under is refused, and nothing is acted on. + + The name a record keeps of the account it is for is indexed but not + unique, and in some deployments stored names are compared without regard + to case, which once let a learner register a retired name again by + changing its capitalisation. Both records of such a pair are built + directly here: registering a second account under a name a record holds is + refused a frame below this API, so the state cannot be reached through + registration, and deployments hold it from before that refusal existed. + + Every operation that finds its record by that name refuses the request, + rather than acting on whichever of the two the database offered last. + """ + + def setUp(self): + super().setUp() + self.detail_url = reverse("user_v2:retirement_detail", kwargs={"username": "subject"}) + self.erasure_url = reverse("user_v2:retirement_erasure", kwargs={"username": "subject"}) + self.queue_url = reverse( + "user_v2:retirement_partner_report_detail", kwargs={"username": "subject"}, + ) + self.cleanup_url = reverse("user_v2:retirement_cleanup_list") + self.partner_cleanup_url = reverse("user_v2:retirement_partner_report_cleanup_list") + + def two_retirements_under_one_name(self, state_name): + """Return two retirement records requested under the one name ``subject``.""" + held = self.retirement_in(state_name, user=self.subject) + twin = self.retirement_in(state_name, user=UserFactory.create(username="subject-twin")) + UserRetirementStatus.objects.filter(id=twin.id).update(original_username="subject") + return held, twin + + def two_partner_reports_under_one_name(self, taken=True): + """Return two partner report entries registered under the one name ``subject``.""" + entries = [ + UserRetirementPartnerReportingStatus.objects.create( + user=user, + original_username=user.username, + original_email=user.email, + original_name=user.profile.name, + is_being_processed=taken, + ) + for user in (self.subject, UserFactory.create(username="subject-twin")) + ] + UserRetirementPartnerReportingStatus.objects.filter(id=entries[1].id).update( + original_username="subject", + ) + return entries + + def assert_the_name_was_refused(self, response): + """Assert the answer is the refusal of an ambiguous name, and return its envelope.""" + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug=AMBIGUOUS_NAME_SLUG, + ) + assert envelope["detail"] == view_services.NAME_IS_AMBIGUOUS + assert envelope["instance"] == response.request["PATH_INFO"] + return envelope + + def test_a_refusal_that_names_one_account_says_so_about_the_request_as_a_whole(self): + """One name is refused at the address that carries it, so there is no record to key it to.""" + self.two_retirements_under_one_name("LOCKING_ACCOUNT") + self.login("worker") + envelope = self.assert_the_name_was_refused( + self.client.patch( + self.detail_url, {"new_state": "LOCKING_COMPLETE", "response": "done"}, format="json", + ), + ) + assert envelope["errors"] == {"non_field_errors": [view_services.NAME_IS_AMBIGUOUS]} + + def test_moving_the_retirement_is_refused_and_neither_record_moves(self): + held, twin = self.two_retirements_under_one_name("LOCKING_ACCOUNT") + self.login("worker") + self.assert_the_name_was_refused( + self.client.patch( + self.detail_url, {"new_state": "LOCKING_COMPLETE", "response": "done"}, format="json", + ), + ) + for record in (held, twin): + record.refresh_from_db() + assert record.current_state.state_name == "LOCKING_ACCOUNT" + + def test_cancelling_is_refused_and_neither_request_is_undone(self): + held, twin = self.two_retirements_under_one_name("PENDING") + self.login("canceller") + self.assert_the_name_was_refused(self.client.delete(self.detail_url)) + assert set(UserRetirementStatus.objects.values_list("id", flat=True)) == {held.id, twin.id} + assert UserRetirementRequest.objects.count() == 2 + + def test_erasing_is_refused_and_nothing_is_erased(self): + self.two_retirements_under_one_name("RETIRING_LMS") + self.subject.profile.city = "Somewhere" + self.subject.profile.save() + self.login("worker") + self.assert_the_name_was_refused( + self.client.post(self.erasure_url, {"scope": "lms_core"}, format="json"), + ) + self.subject.refresh_from_db() + assert (self.subject.username, self.subject.email) == ("subject", "subject@example.com") + assert self.subject.is_active is True + assert UserProfile.objects.get(user=self.subject).city == "Somewhere" + + def test_queueing_for_partner_reporting_is_refused_and_nothing_is_queued(self): + self.two_retirements_under_one_name("RETIRING_LMS") + CourseEnrollmentFactory.create(user=self.subject, course_id=COURSE_KEY) + self.login("worker") + self.assert_the_name_was_refused(self.client.put(self.queue_url, {}, format="json")) + assert UserRetirementPartnerReportingStatus.objects.count() == 0 + + def test_the_batch_disposal_is_refused_whole_and_nothing_is_deleted(self): + held, twin = self.two_retirements_under_one_name("COMPLETE") + self.login("worker") + envelope = self.assert_the_name_was_refused( + self.client.post(self.cleanup_url, {"usernames": ["subject"]}, format="json"), + ) + assert envelope["errors"] == {"subject": [view_services.SEVERAL_RECORDS_HOLD_THIS_NAME]} + for record in (held, twin): + record.refresh_from_db() + assert record.original_username == "subject" + assert record.original_email != view_services.REDACTED_EMAIL + + def test_a_batch_naming_it_alongside_a_name_held_once_deletes_neither(self): + """A batch is acted on whole or not at all, so one ambiguous name holds the rest back.""" + held, twin = self.two_retirements_under_one_name("COMPLETE") + alone = self.retirement_in("COMPLETE", user=UserFactory.create(username="held-once")) + self.login("worker") + self.assert_the_name_was_refused( + self.client.post( + self.cleanup_url, {"usernames": ["subject", "held-once"]}, format="json", + ), + ) + assert set(UserRetirementStatus.objects.values_list("id", flat=True)) == { + held.id, twin.id, alone.id, + } + + def test_the_earlier_batch_disposal_refuses_the_same_name(self): + """The address this one replaces refuses it too, and deletes nothing.""" + held, twin = self.two_retirements_under_one_name("COMPLETE") + self.login("worker") + response = self.client.post( + EARLIER_RETIREMENT_CLEANUP, {"usernames": ["subject"]}, format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST, response.content + assert set(UserRetirementStatus.objects.values_list("id", flat=True)) == {held.id, twin.id} + + def test_the_partner_report_disposal_is_refused_whole_and_nothing_is_deleted(self): + entries = self.two_partner_reports_under_one_name() + self.login("worker") + envelope = self.assert_the_name_was_refused( + self.client.post(self.partner_cleanup_url, {"usernames": ["subject"]}, format="json"), + ) + assert envelope["errors"] == {"subject": [view_services.SEVERAL_RECORDS_HOLD_THIS_NAME]} + assert set( + UserRetirementPartnerReportingStatus.objects.values_list("id", flat=True), + ) == {entry.id for entry in entries} + + def test_the_earlier_partner_report_disposal_refuses_the_same_name(self): + entries = self.two_partner_reports_under_one_name() + self.login("worker") + response = self.client.post( + EARLIER_PARTNER_REPORT_CLEANUP, [{"original_username": "subject"}], format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST, response.content + assert set( + UserRetirementPartnerReportingStatus.objects.values_list("id", flat=True), + ) == {entry.id for entry in entries} + + def test_reading_the_record_of_the_account_addressed_still_answers(self): + """A read addresses the account rather than the name a record keeps, so it is unambiguous.""" + held, _twin = self.two_retirements_under_one_name("LOCKING_ACCOUNT") + self.login("worker") + response = self.client.get(self.detail_url) + assert response.status_code == status.HTTP_200_OK, response.content + assert response.json()["id"] == held.id + + def test_the_queue_carries_both_records(self): + """Nothing is hidden by the refusal: the page answers with every record it holds.""" + held, twin = self.two_retirements_under_one_name("LOCKING_ACCOUNT") + self.login("worker") + response = self.client.get(reverse("user_v2:retirement_list")) + assert [row["id"] for row in response.json()["results"]] == [held.id, twin.id] + + +@skip_unless_lms +class RetirementSelfDescriptionTests(RetirementApiTestCase): + """ + What each address of this pipeline says about itself to a caller that asks. + + The description a view class carries is what an OPTIONS request is + answered with, so it is published text: a client and a documentation + browser read it beside the description of the same address in the schema. + These hold two of them to being the text the class carries, which the + checks on that text elsewhere hold to being written for the caller. + """ + + def test_the_queue_answers_with_the_description_it_carries(self): + self.login("worker") + answer = self.client.options(reverse("user_v2:retirement_list")) + assert answer.status_code == status.HTTP_200_OK + assert answer.json()["description"] == RetirementViewSet().get_view_description() + assert answer.json()["description"].startswith( + "The accounts being retired, addressed by the name each was registered under.", + ) + + def test_the_disposal_answers_with_the_description_it_carries(self): + self.login("worker") + answer = self.client.options(reverse("user_v2:retirement_cleanup_list")) + assert answer.status_code == status.HTTP_200_OK + assert answer.json()["description"] == RetirementCleanupViewSet().get_view_description() + assert answer.json()["description"].startswith( + "Disposes of a set of finished retirement records.", + ) + + +#: The view classes this pipeline is served by, read from the module that +#: routes them so that an address mounted later is covered by the sweep below. +RETIREMENT_VIEW_CLASSES = ( + RetirementViewSet, + RetirementPartnerReportViewSet, + RetirementCleanupViewSet, + RetirementPartnerReportCleanupViewSet, +) + + +def mounted_retirement_operations(): + """Return every address of this pipeline with the methods it serves, by the name it is routed under.""" + return { + (entry.name, method) + for entry in v2_urls.urlpatterns + if getattr(entry.callback, "cls", None) in RETIREMENT_VIEW_CLASSES + for method in getattr(entry.callback, "actions", {}) + } + + +@skip_unless_lms +class UnsatisfiableAcceptTests(RetirementApiTestCase): + """ + What each address of this pipeline answers a request it can give no answer to. + + Which representations an answer may be given in is settled as the request + enters, before the record it names is read or written, so the refusal + reaches an address that answers with a body and one that answers with none + alike. Every operation here publishes that refusal, and these are the + measurements it rests on: each address answers it, and the records each of + them would otherwise have acted on -- including the ones two of them + delete and the account data one of them erases -- are left as they were. + """ + + UNANSWERABLE = "text/csv" + + def setUp(self): + super().setUp() + self.finished = self.retirement_in("COMPLETE", user=self.subject) + self.working = self.retirement_in( + "RETIRING_LMS", user=UserFactory.create(username="working-subject"), + ) + self.pending = self.retirement_in( + "PENDING", user=UserFactory.create(username="pending-subject"), + ) + self.subject.profile.city = "Somewhere" + self.subject.profile.save() + self.entry = UserRetirementPartnerReportingStatus.objects.create( + user=self.working.user, + original_username=self.working.user.username, + original_email=self.working.user.email, + original_name=self.working.user.profile.name, + is_being_processed=True, + ) + + def requests(self): + """Return a request to every method of every address, by the caller that address admits.""" + return { + ("retirement_list", "get"): ("worker", reverse("user_v2:retirement_list"), None), + ("retirement_detail", "get"): ( + "worker", reverse("user_v2:retirement_detail", kwargs={"username": "subject"}), None, + ), + # Both reads are mounted for a request that asks for the answer + # without its body as well, and the refusal is settled before + # either is read, so each is sent that request too. + ("retirement_list", "head"): ("worker", reverse("user_v2:retirement_list"), None), + ("retirement_detail", "head"): ( + "worker", reverse("user_v2:retirement_detail", kwargs={"username": "subject"}), None, + ), + ("retirement_detail", "patch"): ( + "worker", + reverse("user_v2:retirement_detail", kwargs={"username": "working-subject"}), + {"new_state": "RETIRING_LMS_COMPLETE", "response": "done"}, + ), + ("retirement_detail", "delete"): ( + "canceller", + reverse("user_v2:retirement_detail", kwargs={"username": "pending-subject"}), + None, + ), + ("retirement_erasure", "post"): ( + "worker", + reverse("user_v2:retirement_erasure", kwargs={"username": "working-subject"}), + {"scope": "lms_core"}, + ), + ("retirement_cleanup_list", "post"): ( + "worker", reverse("user_v2:retirement_cleanup_list"), {"usernames": ["subject"]}, + ), + ("retirement_partner_report_list", "post"): ( + "worker", reverse("user_v2:retirement_partner_report_list"), None, + ), + ("retirement_partner_report_detail", "put"): ( + "worker", + reverse( + "user_v2:retirement_partner_report_detail", + kwargs={"username": "working-subject"}, + ), + {}, + ), + ("retirement_partner_report_cleanup_list", "post"): ( + "worker", + reverse("user_v2:retirement_partner_report_cleanup_list"), + {"usernames": ["working-subject"]}, + ), + } + + def send_each(self): + """Send every request of the table asking for a representation none can answer in.""" + answered = {} + for (name, method), (caller, url, body) in self.requests().items(): + self.client.logout() + self.login(caller) + send = getattr(self.client, method) + if body is None: + response = send(url, HTTP_ACCEPT=self.UNANSWERABLE) + else: + response = send(url, body, format="json", HTTP_ACCEPT=self.UNANSWERABLE) + answered[(name, method)] = response.status_code + return answered + + def test_every_address_of_this_pipeline_is_covered_here(self): + """A method mounted later is sent one of these requests, or this fails.""" + assert set(self.requests()) == mounted_retirement_operations() + + def test_each_address_refuses_an_accept_it_cannot_satisfy(self): + assert self.send_each() == { + address: status.HTTP_406_NOT_ACCEPTABLE for address in self.requests() + } + + def test_the_refusal_comes_back_in_the_body_this_version_answers_with(self): + """The refusal is readable even though the caller asked for a representation it is not in.""" + self.login("worker") + assert_error_envelope( + self.client.get(reverse("user_v2:retirement_list"), HTTP_ACCEPT=self.UNANSWERABLE), + expected_status=status.HTTP_406_NOT_ACCEPTABLE, + expected_type_slug=NOT_ACCEPTABLE_SLUG, + ) + + def test_nothing_is_moved_erased_or_deleted_on_the_way_to_the_refusal(self): + self.send_each() + assert set(UserRetirementStatus.objects.values_list("id", flat=True)) == { + self.finished.id, self.working.id, self.pending.id, + } + assert UserRetirementRequest.objects.count() == 3 + for record, state_name in ( + (self.finished, "COMPLETE"), + (self.working, "RETIRING_LMS"), + (self.pending, "PENDING"), + ): + record.refresh_from_db() + assert record.current_state.state_name == state_name + assert record.original_email != view_services.REDACTED_EMAIL + self.subject.refresh_from_db() + assert (self.subject.username, self.subject.email) == ("subject", "subject@example.com") + assert self.subject.is_active is True + assert UserProfile.objects.get(user=self.subject).city == "Somewhere" + assert set( + UserRetirementPartnerReportingStatus.objects.values_list("id", flat=True), + ) == {self.entry.id} + self.entry.refresh_from_db() + assert self.entry.is_being_processed is True + + +@skip_unless_lms +class FormEncodedBodyTests(RetirementApiTestCase): + """ + A body sent as a web form is refused wherever a body is read. + + These operations read a body as JSON alone, as the operations they replace + do. The narrowing is kept rather than widened to every media type the + platform can parse because one of them erases personal data for good and + cannot be undone, and a form-encoded request is one a page on another site + can be made to send. + + The operations that read no body are unaffected, which is why the + narrowing is read from the view rather than applied to each method: a + method that never reaches the body never reaches the parser either. + """ + + FORM = "application/x-www-form-urlencoded" + + def setUp(self): + super().setUp() + self.retirement = self.retirement_in("RETIRING_LMS", user=self.subject) + self.detail_url = reverse("user_v2:retirement_detail", kwargs={"username": "subject"}) + self.erasure_url = reverse("user_v2:retirement_erasure", kwargs={"username": "subject"}) + + def assert_the_body_was_unreadable(self, response): + """Assert the answer names the media type as one this endpoint cannot read.""" + assert_error_envelope( + response, + expected_status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + expected_type_slug=UNSUPPORTED_MEDIA_TYPE_SLUG, + ) + + def test_a_form_body_is_refused_rather_than_erasing_the_account(self): + self.subject.profile.city = "Somewhere" + self.subject.profile.save() + self.login("worker") + self.assert_the_body_was_unreadable( + self.client.post(self.erasure_url, "scope=lms_core", content_type=self.FORM), + ) + self.subject.refresh_from_db() + assert (self.subject.username, self.subject.email) == ("subject", "subject@example.com") + assert UserProfile.objects.get(user=self.subject).city == "Somewhere" + + def test_the_erasure_this_one_replaces_refuses_the_same_body(self): + """Both versions answer the same, which is what keeps a caller's request refused.""" + self.login("worker") + response = self.client.post( + EARLIER_CORE_ERASURE, "username=subject", content_type=self.FORM, + ) + assert response.status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, response.content + self.subject.refresh_from_db() + assert self.subject.username == "subject" + + def test_a_form_body_is_refused_when_moving_a_retirement(self): + """The body names a step this deployment runs, so only its media type can refuse it.""" + self.login("worker") + self.assert_the_body_was_unreadable( + self.client.patch( + self.detail_url, "new_state=LMS_COMPLETE&response=done", + content_type=self.FORM, + ), + ) + self.retirement.refresh_from_db() + assert self.retirement.current_state.state_name == "RETIRING_LMS" + + def test_a_form_body_is_refused_by_each_batch_disposal(self): + self.login("worker") + for name in ( + "user_v2:retirement_cleanup_list", + "user_v2:retirement_partner_report_cleanup_list", + ): + with self.subTest(address=name): + self.assert_the_body_was_unreadable( + self.client.post(reverse(name), "usernames=subject", content_type=self.FORM), + ) + + def test_taking_partner_reports_reads_no_body_and_is_answered(self): + """It takes what is queued and reads nothing from the request, as the earlier one does.""" + self.login("worker") + taken = self.client.post( + reverse("user_v2:retirement_partner_report_list"), "anything=at-all", + content_type=self.FORM, + ) + earlier = self.client.post(EARLIER_PARTNER_REPORT, "anything=at-all", content_type=self.FORM) + assert (taken.status_code, earlier.status_code) == ( + status.HTTP_200_OK, status.HTTP_200_OK, + ), (taken.content, earlier.content) + + def test_cancelling_is_answered_whatever_media_type_is_sent(self): + """It names the retirement in the address and reads no body, so no parser is reached.""" + self.retirement_in("PENDING", user=self.learner) + self.login("canceller") + response = self.client.delete( + reverse("user_v2:retirement_detail", kwargs={"username": "learner-caller"}), + "nothing=here", + content_type=self.FORM, + ) + assert response.status_code == status.HTTP_204_NO_CONTENT, response.content + + def test_queueing_for_partner_reporting_is_answered_whatever_media_type_is_sent(self): + """The name is in the address, where the earlier version read it from the body.""" + CourseEnrollmentFactory.create(user=self.subject, course_id=COURSE_KEY) + self.login("worker") + response = self.client.put( + reverse("user_v2:retirement_partner_report_detail", kwargs={"username": "subject"}), + "nothing=here", + content_type=self.FORM, + ) + assert response.status_code == status.HTTP_204_NO_CONTENT, response.content + assert UserRetirementPartnerReportingStatus.objects.count() == 1 + + +@skip_unless_lms +class UnservedMethodTests(RetirementApiTestCase): + """ + A method an address does not serve is answered as not served, not as refused. + + Which caller may make a request is decided before the method is + dispatched, and at an address whose capability follows the operation there + is no operation to follow for a method it does not serve. The answer is + then the dispatcher's own: the methods the address does serve, and nothing + about a capability. A caller who is not signed in is still told to + authenticate, because being signed in is asked of every method alike. + """ + + #: A method each retirement address of this version does not serve, and the + #: methods it does. The second group's addresses guard every method with + #: the same classes, so they answer a capability refusal before this one. + PER_OPERATION = ( + ("post", "user_v2:retirement_detail", {"username": "subject"}), + ("put", "user_v2:retirement_detail", {"username": "subject"}), + ("delete", "user_v2:retirement_list", None), + ("patch", "user_v2:retirement_list", None), + ("get", "user_v2:retirement_erasure", {"username": "subject"}), + ("get", "user_v2:retirement_cleanup_list", None), + ) + PER_VIEW = ( + ("get", "user_v2:retirement_partner_report_list", None), + ("get", "user_v2:retirement_partner_report_detail", {"username": "subject"}), + ("get", "user_v2:retirement_partner_report_cleanup_list", None), + ) + + #: What each address whose capability follows the operation serves, as its + #: refusal reports it. + SERVED = { + "user_v2:retirement_list": {"GET", "HEAD", "OPTIONS"}, + "user_v2:retirement_detail": {"GET", "HEAD", "PATCH", "DELETE", "OPTIONS"}, + "user_v2:retirement_erasure": {"POST", "OPTIONS"}, + "user_v2:retirement_cleanup_list": {"POST", "OPTIONS"}, + } + + def setUp(self): + super().setUp() + self.retirement_in("LOCKING_ACCOUNT", user=self.subject) + + def send(self, method, name, kwargs): + """Send a method the named address does not serve.""" + return getattr(self.client, method)(reverse(name, kwargs=kwargs)) + + def test_a_caller_holding_the_capability_is_told_the_method_is_not_served(self): + self.login("worker") + for method, name, kwargs in self.PER_OPERATION + self.PER_VIEW: + with self.subTest(method=method, address=name): + assert_error_envelope( + self.send(method, name, kwargs), + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug=REFUSED_METHOD_SLUG, + ) + + def test_the_refusal_names_the_methods_the_address_does_serve(self): + """The answer comes from the dispatcher, so it says what is served there.""" + self.login("worker") + for method, name, kwargs in self.PER_OPERATION: + with self.subTest(method=method, address=name): + response = self.send(method, name, kwargs) + assert set(response["Allow"].replace(" ", "").split(",")) == self.SERVED[name] + + def test_a_caller_without_the_capability_is_told_the_same_by_the_collection(self): + """There is no operation at that address for a capability to be asked for.""" + self.login("learner") + for method, name, kwargs in self.PER_OPERATION: + with self.subTest(method=method, address=name): + assert_error_envelope( + self.send(method, name, kwargs), + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug=REFUSED_METHOD_SLUG, + ) + + def test_a_caller_without_the_capability_is_refused_where_every_method_carries_one(self): + """These addresses ask for the capability whatever the method, so that answer comes first.""" + self.login("learner") + for method, name, kwargs in self.PER_VIEW: + with self.subTest(method=method, address=name): + assert_error_envelope( + self.send(method, name, kwargs), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_an_address_that_serves_one_method_refuses_the_same_caller_instead(self): + """Whoever may call is decided before the method is, so where every method carries the + same capability the caller without it is refused rather than told what is served.""" + self.login("learner") + assert self.client.put( + reverse("accounts_retirement_cleanup"), + ).status_code == status.HTTP_403_FORBIDDEN + assert_error_envelope( + self.send("put", "user_v2:retirement_detail", {"username": "subject"}), + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug=REFUSED_METHOD_SLUG, + ) + + def test_an_anonymous_caller_is_told_to_authenticate(self): + self.client.logout() + for method, name, kwargs in self.PER_OPERATION + self.PER_VIEW: + with self.subTest(method=method, address=name): + assert_error_envelope( + self.send(method, name, kwargs), + expected_status=status.HTTP_401_UNAUTHORIZED, + expected_type_slug="authn", + ) + + def test_an_action_the_mapping_does_not_name_still_reaches_no_capability(self): + """An action added later is refused; it is an unserved method that is not.""" + # Read from the declaration, so the answer does not depend on whether a + # request to this view earlier in the run let the middleware copy it. + with layers_read_from_their_own_declaration(RetirementViewSet): + viewset = RetirementViewSet() + viewset.action = "an_action_added_later" + granted = [ + (type(permission), permission.has_permission(mock.Mock(user=self.superuser), viewset)) + for permission in viewset.get_permissions() + ] + viewset.action = None + unserved = [type(permission) for permission in viewset.get_permissions()] + assert granted == [ + (permissions.IsAuthenticated, True), + (permissions.NOT, False), + (NotJwtRestrictedApplication, True), + ] + assert unserved == [permissions.IsAuthenticated, NotJwtRestrictedApplication] diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py new file mode 100644 index 000000000000..030f42c2e2d1 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py @@ -0,0 +1,2841 @@ +"""Tests for what version 2 of the User API publishes in the OpenAPI schema.""" + +import copy +import json +from importlib import import_module +from unittest import mock + +import jsonschema +import pytest +from django.conf import settings +from django.urls import reverse +from drf_spectacular.generators import SchemaGenerator +from drf_spectacular.plumbing import get_override +from drf_spectacular.settings import patched_settings +from rest_framework import serializers +from rest_framework.test import APIClient +from waffle.testutils import override_switch + +from common.djangoapps.student.models import PendingNameChange, UserProfile +from common.djangoapps.student.tests.factories import AccountRecoveryFactory, AdminFactory, UserFactory +from lms.djangoapps.verify_student.models import IDVerificationAttempt, VerificationAttempt +from lms.lib.spectacular import lms_mark_legacy_paths_deprecated +from openedx.core.apidocs import get_api_docs_settings +from openedx.core.djangoapps.profile_images.tests.helpers import make_image_file +from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration_context +from openedx.core.djangoapps.user_api.accounts.image_helpers import ( + get_profile_image_names, + get_profile_image_storage, +) +from openedx.core.djangoapps.user_api.accounts.utils import ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH +from openedx.core.djangoapps.user_api.models import UserRetirementPartnerReportingStatus +from openedx.core.djangoapps.user_api.schema import ( + RENAMED_OPERATION_IDS, + SUPERSEDED_USER_PATHS, + mark_superseded_user_paths, +) +from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory +from openedx.core.djangoapps.user_api.v2.serializers import ( + ACCOUNT_DEFAULT_SORT, + ACCOUNT_MINIMAL_FIELDS, + BATCH_OUTCOMES, + ERASURE_SCOPES, + EXPIRATION_DAY_PATTERN, + PREFERENCE_NAME_MAX_LENGTH, + AccountPreferenceChangesField, + AccountPreferenceDocumentField, + AccountQuerySerializer, + RetirementBatchSerializer, + TimeZoneQuerySerializer, + VerificationQuerySerializer, +) +from openedx.core.djangoapps.user_api.v2.view_services import claim_partner_reports +from openedx.core.djangoapps.user_api.v2.views import ( + PREFERENCE_KEY_PATTERN_PARTIAL, + RETIREMENT_SORT_FIELDS, +) +from openedx.core.djangolib.testing.utils import skip_unless_lms + +PASSWORD = "Password1234" +#: The site's API key, which the user directory and the stored preferences are read with. +API_KEY = "schema-test-api-key" + +#: Stands for a property a response does not carry, where that is the assertion. +ABSENT = "absent" + +ACCOUNT_LIST_PATH = "/api/user/v2/accounts/" +ACCOUNT_MEMBER_PATH = "/api/user/v2/accounts/{username}/" +ACCOUNT_DEACTIVATION_PATH = "/api/user/v2/accounts/{username}/deactivation/" +PROFILE_IMAGE_PATH = "/api/user/v2/accounts/{username}/profile_image/" +VERIFICATION_STATUS_PATH = "/api/user/v2/accounts/{username}/verification_status/" +VERIFICATION_LIST_PATH = "/api/user/v2/accounts/{username}/verifications/" +EMAIL_SEARCH_PATH = "/api/user/v2/email_searches/" +PREFERENCE_DOCUMENT_PATH = "/api/user/v2/accounts/{username}/preferences/" +PREFERENCE_NAME_PATH = "/api/user/v2/accounts/{username}/preferences/{key}/" +EMAIL_OPT_IN_PATH = "/api/user/v2/email_opt_ins/" + +#: What both preference addresses say the name in them is read as. +ACCOUNT_PREFERENCES_USERNAME_DESCRIPTION = ( + "Unique name of the user whose preferences are addressed. The name is read as it is stored, " + "so `me` here names an account holding that name rather than the account of the caller " + "making the request." +) +NAME_CHANGE_PATH = "/api/user/v2/name_changes/" +NAME_CHANGE_CONFIRMATION_PATH = "/api/user/v2/name_changes/{username}/confirmations/" + +USER_LIST_PATH = "/api/user/v2/users/" +USER_MEMBER_PATH = "/api/user/v2/users/{username}/" +PREFERENCE_LIST_PATH = "/api/user/v2/user_preferences/" +PREFERENCE_MEMBER_PATH = "/api/user/v2/user_preferences/{username},{key}/" +TIME_ZONE_LIST_PATH = "/api/user/v2/time_zones/" +RETIREMENT_LIST_PATH = "/api/user/v2/retirements/" +RETIREMENT_MEMBER_PATH = "/api/user/v2/retirements/{username}/" +RETIREMENT_ERASURE_PATH = "/api/user/v2/retirements/{username}/erasures/" +RETIREMENT_CLEANUP_PATH = "/api/user/v2/retirement_cleanups/" +PARTNER_REPORT_LIST_PATH = "/api/user/v2/retirement_partner_reports/" +PARTNER_REPORT_MEMBER_PATH = "/api/user/v2/retirement_partner_reports/{username}/" +PARTNER_REPORT_CLEANUP_PATH = "/api/user/v2/retirement_partner_report_cleanups/" + +#: The meaning published for the part of a retirement's address that carries +#: the name the account was registered under. +RETIREMENT_USERNAME_DESCRIPTION = ( + "Name the account being retired was registered under. A retired account is stored under a " + "hashed name, and either name addresses the same retirement when one is read; a write " + "addresses the registered name alone, spelled exactly as it was registered." +) +PARTNER_REPORT_USERNAME_DESCRIPTION = ( + "Name the account was registered under, spelled exactly as it was registered." +) + +#: The addresses that answer with a page of rows, and the ones that answer with +#: a single record. +LIST_PATHS = { + ACCOUNT_LIST_PATH, + VERIFICATION_LIST_PATH, + USER_LIST_PATH, + PREFERENCE_LIST_PATH, + TIME_ZONE_LIST_PATH, + RETIREMENT_LIST_PATH, +} +MEMBER_PATHS = { + ACCOUNT_MEMBER_PATH, + VERIFICATION_STATUS_PATH, + USER_MEMBER_PATH, + PREFERENCE_MEMBER_PATH, + PREFERENCE_DOCUMENT_PATH, + PREFERENCE_NAME_PATH, + RETIREMENT_MEMBER_PATH, + PARTNER_REPORT_MEMBER_PATH, +} + +#: The addresses that act on a resource rather than answer with one. +ACTION_PATHS = { + EMAIL_SEARCH_PATH, + EMAIL_OPT_IN_PATH, + ACCOUNT_DEACTIVATION_PATH, + PROFILE_IMAGE_PATH, + NAME_CHANGE_PATH, + NAME_CHANGE_CONFIRMATION_PATH, + RETIREMENT_ERASURE_PATH, + RETIREMENT_CLEANUP_PATH, + PARTNER_REPORT_LIST_PATH, + PARTNER_REPORT_CLEANUP_PATH, +} + +#: The addresses a generated client library is built from. The retirement +#: pipeline's are deliberately not among them: one service calls them by path +#: literal, and a library for irreversible destruction of personal data invites +#: a caller who has not read what the steps do. +SDK_PATHS = { + ACCOUNT_LIST_PATH, + ACCOUNT_MEMBER_PATH, + VERIFICATION_STATUS_PATH, + VERIFICATION_LIST_PATH, + PREFERENCE_DOCUMENT_PATH, + PREFERENCE_NAME_PATH, + EMAIL_SEARCH_PATH, + EMAIL_OPT_IN_PATH, + ACCOUNT_DEACTIVATION_PATH, + PROFILE_IMAGE_PATH, + NAME_CHANGE_PATH, + NAME_CHANGE_CONFIRMATION_PATH, +} + +VERSION_TWO_PATHS = LIST_PATHS | MEMBER_PATHS | ACTION_PATHS + +#: The addresses the retirement pipeline is served at. +RETIREMENT_PATHS = { + RETIREMENT_LIST_PATH, + RETIREMENT_MEMBER_PATH, + RETIREMENT_ERASURE_PATH, + RETIREMENT_CLEANUP_PATH, + PARTNER_REPORT_LIST_PATH, + PARTNER_REPORT_MEMBER_PATH, + PARTNER_REPORT_CLEANUP_PATH, +} + +PAGINATION_MEMBERS = {"count", "num_pages", "current_page", "start", "next", "previous", "results"} + +#: The prefix every mount serving a user address is configured under. +USER_MOUNT = "api/user/" + +#: The components this version contributes to the document. The two names +#: ending in ``Enum`` describe sets of choices and are configured in the +#: service's settings rather than derived from the fields carrying them, so +#: that another field of the same name cannot move them. +VERSION_TWO_COMPONENTS = ( + "Account", + "AccountExtendedField", + "AccountLanguage", + "AccountMinimal", + "AccountPreference", + "AccountPreferenceValue", + "AccountProfileImage", + "AccountProjection", + "AccountResponse", + "AccountSocialLink", + "EmailOptIn", + "EmailSearch", + "EmailSearchResult", + "ErrorResponse", + "NameChange", + "PaginatedAccountResponseList", + "PaginatedEmailSearchResultList", + "PaginatedVerificationList", + "PatchedAccountUpdate", + "PaginatedRetirementResponseList", + "Retirement", + "RetirementAccount", + "RetirementAccountProfile", + "RetirementBatch", + "RetirementBatchOutcome", + "RetirementBatchResult", + "RetirementBatchResultOutcomeEnum", + "RetirementErasure", + "RetirementErasureScopeEnum", + "RetirementMinimal", + "RetirementPartnerReportRecord", + "RetirementProjection", + "RetirementResponse", + "RetirementUpdate", + "PaginatedTimeZoneList", + "PaginatedUserDirectoryResponseList", + "PaginatedUserPreferenceRecordResponseList", + "ProfileImageUpload", + "TimeZone", + "UserDirectory", + "UserDirectoryMinimal", + "UserDirectoryProjection", + "UserDirectoryResponse", + "UserPreferenceRecord", + "UserPreferenceRecordMinimal", + "UserPreferenceRecordProjection", + "UserPreferenceRecordResponse", + "Verification", + "VerificationStatus", +) + +#: Every body these endpoints return: the published path it is documented +#: under, the address and parameters that produce it, and the component that +#: describes each record the body carries. +SHAPE_CASES = ( + ("/api/user/v2/users/", "/api/user/v2/users/", {}, "UserDirectory"), + ("/api/user/v2/users/", "/api/user/v2/users/", {"view": "minimal"}, "UserDirectoryMinimal"), + ("/api/user/v2/users/", "/api/user/v2/users/", {"fields": "username,email"}, "UserDirectoryProjection"), + ( + "/api/user/v2/users/", + "/api/user/v2/users/", + {"view": "minimal", "fields": "username"}, + "UserDirectoryProjection", + ), + ("/api/user/v2/users/{username}/", "/api/user/v2/users/shape-admin/", {}, "UserDirectory"), + ( + "/api/user/v2/users/{username}/", + "/api/user/v2/users/shape-admin/", + {"view": "minimal"}, + "UserDirectoryMinimal", + ), + ( + "/api/user/v2/users/{username}/", + "/api/user/v2/users/shape-admin/", + {"fields": "id,url"}, + "UserDirectoryProjection", + ), + ("/api/user/v2/user_preferences/", "/api/user/v2/user_preferences/", {}, "UserPreferenceRecord"), + ("/api/user/v2/user_preferences/", "/api/user/v2/user_preferences/", {"view": "full"}, "UserPreferenceRecord"), + ( + "/api/user/v2/user_preferences/", + "/api/user/v2/user_preferences/", + {"view": "minimal"}, + "UserPreferenceRecordMinimal", + ), + ( + "/api/user/v2/user_preferences/", + "/api/user/v2/user_preferences/", + {"fields": "key,value"}, + "UserPreferenceRecordProjection", + ), + ( + "/api/user/v2/user_preferences/{username},{key}/", + "/api/user/v2/user_preferences/shape-admin,pref-lang/", + {}, + "UserPreferenceRecord", + ), + ( + "/api/user/v2/user_preferences/{username},{key}/", + "/api/user/v2/user_preferences/shape-admin,pref-lang/", + {"view": "full"}, + "UserPreferenceRecord", + ), + ( + "/api/user/v2/user_preferences/{username},{key}/", + "/api/user/v2/user_preferences/shape-admin,pref-lang/", + {"view": "minimal"}, + "UserPreferenceRecordMinimal", + ), + ( + "/api/user/v2/user_preferences/{username},{key}/", + "/api/user/v2/user_preferences/shape-admin,pref-lang/", + {"fields": "value"}, + "UserPreferenceRecordProjection", + ), + ( + PREFERENCE_NAME_PATH, + "/api/user/v2/accounts/shape-admin/preferences/pref-lang/", + {}, + "AccountPreference", + ), + ("/api/user/v2/time_zones/", "/api/user/v2/time_zones/", {"country_code": "FR"}, "TimeZone"), + ("/api/user/v2/accounts/", "/api/user/v2/accounts/", {}, "Account"), + ("/api/user/v2/accounts/", "/api/user/v2/accounts/", {"view": "minimal"}, "AccountMinimal"), + ("/api/user/v2/accounts/", "/api/user/v2/accounts/", {"view": "shared"}, "Account"), + ( + "/api/user/v2/accounts/", + "/api/user/v2/accounts/", + {"fields": "username,email"}, + "AccountProjection", + ), + ("/api/user/v2/accounts/{username}/", "/api/user/v2/accounts/me/", {}, "Account"), + ("/api/user/v2/accounts/{username}/", "/api/user/v2/accounts/shape-admin/", {}, "Account"), + ( + "/api/user/v2/accounts/{username}/", + "/api/user/v2/accounts/shape-admin/", + {"view": "minimal"}, + "AccountMinimal", + ), + ( + "/api/user/v2/accounts/{username}/", + "/api/user/v2/accounts/shape-admin/", + {"fields": "username"}, + "AccountProjection", + ), +) + + +def as_json_schema(node): + """ + Return a published schema node as a JSON Schema document. + + The document describes an absent value with ``nullable``, which JSON + Schema spells as an alternative type, so a validator reading the published + node verbatim would reject a member the API is documented to omit. + """ + if isinstance(node, list): + return [as_json_schema(item) for item in node] + if not isinstance(node, dict): + return node + converted = {key: as_json_schema(value) for key, value in node.items() if key != "nullable"} + if node.get("nullable"): + return {"anyOf": [converted, {"type": "null"}]} + return converted + + +def published_ordering(schema, path): + """Return the sort parameter a list publishes.""" + return next( + published for published in schema["paths"][path]["get"]["parameters"] + if published["name"] == "ordering" + ) + + +def published_body_schema(schema, path): + """Return what a successful body of ``path`` must satisfy, components included.""" + body = schema["paths"][path]["get"]["responses"]["200"]["content"]["application/json"]["schema"] + return as_json_schema({**body, "components": schema["components"]}) + + +def published_request_schema(schema, path, method, media_type): + """Return what a request body of ``path`` must satisfy, components included.""" + body = schema["paths"][path][method]["requestBody"]["content"][media_type]["schema"] + return as_json_schema({**body, "components": schema["components"]}) + + +def published_component_schema(schema, name): + """Return one published component as a document a validator can read on its own.""" + return as_json_schema({ + "$ref": f"#/components/schemas/{name}", + "components": schema["components"], + }) + + +def records_of(body): + """Return the records a body carries, whether it is a page of them or a single one.""" + return body["results"] if "results" in body else [body] + + +def assert_records_match(schema, body, component, case=None): + """ + Assert every record of ``body`` is what the named component says it is. + + A body is checked against the one component that describes it rather than + against the alternatives its address may answer with, because any of those + alternatives accepts a body the others would reject, and a property no + alternative describes is as much a difference as one typed wrongly. + """ + validator = jsonschema.Draft202012Validator(published_component_schema(schema, component)) + published = set(schema["components"]["schemas"][component]["properties"]) + for record in records_of(body): + assert [error.message for error in validator.iter_errors(record)] == [], (case, component, record) + assert set(record) <= published, (case, component, set(record) - published) + +#: The version 1 addresses this change supersedes, written out independently of +#: the constant the hook reads so that changing either one shows up here. +SUPERSEDED_USER_ADDRESSES = { + "/api/user/v1/me": ACCOUNT_MEMBER_PATH, + "/api/user/v1/accounts": ACCOUNT_LIST_PATH, + "/api/user/v1/accounts/search_emails": EMAIL_SEARCH_PATH, + "/api/user/v1/accounts/{username}": ACCOUNT_MEMBER_PATH, + "/api/user/v1/accounts/{username}/deactivate/": ACCOUNT_DEACTIVATION_PATH, + "/api/user/v1/accounts/{username}/image": PROFILE_IMAGE_PATH, + "/api/user/v1/accounts/{username}/verification_status/": VERIFICATION_STATUS_PATH, + "/api/user/v1/accounts/{username}/verifications/": VERIFICATION_LIST_PATH, + "/api/user/v1/accounts/name_change/": NAME_CHANGE_PATH, + "/api/user/v1/accounts/name_change/{username}/confirm/": NAME_CHANGE_CONFIRMATION_PATH, + "/api/user/v1/users/": "/api/user/v2/users/", + "/api/user/v1/users/{id}/": "/api/user/v2/users/{username}/", + "/api/user/v1/user_prefs/": "/api/user/v2/user_preferences/", + "/api/user/v1/user_prefs/{id}/": "/api/user/v2/user_preferences/{username},{key}/", + "/api/user/v1/preferences/{username}": PREFERENCE_DOCUMENT_PATH, + "/api/user/v1/preferences/{username}/{preference_key}": PREFERENCE_NAME_PATH, + "/api/user/v1/preferences/email_opt_in/": EMAIL_OPT_IN_PATH, + "/api/user/v1/preferences/{pref_key}/users/": "/api/user/v2/users/", + "/api/user/v1/forum_roles/{name}/users/": "/api/user/v2/users/", + "/api/user/v1/preferences/time_zones/": "/api/user/v2/time_zones/", + "/api/user/v1/accounts/{username}/retirement_status/": RETIREMENT_MEMBER_PATH, + "/api/user/v1/accounts/retirement_queue/": RETIREMENT_LIST_PATH, + "/api/user/v1/accounts/retirements_by_status_and_date/": RETIREMENT_LIST_PATH, + "/api/user/v1/accounts/update_retirement_status/": RETIREMENT_MEMBER_PATH, + "/api/user/v1/accounts/retirement_cleanup/": RETIREMENT_CLEANUP_PATH, + "/api/user/v1/accounts/retirement_partner_report/": PARTNER_REPORT_LIST_PATH, + "/api/user/v1/accounts/retirement_partner_report_cleanup/": PARTNER_REPORT_CLEANUP_PATH, + "/api/user/v1/accounts/cancel_retirement/": RETIREMENT_MEMBER_PATH, + "/api/user/v1/accounts/retire/": RETIREMENT_ERASURE_PATH, + "/api/user/v1/accounts/retire_misc/": RETIREMENT_ERASURE_PATH, +} + +def user_patterns(): + """Return the mounts of the service's URL configuration that serve user addresses.""" + configured = import_module(settings.ROOT_URLCONF).urlpatterns + return [entry for entry in configured if str(entry.pattern).startswith(USER_MOUNT)] + + +@pytest.fixture(name="generated_documents", scope="module") +@skip_unless_lms +def generated_documents_fixture(django_db_setup, django_db_blocker): # pylint: disable=unused-argument + """ + Both documents the service publishes, each generated once and from the user mounts only. + + Each is generated under the settings it is published with, so what it says + about a user address is what the whole document says about it. + """ + with django_db_blocker.unblock(): + published = SchemaGenerator(patterns=user_patterns()).get_schema(request=None, public=True) + # The whole document derives its path prefix from every address the + # service serves, which share nothing past the root. + with patched_settings({**get_api_docs_settings(), "SCHEMA_PATH_PREFIX": "/"}): + service_wide = SchemaGenerator(patterns=user_patterns()).get_schema(request=None, public=True) + return {"published": published, "service-wide": service_wide} + + +@pytest.fixture(name="schema") +def schema_fixture(generated_documents): + """The schema published under the service's own drf-spectacular settings.""" + return copy.deepcopy(generated_documents["published"]) + + +@pytest.fixture(name="service_wide") +def service_wide_fixture(generated_documents): + """The document the service publishes for browsing its whole surface.""" + return copy.deepcopy(generated_documents["service-wide"]) + + +def service_wide_from_user_mounts(**overrides): + """Generate the service-wide document again from the user mounts, with some of its settings replaced.""" + with patched_settings({**get_api_docs_settings(), "SCHEMA_PATH_PREFIX": "/", **overrides}): + return SchemaGenerator(patterns=user_patterns()).get_schema(request=None, public=True) + + +def operations(schema): + """Yield every (path, method, operation) the schema documents.""" + for path, path_item in schema["paths"].items(): + for method, operation in path_item.items(): + if isinstance(operation, dict) and method in {"get", "post", "put", "patch", "delete"}: + yield path, method, operation + + +@skip_unless_lms +def test_version_two_addresses_are_published(schema): + assert VERSION_TWO_PATHS <= set(schema["paths"]) + + +@skip_unless_lms +def test_every_operation_of_this_version_is_published_under_one_group(schema): + """ + An operation a client library is generated from carries that mark alone. + + A client takes one class per group, so an operation in two of them is + written into both; every operation the platform generates a library from + carries the one mark, and the rest name the area they belong to. + """ + tags = { + operation["operationId"]: operation.get("tags") + for path, _method, operation in operations(schema) + if path in VERSION_TWO_PATHS + } + assert tags == { + "v2_accounts_list": ["openedx-platform-sdk"], + "v2_accounts_retrieve": ["openedx-platform-sdk"], + "v2_accounts_partial_update": ["openedx-platform-sdk"], + "v2_email_searches_create": ["openedx-platform-sdk"], + "v2_accounts_deactivation_create": ["openedx-platform-sdk"], + "v2_accounts_profile_image_create": ["openedx-platform-sdk"], + "v2_accounts_profile_image_destroy": ["openedx-platform-sdk"], + "v2_accounts_verification_status_retrieve": ["openedx-platform-sdk"], + "v2_accounts_verifications_list": ["openedx-platform-sdk"], + "v2_name_changes_create": ["openedx-platform-sdk"], + "v2_name_changes_confirmations_create": ["openedx-platform-sdk"], + "v2_accounts_preferences_retrieve": ["openedx-platform-sdk"], + "v2_accounts_preferences_partial_update": ["openedx-platform-sdk"], + "v2_accounts_preferences_retrieve_one": ["openedx-platform-sdk"], + "v2_accounts_preferences_update": ["openedx-platform-sdk"], + "v2_accounts_preferences_destroy": ["openedx-platform-sdk"], + "v2_email_opt_ins_create": ["openedx-platform-sdk"], + "v2_users_list": ["user"], + "v2_users_retrieve": ["user"], + "v2_user_preferences_list": ["user"], + "v2_user_preferences_retrieve": ["user"], + "v2_time_zones_list": ["user"], + "v2_retirements_list": ["user"], + "v2_retirements_retrieve": ["user"], + "v2_retirements_partial_update": ["user"], + "v2_retirements_destroy": ["user"], + "v2_retirements_erasures_create": ["user"], + "v2_retirement_cleanups_create": ["user"], + "v2_retirement_partner_reports_create": ["user"], + "v2_retirement_partner_reports_update": ["user"], + "v2_retirement_partner_report_cleanups_create": ["user"], + } + + +@skip_unless_lms +def test_only_superseded_addresses_are_marked_deprecated(schema): + deprecated = { + path for path, _method, operation in operations(schema) if operation.get("deprecated") + } + assert deprecated == set(SUPERSEDED_USER_ADDRESSES) + + +@skip_unless_lms +def test_only_this_version_and_the_addresses_it_supersedes_are_published(schema): + """A user address this change neither serves nor replaces stays out of the document.""" + published = {path for path in schema["paths"] if path.startswith("/api/user/")} + assert published == VERSION_TWO_PATHS | set(SUPERSEDED_USER_ADDRESSES) + + +@skip_unless_lms +def test_addresses_owned_by_other_apps_are_not_published(schema): + """Neighbours of the superseded addresses, served from the same mount and left out.""" + for path in ( + "/api/user/v1/accounts/deactivate_logout/", + "/api/user/v1/accounts/replace_usernames/", + ): + assert path not in schema["paths"], path + + +@skip_unless_lms +def test_pagination_envelope_is_published_in_full(schema, service_wide): + """ + Every paged body this version publishes carries the whole envelope, not DRF's four members. + + Each member is in every page, the links as null where there is no page + to link to, so each one is published as required in both documents. + """ + paged = [name for name in VERSION_TWO_COMPONENTS if name.startswith("Paginated")] + assert len(paged) == 7 + for document in (schema, service_wide): + for component in paged: + published = document["components"]["schemas"][component] + assert set(published["properties"]) == PAGINATION_MEMBERS, component + assert set(published["required"]) == PAGINATION_MEMBERS, component + assert published["properties"]["next"]["nullable"] is True, component + assert published["properties"]["previous"]["nullable"] is True, component + + +@pytest.mark.django_db +@skip_unless_lms +def test_published_pagination_envelope_matches_the_response_body(client, schema, settings): + settings.EDX_API_KEY = API_KEY + admin = AdminFactory.create(username="schema-admin", password=PASSWORD) + UserPreferenceFactory.create(user=admin, key="pref-lang", value="en") + body = client.get(reverse("user_v2:user_preference_list"), HTTP_X_EDX_API_KEY=API_KEY).json() + published = schema["components"]["schemas"]["PaginatedUserPreferenceRecordResponseList"] + assert set(body) == set(published["properties"]) + + +@skip_unless_lms +def test_error_responses_reference_the_shared_envelope(schema): + """One refusal carries more than the envelope, and it is the only one that may.""" + extends_the_envelope = {(ACCOUNT_LIST_PATH, "get", "404"): "RetiredAccountErrorResponse"} + for path, method, operation in operations(schema): + if path not in VERSION_TWO_PATHS: + continue + for code, response in operation["responses"].items(): + if not code.startswith(("4", "5")): + continue + component = extends_the_envelope.get((path, method, code), "ErrorResponse") + schema_ref = response["content"]["application/json"]["schema"] + assert schema_ref == {"$ref": f"#/components/schemas/{component}"}, (path, method, code) + + +@skip_unless_lms +def test_a_refusal_that_does_not_depend_on_the_resource_reads_the_same_at_every_address(schema): + """Each of these codes is published with one text, whichever resource of this version answers it.""" + published = {"401": set(), "415": set()} + for path, _method, operation in operations(schema): + if path not in VERSION_TWO_PATHS: + continue + for code, response in operation["responses"].items(): + if code in published: + published[code].add(response["description"]) + assert published == { + "401": {"The requester is not authenticated."}, + "415": {"The request body is in a media type this endpoint cannot read."}, + } + + +@skip_unless_lms +def test_an_operation_that_takes_a_response_preset_publishes_the_refusal(schema): + """A preset the operation does not answer to is a 400, so each of them documents one.""" + takes_a_preset = [ + (path, method) + for path, method, operation in operations(schema) + if path in VERSION_TWO_PATHS + and any(parameter["name"] == "view" for parameter in operation.get("parameters", [])) + and "400" in operation["responses"] + ] + assert sorted(takes_a_preset) == [ + (ACCOUNT_LIST_PATH, "get"), + (ACCOUNT_MEMBER_PATH, "get"), + (ACCOUNT_MEMBER_PATH, "patch"), + ("/api/user/v2/retirements/", "get"), + ("/api/user/v2/retirements/{username}/", "get"), + ("/api/user/v2/user_preferences/", "get"), + ("/api/user/v2/user_preferences/{username},{key}/", "get"), + ("/api/user/v2/users/", "get"), + ("/api/user/v2/users/{username}/", "get"), + ] + + +@skip_unless_lms +def test_user_list_publishes_its_filters(schema): + parameters = { + parameter["name"]: parameter + for parameter in schema["paths"]["/api/user/v2/users/"]["get"]["parameters"] + } + assert set(parameters) == { + "course_key", "email", "fields", "forum_role", "is_active", "ordering", + "page", "page_size", "preference_key", "username", "view", + } + # The filter that refuses an unparsable value is still published as a boolean. + assert parameters["is_active"]["schema"] == {"type": "boolean"} + + +@skip_unless_lms +def test_preference_list_publishes_its_filters(schema): + parameters = { + parameter["name"]: parameter + for parameter in schema["paths"]["/api/user/v2/user_preferences/"]["get"]["parameters"] + } + assert set(parameters) == {"fields", "key", "ordering", "page", "page_size", "username", "view"} + # Every published parameter is one the list reads, and one it keeps. + assert [name for name, parameter in parameters.items() if parameter.get("deprecated")] == [] + + +@skip_unless_lms +def test_every_collection_that_selects_accounts_by_name_publishes_the_same_kind_of_value(schema): + """ + A value learned on one of these collections selects the same accounts on the others. + + Each of the three reads a list of names, and a name no account holds is + left out of the page rather than refused, so the published description + opens with the same sentence and the value is published as the text that + carries the list. + """ + published = { + path: next( + parameter for parameter in operation["parameters"] if parameter["name"] == "username" + ) + for path, _method, operation in operations(schema) + if path in {ACCOUNT_LIST_PATH, USER_LIST_PATH, PREFERENCE_LIST_PATH} + } + assert {path: parameter["schema"] for path, parameter in published.items()} == { + ACCOUNT_LIST_PATH: {"type": "string"}, + USER_LIST_PATH: {"type": "string"}, + PREFERENCE_LIST_PATH: {"type": "string"}, + } + assert { + path: parameter["description"].split(".")[0] for path, parameter in published.items() + } == { + ACCOUNT_LIST_PATH: "Comma-separated usernames", + USER_LIST_PATH: "Comma-separated usernames", + PREFERENCE_LIST_PATH: "Comma-separated usernames", + } + + +@skip_unless_lms +def test_enum_post_processing_runs_before_the_deprecation_hook(): + hooks = settings.SPECTACULAR_SETTINGS["POSTPROCESSING_HOOKS"] + assert hooks[0] == "drf_spectacular.hooks.postprocess_schema_enums" + assert "lms.lib.spectacular.lms_mark_legacy_paths_deprecated" in hooks[1:] + assert "openedx.core.djangoapps.user_api.schema.mark_superseded_user_paths" in hooks[1:] + + +@skip_unless_lms +def test_hook_leaves_other_resources_alone(): + """The hook marks only the addresses it lists, even among their neighbours.""" + untouched = [ + "/api/profile_images/v1/{username}/upload", + "/api/profile_images/v1/{username}/remove", + "/api/user/v1/accounts/deactivate_logout/", + "/api/user/v2/account/registration/", + "/api/user/v2/accounts/{username}/preferences/", + "/user_api/v1/users/", + "/user_api/v1/accounts/{username}", + "/user_api/v1/preferences/time_zones/", + "/api/enrollment/v2/enrollments/", + "/api/courses/v1/courses/", + ] + marked = [ + "/api/user/v1/users/", + "/api/user/v1/accounts", + "/api/user/v1/accounts/{username}", + "/api/user/v1/accounts/{username}/image", + "/api/user/v1/accounts/name_change/", + "/api/user/v1/preferences/{username}", + "/api/user/v1/preferences/{username}/{preference_key}", + "/api/user/v1/preferences/email_opt_in/", + "/api/enrollment/v2/enrollments", + ] + result = {"paths": {path: {"get": {"operationId": path}} for path in untouched + marked}} + + lms_mark_legacy_paths_deprecated(result, generator=None, request=None, public=True) + mark_superseded_user_paths(result, generator=None, request=None, public=True) + + for path in untouched: + assert "deprecated" not in result["paths"][path]["get"], path + for path in marked: + assert result["paths"][path]["get"]["deprecated"] is True, path + + +@skip_unless_lms +def test_every_superseded_address_has_a_published_replacement(schema): + assert set(SUPERSEDED_USER_ADDRESSES) == set(SUPERSEDED_USER_PATHS) + for superseded, replacement in SUPERSEDED_USER_ADDRESSES.items(): + assert superseded in schema["paths"] + assert replacement in schema["paths"] + + +#: The media types each superseded body is published in, by address and method. +SUPERSEDED_REQUEST_MEDIA_TYPES = { + ("/api/user/v1/accounts/{username}", "patch"): {"application/merge-patch+json"}, + ("/api/user/v1/accounts/search_emails", "post"): { + "application/json", "application/merge-patch+json", + }, + ("/api/user/v1/preferences/{username}", "patch"): {"application/merge-patch+json"}, + ("/api/user/v1/preferences/{username}/{preference_key}", "put"): {"application/json"}, + ("/api/user/v1/preferences/email_opt_in/", "post"): {"application/x-www-form-urlencoded"}, +} + + +@pytest.mark.django_db +@skip_unless_lms +def test_each_superseded_body_is_published_in_the_media_types_that_address_reads(client, schema): + """ + Every media type published for a superseded body is sent to that address here. + + Those bodies are named for addresses whose views name no serializer, and a + shape named on its own is published in every media type the view's parsers + accept, which can be more than the handler reads. A generated client sends + the first type the document offers, so each one is answered by the running + endpoint here, and a type the document leaves out is sent as well. + """ + staff = AdminFactory.create(username="media_type_staff", password=PASSWORD) + assert client.login(username=staff.username, password=PASSWORD) + member = f"/api/user/v1/accounts/{staff.username}" + preferences = f"/api/user/v1/preferences/{staff.username}" + requests = { + ("/api/user/v1/accounts/{username}", "patch"): (member, "{}", 200), + ("/api/user/v1/accounts/search_emails", "post"): ( + "/api/user/v1/accounts/search_emails", json.dumps({"emails": [staff.email]}), 200, + ), + ("/api/user/v1/preferences/{username}", "patch"): ( + preferences, json.dumps({"pref-lang": "en"}), 204, + ), + # The key half of that route carries neither a hyphen nor a dot, so the + # name sent here is one it can address. + ("/api/user/v1/preferences/{username}/{preference_key}", "put"): ( + f"{preferences}/account_privacy", json.dumps("private"), 204, + ), + ("/api/user/v1/preferences/email_opt_in/", "post"): ( + "/api/user/v1/preferences/email_opt_in/", + "course_id=course-v1%3AedX%2BDemoX%2BDemo&email_opt_in=True", + 200, + ), + } + for (path, method), media_types in SUPERSEDED_REQUEST_MEDIA_TYPES.items(): + assert set(schema["paths"][path][method]["requestBody"]["content"]) == media_types, path + address, body, expected_status = requests[(path, method)] + for media_type in media_types: + answer = getattr(client, method)(address, data=body, content_type=media_type) + assert answer.status_code == expected_status, (address, media_type, answer.content) + left_out = client.patch(member, data="{}", content_type="application/json") + assert left_out.status_code == 415 + + +#: The sets of choices this version publishes as a model of their own, with +#: the name each is published under and the values it offers. +PINNED_CHOICE_SETS = { + "RetirementErasureScopeEnum": list(ERASURE_SCOPES), + "RetirementBatchResultOutcomeEnum": list(BATCH_OUTCOMES), +} + +#: A second field of each of those names, with the value it would offer and +#: the name it would be published under: a set of choices sharing a field name +#: with another is named after the body carrying it. +CROWDED_CHOICE_SETS = { + "scope": ("somewhere_else", "RetirementBatchScopeEnum"), + "outcome": ("something_else", "RetirementBatchOutcomeEnum"), +} + + +@pytest.mark.django_db +@skip_unless_lms +def test_each_published_set_of_choices_keeps_its_name_when_another_field_shares_the_name(): + """ + A set of choices is published as a model named for the field that carries it. + + That name holds only while one field of the whole service carries it: a + second field of the same name offering different values renames both + models after the bodies they sit in, so the name a client already + generates a class from disappears. Both documents are therefore read with + such a second field in place, and each set has to still be published + under its own name, with the values it offers. + """ + intruders = { + name: serializers.ChoiceField(choices=[(value, f"Another set of choices, under {name}.")]) + for name, (value, _published_as) in CROWDED_CHOICE_SETS.items() + } + with mock.patch.dict(RetirementBatchSerializer._declared_fields, intruders): # pylint: disable=protected-access + crowded = { + "this version": SchemaGenerator(patterns=user_patterns()).get_schema(request=None, public=True), + "service-wide": service_wide_from_user_mounts(), + } + for which, document in crowded.items(): + components = document["components"]["schemas"] + published = { + name: components[name]["enum"] for name in PINNED_CHOICE_SETS if name in components + } + assert published == PINNED_CHOICE_SETS, which + # The second field really was published, so the collision the names + # are held against did happen in this document. + for field, (value, crowded_name) in CROWDED_CHOICE_SETS.items(): + assert components["RetirementBatch"]["properties"][field] == { + "$ref": f"#/components/schemas/{crowded_name}", + }, (which, field) + assert components[crowded_name]["enum"] == [value], (which, field) + + +@skip_unless_lms +def test_every_response_shape_is_published(schema): + """The reduced bodies the presets return are described, not only the full one.""" + components = schema["components"]["schemas"] + assert components["UserDirectoryResponse"]["anyOf"] == [ + {"$ref": "#/components/schemas/UserDirectory"}, + {"$ref": "#/components/schemas/UserDirectoryMinimal"}, + {"$ref": "#/components/schemas/UserDirectoryProjection"}, + ] + assert components["UserPreferenceRecordResponse"]["anyOf"] == [ + {"$ref": "#/components/schemas/UserPreferenceRecord"}, + {"$ref": "#/components/schemas/UserPreferenceRecordMinimal"}, + {"$ref": "#/components/schemas/UserPreferenceRecordProjection"}, + ] + assert sorted(components["UserDirectory"]["required"]) == [ + "email", "id", "name", "preferences", "url", "username", + ] + assert sorted(components["UserDirectoryMinimal"]["required"]) == ["email", "id", "username"] + assert sorted(components["UserPreferenceRecordMinimal"]["required"]) == ["key", "username", "value"] + for component in ("UserDirectoryProjection", "UserPreferenceRecordProjection"): + published = components[component] + assert "required" not in published, component + assert set(published["properties"]) == set( + components[component.replace("Projection", "")]["properties"] + ), component + + +#: A value for every property of a profile an account carries, so that a body +#: checked against its component carries a value the component has to accept +#: rather than a null every type accepts. +FILLED_IN_PROFILE = { + "bio": "Teaches mathematics.", + "city": "Cambridge", + "country": "US", + "gender": "f", + "goals": "Finish the course.", + "level_of_education": "m", + "mailing_address": "12 Park Avenue", + "phone_number": "+18005555555", + "state": "MA", + "year_of_birth": 1985, +} + +#: The properties of an account that only a filled-in one carries: each is held +#: outside the profile, or is left null by a user who never gave it. +FILLED_IN_ONLY_PROPERTIES = ( + "country", + "level_of_education", + "mailing_address", + "pending_name_change", + "phone_number", + "secondary_email", + "state", + "verified_name", +) + + +def fill_in(user): + """Give the user's profile a value for each property it holds, and a name change outstanding.""" + for name, value in FILLED_IN_PROFILE.items(): + setattr(user.profile, name, value) + user.profile.save() + user.profile.language_proficiencies.create(code="fr") + user.profile.social_links.create(platform="twitter", social_link="https://www.twitter.com/example") + PendingNameChange.objects.create( + user=user, new_name="Changed Name", rationale="Asked for in a test", + ) + return user + + +def name_affirmation_answering(verified_name): + """ + Return a patch installing a name affirmation service that holds one accepted name. + + The service is an optional install, and the property it fills is null + wherever it is absent, so the account that carries one is built with a + stand-in rather than left untested on the sites that do not have it. + """ + service = mock.Mock() + service.get_verified_name.return_value = mock.Mock(verified_name=verified_name) + return mock.patch( + "openedx.core.djangoapps.user_api.accounts.serializers.get_name_affirmation_service", + return_value=service, + ) + + +@pytest.mark.django_db +@skip_unless_lms +def test_published_shapes_accept_every_body_the_endpoints_return(client, schema, settings): + """Every preset and selection the parameters offer returns a body the document describes.""" + settings.EDX_API_KEY = API_KEY + admin = AdminFactory.create(username="shape-admin", password=PASSWORD) + UserPreferenceFactory.create(user=admin, key="pref-lang", value="en") + fill_in(admin) + assert client.login(username=admin.username, password=PASSWORD) + # The account addresses read the signed-in administrator; the directory + # addresses read the key. + client.defaults["HTTP_X_EDX_API_KEY"] = API_KEY + for published_path, address, params, component in SHAPE_CASES: + case = (address, params) + response = client.get(address, params) + assert response.status_code == 200, case + body = response.json() + if "results" in body: + assert body["count"] == 1, case + validator = jsonschema.Draft202012Validator(published_body_schema(schema, published_path)) + assert [error.message for error in validator.iter_errors(body)] == [], case + assert_records_match(schema, body, component, case) + + +@skip_unless_lms +def test_sort_parameter_publishes_the_fields_it_accepts(schema): + """The sort allow-list and the order used when none is asked for are published.""" + for path, accepted, default in ( + ("/api/user/v2/users/", ["-date_joined", "-username", "date_joined", "username"], ["username"]), + ("/api/user/v2/user_preferences/", ["-key", "-username", "key", "username"], ["username", "key"]), + ): + parameter = next( + published for published in schema["paths"][path]["get"]["parameters"] + if published["name"] == "ordering" + ) + assert parameter["schema"]["items"]["enum"] == accepted, path + assert parameter["schema"]["default"] == default, path + + +@skip_unless_lms +def test_every_list_publishes_the_page_that_does_not_exist(schema): + """Asking for a page past the end of a list is answered 404, so each list documents it.""" + assert LIST_PATHS | MEMBER_PATHS | ACTION_PATHS == set(VERSION_TWO_PATHS) + documented = { + path: sorted(operation["responses"]) + for path, _method, operation in operations(schema) + if path in LIST_PATHS + } + assert documented == { + ACCOUNT_LIST_PATH: ["200", "400", "401", "403", "404", "406"], + VERIFICATION_LIST_PATH: ["200", "400", "401", "403", "404", "406"], + "/api/user/v2/users/": ["200", "400", "401", "403", "404", "406"], + "/api/user/v2/user_preferences/": ["200", "400", "401", "403", "404", "406"], + TIME_ZONE_LIST_PATH: ["200", "400", "404", "406"], + RETIREMENT_LIST_PATH: ["200", "400", "401", "403", "404", "406"], + } + + +@skip_unless_lms +def test_member_addresses_describe_the_parts_they_are_built_from(schema): + """Each part of a member address is published with its meaning and what its route matches.""" + username_schema = {"type": "string", "pattern": rf"^{settings.USERNAME_REGEX_PARTIAL}$"} + published = { + (path, parameter["name"]): parameter + for path, _method, operation in operations(schema) + if path in MEMBER_PATHS + for parameter in operation["parameters"] + if parameter["in"] == "path" + } + assert published == { + (ACCOUNT_MEMBER_PATH, "username"): { + "in": "path", + "name": "username", + "required": True, + "schema": username_schema, + "description": ( + "Unique name of the user whose account is addressed. `me` names the account of " + "the caller, so an account holding that name is read through the collection, " + "with `username=` naming it." + ), + }, + (VERIFICATION_STATUS_PATH, "username"): { + "in": "path", + "name": "username", + "required": True, + "schema": username_schema, + "description": ( + "Unique name of the user whose identity verification is addressed. The name is " + "read as it is stored, so `me` here names an account holding that name rather " + "than the account of the caller making the request." + ), + }, + (USER_MEMBER_PATH, "username"): { + "in": "path", + "name": "username", + "required": True, + "schema": username_schema, + "description": "Unique name of the user whose directory entry is returned.", + }, + (PREFERENCE_MEMBER_PATH, "username"): { + "in": "path", + "name": "username", + "required": True, + "schema": username_schema, + "description": ( + "Username of the user the preference belongs to: the part of the address " + "before the comma." + ), + }, + (PREFERENCE_MEMBER_PATH, "key"): { + "in": "path", + "name": "key", + "required": True, + "schema": {"type": "string", "pattern": rf"^{PREFERENCE_KEY_PATTERN_PARTIAL}$"}, + "description": ( + "Name the preference is stored under: the part of the address after the comma." + ), + }, + (PREFERENCE_DOCUMENT_PATH, "username"): { + "in": "path", + "name": "username", + "required": True, + "schema": username_schema, + "description": ACCOUNT_PREFERENCES_USERNAME_DESCRIPTION, + }, + (PREFERENCE_NAME_PATH, "username"): { + "in": "path", + "name": "username", + "required": True, + "schema": username_schema, + "description": ACCOUNT_PREFERENCES_USERNAME_DESCRIPTION, + }, + (RETIREMENT_MEMBER_PATH, "username"): { + "in": "path", + "name": "username", + "required": True, + "schema": username_schema, + "description": RETIREMENT_USERNAME_DESCRIPTION, + }, + (PARTNER_REPORT_MEMBER_PATH, "username"): { + "in": "path", + "name": "username", + "required": True, + "schema": username_schema, + "description": PARTNER_REPORT_USERNAME_DESCRIPTION, + }, + (PREFERENCE_NAME_PATH, "key"): { + "in": "path", + "name": "key", + "required": True, + "schema": {"type": "string", "pattern": rf"^{PREFERENCE_KEY_PATTERN_PARTIAL}$"}, + "description": ( + "Name the preference is stored under, which is the whole segment, punctuation " + "included. A name carrying a slash has no address of its own, because the " + "segment ends at the slash; such a name is read with the whole document and " + "removed by sending a null for it there. Nothing is stored under a name longer " + f"than {PREFERENCE_NAME_MAX_LENGTH} characters, nor under one carrying no " + "letter, digit, hyphen or underscore. A name with whitespace at either end names " + "no preference: one is stored under its name without the whitespace around it." + ), + }, + } + + +@skip_unless_lms +def test_the_addresses_that_work_on_one_account_describe_the_name_they_carry(schema): + """ + An address that works on one account publishes what the name in it is read as. + + Neither of these addresses takes the shorthand the account collection + takes for the caller's own account, so what each of them does with a + request sent under that name is published rather than left for a caller to + find out by sending one. + """ + published = { + (path, method): parameter["description"] + for path, method, operation in operations(schema) + if path in (ACCOUNT_DEACTIVATION_PATH, PROFILE_IMAGE_PATH) + for parameter in operation["parameters"] + if parameter["in"] == "path" and parameter["name"] == "username" + } + image_description = ( + "Unique name of the user whose image is addressed. The name is read as it is stored, so " + "`me` here names an account holding that name rather than the account of the caller " + "making the request." + ) + assert published == { + (ACCOUNT_DEACTIVATION_PATH, "post"): ( + "Unique name of the user whose account is deactivated. The name is read as it is " + "stored, `me` included: this action disables the account the address names, never " + "the account of the caller making the request." + ), + (PROFILE_IMAGE_PATH, "post"): image_description, + (PROFILE_IMAGE_PATH, "delete"): image_description, + } + + +#: Every part of an address of this version that carries a username. A client +#: built from the document holds a value to what the route matches before it +#: sends a request, so an address left off this list is one whose caller learns +#: from a refusal what every sibling address refuses for it. +USERNAME_ADDRESS_PARTS = ( + ACCOUNT_MEMBER_PATH, + ACCOUNT_DEACTIVATION_PATH, + PROFILE_IMAGE_PATH, + VERIFICATION_STATUS_PATH, + VERIFICATION_LIST_PATH, + PREFERENCE_DOCUMENT_PATH, + PREFERENCE_NAME_PATH, + NAME_CHANGE_CONFIRMATION_PATH, + USER_MEMBER_PATH, + PREFERENCE_MEMBER_PATH, + RETIREMENT_MEMBER_PATH, + RETIREMENT_ERASURE_PATH, + PARTNER_REPORT_MEMBER_PATH, +) + + +@skip_unless_lms +def test_every_part_of_an_address_that_carries_a_username_publishes_what_the_route_matches(schema): + """ + One resource does not admit a name at one of its addresses and refuse it at the next. + + The addresses of this version are matched on the same spelling of a + username, whichever app serves them, so each of them is published with + that spelling and a client refuses the same names the routes do. + """ + published = { + (path, parameter["name"]): parameter["schema"] + for path, _method, operation in operations(schema) + if path in VERSION_TWO_PATHS + for parameter in operation.get("parameters", []) + if parameter["in"] == "path" and parameter["name"] == "username" + } + assert published == { + (path, "username"): {"type": "string", "pattern": rf"^{settings.USERNAME_REGEX_PARTIAL}$"} + for path in USERNAME_ADDRESS_PARTS + } + + +@skip_unless_lms +def test_the_country_parameter_publishes_the_constraint_it_is_held_to(schema): + """The parameter comes from the serializer that validates it, so the two cannot disagree.""" + field = TimeZoneQuerySerializer().fields["country_code"] + enforced = next( + validator.regex.pattern for validator in field.validators if hasattr(validator, "regex") + ) + parameter = next( + published for published in schema["paths"][TIME_ZONE_LIST_PATH]["get"]["parameters"] + if published["name"] == "country_code" + ) + assert parameter["description"] == field.help_text + assert parameter["schema"] == { + "oneOf": [{"type": "string", "pattern": enforced}, {"type": "string", "maxLength": 0}], + } + assert "required" not in parameter + + +@skip_unless_lms +def test_published_text_is_written_for_the_clients_that_read_it(schema): + """Descriptions reach every consumer, so they carry no governance references.""" + published = json.dumps({ + "paths": {path: schema["paths"][path] for path in VERSION_TWO_PATHS}, + "components": { + name: schema["components"]["schemas"][name] for name in VERSION_TWO_COMPONENTS + }, + }) + for reference in ("ADR", "OEP-", "drf-spectacular", "extend_schema"): + assert reference not in published, reference + assert schema["components"]["schemas"]["ErrorResponse"]["description"].startswith( + "The body of every unsuccessful response." + ) + + +@skip_unless_lms +def test_the_library_tag_marks_the_addresses_a_client_is_built_from_and_nothing_else(schema): + """The operations a generated client library is built from say so, one by one.""" + tagged = { + path for path, _method, operation in operations(schema) + if path.startswith("/api/user/") and "openedx-platform-sdk" in (operation.get("tags") or []) + } + assert tagged == SDK_PATHS + + +@skip_unless_lms +def test_account_list_publishes_its_selectors(schema): + parameters = { + parameter["name"]: parameter + for parameter in schema["paths"][ACCOUNT_LIST_PATH]["get"]["parameters"] + } + assert set(parameters) == { + "email", "fields", "lms_user_id", "ordering", "page", "page_size", "username", "view", + } + assert parameters["lms_user_id"]["schema"] == {"type": "integer"} + # An empty value narrows nothing, the way an empty value of any other + # parameter does, and is published as one the parameter accepts. + assert parameters["view"]["schema"]["enum"] == ["minimal", "shared", ""] + assert parameters["ordering"]["schema"]["enum"] == ["username", "-username", ""] + assert parameters["ordering"]["schema"]["default"] == ACCOUNT_DEFAULT_SORT == "username" + assert [name for name, parameter in parameters.items() if parameter.get("deprecated")] == [] + + +@skip_unless_lms +def test_the_selectors_are_published_from_the_serializer_that_validates_them(schema): + """The constraint a caller is held to is the one the published parameter carries.""" + declared = AccountQuerySerializer().fields + published = { + parameter["name"]: parameter + for parameter in schema["paths"][ACCOUNT_LIST_PATH]["get"]["parameters"] + } + for name, field in declared.items(): + assert published[name]["description"].startswith(field.help_text), name + assert "required" not in published[name], name + assert published["view"]["description"].endswith( + "* `minimal` - The properties that identify the account's owner\n" + "* `shared` - The properties the owner shares with everybody", + ) + + +@skip_unless_lms +def test_the_education_levels_published_are_the_ones_a_profile_stores(schema): + """Every code the profile offers is named where the property is published, both read and written.""" + offered = [code for code, _label in UserProfile.LEVEL_OF_EDUCATION_CHOICES] + assert offered == ["p", "m", "b", "a", "hs", "jhs", "el", "none", "other"] + for component in ("Account", "PatchedAccountUpdate"): + published = schema["components"]["schemas"][component]["properties"]["level_of_education"] + assert [code for code in offered if f"`{code}`" in published["description"]] == offered, component + + +@skip_unless_lms +def test_a_change_publishes_the_body_it_reads_and_the_media_type_it_reads_it_in(schema): + request = schema["paths"][ACCOUNT_MEMBER_PATH]["patch"]["requestBody"] + assert set(request["content"]) == {"application/merge-patch+json"} + assert request["content"]["application/merge-patch+json"]["schema"] == { + "$ref": "#/components/schemas/PatchedAccountUpdate", + } + changeable = schema["components"]["schemas"]["PatchedAccountUpdate"] + assert "required" not in changeable + assert {"email", "name", "bio", "social_links", "extended_profile"} <= set(changeable["properties"]) + assert "username" not in changeable["properties"] + + +@pytest.mark.django_db +@skip_unless_lms +def test_the_order_the_account_list_publishes_as_its_default_is_the_one_it_answers_with(client, schema): + """ + The default is read off the document and asked of the running list. + + An order stated in the document and an order applied in the handler are + two declarations of one thing, and a client that omits the parameter is + answered by the second while it was built from the first. + """ + parameter = next( + published for published in schema["paths"][ACCOUNT_LIST_PATH]["get"]["parameters"] + if published["name"] == "ordering" + ) + default = parameter["schema"]["default"] + staff = AdminFactory.create(username="sorting_staff", password=PASSWORD) + for name in ("sorted_c", "sorted_a", "sorted_b"): + UserFactory.create(username=name) + assert client.login(username=staff.username, password=PASSWORD) + selection = {"username": "sorted_c,sorted_a,sorted_b"} + + omitted = client.get(ACCOUNT_LIST_PATH, selection) + assert [row["username"] for row in omitted.json()["results"]] == [ + "sorted_a", "sorted_b", "sorted_c", + ] + asked_for = client.get(ACCOUNT_LIST_PATH, {**selection, "ordering": default}) + assert [row["username"] for row in asked_for.json()["results"]] == [ + "sorted_a", "sorted_b", "sorted_c", + ] + the_other_way = client.get(ACCOUNT_LIST_PATH, {**selection, "ordering": f"-{default}"}) + assert [row["username"] for row in the_other_way.json()["results"]] == [ + "sorted_c", "sorted_b", "sorted_a", + ] + + +@pytest.mark.django_db +@skip_unless_lms +def test_every_property_the_change_publishes_a_null_for_is_cleared_by_one(client, schema): + """ + A null is sent for each property published as taking one, and is answered. + + A generated client offers a null exactly where the body publishes one, so + each of those is a change this endpoint has to carry out rather than fail + on, and a property that cannot be cleared this way belongs outside the set. + """ + published = schema["components"]["schemas"]["PatchedAccountUpdate"]["properties"] + takes_a_null = sorted(name for name, shape in published.items() if shape.get("nullable")) + assert takes_a_null == [ + "account_privacy", "city", "country", "gender", "goals", "level_of_education", + "mailing_address", "state", "year_of_birth", + ] + owner = UserFactory.create(username="clearing_owner", password=PASSWORD) + assert client.login(username=owner.username, password=PASSWORD) + address = f"/api/user/v2/accounts/{owner.username}/" + for name in takes_a_null: + answer = client.patch( + address, data=json.dumps({name: None}), content_type="application/merge-patch+json", + ) + assert answer.status_code == 200, (name, answer.content) + # The account the last change answered with holds every one of them cleared, + # bar the city, which a change carries and no response does. + account = answer.json() + assert {name: account.get(name, ABSENT) for name in takes_a_null} == { + # Clearing the visibility choice hands the account back to the visibility + # the site gives an account whose profile does not say how old its owner + # is, the year of birth having been cleared here as well. + "account_privacy": "private", + "city": ABSENT, + "country": None, + "gender": None, + "goals": None, + "level_of_education": None, + "mailing_address": None, + "state": None, + "year_of_birth": None, + } + + +@skip_unless_lms +def test_a_change_publishes_the_refusals_only_it_can_answer_with(schema): + assert sorted(schema["paths"][ACCOUNT_MEMBER_PATH]["patch"]["responses"]) == [ + "200", "400", "401", "403", "404", "406", "415", "429", "500", + ] + assert schema["paths"][ACCOUNT_MEMBER_PATH]["patch"]["responses"]["500"]["description"] == ( + "The change was asked for correctly and could not be carried out. Sending it again " + "with something else in it does not help; sending the same request later may." + ) + assert sorted(schema["paths"][ACCOUNT_MEMBER_PATH]["get"]["responses"]) == [ + "200", "400", "401", "403", "404", "406", + ] + + +@skip_unless_lms +def test_the_operations_that_read_a_body_publish_the_refusal_of_one_they_cannot_read(schema): + """A body the endpoint cannot parse is the caller's fault, and every such address says so.""" + for path, method in ( + (ACCOUNT_MEMBER_PATH, "patch"), + (EMAIL_SEARCH_PATH, "post"), + (NAME_CHANGE_PATH, "post"), + ): + assert schema["paths"][path][method]["responses"]["400"]["description"] == ( + "The request body is not well formed in the media type it was sent in, or carries a " + "value this endpoint will not take. A query parameter this endpoint does not read is " + "refused here too." + ), path + assert schema["paths"][PROFILE_IMAGE_PATH]["post"]["responses"]["400"]["description"] == ( + "The request carries no usable image: none was sent, the body is not well formed in the " + "media type it was sent in, one sent as the whole body was sent without a name or under a " + "name whose extension names another media type, the image is of a type or a size this " + "site does not take, or no picture can be drawn from the file, whose bytes may stop " + "early, carry metadata that does not parse, or declare more pixels than this site draws. " + "A query parameter this endpoint does not read is refused here too." + ) + + +@skip_unless_lms +def test_the_operations_only_an_owner_reaches_publish_the_cause_their_refusal_really_has(schema): + """ + Where only the owner is admitted, nothing is published as missing that is refused instead. + + A name nobody holds is not the caller's own, so these operations refuse it + as another account's rather than report it absent. What is left for the + absent answer is an account whose profile is gone, and that is what the + document describes, so a client reading one of these branches meets it. + """ + assert schema["paths"][ACCOUNT_MEMBER_PATH]["patch"]["responses"]["404"]["description"] == ( + "The account that was addressed is no longer there. Only the caller's own account is " + "changed here, so a name nobody holds is refused as somebody else's account instead of " + "being reported as missing." + ) + for method in ("post", "delete"): + assert schema["paths"][PROFILE_IMAGE_PATH][method]["responses"]["404"]["description"] == ( + "The account at this address carries no profile to record an image on. A name no " + "account holds is refused as somebody else's image instead of being reported here." + ), method + assert schema["paths"][NAME_CHANGE_PATH]["post"]["responses"]["404"]["description"] == ( + "The account of the caller carries no profile to put a name on." + ) + + +@skip_unless_lms +def test_the_search_publishes_the_addresses_it_reads_and_the_page_it_answers_with(schema): + operation = schema["paths"][EMAIL_SEARCH_PATH]["post"] + assert operation["requestBody"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/EmailSearch", + } + assert operation["responses"]["200"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/PaginatedEmailSearchResultList", + } + assert set( + schema["components"]["schemas"]["PaginatedEmailSearchResultList"]["properties"], + ) == PAGINATION_MEMBERS + # The most addresses one search may name is published, so a client knows + # the bound before it sends a body the search will refuse. + addresses = schema["components"]["schemas"]["EmailSearch"]["properties"]["emails"] + assert addresses["type"] == "array" + assert addresses["maxItems"] == 100 + assert sorted(operation["responses"]) == ["200", "400", "401", "403", "404", "406", "415"] + # The page is the only thing it reads from the query string. + assert [parameter["name"] for parameter in operation["parameters"]] == ["page", "page_size"] + + +@skip_unless_lms +def test_a_deactivation_publishes_that_it_answers_with_nothing(schema): + operation = schema["paths"][ACCOUNT_DEACTIVATION_PATH]["post"] + assert "requestBody" not in operation + assert sorted(operation["responses"]) == ["204", "400", "401", "403", "404", "406"] + assert "content" not in operation["responses"]["204"] + assert operation["responses"]["204"]["description"] == "The account was deactivated." + + +@skip_unless_lms +def test_the_name_a_deactivation_disables_is_published_as_a_stored_one(schema): + """``me`` names the caller where an account is read, and a stored account where one is disabled.""" + disabled = { + parameter["name"]: parameter + for parameter in schema["paths"][ACCOUNT_DEACTIVATION_PATH]["post"]["parameters"] + } + assert disabled["username"]["description"] == ( + "Unique name of the user whose account is deactivated. The name is read as it is " + "stored, `me` included: this action disables the account the address names, never the " + "account of the caller making the request." + ) + + +#: The properties of an account that always carry a value when they are sent. +ALWAYS_VALUED_ACCOUNT_PROPERTIES = {"account_privacy", "date_joined", "email", "id", "is_active", "username"} + +#: The properties a profile holds, which an account without one answers with null. +PROFILE_HELD_PROPERTIES = ( + "extended_profile", + "language_proficiencies", + "profile_image", + "requires_parental_consent", + "social_links", +) + + +@skip_unless_lms +def test_an_account_is_published_with_the_properties_visibility_decides(schema): + """Only the properties every caller may see are published as always present.""" + components = schema["components"]["schemas"] + assert components["AccountResponse"]["anyOf"] == [ + {"$ref": "#/components/schemas/Account"}, + {"$ref": "#/components/schemas/AccountMinimal"}, + {"$ref": "#/components/schemas/AccountProjection"}, + ] + assert sorted(components["Account"]["required"]) == [ + "account_privacy", "profile_image", "username", + ] + assert set(components["Account"]["properties"]) == set( + settings.ACCOUNT_VISIBILITY_CONFIGURATION["admin_fields"], + ) + assert sorted(components["AccountMinimal"]["required"]) == ["profile_image", "username"] + assert set(components["AccountMinimal"]["properties"]) == { + "username", "email", "name", "profile_image", + } + assert "required" not in components["AccountProjection"] + + +@skip_unless_lms +def test_an_account_publishes_which_of_its_properties_can_come_back_without_a_value(schema): + """A property that is only sometimes held has to be published as one that can be null.""" + components = schema["components"]["schemas"] + properties = components["Account"]["properties"] + assert { + name for name, published in properties.items() if not published.get("nullable") + } == ALWAYS_VALUED_ACCOUNT_PROPERTIES + extra_field_value = components["AccountExtendedField"]["properties"]["field_value"] + assert "type" not in extra_field_value, "an extra field answers with whatever the site stored in it" + + +def account_owner(client, username, **profile_values): + """Create a user old enough to share a profile, sign them in, and return them.""" + user = UserFactory.create(username=username, password=PASSWORD) + user.profile.year_of_birth = 1985 + for name, value in profile_values.items(): + setattr(user.profile, name, value) + user.profile.save() + assert client.login(username=username, password=PASSWORD) + return user + + +@pytest.mark.django_db +@skip_unless_lms +def test_an_owner_reading_their_account_where_no_recovery_address_is_collected(client, schema): + """The site collects no recovery address, which the account says by carrying none.""" + account_owner(client, "shape-owner") + with override_switch(ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH, active=False): + body = client.get("/api/user/v2/accounts/me/").json() + assert body["secondary_email_enabled"] is None + assert body["secondary_email"] is None + assert_records_match(schema, body, "Account") + + +@pytest.mark.django_db +@skip_unless_lms +def test_an_owner_reading_their_account_where_a_recovery_address_is_collected(client, schema): + """The other side of the same choice: the account says the site collects one.""" + account_owner(client, "shape-owner") + with override_switch(ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH, active=True): + body = client.get("/api/user/v2/accounts/me/").json() + assert body["secondary_email_enabled"] is True + assert body["secondary_email"] is None + assert_records_match(schema, body, "Account") + + +@pytest.mark.django_db +@skip_unless_lms +def test_a_staff_caller_reading_an_account_that_has_no_profile(client, schema): + """Every property a profile holds comes back null for an account that has none.""" + staff = AdminFactory.create(username="shape-staff", password=PASSWORD) + profileless = UserFactory.create(username="shape-profileless", password=PASSWORD) + profileless.profile.delete() + assert client.login(username=staff.username, password=PASSWORD) + body = client.get("/api/user/v2/accounts/shape-profileless/").json() + assert {name: body[name] for name in PROFILE_HELD_PROPERTIES} == dict.fromkeys(PROFILE_HELD_PROPERTIES) + assert_records_match(schema, body, "Account") + + +@pytest.mark.django_db +@skip_unless_lms +def test_a_learner_reading_an_account_whose_owner_shares_it_with_everybody(client, schema): + """A profile shared with all users carries more than the properties everybody may see.""" + shared = UserFactory.create(username="shape-shared", password=PASSWORD) + shared.profile.year_of_birth = 1985 + shared.profile.bio = "Teaches mathematics." + shared.profile.save() + account_owner(client, "shape-reader") + body = client.get("/api/user/v2/accounts/shape-shared/").json() + assert body["account_privacy"] == "all_users" + assert body["bio"] == "Teaches mathematics." + assert body["course_certificates"] is None + assert_records_match(schema, body, "Account") + + +@pytest.mark.django_db +@skip_unless_lms +def test_an_owner_reading_their_account_on_a_site_that_collects_extra_profile_fields(client, schema): + """An extra field answers with the value the site stored, which is not always text.""" + owner = account_owner(client, "shape-owner") + owner.profile.meta = json.dumps({"department": None, "age": 41}) + owner.profile.save() + with with_site_configuration_context(configuration={"extended_profile_fields": ["department", "age"]}): + body = client.get("/api/user/v2/accounts/me/").json() + assert body["extended_profile"] == [ + {"field_name": "department", "field_value": None}, + {"field_name": "age", "field_value": 41}, + ] + assert_records_match(schema, body, "Account") + + +@pytest.mark.django_db +@skip_unless_lms +def test_an_account_holding_a_value_for_every_property_matches_its_component(client, schema): + """ + An account with every property valued is checked against the component. + + A property a body only ever carries as null is one no validator can tell + the published type of, so this account holds what a profile, an + outstanding name change, a recovery address and an accepted name give it. + + This is also the one body the component is held to in both directions. + Elsewhere a record may carry fewer properties than the component + describes, because an owner shares only some of theirs; here the account + is the caller's own and carries every property the answer can hold, so a + property the component promises and this account no longer carries is a + promise to a client that nothing else would catch. + """ + owner = account_owner(client, "shape-filled") + fill_in(owner) + AccountRecoveryFactory.create(user=owner, secondary_email="shape-filled-recovery@example.com") + with override_switch(ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH, active=True): + with name_affirmation_answering("Checked Name"): + body = client.get("/api/user/v2/accounts/me/").json() + assert {name: body[name] for name in FILLED_IN_ONLY_PROPERTIES} == { + "country": "US", + "level_of_education": "m", + "mailing_address": "12 Park Avenue", + "pending_name_change": "Changed Name", + "phone_number": "+18005555555", + "secondary_email": "shape-filled-recovery@example.com", + "state": "MA", + "verified_name": "Checked Name", + } + assert body["bio"] == "Teaches mathematics." + assert body["gender"] == "f" + assert body["goals"] == "Finish the course." + assert body["year_of_birth"] == 1985 + assert body["language_proficiencies"] == [{"code": "fr"}] + assert body["social_links"] == [ + {"platform": "twitter", "social_link": "https://www.twitter.com/example"}, + ] + assert_records_match(schema, body, "Account") + assert set(body) == set(schema["components"]["schemas"]["Account"]["properties"]) + + +@pytest.mark.django_db +@skip_unless_lms +def test_a_change_body_built_from_what_the_document_publishes_is_one_the_endpoint_applies(client, schema): + """ + The published change body is checked by sending one and reading the answer. + + A body a generated client builds comes from the request component alone, + so each property it offers is sent here with a value of the type it + publishes, and the account that comes back has to hold every one of them. + """ + account_owner(client, "shape-changer") + change = { + "bio": "Teaches mathematics.", + "country": "US", + "gender": "f", + "goals": "Finish the course.", + "level_of_education": "m", + "mailing_address": "12 Park Avenue", + "name": "Changed Name", + "phone_number": "+18005555555", + "state": "MA", + "year_of_birth": 1985, + } + validator = jsonschema.Draft202012Validator(published_component_schema(schema, "PatchedAccountUpdate")) + assert [error.message for error in validator.iter_errors(change)] == [] + answer = client.patch( + "/api/user/v2/accounts/me/", + data=json.dumps(change), + content_type="application/merge-patch+json", + ) + assert answer.status_code == 200, answer.content + account = answer.json() + assert {name: account[name] for name in change} == change + assert_records_match(schema, account, "Account") + + +@skip_unless_lms +def test_the_refusal_for_a_retired_email_address_publishes_what_it_adds_to_the_envelope(schema): + """The two members that refusal carries beyond the envelope are typed, not left to be guessed.""" + components = schema["components"]["schemas"] + published = components["RetiredAccountErrorResponse"] + assert set(components["ErrorResponse"]["properties"]) < set(published["properties"]) + assert published["properties"]["can_cancel_retirement"] == { + "type": "boolean", + "description": ( + "Whether the retirement can still be reversed, which it can only while it is inside " + "the cooling-off period." + ), + } + assert published["properties"]["retirement_id"] == { + "type": "integer", + "nullable": True, + "description": ( + "Identifies the retirement to a caller able to reverse it, or null when there is " + "nothing left to reverse." + ), + } + assert published["required"] == components["ErrorResponse"]["required"] + refusal = schema["paths"][ACCOUNT_LIST_PATH]["get"]["responses"]["404"] + assert refusal["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/RetiredAccountErrorResponse", + } + + +@skip_unless_lms +def test_the_version_one_account_addresses_keep_an_operation_id_of_their_own(schema): + """The collection and the member tokenize alike, and are published apart.""" + published = { + (path, method): operation["operationId"] + for path, method, operation in operations(schema) + if path in SUPERSEDED_USER_ADDRESSES + } + assert published[("/api/user/v1/accounts", "get")] == "v1_accounts_list" + assert published[("/api/user/v1/accounts/{username}", "get")] == "v1_accounts_retrieve" + assert published[("/api/user/v1/accounts/{username}", "patch")] == "v1_accounts_partial_update" + assert len(set(published.values())) == len(published) + + +@skip_unless_lms +def test_each_renamed_operation_id_names_an_address_in_the_document_that_derives_it(schema, service_wide): + """ + A rename that outlived the address it was written for would name nothing. + + An entry is keyed by the id the generator derives, which differs between the + document that trims the area off the front of each path and the one that + trims nothing, so each entry applies to exactly one of them. + """ + documents = {"this version": schema, "service-wide": service_wide} + for (path, method, _derived), operation_id in RENAMED_OPERATION_IDS.items(): + naming = [ + name for name, document in documents.items() + if document["paths"].get(path, {}).get(method.lower(), {}).get("operationId") == operation_id + ] + assert len(naming) == 1, (path, method, operation_id, naming) + + +@skip_unless_lms +def test_a_name_change_publishes_the_body_it_reads_and_the_one_it_returns(schema): + operation = schema["paths"][NAME_CHANGE_PATH]["post"] + # A name is posted from a web form as often as it is sent as JSON, so the + # media types a form submits in are published alongside it. + assert set(operation["requestBody"]["content"]) == { + "application/json", "application/x-www-form-urlencoded", "multipart/form-data", + } + for media_type in operation["requestBody"]["content"]: + assert operation["requestBody"]["content"][media_type]["schema"] == { + "$ref": "#/components/schemas/NameChange", + }, media_type + assert operation["responses"]["201"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/NameChange", + } + assert sorted(operation["responses"]) == ["201", "400", "401", "403", "404", "406", "415"] + published = schema["components"]["schemas"]["NameChange"] + assert published["required"] == ["name"] + assert published["properties"]["name"]["maxLength"] == 255 + assert published["properties"]["name"]["description"] == ( + "Full name to put on the profile once the request is confirmed." + ) + + +@skip_unless_lms +def test_a_confirmation_publishes_that_it_reads_nothing_and_answers_with_nothing(schema): + operation = schema["paths"][NAME_CHANGE_CONFIRMATION_PATH]["post"] + assert "requestBody" not in operation + assert operation["responses"]["204"]["description"] == ( + "The profile now carries the name that was asked for." + ) + assert "content" not in operation["responses"]["204"] + assert sorted(operation["responses"]) == ["204", "400", "401", "403", "404", "406"] + + +#: Every form an image may be sent in: the media type, the name a body sent in +#: that type carries, and the extension of the image to send. The extension of +#: the name is held against the media type, and a form part carries its own +#: name, so it needs none. +IMAGE_UPLOAD_FORMS = ( + ("multipart/form-data", None, ".jpeg"), + ("image/gif", "picture.gif", ".gif"), + ("image/jpeg", "picture.jpg", ".jpg"), + ("image/pjpeg", "picture.jpg", ".jpg"), + ("image/png", "picture.png", ".png"), +) + +#: The address the image upload supersedes, which reads the same forms. +SUPERSEDED_IMAGE_PATH = "/api/user/v1/accounts/{username}/image" + + +@skip_unless_lms +def test_the_profile_image_publishes_every_media_type_it_reads_the_image_in(schema): + """The image is read as a part of a form or as the whole body, and both are published.""" + operation = schema["paths"][PROFILE_IMAGE_PATH]["post"] + content = operation["requestBody"]["content"] + assert set(content) == {media_type for media_type, _name, _extension in IMAGE_UPLOAD_FORMS} + assert operation["requestBody"]["required"] is True + assert content["multipart/form-data"]["schema"] == { + "$ref": "#/components/schemas/ProfileImageUpload", + } + for media_type in set(content) - {"multipart/form-data"}: + assert content[media_type]["schema"] == {"type": "string", "format": "binary"}, media_type + header = [ + parameter for parameter in operation["parameters"] if parameter["in"] == "header" + ] + assert [parameter["name"] for parameter in header] == ["Content-Disposition"] + assert header[0].get("required", False) is False + assert 'filename="picture.png"' in header[0]["description"] + published = schema["components"]["schemas"]["ProfileImageUpload"] + assert published["required"] == ["file"] + assert published["properties"]["file"] == { + "type": "string", + "format": "binary", + "description": ( + "Image to show beside the user's name. It is stored in each size the site renders, " + "cropped to a square, and must be a JPEG, PNG or GIF within the size limits this " + "site configures." + ), + } + assert sorted(operation["responses"]) == ["204", "400", "401", "403", "404", "406", "415"] + + +@pytest.mark.django_db +@skip_unless_lms +def test_every_media_type_published_for_an_image_is_one_the_endpoint_stores_it_from(client, schema): + """ + Each form the document offers for an image is sent to the endpoints that serve it. + + A client generated from the document sends one of the published types, so + a type published at either address and refused there would leave such a + client with no way to set an image. Both addresses read the same forms and + publish the same list, which is read from the document here rather than + written out again. + """ + owner = UserFactory.create(username="image_media_types", password=PASSWORD) + assert client.login(username=owner.username, password=PASSWORD) + addresses = { + SUPERSEDED_IMAGE_PATH: f"/api/user/v1/accounts/{owner.username}/image", + PROFILE_IMAGE_PATH: f"/api/user/v2/accounts/{owner.username}/profile_image/", + } + storage = get_profile_image_storage() + try: + for published, address in addresses.items(): + offered = set(schema["paths"][published]["post"]["requestBody"]["content"]) + assert offered == {media_type for media_type, _name, _ext in IMAGE_UPLOAD_FORMS}, published + for media_type, name, extension in IMAGE_UPLOAD_FORMS: + with make_image_file(extension=extension) as image_file: + if name is None: + answer = client.post(address, {"file": image_file}) + else: + answer = client.post( + address, + data=image_file.read(), + content_type=media_type, + HTTP_CONTENT_DISPOSITION=f'attachment; filename="{name}"', + ) + assert answer.status_code == 204, (address, media_type, answer.content) + finally: + for stored in get_profile_image_names(owner.username).values(): + storage.delete(stored) + + +@skip_unless_lms +def test_clearing_the_profile_image_publishes_that_it_reads_nothing(schema): + operation = schema["paths"][PROFILE_IMAGE_PATH]["delete"] + assert "requestBody" not in operation + assert [parameter["name"] for parameter in operation["parameters"]] == ["username"] + assert operation["responses"]["204"]["description"] == "The account carries no image of its own." + assert sorted(operation["responses"]) == ["204", "400", "401", "403", "404", "406"] + assert operation["responses"]["400"]["description"] == ( + "The request carries a query parameter this endpoint does not read." + ) + + +@skip_unless_lms +def test_a_verification_status_publishes_the_properties_it_carries(schema): + body = schema["paths"][VERIFICATION_STATUS_PATH]["get"]["responses"]["200"] + assert body["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/VerificationStatus", + } + published = schema["components"]["schemas"]["VerificationStatus"] + assert set(published["properties"]) == {"is_verified", "status", "expiration_datetime"} + assert sorted(published["required"]) == ["expiration_datetime", "is_verified", "status"] + assert published["properties"]["is_verified"]["type"] == "boolean" + assert all(published["properties"][name]["description"] for name in published["properties"]) + + +@skip_unless_lms +def test_the_day_a_summary_reports_is_published_as_the_day_it_carries(schema): + """ + The summary reports a calendar day and an attempt records a moment, under one name. + + A summary says when an accepted proof runs out as the day alone, so the + property is published with the shape of that day rather than as a + date-time, which is what the same property of an attempt carries. + """ + summary = schema["components"]["schemas"]["VerificationStatus"]["properties"]["expiration_datetime"] + attempt = schema["components"]["schemas"]["Verification"]["properties"]["expiration_datetime"] + assert summary["type"] == "string" + assert summary["pattern"] == EXPIRATION_DAY_PATTERN + assert "format" not in summary + assert "MM/DD/YYYY" in summary["description"] + assert attempt["type"] == "string" + assert attempt["format"] == "date-time" + assert attempt["nullable"] is True + + +@skip_unless_lms +def test_every_status_a_recorded_attempt_can_hold_is_published(schema): + """ + A client switching on these values meets no stored value the text leaves out. + + The attempts come from a table per kind of check, and the statuses those + tables hold are not the same set, so what is published has to name every + one of them. + """ + published = schema["components"]["schemas"]["Verification"]["properties"]["status"]["description"] + stored = { + str(value) + for choices in (IDVerificationAttempt.STATUS, VerificationAttempt.STATUS) + for value, _label in choices + } + assert stored == {"created", "ready", "submitted", "must_retry", "pending", "approved", "denied"} + for value in sorted(stored): + assert f"`{value}`" in published, value + + +@skip_unless_lms +def test_a_verification_attempt_publishes_the_properties_it_carries(schema): + page = schema["components"]["schemas"]["PaginatedVerificationList"] + assert page["properties"]["results"]["items"] == {"$ref": "#/components/schemas/Verification"} + published = schema["components"]["schemas"]["Verification"] + assert set(published["properties"]) == { + "type", "status", "expiration_datetime", "message", "updated_at", "receipt_id", + } + assert published["properties"]["expiration_datetime"]["nullable"] is True + assert published["properties"]["receipt_id"]["nullable"] is True + assert published["properties"]["type"]["readOnly"] is True + assert all(published["properties"][name]["description"] for name in published["properties"]) + + +@skip_unless_lms +def test_an_account_publishes_an_example_of_each_shape_it_answers_with(schema): + """ + Each example a client author reads is a body this endpoint really returns. + + The answer is whichever of three shapes the caller's standing and request + produce, so each is published, and each is held to the component and to + the set of properties that shape carries. + """ + published = schema["paths"][ACCOUNT_MEMBER_PATH]["get"]["responses"]["200"] + examples = published["content"]["application/json"]["examples"] + described_by = { + "YourOwnAccount": "Account", + "AnAccountSomebodyElseOwns": "Account", + "AnAccountReducedToWhatIdentifiesItsOwner": "AccountMinimal", + } + assert list(examples) == list(described_by) + assert [example["description"] for example in examples.values()] == [ + "Everything an account carries, which is what its owner and a staff caller receive.", + "The properties this owner shares with every learner. An owner who shares field by field " + "shares fewer, and one who shares nothing leaves only the three properties every account " + "carries.", + "What `view=minimal` answers with; `fields` selects any other set.", + ] + for name, example in examples.items(): + assert_records_match(schema, example["value"], described_by[name], case=name) + + whole = examples["YourOwnAccount"]["value"] + assert set(whole) == set(schema["components"]["schemas"]["Account"]["properties"]) + shared = examples["AnAccountSomebodyElseOwns"]["value"] + assert set(shared) == set(settings.ACCOUNT_VISIBILITY_CONFIGURATION["bulk_shareable_fields"]) + assert shared["account_privacy"] == "all_users" + reduced = examples["AnAccountReducedToWhatIdentifiesItsOwner"]["value"] + assert set(reduced) == set(ACCOUNT_MINIMAL_FIELDS) + + +@skip_unless_lms +def test_the_verification_list_publishes_the_order_it_can_be_read_in(schema): + """The values published are the values the serializer takes, so the two cannot drift apart.""" + field = VerificationQuerySerializer().fields["ordering"] + parameter = published_ordering(schema, VERIFICATION_LIST_PATH) + assert parameter["schema"]["enum"] == ["updated_at", "-updated_at", ""] + assert parameter["schema"]["enum"] == [*field.choices, ""] + assert parameter["schema"]["default"] == "-updated_at" == field.default + assert parameter["description"].startswith(field.help_text) + assert parameter.get("required", False) is False + + +@skip_unless_lms +def test_every_list_publishes_its_sort_as_the_values_it_takes_and_the_order_it_uses_without_one(schema): + """ + The sort of every list is asked for under one name, and each publishes both + the values it takes and the order a caller that sends none is answered in. + + A list that can be sorted by several properties at once takes them as a + comma-separated array; one with a single sortable property takes that + property on its own, with the empty value asking for the default. + """ + published = {} + for path in ( + "/api/user/v2/users/", + "/api/user/v2/user_preferences/", + ACCOUNT_LIST_PATH, + VERIFICATION_LIST_PATH, + ): + shape = published_ordering(schema, path)["schema"] + published[path] = (shape["type"], sorted(shape.get("items", shape)["enum"]), shape["default"]) + assert published == { + "/api/user/v2/users/": ( + "array", ["-date_joined", "-username", "date_joined", "username"], ["username"], + ), + "/api/user/v2/user_preferences/": ( + "array", ["-key", "-username", "key", "username"], ["username", "key"], + ), + ACCOUNT_LIST_PATH: ("string", ["", "-username", "username"], "username"), + VERIFICATION_LIST_PATH: ("string", ["", "-updated_at", "updated_at"], "-updated_at"), + } + + +@skip_unless_lms +def test_an_account_and_a_row_of_the_account_collection_are_narrowed_by_the_same_presets(schema): + """One account is shaped by the presets that shape a row of the collection, and by the same values.""" + presets = {} + for path, method in ((ACCOUNT_LIST_PATH, "get"), (ACCOUNT_MEMBER_PATH, "get"), (ACCOUNT_MEMBER_PATH, "patch")): + parameter = next( + published for published in schema["paths"][path][method]["parameters"] + if published["name"] == "view" + ) + presets[path, method] = set(parameter["schema"]["enum"]) + assert presets == { + (ACCOUNT_LIST_PATH, "get"): {"minimal", "shared", ""}, + (ACCOUNT_MEMBER_PATH, "get"): {"minimal", "shared", ""}, + (ACCOUNT_MEMBER_PATH, "patch"): {"minimal", "shared", ""}, + } + + +@skip_unless_lms +def test_the_addresses_this_phase_adds_publish_no_other_query_parameters(schema): + """Each of these addresses reads what it names and nothing else.""" + published = { + path: sorted( + parameter["name"] for parameter in operation.get("parameters", []) + if parameter["in"] == "query" + ) + for path, _method, operation in operations(schema) + if path in { + NAME_CHANGE_PATH, + NAME_CHANGE_CONFIRMATION_PATH, + PROFILE_IMAGE_PATH, + VERIFICATION_STATUS_PATH, + VERIFICATION_LIST_PATH, + } + } + assert published == { + NAME_CHANGE_PATH: [], + NAME_CHANGE_CONFIRMATION_PATH: [], + PROFILE_IMAGE_PATH: [], + VERIFICATION_STATUS_PATH: [], + VERIFICATION_LIST_PATH: ["ordering", "page", "page_size"], + } + + +#: The names this resource's operations are published under. The two reads +#: tokenize alike, so the read of one preference is named for what it reads and +#: the read of the whole document keeps the name both addresses derive; left to +#: itself the assembled document tells the two apart with a numeral, which says +#: which address sorts first rather than what a client gets back. +PREFERENCE_OPERATION_IDS = { + (PREFERENCE_DOCUMENT_PATH, "get"): "v2_accounts_preferences_retrieve", + (PREFERENCE_DOCUMENT_PATH, "patch"): "v2_accounts_preferences_partial_update", + (PREFERENCE_NAME_PATH, "get"): "v2_accounts_preferences_retrieve_one", + (PREFERENCE_NAME_PATH, "put"): "v2_accounts_preferences_update", + (PREFERENCE_NAME_PATH, "delete"): "v2_accounts_preferences_destroy", + (EMAIL_OPT_IN_PATH, "post"): "v2_email_opt_ins_create", +} + + +@skip_unless_lms +def test_the_preference_operations_carry_the_names_they_are_published_under(schema): + published = { + (path, method): operation["operationId"] + for path, method, operation in operations(schema) + if path in {PREFERENCE_DOCUMENT_PATH, PREFERENCE_NAME_PATH, EMAIL_OPT_IN_PATH} + } + assert published == PREFERENCE_OPERATION_IDS + + +@skip_unless_lms +def test_the_read_of_one_preference_is_named_in_every_document_that_publishes_it(schema, service_wide): + """ + Each document names that read as it names its four siblings, and loses no name. + + The two documents derive different names for one operation, because one + trims the area off the front of every path and the other trims nothing, so + the read is named in each of them through the name that document derives. + That leaves the five operations of this resource carrying the same leading + tokens wherever they are published, which is what a client generated from + a document takes its module from. The addresses of version 1 are named the + same way, and there only the document that trims the area off is covered: + the one that trims nothing keeps every name it has published, numeral and + all. + """ + assert schema["paths"][PREFERENCE_NAME_PATH]["get"]["operationId"] == ( + "v2_accounts_preferences_retrieve_one" + ) + assert service_wide["paths"][PREFERENCE_NAME_PATH]["get"]["operationId"] == ( + "api_user_v2_accounts_preferences_retrieve_one" + ) + assert schema["paths"][PREFERENCE_DOCUMENT_PATH]["get"]["operationId"] == ( + "v2_accounts_preferences_retrieve" + ) + assert service_wide["paths"][PREFERENCE_DOCUMENT_PATH]["get"]["operationId"] == ( + "api_user_v2_accounts_preferences_retrieve" + ) + assert { + (path, method): operation["operationId"] + for path, method, operation in operations(service_wide) + if path in {PREFERENCE_DOCUMENT_PATH, PREFERENCE_NAME_PATH} + } == { + (PREFERENCE_DOCUMENT_PATH, "get"): "api_user_v2_accounts_preferences_retrieve", + (PREFERENCE_DOCUMENT_PATH, "patch"): "api_user_v2_accounts_preferences_partial_update", + (PREFERENCE_NAME_PATH, "get"): "api_user_v2_accounts_preferences_retrieve_one", + (PREFERENCE_NAME_PATH, "put"): "api_user_v2_accounts_preferences_update", + (PREFERENCE_NAME_PATH, "delete"): "api_user_v2_accounts_preferences_destroy", + } + version_one = { + (path, method): operation["operationId"] + for path, method, operation in operations(service_wide) + if path.startswith("/api/user/v1/preferences/{username}") + } + assert version_one == { + ("/api/user/v1/preferences/{username}", "get"): "api_user_v1_preferences_retrieve", + ("/api/user/v1/preferences/{username}", "patch"): "api_user_v1_preferences_partial_update", + ("/api/user/v1/preferences/{username}/{preference_key}", "get"): ( + "api_user_v1_preferences_retrieve_2" + ), + ("/api/user/v1/preferences/{username}/{preference_key}", "put"): "api_user_v1_preferences_update", + ("/api/user/v1/preferences/{username}/{preference_key}", "delete"): ( + "api_user_v1_preferences_destroy" + ), + } + + +@skip_unless_lms +def test_the_stored_preference_reads_are_named_alike_in_every_document_that_publishes_them(schema, service_wide): + """ + The collection read and the member read of this resource keep one prefix wherever published. + + A client takes the module it writes an operation into from the leading + tokens of that operation's name, so a document naming the two reads of one + resource differently splits the resource across two modules. The documents + derive different names for the member read, because one trims the area off + the front of every path and the other trims nothing, so it is named in + each of them through the name that document derives. + """ + assert { + (path, method): operation["operationId"] + for path, method, operation in operations(schema) + if path in {PREFERENCE_LIST_PATH, PREFERENCE_MEMBER_PATH} + } == { + (PREFERENCE_LIST_PATH, "get"): "v2_user_preferences_list", + (PREFERENCE_MEMBER_PATH, "get"): "v2_user_preferences_retrieve", + } + assert { + (path, method): operation["operationId"] + for path, method, operation in operations(service_wide) + if path in {PREFERENCE_LIST_PATH, PREFERENCE_MEMBER_PATH} + } == { + (PREFERENCE_LIST_PATH, "get"): "api_user_v2_user_preferences_list", + (PREFERENCE_MEMBER_PATH, "get"): "api_user_v2_user_preferences_retrieve", + } + + +@skip_unless_lms +def test_the_preference_document_is_published_as_the_open_ended_object_it_is(schema): + """It has no fixed set of names, so it is described rather than named as a component.""" + members = { + ("get", "application/json"): {"type": "string"}, + # A change may also remove a name, which it does by sending a null. + ("patch", "application/merge-patch+json"): {"type": "string", "nullable": True}, + } + for (method, media_type), member in members.items(): + operation = schema["paths"][PREFERENCE_DOCUMENT_PATH][method] + carried = ( + operation["responses"]["200"] if method == "get" else operation["requestBody"] + )["content"] + assert list(carried) == [media_type], method + body = carried[media_type]["schema"] + assert body["type"] == "object", method + assert body["additionalProperties"] == member, method + assert "$ref" not in body, method + + +@skip_unless_lms +def test_the_preference_document_is_published_as_the_fields_that_carry_it_describe(schema): + """The body is formatted and read by these fields, so each address publishes what its own does.""" + carried = { + ("get", "application/json"): AccountPreferenceDocumentField, + ("patch", "application/merge-patch+json"): AccountPreferenceChangesField, + } + for (method, media_type), field in carried.items(): + operation = schema["paths"][PREFERENCE_DOCUMENT_PATH][method] + published = ( + operation["responses"]["200"] if method == "get" else operation["requestBody"] + )["content"][media_type]["schema"] + assert published == get_override(field, "field"), method + + +@pytest.mark.django_db +@skip_unless_lms +def test_a_merge_patch_the_endpoint_carries_out_is_one_the_request_body_takes(client, schema): + """A null removes a name, so a body generated from the document can express a removal.""" + owner = AdminFactory.create(username="patch-preferences", password=PASSWORD) + UserPreferenceFactory.create(user=owner, key="time_zone", value="Asia/Tokyo") + assert client.login(username=owner.username, password=PASSWORD) + validator = jsonschema.Draft202012Validator(published_request_schema( + schema, PREFERENCE_DOCUMENT_PATH, "patch", "application/merge-patch+json", + )) + assert [error.message for error in validator.iter_errors({"time_zone": None})] == [] + assert [error.message for error in validator.iter_errors({"time_zone": "Europe/Paris"})] == [] + assert [error.message for error in validator.iter_errors({"time_zone": 5})] == [ + "5 is not valid under any of the given schemas", + ] + address = reverse("user_v2:account_preference_list", kwargs={"username": owner.username}) + removal = client.patch( + address, data=json.dumps({"time_zone": None}), content_type="application/merge-patch+json", + ) + assert removal.status_code == 204, removal.content + assert client.get(address).json() == {} + + +@pytest.mark.django_db +@skip_unless_lms +def test_a_change_that_names_no_preference_is_refused_by_the_document_too(client, schema): + """ + A body naming nothing to change is refused, and the document says so as well as the handler. + + A caller that generates a request from the document should learn that the + body has to name something before it is sent, rather than being told so + by a refusal. + """ + owner = AdminFactory.create(username="empty-patch", password=PASSWORD) + UserPreferenceFactory.create(user=owner, key="time_zone", value="Asia/Tokyo") + assert client.login(username=owner.username, password=PASSWORD) + validator = jsonschema.Draft202012Validator(published_request_schema( + schema, PREFERENCE_DOCUMENT_PATH, "patch", "application/merge-patch+json", + )) + assert [error.message for error in validator.iter_errors({})] == [ + "{} should be non-empty", + ] + assert [error.message for error in validator.iter_errors({"time_zone": "Europe/Paris"})] == [] + refusal = client.patch( + reverse("user_v2:account_preference_list", kwargs={"username": owner.username}), + data=json.dumps({}), + content_type="application/merge-patch+json", + ) + assert refusal.status_code == 400, refusal.content + assert refusal.json()["errors"] == { + "non_field_errors": ["Name at least one preference to change."], + } + + +@pytest.mark.django_db +@skip_unless_lms +def test_a_preference_document_the_endpoint_answers_is_what_the_document_describes(client, schema): + owner = AdminFactory.create(username="shape-preferences", password=PASSWORD) + for key, value in (("pref-lang", "en"), ("time_zone", "Asia/Tokyo"), ("visibility.bio", "all_users")): + UserPreferenceFactory.create(user=owner, key=key, value=value) + assert client.login(username=owner.username, password=PASSWORD) + body = client.get( + reverse("user_v2:account_preference_list", kwargs={"username": owner.username}), + ).json() + assert body == {"pref-lang": "en", "time_zone": "Asia/Tokyo", "visibility.bio": "all_users"} + validator = jsonschema.Draft202012Validator( + published_body_schema(schema, PREFERENCE_DOCUMENT_PATH), + ) + assert [error.message for error in validator.iter_errors(body)] == [] + + +@pytest.mark.django_db +@skip_unless_lms +def test_the_email_choice_publishes_the_refusal_a_session_is_answered_with_without_its_token(schema): + """ + The refusal a browser session meets here is published alongside the rest. + + This address is reached from a page, and a session is held to the request + token the site hands out with it, so the refusal of a request that carries + none is the one a browser client meets most. It is driven here and matched + against what the operation publishes, because the choice belongs to the + caller and so nothing else about who they are is refused at this address. + """ + user = UserFactory.create(username="optin_token", password=PASSWORD) + held_to_its_token = APIClient(enforce_csrf_checks=True) + assert held_to_its_token.login(username=user.username, password=PASSWORD) + answer = held_to_its_token.post( + EMAIL_OPT_IN_PATH, + {"course_key": "course-v1:edX+DemoX+Demo", "email_opt_in": True}, + format="json", + ) + assert answer.status_code == 403, answer.content + published = schema["paths"][EMAIL_OPT_IN_PATH]["post"]["responses"] + assert str(answer.status_code) in published + assert published["403"]["description"] == ( + "The request was made with a session cookie and carried none of the request token the " + "site hands out with it, so the site cannot tell that the request came from a page it " + "served. Sending that token with the session, or a signed token in place of the " + "session, is answered." + ) + + +@skip_unless_lms +def test_the_name_a_preference_is_stored_under_is_published_under_one_parameter_name(schema): + """ + Both addresses that carry a preference name carry it under the same name. + + A client generated from the document takes an argument from each path + parameter, so one notion published under two names in one version reaches + a reader as two notions, and a published name cannot be changed afterwards. + """ + carried = { + parameter["name"] + for path, _method, operation in operations(schema) + if path in {PREFERENCE_MEMBER_PATH, PREFERENCE_NAME_PATH} + for parameter in operation["parameters"] + if parameter["in"] == "path" + } + assert carried == {"username", "key"} + + +#: What each preference operation answers, by the address and the method. A +#: change admits only the caller the address names, so the account it names is +#: the caller's own and is there to be changed: no such request is answered +#: with the refusal naming an account that does not exist, and none publishes +#: it. +PREFERENCE_PUBLISHED_ANSWERS = { + (PREFERENCE_DOCUMENT_PATH, "get"): ["200", "400", "401", "403", "404", "406", "500"], + (PREFERENCE_DOCUMENT_PATH, "patch"): ["204", "400", "401", "403", "406", "415", "500"], + (PREFERENCE_NAME_PATH, "get"): ["200", "400", "401", "403", "404", "406", "500"], + (PREFERENCE_NAME_PATH, "put"): ["204", "400", "401", "403", "404", "406", "415", "500"], + (PREFERENCE_NAME_PATH, "delete"): ["204", "400", "401", "403", "404", "406", "500"], +} + + +@skip_unless_lms +def test_each_preference_operation_publishes_the_answers_it_can_give(schema): + published = { + (path, method): sorted(operation["responses"]) + for path, method, operation in operations(schema) + if path in {PREFERENCE_DOCUMENT_PATH, PREFERENCE_NAME_PATH} + } + assert published == PREFERENCE_PUBLISHED_ANSWERS + + +@skip_unless_lms +def test_no_preference_operation_publishes_a_parameter_beyond_the_address_it_is_read_from(schema): + """These addresses read no query parameter: not a page, not a preset, not a selection.""" + published = { + (path, method): [parameter["name"] for parameter in operation.get("parameters", [])] + for path, method, operation in operations(schema) + if path in {PREFERENCE_DOCUMENT_PATH, PREFERENCE_NAME_PATH, EMAIL_OPT_IN_PATH} + } + assert published == { + (PREFERENCE_DOCUMENT_PATH, "get"): ["username"], + (PREFERENCE_DOCUMENT_PATH, "patch"): ["username"], + (PREFERENCE_NAME_PATH, "get"): ["key", "username"], + (PREFERENCE_NAME_PATH, "put"): ["key", "username"], + (PREFERENCE_NAME_PATH, "delete"): ["key", "username"], + (EMAIL_OPT_IN_PATH, "post"): [], + } + + +@skip_unless_lms +def test_the_email_choice_publishes_the_body_it_reads_and_that_it_answers_with_nothing(schema): + operation = schema["paths"][EMAIL_OPT_IN_PATH]["post"] + # The page a learner makes this choice on submits a form, so the media + # types a form submits in are published alongside the JSON body. + assert set(operation["requestBody"]["content"]) == { + "application/json", "application/x-www-form-urlencoded", "multipart/form-data", + } + for media_type, carried in operation["requestBody"]["content"].items(): + assert carried["schema"] == {"$ref": "#/components/schemas/EmailOptIn"}, media_type + assert "content" not in operation["responses"]["204"] + assert sorted(operation["responses"]) == [ + "204", "400", "401", "403", "404", "406", "415", "500", + ] + choice = schema["components"]["schemas"]["EmailOptIn"] + assert sorted(choice["required"]) == ["course_key", "email_opt_in"] + assert choice["properties"]["course_key"]["type"] == "string" + assert choice["properties"]["email_opt_in"]["type"] == "boolean" + # What a boolean is read from here is narrower than the media types allow, + # so the published description says so. + assert choice["properties"]["email_opt_in"]["description"] == ( + "Whether this user wants messages from that organization, sent as true or false. A " + "value that says neither is refused rather than read as one of them." + ) + + +#: What every operation of this version says about a request it can give no +#: answer to. Which representations an answer can be given in is settled as +#: the request enters, before the address it names is read or written, so the +#: refusal reaches an operation that answers with a body and one that does not +#: alike. +UNSATISFIABLE_ACCEPT_DESCRIPTION = ( + "The `Accept` header of the request names no representation this endpoint can answer in." +) + + +@skip_unless_lms +def test_every_operation_publishes_the_refusal_of_an_accept_it_cannot_satisfy(schema): + """ + No operation of this version leaves this refusal unpublished. + + This is asserted over every operation rather than per address, so an + operation added later is held to it without this test being changed. + """ + unpublished = { + f"{method.upper()} {path}" + for path, method, operation in operations(schema) + if path in VERSION_TWO_PATHS and "406" not in operation["responses"] + } + assert unpublished == set() + wrongly_described = { + f"{method.upper()} {path}": operation["responses"]["406"]["description"] + for path, method, operation in operations(schema) + if path in VERSION_TWO_PATHS + and operation["responses"]["406"]["description"] != UNSATISFIABLE_ACCEPT_DESCRIPTION + } + assert wrongly_described == {} + + +@skip_unless_lms +def test_asking_for_a_name_change_publishes_the_refusal_of_a_session_without_its_token(schema): + """ + A write made with a session the site cannot tie to a page it served is refused here too. + + The sibling collection of this version that records a choice for the + caller publishes that refusal; this one answers with it and did not + publish it, so a caller handling failures by the statuses the document + carries fell through to a status it had no model for. + """ + asked = schema["paths"][NAME_CHANGE_PATH]["post"]["responses"] + recorded = schema["paths"][EMAIL_OPT_IN_PATH]["post"]["responses"] + assert "403" in asked + assert asked["403"]["description"] == recorded["403"]["description"] + assert asked["403"]["content"] == recorded["403"]["content"] +def dereferenced(schema, node): + """ + Return a published node with its reference followed, if it carries one. + + A node describing a fixed set of values is published as a component whose + name the generator derives from the field, and that name holds only while + one field of the service claims it, so what the node points at is read + here rather than named. A property carrying a description of its own is + published as the one alternative it may match, which is followed too. + """ + alternatives = node.get("allOf") + if alternatives is not None and len(alternatives) == 1: + node = alternatives[0] + reference = node.get("$ref") + if reference is None: + return node + return schema["components"]["schemas"][reference.rsplit("/", 1)[-1]] + + +def published_property(schema, path, method, name): + """Return the published description of one property of a request body.""" + body = schema["paths"][path][method]["requestBody"]["content"]["application/json"]["schema"] + return dereferenced(schema, dereferenced(schema, body)["properties"][name]) + + +@skip_unless_lms +def test_the_erasure_publishes_the_sets_of_data_it_can_erase(schema): + """A caller picks one of them in the body, so each is named in the document.""" + scope = published_property(schema, RETIREMENT_ERASURE_PATH, "post", "scope") + assert scope["enum"] == list(ERASURE_SCOPES) + body = schema["paths"][RETIREMENT_ERASURE_PATH]["post"]["requestBody"] + assert body["required"] is True + assert dereferenced( + schema, body["content"]["application/json"]["schema"], + )["required"] == ["scope"] + + +@skip_unless_lms +def test_a_batch_publishes_what_it_can_report_about_each_name_it_is_given(schema): + result = schema["components"]["schemas"]["RetirementBatchResult"] + outcome = dereferenced(schema, result["properties"]["outcome"]) + assert outcome["enum"] == list(BATCH_OUTCOMES) + assert sorted(result["properties"]) == ["outcome", "username"] + assert sorted(result["required"]) == ["outcome", "username"] + + +@skip_unless_lms +def test_both_batch_addresses_publish_the_same_exchange(schema): + """They act on different records, and a caller reads and writes the same shapes at both.""" + exchanges = { + path: ( + schema["paths"][path]["post"]["requestBody"]["content"]["application/json"]["schema"], + schema["paths"][path]["post"]["responses"]["200"]["content"]["application/json"]["schema"], + ) + for path in (RETIREMENT_CLEANUP_PATH, PARTNER_REPORT_CLEANUP_PATH) + } + assert exchanges[RETIREMENT_CLEANUP_PATH] == exchanges[PARTNER_REPORT_CLEANUP_PATH] + assert exchanges[RETIREMENT_CLEANUP_PATH] == ( + {"$ref": "#/components/schemas/RetirementBatch"}, + {"$ref": "#/components/schemas/RetirementBatchOutcome"}, + ) + + +@skip_unless_lms +def test_the_queue_publishes_the_filters_it_narrows_by(schema): + parameters = { + parameter["name"]: parameter + for parameter in schema["paths"][RETIREMENT_LIST_PATH]["get"]["parameters"] + } + assert set(parameters) == { + "cool_off_days", "created_after", "created_before", "state", + "fields", "ordering", "page", "page_size", "view", + } + for name in ("cool_off_days", "created_after", "created_before", "state"): + assert parameters[name]["in"] == "query" + assert parameters[name].get("required", False) is False, name + assert parameters[name]["description"], name + assert parameters["cool_off_days"]["schema"] == {"type": "integer", "minimum": 0, "maximum": 36500} + assert parameters["created_after"]["schema"]["format"] == "date-time" + assert parameters["created_before"]["schema"]["format"] == "date-time" + assert parameters["state"]["schema"]["type"] == "string" + assert parameters["ordering"]["schema"]["items"]["enum"] == sorted( + [*RETIREMENT_SORT_FIELDS, *(f"-{field}" for field in RETIREMENT_SORT_FIELDS)], + ) + assert "default" not in parameters["ordering"]["schema"] + + +@skip_unless_lms +def test_the_claim_publishes_how_many_entries_it_can_be_held_to(schema): + parameters = { + parameter["name"]: parameter + for parameter in schema["paths"][PARTNER_REPORT_LIST_PATH]["post"]["parameters"] + } + assert set(parameters) == {"limit"} + assert parameters["limit"]["schema"] == {"type": "integer", "minimum": 1, "maximum": 100} + assert parameters["limit"].get("required", False) is False + assert "requestBody" not in schema["paths"][PARTNER_REPORT_LIST_PATH]["post"] + + +@skip_unless_lms +def test_the_claim_answers_with_an_array_rather_than_a_page(schema): + """The entries answered are the entries taken, so the answer is not cut into pages.""" + body = schema["paths"][PARTNER_REPORT_LIST_PATH]["post"]["responses"]["200"] + assert body["content"]["application/json"]["schema"] == { + "type": "array", + "items": {"$ref": "#/components/schemas/RetirementPartnerReportRecord"}, + } + + +@skip_unless_lms +def test_selecting_properties_publishes_how_far_a_name_reaches(schema): + """A caller is told that a dotted name selects nothing and is refused.""" + for path, method in ( + (RETIREMENT_MEMBER_PATH, "get"), + (RETIREMENT_LIST_PATH, "get"), + ): + described = { + parameter["name"]: parameter["description"] + for parameter in schema["paths"][path][method]["parameters"] + }["fields"] + assert "`user.email`" in described, (path, method) + assert "names no field a response carries and is refused" in described, (path, method) + assert "the whole of that property is returned" not in described, (path, method) + + +@pytest.mark.django_db +@skip_unless_lms +def test_the_claim_publishes_exactly_the_properties_an_entry_carries(schema): + """An entry is published with the properties it is answered with, and with no others.""" + user = UserFactory.create() + UserRetirementPartnerReportingStatus.objects.create( + user=user, + original_username=user.username, + original_email=user.email, + original_name=user.profile.name, + ) + answered = set(claim_partner_reports(UserRetirementPartnerReportingStatus.objects.all())[0]) + record = schema["components"]["schemas"]["RetirementPartnerReportRecord"] + assert set(record["properties"]) == answered + assert set(record["required"]) == answered + + +@skip_unless_lms +def test_the_queue_answers_with_a_page(schema): + body = schema["paths"][RETIREMENT_LIST_PATH]["get"]["responses"]["200"] + assert body["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/PaginatedRetirementResponseList", + } + page = schema["components"]["schemas"]["PaginatedRetirementResponseList"] + assert set(page["properties"]) == PAGINATION_MEMBERS + assert page["properties"]["results"] == { + "type": "array", + "items": {"$ref": "#/components/schemas/RetirementResponse"}, + } + + +@skip_unless_lms +def test_a_retirement_record_publishes_the_step_as_a_name(schema): + record = schema["components"]["schemas"]["Retirement"] + for name in ("current_state", "last_state"): + assert record["properties"][name]["type"] == "string", name + assert set(record["properties"]) == { + "id", "user", "original_username", "original_email", "original_name", + "retired_username", "retired_email", "current_state", "last_state", "created", "modified", + } + assert "responses" not in record["properties"] + + +#: What each operation of the retirement pipeline answers, by the address and +#: the method. The erasure is the one operation of this area that lets a +#: failure of the work it drives reach the caller, because a step that fails +#: partway through has already erased part of what it names and the caller +#: decides what to do about that, so it is the one that publishes a 500. +RETIREMENT_PUBLISHED_ANSWERS = { + (RETIREMENT_LIST_PATH, "get"): ["200", "400", "401", "403", "404", "406"], + (RETIREMENT_MEMBER_PATH, "get"): ["200", "400", "401", "403", "404", "406"], + (RETIREMENT_MEMBER_PATH, "patch"): ["204", "400", "401", "403", "404", "406", "409", "415"], + (RETIREMENT_MEMBER_PATH, "delete"): ["204", "400", "401", "403", "404", "406", "409"], + (RETIREMENT_ERASURE_PATH, "post"): [ + "204", "400", "401", "403", "404", "406", "409", "415", "500", + ], + (RETIREMENT_CLEANUP_PATH, "post"): ["200", "400", "401", "403", "406", "409", "415"], + (PARTNER_REPORT_LIST_PATH, "post"): ["200", "400", "401", "403", "406"], + (PARTNER_REPORT_MEMBER_PATH, "put"): ["204", "400", "401", "403", "404", "406", "409"], + (PARTNER_REPORT_CLEANUP_PATH, "post"): ["200", "400", "401", "403", "406", "409", "415"], +} + + +@skip_unless_lms +def test_each_retirement_operation_publishes_the_answers_it_can_give(schema): + published = { + (path, method): sorted(operation["responses"]) + for path, method, operation in operations(schema) + if path in RETIREMENT_PATHS + } + assert published == RETIREMENT_PUBLISHED_ANSWERS + + +@skip_unless_lms +def test_the_move_of_a_retirement_publishes_the_whole_body_it_reads(schema): + """ + The move is an instruction read whole, so what it requires is published as required. + + A body carrying changes to a stored record leaves every field optional and + is published under a name of its own. This one is refused when either the + step to move to or what happened at the step being left is missing, so a + caller generating a request from the document is told both are needed, and + the account update beside it -- which really does carry changes -- keeps + the optional form. + """ + body = schema["paths"][RETIREMENT_MEMBER_PATH]["patch"]["requestBody"] + assert body["required"] is True + assert body["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/RetirementUpdate", + } + component = schema["components"]["schemas"]["RetirementUpdate"] + assert component["required"] == ["new_state", "response"] + assert sorted(component["properties"]) == ["force", "new_state", "response"] + assert "PatchedRetirementUpdate" not in schema["components"]["schemas"] + + changes = schema["paths"][ACCOUNT_MEMBER_PATH]["patch"]["requestBody"] + assert changes.get("required", False) is False + assert "required" not in schema["components"]["schemas"]["PatchedAccountUpdate"] + + +@skip_unless_lms +def test_a_retirement_publishes_the_profile_it_answers_as_null_where_there_is_none(schema): + """An account being retired need not hold a profile, and the property is answered either way.""" + account = schema["components"]["schemas"]["RetirementAccount"] + assert account["properties"]["profile"] == { + "allOf": [{"$ref": "#/components/schemas/RetirementAccountProfile"}], + "description": "The profile of the account, or null for an account that has none.", + "readOnly": True, + "nullable": True, + } + assert "profile" in account["required"] + # The profile itself carries no property that may be absent, so nothing + # below it is published as nullable. + profile = schema["components"]["schemas"]["RetirementAccountProfile"] + assert [name for name, published in profile["properties"].items() if published.get("nullable")] == [] + + +#: What the body of a superseded operation requires, for the operations whose +#: body is written out as a shape rather than named by a serializer. The +#: entries are addresses of several resources, because the rule is read from +#: the shape and not from the address it is published at. +SUPERSEDED_NEEDED_BODIES = { + ("/api/user/v1/accounts/retirement_cleanup/", "post"): ["usernames"], + ("/api/user/v1/accounts/retirement_partner_report/", "put"): ["username"], + ("/api/user/v1/accounts/retirement_partner_report_cleanup/", "post"): ["original_username"], + ("/api/user/v1/accounts/retire/", "post"): ["username"], + ("/api/user/v1/accounts/retire_misc/", "post"): ["username"], + ("/api/user/v1/accounts/update_retirement_status/", "patch"): ["username", "new_state", "response"], +} + + +def members_needed(shape): + """Return the members a body shape cannot be sent without, through a list to its members.""" + if "items" in shape: + return members_needed(shape["items"]) + return shape["required"] + + +@skip_unless_lms +def test_a_superseded_operation_publishes_a_body_it_cannot_be_called_without(schema, service_wide): + """ + A body written out as a shape says what it needs, and the operation needs it. + + An operation whose body is named by a serializer is marked as needing one + by the fields that serializer requires. A body written out as a shape is + published as it stands, so nothing has judged whether the operation can be + called with no body at all: it is read here from what the shape itself + says it cannot be sent without. Both documents are checked, because the + one the service serves for browsing its whole surface has published these + operations for years and a client generated from it stops sending a body + the moment the document stops asking for one. + """ + for document in (schema, service_wide): + for (path, method), needed in SUPERSEDED_NEEDED_BODIES.items(): + body = document["paths"][path][method]["requestBody"] + assert body["required"] is True, (path, method) + assert members_needed( + body["content"]["application/json"]["schema"], + ) == needed, (path, method) + + +@skip_unless_lms +def test_the_claim_of_the_partner_report_queue_publishes_no_body_on_either_version(schema, service_wide): + """Neither reads one: the queue it drains is named by nothing the caller sends.""" + for document in (schema, service_wide): + for path in ("/api/user/v1/accounts/retirement_partner_report/", PARTNER_REPORT_LIST_PATH): + assert "requestBody" not in document["paths"][path]["post"], path + + +#: The retirement-status model as it was published while the corrected request +#: body was its only reference. It is written out here rather than read from +#: the configuration, so that a pin which no longer describes what a client +#: already generates from fails this. +PATCHED_RETIREMENT_STATUS_COMPONENT = { + "type": "object", + "description": "Perform serialization for the RetirementStatus model", + "properties": { + "id": {"type": "integer", "readOnly": True}, + "user": {"allOf": [{"$ref": "#/components/schemas/RetirementUser"}], "readOnly": True}, + "current_state": {"allOf": [{"$ref": "#/components/schemas/RetirementState"}], "readOnly": True}, + "last_state": {"allOf": [{"$ref": "#/components/schemas/RetirementState"}], "readOnly": True}, + "created": {"type": "string", "format": "date-time", "readOnly": True}, + "modified": {"type": "string", "format": "date-time", "readOnly": True}, + "original_username": {"type": "string", "maxLength": 150}, + "original_email": {"type": "string", "format": "email", "maxLength": 254}, + "original_name": {"type": "string", "maxLength": 255}, + "retired_username": {"type": "string", "maxLength": 150}, + "retired_email": {"type": "string", "format": "email", "maxLength": 254}, + }, +} + + +@skip_unless_lms +def test_the_component_the_corrected_request_body_leaves_behind_is_carried_by_both_documents(schema, service_wide): + """ + The partial update of a version 1 retirement was the component's only reference. + + A client generated from a document names a class after each component in + it, so a component that loses its last reference is named in the settings + both documents are built from, rather than disappearing with the body that + was wrong. The document the service serves for browsing its whole surface + overlays its own keys on those settings and keeps this one, and the + narrower document a client library is generated from carries it too. + """ + for document in (schema, service_wide): + components = document["components"]["schemas"] + assert components["PatchedUserRetirementStatus"] == PATCHED_RETIREMENT_STATUS_COMPONENT + # Nothing the kept component refers to is missing from the document + # that carries it, so no reference in it dangles. + assert "RetirementUser" in components + assert "RetirementState" in components + + +#: The two addresses whose published name the body correction would otherwise +#: move, with the name the service-wide document publishes for each. +CORRECTED_QUEUE_READS = { + "/api/user/v1/accounts/retirement_queue/": "api_user_v1_accounts_retirement_queue_retrieve", + "/api/user/v1/accounts/retirements_by_status_and_date/": ( + "api_user_v1_accounts_retirements_by_status_and_date_retrieve" + ), +} + + +@pytest.mark.django_db +@skip_unless_lms +def test_the_corrected_queue_reads_keep_the_names_the_service_wide_document_publishes(schema, service_wide): + """ + Correcting a body to the array it answers with would otherwise move its name. + + The generator names an operation for what it answers with, so an operation + whose body is corrected from one record to the array of them is named for a + list where it was named for a single record. Both of these have been + published under the single-record name for years and a client imports a + module by that name, so each is pinned; the document that has published + neither keeps the name it derives. + """ + published = { + path: service_wide["paths"][path]["get"]["operationId"] for path in CORRECTED_QUEUE_READS + } + assert published == CORRECTED_QUEUE_READS + + with mock.patch.dict("openedx.core.djangoapps.user_api.schema.RENAMED_OPERATION_IDS", {}, clear=True): + unpinned = service_wide_from_user_mounts() + assert { + path: unpinned["paths"][path]["get"]["operationId"] for path in CORRECTED_QUEUE_READS + } == { + "/api/user/v1/accounts/retirement_queue/": "api_user_v1_accounts_retirement_queue_list", + "/api/user/v1/accounts/retirements_by_status_and_date/": ( + "api_user_v1_accounts_retirements_by_status_and_date_list" + ), + } + # The document this area's version is published in has carried neither + # address before, so each keeps the name derived there. + assert { + path: schema["paths"][path]["get"]["operationId"] for path in CORRECTED_QUEUE_READS + } == { + "/api/user/v1/accounts/retirement_queue/": "v1_accounts_retirement_queue_list", + "/api/user/v1/accounts/retirements_by_status_and_date/": ( + "v1_accounts_retirements_by_status_and_date_list" + ), + } diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_selectors.py b/openedx/core/djangoapps/user_api/v2/tests/test_selectors.py new file mode 100644 index 000000000000..c64f945d4f66 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_selectors.py @@ -0,0 +1,304 @@ +"""Tests for the selections version 2 of the User API answers from.""" + +import datetime + +import pytest +from django.conf import settings +from django.test import TestCase + +from common.djangoapps.student.models import get_retired_username_by_username +from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory +from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import ( # pylint: disable=unused-import + create_retirement_status, + setup_retirement_states, # noqa: F401 +) +from openedx.core.djangoapps.user_api.models import ( + RetirementState, + UserRetirementPartnerReportingStatus, + UserRetirementStatus, +) +from openedx.core.djangoapps.user_api.v2.selectors import ( + accounts_holding_emails, + cancellable_retirement_for_email, + partner_reports, + partner_reports_requested_as, + retirement_of_the_account_named, + retirements, + retirements_requested_as, + unclaimed_partner_reports, + username_holding_id, + usernames_among, + usernames_holding_email, +) +from openedx.core.djangolib.testing.utils import skip_unless_lms + + +@skip_unless_lms +class AccountSelectionTests(TestCase): + """What the account selections answer, asked for outside a request.""" + + def setUp(self): + super().setUp() + self.first = UserFactory.create(username="first-holder", email="first@example.com") + self.second = UserFactory.create(username="second-holder", email="second@example.com") + + def test_the_accounts_holding_a_set_of_addresses_come_back_by_name(self): + found = accounts_holding_emails(["second@example.com", "first@example.com"]) + assert [user.username for user in found] == ["first-holder", "second-holder"] + + def test_an_address_no_account_holds_is_left_out(self): + found = accounts_holding_emails(["first@example.com", "nobody@example.com"]) + assert [user.username for user in found] == ["first-holder"] + + def test_no_addresses_find_no_accounts(self): + assert not list(accounts_holding_emails([])) + + def test_the_accounts_named_come_back_in_the_order_asked_for(self): + assert list(usernames_among(["second-holder", "first-holder"], "username")) == [ + "first-holder", "second-holder", + ] + assert list(usernames_among(["first-holder", "second-holder"], "-username")) == [ + "second-holder", "first-holder", + ] + + def test_a_name_no_account_holds_is_left_out_of_the_selection(self): + assert list(usernames_among(["first-holder", "nobody-at-all"], "username")) == ["first-holder"] + + def test_no_names_select_no_accounts(self): + assert not list(usernames_among([], "username")) + + def test_the_account_holding_one_address_is_named(self): + assert usernames_holding_email("second@example.com") == ["second-holder"] + + def test_no_account_holds_an_unregistered_address(self): + assert usernames_holding_email("nobody@example.com") == [] + + def test_every_account_holding_one_address_is_named(self): + """Nothing keeps one address to one account, so an address can be held by several.""" + UserFactory.create(username="also-second", email="second@example.com") + assert usernames_holding_email("second@example.com") == ["also-second", "second-holder"] + + def test_the_account_holding_one_identifier_is_named(self): + assert username_holding_id(self.second.id) == "second-holder" + + def test_no_account_holds_an_unused_identifier(self): + assert username_holding_id(self.second.id + 1000) is None + + +@skip_unless_lms +class RetirementSelectionTests(TestCase): + """Which retirement of a retired address can still be cancelled.""" + + def setUp(self): + super().setUp() + RetirementState.objects.create(state_name="PENDING", state_execution_order=1) + self.user = UserFactory.create(username="leaving", email="leaving@example.com") + + def age(self, retirement, days): + """Record the given retirement as having been asked for that many days ago.""" + asked = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=days) + UserRetirementStatus.objects.filter(pk=retirement.pk).update(created=asked) + + def test_a_retirement_inside_the_cooling_off_period_is_the_one_reported(self): + retirement = UserRetirementStatus.create_retirement(self.user) + found = cancellable_retirement_for_email("leaving@example.com") + assert found is not None + assert found.pk == retirement.pk + assert found.original_email == "leaving@example.com" + + def test_a_retirement_older_than_the_cooling_off_period_is_not_reported(self): + retirement = UserRetirementStatus.create_retirement(self.user) + self.age(retirement, settings.COOL_OFF_DAYS + 1) + assert cancellable_retirement_for_email("leaving@example.com") is None + + def test_an_address_with_no_retirement_behind_it_has_none(self): + UserRetirementStatus.create_retirement(self.user) + assert cancellable_retirement_for_email("staying@example.com") is None + + def test_the_most_recently_asked_for_retirement_of_an_address_is_the_one_reported(self): + """One address can have been held and left more than once; the last one is cancellable.""" + earlier = UserRetirementStatus.create_retirement(self.user) + self.age(earlier, 2) + again = UserFactory.create(username="leaving-again", email="leaving-again@example.com") + later = UserRetirementStatus.create_retirement(again) + UserRetirementStatus.objects.filter(pk=later.pk).update(original_email="leaving@example.com") + found = cancellable_retirement_for_email("leaving@example.com") + assert found.pk == later.pk + assert found.user_id == again.id + + +@skip_unless_lms +@pytest.mark.usefixtures("setup_retirement_states") +class RetirementPipelineSelectionTests(TestCase): + """What the retirement selections answer, asked for outside a request.""" + + def setUp(self): + super().setUp() + self.subject = UserFactory.create(username="pipeline-subject", email="subject@example.com") + self.retirement = create_retirement_status(self.subject) + + def test_the_queue_carries_the_account_and_both_steps_without_asking_again(self): + selected = retirements().get(id=self.retirement.id) + with self.assertNumQueries(0): + assert selected.user.username == "pipeline-subject" + assert selected.user.profile.name == self.subject.profile.name + assert selected.current_state.state_name == "PENDING" + assert selected.last_state.state_name == "PENDING" + + def test_an_account_is_addressed_by_the_name_it_was_registered_under(self): + assert retirement_of_the_account_named(retirements(), "pipeline-subject").id == ( + self.retirement.id + ) + + def test_an_account_is_addressed_by_the_hashed_name_it_was_moved_to(self): + hashed = get_retired_username_by_username("pipeline-subject") + self.subject.username = hashed + self.subject.save() + assert retirement_of_the_account_named(retirements(), hashed).id == self.retirement.id + assert retirement_of_the_account_named(retirements(), "pipeline-subject").id == ( + self.retirement.id + ) + + def test_a_name_no_account_holds_addresses_no_retirement(self): + assert retirement_of_the_account_named(retirements(), "nobody") is None + + def test_an_account_with_no_retirement_behind_it_addresses_none(self): + UserFactory.create(username="not-retiring") + assert retirement_of_the_account_named(retirements(), "not-retiring") is None + + def test_a_record_the_selection_given_leaves_out_addresses_no_retirement(self): + """Each selection is asked of the records the caller may act on, not of every record held.""" + assert retirement_of_the_account_named( + retirements().exclude(id=self.retirement.id), "pipeline-subject", + ) is None + + def test_a_retirement_is_found_by_the_name_it_was_requested_under(self): + found = retirements_requested_as(retirements(), ["pipeline-subject", "nobody"]) + assert set(found) == {"pipeline-subject"} + assert [row.id for row in found["pipeline-subject"]] == [self.retirement.id] + + def test_a_name_spelled_differently_finds_no_retirement(self): + """Stored names are compared without regard to case in some deployments, so this is exact.""" + assert set(retirements_requested_as(retirements(), ["Pipeline-Subject"])) == set() + + def test_both_retirements_of_a_pair_differing_only_in_case_are_found_separately(self): + other = UserFactory.create(username="PIPELINE-SUBJECT", email="other@example.com") + other_retirement = create_retirement_status(other) + found = retirements_requested_as(retirements(), ["pipeline-subject", "PIPELINE-SUBJECT"]) + assert [row.id for row in found["pipeline-subject"]] == [self.retirement.id] + assert [row.id for row in found["PIPELINE-SUBJECT"]] == [other_retirement.id] + + def test_every_retirement_requested_under_one_name_is_found_under_it(self): + """The stored name is indexed but not unique, so a name may carry several records.""" + twin = create_retirement_status(UserFactory.create(username="pipeline-twin")) + UserRetirementStatus.objects.filter(id=twin.id).update(original_username="pipeline-subject") + found = retirements_requested_as(retirements(), ["pipeline-subject"]) + assert sorted(row.id for row in found["pipeline-subject"]) == sorted( + [self.retirement.id, twin.id], + ) + + def test_the_partner_report_queue_leaves_out_what_has_been_taken(self): + waiting = self.queued(self.subject) + taken_user = UserFactory.create(username="already-taken") + self.queued(taken_user, taken=True) + assert [report.id for report in unclaimed_partner_reports()] == [waiting.id] + + def test_every_entry_is_offered_for_disposal_whether_it_has_been_taken_or_not(self): + """An entry is disposed of once it has been taken, which is when the queue stops offering it.""" + waiting = self.queued(self.subject) + taken = self.queued(UserFactory.create(username="already-taken"), taken=True) + assert sorted(report.id for report in partner_reports()) == sorted([waiting.id, taken.id]) + + def test_the_partner_report_queue_carries_the_courses_each_account_studied(self): + self.queued(self.subject) + CourseEnrollmentFactory.create(user=self.subject, course_id="course-v1:edX+DemoX+Demo") + report = list(unclaimed_partner_reports())[0] + with self.assertNumQueries(0): + assert [ + enrolment.course_id.org for enrolment in report.user.courseenrollment_set.all() + ] == ["edX"] + + def test_a_partner_report_is_found_by_the_name_it_was_registered_under(self): + report = self.queued(self.subject) + found = partner_reports_requested_as(partner_reports(), ["pipeline-subject", "nobody"]) + assert set(found) == {"pipeline-subject"} + assert [row.id for row in found["pipeline-subject"]] == [report.id] + + def test_every_partner_report_registered_under_one_name_is_found_under_it(self): + report = self.queued(self.subject) + twin = self.queued(UserFactory.create(username="pipeline-twin")) + UserRetirementPartnerReportingStatus.objects.filter(id=twin.id).update( + original_username="pipeline-subject", + ) + found = partner_reports_requested_as(partner_reports(), ["pipeline-subject"]) + assert sorted(row.id for row in found["pipeline-subject"]) == sorted([report.id, twin.id]) + + def test_a_partner_report_is_not_found_by_a_name_spelled_differently(self): + self.queued(self.subject) + assert set(partner_reports_requested_as(partner_reports(), ["Pipeline-Subject"])) == set() + + def queued(self, user, taken=False): + """Return a partner report entry for this account, taken for processing or not.""" + return UserRetirementPartnerReportingStatus.objects.create( + user=user, + original_username=user.username, + original_email=user.email, + original_name=user.profile.name, + is_being_processed=taken, + ) + + +class _CollationIgnoringCase: + """ + Stands in for a database that ignores case when it compares stored names. + + The deployments this runs on do exactly that, which once let a learner + register a retired name again by changing its capitalisation; the database + the tests run on compares names character for character, so what the + selections do with a second row that differs only in case is settled here + rather than left to the database to decide. + """ + + def __init__(self, rows): + self.rows = rows + + def filter(self, **kwargs): # pylint: disable=unused-argument + """Return every row, whichever names were asked for.""" + return self.rows + + +@skip_unless_lms +@pytest.mark.usefixtures("setup_retirement_states") +class CaseExactSelectionTests(TestCase): + """A name is compared character for character, whatever the database would compare.""" + + def setUp(self): + super().setUp() + self.lower = UserFactory.create(username="exact-subject", email="lower@example.com") + self.upper = UserFactory.create(username="EXACT-SUBJECT", email="upper@example.com") + + def test_a_retirement_is_selected_by_the_spelling_asked_for(self): + wanted = create_retirement_status(self.lower) + create_retirement_status(self.upper) + offered = _CollationIgnoringCase(list(UserRetirementStatus.objects.all())) + found = retirements_requested_as(offered, ["exact-subject"]) + assert set(found) == {"exact-subject"} + assert [row.id for row in found["exact-subject"]] == [wanted.id] + + def test_a_partner_report_is_selected_by_the_spelling_asked_for(self): + wanted = UserRetirementPartnerReportingStatus.objects.create( + user=self.lower, + original_username=self.lower.username, + original_email=self.lower.email, + original_name=self.lower.profile.name, + ) + UserRetirementPartnerReportingStatus.objects.create( + user=self.upper, + original_username=self.upper.username, + original_email=self.upper.email, + original_name=self.upper.profile.name, + ) + offered = _CollationIgnoringCase(list(UserRetirementPartnerReportingStatus.objects.all())) + found = partner_reports_requested_as(offered, ["exact-subject"]) + assert set(found) == {"exact-subject"} + assert [row.id for row in found["exact-subject"]] == [wanted.id] diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_url_mount.py b/openedx/core/djangoapps/user_api/v2/tests/test_url_mount.py new file mode 100644 index 000000000000..a50b202579e6 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_url_mount.py @@ -0,0 +1,84 @@ +"""Addresses of other apps that share the prefix version 2 of the User API is mounted on.""" + +from django.test import SimpleTestCase, TestCase +from django.urls import resolve, reverse + +from openedx.core.djangolib.testing.utils import skip_unless_lms + +AUTHN_LOGIN_VIEWS = "openedx.core.djangoapps.user_authn.views.login" +AUTHN_REGISTER_VIEWS = "openedx.core.djangoapps.user_authn.views.register" + + +@skip_unless_lms +class SharedPrefixTests(SimpleTestCase): + """ + The authentication app keeps the two addresses it serves under this prefix. + + The mount for this version is matched before the include that carries the + rest of the user surface and before the authentication app's own patterns, + so it is first in line for every address that begins with + ``/api/user/v2/``. Those two addresses reach the authentication app only + because no pattern here matches them first. + """ + + def assert_resolves_to(self, address, module, class_name, url_name, kwargs=None): + """Assert an address reaches the named view through the named route.""" + match = resolve(address) + view_class = match.func.view_class + assert (view_class.__module__, view_class.__name__) == (module, class_name), address + assert match.url_name == url_name, address + assert match.kwargs == (kwargs or {}), address + + def test_registration_still_reaches_the_authentication_app(self): + self.assert_resolves_to( + "/api/user/v2/account/registration/", + AUTHN_REGISTER_VIEWS, + "RegistrationView", + "user_api_registration_v2", + ) + + def test_login_session_still_reaches_the_authentication_app(self): + self.assert_resolves_to( + "/api/user/v2/account/login_session/", + AUTHN_LOGIN_VIEWS, + "LoginSessionView", + "user_api_login_session", + {"api_version": "v2"}, + ) + + def test_the_version_one_addresses_beside_them_are_unaffected(self): + self.assert_resolves_to( + "/api/user/v1/account/registration/", + AUTHN_REGISTER_VIEWS, + "RegistrationView", + "user_api_registration", + ) + self.assert_resolves_to( + "/api/user/v1/account/login_session/", + AUTHN_LOGIN_VIEWS, + "LoginSessionView", + "user_api_login_session", + {"api_version": "v1"}, + ) + + def test_both_shared_prefix_addresses_still_reverse_to_themselves(self): + assert reverse("user_api_registration_v2") == "/api/user/v2/account/registration/" + assert reverse( + "user_api_login_session", kwargs={"api_version": "v2"}, + ) == "/api/user/v2/account/login_session/" + + +@skip_unless_lms +class UnservedAddressTests(TestCase): + """An address under this prefix is answered as an address anywhere else in the service is.""" + + def test_an_address_no_route_serves_is_answered_as_one_outside_this_prefix(self): + ours = self.client.get("/api/user/v2/not_a_collection/", HTTP_ACCEPT="application/json") + elsewhere = self.client.get("/api/not_a_service/v2/not_a_collection/", HTTP_ACCEPT="application/json") + assert ours.status_code == elsewhere.status_code == 404 + assert ours["Content-Type"] == elsewhere["Content-Type"] + + def test_an_address_sent_without_its_slash_is_redirected_whichever_app_serves_it(self): + for address in ("/api/user/v2/time_zones", "/api/user/v2/account/registration"): + response = self.client.get(address) + assert (response.status_code, response["Location"]) == (301, f"{address}/"), address diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_urls.py b/openedx/core/djangoapps/user_api/v2/tests/test_urls.py new file mode 100644 index 000000000000..fbedb698047f --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_urls.py @@ -0,0 +1,825 @@ +"""Tests for the version 2 User API URL surface.""" + +import re + +import pytest +from django.core.exceptions import ImproperlyConfigured +from django.urls import NoReverseMatch, Resolver404, get_resolver, resolve, reverse +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser +from rest_framework import status +from rest_framework.request import Request +from rest_framework.settings import api_settings +from rest_framework.test import APIRequestFactory, APITestCase +from rest_framework.views import APIView + +from openedx.core.djangoapps.profile_images.v2.views import ProfileImageView +from openedx.core.djangoapps.user_api.v2 import urls as v2_urls +from openedx.core.djangoapps.user_api.v2.views import ( + AccountDeactivationView, + AccountPreferenceViewSet, + AccountViewSet, + EmailOptInViewSet, + IDVerificationStatusView, + IDVerificationViewSet, + NameChangeViewSet, + RetirementCleanupViewSet, + RetirementPartnerReportCleanupViewSet, + RetirementPartnerReportViewSet, + RetirementViewSet, + UserPreferenceViewSet, + UserViewSet, +) +from openedx.core.djangolib.testing.utils import skip_unless_lms + +V2_VIEW_MODULE = "openedx.core.djangoapps.user_api.v2.views" +PROFILE_IMAGE_VIEW_MODULE = "openedx.core.djangoapps.profile_images.v2.views" +V1_VIEW_MODULE = "openedx.core.djangoapps.user_api.views" +V1_ACCOUNT_VIEW_MODULE = "openedx.core.djangoapps.user_api.accounts.views" +V1_PREFERENCE_VIEW_MODULE = "openedx.core.djangoapps.user_api.preferences.views" +V2_NAMESPACE = "user_v2" + +#: A route name is lower case, starts with a letter and separates words with +#: underscores; nothing else is accepted. +SNAKE_CASE_NAME = re.compile(r"[a-z][a-z0-9_]*\Z") +VERSION_IN_NAME = re.compile(r"v\d") + +EXPECTED_ADDRESSES = { + "user_v2:account_list": ({}, "/api/user/v2/accounts/"), + "user_v2:account_detail": ({"username": "alice"}, "/api/user/v2/accounts/alice/"), + "user_v2:email_search_list": ({}, "/api/user/v2/email_searches/"), + "user_v2:account_deactivation": ( + {"username": "alice"}, + "/api/user/v2/accounts/alice/deactivation/", + ), + "user_v2:account_preference_list": ( + {"username": "alice"}, + "/api/user/v2/accounts/alice/preferences/", + ), + "user_v2:account_preference_detail": ( + {"username": "alice", "key": "time_zone"}, + "/api/user/v2/accounts/alice/preferences/time_zone/", + ), + "user_v2:email_opt_in_list": ({}, "/api/user/v2/email_opt_ins/"), + "user_v2:account_profile_image": ( + {"username": "alice"}, + "/api/user/v2/accounts/alice/profile_image/", + ), + "user_v2:account_verification_status": ( + {"username": "alice"}, + "/api/user/v2/accounts/alice/verification_status/", + ), + "user_v2:account_verification_list": ( + {"username": "alice"}, + "/api/user/v2/accounts/alice/verifications/", + ), + "user_v2:name_change_list": ({}, "/api/user/v2/name_changes/"), + "user_v2:name_change_confirmation": ( + {"username": "alice"}, + "/api/user/v2/name_changes/alice/confirmations/", + ), + "user_v2:user_list": ({}, "/api/user/v2/users/"), + "user_v2:user_detail": ({"username": "alice"}, "/api/user/v2/users/alice/"), + "user_v2:user_preference_list": ({}, "/api/user/v2/user_preferences/"), + "user_v2:user_preference_detail": ( + {"username": "alice", "key": "time_zone"}, + "/api/user/v2/user_preferences/alice,time_zone/", + ), + "user_v2:time_zone_list": ({}, "/api/user/v2/time_zones/"), + "user_v2:retirement_list": ({}, "/api/user/v2/retirements/"), + "user_v2:retirement_detail": ({"username": "alice"}, "/api/user/v2/retirements/alice/"), + "user_v2:retirement_erasure": ( + {"username": "alice"}, + "/api/user/v2/retirements/alice/erasures/", + ), + "user_v2:retirement_cleanup_list": ({}, "/api/user/v2/retirement_cleanups/"), + "user_v2:retirement_partner_report_list": ({}, "/api/user/v2/retirement_partner_reports/"), + "user_v2:retirement_partner_report_detail": ( + {"username": "alice"}, + "/api/user/v2/retirement_partner_reports/alice/", + ), + "user_v2:retirement_partner_report_cleanup_list": ( + {}, + "/api/user/v2/retirement_partner_report_cleanups/", + ), +} + + +def registered_route_names(): + """ + Return the route names the mounted URLconf registers under this namespace. + + These are the names ``reverse()`` accepts; they are read from the resolver + rather than from this module so that a route added or renamed in the + URLconf is seen here. + """ + _prefix, namespace_resolver = get_resolver().namespace_dict[V2_NAMESPACE] + return {key for key in namespace_resolver.reverse_dict if isinstance(key, str)} + + +def resolved_route_names(): + """Return the route names ``resolve()`` reports for the published addresses.""" + return {resolve(address).url_name for _kwargs, address in EXPECTED_ADDRESSES.values()} + + +def routed_actions(view_func): + """ + Return the method-to-action map a viewset mount was registered with. + + DRF adds a ``head`` entry to a mount's ``actions`` the first time it serves + a GET, so the raw attribute depends on what the test suite requested first. + """ + return {method: action for method, action in view_func.actions.items() if method != "head"} + + +def served_methods(view_func): + """ + Return the request methods a route mounted on a plain view serves. + + A viewset mount carries its method-to-action map; a plain view has one + handler per method instead, and the set of those is what the route serves. + """ + return set(view_func.view_class().allowed_methods) + + +def view_identity(view_func): + """ + Return the module and class name of the view a route is mounted on. + + A viewset mount carries the class name on the wrapper itself; Django's + plain class-based views record the class separately instead. + """ + view_class = getattr(view_func, "view_class", None) + name = view_class.__name__ if view_class is not None else view_func.__name__ + return (view_func.__module__, name) + + +@skip_unless_lms +class UrlLiteralTests(APITestCase): + """Every name reverses to the address the API publishes.""" + + def test_reverse_literals(self): + for name, (kwargs, expected) in EXPECTED_ADDRESSES.items(): + assert reverse(name, kwargs=kwargs) == expected, name + + def test_names_are_snake_case_and_carry_no_version(self): + registered = registered_route_names() + assert registered == {name.split(":")[1] for name in EXPECTED_ADDRESSES} + for name in registered: + assert SNAKE_CASE_NAME.match(name), name + assert not VERSION_IN_NAME.search(name), name + + def test_resolved_names_are_the_names_that_reverse(self): + resolved = resolved_route_names() + assert resolved == registered_route_names() + for address, name in ( + ("/api/user/v2/accounts/", "account_list"), + ("/api/user/v2/accounts/alice/", "account_detail"), + ("/api/user/v2/email_searches/", "email_search_list"), + ("/api/user/v2/accounts/alice/deactivation/", "account_deactivation"), + ("/api/user/v2/accounts/alice/preferences/", "account_preference_list"), + ("/api/user/v2/accounts/alice/preferences/time_zone/", "account_preference_detail"), + ("/api/user/v2/email_opt_ins/", "email_opt_in_list"), + ("/api/user/v2/accounts/alice/profile_image/", "account_profile_image"), + ("/api/user/v2/accounts/alice/verification_status/", "account_verification_status"), + ("/api/user/v2/accounts/alice/verifications/", "account_verification_list"), + ("/api/user/v2/name_changes/", "name_change_list"), + ("/api/user/v2/name_changes/alice/confirmations/", "name_change_confirmation"), + ("/api/user/v2/users/", "user_list"), + ("/api/user/v2/users/alice/", "user_detail"), + ("/api/user/v2/user_preferences/", "user_preference_list"), + ("/api/user/v2/user_preferences/alice,time_zone/", "user_preference_detail"), + ("/api/user/v2/time_zones/", "time_zone_list"), + ("/api/user/v2/retirements/", "retirement_list"), + ("/api/user/v2/retirements/alice/", "retirement_detail"), + ("/api/user/v2/retirements/alice/erasures/", "retirement_erasure"), + ("/api/user/v2/retirement_cleanups/", "retirement_cleanup_list"), + ("/api/user/v2/retirement_partner_reports/", "retirement_partner_report_list"), + ( + "/api/user/v2/retirement_partner_reports/alice/", + "retirement_partner_report_detail", + ), + ( + "/api/user/v2/retirement_partner_report_cleanups/", + "retirement_partner_report_cleanup_list", + ), + ): + match = resolve(address) + assert match.url_name == name, address + assert match.view_name == f"{V2_NAMESPACE}:{name}", address + + def test_viewsets_build_action_addresses_under_this_mount(self): + accounts = AccountViewSet(basename="account") + assert accounts.reverse_action("list") == "/api/user/v2/accounts/" + assert accounts.reverse_action( + "detail", kwargs={"username": "alice"}, + ) == "/api/user/v2/accounts/alice/" + name_changes = NameChangeViewSet(basename="name_change") + assert name_changes.reverse_action("list") == "/api/user/v2/name_changes/" + assert name_changes.reverse_action( + "confirmation", kwargs={"username": "alice"}, + ) == "/api/user/v2/name_changes/alice/confirmations/" + assert UserViewSet(basename="user").reverse_action("list") == "/api/user/v2/users/" + assert UserViewSet(basename="user").reverse_action( + "detail", kwargs={"username": "alice"}, + ) == "/api/user/v2/users/alice/" + preferences = UserPreferenceViewSet(basename="user_preference") + assert preferences.reverse_action("list") == "/api/user/v2/user_preferences/" + assert preferences.reverse_action( + "detail", kwargs={"username": "alice", "key": "time_zone"}, + ) == "/api/user/v2/user_preferences/alice,time_zone/" + + def test_action_addresses_follow_the_namespace_the_request_arrived_on(self): + request = Request(APIRequestFactory().get("/api/user/v2/users/")) + request.resolver_match = resolve("/api/user/v2/users/") + view = UserViewSet(basename="user", request=request) + assert view.reverse_action("list") == "http://testserver/api/user/v2/users/" + + def test_addresses_resolve_to_the_version_two_viewsets(self): + expected = { + "/api/user/v2/accounts/": ("AccountViewSet", {"get": "list"}), + "/api/user/v2/accounts/alice/": ( + "AccountViewSet", {"get": "retrieve", "patch": "partial_update"}, + ), + "/api/user/v2/accounts/alice/verifications/": ( + "IDVerificationViewSet", {"get": "list"}, + ), + "/api/user/v2/accounts/alice/preferences/": ( + "AccountPreferenceViewSet", + {"get": "retrieve_all", "patch": "partial_update_all"}, + ), + "/api/user/v2/accounts/alice/preferences/time_zone/": ( + "AccountPreferenceViewSet", + {"get": "retrieve", "put": "update", "delete": "destroy"}, + ), + "/api/user/v2/email_searches/": ("EmailSearchViewSet", {"post": "create"}), + "/api/user/v2/email_opt_ins/": ("EmailOptInViewSet", {"post": "create"}), + "/api/user/v2/name_changes/": ("NameChangeViewSet", {"post": "create"}), + "/api/user/v2/name_changes/alice/confirmations/": ( + "NameChangeViewSet", {"post": "confirmations"}, + ), + "/api/user/v2/users/": ("UserViewSet", {"get": "list"}), + "/api/user/v2/users/alice/": ("UserViewSet", {"get": "retrieve"}), + "/api/user/v2/user_preferences/": ("UserPreferenceViewSet", {"get": "list"}), + "/api/user/v2/user_preferences/alice,time_zone/": ( + "UserPreferenceViewSet", {"get": "retrieve"}, + ), + "/api/user/v2/time_zones/": ("CountryTimeZoneViewSet", {"get": "list"}), + } + for address, (class_name, actions) in expected.items(): + match = resolve(address) + assert view_identity(match.func) == (V2_VIEW_MODULE, class_name), address + assert routed_actions(match.func) == actions, address + + def test_a_deactivation_authenticates_the_way_the_platform_does(self): + """ + The view serving a deactivation declares no authentication of its own. + + Disabling an account is reached with whatever credentials the platform + accepts everywhere else, so the view carries no list of its own that a + deployment changing that setting would have to be changed alongside. + """ + view_class = resolve("/api/user/v2/accounts/alice/deactivation/").func.view_class + assert view_class is AccountDeactivationView + assert "authentication_classes" not in vars(AccountDeactivationView) + assert AccountDeactivationView.authentication_classes is APIView.authentication_classes + + def test_the_views_reached_before_an_address_is_confirmed_replace_one_class(self): + """ + These four are reached with the library's token class and a wider session one. + + The site's own settings pages are reached by a user who has not + confirmed their email address, whom the session class the platform + configures refuses, so that one class is replaced. The class reading a + token is the one the platform's own pair is built on, named here rather + than inherited, because a view that declares its own classes is not one + that pair serves and is not recorded as one. + """ + platform_token = api_settings.DEFAULT_AUTHENTICATION_CLASSES[0] + assert issubclass(platform_token, JwtAuthentication) + for view_class in ( + AccountViewSet, ProfileImageView, AccountPreferenceViewSet, EmailOptInViewSet, + ): + assert view_class.authentication_classes == ( + JwtAuthentication, SessionAuthenticationAllowInactiveUser, + ), view_class.__name__ + + def test_what_is_recorded_about_an_account_authenticates_the_way_the_platform_does(self): + """ + The views serving what has been recorded about an account declare no authentication. + + The account itself is reached with the session class that admits a + user who has not confirmed their address, because the site's own + settings pages are reached that way; what has been recorded about that + account is reached with whatever credentials the platform accepts + everywhere else, so these views carry no list of their own that a + deployment changing that setting would have to be changed alongside. + """ + assert resolve( + "/api/user/v2/accounts/alice/verifications/", + ).func.cls is IDVerificationViewSet + for view_class in (IDVerificationStatusView, IDVerificationViewSet): + assert "authentication_classes" not in vars(view_class), view_class.__name__ + assert view_class.authentication_classes is APIView.authentication_classes + + def test_the_hand_routed_addresses_resolve_to_the_views_that_serve_them(self): + """The addresses that are not router-generated carry a view and a method set too.""" + expected = { + "/api/user/v2/accounts/alice/deactivation/": ( + V2_VIEW_MODULE, "AccountDeactivationView", {"POST", "OPTIONS"}, + ), + "/api/user/v2/accounts/alice/profile_image/": ( + PROFILE_IMAGE_VIEW_MODULE, "ProfileImageView", {"POST", "DELETE", "OPTIONS"}, + ), + "/api/user/v2/accounts/alice/verification_status/": ( + V2_VIEW_MODULE, "IDVerificationStatusView", {"GET", "OPTIONS"}, + ), + } + for address, (module, class_name, methods) in expected.items(): + match = resolve(address) + assert view_identity(match.func) == (module, class_name), address + assert served_methods(match.func) == methods, address + assert match.kwargs == {"username": "alice"}, address + + def test_username_with_a_dot_resolves_to_the_member_route(self): + match = resolve("/api/user/v2/users/ada.lovelace/") + assert view_identity(match.func) == (V2_VIEW_MODULE, "UserViewSet") + assert match.kwargs == {"username": "ada.lovelace"} + + def test_preference_member_address_splits_into_username_and_key(self): + match = resolve("/api/user/v2/user_preferences/ada.lovelace,pref-lang/") + assert match.kwargs == {"username": "ada.lovelace", "key": "pref-lang"} + + def test_a_key_containing_a_comma_belongs_to_the_key(self): + """A preference name is free-form text, and the username half cannot contain a comma.""" + match = resolve("/api/user/v2/user_preferences/ada.lovelace,a,b/") + assert match.kwargs == {"username": "ada.lovelace", "key": "a,b"} + assert reverse( + "user_v2:user_preference_detail", kwargs={"username": "ada.lovelace", "key": "a,b"}, + ) == "/api/user/v2/user_preferences/ada.lovelace,a,b/" + + def test_a_username_outside_the_allowed_pattern_does_not_reach_the_viewset(self): + with pytest.raises(Resolver404): + resolve("/api/user/v2/user_preferences/al!ce,pref-lang/") + + def test_no_database_id_member_address_is_served(self): + with pytest.raises(Resolver404): + resolve("/api/user/v2/user_preferences/42/") + + def test_a_name_carrying_a_slash_is_not_addressed_as_a_preference(self): + """A name is read from one segment, which the slash ends, so no member route serves it.""" + for key in ("a/b", "x/", "/x", "a//b"): + with pytest.raises(Resolver404): + resolve(f"/api/user/v2/accounts/ada.lovelace/preferences/{key}/") + with pytest.raises(NoReverseMatch): + reverse( + "user_v2:account_preference_detail", + kwargs={"username": "ada.lovelace", "key": key}, + ) + + def test_every_name_the_segment_can_carry_can_be_addressed(self): + """A preference name is free-form text, so the key half takes the whole segment.""" + for key in ("pref-lang", "visibility.bio", "visibility.", "account_privacy", "a,b"): + address = f"/api/user/v2/accounts/ada.lovelace/preferences/{key}/" + match = resolve(address) + assert view_identity(match.func) == (V2_VIEW_MODULE, "AccountPreferenceViewSet"), key + assert match.kwargs == {"username": "ada.lovelace", "key": key}, key + assert reverse( + "user_v2:account_preference_detail", + kwargs={"username": "ada.lovelace", "key": key}, + ) == address, key + + def test_a_name_that_is_a_collection_of_this_version_is_read_as_a_preference_name(self): + """The collections stand at the top of this version, so no preference name shadows one.""" + for key in ("email_opt_in", "email_opt_ins", "time_zones"): + match = resolve(f"/api/user/v2/accounts/alice/preferences/{key}/") + assert view_identity(match.func) == (V2_VIEW_MODULE, "AccountPreferenceViewSet"), key + assert match.kwargs == {"username": "alice", "key": key}, key + for address, name in ( + ("/api/user/v2/email_opt_ins/", "email_opt_in_list"), + ("/api/user/v2/time_zones/", "time_zone_list"), + ): + assert resolve(address).url_name == name, address + + def test_the_preference_addresses_are_served_only_with_their_slash(self): + """Without it no route of this version matches them.""" + for address in ( + "/api/user/v2/accounts/alice/preferences", + "/api/user/v2/accounts/alice/preferences/time_zone", + ): + with pytest.raises(Resolver404): + resolve(address) + + def test_the_name_in_a_preference_address_is_read_as_a_stored_one(self): + """``me`` is a username here, and is routed as one rather than resolved to the caller.""" + for address, kwargs in ( + ("/api/user/v2/accounts/me/preferences/", {"username": "me"}), + ( + "/api/user/v2/accounts/me/preferences/time_zone/", + {"username": "me", "key": "time_zone"}, + ), + ): + match = resolve(address) + assert view_identity(match.func) == (V2_VIEW_MODULE, "AccountPreferenceViewSet"), address + assert match.kwargs == kwargs, address + + def test_the_preference_viewset_serves_both_of_its_addresses(self): + """One class holds every action of this resource, over the two addresses it has.""" + collection = resolve("/api/user/v2/accounts/alice/preferences/") + member = resolve("/api/user/v2/accounts/alice/preferences/time_zone/") + assert collection.func.cls is AccountPreferenceViewSet + assert member.func.cls is AccountPreferenceViewSet + assert set(routed_actions(collection.func)) | set(routed_actions(member.func)) == { + "get", "patch", "put", "delete", + } + + +@skip_unless_lms +class RouteShapeTests(APITestCase): + """Every address this version serves is named and ends in a slash.""" + + def test_every_address_is_named_and_ends_in_a_slash(self): + addresses = v2_urls.urlpatterns + assert all(pattern.name for pattern in addresses), [str(p.pattern) for p in addresses if not p.name] + assert sorted(pattern.name for pattern in addresses) == [ + "account_deactivation", + "account_detail", + "account_list", + "account_preference_detail", + "account_preference_list", + "account_profile_image", + "account_verification_list", + "account_verification_status", + "email_opt_in_list", + "email_search_list", + "name_change_confirmation", + "name_change_list", + "retirement_cleanup_list", + "retirement_detail", + "retirement_erasure", + "retirement_list", + "retirement_partner_report_cleanup_list", + "retirement_partner_report_detail", + "retirement_partner_report_list", + "time_zone_list", + "user_detail", + "user_list", + "user_preference_detail", + "user_preference_list", + ] + for pattern in addresses: + assert SNAKE_CASE_NAME.match(pattern.name), pattern.name + assert str(pattern.pattern).endswith("/$"), pattern.name + + def test_every_pattern_is_anchored_where_the_mount_leaves_off(self): + """ + An address is matched against what the mount leaves of the path, by searching it. + + A pattern that does not say where it starts therefore answers any + address that merely ends in it, so every one of them says so. + """ + for pattern in v2_urls.urlpatterns: + assert str(pattern.pattern).startswith("^"), (pattern.name, str(pattern.pattern)) + + def test_the_router_route_that_is_replaced_has_to_exist(self): + """Dropping a route the router does not generate is refused instead of passing silently.""" + routes = v2_urls.router.urls + assert v2_urls.REPLACED_ROUTER_ROUTE in {route.name for route in routes} + kept = v2_urls.without_route(routes, v2_urls.REPLACED_ROUTER_ROUTE) + assert [route.name for route in kept] == [ + "account_list", + "account_detail", + "account_verification_list", + "email_search_list", + "email_opt_in_list", + "name_change_list", + "name_change_confirmation", + "user_list", + "user_detail", + "user_preference_list", + "time_zone_list", + "retirement_list", + "retirement_detail", + "retirement_erasure", + "retirement_cleanup_list", + "retirement_partner_report_list", + "retirement_partner_report_detail", + "retirement_partner_report_cleanup_list", + ] + with pytest.raises(ImproperlyConfigured): + v2_urls.without_route(routes, "user_preference_detail_renamed") + + + +@skip_unless_lms +class LegacyAddressTests(APITestCase): + """Mounting version 2 leaves the version 1 surface exactly where it was.""" + + def test_version_one_account_addresses_still_reach_the_version_one_views(self): + expected = { + "/api/user/v1/me": ("AccountViewSet", {"get": "get"}), + "/api/user/v1/accounts": ("AccountViewSet", {"get": "list"}), + "/api/user/v1/accounts/search_emails": ("AccountViewSet", {"post": "search_emails"}), + "/api/user/v1/accounts/alice": ( + "AccountViewSet", {"get": "retrieve", "patch": "partial_update"}, + ), + } + for address, (class_name, actions) in expected.items(): + match = resolve(address) + assert view_identity(match.func) == (V1_ACCOUNT_VIEW_MODULE, class_name), address + assert routed_actions(match.func) == actions, address + + deactivation = resolve("/api/user/v1/accounts/alice/deactivate/") + assert view_identity(deactivation.func) == ( + V1_ACCOUNT_VIEW_MODULE, "AccountDeactivationView", + ) + + def test_version_one_addresses_of_the_resources_this_version_adds_are_unchanged(self): + name_changes = { + "/api/user/v1/accounts/name_change/": {"post": "create"}, + "/api/user/v1/accounts/name_change/alice/confirm/": {"post": "confirm"}, + } + for address, actions in name_changes.items(): + match = resolve(address) + assert view_identity(match.func) == (V1_ACCOUNT_VIEW_MODULE, "NameChangeView"), address + assert routed_actions(match.func) == actions, address + + image = resolve("/api/user/v1/accounts/alice/image") + assert view_identity(image.func) == ( + "openedx.core.djangoapps.profile_images.views", "ProfileImageView", + ) + for address, class_name in ( + ("/api/user/v1/accounts/alice/verification_status/", "IDVerificationStatusView"), + ("/api/user/v1/accounts/alice/verifications/", "IDVerificationStatusDetailsView"), + ): + match = resolve(address) + assert view_identity(match.func) == ( + "openedx.core.djangoapps.user_api.verification_api.views", class_name, + ), address + + def test_the_profile_image_twins_in_their_own_app_are_left_where_they_are(self): + """This version adds no second address for them, and theirs keep their names.""" + assert reverse("profile_image_upload", kwargs={"username": "alice"}) == ( + "/api/profile_images/v1/alice/upload" + ) + assert reverse("profile_image_remove", kwargs={"username": "alice"}) == ( + "/api/profile_images/v1/alice/remove" + ) + for name in ("profile_image_upload", "profile_image_remove"): + match = resolve(reverse(name, kwargs={"username": "alice"})) + assert match.func.view_class.__module__ == "openedx.core.djangoapps.profile_images.views" + + def test_version_one_addresses_still_reach_the_version_one_views(self): + expected = { + "/api/user/v1/users/": "UserViewSet", + "/api/user/v1/users/42/": "UserViewSet", + "/api/user/v1/user_prefs/": "UserPreferenceViewSet", + "/api/user/v1/user_prefs/42/": "UserPreferenceViewSet", + "/api/user/v1/preferences/pref-lang/users/": "PreferenceUsersListView", + "/api/user/v1/forum_roles/Moderator/users/": "ForumRoleUsersListView", + "/api/user/v1/preferences/time_zones/": "CountryTimeZoneListView", + } + for address, class_name in expected.items(): + match = resolve(address) + assert view_identity(match.func) == (V1_VIEW_MODULE, class_name), address + + def test_version_one_url_names_still_reverse(self): + assert reverse("accounts_api", kwargs={"username": "alice"}) == "/api/user/v1/accounts/alice" + assert reverse("own_username_api") == "/api/user/v1/me" + assert reverse("accounts_detail_api") == "/api/user/v1/accounts" + assert reverse("accounts_search_emails_api") == "/api/user/v1/accounts/search_emails" + assert reverse("accounts_deactivation", kwargs={"username": "alice"}) == ( + "/api/user/v1/accounts/alice/deactivate/" + ) + assert reverse("accounts_profile_image_api", kwargs={"username": "alice"}) == ( + "/api/user/v1/accounts/alice/image" + ) + assert reverse("request_name_change") == "/api/user/v1/accounts/name_change/" + assert reverse("confirm_name_change", kwargs={"username": "alice"}) == ( + "/api/user/v1/accounts/name_change/alice/confirm/" + ) + assert reverse("verification_status", kwargs={"username": "alice"}) == ( + "/api/user/v1/accounts/alice/verification_status/" + ) + assert reverse("verification_details", kwargs={"username": "alice"}) == ( + "/api/user/v1/accounts/alice/verifications/" + ) + assert reverse("preferences_api", kwargs={"username": "alice"}) == "/api/user/v1/preferences/alice" + + def test_version_one_preference_addresses_still_reach_the_version_one_views(self): + expected = { + "/api/user/v1/preferences/alice": (V1_PREFERENCE_VIEW_MODULE, "PreferencesView"), + "/api/user/v1/preferences/alice/time_zone": ( + V1_PREFERENCE_VIEW_MODULE, "PreferencesDetailView", + ), + "/api/user/v1/preferences/email_opt_in/": ( + V1_VIEW_MODULE, "UpdateEmailOptInPreference", + ), + } + for address, identity in expected.items(): + assert view_identity(resolve(address).func) == identity, address + + def test_version_one_preference_url_names_still_reverse(self): + assert reverse( + "preferences_detail_api", kwargs={"username": "alice", "preference_key": "time_zone"}, + ) == "/api/user/v1/preferences/alice/time_zone" + assert reverse("preferences_email_opt_in") == "/api/user/v1/preferences/email_opt_in/" + assert reverse("preferences_email_opt_in_legacy") == ( + "/user_api/v1/preferences/email_opt_in/" + ) + + def test_the_unversioned_opt_in_address_reaches_the_version_one_view_as_before(self): + """Both of its addresses are served by one view, and this version adds no third.""" + versioned = resolve("/api/user/v1/preferences/email_opt_in/") + unversioned = resolve("/user_api/v1/preferences/email_opt_in/") + assert view_identity(unversioned.func) == view_identity(versioned.func) + assert view_identity(unversioned.func) == (V1_VIEW_MODULE, "UpdateEmailOptInPreference") + assert served_methods(unversioned.func) == served_methods(versioned.func) == { + "POST", "OPTIONS", + } + + def test_unversioned_twins_still_resolve(self): + match = resolve("/user_api/v1/users/") + assert view_identity(match.func) == (V1_VIEW_MODULE, "UserViewSet") + + +#: The addresses of the retirement pipeline, with the view each is mounted on +#: and the methods it serves. +RETIREMENT_ROUTES = { + "/api/user/v2/retirements/": (RetirementViewSet, {"get": "list"}), + "/api/user/v2/retirements/alice/": ( + RetirementViewSet, + {"get": "retrieve", "patch": "partial_update", "delete": "destroy"}, + ), + "/api/user/v2/retirements/alice/erasures/": (RetirementViewSet, {"post": "erasures"}), + "/api/user/v2/retirement_partner_reports/": ( + RetirementPartnerReportViewSet, {"post": "create"}, + ), + "/api/user/v2/retirement_partner_reports/alice/": ( + RetirementPartnerReportViewSet, {"put": "update"}, + ), + "/api/user/v2/retirement_cleanups/": (RetirementCleanupViewSet, {"post": "create"}), + "/api/user/v2/retirement_partner_report_cleanups/": ( + RetirementPartnerReportCleanupViewSet, {"post": "create"}, + ), +} + +#: The addresses of the version 1 pipeline, with the view class each still +#: reaches and the actions each is still bound to. +LEGACY_RETIREMENT_ROUTES = { + "/api/user/v1/accounts/alice/retirement_status/": ( + "AccountRetirementStatusView", {"get": "retrieve"}, + ), + "/api/user/v1/accounts/retirement_queue/": ( + "AccountRetirementStatusView", {"get": "retirement_queue"}, + ), + "/api/user/v1/accounts/retirements_by_status_and_date/": ( + "AccountRetirementStatusView", {"get": "retirements_by_status_and_date"}, + ), + "/api/user/v1/accounts/update_retirement_status/": ( + "AccountRetirementStatusView", {"patch": "partial_update"}, + ), + "/api/user/v1/accounts/retirement_cleanup/": ( + "AccountRetirementStatusView", {"post": "cleanup"}, + ), + "/api/user/v1/accounts/retirement_partner_report/": ( + "AccountRetirementPartnerReportView", + {"post": "retirement_partner_report", "put": "retirement_partner_status_create"}, + ), + "/api/user/v1/accounts/retirement_partner_report_cleanup/": ( + "AccountRetirementPartnerReportView", {"post": "retirement_partner_cleanup"}, + ), + "/api/user/v1/accounts/cancel_retirement/": ( + "CancelAccountRetirementStatusView", {"post": "cancel_retirement"}, + ), + "/api/user/v1/accounts/retire/": ("AccountRetirementView", {"post": "post"}), + "/api/user/v1/accounts/retire_misc/": ("LMSAccountRetirementView", {"post": "post"}), +} + +#: The names the version 1 pipeline is reversed by, which out-of-tree callers +#: hold, with the address each still builds. +LEGACY_RETIREMENT_NAMES = { + "accounts_retirement_retrieve": ( + {"username": "alice"}, "/api/user/v1/accounts/alice/retirement_status/", + ), + "accounts_retirement_queue": ({}, "/api/user/v1/accounts/retirement_queue/"), + "accounts_retirements_by_status_and_date": ( + {}, "/api/user/v1/accounts/retirements_by_status_and_date/", + ), + "accounts_retirement_update": ({}, "/api/user/v1/accounts/update_retirement_status/"), + "accounts_retirement_cleanup": ({}, "/api/user/v1/accounts/retirement_cleanup/"), + "accounts_retirement_partner_report": ( + {}, "/api/user/v1/accounts/retirement_partner_report/", + ), + "accounts_retirement_partner_report_cleanup": ( + {}, "/api/user/v1/accounts/retirement_partner_report_cleanup/", + ), + "cancel_account_retirement": ({}, "/api/user/v1/accounts/cancel_retirement/"), + "accounts_retire": ({}, "/api/user/v1/accounts/retire/"), + "accounts_retire_misc": ({}, "/api/user/v1/accounts/retire_misc/"), +} + + +@skip_unless_lms +class RetirementRouteTests(APITestCase): + """Where the retirement addresses are mounted, and what each one serves.""" + + def test_each_address_is_mounted_on_the_view_that_serves_it(self): + for address, (view, actions) in RETIREMENT_ROUTES.items(): + match = resolve(address) + assert view_identity(match.func) == (V2_VIEW_MODULE, view.__name__), address + assert routed_actions(match.func) == actions, address + + def test_neither_disposal_is_served_by_the_collection_whose_records_it_deletes(self): + """Each disposal has a view of its own, because it acts on a set and under its own invariant.""" + assert { + view_identity(resolve(address).func) + for address in ( + "/api/user/v2/retirements/", + "/api/user/v2/retirement_cleanups/", + "/api/user/v2/retirement_partner_reports/", + "/api/user/v2/retirement_partner_report_cleanups/", + ) + } == { + (V2_VIEW_MODULE, "RetirementViewSet"), + (V2_VIEW_MODULE, "RetirementCleanupViewSet"), + (V2_VIEW_MODULE, "RetirementPartnerReportViewSet"), + (V2_VIEW_MODULE, "RetirementPartnerReportCleanupViewSet"), + } + + def test_neither_disposal_has_a_member_address(self): + """A collection with no member operation is given no member route by the router.""" + for address in ( + "/api/user/v2/retirement_cleanups/", + "/api/user/v2/retirement_partner_report_cleanups/", + ): + with pytest.raises(Resolver404): + resolve(f"{address}alice/") + + def test_a_name_carrying_every_character_a_username_may_hold_addresses_a_member(self): + for username in ("a.b", "a b", "a@b", "a+b", "a-b", "a_b", "Mixed.Case+1"): + with self.subTest(username=username): + match = resolve(f"/api/user/v2/retirements/{username}/") + assert match.url_name == "retirement_detail" + assert match.kwargs == {"username": username} + erasure = resolve(f"/api/user/v2/retirements/{username}/erasures/") + assert erasure.url_name == "retirement_erasure" + assert erasure.kwargs == {"username": username} + + def test_a_name_that_reads_like_a_batch_address_is_read_as_a_name(self): + """A user may register any name, so no fixed word sits where a name goes.""" + match = resolve("/api/user/v2/retirements/cleanups/") + assert match.url_name == "retirement_detail" + assert match.kwargs == {"username": "cleanups"} + assert resolve("/api/user/v2/retirement_cleanups/").url_name == "retirement_cleanup_list" + + def test_the_batch_addresses_are_not_members_of_the_collections_they_dispose_of(self): + assert reverse("user_v2:retirement_cleanup_list") == "/api/user/v2/retirement_cleanups/" + assert reverse("user_v2:retirement_partner_report_cleanup_list") == ( + "/api/user/v2/retirement_partner_report_cleanups/" + ) + + def test_the_viewsets_build_their_action_addresses_under_this_mount(self): + retirements = RetirementViewSet(basename="retirement") + assert retirements.reverse_action("list") == "/api/user/v2/retirements/" + assert retirements.reverse_action( + "detail", kwargs={"username": "alice"}, + ) == "/api/user/v2/retirements/alice/" + assert retirements.reverse_action( + "erasure", kwargs={"username": "alice"}, + ) == "/api/user/v2/retirements/alice/erasures/" + reports = RetirementPartnerReportViewSet(basename="retirement_partner_report") + assert reports.reverse_action("list") == "/api/user/v2/retirement_partner_reports/" + assert reports.reverse_action( + "detail", kwargs={"username": "alice"}, + ) == "/api/user/v2/retirement_partner_reports/alice/" + + def test_an_address_near_one_this_version_serves_is_refused_in_the_body_it_answers_with(self): + """No route serves it, so it is answered as an address outside this prefix is.""" + with pytest.raises(Resolver404): + resolve("/api/user/v2/retirement/") + response = self.client.get("/api/user/v2/retirement/") + outside = self.client.get("/api/not_a_service/v2/retirement/") + assert response.status_code == outside.status_code == status.HTTP_404_NOT_FOUND + assert response["Content-Type"] == outside["Content-Type"] + + +@skip_unless_lms +class LegacyRetirementAddressTests(APITestCase): + """Mounting the conforming pipeline leaves the version 1 pipeline exactly where it was.""" + + def test_every_version_one_address_still_reaches_the_view_and_actions_it_always_did(self): + for address, (class_name, actions) in LEGACY_RETIREMENT_ROUTES.items(): + match = resolve(address) + assert view_identity(match.func) == (V1_ACCOUNT_VIEW_MODULE, class_name), address + assert routed_actions(match.func) == actions, address + + def test_every_name_the_version_one_pipeline_is_reversed_by_still_reverses(self): + for name, (kwargs, address) in LEGACY_RETIREMENT_NAMES.items(): + assert reverse(name, kwargs=kwargs) == address, name + + def test_no_version_one_name_is_shadowed_by_a_version_two_one(self): + """The two sets live in different namespaces, so the same word names two addresses.""" + assert reverse("accounts_retirement_queue") != reverse("user_v2:retirement_list") + with pytest.raises(NoReverseMatch): + reverse("retirement_list") diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_verification_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_verification_views.py new file mode 100644 index 000000000000..93bb4dbcd56d --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_verification_views.py @@ -0,0 +1,1078 @@ +"""Tests for the version 2 User API identity verification views.""" + +import datetime +from unittest import mock +from zoneinfo import ZoneInfo + +import pytest +from django.db import connection +from django.test.utils import CaptureQueriesContext, override_settings +from django.urls import reverse +from edx_rest_framework_extensions.testing import assert_error_envelope +from rest_framework import status +from rest_framework.test import APITestCase + +from common.djangoapps.student.models import UserProfile +from common.djangoapps.student.tests.factories import UserFactory +from lms.djangoapps.verify_student.models import ( + ManualVerification, + SoftwareSecurePhotoVerification, + VerificationAttempt, +) +from lms.djangoapps.verify_student.tests.factories import SSOVerificationFactory +from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user +from openedx.core.djangoapps.user_api.v2.views import AMBIGUOUS_ACCOUNT_ADDRESS +from openedx.core.djangolib.testing.utils import skip_unless_lms + +from .test_views import PAGINATION_KEYS, REFUSED_METHOD_SLUG, write_queries + +PASSWORD = "Password1234" + +#: Identity checks are good for a year on this site, and a user is asked to go +#: through one again in the three weeks before an accepted proof runs out. +VERIFY_STUDENT = {"DAYS_GOOD_FOR": 365, "EXPIRING_SOON_WINDOW": 20} + + +def earlier_status_address(username): + """Return the version 1 address of one user's verification status.""" + return f"/api/user/v1/accounts/{username}/verification_status/" + + +def earlier_list_address(username): + """Return the version 1 address of one user's verification attempts.""" + return f"/api/user/v1/accounts/{username}/verifications/" + + +@override_settings(VERIFY_STUDENT=VERIFY_STUDENT) +class VerificationTestCase(APITestCase): + """Fixtures shared by the version 2 identity verification tests.""" + + def setUp(self): + super().setUp() + self.owner = UserFactory.create( + username="verify-owner", email="verify-owner@example.com", password=PASSWORD, + ) + self.other = UserFactory.create( + username="verify-other", email="verify-other@example.com", password=PASSWORD, + ) + self.staff = UserFactory.create( + username="verify-staff", email="verify-staff@example.com", password=PASSWORD, is_staff=True, + ) + self.attempt = SoftwareSecurePhotoVerification.objects.create( + user=self.owner, status="submitted", + ) + self.status_url = self.status_address(self.owner.username) + self.list_url = self.list_address(self.owner.username) + + def status_address(self, username): + """Return the address of one user's verification status.""" + return reverse("user_v2:account_verification_status", kwargs={"username": username}) + + def list_address(self, username): + """Return the address of one user's verification attempts.""" + return reverse("user_v2:account_verification_list", kwargs={"username": username}) + + def login(self, user): + """Sign the given user in through the session these views accept.""" + assert self.client.login(username=user.username, password=PASSWORD) + + def token_headers(self, user): + """Return the request headers that carry a token identifying the given user.""" + return {"HTTP_AUTHORIZATION": f"JWT {create_jwt_for_user(user)}"} + + def sign_in_as_a_case_variant_of(self, user): + """ + Sign in a caller whose own name is the given user's, in upper case. + + The variant is set in memory only, since a site that stores usernames + case-insensitively has no room for both names at once. + """ + caller = UserFactory.create( + username="verify-variant", email="verify-variant@example.com", password=PASSWORD, + ) + caller.username = user.username.upper() + self.client.force_authenticate(user=caller) + return caller + + def approve(self, attempt, expires_in_days=200): + """Record an attempt as accepted, running out the given number of days from now.""" + attempt.status = "approved" + attempt.expiration_date = datetime.datetime.now(ZoneInfo("UTC")) + datetime.timedelta( + days=expires_in_days, + ) + attempt.save() + return attempt + + def run_out(self, attempt, days_ago=1): + """Record an attempt as accepted and no longer good, the given number of days ago.""" + return self.approve(attempt, expires_in_days=-days_ago) + + +@skip_unless_lms +class VerificationStatusTests(VerificationTestCase): + """GET /api/user/v2/accounts/{username}/verification_status/""" + + def test_a_user_reads_their_own_status(self): + self.login(self.owner) + response = self.client.get(self.status_url) + assert response.status_code == status.HTTP_200_OK + assert response.data == { + "is_verified": False, "status": "pending", "expiration_datetime": "", + } + + def test_an_accepted_proof_is_reported_as_verified(self): + self.approve(self.attempt) + self.login(self.owner) + response = self.client.get(self.status_url) + assert response.data == { + "is_verified": True, "status": "approved", "expiration_datetime": "", + } + + def test_an_accepted_proof_about_to_run_out_carries_the_day_it_does(self): + """The day is published only once the user is near being asked to verify again.""" + attempt = self.approve(self.attempt, expires_in_days=10) + self.login(self.owner) + response = self.client.get(self.status_url) + assert response.data["status"] == "approved" + assert response.data["is_verified"] is True + assert response.data["expiration_datetime"] == attempt.expiration_datetime.date().strftime( + "%m/%d/%Y", + ) + + def test_the_summary_reports_the_day_and_the_attempt_the_moment_it_runs_out(self): + """ + The property of this name carries a day in the summary and a moment in the attempt. + + The summary reports the day the user is told about; the attempt holding + the proof records when it stops counting, to the second. + """ + attempt = self.approve(self.attempt, expires_in_days=10) + self.login(self.owner) + summary = self.client.get(self.status_url) + listed = self.client.get(self.list_url).data["results"][0]["expiration_datetime"] + assert summary.data["expiration_datetime"] == attempt.expiration_datetime.date().strftime( + "%m/%d/%Y", + ) + assert listed.startswith(attempt.expiration_datetime.strftime("%Y-%m-%dT%H:%M:%S")) + + def test_a_turned_down_proof_asks_the_user_to_try_again(self): + self.attempt.status = "denied" + self.attempt.error_msg = '[{"userPhotoReasons": ["Face out of view"]}]' + self.attempt.save() + self.login(self.owner) + response = self.client.get(self.status_url) + assert response.data["status"] == "must_reverify" + assert response.data["is_verified"] is False + + def test_a_proof_that_is_no_longer_good_is_reported_as_run_out(self): + self.run_out(self.attempt) + self.login(self.owner) + response = self.client.get(self.status_url) + assert response.data["status"] == "expired" + assert response.data["is_verified"] is False + + def test_a_proof_the_user_was_never_shown_stands_at_none_once_it_runs_out(self): + """A check whose outcome is never shown to its subject is as if it had not been made.""" + self.attempt.delete() + self.run_out(ManualVerification.objects.create( + user=self.owner, status="approved", reason="Checked in person", + )) + self.login(self.owner) + response = self.client.get(self.status_url) + assert response.status_code == status.HTTP_200_OK + assert response.data["status"] == "none" + assert response.data["is_verified"] is False + + def test_a_staff_caller_reads_another_users_status(self): + self.login(self.staff) + response = self.client.get(self.status_url) + assert response.status_code == status.HTTP_200_OK + assert response.data["status"] == "pending" + + def test_a_learner_may_not_read_another_users_status(self): + self.login(self.other) + assert_error_envelope( + self.client.get(self.status_url), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_a_refused_read_stays_refused_whatever_the_query_string_carries(self): + """Who may read this status is settled from the address, before the query string is read.""" + self.login(self.other) + for query in ( + f"username={self.other.username}", + f"user={self.other.username}", + "format=json", + ): + assert_error_envelope( + self.client.get(f"{self.status_url}?{query}"), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_a_caller_named_for_the_address_in_case_alone_may_not_read_the_status(self): + self.sign_in_as_a_case_variant_of(self.owner) + assert_error_envelope( + self.client.get(self.status_url), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_an_anonymous_caller_is_unauthenticated(self): + assert_error_envelope( + self.client.get(self.status_url), + expected_status=status.HTTP_401_UNAUTHORIZED, + expected_type_slug="authn", + ) + + def test_a_user_who_has_never_been_checked_stands_at_none(self): + """Nothing on record is something to report: the summary says so rather than going missing.""" + self.login(self.other) + response = self.client.get(self.status_address(self.other.username)) + assert response.status_code == status.HTTP_200_OK + assert response.data == { + "is_verified": False, "status": "none", "expiration_datetime": "", + } + + def test_a_user_who_has_never_been_checked_is_told_apart_from_one_who_does_not_exist(self): + self.login(self.staff) + assert self.client.get(self.status_address(self.other.username)).status_code == status.HTTP_200_OK + assert_error_envelope( + self.client.get(self.status_address("nobody-at-all")), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_an_unknown_username_is_not_found(self): + self.login(self.staff) + assert_error_envelope( + self.client.get(self.status_address("nobody-at-all")), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_an_unknown_query_parameter_is_refused(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.client.get(f"{self.status_url}?expand=attempts"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "expand": ["No such query parameter. Send any of: format."], + } + + def test_the_name_in_the_address_is_refused_in_the_query_string_as_well(self): + """The address carries the username, so a parameter repeating it is one this view never reads.""" + for caller in (self.owner, self.staff): + self.login(caller) + envelope = assert_error_envelope( + self.client.get(f"{self.status_url}?username={self.owner.username}"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "username": ["No such query parameter. Send any of: format."], + } + self.client.logout() + + def test_the_status_is_not_changed_through_this_address(self): + self.login(self.owner) + assert_error_envelope( + self.client.post(self.status_url, {}, format="json"), + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug=REFUSED_METHOD_SLUG, + ) + + +@skip_unless_lms +class VerificationListTests(VerificationTestCase): + """GET /api/user/v2/accounts/{username}/verifications/""" + + def setUp(self): + super().setUp() + self.manual = ManualVerification.objects.create( + user=self.owner, status="approved", reason="Checked in person", + ) + + def test_a_user_reads_their_own_attempts(self): + self.login(self.owner) + response = self.client.get(self.list_url) + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] == 2 + assert {row["type"] for row in response.data["results"]} == {"Software Secure", "Manual"} + + def test_each_attempt_carries_what_was_recorded_about_it(self): + self.login(self.owner) + rows = {row["type"]: row for row in self.client.get(self.list_url).data["results"]} + assert rows["Manual"]["status"] == "approved" + assert rows["Manual"]["message"] == "Checked in person" + assert rows["Manual"]["receipt_id"] is None + assert rows["Software Secure"]["status"] == "submitted" + assert rows["Software Secure"]["receipt_id"] == str(self.attempt.receipt_id) + + def test_each_kind_of_check_is_named_with_what_was_said_about_it(self): + """The kind of check, the remark on it and the receipt are read from the record holding the attempt.""" + self.attempt.status = "denied" + self.attempt.error_msg = '[{"userPhotoReasons": ["Face out of view"]}]' + self.attempt.save() + SSOVerificationFactory(user=self.owner, status="approved", identity_provider_slug="idp") + self.login(self.owner) + rows = {row["type"]: row for row in self.client.get(self.list_url).data["results"]} + assert set(rows) == {"Software Secure", "Manual", "SSO"} + assert rows["Software Secure"]["message"] == '[{"userPhotoReasons": ["Face out of view"]}]' + assert rows["Software Secure"]["receipt_id"] == str(self.attempt.receipt_id) + assert rows["Manual"]["message"] == "Checked in person" + assert rows["Manual"]["receipt_id"] is None + assert rows["SSO"]["message"] == "" + assert rows["SSO"]["receipt_id"] is None + + def test_an_attempt_carries_the_status_the_table_holding_it_records(self): + """ + The tables behind these attempts do not hold the same statuses, and each is carried as it stands. + + A photo check that failed to go through is held as ``must_retry``, and a + check carried out elsewhere is held as ``pending`` while it is looked at. + """ + self.attempt.status = "must_retry" + self.attempt.save() + VerificationAttempt.objects.create(user=self.owner, name="Verify Owner", status="pending") + self.login(self.owner) + rows = {row["type"]: row for row in self.client.get(self.list_url).data["results"]} + assert set(rows) == {"Software Secure", "Manual", "SSO"} + assert rows["Software Secure"]["status"] == "must_retry" + assert rows["SSO"]["status"] == "pending" + assert rows["SSO"]["message"] == "" + assert rows["SSO"]["receipt_id"] is None + assert rows["SSO"]["expiration_datetime"] is None + + def test_the_page_carries_the_whole_envelope(self): + self.login(self.owner) + response = self.client.get(self.list_url) + assert set(response.data) == PAGINATION_KEYS + assert response.data["count"] == 2 + assert response.data["num_pages"] == 1 + assert response.data["current_page"] == 1 + assert response.data["start"] == 0 + assert response.data["next"] is None + assert response.data["previous"] is None + + def test_the_page_is_bounded(self): + for index in range(5): + SSOVerificationFactory(user=self.owner, status="approved", identity_provider_slug=f"idp{index}") + self.login(self.owner) + response = self.client.get(self.list_url, {"page_size": 3}) + assert response.data["count"] == 7 + assert len(response.data["results"]) == 3 + assert response.data["num_pages"] == 3 + assert response.data["next"].endswith("page=2&page_size=3") + + def test_the_attempts_come_back_newest_first_and_the_order_can_be_reversed(self): + self.login(self.owner) + descending = self.client.get(self.list_url).data["results"] + assert [row["updated_at"] for row in descending] == sorted( + [row["updated_at"] for row in descending], reverse=True, + ) + ascending = self.client.get(self.list_url, {"ordering": "updated_at"}).data["results"] + assert [row["type"] for row in ascending] == [row["type"] for row in reversed(descending)] + + def test_attempts_changed_at_the_same_moment_are_ordered_by_their_receipt(self): + """ + Two attempts sharing a timestamp are told apart by their receipt identifier. + + The service hands back one table after another, so the order the rows + arrive in is not an order the caller asked for: a page that falls back + to it reads the same both ways round. + """ + moment = datetime.datetime(2024, 5, 1, 12, 0, tzinfo=ZoneInfo("UTC")) + for attempt in (self.attempt, self.manual): + type(attempt).objects.filter(pk=attempt.pk).update(updated_at=moment) + self.login(self.owner) + + descending = self.client.get(self.list_url).data["results"] + assert [row["receipt_id"] for row in descending] == [str(self.attempt.receipt_id), None] + ascending = self.client.get(self.list_url, {"ordering": "updated_at"}).data["results"] + assert [row["receipt_id"] for row in ascending] == [None, str(self.attempt.receipt_id)] + + def test_a_sort_the_list_does_not_offer_is_refused(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.client.get(self.list_url, {"ordering": "status"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "ordering": ["Sort by any of: updated_at, each optionally prefixed with '-'."], + } + + def test_an_unknown_query_parameter_is_refused(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.client.get(self.list_url, {"status": "approved"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "status": ["No such query parameter. Send any of: format, ordering, page, page_size."], + } + + def test_the_name_in_the_address_is_refused_in_the_query_string_as_well(self): + """The address carries the username, so a parameter repeating it is one this view never reads.""" + for caller in (self.owner, self.staff): + self.login(caller) + envelope = assert_error_envelope( + self.client.get(f"{self.list_url}?username={self.owner.username}"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "username": ["No such query parameter. Send any of: format, ordering, page, page_size."], + } + self.client.logout() + + def test_a_staff_caller_reads_another_users_attempts(self): + self.login(self.staff) + response = self.client.get(self.list_url) + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] == 2 + + def test_a_learner_may_not_read_another_users_attempts(self): + self.login(self.other) + assert_error_envelope( + self.client.get(self.list_url), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_a_refused_read_stays_refused_whatever_the_query_string_carries(self): + """Who may read these attempts is settled from the address, before the query string is read.""" + self.login(self.other) + for query in ( + f"username={self.other.username}", + f"user={self.other.username}", + "ordering=-updated_at", + ): + assert_error_envelope( + self.client.get(f"{self.list_url}?{query}"), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_a_caller_named_for_the_address_in_case_alone_may_not_read_the_attempts(self): + self.sign_in_as_a_case_variant_of(self.owner) + assert_error_envelope( + self.client.get(self.list_url), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_an_anonymous_caller_is_unauthenticated(self): + assert_error_envelope( + self.client.get(self.list_url), + expected_status=status.HTTP_401_UNAUTHORIZED, + expected_type_slug="authn", + ) + + def test_a_user_who_has_been_through_no_checks_has_an_empty_page(self): + """An existing user with nothing to list is an empty collection, not an absent one.""" + self.login(self.other) + response = self.client.get(self.list_address(self.other.username)) + assert response.status_code == status.HTTP_200_OK + assert set(response.data) == PAGINATION_KEYS + assert response.data["count"] == 0 + assert response.data["results"] == [] + assert response.data["num_pages"] == 1 + assert response.data["next"] is None + + def test_a_user_who_has_been_through_no_checks_is_told_apart_from_one_who_does_not_exist(self): + self.login(self.staff) + empty = self.client.get(self.list_address(self.other.username)) + unknown = self.client.get(self.list_address("nobody-at-all")) + assert empty.status_code == status.HTTP_200_OK + assert unknown.status_code == status.HTTP_404_NOT_FOUND + assert empty.data["count"] == 0 + + def test_an_unknown_username_is_not_found(self): + self.login(self.staff) + assert_error_envelope( + self.client.get(self.list_address("nobody-at-all")), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_a_page_that_does_not_exist_is_refused(self): + self.login(self.owner) + assert_error_envelope( + self.client.get(self.list_url, {"page": 9}), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_the_attempts_are_not_changed_through_this_address(self): + self.login(self.owner) + assert_error_envelope( + self.client.post(self.list_url, {}, format="json"), + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug=REFUSED_METHOD_SLUG, + ) + + +@skip_unless_lms +class VerificationPageSizeTests(VerificationTestCase): + """ + How many attempts a page carries when the caller does not say, and the most it will carry. + + The list is asked for more attempts than it will give, against attempts + that really exist, so what is pinned is the page the endpoint answers + with rather than the numbers a paginator class holds. + """ + + DEFAULT_PAGE_SIZE = 10 + MAX_PAGE_SIZE = 100 + + def extra_attempts(self, count): + """Record the given number of further checks for the owner.""" + for index in range(count): + SSOVerificationFactory( + user=self.owner, status="approved", identity_provider_slug=f"idp{index:03d}", + ) + + def test_a_page_of_attempts_carries_ten_of_them_when_the_caller_asks_for_no_size(self): + self.extra_attempts(self.DEFAULT_PAGE_SIZE) + self.login(self.owner) + response = self.client.get(self.list_url) + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] == self.DEFAULT_PAGE_SIZE + 1 + assert len(response.data["results"]) == self.DEFAULT_PAGE_SIZE + assert response.data["num_pages"] == 2 + assert "page=2" in response.data["next"] + + def test_a_page_of_attempts_carries_a_hundred_of_them_however_many_are_asked_for(self): + self.extra_attempts(self.MAX_PAGE_SIZE) + self.login(self.owner) + response = self.client.get(self.list_url, {"page_size": 1000}) + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] == self.MAX_PAGE_SIZE + 1 + assert len(response.data["results"]) == self.MAX_PAGE_SIZE + assert response.data["num_pages"] == 2 + assert response.data["next"] is not None + + +@skip_unless_lms +class VerificationReadsDoNotWriteTests(VerificationTestCase): + """Reading identity verification changes nothing a later read would see.""" + + def setUp(self): + super().setUp() + ManualVerification.objects.create(user=self.owner, status="approved", reason="Checked in person") + + def assert_no_writes(self, address, params=None, expect=status.HTTP_200_OK, **headers): + """ + Read the address and assert the request wrote nothing, whatever it answered. + + Request headers given here reach the client, which is how a read + presented with a token is captured alongside one made from a session. + Work handed to the end of the transaction is run inside the capture, + so a write put off until the request is over is seen here too. + """ + with CaptureQueriesContext(connection) as captured: + with self.captureOnCommitCallbacks(execute=True): + response = self.client.get(address, params or {}, **headers) + assert response.status_code == expect + assert write_queries(captured) == [] + return response + + def other_status_address(self): + """Return the status address of the account the branch fixtures below belong to.""" + return self.status_address(self.other.username) + + def test_reading_a_status_writes_nothing(self): + self.login(self.owner) + response = self.assert_no_writes(self.status_url) + assert response.data["status"] == "approved" + + def test_reading_an_accepted_status_writes_nothing(self): + self.approve(self.attempt) + self.login(self.owner) + response = self.assert_no_writes(self.status_url) + assert response.data["status"] == "approved" + + def test_reading_an_accepted_status_about_to_run_out_writes_nothing(self): + attempt = self.approve( + SoftwareSecurePhotoVerification.objects.create(user=self.other, status="submitted"), + expires_in_days=10, + ) + self.login(self.other) + response = self.assert_no_writes(self.other_status_address()) + assert response.data["status"] == "approved" + assert response.data["expiration_datetime"] == attempt.expiration_datetime.date().strftime( + "%m/%d/%Y", + ) + + def test_reading_a_pending_status_writes_nothing(self): + SoftwareSecurePhotoVerification.objects.create(user=self.other, status="submitted") + self.login(self.other) + response = self.assert_no_writes(self.other_status_address()) + assert response.data["status"] == "pending" + + def test_reading_a_turned_down_status_writes_nothing(self): + SoftwareSecurePhotoVerification.objects.create( + user=self.other, status="denied", error_msg='[{"userPhotoReasons": ["Face out of view"]}]', + ) + self.login(self.other) + response = self.assert_no_writes(self.other_status_address()) + assert response.data["status"] == "must_reverify" + + def test_reading_a_status_whose_proof_has_run_out_writes_nothing(self): + self.run_out(SoftwareSecurePhotoVerification.objects.create(user=self.other, status="submitted")) + self.login(self.other) + response = self.assert_no_writes(self.other_status_address()) + assert response.data["status"] == "expired" + + def test_reading_the_status_of_a_proof_that_ran_out_unseen_writes_nothing(self): + self.run_out(ManualVerification.objects.create( + user=self.other, status="approved", reason="Checked in person", + )) + self.login(self.other) + response = self.assert_no_writes(self.other_status_address()) + assert response.data["status"] == "none" + assert response.data["is_verified"] is False + + def test_reading_a_status_proved_by_signing_in_elsewhere_writes_nothing(self): + SSOVerificationFactory(user=self.other, status="approved", identity_provider_slug="idp") + self.login(self.other) + response = self.assert_no_writes(self.other_status_address()) + assert response.data["status"] == "approved" + assert response.data["is_verified"] is True + + def test_reading_a_status_proved_in_person_writes_nothing(self): + ManualVerification.objects.create(user=self.other, status="approved", reason="Checked in person") + self.login(self.other) + response = self.assert_no_writes(self.other_status_address()) + assert response.data["status"] == "approved" + assert response.data["is_verified"] is True + + def test_reading_a_status_as_staff_writes_nothing(self): + self.login(self.staff) + response = self.assert_no_writes(self.status_url) + assert response.data["status"] == "approved" + + def test_asking_for_a_status_with_a_head_request_writes_nothing(self): + self.login(self.owner) + with CaptureQueriesContext(connection) as captured: + with self.captureOnCommitCallbacks(execute=True): + response = self.client.head(self.status_url) + assert response.status_code == status.HTTP_200_OK + assert write_queries(captured) == [] + + def test_reading_the_attempts_writes_nothing(self): + self.login(self.owner) + response = self.assert_no_writes(self.list_url) + assert response.data["count"] == 2 + + def test_reading_the_attempts_in_the_other_order_writes_nothing(self): + self.login(self.owner) + response = self.assert_no_writes(self.list_url, {"ordering": "updated_at"}) + assert response.data["count"] == 2 + + def test_reading_a_page_further_in_writes_nothing(self): + for index in range(5): + SSOVerificationFactory(user=self.owner, status="approved", identity_provider_slug=f"idp{index}") + self.login(self.owner) + response = self.assert_no_writes(self.list_url, {"page": 2, "page_size": 3}) + assert response.data["current_page"] == 2 + assert len(response.data["results"]) == 3 + + def test_reading_an_empty_page_of_attempts_writes_nothing(self): + self.login(self.other) + response = self.assert_no_writes(self.list_address(self.other.username)) + assert response.data["count"] == 0 + + def test_reading_the_attempts_as_staff_writes_nothing(self): + self.login(self.staff) + response = self.assert_no_writes(self.list_url) + assert response.data["count"] == 2 + + def test_asking_for_the_attempts_with_a_head_request_writes_nothing(self): + self.login(self.owner) + with CaptureQueriesContext(connection) as captured: + with self.captureOnCommitCallbacks(execute=True): + response = self.client.head(self.list_url) + assert response.status_code == status.HTTP_200_OK + assert write_queries(captured) == [] + + def test_reading_a_status_a_token_identifies_its_caller_by_writes_nothing(self): + """ + A caller resolved from a token reads as a signed-in one does. + + A token is how a service or a micro-frontend reaches these addresses, + and the layer that resolves one runs before the handler, so the reads + are captured under both ways of identifying the caller. + """ + response = self.assert_no_writes(self.status_url, **self.token_headers(self.owner)) + assert response.data["status"] == "approved" + + def test_reading_the_attempts_a_token_identifies_its_caller_by_writes_nothing(self): + response = self.assert_no_writes(self.list_url, **self.token_headers(self.owner)) + assert response.data["count"] == 2 + + def test_a_staff_token_reading_another_users_status_and_attempts_writes_nothing(self): + headers = self.token_headers(self.staff) + status_answer = self.assert_no_writes(self.status_url, **headers) + assert status_answer.data["status"] == "approved" + list_answer = self.assert_no_writes(self.list_url, **headers) + assert list_answer.data["count"] == 2 + + def test_refusing_an_anonymous_caller_writes_nothing(self): + for address in (self.status_url, self.list_url): + response = self.assert_no_writes(address, expect=status.HTTP_401_UNAUTHORIZED) + assert_error_envelope(response, expected_status=401, expected_type_slug="authn") + + def test_refusing_another_learner_writes_nothing(self): + self.login(self.other) + for address in (self.status_url, self.list_url): + response = self.assert_no_writes(address, expect=status.HTTP_403_FORBIDDEN) + assert_error_envelope(response, expected_status=403, expected_type_slug="authz") + + def test_reporting_a_name_no_account_holds_writes_nothing(self): + self.login(self.staff) + for address in (self.status_address("nobody-at-all"), self.list_address("nobody-at-all")): + response = self.assert_no_writes(address, expect=status.HTTP_404_NOT_FOUND) + assert_error_envelope(response, expected_status=404, expected_type_slug="not-found") + + def test_refusing_a_query_parameter_these_addresses_do_not_read_writes_nothing(self): + self.login(self.owner) + for address in (self.status_url, self.list_url): + response = self.assert_no_writes( + address, {"username": self.owner.username}, expect=status.HTTP_400_BAD_REQUEST, + ) + assert_error_envelope(response, expected_status=400, expected_type_slug="validation") + + +@skip_unless_lms +class NothingOnRecordTests(VerificationTestCase): + """What the addresses under one account answer for a user who has done nothing yet.""" + + def setUp(self): + super().setUp() + self.newcomer = UserFactory.create( + username="verify-newcomer", email="verify-newcomer@example.com", password=PASSWORD, + ) + UserProfile.objects.filter(user=self.newcomer).delete() + assert not UserProfile.objects.filter(user=self.newcomer).exists() + + def test_an_account_with_nothing_on_record_is_answered_at_each_of_its_addresses(self): + """ + A user who has been through no check and holds no profile is answered at all three addresses. + + Nothing on record is something to report, so the summary, the page of + attempts and the account itself all answer, and they answer the same + way as each other. + """ + self.login(self.newcomer) + standing = self.client.get(self.status_address(self.newcomer.username)) + assert standing.status_code == status.HTTP_200_OK + assert standing.data == { + "is_verified": False, "status": "none", "expiration_datetime": "", + } + + attempts = self.client.get(self.list_address(self.newcomer.username)) + assert attempts.status_code == status.HTTP_200_OK + assert attempts.data["count"] == 0 + assert attempts.data["results"] == [] + + account = self.client.get(reverse("user_v2:account_detail", kwargs={"username": "me"})) + assert account.status_code == status.HTTP_200_OK + assert account.data["username"] == self.newcomer.username + + def test_the_addresses_of_a_name_no_account_holds_are_all_absent(self): + """A missing user is what makes one of these addresses absent, and the only thing that does.""" + self.login(self.staff) + for address in ( + self.status_address("nobody-at-all"), + self.list_address("nobody-at-all"), + reverse("user_v2:account_detail", kwargs={"username": "nobody-at-all"}), + ): + assert_error_envelope( + self.client.get(address), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + +@skip_unless_lms +class VerificationDisabledCallerTests(VerificationTestCase): + """What these addresses answer a caller whose own account has been disabled.""" + + def test_a_token_held_by_a_disabled_staff_account_may_not_read_another_users_records(self): + """A privilege does not survive the deactivation of the account that held it.""" + headers = self.token_headers(self.staff) + assert self.client.get(self.status_url, **headers).status_code == status.HTTP_200_OK + assert self.client.get(self.list_url, **headers).status_code == status.HTTP_200_OK + self.staff.is_active = False + self.staff.save() + for address in (self.status_url, self.list_url): + assert_error_envelope( + self.client.get(address, **headers), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_a_disabled_account_still_reads_its_own_records(self): + """What a user proved about themselves stays readable to them while the account is disabled.""" + headers = self.token_headers(self.owner) + self.owner.is_active = False + self.owner.save() + summary = self.client.get(self.status_url, **headers) + assert summary.status_code == status.HTTP_200_OK + assert summary.data == {"is_verified": False, "status": "pending", "expiration_datetime": ""} + page = self.client.get(self.list_url, **headers) + assert page.status_code == status.HTTP_200_OK + assert page.data["count"] == 1 + + def test_a_disabled_staff_account_still_reads_its_own_records(self): + """The staff half of the check is the one the disabled account loses.""" + headers = self.token_headers(self.staff) + self.staff.is_active = False + self.staff.save() + summary = self.client.get(self.status_address(self.staff.username), **headers) + assert summary.status_code == status.HTTP_200_OK + assert summary.data == {"is_verified": False, "status": "none", "expiration_datetime": ""} + page = self.client.get(self.list_address(self.staff.username), **headers) + assert page.status_code == status.HTTP_200_OK + assert page.data["count"] == 0 + + +@skip_unless_lms +class VerificationAddressedNameTests(VerificationTestCase): + """ + These addresses carry the name an account is stored under. + + The account collection reads ``me`` as the account of the caller; the + addresses beneath an account read the name they carry as it stands, so a + caller that sends ``me`` to one of them is answered as a caller naming any + other account is. + """ + + def test_a_learner_sending_the_collections_own_name_is_refused(self): + self.login(self.owner) + for address in (self.status_address("me"), self.list_address("me")): + assert_error_envelope( + self.client.get(address), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_a_staff_caller_sending_it_is_told_no_account_holds_that_name(self): + self.login(self.staff) + for address in (self.status_address("me"), self.list_address("me")): + assert_error_envelope( + self.client.get(address), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_a_name_more_than_one_account_answers_to_is_refused_under_the_name(self): + """ + Where the database answers a name with several accounts and none is stored under it, none is read. + + The test database resolves a name only to the record stored under + exactly it, so the lookup is answered here the way it answers where a + name resolves more widely. + """ + self.login(self.staff) + with mock.patch( + "openedx.core.djangoapps.user_api.v2.views.accounts_named", + return_value=[self.other, self.staff], + ): + for address in (self.status_url, self.list_url): + with self.subTest(address=address): + envelope = assert_error_envelope( + self.client.get(address), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="user/ambiguous-account-name", + ) + assert envelope["errors"] == {"username": [AMBIGUOUS_ACCOUNT_ADDRESS]} + + def test_the_account_holding_that_name_is_served_at_it(self): + """A user may register the name, and reads their own records where it is addressed.""" + holder = UserFactory.create(username="me", email="me@example.com", password=PASSWORD) + SoftwareSecurePhotoVerification.objects.create(user=holder, status="approved") + self.login(holder) + summary = self.client.get(self.status_address("me")) + assert summary.status_code == status.HTTP_200_OK + assert summary.data["status"] == "approved" + page = self.client.get(self.list_address("me")) + assert page.status_code == status.HTTP_200_OK + assert page.data["count"] == 1 + assert page.data["results"][0]["status"] == "approved" + + +@skip_unless_lms +class VerificationParityTests(VerificationTestCase): + """The same fixtures through the version 1 and the version 2 addresses.""" + + def setUp(self): + super().setUp() + ManualVerification.objects.create(user=self.owner, status="approved", reason="Checked in person") + + def test_both_versions_report_the_same_status(self): + self.login(self.owner) + earlier = self.client.get(earlier_status_address(self.owner.username)) + later = self.client.get(self.status_url) + assert earlier.status_code == later.status_code == status.HTTP_200_OK + assert later.data == earlier.data + + def test_both_versions_report_the_same_accepted_status(self): + self.approve(self.attempt, expires_in_days=10) + self.login(self.owner) + earlier = self.client.get(earlier_status_address(self.owner.username)) + later = self.client.get(self.status_url) + assert later.data == earlier.data + assert later.data["is_verified"] is True + + def test_both_versions_carry_the_same_attempts(self): + self.login(self.owner) + earlier = self.client.get(earlier_list_address(self.owner.username)) + later = self.client.get(self.list_url) + assert earlier.status_code == later.status_code == status.HTTP_200_OK + # The one declared difference: the attempts come back in a page rather + # than as a bare list, and the rows themselves are unchanged. + assert isinstance(earlier.data, list) + assert later.data["results"] == earlier.data + assert later.data["count"] == len(earlier.data) + + def test_both_versions_refuse_a_learner_reading_another_users_attempts(self): + self.login(self.other) + earlier = self.client.get(earlier_list_address(self.owner.username)) + later = self.client.get(self.list_url) + assert earlier.status_code == later.status_code == status.HTTP_403_FORBIDDEN + assert earlier.data == {"detail": "You do not have permission to perform this action."} + assert_error_envelope(later, expected_status=403, expected_type_slug="authz") + + def test_the_status_of_a_user_who_has_been_through_no_checks_is_reported_rather_than_refused(self): + """ + Declared difference: a user with nothing on record has a status here. + + The earlier address answers the same way for a user who has been + through no check and for a name no account holds, so a client cannot + tell a typo from a user who has not started. Here the summary says + `none` and the refusal is kept for the name no account holds, which + the second half asserts. + """ + self.login(self.other) + earlier = self.client.get(earlier_status_address(self.other.username)) + later = self.client.get(self.status_address(self.other.username)) + assert earlier.status_code == status.HTTP_404_NOT_FOUND + assert later.status_code == status.HTTP_200_OK + assert later.data == {"is_verified": False, "status": "none", "expiration_datetime": ""} + + self.client.logout() + self.login(self.staff) + assert self.client.get(self.status_address("nobody-at-all")).status_code == status.HTTP_404_NOT_FOUND + + def test_the_attempts_of_a_user_who_has_been_through_no_checks_are_an_empty_page(self): + """ + Declared difference: an existing user with no attempts has an empty page here. + + The earlier address answers the same way for a user who has made no + attempt and for a name no account holds, so a client cannot tell a typo + from an empty history. Here the page is empty and the refusal is kept + for the name no account holds, which the second half asserts. + """ + self.login(self.other) + earlier = self.client.get(earlier_list_address(self.other.username)) + later = self.client.get(self.list_address(self.other.username)) + assert earlier.status_code == status.HTTP_404_NOT_FOUND + assert later.status_code == status.HTTP_200_OK + assert later.data["count"] == 0 + assert later.data["results"] == [] + + self.client.logout() + self.login(self.staff) + assert self.client.get(self.list_address("nobody-at-all")).status_code == status.HTTP_404_NOT_FOUND + + def test_both_versions_refuse_an_address_that_is_not_exactly_the_callers_name(self): + """ + Neither version answers an account to a caller who addressed it differently. + + The two refuse such a request in different terms, so each is held to + the code it answers rather than both to one code, and neither hands + back anything of the account that was asked for. + """ + self.login(self.owner) + shouted = self.owner.username.upper() + for earlier_address, later_address in ( + (earlier_status_address(shouted), self.status_address(shouted)), + (earlier_list_address(shouted), self.list_address(shouted)), + ): + earlier = self.client.get(earlier_address) + later = self.client.get(later_address) + assert earlier.status_code == status.HTTP_404_NOT_FOUND + assert "verification" not in str(earlier.data) + assert_error_envelope(later, expected_status=403, expected_type_slug="authz") + + def test_an_anonymous_caller_is_refused_by_both_and_told_so_by_this_one(self): + earlier = self.client.get(earlier_list_address(self.owner.username)) + later = self.client.get(self.list_url) + assert earlier.status_code == later.status_code == status.HTTP_401_UNAUTHORIZED + assert_error_envelope(later, expected_status=401, expected_type_slug="authn") + + +#: What a page of attempts costs, and what a status costs, measured on the +#: version 1 addresses first. The attempts come from four tables, one per kind +#: of check, and the page is cut from what they return, so a page costs the +#: same whatever it carries. A status reads those four tables and then works +#: out when an accepted proof runs out, which costs more. Both versions read +#: the same rows over the same fixtures, so each number is the one number +#: asserted on both addresses: a change on either side is a change in what the +#: answer costs. +LIST_QUERIES = EARLIER_LIST_QUERIES = 18 +STATUS_QUERIES = EARLIER_STATUS_QUERIES = 22 + + +@pytest.fixture(name="verified_user") +@skip_unless_lms +def verified_user_fixture(client, django_user_model): # pylint: disable=unused-argument + """Sign a learner in who has been through several identity checks.""" + owner = UserFactory.create(username="count-verify-owner", password=PASSWORD) + SoftwareSecurePhotoVerification.objects.create(user=owner, status="submitted") + ManualVerification.objects.create(user=owner, status="approved", reason="Checked in person") + for index in range(4): + SSOVerificationFactory(user=owner, status="approved", identity_provider_slug=f"idp{index}") + assert client.login(username=owner.username, password=PASSWORD) + # The first authenticated request of a session populates caches the rest + # of the session reuses; the counts below are the steady-state ones. + client.get(reverse("user_v2:account_verification_list", kwargs={"username": owner.username})) + return owner + + +@pytest.mark.django_db +@pytest.mark.parametrize("page_size", [1, 3, 6]) +@skip_unless_lms +def test_a_page_of_attempts_costs_what_the_earlier_address_costs( + client, verified_user, page_size, django_assert_num_queries, +): + with django_assert_num_queries(EARLIER_LIST_QUERIES): + earlier = client.get(f"/api/user/v1/accounts/{verified_user.username}/verifications/") + assert len(earlier.json()) == 6 + + address = reverse("user_v2:account_verification_list", kwargs={"username": verified_user.username}) + with django_assert_num_queries(LIST_QUERIES): + later = client.get(address, {"page_size": page_size}) + assert later.json()["count"] == 6 + assert len(later.json()["results"]) == page_size + + +@pytest.mark.django_db +@skip_unless_lms +def test_a_status_costs_what_the_earlier_address_costs(client, verified_user, django_assert_num_queries): + with django_assert_num_queries(EARLIER_STATUS_QUERIES): + earlier = client.get(f"/api/user/v1/accounts/{verified_user.username}/verification_status/") + assert earlier.json()["status"] == "approved" + + address = reverse("user_v2:account_verification_status", kwargs={"username": verified_user.username}) + with django_assert_num_queries(STATUS_QUERIES): + later = client.get(address) + assert later.json() == earlier.json() diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_views.py new file mode 100644 index 000000000000..b91303b439d1 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_views.py @@ -0,0 +1,2260 @@ +"""Tests for the version 2 User API views.""" + +import re +from datetime import UTC, datetime +from math import ceil +from unittest.mock import patch + +import pytest +from django.contrib.auth.models import User # pylint: disable=imported-auth-user +from django.core.exceptions import ImproperlyConfigured +from django.db import connection +from django.test import SimpleTestCase +from django.test.utils import CaptureQueriesContext, override_settings +from django.urls import reverse +from edx_rest_framework_extensions.errors import classify_error +from edx_rest_framework_extensions.testing import assert_error_envelope +from opaque_keys.edx.keys import CourseKey +from rest_framework import status +from rest_framework.exceptions import MethodNotAllowed, NotAcceptable, ParseError, UnsupportedMediaType +from rest_framework.test import APIClient, APITestCase + +from common.djangoapps.student.models import PendingNameChange, UserProfile +from common.djangoapps.student.tests.factories import AdminFactory, UserFactory +from openedx.core.djangoapps.django_comment_common.models import Role +from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user +from openedx.core.djangoapps.user_api.models import UserPreference +from openedx.core.djangoapps.user_api.preferences.api import get_country_time_zones +from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory +from openedx.core.djangoapps.user_api.v2 import urls as v2_urls +from openedx.core.djangoapps.user_api.v2.filters import UserFilterSet +from openedx.core.djangoapps.user_api.v2.serializers import UserPreferenceRecordSerializer +from openedx.core.djangoapps.user_api.v2.views import UserPreferenceViewSet, UserViewSet +from openedx.core.djangolib.testing.utils import skip_unless_lms + +PASSWORD = "Password1234" +API_KEY = "directory-test-api-key" +COURSE_KEY = CourseKey.from_string("course-v1:edX+DemoX+Demo") +OTHER_COURSE_KEY = CourseKey.from_string("course-v1:edX+OtherX+Other") + +#: What the shared catalog calls each failure a request meets before a handler +#: runs; this API answers them as every other standardized API does. +REFUSED_METHOD_SLUG, REFUSED_METHOD_TITLE = classify_error(MethodNotAllowed("GET")) +NOT_ACCEPTABLE_SLUG, NOT_ACCEPTABLE_TITLE = classify_error(NotAcceptable()) +UNSUPPORTED_MEDIA_TYPE_SLUG = classify_error(UnsupportedMediaType("text/plain"))[0] +MALFORMED_BODY_SLUG, MALFORMED_BODY_TITLE = classify_error(ParseError()) + +PAGINATION_KEYS = {"count", "num_pages", "current_page", "start", "next", "previous", "results"} + +#: The query parameters each endpoint reads, in the order its refusal lists them. +USER_QUERY_PARAMS = ( + "course_key, email, fields, format, forum_role, is_active, ordering, page, page_size, " + "preference_key, username, view" +) +PREFERENCE_QUERY_PARAMS = "fields, format, key, ordering, page, page_size, username, view" +TIME_ZONE_QUERY_PARAMS = "country_code, format, page, page_size" + +WRITE_VERB = re.compile(r"\s*(?:INSERT|UPDATE|DELETE)\b", re.IGNORECASE) + +#: The table a statement changes is the identifier right after the verb; the +#: quoting character around it differs between database backends, and a +#: conflict clause may sit between the verb and ``INTO``. +WRITE_TARGET = re.compile( + r"\s*(?:INSERT\s+(?:\w+\s+)*?INTO|UPDATE|DELETE\s+FROM)\s+[`\"\[]?(?P\w+)", + re.IGNORECASE, +) + +#: The platform's ``SafeSessionMiddleware`` puts the signed-in caller's e-mail +#: into the session the first time it finds it missing, which rewrites that one +#: row once per session; the deployed configuration keeps sessions in the cache +#: and only the test settings put them in a table at all. It is framework +#: bookkeeping rather than a change to the data this API serves, and it is the +#: only table left out of the captures below. +SESSION_TABLE = "django_session" + + +def write_target(sql): + """Return the table a statement writes to, or ``None`` if it cannot be read.""" + match = WRITE_TARGET.match(sql) + return match.group("table") if match else None + + +def write_queries(captured): + """ + Return the captured statements that would change the data this API serves. + + Only the statement's own target table decides: a write to a domain table is + reported however it mentions the session table elsewhere, and a write whose + target cannot be read is reported rather than assumed harmless. + """ + return [ + query["sql"] + for query in captured + if WRITE_VERB.match(query["sql"]) and write_target(query["sql"]) != SESSION_TABLE + ] + + +class WriteCaptureFilterTests(SimpleTestCase): + """The capture filter reports writes by their target table and nothing else.""" + + PREFERENCE_INSERT = ( + 'INSERT INTO "user_api_userpreference" ("user_id", "key", "value") ' + "VALUES (1, 'pref-lang', 'django_session')" + ) + PREFERENCE_DELETE = ( + 'DELETE FROM "user_api_userpreference" WHERE "user_id" IN ' + '(SELECT "user_id" FROM "django_session" WHERE "session_key" = \'abc\')' + ) + ROLE_INSERT = 'INSERT INTO "django_comment_common_role" ("name") VALUES (\'Moderator\')' + SESSION_UPDATE = 'UPDATE "django_session" SET "session_data" = \'x\' WHERE "session_key" = \'abc\'' + SESSION_INSERT = 'INSERT INTO "django_session" ("session_key", "session_data") VALUES (\'abc\', \'x\')' + SESSION_DELETE = 'DELETE FROM "django_session" WHERE "session_key" = \'abc\'' + USER_SELECT = 'SELECT "auth_user"."id" FROM "auth_user" ORDER BY "auth_user"."username" ASC' + UNREADABLE_WRITE = 'UPDATE /* hint */ "user_api_userpreference" SET "value" = \'en\'' + + def captured(self, *statements): + """Return the statements in the shape ``CaptureQueriesContext`` records them.""" + return [{"sql": statement, "time": "0.000"} for statement in statements] + + def test_domain_writes_are_reported_even_when_they_name_the_session_table(self): + reported = write_queries( + self.captured(self.PREFERENCE_INSERT, self.PREFERENCE_DELETE, self.ROLE_INSERT), + ) + assert reported == [self.PREFERENCE_INSERT, self.PREFERENCE_DELETE, self.ROLE_INSERT] + + def test_session_bookkeeping_is_the_only_statement_dropped(self): + reported = write_queries( + self.captured( + self.SESSION_INSERT, + self.SESSION_UPDATE, + self.SESSION_DELETE, + self.USER_SELECT, + self.ROLE_INSERT, + ), + ) + assert reported == [self.ROLE_INSERT] + + def test_reads_are_not_writes(self): + assert write_queries(self.captured(self.USER_SELECT)) == [] + + def test_a_write_whose_target_cannot_be_read_is_still_reported(self): + assert write_target(self.UNREADABLE_WRITE) is None + assert write_queries(self.captured(self.UNREADABLE_WRITE)) == [self.UNREADABLE_WRITE] + + def test_the_target_table_is_read_through_any_backend_quoting(self): + assert write_target(self.ROLE_INSERT) == "django_comment_common_role" + assert write_target("UPDATE `django_session` SET `session_data` = 'x'") == SESSION_TABLE + assert write_target(" insert into user_api_userpreference (value) values ('x')") == ( + "user_api_userpreference" + ) + assert write_target("INSERT OR IGNORE INTO `user_api_userpreference` (`value`) VALUES ('en')") == ( + "user_api_userpreference" + ) + assert write_target(self.USER_SELECT) is None + + +@override_settings(EDX_API_KEY=API_KEY) +class UserApiV2TestCase(APITestCase): + """Fixtures shared by the version 2 User API tests.""" + + def setUp(self): + super().setUp() + self.admin = AdminFactory.create(username="admin-caller", password=PASSWORD) + self.learner = UserFactory.create(username="learner-caller", password=PASSWORD) + self.alice = UserFactory.create(username="alice", email="alice@example.com", password=PASSWORD) + self.bob = UserFactory.create(username="bob", email="bob@example.com", password=PASSWORD) + UserPreferenceFactory.create(user=self.alice, key="pref-lang", value="en") + UserPreferenceFactory.create(user=self.bob, key="time_zone", value="UTC") + + def login(self, user): + """Sign the given user in through the session the platform default accepts.""" + assert self.client.login(username=user.username, password=PASSWORD) + + def send_api_key(self): + """Send the site's API key with every later request, which is how the directory is read.""" + self.client.credentials(HTTP_X_EDX_API_KEY=API_KEY) + + def usernames_in(self, response): + """Return the usernames carried by a paginated directory page.""" + return [row["username"] for row in response.data["results"]] + + +@skip_unless_lms +class UserListViewTests(UserApiV2TestCase): + """GET /api/user/v2/users/""" + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:user_list") + + def test_anonymous_caller_is_unauthenticated(self): + response = self.client.get(self.url) + assert_error_envelope(response, expected_status=status.HTTP_401_UNAUTHORIZED, expected_type_slug="authn") + + def test_a_caller_without_the_key_is_forbidden(self): + self.login(self.learner) + response = self.client.get(self.url) + assert_error_envelope(response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz") + + def test_a_caller_without_the_key_cannot_list_another_users_preference_holders(self): + self.login(self.learner) + response = self.client.get(self.url, {"preference_key": "pref-lang"}) + assert_error_envelope(response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz") + + def test_a_caller_with_the_key_receives_the_directory(self): + self.send_api_key() + response = self.client.get(self.url, {"page_size": 100}) + assert response.status_code == status.HTTP_200_OK + assert set(response.data) == PAGINATION_KEYS + assert self.usernames_in(response) == ["admin-caller", "alice", "bob", "learner-caller"] + assert response.data["count"] == 4 + assert response.data["num_pages"] == 1 + assert response.data["current_page"] == 1 + assert response.data["start"] == 0 + + def test_row_shape_matches_the_declared_fields(self): + self.send_api_key() + response = self.client.get(self.url, {"username": "alice"}) + row = response.data["results"][0] + assert set(row) == {"id", "url", "email", "name", "username", "preferences"} + assert row["id"] == self.alice.id + assert row["email"] == "alice@example.com" + assert row["name"] == self.alice.profile.name + assert row["preferences"] == {"pref-lang": "en"} + assert row["url"].endswith("/api/user/v2/users/alice/") + + def test_second_page_reports_its_position(self): + self.send_api_key() + response = self.client.get(self.url, {"page_size": 2, "page": 2}) + assert response.status_code == status.HTTP_200_OK + assert self.usernames_in(response) == ["bob", "learner-caller"] + assert response.data["current_page"] == 2 + assert response.data["num_pages"] == 2 + assert response.data["start"] == 2 + assert response.data["previous"] is not None + assert response.data["next"] is None + + def test_a_page_beyond_the_last_one_is_not_found(self): + self.send_api_key() + response = self.client.get(self.url, {"page": 9999}) + assert_error_envelope(response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found") + + def test_a_page_number_that_is_not_a_number_is_not_found(self): + self.send_api_key() + response = self.client.get(self.url, {"page": "abc"}) + assert_error_envelope(response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found") + + def test_ordering_can_be_reversed(self): + self.send_api_key() + response = self.client.get(self.url, {"ordering": "-username", "page_size": 100}) + assert self.usernames_in(response) == ["learner-caller", "bob", "alice", "admin-caller"] + + def test_ordering_by_join_date(self): + self.alice.date_joined = datetime(2020, 1, 1, tzinfo=UTC) + self.alice.save() + self.bob.date_joined = datetime(2019, 1, 1, tzinfo=UTC) + self.bob.save() + self.send_api_key() + response = self.client.get(self.url, {"ordering": "date_joined", "page_size": 100}) + assert self.usernames_in(response)[-2:] == ["bob", "alice"] + + def test_unknown_ordering_is_refused(self): + self.send_api_key() + response = self.client.get(self.url, {"ordering": "password", "page_size": 100}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "ordering": ["Sort by any of: date_joined, username, each optionally prefixed with '-'."], + } + + def test_one_unknown_field_refuses_the_whole_sort(self): + self.send_api_key() + response = self.client.get(self.url, {"ordering": "username,password", "page_size": 100}) + assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + + def test_filter_by_username(self): + self.send_api_key() + response = self.client.get(self.url, {"username": "bob", "page_size": 100}) + assert self.usernames_in(response) == ["bob"] + + def test_several_names_select_the_entries_of_each(self): + """ + The value is a list of names, as it is on the account collection. + + A caller that learned the list on one collection of this version and + sent it to another was answered with an empty page, which reads as a + page of the accounts asked for rather than as a value this collection + does not take. + """ + self.send_api_key() + response = self.client.get(self.url, {"username": "alice,bob", "page_size": 100}) + assert response.status_code == status.HTTP_200_OK + assert self.usernames_in(response) == ["alice", "bob"] + assert response.data["count"] == 2 + + def test_a_name_in_the_list_that_no_account_holds_is_left_out_of_the_page(self): + self.send_api_key() + response = self.client.get(self.url, {"username": "alice,nobody", "page_size": 100}) + assert self.usernames_in(response) == ["alice"] + assert response.data["count"] == 1 + + def test_a_position_in_the_list_that_carries_nothing_names_nothing(self): + self.send_api_key() + response = self.client.get(self.url, {"username": ",alice,,bob,", "page_size": 100}) + assert self.usernames_in(response) == ["alice", "bob"] + + def test_an_empty_name_narrows_nothing(self): + self.send_api_key() + response = self.client.get(self.url, {"username": "", "page_size": 100}) + assert response.status_code == status.HTTP_200_OK + assert self.usernames_in(response) == ["admin-caller", "alice", "bob", "learner-caller"] + + def test_a_name_sent_with_spaces_around_it_is_read_as_sent(self): + """A name is matched exactly as it was sent, so a space before it names no user.""" + self.send_api_key() + for asked, expected in ((" alice", []), ("bob, alice", ["bob"]), (" ", [])): + with self.subTest(username=asked): + response = self.client.get(self.url, {"username": asked, "page_size": 100}) + assert response.status_code == status.HTTP_200_OK, response.content + assert self.usernames_in(response) == expected + + def test_filter_by_email(self): + self.send_api_key() + response = self.client.get(self.url, {"email": "bob@example.com"}) + assert self.usernames_in(response) == ["bob"] + + def test_filter_by_is_active(self): + self.alice.is_active = False + self.alice.save() + self.send_api_key() + response = self.client.get(self.url, {"is_active": "false", "page_size": 100}) + assert self.usernames_in(response) == ["alice"] + + def test_filter_by_is_active_keeps_only_the_active_accounts(self): + self.alice.is_active = False + self.alice.save() + self.send_api_key() + response = self.client.get(self.url, {"is_active": "true", "page_size": 100}) + assert self.usernames_in(response) == ["admin-caller", "bob", "learner-caller"] + + def test_an_is_active_value_that_names_no_side_is_refused(self): + """A mistyped value must be refused, not read as "no preference" and answered unfiltered.""" + self.alice.is_active = False + self.alice.save() + self.send_api_key() + response = self.client.get(self.url, {"is_active": "maybe", "page_size": 100}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == {"is_active": ["Enter true or false."]} + + def test_an_empty_is_active_value_narrows_nothing(self): + self.alice.is_active = False + self.alice.save() + self.send_api_key() + response = self.client.get(self.url, {"is_active": "", "page_size": 100}) + assert response.status_code == status.HTTP_200_OK + assert self.usernames_in(response) == ["admin-caller", "alice", "bob", "learner-caller"] + + def test_filter_by_preference_key(self): + self.send_api_key() + response = self.client.get(self.url, {"preference_key": "pref-lang", "page_size": 100}) + assert self.usernames_in(response) == ["alice"] + + def test_filter_by_unknown_preference_key_returns_no_rows(self): + self.send_api_key() + response = self.client.get(self.url, {"preference_key": "nope", "page_size": 100}) + assert response.data["results"] == [] + assert response.data["count"] == 0 + + def give_alice_the_moderator_role(self): + """Make alice a moderator of the course the forum-role tests narrow by.""" + role = Role.objects.create(course_id=COURSE_KEY, name="Moderator") + role.users.add(self.alice) + return role + + def test_filter_by_forum_role_and_course(self): + self.give_alice_the_moderator_role() + other_role = Role.objects.create(course_id=OTHER_COURSE_KEY, name="Moderator") + other_role.users.add(self.bob) + self.send_api_key() + response = self.client.get( + self.url, {"forum_role": "Moderator", "course_key": str(COURSE_KEY), "page_size": 100}, + ) + assert self.usernames_in(response) == ["alice"] + + def test_anonymous_caller_cannot_list_a_courses_forum_role_holders(self): + self.give_alice_the_moderator_role() + response = self.client.get( + self.url, {"forum_role": "Moderator", "course_key": str(COURSE_KEY)}, + ) + assert_error_envelope(response, expected_status=status.HTTP_401_UNAUTHORIZED, expected_type_slug="authn") + + def test_a_caller_without_the_key_cannot_list_a_courses_forum_role_holders(self): + self.give_alice_the_moderator_role() + self.login(self.learner) + response = self.client.get( + self.url, {"forum_role": "Moderator", "course_key": str(COURSE_KEY)}, + ) + assert_error_envelope(response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz") + + def test_holding_the_role_does_not_let_a_learner_list_its_holders(self): + self.give_alice_the_moderator_role() + self.login(self.alice) + response = self.client.get( + self.url, {"forum_role": "Moderator", "course_key": str(COURSE_KEY)}, + ) + assert_error_envelope(response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz") + + def test_the_published_course_key_example_can_be_pasted_into_a_query_string(self): + """A caller that copies the documented key into a query string reaches the role holders.""" + example = re.search( + r"for example (course-v1:[^\s.]+)", + UserFilterSet.base_filters["course_key"].extra["help_text"], + ).group(1) + role = Role.objects.create(course_id=COURSE_KEY, name="Moderator") + role.users.add(self.alice) + self.send_api_key() + response = self.client.get(f"{self.url}?forum_role=Moderator&course_key={example}") + assert response.status_code == status.HTTP_200_OK + assert self.usernames_in(response) == ["alice"] + + def test_a_course_key_whose_plus_signs_are_not_encoded_is_refused(self): + """An unencoded plus sign arrives as a space, which no course key may contain.""" + self.send_api_key() + response = self.client.get( + f"{self.url}?forum_role=Moderator&course_key=course-v1:edX+DemoX+Demo", + ) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == {"course_key": ["Enter a valid course key."]} + + def test_a_user_holding_the_role_through_several_rows_is_listed_once(self): + """Nothing stops a course from carrying one role name under several rows.""" + for _ in range(2): + role = Role.objects.create(course_id=COURSE_KEY, name="Moderator") + role.users.add(self.alice) + self.send_api_key() + response = self.client.get( + self.url, {"forum_role": "Moderator", "course_key": str(COURSE_KEY), "page_size": 100}, + ) + assert self.usernames_in(response) == ["alice"] + assert response.data["count"] == 1 + + def test_forum_role_without_course_key_is_rejected(self): + self.send_api_key() + response = self.client.get(self.url, {"forum_role": "Moderator"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "course_key": ["This parameter is required when forum_role is supplied."], + } + + def test_course_key_without_forum_role_is_rejected(self): + self.send_api_key() + response = self.client.get(self.url, {"course_key": str(COURSE_KEY)}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "forum_role": ["This parameter is required when course_key is supplied."], + } + + def test_malformed_course_key_is_rejected(self): + self.send_api_key() + response = self.client.get(self.url, {"forum_role": "Moderator", "course_key": "not a course"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == {"course_key": ["Enter a valid course key."]} + + def test_unknown_forum_role_returns_no_rows_and_creates_nothing(self): + self.send_api_key() + with CaptureQueriesContext(connection) as captured: + response = self.client.get( + self.url, {"forum_role": "Inquisitor", "course_key": str(COURSE_KEY)}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["results"] == [] + assert Role.objects.filter(name="Inquisitor").count() == 0 + assert write_queries(captured.captured_queries) == [] + + def test_minimal_view_returns_only_identifying_fields(self): + self.send_api_key() + response = self.client.get(self.url, {"username": "alice", "view": "minimal"}) + row = response.data["results"][0] + assert set(row) == {"id", "username", "email"} + assert row["username"] == "alice" + + def test_fields_parameter_projects_the_row(self): + self.send_api_key() + response = self.client.get(self.url, {"username": "alice", "fields": "username,email"}) + assert response.data["results"] == [{"username": "alice", "email": "alice@example.com"}] + + def test_fields_parameter_narrows_the_minimal_view(self): + self.send_api_key() + response = self.client.get( + self.url, {"username": "alice", "view": "minimal", "fields": "username"}, + ) + assert response.data["results"] == [{"username": "alice"}] + + def test_a_field_name_the_row_does_not_carry_is_refused(self): + self.send_api_key() + response = self.client.get(self.url, {"fields": "user_name"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "fields": [ + "No such field: user_name. " + "Select any of: email, id, name, preferences, url, username.", + ], + } + + def test_a_field_the_minimal_preset_drops_is_refused(self): + """The preset decides what the row carries, so the selection is judged against it.""" + self.send_api_key() + response = self.client.get(self.url, {"view": "minimal", "fields": "name"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "fields": ["No such field: name. Select any of: email, id, username."], + } + + def test_every_unknown_field_name_is_named_at_once(self): + self.send_api_key() + response = self.client.get(self.url, {"fields": "username,e_mail,joined"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"]["fields"][0].startswith("No such field: e_mail, joined.") + + def test_a_field_name_with_surrounding_space_is_accepted(self): + self.send_api_key() + response = self.client.get(self.url, {"username": "alice", "fields": " username , email "}) + assert response.data["results"] == [{"username": "alice", "email": "alice@example.com"}] + + def test_an_unknown_field_does_not_answer_an_anonymous_caller(self): + response = self.client.get(self.url, {"fields": "user_name"}) + assert_error_envelope(response, expected_status=status.HTTP_401_UNAUTHORIZED, expected_type_slug="authn") + + def test_an_unknown_response_preset_is_refused(self): + self.send_api_key() + response = self.client.get(self.url, {"view": "nonsense"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "view": ["Ask for any of: minimal, or omit this parameter for the default response."], + } + + def test_the_preset_that_belongs_to_the_preference_list_is_refused_here(self): + self.send_api_key() + response = self.client.get(self.url, {"view": "full"}) + assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + + def test_an_empty_response_preset_returns_the_default_shape(self): + """A query string built from an empty variable narrows nothing rather than failing.""" + self.send_api_key() + response = self.client.get(self.url, {"username": "alice", "view": ""}) + assert response.status_code == status.HTTP_200_OK + assert set(response.data["results"][0]) == { + "id", "url", "email", "name", "username", "preferences", + } + + def test_an_unknown_preset_does_not_answer_an_anonymous_caller(self): + response = self.client.get(self.url, {"view": "nonsense"}) + assert_error_envelope(response, expected_status=status.HTTP_401_UNAUTHORIZED, expected_type_slug="authn") + + def test_listing_writes_nothing(self): + self.send_api_key() + with CaptureQueriesContext(connection) as captured: + self.client.get(self.url, {"page_size": 100}) + assert write_queries(captured.captured_queries) == [] + + def test_head_writes_nothing(self): + self.send_api_key() + with CaptureQueriesContext(connection) as captured: + response = self.client.head(self.url) + assert response.status_code == status.HTTP_200_OK + assert write_queries(captured.captured_queries) == [] + + +@skip_unless_lms +class UserDetailViewTests(UserApiV2TestCase): + """GET /api/user/v2/users/{username}/""" + + def url_for(self, username): + """Return the member address of the named user.""" + return reverse("user_v2:user_detail", kwargs={"username": username}) + + def test_anonymous_caller_is_unauthenticated(self): + response = self.client.get(self.url_for("alice")) + assert_error_envelope(response, expected_status=status.HTTP_401_UNAUTHORIZED, expected_type_slug="authn") + + def test_a_caller_without_the_key_is_forbidden(self): + self.login(self.learner) + response = self.client.get(self.url_for("alice")) + assert_error_envelope(response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz") + + def test_the_owner_without_the_key_is_forbidden(self): + self.login(self.alice) + response = self.client.get(self.url_for("alice")) + assert_error_envelope(response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz") + + def test_a_caller_with_the_key_receives_the_entry(self): + self.send_api_key() + response = self.client.get(self.url_for("alice")) + assert response.status_code == status.HTTP_200_OK + assert response.data["username"] == "alice" + assert response.data["email"] == "alice@example.com" + assert response.data["preferences"] == {"pref-lang": "en"} + + def test_unknown_username_is_not_found(self): + self.send_api_key() + response = self.client.get(self.url_for("nobody")) + assert_error_envelope(response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found") + + def test_username_containing_a_dot_resolves(self): + UserFactory.create(username="ada.lovelace", email="ada@example.com", password=PASSWORD) + self.send_api_key() + response = self.client.get(self.url_for("ada.lovelace")) + assert response.status_code == status.HTTP_200_OK + assert response.data["username"] == "ada.lovelace" + + def test_minimal_view_returns_only_identifying_fields(self): + self.send_api_key() + response = self.client.get(self.url_for("alice"), {"view": "minimal"}) + assert set(response.data) == {"id", "username", "email"} + + def test_a_field_name_the_entry_does_not_carry_is_refused(self): + self.send_api_key() + response = self.client.get(self.url_for("alice"), {"fields": "user_name"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "fields": [ + "No such field: user_name. " + "Select any of: email, id, name, preferences, url, username.", + ], + } + + def test_fields_parameter_projects_the_entry(self): + self.send_api_key() + response = self.client.get(self.url_for("alice"), {"fields": "username"}) + assert response.data == {"username": "alice"} + + def test_an_unknown_response_preset_is_refused(self): + self.send_api_key() + response = self.client.get(self.url_for("alice"), {"view": "nonsense"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "view": ["Ask for any of: minimal, or omit this parameter for the default response."], + } + + def test_retrieval_writes_nothing(self): + self.send_api_key() + with CaptureQueriesContext(connection) as captured: + self.client.get(self.url_for("alice")) + assert write_queries(captured.captured_queries) == [] + + def test_head_writes_nothing(self): + self.send_api_key() + with CaptureQueriesContext(connection) as captured: + response = self.client.head(self.url_for("alice")) + assert response.status_code == status.HTTP_200_OK + assert write_queries(captured.captured_queries) == [] + + +@skip_unless_lms +class ProfilelessUserTests(UserApiV2TestCase): + """A user with no profile row is listed with a null name rather than failing.""" + + def test_user_without_a_profile_has_a_null_name(self): + User.objects.create_user("profileless", "profileless@example.com", PASSWORD) + self.send_api_key() + response = self.client.get( + reverse("user_v2:user_detail", kwargs={"username": "profileless"}), + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["name"] is None + + +@skip_unless_lms +class UserPreferenceListViewTests(UserApiV2TestCase): + """GET /api/user/v2/user_preferences/""" + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:user_preference_list") + + def keys_in(self, response): + """Return the (username, key) pairs carried by a page of preferences.""" + return [(row["username"], row["key"]) for row in response.data["results"]] + + def test_anonymous_caller_is_unauthenticated(self): + response = self.client.get(self.url) + assert_error_envelope(response, expected_status=status.HTTP_401_UNAUTHORIZED, expected_type_slug="authn") + + def test_a_caller_without_the_key_is_forbidden(self): + self.login(self.learner) + response = self.client.get(self.url) + assert_error_envelope(response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz") + + def test_the_owner_without_the_key_is_forbidden(self): + self.login(self.alice) + response = self.client.get(self.url, {"username": "alice"}) + assert_error_envelope(response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz") + + def test_a_caller_with_the_key_receives_every_preference(self): + self.send_api_key() + response = self.client.get(self.url, {"page_size": 100}) + assert response.status_code == status.HTTP_200_OK + assert set(response.data) == PAGINATION_KEYS + assert self.keys_in(response) == [("alice", "pref-lang"), ("bob", "time_zone")] + + def test_row_shape_is_flat_by_default(self): + self.send_api_key() + response = self.client.get(self.url, {"username": "alice"}) + row = response.data["results"][0] + assert set(row) == {"username", "key", "value", "url", "user"} + assert row["username"] == "alice" + assert row["key"] == "pref-lang" + assert row["value"] == "en" + assert row["user"] is None + assert row["url"].endswith("/api/user/v2/user_preferences/alice,pref-lang/") + + def test_full_view_embeds_the_owning_user(self): + self.send_api_key() + response = self.client.get(self.url, {"username": "alice", "view": "full"}) + row = response.data["results"][0] + assert set(row["user"]) == {"id", "url", "email", "name", "username", "preferences"} + assert row["user"]["username"] == "alice" + assert row["user"]["preferences"] == {"pref-lang": "en"} + + def test_minimal_view_drops_the_address_and_owner(self): + self.send_api_key() + response = self.client.get(self.url, {"username": "alice", "view": "minimal"}) + assert response.data["results"] == [{"username": "alice", "key": "pref-lang", "value": "en"}] + + def test_a_field_name_the_row_does_not_carry_is_refused(self): + self.send_api_key() + response = self.client.get(self.url, {"fields": "preference_key"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "fields": [ + "No such field: preference_key. " + "Select any of: key, url, user, username, value.", + ], + } + + def test_a_page_beyond_the_last_one_is_not_found(self): + self.send_api_key() + response = self.client.get(self.url, {"page": 9999}) + assert_error_envelope(response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found") + + def test_fields_parameter_projects_the_row(self): + self.send_api_key() + response = self.client.get(self.url, {"username": "alice", "fields": "key,value"}) + assert response.data["results"] == [{"key": "pref-lang", "value": "en"}] + + def test_filter_by_key(self): + UserPreferenceFactory.create(user=self.bob, key="pref-lang", value="fr") + self.send_api_key() + response = self.client.get(self.url, {"key": "pref-lang", "page_size": 100}) + assert self.keys_in(response) == [("alice", "pref-lang"), ("bob", "pref-lang")] + + def test_filter_by_username(self): + self.send_api_key() + response = self.client.get(self.url, {"username": "bob", "page_size": 100}) + assert self.keys_in(response) == [("bob", "time_zone")] + + def test_several_names_select_the_preferences_of_each(self): + """The value is a list of names, as it is on the sibling collections of this version.""" + self.send_api_key() + response = self.client.get(self.url, {"username": "alice,bob", "page_size": 100}) + assert response.status_code == status.HTTP_200_OK + assert self.keys_in(response) == [("alice", "pref-lang"), ("bob", "time_zone")] + assert response.data["count"] == 2 + + def test_a_name_in_the_list_that_no_account_holds_is_left_out_of_the_page(self): + self.send_api_key() + response = self.client.get(self.url, {"username": "alice,nobody", "page_size": 100}) + assert self.keys_in(response) == [("alice", "pref-lang")] + + def test_an_empty_name_narrows_nothing(self): + self.send_api_key() + response = self.client.get(self.url, {"username": "", "page_size": 100}) + assert response.status_code == status.HTTP_200_OK + assert self.keys_in(response) == [("alice", "pref-lang"), ("bob", "time_zone")] + + def test_a_name_sent_with_spaces_around_it_is_read_as_sent(self): + """A name is matched exactly as it was sent, so a space before it names no owner.""" + self.send_api_key() + for asked, expected in ((" alice", []), ("bob, alice", [("bob", "time_zone")]), (" ", [])): + with self.subTest(username=asked): + response = self.client.get(self.url, {"username": asked, "page_size": 100}) + assert response.status_code == status.HTTP_200_OK, response.content + assert self.keys_in(response) == expected + + def test_the_owner_id_the_earlier_list_took_is_refused_rather_than_ignored(self): + """A caller that ported by changing the version segment is told, not quietly unfiltered.""" + self.send_api_key() + response = self.client.get(self.url, {"user": self.alice.id, "page_size": 100}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "user": [f"No such query parameter. Send any of: {PREFERENCE_QUERY_PARAMS}."], + } + + def test_an_answered_page_announces_no_deprecated_parameter(self): + self.send_api_key() + response = self.client.get(self.url, {"username": "alice"}) + assert response.status_code == status.HTTP_200_OK + assert "Deprecation" not in response + + def test_ordering_by_key_descending(self): + self.send_api_key() + response = self.client.get(self.url, {"ordering": "-key", "page_size": 100}) + assert self.keys_in(response) == [("bob", "time_zone"), ("alice", "pref-lang")] + + def test_ordering_takes_the_fields_in_the_order_they_are_named(self): + UserPreferenceFactory.create(user=self.bob, key="pref-lang", value="fr") + self.send_api_key() + response = self.client.get(self.url, {"ordering": "key,-username", "page_size": 100}) + assert self.keys_in(response) == [ + ("bob", "pref-lang"), ("alice", "pref-lang"), ("bob", "time_zone"), + ] + + def test_unknown_ordering_is_refused(self): + self.send_api_key() + response = self.client.get(self.url, {"ordering": "value", "page_size": 100}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "ordering": ["Sort by any of: key, username, each optionally prefixed with '-'."], + } + + def test_listing_writes_nothing(self): + self.send_api_key() + with CaptureQueriesContext(connection) as captured: + self.client.get(self.url, {"page_size": 100}) + assert write_queries(captured.captured_queries) == [] + + def test_full_view_writes_nothing(self): + self.send_api_key() + with CaptureQueriesContext(connection) as captured: + self.client.get(self.url, {"page_size": 100, "view": "full"}) + assert write_queries(captured.captured_queries) == [] + + def test_head_writes_nothing(self): + self.send_api_key() + with CaptureQueriesContext(connection) as captured: + response = self.client.head(self.url) + assert response.status_code == status.HTTP_200_OK + assert write_queries(captured.captured_queries) == [] + + def test_an_unknown_response_preset_is_refused(self): + self.send_api_key() + response = self.client.get(self.url, {"view": "nonsense"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "view": [ + "Ask for any of: full, minimal, or omit this parameter for the default response.", + ], + } + + +@skip_unless_lms +class UserPreferenceDetailViewTests(UserApiV2TestCase): + """GET /api/user/v2/user_preferences/{username},{key}/""" + + def url_for(self, username, key): + """Return the member address of one stored preference.""" + return reverse("user_v2:user_preference_detail", kwargs={"username": username, "key": key}) + + def test_anonymous_caller_is_unauthenticated(self): + response = self.client.get(self.url_for("alice", "pref-lang")) + assert_error_envelope(response, expected_status=status.HTTP_401_UNAUTHORIZED, expected_type_slug="authn") + + def test_a_caller_without_the_key_is_forbidden(self): + self.login(self.learner) + response = self.client.get(self.url_for("alice", "pref-lang")) + assert_error_envelope(response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz") + + def test_the_owner_without_the_key_is_forbidden(self): + self.login(self.alice) + response = self.client.get(self.url_for("alice", "pref-lang")) + assert_error_envelope(response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz") + + def test_a_caller_with_the_key_receives_the_preference(self): + self.send_api_key() + response = self.client.get(self.url_for("alice", "pref-lang")) + assert response.status_code == status.HTTP_200_OK + assert set(response.data) == {"username", "key", "value", "url", "user"} + assert response.data["username"] == "alice" + assert response.data["key"] == "pref-lang" + assert response.data["value"] == "en" + assert response.data["user"] is None + + def test_full_view_embeds_the_owning_user(self): + self.send_api_key() + response = self.client.get(self.url_for("alice", "pref-lang"), {"view": "full"}) + assert response.data["user"]["username"] == "alice" + + def test_a_field_name_the_preference_does_not_carry_is_refused(self): + self.send_api_key() + response = self.client.get(self.url_for("alice", "pref-lang"), {"fields": "preference_key"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "fields": [ + "No such field: preference_key. " + "Select any of: key, url, user, username, value.", + ], + } + + def test_unknown_key_is_not_found(self): + self.send_api_key() + response = self.client.get(self.url_for("alice", "nope")) + assert_error_envelope(response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found") + + def test_key_belonging_to_another_user_is_not_found(self): + self.send_api_key() + response = self.client.get(self.url_for("alice", "time_zone")) + assert_error_envelope(response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found") + + def test_an_unknown_response_preset_is_refused(self): + self.send_api_key() + response = self.client.get(self.url_for("alice", "pref-lang"), {"view": "nonsense"}) + assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + + def test_retrieval_writes_nothing(self): + self.send_api_key() + with CaptureQueriesContext(connection) as captured: + self.client.get(self.url_for("alice", "pref-lang")) + assert write_queries(captured.captured_queries) == [] + + def test_head_writes_nothing(self): + self.send_api_key() + with CaptureQueriesContext(connection) as captured: + response = self.client.head(self.url_for("alice", "pref-lang")) + assert response.status_code == status.HTTP_200_OK + assert write_queries(captured.captured_queries) == [] + + +@skip_unless_lms +class NameSelectorTests(UserApiV2TestCase): + """``?username=`` on the collections of this version that select accounts by name.""" + + def test_every_collection_that_selects_accounts_by_name_reads_one_list(self): + """ + One value names the same accounts wherever it is sent. + + The member address of the stored preferences already reads a comma as + the separator between the two names that identify a preference, so a + collection of this version that read the comma as part of a username + would be answering a value its own addresses spell differently. + """ + self.login(self.admin) + self.send_api_key() + selection = {"username": "alice,bob", "page_size": 100} + pages = { + name: self.client.get(reverse(f"user_v2:{name}"), selection) + for name in ("user_list", "user_preference_list", "account_list") + } + assert {name: page.status_code for name, page in pages.items()} == { + "user_list": status.HTTP_200_OK, + "user_preference_list": status.HTTP_200_OK, + "account_list": status.HTTP_200_OK, + } + assert { + name: sorted({row["username"] for row in page.data["results"]}) + for name, page in pages.items() + } == { + "user_list": ["alice", "bob"], + "user_preference_list": ["alice", "bob"], + "account_list": ["alice", "bob"], + } + + +@skip_unless_lms +class TimeZoneListViewTests(UserApiV2TestCase): + """GET /api/user/v2/time_zones/""" + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:time_zone_list") + + def test_anonymous_caller_receives_the_list(self): + response = self.client.get(self.url) + assert response.status_code == status.HTTP_200_OK + assert set(response.data) == PAGINATION_KEYS + assert len(response.data["results"]) == 10 + assert set(response.data["results"][0]) == {"time_zone", "description"} + + def test_authenticated_caller_receives_the_same_count(self): + anonymous_count = self.client.get(self.url).data["count"] + self.login(self.learner) + response = self.client.get(self.url) + assert response.data["count"] == anonymous_count + + def test_country_code_narrows_the_list(self): + response = self.client.get(self.url, {"country_code": "FR", "page_size": 100}) + assert [row["time_zone"] for row in response.data["results"]] == ["Europe/Paris"] + + def test_country_code_is_case_insensitive(self): + upper = self.client.get(self.url, {"country_code": "FR", "page_size": 100}).data["results"] + lower = self.client.get(self.url, {"country_code": "fr", "page_size": 100}).data["results"] + assert upper == lower + + def test_country_without_time_zones_falls_back_to_every_zone(self): + every = self.client.get(self.url).data["count"] + response = self.client.get(self.url, {"country_code": "BV"}) + assert response.data["count"] == every + + def test_an_empty_country_code_returns_every_zone(self): + """A query string built from an empty variable narrows nothing rather than failing.""" + every = self.client.get(self.url).data["count"] + response = self.client.get(self.url, {"country_code": ""}) + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] == every + + def test_a_page_beyond_the_last_one_is_not_found(self): + """The list is public, so its most common failure is answered to anyone.""" + response = self.client.get(self.url, {"page": 9999}) + assert_error_envelope(response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found") + + def test_malformed_country_code_is_rejected(self): + response = self.client.get(self.url, {"country_code": "FRA"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "country_code": ["Enter a two-letter ISO 3166-1 alpha-2 country code, for example FR."], + } + + def test_listing_writes_nothing(self): + with CaptureQueriesContext(connection) as captured: + self.client.get(self.url, {"page_size": 100}) + assert write_queries(captured.captured_queries) == [] + + def test_head_writes_nothing(self): + with CaptureQueriesContext(connection) as captured: + response = self.client.head(self.url) + assert response.status_code == status.HTTP_200_OK + assert write_queries(captured.captured_queries) == [] + + +@skip_unless_lms +class PageSizeCapTests(UserApiV2TestCase): + """ + No caller can ask any of the lists for more rows than one maximum page. + + The time-zone list is the one a caller reaches without signing in, and it + is long enough to be worth asking for whole, so the cap is asserted against + live rows on every list rather than read off the paginator. + """ + + MAX_PAGE_SIZE = 100 + + def test_the_directory_page_is_capped(self): + User.objects.bulk_create( + User(username=f"extra-{index:03d}", email=f"extra-{index:03d}@example.com") + for index in range(self.MAX_PAGE_SIZE) + ) + self.send_api_key() + response = self.client.get(reverse("user_v2:user_list"), {"page_size": 1000}) + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] == self.MAX_PAGE_SIZE + 4 + assert len(response.data["results"]) == self.MAX_PAGE_SIZE + assert response.data["num_pages"] == 2 + assert response.data["next"] is not None + + def test_the_stored_preference_page_is_capped(self): + UserPreference.objects.bulk_create( + UserPreference(user=self.alice, key=f"extra-{index:03d}", value="x") + for index in range(self.MAX_PAGE_SIZE) + ) + self.send_api_key() + response = self.client.get(reverse("user_v2:user_preference_list"), {"page_size": 1000}) + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] == self.MAX_PAGE_SIZE + 2 + assert len(response.data["results"]) == self.MAX_PAGE_SIZE + assert response.data["num_pages"] == 2 + + def test_the_time_zone_page_is_capped_for_an_anonymous_caller(self): + every_zone = len(get_country_time_zones()) + assert every_zone > self.MAX_PAGE_SIZE, every_zone + response = self.client.get(reverse("user_v2:time_zone_list"), {"page_size": 1000}) + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] == every_zone + assert len(response.data["results"]) == self.MAX_PAGE_SIZE + assert response.data["num_pages"] == ceil(every_zone / self.MAX_PAGE_SIZE) + + +@skip_unless_lms +class RefusedRequestTests(UserApiV2TestCase): + """A method this API does not serve, and an Accept header it cannot answer.""" + + #: Public, standardized, and served by another API, which answers the same + #: failures with the same type. + OTHER_API_URL = "/api/enrollment/v2/courses/course-v1:edX+DemoX+Demo/" + + def setUp(self): + super().setUp() + self.public_url = reverse("user_v2:time_zone_list") + + def test_a_method_this_address_does_not_serve_is_refused_for_an_anonymous_caller(self): + response = self.client.post(self.public_url, {}, format="json") + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug=REFUSED_METHOD_SLUG, + ) + assert envelope["title"] == REFUSED_METHOD_TITLE + assert envelope["detail"] == 'Method "POST" not allowed.' + assert response["Content-Type"] == "application/json" + + def test_an_accept_header_this_api_cannot_answer_is_refused(self): + response = self.client.get(self.public_url, HTTP_ACCEPT="application/xml") + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_406_NOT_ACCEPTABLE, + expected_type_slug=NOT_ACCEPTABLE_SLUG, + ) + assert envelope["title"] == NOT_ACCEPTABLE_TITLE + assert envelope["detail"] == "Could not satisfy the request Accept header." + assert response["Content-Type"] == "application/json" + + def test_a_method_the_gated_collections_do_not_serve_is_refused(self): + self.send_api_key() + for url_name in ("user_v2:user_list", "user_v2:user_preference_list"): + response = self.client.delete(reverse(url_name)) + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug=REFUSED_METHOD_SLUG, + ) + assert envelope["detail"] == 'Method "DELETE" not allowed.' + + def test_a_method_the_gated_members_do_not_serve_is_refused(self): + self.send_api_key() + response = self.client.put(reverse("user_v2:user_detail", args=[self.alice.username])) + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug=REFUSED_METHOD_SLUG, + ) + assert envelope["detail"] == 'Method "PUT" not allowed.' + + def test_no_other_collection_of_this_version_learns_a_method_from_this_one(self): + """The document beneath an account is merge-patched; the collections above it are not.""" + self.login(self.admin) + for address in (reverse("user_v2:account_list"), reverse("user_v2:user_list")): + with self.subTest(address=address): + response = self.client.patch( + address, "{}", content_type="application/merge-patch+json", HTTP_X_EDX_API_KEY=API_KEY, + ) + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug=REFUSED_METHOD_SLUG, + ) + assert envelope["detail"] == 'Method "PATCH" not allowed.' + + def test_another_api_is_classified_exactly_as_the_shared_catalog_classifies_it(self): + """The catalog names no refused method, and nothing here changes that for anyone else.""" + response = self.client.post(self.OTHER_API_URL, {}, format="json") + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug="internal", + ) + assert envelope["title"] == "Internal Server Error" + + def test_a_refused_method_is_typed_here_as_another_api_types_it(self): + ours = self.client.post(self.public_url, {}, format="json").json() + theirs = self.client.post(self.OTHER_API_URL, {}, format="json").json() + assert (ours["status"], ours["type"], ours["title"]) == (theirs["status"], theirs["type"], theirs["title"]) + + +@skip_unless_lms +class UnknownQueryParameterTests(UserApiV2TestCase): + """Each endpoint refuses a query parameter it reads nothing from, and takes the rest.""" + + def setUp(self): + super().setUp() + self.user_list = reverse("user_v2:user_list") + self.user_detail = reverse("user_v2:user_detail", kwargs={"username": "alice"}) + self.preference_list = reverse("user_v2:user_preference_list") + self.preference_detail = reverse( + "user_v2:user_preference_detail", kwargs={"username": "alice", "key": "pref-lang"}, + ) + self.time_zone_list = reverse("user_v2:time_zone_list") + + def assert_refused(self, response, name, offered): + """Assert the response named the parameter it would not take, and the ones it takes.""" + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == {name: [f"No such query parameter. Send any of: {offered}."]} + + def assert_each_is_answered(self, address, parameter_sets): + """Assert the endpoint answers each of these query strings.""" + for params in parameter_sets: + with self.subTest(params=params): + response = self.client.get(address, params) + assert response.status_code == status.HTTP_200_OK, response.content + + def test_the_directory_refuses_a_parameter_it_does_not_read(self): + self.send_api_key() + response = self.client.get(self.user_list, {"lms_user_id": self.alice.id, "page_size": 100}) + self.assert_refused(response, "lms_user_id", USER_QUERY_PARAMS) + + def test_a_mistyped_filter_refuses_the_page_instead_of_returning_every_row(self): + self.send_api_key() + response = self.client.get(self.user_list, {"usernme": "alice", "page_size": 100}) + self.assert_refused(response, "usernme", USER_QUERY_PARAMS) + + def test_every_unrecognised_parameter_is_named_at_once(self): + self.send_api_key() + response = self.client.get(self.user_list, {"sort": "username", "limit": "10"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert sorted(envelope["errors"]) == ["limit", "sort"] + + def test_a_directory_entry_refuses_a_parameter_it_does_not_read(self): + self.send_api_key() + response = self.client.get(self.user_detail, {"expand": "profile"}) + self.assert_refused(response, "expand", USER_QUERY_PARAMS) + + def test_the_preference_list_refuses_a_parameter_it_does_not_read(self): + self.send_api_key() + response = self.client.get(self.preference_list, {"value": "en", "page_size": 100}) + self.assert_refused(response, "value", PREFERENCE_QUERY_PARAMS) + + def test_a_stored_preference_refuses_a_parameter_it_does_not_read(self): + self.send_api_key() + response = self.client.get(self.preference_detail, {"value": "en"}) + self.assert_refused(response, "value", PREFERENCE_QUERY_PARAMS) + + def test_the_time_zone_list_refuses_a_parameter_it_does_not_read(self): + response = self.client.get(self.time_zone_list, {"country": "FR"}) + self.assert_refused(response, "country", TIME_ZONE_QUERY_PARAMS) + + def test_the_time_zone_list_refuses_a_sort_it_does_not_offer(self): + """This list is ordered by its display form and reads no sort parameter.""" + response = self.client.get(self.time_zone_list, {"ordering": "country_code"}) + self.assert_refused(response, "ordering", TIME_ZONE_QUERY_PARAMS) + + def test_an_unknown_parameter_does_not_answer_an_anonymous_caller(self): + response = self.client.get(self.user_list, {"lms_user_id": self.alice.id}) + assert_error_envelope( + response, expected_status=status.HTTP_401_UNAUTHORIZED, expected_type_slug="authn", + ) + + def test_an_unknown_parameter_does_not_answer_a_caller_without_the_key(self): + self.login(self.learner) + response = self.client.get(self.preference_list, {"lms_user_id": self.alice.id}) + assert_error_envelope( + response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + + def test_the_directory_answers_every_parameter_it_reads(self): + self.send_api_key() + self.assert_each_is_answered(self.user_list, [ + {"page": 1}, + {"page_size": 2}, + {"ordering": "-username"}, + {"view": "minimal"}, + {"fields": "username"}, + {"format": "json"}, + {"username": "alice"}, + {"email": "alice@example.com"}, + {"is_active": "true"}, + {"preference_key": "pref-lang"}, + {"forum_role": "Moderator", "course_key": str(COURSE_KEY)}, + ]) + + def test_a_directory_entry_answers_every_parameter_it_reads(self): + self.send_api_key() + self.assert_each_is_answered(self.user_detail, [ + {"view": "minimal"}, {"fields": "username"}, {"format": "json"}, {"ordering": "username"}, + ]) + + def test_the_preference_list_answers_every_parameter_it_reads(self): + self.send_api_key() + self.assert_each_is_answered(self.preference_list, [ + {"page": 1}, + {"page_size": 2}, + {"ordering": "-key"}, + {"view": "full"}, + {"fields": "key"}, + {"format": "json"}, + {"key": "pref-lang"}, + {"username": "alice"}, + ]) + + def test_a_stored_preference_answers_every_parameter_it_reads(self): + self.send_api_key() + self.assert_each_is_answered(self.preference_detail, [ + {"view": "full"}, {"fields": "key"}, {"format": "json"}, + ]) + + def test_the_time_zone_list_answers_every_parameter_it_reads(self): + self.assert_each_is_answered(self.time_zone_list, [ + {"country_code": "FR"}, {"page": 1}, {"page_size": 5}, {"format": "json"}, + ]) + + def test_a_page_of_the_directory_is_still_narrowed_by_the_filters_it_takes(self): + """The refusal guards the parameters, not the filtering they ask for.""" + self.send_api_key() + response = self.client.get(self.user_list, {"username": "alice", "page_size": 100}) + assert self.usernames_in(response) == ["alice"] + + +@skip_unless_lms +class TokenCallerTests(UserApiV2TestCase): + """ + The directory addresses answer a request carrying the site's API key and refuse every other. + + The key is what admits a caller here, whoever sends it: a token or a + session identifies the account a request comes from, and no account, + staff included, reads the directory without the key. + """ + + def setUp(self): + super().setUp() + self.addresses = { + "user_list": reverse("user_v2:user_list"), + "user_detail": reverse("user_v2:user_detail", kwargs={"username": "alice"}), + "preference_list": reverse("user_v2:user_preference_list"), + "preference_detail": reverse( + "user_v2:user_preference_detail", kwargs={"username": "alice", "key": "pref-lang"}, + ), + } + + def token_headers(self, user): + """Return the request headers that carry a token identifying the given user.""" + return {"HTTP_AUTHORIZATION": f"JWT {create_jwt_for_user(user)}"} + + def test_the_key_alone_reaches_the_directory(self): + response = self.client.get(self.addresses["user_list"], {"page_size": 100}, HTTP_X_EDX_API_KEY=API_KEY) + assert response.status_code == status.HTTP_200_OK + assert self.usernames_in(response) == ["admin-caller", "alice", "bob", "learner-caller"] + assert response.data["count"] == 4 + + def test_the_key_alone_reaches_every_directory_address(self): + self.send_api_key() + detail = self.client.get(self.addresses["user_detail"]) + assert detail.status_code == status.HTTP_200_OK + assert detail.data["username"] == "alice" + + preferences = self.client.get(self.addresses["preference_list"], {"username": "alice"}) + assert preferences.status_code == status.HTTP_200_OK + assert [(row["username"], row["key"]) for row in preferences.data["results"]] == [ + ("alice", "pref-lang"), + ] + + preference = self.client.get(self.addresses["preference_detail"]) + assert preference.status_code == status.HTTP_200_OK + assert preference.data["value"] == "en" + + def test_the_key_admits_whichever_account_sends_it(self): + response = self.client.get( + self.addresses["user_list"], {"username": "alice"}, + HTTP_X_EDX_API_KEY=API_KEY, **self.token_headers(self.learner), + ) + assert response.status_code == status.HTTP_200_OK + assert response.wsgi_request.user == self.learner + assert self.usernames_in(response) == ["alice"] + + def test_a_staff_token_without_the_key_is_forbidden_on_every_directory_address(self): + headers = self.token_headers(self.admin) + for name, address in self.addresses.items(): + with self.subTest(address=name): + assert_error_envelope( + self.client.get(address, **headers), + expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + + def test_a_staff_session_without_the_key_is_forbidden_on_every_directory_address(self): + self.login(self.admin) + for name, address in self.addresses.items(): + with self.subTest(address=name): + assert_error_envelope( + self.client.get(address), + expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + + def test_a_learners_token_is_forbidden_on_every_directory_address(self): + headers = self.token_headers(self.learner) + for name, address in self.addresses.items(): + with self.subTest(address=name): + response = self.client.get(address, **headers) + assert_error_envelope( + response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + + def test_the_owner_of_the_record_is_still_refused_without_the_key(self): + response = self.client.get( + self.addresses["preference_detail"], **self.token_headers(self.alice), + ) + assert_error_envelope( + response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + + def test_a_key_other_than_the_sites_is_unauthenticated(self): + for name, address in self.addresses.items(): + with self.subTest(address=name): + assert_error_envelope( + self.client.get(address, HTTP_X_EDX_API_KEY=f"{API_KEY}-other"), + expected_status=status.HTTP_401_UNAUTHORIZED, expected_type_slug="authn", + ) + + @override_settings(EDX_API_KEY=None) + def test_no_key_admits_a_caller_on_a_site_that_configures_none(self): + assert_error_envelope( + self.client.get(self.addresses["user_list"], HTTP_X_EDX_API_KEY="None"), + expected_status=status.HTTP_401_UNAUTHORIZED, expected_type_slug="authn", + ) + + def test_a_token_that_does_not_decode_is_unauthenticated(self): + response = self.client.get( + self.addresses["user_list"], HTTP_AUTHORIZATION="JWT not.a.token", HTTP_X_EDX_API_KEY=API_KEY, + ) + assert_error_envelope( + response, expected_status=status.HTTP_401_UNAUTHORIZED, expected_type_slug="authn", + ) + + +@skip_unless_lms +class SharedSortValueTests(UserApiV2TestCase): + """Users that compare equal on the sorted column are ordered the same way on every page.""" + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:user_list") + joined = datetime(2024, 3, 1, tzinfo=UTC) + for username in ("zoe-shared", "mia-shared", "ada-shared"): + UserFactory.create(username=username, date_joined=joined) + self.send_api_key() + + def test_users_sharing_a_join_date_are_ordered_by_username(self): + response = self.client.get(self.url, {"ordering": "date_joined", "page_size": 100}) + shared = [name for name in self.usernames_in(response) if name.endswith("-shared")] + assert shared == ["ada-shared", "mia-shared", "zoe-shared"] + + def test_the_sort_the_caller_asked_for_still_decides_the_order(self): + """The tie-break settles rows that compare equal; the requested sort places them.""" + response = self.client.get(self.url, {"ordering": "-date_joined", "page_size": 100}) + assert self.usernames_in(response)[:3] == ["ada-shared", "mia-shared", "zoe-shared"] + + def test_paging_through_a_shared_join_date_lists_each_user_once(self): + listed = [] + for page in (1, 2, 3): + response = self.client.get( + self.url, {"ordering": "date_joined", "page_size": 3, "page": page}, + ) + listed.extend(self.usernames_in(response)) + assert sorted(listed) == [ + "ada-shared", "admin-caller", "alice", "bob", "learner-caller", "mia-shared", "zoe-shared", + ] + + +@skip_unless_lms +class DeactivatedStaffCallerTests(UserApiV2TestCase): + """ + A staff account is refused here without the key whether or not it is still active. + + Deactivating the account changes only which half of the default + authentication pair refuses it: the session half stops accepting it, while + a token issued before the account was deactivated is still read and then + refused for the missing key. The key admits the request whatever state the + account sending it is in. + """ + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:user_list") + self.staff = AdminFactory.create(username="deactivated-admin", password=PASSWORD) + self.directory = { + "user_list": self.url, + "user_detail": reverse("user_v2:user_detail", kwargs={"username": "alice"}), + "preference_list": reverse("user_v2:user_preference_list"), + "preference_detail": reverse( + "user_v2:user_preference_detail", kwargs={"username": "alice", "key": "pref-lang"}, + ), + } + + def deactivate(self, user): + """Clear an account's active flag the way an operator does, leaving its staff flag.""" + user.is_active = False + user.save() + + def test_a_session_held_by_a_staff_account_is_refused_before_and_after_it_is_deactivated(self): + self.login(self.staff) + assert_error_envelope( + self.client.get(self.url), expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + self.deactivate(self.staff) + response = self.client.get(self.url) + assert_error_envelope( + response, expected_status=status.HTTP_401_UNAUTHORIZED, expected_type_slug="authn", + ) + + def test_a_token_held_by_a_staff_account_is_refused_before_and_after_it_is_deactivated(self): + headers = {"HTTP_AUTHORIZATION": f"JWT {create_jwt_for_user(self.staff)}"} + assert_error_envelope( + self.client.get(self.url, **headers), + expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + self.deactivate(self.staff) + response = self.client.get(self.url, {"page_size": 100}, **headers) + assert_error_envelope( + response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + + def test_a_deactivated_staff_token_is_refused_at_every_directory_address(self): + self.deactivate(self.staff) + headers = {"HTTP_AUTHORIZATION": f"JWT {create_jwt_for_user(self.staff)}"} + for name, address in self.directory.items(): + with self.subTest(address=name): + response = self.client.get(address, **headers) + assert_error_envelope( + response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + + def test_a_deactivated_staff_session_is_refused_at_every_directory_address(self): + self.login(self.staff) + self.deactivate(self.staff) + for name, address in self.directory.items(): + with self.subTest(address=name): + response = self.client.get(address) + assert_error_envelope( + response, expected_status=status.HTTP_401_UNAUTHORIZED, expected_type_slug="authn", + ) + + def test_the_directory_does_not_list_the_account_that_was_refused(self): + """The refusal is the whole response: no row of the page leaks past it.""" + self.deactivate(self.staff) + response = self.client.get( + self.url, {"page_size": 100}, HTTP_AUTHORIZATION=f"JWT {create_jwt_for_user(self.staff)}", + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + assert "results" not in response.data + + def test_the_key_admits_a_deactivated_staff_account_as_it_admits_anyone(self): + self.deactivate(self.staff) + response = self.client.get( + self.url, {"page_size": 100}, + HTTP_AUTHORIZATION=f"JWT {create_jwt_for_user(self.staff)}", HTTP_X_EDX_API_KEY=API_KEY, + ) + assert response.status_code == status.HTTP_200_OK + assert "deactivated-admin" in self.usernames_in(response) + + def test_a_token_held_by_a_deactivated_learner_is_still_forbidden(self): + self.deactivate(self.learner) + response = self.client.get( + self.url, HTTP_AUTHORIZATION=f"JWT {create_jwt_for_user(self.learner)}", + ) + assert_error_envelope( + response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + + +@skip_unless_lms +class PreferenceSerializerTests(UserApiV2TestCase): + """The preference address is built with or without a request to make it absolute.""" + + def test_url_is_relative_without_a_request(self): + preference = self.alice.preferences.get(key="pref-lang") + data = UserPreferenceRecordSerializer(preference).data + assert data["url"] == "/api/user/v2/user_preferences/alice,pref-lang/" + assert data["user"] is None + + +class RecordingScopePolicy: + """A record-visibility policy that drops one owner's rows and records each call.""" + + def __init__(self, hidden_username): + self.hidden_username = hidden_username + self.calls = [] + + def scope(self, queryset, subject): + """Return the rows of ``queryset`` that do not belong to the hidden owner.""" + self.calls.append((queryset.model, subject)) + owner_field = "username" if queryset.model is User else "user__username" + return queryset.exclude(**{owner_field: self.hidden_username}) + + +@skip_unless_lms +class QuerysetScopingSeamTests(UserApiV2TestCase): + """ + The record-visibility layer of the two collections backed by a queryset. + + Each of these collections declares a policy that decides which rows a + caller may see, and the rows it leaves out are absent from the answer + rather than refused. The policy that ships keeps every row, because every + caller holding the site's API key may read all of them, so these tests + put a narrowing policy in its place and read the answer: a collection + that stopped consulting its policy would hand back the rows the policy + left out, and a collection that no longer declared one would narrow + nothing whatever policy it was given. + """ + + def scoped(self, view_class, hidden_username): + """Run the view class with a policy that hides one owner's rows.""" + policy = RecordingScopePolicy(hidden_username) + return patch.object(view_class, "scoping_policy", policy, create=True), policy + + def test_user_list_leaves_out_the_rows_its_policy_excludes(self): + self.login(self.admin) + self.send_api_key() + patched, policy = self.scoped(UserViewSet, "alice") + with patched: + response = self.client.get(reverse("user_v2:user_list")) + assert response.status_code == status.HTTP_200_OK + assert self.usernames_in(response) == ["admin-caller", "bob", "learner-caller"] + assert policy.calls == [(User, self.admin)] + + def test_user_detail_of_an_excluded_row_is_not_found(self): + self.send_api_key() + patched, _ = self.scoped(UserViewSet, "alice") + with patched: + response = self.client.get(reverse("user_v2:user_detail", kwargs={"username": "alice"})) + assert_error_envelope( + response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + + def test_user_detail_of_an_included_row_is_returned(self): + self.send_api_key() + patched, _ = self.scoped(UserViewSet, "alice") + with patched: + response = self.client.get(reverse("user_v2:user_detail", kwargs={"username": "bob"})) + assert response.status_code == status.HTTP_200_OK + assert response.data["username"] == "bob" + + def test_preference_list_leaves_out_the_rows_its_policy_excludes(self): + self.login(self.admin) + self.send_api_key() + patched, policy = self.scoped(UserPreferenceViewSet, "alice") + with patched: + response = self.client.get(reverse("user_v2:user_preference_list")) + assert response.status_code == status.HTTP_200_OK + assert [(row["username"], row["key"]) for row in response.data["results"]] == [("bob", "time_zone")] + assert policy.calls == [(UserPreference, self.admin)] + + def test_preference_detail_of_an_excluded_row_is_not_found(self): + self.send_api_key() + patched, _ = self.scoped(UserPreferenceViewSet, "alice") + with patched: + response = self.client.get( + reverse("user_v2:user_preference_detail", kwargs={"username": "alice", "key": "pref-lang"}), + ) + assert_error_envelope( + response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + + def test_preference_detail_of_an_included_row_is_returned(self): + self.send_api_key() + patched, _ = self.scoped(UserPreferenceViewSet, "alice") + with patched: + response = self.client.get( + reverse("user_v2:user_preference_detail", kwargs={"username": "bob", "key": "time_zone"}), + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["value"] == "UTC" + + def test_each_scoped_collection_declares_a_policy_of_its_own(self): + """ + Each of these two views names the policy that governs its rows. + + A view that inherited the default instead of naming one would be left + with no policy at all, so the declaration is read from the view's own + attributes rather than from anything it inherits. + """ + for view_class in (UserViewSet, UserPreferenceViewSet): + declared = vars(view_class).get("scoping_policy") + assert declared is not None, f"{view_class.__name__} declares no record-visibility policy" + assert callable(getattr(declared, "scope", None)), ( + f"{view_class.__name__} declares a record-visibility policy that cannot narrow rows" + ) + + def test_the_rows_are_fetched_through_the_declared_policy(self): + """ + The policy is what the rows are fetched through, not a step that can be skipped. + + A view left without a usable policy hands back no rows at all rather + than every row, which is how a collection that stopped consulting its + policy is told apart from one whose policy happens to keep every row. + """ + for view_class in (UserViewSet, UserPreferenceViewSet): + with patch.object(view_class, "scoping_policy", None, create=True): + with pytest.raises(ImproperlyConfigured): + view_class().get_queryset() + + +def routed_view_classes(): + """Return every view class this version mounts, by the name it is routed under.""" + classes = {} + for entry in v2_urls.urlpatterns: + view_class = getattr(entry.callback, "cls", None) + if view_class is not None: + classes[view_class.__name__] = view_class + return classes + + +#: Words that name how an address is built rather than what it answers. A +#: description carrying one of these was written for somebody reading this +#: module, and the body of an OPTIONS request is not where they will read it. +IMPLEMENTATION_TERMS = ( + "ViewSet", + "queryset", + "permission_classes", + "serializer", + "filter set", + "filter backend", + "mixin", + "scoping policy", + "router", + "selectors", + "class attribute", +) + +#: The longest description an address gives of itself. The client-facing text +#: is a short paragraph; the maintainer-facing text these replaced ran to four. +DESCRIPTION_LIMIT = 700 + + +@skip_unless_lms +class AddressSelfDescriptionTests(UserApiV2TestCase): + """ + What each address of this version says about itself to a caller that asks. + + An OPTIONS request is answered with the description the view class + carries, so that description is published text and is read by clients and + by documentation browsers alongside the description of the same address in + the schema. These tests hold it to being written for the caller: no markup + that renders as itself in a JSON body, no vocabulary from the + implementation, and no essay. + """ + + def test_every_mounted_address_is_described(self): + """Each view this version mounts says something about itself.""" + described = { + name: view_class().get_view_description() + for name, view_class in routed_view_classes().items() + } + assert len(described) == 16, sorted(described) + assert [name for name, text in described.items() if not text.strip()] == [] + + def test_no_address_describes_itself_in_implementation_terms(self): + offenders = { + name: [term for term in IMPLEMENTATION_TERMS if term in view_class().get_view_description()] + for name, view_class in routed_view_classes().items() + } + assert {name: terms for name, terms in offenders.items() if terms} == {} + + def test_no_address_describes_itself_with_markup_a_caller_sees_as_itself(self): + """Double backticks are markup in this module's own documentation and text in a JSON body.""" + carrying_markup = [ + name for name, view_class in routed_view_classes().items() + if "``" in view_class().get_view_description() + ] + assert carrying_markup == [] + + def test_no_address_describes_itself_at_length(self): + too_long = { + name: len(view_class().get_view_description()) + for name, view_class in routed_view_classes().items() + if len(view_class().get_view_description()) > DESCRIPTION_LIMIT + } + assert too_long == {} + + def test_options_answers_with_the_description_the_view_carries(self): + """ + The description a view carries is the one a caller is handed. + + This is what makes the text above published rather than internal: the + framework answers an OPTIONS request with it verbatim. + """ + self.send_api_key() + answer = self.client.options(reverse("user_v2:user_list")) + assert answer.status_code == status.HTTP_200_OK + assert answer.json()["description"] == UserViewSet().get_view_description() + assert answer.json()["description"].startswith( + "Directory entries of the platform's users, each addressed by username.", + ) + + +@skip_unless_lms +class SelectedPropertyNameTests(UserApiV2TestCase): + """ + What a selected property name may be, at the collections that take one. + + The parameter takes the names of properties the answer carries. A name + written with a dot in it names no such property: the answer carries the + property the name starts with, whole, and the rest of the name selects + nothing. Reading such a name as its first part would hand a caller the + whole of a property it asked one piece of, and would accept a second part + that names nothing at all. + """ + + def setUp(self): + super().setUp() + self.list_url = reverse("user_v2:user_list") + self.member_url = reverse("user_v2:user_detail", kwargs={"username": "alice"}) + self.preference_list_url = reverse("user_v2:user_preference_list") + self.send_api_key() + + def test_a_name_written_with_a_dot_is_refused_on_the_directory_list(self): + response = self.client.get(f"{self.list_url}?fields=username.first") + assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert response.json()["errors"]["fields"] == [ + "No such field: username.first. Select any of: email, id, name, preferences, url, " + "username.", + ] + + def test_a_name_written_with_a_dot_is_refused_on_a_directory_entry(self): + response = self.client.get(f"{self.member_url}?fields=preferences.pref-lang") + assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert "No such field: preferences.pref-lang." in response.json()["errors"]["fields"][0] + + def test_a_name_written_with_a_dot_is_refused_on_the_preference_list(self): + response = self.client.get(f"{self.preference_list_url}?fields=user.email") + assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert "No such field: user.email." in response.json()["errors"]["fields"][0] + + def test_a_plain_name_still_selects_that_property_alone(self): + response = self.client.get(f"{self.member_url}?fields=username") + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"username": "alice"} + + def test_a_plain_name_still_selects_that_property_alone_on_a_list(self): + response = self.client.get(f"{self.list_url}?fields=username,email&ordering=username") + assert response.status_code == status.HTTP_200_OK + assert response.json()["results"] == [ + {"username": "admin-caller", "email": self.admin.email}, + {"username": "alice", "email": "alice@example.com"}, + {"username": "bob", "email": "bob@example.com"}, + {"username": "learner-caller", "email": self.learner.email}, + ] + + +@skip_unless_lms +class UnsatisfiableAcceptTests(UserApiV2TestCase): + """ + What an address answers a request it can give no acceptable answer to. + + Which representations an answer may be given in is settled as the request + enters, so the refusal reaches an address that answers with a body and one + that answers with none alike. It is published by every operation of this + version, and these are the measurements that publication rests on. + """ + + def addresses(self): + """Return a representative request to each kind of address of this version, by method.""" + return [ + ("get", reverse("user_v2:user_list"), self.admin), + ("get", reverse("user_v2:user_detail", kwargs={"username": "alice"}), self.admin), + ("get", reverse("user_v2:user_preference_list"), self.admin), + ("get", reverse("user_v2:time_zone_list"), self.learner), + ("get", reverse("user_v2:account_list"), self.learner), + ("get", reverse("user_v2:account_detail", kwargs={"username": "me"}), self.learner), + ("get", reverse("user_v2:account_preference_list", kwargs={"username": "alice"}), self.alice), + ( + "get", + reverse("user_v2:account_preference_detail", kwargs={"username": "alice", "key": "pref-lang"}), + self.alice, + ), + ("delete", reverse("user_v2:account_profile_image", kwargs={"username": "alice"}), self.alice), + ] + + def test_an_accept_header_naming_nothing_the_address_answers_in_is_refused(self): + answered = {} + for method, url, caller in self.addresses(): + self.client.logout() + self.login(caller) + response = getattr(self.client, method)(url, HTTP_ACCEPT="text/csv") + answered[f"{method.upper()} {url}"] = response.status_code + assert set(answered.values()) == {status.HTTP_406_NOT_ACCEPTABLE}, answered + + def test_the_refusal_comes_back_in_the_body_this_version_answers_with(self): + """The refusal is readable even though the caller asked for a representation it is not in.""" + self.send_api_key() + response = self.client.get(reverse("user_v2:user_list"), HTTP_ACCEPT="text/csv") + assert_error_envelope( + response, expected_status=status.HTTP_406_NOT_ACCEPTABLE, expected_type_slug=NOT_ACCEPTABLE_SLUG, + ) + + def test_an_address_that_answers_with_no_body_refuses_it_too(self): + """ + Nothing is read or changed on the way to the refusal. + + Clearing an image answers with no body at all, so the representation + asked for could make no difference to what comes back; the refusal is + settled before the handler runs, and the stored image is left alone. + """ + UserProfile.objects.filter(user=self.alice).update(profile_image_uploaded_at=datetime.now(UTC)) + self.login(self.alice) + response = self.client.delete( + reverse("user_v2:account_profile_image", kwargs={"username": "alice"}), + HTTP_ACCEPT="text/csv", + ) + assert response.status_code == status.HTTP_406_NOT_ACCEPTABLE + self.alice.profile.refresh_from_db() + assert self.alice.profile.has_profile_image is True + + def test_an_accept_header_the_address_can_satisfy_is_answered(self): + """The refusal is about what was asked for, not about asking at all.""" + self.send_api_key() + response = self.client.get(reverse("user_v2:user_list"), HTTP_ACCEPT="application/json") + assert response.status_code == status.HTTP_200_OK + assert self.usernames_in(response) == ["admin-caller", "alice", "bob", "learner-caller"] + + +@skip_unless_lms +class SessionWithoutItsTokenTests(UserApiV2TestCase): + """ + What a write answers a session the site cannot tie to a page it served. + + Both of these collections record something for the caller and are reached + from the site's own pages, and both refuse such a session. Only one of + them published that refusal, so they are measured together here. + """ + + def test_asking_for_a_name_change_with_a_session_without_its_token_is_refused(self): + client = APIClient(enforce_csrf_checks=True) + assert client.login(username=self.alice.username, password=PASSWORD) + assert_error_envelope( + client.post(reverse("user_v2:name_change_list"), {"name": "Alice Example"}, format="json"), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + assert PendingNameChange.objects.filter(user=self.alice).count() == 0 + + def test_recording_an_organizations_messages_with_the_same_session_is_refused_the_same_way(self): + """The refusal the sibling collection publishes is the one this one answers with.""" + client = APIClient(enforce_csrf_checks=True) + assert client.login(username=self.alice.username, password=PASSWORD) + assert_error_envelope( + client.post( + reverse("user_v2:email_opt_in_list"), + {"course_key": str(COURSE_KEY), "email_opt_in": True}, + format="json", + ), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_a_name_change_asked_for_with_the_token_the_session_carries_is_recorded(self): + """The refusal is about the missing token, not about the address.""" + self.login(self.alice) + response = self.client.post( + reverse("user_v2:name_change_list"), {"name": "Alice Example"}, format="json", + ) + assert response.status_code == status.HTTP_201_CREATED + assert PendingNameChange.objects.get(user=self.alice).new_name == "Alice Example" + + +class HiddenKeyPolicy: + """A record-visibility policy that drops the preferences stored under one name.""" + + def __init__(self, hidden_key): + self.hidden_key = hidden_key + self.calls = 0 + + def scope(self, queryset, subject): # pylint: disable=unused-argument + """Return the rows of ``queryset`` stored under any other name.""" + self.calls += 1 + return queryset.exclude(key=self.hidden_key) + + +@skip_unless_lms +class EmbeddedRowScopingTests(UserApiV2TestCase): + """ + Rows of one resource carried inside another come through the policy that governs them. + + A directory entry carries the preferences stored against its account, and + a stored preference asked for whole carries its owner's entry, which + carries those preferences again. Those rows belong to the other + collection, so the policy that decides which of them a caller may see is + that collection's and not the one the entry came from. Applying it is + arranged where the page is fetched, so it costs no query per row. + """ + + def directory_of(self, rows): + """Create ``rows`` users, each with two stored preferences.""" + made = [] + for index in range(rows): + user = UserFactory.create(username=f"row-{index:03d}", password=PASSWORD) + UserPreferenceFactory.create(user=user, key="pref-lang", value="en") + UserPreferenceFactory.create(user=user, key="time_zone", value="UTC") + made.append(user) + return made + + def test_the_preferences_inside_a_directory_entry_come_through_their_own_policy(self): + policy = HiddenKeyPolicy("pref-lang") + self.send_api_key() + with patch.object(UserViewSet, "embedded_preference_policy", policy, create=True): + response = self.client.get(reverse("user_v2:user_detail", kwargs={"username": "alice"})) + assert response.status_code == status.HTTP_200_OK + assert response.data["preferences"] == {} + assert policy.calls == 1 + + def test_a_preference_the_embedded_policy_keeps_is_still_carried(self): + policy = HiddenKeyPolicy("pref-lang") + self.send_api_key() + with patch.object(UserViewSet, "embedded_preference_policy", policy, create=True): + response = self.client.get(reverse("user_v2:user_detail", kwargs={"username": "bob"})) + assert response.status_code == status.HTTP_200_OK + assert response.data["preferences"] == {"time_zone": "UTC"} + + def test_the_preferences_inside_a_page_of_entries_come_through_their_own_policy(self): + self.directory_of(5) + policy = HiddenKeyPolicy("pref-lang") + self.send_api_key() + with patch.object(UserViewSet, "embedded_preference_policy", policy, create=True): + response = self.client.get(reverse("user_v2:user_list"), {"page_size": 100}) + assert response.status_code == status.HTTP_200_OK + carried = {row["username"]: row["preferences"] for row in response.data["results"]} + assert [name for name, preferences in carried.items() if "pref-lang" in preferences] == [] + assert carried["row-000"] == {"time_zone": "UTC"} + + def test_narrowing_the_embedded_preferences_costs_no_query_per_row(self): + """ + The policy is applied where the page is fetched, so it is run once however many rows there are. + + One row would hide a per-row query that cancels a prefetch, so this is + measured at five rows and again at twenty-five. + """ + self.send_api_key() + measured = {} + for rows in (5, 25): + User.objects.filter(username__startswith="row-").delete() + self.directory_of(rows) + policy = HiddenKeyPolicy("pref-lang") + with patch.object(UserViewSet, "embedded_preference_policy", policy, create=True): + # The first request of a signed-in client pays for its session, + # which is not part of what is being measured here. + self.client.get(reverse("user_v2:user_list"), {"page_size": 100}) + with CaptureQueriesContext(connection) as captured: + response = self.client.get(reverse("user_v2:user_list"), {"page_size": 100}) + assert response.status_code == status.HTTP_200_OK + assert len(response.data["results"]) == rows + 4 + measured[rows] = len(captured) + assert measured[5] == measured[25], measured + + def test_the_preferences_inside_an_embedded_owner_come_through_their_own_policy(self): + """A preference asked for whole carries its owner, whose own preferences are narrowed too.""" + policy = HiddenKeyPolicy("pref-lang") + self.send_api_key() + with patch.object(UserPreferenceViewSet, "embedded_preference_policy", policy, create=True): + response = self.client.get( + reverse("user_v2:user_preference_detail", kwargs={"username": "alice", "key": "pref-lang"}), + {"view": "full"}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["user"]["preferences"] == {} + + def test_narrowing_an_embedded_owners_preferences_costs_no_query_per_row(self): + self.send_api_key() + measured = {} + for rows in (5, 25): + User.objects.filter(username__startswith="row-").delete() + self.directory_of(rows) + policy = HiddenKeyPolicy("pref-lang") + address = reverse("user_v2:user_preference_list") + with patch.object(UserPreferenceViewSet, "embedded_preference_policy", policy, create=True): + self.client.get(address, {"view": "full", "page_size": 100}) + with CaptureQueriesContext(connection) as captured: + response = self.client.get(address, {"view": "full", "page_size": 100}) + assert response.status_code == status.HTTP_200_OK + assert len(response.data["results"]) == rows * 2 + 2 + measured[rows] = len(captured) + assert measured[5] == measured[25], measured + + +class HiddenOwnerPolicy: + """A record-visibility policy over owners that hides one of them and counts its calls.""" + + def __init__(self, hidden_username): + self.hidden_username = hidden_username + self.calls = 0 + + def scope(self, queryset, subject): # pylint: disable=unused-argument + """Return the rows of ``queryset`` that are not the hidden owner.""" + self.calls += 1 + return queryset.exclude(username=self.hidden_username) + + +@skip_unless_lms +class EmbeddedOwnerScopingTests(UserApiV2TestCase): + """ + The owner carried inside a stored preference comes through the policy that governs owners. + + A preference asked for whole carries its owner's directory entry, which is + a row of the directory and not of the preference collection, so which + owners a caller may see is the directory's decision. An entry the caller + may not see is left out of the answer; the preference itself is still + returned, named by the owner it is stored against, because which + preferences come back is the preference collection's own decision. + """ + + def preferences_of(self, rows): + """Create ``rows`` users, each with two stored preferences.""" + for index in range(rows): + user = UserFactory.create(username=f"row-{index:03d}", password=PASSWORD) + UserPreferenceFactory.create(user=user, key="pref-lang", value="en") + UserPreferenceFactory.create(user=user, key="time_zone", value="UTC") + + def test_an_owner_the_embedded_policy_leaves_out_is_not_carried_inside_a_page(self): + policy = HiddenOwnerPolicy("alice") + self.send_api_key() + with patch.object(UserPreferenceViewSet, "embedded_user_policy", policy, create=True): + response = self.client.get( + reverse("user_v2:user_preference_list"), {"view": "full", "page_size": 100}, + ) + assert response.status_code == status.HTTP_200_OK + carried = {row["username"]: row["user"] for row in response.data["results"]} + assert set(carried) == {"alice", "bob"} + assert carried["alice"] is None + assert carried["bob"]["username"] == "bob" + assert carried["bob"]["email"] == "bob@example.com" + + def test_an_owner_the_embedded_policy_leaves_out_is_not_carried_inside_one_preference(self): + policy = HiddenOwnerPolicy("alice") + self.send_api_key() + with patch.object(UserPreferenceViewSet, "embedded_user_policy", policy, create=True): + response = self.client.get( + reverse("user_v2:user_preference_detail", kwargs={"username": "alice", "key": "pref-lang"}), + {"view": "full"}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["user"] is None + assert response.data["username"] == "alice" + assert response.data["key"] == "pref-lang" + assert response.data["value"] == "en" + + def test_an_owner_the_embedded_policy_keeps_is_still_carried(self): + """ + An owner the policy keeps comes back whole. + + This is a control rather than a guard: it reads a preference whose + owner the policy keeps, so it is green whether or not the policy is + consulted. It is here to show the answer is not narrowed too far. + """ + policy = HiddenOwnerPolicy("alice") + self.send_api_key() + with patch.object(UserPreferenceViewSet, "embedded_user_policy", policy, create=True): + response = self.client.get( + reverse("user_v2:user_preference_detail", kwargs={"username": "bob", "key": "time_zone"}), + {"view": "full"}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["user"]["username"] == "bob" + assert response.data["user"]["preferences"] == {"time_zone": "UTC"} + + def test_the_owners_of_a_page_are_resolved_once_however_many_rows_it_carries(self): + """ + The owners a page may carry are settled for the whole page, so the cost does not grow with rows. + + One row would hide a per-row lookup, so this is measured at five rows + and again at twenty-five, and the policy is asked once either way. + """ + self.send_api_key() + measured = {} + address = reverse("user_v2:user_preference_list") + for rows in (5, 25): + User.objects.filter(username__startswith="row-").delete() + self.preferences_of(rows) + policy = HiddenOwnerPolicy("alice") + with patch.object(UserPreferenceViewSet, "embedded_user_policy", policy, create=True): + # The first request of a signed-in client pays for its session, + # which is not part of what is being measured here. + self.client.get(address, {"view": "full", "page_size": 100}) + with CaptureQueriesContext(connection) as captured: + response = self.client.get(address, {"view": "full", "page_size": 100}) + assert response.status_code == status.HTTP_200_OK + assert len(response.data["results"]) == rows * 2 + 2 + assert policy.calls == 2, "the owners of a page are resolved once per request" + measured[rows] = len(captured) + assert measured[5] == measured[25], measured + + def test_no_owner_is_resolved_for_a_page_that_carries_none(self): + """A page that does not carry its owners does not ask which owners it could carry.""" + self.send_api_key() + policy = HiddenOwnerPolicy("alice") + with patch.object(UserPreferenceViewSet, "embedded_user_policy", policy, create=True): + response = self.client.get(reverse("user_v2:user_preference_list"), {"page_size": 100}) + assert response.status_code == status.HTTP_200_OK + assert [row["username"] for row in response.data["results"]] == ["alice", "bob"] + assert policy.calls == 0 + + def test_a_policy_answering_with_a_collection_of_its_own_still_narrows_what_is_carried(self): + """ + A policy need not narrow the collection it is handed for its answer to hold. + + The owners of the page are taken out of what the policy answers with, + so a policy that builds its own collection instead of narrowing the + one it is given narrows the page just the same. + """ + class OwnCollectionPolicy: + """A policy that answers with a collection of its own.""" + + def scope(self, queryset, subject): # pylint: disable=unused-argument + """Return the one owner this policy keeps, built without the given collection.""" + return User.objects.filter(username="bob") + + self.send_api_key() + with patch.object(UserPreferenceViewSet, "embedded_user_policy", OwnCollectionPolicy(), create=True): + response = self.client.get( + reverse("user_v2:user_preference_list"), {"view": "full", "page_size": 100}, + ) + assert response.status_code == status.HTTP_200_OK + carried = {row["username"]: row["user"] for row in response.data["results"]} + assert carried["alice"] is None + assert carried["bob"]["username"] == "bob" + + def test_the_embedded_owner_policy_is_declared_by_the_view_that_embeds(self): + """ + The view names the policy that governs the owners it carries, rather than inheriting one. + + A view that inherited the default would be left with no policy over + the entries it embeds, which is how a view that stopped declaring one + is told apart from a view whose policy happens to keep every owner. + """ + declared = vars(UserPreferenceViewSet).get("embedded_user_policy") + assert declared is not None, "UserPreferenceViewSet declares no policy over the owners it carries" + assert callable(getattr(declared, "scope", None)), ( + "UserPreferenceViewSet declares a policy over the owners it carries that cannot narrow rows" + ) diff --git a/openedx/core/djangoapps/user_api/v2/urls.py b/openedx/core/djangoapps/user_api/v2/urls.py new file mode 100644 index 000000000000..a12ec6fa4125 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/urls.py @@ -0,0 +1,132 @@ +"""URLs for version 2 of the User API.""" + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.urls import re_path +from rest_framework.routers import SimpleRouter + +from openedx.core.djangoapps.profile_images.v2.views import ProfileImageView + +from .views import ( + PREFERENCE_KEY_PATTERN_PARTIAL, + AccountDeactivationView, + AccountPreferenceViewSet, + AccountViewSet, + CountryTimeZoneViewSet, + EmailOptInViewSet, + EmailSearchViewSet, + IDVerificationStatusView, + IDVerificationViewSet, + NameChangeViewSet, + RetirementCleanupViewSet, + RetirementPartnerReportCleanupViewSet, + RetirementPartnerReportViewSet, + RetirementViewSet, + UserPreferenceViewSet, + UserViewSet, +) + +app_name = "user_v2" + +#: The router's id-based preference route, replaced by the owner,key address below. +REPLACED_ROUTER_ROUTE = "user_preference_detail" + +def without_route(routes, name): + """Return ``routes`` without the one named ``name``, refusing a name the router does not register.""" + kept = [route for route in routes if route.name != name] + if len(kept) == len(routes): + raise ImproperlyConfigured(f"The router registers no route named {name!r} to replace.") + return kept + + +class SnakeCaseRouter(SimpleRouter): + """A router that names its routes ``_``.""" + + routes = [route._replace(name=route.name.replace("-", "_")) for route in SimpleRouter.routes] + + +router = SnakeCaseRouter() +router.register(r"accounts", AccountViewSet, basename="account") +router.register( + rf"accounts/(?P{settings.USERNAME_REGEX_PARTIAL})/verifications", + IDVerificationViewSet, + basename="account_verification", +) +# Beside the accounts rather than under them: an account may be named anything. +router.register(r"email_searches", EmailSearchViewSet, basename="email_search") +# What a user wants to be sent is recorded against that user and against the +# organization running a course they named, so the choice is reached without +# naming either in the address and stands beside the accounts rather than +# under them. +router.register(r"email_opt_ins", EmailOptInViewSet, basename="email_opt_in") +router.register(r"name_changes", NameChangeViewSet, basename="name_change") +router.register(r"users", UserViewSet, basename="user") +router.register(r"user_preferences", UserPreferenceViewSet, basename="user_preference") +router.register(r"time_zones", CountryTimeZoneViewSet, basename="time_zone") +# Retirements are worked across accounts, so they stand beside accounts, keyed +# by the name the account was registered under. +router.register(r"retirements", RetirementViewSet, basename="retirement") +# Batch disposal has no member address, so it gets its own collection: a fixed +# word after a collection would take the place of a member's name. +router.register(r"retirement_cleanups", RetirementCleanupViewSet, basename="retirement_cleanup") +# What is owed to the organizations an account studied with outlives the +# account, so it is recorded on its own and reached without naming one. +router.register( + r"retirement_partner_reports", + RetirementPartnerReportViewSet, + basename="retirement_partner_report", +) +# The entries that have been reported on are disposed of in the same way, and +# at a collection of their own for the same reason. +router.register( + r"retirement_partner_report_cleanups", + RetirementPartnerReportCleanupViewSet, + basename="retirement_partner_report_cleanup", +) + + +urlpatterns = [ + *without_route(router.urls, REPLACED_ROUTER_ROUTE), + re_path( + rf"^accounts/(?P{settings.USERNAME_REGEX_PARTIAL})/profile_image/$", + ProfileImageView.as_view(), + name="account_profile_image", + ), + re_path( + rf"^accounts/(?P{settings.USERNAME_REGEX_PARTIAL})/deactivation/$", + AccountDeactivationView.as_view(), + name="account_deactivation", + ), + re_path( + rf"^accounts/(?P{settings.USERNAME_REGEX_PARTIAL})/verification_status/$", + IDVerificationStatusView.as_view(), + name="account_verification_status", + ), + # One account's preferences are one document, read and changed whole, and + # each of them is also reached on its own; a preference is stored under its + # owner and its name together, so neither address can be built without the + # account. The two are declared rather than generated because the document + # is changed with a method the generated collection route does not carry. + re_path( + rf"^accounts/(?P{settings.USERNAME_REGEX_PARTIAL})/preferences/$", + AccountPreferenceViewSet.as_view({"get": "retrieve_all", "patch": "partial_update_all"}), + name="account_preference_list", + ), + # The name a preference is stored under takes the rest of the segment, so + # every name that carries no separator of its own can be addressed. + re_path( + rf"^accounts/(?P{settings.USERNAME_REGEX_PARTIAL})/preferences/" + rf"(?P{PREFERENCE_KEY_PATTERN_PARTIAL})/$", + AccountPreferenceViewSet.as_view( + {"get": "retrieve", "put": "update", "delete": "destroy"}, + ), + name="account_preference_detail", + ), + # The key half takes the rest of the segment after the comma. + re_path( + rf"^user_preferences/(?P{settings.USERNAME_REGEX_PARTIAL})" + rf",(?P{PREFERENCE_KEY_PATTERN_PARTIAL})/$", + UserPreferenceViewSet.as_view({"get": "retrieve"}), + name="user_preference_detail", + ), +] diff --git a/openedx/core/djangoapps/user_api/v2/view_services.py b/openedx/core/djangoapps/user_api/v2/view_services.py new file mode 100644 index 000000000000..669af525aae2 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/view_services.py @@ -0,0 +1,400 @@ +"""Operations version 2 of the User API performs on the account retirement pipeline.""" + +import logging + +from rest_framework.exceptions import NotFound +from wiki.models import ArticleRevision +from wiki.models.pluginbase import RevisionPluginRevision + +from common.djangoapps.student.models import ( + AccountRecovery, + CourseEnrollmentAllowed, + ManualEnrollmentAudit, + PendingEmailChange, + PendingNameChange, + PendingSecondaryEmailChange, + get_retired_email_by_email, + get_retired_username_by_username, +) +from openedx.core.djangoapps.api_admin.models import ApiAccessRequest +from openedx.core.djangoapps.course_groups.models import UnregisteredLearnerCohortAssignments +from openedx.core.djangoapps.credit.models import CreditRequest, CreditRequirementStatus +from openedx.core.djangoapps.user_api.accounts.signals import ( + USER_RETIRE_LMS_CRITICAL, + USER_RETIRE_LMS_MISC, + USER_RETIRE_MAILINGS, +) +from openedx.core.djangoapps.user_api.accounts.utils import ( + get_orgs_for_user, + handle_retirement_cancellation, + redact_and_delete_historical_social_auth, +) +from openedx.core.djangoapps.user_api.accounts.views import AccountRetirementView, LMSAccountRetirementView +from openedx.core.djangoapps.user_api.models import ( + RetirementStateError, + UserOrgTag, + UserRetirementPartnerReportingStatus, + UserRetirementStatus, +) + +from .errors import AmbiguousAccountName, RetirementStateConflict +from .selectors import partner_reports_requested_as, retirements_requested_as +from .serializers import ( + ERASURE_CORE_SCOPE, + ERASURE_MISC_SCOPE, + OUTCOME_ALREADY_ABSENT, + OUTCOME_DELETED, +) + +log = logging.getLogger(__name__) + +#: The step a retirement stands at for as long as the learner may still change +#: their mind, which is the only step a retirement can be cancelled from. +PENDING_STATE = "PENDING" + +#: The step a retirement stands at once every other step has run, which is the +#: only step its record is deleted from. +COMPLETE_STATE = "COMPLETE" + +#: Written over a record's copies of name and address before it is deleted. +REDACTED_VALUE = "redacted-before-delete" +REDACTED_EMAIL = "redacted-before-delete@safe.com" + +#: What a caller is told about a failure; the specific reason is logged instead. +NO_SUCH_RETIREMENT = "No retirement was requested under that name." +NOT_OPEN_TO_ACTION = "This retirement does not stand at a step of the pipeline that admits this operation." +CANNOT_MOVE_TO_STEP = "This retirement cannot be moved to that step from the step it stands at." +ONLY_PENDING_IS_CANCELLED = ( + f"Only a retirement still at {PENDING_STATE} can be cancelled, and this one has moved past it." +) +BATCH_LEFT_ALONE = "Some of the records named are not in a state this operation acts on, so none of them was changed." +NAME_IS_AMBIGUOUS = "More than one record was requested under that name, so nothing was done." +SEVERAL_RECORDS_HOLD_THIS_NAME = ( + "More than one record was requested under this name, and acting on one of them would act on an " + "account this request did not name." +) + + +def _only_record_named(username, records): + """ + Return the one record requested under this name, or refuse the name. + + Every operation of this pipeline that names one account resolves it here, + so one name addresses the same record whichever operation is asked for. + The name a record keeps is not unique, so a name several records were + requested under is refused: one of them belongs to an account the caller + did not name, and nothing says which. + """ + if not records: + log.info("No record is requested under the name %r.", username) + raise NotFound(NO_SUCH_RETIREMENT) + if len(records) > 1: + log.warning( + "The name %r was refused: %s records are requested under it, %r.", + username, len(records), sorted(record.id for record in records), + ) + raise AmbiguousAccountName(NAME_IS_AMBIGUOUS) + return records[0] + + +def retirement_requested_as(records, username): + """Return the one record of this selection requested under exactly this name.""" + return _only_record_named( + username, retirements_requested_as(records, [username]).get(username, []), + ) + + +def _admits_pipeline_work(retirement): + """ + Say whether the step this retirement stands at admits pipeline work. + + A step the pipeline itself requires -- the step waiting for the learner + and the steps a retirement ends at -- admits none, and neither does a step + recording that a part of the pipeline has finished. This is the rule + ``UserRetirementStatus.get_retirement_for_retirement_action`` applies to a + name, applied here to the record already resolved, so that the record + judged is the record the caller is handed to work on. + """ + state = retirement.current_state + return not (state.required or state.state_name.endswith("_COMPLETE")) + + +def retirement_open_to_action(records, username): + """ + Return the retirement requested under this name while it admits pipeline work. + + A retirement admits work only between the step that waits for the learner + and the step that records a part of the pipeline as finished, which is what + keeps a step from being run twice over the same account. + """ + retirement = retirement_requested_as(records, username) + if not _admits_pipeline_work(retirement): + log.info( + "The retirement requested as %r stands at %s, which admits no pipeline work.", + username, retirement.current_state.state_name, + ) + raise RetirementStateConflict(NOT_OPEN_TO_ACTION) + return retirement + + +def move_retirement(retirement, new_state, response, force): + """Record that this retirement now stands at another step of the pipeline.""" + try: + retirement.update_state({ + "username": retirement.original_username, + "new_state": new_state, + "response": response, + "force": force, + }) + except RetirementStateError as exc: + log.info("Retirement %s was not moved to %r: %s", retirement.id, new_state, exc) + raise RetirementStateConflict(CANNOT_MOVE_TO_STEP) from exc + + +def cancel_retirement(retirement): + """ + Undo this retirement request, restoring the account it was made for. + + The record is deleted, which is what also removes the permanent record of + the request, and the account is returned to the address it was registered + under with a password nobody holds, so its owner signs in again by asking + for a new one. + """ + standing_at = retirement.current_state.state_name + if standing_at != PENDING_STATE: + log.info("Retirement %s stands at %s, so it was not cancelled.", retirement.id, standing_at) + raise RetirementStateConflict(ONLY_PENDING_IS_CANCELLED) + handle_retirement_cancellation(retirement) + + +def _erase_core_data(retirement): + """Erase what the platform itself holds about the account being retired.""" + user = retirement.user + # The registered name, not the current one: a re-run would otherwise hash a hash. + retired_username = retirement.retired_username or get_retired_username_by_username( + retirement.original_username, + ) + retired_email = retirement.retired_email or get_retired_email_by_email(user.email) + original_email = retirement.original_email + + AccountRetirementView.clear_pii_from_userprofile(user) + AccountRetirementView.delete_users_profile_images(user) + AccountRetirementView.delete_users_country_cache(user) + AccountRetirementView.clear_pii_from_certificate_records(user) + AccountRetirementView.retire_entitlement_support_detail(user) + + PendingEmailChange.delete_by_user_value(user, field="user") + UserOrgTag.delete_by_user_value(user, field="user") + PendingSecondaryEmailChange.redact_and_delete_pending_secondary_email(user.id) + AccountRecovery.retire_recovery_email(user.id) + + CourseEnrollmentAllowed.delete_by_user_value(original_email, field="email") + UnregisteredLearnerCohortAssignments.delete_by_user_value(original_email, field="email") + + USER_RETIRE_LMS_CRITICAL.send( + sender=AccountRetirementView, + user=user, + retired_username=retired_username, + retired_email=retired_email, + ) + + user.first_name = "" + user.last_name = "" + user.is_active = False + user.username = retired_username + user.email = retired_email + user.save() + + +def _erase_misc_data(retirement): + """Erase what the features built on top of the platform hold about the account being retired.""" + user = retirement.user + RevisionPluginRevision.retire_user(user) + ArticleRevision.retire_user(user) + PendingNameChange.delete_by_user_value(user, field="user") + ManualEnrollmentAudit.retire_manual_enrollments(user, retirement.retired_email) + + CreditRequest.retire_user(retirement) + ApiAccessRequest.retire_user(user) + CreditRequirementStatus.retire_user(retirement) + redact_and_delete_historical_social_auth(user.id) + + USER_RETIRE_LMS_MISC.send(sender=LMSAccountRetirementView, user=user) + USER_RETIRE_MAILINGS.send( + sender=LMSAccountRetirementView, + email=retirement.original_email, + new_email=retirement.retired_email, + user=user, + ) + + +#: What each scope erases, in order, and the signal it sends; the sender is the +#: v1 view, because a listener may select on it. +_ERASURES = { + ERASURE_CORE_SCOPE: _erase_core_data, + ERASURE_MISC_SCOPE: _erase_misc_data, +} + + +def erase_account_data(retirement, scope): + """ + Erase one set of the retiring account's personal data. + + The steps are not wrapped in a transaction: a part of the pipeline that + fails halfway is recovered by the driver recording the retirement as + errored and running the step again, and a rollback would undo erasures + that had already succeeded. + """ + try: + _ERASURES[scope](retirement) + except Exception: + log.exception("Erasing the %s data of retirement %s failed partway through.", scope, retirement.id) + raise + + +def record_partner_report(retirement): + """Queue this retirement for reporting to the organizations the account studied with.""" + organizations = get_orgs_for_user(retirement.user) + if not organizations: + return + UserRetirementPartnerReportingStatus.objects.get_or_create( + user=retirement.user, + defaults={ + "original_username": retirement.original_username, + "original_email": retirement.original_email, + "original_name": retirement.original_name, + }, + ) + + +def _partner_report_of(report): + """Return what a partner needs in order to forget the account this queue entry names.""" + organizations = get_orgs_for_user(report.user) + return { + "user_id": report.user.pk, + "original_username": report.original_username, + "original_email": report.original_email, + "original_name": report.original_name, + "orgs": sorted(organizations), + "created": report.created, + } + + +def claim_partner_reports(waiting, limit=None): + """ + Take the partner reports waiting to be processed, and return them. + + The entries returned are exactly the entries taken: they are read first + and then marked by the identifiers that were read, so an entry added while + the answer is being built is left for the next caller rather than marked as + taken by one that never saw it. Nothing releases an entry once it is + taken. + """ + if limit is not None: + waiting = waiting[:limit] + taken = list(waiting) + reports = [_partner_report_of(report) for report in taken] + UserRetirementPartnerReportingStatus.objects.filter( + id__in=[report.id for report in taken], + ).update(is_being_processed=True) + return reports + + +def _batch_outcomes(usernames, deleted): + """Return what was done about each name given, in the order the names were given.""" + return [ + { + "username": username, + "outcome": OUTCOME_DELETED if username in deleted else OUTCOME_ALREADY_ABSENT, + } + for username in usernames + ] + + +def _refuse_batch(refused): + """Refuse a whole batch, naming the records at fault and why.""" + log.info("A batch was left alone because of %r.", sorted(refused)) + raise RetirementStateConflict(BATCH_LEFT_ALONE, errors=refused) + + +def _one_record_for_each_name(found): + """ + Return the one record each name carries, refusing the batch over any name carrying several. + + A batch is acted on whole or not at all, and a name several records were + requested under names an account the caller did not, so the batch is + refused and nothing is deleted. + """ + ambiguous = { + username: [SEVERAL_RECORDS_HOLD_THIS_NAME] + for username, records in found.items() + if len(records) > 1 + } + if ambiguous: + log.warning("A batch was left alone because these names are ambiguous: %r.", sorted(ambiguous)) + raise AmbiguousAccountName(NAME_IS_AMBIGUOUS, errors=ambiguous) + return {username: records[0] for username, records in found.items()} + + +def delete_retirements(records, usernames): + """ + Delete the retirement records of the accounts named, once the pipeline has finished with them. + + The records are resolved from the selection given, so a record outside it + is reported as absent and left alone. A name no record was requested under + is reported as such rather than refused, so a caller repeating a request + that has already been carried out is answered rather than left to tell + "already done" from "never existed". + A record the pipeline has not finished with refuses the whole batch and + nothing is deleted, because the caller asked for a set of records and a + half-applied delete cannot be undone. + """ + found = _one_record_for_each_name(retirements_requested_as(records, usernames)) + refused = { + username: [ + f"This retirement stands at {record.current_state.state_name}; only a retirement at " + f"{COMPLETE_STATE} is deleted here.", + ] + for username, record in found.items() + if record.current_state.state_name != COMPLETE_STATE + } + if refused: + _refuse_batch(refused) + doomed = [record.id for record in found.values()] + if doomed: + # Overwrite the record's PII first, for systems that treat a delete as soft. + UserRetirementStatus.objects.filter(id__in=doomed).update( + original_username=REDACTED_VALUE, + original_email=REDACTED_EMAIL, + original_name=REDACTED_VALUE, + ) + # Re-check the step on delete: a record moved off it after the write was never + # answered about. + UserRetirementStatus.objects.filter( + id__in=doomed, current_state__state_name=COMPLETE_STATE, + ).delete() + return _batch_outcomes(usernames, found) + + +def delete_partner_reports(entries, usernames): + """ + Delete the partner report entries of the accounts named, once they have been reported on. + + The entries are resolved from the selection given, so an entry outside it + is reported as absent and left alone. An entry nobody has taken for + processing has not been reported on yet, so it refuses the whole batch and + nothing is deleted; a name with no entry at all is reported as such, which + is what a repeat of a request already carried out reports for every name. + """ + found = _one_record_for_each_name(partner_reports_requested_as(entries, usernames)) + refused = { + username: ["This partner report has not been taken for processing, so it has not been reported on yet."] + for username, report in found.items() + if not report.is_being_processed + } + if refused: + _refuse_batch(refused) + doomed = [report.id for report in found.values()] + if doomed: + UserRetirementPartnerReportingStatus.objects.filter(id__in=doomed).delete() + return _batch_outcomes(usernames, found) diff --git a/openedx/core/djangoapps/user_api/v2/view_utils.py b/openedx/core/djangoapps/user_api/v2/view_utils.py new file mode 100644 index 000000000000..30bb6c833cf6 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/view_utils.py @@ -0,0 +1,116 @@ +"""View behaviour shared by the endpoints of version 2 of the User API.""" + +import logging + +from django.conf import settings +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse +from rest_framework.exceptions import ValidationError +from rest_framework.settings import api_settings + +from .serializers import ErrorResponse + +log = logging.getLogger(__name__) + +#: Groups the user addresses of every version under one heading in the schema. +USER_TAG = "user" + +#: Marks the operations a generated client library is built from. +SDK_TAG = "openedx-platform-sdk" + +#: The framework's own representation selector. This deployment reads nothing +#: from it, since ``URL_FORMAT_OVERRIDE`` is unset and JSON is the only +#: renderer, and a caller that sends it anyway is answered rather than refused. +FORMAT_PARAM = "format" + +#: The whole of a username, as every route of this version matches one. A +#: client built from the published document holds a name to this shape before +#: it sends a request, so each address that carries a name is described with +#: it and no address of one resource admits a name another refuses. +USERNAME_PATTERN = rf"^{settings.USERNAME_REGEX_PARTIAL}$" + + +def query_param(name, description, **kwargs): + """Build an optional query parameter for the published schema.""" + return OpenApiParameter( + name=name, + description=description, + required=False, + location=OpenApiParameter.QUERY, + **kwargs, + ) + + +def path_param(name, description, pattern): + """Build a path parameter for the published schema, carrying what its route matches.""" + return OpenApiParameter( + name=name, + description=description, + required=True, + location=OpenApiParameter.PATH, + type=str, + pattern=pattern, + ) + + +def names_in(value): + """ + Return the names a comma-separated value carries, in the order they were sent. + + A position carrying nothing names nothing, so an empty one is left out + rather than read as a name no account holds. Every collection of this + version that selects rows by name reads its value through here, so the + same value selects the same rows wherever it is sent. + """ + return [name for name in value.strip(",").split(",") if name] + + +RESP_UNAUTHENTICATED = OpenApiResponse(ErrorResponse, description="The requester is not authenticated.") +RESP_UNREADABLE_BODY = OpenApiResponse( + ErrorResponse, description="The request body is in a media type this endpoint cannot read.", +) + + +class StrictQueryParametersMixin: + """ + Refuses a query parameter the view reads nothing from. + + An unknown parameter is otherwise dropped in silence: a caller that + mistyped a filter, or kept one an earlier version of the endpoint + answered, receives the whole collection and reads it as the narrowed page + it asked for. The names a view answers to are assembled from what it + declares (its paginator, its sort backend, its filter set, and the + serializer that validates its query string), so a parameter that is added + or renamed cannot fall out of step with this check. + """ + + #: Names the view reads that none of the declarations below account for. + extra_query_params = () + + def known_query_params(self): + """Return the names of the query parameters this view reads.""" + names = {FORMAT_PARAM, api_settings.URL_FORMAT_OVERRIDE, *self.extra_query_params} + pagination_class = getattr(self, "pagination_class", None) + if pagination_class is not None: + names.update({pagination_class.page_query_param, pagination_class.page_size_query_param}) + for backend in getattr(self, "filter_backends", ()): + names.add(getattr(backend, "ordering_param", None)) + filterset_class = getattr(self, "filterset_class", None) + if filterset_class is not None: + names.update(filterset_class.base_filters) + query_serializer_class = getattr(self, "query_serializer_class", None) + if query_serializer_class is not None: + names.update(query_serializer_class().fields) + return names - {None} + + def initial(self, request, *args, **kwargs): + """Check the query string once the caller has been admitted.""" + super().initial(request, *args, **kwargs) + known = self.known_query_params() + unknown = sorted(set(request.query_params) - known) + if unknown: + log.info("Query refused on %s: %r", type(self).__name__, unknown) + offered = ", ".join(sorted(known)) + raise ValidationError({ + name: [f"No such query parameter. Send any of: {offered}."] + for name in unknown + }) diff --git a/openedx/core/djangoapps/user_api/v2/views.py b/openedx/core/djangoapps/user_api/v2/views.py new file mode 100644 index 000000000000..281cf8dd549d --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/views.py @@ -0,0 +1,3018 @@ +"""Views for version 2 of the User API.""" + +import contextlib +import logging + +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 F, Prefetch +from django.http import Http404 +from django_filters.rest_framework import DjangoFilterBackend +from django_ratelimit.core import is_ratelimited +from drf_spectacular.utils import OpenApiExample, OpenApiResponse, extend_schema, extend_schema_view +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser +from edx_rest_framework_extensions.mixins import StandardizedErrorMixin +from edx_rest_framework_extensions.paginators import IterablePaginationMixin +from edx_rest_framework_extensions.permissions import IsStaff +from edx_rest_framework_extensions.scoping import FullScopePolicy, ScopedQuerysetMixin +from edx_rest_framework_extensions.shaping import MinimalViewMixin, project +from rest_framework import permissions, status, viewsets +from rest_framework.decorators import action +from rest_framework.exceptions import APIException, NotFound, PermissionDenied, Throttled, ValidationError +from rest_framework.metadata import SimpleMetadata +from rest_framework.parsers import JSONParser +from rest_framework.response import Response +from rest_framework.reverse import reverse +from rest_framework.settings import api_settings +from rest_framework.views import APIView + +from common.djangoapps.student.models import UserProfile, is_email_retired +from common.djangoapps.student.models_api import confirm_name_change, do_name_change_request, get_pending_name_change +from common.djangoapps.track import segment +from lms.djangoapps.verify_student.services import IDVerificationService +from openedx.core.djangoapps.user_api import accounts +from openedx.core.djangoapps.user_api.accounts.api import get_account_settings, update_account_settings +from openedx.core.djangoapps.user_api.accounts.permissions import ( + CanCancelUserRetirement, + CanDeactivateUser, + CanGetAccountInfo, + CanRetireUser, +) +from openedx.core.djangoapps.user_api.errors import ( + AccountUpdateError, + AccountValidationError, + PreferenceUpdateError, + PreferenceValidationError, + UserAPIInternalError, + UserNotAuthorized, + UserNotFound, +) +from openedx.core.djangoapps.user_api.models import UserPreference +from openedx.core.djangoapps.user_api.preferences.api import ( + delete_user_preference, + get_country_time_zones, + get_user_preference, + get_user_preferences, + set_user_preference, + update_email_opt_in, + update_user_preferences, +) +from openedx.core.lib.api.parsers import MergePatchParser +from openedx.core.lib.api.permissions import ApiKeyHeaderPermission + +from .errors import AmbiguousAccountName, ErrorExtensionsMixin, ReadableErrorDetailMixin, RetiredAccount +from .filters import RetirementFilterSet, StrictOrderingFilter, UserFilterSet, UserPreferenceFilterSet +from .paginators import UserApiPagination +from .permissions import ( + CanSelectAccountsByContact, + IsActiveUser, + IsAddressedUser, + IsStaffOrAbsent, + RestrictedApplicationGuardMixin, +) +from .schema import ResponseShapes +from .selectors import ( + accounts_holding_emails, + accounts_named, + cancellable_retirement_for_email, + partner_reports, + retirement_of_the_account_named, + retirements, + unclaimed_partner_reports, + username_holding_id, + usernames_among, + usernames_holding_email, +) +from .serializers import ( + ACCOUNT_DEFAULT_SORT, + ACCOUNT_MINIMAL_FIELDS, + ACCOUNT_PREFERENCE_CHANGES_SCHEMA, + ACCOUNT_PREFERENCE_DOCUMENT, + ACCOUNT_PREFERENCES_SCHEMA, + EMAIL_SEARCH_MAX_ADDRESSES, + EMBEDDABLE_OWNER_IDS, + FULL_VIEW, + MINIMAL_VIEW, + PARTNER_REPORT_CLAIM_MAX, + PREFERENCE_MINIMAL_FIELDS, + PREFERENCE_NAME_MAX_LENGTH, + RETIREMENT_MINIMAL_FIELDS, + SHARED_VIEW, + USER_MINIMAL_FIELDS, + VERIFICATION_DEFAULT_SORT, + AccountMinimalSerializer, + AccountPreferenceSerializer, + AccountPreferencesUpdateSerializer, + AccountPreferenceValueSerializer, + AccountProjectionSerializer, + AccountQuerySerializer, + AccountSerializer, + AccountUpdateSerializer, + EmailOptInSerializer, + EmailSearchResultSerializer, + EmailSearchSerializer, + ErrorResponse, + NameChangeSerializer, + RetiredAccountErrorResponse, + RetirementBatchOutcomeSerializer, + RetirementBatchSerializer, + RetirementErasureSerializer, + RetirementMinimalSerializer, + RetirementPartnerReportQuerySerializer, + RetirementPartnerReportRecordSerializer, + RetirementProjectionSerializer, + RetirementSerializer, + RetirementUpdateSerializer, + TimeZoneQuerySerializer, + TimeZoneSerializer, + UserDirectoryMinimalSerializer, + UserDirectoryProjectionSerializer, + UserDirectorySerializer, + UserPreferenceRecordMinimalSerializer, + UserPreferenceRecordProjectionSerializer, + UserPreferenceRecordSerializer, + VerificationQuerySerializer, + VerificationSerializer, + VerificationStatusSerializer, + full_view_requested, + why_a_name_cannot_be_stored, +) +from .view_services import ( + NO_SUCH_RETIREMENT, + cancel_retirement, + claim_partner_reports, + delete_partner_reports, + delete_retirements, + erase_account_data, + move_retirement, + record_partner_report, + retirement_open_to_action, + retirement_requested_as, +) +from .view_utils import ( + RESP_UNAUTHENTICATED, + RESP_UNREADABLE_BODY, + SDK_TAG, + USER_TAG, + USERNAME_PATTERN, + StrictQueryParametersMixin, + names_in, + path_param, + query_param, +) + +log = logging.getLogger(__name__) + +#: Fields the user directory sorts on, and the order it uses when asked for none. +USER_SORT_FIELDS = ("username", "date_joined") +USER_DEFAULT_SORT = ("username",) + +#: Fields the stored-preference list sorts on, and the order it uses when asked for none. +PREFERENCE_SORT_FIELDS = ("key", "username") +PREFERENCE_DEFAULT_SORT = ("username", "key") + +#: The part of a preference's address that carries its key. The key is +#: free-form text that may itself contain a comma, so it takes the rest of the +#: segment; the route and the published address are built from this one pattern. +PREFERENCE_KEY_PATTERN_PARTIAL = "[^/]+" + +#: What the identity verification service reports for a user whose proof of +#: identity has been accepted. +APPROVED_STATUS = "approved" + +_USER_PATH_PARAM = path_param( + "username", + "Unique name of the user whose directory entry is returned.", + USERNAME_PATTERN, +) +_PREFERENCE_USERNAME_PATH_PARAM = path_param( + "username", + "Username of the user the preference belongs to: the part of the address before the comma.", + USERNAME_PATTERN, +) +_PREFERENCE_KEY_PATH_PARAM = path_param( + "key", + "Name the preference is stored under: the part of the address after the comma.", + rf"^{PREFERENCE_KEY_PATTERN_PARTIAL}$", +) + +#: The presets an account response answers to. +ACCOUNT_PRESETS = (MINIMAL_VIEW, SHARED_VIEW) + +#: The account-collection member naming the caller's own account. Addresses +#: beneath an account do not translate it: there it is a stored username. +OWN_ACCOUNT_IDENTIFIER = "me" + +#: What a caller is told about a member address that more than one account +#: answers to. One account is addressed there, so an address several of them +#: answer to names none of them in particular. +AMBIGUOUS_ACCOUNT_ADDRESS = "More than one account answers to this address. Name one of them as it is stored." + +#: The account actions that answer with an account, and so read the parameters +#: that shape one. +SHAPED_ACCOUNT_ACTIONS = ("list", "retrieve", "partial_update") + +#: The properties whose change is limited to a rate, and the setting carrying +#: the limit of each. Both settings are empty until a deployment gives one of +#: them a value, and an empty limit limits nothing, so a site that has +#: configured neither takes every change to either property. +RATE_LIMITED_ACCOUNT_FIELDS = ( + ("email", "EMAIL_CHANGE_RATE_LIMIT"), + ("secondary_email", "SECONDARY_EMAIL_RATE_LIMIT"), +) + +#: Parameters that shape a response rather than select the rows in it. +VIEW_PARAM = "view" +FIELDS_PARAM = "fields" +RESPONSE_SHAPING_PARAMS = (VIEW_PARAM, FIELDS_PARAM) + +_FIELDS_PARAM = query_param( + FIELDS_PARAM, + "Comma-separated list of top-level field names to keep in each returned object. " + "A name the response cannot carry is refused. Selection reaches the top level only: " + "a name written as a path, such as `user.email`, names no field a response carries " + "and is refused with the names that can be selected.", +) +#: The presets each list answers to, published as the parameter's enum and +#: accepted by the view from the same declaration. +USER_PRESETS = (MINIMAL_VIEW,) +PREFERENCE_PRESETS = (FULL_VIEW, MINIMAL_VIEW) + +_MINIMAL_VIEW_PARAM = query_param( + VIEW_PARAM, + "Response preset. `minimal` reduces each user to its identifying fields " + "(`id`, `username`, `email`). Omit it to receive the full entry.", + enum=list(USER_PRESETS), +) +_PREFERENCE_VIEW_PARAM = query_param( + VIEW_PARAM, + "Response preset. `full` embeds the owning user's whole directory entry in each " + "preference; `minimal` returns only `username`, `key` and `value`. Omit it to " + "receive the owner's username alongside the preference.", + enum=list(PREFERENCE_PRESETS), +) + + +class AddressMetadata(SimpleMetadata): + """ + Describes an address by what it reads, rather than by what its view declares. + + The standard description is built from the parsers and the serializer + declared on the view, which belong to the view as a whole rather than to + the address being asked about: an address that reads one media type is + otherwise described as reading every media type the platform reads + anywhere, a method whose handler reads no body is described as taking the + one the view's serializer carries, and a method whose body differs from + what the view answers with is described as taking the answer. A caller + that negotiates from this description sends what it was told to send, so + each part of it is answered from the address and the method being + described. + """ + + def determine_metadata(self, request, view): + """Describe the address, naming the media types the bodies it takes are read in.""" + description = super().determine_metadata(request, view) + description["parses"] = [parser.media_type for parser in view.get_parsers()] + return description + + def determine_actions(self, request, view): + """Describe the body of each method that takes one, and leave out the methods that take none.""" + described = super().determine_actions(request, view) + served = getattr(view, "action_map", None) or {} + bodiless = set(getattr(view, "bodiless_actions", ())) + taken = getattr(view, "request_serializer_class", None) + if taken is not None: + described = {method: self.get_serializer_info(taken()) for method in described} + return { + method: fields for method, fields in described.items() + if served.get(method.lower()) not in bodiless + } + + +def _ordering_param(sort_fields, default): + """Build the sort parameter of a list, naming the fields it accepts.""" + return query_param( + "ordering", + "Fields to sort by, in order of precedence, each optionally prefixed with `-` to sort " + "descending. A field that is not listed here is refused.", + many=True, + enum=[*sort_fields, *(f"-{field}" for field in sort_fields)], + default=list(default), + style="form", + explode=False, + ) + + +_USER_ORDERING_PARAM = _ordering_param(USER_SORT_FIELDS, USER_DEFAULT_SORT) +_PREFERENCE_ORDERING_PARAM = _ordering_param(PREFERENCE_SORT_FIELDS, PREFERENCE_DEFAULT_SORT) + +_RESP_FORBIDDEN = OpenApiResponse(ErrorResponse, description="The requester may not use this endpoint.") + +#: How a caller is admitted to the user directory and to the stored preferences. +_API_KEY_ADMISSION = "Answered only to a request carrying the site's API key in the `X-Edx-Api-Key` header." +#: Published by every operation of this version. Which representations an +#: answer can be given in is settled as the request enters, before the address +#: it names is read or written, so an ``Accept`` header naming none of them is +#: refused at every address whether or not that address answers with a body. +_RESP_SESSION_WITHOUT_ITS_TOKEN = OpenApiResponse( + ErrorResponse, + description=( + "The request was made with a session cookie and carried none of the request token the " + "site hands out with it, so the site cannot tell that the request came from a page it " + "served. Sending that token with the session, or a signed token in place of the " + "session, is answered." + ), +) +RESP_NOT_ACCEPTABLE = OpenApiResponse( + ErrorResponse, + description=( + "The `Accept` header of the request names no representation this endpoint can answer in." + ), +) +_RESP_NOT_FOUND = OpenApiResponse(ErrorResponse, description="No such resource.") +_RESP_NO_SUCH_PAGE = OpenApiResponse(ErrorResponse, description="There is no such page of results.") +_RESP_BAD_REQUEST = OpenApiResponse( + ErrorResponse, description="A query parameter is missing, invalid, or not one this endpoint reads.", +) +_RESP_UNUSABLE_BODY = OpenApiResponse( + ErrorResponse, + description=( + "The request body is not well formed in the media type it was sent in, or carries a " + "value this endpoint will not take. A query parameter this endpoint does not read is " + "refused here too." + ), +) +_RESP_RATE_LIMITED = OpenApiResponse( + ErrorResponse, + description=( + "The caller has changed this property too often lately and has to wait. Only a site that " + "has given a limit for the property answers this; a site that has given none, which is " + "how the platform ships, takes every change." + ), +) +_RESP_CHANGE_FAILED = OpenApiResponse( + ErrorResponse, + description=( + "The change was asked for correctly and could not be carried out. Sending it again " + "with something else in it does not help; sending the same request later may." + ), +) +_RESP_NO_SUCH_ACCOUNT = OpenApiResponse( + ErrorResponse, description="No account is held at the address given.", +) +_RESP_CHANGED_ACCOUNT_GONE = OpenApiResponse( + ErrorResponse, + description=( + "The account that was addressed is no longer there. Only the caller's own account is " + "changed here, so a name nobody holds is refused as somebody else's account instead of " + "being reported as missing." + ), +) +_RESP_NO_SUCH_PAGE_OR_RETIRED = OpenApiResponse( + RetiredAccountErrorResponse, + description=( + "There is no such page of results, or the `email` address given is one an account held " + "before it was retired. In that second case the body also carries `can_cancel_retirement` " + "and `retirement_id`, which name what is left of that account to a caller able to reverse " + "the retirement." + ), +) + +#: A directory entry comes back whole, as the identifying fields the ``minimal`` +#: preset keeps, or as the properties ``fields`` selected. +_USER_SHAPES = ResponseShapes( + component_name="UserDirectoryResponse", + serializers=[ + UserDirectorySerializer, + UserDirectoryMinimalSerializer, + UserDirectoryProjectionSerializer, + ], +) + +_ACCOUNT_PATH_PARAM = path_param( + "username", + "Unique name of the user whose account is addressed. " + f"`{OWN_ACCOUNT_IDENTIFIER}` names the account of the caller, so an account holding that " + "name is read through the collection, with `username=` naming it.", + USERNAME_PATTERN, +) +_DEACTIVATION_PATH_PARAM = path_param( + "username", + "Unique name of the user whose account is deactivated. The name is read as it is stored, " + f"`{OWN_ACCOUNT_IDENTIFIER}` included: this action disables the account the address names, " + "never the account of the caller making the request.", + USERNAME_PATTERN, +) +_ACCOUNT_VIEW_PARAM = query_param( + VIEW_PARAM, + "Response preset. `minimal` reduces the account to the properties that identify its owner; " + "`shared` returns only the properties the owner shares with everybody, even to a caller who " + "may see more. Omit it, or send it empty, to receive everything the caller may see.", + enum=[*ACCOUNT_PRESETS, ""], +) + +#: An account comes back with every property the caller may see, as the +#: identifying properties the ``minimal`` preset keeps, or as the properties +#: ``fields`` selected. +_ACCOUNT_SHAPES = ResponseShapes( + component_name="AccountResponse", + serializers=[ + AccountSerializer, + AccountMinimalSerializer, + AccountProjectionSerializer, + ], +) + +_ACCOUNT_IMAGE_EXAMPLE = { + "has_image": True, + "image_url_full": "https://example.com/media/profile_images/jane_doe_500.jpg", + "image_url_large": "https://example.com/media/profile_images/jane_doe_120.jpg", + "image_url_medium": "https://example.com/media/profile_images/jane_doe_50.jpg", + "image_url_small": "https://example.com/media/profile_images/jane_doe_30.jpg", +} + +_ACCOUNT_EXAMPLES = [ + OpenApiExample( + "Your own account", + description=( + "Everything an account carries, which is what its owner and a staff caller receive." + ), + value={ + "username": "jane_doe", + "profile_image": _ACCOUNT_IMAGE_EXAMPLE, + "account_privacy": "custom", + "email": "jane.doe@example.com", + "id": 42, + "name": "Jane Doe", + "bio": "Teaches mathematics.", + "country": "US", + "state": "MA", + "date_joined": "2024-03-01T09:30:00Z", + "last_login": "2026-02-11T18:02:41Z", + "is_active": True, + "gender": "f", + "year_of_birth": 1985, + "level_of_education": "m", + "goals": "Finish the course.", + "mailing_address": "12 Park Avenue", + "phone_number": "+18005555555", + "language_proficiencies": [{"code": "fr"}], + "social_links": [ + {"platform": "twitter", "social_link": "https://www.twitter.com/example"}, + ], + "extended_profile": [{"field_name": "occupation", "field_value": "Teacher"}], + "requires_parental_consent": False, + "pending_name_change": None, + "verified_name": "Jane Doe", + "secondary_email": None, + "secondary_email_enabled": None, + "course_certificates": None, + "time_zone": None, + }, + response_only=True, + status_codes=["200"], + ), + OpenApiExample( + "An account somebody else owns", + description=( + "The properties this owner shares with every learner. An owner who shares field by " + "field shares fewer, and one who shares nothing leaves only the three properties " + "every account carries." + ), + value={ + "username": "jane_doe", + "profile_image": _ACCOUNT_IMAGE_EXAMPLE, + "account_privacy": "all_users", + "bio": "Teaches mathematics.", + "country": "US", + "course_certificates": None, + "date_joined": "2024-03-01T09:30:00Z", + "language_proficiencies": [{"code": "fr"}], + "level_of_education": "m", + "social_links": [ + {"platform": "twitter", "social_link": "https://www.twitter.com/example"}, + ], + "time_zone": None, + }, + response_only=True, + status_codes=["200"], + ), + OpenApiExample( + "An account reduced to what identifies its owner", + description="What `view=minimal` answers with; `fields` selects any other set.", + value={ + "username": "jane_doe", + "email": "jane.doe@example.com", + "name": "Jane Doe", + "profile_image": _ACCOUNT_IMAGE_EXAMPLE, + }, + response_only=True, + status_codes=["200"], + ), +] + +#: A stored preference comes back whole, as the three fields the ``minimal`` +#: preset keeps, or as the properties ``fields`` selected. +_PREFERENCE_SHAPES = ResponseShapes( + component_name="UserPreferenceRecordResponse", + serializers=[ + UserPreferenceRecordSerializer, + UserPreferenceRecordMinimalSerializer, + UserPreferenceRecordProjectionSerializer, + ], +) + + +def _the_one_addressed(username, records, name_of): + """ + Return the one of these records the address names, or refuse the address. + + A name can be answered with more than one record, in which case the record + stored under exactly the name given is the one addressed. Where none of + them is stored under it the address names no one of them, so it is refused + rather than worked on: which record came back first is no basis for + reading or changing an account. The refusal is reported under the path + parameter that carried the name. + """ + stored_under_it = [record for record in records if name_of(record) == username] + if stored_under_it: + return stored_under_it[0] + if len(records) > 1: + raise AmbiguousAccountName({"username": [AMBIGUOUS_ACCOUNT_ADDRESS]}) + if not records: + raise NotFound + return records[0] + + +def _user_named(username): + """Return the user an address names, or refuse the address.""" + return _the_one_addressed(username, accounts_named(username), lambda user: user.username) + + +class _MemberLookupMixin: + """Report a missing member through the standard error envelope.""" + + def get_object(self): + try: + return super().get_object() + except Http404 as exc: + raise NotFound from exc + + +class _ActionAddressMixin: + """ + Builds the address of an action from the snake_case names these routes carry. + + A router names an action ``-`` by default, and that name + belongs to an earlier mount of the same resource, so the default lookup + would hand out the older address instead of this one. + """ + + #: The namespace these routes are mounted under, read when an address is + #: asked for outside a request and there is no resolver match to take it from. + url_namespace = "user_v2" + + def reverse_action(self, url_name, *args, **kwargs): + """Return the address of one of this viewset's actions.""" + request = getattr(self, "request", None) + namespace = self.url_namespace + if request is not None and request.resolver_match is not None: + namespace = request.resolver_match.namespace or namespace + kwargs.setdefault("request", request) + return reverse(f"{namespace}:{self.basename}_{url_name}", *args, **kwargs) + + +def _requested_field_names(raw): + """ + Return the property names a ``fields`` value asks for, each as it was written. + + A name is held against the properties the answer carries whole, so a name + written with a dot in it names no such property and is refused. Reading + only the part before the dot would hand back the whole of a property the + caller asked one piece of, and would take a part after it that names + nothing at all without saying so. + """ + if not raw: + return [] + return [name.strip() for name in raw.split(",") if name.strip()] + + +class _ResponsePresetMixin(MinimalViewMixin): + """ + Applies the ``view`` and ``fields`` presets to an already serialized payload. + + A view lists the presets it answers to, and a ``view`` value outside that + list is refused: a caller that mistypes a preset asked for a smaller + payload and would otherwise be handed the full one without being told. + The same holds for a field name the response cannot carry, which would + otherwise select nothing and leave the caller with empty objects. + + Both are settled as the request enters, before the address it names is + read or written. Which properties an answer can carry depends on the + caller and on the address, so the refusal comes back the same at a name + nobody holds as at one somebody does, and nothing is read or changed on + the way to it. + """ + + #: Presets this view answers to. An empty ``view`` narrows nothing, the way + #: an empty value of any other parameter does. + response_presets = () + + def initial(self, request, *args, **kwargs): + """Check the requested preset and fields once the caller has been admitted, before anything else.""" + super().initial(request, *args, **kwargs) + preset = request.query_params.get(VIEW_PARAM) + if preset and preset not in self.response_presets: + raise ValidationError({ + VIEW_PARAM: [ + f"Ask for any of: {', '.join(self.response_presets)}, " + "or omit this parameter for the default response.", + ], + }) + self.check_requested_fields(request) + + def shapeable_fields(self): + """Return the names of the properties a response of this view can carry.""" + if self.is_minimal_view_requested(): + return set(self.minimal_fields) + return set(self.get_serializer_class()().fields) + + #: What a caller is told in place of the names, at a view that may withhold + #: them. A view that narrows what a response carries by who is asking says + #: here what it tells a caller it has narrowed the answer for. + withheld_fields_message = "" + + def shapeable_fields_may_be_named(self): + """Whether a refusal may list the properties a response of this view can carry.""" + return True + + def check_requested_fields(self, request): + """Refuse a selected field the response cannot carry, naming the ones it can.""" + available = self.shapeable_fields() + unknown = sorted(set(_requested_field_names(request.query_params.get(FIELDS_PARAM))) - available) + if unknown: + if self.shapeable_fields_may_be_named(): + rest = f"Select any of: {', '.join(sorted(available))}." + else: + rest = self.withheld_fields_message + raise ValidationError({ + FIELDS_PARAM: [f"No such field: {', '.join(unknown)}. {rest}"], + }) + + def shape(self, data, request): + """Return ``data`` reduced to the preset and the fields the caller asked for.""" + data = self.shape_minimal(data, request) + requested_fields = _requested_field_names(request.query_params.get(FIELDS_PARAM)) + if not requested_fields: + return data + if isinstance(data, list): + return [project(item, requested_fields) for item in data] + return project(data, requested_fields) + + def shape_page(self, response, request): + """Shape the items of a paginated response in place.""" + response.data["results"] = self.shape(response.data["results"], request) + return response + + +@extend_schema(tags=[USER_TAG]) +@extend_schema_view( + list=extend_schema( + summary="List users", + description=( + "Returns a page of the user directory. Supply `preference_key` to keep only the " + "users who have stored a preference under that key, or `forum_role` together with " + "`course_key` to keep only the holders of a course forum role. " + f"{_API_KEY_ADMISSION}" + ), + parameters=[_MINIMAL_VIEW_PARAM, _FIELDS_PARAM, _USER_ORDERING_PARAM], + responses={ + 200: _USER_SHAPES, + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NO_SUCH_PAGE, + 406: RESP_NOT_ACCEPTABLE, + }, + ), + retrieve=extend_schema( + summary="Get one user", + description=f"Returns the directory entry of the user with this username. {_API_KEY_ADMISSION}", + parameters=[_USER_PATH_PARAM, _MINIMAL_VIEW_PARAM, _FIELDS_PARAM], + responses={ + 200: _USER_SHAPES, + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NOT_FOUND, + 406: RESP_NOT_ACCEPTABLE, + }, + ), +) +class UserViewSet( + StandardizedErrorMixin, + RestrictedApplicationGuardMixin, + ReadableErrorDetailMixin, + _ActionAddressMixin, + _MemberLookupMixin, + _ResponsePresetMixin, + StrictQueryParametersMixin, + ScopedQuerysetMixin, + viewsets.ReadOnlyModelViewSet, +): + """ + Directory entries of the platform's users, each addressed by username. + + An entry carries the identifying properties of one account together with + the preferences stored against it. It is read by server integrations, + which send the site's API key in the X-Edx-Api-Key header, and is + refused to every request that does not carry that key. + """ + + # Who may read the directory is settled in three layers, each of which can + # be replaced on its own: the permission classes below settle who may call + # the collection at all, the record-visibility policy settles which rows + # such a caller may see, and the filter set narrows those rows by the + # parameters the request carries. + + permission_classes = (ApiKeyHeaderPermission,) + serializer_class = UserDirectorySerializer + pagination_class = UserApiPagination + queryset = User.objects.all().select_related("profile") + # A caller holding the API key may read every entry, so the policy keeps + # the queryset whole; a narrower policy can replace it without touching the + # filtering below. + scoping_policy = FullScopePolicy() + #: The preferences carried inside an entry are rows of the stored + #: preference collection, so which of them a caller may see is that + #: collection's to decide and not this one's. It is declared separately for + #: that reason, and applied where the page is fetched rather than once per + #: entry, which would cost a query per row. + embedded_preference_policy = FullScopePolicy() + lookup_field = "username" + # The router's default lookup pattern excludes dots, which usernames allow. + lookup_value_regex = settings.USERNAME_REGEX_PARTIAL + filter_backends = (DjangoFilterBackend, StrictOrderingFilter) + filterset_class = UserFilterSet + ordering_fields = USER_SORT_FIELDS + ordering = USER_DEFAULT_SORT + # A sort the caller asks for is completed with these so that it is total: + # rows that compare equal would otherwise be ordered differently from one + # page to the next, and a client reading the pages in turn would see some + # of them twice and miss others. + ordering_tiebreak = ("username",) + minimal_fields = USER_MINIMAL_FIELDS + response_presets = USER_PRESETS + extra_query_params = RESPONSE_SHAPING_PARAMS + + def get_queryset(self): + """Fetch the preferences carried inside each entry through the policy that governs them.""" + return super().get_queryset().prefetch_related( + Prefetch( + "preferences", + queryset=self.embedded_preference_policy.scope( + UserPreference.objects.all(), self.request.user, + ), + ), + ) + + def list(self, request, *args, **kwargs): + """Return a page of directory entries, shaped as the caller asked.""" + return self.shape_page(super().list(request, *args, **kwargs), request) + + def retrieve(self, request, *args, **kwargs): + """Return one directory entry, shaped as the caller asked.""" + response = super().retrieve(request, *args, **kwargs) + response.data = self.shape(response.data, request) + return response + + +@extend_schema(tags=[USER_TAG]) +@extend_schema_view( + list=extend_schema( + summary="List stored preferences", + description=( + "Returns a page of stored user preferences. Supply `key` to keep only the " + "preferences stored under that name, or `username` to keep only one user's. " + f"{_API_KEY_ADMISSION}" + ), + parameters=[_PREFERENCE_VIEW_PARAM, _FIELDS_PARAM, _PREFERENCE_ORDERING_PARAM], + responses={ + 200: _PREFERENCE_SHAPES, + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NO_SUCH_PAGE, + 406: RESP_NOT_ACCEPTABLE, + }, + ), + retrieve=extend_schema( + summary="Get one stored preference", + description=( + "Returns the preference stored under this key for this user. A preference is " + "identified by the pair, which the address carries as the username, a comma and " + "the key, for example `alice,time_zone`. " + f"{_API_KEY_ADMISSION}" + ), + parameters=[ + _PREFERENCE_USERNAME_PATH_PARAM, + _PREFERENCE_KEY_PATH_PARAM, + _PREFERENCE_VIEW_PARAM, + _FIELDS_PARAM, + ], + responses={ + 200: _PREFERENCE_SHAPES, + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NOT_FOUND, + 406: RESP_NOT_ACCEPTABLE, + }, + ), +) +class UserPreferenceViewSet( + StandardizedErrorMixin, + RestrictedApplicationGuardMixin, + ReadableErrorDetailMixin, + _ActionAddressMixin, + _ResponsePresetMixin, + StrictQueryParametersMixin, + ScopedQuerysetMixin, + viewsets.ReadOnlyModelViewSet, +): + """ + Preferences stored for the platform's users, each addressed by its owner and its name. + + A preference is identified by the two together, so a member address + carries the owner's username, a comma and the name the preference is + stored under, as in alice,time_zone. Like the user directory, these are + read only by a request carrying the site's API key. + """ + + # The pair identifies a row, so the member address is built from it rather + # than from a database id, and the route that serves it is declared with + # the collection rather than generated. Who may read these rows is settled + # in the same three layers as the user directory. + + permission_classes = (ApiKeyHeaderPermission,) + serializer_class = UserPreferenceRecordSerializer + pagination_class = UserApiPagination + queryset = ( + UserPreference.objects.all() + .select_related("user", "user__profile") + .annotate(username=F("user__username")) + ) + scoping_policy = FullScopePolicy() + #: The entry of a preference's owner carries that owner's own preferences, + #: which are rows of this same collection but are reached through the + #: entry rather than as rows of the page, so the policy that governs them + #: is applied where they are fetched. + embedded_preference_policy = FullScopePolicy() + #: Which owners' user entries a row may embed, settled once per page; a row + #: whose owner is left out keeps only the owner's name. + embedded_user_policy = FullScopePolicy() + filter_backends = (DjangoFilterBackend, StrictOrderingFilter) + filterset_class = UserPreferenceFilterSet + ordering_fields = PREFERENCE_SORT_FIELDS + ordering = PREFERENCE_DEFAULT_SORT + # The owner and the key identify a row, so they complete any sort the + # caller asks for and keep the order of a page stable. + ordering_tiebreak = ("username", "key") + minimal_fields = PREFERENCE_MINIMAL_FIELDS + response_presets = PREFERENCE_PRESETS + extra_query_params = RESPONSE_SHAPING_PARAMS + + def get_queryset(self): + """Fetch each owner's own preferences through the policy that governs them, when embedding.""" + queryset = super().get_queryset() + if full_view_requested(getattr(self, "request", None)): + queryset = queryset.prefetch_related( + Prefetch( + "user__preferences", + queryset=self.embedded_preference_policy.scope( + UserPreference.objects.all(), self.request.user, + ), + ), + ) + return queryset + + def get_serializer(self, *args, **kwargs): + """Build the serializer, having settled which owners the rows it is given may carry.""" + serializer = super().get_serializer(*args, **kwargs) + if args and full_view_requested(getattr(self, "request", None)): + serializer.context[EMBEDDABLE_OWNER_IDS] = self._embeddable_owner_ids(args[0]) + return serializer + + def _embeddable_owner_ids(self, rows): + """Return the ids of the owners of ``rows`` whose entry the caller may be given, in one query.""" + if isinstance(rows, UserPreference): + rows = [rows] + owner_ids = {row.user_id for row in rows} + if not owner_ids: + return frozenset() + # The owners of the page are taken out of the policy's answer rather + # than handed to it: a policy free to answer with a collection of its + # own is then still narrowed to this page, and what comes back is the + # size of the page rather than the size of the collection. + owners = self.embedded_user_policy.scope(User.objects.all(), self.request.user) + return frozenset(owners.filter(pk__in=owner_ids).values_list("pk", flat=True)) + + def get_object(self): + """Look the preference up by the username and key that identify it.""" + try: + preference = self.get_queryset().get( + user__username=self.kwargs["username"], key=self.kwargs["key"], + ) + except UserPreference.DoesNotExist as exc: + raise NotFound from exc + self.check_object_permissions(self.request, preference) + return preference + + def list(self, request, *args, **kwargs): + """Return a page of stored preferences, shaped as the caller asked.""" + return self.shape_page(super().list(request, *args, **kwargs), request) + + def retrieve(self, request, *args, **kwargs): + """Return one stored preference, shaped as the caller asked.""" + response = super().retrieve(request, *args, **kwargs) + response.data = self.shape(response.data, request) + return response + + +@extend_schema(tags=[USER_TAG]) +class CountryTimeZoneViewSet( + StandardizedErrorMixin, + RestrictedApplicationGuardMixin, + ReadableErrorDetailMixin, + _ActionAddressMixin, + StrictQueryParametersMixin, + IterablePaginationMixin, + viewsets.ViewSet, +): + """ + Time zones commonly used in a country, or every time zone when no country is named. + + The list is the same for every caller and is open to anyone. + """ + + # The rows come from the time-zone database rather than from a table of + # this app's, so there is no queryset to narrow and the one parameter the + # view reads is validated by a serializer instead of a filter set. The + # data is reference data, which is why no caller is turned away. + + permission_classes = (permissions.AllowAny,) + serializer_class = TimeZoneSerializer + #: Validates the query string, and names the parameters this view reads. + query_serializer_class = TimeZoneQuerySerializer + pagination_class = UserApiPagination + + def get_serializer(self, *args, **kwargs): + """Build the output serializer; a plain ViewSet has no generic machinery to do it.""" + return self.serializer_class(*args, **kwargs) + + @extend_schema( + summary="List time zones", + description=( + "Returns a page of time zones, ordered by their display form. Supply " + "`country_code` to receive the time zones commonly used in one country." + ), + # The parameters are published from the serializer that validates them, + # so the constraint a caller is held to is the one it reads. + parameters=[TimeZoneQuerySerializer], + responses={ + 200: TimeZoneSerializer, + 400: _RESP_BAD_REQUEST, + 404: _RESP_NO_SUCH_PAGE, + 406: RESP_NOT_ACCEPTABLE, + }, + ) + def list(self, request): + """Return a page of time zones, for one country or for the world.""" + query = self.query_serializer_class(data=request.query_params) + query.is_valid(raise_exception=True) + time_zones = get_country_time_zones(query.validated_data.get("country_code")) + return self.paginate_iterable(request, time_zones) + + +def _shareable_account_fields(): + """Return every property an account's owner is able to share with another user.""" + return set(settings.ACCOUNT_VISIBILITY_CONFIGURATION["custom_shareable_fields"]) + + +#: Admission classes for every operation on an account, wherever it is served. +#: Read from here, not from a view attribute that middleware may extend in place. +ACCOUNT_RESOURCE_PERMISSIONS = ( + permissions.IsAuthenticated, + CanGetAccountInfo, + CanSelectAccountsByContact, +) + + +class AccountResourceMixin: + """ + Carries the rules of the account resource into the operations on one account. + + An address beneath an account works on a row of the collection the account + resource answers from, so a caller that resource refuses is a caller the + operation refuses too: the classes the resource declares are applied first + and the ones this address declares after, which keeps an address reached + this way from standing open to a caller the resource is closed to. The + account the address names is resolved here as well, so the record a + permission settled the request on is the record the work lands on. + """ + + def account_resource_permissions(self): + """Return the classes the account resource admits a caller by.""" + return ACCOUNT_RESOURCE_PERMISSIONS + + def declared_permissions(self): + """Return the classes the address being served declares of its own.""" + return super().get_permissions() + + def get_permissions(self): + """Return the collection's classes, then the ones this address declares.""" + declared = self.declared_permissions() + already_carried = {type(permission) for permission in declared} + carried = [ + permission() for permission in self.account_resource_permissions() + if permission not in already_carried + ] + return carried + declared + + def addressed_account(self): + """Return the account this address names, refusing an address that names no one account.""" + return _user_named(self.kwargs["username"]) + + +@extend_schema(tags=[SDK_TAG]) +@extend_schema_view( + list=extend_schema( + summary="List accounts", + description=( + "Returns a page of accounts. Name them with `username`, or select the accounts " + "holding an `email` address or the one holding an `lms_user_id`; omit all three to " + "receive your own account. A name, an address or an identifier no account holds is " + "left out of the page, which is empty when nothing given is held. An address held by " + "more than one account brings back each of them. " + "Each account carries the properties the caller may " + "see: everything for your own account and for a staff caller, and otherwise the " + "properties its owner shares. " + "A property named in `fields` that an owner does not share is left out of their " + "account rather than refused, so a row of the page can come back empty." + ), + parameters=[AccountQuerySerializer, _FIELDS_PARAM], + responses={ + 200: _ACCOUNT_SHAPES, + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NO_SUCH_PAGE_OR_RETIRED, + 406: RESP_NOT_ACCEPTABLE, + }, + ), + retrieve=extend_schema( + summary="Get one account", + description=( + "Returns the account of this user, carrying the properties the caller may see. " + "Address `me` for the account of the caller. A property named in `fields` that " + "this owner does not share is left out rather than refused, so the answer can be " + "an empty object." + ), + parameters=[_ACCOUNT_PATH_PARAM, _ACCOUNT_VIEW_PARAM, _FIELDS_PARAM], + responses={ + 200: _ACCOUNT_SHAPES, + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NO_SUCH_ACCOUNT, + 406: RESP_NOT_ACCEPTABLE, + }, + examples=_ACCOUNT_EXAMPLES, + ), + partial_update=extend_schema( + summary="Change one account", + description=( + "Changes the properties named in the body and returns the account as it now stands. " + "A user may change only their own account, which `me` also addresses. The body is a " + "merge patch: only the properties it carries are changed, and `social_links` are " + "merged into the stored list by platform. A new email address takes effect only once " + "the user confirms it from the message sent there." + ), + parameters=[_ACCOUNT_PATH_PARAM, _ACCOUNT_VIEW_PARAM, _FIELDS_PARAM], + request=AccountUpdateSerializer, + responses={ + 200: _ACCOUNT_SHAPES, + 400: _RESP_UNUSABLE_BODY, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_CHANGED_ACCOUNT_GONE, + 406: RESP_NOT_ACCEPTABLE, + 415: RESP_UNREADABLE_BODY, + 429: _RESP_RATE_LIMITED, + 500: _RESP_CHANGE_FAILED, + }, + ), +) +class AccountViewSet( + StandardizedErrorMixin, + RestrictedApplicationGuardMixin, + ReadableErrorDetailMixin, + ErrorExtensionsMixin, + AccountResourceMixin, + _ActionAddressMixin, + _ResponsePresetMixin, + StrictQueryParametersMixin, + IterablePaginationMixin, + viewsets.ViewSet, +): + """ + Accounts of the platform's users. Address one by username, or me for your own. + + An account carries the properties the caller may see: all of them for your + own account and for a staff caller, and otherwise the ones its owner + shares. A property an owner does not share is left out of their account + rather than refused, so an account can come back as an empty object. + + Selecting accounts that nothing holds gives an empty page rather than a + refusal. An address an account held before it was retired is refused + instead, and that refusal says whether the retirement can still be + cancelled. + + A property no account of this kind could show this caller is refused + rather than answered as an empty object. + """ + + # Accounts come from the account service, not a queryset, and a filter set + # cannot report a retired address, so the selection is applied in the handler. + + # Admits the session of an account whose email is not yet confirmed, which + # the platform default refuses; tokens are read as the platform reads them. + authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser) + permission_classes = ACCOUNT_RESOURCE_PERMISSIONS + #: A change is a merge patch: a body in any other media type is refused. + parser_classes = (MergePatchParser,) + serializer_class = AccountSerializer + #: Validates the query string of the list, and names the parameters it reads. + query_serializer_class = AccountQuerySerializer + pagination_class = UserApiPagination + lookup_field = "username" + # The router's default lookup pattern excludes dots, which usernames allow. + lookup_value_regex = settings.USERNAME_REGEX_PARTIAL + minimal_fields = ACCOUNT_MINIMAL_FIELDS + response_presets = ACCOUNT_PRESETS + extra_query_params = RESPONSE_SHAPING_PARAMS + withheld_fields_message = "An account another user holds carries only the properties its owner shares." + #: The validated query string of the request being served, read once. + _validated_selection = None + + def get_serializer(self, *args, **kwargs): + """Build the output serializer; a plain ViewSet has no generic machinery to do it.""" + return self.serializer_class(*args, **kwargs) + + def known_query_params(self): + """Return the query parameters the action being served reads.""" + names = super().known_query_params() + if self.action != "list": + # One account is answered whole, so neither the selectors that + # choose which accounts a page carries nor the parameters that cut + # it into pages are read at a member address. + names -= set(self.query_serializer_class().fields) - {VIEW_PARAM} + names -= { + self.pagination_class.page_query_param, + self.pagination_class.page_size_query_param, + } + if self.action not in SHAPED_ACCOUNT_ACTIONS: + names -= set(RESPONSE_SHAPING_PARAMS) + return names + + def shapeable_fields(self): + """Return the properties an account response can carry for the caller making it.""" + describable = set(AccountSerializer().fields) + if self.is_minimal_view_requested(): + describable &= set(self.minimal_fields) + if self._whole_accounts_are_readable(self.request): + return describable + return describable & _shareable_account_fields() + + def shapeable_fields_may_be_named(self): + """ + Whether a refusal may list the properties an answer of this kind can carry. + + Which properties an owner shares is that owner's to decide, and the + set the site offers them to choose from is the site's own + configuration. A caller addressing an account it does not hold is + therefore told that it may not select what it selected, and not what + the site holds for other people: the list would otherwise name every + property the site is configured to let an owner share, including ones + nobody on the site shares. A caller that narrowed the answer itself, + by asking for a preset, is still told what that preset carries. + """ + return not self._only_shared_properties_are_readable(self.request) + + def _whole_accounts_are_readable(self, request): + """Whether every account this request addresses comes back whole to this caller.""" + if request.query_params.get(VIEW_PARAM) == SHARED_VIEW: + return False + return not self._only_shared_properties_are_readable(request) + + def _only_shared_properties_are_readable(self, request): + """Whether who the caller is keeps them from every property of the accounts addressed.""" + if request.user.is_staff: + return False + return self._addressed_names(request) != [request.user.username] + + def _selection(self, request): + """ + Return the query string as the serializer that validates it reads it. + + Which accounts a request addresses is read both before the handler + runs, to settle what the answer can carry, and inside it, to cut the + page. Both read the values the serializer validated, so the two cannot + disagree about which accounts are meant; and the serializer runs once, + because it is what refuses a selector the list does not take. + """ + if self._validated_selection is None: + query = self.query_serializer_class(data=request.query_params) + query.is_valid(raise_exception=True) + self._validated_selection = query.validated_data + return self._validated_selection + + def _addressed_names(self, request): + """Return the usernames this request addresses, as the address and the query string give them.""" + if self.action != "list": + return [self._addressed_username(request, self.kwargs.get("username"))] + selection = self._selection(request) + named = selection.get("username") + if named: + return [name for name in named.strip(",").split(",") if name] + if {"email", "lms_user_id"} & set(selection): + return [] + return [request.user.username] + + def list(self, request): + """Return a page of accounts, shaped as the caller asked.""" + selection = self._selection(request) + preset = selection.get(VIEW_PARAM) + page = self.paginate_iterable( + request, + self._selected_usernames(request, selection), + serialize=lambda usernames: self._accounts_named(request, usernames, preset), + ) + return self.shape_page(page, request) + + def retrieve(self, request, username): + """Return one account, shaped as the caller asked.""" + account = self._account_addressed( + request, + self._addressed_username(request, username), + request.query_params.get(VIEW_PARAM), + ) + return Response(self.shape(account, request)) + + def partial_update(self, request, username): + """Change the properties the body carries and return the account as it now stands.""" + username = self._addressed_username(request, username) + changes = request.data + if not isinstance(changes, dict): + raise ValidationError({ + api_settings.NON_FIELD_ERRORS_KEY: ["Send the properties to change as an object."], + }) + # Validated against the published shape, then passed on as sent so the + # account service still refuses properties that cannot be changed. + AccountUpdateSerializer(data=changes).is_valid(raise_exception=True) + if "email" in changes and not settings.ALLOW_EMAIL_ADDRESS_CHANGE: + raise ValidationError({"email": ["This site does not let an email address be changed here."]}) + self._check_change_rate(request, changes) + try: + with transaction.atomic(): + update_account_settings(request.user, changes, username=username) + account = self._account_addressed( + request, username, request.query_params.get(VIEW_PARAM), + ) + except UserNotAuthorized as exc: + raise PermissionDenied from exc + # The caller's own row is read again here and can be gone by now. + except UserNotFound as exc: + raise NotFound from exc + except AccountValidationError as exc: + raise self._refused_changes(username, exc) from exc + except AccountUpdateError as exc: + raise self._failed_change(username, exc) from exc + return Response(self.shape(account, request)) + + def _addressed_username(self, request, username): + """Return the username an address names, reading ``me`` as the caller's own.""" + if username == OWN_ACCOUNT_IDENTIFIER: + return request.user.username + return username + + def _accounts_named(self, request, usernames, preset=None): + """Return each named account, in the order asked for, as this caller may see it.""" + names = list(usernames) + if not names: + return [] + found = self._accounts_found(request, names, preset) + # Restore the page's order; an account stored under none of the selected + # spellings is kept, at the end. + by_name = {account["username"]: account for account in found} + claimed = {name for name in names if name in by_name} + ordered = [by_name[name] for name in names if name in by_name] + ordered.extend(account for account in found if account["username"] not in claimed) + return ordered + + def _account_addressed(self, request, username, preset=None): + """ + Return the one account a member address names, as this caller may see it. + + The lookup settles which stored account an address names, so the + account it comes back with is the answer here whatever name that + account is stored under. An address the lookup comes back from with + several accounts and none of them stored under exactly the name given + names no one of them, so it is refused rather than answered with + whichever of them came back first. + """ + found = self._accounts_found(request, [username], preset) + return _the_one_addressed(username, found, lambda account: account["username"]) + + def _accounts_found(self, request, names, preset): + """Return the accounts the lookup comes back with for these names.""" + try: + return get_account_settings(request, names, view=preset if preset == SHARED_VIEW else None) + except UserNotFound as exc: + raise NotFound from exc + + def _selected_usernames(self, request, selection): + """Return the usernames the selectors name, in the order the page carries them.""" + return usernames_among( + self._selected_names(request, selection), + selection.get("ordering") or ACCOUNT_DEFAULT_SORT, + ) + + def _selected_names(self, request, selection): + """ + Return the usernames named by whichever selector the caller used. + + Every selector narrows the same collection, so each one that finds + nothing narrows it to nothing and leaves the page empty, whether it + was given a name, an address or an identifier. An address an account + held before it was retired is the exception: the account was there and + is not a row this page can carry, which is reported rather than left + out in silence. + """ + usernames = selection.get("username") + if usernames: + return names_in(usernames) + email = selection.get("email") + if email: + return self._usernames_holding_email(email) + identifier = selection.get("lms_user_id") + if identifier is not None: + return self._usernames_holding_identifier(identifier) + return [request.user.username] + + def _usernames_holding_email(self, email): + """Return the accounts holding this address, by name, and nothing when none does.""" + if is_email_retired(email): + raise self._retired_account(email) + return usernames_holding_email(email) + + def _usernames_holding_identifier(self, identifier): + """Return the account holding this numeric identifier, and nothing when none does.""" + username = username_holding_id(identifier) + return [username] if username is not None else [] + + def _retired_account(self, email): + """Build the refusal for an address an account held before it was retired.""" + retirement = cancellable_retirement_for_email(email) + return RetiredAccount( + accounts.RETIRED_EMAIL_MSG, + can_cancel_retirement=retirement is not None, + retirement_id=retirement.id if retirement is not None else None, + ) + + def _check_change_rate(self, request, changes): + """Refuse a property the caller has changed too often lately.""" + for field, limit_setting in RATE_LIMITED_ACCOUNT_FIELDS: + limit = getattr(settings, limit_setting, None) + if not (changes.get(field) and limit): + continue + if is_ratelimited( + request=request, + group=f"{field}_change_rate_limit", + key="user", + rate=limit, + increment=True, + ): + log.info("Change of %r refused for %r: over the rate limit", field, request.user.username) + raise Throttled(detail=f"Wait before changing {field} again.") + + def _refused_changes(self, username, error): + """Return the refusal naming the property that each failed validation.""" + log.info("Account %r not changed: %r", username, error.field_errors) + refusals = {} + for field, reported in error.field_errors.items(): + message = reported.get("user_message") if isinstance(reported, dict) else None + refusals[field] = [message or "This value cannot be used here."] + return ValidationError(refusals) + + def _failed_change(self, username, error): + """ + Return the failure for a change that was asked for correctly and could not be carried out. + + The body has already been measured against what this endpoint takes, + and a value the account refuses is reported property by property, so + what is left here is the work itself failing: a row that would not + save, a message that would not send. Neither is answered by sending + the request again with something else in it, so neither is the + caller's fault. + """ + log.error("Account %r not changed: %s", username, error.developer_message) + return APIException("The change could not be carried out.") + + +@extend_schema(tags=[SDK_TAG]) +@extend_schema_view( + post=extend_schema( + summary="Deactivate an account", + description=( + "Makes the account's password unusable, so its owner can no longer sign in with it. " + "The account and the data belonging to it are kept, and none of it is removed. Asking " + "for a password reset does not undo this: the reset flow refuses an account whose " + "password is unusable, so signing in again takes a new password set on the account by " + "other means." + ), + parameters=[_DEACTIVATION_PATH_PARAM], + request=None, + responses={ + 204: OpenApiResponse(description="The account was deactivated."), + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NOT_FOUND, + 406: RESP_NOT_ACCEPTABLE, + }, + ), +) +class AccountDeactivationView( + StandardizedErrorMixin, + RestrictedApplicationGuardMixin, + ReadableErrorDetailMixin, + AccountResourceMixin, + StrictQueryParametersMixin, + APIView, +): + """ + Disables the account the address names, so its owner can no longer sign in with it. + + The account and everything belonging to it are kept, and none of it is + removed. Asking for a password reset does not undo this. + """ + + # A view of its own: its callers and body differ from the account collection's. + + permission_classes = (permissions.IsAuthenticated, IsActiveUser, CanDeactivateUser) + + def post(self, request, username): + """Make the password of the named account unusable and record it as disabled.""" + user = self.addressed_account() + segment.identify(user.id, {"is_disabled": "true"}) + user.set_unusable_password() + user.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class _SearchedAccountsMixin: + """ + The accounts a search names, before it is narrowed to the ones a caller may see. + + A view's scoping policy is applied on top of the selection the classes + resolved after it answer with, so the unnarrowed selection is declared + here rather than on the view, where it would stand in front of the layer + that narrows it and leave the policy with nothing to do. + """ + + #: The addresses asked about, held for as long as one request is served. + searched_emails = () + + def get_queryset(self): + """Return the accounts holding the addresses this request asked about.""" + return accounts_holding_emails(self.searched_emails) + + +@extend_schema(tags=[SDK_TAG]) +@extend_schema_view( + create=extend_schema( + summary="Search accounts by email address", + description=( + "Returns a page of the accounts holding the email addresses in the body. An address " + "no account holds is left out of the answer, and a search names at most " + f"{EMAIL_SEARCH_MAX_ADDRESSES} addresses. The addresses travel in the body so that " + "they are not written to a server log or a browser history, which is why this reads " + "with a POST. For the same reason the `next` and `previous` links of the page are " + "reached by sending the same body to them, the way this request was sent, rather " + "than by fetching them." + ), + request=EmailSearchSerializer, + responses={ + 200: EmailSearchResultSerializer(many=True), + 400: _RESP_UNUSABLE_BODY, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NOT_FOUND, + 406: RESP_NOT_ACCEPTABLE, + 415: RESP_UNREADABLE_BODY, + }, + ), +) +class EmailSearchViewSet( + StandardizedErrorMixin, + RestrictedApplicationGuardMixin, + ReadableErrorDetailMixin, + StrictQueryParametersMixin, + IterablePaginationMixin, + ScopedQuerysetMixin, + _SearchedAccountsMixin, + viewsets.ViewSet, +): + """ + The accounts holding a given set of email addresses. + + The addresses to look for are sent in the request body, so a search is + made with POST, and the links on a page of results are followed by sending + the body to them again rather than by fetching them. Only a platform + administrator may search here; every other caller is told there is nothing + at this address, whatever state their own account is in. An administrator + whose account is not active is refused. + """ + + # A POST so the addresses stay out of server logs and browser histories. + + # The session of an account that has not confirmed its email address is + # read rather than dropped, so it gets the answer a token of that account + # gets instead of the request being treated as anonymous. A token is read + # by the library class the platform's own pair is built on, named because + # a view declaring its own classes is not one that pair serves. + authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser) + # A caller who is not an administrator is told there is nothing here + # before the state of their own account is read, so an inactive account + # is not refused in a way that confirms the search exists. + permission_classes = ( + permissions.IsAuthenticated, + CanGetAccountInfo, + IsStaffOrAbsent, + IsActiveUser, + ) + # A platform administrator may read every account the addresses match, so + # the policy keeps the selection whole; a narrower policy can replace it + # without touching the search below, and the accounts come from the same + # table the other collections of this version narrow. + scoping_policy = FullScopePolicy() + parser_classes = (JSONParser,) + metadata_class = AddressMetadata + serializer_class = EmailSearchResultSerializer + #: A search is asked for by sending the addresses to look for and is + #: answered with an account per address found, so the body it takes is not + #: the shape it answers with. + request_serializer_class = EmailSearchSerializer + pagination_class = UserApiPagination + + def get_serializer(self, *args, **kwargs): + """Build the output serializer; a plain ViewSet has no generic machinery to do it.""" + return self.serializer_class(*args, **kwargs) + + def create(self, request): + """Return a page of the accounts holding the addresses the body carries.""" + search = EmailSearchSerializer(data=request.data) + search.is_valid(raise_exception=True) + self.searched_emails = search.validated_data["emails"] + return self.paginate_iterable(request, self.get_queryset()) + + +_NAME_CHANGE_PATH_PARAM = path_param( + "username", + "Unique name of the user whose requested change is confirmed. The name is read as it is " + f"stored, so `{OWN_ACCOUNT_IDENTIFIER}` here names an account holding that name rather than " + "the account of the caller making the request.", + USERNAME_PATTERN, +) + + +@extend_schema(tags=[SDK_TAG]) +@extend_schema_view( + create=extend_schema( + summary="Request a profile name change", + description=( + "Records the full name the caller wants on their profile. The profile keeps its " + "current name until the request is confirmed, and a caller who already has a request " + "outstanding replaces it with this one." + ), + request=NameChangeSerializer, + responses={ + 201: NameChangeSerializer, + 400: _RESP_UNUSABLE_BODY, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_SESSION_WITHOUT_ITS_TOKEN, + 404: OpenApiResponse( + ErrorResponse, + description="The account of the caller carries no profile to put a name on.", + ), + 406: RESP_NOT_ACCEPTABLE, + 415: RESP_UNREADABLE_BODY, + }, + ), +) +class NameChangeViewSet( + StandardizedErrorMixin, + RestrictedApplicationGuardMixin, + ReadableErrorDetailMixin, + _ActionAddressMixin, + StrictQueryParametersMixin, + viewsets.ViewSet, +): + """ + Requests to put a different full name on a user's profile. + + A user asks for the name they want; the profile keeps the name it has + until a member of staff confirms the request, which is the point at which + the old name is recorded in the profile's history. + """ + + permission_classes = (permissions.IsAuthenticated,) + serializer_class = NameChangeSerializer + #: Describes each address by the body and the media types it reads. + metadata_class = AddressMetadata + #: The actions whose handler reads no body, which their address describes + #: none for. Confirming a change carries out the one already asked for, so + #: there is nothing for a caller to send. + bodiless_actions = ("confirmations",) + lookup_field = "username" + # The router's default lookup pattern excludes dots, which usernames allow. + lookup_value_regex = settings.USERNAME_REGEX_PARTIAL + + def get_serializer(self, *args, **kwargs): + """Build the serializer; a plain ViewSet has no generic machinery to do it.""" + return self.serializer_class(*args, **kwargs) + + def create(self, request): + """Record the name the caller wants on their profile.""" + requested = self.get_serializer(data=request.data) + requested.is_valid(raise_exception=True) + name = requested.validated_data["name"] + rationale = f"Name change requested through account API by {request.user.username}" + pending = self._requested_change(request.user, name, rationale) + if pending is None: + raise ValidationError({"name": ["The profile already carries this name."]}) + return Response( + self.get_serializer({"name": pending.new_name}).data, + status=status.HTTP_201_CREATED, + ) + + @extend_schema( + summary="Confirm a requested profile name change", + description=( + "Puts the name the user asked for on their profile and keeps the name it carried " + "until now in the profile's history. Only a member of staff may confirm a request." + ), + parameters=[_NAME_CHANGE_PATH_PARAM], + request=None, + responses={ + 204: OpenApiResponse(description="The profile now carries the name that was asked for."), + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: OpenApiResponse( + ErrorResponse, + description="No such user, or the user has asked for no name change.", + ), + 406: RESP_NOT_ACCEPTABLE, + }, + ) + @action( + detail=True, + methods=["post"], + url_path="confirmations", + url_name="confirmation", + # Confirming somebody else's requested name is a privilege, so this + # action also refuses a caller whose own account is no longer active. + permission_classes=[permissions.IsAuthenticated, IsActiveUser, IsStaff], + ) + def confirmations(self, request, username): + """Put the name the named user asked for on their profile.""" + user = _user_named(username) + pending = get_pending_name_change(user) + if pending is None: + raise NotFound + self._confirm_change(user, pending) + return Response(status=status.HTTP_204_NO_CONTENT) + + def _requested_change(self, user, name, rationale): + """Store the name this user asks for, or return nothing when it is the name they have.""" + try: + return do_name_change_request(user, name, rationale)[0] + except UserProfile.DoesNotExist as exc: + log.warning("Name change refused for %r: the account carries no profile", user.username) + raise NotFound from exc + + def _confirm_change(self, user, pending): + """Put the requested name on the profile.""" + try: + confirm_name_change(user, pending) + except UserProfile.DoesNotExist as exc: + log.warning("Name change refused for %r: the account carries no profile", user.username) + raise NotFound from exc + + +_VERIFICATION_PATH_PARAM = path_param( + "username", + "Unique name of the user whose identity verification is addressed. The name is read as it is " + f"stored, so `{OWN_ACCOUNT_IDENTIFIER}` here names an account holding that name rather than " + "the account of the caller making the request.", + USERNAME_PATTERN, +) + + +def _verification_sort_key(attempt): + """Return what an attempt is ordered by, with a tie-break that is always comparable.""" + return (attempt.updated_at, str(getattr(attempt, "receipt_id", "") or "")) + + +@extend_schema(tags=[SDK_TAG]) +class IDVerificationStatusView( + StandardizedErrorMixin, + RestrictedApplicationGuardMixin, + ReadableErrorDetailMixin, + AccountResourceMixin, + StrictQueryParametersMixin, + APIView, +): + """ + Whether a user has proved their identity, as one summary. + + Every account has a summary, and one belonging to a user who has been + through no check says so; the address is absent only when the user it + names is. A user may read their own summary, and a staff caller whose own + account is still active may read anyone's. + """ + + # Computed by the identity verification service, one per account; a view of + # its own because it admits different callers from the account collection. + + permission_classes = ( + permissions.IsAuthenticated, + # Reading somebody else's summary is a privilege, so the staff half + # also refuses a caller whose own account is no longer active. + (IsStaff & IsActiveUser) | IsAddressedUser, + ) + serializer_class = VerificationStatusSerializer + + def get_serializer(self, *args, **kwargs): + """Build the serializer; a plain APIView has no generic machinery to do it.""" + return self.serializer_class(*args, **kwargs) + + @extend_schema( + summary="Get the identity verification status", + description=( + "Returns where this user stands on having proved their identity. A user may read " + "their own status, and a staff caller may read anyone's. A user who has been through " + "no check stands at `none`." + ), + parameters=[_VERIFICATION_PATH_PARAM], + responses={ + 200: VerificationStatusSerializer, + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: OpenApiResponse(ErrorResponse, description="No such user."), + 406: RESP_NOT_ACCEPTABLE, + }, + ) + def get(self, request, username): + """Return where this user stands on having proved their identity.""" + standing = IDVerificationService.user_status(_user_named(username)) + return Response(self.get_serializer({ + "is_verified": standing.get("status") == APPROVED_STATUS, + "status": standing.get("status"), + "expiration_datetime": standing.get("verification_expiry", ""), + }).data) + + +@extend_schema(tags=[SDK_TAG]) +@extend_schema_view( + list=extend_schema( + summary="List identity verification attempts", + description=( + "Returns a page of the attempts this user has made at proving their identity, the " + "most recently changed one first. A user who has been through no checks has an " + "empty page. A user may read their own attempts, and a staff caller may read " + "anyone's." + ), + # The sort is published from the serializer that validates it, so the + # values a caller is held to are the values it reads. + parameters=[_VERIFICATION_PATH_PARAM, VerificationQuerySerializer], + responses={ + 200: VerificationSerializer(many=True), + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: OpenApiResponse( + ErrorResponse, description="No such user, or no such page of attempts.", + ), + 406: RESP_NOT_ACCEPTABLE, + }, + ), +) +class IDVerificationViewSet( + StandardizedErrorMixin, + RestrictedApplicationGuardMixin, + ReadableErrorDetailMixin, + AccountResourceMixin, + StrictQueryParametersMixin, + IterablePaginationMixin, + viewsets.ViewSet, +): + """ + The attempts one user has made at proving their identity. + + A user may read their own attempts, and a staff caller may read anyone's. + A user who has been through no checks has an empty page; the address is + absent only when the user it names is. + """ + + # Collected from the identity verification service's tables and sorted + # here, since there is no queryset of this app's to narrow. + + permission_classes = ( + permissions.IsAuthenticated, + # Reading somebody else's attempts is a privilege, so the staff half + # also refuses a caller whose own account is no longer active. + (IsStaff & IsActiveUser) | IsAddressedUser, + ) + serializer_class = VerificationSerializer + pagination_class = UserApiPagination + #: Validates the query string, and names the parameters this view reads. + query_serializer_class = VerificationQuerySerializer + + def get_serializer(self, *args, **kwargs): + """Build the serializer; a plain ViewSet has no generic machinery to do it.""" + return self.serializer_class(*args, **kwargs) + + def list(self, request, username): + """Return a page of this user's attempts, in the order the caller asked for.""" + query = self.query_serializer_class(data=request.query_params) + query.is_valid(raise_exception=True) + attempts = IDVerificationService.verifications_for_user(_user_named(username)) + ordering = query.validated_data.get("ordering") or VERIFICATION_DEFAULT_SORT + return self.paginate_iterable( + request, + sorted(attempts, key=_verification_sort_key, reverse=ordering.startswith("-")), + ) + + +_ACCOUNT_PREFERENCES_PATH_PARAM = path_param( + "username", + "Unique name of the user whose preferences are addressed. The name is read as it is stored, " + f"so `{OWN_ACCOUNT_IDENTIFIER}` here names an account holding that name rather than the " + "account of the caller making the request.", + USERNAME_PATTERN, +) +_ACCOUNT_PREFERENCE_KEY_PATH_PARAM = path_param( + "key", + "Name the preference is stored under, which is the whole segment, punctuation included. A " + "name carrying a slash has no address of its own, because the segment ends at the slash; " + "such a name is read with the whole document and removed by sending a null for it there. " + f"Nothing is stored under a name longer than {PREFERENCE_NAME_MAX_LENGTH} characters, nor " + "under one carrying no letter, digit, hyphen or underscore. A name with whitespace at either " + "end names no preference: one is stored under its name without the whitespace around it.", + rf"^{PREFERENCE_KEY_PATTERN_PARTIAL}$", +) + +#: The actions of the preference resource an active administrator may call +#: against an account other than their own. +STAFF_READABLE_PREFERENCE_ACTIONS = ("retrieve_all", "retrieve") + +#: The action that changes the whole preference document of one account. +DOCUMENT_CHANGE_ACTION = "partial_update_all" + +#: The action recorded for a request that asks what an address answers rather +#: than for what it holds. Such a request is answered to whoever may read the +#: address; the description of a write it carries is built behind a second +#: check, which carries the method of that write. +DESCRIBE_ADDRESS_ACTION = "metadata" + +#: Who may read the preferences of the account an address names, and who may +#: change them. Reading somebody else's is a privilege, so the administrator +#: half also refuses a caller whose own account is no longer active; changing +#: them belongs to their owner alone. +_PREFERENCE_READ_PERMISSIONS = ( + permissions.IsAuthenticated, + (IsStaff & IsActiveUser) | IsAddressedUser, +) +_PREFERENCE_WRITE_PERMISSIONS = (permissions.IsAuthenticated, IsAddressedUser) + +#: The media type a change to the whole document is sent in: a merge patch, in +#: which a null removes a name. Every other body of this resource is read in +#: the media types the platform reads everywhere. +_DOCUMENT_PARSERS = (MergePatchParser,) + +_RESP_PREFERENCE_DOCUMENT = OpenApiResponse( + ACCOUNT_PREFERENCES_SCHEMA, description="The preferences this account stores.", +) +_RESP_NO_SUCH_PREFERENCE = OpenApiResponse( + ErrorResponse, description="No account is held at the address given, or it stores no such preference.", +) +_RESP_PREFERENCE_NOT_STORED = OpenApiResponse( + ErrorResponse, + description=( + "The request body is not well formed in the media type it was sent in, or a preference " + "name is longer than one can be stored under, or a value is one a preference cannot " + "hold. A query parameter this endpoint does not read is refused here too." + ), +) +_RESP_PREFERENCE_SERVICE_FAILED = OpenApiResponse( + ErrorResponse, + description=( + "The request was correct and could not be carried out. Sending it again with something " + "else in it does not help; sending the same request later may." + ), +) + + +@extend_schema(tags=[SDK_TAG]) +class AccountPreferenceViewSet( + StandardizedErrorMixin, + RestrictedApplicationGuardMixin, + ReadableErrorDetailMixin, + AccountResourceMixin, + StrictQueryParametersMixin, + viewsets.ViewSet, +): + """ + The preferences of the one account an address names. + + They are one document: an object of preference name to stored value, read + whole and changed with a merge patch. Each preference is also reached on + its own. A user reads and changes their own preferences, and a staff + caller whose own account is still active reads anyone's but changes + nobody else's. + + A preference name takes the whole of its segment of the address, so the + names carrying punctuation that the platform's own features store under + can be addressed. A name carrying a slash cannot, because the segment ends + there; such a name is read and removed through the whole document, which + is also the only way one arrives. + """ + + # The preferences are read and written through the preference service + # rather than from a table of this app's, so there is no queryset here. + # Treating them as one document is what the pages that keep a user's + # settings read and save; this collection carries no pages of its own, and + # the record-shaped view of the same data across accounts is the stored + # preference collection of this version. + + # The language a user picked is written from every page of the site, and + # the settings page these preferences are kept on is reached by users who + # have not confirmed their email address yet, whom the platform default + # refuses. Only the session class differs in what it admits: a token is + # read by the library class the platform's own pair is built on, named here + # because a view declaring its own classes is not one that pair serves. + authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser) + permission_classes = _PREFERENCE_WRITE_PERMISSIONS + serializer_class = AccountPreferenceSerializer + #: Describes each address by the body and the media types it reads. + metadata_class = AddressMetadata + + #: Action being served, which the parsers follow. It is declared here + #: because it is recorded while the request is being built, which is + #: before the view machinery would otherwise set it. + action = None + + def get_serializer(self, *args, **kwargs): + """Build the output serializer; a plain ViewSet has no generic machinery to do it.""" + return self.serializer_class(*args, **kwargs) + + def declared_permissions(self): + """Return the classes this address declares for the request being served.""" + reads = self.action in STAFF_READABLE_PREFERENCE_ACTIONS or self._describes_the_address() + permission_classes = _PREFERENCE_READ_PERMISSIONS if reads else self.permission_classes + return [permission() for permission in permission_classes] + + def _describes_the_address(self): + """ + Return whether the request asks what the address answers rather than for what it holds. + + Such a request is guarded as a read of the address, so a caller who may + read somebody else's preferences may also ask what the address does + with them. The description of a write that the answer carries is built + behind a second check made with the method of that write, which is + guarded as that write and so is left out for a caller who may not make + it. + """ + return self.action == DESCRIBE_ADDRESS_ACTION and self.request.method == "OPTIONS" + + def initial(self, request, *args, **kwargs): + """ + Admit the caller, then answer an address whose name has whitespace at either end as absent. + + The preference service stores a name without the whitespace around it, + so a value stored from such an address would land under another name + and could not be read back or removed there. The address is therefore + absent whatever is asked of it. + """ + super().initial(request, *args, **kwargs) + key = kwargs.get("key") + if key is not None and key != key.strip(): + raise NotFound + + def initialize_request(self, request, *args, **kwargs): + """ + Record which action is being served before anything reads the request. + + The media type a body is read in follows the action here, and the + parsers are chosen while the request is being built, which is before + the action would otherwise be recorded. + """ + self.action = self.action_map.get(request.method.lower()) + return super().initialize_request(request, *args, **kwargs) + + def get_parsers(self): + """Return the parsers that read the bodies the address being served takes.""" + if self.action == DOCUMENT_CHANGE_ACTION or self._describes_an_address_the_document_is_changed_at(): + return [parser() for parser in _DOCUMENT_PARSERS] + return super().get_parsers() + + def _describes_an_address_the_document_is_changed_at(self): + """ + Return whether the request asks what an address that takes the whole document answers. + + Such a request is answered with the media types that address reads, + which are the ones of the change it serves rather than the ones read + everywhere else; the action recorded for it is not the change, so the + methods the address serves are what decide. + """ + return ( + self.action == DESCRIBE_ADDRESS_ACTION + and DOCUMENT_CHANGE_ACTION in (self.action_map or {}).values() + ) + + @extend_schema( + summary="Get the preferences of one account", + description=( + "Returns every preference stored for this user, as an object of preference name to " + "stored value. A user may read their own preferences, and a staff caller may read " + "anyone's. An account that stores none is answered with an empty object. The names " + "come back sorted by name, character by character and by the code of each character, " + "so a name beginning with a digit or a capital letter comes before one beginning in " + "lower case." + ), + parameters=[_ACCOUNT_PREFERENCES_PATH_PARAM], + responses={ + 200: _RESP_PREFERENCE_DOCUMENT, + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NO_SUCH_ACCOUNT, + 406: RESP_NOT_ACCEPTABLE, + 500: _RESP_PREFERENCE_SERVICE_FAILED, + }, + ) + def retrieve_all(self, request, username): + """Return every preference stored for the account the address names.""" + with self._service_failures_named(username): + stored = get_user_preferences(request.user, username=username) + return Response(ACCOUNT_PREFERENCE_DOCUMENT.to_representation(stored)) + + @extend_schema( + summary="Change the preferences of one account", + description=( + "Changes the preferences the body names and leaves the rest as they are. The body is " + "a merge patch: a value is the text to store under that name, and a null removes the " + "name. A user may change only their own preferences. Nothing is stored unless every " + "name in the body can be, and a value that cannot be stored is reported under the " + "name that carried it." + ), + parameters=[_ACCOUNT_PREFERENCES_PATH_PARAM], + request={MergePatchParser.media_type: ACCOUNT_PREFERENCE_CHANGES_SCHEMA}, + responses={ + 204: OpenApiResponse(description="The preferences now stand as the body asked."), + 400: _RESP_PREFERENCE_NOT_STORED, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 406: RESP_NOT_ACCEPTABLE, + 415: RESP_UNREADABLE_BODY, + 500: _RESP_PREFERENCE_SERVICE_FAILED, + }, + ) + def partial_update_all(self, request, username): + """Store the preferences the body names and remove the ones it sends a null for.""" + changes = AccountPreferencesUpdateSerializer(data=request.data) + changes.is_valid(raise_exception=True) + with self._service_failures_named(username): + with transaction.atomic(): + update_user_preferences(request.user, changes.validated_data, user=username) + return Response(status=status.HTTP_204_NO_CONTENT) + + @extend_schema( + summary="Get one preference of one account", + description=( + "Returns one preference of this user, as the name it is stored under and the value " + "stored there. A user may read their own preferences, and a staff caller may read " + "anyone's. A name the account stores no value under is absent." + ), + parameters=[_ACCOUNT_PREFERENCES_PATH_PARAM, _ACCOUNT_PREFERENCE_KEY_PATH_PARAM], + responses={ + 200: AccountPreferenceSerializer, + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NO_SUCH_PREFERENCE, + 406: RESP_NOT_ACCEPTABLE, + 500: _RESP_PREFERENCE_SERVICE_FAILED, + }, + ) + def retrieve(self, request, username, key): + """Return one preference of the account the address names.""" + with self._service_failures_named(username): + value = get_user_preference(request.user, key, username=username) + if value is None: + raise NotFound + return Response(self.get_serializer({"key": key, "value": value}).data) + + @extend_schema( + summary="Store one preference of one account", + description=( + "Stores a value under one preference of this user, whether or not the name already " + "holds one. A user may change only their own preferences. A value a preference " + "cannot hold is refused under `value`, and a preference cannot hold an empty value. " + "A name nothing can be stored under, whether because it is longer than the store " + "holds or because it carries none of the characters a name is made of, is refused " + "under `key`, which is the part of the address that carried it. A name with " + "whitespace at either end is absent, and nothing is stored." + ), + parameters=[_ACCOUNT_PREFERENCES_PATH_PARAM, _ACCOUNT_PREFERENCE_KEY_PATH_PARAM], + request=AccountPreferenceValueSerializer, + responses={ + 204: OpenApiResponse(description="The preference now holds this value."), + 400: _RESP_PREFERENCE_NOT_STORED, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: OpenApiResponse( + ErrorResponse, + description="The name in the address has whitespace at one end or both, so it names no preference.", + ), + 406: RESP_NOT_ACCEPTABLE, + 415: RESP_UNREADABLE_BODY, + 500: _RESP_PREFERENCE_SERVICE_FAILED, + }, + ) + def update(self, request, username, key): + """Store a value under one preference of the account the address names.""" + self._refuse_a_name_nothing_is_stored_under(key) + sent = AccountPreferenceValueSerializer(data=request.data) + sent.is_valid(raise_exception=True) + with self._service_failures_named(username, carried_by="value"): + set_user_preference( + request.user, key, sent.validated_data["value"], username=username, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + @extend_schema( + summary="Remove one preference of one account", + description=( + "Removes one preference of this user, so the name is no longer stored. A user may " + "remove only their own preferences. A name the account stores no value under is " + "absent." + ), + parameters=[_ACCOUNT_PREFERENCES_PATH_PARAM, _ACCOUNT_PREFERENCE_KEY_PATH_PARAM], + responses={ + 204: OpenApiResponse(description="The account stores no value under that name."), + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NO_SUCH_PREFERENCE, + 406: RESP_NOT_ACCEPTABLE, + 500: _RESP_PREFERENCE_SERVICE_FAILED, + }, + ) + def destroy(self, request, username, key): + """Remove one preference of the account the address names.""" + with self._service_failures_named(username): + removed = delete_user_preference(request.user, key, username=username) + if not removed: + raise NotFound + return Response(status=status.HTTP_204_NO_CONTENT) + + @staticmethod + def _refuse_a_name_nothing_is_stored_under(key): + """ + Refuse a name no value can be stored under, as the part of the address that carried it. + + The name arrives in the address rather than in the body, so a name the + store refuses is reported under the address member that carried it: a + caller cannot mend it by changing the value they sent. Which names are + held is the store's own to decide, so what it refuses is read from the + column that keeps them rather than from a list kept here. + """ + refused = why_a_name_cannot_be_stored(key) + if refused: + raise ValidationError({"key": refused}) + + @contextlib.contextmanager + def _service_failures_named(self, username, carried_by=None): + """ + Answer a failure of the preference service in the body this API answers with. + + A value the preference refuses is reported under the member of the + body that carried it, which is the preference name in a document and + the one property of a single change. Work that was asked for correctly + and failed anyway is not the caller's to correct, so what it says about + itself is logged rather than published. + """ + try: + yield + except UserNotFound as exc: + raise NotFound from exc + except PreferenceValidationError as exc: + raise self._refused_preferences(username, exc, carried_by) from exc + except (PreferenceUpdateError, UserAPIInternalError) as exc: + log.error("Preferences of %r could not be served: %s", username, exc) + raise APIException("The request could not be carried out.") from exc + + def _refused_preferences(self, username, error, carried_by=None): + """Return the refusal naming the member of the body that carried each value refused.""" + log.info("Preferences of %r not changed: %r", username, error.preference_errors) + refusals = {} + for key, reported in error.preference_errors.items(): + message = reported.get("user_message") if isinstance(reported, dict) else None + refusals[carried_by or key] = [message or "This value cannot be stored here."] + return ValidationError(refusals) + + +@extend_schema(tags=[SDK_TAG]) +@extend_schema_view( + create=extend_schema( + summary="Record whether to send a user an organization's messages", + description=( + "Records whether the caller wants messages from the organization running the course " + "named in the body. The choice is kept per organization, so it covers every course " + "that organization runs, and it is always the caller's own. A user whose account " + "needs a guardian's consent is recorded as not wanting them, whatever the body says." + ), + request=EmailOptInSerializer, + responses={ + 204: OpenApiResponse(description="The choice was recorded."), + 400: _RESP_UNUSABLE_BODY, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_SESSION_WITHOUT_ITS_TOKEN, + 404: OpenApiResponse( + ErrorResponse, + description="The account of the caller carries no profile to read a consent rule from.", + ), + 406: RESP_NOT_ACCEPTABLE, + 415: RESP_UNREADABLE_BODY, + 500: _RESP_PREFERENCE_SERVICE_FAILED, + }, + ), +) +class EmailOptInViewSet( + StandardizedErrorMixin, + RestrictedApplicationGuardMixin, + ReadableErrorDetailMixin, + StrictQueryParametersMixin, + viewsets.ViewSet, +): + """ + The choices users have made about the messages organizations send them. + + A choice belongs to the user who made it, so it is recorded against the + caller and no username is named anywhere in the request. The organization + it applies to is the one running the course the body names. The choice is + held per organization rather than per course, so naming two courses of one + organization records one choice. + """ + + # Naming the course rather than the organization is how the pages a user + # makes this choice on identify the organization they are making it about. + + # The choice is made while enrolling, which a user reaches before they have + # confirmed their email address, and the platform default refuses a session + # belonging to such an account. Only the session class differs in what it + # admits: a token is read by the library class the platform's own pair is + # built on, named here because a view declaring its own classes is not one + # that pair serves. + authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser) + permission_classes = (permissions.IsAuthenticated,) + serializer_class = EmailOptInSerializer + + def get_serializer(self, *args, **kwargs): + """Build the serializer; a plain ViewSet has no generic machinery to do it.""" + return self.serializer_class(*args, **kwargs) + + def create(self, request): + """Record whether the caller wants messages from the organization running this course.""" + choice = self.get_serializer(data=request.data) + choice.is_valid(raise_exception=True) + try: + update_email_opt_in( + request.user, + choice.validated_data["course_key"].org, + choice.validated_data["email_opt_in"], + ) + except UserNotFound as exc: + raise NotFound from exc + except UserAPIInternalError as exc: + log.error("Email choice of %r not recorded: %s", request.user.username, exc) + raise APIException("The choice could not be recorded.") from exc + return Response(status=status.HTTP_204_NO_CONTENT) + + +#: Fields the retirement queue sorts on (the id is the tie-break, not a choice). +RETIREMENT_SORT_FIELDS = ("created", "modified", "original_username", "current_state") + +#: The order the queue is worked in, which is the order retirements were +#: requested in. +RETIREMENT_DEFAULT_SORT = ("id",) + +#: The presets a retirement response answers to. +RETIREMENT_PRESETS = (MINIMAL_VIEW,) + +#: The retirement actions that answer with a retirement, and so read the +#: parameters that shape one. +SHAPED_RETIREMENT_ACTIONS = ("list", "retrieve") + +_RETIREMENT_PATH_PARAM = path_param( + "username", + "Name the account being retired was registered under. A retired account is stored under a " + "hashed name, and either name addresses the same retirement when one is read; a write " + "addresses the registered name alone, spelled exactly as it was registered.", + USERNAME_PATTERN, +) +_RETIREMENT_VIEW_PARAM = query_param( + VIEW_PARAM, + "Response preset. `minimal` reduces each retirement to which account it is for and where it " + "stands, carrying none of the account owner's own data. Omit it to receive the whole record.", + enum=list(RETIREMENT_PRESETS), +) +#: Published from the filter that reads it; a method filter's type cannot be inferred. +_COOL_OFF_FILTER = RetirementFilterSet.base_filters["cool_off_days"] +_RETIREMENT_COOL_OFF_PARAM = query_param( + "cool_off_days", + _COOL_OFF_FILTER.extra["help_text"], + type={ + "type": "integer", + "minimum": _COOL_OFF_FILTER.extra["min_value"], + "maximum": _COOL_OFF_FILTER.extra["max_value"], + }, +) +_RETIREMENT_ORDERING_PARAM = query_param( + "ordering", + "Fields to sort by, in order of precedence, each optionally prefixed with `-` to sort " + "descending. A field that is not listed here is refused. Omit it to receive the retirements " + "in the order they were requested, which is the order the pipeline works them in.", + many=True, + enum=[*RETIREMENT_SORT_FIELDS, *(f"-{field}" for field in RETIREMENT_SORT_FIELDS)], + style="form", + explode=False, +) +_PARTNER_REPORT_PATH_PARAM = path_param( + "username", + "Name the account was registered under, spelled exactly as it was registered.", + USERNAME_PATTERN, +) + +#: A retirement comes back whole, as the fields the ``minimal`` preset keeps, +#: or as the properties ``fields`` selected. +_RETIREMENT_SHAPES = ResponseShapes( + component_name="RetirementResponse", + serializers=[ + RetirementSerializer, + RetirementMinimalSerializer, + RetirementProjectionSerializer, + ], +) + +_RESP_NO_SUCH_RETIREMENT = OpenApiResponse( + ErrorResponse, description="No retirement was requested under that name.", +) +#: The refusal when the requested-under name (not unique) matches several records. +_RESP_AMBIGUOUS_OR_BAD_REQUEST = OpenApiResponse( + ErrorResponse, + description=( + "A query parameter is missing, invalid, or not one this endpoint reads, or more than one " + "record was requested under the name given. Nothing was changed." + ), +) +_RESP_AMBIGUOUS_OR_UNUSABLE_BODY = OpenApiResponse( + ErrorResponse, + description=( + "The request body is not well formed in the media type it was sent in, or carries a value " + "this endpoint will not take, or more than one record was requested under a name the " + "request gives. A query parameter this endpoint does not read is refused here too. " + "Nothing was changed." + ), +) +_RESP_RETIREMENT_STATE = OpenApiResponse( + ErrorResponse, + description=( + "The retirement does not stand at a step of the pipeline that admits this operation. " + "Nothing was changed." + ), +) +_RESP_BATCH_STATE = OpenApiResponse( + ErrorResponse, + description=( + "One or more of the records named is not in a state this operation acts on. Nothing was " + "deleted, and `errors` names each record at fault and why." + ), +) +_RESP_RETIREMENT_MOVED = OpenApiResponse(description="The retirement now stands at the step asked for.") +_RESP_RETIREMENT_CANCELLED = OpenApiResponse( + description="The retirement request is gone and the account it was made for is usable again.", +) +_RESP_DATA_ERASED = OpenApiResponse(description="The data named by the scope has been erased.") +_RESP_ERASURE_FAILED = OpenApiResponse( + ErrorResponse, + description=( + "A step of the erasure did not finish. Part of the data named by the scope may already " + "be gone, and the request may be sent again: each step clears what it clears whether or " + "not it has run before." + ), +) +_RESP_PARTNER_REPORT_QUEUED = OpenApiResponse( + description=( + "The account is queued for reporting to the organizations it studied with, or was not " + "enrolled with any and so needs no report." + ), +) + +class _RetirementRecordAccessMixin: + """ + Who may act on the retirement records, and which of those records a caller acts on. + + The records this version lists, moves and cancels are the records its + batch disposal deletes, so the capability each operation asks for and the + policy deciding which records a caller acts on are declared here, where + all of them resolve them, rather than once per view. A rule added here is + asked of the disposal as well as of the operations on one record. + + These are declared in a class resolved before the library mixin that + reads the policy, which declares a policy of its own for a view that has + none; the selection the policy narrows is declared in a class resolved + after it instead. + """ + + permission_classes = (permissions.IsAuthenticated,) + # JSON only, as in the v1 operations these replace. + parser_classes = (JSONParser,) + # The pipeline capability reaches every retirement; a narrower policy goes here. + scoping_policy = FullScopePolicy() + #: The capability each action asks for beyond the classes above. + action_permissions = {} + + def get_permissions(self): + """Return the classes deciding who may make the request being served.""" + if self.action is None: + # No action serves this method here: the shared classes admit it and it answers 405. + return [permission() for permission in self.permission_classes] + # An action this mapping does not name reaches no capability of this + # resource, rather than inheriting the one every other action shares. + specific = self.action_permissions.get(self.action, (~permissions.AllowAny,)) + return [permission() for permission in (*self.permission_classes, *specific)] + + +class _RetirementRecordsMixin: + """ + The retirement records, before they are narrowed to the ones a caller may act on. + + A view's policy is applied on top of the selection the classes resolved + after it answer with, so the unnarrowed selection is declared here rather + than on the view, where it would stand in front of the layer that narrows + it and leave the policy with nothing to do. + """ + + def get_queryset(self): + """Return the retirement records, before the policy narrows them to the caller's.""" + return retirements() + + +#: Who may make each request. Cancellation is not pipeline work, so it takes a +#: different capability; an unnamed action is refused. +RETIREMENT_ACTION_PERMISSIONS = { + "list": (CanRetireUser,), + "retrieve": (CanRetireUser,), + "partial_update": (CanRetireUser,), + "erasures": (CanRetireUser,), + "destroy": (CanCancelUserRetirement,), + "metadata": (CanRetireUser,), +} + + +# Changes go through the model layer. Access: permission classes, then the +# visibility policy, then filters; a record the policy hides answers 404. +@extend_schema(tags=[USER_TAG]) +@extend_schema_view( + list=extend_schema( + summary="List retirements", + description=( + "Returns a page of the retirement queue. Supply `state` to keep only the retirements " + "standing at one of the pipeline steps it names, `cool_off_days` to keep only those " + "requested at least that many days ago, and `created_after` or `created_before` to " + "bound when they were requested." + ), + parameters=[ + _RETIREMENT_VIEW_PARAM, + _FIELDS_PARAM, + _RETIREMENT_COOL_OFF_PARAM, + _RETIREMENT_ORDERING_PARAM, + ], + responses={ + 200: _RETIREMENT_SHAPES, + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NO_SUCH_PAGE, + 406: RESP_NOT_ACCEPTABLE, + }, + ), + retrieve=extend_schema( + summary="Get one retirement", + description=( + "Returns the retirement of the account this name addresses. A retired account is " + "stored under a hashed name, and both that name and the one the account was " + "registered under address the same retirement." + ), + parameters=[_RETIREMENT_PATH_PARAM, _RETIREMENT_VIEW_PARAM, _FIELDS_PARAM], + responses={ + 200: _RETIREMENT_SHAPES, + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NO_SUCH_RETIREMENT, + 406: RESP_NOT_ACCEPTABLE, + }, + ), + partial_update=extend_schema( + summary="Move a retirement to another pipeline step", + description=( + "Records that this retirement now stands at another step of the pipeline, and " + "appends what happened at the step it is leaving to its log. The steps and their " + "order are configured by the operator, and a step that is not a later one than the " + "current step is refused unless `force` is sent." + ), + parameters=[_RETIREMENT_PATH_PARAM], + request=RetirementUpdateSerializer, + responses={ + 204: _RESP_RETIREMENT_MOVED, + 400: _RESP_AMBIGUOUS_OR_UNUSABLE_BODY, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NO_SUCH_RETIREMENT, + 406: RESP_NOT_ACCEPTABLE, + 409: _RESP_RETIREMENT_STATE, + 415: RESP_UNREADABLE_BODY, + }, + ), + destroy=extend_schema( + summary="Cancel a retirement", + description=( + "Deletes this retirement request and returns the account it was made for to the " + "address it was registered under, with a password nobody holds, so its owner signs " + "in again by asking for a new one. Only a retirement the pipeline has not started " + "work on can be cancelled." + ), + parameters=[_RETIREMENT_PATH_PARAM], + responses={ + 204: _RESP_RETIREMENT_CANCELLED, + 400: _RESP_AMBIGUOUS_OR_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NO_SUCH_RETIREMENT, + 406: RESP_NOT_ACCEPTABLE, + 409: _RESP_RETIREMENT_STATE, + }, + ), + erasures=extend_schema( + summary="Erase a retiring account's data", + description=( + "Erases one set of the retiring account's personal data. This cannot be undone, and " + "which set is erased is named by `scope` in the body. The retirement is not moved by " + "this request: the caller records where it stands." + ), + parameters=[_RETIREMENT_PATH_PARAM], + request=RetirementErasureSerializer, + responses={ + 204: _RESP_DATA_ERASED, + 400: _RESP_AMBIGUOUS_OR_UNUSABLE_BODY, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NO_SUCH_RETIREMENT, + 406: RESP_NOT_ACCEPTABLE, + 409: _RESP_RETIREMENT_STATE, + 415: RESP_UNREADABLE_BODY, + 500: _RESP_ERASURE_FAILED, + }, + ), +) +class RetirementViewSet( + StandardizedErrorMixin, + RestrictedApplicationGuardMixin, + ReadableErrorDetailMixin, + ErrorExtensionsMixin, + _ActionAddressMixin, + _ResponsePresetMixin, + StrictQueryParametersMixin, + _RetirementRecordAccessMixin, + ScopedQuerysetMixin, + _RetirementRecordsMixin, + viewsets.GenericViewSet, +): + """ + The accounts being retired, addressed by the name each was registered under. + + A retirement records one account's progress through the steps the operator + has configured. The requests here carry that work out; where an account + stands is recorded separately, by the caller driving them. + + A read answers for either the name the account was registered under or the + hashed name it was given when retired: both address the same account. A + write takes the registered name alone, spelled exactly as it was + registered, so it reaches one account and no other. + + Cancelling a retirement is open to a different caller from the requests + that carry one out. + """ + + serializer_class = RetirementSerializer + pagination_class = UserApiPagination + action_permissions = RETIREMENT_ACTION_PERMISSIONS + lookup_field = "username" + # The router's default lookup pattern excludes dots, which usernames allow. + lookup_value_regex = settings.USERNAME_REGEX_PARTIAL + filter_backends = (DjangoFilterBackend, StrictOrderingFilter) + filterset_class = RetirementFilterSet + ordering_fields = RETIREMENT_SORT_FIELDS + ordering = RETIREMENT_DEFAULT_SORT + # Tie-break the caller's sort with the queue order so pages are stable. + ordering_tiebreak = ("id",) + minimal_fields = RETIREMENT_MINIMAL_FIELDS + response_presets = RETIREMENT_PRESETS + extra_query_params = RESPONSE_SHAPING_PARAMS + + def known_query_params(self): + """Return the query parameters the action being served reads.""" + names = super().known_query_params() + if self.action != "list": + # A member address reads no filters or page parameters. + names -= set(self.filterset_class.base_filters) + names -= { + StrictOrderingFilter.ordering_param, + self.pagination_class.page_query_param, + self.pagination_class.page_size_query_param, + } + if self.action not in SHAPED_RETIREMENT_ACTIONS: + names -= set(RESPONSE_SHAPING_PARAMS) + return names + + def list(self, request, *args, **kwargs): + """Return a page of the retirement queue, shaped as the caller asked.""" + page = self.paginate_queryset(self.filter_queryset(self.get_queryset())) + serializer = self.get_serializer(page, many=True) + return self.shape_page(self.get_paginated_response(serializer.data), request) + + def retrieve(self, request, username): + """Return the retirement of the account this name addresses.""" + retirement = retirement_of_the_account_named(self.get_queryset(), username) + if retirement is None: + log.info("No retirement is held for an account addressed as %r.", username) + raise NotFound(NO_SUCH_RETIREMENT) + return Response(self.shape(self.get_serializer(retirement).data, request)) + + def partial_update(self, request, username): + """Record that this retirement now stands at another step of the pipeline.""" + changes = RetirementUpdateSerializer(data=request.data) + changes.is_valid(raise_exception=True) + move_retirement( + retirement_requested_as(self.get_queryset(), username), + changes.validated_data["new_state"], + changes.validated_data["response"], + changes.validated_data["force"], + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + def destroy(self, request, username): + """Undo this retirement request and return the account to its owner.""" + cancel_retirement(retirement_requested_as(self.get_queryset(), username)) + return Response(status=status.HTTP_204_NO_CONTENT) + + @action(detail=True, methods=["post"], url_path="erasures", url_name="erasure") + def erasures(self, request, username): + """Erase one set of the retiring account's personal data.""" + erasure = RetirementErasureSerializer(data=request.data) + erasure.is_valid(raise_exception=True) + erase_account_data( + retirement_open_to_action(self.get_queryset(), username), + erasure.validated_data["scope"], + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + + +class _PartnerReportAccessMixin: + """ + Who may act on the partner report entries, and which of those entries a caller acts on. + + The entries this version queues and takes for processing are the entries + its batch disposal deletes, so the capability every operation of them asks + for and the policy deciding which of them a caller acts on are declared + here, where all of them resolve them. A rule added here is asked of the + disposal as well as of the queue. Which entries each operation starts from + differs -- one takes those nobody has taken, the other disposes of those + that have been -- so that selection is declared by each of them, in a + class resolved after the library mixin that reads the policy. + """ + + permission_classes = (permissions.IsAuthenticated, CanRetireUser) + # JSON only, as in the v1 operations these replace. + parser_classes = (JSONParser,) + scoping_policy = FullScopePolicy() + + +class _UnclaimedPartnerReportsMixin: + """ + The partner reports waiting to be processed, before they are narrowed to the ones a caller may see. + + A view's scoping policy is applied on top of the selection the classes + resolved after it answer with, so the unnarrowed selection is declared + here rather than on the view, where it would stand in front of the layer + that narrows it and leave the policy with nothing to do. + """ + + def get_queryset(self): + """Return the partner reports nobody has taken for processing.""" + return unclaimed_partner_reports() + + +class _EveryPartnerReportMixin: + """ + Every partner report entry, before they are narrowed to the ones a caller may act on. + + Declared in a class of its own for the same reason as the queue's own + selection, and separately from it because an entry is disposed of once it + has been taken for processing, which is what the queue leaves out. + """ + + def get_queryset(self): + """Return every partner report entry, taken for processing or not.""" + return partner_reports() + + +_RESP_PARTNER_REPORTS_TAKEN = OpenApiResponse( + RetirementPartnerReportRecordSerializer(many=True), + description=( + "The entries taken by this request, which are exactly the entries it marked as being " + "processed. A second request returns the entries added since." + ), +) + + +@extend_schema(tags=[USER_TAG]) +@extend_schema_view( + create=extend_schema( + summary="Take partner reports for processing", + description=( + "Returns the queued partner reports nobody has taken yet and marks the ones it " + "returns as being processed, so that two callers do not report the same account. " + "Nothing releases an entry once it has been taken, which is why this is a write " + "rather than a read and why the answer is not cut into pages: a page would hand the " + "caller the first entries while taking all of them." + ), + parameters=[ + query_param( + "limit", + f"Take at most this many entries, from 1 to {PARTNER_REPORT_CLAIM_MAX}. Omit it to " + "take every entry waiting.", + type={"type": "integer", "minimum": 1, "maximum": PARTNER_REPORT_CLAIM_MAX}, + ), + ], + request=None, + responses={ + 200: _RESP_PARTNER_REPORTS_TAKEN, + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 406: RESP_NOT_ACCEPTABLE, + }, + ), + update=extend_schema( + summary="Queue an account for partner reporting", + description=( + "Queues the retiring account this name addresses for reporting to the organizations " + "running the courses it was enrolled in. An account enrolled with no organization " + "needs no report and is not queued. Sending this twice for the same account leaves " + "one entry." + ), + parameters=[_PARTNER_REPORT_PATH_PARAM], + request=None, + responses={ + 204: _RESP_PARTNER_REPORT_QUEUED, + 400: _RESP_AMBIGUOUS_OR_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NO_SUCH_RETIREMENT, + 406: RESP_NOT_ACCEPTABLE, + 409: _RESP_RETIREMENT_STATE, + }, + ), +) +class RetirementPartnerReportViewSet( + StandardizedErrorMixin, + RestrictedApplicationGuardMixin, + ReadableErrorDetailMixin, + ErrorExtensionsMixin, + _ActionAddressMixin, + StrictQueryParametersMixin, + _PartnerReportAccessMixin, + ScopedQuerysetMixin, + _UnclaimedPartnerReportsMixin, + viewsets.ViewSet, +): + """ + The accounts whose retirement still has to be reported to the organizations they studied with. + + An entry is added as the pipeline finishes an account, and carries the name + and address that account held before it was retired together with the short + names of the organizations running the courses it was enrolled in. + + Taking entries is a write rather than a read: a request is answered with + exactly the entries it has marked as taken, so an entry added while that + answer is being built is left for the next caller rather than reported to + this one. Nothing releases an entry once it has been taken. + """ + + serializer_class = RetirementPartnerReportRecordSerializer + #: Validates the query string, and names the parameters this view reads. + query_serializer_class = RetirementPartnerReportQuerySerializer + lookup_field = "username" + # The router's default lookup pattern excludes dots, which usernames allow. + lookup_value_regex = settings.USERNAME_REGEX_PARTIAL + + def get_serializer(self, *args, **kwargs): + """Build the output serializer; a plain ViewSet has no generic machinery to do it.""" + return self.serializer_class(*args, **kwargs) + + def known_query_params(self): + """Return the query parameters the action being served reads.""" + names = super().known_query_params() + if self.action != "create": + # How many entries to take is only meaningful where entries are taken. + names -= set(self.query_serializer_class().fields) + return names + + def create(self, request): + """Take the queued partner reports and return them.""" + query = self.query_serializer_class(data=request.query_params) + query.is_valid(raise_exception=True) + reports = claim_partner_reports(self.get_queryset(), query.validated_data.get("limit")) + return Response(self.get_serializer(reports, many=True).data) + + def update(self, request, username): + """Queue the retiring account this name addresses for partner reporting.""" + # The record queued is a retirement, so name its records and apply this view's + # policy to them. + records = self.scoping_policy.scope(retirements(), request.user) + record_partner_report(retirement_open_to_action(records, username)) + return Response(status=status.HTTP_204_NO_CONTENT) + + +# Entries are read, then marked by the ids read, so one added meanwhile is not +# marked; enrolments are prefetched. +_RESP_BATCH_OUTCOME = OpenApiResponse( + RetirementBatchOutcomeSerializer, + description=( + "What was done about each name given. A name with no record left is reported as such " + "rather than refused, so a repeat of a request already carried out is answered." + ), +) + + +# A batch operation with no member address, so its own collection; access and +# selection go through the retirement records' layers. +@extend_schema(tags=[USER_TAG]) +@extend_schema_view( + create=extend_schema( + summary="Delete finished retirement records", + description=( + "Deletes the retirement records of the accounts named, once the pipeline has " + "finished with them. The record's own copies of the account's name, address and " + "full name are written over before it is deleted, so that a system syncing these " + "rows and treating a delete as a soft one is left holding the replacement. A record " + "the pipeline has not finished with refuses the whole batch, and nothing is deleted." + ), + request=RetirementBatchSerializer, + responses={ + 200: _RESP_BATCH_OUTCOME, + 400: _RESP_AMBIGUOUS_OR_UNUSABLE_BODY, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 406: RESP_NOT_ACCEPTABLE, + 409: _RESP_BATCH_STATE, + 415: RESP_UNREADABLE_BODY, + }, + ), +) +class RetirementCleanupViewSet( + StandardizedErrorMixin, + RestrictedApplicationGuardMixin, + ReadableErrorDetailMixin, + ErrorExtensionsMixin, + StrictQueryParametersMixin, + _RetirementRecordAccessMixin, + ScopedQuerysetMixin, + _RetirementRecordsMixin, + viewsets.ViewSet, +): + """ + Disposes of a set of finished retirement records. + + The accounts to dispose of are named in the body, and every one of them has + to name a retirement the pipeline has finished with: if any does not, the + whole batch is refused and nothing is deleted. + + This is not how a retirement request is called off. That works on a request + the pipeline has not started on, is asked for one account at a time, and is + open to a different caller. + """ + + serializer_class = RetirementBatchOutcomeSerializer + #: Disposing of finished records is pipeline work, so it asks for the + #: capability the rest of that work asks for. + action_permissions = {"create": (CanRetireUser,), "metadata": (CanRetireUser,)} + + def get_serializer(self, *args, **kwargs): + """Build the output serializer; a plain ViewSet has no generic machinery to do it.""" + return self.serializer_class(*args, **kwargs) + + def create(self, request): + """Delete the retirement records of the accounts the body names.""" + batch = RetirementBatchSerializer(data=request.data) + batch.is_valid(raise_exception=True) + results = delete_retirements(self.get_queryset(), batch.validated_data["usernames"]) + return Response(self.get_serializer({"results": results}).data) + + +# Its own collection, as above; access and selection go through the partner +# report entries' layers. +@extend_schema(tags=[USER_TAG]) +@extend_schema_view( + create=extend_schema( + summary="Delete reported partner report entries", + description=( + "Deletes the partner report entries of the accounts named, once they have been " + "reported on. An entry nobody has taken for processing has not been reported on " + "yet, so it refuses the whole batch and nothing is deleted." + ), + request=RetirementBatchSerializer, + responses={ + 200: _RESP_BATCH_OUTCOME, + 400: _RESP_AMBIGUOUS_OR_UNUSABLE_BODY, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 406: RESP_NOT_ACCEPTABLE, + 409: _RESP_BATCH_STATE, + 415: RESP_UNREADABLE_BODY, + }, + ), +) +class RetirementPartnerReportCleanupViewSet( + StandardizedErrorMixin, + RestrictedApplicationGuardMixin, + ReadableErrorDetailMixin, + ErrorExtensionsMixin, + StrictQueryParametersMixin, + _PartnerReportAccessMixin, + ScopedQuerysetMixin, + _EveryPartnerReportMixin, + viewsets.ViewSet, +): + """ + Disposes of a set of partner report entries that have been reported on. + + The accounts whose entries are to be disposed of are named in the body, and + every one of them has to name an entry that was taken for reporting: an + entry nobody has taken has not been reported on yet, so it refuses the + whole batch and nothing is deleted. + """ + + serializer_class = RetirementBatchOutcomeSerializer + + def get_serializer(self, *args, **kwargs): + """Build the output serializer; a plain ViewSet has no generic machinery to do it.""" + return self.serializer_class(*args, **kwargs) + + def create(self, request): + """Delete the partner report entries of the accounts the body names.""" + batch = RetirementBatchSerializer(data=request.data) + batch.is_valid(raise_exception=True) + results = delete_partner_reports(self.get_queryset(), batch.validated_data["usernames"]) + return Response(self.get_serializer({"results": results}).data) diff --git a/openedx/core/djangolib/testing/tests/test_utils.py b/openedx/core/djangolib/testing/tests/test_utils.py index d5caf867c1ae..39119a47ecab 100644 --- a/openedx/core/djangolib/testing/tests/test_utils.py +++ b/openedx/core/djangolib/testing/tests/test_utils.py @@ -6,9 +6,9 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser from django.http.request import HttpRequest -from django.test import TestCase +from django.test import SimpleTestCase, TestCase -from ..utils import get_mock_request +from ..utils import get_mock_request, unresolved_references USER_MODEL = get_user_model() @@ -32,3 +32,25 @@ def test_user_is_attached_to_mock_request(self): def test_mock_request_without_user(self): request = get_mock_request() assert isinstance(request.user, AnonymousUser) + + +class TestUnresolvedReferences(SimpleTestCase): + """ + Tests for the check that every reference in an OpenAPI document names a component it defines. + """ + + def test_a_reference_to_a_missing_component_is_reported(self): + document = { + "paths": {"/x/": {"get": {"responses": {"200": {"$ref": "#/components/responses/Gone"}}}}}, + "components": {"schemas": { + "Kept": {"properties": {"a": {"allOf": [ + {"$ref": "#/components/schemas/Present"}, {"$ref": "#/components/schemas/Missing"}, + ]}}}, + "Present": {}, + }}, + } + assert unresolved_references(document) == ["#/components/responses/Gone", "#/components/schemas/Missing"] + + def test_a_document_whose_references_all_resolve_reports_none(self): + document = {"components": {"schemas": {"A": {"items": {"$ref": "#/components/schemas/B"}}, "B": {}}}} + assert not unresolved_references(document) diff --git a/openedx/core/djangolib/testing/utils.py b/openedx/core/djangolib/testing/utils.py index 3b263c2b1a53..009460605439 100644 --- a/openedx/core/djangolib/testing/utils.py +++ b/openedx/core/djangolib/testing/utils.py @@ -313,3 +313,32 @@ def skip_unless_lms(func): else: is_lms = True return skipUnless(is_lms, 'Test only valid in LMS')(func) + + +def unresolved_references(document): + """ + Return every ``$ref`` in an OpenAPI document that names a component the document lacks. + + The generator defines a component for each reference it writes, but a + component appended through the settings is copied in as written, and a + reference inside it resolves only where the document defines its target. + A client generator refuses a document with a reference left unresolved. + """ + prefix = "#/components/" + components = document.get("components", {}) + references = set() + pending = [document] + while pending: + node = pending.pop() + if isinstance(node, dict): + if isinstance(node.get("$ref"), str): + references.add(node["$ref"]) + pending.extend(node.values()) + elif isinstance(node, list): + pending.extend(node) + unresolved = [] + for reference in sorted(references): + kind, _, name = reference.removeprefix(prefix).partition("/") + if not reference.startswith(prefix) or name not in components.get(kind, {}): + unresolved.append(reference) + return unresolved diff --git a/openedx/core/tests/test_apidocs.py b/openedx/core/tests/test_apidocs.py index 4d7d79a49eb2..20a1a549bad5 100644 --- a/openedx/core/tests/test_apidocs.py +++ b/openedx/core/tests/test_apidocs.py @@ -48,9 +48,15 @@ class UntagDeprecatedOperationsRegistrationTest(SimpleTestCase): "cms/envs/common.py": "'cms.lib.spectacular.cms_mark_migrated_paths',", "lms/envs/common.py": "'lms.lib.spectacular.lms_mark_legacy_paths_deprecated',", } + SHARED_HOOK = "'openedx.core.djangoapps.user_api.schema.mark_superseded_user_paths'," def test_the_hook_runs_last(self): for module, marking_hook in self.MARKING_HOOKS.items(): source = (REPO_ROOT / module).read_text() assert HOOK in source, module assert source.index(marking_hook) < source.index(HOOK), module + + def test_the_hook_runs_after_the_hook_marking_the_superseded_user_addresses(self): + source = (REPO_ROOT / "lms/envs/common.py").read_text() + assert self.SHARED_HOOK in source + assert source.index(self.SHARED_HOOK) < source.index(HOOK)