diff --git a/lms/envs/common.py b/lms/envs/common.py index 1fc662d26e15..e01819bc7581 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2157,22 +2157,21 @@ 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', ], # 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..87c31c7aea80 100644 --- a/lms/lib/spectacular.py +++ b/lms/lib/spectacular.py @@ -1,8 +1,11 @@ """Helper functions for drf-spectacular (LMS schema).""" import re +from collections import namedtuple +from functools import cache from drf_spectacular.openapi import AutoSchema +from drf_spectacular.utils import OpenApiResponse LEGACY_V2_PREFIX = "/api/enrollment/v2/" @@ -11,21 +14,175 @@ # addresses already have distinct ids and keep them. COLLIDING_LEGACY_PATH = "/api/enrollment/v2/enrollments" +# Addresses listed here are superseded by a conforming address this schema also +# publishes. Each entry is spelled out rather than matched by prefix, so an +# address whose replacement has not been written yet 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/{pref_key}/users/", + "/api/user/v1/forum_roles/{name}/users/", + "/api/user/v1/preferences/time_zones/", +) + +# A path parameter leaves no token behind, and the version 1 account collection +# has no trailing slash to tell it from the member address, so the two tokenize +# to one operation id and are told apart only by a numeral handed out in +# registration order. The collection answers with a list of accounts, which the +# generator names for a list, while the document that trims nothing off the +# front of each path has always published that address under the name derived +# for a single account -- the name that leaves the member address the numeral it +# carries there -- so that one is pinned. The key carries the id the generator +# derives, so an entry applies only to the document that derives it; a key no +# document derives would never be reached. +RENAMED_OPERATION_IDS = { + ("/api/user/v1/accounts", "GET", "api_user_v1_accounts_list"): "api_user_v1_accounts_retrieve", +} + +PUBLISHED_PATH_PATTERN = re.compile(r"^/api/enrollment/v\d+/") + +# The user addresses this schema documents are the ones the version below +# serves, and beside each of those the address it supersedes. Apps older than +# that version are mounted under the same ``/api/user/v2/`` prefix, so matching +# the prefix alone would publish endpoints described nowhere here; the packages +# a view may come from decide instead. One resource of this version lives in +# the app that owns it rather than in the user API's own package. +PUBLISHED_USER_PREFIX = "/api/user/v2/" +PUBLISHED_USER_VIEW_MODULES = ( + "openedx.core.djangoapps.user_api.v2.", + "openedx.core.djangoapps.profile_images.v2.", +) + +# Every user address, whatever its version, belongs to one tag group. A tag is +# the first path segment left once the configured prefix is removed, which in a +# document that trims ``/api/user`` is the version, so user operations would be +# grouped with the enrollment operations that carry the same version. A document +# that trims nothing already derives a tag naming an area, and regrouping its +# operations would move them in every client generated from it. +USER_PATH_PREFIX = "/api/user/" +USER_TAG = "user" + +_PATH_PARAMETER = re.compile(r"\{([\w-]+)\}") +_VERSION_TAG = re.compile(r"v\d+") + +# The superseded addresses with their parameter names removed. The generator +# renames a ``{pk}`` parameter to ``{id}`` only after this filter has run, so +# the comparison cannot depend on how a parameter is spelled. +_SUPERSEDED_USER_SHAPES = frozenset(_PATH_PARAMETER.sub("{}", path) for path in SUPERSEDED_USER_PATHS) + + +#: What one superseded operation reads and what it answers with on success. +_SupersededBodies = namedtuple("_SupersededBodies", ("request", "response")) + + +@cache +def superseded_bodies(): + """ + Return the bodies of each superseded operation, keyed by address and method. + + The views serving those addresses name no serializer, so an address + published beside the one that replaces it would carry nothing on either + side of the exchange. Each entry below names the shape the handler reads or + returns with the serializer describing that same shape at the replacing + address, which the parity tests of this area hold to the same values; a + request of ``None`` is an operation that reads no body. An address whose + view names a serializer of its own is absent from the table and keeps the + component it has always published. + + A request is keyed by media type, because a shape named on its own is + published under every type the view's parsers accept: where that is more + types than the handler reads, a client generated from the document would + pick one it refuses, and where the handler reads a body no serializer + describes, such as an image sent as the whole body, the document would + leave that form out. An address whose body is also read at the address + replacing it takes the same mapping from there, so the two cannot drift. + """ + # The serializers read models, which are not loaded when this module is + # first imported. + # pylint: disable=import-outside-toplevel + from openedx.core.djangoapps.profile_images.v2.serializers import IMAGE_UPLOAD_REQUEST + from openedx.core.djangoapps.user_api.v2.serializers import ( + AccountSerializer, + AccountUpdateSerializer, + EmailSearchResultSerializer, + EmailSearchSerializer, + NameChangeSerializer, + VerificationStatusSerializer, + ) + from openedx.core.lib.api.parsers import MergePatchParser + + account = {200: AccountSerializer} + described = { + ("/api/user/v1/accounts", "GET"): _SupersededBodies(None, {200: AccountSerializer(many=True)}), + ("/api/user/v1/accounts/{username}", "GET"): _SupersededBodies(None, account), + ("/api/user/v1/accounts/{username}", "PATCH"): _SupersededBodies( + {MergePatchParser.media_type: AccountUpdateSerializer}, account, + ), + ("/api/user/v1/accounts/search_emails", "POST"): _SupersededBodies( + EmailSearchSerializer, {200: EmailSearchResultSerializer(many=True)}, + ), + ("/api/user/v1/accounts/{username}/deactivate/", "POST"): _SupersededBodies(None, account), + ("/api/user/v1/accounts/name_change/", "POST"): _SupersededBodies( + NameChangeSerializer, + {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: VerificationStatusSerializer}, + ), + } + # 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 _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 _PATH_PARAMETER.sub("{}", path) in _SUPERSEDED_USER_SHAPES: + 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 @@ -33,26 +190,75 @@ def lms_api_filter(endpoints): class LmsAutoSchema(AutoSchema): """ - Give the colliding legacy Enrollment v2 address a distinct operationId. + Keep colliding addresses distinct, group each operation under its own area, + and name the bodies of the addresses this schema publishes as superseded, + marking a body the operation cannot be called without as one it needs. - It and its conforming twin tokenize to the same operationId, so - 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``. + The slashless Enrollment v2 address tokenizes to the id of its conforming + twin, so the two would be told apart only by a numeral suffix handed out + in registration order, which says nothing about the operation and moves + when the routes move; it is suffixed ``_legacy`` instead, leaving the + conforming address the clean id, and the address listed above keeps the + name it is already published under. Every other address keeps the id and + the tag the generator derives for it, because a client generated from a + document takes a module from each operationId and groups it by tag, so + renaming either drops what a consumer already imports. """ def get_operation_id(self): - """Suffix the colliding legacy address so the pair never shares an operationId.""" + """Keep each address that tokenizes to another one's id distinct from it.""" operation_id = super().get_operation_id() + renamed = RENAMED_OPERATION_IDS.get((self.path, self.method, operation_id)) + if renamed is not None: + return renamed if self.path == COLLIDING_LEGACY_PATH: 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 _get_request_body(self, direction="request"): + """ + Publish a body as one the operation needs when any form it may be sent in is. + + An operation that names the media types it reads loses that mark as + soon as one of them is the bare body of a file, which carries no field + that can be called required. The forms are alternatives rather than + parts of one request, so a body that is required in any of them is a + body the operation cannot be called without. + """ + body = super()._get_request_body(direction) + shapes = self.get_request_serializer() + if not body or body.get("required") or not isinstance(shapes, dict): + return body + if any(self._get_request_for_media_type(shape, direction)[1] for shape in shapes.values()): + body["required"] = True + return body + + 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((_PATH_PARAMETER.sub("{}", self.path), self.method)) + def lms_mark_legacy_paths_deprecated(result, generator, request, public): # pylint: disable=unused-argument - """Mark the legacy slashless Enrollment v2 addresses ``deprecated: true``.""" + """Mark every superseded address ``deprecated: true``.""" for path, path_item in result.get("paths", {}).items(): - if not _is_legacy_path(path): + if not (_is_legacy_path(path) or path in SUPERSEDED_USER_PATHS): continue for operation in path_item.values(): if isinstance(operation, dict): diff --git a/lms/lib/tests/test_spectacular.py b/lms/lib/tests/test_spectacular.py index b611c8ad7776..90d7f9a2a382 100644 --- a/lms/lib/tests/test_spectacular.py +++ b/lms/lib/tests/test_spectacular.py @@ -1,20 +1,42 @@ """Tests for the LMS drf-spectacular hooks and schema class.""" +import re +from collections import defaultdict +from types import SimpleNamespace from unittest import mock -from django.test import SimpleTestCase +from django.test import SimpleTestCase, TestCase from django.urls import path 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 lms.lib.spectacular import LmsAutoSchema, lms_api_filter, lms_mark_legacy_paths_deprecated +from lms.lib.spectacular import ( + RENAMED_OPERATION_IDS, + SUPERSEDED_USER_PATHS, + LmsAutoSchema, + lms_api_filter, + lms_mark_legacy_paths_deprecated, + superseded_bodies, +) +from openedx.core.apidocs import get_api_docs_settings +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" +COURSE_VIEWS = "lms.djangoapps.course_api.views" -def _endpoint(route): - return (route, route, "GET", None) + +def _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): @@ -22,19 +44,57 @@ def _schema(*routes): class LmsApiFilterTest(SimpleTestCase): - """The pre-processing hook keeps only enrollment endpoints.""" + """The hook keeps the enrollment mounts, this version of the user mounts, and what they supersede.""" - def test_keeps_enrollment_drops_the_rest(self): + def test_keeps_enrollment_this_version_and_the_addresses_it_supersedes(self): kept = lms_api_filter([ - _endpoint("/api/enrollment/v2/enrollments/"), - _endpoint("/api/enrollment/v1/enrollment"), - _endpoint("/api/user/v1/accounts/"), + _endpoint("/api/enrollment/v2/enrollments/", ENROLLMENT_VIEWS), + _endpoint("/api/enrollment/v1/enrollment", ENROLLMENT_VIEWS), + _endpoint("/api/user/v2/users/", USER_API_V2_VIEWS), + _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. + _endpoint("/api/user/v1/users/{pk}/", USER_API_V1_VIEWS), + _endpoint("/api/user/v1/preferences/time_zones/", USER_API_V1_VIEWS), + _endpoint("/api/user/v1/accounts", USER_API_V1_VIEWS), + _endpoint("/api/user/v1/accounts/{username}", USER_API_V1_VIEWS), + _endpoint("/api/user/v1/me", USER_API_V1_VIEWS), + _endpoint("/api/user/v1/accounts/{username}/image", USER_API_V1_VIEWS), + _endpoint("/api/user/v1/accounts/name_change/", USER_API_V1_VIEWS), + # Served by this version from the app that owns the resource. + _endpoint("/api/user/v2/accounts/{username}/profile_image/", PROFILE_IMAGE_V2_VIEWS), + # Neither served nor superseded by this version. + _endpoint("/api/user/v2/account/registration/", USER_AUTHN_VIEWS), + _endpoint("/api/profile_images/v1/{username}/upload", PROFILE_IMAGE_V1_VIEWS), + _endpoint("/api/user/v1/preferences/{username}", USER_API_V1_VIEWS), + _endpoint("/api/courses/v1/courses/", COURSE_VIEWS), + _endpoint("/api/user_tours/v1/tours/"), + _endpoint("/user_api/v1/users/", 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/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([_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.""" @@ -54,6 +114,53 @@ def test_only_slashless_v2_paths_are_deprecated(self): assert "deprecated" not in paths["/api/enrollment/v2/courses/{course_key}/"]["get"] assert "deprecated" not in paths["/api/enrollment/v1/enrollment"]["get"] + def test_only_the_listed_user_addresses_are_deprecated(self): + result = lms_mark_legacy_paths_deprecated(_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/profile_images/v1/{username}/upload", + "/api/user/v2/users/", + "/api/user/v2/accounts/{username}/profile_image/", + "/user_api/v1/users/", + "/user_api/v1/accounts/{username}", + ), 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/", + ): + assert paths[marked]["get"]["deprecated"] is True, marked + for untouched in ( + "/api/user/v1/preferences/{username}", + "/api/profile_images/v1/{username}/upload", + "/api/user/v2/users/", + "/api/user/v2/accounts/{username}/profile_image/", + "/user_api/v1/users/", + "/user_api/v1/accounts/{username}", + ): + assert "deprecated" not in paths[untouched]["get"], untouched + def test_paths_are_left_as_full_urls(self): """Paths are not trimmed, so every key resolves against LMS_ROOT_URL.""" result = lms_mark_legacy_paths_deprecated(_schema("/api/enrollment/v2/enrollments/"), None, None, False) @@ -99,3 +206,222 @@ 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"})), + path("api/user/v2/users/", view.as_view({"get": "list"})), + 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.""" + with mock.patch.dict("lms.lib.spectacular.RENAMED_OPERATION_IDS", {}, clear=True): + return _named_operations(schema_class, patches) + + +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 { + route: (operation["operationId"], tuple(operation["tags"])) + for route, path_item in schema["paths"].items() + for operation in path_item.values() + } + + +class UserOperationIdTest(SimpleTestCase): + """User addresses keep the names the generator derives; only their tag group is chosen here.""" + + def test_the_service_wide_document_keeps_every_name_the_generator_derives(self): + """Each of these is the name that document already published, and a client imports it by name.""" + 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/v2/users/": ("api_user_v2_users_list", ("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_2", ("user",)), + "/api/user/v1/users/": ("v1_users_list", ("user",)), + "/api/user/v2/users/": ("v2_users_list", ("user",)), + "/api/enrollment/v1/enrollments/": ("v1_enrollments_list", ("v1",)), + "/user_api/v1/preferences/{username}": ("user_api_v1_preferences_retrieve", ("user_api",)), + } + + +#: 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"}), +} + + +def _named_body(response): + """Return the component a response carries, suffixed ``[]`` for an array, or ``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]}[]" + return body["$ref"].rsplit("/", 1)[-1] + + +class SupersededBodyTest(TestCase): + """Every address published as superseded carries the bodies it exchanges.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.schema = SchemaGenerator().get_schema(request=None, public=True) + + 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 == [] + + +class PublishedDocumentTest(TestCase): + """What the service actually publishes: distinct ids, and one area per tag group.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.schema = SchemaGenerator().get_schema(request=None, public=True) + + @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_only_the_one_enrollment_pair_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"]) + } + # Renaming this one would change an id the document already published, so it stays. + assert suffixed == {"/api/enrollment/v1/enrollment/{course_id}": "v1_enrollment_retrieve_2"} + + 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()} == { + "v1": ["enrollment"], + "openedx-platform-sdk": ["enrollment", "user"], + "user": ["user"], + } diff --git a/lms/urls.py b/lms/urls.py index 35a1b6cdabcd..82526e9a83ef 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 4c96c8cce109..acf4de94ce16 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..3973feb31f16 --- /dev/null +++ b/openedx/core/djangoapps/profile_images/v2/tests/test_views.py @@ -0,0 +1,905 @@ +"""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.core.files.uploadedfile import SimpleUploadedFile +from django.urls import reverse +from edx_rest_framework_extensions.testing import assert_error_envelope +from PIL import Image +from rest_framework import status +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" + +#: 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_declaring_more_pixels_than_it_holds(self, dimensions=(40000, 40000)): + """ + Return a PNG of a few hundred bytes whose header declares a vast picture. + + A PNG records its dimensions in its header and its pixels in a later + chunk, so a file that declares far more pixels than it carries is + refused on the declaration alone, without anything being decoded. + """ + def chunk(kind, payload): + return ( + 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 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", + ) + assert envelope["title"] == "Malformed Request Body" + 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", + ) + assert envelope["title"] == "Malformed Request Body" + 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_by_the_class_the_platform_declares(self): + """ + Only the session class is replaced here, so a token is read by the platform's own. + + That class records every request it reads a token for, which is how an + operator finds the addresses that authenticate the way the platform + declares rather than by a list of their own. + """ + 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 [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", + ) + 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="method-not-allowed", + ) + + +@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() + + +@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..2bda5f6923f8 --- /dev/null +++ b/openedx/core/djangoapps/profile_images/v2/views.py @@ -0,0 +1,255 @@ +"""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.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 openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_names, set_has_profile_image +from openedx.core.djangoapps.user_api.errors import UserNotFound +from openedx.core.djangoapps.user_api.v2.errors import CatalogedRequestErrorsMixin +from openedx.core.djangoapps.user_api.v2.permissions import IsAddressedUser +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, + StrictQueryParametersMixin, +) +from openedx.core.djangolib.default_auth_classes import DefaultJwtAuthentication +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__) + +_USERNAME_PATH_PARAM = OpenApiParameter( + name="username", + 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." + ), + required=True, + location=OpenApiParameter.PATH, + type=str, +) + +_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) + + +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, + CatalogedRequestErrorsMixin, + StrictQueryParametersMixin, + APIView, +): + """ + The image shown beside a user's name, in each size the site renders. + + An account has one image, set and cleared whole and never listed or + addressed by an identifier of its own, so it is served from this single + address rather than routed as a collection. A user sets and clears the + image on their own account, and nobody else's: staff included, since + clearing an image is an account change rather than a moderation tool. That + is not what the account collection admits, which is the other reason this + is a view of its own rather than an action of that collection. The sizes + are stored under the username the address carries. The addresses those + stored sizes are served from belong to the account resource rather than to + this one. + """ + + # The site's own pages let a user who has not confirmed their email + # address set a picture, and the platform default refuses a session + # belonging to such an account. Only that one class is replaced: the token + # class stays the one the platform configures, which is what records a + # request as having been authenticated the default way. + authentication_classes = (DefaultJwtAuthentication, 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, + 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(username, 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, + }, + ) + def delete(self, request, username): + """Remove the stored sizes of the user's image.""" + self._record_image(username, 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, so a file whose bytes stop early, whose metadata does + not parse, or whose header declares a picture far larger than any this + site draws fails partway through that work. Reading it here 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: + piexif.load(metadata) + 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, username, is_uploaded): + """Record on the account whether it carries an image of its own.""" + uploaded_at = datetime.datetime.now(ZoneInfo("UTC")) if is_uploaded else None + try: + set_has_profile_image(username, is_uploaded, uploaded_at) + except UserNotFound as exc: + raise NotFound from exc + + 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/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..bbd8b5ab2b60 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/errors.py @@ -0,0 +1,96 @@ +"""Error types this version of the User API classifies for itself.""" + +from edx_rest_framework_extensions.errors import register_error_type +from rest_framework.exceptions import ( + MethodNotAllowed, + NotAcceptable, + NotFound, + ParseError, + UnsupportedMediaType, +) + + +class RefusedMethod(MethodNotAllowed): + """A request method this API does not serve at the address it was sent to.""" + + +class UnsatisfiableAccept(NotAcceptable): + """An ``Accept`` header naming no representation this API can produce.""" + + +class UnreadableRepresentation(UnsupportedMediaType): + """A request body in a media type this API cannot read.""" + + +class MalformedBody(ParseError): + """A request body that is not well formed in the media type it was sent as.""" + + +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, + } + + +register_error_type(RefusedMethod, "method-not-allowed", "Method Not Allowed") +register_error_type(UnsatisfiableAccept, "not-acceptable", "Not Acceptable") +register_error_type(UnreadableRepresentation, "unsupported-media-type", "Unsupported Media Type") +register_error_type(MalformedBody, "malformed-body", "Malformed Request Body") +register_error_type(RetiredAccount, "user/retired-account", "Retired Account") + + +class CatalogedRequestErrorsMixin: + """ + Names the error type of a refused method, an unanswerable ``Accept`` + header, a body in a media type this API cannot read and a body that is not + well formed in the media type it was sent as. + + The shared catalog classifies none of them, so each would reach the caller + described as a server fault at a 4xx status. Raising this API's own + subclasses of them names each error without changing how the same failure + is classified for any other API in the process; once the shared catalog + covers them, the subclasses can give way to it. + """ + + def handle_exception(self, exc): + """Classify the request failures the shared catalog leaves unnamed.""" + if isinstance(exc, MethodNotAllowed) and not isinstance(exc, RefusedMethod): + exc = RefusedMethod(self.request.method, detail=exc.detail) + elif isinstance(exc, NotAcceptable) and not isinstance(exc, UnsatisfiableAccept): + exc = UnsatisfiableAccept(detail=exc.detail) + elif isinstance(exc, UnsupportedMediaType) and not isinstance(exc, UnreadableRepresentation): + exc = UnreadableRepresentation(self.request.content_type, detail=exc.detail) + elif isinstance(exc, ParseError) and not isinstance(exc, MalformedBody): + exc = MalformedBody(detail=exc.detail) + return super().handle_exception(exc) + + +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..741aaee5fc12 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/filters.py @@ -0,0 +1,178 @@ +"""Filter sets for version 2 of the User API.""" + +import logging + +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 UserPreference + +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 _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(help_text="Return the user with exactly this username.") + 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_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", + help_text="Return only preferences belonging to this user.", + ) + + class Meta: + model = UserPreference + fields = ("key", "username") 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..5e796180d3cb --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/paginators.py @@ -0,0 +1,57 @@ +"""Pagination for version 2 of the User API.""" + +from edx_rest_framework_extensions.paginators import DefaultPagination + + +class UserApiPagination(DefaultPagination): + """ + Page-number pagination whose published schema matches the body it returns. + + ``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. + This subclass only restates the response schema; the body is unchanged. + """ + + def get_paginated_response_schema(self, schema): + """Describe the full envelope the paginator returns.""" + return { + "type": "object", + "required": ["count", "num_pages", "current_page", "start", "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..33fd8d8c75a3 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/permissions.py @@ -0,0 +1,79 @@ +"""Permission classes for version 2 of the User API.""" + +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 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..096a7d6eed46 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/selectors.py @@ -0,0 +1,70 @@ +"""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 openedx.core.djangoapps.user_api.models import 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 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 username_holding_email(email): + """Return the name of the account holding this address, or ``None`` when no account holds it.""" + try: + return User.objects.get(email=email).username + except User.DoesNotExist: + return None + + +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. + """ + 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() + ) 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..c90d28676782 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/serializers.py @@ -0,0 +1,788 @@ +"""Serializers for version 2 of the User API.""" + + +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 lms.djangoapps.verify_student.models import ManualVerification, SoftwareSecurePhotoVerification +from openedx.core.djangoapps.user_api.models import UserPreference +from openedx.core.djangoapps.user_authn.views.registration_form import contains_html, contains_url + +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") + + +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; 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.", + ) + + 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 only when the caller asked for the full preset.""" + if not full_view_requested(self.context.get("request")): + 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, + 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, + 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), + 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.""" + + name = serializers.CharField( + max_length=NAME_MAX_LENGTH, + help_text="Full name to put on the profile once the request is confirmed.", + ) + + def validate_name(self, name): + """Refuse a name carrying markup or a web address.""" + 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." + ), + ) 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..54560629d99a --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_account_parity.py @@ -0,0 +1,574 @@ +"""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.djangolib.testing.utils import skip_unless_lms + +from .test_parity import diff, unexpected + +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", + ) + + 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_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_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..4f89185ca2f9 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py @@ -0,0 +1,2579 @@ +"""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.testing import assert_error_envelope +from rest_framework import status +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.permissions import ACCOUNT_CONTACT_SELECTORS +from openedx.core.djangoapps.user_api.v2.serializers import ACCOUNT_DEFAULT_SORT, AccountQuerySerializer +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 PAGINATION_KEYS, write_queries, write_target + +PASSWORD = "Password1234" + +#: The properties every caller may see, whoever they are. +PUBLIC_FIELDS = {"account_privacy", "profile_image", "username"} + +#: The properties a user sees of their own account, and a staff caller of any. +FULL_FIELDS = set(settings.ACCOUNT_VISIBILITY_CONFIGURATION["admin_fields"]) + +#: The properties a learner sees of an account whose owner shares it with everybody. +SHARED_FIELDS = set(settings.ACCOUNT_VISIBILITY_CONFIGURATION["bulk_shareable_fields"]) + +#: The properties an owner is able to share, and so the only ones another caller +#: may select; the refusal offers them in this order. +SHAREABLE_FIELDS = set(settings.ACCOUNT_VISIBILITY_CONFIGURATION["custom_shareable_fields"]) +SHAREABLE_FIELDS_OFFERED = ", ".join(sorted(SHAREABLE_FIELDS)) + +#: The identifying properties the ``minimal`` preset keeps, and the ones of those +#: another caller may select; the refusal offers them in this order. +MINIMAL_FIELDS = {"username", "email", "name", "profile_image"} +SHAREABLE_MINIMAL_FIELDS_OFFERED = ", ".join(sorted(SHAREABLE_FIELDS & MINIMAL_FIELDS)) + +#: 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" + +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_names_the_same_account_throughout(self): + """ + The name is read the same way when the answer is shaped and when the page is cut. + + What a caller may select depends on whose accounts the page carries, + and spaces around the name they sent do not make their own account + somebody else's for that purpose. + """ + self.login(self.owner) + for asked in ("owner-caller", " owner-caller", "owner-caller "): + with self.subTest(username=asked): + response = self.client.get(self.list_url, {"username": asked, "fields": "email"}) + assert response.status_code == status.HTTP_200_OK, response.content + assert response.data["results"] == [{"email": "owner-caller@example.com"}] + + 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_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"] == [ + f"No such field: email. Select any of: {SHAREABLE_MINIMAL_FIELDS_OFFERED}.", + ] + + 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"] == [ + f"No such field: email. Select any of: {SHAREABLE_FIELDS_OFFERED}.", + ] + + 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_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_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_spelled_in_another_case_carries_the_account_it_names(self): + """ + A database that holds usernames case-insensitively answers an address spelled differently. + + Whether two names differing only in case name one account is the + database's decision, and the collation MySQL applies to these columns + by default says they do, so an account can come back stored under a + spelling other than the one the address carried. The test database + compares the names exactly, so the account service is answered here as + a database holding them case-insensitively answers it. + """ + 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( + "openedx.core.djangoapps.user_api.v2.views.get_account_settings", + 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_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"] == [ + f"No such field: email. Select any of: {SHAREABLE_MINIMAL_FIELDS_OFFERED}.", + ] + + 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"] == [ + f"No such field: email. Select any of: {SHAREABLE_FIELDS_OFFERED}.", + ] + + 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_by_the_class_the_platform_declares(self): + """ + Only the session class is replaced here, so a token is read by the platform's own. + + That class records every request it reads a token for, which is how an + operator finds the addresses that authenticate the way the platform + declares rather than by a list of their own. + """ + 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 [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_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", + ) + + 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", + ) + assert envelope["title"] == "Malformed Request Body" + + 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", + ) + # The envelope carries one line per property, so a refusal from inside a + # list arrives as the text of the refusal rather than as its own object. + assert list(envelope["errors"]) == ["social_links"] + assert "social_link" in envelope["errors"]["social_links"][0] + assert "This field is required." in envelope["errors"]["social_links"][0] + 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): + """ + What another API calls a request failure is not changed by what this one calls it. + + The catalog of error types is shared by every API in the process, so naming + one of the failures below for this API by registering the type it is raised + as would rename it for all of them. The names used here belong to this + API's own exception classes; the same two failures are read below at a + standardized address of another API, which keeps the name the shared + catalog gives a failure it does not know. + """ + + #: 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_named_at_an_address_of_this_api(self): + """The contrast the two above are measured against.""" + malformed = self.client.patch(self.own_url, "{not json", content_type=MERGE_PATCH) + assert_error_envelope( + malformed, + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="malformed-body", + ) + unreadable = self.client.patch(self.own_url, "bio=x", content_type="text/plain") + assert_error_envelope( + unreadable, + expected_status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + expected_type_slug="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_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", + ) + assert envelope["title"] == "Malformed Request Body" + + 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_identifies_nobody_here(self): + """ + Searching other people's accounts is not done from the pages an unconfirmed account reaches. + + The account addresses accept such a session because the site's own + settings pages are reached with one; this address keeps the platform + default, under which it identifies nobody, 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_401_UNAUTHORIZED, + expected_type_slug="authn", + ) + own = self.client.get(self.own_url) + assert own.status_code == status.HTTP_200_OK + assert own.data["username"] == "staff-caller" + + 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="method-not-allowed", + ) + + 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="method-not-allowed", + ) + + 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 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="method-not-allowed", + ) + 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.""" 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..8ff088720cf2 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_name_change_views.py @@ -0,0 +1,567 @@ +"""Tests for the version 2 User API profile name change views.""" + +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.djangolib.testing.utils import skip_unless_lms + +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", + ) + assert envelope["title"] == "Malformed Request Body" + 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", + ) + 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="method-not-allowed", + ) + + +@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="method-not-allowed", + ) + 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 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_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_parity.py b/openedx/core/djangoapps/user_api/v2/tests/test_parity.py new file mode 100644 index 000000000000..38a75fe6bd13 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_parity.py @@ -0,0 +1,374 @@ +"""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 as the administrator it is gated by.""" + assert self.client.login(username=self.admin.username, password=PASSWORD) + response = self.client.get(address, params or {}) + self.client.logout() + return response + + 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 test_the_api_key_admits_at_the_legacy_addresses_only(self): + """The header the version 1 addresses are gated by is not a credential at version 2.""" + legacy = self.legacy("/api/user/v1/users/", {"page_size": 100}) + assert legacy.status_code == status.HTTP_200_OK, legacy.content + assert sorted(row["username"] for row in json.loads(legacy.content)["results"]) == [ + "parity-admin", "parity-user", + ] + + for address in ( + reverse("user_v2:user_list"), + reverse("user_v2:user_detail", kwargs={"username": "parity-user"}), + reverse("user_v2:user_preference_list"), + reverse( + "user_v2:user_preference_detail", + kwargs={"username": "parity-user", "key": "pref-lang"}, + ), + ): + refused = self.client.get(address, HTTP_X_EDX_API_KEY=API_KEY) + envelope = assert_error_envelope( + refused, expected_status=status.HTTP_401_UNAUTHORIZED, expected_type_slug="authn", + ) + assert envelope["detail"] == "Authentication credentials were not provided.", address + + 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 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..e8cc522fa8f1 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_query_counts.py @@ -0,0 +1,178 @@ +"""Query-count guards for the version 2 User API list and member addresses.""" + +import pytest +from django.test.utils import override_settings +from django.urls import reverse + +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" +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 = 16 +USER_DETAIL_QUERIES = 15 +PREFERENCE_LIST_QUERIES = 15 +PREFERENCE_LIST_FULL_VIEW_QUERIES = 16 +PREFERENCE_DETAIL_QUERIES = 14 +TIME_ZONE_LIST_QUERIES = 13 + +#: Address of the stored-preference list this version replaces, and the key it +#: is gated by, so the cost of the two can be compared in one measurement. +EARLIER_PREFERENCE_LIST = "/api/user/v1/user_prefs/" +EARLIER_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, django_user_model): # pylint: disable=unused-argument + """Sign an administrator in and populate the directory with ``request.param`` users.""" + admin = AdminFactory.create(username="count-admin", password=PASSWORD) + 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) + assert client.login(username=admin.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: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) + 1 + + +@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", 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) + + client.logout() + with override_settings(EDX_API_KEY=EARLIER_API_KEY): + with django_assert_num_queries(EARLIER_PREFERENCE_LIST_QUERIES[len(directory)]): + earlier = client.get( + EARLIER_PREFERENCE_LIST, + {"page_size": 100}, + HTTP_X_EDX_API_KEY=EARLIER_API_KEY, + ) + assert len(earlier.json()["results"]) == 2 * len(directory) 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..75a5acd412c6 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py @@ -0,0 +1,1728 @@ +"""Tests for what version 2 of the User API publishes in the OpenAPI schema.""" + +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.settings import patched_settings +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 ( + RENAMED_OPERATION_IDS, + SUPERSEDED_USER_PATHS, + 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.tests.factories import UserPreferenceFactory +from openedx.core.djangoapps.user_api.v2.serializers import ( + ACCOUNT_DEFAULT_SORT, + ACCOUNT_MINIMAL_FIELDS, + EXPIRATION_DAY_PATTERN, + AccountQuerySerializer, + TimeZoneQuerySerializer, + VerificationQuerySerializer, +) +from openedx.core.djangoapps.user_api.v2.views import PREFERENCE_KEY_PATTERN_PARTIAL +from openedx.core.djangolib.testing.utils import skip_unless_lms + +PASSWORD = "Password1234" + +#: 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/" +NAME_CHANGE_PATH = "/api/user/v2/name_changes/" +NAME_CHANGE_CONFIRMATION_PATH = "/api/user/v2/name_changes/{username}/confirmations/" + +USER_MEMBER_PATH = "/api/user/v2/users/{username}/" +PREFERENCE_MEMBER_PATH = "/api/user/v2/user_preferences/{username},{key}/" +TIME_ZONE_LIST_PATH = "/api/user/v2/time_zones/" + +#: 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, + "/api/user/v2/users/", + "/api/user/v2/user_preferences/", + TIME_ZONE_LIST_PATH, +} +MEMBER_PATHS = { + ACCOUNT_MEMBER_PATH, + VERIFICATION_STATUS_PATH, + USER_MEMBER_PATH, + PREFERENCE_MEMBER_PATH, +} + +#: The addresses that act on a resource rather than answer with one. +ACTION_PATHS = { + EMAIL_SEARCH_PATH, + ACCOUNT_DEACTIVATION_PATH, + PROFILE_IMAGE_PATH, + NAME_CHANGE_PATH, + NAME_CHANGE_CONFIRMATION_PATH, +} + +#: The addresses a generated client library is built from. +SDK_PATHS = { + ACCOUNT_LIST_PATH, + ACCOUNT_MEMBER_PATH, + VERIFICATION_STATUS_PATH, + VERIFICATION_LIST_PATH, +} | ACTION_PATHS + +VERSION_TWO_PATHS = LIST_PATHS | MEMBER_PATHS | ACTION_PATHS + +PAGINATION_MEMBERS = {"count", "num_pages", "current_page", "start", "next", "previous", "results"} + +#: Where this version is mounted in the service's URL configuration. +VERSION_TWO_MOUNT = "api/user/v2/" + +#: The components this version contributes to the document. +VERSION_TWO_COMPONENTS = ( + "Account", + "AccountExtendedField", + "AccountLanguage", + "AccountMinimal", + "AccountProfileImage", + "AccountProjection", + "AccountResponse", + "AccountSocialLink", + "EmailSearch", + "EmailSearchResult", + "ErrorResponse", + "NameChange", + "PaginatedAccountResponseList", + "PaginatedEmailSearchResultList", + "PaginatedVerificationList", + "PatchedAccountUpdate", + "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", + ), + ("/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_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/{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/", +} + +#: Legacy enrollment addresses this schema already marked before the user API joined it. +SUPERSEDED_ENROLLMENT_PATHS = { + "/api/enrollment/v2/course/{course_id}", + "/api/enrollment/v2/enrollment/{course_id}", + "/api/enrollment/v2/enrollment/{username},{course_id}", + "/api/enrollment/v2/enrollments", +} + +#: Enrollment operations whose ids must survive the widened path prefix. +ENROLLMENT_OPERATION_IDS = { + "v1_enrollments_list", + "v1_enrollment_create", + "v2_enrollments_list", + "v2_enrollments_list_legacy", + "v2_enrollment_,_retrieve", + "v2_roles_retrieve", +} + + +@pytest.fixture(name="schema") +@skip_unless_lms +def schema_fixture(db): # pylint: disable=invalid-name, unused-argument + """The schema published under the service's own drf-spectacular settings.""" + return SchemaGenerator().get_schema(request=None, public=True) + + +def service_wide_schema(patterns=None): + """Return the document the service publishes for browsing its whole surface.""" + with patched_settings(get_api_docs_settings()): + return SchemaGenerator(patterns=patterns).get_schema(request=None, public=True) + + +def patterns_without_this_version(): + """Return the service's URL configuration with this version's mount taken out.""" + configured = import_module(settings.ROOT_URLCONF).urlpatterns + remaining = [entry for entry in configured if str(entry.pattern) != VERSION_TWO_MOUNT] + assert len(remaining) == len(configured) - 1, "this version is not mounted where it is looked for" + return remaining + + +def published_names(schema): + """Return every component name and operation id a document carries.""" + return set(schema["components"]["schemas"]) | { + operation["operationId"] for _path, _method, operation in operations(schema) + } + + +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_users_list": ["user"], + "v2_users_retrieve": ["user"], + "v2_user_preferences_list": ["user"], + "v2_user_preferences_retrieve": ["user"], + "v2_time_zones_list": ["user"], + } + + +@skip_unless_lms +def test_enrollment_operation_ids_survive_the_widened_prefix(schema): + published = {operation["operationId"] for _path, _method, operation in operations(schema)} + assert ENROLLMENT_OPERATION_IDS <= published + + +@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 == SUPERSEDED_ENROLLMENT_PATHS | 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 mounts this change adds, including one under its own prefix.""" + for path in ( + "/api/user/v1/accounts/deactivate_logout/", + "/api/user/v1/accounts/{username}/retirement_status/", + "/api/user/v1/preferences/{username}", + "/api/user/v1/account/registration/", + "/api/user/v2/account/registration/", + "/api/profile_images/v1/{username}/upload", + "/api/profile_images/v1/{username}/remove", + ): + assert path not in schema["paths"], path + + +@skip_unless_lms +def test_pagination_envelope_is_published_in_full(schema): + """Every paged body this version publishes carries the whole envelope, not DRF's four members.""" + paged = [name for name in VERSION_TWO_COMPONENTS if name.startswith("Paginated")] + assert len(paged) == 6 + for component in paged: + published = schema["components"]["schemas"][component] + assert set(published["properties"]) == PAGINATION_MEMBERS, component + + +@pytest.mark.django_db +@skip_unless_lms +def test_published_pagination_envelope_matches_the_response_body(client, schema): + admin = AdminFactory.create(username="schema-admin", password=PASSWORD) + UserPreferenceFactory.create(user=admin, key="pref-lang", value="en") + assert client.login(username=admin.username, password=PASSWORD) + body = client.get(reverse("user_v2:user_preference_list")).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/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_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:] + + +@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/v1/preferences/email_opt_in/", + "/api/user/v2/account/registration/", + "/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/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) + + 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", + }, +} + + +@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}" + requests = { + ("/api/user/v1/accounts/{username}", "patch"): (member, "{}"), + ("/api/user/v1/accounts/search_emails", "post"): ( + "/api/user/v1/accounts/search_emails", json.dumps({"emails": [staff.email]}), + ), + } + for (path, method), media_types in SUPERSEDED_REQUEST_MEDIA_TYPES.items(): + assert set(schema["paths"][path][method]["requestBody"]["content"]) == media_types, path + address, body = requests[(path, method)] + for media_type in media_types: + answer = getattr(client, method)(address, data=body, content_type=media_type) + assert answer.status_code == 200, (address, media_type, answer.content) + left_out = client.patch(member, data="{}", content_type="application/json") + assert left_out.status_code == 415 + + +@pytest.mark.django_db +@skip_unless_lms +def test_this_version_only_adds_to_the_service_wide_document(): + """ + Nothing published for browsing the whole service is lost when this version joins it. + + Enum post-processing runs over every operation the service publishes and + renames both sides of a name collision, so a component this version never + declared can be renamed out of the document, and a client generated from it + loses the matching model. The document is therefore read with this version + mounted and with its mount taken out, and the second has to be contained in + the first. + """ + without_this_version = published_names(service_wide_schema(patterns_without_this_version())) + whole = service_wide_schema() + missing = without_this_version - published_names(whole) + assert missing == set(), sorted(missing) + + components = whole["components"]["schemas"] + assert "user_api.User" in components + assert {"UserDirectory", "UserPreferenceRecord", "TimeZone"} <= set(components) + assert "CountryTimeZone" in components + + +@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): + """Every preset and selection the parameters offer returns a body the document describes.""" + 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) + 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"], + VERIFICATION_LIST_PATH: ["200", "400", "401", "403", "404"], + "/api/user/v2/users/": ["200", "400", "401", "403", "404"], + "/api/user/v2/user_preferences/": ["200", "400", "401", "403", "404"], + TIME_ZONE_LIST_PATH: ["200", "400", "404"], + } + + +@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." + ), + }, + } + + +@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", "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", + ] + + +@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", "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"] + 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): + """ + 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_schema()} + 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", "404", "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"] + + +#: 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", "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"] + 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"], + } 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..c8ea8a1ed190 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_selectors.py @@ -0,0 +1,106 @@ +"""Tests for the selections version 2 of the User API answers from.""" + +import datetime + +from django.conf import settings +from django.test import TestCase + +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.user_api.models import RetirementState, UserRetirementStatus +from openedx.core.djangoapps.user_api.v2.selectors import ( + accounts_holding_emails, + cancellable_retirement_for_email, + username_holding_email, + username_holding_id, + usernames_among, +) +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 username_holding_email("second@example.com") == "second-holder" + + def test_no_account_holds_an_unregistered_address(self): + assert username_holding_email("nobody@example.com") is None + + 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 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..019d3c535434 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_url_mount.py @@ -0,0 +1,68 @@ +"""Addresses of other apps that share the prefix version 2 of the User API is mounted on.""" + +from django.test import SimpleTestCase +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/" 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..7f14cd526cb2 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_urls.py @@ -0,0 +1,565 @@ +"""Tests for the version 2 User API URL surface.""" + +import re + +import pytest +from django.core.exceptions import ImproperlyConfigured +from django.urls import get_resolver, resolve, reverse +from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser +from edx_rest_framework_extensions.testing import assert_error_envelope +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 common.djangoapps.student.tests.factories import AdminFactory +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, + AccountViewSet, + IDVerificationStatusView, + IDVerificationViewSet, + NameChangeViewSet, + UserPreferenceViewSet, + UserViewSet, +) +from openedx.core.djangolib.testing.utils import skip_unless_lms + +PASSWORD = "Password1234" + +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" +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_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/"), +} + + +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/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"), + ): + 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/email_searches/": ("EmailSearchViewSet", {"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): + """ + An account and its picture are reached with the platform'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 left as the platform declares it, which is what records these + requests alongside every other request authenticated that way. + """ + platform_token = api_settings.DEFAULT_AUTHENTICATION_CLASSES[0] + for view_class in (AccountViewSet, ProfileImageView): + assert view_class.authentication_classes == ( + platform_token, 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): + match = resolve("/api/user/v2/user_preferences/al!ce,pref-lang/") + assert view_identity(match.func) == (V2_VIEW_MODULE, "UnknownAddressView") + + def test_no_database_id_member_address_is_served(self): + match = resolve("/api/user/v2/user_preferences/42/") + assert view_identity(match.func) == (V2_VIEW_MODULE, "UnknownAddressView") + + +@skip_unless_lms +class RouteShapeTests(APITestCase): + """ + Every address this version serves is named and ends in a slash. + + The mount also carries one pattern that is neither: the fallback matched + after all of them, which answers an address no route serves. It addresses + no resource and reverses to nothing, which is why it has no name, and + pinning it here keeps a second wildcard from being added quietly beside + it. + """ + + def test_every_address_is_named_and_ends_in_a_slash(self): + addresses = [pattern for pattern in v2_urls.urlpatterns if pattern.name] + assert sorted(pattern.name for pattern in addresses) == [ + "account_deactivation", + "account_detail", + "account_list", + "account_profile_image", + "account_verification_list", + "account_verification_status", + "email_search_list", + "name_change_confirmation", + "name_change_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_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", + "name_change_list", + "name_change_confirmation", + "user_list", + "user_detail", + "user_preference_list", + "time_zone_list", + ] + with pytest.raises(ImproperlyConfigured): + v2_urls.without_route(routes, "user_preference_detail_renamed") + + def test_the_one_unnamed_pattern_is_the_fallback_and_it_is_matched_last(self): + unnamed = [pattern for pattern in v2_urls.urlpatterns if not pattern.name] + assert len(unnamed) == 1 + assert unnamed[0] is v2_urls.urlpatterns[-1] + assert view_identity(unnamed[0].callback) == (V2_VIEW_MODULE, "UnknownAddressView") + + +@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_unversioned_twins_still_resolve(self): + match = resolve("/user_api/v1/users/") + assert view_identity(match.func) == (V1_VIEW_MODULE, "UserViewSet") + + +@skip_unless_lms +class MalformedAddressTests(APITestCase): + """An address that matches no route is refused in the body shape this API answers with.""" + + def setUp(self): + super().setUp() + self.admin = AdminFactory.create(username="url-admin", password=PASSWORD) + assert self.client.login(username=self.admin.username, password=PASSWORD) + + def assert_refused_in_json(self, response): + """Assert the refusal is the standard error body and not a page meant for a browser.""" + assert response["Content-Type"].startswith("application/json"), response["Content-Type"] + assert "\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 + + +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 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_non_admin_caller_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_non_admin_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_admin_caller_receives_the_directory(self): + self.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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_email(self): + self.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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_non_admin_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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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_non_admin_caller_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_owner_who_is_not_an_admin_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_admin_caller_receives_the_entry(self): + self.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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_non_admin_caller_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_owner_who_is_not_an_admin_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_admin_caller_receives_every_preference(self): + self.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + response = self.client.get(self.url, {"username": "bob", "page_size": 100}) + assert self.keys_in(response) == [("bob", "time_zone")] + + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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_non_admin_caller_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_owner_who_is_not_an_admin_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_admin_caller_receives_the_preference(self): + self.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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 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.login(self.admin) + 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.login(self.admin) + 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: what its refused method + #: is called must not change because this one names its own. + 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_refused_method_is_named_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="method-not-allowed", + ) + assert envelope["title"] == "Method Not Allowed" + assert envelope["detail"] == 'Method "POST" not allowed.' + assert response["Content-Type"] == "application/json" + + def test_an_accept_header_this_api_cannot_answer_is_named(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", + ) + assert envelope["title"] == "Not Acceptable" + assert envelope["detail"] == "Could not satisfy the request Accept header." + assert response["Content-Type"] == "application/json" + + def test_a_refused_method_is_named_on_the_gated_collections(self): + self.login(self.admin) + 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="method-not-allowed", + ) + assert envelope["detail"] == 'Method "DELETE" not allowed.' + + def test_a_refused_method_is_named_on_the_gated_members(self): + self.login(self.admin) + 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="method-not-allowed", + ) + assert envelope["detail"] == 'Method "PUT" 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" + + +@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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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_non_admin_caller(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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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.login(self.admin) + 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 staff-gated endpoints answer a bearer of a staff token and refuse everyone else.""" + + 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_a_staff_token_reaches_the_directory(self): + response = self.client.get( + self.addresses["user_list"], {"page_size": 100}, **self.token_headers(self.admin), + ) + 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_a_staff_token_reaches_every_staff_gated_address(self): + headers = self.token_headers(self.admin) + detail = self.client.get(self.addresses["user_detail"], **headers) + assert detail.status_code == status.HTTP_200_OK + assert detail.data["username"] == "alice" + + preferences = self.client.get( + self.addresses["preference_list"], {"username": "alice"}, **headers, + ) + 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"], **headers) + assert preference.status_code == status.HTTP_200_OK + assert preference.data["value"] == "en" + + def test_the_token_and_not_a_session_identifies_the_caller(self): + token = create_jwt_for_user(self.admin) + response = self.client.get( + self.addresses["user_list"], {"username": "alice"}, HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == status.HTTP_200_OK + assert response.wsgi_request.user == self.admin + assert response.wsgi_request.auth == token + assert "sessionid" not in response.wsgi_request.COOKIES + + def test_a_learners_token_is_forbidden_on_every_staff_gated_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_not_staff(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_token_that_does_not_decode_is_unauthenticated(self): + response = self.client.get( + self.addresses["user_list"], HTTP_AUTHORIZATION="JWT not.a.token", + ) + 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.login(self.admin) + + 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 whose user record has been deactivated is refused by both halves of + the default authentication pair: the session half stops accepting it, and the active + account these views require refuses a token issued before the account was deactivated. + """ + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:user_list") + self.staff = AdminFactory.create(username="deactivated-admin", password=PASSWORD) + self.staff_gated = { + "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_deactivated_staff_account_is_refused(self): + self.login(self.staff) + assert self.client.get(self.url).status_code == status.HTTP_200_OK + 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_deactivated_staff_account_is_refused(self): + headers = {"HTTP_AUTHORIZATION": f"JWT {create_jwt_for_user(self.staff)}"} + assert self.client.get(self.url, **headers).status_code == status.HTTP_200_OK + 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_staff_gated_address(self): + self.deactivate(self.staff) + headers = {"HTTP_AUTHORIZATION": f"JWT {create_jwt_for_user(self.staff)}"} + for name, address in self.staff_gated.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_staff_gated_address(self): + self.login(self.staff) + self.deactivate(self.staff) + for name, address in self.staff_gated.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_an_active_staff_token_still_reaches_the_directory(self): + 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_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 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..c9a299596cbf --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/urls.py @@ -0,0 +1,127 @@ +"""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, + AccountViewSet, + CountryTimeZoneViewSet, + EmailSearchViewSet, + IDVerificationStatusView, + IDVerificationViewSet, + NameChangeViewSet, + UnknownAddressView, + UserPreferenceViewSet, + UserViewSet, +) + +app_name = "user_v2" + +#: A preference is identified by its owner and its key, so the router's +#: database-id member route is dropped and replaced by the address declared +#: below under the same name. +REPLACED_ROUTER_ROUTE = "user_preference_detail" + +#: The addresses under this prefix that belong to the authentication app, which +#: this mount is matched before. They are spelled out one by one rather than by +#: the segment they share, so that only what that app serves is left to it and +#: every other address under this prefix is answered here. The trailing slash is +#: optional, which leaves an address sent without one to be redirected to the +#: slashed form the app serves rather than answered as unknown. +OTHER_APP_ADDRESSES = r"account/(registration|login_session)/?" + + +def without_route(routes, name): + """ + Return ``routes`` without the one registered under ``name``. + + A name that matches nothing means the router stopped generating that route + under that name: the address it serves would then be published alongside + the one declared here instead of being replaced by it, so the mismatch is + refused here rather than discovered at the address. + """ + 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 ``_``. + + The names a route is registered under are the ones ``resolve()`` reports + and the ones ``reverse()`` accepts, so they are generated in the published + form rather than rewritten afterwards. + """ + + routes = [route._replace(name=route.name.replace("-", "_")) for route in SimpleRouter.routes] + + +# An address beneath an account is plural where it names rows that accumulate +# behind it, and singular where it names the one thing every account has. +router = SnakeCaseRouter() +router.register(r"accounts", AccountViewSet, basename="account") +# An identity check belongs to the user who went through it and is reached by +# no other route, so the collection is registered beneath the account it +# belongs to. +router.register( + rf"accounts/(?P{settings.USERNAME_REGEX_PARTIAL})/verifications", + IDVerificationViewSet, + basename="account_verification", +) +# What follows the account collection in an address is the name of an account, +# and a user may register any name, so the search over email addresses stands +# beside the accounts rather than under them. +router.register(r"email_searches", EmailSearchViewSet, basename="email_search") +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") + + +urlpatterns = [ + *without_route(router.urls, REPLACED_ROUTER_ROUTE), + # The image belongs to one account and is stored in several sizes, so it + # is addressed as a property of that account rather than as a collection. + re_path( + rf"^accounts/(?P{settings.USERNAME_REGEX_PARTIAL})/profile_image/$", + ProfileImageView.as_view(), + name="account_profile_image", + ), + # Disabling an account is something a privileged caller does to somebody + # else's account rather than a property of that account, so it is served + # by a view of its own beneath the account it names. + re_path( + rf"^accounts/(?P{settings.USERNAME_REGEX_PARTIAL})/deactivation/$", + AccountDeactivationView.as_view(), + name="account_deactivation", + ), + # Where one account's owner stands on having proved their identity is + # worked out from the attempts behind it rather than stored, and there is + # one such summary per account, so it is served from an address of its own. + re_path( + rf"^accounts/(?P{settings.USERNAME_REGEX_PARTIAL})/verification_status/$", + IDVerificationStatusView.as_view(), + name="account_verification_status", + ), + # The username half carries the pattern a username is allowed to match and + # the key half takes the rest of the segment. + 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", + ), + # Everything under this prefix that the authentication app does not serve + # belongs to this version, including an address no route of it answers, + # which is refused in the body the rest of this version answers with. The + # route answers rather than reverses, so it carries no name. + re_path(rf"^(?!{OTHER_APP_ADDRESSES}$).*$", UnknownAddressView.as_view()), +] 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..4a7b9124ab0f --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/view_utils.py @@ -0,0 +1,73 @@ +"""View behaviour shared by the endpoints of version 2 of the User API.""" + +import logging + +from drf_spectacular.utils import 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" + +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..7268cce40f36 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/views.py @@ -0,0 +1,1564 @@ +"""Views for version 2 of the User API.""" + +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 +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, + OpenApiParameter, + OpenApiResponse, + extend_schema, + extend_schema_view, +) +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.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 CanDeactivateUser, CanGetAccountInfo +from openedx.core.djangoapps.user_api.errors import ( + AccountUpdateError, + AccountValidationError, + UserNotAuthorized, + UserNotFound, +) +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.djangolib.default_auth_classes import DefaultJwtAuthentication +from openedx.core.lib.api.parsers import MergePatchParser + +from .errors import CatalogedRequestErrorsMixin, ErrorExtensionsMixin, RetiredAccount +from .filters import StrictOrderingFilter, UserFilterSet, UserPreferenceFilterSet +from .paginators import UserApiPagination +from .permissions import CanSelectAccountsByContact, IsActiveUser, IsAddressedUser, IsStaffOrAbsent +from .schema import ResponseShapes +from .selectors import ( + accounts_holding_emails, + cancellable_retirement_for_email, + username_holding_email, + username_holding_id, + usernames_among, +) +from .serializers import ( + ACCOUNT_DEFAULT_SORT, + ACCOUNT_MINIMAL_FIELDS, + EMAIL_SEARCH_MAX_ADDRESSES, + FULL_VIEW, + MINIMAL_VIEW, + PREFERENCE_MINIMAL_FIELDS, + SHARED_VIEW, + USER_MINIMAL_FIELDS, + VERIFICATION_DEFAULT_SORT, + AccountMinimalSerializer, + AccountProjectionSerializer, + AccountQuerySerializer, + AccountSerializer, + AccountUpdateSerializer, + EmailSearchResultSerializer, + EmailSearchSerializer, + ErrorResponse, + NameChangeSerializer, + RetiredAccountErrorResponse, + TimeZoneQuerySerializer, + TimeZoneSerializer, + UserDirectoryMinimalSerializer, + UserDirectoryProjectionSerializer, + UserDirectorySerializer, + UserPreferenceRecordMinimalSerializer, + UserPreferenceRecordProjectionSerializer, + UserPreferenceRecordSerializer, + VerificationQuerySerializer, + VerificationSerializer, + VerificationStatusSerializer, + full_view_requested, +) +from .view_utils import ( + RESP_UNAUTHENTICATED, + RESP_UNREADABLE_BODY, + SDK_TAG, + USER_TAG, + StrictQueryParametersMixin, +) + +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" + + +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, + ) + + +_USERNAME_PATTERN = rf"^{settings.USERNAME_REGEX_PARTIAL}$" + +_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 member of the account collection that addresses the caller's own account. +OWN_ACCOUNT_IDENTIFIER = "me" + +#: 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. +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.", +) +#: 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), +) + + +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.") +_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.", +) +_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 _user_named(username): + """Return the user an address names, or refuse the address.""" + try: + return User.objects.get(username=username) + except User.DoesNotExist as exc: + raise NotFound from exc + + +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 top-level property names a ``fields`` value asks for.""" + if not raw: + return [] + return [name.strip().split(".", 1)[0] 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) + + 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: + raise ValidationError({ + FIELDS_PARAM: [ + f"No such field: {', '.join(unknown)}. " + f"Select any of: {', '.join(sorted(available))}.", + ], + }) + + 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 = 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." + ), + 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, + }, + ), + retrieve=extend_schema( + summary="Get one user", + description="Returns the directory entry of the user with this username.", + 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, + }, + ), +) +class UserViewSet( + StandardizedErrorMixin, + CatalogedRequestErrorsMixin, + _ActionAddressMixin, + _MemberLookupMixin, + _ResponsePresetMixin, + StrictQueryParametersMixin, + ScopedQuerysetMixin, + viewsets.ReadOnlyModelViewSet, +): + """ + Read-only directory of platform users, addressed by username. + + Access is layered: ``permission_classes`` admits only platform + administrators whose account is still active, the scoping policy decides + which rows such an administrator may see, and the filter set narrows those + rows further by the caller's query parameters. + """ + + permission_classes = (permissions.IsAuthenticated, IsActiveUser, permissions.IsAdminUser) + serializer_class = UserDirectorySerializer + pagination_class = UserApiPagination + queryset = User.objects.all().prefetch_related("preferences").select_related("profile") + # Platform administrators 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() + 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 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." + ), + 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, + }, + ), + 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`." + ), + operation_id="v2_user_preferences_retrieve", + 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, + }, + ), +) +class UserPreferenceViewSet( + StandardizedErrorMixin, + CatalogedRequestErrorsMixin, + _ActionAddressMixin, + _ResponsePresetMixin, + StrictQueryParametersMixin, + ScopedQuerysetMixin, + viewsets.ReadOnlyModelViewSet, +): + """ + Read-only list of stored user preferences, each addressed by its owner and key. + + A preference is identified by the pair, so the member address is + ``{username},{key}`` rather than a database id. Access is layered the same + way as the user directory: permission classes, then the scoping policy, + then the filter set. + """ + + permission_classes = (permissions.IsAuthenticated, IsActiveUser, permissions.IsAdminUser) + serializer_class = UserPreferenceRecordSerializer + pagination_class = UserApiPagination + queryset = ( + UserPreference.objects.all() + .select_related("user", "user__profile") + .annotate(username=F("user__username")) + ) + scoping_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 filter_queryset(self, queryset): + """Fetch each owner's own preferences too when they are about to be embedded.""" + queryset = super().filter_queryset(queryset) + if full_view_requested(getattr(self, "request", None)): + queryset = queryset.prefetch_related("user__preferences") + return queryset + + 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, + CatalogedRequestErrorsMixin, + _ActionAddressMixin, + StrictQueryParametersMixin, + IterablePaginationMixin, + viewsets.ViewSet, +): + """ + Time zones commonly used in a country, or every time zone when no country is named. + + The rows come from the time-zone database rather than from a queryset, so + this is a plain ViewSet whose one query parameter is validated by a + serializer. The reference data it returns is the same for every caller and + is public on purpose. + """ + + 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, + }, + ) + 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"]) + + +@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 one holding an " + "`email` address or 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. 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, + }, + ), + 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, + }, + 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, + 415: RESP_UNREADABLE_BODY, + 429: _RESP_RATE_LIMITED, + 500: _RESP_CHANGE_FAILED, + }, + ), +) +class AccountViewSet( + StandardizedErrorMixin, + CatalogedRequestErrorsMixin, + ErrorExtensionsMixin, + _ActionAddressMixin, + _ResponsePresetMixin, + StrictQueryParametersMixin, + IterablePaginationMixin, + viewsets.ViewSet, +): + """ + Accounts of the platform's users, addressed by username or by ``me``. + + The properties of an account are read from the account service rather than + from a queryset of this app's, so this is a plain ViewSet; which accounts + the answer carries is settled first, from the accounts holding the names + the caller gave, in the order the page is cut in. Access is layered: the + permission classes decide who may call an action at all, the service + decides which properties of each account the caller may see -- everything + for their own account and for a staff caller, the owner's shared + properties otherwise -- and the selectors decide which accounts the page + carries. + + A selector that finds nothing narrows the page to nothing, whichever of + the three it is, so a selection no account matches is answered by an empty + page rather than by a refusal. An address an account held before it was + retired is the one exception: that account was there and is not a row this + page can carry, so it is refused, with whether the retirement can still be + cancelled. That refusal is why the selectors are read from the query + string by a serializer and applied here rather than declared as a filter + set, which has no room for it. + + A selection of properties is measured against the properties a response of + this kind can carry for this caller, so one no account could ever show + them is refused rather than answered as an empty object. Which of the rest + a particular owner shares is settled per account, so a selected property + that this owner keeps to themselves is left out of their object, which can + leave it empty. + + ``me`` stands for the caller's own account wherever a caller reads or + changes one here. + """ + + # Account settings are reached from the site's own pages by users who have + # not confirmed their email address yet, and the platform default refuses + # a session belonging to such an account. Only that one class is replaced: + # the token class stays the one the platform configures, which is what + # records a request as having been authenticated the default way. + authentication_classes = (DefaultJwtAuthentication, SessionAuthenticationAllowInactiveUser) + permission_classes = ( + permissions.IsAuthenticated, + CanGetAccountInfo, + CanSelectAccountsByContact, + ) + #: 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 + #: 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 _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 + if request.user.is_staff: + return True + 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 a name sent + with spaces around it names the same account at each point; 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._accounts_named( + request, + [self._addressed_username(request, username)], + request.query_params.get(VIEW_PARAM), + )[0] + 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."], + }) + # The body is measured against the shape this endpoint publishes before + # anything is done with it, and then carried out as it was sent: the + # account service takes properties this version does not publish, and + # refuses the ones that cannot be changed, which dropping them here + # would turn into silence. Every property is optional of itself, which + # is what makes the body a merge patch, and a property that is an + # object of its own is still sent whole. + 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._accounts_named( + request, [username], request.query_params.get(VIEW_PARAM), + )[0] + except UserNotAuthorized as exc: + raise PermissionDenied from exc + 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 [] + try: + found = get_account_settings(request, names, view=preset if preset == SHARED_VIEW else None) + except UserNotFound as exc: + raise NotFound from exc + # The accounts come back in the order the database returned them, which + # is not the order of the page they were selected for. Each name is + # matched to its account by the name that account stores, and failing + # that by its case-folded form: the database decides which names a + # selection matches, and the collation MySQL applies to these columns + # by default matches names differing only in case, so an account can + # come back stored under a spelling other than the one asked for. + by_name = {account["username"]: account for account in found} + folded = {} + for stored, account in by_name.items(): + folded.setdefault(stored.casefold(), account) + ordered = [] + for name in names: + account = by_name.get(name) + if account is None: + account = folded.get(name.casefold()) + if account is not None: + ordered.append(account) + return ordered + + 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 [name for name in usernames.strip(",").split(",") if name] + 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 account holding this address, and nothing when none does.""" + if is_email_retired(email): + raise self._retired_account(email) + username = username_holding_email(email) + return [username] if username is not None else [] + + 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, + }, + ), +) +class AccountDeactivationView( + StandardizedErrorMixin, + CatalogedRequestErrorsMixin, + StrictQueryParametersMixin, + APIView, +): + """ + Disables the account the address names. + + This is an operation a privileged caller carries out on somebody else's + account rather than a property of that account, and it shares nothing with + the way an account is read or changed: the caller holds the privilege to + disable accounts and has confirmed their own address, the body is read as + JSON rather than as a merge patch, and the name in the address is read as + a stored username, ``me`` included, so a caller that addressed one account + is never answered by disabling another. It is therefore its own view + rather than an action of the account collection. + """ + + 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 = _user_named(username) + segment.identify(user.id, {"is_disabled": "true"}) + user.set_unusable_password() + user.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + +@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, + 415: RESP_UNREADABLE_BODY, + }, + ), +) +class EmailSearchViewSet( + StandardizedErrorMixin, + CatalogedRequestErrorsMixin, + StrictQueryParametersMixin, + IterablePaginationMixin, + viewsets.ViewSet, +): + """ + The accounts holding a given set of email addresses. + + The addresses are sent in a request body rather than in a query string, so + that they stay out of server logs and browser histories, which is why a + search is made with a POST, and why the links on a page of results are + followed by sending the body to them again rather than by fetching them. + The search is a collection of its own rather than an action under the + accounts, because what follows the account collection in an address is the + name of an account, and an account may be named anything a user may + register. Only a platform administrator may search, and every other caller + is told there is nothing at this address. + """ + + permission_classes = ( + permissions.IsAuthenticated, + IsActiveUser, + CanGetAccountInfo, + IsStaffOrAbsent, + ) + parser_classes = (JSONParser,) + serializer_class = EmailSearchResultSerializer + 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) + return self.paginate_iterable(request, accounts_holding_emails(search.validated_data["emails"])) + + +_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, + 404: OpenApiResponse( + ErrorResponse, + description="The account of the caller carries no profile to put a name on.", + ), + 415: RESP_UNREADABLE_BODY, + }, + ), +) +class NameChangeViewSet( + StandardizedErrorMixin, + CatalogedRequestErrorsMixin, + _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 + 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.", + ), + }, + ) + @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, + CatalogedRequestErrorsMixin, + StrictQueryParametersMixin, + APIView, +): + """ + Whether a user has proved their identity, as one summary. + + An account has one such summary, computed rather than stored and never + listed, so it is served from this single address rather than routed as a + collection. The summary is read from the identity verification service + rather than from this app's own tables, and the username in the address + names the user whose summary is read. A user may read their own summary, + and a staff caller whose own account is active may read anyone's, which is + not what the account collection admits, so the summary is served by a view + of its own rather than as an action of that collection. Every account has a + summary: one belonging to a user who has been through no check says so, and + the address is absent only when the user it names is. + """ + + 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."), + }, + ) + 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.", + ), + }, + ), +) +class IDVerificationViewSet( + StandardizedErrorMixin, + CatalogedRequestErrorsMixin, + StrictQueryParametersMixin, + IterablePaginationMixin, + viewsets.ViewSet, +): + """ + The attempts one user has made at proving their identity. + + The attempts are collected from the identity verification service, which + holds a table per kind of check, so there is no queryset of this app's to + narrow: the page is cut from what those tables return and is sorted here + rather than by a filter backend. An attempt belongs to the user who made + it and is reached by no other route, so the collection is addressed + beneath that user's account. 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. + """ + + 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("-")), + ) + + +@extend_schema(exclude=True) +class UnknownAddressView(StandardizedErrorMixin, APIView): + """ + Refuses every address under this version of the API that no route serves. + + It serves no resource: it exists so that a mistyped address is answered + with the same JSON body as every other failure instead of the service's + HTML error page, which a client that asked for JSON cannot read. + """ + + permission_classes = (permissions.AllowAny,) + + def initial(self, request, *args, **kwargs): + """Refuse the address before a handler is chosen, whatever the method.""" + raise NotFound