From a7e307f204caf2f07285097b7ca9bd6260824432 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Thu, 15 Oct 2026 03:41:09 +0500 Subject: [PATCH 01/82] feat: add /api/user/v2/ users, user_preferences and time_zones The user directory, the stored-preference listings and the time-zone reference list were the least conforming endpoints of the User API: the only gate was the deprecated X-Edx-Api-Key header, which ignores request.user entirely, so any holder of the key could read every user's email address and full preference mapping. The listings were addressed by database id, two of them put the parent key in the path instead of the query string, the time-zone list returned an unbounded array, the stored preference row embedded its owner's entire record (one extra query per row), and the forum-role listing created the role it was asked about on every GET. Each of those is a contract change, so they land on a new version and /api/user/v1/ is untouched and keeps serving them. What callers get instead: GET /api/user/v2/users/ (also ?preference_key=, ?forum_role=&course_key=) GET /api/user/v2/users/{username}/ GET /api/user/v2/user_preferences/ GET /api/user/v2/user_preferences/{username},{key}/ GET /api/user/v2/time_zones/ Authenticated staff callers rather than an API key, the standard error envelope, the standard pagination envelope on all three lists, filter sets and a deterministic ordering, and the ?view= and ?fields= response presets. The forum-role filter no longer creates a role, and an unparsable course key is a validation error rather than a 500. Being refused is part of the contract, so the refusals are explicit. A deactivated staff account is turned away on both paths rather than only one: the session authenticator already rejected it, but IsAdminUser reads only is_staff and the JWT authenticator does not read is_active, so such an account could read the whole directory until its token expired. These addresses require an active user. An unrecognised query parameter is a 400 rather than silence. A client porting from a superseded address by changing the version segment alone would otherwise pass a filter this version does not define, have it ignored, and receive every row believing the page was filtered. The allowlist is derived from the paginator, the ordering backend, the filter set and the query serializer, so it cannot drift away from the filters it describes. Shared building blocks are used rather than rewritten: StandardizedErrorMixin, DefaultPagination, IterablePaginationMixin, ScopedQuerysetMixin with FullScopePolicy, MinimalViewMixin and project. The two local classes each supply one predicate the library does not, and each docstring says which. The tests pin behaviour rather than structure: every allowed and every refused caller with the error type asserted, value-level parity with each superseded address, query counts that stay constant across 1, 5 and 25 rows where the superseded listing pays per row, SQL captures proving no GET writes, and the URL literals each name reverses to. --- lms/urls.py | 1 + .../core/djangoapps/user_api/v2/__init__.py | 1 + openedx/core/djangoapps/user_api/v2/errors.py | 36 + .../core/djangoapps/user_api/v2/filters.py | 178 +++ .../core/djangoapps/user_api/v2/paginators.py | 57 + .../djangoapps/user_api/v2/permissions.py | 20 + openedx/core/djangoapps/user_api/v2/schema.py | 31 + .../djangoapps/user_api/v2/serializers.py | 206 +++ .../djangoapps/user_api/v2/tests/__init__.py | 1 + .../user_api/v2/tests/test_parity.py | 374 +++++ .../user_api/v2/tests/test_query_counts.py | 178 +++ .../user_api/v2/tests/test_schema.py | 531 +++++++ .../user_api/v2/tests/test_url_mount.py | 68 + .../djangoapps/user_api/v2/tests/test_urls.py | 296 ++++ .../user_api/v2/tests/test_views.py | 1401 +++++++++++++++++ openedx/core/djangoapps/user_api/v2/urls.py | 71 + openedx/core/djangoapps/user_api/v2/views.py | 593 +++++++ 17 files changed, 4043 insertions(+) create mode 100644 openedx/core/djangoapps/user_api/v2/__init__.py create mode 100644 openedx/core/djangoapps/user_api/v2/errors.py create mode 100644 openedx/core/djangoapps/user_api/v2/filters.py create mode 100644 openedx/core/djangoapps/user_api/v2/paginators.py create mode 100644 openedx/core/djangoapps/user_api/v2/permissions.py create mode 100644 openedx/core/djangoapps/user_api/v2/schema.py create mode 100644 openedx/core/djangoapps/user_api/v2/serializers.py create mode 100644 openedx/core/djangoapps/user_api/v2/tests/__init__.py create mode 100644 openedx/core/djangoapps/user_api/v2/tests/test_parity.py create mode 100644 openedx/core/djangoapps/user_api/v2/tests/test_query_counts.py create mode 100644 openedx/core/djangoapps/user_api/v2/tests/test_schema.py create mode 100644 openedx/core/djangoapps/user_api/v2/tests/test_url_mount.py create mode 100644 openedx/core/djangoapps/user_api/v2/tests/test_urls.py create mode 100644 openedx/core/djangoapps/user_api/v2/tests/test_views.py create mode 100644 openedx/core/djangoapps/user_api/v2/urls.py create mode 100644 openedx/core/djangoapps/user_api/v2/views.py diff --git a/lms/urls.py b/lms/urls.py index 0e624d44981a..8a4c1cc929ae 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -142,6 +142,7 @@ path('api/courses/', include('lms.djangoapps.course_api.urls')), # User API endpoints + path('api/user/v2/', include('openedx.core.djangoapps.user_api.v2.urls')), path('api/user/', include('openedx.core.djangoapps.user_api.urls')), # Note: these are older versions of the User API that will eventually be # subsumed by api/user listed above. diff --git a/openedx/core/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..223f7d59e69a --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/errors.py @@ -0,0 +1,36 @@ +"""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 + + +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.""" + + +register_error_type(RefusedMethod, "method-not-allowed", "Method Not Allowed") +register_error_type(UnsatisfiableAccept, "not-acceptable", "Not Acceptable") + + +class CatalogedRequestErrorsMixin: + """ + Names the error type of a refused method and of an unanswerable ``Accept`` header. + + The shared catalog classifies neither, so both 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 two 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) + return super().handle_exception(exc) 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..80e78b269541 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/permissions.py @@ -0,0 +1,20 @@ +"""Permission classes for version 2 of the User API.""" + +from rest_framework.permissions import BasePermission + + +class IsActiveUser(BasePermission): + """ + Admits only a caller whose account is still active. + + The authenticated and administrator checks read ``is_authenticated`` and + ``is_staff``, and neither reads ``is_active``. A token stays valid until it + expires and is accepted without the account being re-read for it, so an + account deactivated after a token was issued keeps every access that token + carries unless the account's state is checked here. 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) 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/serializers.py b/openedx/core/djangoapps/user_api/v2/serializers.py new file mode 100644 index 000000000000..8c418252640d --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/serializers.py @@ -0,0 +1,206 @@ +"""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 openedx.core.djangoapps.user_api.models import UserPreference + +#: 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 _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." + ), + ) 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_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..de29bdae413e --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py @@ -0,0 +1,531 @@ +"""Tests for what version 2 of the User API publishes in the OpenAPI schema.""" + +import json +from importlib import import_module + +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 common.djangoapps.student.tests.factories import AdminFactory +from lms.lib.spectacular import SUPERSEDED_USER_PATHS, lms_mark_legacy_paths_deprecated +from openedx.core.apidocs import get_api_docs_settings +from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory +from openedx.core.djangoapps.user_api.v2.serializers import TimeZoneQuerySerializer +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" + +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 = { + "/api/user/v2/users/", + "/api/user/v2/user_preferences/", + TIME_ZONE_LIST_PATH, +} +MEMBER_PATHS = {USER_MEMBER_PATH, PREFERENCE_MEMBER_PATH} + +VERSION_TWO_PATHS = LIST_PATHS | MEMBER_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 = ( + "ErrorResponse", + "PaginatedTimeZoneList", + "PaginatedUserDirectoryResponseList", + "PaginatedUserPreferenceRecordResponseList", + "TimeZone", + "UserDirectory", + "UserDirectoryMinimal", + "UserDirectoryProjection", + "UserDirectoryResponse", + "UserPreferenceRecord", + "UserPreferenceRecordMinimal", + "UserPreferenceRecordProjection", + "UserPreferenceRecordResponse", +) + +#: Every body these endpoints return: the address and parameters that produce +#: it, the published path it is documented under, and the rows it carries. +SHAPE_CASES = ( + ("/api/user/v2/users/", "/api/user/v2/users/", {}), + ("/api/user/v2/users/", "/api/user/v2/users/", {"view": "minimal"}), + ("/api/user/v2/users/", "/api/user/v2/users/", {"fields": "username,email"}), + ("/api/user/v2/users/", "/api/user/v2/users/", {"view": "minimal", "fields": "username"}), + ("/api/user/v2/users/{username}/", "/api/user/v2/users/shape-admin/", {}), + ("/api/user/v2/users/{username}/", "/api/user/v2/users/shape-admin/", {"view": "minimal"}), + ("/api/user/v2/users/{username}/", "/api/user/v2/users/shape-admin/", {"fields": "id,url"}), + ("/api/user/v2/user_preferences/", "/api/user/v2/user_preferences/", {}), + ("/api/user/v2/user_preferences/", "/api/user/v2/user_preferences/", {"view": "full"}), + ("/api/user/v2/user_preferences/", "/api/user/v2/user_preferences/", {"view": "minimal"}), + ("/api/user/v2/user_preferences/", "/api/user/v2/user_preferences/", {"fields": "key,value"}), + ( + "/api/user/v2/user_preferences/{username},{key}/", + "/api/user/v2/user_preferences/shape-admin,pref-lang/", + {}, + ), + ( + "/api/user/v2/user_preferences/{username},{key}/", + "/api/user/v2/user_preferences/shape-admin,pref-lang/", + {"view": "full"}, + ), + ( + "/api/user/v2/user_preferences/{username},{key}/", + "/api/user/v2/user_preferences/shape-admin,pref-lang/", + {"view": "minimal"}, + ), + ( + "/api/user/v2/user_preferences/{username},{key}/", + "/api/user/v2/user_preferences/shape-admin,pref-lang/", + {"fields": "value"}, + ), + ("/api/user/v2/time_zones/", "/api/user/v2/time_zones/", {"country_code": "FR"}), +) + + +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_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"]}) + +#: 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/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_version_two_operations_carry_the_area_tag(schema): + tags = { + operation["operationId"]: operation.get("tags") + for path, _method, operation in operations(schema) + if path in VERSION_TWO_PATHS + } + assert tags == { + "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", + "/api/user/v1/me", + "/api/user/v1/preferences/{username}", + "/api/user/v1/account/registration/", + "/api/user/v2/account/registration/", + ): + assert path not in schema["paths"], path + + +@skip_unless_lms +def test_pagination_envelope_is_published_in_full(schema): + for component in ( + "PaginatedUserDirectoryResponseList", + "PaginatedUserPreferenceRecordResponseList", + "PaginatedTimeZoneList", + ): + 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): + 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"): + continue + schema_ref = response["content"]["application/json"]["schema"] + assert schema_ref == {"$ref": "#/components/schemas/ErrorResponse"}, (path, method, code) + + +@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) == [ + ("/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/user/v1/accounts", + "/api/user/v1/me", + "/api/user/v1/preferences/email_opt_in/", + "/api/user/v2/account/registration/", + "/user_api/v1/users/", + "/user_api/v1/preferences/time_zones/", + "/api/enrollment/v2/enrollments/", + "/api/courses/v1/courses/", + ] + marked = ["/api/user/v1/users/", "/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"] + + +@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 + + +@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") + assert client.login(username=admin.username, password=PASSWORD) + for published_path, address, params 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 + + +@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 == set(VERSION_TWO_PATHS) + documented = { + path: sorted(operation["responses"]) + for path, _method, operation in operations(schema) + if path in LIST_PATHS + } + assert documented == { + "/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 == { + (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." + ) 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..c51ceab78a13 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_urls.py @@ -0,0 +1,296 @@ +"""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.testing import assert_error_envelope +from rest_framework import status +from rest_framework.request import Request +from rest_framework.test import APIRequestFactory, APITestCase + +from common.djangoapps.student.tests.factories import AdminFactory +from openedx.core.djangoapps.user_api.v2 import urls as v2_urls +from openedx.core.djangoapps.user_api.v2.views import UserPreferenceViewSet, UserViewSet +from openedx.core.djangolib.testing.utils import skip_unless_lms + +PASSWORD = "Password1234" + +V2_VIEW_MODULE = "openedx.core.djangoapps.user_api.v2.views" +V1_VIEW_MODULE = "openedx.core.djangoapps.user_api.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: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 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/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): + 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/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_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) == [ + "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] == [ + "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_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("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..911fb321af69 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/urls.py @@ -0,0 +1,71 @@ +"""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 .views import ( + PREFERENCE_KEY_PATTERN_PARTIAL, + CountryTimeZoneViewSet, + 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" + + +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] + + +router = SnakeCaseRouter() +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 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", + ), + # The authentication app serves two addresses under this prefix, which this + # mount is matched before; everything else under it belongs to this version. + # The route answers rather than reverses, so it carries no name. + re_path(r"^(?!account/).*$", UnknownAddressView.as_view()), +] 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..6022c5dcec40 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/views.py @@ -0,0 +1,593 @@ +"""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.models import F +from django.http import Http404 +from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema, extend_schema_view +from edx_rest_framework_extensions.mixins import StandardizedErrorMixin +from edx_rest_framework_extensions.paginators import IterablePaginationMixin +from edx_rest_framework_extensions.scoping import FullScopePolicy, ScopedQuerysetMixin +from edx_rest_framework_extensions.shaping import MinimalViewMixin, project +from rest_framework import permissions, viewsets +from rest_framework.exceptions import NotFound, ValidationError +from rest_framework.reverse import reverse +from rest_framework.settings import api_settings +from rest_framework.views import APIView + +from openedx.core.djangoapps.user_api.models import UserPreference +from openedx.core.djangoapps.user_api.preferences.api import get_country_time_zones + +from .errors import CatalogedRequestErrorsMixin +from .filters import StrictOrderingFilter, UserFilterSet, UserPreferenceFilterSet +from .paginators import UserApiPagination +from .permissions import IsActiveUser +from .schema import ResponseShapes +from .serializers import ( + FULL_VIEW, + MINIMAL_VIEW, + PREFERENCE_MINIMAL_FIELDS, + USER_MINIMAL_FIELDS, + ErrorResponse, + TimeZoneQuerySerializer, + TimeZoneSerializer, + UserDirectoryMinimalSerializer, + UserDirectoryProjectionSerializer, + UserDirectorySerializer, + UserPreferenceRecordMinimalSerializer, + UserPreferenceRecordProjectionSerializer, + UserPreferenceRecordSerializer, + full_view_requested, +) + +log = logging.getLogger(__name__) + +USER_TAG = "user" + +#: 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 = "[^/]+" + + +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}$", +) + +#: 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) + +#: 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" + +_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_UNAUTHENTICATED = OpenApiResponse(ErrorResponse, description="The requester is not authenticated.") +_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.", +) + +#: 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, + ], +) + +#: 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, + ], +) + + +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 _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 + }) + + +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. + """ + + #: 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.""" + 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) + + +@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 From 149e8c82a9fcf28604d7c1607d193e090a3590c0 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Thu, 15 Oct 2026 03:41:09 +0500 Subject: [PATCH 02/82] feat: publish the user API in the LMS schema The LMS drf-spectacular pre-processing hook is an allowlist and admitted only /api/enrollment/vN/, so everything under /api/user/ generated, routed and tested while being invisible in the published document. The new v2 addresses would have shipped undocumented and the schema gate would have reported the prefix missing with no clue as to why. The filter now admits exactly what this change owns: the v2 addresses, matched by their view package rather than by path prefix, plus the seven legacy paths they supersede, so each superseded address appears beside its replacement. It is deliberately not a /api/user/vN/ prefix. Admitting the whole prefix pulled thirty-eight further v1 operations into the document -- undocumented request bodies and internal docstrings published as client-facing text -- none of which this change owns or documents. SCHEMA_PATH_PREFIX becomes a two-branch pattern, stripped only to derive tags and operationIds. Every existing enrollment operationId was compared before and after and is unchanged, and so is every operationId, path and component of the service-wide /api-docs document: a generated client derives a module per operationId and a model per component, so a rename there deletes a module as surely as a removal does. The title follows the contents, and the three comments that named the old title follow it. Deprecation is marked where it belongs, outside the frozen modules: the existing post-processing hook gains a tuple of the seven exact legacy paths this change supersedes. Exact paths, not a prefix, so an address whose replacement nobody has written yet can never be marked -- the hook test includes neighbouring user addresses, the unversioned twins and another area's paths and asserts they are left alone. POSTPROCESSING_HOOKS is unchanged: setting it replaces drf-spectacular's defaults, so the enum hook stays restated and stays first. --- lms/envs/common.py | 11 +- lms/envs/devstack.py | 2 +- lms/envs/production.py | 2 +- lms/lib/spectacular.py | 84 +++++++++++-- lms/lib/tests/test_spectacular.py | 193 ++++++++++++++++++++++++++++-- openedx/core/apidocs.py | 2 +- 6 files changed, 264 insertions(+), 30 deletions(-) diff --git a/lms/envs/common.py b/lms/envs/common.py index 7ce78ae83ae0..6f27f270965f 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2179,15 +2179,14 @@ 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', @@ -2195,7 +2194,7 @@ ], # 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..cfd310de404a 100644 --- a/lms/lib/spectacular.py +++ b/lms/lib/spectacular.py @@ -11,21 +11,70 @@ # 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/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/", +) + +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 package +# a view comes from decides instead. +PUBLISHED_USER_PREFIX = "/api/user/v2/" +PUBLISHED_USER_VIEW_MODULE = "openedx.core.djangoapps.user_api.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) + 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_MODULE) + + 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 +82,37 @@ def lms_api_filter(endpoints): class LmsAutoSchema(AutoSchema): """ - Give the colliding legacy Enrollment v2 address a distinct operationId. - - 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``. + Keep the one colliding address distinct, and group each operation under its own area. + + 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. 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.""" + """Suffix the one colliding address so the pair never shares an operationId.""" operation_id = super().get_operation_id() 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 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..66c193901f33 100644 --- a/lms/lib/tests/test_spectacular.py +++ b/lms/lib/tests/test_spectacular.py @@ -1,20 +1,33 @@ """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 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" +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 +35,45 @@ 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), + # Neither served nor superseded by this version. + _endpoint("/api/user/v2/account/registration/", USER_AUTHN_VIEWS), + _endpoint("/api/user/v1/accounts", USER_API_V1_VIEWS), + _endpoint("/api/user/v1/me", USER_API_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/", ] + 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 +93,25 @@ 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/preferences/{username}", + "/api/user/v2/users/", + "/user_api/v1/users/", + ), None, None, False) + paths = result["paths"] + assert paths["/api/user/v1/users/"]["get"]["deprecated"] is True + assert paths["/api/user/v1/users/{id}/"]["get"]["deprecated"] is True + assert paths["/api/user/v1/preferences/time_zones/"]["get"]["deprecated"] is True + assert "deprecated" not in paths["/api/user/v1/accounts"]["get"] + assert "deprecated" not in paths["/api/user/v1/preferences/{username}"]["get"] + assert "deprecated" not in paths["/api/user/v2/users/"]["get"] + assert "deprecated" not in paths["/user_api/v1/users/"]["get"] + 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 +157,120 @@ 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 [ + path("api/user/v1/accounts", view.as_view({"get": "retrieve"})), + 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 _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_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_retrieve", ("user",)), + "/api/user/v1/accounts/{username}": ("v1_accounts_retrieve_2", ("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",)), + } + + +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_no_tag_group_holds_two_areas(self): + 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"], + } diff --git a/openedx/core/apidocs.py b/openedx/core/apidocs.py index 838c61b6bb56..05f8f59888f0 100644 --- a/openedx/core/apidocs.py +++ b/openedx/core/apidocs.py @@ -21,7 +21,7 @@ # These are passed as ``custom_settings`` to SpectacularAPIView rather than # living in ``SPECTACULAR_SETTINGS``, because that global is already claimed by # a deliberately narrow schema in each service -- the Authoring API -# (``/authoring-api/``) in CMS and the Enrollment API (``/lms-api/``) in LMS. +# (``/authoring-api/``) in CMS and the LMS API (``/lms-api/``) in LMS. # Both filter the surface down via ``PREPROCESSING_HOOKS`` and trim a path # prefix, so ``/api-docs`` must switch that filtering off explicitly to cover # the whole service. From 930d42f22eb7444d366c3a61826a7db896d2c281 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Fri, 23 Oct 2026 11:37:22 +0500 Subject: [PATCH 03/82] feat: standardize the account, name-change and verification APIs as /api/user/v2/ The account surface was the largest non-conforming part of the User API and the one most callers depend on: frontend-platform's account fetch, the account and profile MFEs, and both mobile apps all read it. Ten of its operations answered in shapes nothing documented, refused malformed input with server faults, and addressed one account's records through a permission that read a username from somewhere other than the address. Every change here is a contract change, so they land on a new version: /api/user/v1/ is untouched and keeps serving every one of its callers. What this version answers: GET /api/user/v2/accounts/ (?username=, ?email=, ?lms_user_id=, ?view=, ?fields=, ?ordering=) GET /api/user/v2/accounts/me/ GET /api/user/v2/accounts/{username}/ PATCH /api/user/v2/accounts/{username}/ POST /api/user/v2/accounts/{username}/deactivation/ POST /api/user/v2/email_searches/ POST /api/user/v2/name_changes/ POST /api/user/v2/name_changes/{username}/confirmations/ GET /api/user/v2/accounts/{username}/verification_status/ GET /api/user/v2/accounts/{username}/verifications/ A refusal is part of the contract, so the refusals are explicit. The request body is measured against a serializer before the account service reads it, so a wrongly typed property is a validation envelope rather than a server fault; a failure inside the service is a server fault rather than a validation envelope. A permission reads the username from the address and from nowhere else, and compares it exactly, because the handler behind it selects the record by that same value. An account that is no longer active is refused on both halves of the default authentication pair rather than only one. Lists answer in the standard envelope, bounded and ordered by username; a filter that matches nothing answers an empty page rather than reporting the address as missing, and a status that has never been established answers "none" rather than reporting the account as missing. An email search is bounded at a hundred addresses, which bounds its answer. The schema describes what the code does: the superseded addresses publish the media types they actually read, every operation publishes the codes it really answers, and the response components carry the properties the bodies really hold, with presets modelled rather than implied. The tests pin behaviour rather than structure. Every allowed and every refused caller is named with the error type asserted; value-level parity is checked against each superseded address; query counts are measured on the earlier endpoint first and asserted not to grow with the collection; SQL captures prove no read writes, including writes deferred to commit; and each guard was checked by breaking what it covers and watching the right tests fail. --- lms/lib/spectacular.py | 162 +- lms/lib/tests/test_spectacular.py | 181 +- openedx/core/djangoapps/user_api/v2/errors.py | 68 +- .../djangoapps/user_api/v2/permissions.py | 71 +- .../core/djangoapps/user_api/v2/selectors.py | 70 + .../djangoapps/user_api/v2/serializers.py | 582 ++++ .../user_api/v2/tests/test_account_parity.py | 574 ++++ .../v2/tests/test_account_query_counts.py | 410 +++ .../user_api/v2/tests/test_account_views.py | 2579 +++++++++++++++++ .../v2/tests/test_name_change_views.py | 567 ++++ .../user_api/v2/tests/test_schema.py | 1267 +++++++- .../user_api/v2/tests/test_selectors.py | 106 + .../djangoapps/user_api/v2/tests/test_urls.py | 271 +- .../v2/tests/test_verification_views.py | 1047 +++++++ openedx/core/djangoapps/user_api/v2/urls.py | 64 +- .../core/djangoapps/user_api/v2/view_utils.py | 73 + openedx/core/djangoapps/user_api/v2/views.py | 1105 ++++++- 17 files changed, 9057 insertions(+), 140 deletions(-) create mode 100644 openedx/core/djangoapps/user_api/v2/selectors.py create mode 100644 openedx/core/djangoapps/user_api/v2/tests/test_account_parity.py create mode 100644 openedx/core/djangoapps/user_api/v2/tests/test_account_query_counts.py create mode 100644 openedx/core/djangoapps/user_api/v2/tests/test_account_views.py create mode 100644 openedx/core/djangoapps/user_api/v2/tests/test_name_change_views.py create mode 100644 openedx/core/djangoapps/user_api/v2/tests/test_selectors.py create mode 100644 openedx/core/djangoapps/user_api/v2/tests/test_verification_views.py create mode 100644 openedx/core/djangoapps/user_api/v2/view_utils.py diff --git a/lms/lib/spectacular.py b/lms/lib/spectacular.py index cfd310de404a..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/" @@ -15,6 +18,16 @@ # 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/", @@ -24,15 +37,33 @@ "/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 package -# a view comes from decides instead. +# 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_MODULE = "openedx.core.djangoapps.user_api.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 @@ -52,6 +83,83 @@ _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("/") @@ -64,7 +172,7 @@ def _is_published_user_path(path, callback): if not path.startswith(PUBLISHED_USER_PREFIX): return False view = getattr(callback, "cls", None) - return getattr(view, "__module__", "").startswith(PUBLISHED_USER_VIEW_MODULE) + return getattr(view, "__module__", "").startswith(PUBLISHED_USER_VIEW_MODULES) def lms_api_filter(endpoints): @@ -82,21 +190,27 @@ def lms_api_filter(endpoints): class LmsAutoSchema(AutoSchema): """ - Keep the one colliding address distinct, and group each operation under its own area. + 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. 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. Every other address keeps the id and the - tag the generator derives for it, because a client generated from a + 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 one colliding 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 @@ -108,6 +222,38 @@ def get_tags(self): 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 every superseded address ``deprecated: true``.""" diff --git a/lms/lib/tests/test_spectacular.py b/lms/lib/tests/test_spectacular.py index 66c193901f33..90d7f9a2a382 100644 --- a/lms/lib/tests/test_spectacular.py +++ b/lms/lib/tests/test_spectacular.py @@ -14,12 +14,21 @@ 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" @@ -47,10 +56,16 @@ def test_keeps_enrollment_this_version_and_the_addresses_it_supersedes(self): # 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), - # Neither served nor superseded by this version. - _endpoint("/api/user/v2/account/registration/", USER_AUTHN_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/"), @@ -63,6 +78,12 @@ def test_keeps_enrollment_this_version_and_the_addresses_it_supersedes(self): "/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): @@ -99,18 +120,46 @@ def test_only_the_listed_user_addresses_are_deprecated(self): "/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"] - assert paths["/api/user/v1/users/"]["get"]["deprecated"] is True - assert paths["/api/user/v1/users/{id}/"]["get"]["deprecated"] is True - assert paths["/api/user/v1/preferences/time_zones/"]["get"]["deprecated"] is True - assert "deprecated" not in paths["/api/user/v1/accounts"]["get"] - assert "deprecated" not in paths["/api/user/v1/preferences/{username}"]["get"] - assert "deprecated" not in paths["/api/user/v2/users/"]["get"] - assert "deprecated" not in paths["/user_api/v1/users/"]["get"] + 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.""" @@ -176,7 +225,9 @@ def _user_routes(schema_class): """User, enrollment and non-``/api/`` addresses, inspected by ``schema_class``.""" view = type("_InspectedViewSet", (_UserApiViewSet,), {"schema": schema_class()}) return [ - path("api/user/v1/accounts", view.as_view({"get": "retrieve"})), + # 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( @@ -192,6 +243,12 @@ def _user_routes(schema_class): ] +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}): @@ -220,11 +277,20 @@ def test_the_service_wide_document_keeps_every_name_the_generator_derives(self): "/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_retrieve", ("user",)), - "/api/user/v1/accounts/{username}": ("v1_accounts_retrieve_2", ("user",)), + "/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",)), @@ -234,6 +300,85 @@ def test_a_user_address_is_grouped_by_area_where_the_derived_tag_would_name_a_ve } +#: 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.""" @@ -264,13 +409,19 @@ def test_only_the_one_enrollment_pair_is_told_apart_by_a_numeral_suffix(self): # 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_no_tag_group_holds_two_areas(self): + 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"], + "openedx-platform-sdk": ["enrollment", "user"], "user": ["user"], } diff --git a/openedx/core/djangoapps/user_api/v2/errors.py b/openedx/core/djangoapps/user_api/v2/errors.py index 223f7d59e69a..bbd8b5ab2b60 100644 --- a/openedx/core/djangoapps/user_api/v2/errors.py +++ b/openedx/core/djangoapps/user_api/v2/errors.py @@ -1,7 +1,13 @@ """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 +from rest_framework.exceptions import ( + MethodNotAllowed, + NotAcceptable, + NotFound, + ParseError, + UnsupportedMediaType, +) class RefusedMethod(MethodNotAllowed): @@ -12,15 +18,45 @@ 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 and of an unanswerable ``Accept`` header. + 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 neither, so both would reach the caller + 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 @@ -28,9 +64,33 @@ class CatalogedRequestErrorsMixin: """ def handle_exception(self, exc): - """Classify the two request failures the shared catalog leaves unnamed.""" + """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/permissions.py b/openedx/core/djangoapps/user_api/v2/permissions.py index 80e78b269541..33fd8d8c75a3 100644 --- a/openedx/core/djangoapps/user_api/v2/permissions.py +++ b/openedx/core/djangoapps/user_api/v2/permissions.py @@ -1,5 +1,6 @@ """Permission classes for version 2 of the User API.""" +from rest_framework.exceptions import NotFound from rest_framework.permissions import BasePermission @@ -7,14 +8,72 @@ class IsActiveUser(BasePermission): """ Admits only a caller whose account is still active. - The authenticated and administrator checks read ``is_authenticated`` and - ``is_staff``, and neither reads ``is_active``. A token stays valid until it - expires and is accepted without the account being re-read for it, so an - account deactivated after a token was issued keeps every access that token - carries unless the account's state is checked here. edx-drf-extensions - ships no equivalent class, so this one stands in for it. + 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/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 index 8c418252640d..c90d28676782 100644 --- a/openedx/core/djangoapps/user_api/v2/serializers.py +++ b/openedx/core/djangoapps/user_api/v2/serializers.py @@ -1,12 +1,17 @@ """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" @@ -31,6 +36,32 @@ class ErrorResponse(ErrorResponseSerializer): # pylint: disable=abstract-method """ +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``. @@ -204,3 +235,554 @@ class TimeZoneQuerySerializer(serializers.Serializer): # pylint: disable=abstra "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/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_schema.py b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py index de29bdae413e..75a5acd412c6 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py @@ -2,6 +2,7 @@ import json from importlib import import_module +from unittest import mock import jsonschema import pytest @@ -9,17 +10,51 @@ from django.urls import reverse from drf_spectacular.generators import SchemaGenerator from drf_spectacular.settings import patched_settings - -from common.djangoapps.student.tests.factories import AdminFactory -from lms.lib.spectacular import SUPERSEDED_USER_PATHS, lms_mark_legacy_paths_deprecated +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 TimeZoneQuerySerializer +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/" @@ -27,13 +62,37 @@ #: 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 = {USER_MEMBER_PATH, PREFERENCE_MEMBER_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, +} -VERSION_TWO_PATHS = LIST_PATHS | MEMBER_PATHS +#: 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"} @@ -42,10 +101,26 @@ #: 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", @@ -55,43 +130,98 @@ "UserPreferenceRecordMinimal", "UserPreferenceRecordProjection", "UserPreferenceRecordResponse", + "Verification", + "VerificationStatus", ) -#: Every body these endpoints return: the address and parameters that produce -#: it, the published path it is documented under, and the rows it carries. +#: 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/", {}), - ("/api/user/v2/users/", "/api/user/v2/users/", {"view": "minimal"}), - ("/api/user/v2/users/", "/api/user/v2/users/", {"fields": "username,email"}), - ("/api/user/v2/users/", "/api/user/v2/users/", {"view": "minimal", "fields": "username"}), - ("/api/user/v2/users/{username}/", "/api/user/v2/users/shape-admin/", {}), - ("/api/user/v2/users/{username}/", "/api/user/v2/users/shape-admin/", {"view": "minimal"}), - ("/api/user/v2/users/{username}/", "/api/user/v2/users/shape-admin/", {"fields": "id,url"}), - ("/api/user/v2/user_preferences/", "/api/user/v2/user_preferences/", {}), - ("/api/user/v2/user_preferences/", "/api/user/v2/user_preferences/", {"view": "full"}), - ("/api/user/v2/user_preferences/", "/api/user/v2/user_preferences/", {"view": "minimal"}), - ("/api/user/v2/user_preferences/", "/api/user/v2/user_preferences/", {"fields": "key,value"}), + ("/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", ), - ("/api/user/v2/time_zones/", "/api/user/v2/time_zones/", {"country_code": "FR"}), ) @@ -113,14 +243,61 @@ def as_json_schema(node): 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/", @@ -191,13 +368,31 @@ def test_version_two_addresses_are_published(schema): @skip_unless_lms -def test_version_two_operations_carry_the_area_tag(schema): +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"], @@ -231,22 +426,23 @@ def test_only_this_version_and_the_addresses_it_supersedes_are_published(schema) 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", - "/api/user/v1/me", + "/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): - for component in ( - "PaginatedUserDirectoryResponseList", - "PaginatedUserPreferenceRecordResponseList", - "PaginatedTimeZoneList", - ): + """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 @@ -264,14 +460,33 @@ def test_published_pagination_envelope_matches_the_response_body(client, schema) @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"): + 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": "#/components/schemas/ErrorResponse"}, (path, method, code) + 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 @@ -285,6 +500,9 @@ def test_an_operation_that_takes_a_response_preset_publishes_the_refusal(schema) 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"), @@ -328,16 +546,25 @@ def test_enum_post_processing_runs_before_the_deprecation_hook(): def test_hook_leaves_other_resources_alone(): """The hook marks only the addresses it lists, even among their neighbours.""" untouched = [ - "/api/user/v1/accounts", - "/api/user/v1/me", + "/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/enrollment/v2/enrollments"] + 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) @@ -356,6 +583,46 @@ def test_every_superseded_address_has_a_published_replacement(schema): 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(): @@ -407,14 +674,74 @@ def test_every_response_shape_is_published(schema): ), 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 in SHAPE_CASES: + for published_path, address, params, component in SHAPE_CASES: case = (address, params) response = client.get(address, params) assert response.status_code == 200, case @@ -423,6 +750,7 @@ def test_published_shapes_accept_every_body_the_endpoints_return(client, schema) 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 @@ -443,13 +771,15 @@ def test_sort_parameter_publishes_the_fields_it_accepts(schema): @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 == set(VERSION_TWO_PATHS) + 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"], @@ -468,6 +798,28 @@ def test_member_addresses_describe_the_parts_they_are_built_from(schema): 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", @@ -529,3 +881,848 @@ def test_published_text_is_written_for_the_clients_that_read_it(schema): 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_urls.py b/openedx/core/djangoapps/user_api/v2/tests/test_urls.py index c51ceab78a13..7f14cd526cb2 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_urls.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_urls.py @@ -5,20 +5,34 @@ 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 UserPreferenceViewSet, UserViewSet +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 @@ -27,6 +41,30 @@ 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/"), @@ -65,6 +103,16 @@ def routed_actions(view_func): 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. @@ -96,6 +144,15 @@ 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"), @@ -107,6 +164,16 @@ def test_resolved_names_are_the_names_that_reverse(self): 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"}, @@ -125,6 +192,18 @@ def test_action_addresses_follow_the_namespace_the_request_arrived_on(self): 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"}), @@ -138,6 +217,72 @@ def test_addresses_resolve_to_the_version_two_viewsets(self): 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") @@ -179,6 +324,15 @@ class RouteShapeTests(APITestCase): 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", @@ -195,6 +349,12 @@ def test_the_router_route_that_is_replaced_has_to_exist(self): 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", @@ -214,6 +374,60 @@ def test_the_one_unnamed_pattern_is_the_fallback_and_it_is_matched_last(self): 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", @@ -231,6 +445,24 @@ def test_version_one_addresses_still_reach_the_version_one_views(self): 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): @@ -294,3 +526,40 @@ def test_the_version_this_mount_shares_its_prefix_with_is_left_alone(self): assert match.func.view_class.__module__.startswith( "openedx.core.djangoapps.user_authn", ), address + + def test_the_singular_spelling_of_the_account_collection_is_refused_in_json(self): + """The nearest misspelling of a collection of this version is answered by it.""" + for address in ( + "/api/user/v2/account/", + "/api/user/v2/account/alice/", + "/api/user/v2/account/alice/profile_image/", + ): + response = self.client.get(address, HTTP_ACCEPT="application/json") + assert view_identity(resolve(address).func) == (V2_VIEW_MODULE, "UnknownAddressView"), address + self.assert_refused_in_json(response) + + def test_an_address_of_the_other_app_sent_without_its_slash_is_still_redirected(self): + """That app serves the slashed form, which the caller is sent to as it was before.""" + response = self.client.get("/api/user/v2/account/registration") + assert response.status_code == status.HTTP_301_MOVED_PERMANENTLY + assert response["Location"] == "/api/user/v2/account/registration/" + + def test_every_address_left_to_the_other_app_is_one_that_app_serves(self): + """ + An address that app adds under this prefix is answered here until it is named. + + The two are matched by the pattern that keeps them out of the fallback + and are read back from the routes serving them, so neither side can be + edited alone. + """ + left_alone = re.compile(rf"{v2_urls.OTHER_APP_ADDRESSES}\Z") + served = { + reverse("user_api_registration_v2"), + reverse("user_api_login_session", kwargs={"api_version": "v2"}), + } + assert served == { + "/api/user/v2/account/registration/", + "/api/user/v2/account/login_session/", + } + for address in served: + assert left_alone.match(address.removeprefix("/api/user/v2/")), address diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_verification_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_verification_views.py new file mode 100644 index 000000000000..12332694864d --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_verification_views.py @@ -0,0 +1,1047 @@ +"""Tests for the version 2 User API identity verification views.""" + +import datetime +from zoneinfo import ZoneInfo + +import pytest +from django.db import connection +from django.test.utils import CaptureQueriesContext, override_settings +from django.urls import reverse +from edx_rest_framework_extensions.testing import assert_error_envelope +from rest_framework import status +from rest_framework.test import APITestCase + +from common.djangoapps.student.models import UserProfile +from common.djangoapps.student.tests.factories import UserFactory +from lms.djangoapps.verify_student.models import ( + ManualVerification, + SoftwareSecurePhotoVerification, + VerificationAttempt, +) +from lms.djangoapps.verify_student.tests.factories import SSOVerificationFactory +from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user +from openedx.core.djangolib.testing.utils import skip_unless_lms + +from .test_views import PAGINATION_KEYS, write_queries + +PASSWORD = "Password1234" + +#: Identity checks are good for a year on this site, and a user is asked to go +#: through one again in the three weeks before an accepted proof runs out. +VERIFY_STUDENT = {"DAYS_GOOD_FOR": 365, "EXPIRING_SOON_WINDOW": 20} + + +def earlier_status_address(username): + """Return the version 1 address of one user's verification status.""" + return f"/api/user/v1/accounts/{username}/verification_status/" + + +def earlier_list_address(username): + """Return the version 1 address of one user's verification attempts.""" + return f"/api/user/v1/accounts/{username}/verifications/" + + +@override_settings(VERIFY_STUDENT=VERIFY_STUDENT) +class VerificationTestCase(APITestCase): + """Fixtures shared by the version 2 identity verification tests.""" + + def setUp(self): + super().setUp() + self.owner = UserFactory.create( + username="verify-owner", email="verify-owner@example.com", password=PASSWORD, + ) + self.other = UserFactory.create( + username="verify-other", email="verify-other@example.com", password=PASSWORD, + ) + self.staff = UserFactory.create( + username="verify-staff", email="verify-staff@example.com", password=PASSWORD, is_staff=True, + ) + self.attempt = SoftwareSecurePhotoVerification.objects.create( + user=self.owner, status="submitted", + ) + self.status_url = self.status_address(self.owner.username) + self.list_url = self.list_address(self.owner.username) + + def status_address(self, username): + """Return the address of one user's verification status.""" + return reverse("user_v2:account_verification_status", kwargs={"username": username}) + + def list_address(self, username): + """Return the address of one user's verification attempts.""" + return reverse("user_v2:account_verification_list", kwargs={"username": username}) + + def login(self, user): + """Sign the given user in through the session these views accept.""" + assert self.client.login(username=user.username, password=PASSWORD) + + def token_headers(self, user): + """Return the request headers that carry a token identifying the given user.""" + return {"HTTP_AUTHORIZATION": f"JWT {create_jwt_for_user(user)}"} + + def sign_in_as_a_case_variant_of(self, user): + """ + Sign in a caller whose own name is the given user's, in upper case. + + The variant is set in memory only, since a site that stores usernames + case-insensitively has no room for both names at once. + """ + caller = UserFactory.create( + username="verify-variant", email="verify-variant@example.com", password=PASSWORD, + ) + caller.username = user.username.upper() + self.client.force_authenticate(user=caller) + return caller + + def approve(self, attempt, expires_in_days=200): + """Record an attempt as accepted, running out the given number of days from now.""" + attempt.status = "approved" + attempt.expiration_date = datetime.datetime.now(ZoneInfo("UTC")) + datetime.timedelta( + days=expires_in_days, + ) + attempt.save() + return attempt + + def run_out(self, attempt, days_ago=1): + """Record an attempt as accepted and no longer good, the given number of days ago.""" + return self.approve(attempt, expires_in_days=-days_ago) + + +@skip_unless_lms +class VerificationStatusTests(VerificationTestCase): + """GET /api/user/v2/accounts/{username}/verification_status/""" + + def test_a_user_reads_their_own_status(self): + self.login(self.owner) + response = self.client.get(self.status_url) + assert response.status_code == status.HTTP_200_OK + assert response.data == { + "is_verified": False, "status": "pending", "expiration_datetime": "", + } + + def test_an_accepted_proof_is_reported_as_verified(self): + self.approve(self.attempt) + self.login(self.owner) + response = self.client.get(self.status_url) + assert response.data == { + "is_verified": True, "status": "approved", "expiration_datetime": "", + } + + def test_an_accepted_proof_about_to_run_out_carries_the_day_it_does(self): + """The day is published only once the user is near being asked to verify again.""" + attempt = self.approve(self.attempt, expires_in_days=10) + self.login(self.owner) + response = self.client.get(self.status_url) + assert response.data["status"] == "approved" + assert response.data["is_verified"] is True + assert response.data["expiration_datetime"] == attempt.expiration_datetime.date().strftime( + "%m/%d/%Y", + ) + + def test_the_summary_reports_the_day_and_the_attempt_the_moment_it_runs_out(self): + """ + The property of this name carries a day in the summary and a moment in the attempt. + + The summary reports the day the user is told about; the attempt holding + the proof records when it stops counting, to the second. + """ + attempt = self.approve(self.attempt, expires_in_days=10) + self.login(self.owner) + summary = self.client.get(self.status_url) + listed = self.client.get(self.list_url).data["results"][0]["expiration_datetime"] + assert summary.data["expiration_datetime"] == attempt.expiration_datetime.date().strftime( + "%m/%d/%Y", + ) + assert listed.startswith(attempt.expiration_datetime.strftime("%Y-%m-%dT%H:%M:%S")) + + def test_a_turned_down_proof_asks_the_user_to_try_again(self): + self.attempt.status = "denied" + self.attempt.error_msg = '[{"userPhotoReasons": ["Face out of view"]}]' + self.attempt.save() + self.login(self.owner) + response = self.client.get(self.status_url) + assert response.data["status"] == "must_reverify" + assert response.data["is_verified"] is False + + def test_a_proof_that_is_no_longer_good_is_reported_as_run_out(self): + self.run_out(self.attempt) + self.login(self.owner) + response = self.client.get(self.status_url) + assert response.data["status"] == "expired" + assert response.data["is_verified"] is False + + def test_a_proof_the_user_was_never_shown_stands_at_none_once_it_runs_out(self): + """A check whose outcome is never shown to its subject is as if it had not been made.""" + self.attempt.delete() + self.run_out(ManualVerification.objects.create( + user=self.owner, status="approved", reason="Checked in person", + )) + self.login(self.owner) + response = self.client.get(self.status_url) + assert response.status_code == status.HTTP_200_OK + assert response.data["status"] == "none" + assert response.data["is_verified"] is False + + def test_a_staff_caller_reads_another_users_status(self): + self.login(self.staff) + response = self.client.get(self.status_url) + assert response.status_code == status.HTTP_200_OK + assert response.data["status"] == "pending" + + def test_a_learner_may_not_read_another_users_status(self): + self.login(self.other) + assert_error_envelope( + self.client.get(self.status_url), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_a_refused_read_stays_refused_whatever_the_query_string_carries(self): + """Who may read this status is settled from the address, before the query string is read.""" + self.login(self.other) + for query in ( + f"username={self.other.username}", + f"user={self.other.username}", + "format=json", + ): + assert_error_envelope( + self.client.get(f"{self.status_url}?{query}"), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_a_caller_named_for_the_address_in_case_alone_may_not_read_the_status(self): + self.sign_in_as_a_case_variant_of(self.owner) + assert_error_envelope( + self.client.get(self.status_url), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_an_anonymous_caller_is_unauthenticated(self): + assert_error_envelope( + self.client.get(self.status_url), + expected_status=status.HTTP_401_UNAUTHORIZED, + expected_type_slug="authn", + ) + + def test_a_user_who_has_never_been_checked_stands_at_none(self): + """Nothing on record is something to report: the summary says so rather than going missing.""" + self.login(self.other) + response = self.client.get(self.status_address(self.other.username)) + assert response.status_code == status.HTTP_200_OK + assert response.data == { + "is_verified": False, "status": "none", "expiration_datetime": "", + } + + def test_a_user_who_has_never_been_checked_is_told_apart_from_one_who_does_not_exist(self): + self.login(self.staff) + assert self.client.get(self.status_address(self.other.username)).status_code == status.HTTP_200_OK + assert_error_envelope( + self.client.get(self.status_address("nobody-at-all")), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_an_unknown_username_is_not_found(self): + self.login(self.staff) + assert_error_envelope( + self.client.get(self.status_address("nobody-at-all")), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_an_unknown_query_parameter_is_refused(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.client.get(f"{self.status_url}?expand=attempts"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "expand": ["No such query parameter. Send any of: format."], + } + + def test_the_name_in_the_address_is_refused_in_the_query_string_as_well(self): + """The address carries the username, so a parameter repeating it is one this view never reads.""" + for caller in (self.owner, self.staff): + self.login(caller) + envelope = assert_error_envelope( + self.client.get(f"{self.status_url}?username={self.owner.username}"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "username": ["No such query parameter. Send any of: format."], + } + self.client.logout() + + def test_the_status_is_not_changed_through_this_address(self): + self.login(self.owner) + assert_error_envelope( + self.client.post(self.status_url, {}, format="json"), + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug="method-not-allowed", + ) + + +@skip_unless_lms +class VerificationListTests(VerificationTestCase): + """GET /api/user/v2/accounts/{username}/verifications/""" + + def setUp(self): + super().setUp() + self.manual = ManualVerification.objects.create( + user=self.owner, status="approved", reason="Checked in person", + ) + + def test_a_user_reads_their_own_attempts(self): + self.login(self.owner) + response = self.client.get(self.list_url) + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] == 2 + assert {row["type"] for row in response.data["results"]} == {"Software Secure", "Manual"} + + def test_each_attempt_carries_what_was_recorded_about_it(self): + self.login(self.owner) + rows = {row["type"]: row for row in self.client.get(self.list_url).data["results"]} + assert rows["Manual"]["status"] == "approved" + assert rows["Manual"]["message"] == "Checked in person" + assert rows["Manual"]["receipt_id"] is None + assert rows["Software Secure"]["status"] == "submitted" + assert rows["Software Secure"]["receipt_id"] == str(self.attempt.receipt_id) + + def test_each_kind_of_check_is_named_with_what_was_said_about_it(self): + """The kind of check, the remark on it and the receipt are read from the record holding the attempt.""" + self.attempt.status = "denied" + self.attempt.error_msg = '[{"userPhotoReasons": ["Face out of view"]}]' + self.attempt.save() + SSOVerificationFactory(user=self.owner, status="approved", identity_provider_slug="idp") + self.login(self.owner) + rows = {row["type"]: row for row in self.client.get(self.list_url).data["results"]} + assert set(rows) == {"Software Secure", "Manual", "SSO"} + assert rows["Software Secure"]["message"] == '[{"userPhotoReasons": ["Face out of view"]}]' + assert rows["Software Secure"]["receipt_id"] == str(self.attempt.receipt_id) + assert rows["Manual"]["message"] == "Checked in person" + assert rows["Manual"]["receipt_id"] is None + assert rows["SSO"]["message"] == "" + assert rows["SSO"]["receipt_id"] is None + + def test_an_attempt_carries_the_status_the_table_holding_it_records(self): + """ + The tables behind these attempts do not hold the same statuses, and each is carried as it stands. + + A photo check that failed to go through is held as ``must_retry``, and a + check carried out elsewhere is held as ``pending`` while it is looked at. + """ + self.attempt.status = "must_retry" + self.attempt.save() + VerificationAttempt.objects.create(user=self.owner, name="Verify Owner", status="pending") + self.login(self.owner) + rows = {row["type"]: row for row in self.client.get(self.list_url).data["results"]} + assert set(rows) == {"Software Secure", "Manual", "SSO"} + assert rows["Software Secure"]["status"] == "must_retry" + assert rows["SSO"]["status"] == "pending" + assert rows["SSO"]["message"] == "" + assert rows["SSO"]["receipt_id"] is None + assert rows["SSO"]["expiration_datetime"] is None + + def test_the_page_carries_the_whole_envelope(self): + self.login(self.owner) + response = self.client.get(self.list_url) + assert set(response.data) == PAGINATION_KEYS + assert response.data["count"] == 2 + assert response.data["num_pages"] == 1 + assert response.data["current_page"] == 1 + assert response.data["start"] == 0 + assert response.data["next"] is None + assert response.data["previous"] is None + + def test_the_page_is_bounded(self): + for index in range(5): + SSOVerificationFactory(user=self.owner, status="approved", identity_provider_slug=f"idp{index}") + self.login(self.owner) + response = self.client.get(self.list_url, {"page_size": 3}) + assert response.data["count"] == 7 + assert len(response.data["results"]) == 3 + assert response.data["num_pages"] == 3 + assert response.data["next"].endswith("page=2&page_size=3") + + def test_the_attempts_come_back_newest_first_and_the_order_can_be_reversed(self): + self.login(self.owner) + descending = self.client.get(self.list_url).data["results"] + assert [row["updated_at"] for row in descending] == sorted( + [row["updated_at"] for row in descending], reverse=True, + ) + ascending = self.client.get(self.list_url, {"ordering": "updated_at"}).data["results"] + assert [row["type"] for row in ascending] == [row["type"] for row in reversed(descending)] + + def test_attempts_changed_at_the_same_moment_are_ordered_by_their_receipt(self): + """ + Two attempts sharing a timestamp are told apart by their receipt identifier. + + The service hands back one table after another, so the order the rows + arrive in is not an order the caller asked for: a page that falls back + to it reads the same both ways round. + """ + moment = datetime.datetime(2024, 5, 1, 12, 0, tzinfo=ZoneInfo("UTC")) + for attempt in (self.attempt, self.manual): + type(attempt).objects.filter(pk=attempt.pk).update(updated_at=moment) + self.login(self.owner) + + descending = self.client.get(self.list_url).data["results"] + assert [row["receipt_id"] for row in descending] == [str(self.attempt.receipt_id), None] + ascending = self.client.get(self.list_url, {"ordering": "updated_at"}).data["results"] + assert [row["receipt_id"] for row in ascending] == [None, str(self.attempt.receipt_id)] + + def test_a_sort_the_list_does_not_offer_is_refused(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.client.get(self.list_url, {"ordering": "status"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "ordering": ["Sort by any of: updated_at, each optionally prefixed with '-'."], + } + + def test_an_unknown_query_parameter_is_refused(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.client.get(self.list_url, {"status": "approved"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "status": ["No such query parameter. Send any of: format, ordering, page, page_size."], + } + + def test_the_name_in_the_address_is_refused_in_the_query_string_as_well(self): + """The address carries the username, so a parameter repeating it is one this view never reads.""" + for caller in (self.owner, self.staff): + self.login(caller) + envelope = assert_error_envelope( + self.client.get(f"{self.list_url}?username={self.owner.username}"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "username": ["No such query parameter. Send any of: format, ordering, page, page_size."], + } + self.client.logout() + + def test_a_staff_caller_reads_another_users_attempts(self): + self.login(self.staff) + response = self.client.get(self.list_url) + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] == 2 + + def test_a_learner_may_not_read_another_users_attempts(self): + self.login(self.other) + assert_error_envelope( + self.client.get(self.list_url), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_a_refused_read_stays_refused_whatever_the_query_string_carries(self): + """Who may read these attempts is settled from the address, before the query string is read.""" + self.login(self.other) + for query in ( + f"username={self.other.username}", + f"user={self.other.username}", + "ordering=-updated_at", + ): + assert_error_envelope( + self.client.get(f"{self.list_url}?{query}"), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_a_caller_named_for_the_address_in_case_alone_may_not_read_the_attempts(self): + self.sign_in_as_a_case_variant_of(self.owner) + assert_error_envelope( + self.client.get(self.list_url), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_an_anonymous_caller_is_unauthenticated(self): + assert_error_envelope( + self.client.get(self.list_url), + expected_status=status.HTTP_401_UNAUTHORIZED, + expected_type_slug="authn", + ) + + def test_a_user_who_has_been_through_no_checks_has_an_empty_page(self): + """An existing user with nothing to list is an empty collection, not an absent one.""" + self.login(self.other) + response = self.client.get(self.list_address(self.other.username)) + assert response.status_code == status.HTTP_200_OK + assert set(response.data) == PAGINATION_KEYS + assert response.data["count"] == 0 + assert response.data["results"] == [] + assert response.data["num_pages"] == 1 + assert response.data["next"] is None + + def test_a_user_who_has_been_through_no_checks_is_told_apart_from_one_who_does_not_exist(self): + self.login(self.staff) + empty = self.client.get(self.list_address(self.other.username)) + unknown = self.client.get(self.list_address("nobody-at-all")) + assert empty.status_code == status.HTTP_200_OK + assert unknown.status_code == status.HTTP_404_NOT_FOUND + assert empty.data["count"] == 0 + + def test_an_unknown_username_is_not_found(self): + self.login(self.staff) + assert_error_envelope( + self.client.get(self.list_address("nobody-at-all")), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_a_page_that_does_not_exist_is_refused(self): + self.login(self.owner) + assert_error_envelope( + self.client.get(self.list_url, {"page": 9}), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_the_attempts_are_not_changed_through_this_address(self): + self.login(self.owner) + assert_error_envelope( + self.client.post(self.list_url, {}, format="json"), + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug="method-not-allowed", + ) + + +@skip_unless_lms +class VerificationPageSizeTests(VerificationTestCase): + """ + How many attempts a page carries when the caller does not say, and the most it will carry. + + The list is asked for more attempts than it will give, against attempts + that really exist, so what is pinned is the page the endpoint answers + with rather than the numbers a paginator class holds. + """ + + DEFAULT_PAGE_SIZE = 10 + MAX_PAGE_SIZE = 100 + + def extra_attempts(self, count): + """Record the given number of further checks for the owner.""" + for index in range(count): + SSOVerificationFactory( + user=self.owner, status="approved", identity_provider_slug=f"idp{index:03d}", + ) + + def test_a_page_of_attempts_carries_ten_of_them_when_the_caller_asks_for_no_size(self): + self.extra_attempts(self.DEFAULT_PAGE_SIZE) + self.login(self.owner) + response = self.client.get(self.list_url) + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] == self.DEFAULT_PAGE_SIZE + 1 + assert len(response.data["results"]) == self.DEFAULT_PAGE_SIZE + assert response.data["num_pages"] == 2 + assert "page=2" in response.data["next"] + + def test_a_page_of_attempts_carries_a_hundred_of_them_however_many_are_asked_for(self): + self.extra_attempts(self.MAX_PAGE_SIZE) + self.login(self.owner) + response = self.client.get(self.list_url, {"page_size": 1000}) + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] == self.MAX_PAGE_SIZE + 1 + assert len(response.data["results"]) == self.MAX_PAGE_SIZE + assert response.data["num_pages"] == 2 + assert response.data["next"] is not None + + +@skip_unless_lms +class VerificationReadsDoNotWriteTests(VerificationTestCase): + """Reading identity verification changes nothing a later read would see.""" + + def setUp(self): + super().setUp() + ManualVerification.objects.create(user=self.owner, status="approved", reason="Checked in person") + + def assert_no_writes(self, address, params=None, expect=status.HTTP_200_OK, **headers): + """ + Read the address and assert the request wrote nothing, whatever it answered. + + Request headers given here reach the client, which is how a read + presented with a token is captured alongside one made from a session. + Work handed to the end of the transaction is run inside the capture, + so a write put off until the request is over is seen here too. + """ + with CaptureQueriesContext(connection) as captured: + with self.captureOnCommitCallbacks(execute=True): + response = self.client.get(address, params or {}, **headers) + assert response.status_code == expect + assert write_queries(captured) == [] + return response + + def other_status_address(self): + """Return the status address of the account the branch fixtures below belong to.""" + return self.status_address(self.other.username) + + def test_reading_a_status_writes_nothing(self): + self.login(self.owner) + response = self.assert_no_writes(self.status_url) + assert response.data["status"] == "approved" + + def test_reading_an_accepted_status_writes_nothing(self): + self.approve(self.attempt) + self.login(self.owner) + response = self.assert_no_writes(self.status_url) + assert response.data["status"] == "approved" + + def test_reading_an_accepted_status_about_to_run_out_writes_nothing(self): + attempt = self.approve( + SoftwareSecurePhotoVerification.objects.create(user=self.other, status="submitted"), + expires_in_days=10, + ) + self.login(self.other) + response = self.assert_no_writes(self.other_status_address()) + assert response.data["status"] == "approved" + assert response.data["expiration_datetime"] == attempt.expiration_datetime.date().strftime( + "%m/%d/%Y", + ) + + def test_reading_a_pending_status_writes_nothing(self): + SoftwareSecurePhotoVerification.objects.create(user=self.other, status="submitted") + self.login(self.other) + response = self.assert_no_writes(self.other_status_address()) + assert response.data["status"] == "pending" + + def test_reading_a_turned_down_status_writes_nothing(self): + SoftwareSecurePhotoVerification.objects.create( + user=self.other, status="denied", error_msg='[{"userPhotoReasons": ["Face out of view"]}]', + ) + self.login(self.other) + response = self.assert_no_writes(self.other_status_address()) + assert response.data["status"] == "must_reverify" + + def test_reading_a_status_whose_proof_has_run_out_writes_nothing(self): + self.run_out(SoftwareSecurePhotoVerification.objects.create(user=self.other, status="submitted")) + self.login(self.other) + response = self.assert_no_writes(self.other_status_address()) + assert response.data["status"] == "expired" + + def test_reading_the_status_of_a_proof_that_ran_out_unseen_writes_nothing(self): + self.run_out(ManualVerification.objects.create( + user=self.other, status="approved", reason="Checked in person", + )) + self.login(self.other) + response = self.assert_no_writes(self.other_status_address()) + assert response.data["status"] == "none" + assert response.data["is_verified"] is False + + def test_reading_a_status_proved_by_signing_in_elsewhere_writes_nothing(self): + SSOVerificationFactory(user=self.other, status="approved", identity_provider_slug="idp") + self.login(self.other) + response = self.assert_no_writes(self.other_status_address()) + assert response.data["status"] == "approved" + assert response.data["is_verified"] is True + + def test_reading_a_status_proved_in_person_writes_nothing(self): + ManualVerification.objects.create(user=self.other, status="approved", reason="Checked in person") + self.login(self.other) + response = self.assert_no_writes(self.other_status_address()) + assert response.data["status"] == "approved" + assert response.data["is_verified"] is True + + def test_reading_a_status_as_staff_writes_nothing(self): + self.login(self.staff) + response = self.assert_no_writes(self.status_url) + assert response.data["status"] == "approved" + + def test_asking_for_a_status_with_a_head_request_writes_nothing(self): + self.login(self.owner) + with CaptureQueriesContext(connection) as captured: + with self.captureOnCommitCallbacks(execute=True): + response = self.client.head(self.status_url) + assert response.status_code == status.HTTP_200_OK + assert write_queries(captured) == [] + + def test_reading_the_attempts_writes_nothing(self): + self.login(self.owner) + response = self.assert_no_writes(self.list_url) + assert response.data["count"] == 2 + + def test_reading_the_attempts_in_the_other_order_writes_nothing(self): + self.login(self.owner) + response = self.assert_no_writes(self.list_url, {"ordering": "updated_at"}) + assert response.data["count"] == 2 + + def test_reading_a_page_further_in_writes_nothing(self): + for index in range(5): + SSOVerificationFactory(user=self.owner, status="approved", identity_provider_slug=f"idp{index}") + self.login(self.owner) + response = self.assert_no_writes(self.list_url, {"page": 2, "page_size": 3}) + assert response.data["current_page"] == 2 + assert len(response.data["results"]) == 3 + + def test_reading_an_empty_page_of_attempts_writes_nothing(self): + self.login(self.other) + response = self.assert_no_writes(self.list_address(self.other.username)) + assert response.data["count"] == 0 + + def test_reading_the_attempts_as_staff_writes_nothing(self): + self.login(self.staff) + response = self.assert_no_writes(self.list_url) + assert response.data["count"] == 2 + + def test_asking_for_the_attempts_with_a_head_request_writes_nothing(self): + self.login(self.owner) + with CaptureQueriesContext(connection) as captured: + with self.captureOnCommitCallbacks(execute=True): + response = self.client.head(self.list_url) + assert response.status_code == status.HTTP_200_OK + assert write_queries(captured) == [] + + def test_reading_a_status_a_token_identifies_its_caller_by_writes_nothing(self): + """ + A caller resolved from a token reads as a signed-in one does. + + A token is how a service or a micro-frontend reaches these addresses, + and the layer that resolves one runs before the handler, so the reads + are captured under both ways of identifying the caller. + """ + response = self.assert_no_writes(self.status_url, **self.token_headers(self.owner)) + assert response.data["status"] == "approved" + + def test_reading_the_attempts_a_token_identifies_its_caller_by_writes_nothing(self): + response = self.assert_no_writes(self.list_url, **self.token_headers(self.owner)) + assert response.data["count"] == 2 + + def test_a_staff_token_reading_another_users_status_and_attempts_writes_nothing(self): + headers = self.token_headers(self.staff) + status_answer = self.assert_no_writes(self.status_url, **headers) + assert status_answer.data["status"] == "approved" + list_answer = self.assert_no_writes(self.list_url, **headers) + assert list_answer.data["count"] == 2 + + def test_refusing_an_anonymous_caller_writes_nothing(self): + for address in (self.status_url, self.list_url): + response = self.assert_no_writes(address, expect=status.HTTP_401_UNAUTHORIZED) + assert_error_envelope(response, expected_status=401, expected_type_slug="authn") + + def test_refusing_another_learner_writes_nothing(self): + self.login(self.other) + for address in (self.status_url, self.list_url): + response = self.assert_no_writes(address, expect=status.HTTP_403_FORBIDDEN) + assert_error_envelope(response, expected_status=403, expected_type_slug="authz") + + def test_reporting_a_name_no_account_holds_writes_nothing(self): + self.login(self.staff) + for address in (self.status_address("nobody-at-all"), self.list_address("nobody-at-all")): + response = self.assert_no_writes(address, expect=status.HTTP_404_NOT_FOUND) + assert_error_envelope(response, expected_status=404, expected_type_slug="not-found") + + def test_refusing_a_query_parameter_these_addresses_do_not_read_writes_nothing(self): + self.login(self.owner) + for address in (self.status_url, self.list_url): + response = self.assert_no_writes( + address, {"username": self.owner.username}, expect=status.HTTP_400_BAD_REQUEST, + ) + assert_error_envelope(response, expected_status=400, expected_type_slug="validation") + + +@skip_unless_lms +class NothingOnRecordTests(VerificationTestCase): + """What the addresses under one account answer for a user who has done nothing yet.""" + + def setUp(self): + super().setUp() + self.newcomer = UserFactory.create( + username="verify-newcomer", email="verify-newcomer@example.com", password=PASSWORD, + ) + UserProfile.objects.filter(user=self.newcomer).delete() + assert not UserProfile.objects.filter(user=self.newcomer).exists() + + def test_an_account_with_nothing_on_record_is_answered_at_each_of_its_addresses(self): + """ + A user who has been through no check and holds no profile is answered at all three addresses. + + Nothing on record is something to report, so the summary, the page of + attempts and the account itself all answer, and they answer the same + way as each other. + """ + self.login(self.newcomer) + standing = self.client.get(self.status_address(self.newcomer.username)) + assert standing.status_code == status.HTTP_200_OK + assert standing.data == { + "is_verified": False, "status": "none", "expiration_datetime": "", + } + + attempts = self.client.get(self.list_address(self.newcomer.username)) + assert attempts.status_code == status.HTTP_200_OK + assert attempts.data["count"] == 0 + assert attempts.data["results"] == [] + + account = self.client.get(reverse("user_v2:account_detail", kwargs={"username": "me"})) + assert account.status_code == status.HTTP_200_OK + assert account.data["username"] == self.newcomer.username + + def test_the_addresses_of_a_name_no_account_holds_are_all_absent(self): + """A missing user is what makes one of these addresses absent, and the only thing that does.""" + self.login(self.staff) + for address in ( + self.status_address("nobody-at-all"), + self.list_address("nobody-at-all"), + reverse("user_v2:account_detail", kwargs={"username": "nobody-at-all"}), + ): + assert_error_envelope( + self.client.get(address), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + +@skip_unless_lms +class VerificationDisabledCallerTests(VerificationTestCase): + """What these addresses answer a caller whose own account has been disabled.""" + + def test_a_token_held_by_a_disabled_staff_account_may_not_read_another_users_records(self): + """A privilege does not survive the deactivation of the account that held it.""" + headers = self.token_headers(self.staff) + assert self.client.get(self.status_url, **headers).status_code == status.HTTP_200_OK + assert self.client.get(self.list_url, **headers).status_code == status.HTTP_200_OK + self.staff.is_active = False + self.staff.save() + for address in (self.status_url, self.list_url): + assert_error_envelope( + self.client.get(address, **headers), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_a_disabled_account_still_reads_its_own_records(self): + """What a user proved about themselves stays readable to them while the account is disabled.""" + headers = self.token_headers(self.owner) + self.owner.is_active = False + self.owner.save() + summary = self.client.get(self.status_url, **headers) + assert summary.status_code == status.HTTP_200_OK + assert summary.data == {"is_verified": False, "status": "pending", "expiration_datetime": ""} + page = self.client.get(self.list_url, **headers) + assert page.status_code == status.HTTP_200_OK + assert page.data["count"] == 1 + + def test_a_disabled_staff_account_still_reads_its_own_records(self): + """The staff half of the check is the one the disabled account loses.""" + headers = self.token_headers(self.staff) + self.staff.is_active = False + self.staff.save() + summary = self.client.get(self.status_address(self.staff.username), **headers) + assert summary.status_code == status.HTTP_200_OK + assert summary.data == {"is_verified": False, "status": "none", "expiration_datetime": ""} + page = self.client.get(self.list_address(self.staff.username), **headers) + assert page.status_code == status.HTTP_200_OK + assert page.data["count"] == 0 + + +@skip_unless_lms +class VerificationAddressedNameTests(VerificationTestCase): + """ + These addresses carry the name an account is stored under. + + The account collection reads ``me`` as the account of the caller; the + addresses beneath an account read the name they carry as it stands, so a + caller that sends ``me`` to one of them is answered as a caller naming any + other account is. + """ + + def test_a_learner_sending_the_collections_own_name_is_refused(self): + self.login(self.owner) + for address in (self.status_address("me"), self.list_address("me")): + assert_error_envelope( + self.client.get(address), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_a_staff_caller_sending_it_is_told_no_account_holds_that_name(self): + self.login(self.staff) + for address in (self.status_address("me"), self.list_address("me")): + assert_error_envelope( + self.client.get(address), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_the_account_holding_that_name_is_served_at_it(self): + """A user may register the name, and reads their own records where it is addressed.""" + holder = UserFactory.create(username="me", email="me@example.com", password=PASSWORD) + SoftwareSecurePhotoVerification.objects.create(user=holder, status="approved") + self.login(holder) + summary = self.client.get(self.status_address("me")) + assert summary.status_code == status.HTTP_200_OK + assert summary.data["status"] == "approved" + page = self.client.get(self.list_address("me")) + assert page.status_code == status.HTTP_200_OK + assert page.data["count"] == 1 + assert page.data["results"][0]["status"] == "approved" + + +@skip_unless_lms +class VerificationParityTests(VerificationTestCase): + """The same fixtures through the version 1 and the version 2 addresses.""" + + def setUp(self): + super().setUp() + ManualVerification.objects.create(user=self.owner, status="approved", reason="Checked in person") + + def test_both_versions_report_the_same_status(self): + self.login(self.owner) + earlier = self.client.get(earlier_status_address(self.owner.username)) + later = self.client.get(self.status_url) + assert earlier.status_code == later.status_code == status.HTTP_200_OK + assert later.data == earlier.data + + def test_both_versions_report_the_same_accepted_status(self): + self.approve(self.attempt, expires_in_days=10) + self.login(self.owner) + earlier = self.client.get(earlier_status_address(self.owner.username)) + later = self.client.get(self.status_url) + assert later.data == earlier.data + assert later.data["is_verified"] is True + + def test_both_versions_carry_the_same_attempts(self): + self.login(self.owner) + earlier = self.client.get(earlier_list_address(self.owner.username)) + later = self.client.get(self.list_url) + assert earlier.status_code == later.status_code == status.HTTP_200_OK + # The one declared difference: the attempts come back in a page rather + # than as a bare list, and the rows themselves are unchanged. + assert isinstance(earlier.data, list) + assert later.data["results"] == earlier.data + assert later.data["count"] == len(earlier.data) + + def test_both_versions_refuse_a_learner_reading_another_users_attempts(self): + self.login(self.other) + earlier = self.client.get(earlier_list_address(self.owner.username)) + later = self.client.get(self.list_url) + assert earlier.status_code == later.status_code == status.HTTP_403_FORBIDDEN + assert earlier.data == {"detail": "You do not have permission to perform this action."} + assert_error_envelope(later, expected_status=403, expected_type_slug="authz") + + def test_the_status_of_a_user_who_has_been_through_no_checks_is_reported_rather_than_refused(self): + """ + Declared difference: a user with nothing on record has a status here. + + The earlier address answers the same way for a user who has been + through no check and for a name no account holds, so a client cannot + tell a typo from a user who has not started. Here the summary says + `none` and the refusal is kept for the name no account holds, which + the second half asserts. + """ + self.login(self.other) + earlier = self.client.get(earlier_status_address(self.other.username)) + later = self.client.get(self.status_address(self.other.username)) + assert earlier.status_code == status.HTTP_404_NOT_FOUND + assert later.status_code == status.HTTP_200_OK + assert later.data == {"is_verified": False, "status": "none", "expiration_datetime": ""} + + self.client.logout() + self.login(self.staff) + assert self.client.get(self.status_address("nobody-at-all")).status_code == status.HTTP_404_NOT_FOUND + + def test_the_attempts_of_a_user_who_has_been_through_no_checks_are_an_empty_page(self): + """ + Declared difference: an existing user with no attempts has an empty page here. + + The earlier address answers the same way for a user who has made no + attempt and for a name no account holds, so a client cannot tell a typo + from an empty history. Here the page is empty and the refusal is kept + for the name no account holds, which the second half asserts. + """ + self.login(self.other) + earlier = self.client.get(earlier_list_address(self.other.username)) + later = self.client.get(self.list_address(self.other.username)) + assert earlier.status_code == status.HTTP_404_NOT_FOUND + assert later.status_code == status.HTTP_200_OK + assert later.data["count"] == 0 + assert later.data["results"] == [] + + self.client.logout() + self.login(self.staff) + assert self.client.get(self.list_address("nobody-at-all")).status_code == status.HTTP_404_NOT_FOUND + + def test_both_versions_refuse_an_address_that_is_not_exactly_the_callers_name(self): + self.login(self.owner) + shouted = self.owner.username.upper() + for earlier_address, later_address in ( + (earlier_status_address(shouted), self.status_address(shouted)), + (earlier_list_address(shouted), self.list_address(shouted)), + ): + earlier = self.client.get(earlier_address) + later = self.client.get(later_address) + assert earlier.status_code == status.HTTP_403_FORBIDDEN + assert earlier.data == {"detail": "You do not have permission to perform this action."} + assert_error_envelope(later, expected_status=403, expected_type_slug="authz") + + def test_an_anonymous_caller_is_refused_by_both_and_told_so_by_this_one(self): + earlier = self.client.get(earlier_list_address(self.owner.username)) + later = self.client.get(self.list_url) + assert earlier.status_code == later.status_code == status.HTTP_401_UNAUTHORIZED + assert_error_envelope(later, expected_status=401, expected_type_slug="authn") + + +#: What a page of attempts costs, and what a status costs, measured on the +#: version 1 addresses first. The attempts come from four tables, one per kind +#: of check, and the page is cut from what they return, so a page costs the +#: same whatever it carries. A status reads those four tables and then works +#: out when an accepted proof runs out, which costs more. Both versions read +#: the same rows over the same fixtures, so each number is the one number +#: asserted on both addresses: a change on either side is a change in what the +#: answer costs. +LIST_QUERIES = EARLIER_LIST_QUERIES = 18 +STATUS_QUERIES = EARLIER_STATUS_QUERIES = 22 + + +@pytest.fixture(name="verified_user") +@skip_unless_lms +def verified_user_fixture(client, django_user_model): # pylint: disable=unused-argument + """Sign a learner in who has been through several identity checks.""" + owner = UserFactory.create(username="count-verify-owner", password=PASSWORD) + SoftwareSecurePhotoVerification.objects.create(user=owner, status="submitted") + ManualVerification.objects.create(user=owner, status="approved", reason="Checked in person") + for index in range(4): + SSOVerificationFactory(user=owner, status="approved", identity_provider_slug=f"idp{index}") + assert client.login(username=owner.username, password=PASSWORD) + # The first authenticated request of a session populates caches the rest + # of the session reuses; the counts below are the steady-state ones. + client.get(reverse("user_v2:account_verification_list", kwargs={"username": owner.username})) + return owner + + +@pytest.mark.django_db +@pytest.mark.parametrize("page_size", [1, 3, 6]) +@skip_unless_lms +def test_a_page_of_attempts_costs_what_the_earlier_address_costs( + client, verified_user, page_size, django_assert_num_queries, +): + with django_assert_num_queries(EARLIER_LIST_QUERIES): + earlier = client.get(f"/api/user/v1/accounts/{verified_user.username}/verifications/") + assert len(earlier.json()) == 6 + + address = reverse("user_v2:account_verification_list", kwargs={"username": verified_user.username}) + with django_assert_num_queries(LIST_QUERIES): + later = client.get(address, {"page_size": page_size}) + assert later.json()["count"] == 6 + assert len(later.json()["results"]) == page_size + + +@pytest.mark.django_db +@skip_unless_lms +def test_a_status_costs_what_the_earlier_address_costs(client, verified_user, django_assert_num_queries): + with django_assert_num_queries(EARLIER_STATUS_QUERIES): + earlier = client.get(f"/api/user/v1/accounts/{verified_user.username}/verification_status/") + assert earlier.json()["status"] == "approved" + + address = reverse("user_v2:account_verification_status", kwargs={"username": verified_user.username}) + with django_assert_num_queries(STATUS_QUERIES): + later = client.get(address) + assert later.json() == earlier.json() diff --git a/openedx/core/djangoapps/user_api/v2/urls.py b/openedx/core/djangoapps/user_api/v2/urls.py index 911fb321af69..c9a299596cbf 100644 --- a/openedx/core/djangoapps/user_api/v2/urls.py +++ b/openedx/core/djangoapps/user_api/v2/urls.py @@ -5,9 +5,17 @@ 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, @@ -20,6 +28,14 @@ #: 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): """ @@ -48,7 +64,23 @@ class SnakeCaseRouter(SimpleRouter): 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") @@ -56,6 +88,29 @@ class SnakeCaseRouter(SimpleRouter): 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( @@ -64,8 +119,9 @@ class SnakeCaseRouter(SimpleRouter): UserPreferenceViewSet.as_view({"get": "retrieve"}), name="user_preference_detail", ), - # The authentication app serves two addresses under this prefix, which this - # mount is matched before; everything else under it belongs to this version. - # The route answers rather than reverses, so it carries no name. - re_path(r"^(?!account/).*$", UnknownAddressView.as_view()), + # 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 index 6022c5dcec40..7268cce40f36 100644 --- a/openedx/core/djangoapps/user_api/v2/views.py +++ b/openedx/core/djangoapps/user_api/v2/views.py @@ -4,34 +4,83 @@ 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 drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema, extend_schema_view +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, viewsets -from rest_framework.exceptions import NotFound, ValidationError +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 +from .errors import CatalogedRequestErrorsMixin, ErrorExtensionsMixin, RetiredAccount from .filters import StrictOrderingFilter, UserFilterSet, UserPreferenceFilterSet from .paginators import UserApiPagination -from .permissions import IsActiveUser +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, @@ -40,13 +89,21 @@ 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__) -USER_TAG = "user" - #: 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",) @@ -60,6 +117,10 @@ #: 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.""" @@ -102,16 +163,28 @@ def _path_param(name, description, pattern): 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) -#: 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" - _FIELDS_PARAM = _query_param( FIELDS_PARAM, "Comma-separated list of top-level field names to keep in each returned object. " @@ -154,13 +227,50 @@ def _ordering_param(sort_fields, default): _USER_ORDERING_PARAM = _ordering_param(USER_SORT_FIELDS, USER_DEFAULT_SORT) _PREFERENCE_ORDERING_PARAM = _ordering_param(PREFERENCE_SORT_FIELDS, PREFERENCE_DEFAULT_SORT) -_RESP_UNAUTHENTICATED = OpenApiResponse(ErrorResponse, description="The requester is not authenticated.") _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. @@ -173,6 +283,128 @@ def _ordering_param(sort_fields, default): ], ) +_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( @@ -185,6 +417,14 @@ def _ordering_param(sort_fields, default): ) +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.""" @@ -225,52 +465,6 @@ def _requested_field_names(raw): return [name.strip().split(".", 1)[0] for name in raw.split(",") if name.strip()] -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 - }) - - class _ResponsePresetMixin(MinimalViewMixin): """ Applies the ``view`` and ``fields`` presets to an already serialized payload. @@ -280,6 +474,12 @@ class _ResponsePresetMixin(MinimalViewMixin): 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 @@ -287,7 +487,7 @@ class _ResponsePresetMixin(MinimalViewMixin): response_presets = () def initial(self, request, *args, **kwargs): - """Check the requested preset and fields once the caller has been admitted.""" + """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: @@ -346,7 +546,7 @@ def shape_page(self, response, request): responses={ 200: _USER_SHAPES, 400: _RESP_BAD_REQUEST, - 401: _RESP_UNAUTHENTICATED, + 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, 404: _RESP_NO_SUCH_PAGE, }, @@ -358,7 +558,7 @@ def shape_page(self, response, request): responses={ 200: _USER_SHAPES, 400: _RESP_BAD_REQUEST, - 401: _RESP_UNAUTHENTICATED, + 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, 404: _RESP_NOT_FOUND, }, @@ -370,7 +570,7 @@ class UserViewSet( _ActionAddressMixin, _MemberLookupMixin, _ResponsePresetMixin, - _StrictQueryParametersMixin, + StrictQueryParametersMixin, ScopedQuerysetMixin, viewsets.ReadOnlyModelViewSet, ): @@ -430,7 +630,7 @@ def retrieve(self, request, *args, **kwargs): responses={ 200: _PREFERENCE_SHAPES, 400: _RESP_BAD_REQUEST, - 401: _RESP_UNAUTHENTICATED, + 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, 404: _RESP_NO_SUCH_PAGE, }, @@ -452,7 +652,7 @@ def retrieve(self, request, *args, **kwargs): responses={ 200: _PREFERENCE_SHAPES, 400: _RESP_BAD_REQUEST, - 401: _RESP_UNAUTHENTICATED, + 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, 404: _RESP_NOT_FOUND, }, @@ -463,7 +663,7 @@ class UserPreferenceViewSet( CatalogedRequestErrorsMixin, _ActionAddressMixin, _ResponsePresetMixin, - _StrictQueryParametersMixin, + StrictQueryParametersMixin, ScopedQuerysetMixin, viewsets.ReadOnlyModelViewSet, ): @@ -530,7 +730,7 @@ class CountryTimeZoneViewSet( StandardizedErrorMixin, CatalogedRequestErrorsMixin, _ActionAddressMixin, - _StrictQueryParametersMixin, + StrictQueryParametersMixin, IterablePaginationMixin, viewsets.ViewSet, ): @@ -576,6 +776,777 @@ def list(self, request): 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): """ From efcace53ae93adaeab1c3a445e5225ce670b67ff Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Fri, 23 Oct 2026 11:37:35 +0500 Subject: [PATCH 04/82] feat: serve the profile image from /api/user/v2/accounts/{username}/profile_image/ The profile-image endpoints are addressed under the User API but served by a view that belongs to the profile_images app, so the conforming version lives in that app's own new version package and is mounted into the user version two URLconf exactly as the earlier view is imported today. The app's existing modules are untouched, and the image validation they own is called rather than copied. POST /api/user/v2/accounts/{username}/profile_image/ DELETE /api/user/v2/accounts/{username}/profile_image/ An upload is read before anything is stored, so a truncated file, metadata that cannot be parsed, or a header declaring more pixels than the site will ever draw are answered as the caller's error rather than as a server fault, while a failure to store a well-formed image stays a server fault. The media types the address reads are published, and the body is published as required. A caller may set and clear the image on the account the address names and on no other, staff included, since clearing an image is a change to an account rather than a moderation tool. The permission reads the username from the address and compares it exactly, because that is the value the stored names are built from, so the account a caller is admitted for is always the account written to. The tests drive real uploads: each refusal with its error type, parity against the earlier address for the bodies both answer, and a check that a caller holding another spelling of the owner's name is refused and stores nothing. --- .../djangoapps/profile_images/v2/__init__.py | 1 + .../profile_images/v2/serializers.py | 43 + .../profile_images/v2/tests/__init__.py | 1 + .../profile_images/v2/tests/test_views.py | 905 ++++++++++++++++++ .../djangoapps/profile_images/v2/views.py | 255 +++++ 5 files changed, 1205 insertions(+) create mode 100644 openedx/core/djangoapps/profile_images/v2/__init__.py create mode 100644 openedx/core/djangoapps/profile_images/v2/serializers.py create mode 100644 openedx/core/djangoapps/profile_images/v2/tests/__init__.py create mode 100644 openedx/core/djangoapps/profile_images/v2/tests/test_views.py create mode 100644 openedx/core/djangoapps/profile_images/v2/views.py 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]}) From 73e7adf7415972f47a9bc950380641a8e1e58594 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Tue, 27 Oct 2026 00:00:34 +0500 Subject: [PATCH 05/82] fix: let the account tests be collected under the CMS settings This package is shared with the CMS, and the CI shard that runs it under cms.envs.test could not import this module at all: it read settings.ACCOUNT_VISIBILITY_CONFIGURATION, an LMS setting, in the module body. Collection failed, which failed the shard, which cancelled the rest of the matrix. Every test here that reads these sets is already @skip_unless_lms, but a module body runs before any skip can apply, so the setting is now read through the same guard user_api/accounts/tests/test_views.py uses for it, and the field sets fall back to empty when it is absent. Under cms.envs.test this module now collects, and the failures left in these two packages are the same 44 as on master, in the same tests -- an artefact of running two of the shard's sixty-nine paths rather than anything in this branch. Under lms.envs.test nothing changes. --- .../user_api/v2/tests/test_account_views.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) 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 index 4f89185ca2f9..0be8fa555a50 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py @@ -40,15 +40,23 @@ #: The properties every caller may see, whoever they are. PUBLIC_FIELDS = {"account_privacy", "profile_image", "username"} +#: ``ACCOUNT_VISIBILITY_CONFIGURATION`` is an LMS setting, and this package is +#: shared with the CMS. Every test below that reads these sets is +#: ``@skip_unless_lms``, but a module body runs before any skip can apply, so a +#: missing setting is tolerated here instead of breaking collection under the +#: CMS settings. The same guard is used for the same setting in +#: ``user_api/accounts/tests/test_views.py``. +_VISIBILITY = getattr(settings, "ACCOUNT_VISIBILITY_CONFIGURATION", {}) + #: The properties a user sees of their own account, and a staff caller of any. -FULL_FIELDS = set(settings.ACCOUNT_VISIBILITY_CONFIGURATION["admin_fields"]) +FULL_FIELDS = set(_VISIBILITY.get("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"]) +SHARED_FIELDS = set(_VISIBILITY.get("bulk_shareable_fields", ())) #: The properties an owner is able to share, and so the only ones another caller #: may select; the refusal offers them in this order. -SHAREABLE_FIELDS = set(settings.ACCOUNT_VISIBILITY_CONFIGURATION["custom_shareable_fields"]) +SHAREABLE_FIELDS = set(_VISIBILITY.get("custom_shareable_fields", ())) SHAREABLE_FIELDS_OFFERED = ", ".join(sorted(SHAREABLE_FIELDS)) #: The identifying properties the ``minimal`` preset keeps, and the ones of those From 98bc51ad6aa27d0852b823fbdf737ff3f6045ee1 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Mon, 26 Oct 2026 23:32:00 +0500 Subject: [PATCH 06/82] feat: standardize the preferences and email opt-in APIs as /api/user/v2/ Six operations move to this version's conventions: a learner's preference document and its individual members, addressed under the account they belong to, and the email opt-in as a collection of its own. GET, PATCH /api/user/v2/accounts/{username}/preferences/ GET, PUT, DELETE /api/user/v2/accounts/{username}/preferences/{key}/ POST /api/user/v2/email_opt_ins/ A preference name is the whole final segment, punctuation included, so a name carrying a slash has no address of its own; it is read with the whole document and removed by sending a null for it there. The superseded /api/user/v1/ addresses keep answering exactly as they did and are marked deprecated in the published LMS schema. The unversioned twin /user_api/v1/preferences/email_opt_in/ is the same view at a second address: it keeps resolving and carries no schema mark, which is how the other unversioned twins are treated. --- lms/lib/spectacular.py | 104 +- lms/lib/tests/test_spectacular.py | 188 +- .../djangoapps/user_api/v2/serializers.py | 297 ++- .../v2/tests/test_email_opt_in_views.py | 621 ++++++ .../v2/tests/test_preference_parity.py | 409 ++++ .../v2/tests/test_preference_views.py | 1718 +++++++++++++++++ .../user_api/v2/tests/test_query_counts.py | 140 +- .../user_api/v2/tests/test_schema.py | 378 +++- .../djangoapps/user_api/v2/tests/test_urls.py | 172 +- openedx/core/djangoapps/user_api/v2/urls.py | 27 + openedx/core/djangoapps/user_api/v2/views.py | 443 ++++- 11 files changed, 4464 insertions(+), 33 deletions(-) create mode 100644 openedx/core/djangoapps/user_api/v2/tests/test_email_opt_in_views.py create mode 100644 openedx/core/djangoapps/user_api/v2/tests/test_preference_parity.py create mode 100644 openedx/core/djangoapps/user_api/v2/tests/test_preference_views.py diff --git a/lms/lib/spectacular.py b/lms/lib/spectacular.py index 87c31c7aea80..7bff9115c970 100644 --- a/lms/lib/spectacular.py +++ b/lms/lib/spectacular.py @@ -32,6 +32,9 @@ "/api/user/v1/users/{id}/", "/api/user/v1/user_prefs/", "/api/user/v1/user_prefs/{id}/", + "/api/user/v1/preferences/{username}", + "/api/user/v1/preferences/{username}/{preference_key}", + "/api/user/v1/preferences/email_opt_in/", "/api/user/v1/preferences/{pref_key}/users/", "/api/user/v1/forum_roles/{name}/users/", "/api/user/v1/preferences/time_zones/", @@ -39,16 +42,42 @@ # 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 +# to one operation id and are told apart only by a numeral handed out in the +# order the addresses sort. 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. +# +# The two preference reads tokenize to one id for the same reason. The numeral +# a document hands the second of them says which of the two addresses sorts +# first rather than what the operation reads, so the read of one preference is +# named for what it reads and the read of the whole document keeps the name +# both derive. Of the version 1 pair only the document that trims the area off +# the front of each path is covered: the one that trims nothing already +# publishes that read under the numeral, and a name a client imports by is not +# taken away. Neither document has published either name of the version 2 +# pair, so both are covered there, each through the name the document it +# covers derives; that leaves the five operations of that resource carrying the +# same leading tokens in every document, which is what a client generated from +# one takes its module from. RENAMED_OPERATION_IDS = { ("/api/user/v1/accounts", "GET", "api_user_v1_accounts_list"): "api_user_v1_accounts_retrieve", + ("/api/user/v1/preferences/{username}/{preference_key}", "GET", "v1_preferences_retrieve"): ( + "v1_preferences_retrieve_one" + ), + ( + "/api/user/v2/accounts/{username}/preferences/{key}/", + "GET", + "v2_accounts_preferences_retrieve", + ): "v2_accounts_preferences_retrieve_one", + ( + "/api/user/v2/accounts/{username}/preferences/{key}/", + "GET", + "api_user_v2_accounts_preferences_retrieve", + ): "api_user_v2_accounts_preferences_retrieve_one", } PUBLISHED_PATH_PATTERN = re.compile(r"^/api/enrollment/v\d+/") @@ -114,6 +143,8 @@ def superseded_bodies(): # 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 ( + ACCOUNT_PREFERENCE_CHANGES_SCHEMA, + ACCOUNT_PREFERENCES_SCHEMA, AccountSerializer, AccountUpdateSerializer, EmailSearchResultSerializer, @@ -124,6 +155,12 @@ def superseded_bodies(): from openedx.core.lib.api.parsers import MergePatchParser account = {200: AccountSerializer} + # One preference is answered and stored as the bare value, which is what + # the address replacing it carries as a property of an object. + preference_value = {"type": "string"} + preferences_changed = { + 204: OpenApiResponse(description="The preferences now stand as the body asked."), + } described = { ("/api/user/v1/accounts", "GET"): _SupersededBodies(None, {200: AccountSerializer(many=True)}), ("/api/user/v1/accounts/{username}", "GET"): _SupersededBodies(None, account), @@ -151,6 +188,53 @@ def superseded_bodies(): ("/api/user/v1/accounts/{username}/verification_status/", "GET"): _SupersededBodies( None, {200: VerificationStatusSerializer}, ), + ("/api/user/v1/preferences/{username}", "GET"): _SupersededBodies( + None, + { + 200: OpenApiResponse( + ACCOUNT_PREFERENCES_SCHEMA, description="The preferences this account stores.", + ), + }, + ), + ("/api/user/v1/preferences/{username}", "PATCH"): _SupersededBodies( + {MergePatchParser.media_type: ACCOUNT_PREFERENCE_CHANGES_SCHEMA}, preferences_changed, + ), + ("/api/user/v1/preferences/{username}/{preference_key}", "GET"): _SupersededBodies( + None, {200: preference_value}, + ), + ("/api/user/v1/preferences/{username}/{preference_key}", "PUT"): _SupersededBodies( + {"application/json": preference_value}, preferences_changed, + ), + ("/api/user/v1/preferences/{username}/{preference_key}", "DELETE"): _SupersededBodies( + None, {204: OpenApiResponse(description="The account stores no value under that name.")}, + ), + # The handler reads the choice from the submitted form rather than from + # a body of its own, and reads each value as the text a form carries. + ("/api/user/v1/preferences/email_opt_in/", "POST"): _SupersededBodies( + { + "application/x-www-form-urlencoded": { + "type": "object", + "properties": { + "course_id": { + "type": "string", + "description": ( + "Course the organization runs, which is how the organization the " + "choice applies to is identified." + ), + }, + "email_opt_in": { + "type": "string", + "description": ( + "`True` if this user wants messages from that organization. Any " + "other value is read as not wanting them." + ), + }, + }, + "required": ["course_id", "email_opt_in"], + }, + }, + {200: OpenApiResponse(description="The choice was recorded.")}, + ), } # Parameter names are spelled differently at different points of # generation, so an address is keyed by its shape alone. @@ -195,14 +279,14 @@ class LmsAutoSchema(AutoSchema): marking a body the operation cannot be called without as one it needs. 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. + twin, so the two would be told apart only by a numeral suffix, which says + which of the two addresses sorts first rather than anything about the + operation; 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 address ``RENAMED_OPERATION_IDS`` does not + name 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): diff --git a/lms/lib/tests/test_spectacular.py b/lms/lib/tests/test_spectacular.py index 90d7f9a2a382..7ab394b7f514 100644 --- a/lms/lib/tests/test_spectacular.py +++ b/lms/lib/tests/test_spectacular.py @@ -61,15 +61,20 @@ def test_keeps_enrollment_this_version_and_the_addresses_it_supersedes(self): _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), + _endpoint("/api/user/v1/preferences/{username}", USER_API_V1_VIEWS), + _endpoint( + "/api/user/v1/preferences/{username}/{preference_key}", USER_API_V1_VIEWS, + ), + _endpoint("/api/user/v1/preferences/email_opt_in/", 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), + _endpoint("/user_api/v1/preferences/email_opt_in/", USER_API_V1_VIEWS), ]) assert [path for path, *_ in kept] == [ "/api/enrollment/v2/enrollments/", @@ -83,6 +88,9 @@ def test_keeps_enrollment_this_version_and_the_addresses_it_supersedes(self): "/api/user/v1/me", "/api/user/v1/accounts/{username}/image", "/api/user/v1/accounts/name_change/", + "/api/user/v1/preferences/{username}", + "/api/user/v1/preferences/{username}/{preference_key}", + "/api/user/v1/preferences/email_opt_in/", "/api/user/v2/accounts/{username}/profile_image/", ] @@ -129,11 +137,15 @@ def test_only_the_listed_user_addresses_are_deprecated(self): "/api/user/v1/accounts/{username}/verification_status/", "/api/user/v1/accounts/{username}/verifications/", "/api/user/v1/preferences/{username}", + "/api/user/v1/preferences/{username}/{preference_key}", + "/api/user/v1/preferences/email_opt_in/", "/api/profile_images/v1/{username}/upload", "/api/user/v2/users/", "/api/user/v2/accounts/{username}/profile_image/", + "/api/user/v2/accounts/{username}/preferences/", "/user_api/v1/users/", "/user_api/v1/accounts/{username}", + "/user_api/v1/preferences/email_opt_in/", ), None, None, False) paths = result["paths"] for marked in ( @@ -149,15 +161,19 @@ def test_only_the_listed_user_addresses_are_deprecated(self): "/api/user/v1/accounts/name_change/{username}/confirm/", "/api/user/v1/accounts/{username}/verification_status/", "/api/user/v1/accounts/{username}/verifications/", + "/api/user/v1/preferences/{username}", + "/api/user/v1/preferences/{username}/{preference_key}", + "/api/user/v1/preferences/email_opt_in/", ): assert paths[marked]["get"]["deprecated"] is True, marked for untouched in ( - "/api/user/v1/preferences/{username}", "/api/profile_images/v1/{username}/upload", "/api/user/v2/users/", "/api/user/v2/accounts/{username}/profile_image/", + "/api/user/v2/accounts/{username}/preferences/", "/user_api/v1/users/", "/user_api/v1/accounts/{username}", + "/user_api/v1/preferences/email_opt_in/", ): assert "deprecated" not in paths[untouched]["get"], untouched @@ -185,7 +201,7 @@ def retrieve(self, request, pk=None): class LmsAutoSchemaTest(SimpleTestCase): - """Dual mounts get deterministic operationIds instead of registration-order numeral suffixes.""" + """Dual mounts get deterministic operationIds instead of numeral suffixes.""" def test_is_the_default_schema_class(self): """Views without their own ``schema`` pick this up via REST_FRAMEWORK settings.""" @@ -235,6 +251,11 @@ def _user_routes(schema_class): view.as_view({"get": "retrieve"}), ), path("api/user/v1/users/", view.as_view({"get": "list"})), + path("api/user/v2/accounts//preferences/", view.as_view({"get": "retrieve"})), + path( + "api/user/v2/accounts//preferences//", + view.as_view({"get": "retrieve"}), + ), path("api/user/v2/users/", view.as_view({"get": "list"})), path("api/enrollment/v1/enrollments/", view.as_view({"get": "list"})), # Outside ``/api/``, so a document that derives its prefix from the @@ -244,24 +265,37 @@ def _user_routes(schema_class): def _derived_operations(schema_class, patches): - """The names ``schema_class`` produces with nothing pinned, which is what the generator derives.""" + """ + The names ``schema_class`` produces with nothing pinned, which is what the generator derives. + + The paths are read as they are assembled, which is before the numeral + telling two addresses that derived one name apart is handed out: a name + carrying that numeral was derived without it, and is looked up without it. + """ with mock.patch.dict("lms.lib.spectacular.RENAMED_OPERATION_IDS", {}, clear=True): - return _named_operations(schema_class, patches) + with patched_settings({"PREPROCESSING_HOOKS": [], **patches}): + generator = SchemaGenerator(patterns=_user_routes(schema_class)) + return _by_address(generator.parse(None, public=True)) def _named_operations(schema_class, patches): """The ``{address: (operationId, tags)}`` ``schema_class`` produces under ``patches``.""" with patched_settings({"PREPROCESSING_HOOKS": [], **patches}): schema = SchemaGenerator(patterns=_user_routes(schema_class)).get_schema(request=None, public=True) + return _by_address(schema["paths"]) + + +def _by_address(paths): + """The ``{address: (operationId, tags)}`` the paths of a document carry.""" return { route: (operation["operationId"], tuple(operation["tags"])) - for route, path_item in schema["paths"].items() + for route, path_item in 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.""" + """A user address keeps the name the generator derives unless one is pinned for it.""" def test_the_service_wide_document_keeps_every_name_the_generator_derives(self): """Each of these is the name that document already published, and a client imports it by name.""" @@ -272,6 +306,10 @@ def test_the_service_wide_document_keeps_every_name_the_generator_derives(self): "/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/accounts/{username}/preferences/": + ("api_user_v2_accounts_preferences_retrieve", ("api",)), + "/api/user/v2/accounts/{username}/preferences/{key}/": + ("api_user_v2_accounts_preferences_retrieve_one", ("api",)), "/api/user/v2/users/": ("api_user_v2_users_list", ("api",)), "/api/enrollment/v1/enrollments/": ("api_enrollment_v1_enrollments_list", ("api",)), "/user_api/v1/preferences/{username}": ("user_api_v1_preferences_retrieve", ("user_api",)), @@ -292,14 +330,34 @@ def test_a_user_address_is_grouped_by_area_where_the_derived_tag_would_name_a_ve "/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/preferences/{username}/{preference_key}": ( + "v1_preferences_retrieve_one", ("user",), + ), "/api/user/v1/users/": ("v1_users_list", ("user",)), + "/api/user/v2/accounts/{username}/preferences/": ( + "v2_accounts_preferences_retrieve", ("user",), + ), + "/api/user/v2/accounts/{username}/preferences/{key}/": ( + "v2_accounts_preferences_retrieve_one", ("user",), + ), "/api/user/v2/users/": ("v2_users_list", ("user",)), "/api/enrollment/v1/enrollments/": ("v1_enrollments_list", ("v1",)), "/user_api/v1/preferences/{username}": ("user_api_v1_preferences_retrieve", ("user_api",)), } +#: The preference document a superseded read answers with, spelled out here +#: rather than read from the module that publishes it: an edit to the shape +#: that module publishes is then an edit this test sees. +PREFERENCE_DOCUMENT_BODY = { + "type": "object", + "additionalProperties": {"type": "string"}, + "description": ( + "Preferences of one account, as an object of preference name to stored value. Every " + "value is text. Which names an account carries depends on the features it uses." + ), +} + #: What each superseded operation the schema describes is expected to exchange: #: the media types of the body it reads, and the success code with the body it #: answers with. ``None`` is a response code carrying no body at all. @@ -322,17 +380,33 @@ def test_a_user_address_is_grouped_by_area_where_the_derived_tag_would_name_a_ve ), ("/api/user/v1/accounts/{username}/image", "delete"): ([], {"204": None}), ("/api/user/v1/accounts/{username}/verification_status/", "get"): ([], {"200": "VerificationStatus"}), + ("/api/user/v1/preferences/{username}", "get"): ([], {"200": PREFERENCE_DOCUMENT_BODY}), + ("/api/user/v1/preferences/{username}", "patch"): (["application/merge-patch+json"], {"204": None}), + ("/api/user/v1/preferences/{username}/{preference_key}", "get"): ([], {"200": {"type": "string"}}), + ("/api/user/v1/preferences/{username}/{preference_key}", "put"): (["application/json"], {"204": None}), + ("/api/user/v1/preferences/{username}/{preference_key}", "delete"): ([], {"204": None}), + ("/api/user/v1/preferences/email_opt_in/", "post"): ( + ["application/x-www-form-urlencoded"], {"200": None}, + ), } def _named_body(response): - """Return the component a response carries, suffixed ``[]`` for an array, or ``None``.""" + """ + Return what a response carries: the component it names, or the shape itself. + + A component is named by its own name, suffixed ``[]`` where the body is an + array of it; a body described in place comes back as that description, and + a response with no body at all as ``None``. + """ content = response.get("content") if not content: return None body = content["application/json"]["schema"] if body.get("type") == "array": return f"{body['items']['$ref'].rsplit('/', 1)[-1]}[]" + if "$ref" not in body: + return body return body["$ref"].rsplit("/", 1)[-1] @@ -379,6 +453,57 @@ def test_no_superseded_operation_answers_with_a_body_it_does_not_describe(self): assert undescribed == [] +class SupersededBodyInTheServiceWideDocumentTest(TestCase): + """ + The same bodies describe these addresses in the document that covers the whole service. + + That document publishes every endpoint rather than this area alone, and it + already carried these addresses before they were described, so the table + changes what they publish there instead of adding them. Two of them + answered with no body all along and are published with the code they + really answer with. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + with patched_settings(get_api_docs_settings()): + cls.schema = SchemaGenerator().get_schema(request=None, public=True) + + def test_each_superseded_operation_publishes_the_same_exchange_there(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_the_two_changes_that_answer_with_nothing_publish_only_that(self): + """Both handlers answer with no body, which is not what that document carried for them.""" + answered = { + (address, method): sorted(self.schema["paths"][address][method]["responses"]) + for address, method in ( + ("/api/user/v1/preferences/{username}", "patch"), + ("/api/user/v1/preferences/{username}/{preference_key}", "put"), + ) + } + assert answered == { + ("/api/user/v1/preferences/{username}", "patch"): ["204"], + ("/api/user/v1/preferences/{username}/{preference_key}", "put"): ["204"], + } + + def test_the_twin_of_the_opt_in_is_left_as_that_document_always_published_it(self): + """It keeps resolving, is described by nothing here, and is not marked superseded.""" + twin = self.schema["paths"]["/user_api/v1/preferences/email_opt_in/"]["post"] + assert "requestBody" not in twin + assert sorted(twin["responses"]) == ["200"] + assert "content" not in twin["responses"]["200"] + assert twin.get("deprecated", False) is False + + class PublishedDocumentTest(TestCase): """What the service actually publishes: distinct ids, and one area per tag group.""" @@ -399,15 +524,52 @@ 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.""" + def test_the_addresses_told_apart_by_a_numeral_suffix_are_the_ones_that_tokenize_alike(self): + """ + Two addresses differing only in a path parameter derive one name, and one takes a numeral. + + The numeral is handed out once the whole document is assembled, to + every address but the first of those whose names came out alike, so it + tells a reader which address sorts first rather than what the + operation reads. Every pair this document publishes is named apart + instead, and the one below keeps the numeral it is published under. + """ 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"} + assert suffixed == { + "/api/enrollment/v1/enrollment/{course_id}": "v1_enrollment_retrieve_2", + } + + def test_a_read_of_one_preference_is_named_for_the_one_preference_it_reads(self): + """ + The read of a whole preference document and the read of one of its names are named apart. + + Both addresses derive one name, which the assembled document would + tell apart with a numeral, so the name a client imports for the read + of one preference would say which of the two addresses sorts first + rather than what it answers with. Each version names that read for + what it reads, and the read of the whole document keeps the name both + addresses derive. + """ + ids = { + (route, method): operation["operationId"] + for route, path_item in self.schema["paths"].items() + for method, operation in path_item.items() + if isinstance(operation, dict) and "operationId" in operation + } + assert ids[("/api/user/v1/preferences/{username}", "get")] == "v1_preferences_retrieve" + assert ids[("/api/user/v1/preferences/{username}/{preference_key}", "get")] == ( + "v1_preferences_retrieve_one" + ) + assert ids[("/api/user/v2/accounts/{username}/preferences/", "get")] == ( + "v2_accounts_preferences_retrieve" + ) + assert ids[("/api/user/v2/accounts/{username}/preferences/{key}/", "get")] == ( + "v2_accounts_preferences_retrieve_one" + ) def test_a_tag_group_holds_one_area_unless_it_marks_the_client_library(self): """ diff --git a/openedx/core/djangoapps/user_api/v2/serializers.py b/openedx/core/djangoapps/user_api/v2/serializers.py index c90d28676782..cb1cba5590d7 100644 --- a/openedx/core/djangoapps/user_api/v2/serializers.py +++ b/openedx/core/djangoapps/user_api/v2/serializers.py @@ -1,15 +1,20 @@ """Serializers for version 2 of the User API.""" +import re from django.contrib.auth.models import User # pylint: disable=imported-auth-user from django.urls import reverse from drf_spectacular.utils import extend_schema_field from edx_rest_framework_extensions.errors import ErrorResponseSerializer from rest_framework import serializers +from rest_framework.fields import empty +from rest_framework.settings import api_settings +from rest_framework.validators import ProhibitSurrogateCharactersValidator from lms.djangoapps.verify_student.models import ManualVerification, SoftwareSecurePhotoVerification -from openedx.core.djangoapps.user_api.models import UserPreference +from openedx.core.djangoapps.user_api.models import UserOrgTag, UserPreference from openedx.core.djangoapps.user_authn.views.registration_form import contains_html, contains_url +from openedx.core.lib.api.serializers import CourseKeyField from .paginators import UserApiPagination @@ -786,3 +791,293 @@ class VerificationQuerySerializer(serializers.Serializer): # pylint: disable=ab "moment are ordered by the receipt identifier, so a page is always in the same order." ), ) + + +#: Longest name a preference can be stored under, taken from the column that +#: stores it. +PREFERENCE_NAME_MAX_LENGTH = UserPreference._meta.get_field("key").max_length + +#: Refusal of a name longer than a preference can be stored under. +PREFERENCE_NAME_TOO_LONG = ( + f"A preference name is at most {PREFERENCE_NAME_MAX_LENGTH} characters long." +) + +#: What a name must carry to be stored under, taken from the column that stores +#: it. The column looks for this anywhere in the name rather than over the +#: whole of it, so the same search is made here and the names refused are the +#: names the store refuses. +PREFERENCE_NAME_PATTERN = UserPreference.KEY_REGEX + +#: Refusal of a name nothing can be stored under at all. +PREFERENCE_NAME_UNUSABLE = ( + "A preference name carries at least one letter, digit, hyphen or underscore." +) + + +def why_a_name_cannot_be_stored(name): + """Return why nothing can be stored under this name, or nothing if something can.""" + if len(name) > PREFERENCE_NAME_MAX_LENGTH: + return [PREFERENCE_NAME_TOO_LONG] + if not re.search(PREFERENCE_NAME_PATTERN, name): + return [PREFERENCE_NAME_UNUSABLE] + return [] + +#: Refuses text that has no encoding of its own, which is text that can neither +#: be stored nor be carried back in an answer. +_REFUSE_UNENCODABLE_TEXT = ProhibitSurrogateCharactersValidator() + + +def _members_at_fault(refusal): + """Return a refusal keyed by the body member it is about, or against the body itself.""" + if isinstance(refusal, dict): + return refusal + return {api_settings.NON_FIELD_ERRORS_KEY: refusal} + + +def _why_text_cannot_be_carried(text): + """Return why this text can be neither stored nor answered with, or nothing if it can.""" + try: + _REFUSE_UNENCODABLE_TEXT(text) + except serializers.ValidationError as refused: + return list(refused.detail) + return [] + + +#: What the preference document of one account looks like: an object whose +#: every member is a preference name and the text stored under it. The +#: document has no fixed set of members -- any name an account's own features +#: store a value under is one of them -- so the shape of its members is named +#: here rather than declared as properties, and the fields below carry it: +#: a body named by a field alone is published as an object of no stated shape. +ACCOUNT_PREFERENCES_SCHEMA = { + "type": "object", + "additionalProperties": {"type": "string"}, + "description": ( + "Preferences of one account, as an object of preference name to stored value. Every " + "value is text. Which names an account carries depends on the features it uses." + ), +} + + +#: What a merge patch of the preference document carries: the same object as +#: the document, except that a member may also be null. +ACCOUNT_PREFERENCE_CHANGES_SCHEMA = { + "type": "object", + "additionalProperties": {"type": "string", "nullable": True}, + "description": ( + "Preferences to change, as an object of preference name to the value to store under it. " + "Every value is text, and a null removes the preference. Names the body leaves out are " + "left as they are." + ), +} + + +@extend_schema_field(ACCOUNT_PREFERENCES_SCHEMA) +class AccountPreferenceDocumentField(serializers.DictField): + """ + The preferences of one account, as the body they are answered in. + + Every stored value is text and the names are answered sorted by name, + character by character and by the code of each character, rather than in + the order they were stored. Both are settled here so that the body cannot + say something the shape above does not. + """ + + child = serializers.CharField( + help_text="Value stored under the preference name this member is, which is always text.", + ) + + def to_representation(self, value): + """Return the stored preferences as the document, by name.""" + return super().to_representation(dict(sorted(value.items()))) + + +class _PreferenceChangeField(_TextField): + """The value to store under one preference name, which a null removes instead.""" + + default_error_messages = {"invalid": "Send a value as text, or null to remove the preference."} + + +@extend_schema_field(ACCOUNT_PREFERENCE_CHANGES_SCHEMA) +class AccountPreferenceChangesField(serializers.DictField): + """ + The members a merge patch of a preference document carries. + + Each value is read as the text to store under the name carrying it, and a + null is read as a removal. An empty value is read rather than refused, + because what a preference does with one is the preference service's to + decide and it answers for every address storing a value. + + A body that is not an object, and one that names nothing to change, are + refused by the field as part of reading it: only the wording of those two + refusals is settled here, and it is the wording this version answers with + for the other body read as a merge patch. + """ + + default_error_messages = { + "not_a_dict": "Send the preferences to change as an object.", + "empty": "Name at least one preference to change.", + } + + child = _PreferenceChangeField( + allow_null=True, + allow_blank=True, + help_text="Value to store under the preference name this member is, or null to remove it.", + ) + + +#: Formats the preference document of one account into the body it is answered in. +ACCOUNT_PREFERENCE_DOCUMENT = AccountPreferenceDocumentField() + +#: Reads the members a merge patch of a preference document carries. A body +#: naming nothing is refused rather than read as a change that does nothing, +#: since a caller who sent one has asked for something that did not arrive. +ACCOUNT_PREFERENCE_CHANGES = AccountPreferenceChangesField(allow_empty=False) + + +class AccountPreferencesUpdateSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + The changes a merge patch of an account's preference document carries. + + The document has no fixed set of members, so the body is read member by + member against the field describing one change instead of against + declared fields. Refusals are reported under the preference name that + carried the value at fault, which is the member of the body a caller + would correct; a name that could not itself be carried back is reported + against the body, since the answer could not name it. + """ + + def to_internal_value(self, data): + """Return the changes sent, refusing a body, a name or a value that cannot be stored.""" + names = data.keys() if isinstance(data, dict) else () + unnameable = sorted({ + message for name in names for message in _why_text_cannot_be_carried(name) + }) + if unnameable: + raise serializers.ValidationError({api_settings.NON_FIELD_ERRORS_KEY: unnameable}) + # A name longer than one can be stored under is reported under itself, + # ahead of whatever the value carried by it is refused for: the name is + # the part of that member a caller has to change either way. + too_long = { + name: [PREFERENCE_NAME_TOO_LONG] + for name in names + if len(name) > PREFERENCE_NAME_MAX_LENGTH + } + try: + changes = ACCOUNT_PREFERENCE_CHANGES.run_validation(data) + except serializers.ValidationError as refused: + raise serializers.ValidationError({ + **_members_at_fault(refused.detail), **too_long, + }) from refused + if too_long: + raise serializers.ValidationError(too_long) + return changes + + +class AccountPreferenceSerializer(serializers.Serializer): # pylint: disable=abstract-method + """One preference of one account.""" + + key = serializers.CharField(help_text="Name the preference is stored under.") + value = serializers.CharField(help_text="Value stored under that name, which is always text.") + + +class AccountPreferenceValueSerializer(serializers.Serializer): # pylint: disable=abstract-method + """The value to store under one preference of one account.""" + + value = _TextField( + help_text=( + "Value to store, as text. A preference cannot hold an empty value; remove the " + "preference instead." + ), + ) + + +@extend_schema_field(serializers.CharField) +class _CourseKeyField(CourseKeyField): + """ + A course, read from and published as the text that writes its key. + + The platform's field turns that text into the key object the services + behind these endpoints take, and describes itself to no document, so the + text form is named here. It reads a value of any other type by splitting + it, which no value but text can be, so one is refused instead. + """ + + default_error_messages = {"invalid": "Send the course key as the text that writes it."} + + def to_internal_value(self, data): + """Return the course the text names, refusing a value that is not text.""" + if not isinstance(data, str): + self.fail("invalid") + return super().to_internal_value(data) + + +#: Longest organization name a choice can be recorded against, taken from the +#: column the choice is recorded in. +ORGANIZATION_MAX_LENGTH = UserOrgTag._meta.get_field("org").max_length + +#: Refusal of a key that names something other than a course. Some keys of +#: other kinds are read by the platform's course key reader for historical +#: reasons and say themselves that they name no course. +NOT_A_COURSE = ( + "This key names something other than a course, and the organization a choice is recorded " + "against is the one running a course." +) + +#: Refusal of a course whose organization no choice can be recorded against. +ORGANIZATION_TOO_LONG = ( + f"The organization running this course is named by more than {ORGANIZATION_MAX_LENGTH} " + "characters, which is more than a choice can be recorded against." +) + +#: What the choice itself is published and read as. +EMAIL_OPT_IN_HELP = ( + "Whether this user wants messages from that organization, sent as true or false. A value " + "that says neither is refused rather than read as one of them." +) + + +class _EmailOptInChoiceField(serializers.BooleanField): + """ + A yes or no about messages, read from a boolean or from the word for one. + + The value records consent, so it is read strictly: a boolean, or the text + ``true`` or ``false`` in any case. The further spellings a boolean field + admits (``1``, ``on``, ``yes``) are refused rather than read, because a + caller who sends one of them has not said which choice they mean and a + choice recorded the wrong way round is not something they can see. For the + same reason a body that leaves the choice out is refused instead of being + read as an unticked checkbox, which is what an absent form member would + otherwise mean. + """ + + default_empty_html = empty + default_error_messages = {"invalid": "Send the choice as true or false."} + + def to_internal_value(self, data): + """Return the choice sent, refusing a value that does not say which choice it is.""" + if isinstance(data, bool): + return data + if isinstance(data, str) and data.lower() in ("true", "false"): + return data.lower() == "true" + self.fail("invalid") + + +class EmailOptInSerializer(serializers.Serializer): # pylint: disable=abstract-method + """A user's choice about messages from the organization running a course.""" + + course_key = _CourseKeyField( + help_text=( + "Course the organization runs, which is how the organization the choice applies to " + "is identified." + ), + ) + email_opt_in = _EmailOptInChoiceField(help_text=EMAIL_OPT_IN_HELP) + + def validate_course_key(self, value): + """Return the course, refusing a key that names none and one whose organization is too long.""" + if not value.is_course: + raise serializers.ValidationError(NOT_A_COURSE) + if len(value.org) > ORGANIZATION_MAX_LENGTH: + raise serializers.ValidationError(ORGANIZATION_TOO_LONG) + return value diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_email_opt_in_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_email_opt_in_views.py new file mode 100644 index 000000000000..e184d615ba8b --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_email_opt_in_views.py @@ -0,0 +1,621 @@ +"""Tests for the version 2 User API email opt-in address.""" + +import datetime +from unittest import mock +from urllib.parse import urlencode + +from django.conf import settings +from django.urls import reverse +from edx_rest_framework_extensions.testing import assert_error_envelope +from rest_framework import status +from rest_framework.test import APIClient, APITestCase + +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user +from openedx.core.djangoapps.user_api.errors import UserAPIInternalError +from openedx.core.djangoapps.user_api.models import UserOrgTag +from openedx.core.djangoapps.user_api.v2.serializers import ( + NOT_A_COURSE, + ORGANIZATION_MAX_LENGTH, + ORGANIZATION_TOO_LONG, +) +from openedx.core.djangolib.testing.utils import skip_unless_lms + +from .test_preference_views import requests_served_outside_a_transaction + +PASSWORD = "Password1234" + +#: The address the choice is recorded at, and the one it supersedes. +EARLIER_ADDRESS = "/api/user/v1/preferences/email_opt_in/" +UNVERSIONED_EARLIER_ADDRESS = "/user_api/v1/preferences/email_opt_in/" + +COURSE_KEY = "course-v1:edX+DemoX+Demo" +OTHER_COURSE_KEY = "course-v1:edX+OtherX+Other" +OTHER_ORG_COURSE_KEY = "course-v1:MITx+6.002x+2026" + +#: The form a course key was written in before the current one, which the +#: platform still reads. +EARLIER_COURSE_KEY = "edX/DemoX/Demo" + +#: A key the platform's course key reader reads, and that names a library +#: rather than a course. It carries an organization like a course key does, +#: which is the whole of what this address takes a key for. +LIBRARY_KEY = "library-v1:edX+lib" + +#: Where the choice is stored: one row for each user and organization. +OPT_IN_KEY = "email-optin" + +#: What such a row holds when it was opened and no choice was written into it. +NO_CHOICE_AT_ALL = "" + +#: An age below the one a guardian's consent is needed under. +MINOR_AGE = 10 + +#: Values that suggest a choice without saying which one, each of which the +#: earlier address recorded as a choice not to receive messages. +SUGGESTED_CHOICES = ("1", "0", "on", "off", "yes", "no", "t", "f", "y", "n", 1, 0) + +#: Refusal of a value that does not say which choice it is. +CHOICE_REFUSAL = "Send the choice as true or false." + +#: A course whose organization is as long as the column that records a choice +#: against it holds, and one whose organization is a character longer. +LONGEST_ORG = "o" * ORGANIZATION_MAX_LENGTH +TOO_LONG_ORG = "o" * (ORGANIZATION_MAX_LENGTH + 1) + + +@skip_unless_lms +class EmailOptInTestCase(APITestCase): + """Fixtures shared by the version 2 email opt-in tests.""" + + def setUp(self): + super().setUp() + self.user = UserFactory.create( + username="optin-user", email="optin-user@example.com", password=PASSWORD, + ) + self.other = UserFactory.create( + username="optin-other", email="optin-other@example.com", password=PASSWORD, + ) + self.url = reverse("user_v2:email_opt_in_list") + + def login(self, user): + """Sign the given user in through the session this view accepts.""" + assert self.client.login(username=user.username, password=PASSWORD) + + def token_headers(self, user): + """Return the request headers that carry a token identifying the given user.""" + return {"HTTP_AUTHORIZATION": f"JWT {create_jwt_for_user(user)}"} + + def choose(self, course_key=COURSE_KEY, opt_in=True, address=None, **kwargs): + """Record a choice at the conforming address.""" + return self.client.post( + address or self.url, + {"course_key": course_key, "email_opt_in": opt_in}, + format="json", + **kwargs, + ) + + def choices(self, user=None): + """Return the choices one account has made, by organization.""" + return { + tag.org: tag.value + for tag in UserOrgTag.objects.filter(user=user or self.user, key=OPT_IN_KEY) + } + + def make_minor(self, user): + """Record the given account as belonging to someone a guardian answers for.""" + user.profile.year_of_birth = datetime.date.today().year - MINOR_AGE + user.profile.save() + return user + + +class EmailOptInViewTests(EmailOptInTestCase): + """POST /api/user/v2/email_opt_ins/""" + + def test_a_choice_to_receive_messages_is_recorded_for_the_organization(self): + self.login(self.user) + response = self.choose() + assert response.status_code == status.HTTP_204_NO_CONTENT + assert response.content == b"" + assert self.choices() == {"edX": "True"} + + def test_a_choice_not_to_receive_them_is_recorded_too(self): + self.login(self.user) + assert self.choose(opt_in=False).status_code == status.HTTP_204_NO_CONTENT + assert self.choices() == {"edX": "False"} + + def test_a_choice_replaces_the_one_that_account_made_before(self): + self.login(self.user) + self.choose(opt_in=True) + assert self.choose(opt_in=False).status_code == status.HTTP_204_NO_CONTENT + assert self.choices() == {"edX": "False"} + assert UserOrgTag.objects.filter(user=self.user, key=OPT_IN_KEY).count() == 1 + + def test_the_choice_covers_every_course_the_organization_runs(self): + self.login(self.user) + self.choose(course_key=COURSE_KEY, opt_in=True) + assert self.choose( + course_key=OTHER_COURSE_KEY, opt_in=False, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.choices() == {"edX": "False"} + + def test_a_choice_is_kept_for_each_organization_on_its_own(self): + self.login(self.user) + self.choose(course_key=COURSE_KEY, opt_in=True) + self.choose(course_key=OTHER_ORG_COURSE_KEY, opt_in=False) + assert self.choices() == {"edX": "True", "MITx": "False"} + + def test_a_course_key_written_in_the_earlier_form_is_still_read(self): + self.login(self.user) + assert self.choose( + course_key=EARLIER_COURSE_KEY, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.choices() == {"edX": "True"} + + def test_a_choice_submitted_as_a_form_is_read(self): + """The page a learner makes this choice on submits a form.""" + self.login(self.user) + response = self.client.post( + self.url, {"course_key": COURSE_KEY, "email_opt_in": "true"}, format="multipart", + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert self.choices() == {"edX": "True"} + + def test_an_account_a_guardian_answers_for_is_recorded_as_not_receiving_them(self): + """Whatever the body asks for, consent decides, as it does at the earlier address.""" + self.make_minor(self.user) + self.login(self.user) + assert self.choose(opt_in=True).status_code == status.HTTP_204_NO_CONTENT + assert self.choices() == {"edX": "False"} + + def test_the_choice_is_recorded_for_the_caller_and_nobody_else(self): + """No account is named in the address or read from the body.""" + self.login(self.user) + response = self.client.post( + self.url, + { + "course_key": COURSE_KEY, + "email_opt_in": True, + "username": self.other.username, + "user": self.other.id, + }, + format="json", + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert self.choices() == {"edX": "True"} + assert self.choices(self.other) == {} + + def test_a_value_that_is_neither_true_nor_false_is_refused(self): + """The earlier address read anything other than ``True`` as a choice not to receive them.""" + self.login(self.user) + envelope = assert_error_envelope( + self.choose(opt_in="Banana"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"email_opt_in": [CHOICE_REFUSAL]} + assert self.choices() == {} + + def test_the_choice_is_read_from_the_word_for_it_in_any_case(self): + self.login(self.user) + for sent, recorded in ( + ("true", "True"), ("True", "True"), ("TRUE", "True"), + ("false", "False"), ("False", "False"), ("FALSE", "False"), + ): + assert self.choose(opt_in=sent).status_code == status.HTTP_204_NO_CONTENT, sent + assert self.choices() == {"edX": recorded}, sent + + def test_a_value_that_only_suggests_a_choice_is_refused_rather_than_read(self): + """ + A value a boolean field would read one way and the earlier address the + other records nothing: it is refused, and a choice already made stands. + """ + self.login(self.user) + assert self.choose(opt_in=True).status_code == status.HTTP_204_NO_CONTENT + for suggestion in SUGGESTED_CHOICES: + envelope = assert_error_envelope( + self.choose(opt_in=suggestion), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"email_opt_in": [CHOICE_REFUSAL]}, suggestion + assert self.choices() == {"edX": "True"}, suggestion + + def test_a_form_that_leaves_the_choice_out_is_refused(self): + """An absent form member is a missing choice here, not an unticked checkbox.""" + self.login(self.user) + assert self.choose(opt_in=True).status_code == status.HTTP_204_NO_CONTENT + bodies = ( + self.client.post(self.url, {"course_key": COURSE_KEY}, format="multipart"), + self.client.post( + self.url, + urlencode({"course_key": COURSE_KEY}), + content_type="application/x-www-form-urlencoded", + ), + ) + for answer in bodies: + envelope = assert_error_envelope( + answer, + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"email_opt_in": ["This field is required."]} + assert self.choices() == {"edX": "True"} + + def test_a_form_carrying_the_choice_empty_is_refused(self): + self.login(self.user) + envelope = assert_error_envelope( + self.client.post( + self.url, {"course_key": COURSE_KEY, "email_opt_in": ""}, format="multipart", + ), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"email_opt_in": [CHOICE_REFUSAL]} + assert self.choices() == {} + + def test_a_course_whose_organization_fills_the_column_is_recorded(self): + self.login(self.user) + assert self.choose( + course_key=f"course-v1:{LONGEST_ORG}+DemoX+Demo", + ).status_code == status.HTTP_204_NO_CONTENT + assert self.choices() == {LONGEST_ORG: "True"} + + def test_a_course_whose_organization_is_longer_than_the_column_is_refused(self): + """The organization is the one part of the body no key reader bounds.""" + self.login(self.user) + envelope = assert_error_envelope( + self.choose(course_key=f"course-v1:{TOO_LONG_ORG}+DemoX+Demo"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"course_key": [ORGANIZATION_TOO_LONG]} + assert str(ORGANIZATION_MAX_LENGTH) in envelope["errors"]["course_key"][0] + assert self.choices() == {} + + def test_a_course_key_that_cannot_be_read_is_refused(self): + self.login(self.user) + envelope = assert_error_envelope( + self.choose(course_key="not a key"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"course_key": ["Invalid course key"]} + assert self.choices() == {} + + def test_a_key_that_names_something_other_than_a_course_is_refused(self): + """A choice is recorded against the organization running a course, so a key names one.""" + self.login(self.user) + envelope = assert_error_envelope( + self.choose(course_key=LIBRARY_KEY), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"course_key": [NOT_A_COURSE]} + assert self.choices() == {} + + def test_a_course_named_by_something_other_than_text_is_refused(self): + """A key is read by splitting it, which only text can be, so it is refused as a body fault.""" + self.login(self.user) + for named_by in (5, 2.5, True, [COURSE_KEY], {"id": COURSE_KEY}): + envelope = assert_error_envelope( + self.choose(course_key=named_by), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "course_key": ["Send the course key as the text that writes it."], + }, named_by + assert self.choices() == {} + + def test_a_course_named_by_nothing_at_all_is_refused(self): + self.login(self.user) + envelope = assert_error_envelope( + self.choose(course_key=None), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"course_key": ["This field may not be null."]} + assert self.choices() == {} + + def test_a_body_missing_either_half_of_the_choice_is_refused(self): + self.login(self.user) + envelope = assert_error_envelope( + self.client.post(self.url, {}, format="json"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "course_key": ["This field is required."], + "email_opt_in": ["This field is required."], + } + + def test_a_body_in_a_media_type_this_address_does_not_read_is_refused(self): + self.login(self.user) + assert_error_envelope( + self.client.post( + self.url, "{}", content_type="application/merge-patch+json", + ), + expected_status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + expected_type_slug="unsupported-media-type", + ) + + def test_a_body_that_is_not_well_formed_is_refused(self): + self.login(self.user) + assert_error_envelope( + self.client.post(self.url, "{not json", content_type="application/json"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="malformed-body", + ) + + def test_the_choices_made_are_not_listed_here(self): + """This collection records a choice; nothing reads it back.""" + self.login(self.user) + for method in ("get", "put", "patch", "delete"): + assert_error_envelope( + getattr(self.client, method)(self.url), + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug="method-not-allowed", + ) + + def test_a_query_parameter_this_address_does_not_read_is_refused(self): + self.login(self.user) + envelope = assert_error_envelope( + self.choose(address=f"{self.url}?username={self.other.username}"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "username": ["No such query parameter. Send any of: format."], + } + assert self.choices() == {} + assert self.choices(self.other) == {} + + def test_an_account_that_carries_no_profile_is_reported_absent(self): + """ + The consent rule is read from the profile, and there is nothing to read it from. + + The row a choice is kept in is opened before that rule is read, and + the refusal undoes it: nothing is stored where each request is served + in a transaction, which is how the platform serves them. + """ + self.login(self.user) + self.user.profile.delete() + assert_error_envelope( + self.choose(), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + assert self.choices() == {} + + def test_the_row_a_refusal_opened_stands_where_a_statement_stands_on_its_own(self): + """ + A refused choice leaves a row behind on a database that commits each statement. + + The row is opened before the consent rule is read, so where the + request is not served in a transaction there is nothing to undo it and + the account is left holding a row carrying no choice at all. What such + a row holds is pinned because a reader of it compares it against the + word for wanting the messages and so reads it as not wanting them. + """ + self.login(self.user) + self.user.profile.delete() + with requests_served_outside_a_transaction(): + assert_error_envelope( + self.choose(), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + assert self.choices() == {"edX": NO_CHOICE_AT_ALL} + + def test_a_choice_that_could_not_be_recorded_is_a_server_fault(self): + self.login(self.user) + with mock.patch( + "openedx.core.djangoapps.user_api.v2.views.update_email_opt_in", + side_effect=UserAPIInternalError("the tag table is gone"), + ): + response = self.choose() + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_500_INTERNAL_SERVER_ERROR, + expected_type_slug="internal", + ) + assert envelope["detail"] == "The choice could not be recorded." + assert "tag table is gone" not in response.content.decode() + + +class EmailOptInAccessTests(EmailOptInTestCase): + """Who may record a choice, and whose choice is recorded.""" + + def test_an_anonymous_caller_is_refused_with_the_standard_body(self): + """The earlier address refuses them as somebody else's request instead.""" + assert_error_envelope( + self.choose(), + expected_status=status.HTTP_401_UNAUTHORIZED, + expected_type_slug="authn", + ) + assert self.choices() == {} + + def test_a_choice_presented_with_a_token_is_recorded_for_the_account_it_names(self): + assert self.choose(**self.token_headers(self.other)).status_code == ( + status.HTTP_204_NO_CONTENT + ) + assert self.choices(self.other) == {"edX": "True"} + assert self.choices() == {} + + def test_an_account_with_an_unconfirmed_address_records_a_choice(self): + """A learner makes this choice while enrolling, before confirming their address.""" + self.user.is_active = False + self.user.save() + self.login(self.user) + assert self.choose().status_code == status.HTTP_204_NO_CONTENT + assert self.choices() == {"edX": "True"} + + def test_a_session_without_the_token_that_goes_with_it_is_refused(self): + """The session class still holds a request to the token the site hands out with it.""" + client = APIClient(enforce_csrf_checks=True) + assert client.login(username=self.user.username, password=PASSWORD) + assert_error_envelope( + client.post( + self.url, {"course_key": COURSE_KEY, "email_opt_in": True}, format="json", + ), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + assert self.choices() == {} + + def test_a_session_carrying_the_token_the_sign_in_handed_out_records_the_choice(self): + """ + The token a session is held to comes from signing in, not from this address. + + A page reaches this address with a session it got by signing in, so the + sign-in is what hands out the token the session is held to. The whole + way round is driven here, because nothing this version serves sets that + cookie and a caller presenting only a session would otherwise be + refused at every write. + """ + client = APIClient(enforce_csrf_checks=True) + sign_in_address = reverse("user_api_login_session", kwargs={"api_version": "v2"}) + form = client.get(sign_in_address) + assert form.status_code == status.HTTP_200_OK, form.content + assert settings.CSRF_COOKIE_NAME in form.cookies + signed_in = client.post( + sign_in_address, + {"email_or_username": self.user.email, "password": PASSWORD}, + HTTP_X_CSRFTOKEN=client.cookies[settings.CSRF_COOKIE_NAME].value, + ) + assert signed_in.status_code == status.HTTP_200_OK, signed_in.content + recorded = client.post( + self.url, + {"course_key": COURSE_KEY, "email_opt_in": True}, + format="json", + HTTP_X_CSRFTOKEN=client.cookies[settings.CSRF_COOKIE_NAME].value, + ) + assert recorded.status_code == status.HTTP_204_NO_CONTENT, recorded.content + assert self.choices() == {"edX": "True"} + + +class EmailOptInParityTests(EmailOptInTestCase): + """The same choice through the earlier address and through this one.""" + + def earlier(self, course_id=COURSE_KEY, email_opt_in="True", address=EARLIER_ADDRESS): + """Record a choice at the address this version supersedes, as its clients do.""" + return self.client.post( + address, {"course_id": course_id, "email_opt_in": email_opt_in}, + ) + + def test_both_addresses_record_the_same_row(self): + self.login(self.user) + assert self.earlier().status_code == status.HTTP_200_OK + earlier = self.choices() + UserOrgTag.objects.filter(user=self.user, key=OPT_IN_KEY).delete() + assert self.choose().status_code == status.HTTP_204_NO_CONTENT + assert self.choices() == earlier == {"edX": "True"} + + def test_both_addresses_record_the_same_row_for_an_account_a_guardian_answers_for(self): + self.make_minor(self.user) + self.login(self.user) + self.earlier(email_opt_in="True") + earlier = self.choices() + UserOrgTag.objects.filter(user=self.user, key=OPT_IN_KEY).delete() + self.choose(opt_in=True) + assert self.choices() == earlier == {"edX": "False"} + + def test_both_addresses_read_a_course_key_written_in_the_earlier_form(self): + self.login(self.user) + self.earlier(course_id=EARLIER_COURSE_KEY) + earlier = self.choices() + UserOrgTag.objects.filter(user=self.user, key=OPT_IN_KEY).delete() + self.choose(course_key=EARLIER_COURSE_KEY) + assert self.choices() == earlier == {"edX": "True"} + + def test_the_earlier_address_answers_with_an_empty_body_and_this_one_with_no_body(self): + """Declared difference: the choice is recorded, and there is nothing to return.""" + self.login(self.user) + earlier = self.earlier() + assert earlier.status_code == status.HTTP_200_OK + assert earlier.content == b"" + current = self.choose() + assert current.status_code == status.HTTP_204_NO_CONTENT + assert current.content == b"" + + def test_a_value_the_earlier_address_read_as_a_refusal_is_refused_here(self): + """Declared difference: a value that is neither true nor false is not read as false.""" + self.login(self.user) + assert self.earlier(email_opt_in="Banana").status_code == status.HTTP_200_OK + assert self.choices() == {"edX": "False"} + UserOrgTag.objects.filter(user=self.user, key=OPT_IN_KEY).delete() + assert self.choose(opt_in="Banana").status_code == status.HTTP_400_BAD_REQUEST + assert self.choices() == {} + + def test_a_value_that_only_suggests_a_choice_is_refused_here_and_not_read_as_one(self): + """ + Declared difference: the earlier address recorded a choice not to + receive messages for each of these, and a boolean field would have + recorded the opposite for half of them, so they are refused instead. + """ + self.login(self.user) + for suggestion in SUGGESTED_CHOICES: + assert self.earlier(email_opt_in=suggestion).status_code == status.HTTP_200_OK + assert self.choices() == {"edX": "False"}, suggestion + UserOrgTag.objects.filter(user=self.user, key=OPT_IN_KEY).delete() + assert self.choose(opt_in=suggestion).status_code == ( + status.HTTP_400_BAD_REQUEST + ), suggestion + assert self.choices() == {}, suggestion + + def test_a_form_that_leaves_the_choice_out_is_refused_by_both_addresses(self): + """Both answer a form body that names a course and no choice as a fault to correct.""" + self.login(self.user) + self.choose(opt_in=True) + earlier = self.client.post(EARLIER_ADDRESS, {"course_id": COURSE_KEY}) + assert earlier.status_code == status.HTTP_400_BAD_REQUEST + assert self.choices() == {"edX": "True"} + current = self.client.post(self.url, {"course_key": COURSE_KEY}, format="multipart") + assert current.status_code == status.HTTP_400_BAD_REQUEST + assert self.choices() == {"edX": "True"} + + def test_a_key_that_names_no_course_is_refused_by_both_addresses(self): + """Neither address records a choice for the organization of something that is not a course.""" + self.login(self.user) + earlier = self.earlier(course_id=LIBRARY_KEY) + assert earlier.status_code == status.HTTP_400_BAD_REQUEST + assert earlier.content == f"No course '{LIBRARY_KEY}' found".encode() + assert self.choices() == {} + current = self.choose(course_key=LIBRARY_KEY) + assert current.status_code == status.HTTP_400_BAD_REQUEST + assert self.choices() == {} + + def test_neither_address_records_a_choice_for_an_account_carrying_no_profile(self): + """ + Declared difference: the earlier address fails outright where this one reports it absent. + + Neither address writes a choice, and each leaves the same thing behind + on the same database: nothing where the request is served in a + transaction, and a row carrying no choice where each statement stands + on its own. The row is opened before the consent rule is read, which + is why it is there to be left at all. + """ + self.login(self.user) + self.user.profile.delete() + self.client.raise_request_exception = False + + assert self.earlier().status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + assert self.choices() == {} + assert self.choose().status_code == status.HTTP_404_NOT_FOUND + assert self.choices() == {} + + with requests_served_outside_a_transaction(): + assert self.earlier().status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + left_by_the_earlier_address = self.choices() + UserOrgTag.objects.filter(user=self.user, key=OPT_IN_KEY).delete() + assert self.choose().status_code == status.HTTP_404_NOT_FOUND + assert self.choices() == left_by_the_earlier_address == {"edX": NO_CHOICE_AT_ALL} + + def test_the_unversioned_earlier_address_records_the_same_row(self): + """It keeps serving the clients that call it, and this version adds no third address.""" + self.login(self.user) + assert self.earlier( + address=UNVERSIONED_EARLIER_ADDRESS, + ).status_code == status.HTTP_200_OK + assert self.choices() == {"edX": "True"} diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_preference_parity.py b/openedx/core/djangoapps/user_api/v2/tests/test_preference_parity.py new file mode 100644 index 000000000000..a2dfab367b8c --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_preference_parity.py @@ -0,0 +1,409 @@ +"""Behaviour parity between the version 1 and version 2 account preference addresses.""" + +import json + +from rest_framework import status + +from openedx.core.djangoapps.user_api.models import UserPreference +from openedx.core.djangolib.testing.utils import skip_unless_lms + +from .test_email_opt_in_views import COURSE_KEY, EARLIER_ADDRESS, UNVERSIONED_EARLIER_ADDRESS +from .test_parity import diff, unexpected +from .test_preference_views import ( + MERGE_PATCH, + SORTED_NAMES, + UNREACHABLE_KEYS, + UNSORTED_NAMES, + PreferenceApiTestCase, + earlier_document_address, + earlier_member_address, +) + +#: Preference names a filled-in account stores, in the numbers the document is +#: compared at: none, one, and enough that a body would be paged if it were a +#: list of them. +STORED_COUNTS = (0, 1, 25) + +#: The members every refusal of this version carries, which the earlier +#: addresses answer with bodies of their own instead. +ENVELOPE_MEMBERS = {"type", "title", "status", "detail", "instance"} + +#: Differences each case is expected to show, as (json path, reason) pairs: the +#: body compared must carry all of them and nothing else. The empty path is the +#: whole body, which is a value rather than an object at one of the addresses +#: compared. A difference in the order of the names a body carries is not a +#: path and is pinned by the test named for it instead. +INTENTIONAL_DIFFERENCES = { + "document": [], + "staff_document": [], + "sorted_document": [], + "member": [ + ("", "the earlier address answers with the stored value and nothing else"), + ("key", "the body names the preference the value belongs to"), + ("value", "the value is carried under a property of an object"), + ], + "anonymous_refusal": [ + ("type", "a refusal names the kind of fault it reports"), + ("title", "a refusal carries a summary of the kind of fault it reports"), + ("status", "a refusal carries the status it was answered with"), + ], +} + + +def assert_parity(earlier, current, case): + """Assert the two bodies differ only where the case declares they do.""" + changed = diff(earlier, current) + intentional = INTENTIONAL_DIFFERENCES[case] + assert not unexpected(changed, intentional), ( + f"undeclared differences: {sorted(unexpected(changed, intentional))}\n" + f"earlier={earlier}\ncurrent={current}" + ) + for path, reason in intentional: + assert any( + key == path or key.startswith(path + ".") or key.startswith(path + "[") + for key in changed + ), f"declared difference did not occur: {path} ({reason})" + + +@skip_unless_lms +class PreferenceDocumentParityTests(PreferenceApiTestCase): + """The document each version answers with for the same account.""" + + def documents(self, user, username=None): + """Return the document each version answers, read in one session.""" + username = username or user.username + self.login(user) + earlier = self.client.get(earlier_document_address(username)) + current = self.client.get(self.document_address(username)) + assert earlier.status_code == status.HTTP_200_OK, earlier.content + assert current.status_code == status.HTTP_200_OK, current.content + return json.loads(earlier.content), json.loads(current.content) + + def test_the_document_carries_the_same_names_and_values_however_many_are_stored(self): + for count in STORED_COUNTS: + UserPreference.objects.filter(user=self.owner).delete() + for index in range(count): + self.prefer(f"counted_{index:03d}", f"value {index}") + earlier, current = self.documents(self.owner) + assert_parity(earlier, current, "document") + assert current == { + f"counted_{index:03d}": f"value {index}" for index in range(count) + }, count + + def test_a_staff_caller_reads_the_same_document_through_either_address(self): + earlier, current = self.documents(self.staff, username=self.owner.username) + assert_parity(earlier, current, "staff_document") + assert current == {"time_zone": "Asia/Tokyo"} + + def test_the_document_this_version_answers_is_sorted_and_carries_the_same_names(self): + """ + Declared difference: the names come back in one order rather than the stored order. + + The names stored include ones that sort differently by the code of + each character than they would with the case folded, so the order + pinned here is the one order the document promises rather than any + order that happens to agree with it. + """ + for key in UNSORTED_NAMES: + self.prefer(key, "stored") + earlier, current = self.documents(self.owner) + assert_parity(earlier, current, "sorted_document") + assert list(current) == sorted(earlier) == [*SORTED_NAMES, "time_zone"] + assert current == { + **{name: "stored" for name in UNSORTED_NAMES}, "time_zone": "Asia/Tokyo", + } + + def test_the_same_merge_patch_leaves_both_versions_storing_the_same_rows(self): + self.login(self.owner) + changes = json.dumps({"account_privacy": "private", "time_zone": None}) + assert self.client.patch( + earlier_document_address(self.owner.username), changes, content_type=MERGE_PATCH, + ).status_code == status.HTTP_204_NO_CONTENT + after_earlier = self.stored() + + UserPreference.objects.filter(user=self.owner).delete() + self.prefer("time_zone", "Asia/Tokyo") + assert self.client.patch( + self.document_url, changes, content_type=MERGE_PATCH, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == after_earlier == {"account_privacy": "private"} + + def test_both_versions_refuse_a_change_sent_in_another_media_type(self): + self.login(self.owner) + body = json.dumps({"time_zone": "Europe/Paris"}) + for address in (earlier_document_address(self.owner.username), self.document_url): + assert self.client.patch( + address, body, content_type="application/json", + ).status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, address + + def test_both_versions_refuse_a_value_the_preference_cannot_hold(self): + """The message a user is shown comes from the same place in both.""" + self.login(self.owner) + body = json.dumps({"time_zone": "Asia/Africa"}) + earlier = self.client.patch( + earlier_document_address(self.owner.username), body, content_type=MERGE_PATCH, + ) + current = self.client.patch(self.document_url, body, content_type=MERGE_PATCH) + assert earlier.status_code == current.status_code == status.HTTP_400_BAD_REQUEST + message = "Value 'Asia/Africa' is not a valid time zone selection." + assert json.loads(earlier.content)["field_errors"]["time_zone"]["user_message"] == message + assert json.loads(current.content)["errors"] == {"time_zone": [message]} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_value_that_is_not_text_was_spelled_out_and_stored_and_is_now_refused(self): + """Declared difference: a value of another type is the caller's mistake, not a string.""" + self.login(self.owner) + body = json.dumps({"account_privacy": 5}) + assert self.client.patch( + earlier_document_address(self.owner.username), body, content_type=MERGE_PATCH, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored()["account_privacy"] == "5" + + UserPreference.objects.filter(user=self.owner, key="account_privacy").delete() + assert self.client.patch( + self.document_url, body, content_type=MERGE_PATCH, + ).status_code == status.HTTP_400_BAD_REQUEST + assert "account_privacy" not in self.stored() + + def test_a_body_naming_nothing_is_refused_by_both_versions(self): + self.login(self.owner) + for address in (earlier_document_address(self.owner.username), self.document_url): + assert self.client.patch( + address, json.dumps({}), content_type=MERGE_PATCH, + ).status_code == status.HTTP_400_BAD_REQUEST, address + + +@skip_unless_lms +class PreferenceMemberParityTests(PreferenceApiTestCase): + """One preference through either version's member address.""" + + def test_the_value_is_the_same_and_this_version_names_it(self): + """Declared difference: the body is the name and the value rather than the value alone.""" + self.login(self.owner) + earlier = self.client.get(earlier_member_address(self.owner.username, "time_zone")) + current = self.client.get(self.member_url) + assert earlier.status_code == current.status_code == status.HTTP_200_OK + assert_parity(json.loads(earlier.content), json.loads(current.content), "member") + assert json.loads(earlier.content) == "Asia/Tokyo" + assert json.loads(current.content) == {"key": "time_zone", "value": "Asia/Tokyo"} + + def test_both_versions_report_a_name_the_account_stores_no_value_under_as_absent(self): + self.login(self.owner) + for address in ( + earlier_member_address(self.owner.username, "account_privacy"), + self.member_address(self.owner.username, "account_privacy"), + ): + assert self.client.get(address).status_code == status.HTTP_404_NOT_FOUND, address + assert self.client.delete(address).status_code == status.HTTP_404_NOT_FOUND, address + + def test_the_names_the_earlier_member_route_cannot_carry_are_addressed_here(self): + """Declared difference: the key half of the address takes the whole segment.""" + self.login(self.owner) + for key in UNREACHABLE_KEYS: + self.prefer(key, "stored") + earlier = self.client.get(earlier_member_address(self.owner.username, key)) + assert earlier.status_code == status.HTTP_404_NOT_FOUND, key + assert earlier["Content-Type"].startswith("text/html"), key + current = self.client.get(self.member_address(self.owner.username, key)) + assert current.status_code == status.HTTP_200_OK, key + assert json.loads(current.content) == {"key": key, "value": "stored"}, key + + def test_the_value_sent_is_stored_by_both_versions(self): + self.login(self.owner) + assert self.client.put( + earlier_member_address(self.owner.username, "time_zone"), + json.dumps("Europe/Paris"), + content_type="application/json", + ).status_code == status.HTTP_204_NO_CONTENT + after_earlier = self.stored() + + assert self.put_value( + self.member_url, {"value": "Europe/Paris"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == after_earlier == {"time_zone": "Europe/Paris"} + + def test_a_value_sent_as_a_bare_string_is_no_longer_read(self): + """Declared difference: the value travels as a property of an object.""" + self.login(self.owner) + refused = self.client.put( + self.member_url, json.dumps("Europe/Paris"), content_type="application/json", + ) + assert refused.status_code == status.HTTP_400_BAD_REQUEST + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_both_versions_refuse_an_empty_value_with_the_same_message(self): + self.login(self.owner) + earlier = self.client.put( + earlier_member_address(self.owner.username, "account_privacy"), + json.dumps(""), + content_type="application/json", + ) + assert earlier.status_code == status.HTTP_400_BAD_REQUEST + assert json.loads(earlier.content)["user_message"] == ( + "Preference 'account_privacy' cannot be set to an empty value." + ) + current = self.put_value( + self.member_address(self.owner.username, "account_privacy"), {"value": ""}, + ) + assert current.status_code == status.HTTP_400_BAD_REQUEST + assert json.loads(current.content)["errors"] == {"value": ["This field may not be blank."]} + + def test_a_preference_is_removed_by_both_versions(self): + self.login(self.owner) + assert self.client.delete( + earlier_member_address(self.owner.username, "time_zone"), + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {} + self.prefer("time_zone", "Asia/Tokyo") + assert self.client.delete(self.member_url).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {} + + +@skip_unless_lms +class PreferenceRefusalParityTests(PreferenceApiTestCase): + """How each version refuses the same caller.""" + + def addresses(self, username): + """Return the document and member address of one account in each version.""" + return ( + (earlier_document_address(username), self.document_address(username)), + ( + earlier_member_address(username, "time_zone"), + self.member_address(username, "time_zone"), + ), + ) + + def test_both_versions_refuse_a_learner_reading_another_account(self): + self.login(self.other) + for earlier_address, address in self.addresses(self.owner.username): + earlier = self.client.get(earlier_address) + current = self.client.get(address) + assert earlier.status_code == current.status_code == status.HTTP_403_FORBIDDEN + assert earlier.content == b"" + assert set(json.loads(current.content)) >= ENVELOPE_MEMBERS + + def test_both_versions_refuse_a_learner_changing_another_account(self): + self.login(self.other) + body = json.dumps({"time_zone": "Europe/Paris"}) + earlier_document, document = self.addresses(self.owner.username)[0] + for address in (earlier_document, document): + assert self.client.patch( + address, body, content_type=MERGE_PATCH, + ).status_code == status.HTTP_403_FORBIDDEN, address + earlier_member, member = self.addresses(self.owner.username)[1] + for address in (earlier_member, member): + assert self.client.delete(address).status_code == status.HTTP_403_FORBIDDEN, address + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_both_versions_refuse_a_staff_caller_changing_another_account(self): + self.login(self.staff) + earlier = self.client.patch( + earlier_document_address(self.owner.username), + json.dumps({"time_zone": "Europe/Paris"}), + content_type=MERGE_PATCH, + ) + current = self.client.patch( + self.document_url, json.dumps({"time_zone": "Europe/Paris"}), content_type=MERGE_PATCH, + ) + assert earlier.status_code == current.status_code == status.HTTP_403_FORBIDDEN + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_both_versions_refuse_an_address_that_is_not_exactly_the_callers_name(self): + caller = self.sign_in_as_a_case_variant_of(self.owner) + assert caller.username == self.owner.username.upper() + for earlier_address, address in self.addresses(self.owner.username): + assert self.client.get(earlier_address).status_code == status.HTTP_403_FORBIDDEN + assert self.client.get(address).status_code == status.HTTP_403_FORBIDDEN + + def test_a_staff_caller_is_told_about_a_name_nobody_holds_by_both_versions(self): + self.login(self.staff) + for earlier_address, address in self.addresses("nobody"): + assert self.client.get(earlier_address).status_code == status.HTTP_404_NOT_FOUND + assert self.client.get(address).status_code == status.HTTP_404_NOT_FOUND + + def test_a_learner_is_refused_a_name_nobody_holds_by_both_versions(self): + """Neither version lets a caller learn whether a name is held from these addresses.""" + self.login(self.other) + for earlier_address, address in self.addresses("nobody"): + assert self.client.get(earlier_address).status_code == status.HTTP_403_FORBIDDEN + assert self.client.get(address).status_code == status.HTTP_403_FORBIDDEN + + def test_an_anonymous_caller_is_refused_by_both_versions(self): + """Declared difference: the refusal carries the standard body.""" + for earlier_address, address in self.addresses(self.owner.username): + earlier = self.client.get(earlier_address) + current = self.client.get(address) + assert earlier.status_code == current.status_code == status.HTTP_401_UNAUTHORIZED + assert_parity( + json.loads(earlier.content), json.loads(current.content), "anonymous_refusal", + ) + assert set(json.loads(earlier.content)) == {"detail"} + assert set(json.loads(current.content)) >= ENVELOPE_MEMBERS + + def test_a_query_parameter_neither_version_reads_is_ignored_by_one_and_refused_by_the_other(self): + """Declared difference: a parameter this version does not read is refused.""" + self.login(self.owner) + for earlier_address, address in self.addresses(self.owner.username): + assert self.client.get(earlier_address, {"page": "2"}).status_code == ( + status.HTTP_200_OK + ) + assert self.client.get(address, {"page": "2"}).status_code == ( + status.HTTP_400_BAD_REQUEST + ) + + +@skip_unless_lms +class SupersededAddressAnnouncementTests(PreferenceApiTestCase): + """ + What the superseded addresses say about being superseded: nothing, in any header. + + Which addresses are superseded is published in the document and carried by + the removal ticket. The addresses themselves answer exactly as they always + have, because a response header would be a change to a version already in + use. That is pinned here so the declaration and the running code cannot + drift apart. + """ + + #: Headers an answer would carry an announcement of its own removal in. + ANNOUNCING_HEADERS = ("Deprecation", "Sunset", "Warning", "Link") + + def assert_nothing_was_announced(self, response, expected_status, address): + """Assert the answer is the one that address gives, and announces nothing.""" + assert response.status_code == expected_status, (address, response.content) + announced = [header for header in self.ANNOUNCING_HEADERS if header in response] + assert announced == [], address + + def test_no_superseded_preference_address_announces_its_own_removal(self): + self.login(self.owner) + document = earlier_document_address(self.owner.username) + member = earlier_member_address(self.owner.username, "time_zone") + exchanges = ( + (f"GET {document}", self.client.get(document), status.HTTP_200_OK), + ( + f"PATCH {document}", + self.client.patch( + document, json.dumps({"zoneinfo": "Europe/Paris"}), content_type=MERGE_PATCH, + ), + status.HTTP_204_NO_CONTENT, + ), + (f"GET {member}", self.client.get(member), status.HTTP_200_OK), + ( + f"PUT {member}", + self.client.put(member, json.dumps("Asia/Tokyo"), content_type="application/json"), + status.HTTP_204_NO_CONTENT, + ), + (f"DELETE {member}", self.client.delete(member), status.HTTP_204_NO_CONTENT), + ) + for address, response, expected_status in exchanges: + with self.subTest(address=address): + self.assert_nothing_was_announced(response, expected_status, address) + + def test_neither_address_of_the_superseded_email_choice_announces_one(self): + self.login(self.owner) + for address in (EARLIER_ADDRESS, UNVERSIONED_EARLIER_ADDRESS): + with self.subTest(address=address): + self.assert_nothing_was_announced( + self.client.post(address, {"course_id": COURSE_KEY, "email_opt_in": "True"}), + status.HTTP_200_OK, + address, + ) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_preference_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_preference_views.py new file mode 100644 index 000000000000..95e2d0937c4e --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_preference_views.py @@ -0,0 +1,1718 @@ +"""Tests for the version 2 User API account preference addresses.""" + +import contextlib +import json +from unittest import mock +from urllib.parse import quote + +from django.conf import settings +from django.contrib.auth.models import AnonymousUser +from django.db import DatabaseError, connection +from django.test.utils import CaptureQueriesContext +from django.urls import resolve, reverse +from edx_rest_framework_extensions.testing import assert_error_envelope +from rest_framework import status +from rest_framework.test import APIClient, APIRequestFactory, APITestCase + +from common.djangoapps.student.tests.factories import UserFactory +from common.djangoapps.util import model_utils +from common.djangoapps.util.model_utils import USER_FIELDS_CHANGED, USER_SETTINGS_CHANGED_EVENT_NAME +from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user +from openedx.core.djangoapps.user_api.errors import ( + PreferenceUpdateError, + PreferenceValidationError, + UserAPIInternalError, +) +from openedx.core.djangoapps.user_api.models import UserPreference +from openedx.core.djangoapps.user_api.preferences.api import set_user_preference +from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory +from openedx.core.djangoapps.user_api.v2.serializers import ( + PREFERENCE_NAME_MAX_LENGTH, + PREFERENCE_NAME_TOO_LONG, + PREFERENCE_NAME_UNUSABLE, +) +from openedx.core.djangoapps.user_api.v2.views import DESCRIBE_ADDRESS_ACTION, AccountPreferenceViewSet +from openedx.core.djangolib.testing.utils import skip_unless_lms + +from .test_views import write_queries + +PASSWORD = "Password1234" +MERGE_PATCH = "application/merge-patch+json" + +#: The query parameters these addresses read, in the order their refusal lists them. +PREFERENCE_QUERY_PARAMS = "format" + +#: The table the preferences of an account are stored in. +PREFERENCE_TABLE = "user_api_userpreference" + +#: What the platform's own record of a request carries for a read of one of +#: these addresses: the query string it was made with and the form body it +#: carried, both empty. +REQUEST_RECORD_DATA = '{"GET": {}, "POST": {}}' + +#: Where the language a user picked is carried between requests, and the name +#: the platform stores it under once it is carried. +LANGUAGE_COOKIE = settings.LANGUAGE_COOKIE_NAME +LANGUAGE_PREFERENCE = "pref-lang" + +#: Names a preference may be stored under that the version 1 member address +#: cannot carry: the language the platform itself stores, the names the profile +#: pages store a sharing choice under, and a name ending in a separator. +UNREACHABLE_KEYS = (LANGUAGE_PREFERENCE, "visibility.bio", "visibility.") + +#: Names a preference may be stored under that no member address can carry, +#: because the name is read from one segment and a segment ends at a slash. +SLASHED_NAMES = ("a/b", "x/", "/x", "a//b") + +#: Names that address another collection at version 1 and are ordinary +#: preference names here. +COLLECTION_NAMES = ("email_opt_in", "time_zones") + +#: The longest name a preference can be stored under, and one character more. +LONGEST_NAME = "k" * PREFERENCE_NAME_MAX_LENGTH +TOO_LONG_NAME = "k" * (PREFERENCE_NAME_MAX_LENGTH + 1) + +#: Names that differ in more than their letters, stored in an order none of the +#: orders they could come back in would put them in. +UNSORTED_NAMES = ("Zeta", "alpha", "Beta", "_under", "9nine") + +#: Those names in the order they are answered in: by the code of each +#: character, which puts digits, capitals and the underscore ahead of lower +#: case. A sort that folded case would answer them in another order. +SORTED_NAMES = ["9nine", "Beta", "Zeta", "_under", "alpha"] + +#: Text carrying a character that stands for half of a pair, which has no +#: encoding of its own: it can be neither stored nor carried back in an answer. +UNENCODABLE_TEXT = "x\ud800y" + +#: How such a character is reported, by the code point that was sent. +UNENCODABLE_REFUSAL = "Surrogate characters are not allowed: U+D800." + + +def earlier_document_address(username): + """Return the version 1 address of every preference of one account.""" + return f"/api/user/v1/preferences/{username}" + + +def earlier_member_address(username, key): + """Return the version 1 address of one preference of one account.""" + return f"/api/user/v1/preferences/{username}/{key}" + + +@contextlib.contextmanager +def requests_served_outside_a_transaction(): + """Serve requests the way a database that commits each statement on its own would.""" + connected_as = connection.settings_dict + each_request_in_a_transaction = connected_as["ATOMIC_REQUESTS"] + connected_as["ATOMIC_REQUESTS"] = False + try: + yield + finally: + connected_as["ATOMIC_REQUESTS"] = each_request_in_a_transaction + + +class PreferenceApiTestCase(APITestCase): + """Fixtures shared by the version 2 account preference tests.""" + + def setUp(self): + super().setUp() + self.owner = UserFactory.create( + username="pref-owner", email="pref-owner@example.com", password=PASSWORD, + ) + self.other = UserFactory.create( + username="pref-other", email="pref-other@example.com", password=PASSWORD, + ) + self.staff = UserFactory.create( + username="pref-staff", email="pref-staff@example.com", password=PASSWORD, is_staff=True, + ) + self.prefer("time_zone", "Asia/Tokyo") + self.document_url = self.document_address(self.owner.username) + self.member_url = self.member_address(self.owner.username, "time_zone") + + def document_address(self, username): + """Return the address of every preference of one account.""" + return reverse("user_v2:account_preference_list", kwargs={"username": username}) + + def member_address(self, username, key): + """Return the address of one preference of one account.""" + return reverse( + "user_v2:account_preference_detail", + kwargs={"username": username, "key": key}, + ) + + def login(self, user): + """Sign the given user in through the session these views accept.""" + assert self.client.login(username=user.username, password=PASSWORD) + + def token_headers(self, user): + """Return the request headers that carry a token identifying the given user.""" + return {"HTTP_AUTHORIZATION": f"JWT {create_jwt_for_user(user)}"} + + def prefer(self, key, value, user=None): + """Store one preference for the given account, the owner's by default.""" + return UserPreferenceFactory.create(user=user or self.owner, key=key, value=value) + + def stored(self, user=None): + """Return the preferences an account holds, as the names and values stored.""" + return { + preference.key: preference.value + for preference in UserPreference.objects.filter(user=user or self.owner) + } + + def sign_in_as_a_case_variant_of(self, user): + """ + Sign in a caller whose own name is the given user's, in upper case. + + The variant is set in memory only, since a site that stores usernames + case-insensitively has no room for both names at once. + """ + caller = UserFactory.create( + username="pref-variant", email="pref-variant@example.com", password=PASSWORD, + ) + caller.username = user.username.upper() + self.client.force_authenticate(user=caller) + return caller + + def patch_document(self, address, body, content_type=MERGE_PATCH, client=None, **kwargs): + """Send a merge patch of the preference document.""" + return (client or self.client).patch( + address, json.dumps(body), content_type=content_type, **kwargs, + ) + + def put_value(self, address, body, client=None, **kwargs): + """Send the value one preference is to hold.""" + return (client or self.client).put( + address, json.dumps(body), content_type="application/json", **kwargs, + ) + + def read_requests(self, username, client=None, **headers): + """Return the requests that read the preferences of one account, by name.""" + sender = client or self.client + return { + "document": lambda: sender.get(self.document_address(username), **headers), + "member": lambda: sender.get( + self.member_address(username, "time_zone"), **headers, + ), + } + + def write_requests(self, username, client=None, **headers): + """Return the requests that change the preferences of one account, by name.""" + sender = client or self.client + return { + "document": lambda: self.patch_document( + self.document_address(username), + {"time_zone": "Europe/Paris"}, + client=sender, + **headers, + ), + "member": lambda: self.put_value( + self.member_address(username, "time_zone"), + {"value": "Europe/Paris"}, + client=sender, + **headers, + ), + "member removal": lambda: sender.delete( + self.member_address(username, "time_zone"), **headers, + ), + } + + def assert_all_refused(self, requests, expected_status, expected_type_slug): + """ + Assert every request named is refused the same way. + + Each request is named in the failure as well as in the subtest it is + run under, because a subtest name reaches the report only where + something reads it, and the name of the one refusal that stopped + happening is the first thing a reader of the failure needs. + """ + for case, send in dict(requests).items(): + with self.subTest(case=case): + refused = send() + assert refused.status_code == expected_status, (case, refused.content) + assert_error_envelope( + refused, + expected_status=expected_status, + expected_type_slug=expected_type_slug, + ) + + +@skip_unless_lms +class PreferenceDocumentTests(PreferenceApiTestCase): + """GET and PATCH /api/user/v2/accounts/{username}/preferences/""" + + def test_an_owner_reads_every_preference_the_account_stores(self): + self.prefer("account_privacy", "private") + self.login(self.owner) + response = self.client.get(self.document_url) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"account_privacy": "private", "time_zone": "Asia/Tokyo"} + + def test_an_account_that_stores_nothing_is_answered_with_an_empty_document(self): + self.login(self.other) + response = self.client.get(self.document_address(self.other.username)) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {} + + def test_the_names_come_back_in_one_order_whatever_order_they_were_stored_in(self): + for key in ("zoneinfo", "account_privacy", LANGUAGE_PREFERENCE, "visibility.bio"): + self.prefer(key, "stored") + self.login(self.owner) + names = list(self.client.get(self.document_url).json()) + assert names == ["account_privacy", LANGUAGE_PREFERENCE, "time_zone", "visibility.bio", "zoneinfo"] + + def test_the_order_the_names_come_back_in_is_the_code_of_each_character(self): + """Which is what the document says, and is not the order a reader of English expects.""" + for key in UNSORTED_NAMES: + self.prefer(key, "stored") + self.login(self.owner) + assert list(self.client.get(self.document_url).json()) == [*SORTED_NAMES, "time_zone"] + + def test_a_staff_caller_reads_another_accounts_preferences(self): + self.login(self.staff) + response = self.client.get(self.document_url) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"time_zone": "Asia/Tokyo"} + + def test_an_owner_changes_the_names_the_body_carries_and_leaves_the_rest(self): + self.prefer("account_privacy", "private") + self.login(self.owner) + response = self.patch_document(self.document_url, {"time_zone": "Europe/Paris"}) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert response.content == b"" + assert self.stored() == {"account_privacy": "private", "time_zone": "Europe/Paris"} + + def test_a_name_the_account_does_not_store_is_added(self): + self.login(self.owner) + assert self.patch_document( + self.document_url, {"account_privacy": "private"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {"account_privacy": "private", "time_zone": "Asia/Tokyo"} + + def test_a_null_removes_the_name_from_the_account_that_was_addressed(self): + self.prefer("time_zone", "Europe/Paris", user=self.other) + self.login(self.owner) + assert self.patch_document( + self.document_url, {"time_zone": None}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {} + assert self.stored(self.other) == {"time_zone": "Europe/Paris"} + + def test_the_document_read_back_and_sent_again_changes_nothing(self): + self.prefer("account_privacy", "private") + self.login(self.owner) + document = self.client.get(self.document_url).json() + assert self.patch_document( + self.document_url, document, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.client.get(self.document_url).json() == document + + def test_the_names_the_earlier_member_address_cannot_carry_are_changed_here(self): + self.login(self.owner) + changes = {key: "stored" for key in UNREACHABLE_KEYS} + assert self.patch_document( + self.document_url, changes, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.client.get(self.document_url).json() == { + "time_zone": "Asia/Tokyo", **changes, + } + + def test_a_value_that_is_not_text_is_refused_under_the_name_that_carried_it(self): + """The earlier address spelled a number out and stored it.""" + self.login(self.owner) + response = self.patch_document(self.document_url, {"time_zone": 5, "account_privacy": "private"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "time_zone": ["Send a value as text, or null to remove the preference."], + } + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_what_a_value_of_spaces_alone_means_is_settled_where_it_is_stored(self): + """A value arrives as text; trimming it and refusing an empty one are the service's.""" + self.login(self.owner) + assert self.patch_document( + self.document_url, {"notes": " draft "}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {"notes": "draft", "time_zone": "Asia/Tokyo"} + envelope = assert_error_envelope( + self.patch_document(self.document_url, {"other": " "}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "other": ["Preference 'other' cannot be set to an empty value."], + } + assert self.stored() == {"notes": "draft", "time_zone": "Asia/Tokyo"} + + def test_an_empty_value_is_refused_under_the_name_that_carried_it(self): + self.login(self.owner) + response = self.patch_document(self.document_url, {"account_privacy": ""}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "account_privacy": ["Preference 'account_privacy' cannot be set to an empty value."], + } + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_nothing_is_stored_unless_every_name_in_the_body_can_be(self): + self.login(self.owner) + response = self.patch_document( + self.document_url, {"account_privacy": "private", "time_zone": "Asia/Africa"}, + ) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "time_zone": ["Value 'Asia/Africa' is not a valid time zone selection."], + } + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_name_as_long_as_the_column_holds_is_stored(self): + self.login(self.owner) + assert self.patch_document( + self.document_url, {LONGEST_NAME: "stored"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {"time_zone": "Asia/Tokyo", LONGEST_NAME: "stored"} + + def test_a_name_longer_than_the_column_holds_is_refused_under_itself(self): + self.login(self.owner) + response = self.patch_document(self.document_url, {TOO_LONG_NAME: "stored"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == {TOO_LONG_NAME: [PREFERENCE_NAME_TOO_LONG]} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_value_that_cannot_be_encoded_is_refused_under_the_name_that_carried_it(self): + self.login(self.owner) + response = self.patch_document(self.document_url, {"account_privacy": UNENCODABLE_TEXT}) + assert response["Content-Type"] == "application/json" + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == {"account_privacy": [UNENCODABLE_REFUSAL]} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_name_that_cannot_be_encoded_is_refused_against_the_body(self): + """The answer could not carry the name back, so it names the body instead of the member.""" + self.login(self.owner) + for body in ( + {"account_privacy\ud800": "private"}, + {"\ud800": "private"}, + {"account_privacy\ud800": 5}, + {"account_privacy\ud800": None}, + ): + response = self.patch_document(self.document_url, body) + assert response["Content-Type"] == "application/json" + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"non_field_errors": [UNENCODABLE_REFUSAL]}, body + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_body_that_is_not_an_object_is_refused(self): + self.login(self.owner) + for body in ([], "time_zone", 5): + envelope = assert_error_envelope( + self.patch_document(self.document_url, body), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "non_field_errors": ["Send the preferences to change as an object."], + }, body + + def test_a_body_that_names_no_preference_is_refused(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.patch_document(self.document_url, {}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "non_field_errors": ["Name at least one preference to change."], + } + + def test_a_body_that_is_not_well_formed_is_refused(self): + self.login(self.owner) + assert_error_envelope( + self.client.patch(self.document_url, "{not json", content_type=MERGE_PATCH), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="malformed-body", + ) + + def test_a_change_sent_in_another_media_type_is_refused(self): + """The document is changed with a merge patch, in which a null removes a name.""" + self.login(self.owner) + for content_type in ("application/json", "application/x-www-form-urlencoded"): + assert_error_envelope( + self.patch_document( + self.document_url, {"time_zone": "Europe/Paris"}, content_type=content_type, + ), + expected_status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + expected_type_slug="unsupported-media-type", + ) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_the_document_is_not_written_whole_or_added_to(self): + self.login(self.owner) + for method in ("put", "post", "delete"): + response = getattr(self.client, method)( + self.document_url, json.dumps({}), content_type="application/json", + ) + assert_error_envelope( + response, + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug="method-not-allowed", + ) + + +@skip_unless_lms +class PreferenceMemberTests(PreferenceApiTestCase): + """GET, PUT and DELETE /api/user/v2/accounts/{username}/preferences/{key}/""" + + def test_an_owner_reads_one_preference_as_its_name_and_its_value(self): + self.login(self.owner) + response = self.client.get(self.member_url) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"key": "time_zone", "value": "Asia/Tokyo"} + + def test_a_staff_caller_reads_one_preference_of_another_account(self): + self.login(self.staff) + assert self.client.get(self.member_url).json() == { + "key": "time_zone", "value": "Asia/Tokyo", + } + + def test_a_name_the_account_stores_no_value_under_is_absent(self): + self.login(self.owner) + assert_error_envelope( + self.client.get(self.member_address(self.owner.username, "account_privacy")), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_every_name_one_segment_can_carry_is_readable_and_writable(self): + self.login(self.owner) + for key in UNREACHABLE_KEYS + COLLECTION_NAMES: + address = self.member_address(self.owner.username, key) + assert self.put_value(address, {"value": "stored"}).status_code == ( + status.HTTP_204_NO_CONTENT + ), key + assert self.client.get(address).json() == {"key": key, "value": "stored"}, key + assert self.client.delete(address).status_code == status.HTTP_204_NO_CONTENT, key + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_name_as_long_as_the_column_holds_is_given_a_value(self): + self.login(self.owner) + assert self.put_value( + self.member_address(self.owner.username, LONGEST_NAME), {"value": "stored"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {"time_zone": "Asia/Tokyo", LONGEST_NAME: "stored"} + + def test_a_longer_name_is_refused_under_the_part_of_the_address_that_carried_it(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.put_value( + self.member_address(self.owner.username, TOO_LONG_NAME), {"value": "stored"}, + ), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"key": [PREFERENCE_NAME_TOO_LONG]} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_name_nothing_can_be_stored_under_is_refused_under_that_part_too(self): + """The fault is in the address, so changing the value sent would not mend it.""" + self.login(self.owner) + envelope = assert_error_envelope( + self.put_value( + self.member_address(self.owner.username, "!!!"), {"value": "stored"}, + ), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"key": [PREFERENCE_NAME_UNUSABLE]} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_the_names_this_address_refuses_are_the_ones_the_store_refuses(self): + """ + Which names are held is the store's to decide, so each is offered to it as well. + + A name is checked before the value is read, against what the store + holds rather than against a list kept here, so a change to what it + holds shows up as this address keeping a name the store would refuse or + refusing one it would keep. + """ + self.login(self.owner) + for name in ("a!!!", "!!!", "...", "9", "visibility.bio", "pref-lang"): + UserPreference.objects.filter(user=self.owner, key=name).delete() + try: + set_user_preference(self.owner, name, "stored") + except PreferenceValidationError: + the_store_refuses_it = True + else: + the_store_refuses_it = False + UserPreference.objects.filter(user=self.owner, key=name).delete() + answered = self.put_value( + self.member_address(self.owner.username, name), {"value": "stored"}, + ) + assert (answered.status_code == status.HTTP_400_BAD_REQUEST) == the_store_refuses_it, ( + f"{name}: answered {answered.status_code}" + ) + assert (name in self.stored()) == (not the_store_refuses_it), name + + def test_a_name_no_value_can_be_stored_under_is_read_and_removed_as_absent(self): + """Nothing is stored under a name that long, so both report it missing.""" + self.login(self.owner) + address = self.member_address(self.owner.username, TOO_LONG_NAME) + for response in (self.client.get(address), self.client.delete(address)): + assert_error_envelope( + response, + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_a_value_that_cannot_be_encoded_is_refused_under_the_property_that_carried_it(self): + self.login(self.owner) + response = self.put_value(self.member_url, {"value": UNENCODABLE_TEXT}) + assert response["Content-Type"] == "application/json" + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == {"value": [UNENCODABLE_REFUSAL]} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_name_carrying_a_slash_is_stored_read_and_removed_through_the_document(self): + """ + A name is read from one segment, which ends at the slash, so no member address serves it. + + Encoding the slash does not reach one either: an address is read in its + decoded form, which puts the slash back. The name the document stores + is therefore left as it was by every method the member address serves, + and the document is the only place it is read and removed. + """ + self.login(self.owner) + for name in SLASHED_NAMES: + with self.subTest(name=name): + held = {name: "stored", "time_zone": "Asia/Tokyo"} + assert self.patch_document( + self.document_url, {name: "stored"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == held + assert self.client.get(self.document_url).json() == held + for spelling in (name, quote(name, safe="")): + address = f"{self.document_url}{spelling}/" + attempts = ( + ("get", self.client.get(address)), + ("put", self.put_value(address, {"value": "other"})), + ("delete", self.client.delete(address)), + ) + for method, response in attempts: + assert response["Content-Type"] == "application/json", (spelling, method) + assert_error_envelope( + response, + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + assert self.stored() == held + assert self.patch_document( + self.document_url, {name: None}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_value_is_stored_whether_or_not_the_name_already_held_one(self): + self.login(self.owner) + new = self.member_address(self.owner.username, "account_privacy") + assert self.put_value(new, {"value": "private"}).status_code == status.HTTP_204_NO_CONTENT + assert self.put_value( + self.member_url, {"value": "Europe/Paris"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {"account_privacy": "private", "time_zone": "Europe/Paris"} + + def test_a_value_that_is_not_text_is_refused_under_the_property_that_carried_it(self): + """The earlier address spelled a number out and stored it.""" + self.login(self.owner) + envelope = assert_error_envelope( + self.put_value(self.member_url, {"value": 5}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"value": ["Not a valid string."]} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_an_empty_value_is_refused(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.put_value(self.member_url, {"value": ""}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"value": ["This field may not be blank."]} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_body_carrying_no_value_is_refused(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.put_value(self.member_url, {}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"value": ["This field is required."]} + + def test_a_value_the_preference_cannot_hold_is_refused_with_the_reason(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.put_value(self.member_url, {"value": "Asia/Africa"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "value": ["Value 'Asia/Africa' is not a valid time zone selection."], + } + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_time_zone_the_zone_database_carries_is_stored(self): + """Which zones there are is decided by the database of them, not by a list kept here.""" + self.login(self.owner) + assert self.put_value( + self.member_url, {"value": "America/Argentina/Buenos_Aires"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {"time_zone": "America/Argentina/Buenos_Aires"} + + def test_a_preference_is_removed(self): + self.login(self.owner) + assert self.client.delete(self.member_url).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {} + + def test_removing_a_name_the_account_stores_no_value_under_reports_it_absent(self): + self.login(self.owner) + assert_error_envelope( + self.client.delete(self.member_address(self.owner.username, "account_privacy")), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_one_preference_is_not_merge_patched(self): + self.login(self.owner) + assert_error_envelope( + self.client.patch( + self.member_url, json.dumps({"value": "Europe/Paris"}), content_type=MERGE_PATCH, + ), + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug="method-not-allowed", + ) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_value_sent_in_a_media_type_this_address_does_not_read_is_refused(self): + self.login(self.owner) + assert_error_envelope( + self.client.put( + self.member_url, json.dumps({"value": "Europe/Paris"}), content_type=MERGE_PATCH, + ), + expected_status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + expected_type_slug="unsupported-media-type", + ) + + def test_a_value_submitted_as_a_form_is_read(self): + """These addresses read the media types the platform reads everywhere.""" + self.login(self.owner) + assert self.client.put( + self.member_url, "value=Europe/Paris", + content_type="application/x-www-form-urlencoded", + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {"time_zone": "Europe/Paris"} + + +@skip_unless_lms +class PreferenceAccessTests(PreferenceApiTestCase): + """Who may read and who may change the preferences of the account an address names.""" + + def test_an_anonymous_caller_is_refused_with_the_standard_body(self): + self.assert_all_refused( + {**self.read_requests(self.owner.username), **self.write_requests(self.owner.username)}, + status.HTTP_401_UNAUTHORIZED, + "authn", + ) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_learner_may_not_read_another_accounts_preferences(self): + self.login(self.other) + self.assert_all_refused( + self.read_requests(self.owner.username), status.HTTP_403_FORBIDDEN, "authz", + ) + + def test_a_learner_may_not_change_another_accounts_preferences(self): + self.login(self.other) + self.assert_all_refused( + self.write_requests(self.owner.username), status.HTTP_403_FORBIDDEN, "authz", + ) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_staff_caller_reads_but_may_not_change_another_accounts_preferences(self): + self.login(self.staff) + for case, send in self.read_requests(self.owner.username).items(): + assert send().status_code == status.HTTP_200_OK, case + self.assert_all_refused( + self.write_requests(self.owner.username), status.HTTP_403_FORBIDDEN, "authz", + ) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_staff_caller_reads_and_changes_their_own_preferences(self): + self.prefer("time_zone", "Europe/Paris", user=self.staff) + self.login(self.staff) + own = self.staff.username + for case, send in self.read_requests(own).items(): + assert send().status_code == status.HTTP_200_OK, case + assert self.write_requests(own)["document"]().status_code == status.HTTP_204_NO_CONTENT + assert self.write_requests(own)["member"]().status_code == status.HTTP_204_NO_CONTENT + assert self.write_requests(own)["member removal"]().status_code == ( + status.HTTP_204_NO_CONTENT + ) + + def test_a_token_held_by_a_disabled_staff_account_may_not_read_another_accounts_preferences(self): + """A privilege does not survive the deactivation of the account that held it.""" + headers = self.token_headers(self.staff) + assert self.client.get(self.document_url, **headers).status_code == status.HTTP_200_OK + assert self.client.get(self.member_url, **headers).status_code == status.HTTP_200_OK + self.staff.is_active = False + self.staff.save() + for address in (self.document_url, self.member_url): + assert_error_envelope( + self.client.get(address, **headers), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_a_disabled_account_still_reads_and_changes_its_own_preferences(self): + """The staff half of the check is the one a disabled account loses.""" + self.owner.is_active = False + self.owner.save() + headers = self.token_headers(self.owner) + assert self.client.get(self.document_url, **headers).json() == {"time_zone": "Asia/Tokyo"} + assert self.patch_document( + self.document_url, {"time_zone": "Europe/Paris"}, **headers, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {"time_zone": "Europe/Paris"} + + def test_a_caller_named_for_the_address_in_case_alone_is_refused(self): + """Two names differing in case alone are two names, and only one of them is addressed.""" + self.sign_in_as_a_case_variant_of(self.owner) + self.assert_all_refused( + { + **self.read_requests(self.owner.username), + **self.write_requests(self.owner.username), + }, + status.HTTP_403_FORBIDDEN, + "authz", + ) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_learner_naming_an_account_that_does_not_exist_is_refused_without_learning_that(self): + self.login(self.other) + self.assert_all_refused( + {**self.read_requests("nobody"), **self.write_requests("nobody")}, + status.HTTP_403_FORBIDDEN, + "authz", + ) + + def test_an_administrator_naming_an_account_that_does_not_exist_is_told_so(self): + self.login(self.staff) + self.assert_all_refused( + self.read_requests("nobody"), status.HTTP_404_NOT_FOUND, "not-found", + ) + + def test_an_administrator_may_not_change_the_preferences_of_a_name_nobody_holds(self): + """The refusal is the one every change to another account gets, missing name or not.""" + self.login(self.staff) + self.assert_all_refused( + self.write_requests("nobody"), status.HTTP_403_FORBIDDEN, "authz", + ) + + def test_an_account_with_an_unconfirmed_address_reads_and_changes_its_own_preferences(self): + """The site sends such a user to the pages that read and write these preferences.""" + self.owner.is_active = False + self.owner.save() + self.login(self.owner) + assert self.client.get(self.document_url).json() == {"time_zone": "Asia/Tokyo"} + assert self.client.get(self.member_url).status_code == status.HTTP_200_OK + assert self.patch_document( + self.document_url, {"account_privacy": "private"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.put_value( + self.member_url, {"value": "Europe/Paris"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.client.delete(self.member_url).status_code == status.HTTP_204_NO_CONTENT + + def test_a_session_without_the_token_that_goes_with_it_is_refused_every_change(self): + """ + The session class still holds a change to the token the site hands out with it. + + A read is not held to it, because only a change can be provoked from + another site. The pages that keep a user's settings reach these + addresses with the session they signed in with, so the refusal a + browser meets here is the one pinned. + """ + held_to_its_token = APIClient(enforce_csrf_checks=True) + assert held_to_its_token.login(username=self.owner.username, password=PASSWORD) + self.assert_all_refused( + self.write_requests(self.owner.username, client=held_to_its_token), + status.HTTP_403_FORBIDDEN, + "authz", + ) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + for case, send in self.read_requests( + self.owner.username, client=held_to_its_token, + ).items(): + assert send().status_code == status.HTTP_200_OK, case + + def test_an_administrator_asking_what_another_accounts_addresses_answer_is_told(self): + """ + What an address does with an account is answered to whoever may read it. + + The description of a change the answer carries is built behind a check + of its own, which is made with the method of that change, so a caller + who may not make it is not told what it would carry. + """ + self.login(self.staff) + for address in (self.document_url, self.member_url): + answer = self.client.options(address) + assert answer.status_code == status.HTTP_200_OK, address + assert answer.json()["name"] == "Account Preference", address + assert "actions" not in answer.json(), address + + def test_the_addressed_account_is_told_what_a_change_at_their_own_address_carries(self): + """ + The answer describes the body this address serves, which carries the name as well. + + What a change carries is named in the published document, which gives + the value alone; the description assembled here is built from the body + the address answers with, so it names both halves of a stored + preference. + """ + self.login(self.owner) + answer = self.client.options(self.member_url) + assert answer.status_code == status.HTTP_200_OK + assert set(answer.json()["actions"]["PUT"]) == {"key", "value"} + + def test_a_learner_asking_what_another_accounts_addresses_answer_is_refused(self): + self.login(self.other) + for address in (self.document_url, self.member_url): + assert_error_envelope( + self.client.options(address), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_an_anonymous_caller_asking_what_these_addresses_answer_is_refused(self): + for address in (self.document_url, self.member_url): + assert_error_envelope( + self.client.options(address), + expected_status=status.HTTP_401_UNAUTHORIZED, + expected_type_slug="authn", + ) + + def test_a_token_is_read_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, so these + addresses are recorded beside the ones that read both halves the way + the platform declares, which is the cost of reading a token the + platform's own way. + """ + with mock.patch( + "openedx.core.djangolib.default_auth_classes.set_custom_attribute", + ) as recorded: + answer = self.client.get(self.document_url, **self.token_headers(self.owner)) + assert answer.status_code == status.HTTP_200_OK + assert [call.args for call in recorded.call_args_list] == [ + ("using_default_auth_classes", True), + ] + + +@skip_unless_lms +class PreferenceRefusedWithNothingBehindThemTests(PreferenceApiTestCase): + """ + Who these addresses refuse when nothing behind them refuses anybody. + + The preference service applies the same owner-or-administrator rule a + second time, one frame below the handler, so an answer produced with it in + place does not say which of the two refused a caller. Every request here is + made with the service calls replaced by stand-ins that serve whoever asks, + which leaves the classes guarding the actions as the only thing that can + refuse: each refusal below is one of theirs, and a caller they started + admitting would be answered here rather than refused. + """ + + #: What every preference the stand-ins serve is stored under, and its value. + SERVED = {"time_zone": "Asia/Tokyo"} + + def setUp(self): + super().setUp() + self.disabled_staff = UserFactory.create( + username="pref-disabled-staff", + email="pref-disabled-staff@example.com", + password=PASSWORD, + is_staff=True, + is_active=False, + ) + + @contextlib.contextmanager + def nothing_refusing_behind_the_handler(self): + """ + Serve every preference call, so only the classes guarding an action can refuse. + + The stand-ins are handed to the caller of this so a refusal can be + held to having happened in front of them: a request they were never + asked to serve was refused before anything behind the handler saw it. + """ + stand_ins = { + "get_user_preferences": mock.Mock(return_value=dict(self.SERVED)), + "get_user_preference": mock.Mock(return_value=self.SERVED["time_zone"]), + "update_user_preferences": mock.Mock(return_value=None), + "set_user_preference": mock.Mock(return_value=None), + "delete_user_preference": mock.Mock(return_value=True), + } + with mock.patch.multiple("openedx.core.djangoapps.user_api.v2.views", **stand_ins): + yield stand_ins + + def assert_nothing_behind_the_handler_was_asked(self, stand_ins): + """Assert no preference call was reached, so the refusal was the classes' own.""" + assert [name for name, stand_in in stand_ins.items() if stand_in.called] == [] + + def test_the_addressed_account_is_served_at_every_address(self): + """The stand-ins refuse nobody, which is what makes each refusal below the classes' own.""" + self.login(self.owner) + with self.nothing_refusing_behind_the_handler() as stand_ins: + reads = self.read_requests(self.owner.username) + assert reads["document"]().json() == self.SERVED + assert reads["member"]().json() == {"key": "time_zone", "value": "Asia/Tokyo"} + for case, send in self.write_requests(self.owner.username).items(): + assert send().status_code == status.HTTP_204_NO_CONTENT, case + assert sorted(name for name, stand_in in stand_ins.items() if stand_in.called) == [ + "delete_user_preference", + "get_user_preference", + "get_user_preferences", + "set_user_preference", + "update_user_preferences", + ] + + def test_a_learner_reaching_another_account_is_refused_at_every_address(self): + self.login(self.other) + with self.nothing_refusing_behind_the_handler() as stand_ins: + self.assert_all_refused( + { + **self.read_requests(self.owner.username), + **self.write_requests(self.owner.username), + }, + status.HTTP_403_FORBIDDEN, + "authz", + ) + self.assert_nothing_behind_the_handler_was_asked(stand_ins) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_an_administrator_reads_another_account_and_is_refused_every_change_to_it(self): + self.login(self.staff) + with self.nothing_refusing_behind_the_handler() as stand_ins: + for case, send in self.read_requests(self.owner.username).items(): + assert send().status_code == status.HTTP_200_OK, case + self.assert_all_refused( + self.write_requests(self.owner.username), status.HTTP_403_FORBIDDEN, "authz", + ) + assert sorted(name for name, stand_in in stand_ins.items() if stand_in.called) == [ + "get_user_preference", + "get_user_preferences", + ] + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_caller_named_for_the_address_in_case_alone_is_refused_at_every_address(self): + self.sign_in_as_a_case_variant_of(self.owner) + with self.nothing_refusing_behind_the_handler() as stand_ins: + self.assert_all_refused( + { + **self.read_requests(self.owner.username), + **self.write_requests(self.owner.username), + }, + status.HTTP_403_FORBIDDEN, + "authz", + ) + self.assert_nothing_behind_the_handler_was_asked(stand_ins) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_an_administrator_whose_account_is_no_longer_active_is_refused_every_address(self): + headers = self.token_headers(self.disabled_staff) + with self.nothing_refusing_behind_the_handler() as stand_ins: + self.assert_all_refused( + { + **self.read_requests(self.owner.username, **headers), + **self.write_requests(self.owner.username, **headers), + }, + status.HTTP_403_FORBIDDEN, + "authz", + ) + self.assert_nothing_behind_the_handler_was_asked(stand_ins) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + +@skip_unless_lms +class PreferenceGuardTests(PreferenceApiTestCase): + """ + Which callers the classes guarding each action admit, read from the view itself. + + The preference service refuses the same callers a second time, from behind + these classes, so a request refused at either point is refused the same + way. What each action is declared to admit is therefore asserted here, on + the declarations themselves, as well as through the answers these + addresses give when nothing behind them refuses anybody. + """ + + def setUp(self): + super().setUp() + self.disabled_staff = UserFactory.create( + username="pref-disabled-staff", + email="pref-disabled-staff@example.com", + password=PASSWORD, + is_staff=True, + is_active=False, + ) + + def routed_actions(self): + """Return every action the two preference addresses route a method to.""" + routed = set() + for address in (self.document_url, self.member_url): + routed.update( + action + for method, action in resolve(address).func.actions.items() + if method != "head" + ) + return routed + + def admits(self, action, caller, username, method="get"): + """Return whether every class guarding an action admits one caller at one address.""" + request = getattr(APIRequestFactory(), method)("/") + request.user = caller + view = AccountPreferenceViewSet() + view.action = action + view.kwargs = {"username": username} + view.request = request + return all( + permission.has_permission(request, view) for permission in view.get_permissions() + ) + + def test_the_reads_admit_the_addressed_account_and_an_active_administrator(self): + for action in ("retrieve_all", "retrieve"): + assert self.admits(action, self.owner, self.owner.username), action + assert self.admits(action, self.staff, self.owner.username), action + assert not self.admits(action, self.other, self.owner.username), action + assert not self.admits(action, self.disabled_staff, self.owner.username), action + assert not self.admits(action, AnonymousUser(), self.owner.username), action + + def test_a_change_admits_the_addressed_account_and_nobody_else(self): + for action in ("partial_update_all", "update", "destroy"): + assert self.admits(action, self.owner, self.owner.username), action + assert not self.admits(action, self.staff, self.owner.username), action + assert not self.admits(action, self.other, self.owner.username), action + assert not self.admits(action, self.disabled_staff, self.owner.username), action + assert not self.admits(action, AnonymousUser(), self.owner.username), action + + def test_the_address_is_the_only_place_a_name_is_read_from(self): + """A name differing in case alone is a different name, whatever the action.""" + for action in self.routed_actions(): + assert not self.admits(action, self.owner, self.owner.username.upper()), action + assert not self.admits(action, self.owner, self.other.username), action + assert not self.admits(action, self.owner, "nobody"), action + + def test_the_actions_an_administrator_may_call_against_another_account_are_the_two_reads(self): + """ + Read from the actions the addresses route, so a new one is not admitted unnoticed. + + The actions come from the mounts rather than from a list of their own, + which is what makes this an answer about who is admitted rather than a + restatement of the names the view holds them under. + """ + admitted = { + action + for action in self.routed_actions() + if self.admits(action, self.staff, self.owner.username) + } + assert admitted == {"retrieve_all", "retrieve"} + assert self.routed_actions() - admitted == {"partial_update_all", "update", "destroy"} + + def test_asking_what_an_address_answers_is_guarded_as_a_read_of_it(self): + """ + The action recorded for that question is named by no mount, so it is asserted apart. + + The same action is recorded again while the description of a change is + built, with the method of that change rather than the one the caller + sent, and is guarded as the change itself. + """ + for caller, admitted in ( + (self.owner, True), + (self.staff, True), + (self.other, False), + (self.disabled_staff, False), + (AnonymousUser(), False), + ): + assert self.admits( + DESCRIBE_ADDRESS_ACTION, caller, self.owner.username, method="options", + ) is admitted, caller + assert self.admits( + DESCRIBE_ADDRESS_ACTION, self.owner, self.owner.username, method="put", + ) + assert not self.admits( + DESCRIBE_ADDRESS_ACTION, self.staff, self.owner.username, method="put", + ) + + +@skip_unless_lms +class PreferenceAddressedByMeTests(PreferenceApiTestCase): + """``me`` beneath an account is read as a stored name on these addresses.""" + + def setUp(self): + super().setUp() + self.me_document = self.document_address("me") + self.me_member = self.member_address("me", "time_zone") + + def changes(self): + """Return each change these addresses serve, as what it changes and how it is sent.""" + return ( + ("the whole document", lambda: self.patch_document( + self.me_document, {"time_zone": "Europe/Paris"}, + )), + ("one preference", lambda: self.put_value(self.me_member, {"value": "Europe/Paris"})), + ("the removal of one preference", lambda: self.client.delete(self.me_member)), + ) + + def reads(self): + """Return each read these addresses serve, as what it reads and how it is sent.""" + return ( + ("the whole document", lambda: self.client.get(self.me_document)), + ("one preference", lambda: self.client.get(self.me_member)), + ) + + def test_a_learner_addressing_me_is_refused(self): + self.login(self.owner) + self.assert_all_refused(self.reads(), status.HTTP_403_FORBIDDEN, "authz") + + def test_an_administrator_addressing_me_is_told_no_account_holds_that_name(self): + self.login(self.staff) + self.assert_all_refused(self.reads(), status.HTTP_404_NOT_FOUND, "not-found") + + def test_a_learner_changing_the_preferences_at_me_is_refused(self): + self.login(self.owner) + self.assert_all_refused(self.changes(), status.HTTP_403_FORBIDDEN, "authz") + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_an_administrator_changing_them_is_refused_rather_than_told_the_name_is_free(self): + self.login(self.staff) + self.assert_all_refused(self.changes(), status.HTTP_403_FORBIDDEN, "authz") + assert self.stored() == {"time_zone": "Asia/Tokyo"} + assert self.stored(self.staff) == {} + + def test_the_account_holding_that_name_is_served(self): + held = UserFactory.create(username="me", email="me@example.com", password=PASSWORD) + self.prefer("time_zone", "Europe/Paris", user=held) + self.login(held) + assert self.client.get(self.me_document).json() == {"time_zone": "Europe/Paris"} + assert self.client.get(self.me_member).json() == { + "key": "time_zone", "value": "Europe/Paris", + } + assert self.put_value( + self.me_member, {"value": "Asia/Tokyo"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored(held) == {"time_zone": "Asia/Tokyo"} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + assert self.patch_document( + self.me_document, {"time_zone": "America/Lima"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored(held) == {"time_zone": "America/Lima"} + assert self.client.delete(self.me_member).status_code == status.HTTP_204_NO_CONTENT + assert self.stored(held) == {} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + +@skip_unless_lms +class PreferenceQueryParameterTests(PreferenceApiTestCase): + """These addresses read no query parameter, and refuse the ones they are sent.""" + + def test_a_parameter_none_of_these_addresses_reads_is_refused(self): + self.login(self.owner) + for address in (self.document_url, self.member_url): + envelope = assert_error_envelope( + self.client.get(address, {"foo": "1"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "foo": [f"No such query parameter. Send any of: {PREFERENCE_QUERY_PARAMS}."], + }, address + + def test_the_document_carries_no_pages(self): + """It is one account's preferences, read and changed whole.""" + self.login(self.owner) + for parameter in ("page", "page_size"): + envelope = assert_error_envelope( + self.client.get(self.document_url, {parameter: "1"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert list(envelope["errors"]) == [parameter], parameter + + def test_no_preference_address_shapes_what_it_answers_with(self): + """A caller wanting one name has the address of it.""" + self.login(self.owner) + for address in (self.document_url, self.member_url): + for parameter in ("view", "fields"): + assert_error_envelope( + self.client.get(address, {parameter: "minimal"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + + def test_a_change_carrying_a_parameter_it_does_not_read_is_refused(self): + self.login(self.owner) + assert_error_envelope( + self.patch_document( + f"{self.document_url}?foo=1", {"time_zone": "Europe/Paris"}, + ), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + +@skip_unless_lms +class PreferenceServiceFailureTests(PreferenceApiTestCase): + """Work that was asked for correctly and failed anyway is not the caller's fault.""" + + def test_a_change_that_could_not_be_carried_out_is_a_server_fault(self): + """The earlier address answered a failed save as a bad request.""" + self.login(self.owner) + failure = PreferenceUpdateError( + developer_message="Save failed for user preference 'time_zone': disk full", + user_message="Save failed.", + ) + with mock.patch( + "openedx.core.djangoapps.user_api.v2.views.update_user_preferences", side_effect=failure, + ): + response = self.patch_document(self.document_url, {"time_zone": "Europe/Paris"}) + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_500_INTERNAL_SERVER_ERROR, + expected_type_slug="internal", + ) + assert envelope["detail"] == "The request could not be carried out." + assert "disk full" not in json.dumps(envelope) + + def test_a_change_the_database_refused_is_a_server_fault(self): + """ + A failure the caller cannot correct stays a server fault, whatever the body carried. + + The failure here is one the service did not make anything of, which is + answered by the standard handling rather than by what this endpoint + makes of the failures the service reports. + """ + self.login(self.owner) + with mock.patch( + "openedx.core.djangoapps.user_api.v2.views.update_user_preferences", + side_effect=DatabaseError("the table is gone"), + ): + response = self.patch_document(self.document_url, {"time_zone": "Europe/Paris"}) + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_500_INTERNAL_SERVER_ERROR, + expected_type_slug="internal", + ) + assert "the table is gone" not in json.dumps(envelope) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def send_a_change_whose_removal_fails(self): + """Send a merge patch whose first name is stored and whose second cannot be removed.""" + with mock.patch( + "openedx.core.djangoapps.user_api.models.UserPreference.delete", + side_effect=DatabaseError("the row is locked"), + ): + return self.patch_document( + self.document_url, + {"account_privacy": "all_users", "time_zone": None, "pref-lang": "eo"}, + ) + + def test_a_change_that_fails_part_way_through_stores_none_of_it(self): + """A merge patch is one change: a name that cannot be changed leaves every other alone.""" + self.login(self.owner) + self.prefer("account_privacy", "private") + self.prefer("pref-lang", "en") + envelope = assert_error_envelope( + self.send_a_change_whose_removal_fails(), + expected_status=status.HTTP_500_INTERNAL_SERVER_ERROR, + expected_type_slug="internal", + ) + assert "the row is locked" not in json.dumps(envelope) + assert self.stored() == { + "account_privacy": "private", "pref-lang": "en", "time_zone": "Asia/Tokyo", + } + + def test_a_change_that_fails_stores_none_of_it_where_a_statement_stands_on_its_own(self): + """ + The change is carried out as one piece by the endpoint itself. + + The platform serves every request inside a transaction, which would + undo the names stored before the one that failed on its own, so the + promise is held here to a database configured without one. + """ + self.login(self.owner) + self.prefer("account_privacy", "private") + self.prefer("pref-lang", "en") + with requests_served_outside_a_transaction(): + response = self.send_a_change_whose_removal_fails() + assert_error_envelope( + response, + expected_status=status.HTTP_500_INTERNAL_SERVER_ERROR, + expected_type_slug="internal", + ) + assert self.stored() == { + "account_privacy": "private", "pref-lang": "en", "time_zone": "Asia/Tokyo", + } + + def test_a_read_that_could_not_be_served_is_a_server_fault(self): + self.login(self.owner) + with mock.patch( + "openedx.core.djangoapps.user_api.v2.views.get_user_preferences", + side_effect=UserAPIInternalError("the preference table is gone"), + ): + response = self.client.get(self.document_url) + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_500_INTERNAL_SERVER_ERROR, + expected_type_slug="internal", + ) + assert "preference table is gone" not in json.dumps(envelope) + + def test_a_removal_that_could_not_be_carried_out_is_a_server_fault(self): + """ + The failure is provoked at the row, so it arrives as the service reports it. + + What the service makes of a failed removal is the thing this endpoint + turns into an answer, so the failure is raised where the row is + removed rather than handed to the handler ready-made. + """ + self.login(self.owner) + with mock.patch( + "openedx.core.djangoapps.user_api.models.UserPreference.delete", + side_effect=DatabaseError("the row is locked"), + ): + response = self.client.delete(self.member_url) + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_500_INTERNAL_SERVER_ERROR, + expected_type_slug="internal", + ) + assert envelope["detail"] == "The request could not be carried out." + assert "the row is locked" not in json.dumps(envelope) + assert "Delete failed" not in json.dumps(envelope) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + +class PreferenceReadWriteCaptureCase(PreferenceApiTestCase): + """ + What a read of these addresses does, measured statement by statement. + + Three channels are captured, because a read that changes stored data need + not do so itself: the statements it ran, the callbacks it deferred to the + end of its transaction, and what it announced. An announcement is captured + because the platform's own listeners act on one, and a change announced + from a read reaches those listeners whether or not the read wrote + anything: the field-change signal is captured wherever it is sent from, + and the analytics channel it is sent alongside with it. + """ + + def setUp(self): + super().setUp() + self.deferred = [] + self.announced = [] + self.tracked = [] + + def captured_writes(self, method, address, expect=status.HTTP_200_OK, **headers): + """Return the statements a request made that would change stored data.""" + with CaptureQueriesContext(connection) as captured, \ + mock.patch.object(USER_FIELDS_CHANGED, "send") as announced, \ + mock.patch.object(model_utils.tracker, "emit") as tracked, \ + self.captureOnCommitCallbacks(execute=True) as callbacks: + answer = getattr(self.client, method)(address, **headers) + assert answer.status_code == expect, answer.content + self.deferred = callbacks + self.announced = [call.kwargs for call in announced.call_args_list] + self.tracked = [self.emitted(call) for call in tracked.call_args_list] + return write_queries(captured.captured_queries) + + @staticmethod + def emitted(call): + """Return the name and the payload of one analytics event, however it was passed.""" + passed = (*call.args, None, None) + return call.kwargs.get("name", passed[0]), call.kwargs.get("data", passed[1]) + + def targets(self, statements): + """Return the table each statement writes to.""" + return [statement.split('"')[1] for statement in statements] + + def assert_only_the_request_itself_was_recorded(self, address): + """Assert the one event the request emitted was the platform's record of the request.""" + assert self.tracked == [(address, REQUEST_RECORD_DATA)] + + def assert_nothing_was_changed_or_announced(self, statements, address): + """Assert the request wrote nothing, deferred nothing and announced no change.""" + assert (statements, self.deferred, self.announced) == ([], [], []) + self.assert_only_the_request_itself_was_recorded(address) + + def assert_the_language_change_was_announced(self, address, caller=None): + """Assert the one change announced was the language row the middleware stored.""" + caller = caller or self.owner + assert self.announced == [{ + "sender": None, + "user": caller, + "table": PREFERENCE_TABLE, + "changed_fields": {LANGUAGE_PREFERENCE: (None, "eo")}, + }] + assert self.tracked == [ + (address, REQUEST_RECORD_DATA), + ( + USER_SETTINGS_CHANGED_EVENT_NAME, + { + "old": None, + "new": "eo", + "truncated": [], + "setting": LANGUAGE_PREFERENCE, + "user_id": caller.id, + "table": PREFERENCE_TABLE, + }, + ), + ] + + +@skip_unless_lms +class PreferenceReadsWithoutTheLanguageCookieTests(PreferenceReadWriteCaptureCase): + """A read of these addresses changes nothing of what they serve, and announces nothing.""" + + def assert_the_read_changed_nothing(self, method, address, expect=status.HTTP_200_OK, **headers): + """Assert a read of this address wrote nothing, deferred nothing and announced nothing.""" + self.assert_nothing_was_changed_or_announced( + self.captured_writes(method, address, expect=expect, **headers), address, + ) + + def test_reading_the_document_writes_nothing(self): + self.login(self.owner) + self.assert_the_read_changed_nothing("get", self.document_url) + + def test_reading_one_preference_writes_nothing(self): + self.login(self.owner) + self.assert_the_read_changed_nothing("get", self.member_url) + + def test_a_staff_caller_reading_another_account_writes_nothing(self): + self.login(self.staff) + for address in (self.document_url, self.member_url): + self.assert_the_read_changed_nothing("get", address) + + def test_a_read_presented_with_a_token_writes_nothing(self): + for address in (self.document_url, self.member_url): + self.assert_the_read_changed_nothing("get", address, **self.token_headers(self.owner)) + + def test_heading_either_address_writes_nothing(self): + self.login(self.owner) + for address in (self.document_url, self.member_url): + self.assert_the_read_changed_nothing("head", address) + + def test_the_earlier_addresses_write_nothing_either(self): + self.login(self.owner) + for address in ( + earlier_document_address(self.owner.username), + earlier_member_address(self.owner.username, "time_zone"), + ): + self.assert_the_read_changed_nothing("get", address) + + def test_reading_a_name_the_account_stores_no_value_under_writes_nothing(self): + """Being told nothing is stored under a name is no reason to store anything.""" + self.login(self.owner) + self.assert_the_read_changed_nothing( + "get", + self.member_address(self.owner.username, "account_privacy"), + expect=status.HTTP_404_NOT_FOUND, + ) + + def test_a_staff_caller_told_another_account_stores_no_such_name_writes_nothing(self): + self.login(self.staff) + self.assert_the_read_changed_nothing( + "get", + self.member_address(self.other.username, "account_privacy"), + expect=status.HTTP_404_NOT_FOUND, + ) + + def test_a_read_of_an_account_nobody_holds_writes_nothing(self): + """The other branch that answers nothing: no such account, rather than no such name.""" + self.login(self.staff) + for address in ( + self.document_address("pref-nobody"), + self.member_address("pref-nobody", "time_zone"), + ): + self.assert_the_read_changed_nothing( + "get", address, expect=status.HTTP_404_NOT_FOUND, + ) + + def test_a_read_leaves_the_stored_preferences_as_they_were(self): + self.login(self.owner) + before = self.stored() + self.client.get(self.document_url) + self.client.get(self.member_url) + assert self.stored() == before == {"time_zone": "Asia/Tokyo"} + + +@skip_unless_lms +class PreferenceReadsWithTheLanguageCookieTests(PreferenceReadWriteCaptureCase): + """ + A request carrying the language cookie stores the language it carries. + + The platform keeps the language a user picked in a cookie and syncs it into + that user's preferences from middleware, which runs before any view and on + every method, so it is reached by a read of these addresses as it is by a + read of any other. The capture below pins what it writes: the one row, by + the table it is stored in, and nothing else. The same request to the + address this version supersedes writes the same row, which is what shows + the write belongs to the platform rather than to either version. + """ + + def setUp(self): + super().setUp() + self.client.cookies[LANGUAGE_COOKIE] = "eo" + + def language_rows(self): + """Return the stored language of every account, by account.""" + return { + preference.user.username: preference.value + for preference in UserPreference.objects.filter(key=LANGUAGE_PREFERENCE) + } + + def assert_only_the_language_was_stored( + self, method, address, expect=status.HTTP_200_OK, **headers, + ): + """Assert the one write made, and the one change announced, was the caller's language.""" + statements = self.captured_writes(method, address, expect=expect, **headers) + assert (self.targets(statements), self.deferred) == ([PREFERENCE_TABLE], []), statements + assert self.language_rows() == {self.owner.username: "eo"} + self.assert_the_language_change_was_announced(address) + + def test_reading_the_document_stores_the_language_the_cookie_carries(self): + self.login(self.owner) + assert self.language_rows() == {} + self.assert_only_the_language_was_stored("get", self.document_url) + + def test_reading_one_preference_stores_it_too(self): + self.login(self.owner) + self.assert_only_the_language_was_stored("get", self.member_url) + + def test_the_address_this_version_supersedes_stores_the_same_row(self): + self.login(self.owner) + self.assert_only_the_language_was_stored( + "get", earlier_document_address(self.owner.username), + ) + + def test_the_earlier_member_address_stores_the_same_row(self): + self.login(self.owner) + self.assert_only_the_language_was_stored( + "get", earlier_member_address(self.owner.username, "time_zone"), + ) + + def test_heading_the_document_stores_it_as_well(self): + """A write one frame below the handler is reached by every method, this one included.""" + self.login(self.owner) + self.assert_only_the_language_was_stored("head", self.document_url) + + def test_a_read_presented_with_a_token_stores_it_as_well(self): + self.assert_only_the_language_was_stored( + "get", self.document_url, **self.token_headers(self.owner), + ) + + def test_reading_a_name_the_account_stores_no_value_under_stores_only_the_language(self): + """The refusal writes nothing of its own: the row below it is still the only write.""" + self.login(self.owner) + self.assert_only_the_language_was_stored( + "get", + self.member_address(self.owner.username, "account_privacy"), + expect=status.HTTP_404_NOT_FOUND, + ) + + def test_the_row_is_written_once_and_not_once_a_request(self): + """It is the language being synced, not the read recording anything.""" + self.login(self.owner) + self.assert_only_the_language_was_stored("get", self.document_url) + for address in (self.document_url, self.member_url): + self.assert_nothing_was_changed_or_announced( + self.captured_writes("get", address), address, + ) + + def test_the_language_a_staff_caller_carries_is_stored_against_their_own_account(self): + """The row is the caller's, whichever account the address names.""" + self.login(self.staff) + statements = self.captured_writes("get", self.document_url) + assert self.targets(statements) == [PREFERENCE_TABLE], statements + assert self.language_rows() == {self.staff.username: "eo"} + self.assert_the_language_change_was_announced(self.document_url, self.staff) + + def test_removing_the_language_removes_the_row_the_middleware_had_just_stored(self): + """ + The row below the handler is written first and removed by the handler itself. + + A client that removes the language it is carrying is told the account + stores no value under that name, and none does: the write one frame + below is not a second row and does not outlast the request that made + it. + """ + self.login(self.owner) + statements = self.captured_writes( + "delete", + self.member_address(self.owner.username, LANGUAGE_PREFERENCE), + expect=status.HTTP_204_NO_CONTENT, + ) + assert self.targets(statements) == [PREFERENCE_TABLE, PREFERENCE_TABLE], statements + assert self.language_rows() == {} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_the_document_removing_the_language_leaves_it_removed_as_well(self): + self.login(self.owner) + assert self.patch_document( + self.document_url, {LANGUAGE_PREFERENCE: None}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.language_rows() == {} + + def test_removing_another_name_stores_the_language_the_cookie_carries(self): + """The write one frame below is made whatever the method, a removal included.""" + self.login(self.owner) + statements = self.captured_writes( + "delete", self.member_url, expect=status.HTTP_204_NO_CONTENT, + ) + assert self.targets(statements) == [PREFERENCE_TABLE, PREFERENCE_TABLE], statements + assert self.language_rows() == {self.owner.username: "eo"} + assert self.stored() == {LANGUAGE_PREFERENCE: "eo"} + + def test_a_name_stored_only_by_the_middleware_is_removed_rather_than_reported_absent(self): + """ + The row is there to remove by the time the handler looks, so the removal is answered. + + Without the cookie the same request is answered with the refusal a + name no value is stored under gets, which is what the two assertions + below stand on either side of. + """ + self.login(self.owner) + address = self.member_address(self.owner.username, LANGUAGE_PREFERENCE) + assert self.language_rows() == {} + assert self.client.delete(address).status_code == status.HTTP_204_NO_CONTENT + del self.client.cookies[LANGUAGE_COOKIE] + assert self.client.delete(address).status_code == status.HTTP_404_NOT_FOUND + + def test_the_document_answered_carries_the_language_that_was_stored(self): + self.login(self.owner) + assert self.client.get(self.document_url).json() == { + LANGUAGE_PREFERENCE: "eo", "time_zone": "Asia/Tokyo", + } diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_query_counts.py b/openedx/core/djangoapps/user_api/v2/tests/test_query_counts.py index e8cc522fa8f1..2b0d8b87c349 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_query_counts.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_query_counts.py @@ -1,11 +1,13 @@ """Query-count guards for the version 2 User API list and member addresses.""" import pytest -from django.test.utils import override_settings +from django.db import connection +from django.test.utils import CaptureQueriesContext, 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.models import UserOrgTag from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory from openedx.core.djangolib.testing.utils import skip_unless_lms @@ -176,3 +178,139 @@ def test_the_earlier_preference_list_pays_per_row_where_this_one_does_not( HTTP_X_EDX_API_KEY=EARLIER_API_KEY, ) assert len(earlier.json()["results"]) == 2 * len(directory) + + +#: Address of one account's preferences in the version this one supersedes. +#: Both versions call the same service over the same rows, so what each of the +#: addresses below costs is measured at the superseded address in the same run +#: and this version is held to that measurement rather than to a number +#: written down here. A change in what every request of the site costs then +#: moves both measurements together instead of turning these red. +EARLIER_PREFERENCE_DOCUMENT = "/api/user/v1/preferences/{username}" + +#: How many more preferences are stored before a cost is measured again, so +#: that a query paid for each preference stored shows up as a rise. +MORE_PREFERENCES = 5 + + +def cost_of(request): + """Return what one request cost in queries, and what it answered with.""" + with CaptureQueriesContext(connection) as queries: + answer = request() + return len(queries), answer + + +@pytest.fixture(name="account_with_preferences") +@skip_unless_lms +def account_with_preferences_fixture(request, client, django_user_model): # pylint: disable=unused-argument + """Sign a learner in and give their own account ``request.param`` preferences besides a time zone.""" + owner = UserFactory.create(username="count-owner", password=PASSWORD) + UserPreferenceFactory.create(user=owner, key="time_zone", value="UTC") + for index in range(request.param): + UserPreferenceFactory.create(user=owner, key=f"counted_{index:03d}", value="stored") + assert client.login(username=owner.username, password=PASSWORD) + # The first authenticated request of a session populates caches the rest + # of the session reuses; the counts below are the steady-state ones. + client.get(reverse("user_v2:account_preference_list", kwargs={"username": owner.username})) + return owner, request.param + + +@pytest.mark.django_db +@pytest.mark.parametrize("account_with_preferences", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_the_preference_document_costs_the_same_at_either_address( + client, account_with_preferences, +): + """Neither version pays per preference: only the body grows with them.""" + owner, stored = account_with_preferences + address = reverse("user_v2:account_preference_list", kwargs={"username": owner.username}) + earlier_cost, earlier = cost_of( + lambda: client.get(EARLIER_PREFERENCE_DOCUMENT.format(username=owner.username)), + ) + current_cost, current = cost_of(lambda: client.get(address)) + assert current_cost == earlier_cost + assert earlier.json() == current.json() + assert set(current.json()) == {"time_zone"} | { + f"counted_{index:03d}" for index in range(stored) + } + + for index in range(stored, stored + MORE_PREFERENCES): + UserPreferenceFactory.create(user=owner, key=f"counted_{index:03d}", value="stored") + with_more_cost, with_more = cost_of(lambda: client.get(address)) + assert with_more_cost == current_cost + assert len(with_more.json()) == len(current.json()) + MORE_PREFERENCES + + +@pytest.mark.django_db +@pytest.mark.parametrize("account_with_preferences", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_one_preference_costs_the_same_at_either_address(client, account_with_preferences): + """Reading one preference costs what it costs at the superseded address, stored rows aside.""" + owner, stored = account_with_preferences + address = reverse( + "user_v2:account_preference_detail", + kwargs={"username": owner.username, "key": "time_zone"}, + ) + earlier_cost, earlier = cost_of( + lambda: client.get( + f"{EARLIER_PREFERENCE_DOCUMENT.format(username=owner.username)}/time_zone", + ), + ) + current_cost, current = cost_of(lambda: client.get(address)) + assert current_cost == earlier_cost + assert earlier.json() == "UTC" + assert current.json() == {"key": "time_zone", "value": "UTC"} + + for index in range(stored, stored + MORE_PREFERENCES): + UserPreferenceFactory.create(user=owner, key=f"counted_{index:03d}", value="stored") + with_more_cost, with_more = cost_of(lambda: client.get(address)) + assert with_more_cost == current_cost + assert with_more.json() == {"key": "time_zone", "value": "UTC"} + + +@pytest.mark.django_db +@pytest.mark.parametrize("account_with_preferences", [1], indirect=True) +@skip_unless_lms +def test_recording_an_email_choice_costs_what_the_earlier_address_costs( + client, account_with_preferences, +): + """ + Each address is measured from the same starting state as the other. + + Recording the first choice creates the row it is kept in and recording a + later one updates it, so a count taken after the other address has already + recorded the choice would be the cheaper path measured against the dearer + one. Both are measured here, and the rise between them is what makes the + starting state matter. + """ + owner, _stored = account_with_preferences + choices = UserOrgTag.objects.filter(user=owner, key="email-optin") + earlier_body = {"course_id": COURSE_KEY, "email_opt_in": "True"} + current_body = {"course_key": COURSE_KEY, "email_opt_in": True} + + def record_at_the_earlier_address(): + """Record the choice the way the pages calling the earlier address do.""" + return client.post("/api/user/v1/preferences/email_opt_in/", earlier_body) + + def record(): + """Record the choice at this version's address.""" + return client.post( + reverse("user_v2:email_opt_in_list"), current_body, content_type="application/json", + ) + + costs = {} + for address, record_choice, expected_status in ( + ("the earlier address", record_at_the_earlier_address, 200), + ("this version", record, 204), + ): + choices.delete() + opening_cost, opened = cost_of(record_choice) + assert opened.status_code == expected_status, address + changing_cost, changed = cost_of(record_choice) + assert changed.status_code == expected_status, address + assert choices.count() == 1, address + costs[address] = (opening_cost, changing_cost) + + assert costs["this version"] == costs["the earlier address"] + opening_cost, changing_cost = costs["this version"] + assert opening_cost > changing_cost diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py index 75a5acd412c6..f2f729b6a8ca 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py @@ -9,7 +9,9 @@ from django.conf import settings from django.urls import reverse from drf_spectacular.generators import SchemaGenerator +from drf_spectacular.plumbing import get_override from drf_spectacular.settings import patched_settings +from rest_framework.test import APIClient from waffle.testutils import override_switch from common.djangoapps.student.models import PendingNameChange, UserProfile @@ -33,6 +35,9 @@ ACCOUNT_DEFAULT_SORT, ACCOUNT_MINIMAL_FIELDS, EXPIRATION_DAY_PATTERN, + PREFERENCE_NAME_MAX_LENGTH, + AccountPreferenceChangesField, + AccountPreferenceDocumentField, AccountQuerySerializer, TimeZoneQuerySerializer, VerificationQuerySerializer, @@ -52,6 +57,16 @@ VERIFICATION_STATUS_PATH = "/api/user/v2/accounts/{username}/verification_status/" VERIFICATION_LIST_PATH = "/api/user/v2/accounts/{username}/verifications/" EMAIL_SEARCH_PATH = "/api/user/v2/email_searches/" +PREFERENCE_DOCUMENT_PATH = "/api/user/v2/accounts/{username}/preferences/" +PREFERENCE_NAME_PATH = "/api/user/v2/accounts/{username}/preferences/{key}/" +EMAIL_OPT_IN_PATH = "/api/user/v2/email_opt_ins/" + +#: What both preference addresses say the name in them is read as. +ACCOUNT_PREFERENCES_USERNAME_DESCRIPTION = ( + "Unique name of the user whose preferences are addressed. The name is read as it is stored, " + "so `me` here names an account holding that name rather than the account of the caller " + "making the request." +) NAME_CHANGE_PATH = "/api/user/v2/name_changes/" NAME_CHANGE_CONFIRMATION_PATH = "/api/user/v2/name_changes/{username}/confirmations/" @@ -73,11 +88,14 @@ VERIFICATION_STATUS_PATH, USER_MEMBER_PATH, PREFERENCE_MEMBER_PATH, + PREFERENCE_DOCUMENT_PATH, + PREFERENCE_NAME_PATH, } #: The addresses that act on a resource rather than answer with one. ACTION_PATHS = { EMAIL_SEARCH_PATH, + EMAIL_OPT_IN_PATH, ACCOUNT_DEACTIVATION_PATH, PROFILE_IMAGE_PATH, NAME_CHANGE_PATH, @@ -90,6 +108,8 @@ ACCOUNT_MEMBER_PATH, VERIFICATION_STATUS_PATH, VERIFICATION_LIST_PATH, + PREFERENCE_DOCUMENT_PATH, + PREFERENCE_NAME_PATH, } | ACTION_PATHS VERSION_TWO_PATHS = LIST_PATHS | MEMBER_PATHS | ACTION_PATHS @@ -105,10 +125,13 @@ "AccountExtendedField", "AccountLanguage", "AccountMinimal", + "AccountPreference", + "AccountPreferenceValue", "AccountProfileImage", "AccountProjection", "AccountResponse", "AccountSocialLink", + "EmailOptIn", "EmailSearch", "EmailSearchResult", "ErrorResponse", @@ -198,6 +221,12 @@ {"fields": "value"}, "UserPreferenceRecordProjection", ), + ( + PREFERENCE_NAME_PATH, + "/api/user/v2/accounts/shape-admin/preferences/pref-lang/", + {}, + "AccountPreference", + ), ("/api/user/v2/time_zones/", "/api/user/v2/time_zones/", {"country_code": "FR"}, "TimeZone"), ("/api/user/v2/accounts/", "/api/user/v2/accounts/", {}, "Account"), ("/api/user/v2/accounts/", "/api/user/v2/accounts/", {"view": "minimal"}, "AccountMinimal"), @@ -257,6 +286,12 @@ def published_body_schema(schema, path): return as_json_schema({**body, "components": schema["components"]}) +def published_request_schema(schema, path, method, media_type): + """Return what a request body of ``path`` must satisfy, components included.""" + body = schema["paths"][path][method]["requestBody"]["content"][media_type]["schema"] + return as_json_schema({**body, "components": schema["components"]}) + + def published_component_schema(schema, name): """Return one published component as a document a validator can read on its own.""" return as_json_schema({ @@ -302,6 +337,9 @@ def assert_records_match(schema, body, component, case=None): "/api/user/v1/users/{id}/": "/api/user/v2/users/{username}/", "/api/user/v1/user_prefs/": "/api/user/v2/user_preferences/", "/api/user/v1/user_prefs/{id}/": "/api/user/v2/user_preferences/{username},{key}/", + "/api/user/v1/preferences/{username}": PREFERENCE_DOCUMENT_PATH, + "/api/user/v1/preferences/{username}/{preference_key}": PREFERENCE_NAME_PATH, + "/api/user/v1/preferences/email_opt_in/": EMAIL_OPT_IN_PATH, "/api/user/v1/preferences/{pref_key}/users/": "/api/user/v2/users/", "/api/user/v1/forum_roles/{name}/users/": "/api/user/v2/users/", "/api/user/v1/preferences/time_zones/": "/api/user/v2/time_zones/", @@ -393,6 +431,12 @@ def test_every_operation_of_this_version_is_published_under_one_group(schema): "v2_accounts_verifications_list": ["openedx-platform-sdk"], "v2_name_changes_create": ["openedx-platform-sdk"], "v2_name_changes_confirmations_create": ["openedx-platform-sdk"], + "v2_accounts_preferences_retrieve": ["openedx-platform-sdk"], + "v2_accounts_preferences_partial_update": ["openedx-platform-sdk"], + "v2_accounts_preferences_retrieve_one": ["openedx-platform-sdk"], + "v2_accounts_preferences_update": ["openedx-platform-sdk"], + "v2_accounts_preferences_destroy": ["openedx-platform-sdk"], + "v2_email_opt_ins_create": ["openedx-platform-sdk"], "v2_users_list": ["user"], "v2_users_retrieve": ["user"], "v2_user_preferences_list": ["user"], @@ -428,7 +472,6 @@ def test_addresses_owned_by_other_apps_are_not_published(schema): 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", @@ -549,8 +592,8 @@ def test_hook_leaves_other_resources_alone(): "/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/", + "/api/user/v2/accounts/{username}/preferences/", "/user_api/v1/users/", "/user_api/v1/accounts/{username}", "/user_api/v1/preferences/time_zones/", @@ -563,6 +606,9 @@ def test_hook_leaves_other_resources_alone(): "/api/user/v1/accounts/{username}", "/api/user/v1/accounts/{username}/image", "/api/user/v1/accounts/name_change/", + "/api/user/v1/preferences/{username}", + "/api/user/v1/preferences/{username}/{preference_key}", + "/api/user/v1/preferences/email_opt_in/", "/api/enrollment/v2/enrollments", ] result = {"paths": {path: {"get": {"operationId": path}} for path in untouched + marked}} @@ -589,6 +635,9 @@ def test_every_superseded_address_has_a_published_replacement(schema): ("/api/user/v1/accounts/search_emails", "post"): { "application/json", "application/merge-patch+json", }, + ("/api/user/v1/preferences/{username}", "patch"): {"application/merge-patch+json"}, + ("/api/user/v1/preferences/{username}/{preference_key}", "put"): {"application/json"}, + ("/api/user/v1/preferences/email_opt_in/", "post"): {"application/x-www-form-urlencoded"}, } @@ -607,18 +656,32 @@ def test_each_superseded_body_is_published_in_the_media_types_that_address_reads staff = AdminFactory.create(username="media_type_staff", password=PASSWORD) assert client.login(username=staff.username, password=PASSWORD) member = f"/api/user/v1/accounts/{staff.username}" + preferences = f"/api/user/v1/preferences/{staff.username}" requests = { - ("/api/user/v1/accounts/{username}", "patch"): (member, "{}"), + ("/api/user/v1/accounts/{username}", "patch"): (member, "{}", 200), ("/api/user/v1/accounts/search_emails", "post"): ( - "/api/user/v1/accounts/search_emails", json.dumps({"emails": [staff.email]}), + "/api/user/v1/accounts/search_emails", json.dumps({"emails": [staff.email]}), 200, + ), + ("/api/user/v1/preferences/{username}", "patch"): ( + preferences, json.dumps({"pref-lang": "en"}), 204, + ), + # The key half of that route carries neither a hyphen nor a dot, so the + # name sent here is one it can address. + ("/api/user/v1/preferences/{username}/{preference_key}", "put"): ( + f"{preferences}/account_privacy", json.dumps("private"), 204, + ), + ("/api/user/v1/preferences/email_opt_in/", "post"): ( + "/api/user/v1/preferences/email_opt_in/", + "course_id=course-v1%3AedX%2BDemoX%2BDemo&email_opt_in=True", + 200, ), } for (path, method), media_types in SUPERSEDED_REQUEST_MEDIA_TYPES.items(): assert set(schema["paths"][path][method]["requestBody"]["content"]) == media_types, path - address, body = requests[(path, method)] + address, body, expected_status = requests[(path, method)] for media_type in media_types: answer = getattr(client, method)(address, data=body, content_type=media_type) - assert answer.status_code == 200, (address, media_type, answer.content) + assert answer.status_code == expected_status, (address, media_type, answer.content) left_out = client.patch(member, data="{}", content_type="application/json") assert left_out.status_code == 415 @@ -846,6 +909,34 @@ def test_member_addresses_describe_the_parts_they_are_built_from(schema): "Name the preference is stored under: the part of the address after the comma." ), }, + (PREFERENCE_DOCUMENT_PATH, "username"): { + "in": "path", + "name": "username", + "required": True, + "schema": username_schema, + "description": ACCOUNT_PREFERENCES_USERNAME_DESCRIPTION, + }, + (PREFERENCE_NAME_PATH, "username"): { + "in": "path", + "name": "username", + "required": True, + "schema": username_schema, + "description": ACCOUNT_PREFERENCES_USERNAME_DESCRIPTION, + }, + (PREFERENCE_NAME_PATH, "key"): { + "in": "path", + "name": "key", + "required": True, + "schema": {"type": "string", "pattern": rf"^{PREFERENCE_KEY_PATTERN_PARTIAL}$"}, + "description": ( + "Name the preference is stored under, which is the whole segment, punctuation " + "included. A name carrying a slash has no address of its own, because the " + "segment ends at the slash; such a name is read with the whole document and " + "removed by sending a null for it there. Nothing is stored under a name longer " + f"than {PREFERENCE_NAME_MAX_LENGTH} characters, nor under one carrying no " + "letter, digit, hyphen or underscore." + ), + }, } @@ -1726,3 +1817,278 @@ def test_the_addresses_this_phase_adds_publish_no_other_query_parameters(schema) VERIFICATION_STATUS_PATH: [], VERIFICATION_LIST_PATH: ["ordering", "page", "page_size"], } + + +#: The names this resource's operations are published under. The two reads +#: tokenize alike, so the read of one preference is named for what it reads and +#: the read of the whole document keeps the name both addresses derive; left to +#: itself the assembled document tells the two apart with a numeral, which says +#: which address sorts first rather than what a client gets back. +PREFERENCE_OPERATION_IDS = { + (PREFERENCE_DOCUMENT_PATH, "get"): "v2_accounts_preferences_retrieve", + (PREFERENCE_DOCUMENT_PATH, "patch"): "v2_accounts_preferences_partial_update", + (PREFERENCE_NAME_PATH, "get"): "v2_accounts_preferences_retrieve_one", + (PREFERENCE_NAME_PATH, "put"): "v2_accounts_preferences_update", + (PREFERENCE_NAME_PATH, "delete"): "v2_accounts_preferences_destroy", + (EMAIL_OPT_IN_PATH, "post"): "v2_email_opt_ins_create", +} + + +@skip_unless_lms +def test_the_preference_operations_carry_the_names_they_are_published_under(schema): + published = { + (path, method): operation["operationId"] + for path, method, operation in operations(schema) + if path in {PREFERENCE_DOCUMENT_PATH, PREFERENCE_NAME_PATH, EMAIL_OPT_IN_PATH} + } + assert published == PREFERENCE_OPERATION_IDS + + +@skip_unless_lms +def test_the_read_of_one_preference_is_named_in_every_document_that_publishes_it(schema): + """ + Each document names that read as it names its four siblings, and loses no name. + + The two documents derive different names for one operation, because one + trims the area off the front of every path and the other trims nothing, so + the read is named in each of them through the name that document derives. + That leaves the five operations of this resource carrying the same leading + tokens wherever they are published, which is what a client generated from + a document takes its module from. The addresses of version 1 are named the + same way, and there only the document that trims the area off is covered: + the one that trims nothing keeps every name it has published, numeral and + all. + """ + service_wide = service_wide_schema() + assert schema["paths"][PREFERENCE_NAME_PATH]["get"]["operationId"] == ( + "v2_accounts_preferences_retrieve_one" + ) + assert service_wide["paths"][PREFERENCE_NAME_PATH]["get"]["operationId"] == ( + "api_user_v2_accounts_preferences_retrieve_one" + ) + assert schema["paths"][PREFERENCE_DOCUMENT_PATH]["get"]["operationId"] == ( + "v2_accounts_preferences_retrieve" + ) + assert service_wide["paths"][PREFERENCE_DOCUMENT_PATH]["get"]["operationId"] == ( + "api_user_v2_accounts_preferences_retrieve" + ) + assert { + (path, method): operation["operationId"] + for path, method, operation in operations(service_wide) + if path in {PREFERENCE_DOCUMENT_PATH, PREFERENCE_NAME_PATH} + } == { + (PREFERENCE_DOCUMENT_PATH, "get"): "api_user_v2_accounts_preferences_retrieve", + (PREFERENCE_DOCUMENT_PATH, "patch"): "api_user_v2_accounts_preferences_partial_update", + (PREFERENCE_NAME_PATH, "get"): "api_user_v2_accounts_preferences_retrieve_one", + (PREFERENCE_NAME_PATH, "put"): "api_user_v2_accounts_preferences_update", + (PREFERENCE_NAME_PATH, "delete"): "api_user_v2_accounts_preferences_destroy", + } + version_one = { + (path, method): operation["operationId"] + for path, method, operation in operations(service_wide) + if path.startswith("/api/user/v1/preferences/{username}") + } + assert version_one == { + ("/api/user/v1/preferences/{username}", "get"): "api_user_v1_preferences_retrieve", + ("/api/user/v1/preferences/{username}", "patch"): "api_user_v1_preferences_partial_update", + ("/api/user/v1/preferences/{username}/{preference_key}", "get"): ( + "api_user_v1_preferences_retrieve_2" + ), + ("/api/user/v1/preferences/{username}/{preference_key}", "put"): "api_user_v1_preferences_update", + ("/api/user/v1/preferences/{username}/{preference_key}", "delete"): ( + "api_user_v1_preferences_destroy" + ), + } + + +@skip_unless_lms +def test_the_preference_document_is_published_as_the_open_ended_object_it_is(schema): + """It has no fixed set of names, so it is described rather than named as a component.""" + members = { + ("get", "application/json"): {"type": "string"}, + # A change may also remove a name, which it does by sending a null. + ("patch", "application/merge-patch+json"): {"type": "string", "nullable": True}, + } + for (method, media_type), member in members.items(): + operation = schema["paths"][PREFERENCE_DOCUMENT_PATH][method] + carried = ( + operation["responses"]["200"] if method == "get" else operation["requestBody"] + )["content"] + assert list(carried) == [media_type], method + body = carried[media_type]["schema"] + assert body["type"] == "object", method + assert body["additionalProperties"] == member, method + assert "$ref" not in body, method + + +@skip_unless_lms +def test_the_preference_document_is_published_as_the_fields_that_carry_it_describe(schema): + """The body is formatted and read by these fields, so each address publishes what its own does.""" + carried = { + ("get", "application/json"): AccountPreferenceDocumentField, + ("patch", "application/merge-patch+json"): AccountPreferenceChangesField, + } + for (method, media_type), field in carried.items(): + operation = schema["paths"][PREFERENCE_DOCUMENT_PATH][method] + published = ( + operation["responses"]["200"] if method == "get" else operation["requestBody"] + )["content"][media_type]["schema"] + assert published == get_override(field, "field"), method + + +@pytest.mark.django_db +@skip_unless_lms +def test_a_merge_patch_the_endpoint_carries_out_is_one_the_request_body_takes(client, schema): + """A null removes a name, so a body generated from the document can express a removal.""" + owner = AdminFactory.create(username="patch-preferences", password=PASSWORD) + UserPreferenceFactory.create(user=owner, key="time_zone", value="Asia/Tokyo") + assert client.login(username=owner.username, password=PASSWORD) + validator = jsonschema.Draft202012Validator(published_request_schema( + schema, PREFERENCE_DOCUMENT_PATH, "patch", "application/merge-patch+json", + )) + assert [error.message for error in validator.iter_errors({"time_zone": None})] == [] + assert [error.message for error in validator.iter_errors({"time_zone": "Europe/Paris"})] == [] + assert [error.message for error in validator.iter_errors({"time_zone": 5})] == [ + "5 is not valid under any of the given schemas", + ] + address = reverse("user_v2:account_preference_list", kwargs={"username": owner.username}) + removal = client.patch( + address, data=json.dumps({"time_zone": None}), content_type="application/merge-patch+json", + ) + assert removal.status_code == 204, removal.content + assert client.get(address).json() == {} + + +@pytest.mark.django_db +@skip_unless_lms +def test_a_preference_document_the_endpoint_answers_is_what_the_document_describes(client, schema): + owner = AdminFactory.create(username="shape-preferences", password=PASSWORD) + for key, value in (("pref-lang", "en"), ("time_zone", "Asia/Tokyo"), ("visibility.bio", "all_users")): + UserPreferenceFactory.create(user=owner, key=key, value=value) + assert client.login(username=owner.username, password=PASSWORD) + body = client.get( + reverse("user_v2:account_preference_list", kwargs={"username": owner.username}), + ).json() + assert body == {"pref-lang": "en", "time_zone": "Asia/Tokyo", "visibility.bio": "all_users"} + validator = jsonschema.Draft202012Validator( + published_body_schema(schema, PREFERENCE_DOCUMENT_PATH), + ) + assert [error.message for error in validator.iter_errors(body)] == [] + + +@pytest.mark.django_db +@skip_unless_lms +def test_the_email_choice_publishes_the_refusal_a_session_is_answered_with_without_its_token(schema): + """ + The refusal a browser session meets here is published alongside the rest. + + This address is reached from a page, and a session is held to the request + token the site hands out with it, so the refusal of a request that carries + none is the one a browser client meets most. It is driven here and matched + against what the operation publishes, because the choice belongs to the + caller and so nothing else about who they are is refused at this address. + """ + user = UserFactory.create(username="optin_token", password=PASSWORD) + held_to_its_token = APIClient(enforce_csrf_checks=True) + assert held_to_its_token.login(username=user.username, password=PASSWORD) + answer = held_to_its_token.post( + EMAIL_OPT_IN_PATH, + {"course_key": "course-v1:edX+DemoX+Demo", "email_opt_in": True}, + format="json", + ) + assert answer.status_code == 403, answer.content + published = schema["paths"][EMAIL_OPT_IN_PATH]["post"]["responses"] + assert str(answer.status_code) in published + assert published["403"]["description"] == ( + "The request was made with a session cookie and carried none of the request token the " + "site hands out with it, so the site cannot tell that the request came from a page it " + "served. Sending that token with the session, or a signed token in place of the " + "session, is answered." + ) + + +@skip_unless_lms +def test_the_name_a_preference_is_stored_under_is_published_under_one_parameter_name(schema): + """ + Both addresses that carry a preference name carry it under the same name. + + A client generated from the document takes an argument from each path + parameter, so one notion published under two names in one version reaches + a reader as two notions, and a published name cannot be changed afterwards. + """ + carried = { + parameter["name"] + for path, _method, operation in operations(schema) + if path in {PREFERENCE_MEMBER_PATH, PREFERENCE_NAME_PATH} + for parameter in operation["parameters"] + if parameter["in"] == "path" + } + assert carried == {"username", "key"} + + +#: What each preference operation answers, by the address and the method. A +#: change admits only the caller the address names, so the account it names is +#: the caller's own and is there to be changed: no such request is answered +#: with the refusal naming an account that does not exist, and none publishes +#: it. +PREFERENCE_PUBLISHED_ANSWERS = { + (PREFERENCE_DOCUMENT_PATH, "get"): ["200", "400", "401", "403", "404", "500"], + (PREFERENCE_DOCUMENT_PATH, "patch"): ["204", "400", "401", "403", "415", "500"], + (PREFERENCE_NAME_PATH, "get"): ["200", "400", "401", "403", "404", "500"], + (PREFERENCE_NAME_PATH, "put"): ["204", "400", "401", "403", "415", "500"], + (PREFERENCE_NAME_PATH, "delete"): ["204", "400", "401", "403", "404", "500"], +} + + +@skip_unless_lms +def test_each_preference_operation_publishes_the_answers_it_can_give(schema): + published = { + (path, method): sorted(operation["responses"]) + for path, method, operation in operations(schema) + if path in {PREFERENCE_DOCUMENT_PATH, PREFERENCE_NAME_PATH} + } + assert published == PREFERENCE_PUBLISHED_ANSWERS + + +@skip_unless_lms +def test_no_preference_operation_publishes_a_parameter_beyond_the_address_it_is_read_from(schema): + """These addresses read no query parameter: not a page, not a preset, not a selection.""" + published = { + (path, method): [parameter["name"] for parameter in operation.get("parameters", [])] + for path, method, operation in operations(schema) + if path in {PREFERENCE_DOCUMENT_PATH, PREFERENCE_NAME_PATH, EMAIL_OPT_IN_PATH} + } + assert published == { + (PREFERENCE_DOCUMENT_PATH, "get"): ["username"], + (PREFERENCE_DOCUMENT_PATH, "patch"): ["username"], + (PREFERENCE_NAME_PATH, "get"): ["key", "username"], + (PREFERENCE_NAME_PATH, "put"): ["key", "username"], + (PREFERENCE_NAME_PATH, "delete"): ["key", "username"], + (EMAIL_OPT_IN_PATH, "post"): [], + } + + +@skip_unless_lms +def test_the_email_choice_publishes_the_body_it_reads_and_that_it_answers_with_nothing(schema): + operation = schema["paths"][EMAIL_OPT_IN_PATH]["post"] + # The page a learner makes this choice on submits a form, so the media + # types a form submits in are published alongside the JSON body. + assert set(operation["requestBody"]["content"]) == { + "application/json", "application/x-www-form-urlencoded", "multipart/form-data", + } + for media_type, carried in operation["requestBody"]["content"].items(): + assert carried["schema"] == {"$ref": "#/components/schemas/EmailOptIn"}, media_type + assert "content" not in operation["responses"]["204"] + assert sorted(operation["responses"]) == [ + "204", "400", "401", "403", "404", "415", "500", + ] + choice = schema["components"]["schemas"]["EmailOptIn"] + assert sorted(choice["required"]) == ["course_key", "email_opt_in"] + assert choice["properties"]["course_key"]["type"] == "string" + assert choice["properties"]["email_opt_in"]["type"] == "boolean" + # What a boolean is read from here is narrower than the media types allow, + # so the published description says so. + assert choice["properties"]["email_opt_in"]["description"] == ( + "Whether this user wants messages from that organization, sent as true or false. A " + "value that says neither is refused rather than read as one of them." + ) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_urls.py b/openedx/core/djangoapps/user_api/v2/tests/test_urls.py index 7f14cd526cb2..4ebae623ddd7 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_urls.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_urls.py @@ -4,7 +4,7 @@ import pytest from django.core.exceptions import ImproperlyConfigured -from django.urls import get_resolver, resolve, reverse +from django.urls import NoReverseMatch, 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 @@ -18,6 +18,7 @@ from openedx.core.djangoapps.user_api.v2 import urls as v2_urls from openedx.core.djangoapps.user_api.v2.views import ( AccountDeactivationView, + AccountPreferenceViewSet, AccountViewSet, IDVerificationStatusView, IDVerificationViewSet, @@ -33,6 +34,7 @@ PROFILE_IMAGE_VIEW_MODULE = "openedx.core.djangoapps.profile_images.v2.views" V1_VIEW_MODULE = "openedx.core.djangoapps.user_api.views" V1_ACCOUNT_VIEW_MODULE = "openedx.core.djangoapps.user_api.accounts.views" +V1_PREFERENCE_VIEW_MODULE = "openedx.core.djangoapps.user_api.preferences.views" V2_NAMESPACE = "user_v2" #: A route name is lower case, starts with a letter and separates words with @@ -48,6 +50,15 @@ {"username": "alice"}, "/api/user/v2/accounts/alice/deactivation/", ), + "user_v2:account_preference_list": ( + {"username": "alice"}, + "/api/user/v2/accounts/alice/preferences/", + ), + "user_v2:account_preference_detail": ( + {"username": "alice", "key": "time_zone"}, + "/api/user/v2/accounts/alice/preferences/time_zone/", + ), + "user_v2:email_opt_in_list": ({}, "/api/user/v2/email_opt_ins/"), "user_v2:account_profile_image": ( {"username": "alice"}, "/api/user/v2/accounts/alice/profile_image/", @@ -148,6 +159,9 @@ def test_resolved_names_are_the_names_that_reverse(self): ("/api/user/v2/accounts/alice/", "account_detail"), ("/api/user/v2/email_searches/", "email_search_list"), ("/api/user/v2/accounts/alice/deactivation/", "account_deactivation"), + ("/api/user/v2/accounts/alice/preferences/", "account_preference_list"), + ("/api/user/v2/accounts/alice/preferences/time_zone/", "account_preference_detail"), + ("/api/user/v2/email_opt_ins/", "email_opt_in_list"), ("/api/user/v2/accounts/alice/profile_image/", "account_profile_image"), ("/api/user/v2/accounts/alice/verification_status/", "account_verification_status"), ("/api/user/v2/accounts/alice/verifications/", "account_verification_list"), @@ -199,7 +213,16 @@ def test_addresses_resolve_to_the_version_two_viewsets(self): "/api/user/v2/accounts/alice/verifications/": ( "IDVerificationViewSet", {"get": "list"}, ), + "/api/user/v2/accounts/alice/preferences/": ( + "AccountPreferenceViewSet", + {"get": "retrieve_all", "patch": "partial_update_all"}, + ), + "/api/user/v2/accounts/alice/preferences/time_zone/": ( + "AccountPreferenceViewSet", + {"get": "retrieve", "put": "update", "delete": "destroy"}, + ), "/api/user/v2/email_searches/": ("EmailSearchViewSet", {"post": "create"}), + "/api/user/v2/email_opt_ins/": ("EmailOptInViewSet", {"post": "create"}), "/api/user/v2/name_changes/": ("NameChangeViewSet", {"post": "create"}), "/api/user/v2/name_changes/alice/confirmations/": ( "NameChangeViewSet", {"post": "confirmations"}, @@ -308,6 +331,74 @@ 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") + def test_a_name_carrying_a_slash_is_not_addressed_as_a_preference(self): + """A name is read from one segment, which the slash ends, so no member route serves it.""" + for key in ("a/b", "x/", "/x", "a//b"): + match = resolve(f"/api/user/v2/accounts/ada.lovelace/preferences/{key}/") + assert view_identity(match.func) == (V2_VIEW_MODULE, "UnknownAddressView"), key + with pytest.raises(NoReverseMatch): + reverse( + "user_v2:account_preference_detail", + kwargs={"username": "ada.lovelace", "key": key}, + ) + + def test_every_name_the_segment_can_carry_can_be_addressed(self): + """A preference name is free-form text, so the key half takes the whole segment.""" + for key in ("pref-lang", "visibility.bio", "visibility.", "account_privacy", "a,b"): + address = f"/api/user/v2/accounts/ada.lovelace/preferences/{key}/" + match = resolve(address) + assert view_identity(match.func) == (V2_VIEW_MODULE, "AccountPreferenceViewSet"), key + assert match.kwargs == {"username": "ada.lovelace", "key": key}, key + assert reverse( + "user_v2:account_preference_detail", + kwargs={"username": "ada.lovelace", "key": key}, + ) == address, key + + def test_a_name_that_is_a_collection_of_this_version_is_read_as_a_preference_name(self): + """The collections stand at the top of this version, so no preference name shadows one.""" + for key in ("email_opt_in", "email_opt_ins", "time_zones"): + match = resolve(f"/api/user/v2/accounts/alice/preferences/{key}/") + assert view_identity(match.func) == (V2_VIEW_MODULE, "AccountPreferenceViewSet"), key + assert match.kwargs == {"username": "alice", "key": key}, key + for address, name in ( + ("/api/user/v2/email_opt_ins/", "email_opt_in_list"), + ("/api/user/v2/time_zones/", "time_zone_list"), + ): + assert resolve(address).url_name == name, address + + def test_the_preference_addresses_are_served_only_with_their_slash(self): + """Without it they are matched by the fallback, which answers rather than redirects.""" + for address in ( + "/api/user/v2/accounts/alice/preferences", + "/api/user/v2/accounts/alice/preferences/time_zone", + ): + assert view_identity(resolve(address).func) == ( + V2_VIEW_MODULE, "UnknownAddressView", + ), address + + def test_the_name_in_a_preference_address_is_read_as_a_stored_one(self): + """``me`` is a username here, and is routed as one rather than resolved to the caller.""" + for address, kwargs in ( + ("/api/user/v2/accounts/me/preferences/", {"username": "me"}), + ( + "/api/user/v2/accounts/me/preferences/time_zone/", + {"username": "me", "key": "time_zone"}, + ), + ): + match = resolve(address) + assert view_identity(match.func) == (V2_VIEW_MODULE, "AccountPreferenceViewSet"), address + assert match.kwargs == kwargs, address + + def test_the_preference_viewset_serves_both_of_its_addresses(self): + """One class holds every action of this resource, over the two addresses it has.""" + collection = resolve("/api/user/v2/accounts/alice/preferences/") + member = resolve("/api/user/v2/accounts/alice/preferences/time_zone/") + assert collection.func.cls is AccountPreferenceViewSet + assert member.func.cls is AccountPreferenceViewSet + assert set(routed_actions(collection.func)) | set(routed_actions(member.func)) == { + "get", "patch", "put", "delete", + } + @skip_unless_lms class RouteShapeTests(APITestCase): @@ -327,9 +418,12 @@ def test_every_address_is_named_and_ends_in_a_slash(self): "account_deactivation", "account_detail", "account_list", + "account_preference_detail", + "account_preference_list", "account_profile_image", "account_verification_list", "account_verification_status", + "email_opt_in_list", "email_search_list", "name_change_confirmation", "name_change_list", @@ -343,6 +437,17 @@ def test_every_address_is_named_and_ends_in_a_slash(self): assert SNAKE_CASE_NAME.match(pattern.name), pattern.name assert str(pattern.pattern).endswith("/$"), pattern.name + def test_every_pattern_is_anchored_where_the_mount_leaves_off(self): + """ + An address is matched against what the mount leaves of the path, by searching it. + + A pattern that does not say where it starts therefore answers any + address that merely ends in it, so every one of them says so, + including the fallback. + """ + for pattern in v2_urls.urlpatterns: + assert str(pattern.pattern).startswith("^"), (pattern.name, str(pattern.pattern)) + def test_the_router_route_that_is_replaced_has_to_exist(self): """Dropping a route the router does not generate is refused instead of passing silently.""" routes = v2_urls.router.urls @@ -353,6 +458,7 @@ def test_the_router_route_that_is_replaced_has_to_exist(self): "account_detail", "account_verification_list", "email_search_list", + "email_opt_in_list", "name_change_list", "name_change_confirmation", "user_list", @@ -465,6 +571,38 @@ def test_version_one_url_names_still_reverse(self): ) assert reverse("preferences_api", kwargs={"username": "alice"}) == "/api/user/v1/preferences/alice" + def test_version_one_preference_addresses_still_reach_the_version_one_views(self): + expected = { + "/api/user/v1/preferences/alice": (V1_PREFERENCE_VIEW_MODULE, "PreferencesView"), + "/api/user/v1/preferences/alice/time_zone": ( + V1_PREFERENCE_VIEW_MODULE, "PreferencesDetailView", + ), + "/api/user/v1/preferences/email_opt_in/": ( + V1_VIEW_MODULE, "UpdateEmailOptInPreference", + ), + } + for address, identity in expected.items(): + assert view_identity(resolve(address).func) == identity, address + + def test_version_one_preference_url_names_still_reverse(self): + assert reverse( + "preferences_detail_api", kwargs={"username": "alice", "preference_key": "time_zone"}, + ) == "/api/user/v1/preferences/alice/time_zone" + assert reverse("preferences_email_opt_in") == "/api/user/v1/preferences/email_opt_in/" + assert reverse("preferences_email_opt_in_legacy") == ( + "/user_api/v1/preferences/email_opt_in/" + ) + + def test_the_unversioned_opt_in_address_reaches_the_version_one_view_as_before(self): + """Both of its addresses are served by one view, and this version adds no third.""" + versioned = resolve("/api/user/v1/preferences/email_opt_in/") + unversioned = resolve("/user_api/v1/preferences/email_opt_in/") + assert view_identity(unversioned.func) == view_identity(versioned.func) + assert view_identity(unversioned.func) == (V1_VIEW_MODULE, "UpdateEmailOptInPreference") + assert served_methods(unversioned.func) == served_methods(versioned.func) == { + "POST", "OPTIONS", + } + def test_unversioned_twins_still_resolve(self): match = resolve("/user_api/v1/users/") assert view_identity(match.func) == (V1_VIEW_MODULE, "UserViewSet") @@ -511,11 +649,43 @@ def test_an_anonymous_caller_is_refused_the_same_way(self): self.client.logout() self.assert_refused_in_json(self.client.get("/api/user/v2/not_a_collection/")) + def test_an_address_merely_ending_in_one_this_version_serves_is_not_found(self): + """Nothing is served from an address that carries anything in front of a known one.""" + for address in ( + "/api/user/v2/anything/accounts/url-admin/preferences/", + "/api/user/v2/anything/accounts/url-admin/preferences/time_zone/", + "/api/user/v2/anything/accounts/", + "/api/user/v2/anything/user_preferences/url-admin,time_zone/", + ): + with self.subTest(address=address): + assert view_identity(resolve(address).func) == ( + V2_VIEW_MODULE, "UnknownAddressView", + ), address + self.assert_refused_in_json( + self.client.get(address, HTTP_ACCEPT="application/json"), + ) + def test_a_write_to_an_unknown_address_is_not_found_rather_than_not_allowed(self): self.assert_refused_in_json( self.client.post("/api/user/v2/not_a_collection/", {}, format="json"), ) + def test_no_other_collection_of_this_version_learns_a_method_from_this_one(self): + """The document beneath an account is merge-patched; the collections above it are not.""" + for address in ("/api/user/v2/accounts/", "/api/user/v2/users/"): + with self.subTest(address=address): + response = self.client.patch( + address, "{}", content_type="application/merge-patch+json", + ) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED, ( + address, response.content, + ) + assert_error_envelope( + response, + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug="method-not-allowed", + ) + def test_the_version_this_mount_shares_its_prefix_with_is_left_alone(self): """The authentication app's addresses under this prefix keep reaching it.""" for address in ( diff --git a/openedx/core/djangoapps/user_api/v2/urls.py b/openedx/core/djangoapps/user_api/v2/urls.py index c9a299596cbf..8fff7cf74c6e 100644 --- a/openedx/core/djangoapps/user_api/v2/urls.py +++ b/openedx/core/djangoapps/user_api/v2/urls.py @@ -10,8 +10,10 @@ from .views import ( PREFERENCE_KEY_PATTERN_PARTIAL, AccountDeactivationView, + AccountPreferenceViewSet, AccountViewSet, CountryTimeZoneViewSet, + EmailOptInViewSet, EmailSearchViewSet, IDVerificationStatusView, IDVerificationViewSet, @@ -80,6 +82,11 @@ class SnakeCaseRouter(SimpleRouter): # 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") +# What a user wants to be sent is recorded against that user and against the +# organization running a course they named, so the choice is reached without +# naming either in the address and stands beside the accounts rather than +# under them. +router.register(r"email_opt_ins", EmailOptInViewSet, basename="email_opt_in") router.register(r"name_changes", NameChangeViewSet, basename="name_change") router.register(r"users", UserViewSet, basename="user") router.register(r"user_preferences", UserPreferenceViewSet, basename="user_preference") @@ -111,6 +118,26 @@ class SnakeCaseRouter(SimpleRouter): IDVerificationStatusView.as_view(), name="account_verification_status", ), + # One account's preferences are one document, read and changed whole, and + # each of them is also reached on its own; a preference is stored under its + # owner and its name together, so neither address can be built without the + # account. The two are declared rather than generated because the document + # is changed with a method the generated collection route does not carry. + re_path( + rf"^accounts/(?P{settings.USERNAME_REGEX_PARTIAL})/preferences/$", + AccountPreferenceViewSet.as_view({"get": "retrieve_all", "patch": "partial_update_all"}), + name="account_preference_list", + ), + # The name a preference is stored under takes the rest of the segment, so + # every name that carries no separator of its own can be addressed. + re_path( + rf"^accounts/(?P{settings.USERNAME_REGEX_PARTIAL})/preferences/" + rf"(?P{PREFERENCE_KEY_PATTERN_PARTIAL})/$", + AccountPreferenceViewSet.as_view( + {"get": "retrieve", "put": "update", "delete": "destroy"}, + ), + name="account_preference_detail", + ), # The username half carries the pattern a username is allowed to match and # the key half takes the rest of the segment. re_path( diff --git a/openedx/core/djangoapps/user_api/v2/views.py b/openedx/core/djangoapps/user_api/v2/views.py index 7268cce40f36..210046b7e580 100644 --- a/openedx/core/djangoapps/user_api/v2/views.py +++ b/openedx/core/djangoapps/user_api/v2/views.py @@ -1,5 +1,6 @@ """Views for version 2 of the User API.""" +import contextlib import logging from django.conf import settings @@ -41,11 +42,22 @@ from openedx.core.djangoapps.user_api.errors import ( AccountUpdateError, AccountValidationError, + PreferenceUpdateError, + PreferenceValidationError, + UserAPIInternalError, UserNotAuthorized, UserNotFound, ) from openedx.core.djangoapps.user_api.models import UserPreference -from openedx.core.djangoapps.user_api.preferences.api import get_country_time_zones +from openedx.core.djangoapps.user_api.preferences.api import ( + delete_user_preference, + get_country_time_zones, + get_user_preference, + get_user_preferences, + set_user_preference, + update_email_opt_in, + update_user_preferences, +) from openedx.core.djangolib.default_auth_classes import DefaultJwtAuthentication from openedx.core.lib.api.parsers import MergePatchParser @@ -64,18 +76,26 @@ from .serializers import ( ACCOUNT_DEFAULT_SORT, ACCOUNT_MINIMAL_FIELDS, + ACCOUNT_PREFERENCE_CHANGES_SCHEMA, + ACCOUNT_PREFERENCE_DOCUMENT, + ACCOUNT_PREFERENCES_SCHEMA, EMAIL_SEARCH_MAX_ADDRESSES, FULL_VIEW, MINIMAL_VIEW, PREFERENCE_MINIMAL_FIELDS, + PREFERENCE_NAME_MAX_LENGTH, SHARED_VIEW, USER_MINIMAL_FIELDS, VERIFICATION_DEFAULT_SORT, AccountMinimalSerializer, + AccountPreferenceSerializer, + AccountPreferencesUpdateSerializer, + AccountPreferenceValueSerializer, AccountProjectionSerializer, AccountQuerySerializer, AccountSerializer, AccountUpdateSerializer, + EmailOptInSerializer, EmailSearchResultSerializer, EmailSearchSerializer, ErrorResponse, @@ -93,6 +113,7 @@ VerificationSerializer, VerificationStatusSerializer, full_view_requested, + why_a_name_cannot_be_stored, ) from .view_utils import ( RESP_UNAUTHENTICATED, @@ -1547,6 +1568,426 @@ def list(self, request, username): ) +_ACCOUNT_PREFERENCES_PATH_PARAM = _path_param( + "username", + "Unique name of the user whose preferences are addressed. The name is read as it is stored, " + f"so `{OWN_ACCOUNT_IDENTIFIER}` here names an account holding that name rather than the " + "account of the caller making the request.", + _USERNAME_PATTERN, +) +_ACCOUNT_PREFERENCE_KEY_PATH_PARAM = _path_param( + "key", + "Name the preference is stored under, which is the whole segment, punctuation included. A " + "name carrying a slash has no address of its own, because the segment ends at the slash; " + "such a name is read with the whole document and removed by sending a null for it there. " + f"Nothing is stored under a name longer than {PREFERENCE_NAME_MAX_LENGTH} characters, nor " + "under one carrying no letter, digit, hyphen or underscore.", + rf"^{PREFERENCE_KEY_PATTERN_PARTIAL}$", +) + +#: The actions of the preference resource an active administrator may call +#: against an account other than their own. +STAFF_READABLE_PREFERENCE_ACTIONS = ("retrieve_all", "retrieve") + +#: The action recorded for a request that asks what an address answers rather +#: than for what it holds. Such a request is answered to whoever may read the +#: address; the description of a write it carries is built behind a second +#: check, which carries the method of that write. +DESCRIBE_ADDRESS_ACTION = "metadata" + +#: Who may read the preferences of the account an address names, and who may +#: change them. Reading somebody else's is a privilege, so the administrator +#: half also refuses a caller whose own account is no longer active; changing +#: them belongs to their owner alone. +_PREFERENCE_READ_PERMISSIONS = ( + permissions.IsAuthenticated, + (IsStaff & IsActiveUser) | IsAddressedUser, +) +_PREFERENCE_WRITE_PERMISSIONS = (permissions.IsAuthenticated, IsAddressedUser) + +#: The media type a change to the whole document is sent in: a merge patch, in +#: which a null removes a name. Every other body of this resource is read in +#: the media types the platform reads everywhere. +_DOCUMENT_PARSERS = (MergePatchParser,) + +_RESP_PREFERENCE_DOCUMENT = OpenApiResponse( + ACCOUNT_PREFERENCES_SCHEMA, description="The preferences this account stores.", +) +_RESP_NO_SUCH_PREFERENCE = OpenApiResponse( + ErrorResponse, description="No account is held at the address given, or it stores no such preference.", +) +_RESP_PREFERENCE_NOT_STORED = OpenApiResponse( + ErrorResponse, + description=( + "The request body is not well formed in the media type it was sent in, or a preference " + "name is longer than one can be stored under, or a value is one a preference cannot " + "hold. A query parameter this endpoint does not read is refused here too." + ), +) +_RESP_PREFERENCE_SERVICE_FAILED = OpenApiResponse( + ErrorResponse, + description=( + "The request was correct and could not be carried out. Sending it again with something " + "else in it does not help; sending the same request later may." + ), +) + + +@extend_schema(tags=[SDK_TAG]) +class AccountPreferenceViewSet( + StandardizedErrorMixin, + CatalogedRequestErrorsMixin, + StrictQueryParametersMixin, + viewsets.ViewSet, +): + """ + The preferences of the one account an address names. + + The preferences are read and written through the preference service rather + than from a queryset of this app's, so this is a plain ViewSet. They are + one document: an object of preference name to stored value, read and + merge-patched whole, which is how the pages that keep a user's settings + read and save them. It is not a list of records and carries no pages; the + record-shaped view of the same data, across accounts, is the stored + preference collection of this version. + + Which account the answer is about is settled by the name in the address + alone: a user reads and changes their own preferences, and a staff caller + whose own account is active reads anyone's but changes nobody else's. The + name is read as it is stored, so ``me`` names an account holding that name + rather than the account of the caller. + + A name is read as the whole segment of the address, so the names carrying + punctuation that the platform's own features and the profile pages store + under can be addressed. A name carrying a slash cannot, because the + segment ends there; the document reads and removes such a name, which is + also the only way one arrives. + """ + + # The language a user picked is written from every page of the site, and + # the settings page these preferences are kept on is reached by users who + # have not confirmed their email address yet, whom the platform default + # refuses. Only 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 = _PREFERENCE_WRITE_PERMISSIONS + serializer_class = AccountPreferenceSerializer + + #: Action being served, which the parsers follow. It is declared here + #: because it is recorded while the request is being built, which is + #: before the view machinery would otherwise set it. + action = None + + def get_serializer(self, *args, **kwargs): + """Build the output serializer; a plain ViewSet has no generic machinery to do it.""" + return self.serializer_class(*args, **kwargs) + + def get_permissions(self): + """Return the classes deciding who may make the request being served.""" + reads = self.action in STAFF_READABLE_PREFERENCE_ACTIONS or self._describes_the_address() + permission_classes = _PREFERENCE_READ_PERMISSIONS if reads else self.permission_classes + return [permission() for permission in permission_classes] + + def _describes_the_address(self): + """ + Return whether the request asks what the address answers rather than for what it holds. + + Such a request is guarded as a read of the address, so a caller who may + read somebody else's preferences may also ask what the address does + with them. The description of a write that the answer carries is built + behind a second check made with the method of that write, which is + guarded as that write and so is left out for a caller who may not make + it. + """ + return self.action == DESCRIBE_ADDRESS_ACTION and self.request.method == "OPTIONS" + + def initialize_request(self, request, *args, **kwargs): + """ + Record which action is being served before anything reads the request. + + The media type a body is read in follows the action here, and the + parsers are chosen while the request is being built, which is before + the action would otherwise be recorded. + """ + self.action = self.action_map.get(request.method.lower()) + return super().initialize_request(request, *args, **kwargs) + + def get_parsers(self): + """Return the parsers that read the body the action being served takes.""" + if self.action == "partial_update_all": + return [parser() for parser in _DOCUMENT_PARSERS] + return super().get_parsers() + + @extend_schema( + summary="Get the preferences of one account", + description=( + "Returns every preference stored for this user, as an object of preference name to " + "stored value. A user may read their own preferences, and a staff caller may read " + "anyone's. An account that stores none is answered with an empty object. The names " + "come back sorted by name, character by character and by the code of each character, " + "so a name beginning with a digit or a capital letter comes before one beginning in " + "lower case." + ), + parameters=[_ACCOUNT_PREFERENCES_PATH_PARAM], + responses={ + 200: _RESP_PREFERENCE_DOCUMENT, + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NO_SUCH_ACCOUNT, + 500: _RESP_PREFERENCE_SERVICE_FAILED, + }, + ) + def retrieve_all(self, request, username): + """Return every preference stored for the account the address names.""" + with self._service_failures_named(username): + stored = get_user_preferences(request.user, username=username) + return Response(ACCOUNT_PREFERENCE_DOCUMENT.to_representation(stored)) + + @extend_schema( + summary="Change the preferences of one account", + description=( + "Changes the preferences the body names and leaves the rest as they are. The body is " + "a merge patch: a value is the text to store under that name, and a null removes the " + "name. A user may change only their own preferences. Nothing is stored unless every " + "name in the body can be, and a value that cannot be stored is reported under the " + "name that carried it." + ), + parameters=[_ACCOUNT_PREFERENCES_PATH_PARAM], + request={MergePatchParser.media_type: ACCOUNT_PREFERENCE_CHANGES_SCHEMA}, + responses={ + 204: OpenApiResponse(description="The preferences now stand as the body asked."), + 400: _RESP_PREFERENCE_NOT_STORED, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 415: RESP_UNREADABLE_BODY, + 500: _RESP_PREFERENCE_SERVICE_FAILED, + }, + ) + def partial_update_all(self, request, username): + """Store the preferences the body names and remove the ones it sends a null for.""" + changes = AccountPreferencesUpdateSerializer(data=request.data) + changes.is_valid(raise_exception=True) + with self._service_failures_named(username): + with transaction.atomic(): + update_user_preferences(request.user, changes.validated_data, user=username) + return Response(status=status.HTTP_204_NO_CONTENT) + + @extend_schema( + summary="Get one preference of one account", + description=( + "Returns one preference of this user, as the name it is stored under and the value " + "stored there. A user may read their own preferences, and a staff caller may read " + "anyone's. A name the account stores no value under is absent." + ), + parameters=[_ACCOUNT_PREFERENCES_PATH_PARAM, _ACCOUNT_PREFERENCE_KEY_PATH_PARAM], + responses={ + 200: AccountPreferenceSerializer, + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NO_SUCH_PREFERENCE, + 500: _RESP_PREFERENCE_SERVICE_FAILED, + }, + ) + def retrieve(self, request, username, key): + """Return one preference of the account the address names.""" + with self._service_failures_named(username): + value = get_user_preference(request.user, key, username=username) + if value is None: + raise NotFound + return Response(self.get_serializer({"key": key, "value": value}).data) + + @extend_schema( + summary="Store one preference of one account", + description=( + "Stores a value under one preference of this user, whether or not the name already " + "holds one. A user may change only their own preferences. A value a preference " + "cannot hold is refused under `value`, and a preference cannot hold an empty value. " + "A name nothing can be stored under, whether because it is longer than the store " + "holds or because it carries none of the characters a name is made of, is refused " + "under `key`, which is the part of the address that carried it." + ), + parameters=[_ACCOUNT_PREFERENCES_PATH_PARAM, _ACCOUNT_PREFERENCE_KEY_PATH_PARAM], + request=AccountPreferenceValueSerializer, + responses={ + 204: OpenApiResponse(description="The preference now holds this value."), + 400: _RESP_PREFERENCE_NOT_STORED, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 415: RESP_UNREADABLE_BODY, + 500: _RESP_PREFERENCE_SERVICE_FAILED, + }, + ) + def update(self, request, username, key): + """Store a value under one preference of the account the address names.""" + self._refuse_a_name_nothing_is_stored_under(key) + sent = AccountPreferenceValueSerializer(data=request.data) + sent.is_valid(raise_exception=True) + with self._service_failures_named(username, carried_by="value"): + set_user_preference( + request.user, key, sent.validated_data["value"], username=username, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + @extend_schema( + summary="Remove one preference of one account", + description=( + "Removes one preference of this user, so the name is no longer stored. A user may " + "remove only their own preferences. A name the account stores no value under is " + "absent." + ), + parameters=[_ACCOUNT_PREFERENCES_PATH_PARAM, _ACCOUNT_PREFERENCE_KEY_PATH_PARAM], + responses={ + 204: OpenApiResponse(description="The account stores no value under that name."), + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NO_SUCH_PREFERENCE, + 500: _RESP_PREFERENCE_SERVICE_FAILED, + }, + ) + def destroy(self, request, username, key): + """Remove one preference of the account the address names.""" + with self._service_failures_named(username): + removed = delete_user_preference(request.user, key, username=username) + if not removed: + raise NotFound + return Response(status=status.HTTP_204_NO_CONTENT) + + @staticmethod + def _refuse_a_name_nothing_is_stored_under(key): + """ + Refuse a name no value can be stored under, as the part of the address that carried it. + + The name arrives in the address rather than in the body, so a name the + store refuses is reported under the address member that carried it: a + caller cannot mend it by changing the value they sent. Which names are + held is the store's own to decide, so what it refuses is read from the + column that keeps them rather than from a list kept here. + """ + refused = why_a_name_cannot_be_stored(key) + if refused: + raise ValidationError({"key": refused}) + + @contextlib.contextmanager + def _service_failures_named(self, username, carried_by=None): + """ + Answer a failure of the preference service in the body this API answers with. + + A value the preference refuses is reported under the member of the + body that carried it, which is the preference name in a document and + the one property of a single change. Work that was asked for correctly + and failed anyway is not the caller's to correct, so what it says about + itself is logged rather than published. + """ + try: + yield + except UserNotAuthorized as exc: + raise PermissionDenied from exc + except UserNotFound as exc: + raise NotFound from exc + except PreferenceValidationError as exc: + raise self._refused_preferences(username, exc, carried_by) from exc + except (PreferenceUpdateError, UserAPIInternalError) as exc: + log.error("Preferences of %r could not be served: %s", username, exc) + raise APIException("The request could not be carried out.") from exc + + def _refused_preferences(self, username, error, carried_by=None): + """Return the refusal naming the member of the body that carried each value refused.""" + log.info("Preferences of %r not changed: %r", username, error.preference_errors) + refusals = {} + for key, reported in error.preference_errors.items(): + message = reported.get("user_message") if isinstance(reported, dict) else None + refusals[carried_by or key] = [message or "This value cannot be stored here."] + return ValidationError(refusals) + + +_RESP_SESSION_WITHOUT_ITS_TOKEN = OpenApiResponse( + ErrorResponse, + description=( + "The request was made with a session cookie and carried none of the request token the " + "site hands out with it, so the site cannot tell that the request came from a page it " + "served. Sending that token with the session, or a signed token in place of the " + "session, is answered." + ), +) + + +@extend_schema(tags=[SDK_TAG]) +@extend_schema_view( + create=extend_schema( + summary="Record whether to send a user an organization's messages", + description=( + "Records whether the caller wants messages from the organization running the course " + "named in the body. The choice is kept per organization, so it covers every course " + "that organization runs, and it is always the caller's own. A user whose account " + "needs a guardian's consent is recorded as not wanting them, whatever the body says." + ), + request=EmailOptInSerializer, + responses={ + 204: OpenApiResponse(description="The choice was recorded."), + 400: _RESP_UNUSABLE_BODY, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_SESSION_WITHOUT_ITS_TOKEN, + 404: OpenApiResponse( + ErrorResponse, + description="The account of the caller carries no profile to read a consent rule from.", + ), + 415: RESP_UNREADABLE_BODY, + 500: _RESP_PREFERENCE_SERVICE_FAILED, + }, + ), +) +class EmailOptInViewSet( + StandardizedErrorMixin, + CatalogedRequestErrorsMixin, + StrictQueryParametersMixin, + viewsets.ViewSet, +): + """ + The choices users have made about the messages organizations send them. + + A choice belongs to the user who made it, so it is recorded against the + caller and no username is read from the address, the body or the query + string. The organization it applies to is the one running the course the + body names, which is how the pages a user makes the choice on identify it. + The choice is held per organization rather than per course, and asking for + one course's organization twice records one choice. + """ + + # The choice is made while enrolling, which a user reaches before they have + # confirmed their email address, and the platform default refuses a session + # belonging to such an account. Only 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,) + serializer_class = EmailOptInSerializer + + def get_serializer(self, *args, **kwargs): + """Build the serializer; a plain ViewSet has no generic machinery to do it.""" + return self.serializer_class(*args, **kwargs) + + def create(self, request): + """Record whether the caller wants messages from the organization running this course.""" + choice = self.get_serializer(data=request.data) + choice.is_valid(raise_exception=True) + try: + update_email_opt_in( + request.user, + choice.validated_data["course_key"].org, + choice.validated_data["email_opt_in"], + ) + except UserNotFound as exc: + raise NotFound from exc + except UserAPIInternalError as exc: + log.error("Email choice of %r not recorded: %s", request.user.username, exc) + raise APIException("The choice could not be recorded.") from exc + return Response(status=status.HTTP_204_NO_CONTENT) + + @extend_schema(exclude=True) class UnknownAddressView(StandardizedErrorMixin, APIView): """ From 9854bb5b1c242278bd65a88d11da691e530a6958 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Mon, 26 Oct 2026 23:32:24 +0500 Subject: [PATCH 07/82] fix: name the library JWT class on the views that replace a default auth class Four views declare their own authentication_classes so that a caller who has not yet confirmed their email address can still reach their own account, its picture, its preferences and its email choice: the session class the platform configures refuses such a caller, so that half is replaced. For the token half they named DefaultJwtAuthentication. That class exists only to be named in DEFAULT_AUTHENTICATION_CLASSES -- its own module says it is only meant for that -- and the using_default_auth_classes attribute it records exists so an operator can find the endpoints that are on the defaults. A view that declares its own classes is not one of those, so recording the attribute filed a false signal and polluted the query the attribute serves. Nothing on master declares the class: two files name it, both definitional, against the library class used throughout the rest of the platform. The four views now name JwtAuthentication, the class the platform's own pair is built on. Two of these addresses, the account and its picture, were added earlier on this branch and are corrected in place here. The guards assert the attribute is absent, each alongside a control request to a sibling address that declares no classes and must still record it, so a pin cannot pass merely because a patch target was mistyped. --- .../profile_images/v2/tests/test_views.py | 23 ++++++++++---- .../djangoapps/profile_images/v2/views.py | 11 ++++--- .../user_api/v2/tests/test_account_views.py | 23 ++++++++++---- .../v2/tests/test_email_opt_in_views.py | 31 ++++++++++++++++++- .../v2/tests/test_preference_views.py | 31 +++++++++++++------ .../djangoapps/user_api/v2/tests/test_urls.py | 16 +++++++--- openedx/core/djangoapps/user_api/v2/views.py | 28 +++++++++-------- 7 files changed, 117 insertions(+), 46 deletions(-) diff --git a/openedx/core/djangoapps/profile_images/v2/tests/test_views.py b/openedx/core/djangoapps/profile_images/v2/tests/test_views.py index 3973feb31f16..b9e5dd5ce49e 100644 --- a/openedx/core/djangoapps/profile_images/v2/tests/test_views.py +++ b/openedx/core/djangoapps/profile_images/v2/tests/test_views.py @@ -565,13 +565,16 @@ def test_a_caller_who_has_not_confirmed_their_email_address_may_still_set_an_ima 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): + def test_a_token_is_read_here_without_recording_the_address_as_one_on_the_defaults(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. + This address names its own classes, so it is not one the default pair serves. + + The pair the platform configures records every request it reads a + token for, so that an operator can list the addresses it serves. This + one replaces the session half of it and is therefore not among them, + and nothing here is recorded as if it were. The address read afterwards + names no classes of its own and is recorded, which is what shows the + record is reachable from a request made this way at all. """ with mock.patch(DEFAULT_AUTH_RECORDER) as recorded: with make_image_file() as image_file: @@ -579,6 +582,14 @@ def test_a_token_is_read_by_the_class_the_platform_declares(self): self.url, {"file": image_file}, format="multipart", **self.token_headers(self.owner), ) assert response.status_code == status.HTTP_204_NO_CONTENT + assert self.stored_sizes(self.owner.username) == self.all_sizes(self.owner.username) + assert recorded.call_args_list == [] + + with mock.patch(DEFAULT_AUTH_RECORDER) as recorded: + on_the_default_pair = self.client.get( + reverse("user_v2:user_preference_list"), **self.token_headers(self.staff), + ) + assert on_the_default_pair.status_code == status.HTTP_200_OK assert [call.args for call in recorded.call_args_list] == [("using_default_auth_classes", True)] def test_a_body_in_a_media_type_this_endpoint_cannot_read_is_refused(self): diff --git a/openedx/core/djangoapps/profile_images/v2/views.py b/openedx/core/djangoapps/profile_images/v2/views.py index 2bda5f6923f8..29438971e9bb 100644 --- a/openedx/core/djangoapps/profile_images/v2/views.py +++ b/openedx/core/djangoapps/profile_images/v2/views.py @@ -8,6 +8,7 @@ import piexif from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from edx_rest_framework_extensions.mixins import StandardizedErrorMixin from PIL import Image @@ -28,7 +29,6 @@ SDK_TAG, StrictQueryParametersMixin, ) -from openedx.core.djangolib.default_auth_classes import DefaultJwtAuthentication from openedx.core.lib.api.parsers import TypedFileUploadParser from ..exceptions import ImageValidationError @@ -128,10 +128,11 @@ class ProfileImageView( # 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) + # belonging to such an account. Only the session class differs in what it + # admits: a token is read by the library class the platform's own pair is + # built on, named here because a view declaring its own classes is not one + # that pair serves. + authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser) permission_classes = (permissions.IsAuthenticated, IsAddressedUser) parser_classes = (MultiPartParser, FormParser, NamedImageUploadParser) serializer_class = ProfileImageUploadSerializer 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 index 0be8fa555a50..eaa3e82b36ed 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py @@ -856,17 +856,28 @@ def test_a_disabled_staff_account_still_reads_the_accounts_it_names(self): 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): + def test_a_token_is_read_here_without_recording_the_address_as_one_on_the_defaults(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. + This address names its own classes, so it is not one the default pair serves. + + The pair the platform configures records every request it reads a + token for, so that an operator can list the addresses it serves. This + one replaces the session half of it and is therefore not among them, + and nothing here is recorded as if it were. The address read afterwards + names no classes of its own and is recorded, which is what shows the + record is reachable from a request made this way at all. """ with mock.patch(DEFAULT_AUTH_RECORDER) as recorded: answer = self.client.get(self.detail_url("other-caller"), **self.token_headers(self.other)) assert answer.status_code == status.HTTP_200_OK + assert answer.json()["username"] == "other-caller" + assert recorded.call_args_list == [] + + with mock.patch(DEFAULT_AUTH_RECORDER) as recorded: + on_the_default_pair = self.client.get( + reverse("user_v2:user_preference_list"), **self.token_headers(self.staff), + ) + assert on_the_default_pair.status_code == status.HTTP_200_OK assert [call.args for call in recorded.call_args_list] == [("using_default_auth_classes", True)] def test_an_unconfirmed_account_still_may_not_search_for_others(self): diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_email_opt_in_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_email_opt_in_views.py index e184d615ba8b..d13db76b3827 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_email_opt_in_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_email_opt_in_views.py @@ -21,7 +21,7 @@ ) from openedx.core.djangolib.testing.utils import skip_unless_lms -from .test_preference_views import requests_served_outside_a_transaction +from .test_preference_views import DEFAULT_AUTH_RECORDER, requests_served_outside_a_transaction PASSWORD = "Password1234" @@ -443,6 +443,35 @@ def test_a_choice_presented_with_a_token_is_recorded_for_the_account_it_names(se assert self.choices(self.other) == {"edX": "True"} assert self.choices() == {} + def test_a_token_is_read_here_without_recording_the_address_as_one_on_the_defaults(self): + """ + This address names its own classes, so it is not one the default pair serves. + + The pair the platform configures records every request it reads a + token for, so that an operator can list the addresses it serves. This + one replaces the session half of it and is therefore not among them, + and nothing here is recorded as if it were. The address read afterwards + names no classes of its own and is recorded, which is what shows the + record is reachable from a request made this way at all. + """ + with mock.patch(DEFAULT_AUTH_RECORDER) as recorded: + answer = self.choose(**self.token_headers(self.other)) + assert answer.status_code == status.HTTP_204_NO_CONTENT + assert self.choices(self.other) == {"edX": "True"} + assert recorded.call_args_list == [] + + administrator = UserFactory.create( + username="optin-admin", email="optin-admin@example.com", password=PASSWORD, is_staff=True, + ) + with mock.patch(DEFAULT_AUTH_RECORDER) as recorded: + on_the_default_pair = self.client.get( + reverse("user_v2:user_preference_list"), **self.token_headers(administrator), + ) + assert on_the_default_pair.status_code == status.HTTP_200_OK + assert [call.args for call in recorded.call_args_list] == [ + ("using_default_auth_classes", True), + ] + def test_an_account_with_an_unconfirmed_address_records_a_choice(self): """A learner makes this choice while enrolling, before confirming their address.""" self.user.is_active = False diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_preference_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_preference_views.py index 95e2d0937c4e..f39b20c90f3f 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_preference_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_preference_views.py @@ -45,6 +45,9 @@ #: The table the preferences of an account are stored in. PREFERENCE_TABLE = "user_api_userpreference" +#: Where the classes the platform configures record that they read a request. +DEFAULT_AUTH_RECORDER = "openedx.core.djangolib.default_auth_classes.set_custom_attribute" + #: What the platform's own record of a request carries for a read of one of #: these addresses: the query string it was made with and the form body it #: carried, both empty. @@ -915,20 +918,28 @@ def test_an_anonymous_caller_asking_what_these_addresses_answer_is_refused(self) expected_type_slug="authn", ) - def test_a_token_is_read_by_the_class_the_platform_declares(self): + def test_a_token_is_read_here_without_recording_the_address_as_one_on_the_defaults(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, so these - addresses are recorded beside the ones that read both halves the way - the platform declares, which is the cost of reading a token the - platform's own way. + These addresses name their own classes, so they are not the ones the default pair serves. + + The pair the platform configures records every request it reads a + token for, so that an operator can list the addresses it serves. These + replace the session half of it and are therefore not among them, and + nothing here is recorded as if they were. The address read afterwards + names no classes of its own and is recorded, which is what shows the + record is reachable from a request made this way at all. """ - with mock.patch( - "openedx.core.djangolib.default_auth_classes.set_custom_attribute", - ) as recorded: + with mock.patch(DEFAULT_AUTH_RECORDER) as recorded: answer = self.client.get(self.document_url, **self.token_headers(self.owner)) assert answer.status_code == status.HTTP_200_OK + assert answer.json() == {"time_zone": "Asia/Tokyo"} + assert recorded.call_args_list == [] + + with mock.patch(DEFAULT_AUTH_RECORDER) as recorded: + on_the_default_pair = self.client.get( + reverse("user_v2:user_preference_list"), **self.token_headers(self.staff), + ) + assert on_the_default_pair.status_code == status.HTTP_200_OK assert [call.args for call in recorded.call_args_list] == [ ("using_default_auth_classes", True), ] diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_urls.py b/openedx/core/djangoapps/user_api/v2/tests/test_urls.py index 4ebae623ddd7..7e1881b1f47e 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_urls.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_urls.py @@ -5,6 +5,7 @@ import pytest from django.core.exceptions import ImproperlyConfigured from django.urls import NoReverseMatch, get_resolver, resolve, reverse +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from edx_rest_framework_extensions.testing import assert_error_envelope from rest_framework import status @@ -20,6 +21,7 @@ AccountDeactivationView, AccountPreferenceViewSet, AccountViewSet, + EmailOptInViewSet, IDVerificationStatusView, IDVerificationViewSet, NameChangeViewSet, @@ -255,18 +257,22 @@ def test_a_deactivation_authenticates_the_way_the_platform_does(self): 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. + These four are reached with the library's token class and a wider session one. The site's own settings pages are reached by a user who has not confirmed their email address, whom the session class the platform configures refuses, so that one class is replaced. The class reading a - token is left as the platform declares it, which is what records these - requests alongside every other request authenticated that way. + token is the one the platform's own pair is built on, named here rather + than inherited, because a view that declares its own classes is not one + that pair serves and is not recorded as one. """ platform_token = api_settings.DEFAULT_AUTHENTICATION_CLASSES[0] - for view_class in (AccountViewSet, ProfileImageView): + assert issubclass(platform_token, JwtAuthentication) + for view_class in ( + AccountViewSet, ProfileImageView, AccountPreferenceViewSet, EmailOptInViewSet, + ): assert view_class.authentication_classes == ( - platform_token, SessionAuthenticationAllowInactiveUser, + JwtAuthentication, SessionAuthenticationAllowInactiveUser, ), view_class.__name__ def test_what_is_recorded_about_an_account_authenticates_the_way_the_platform_does(self): diff --git a/openedx/core/djangoapps/user_api/v2/views.py b/openedx/core/djangoapps/user_api/v2/views.py index 210046b7e580..f796484e21dc 100644 --- a/openedx/core/djangoapps/user_api/v2/views.py +++ b/openedx/core/djangoapps/user_api/v2/views.py @@ -17,6 +17,7 @@ extend_schema, extend_schema_view, ) +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from edx_rest_framework_extensions.mixins import StandardizedErrorMixin from edx_rest_framework_extensions.paginators import IterablePaginationMixin @@ -58,7 +59,6 @@ update_email_opt_in, update_user_preferences, ) -from openedx.core.djangolib.default_auth_classes import DefaultJwtAuthentication from openedx.core.lib.api.parsers import MergePatchParser from .errors import CatalogedRequestErrorsMixin, ErrorExtensionsMixin, RetiredAccount @@ -911,10 +911,11 @@ class AccountViewSet( # 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) + # a session belonging to such an account. Only the session class differs in + # what it admits: a token is read by the library class the platform's own + # pair is built on, named here because a view declaring its own classes is + # not one that pair serves. + authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser) permission_classes = ( permissions.IsAuthenticated, CanGetAccountInfo, @@ -1667,10 +1668,10 @@ class AccountPreferenceViewSet( # The language a user picked is written from every page of the site, and # the settings page these preferences are kept on is reached by users who # have not confirmed their email address yet, whom the platform default - # refuses. Only 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) + # refuses. Only the session class differs in what it admits: a token is + # read by the library class the platform's own pair is built on, named here + # because a view declaring its own classes is not one that pair serves. + authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser) permission_classes = _PREFERENCE_WRITE_PERMISSIONS serializer_class = AccountPreferenceSerializer @@ -1959,10 +1960,11 @@ class EmailOptInViewSet( # The choice is made while enrolling, which a user reaches before they have # confirmed their email address, and the platform default refuses a session - # belonging to such an account. Only 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) + # belonging to such an account. Only the session class differs in what it + # admits: a token is read by the library class the platform's own pair is + # built on, named here because a view declaring its own classes is not one + # that pair serves. + authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser) permission_classes = (permissions.IsAuthenticated,) serializer_class = EmailOptInSerializer From 431c87ada841f99d8132d9664bf7e2e5aaf4437d Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Tue, 27 Oct 2026 01:29:33 +0500 Subject: [PATCH 08/82] fix: align four published details across the three /api/user/v2/ phases An audit across the three phases on this branch found four defects, each introduced by an earlier phase and each visible to a caller or to a generated client. They are corrected together here, as the JWT class was, rather than left to differ between sibling addresses of one version. The profile image published a username with no pattern, the only one of the eleven username path parameters in this version without it, so a generated client validated the name before the request on nine addresses of the resource and not on the tenth. The parameter helpers and the username pattern move from views.py into view_utils.py, which that module already imports from, and the parameter is built with the helper. The helper being private to one module was the cause, and a further module would have hit it again. The stored-preference resource pinned one operation id inline, which applies to every document, so the service-wide document published that operation beside a collection read carrying a different prefix, and a client generated from it derived the two from different modules. The pin moves into the per-document table beside the entries that already do this, so the resource's operations share their leading tokens in each document. One id changes, in the service-wide document only, to a name that document has never published. The username filter accepted a single exact name on two collections and a comma-separated list on a third, so a caller who learned the list form on the account collection was answered a 200 with an empty page rather than told the filter does not take a list. All three now accept the list, through one shared selector, matching the member address of that resource, which already reads a comma as a separator. The email search was the one collection in this version reading accounts directly without declaring a scoping seam, while the two collections over the same table declare one. It now declares the same policy through a queryset the shared mixin can wrap. No caller gains or loses access and no row changes: the policy is a pass-through today, and the seam is what was missing. --- lms/lib/spectacular.py | 19 +++ lms/lib/tests/test_spectacular.py | 20 +++- .../djangoapps/profile_images/v2/views.py | 18 ++- .../core/djangoapps/user_api/v2/filters.py | 24 +++- .../user_api/v2/tests/test_account_views.py | 34 ++++++ .../user_api/v2/tests/test_schema.py | 108 ++++++++++++++++- .../user_api/v2/tests/test_views.py | 90 ++++++++++++++ .../core/djangoapps/user_api/v2/view_utils.py | 45 ++++++- openedx/core/djangoapps/user_api/v2/views.py | 111 +++++++++--------- 9 files changed, 396 insertions(+), 73 deletions(-) diff --git a/lms/lib/spectacular.py b/lms/lib/spectacular.py index 7bff9115c970..2ae5bd4b88ef 100644 --- a/lms/lib/spectacular.py +++ b/lms/lib/spectacular.py @@ -63,6 +63,15 @@ # covers derives; that leaves the five operations of that resource carrying the # same leading tokens in every document, which is what a client generated from # one takes its module from. +# +# A stored preference is addressed by the pair that identifies it, and the +# comma between the two parameters is left behind as a token of its own, so +# every document derives an id for that read carrying a bare separator where +# the rest of the resource carries a word. That read is named here in each +# document through the id that document derives, which leaves the collection +# read and the member read of the resource under the same leading tokens +# wherever they are published; neither document has published either name, so +# nothing a client imports is taken away. RENAMED_OPERATION_IDS = { ("/api/user/v1/accounts", "GET", "api_user_v1_accounts_list"): "api_user_v1_accounts_retrieve", ("/api/user/v1/preferences/{username}/{preference_key}", "GET", "v1_preferences_retrieve"): ( @@ -78,6 +87,16 @@ "GET", "api_user_v2_accounts_preferences_retrieve", ): "api_user_v2_accounts_preferences_retrieve_one", + ( + "/api/user/v2/user_preferences/{username},{key}/", + "GET", + "v2_user_preferences_,_retrieve", + ): "v2_user_preferences_retrieve", + ( + "/api/user/v2/user_preferences/{username},{key}/", + "GET", + "api_user_v2_user_preferences_,_retrieve", + ): "api_user_v2_user_preferences_retrieve", } PUBLISHED_PATH_PATTERN = re.compile(r"^/api/enrollment/v\d+/") diff --git a/lms/lib/tests/test_spectacular.py b/lms/lib/tests/test_spectacular.py index 7ab394b7f514..7d42b6c70c33 100644 --- a/lms/lib/tests/test_spectacular.py +++ b/lms/lib/tests/test_spectacular.py @@ -257,6 +257,13 @@ def _user_routes(schema_class): view.as_view({"get": "retrieve"}), ), path("api/user/v2/users/", view.as_view({"get": "list"})), + # A stored preference is addressed by its owner and its name together, + # and the comma between them is a token of the address like any other, + # so the generator leaves it in the name it derives. + path( + "api/user/v2/user_preferences/,/", + view.as_view({"get": "retrieve"}), + ), path("api/enrollment/v1/enrollments/", view.as_view({"get": "list"})), # Outside ``/api/``, so a document that derives its prefix from the # routes it covers derives the one the service-wide document has. @@ -298,7 +305,13 @@ class UserOperationIdTest(SimpleTestCase): """A user address keeps the name the generator derives unless one is pinned for it.""" def test_the_service_wide_document_keeps_every_name_the_generator_derives(self): - """Each of these is the name that document already published, and a client imports it by name.""" + """ + Each of these is the name that document publishes, and a client imports it by name. + + An address that document has published for years keeps the name it + derived there, numeral and all; an address of a version it has not + published yet carries the name pinned for it. + """ assert _named_operations(LmsAutoSchema, get_api_docs_settings()) == { "/api/user/v1/accounts": ("api_user_v1_accounts_retrieve", ("api",)), "/api/user/v1/accounts/{username}": ("api_user_v1_accounts_retrieve_2", ("api",)), @@ -311,6 +324,8 @@ def test_the_service_wide_document_keeps_every_name_the_generator_derives(self): "/api/user/v2/accounts/{username}/preferences/{key}/": ("api_user_v2_accounts_preferences_retrieve_one", ("api",)), "/api/user/v2/users/": ("api_user_v2_users_list", ("api",)), + "/api/user/v2/user_preferences/{username},{key}/": + ("api_user_v2_user_preferences_retrieve", ("api",)), "/api/enrollment/v1/enrollments/": ("api_enrollment_v1_enrollments_list", ("api",)), "/user_api/v1/preferences/{username}": ("user_api_v1_preferences_retrieve", ("user_api",)), } @@ -341,6 +356,9 @@ def test_a_user_address_is_grouped_by_area_where_the_derived_tag_would_name_a_ve "v2_accounts_preferences_retrieve_one", ("user",), ), "/api/user/v2/users/": ("v2_users_list", ("user",)), + "/api/user/v2/user_preferences/{username},{key}/": ( + "v2_user_preferences_retrieve", ("user",), + ), "/api/enrollment/v1/enrollments/": ("v1_enrollments_list", ("v1",)), "/user_api/v1/preferences/{username}": ("user_api_v1_preferences_retrieve", ("user_api",)), } diff --git a/openedx/core/djangoapps/profile_images/v2/views.py b/openedx/core/djangoapps/profile_images/v2/views.py index 29438971e9bb..2efcc5a55d03 100644 --- a/openedx/core/djangoapps/profile_images/v2/views.py +++ b/openedx/core/djangoapps/profile_images/v2/views.py @@ -27,7 +27,9 @@ RESP_UNAUTHENTICATED, RESP_UNREADABLE_BODY, SDK_TAG, + USERNAME_PATTERN, StrictQueryParametersMixin, + path_param, ) from openedx.core.lib.api.parsers import TypedFileUploadParser @@ -37,16 +39,12 @@ 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, +_USERNAME_PATH_PARAM = path_param( + "username", + "Unique name of the user whose image is addressed. The name is read as it is stored, so " + "`me` here names an account holding that name rather than the account of the caller " + "making the request.", + USERNAME_PATTERN, ) _RESP_ANOTHER_USERS_IMAGE = OpenApiResponse( diff --git a/openedx/core/djangoapps/user_api/v2/filters.py b/openedx/core/djangoapps/user_api/v2/filters.py index 741aaee5fc12..60c35adf072d 100644 --- a/openedx/core/djangoapps/user_api/v2/filters.py +++ b/openedx/core/djangoapps/user_api/v2/filters.py @@ -13,6 +13,8 @@ from openedx.core.djangoapps.user_api.models import UserPreference +from .view_utils import names_in + log = logging.getLogger(__name__) @@ -96,7 +98,13 @@ class UserFilterSet(filters.FilterSet): course has defined matches no users and is not created. """ - username = filters.CharFilter(help_text="Return the user with exactly this username.") + username = filters.CharFilter( + method="filter_usernames", + help_text=( + "Comma-separated usernames. Only these users are returned, and a name no user holds " + "is left out of the page rather than refused." + ), + ) email = filters.CharFilter(help_text="Return the user with exactly this email address.") is_active = StrictBooleanFilter( help_text="Return only users whose account is active, or only inactive ones. true or false.", @@ -133,6 +141,10 @@ def filter_queryset(self, queryset): raise ValidationError({"forum_role": ["This parameter is required when course_key is supplied."]}) return super().filter_queryset(queryset) + def filter_usernames(self, queryset, name, value): + """Keep the users the value names, which it carries as a comma-separated list.""" + return queryset.filter(**{f"{name}__in": names_in(value)}) + def filter_preference_key(self, queryset, name, value): # pylint: disable=unused-argument """Keep users who have stored a preference under ``value``.""" return queryset.filter(preferences__key=value) @@ -170,9 +182,17 @@ class UserPreferenceFilterSet(filters.FilterSet): 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.", + method="filter_usernames", + help_text=( + "Comma-separated usernames. Only these users' preferences are returned, and a name " + "no user holds is left out of the page rather than refused." + ), ) class Meta: model = UserPreference fields = ("key", "username") + + def filter_usernames(self, queryset, name, value): + """Keep the preferences of the users the value names, read as a comma-separated list.""" + return queryset.filter(**{f"{name}__in": names_in(value)}) 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 index eaa3e82b36ed..e80a8351f17b 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py @@ -10,6 +10,7 @@ from django.db import DatabaseError, connection from django.test.utils import CaptureQueriesContext, override_settings from django.urls import reverse +from edx_rest_framework_extensions.scoping import FullScopePolicy from edx_rest_framework_extensions.testing import assert_error_envelope from rest_framework import status from rest_framework.test import APITestCase @@ -29,6 +30,7 @@ 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_api.v2.views import EmailSearchViewSet from openedx.core.djangoapps.user_authn.views.password_reset import PasswordResetFormNoActive from openedx.core.djangolib.testing.utils import skip_unless_lms @@ -1539,6 +1541,38 @@ def test_an_address_no_account_holds_is_left_out_of_the_page(self): assert [row["username"] for row in response.data["results"]] == ["owner-caller"] assert response.data["count"] == 1 + def test_the_page_carries_the_accounts_the_visibility_layer_leaves(self): + """ + The page is answered from a selection the view's policy has narrowed. + + The policy this view declares keeps every matched account, and the + only caller admitted may read them all, so what is held here is the + layer rather than a narrowing: a policy that keeps a row back is one + the page is answered under, not one the search reads past. The rows + come from the same table the collections of this version narrow, so + the same policy applies to the search as to them. + """ + class _WithoutTheOtherAccount: + """Keeps one account back, standing for a policy narrower than the declared one.""" + + def scope(self, queryset, subject): # pylint: disable=unused-argument + """Return the accounts of this selection other than the other caller's.""" + return queryset.exclude(username="other-caller") + + self.login(self.staff) + with mock.patch.object(EmailSearchViewSet, "scoping_policy", _WithoutTheOtherAccount()): + response = self.search(["owner-caller@example.com", "other-caller@example.com"]) + assert response.status_code == status.HTTP_200_OK + assert [row["username"] for row in response.data["results"]] == ["owner-caller"] + assert response.data["count"] == 1 + + def test_the_search_declares_which_of_the_accounts_it_finds_a_caller_may_see(self): + """Every account the addresses match is answered, because that is what the policy keeps.""" + assert isinstance(EmailSearchViewSet.scoping_policy, FullScopePolicy) + self.login(self.staff) + response = self.search(["owner-caller@example.com", "other-caller@example.com"]) + assert [row["username"] for row in response.data["results"]] == ["other-caller", "owner-caller"] + def test_an_empty_search_finds_nothing(self): self.login(self.staff) response = self.search([]) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py index f2f729b6a8ca..818db84fa7be 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py @@ -70,7 +70,9 @@ NAME_CHANGE_PATH = "/api/user/v2/name_changes/" NAME_CHANGE_CONFIRMATION_PATH = "/api/user/v2/name_changes/{username}/confirmations/" +USER_LIST_PATH = "/api/user/v2/users/" USER_MEMBER_PATH = "/api/user/v2/users/{username}/" +PREFERENCE_LIST_PATH = "/api/user/v2/user_preferences/" PREFERENCE_MEMBER_PATH = "/api/user/v2/user_preferences/{username},{key}/" TIME_ZONE_LIST_PATH = "/api/user/v2/time_zones/" @@ -79,8 +81,8 @@ LIST_PATHS = { ACCOUNT_LIST_PATH, VERIFICATION_LIST_PATH, - "/api/user/v2/users/", - "/api/user/v2/user_preferences/", + USER_LIST_PATH, + PREFERENCE_LIST_PATH, TIME_ZONE_LIST_PATH, } MEMBER_PATHS = { @@ -578,6 +580,37 @@ def test_preference_list_publishes_its_filters(schema): assert [name for name, parameter in parameters.items() if parameter.get("deprecated")] == [] +@skip_unless_lms +def test_every_collection_that_selects_accounts_by_name_publishes_the_same_kind_of_value(schema): + """ + A value learned on one of these collections selects the same accounts on the others. + + Each of the three reads a list of names, and a name no account holds is + left out of the page rather than refused, so the published description + opens with the same sentence and the value is published as the text that + carries the list. + """ + published = { + path: next( + parameter for parameter in operation["parameters"] if parameter["name"] == "username" + ) + for path, _method, operation in operations(schema) + if path in {ACCOUNT_LIST_PATH, USER_LIST_PATH, PREFERENCE_LIST_PATH} + } + assert {path: parameter["schema"] for path, parameter in published.items()} == { + ACCOUNT_LIST_PATH: {"type": "string"}, + USER_LIST_PATH: {"type": "string"}, + PREFERENCE_LIST_PATH: {"type": "string"}, + } + assert { + path: parameter["description"].split(".")[0] for path, parameter in published.items() + } == { + ACCOUNT_LIST_PATH: "Comma-separated usernames", + USER_LIST_PATH: "Comma-separated usernames", + PREFERENCE_LIST_PATH: "Comma-separated usernames", + } + + @skip_unless_lms def test_enum_post_processing_runs_before_the_deprecation_hook(): hooks = settings.SPECTACULAR_SETTINGS["POSTPROCESSING_HOOKS"] @@ -940,6 +973,46 @@ def test_member_addresses_describe_the_parts_they_are_built_from(schema): } +#: Every part of an address of this version that carries a username. A client +#: built from the document holds a value to what the route matches before it +#: sends a request, so an address left off this list is one whose caller learns +#: from a refusal what every sibling address refuses for it. +USERNAME_ADDRESS_PARTS = ( + ACCOUNT_MEMBER_PATH, + ACCOUNT_DEACTIVATION_PATH, + PROFILE_IMAGE_PATH, + VERIFICATION_STATUS_PATH, + VERIFICATION_LIST_PATH, + PREFERENCE_DOCUMENT_PATH, + PREFERENCE_NAME_PATH, + NAME_CHANGE_CONFIRMATION_PATH, + USER_MEMBER_PATH, + PREFERENCE_MEMBER_PATH, +) + + +@skip_unless_lms +def test_every_part_of_an_address_that_carries_a_username_publishes_what_the_route_matches(schema): + """ + One resource does not admit a name at one of its addresses and refuse it at the next. + + The addresses of this version are matched on the same spelling of a + username, whichever app serves them, so each of them is published with + that spelling and a client refuses the same names the routes do. + """ + published = { + (path, parameter["name"]): parameter["schema"] + for path, _method, operation in operations(schema) + if path in VERSION_TWO_PATHS + for parameter in operation.get("parameters", []) + if parameter["in"] == "path" and parameter["name"] == "username" + } + assert published == { + (path, "username"): {"type": "string", "pattern": rf"^{settings.USERNAME_REGEX_PARTIAL}$"} + for path in USERNAME_ADDRESS_PARTS + } + + @skip_unless_lms def test_the_country_parameter_publishes_the_constraint_it_is_held_to(schema): """The parameter comes from the serializer that validates it, so the two cannot disagree.""" @@ -1901,6 +1974,37 @@ def test_the_read_of_one_preference_is_named_in_every_document_that_publishes_it } +@skip_unless_lms +def test_the_stored_preference_reads_are_named_alike_in_every_document_that_publishes_them(schema): + """ + The collection read and the member read of this resource keep one prefix wherever published. + + A client takes the module it writes an operation into from the leading + tokens of that operation's name, so a document naming the two reads of one + resource differently splits the resource across two modules. The documents + derive different names for the member read, because one trims the area off + the front of every path and the other trims nothing, so it is named in + each of them through the name that document derives. + """ + service_wide = service_wide_schema() + assert { + (path, method): operation["operationId"] + for path, method, operation in operations(schema) + if path in {PREFERENCE_LIST_PATH, PREFERENCE_MEMBER_PATH} + } == { + (PREFERENCE_LIST_PATH, "get"): "v2_user_preferences_list", + (PREFERENCE_MEMBER_PATH, "get"): "v2_user_preferences_retrieve", + } + assert { + (path, method): operation["operationId"] + for path, method, operation in operations(service_wide) + if path in {PREFERENCE_LIST_PATH, PREFERENCE_MEMBER_PATH} + } == { + (PREFERENCE_LIST_PATH, "get"): "api_user_v2_user_preferences_list", + (PREFERENCE_MEMBER_PATH, "get"): "api_user_v2_user_preferences_retrieve", + } + + @skip_unless_lms def test_the_preference_document_is_published_as_the_open_ended_object_it_is(schema): """It has no fixed set of names, so it is described rather than named as a component.""" diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_views.py index 6f6b30e80f91..e30730c11d8a 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_views.py @@ -254,6 +254,43 @@ def test_one_unknown_field_refuses_the_whole_sort(self): response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", ) + def test_filter_by_username(self): + self.login(self.admin) + response = self.client.get(self.url, {"username": "bob", "page_size": 100}) + assert self.usernames_in(response) == ["bob"] + + def test_several_names_select_the_entries_of_each(self): + """ + The value is a list of names, as it is on the account collection. + + A caller that learned the list on one collection of this version and + sent it to another was answered with an empty page, which reads as a + page of the accounts asked for rather than as a value this collection + does not take. + """ + self.login(self.admin) + response = self.client.get(self.url, {"username": "alice,bob", "page_size": 100}) + assert response.status_code == status.HTTP_200_OK + assert self.usernames_in(response) == ["alice", "bob"] + assert response.data["count"] == 2 + + def test_a_name_in_the_list_that_no_account_holds_is_left_out_of_the_page(self): + self.login(self.admin) + response = self.client.get(self.url, {"username": "alice,nobody", "page_size": 100}) + assert self.usernames_in(response) == ["alice"] + assert response.data["count"] == 1 + + def test_a_position_in_the_list_that_carries_nothing_names_nothing(self): + self.login(self.admin) + response = self.client.get(self.url, {"username": ",alice,,bob,", "page_size": 100}) + assert self.usernames_in(response) == ["alice", "bob"] + + def test_an_empty_name_narrows_nothing(self): + self.login(self.admin) + response = self.client.get(self.url, {"username": "", "page_size": 100}) + assert response.status_code == status.HTTP_200_OK + assert self.usernames_in(response) == ["admin-caller", "alice", "bob", "learner-caller"] + def test_filter_by_email(self): self.login(self.admin) response = self.client.get(self.url, {"email": "bob@example.com"}) @@ -715,6 +752,25 @@ def test_filter_by_username(self): response = self.client.get(self.url, {"username": "bob", "page_size": 100}) assert self.keys_in(response) == [("bob", "time_zone")] + def test_several_names_select_the_preferences_of_each(self): + """The value is a list of names, as it is on the sibling collections of this version.""" + self.login(self.admin) + response = self.client.get(self.url, {"username": "alice,bob", "page_size": 100}) + assert response.status_code == status.HTTP_200_OK + assert self.keys_in(response) == [("alice", "pref-lang"), ("bob", "time_zone")] + assert response.data["count"] == 2 + + def test_a_name_in_the_list_that_no_account_holds_is_left_out_of_the_page(self): + self.login(self.admin) + response = self.client.get(self.url, {"username": "alice,nobody", "page_size": 100}) + assert self.keys_in(response) == [("alice", "pref-lang")] + + def test_an_empty_name_narrows_nothing(self): + self.login(self.admin) + response = self.client.get(self.url, {"username": "", "page_size": 100}) + assert response.status_code == status.HTTP_200_OK + assert self.keys_in(response) == [("alice", "pref-lang"), ("bob", "time_zone")] + def test_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) @@ -868,6 +924,40 @@ def test_head_writes_nothing(self): assert write_queries(captured.captured_queries) == [] +@skip_unless_lms +class NameSelectorTests(UserApiV2TestCase): + """``?username=`` on the collections of this version that select accounts by name.""" + + def test_every_collection_that_selects_accounts_by_name_reads_one_list(self): + """ + One value names the same accounts wherever it is sent. + + The member address of the stored preferences already reads a comma as + the separator between the two names that identify a preference, so a + collection of this version that read the comma as part of a username + would be answering a value its own addresses spell differently. + """ + self.login(self.admin) + selection = {"username": "alice,bob", "page_size": 100} + pages = { + name: self.client.get(reverse(f"user_v2:{name}"), selection) + for name in ("user_list", "user_preference_list", "account_list") + } + assert {name: page.status_code for name, page in pages.items()} == { + "user_list": status.HTTP_200_OK, + "user_preference_list": status.HTTP_200_OK, + "account_list": status.HTTP_200_OK, + } + assert { + name: sorted({row["username"] for row in page.data["results"]}) + for name, page in pages.items() + } == { + "user_list": ["alice", "bob"], + "user_preference_list": ["alice", "bob"], + "account_list": ["alice", "bob"], + } + + @skip_unless_lms class TimeZoneListViewTests(UserApiV2TestCase): """GET /api/user/v2/time_zones/""" diff --git a/openedx/core/djangoapps/user_api/v2/view_utils.py b/openedx/core/djangoapps/user_api/v2/view_utils.py index 4a7b9124ab0f..30bb6c833cf6 100644 --- a/openedx/core/djangoapps/user_api/v2/view_utils.py +++ b/openedx/core/djangoapps/user_api/v2/view_utils.py @@ -2,7 +2,8 @@ import logging -from drf_spectacular.utils import OpenApiResponse +from django.conf import settings +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse from rest_framework.exceptions import ValidationError from rest_framework.settings import api_settings @@ -21,6 +22,48 @@ #: renderer, and a caller that sends it anyway is answered rather than refused. FORMAT_PARAM = "format" +#: The whole of a username, as every route of this version matches one. A +#: client built from the published document holds a name to this shape before +#: it sends a request, so each address that carries a name is described with +#: it and no address of one resource admits a name another refuses. +USERNAME_PATTERN = rf"^{settings.USERNAME_REGEX_PARTIAL}$" + + +def query_param(name, description, **kwargs): + """Build an optional query parameter for the published schema.""" + return OpenApiParameter( + name=name, + description=description, + required=False, + location=OpenApiParameter.QUERY, + **kwargs, + ) + + +def path_param(name, description, pattern): + """Build a path parameter for the published schema, carrying what its route matches.""" + return OpenApiParameter( + name=name, + description=description, + required=True, + location=OpenApiParameter.PATH, + type=str, + pattern=pattern, + ) + + +def names_in(value): + """ + Return the names a comma-separated value carries, in the order they were sent. + + A position carrying nothing names nothing, so an empty one is left out + rather than read as a name no account holds. Every collection of this + version that selects rows by name reads its value through here, so the + same value selects the same rows wherever it is sent. + """ + return [name for name in value.strip(",").split(",") if name] + + RESP_UNAUTHENTICATED = OpenApiResponse(ErrorResponse, description="The requester is not authenticated.") RESP_UNREADABLE_BODY = OpenApiResponse( ErrorResponse, description="The request body is in a media type this endpoint cannot read.", diff --git a/openedx/core/djangoapps/user_api/v2/views.py b/openedx/core/djangoapps/user_api/v2/views.py index f796484e21dc..b423b9f42746 100644 --- a/openedx/core/djangoapps/user_api/v2/views.py +++ b/openedx/core/djangoapps/user_api/v2/views.py @@ -10,13 +10,7 @@ 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 drf_spectacular.utils import OpenApiExample, OpenApiResponse, extend_schema, extend_schema_view from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from edx_rest_framework_extensions.mixins import StandardizedErrorMixin @@ -120,7 +114,11 @@ RESP_UNREADABLE_BODY, SDK_TAG, USER_TAG, + USERNAME_PATTERN, StrictQueryParametersMixin, + names_in, + path_param, + query_param, ) log = logging.getLogger(__name__) @@ -142,43 +140,17 @@ #: 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( +_USER_PATH_PARAM = path_param( "username", "Unique name of the user whose directory entry is returned.", - _USERNAME_PATTERN, + USERNAME_PATTERN, ) -_PREFERENCE_USERNAME_PATH_PARAM = _path_param( +_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, + USERNAME_PATTERN, ) -_PREFERENCE_KEY_PATH_PARAM = _path_param( +_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}$", @@ -206,7 +178,7 @@ def _path_param(name, description, pattern): FIELDS_PARAM = "fields" RESPONSE_SHAPING_PARAMS = (VIEW_PARAM, FIELDS_PARAM) -_FIELDS_PARAM = _query_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.", @@ -216,13 +188,13 @@ def _path_param(name, description, pattern): USER_PRESETS = (MINIMAL_VIEW,) PREFERENCE_PRESETS = (FULL_VIEW, MINIMAL_VIEW) -_MINIMAL_VIEW_PARAM = _query_param( +_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( +_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 " @@ -233,7 +205,7 @@ def _path_param(name, description, pattern): def _ordering_param(sort_fields, default): """Build the sort parameter of a list, naming the fields it accepts.""" - return _query_param( + 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.", @@ -304,21 +276,21 @@ def _ordering_param(sort_fields, default): ], ) -_ACCOUNT_PATH_PARAM = _path_param( +_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, + USERNAME_PATTERN, ) -_DEACTIVATION_PATH_PARAM = _path_param( +_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, + USERNAME_PATTERN, ) -_ACCOUNT_VIEW_PARAM = _query_param( +_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 " @@ -663,7 +635,6 @@ def retrieve(self, request, *args, **kwargs): "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, @@ -1112,7 +1083,7 @@ def _selected_names(self, request, selection): """ usernames = selection.get("username") if usernames: - return [name for name in usernames.strip(",").split(",") if name] + return names_in(usernames) email = selection.get("email") if email: return self._usernames_holding_email(email) @@ -1234,6 +1205,24 @@ def post(self, request, username): return Response(status=status.HTTP_204_NO_CONTENT) +class _SearchedAccountsMixin: + """ + The accounts a search names, before it is narrowed to the ones a caller may see. + + A view's scoping policy is applied on top of the selection the classes + resolved after it answer with, so the unnarrowed selection is declared + here rather than on the view, where it would stand in front of the layer + that narrows it and leave the policy with nothing to do. + """ + + #: The addresses asked about, held for as long as one request is served. + searched_emails = () + + def get_queryset(self): + """Return the accounts holding the addresses this request asked about.""" + return accounts_holding_emails(self.searched_emails) + + @extend_schema(tags=[SDK_TAG]) @extend_schema_view( create=extend_schema( @@ -1263,6 +1252,8 @@ class EmailSearchViewSet( CatalogedRequestErrorsMixin, StrictQueryParametersMixin, IterablePaginationMixin, + ScopedQuerysetMixin, + _SearchedAccountsMixin, viewsets.ViewSet, ): """ @@ -1285,6 +1276,11 @@ class EmailSearchViewSet( CanGetAccountInfo, IsStaffOrAbsent, ) + # A platform administrator may read every account the addresses match, so + # the policy keeps the selection whole; a narrower policy can replace it + # without touching the search below, and the accounts come from the same + # table the other collections of this version narrow. + scoping_policy = FullScopePolicy() parser_classes = (JSONParser,) serializer_class = EmailSearchResultSerializer pagination_class = UserApiPagination @@ -1297,15 +1293,16 @@ 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"])) + self.searched_emails = search.validated_data["emails"] + return self.paginate_iterable(request, self.get_queryset()) -_NAME_CHANGE_PATH_PARAM = _path_param( +_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, + USERNAME_PATTERN, ) @@ -1424,12 +1421,12 @@ def _confirm_change(self, user, pending): raise NotFound from exc -_VERIFICATION_PATH_PARAM = _path_param( +_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, + USERNAME_PATTERN, ) @@ -1569,14 +1566,14 @@ def list(self, request, username): ) -_ACCOUNT_PREFERENCES_PATH_PARAM = _path_param( +_ACCOUNT_PREFERENCES_PATH_PARAM = path_param( "username", "Unique name of the user whose preferences are addressed. The name is read as it is stored, " f"so `{OWN_ACCOUNT_IDENTIFIER}` here names an account holding that name rather than the " "account of the caller making the request.", - _USERNAME_PATTERN, + USERNAME_PATTERN, ) -_ACCOUNT_PREFERENCE_KEY_PATH_PARAM = _path_param( +_ACCOUNT_PREFERENCE_KEY_PATH_PARAM = path_param( "key", "Name the preference is stored under, which is the whole segment, punctuation included. A " "name carrying a slash has no address of its own, because the segment ends at the slash; " From 38f013e1e95ff049c1e6cf06026489f155199b01 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Fri, 30 Oct 2026 00:50:00 +0500 Subject: [PATCH 09/82] fix: publish a body no request may leave out as one the operation needs Three operations describe the body they read in place rather than through a serializer. A shape given in place carries no field the generator can read requiredness from, so the document published no mark at all and a generated client made the body an argument the caller may omit, while each handler answers a request carrying no body with a 400. The addresses are named explicitly and each is driven in the tests with its form named and no body behind it, so the mark the document carries is held to what the handler answers. The write of one stored preference is left unmarked on the same evidence: it takes a request with no body. --- lms/lib/spectacular.py | 34 +++++++++++- lms/lib/tests/test_spectacular.py | 86 +++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 2 deletions(-) diff --git a/lms/lib/spectacular.py b/lms/lib/spectacular.py index 2ae5bd4b88ef..b470d53e2903 100644 --- a/lms/lib/spectacular.py +++ b/lms/lib/spectacular.py @@ -99,6 +99,23 @@ ): "api_user_v2_user_preferences_retrieve", } +# Operations whose handler answers a request carrying no body with a 400. The +# body of each is described in place rather than through a serializer, and a +# shape given in place carries no field the generator can read requiredness +# from, so it publishes no mark at all and a client generated from the document +# makes the body an argument the caller may leave out. Each address is spelled +# with its parameter names removed, as the table of bodies below is. +# +# An address belongs here only once its handler has been sent a request with +# the form named and no body behind it and has refused it. The write of one +# stored preference is deliberately absent: that handler takes a request with +# no body and answers that the preference now stands as the request asked. +REQUIRED_REQUEST_BODIES = frozenset({ + ("/api/user/v1/preferences/{}", "PATCH"), + ("/api/user/v1/preferences/email_opt_in/", "POST"), + ("/api/user/v2/accounts/{}/preferences/", "PATCH"), +}) + PUBLISHED_PATH_PATTERN = re.compile(r"^/api/enrollment/v\d+/") # The user addresses this schema documents are the ones the version below @@ -334,10 +351,19 @@ def _get_request_body(self, direction="request"): 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. + + An operation whose body is described in place carries no such field + either, in any of its forms, so the ones whose handler refuses a + request that carries no body are named above and marked from there. """ body = super()._get_request_body(direction) + if not body: + return body + if self._operation_shape() in REQUIRED_REQUEST_BODIES: + body["required"] = True + return body shapes = self.get_request_serializer() - if not body or body.get("required") or not isinstance(shapes, dict): + if 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 @@ -355,7 +381,11 @@ def get_response_serializers(self): 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)) + return superseded_bodies().get(self._operation_shape()) + + def _operation_shape(self): + """This operation as the tables above key one: its address without parameter names, and its method.""" + return (_PATH_PARAMETER.sub("{}", self.path), self.method) def lms_mark_legacy_paths_deprecated(result, generator, request, public): # pylint: disable=unused-argument diff --git a/lms/lib/tests/test_spectacular.py b/lms/lib/tests/test_spectacular.py index 7d42b6c70c33..334387a401e5 100644 --- a/lms/lib/tests/test_spectacular.py +++ b/lms/lib/tests/test_spectacular.py @@ -6,6 +6,7 @@ from unittest import mock from django.test import SimpleTestCase, TestCase +from django.test.client import FakePayload from django.urls import path from drf_spectacular.generators import SchemaGenerator from drf_spectacular.settings import patched_settings, spectacular_settings @@ -22,6 +23,7 @@ lms_mark_legacy_paths_deprecated, superseded_bodies, ) +from common.djangoapps.student.tests.factories import TEST_PASSWORD, UserFactory from openedx.core.apidocs import get_api_docs_settings ENROLLMENT_VIEWS = "openedx.core.djangoapps.enrollments.views" @@ -522,6 +524,90 @@ def test_the_twin_of_the_opt_in_is_left_as_that_document_always_published_it(sel assert twin.get("deprecated", False) is False +MERGE_PATCH = "application/merge-patch+json" + +#: Each address that describes its body in place, the form it reads it in, and +#: what the handler answers a request carrying no body at all. +BODIES_SENT_AS_NOTHING = ( + ("PATCH", "/api/user/v1/preferences/{}", "/api/user/v1/preferences/bodyowner", MERGE_PATCH, 400), + ("POST", "/api/user/v1/preferences/email_opt_in/", "/api/user/v1/preferences/email_opt_in/", + "application/x-www-form-urlencoded", 400), + ("PATCH", "/api/user/v2/accounts/{}/preferences/", "/api/user/v2/accounts/bodyowner/preferences/", + MERGE_PATCH, 400), + ("PUT", "/api/user/v1/preferences/{}/{}", "/api/user/v1/preferences/bodyowner/account_privacy", + "application/json", 204), +) + + +class RequiredRequestBodyTest(TestCase): + """ + A body no request may leave out is published as one the operation needs. + + Every address below describes its body in place rather than through a + serializer, which leaves the generator no field to read that from, so it + publishes no mark at all and a client makes the body optional. Each is + driven here with no body, in both documents, so the mark one carries is + held to what the handler answers. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.documents = {"published": SchemaGenerator().get_schema(request=None, public=True)} + with patched_settings(get_api_docs_settings()): + cls.documents["service-wide"] = SchemaGenerator().get_schema(request=None, public=True) + + def setUp(self): + super().setUp() + self.owner = UserFactory.create(username="bodyowner", password=TEST_PASSWORD) + assert self.client.login(username=self.owner.username, password=TEST_PASSWORD) + + @staticmethod + def _published_address(shape): + """The address as a document spells it, from the shape its parameters are removed from.""" + return { + "/api/user/v1/preferences/{}": "/api/user/v1/preferences/{username}", + "/api/user/v1/preferences/{}/{}": "/api/user/v1/preferences/{username}/{preference_key}", + "/api/user/v2/accounts/{}/preferences/": "/api/user/v2/accounts/{username}/preferences/", + }.get(shape, shape) + + def _send_nothing(self, method, address, media_type): + """Send the request with its form named and no body behind it.""" + return self.client.generic( + method, + address, + data=b"", + content_type=media_type, + CONTENT_TYPE=media_type, + CONTENT_LENGTH="0", + **{"wsgi.input": FakePayload(b"")}, + ) + + def test_a_handler_that_refuses_a_request_carrying_no_body_is_the_reason_for_the_mark(self): + """What each handler answers, which is what the mark below is held to.""" + answered = { + (method, address): self._send_nothing(method, address, media_type).status_code + for method, _shape, address, media_type, _expected in BODIES_SENT_AS_NOTHING + } + assert answered == { + (method, address): expected + for method, _shape, address, media_type, expected in BODIES_SENT_AS_NOTHING + } + + def test_every_document_marks_a_body_the_handler_refuses_to_work_without(self): + marks = {} + for name, document in self.documents.items(): + for method, shape, _address, _media_type, answer in BODIES_SENT_AS_NOTHING: + address = self._published_address(shape) + operation = document["paths"][address][method.lower()] + marks[(name, method, address)] = operation["requestBody"].get("required", "") + assert marks == { + (name, method, self._published_address(shape)): (True if answer == 400 else "") + for name in self.documents + for method, shape, _address, _media_type, answer in BODIES_SENT_AS_NOTHING + } + + class PublishedDocumentTest(TestCase): """What the service actually publishes: distinct ids, and one area per tag group.""" From 6c3501fb1eff01e2a066541f925df5b87486796a Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Fri, 30 Oct 2026 00:53:37 +0500 Subject: [PATCH 10/82] test: hold the four corrected success codes to the handlers that answer them Both documents already published these four addresses with a 200 and no body before their bodies were described. No handler answers that: each answers 204. The corrected code is now pinned in both documents and each handler is driven on its success path in the same test, so the published code and the answered one can only move together. Replaces the pin that covered two of the four in one document. --- lms/lib/tests/test_spectacular.py | 118 ++++++++++++++++++++++++++---- 1 file changed, 102 insertions(+), 16 deletions(-) diff --git a/lms/lib/tests/test_spectacular.py b/lms/lib/tests/test_spectacular.py index 334387a401e5..00095a8c9bac 100644 --- a/lms/lib/tests/test_spectacular.py +++ b/lms/lib/tests/test_spectacular.py @@ -5,7 +5,7 @@ from types import SimpleNamespace from unittest import mock -from django.test import SimpleTestCase, TestCase +from django.test import Client, SimpleTestCase, TestCase from django.test.client import FakePayload from django.urls import path from drf_spectacular.generators import SchemaGenerator @@ -15,6 +15,8 @@ from rest_framework.response import Response from rest_framework.settings import api_settings +from common.djangoapps.student.models_api import do_name_change_request +from common.djangoapps.student.tests.factories import TEST_PASSWORD, UserFactory from lms.lib.spectacular import ( RENAMED_OPERATION_IDS, SUPERSEDED_USER_PATHS, @@ -23,8 +25,12 @@ lms_mark_legacy_paths_deprecated, superseded_bodies, ) -from common.djangoapps.student.tests.factories import TEST_PASSWORD, UserFactory from openedx.core.apidocs import get_api_docs_settings +from openedx.core.djangoapps.profile_images.tests.helpers import make_image_file +from openedx.core.djangoapps.user_api.accounts.image_helpers import ( + get_profile_image_names, + get_profile_image_storage, +) ENROLLMENT_VIEWS = "openedx.core.djangoapps.enrollments.views" USER_API_V1_VIEWS = "openedx.core.djangoapps.user_api.views" @@ -501,20 +507,6 @@ def test_each_superseded_operation_publishes_the_same_exchange_there(self): ) assert published == SUPERSEDED_EXCHANGES - def test_the_two_changes_that_answer_with_nothing_publish_only_that(self): - """Both handlers answer with no body, which is not what that document carried for them.""" - answered = { - (address, method): sorted(self.schema["paths"][address][method]["responses"]) - for address, method in ( - ("/api/user/v1/preferences/{username}", "patch"), - ("/api/user/v1/preferences/{username}/{preference_key}", "put"), - ) - } - assert answered == { - ("/api/user/v1/preferences/{username}", "patch"): ["204"], - ("/api/user/v1/preferences/{username}/{preference_key}", "put"): ["204"], - } - def test_the_twin_of_the_opt_in_is_left_as_that_document_always_published_it(self): """It keeps resolving, is described by nothing here, and is not marked superseded.""" twin = self.schema["paths"]["/user_api/v1/preferences/email_opt_in/"]["post"] @@ -524,6 +516,100 @@ def test_the_twin_of_the_opt_in_is_left_as_that_document_always_published_it(sel assert twin.get("deprecated", False) is False +#: Each address this document already published before its body was described, +#: and the success code its handler really answers. That document carried a 200 +#: with no body for all four, which no handler answers; the code each is +#: published under now is driven below. +CORRECTED_SUCCESS_CODES = ( + ("post", "/api/user/v1/accounts/name_change/{username}/confirm/", "204"), + ("post", "/api/user/v1/accounts/{username}/image", "204"), + ("patch", "/api/user/v1/preferences/{username}", "204"), + ("put", "/api/user/v1/preferences/{username}/{preference_key}", "204"), +) + + +class CorrectedSuccessCodeTest(TestCase): + """ + Four addresses both documents already published answer a success code neither carried. + + These are not new addresses: a client generated before this change branches + on the code the document gave them, so each is pinned here in both + documents and each handler is driven, so that the published code can only + change again together with the handler that answers it. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.documents = {"published": SchemaGenerator().get_schema(request=None, public=True)} + with patched_settings(get_api_docs_settings()): + cls.documents["service-wide"] = SchemaGenerator().get_schema(request=None, public=True) + + def setUp(self): + super().setUp() + self.owner = UserFactory.create(username="codeowner", password=TEST_PASSWORD) + self.owner.profile.year_of_birth = 1980 + self.owner.profile.save() + self.staff = UserFactory.create(username="codestaff", password=TEST_PASSWORD, is_staff=True) + self.storage = get_profile_image_storage() + + def tearDown(self): + for name in get_profile_image_names(self.owner.username).values(): + self.storage.delete(name) + super().tearDown() + + def _as(self, user): + """A client signed in as that account.""" + client = Client() + assert client.login(username=user.username, password=TEST_PASSWORD) + return client + + def _answered(self): + """Drive each of the four on its success path and return the code it answers.""" + owner, staff = self._as(self.owner), self._as(self.staff) + do_name_change_request(self.owner, "Another Name", "a reason") + answered = { + ("post", "/api/user/v1/accounts/name_change/{username}/confirm/"): staff.post( + f"/api/user/v1/accounts/name_change/{self.owner.username}/confirm/", + ).status_code, + ("patch", "/api/user/v1/preferences/{username}"): owner.patch( + f"/api/user/v1/preferences/{self.owner.username}", + data='{"pref-lang": "en"}', + content_type=MERGE_PATCH, + ).status_code, + ("put", "/api/user/v1/preferences/{username}/{preference_key}"): owner.put( + f"/api/user/v1/preferences/{self.owner.username}/account_privacy", + data='"private"', + content_type="application/json", + ).status_code, + } + with make_image_file() as image_file: + answered[("post", "/api/user/v1/accounts/{username}/image")] = owner.post( + f"/api/user/v1/accounts/{self.owner.username}/image", {"file": image_file}, + ).status_code + return answered + + def test_each_handler_answers_the_code_it_is_published_under(self): + assert self._answered() == { + (method, address): int(code) for method, address, code in CORRECTED_SUCCESS_CODES + } + + def test_every_document_publishes_that_code_and_nothing_beside_it(self): + published = {} + for name, document in self.documents.items(): + for method, address, _code in CORRECTED_SUCCESS_CODES: + responses = document["paths"][address][method]["responses"] + published[(name, method, address)] = ( + sorted(responses), + ["content" in response for response in responses.values()], + ) + assert published == { + (name, method, address): ([code], [False]) + for name in self.documents + for method, address, code in CORRECTED_SUCCESS_CODES + } + + MERGE_PATCH = "application/merge-patch+json" #: Each address that describes its body in place, the form it reads it in, and From 305d883a4a501816e2d17e5b212b7688c3b790ff Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Fri, 30 Oct 2026 00:57:20 +0500 Subject: [PATCH 11/82] fix: mark the superseded user addresses Studio serves as superseded there too Studio mounts the user API at the same addresses the LMS does and publishes its own unauthenticated document at /api-docs/, built from its own settings, so none of the marking applied in the LMS reached it: all twenty-five of those operations were published there as current while the LMS published them as superseded. A client generated from Studio's document therefore offered a caller an address the LMS document steers them off. The list of superseded addresses and the hook that reads it are shared rather than restated, so the two services cannot drift apart. Measured on the two generated documents: Studio marks 25 of 25, as the LMS does, and nothing else in Studio's document changes -- no path, operation, operationId or component is added or lost. The narrow Authoring API document Studio also serves carries none of these addresses, so the hook cannot reach it. --- cms/envs/common.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cms/envs/common.py b/cms/envs/common.py index ed0d2b0cbf3c..4f43d47e7363 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1338,6 +1338,14 @@ def _should_send_xblock_events(settings): 'drf_spectacular.hooks.postprocess_schema_enums', 'cms.lib.spectacular.cms_mark_superseded_paths', 'cms.lib.spectacular.cms_mark_migrated_paths', + # Studio mounts the user API at the same addresses the LMS does, and + # both services publish an unauthenticated document at /api-docs/, so + # an address superseded in one is superseded in the other. The list of + # them and the hook that reads it are shared rather than restated here, + # which is what stops a client generated from Studio's document from + # offering an address as current that the LMS document marks as + # superseded. + 'lms.lib.spectacular.lms_mark_legacy_paths_deprecated', 'openedx.core.apidocs.untag_deprecated_operations', ], # Stripped from each path to derive its tags and operationId, so changing From 2f0e94648f7b91f88fc51618a9703bd3a355f936 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Fri, 30 Oct 2026 01:03:28 +0500 Subject: [PATCH 12/82] fix: carry the service's appended components into the documentation settings The documentation settings replaced the appended components wholesale rather than adding the security schemes to them, so any component name the service settings append was dropped from the settings the published reference is generated from. Nothing showed it: the name simply stops being in the reference, and in every client built from it, while every count stays where it was. They now add to what they inherit. The test loads those settings in a process of its own -- loading them here would turn every feature flag on and add Studio's applications to the settings this process already runs under -- and checks that a component the service settings append arrives alongside the security schemes. --- docs/docs_settings.py | 7 ++++ lms/lib/tests/test_spectacular.py | 65 +++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/docs/docs_settings.py b/docs/docs_settings.py index 48ec4a5cc63e..12e1bca30c9b 100644 --- a/docs/docs_settings.py +++ b/docs/docs_settings.py @@ -82,7 +82,14 @@ # OpenAPI 3 security schemes, consumed by drf-spectacular. "Basic" uses the # OpenAPI 3 http/basic form rather than Swagger 2.0's type: basic. +# +# Whatever the service settings already append is carried through rather than +# replaced: that key holds component names the published reference is expected +# to carry, and dropping one here takes it out of the generated reference and +# out of every client built from it without changing any count that would show +# it had gone. SPECTACULAR_SETTINGS["APPEND_COMPONENTS"] = { # noqa: F405 + **SPECTACULAR_SETTINGS.get("APPEND_COMPONENTS", {}), # noqa: F405 "securitySchemes": { "Basic": { "type": "http", diff --git a/lms/lib/tests/test_spectacular.py b/lms/lib/tests/test_spectacular.py index 00095a8c9bac..ec1109e92636 100644 --- a/lms/lib/tests/test_spectacular.py +++ b/lms/lib/tests/test_spectacular.py @@ -1,7 +1,12 @@ """Tests for the LMS drf-spectacular hooks and schema class.""" +import json +import os import re +import subprocess +import sys from collections import defaultdict +from pathlib import Path from types import SimpleNamespace from unittest import mock @@ -610,6 +615,66 @@ def test_every_document_publishes_that_code_and_nothing_beside_it(self): } +#: Loaded in a process of its own below. The service settings append a +#: component, and the documentation settings are then asked what they append, +#: so that a key one of them carries is seen to reach the other. +DOCUMENTATION_SETTINGS_CHILD = """ +import json +import os + +import lms.envs.common as service_settings + +service_settings.SPECTACULAR_SETTINGS["APPEND_COMPONENTS"] = { + "schemas": {"ServiceComponent": {"type": "object"}}, +} + +os.environ["DJANGO_SETTINGS_MODULE"] = "docs.docs_settings" +from django.conf import settings + +appended = settings.SPECTACULAR_SETTINGS.get("APPEND_COMPONENTS", {}) +print("APPENDED " + json.dumps({ + "keys": sorted(appended), + "schemas": sorted(appended.get("schemas", {})), + "security": sorted(appended.get("securitySchemes", {})), +})) +""" + + +class DocumentationSettingsTest(SimpleTestCase): + """ + The settings the published reference is generated from keep what the service appends. + + They are loaded in a process of their own because they turn every feature + flag on and add Studio's applications to the settings object this process + is already running under, so loading them here would reconfigure the + service for every test that follows. What they append holds component + names the published reference is expected to carry, and one dropped there + leaves the reference and every client built from it without that name + while no count moves. + """ + + def test_a_component_the_service_appends_reaches_the_published_reference(self): + repository = Path(__file__).resolve().parents[3] + environment = dict(os.environ, PYTHONPATH=str(repository)) + environment.pop("DJANGO_SETTINGS_MODULE", None) + finished = subprocess.run( + [sys.executable, "-c", DOCUMENTATION_SETTINGS_CHILD], + cwd=str(repository), + env=environment, + capture_output=True, + text=True, + check=False, + ) + assert finished.returncode == 0, finished.stderr[-2000:] + reported = [line for line in finished.stdout.splitlines() if line.startswith("APPENDED ")] + assert len(reported) == 1, finished.stdout[-2000:] + assert json.loads(reported[0].split(" ", 1)[1]) == { + "keys": ["schemas", "securitySchemes"], + "schemas": ["ServiceComponent"], + "security": ["Basic", "csrf", "jwt"], + } + + MERGE_PATCH = "application/merge-patch+json" #: Each address that describes its body in place, the form it reads it in, and From b299ce151ccc37fe888400fd4f52a5ceb7497361 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Fri, 30 Oct 2026 00:33:08 +0500 Subject: [PATCH 13/82] fix: refuse a page size no collection of this version will give Asking for a page of nothing, of fewer than nothing, or of something that is not a whole number of rows was answered 200 with the default page of ten, on every collection of this version that pages its rows. The framework drops a size it cannot honour and falls back without telling the caller, so the page reads as the one that was asked for; the links on it carry the value back, so a client that follows them keeps sending a number nothing reads. The shared paginator now reads the value itself and refuses one no page can have, the way this version already refuses a query parameter it does not read, a sort field it does not offer and a boolean naming neither side. A number above what a page carries is still answered with a full page, which is what it has always meant, and a value carrying nothing still leaves the size alone. --- .../core/djangoapps/user_api/v2/paginators.py | 39 ++++- .../user_api/v2/tests/test_pagination.py | 154 ++++++++++++++++++ 2 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 openedx/core/djangoapps/user_api/v2/tests/test_pagination.py diff --git a/openedx/core/djangoapps/user_api/v2/paginators.py b/openedx/core/djangoapps/user_api/v2/paginators.py index 5e796180d3cb..8251e1c3f52d 100644 --- a/openedx/core/djangoapps/user_api/v2/paginators.py +++ b/openedx/core/djangoapps/user_api/v2/paginators.py @@ -1,19 +1,54 @@ """Pagination for version 2 of the User API.""" +import logging + from edx_rest_framework_extensions.paginators import DefaultPagination +from rest_framework.exceptions import ValidationError + +log = logging.getLogger(__name__) class UserApiPagination(DefaultPagination): """ - Page-number pagination whose published schema matches the body it returns. + Page-number pagination whose published schema matches the body it returns, + and which refuses a number of rows no page of it can carry. ``DefaultPagination`` answers with ``count``, ``num_pages``, ``current_page``, ``start``, ``next``, ``previous`` and ``results`` but inherits the four-member schema of DRF's ``PageNumberPagination``, so a generated client would not know about the three page-position members. - This subclass only restates the response schema; the body is unchanged. + The response schema below only restates them; the body is unchanged. """ + def get_page_size(self, request): + """ + Return how many rows a page carries, refusing a number no page can have. + + The number asked for is otherwise dropped where it cannot be honoured + and the default page answered in its place, which a caller reads as + the page it asked for; worse, the links on that page carry the value + back, so a client that follows them keeps sending a number that is + never read. A number above what a page carries is still answered with + as many rows as it carries, because asking for more rows than a page + holds is asking for a full one. + """ + asked = request.query_params.get(self.page_size_query_param) + if asked is None or asked == "": + return self.page_size + try: + rows = int(asked) + except ValueError: + rows = 0 + if rows < 1: + log.info("Page size refused at %s: %r", request.path, asked) + raise ValidationError({ + self.page_size_query_param: [ + "Enter a whole number of rows of at least 1. A larger number than " + f"{self.max_page_size} returns {self.max_page_size} rows.", + ], + }) + return min(rows, self.max_page_size) + def get_paginated_response_schema(self, schema): """Describe the full envelope the paginator returns.""" return { diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_pagination.py b/openedx/core/djangoapps/user_api/v2/tests/test_pagination.py new file mode 100644 index 000000000000..667417184f4c --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_pagination.py @@ -0,0 +1,154 @@ +"""Page-size handling on the paginated collections of version 2 of the User API.""" + +import inspect + +from django.urls import reverse +from edx_rest_framework_extensions.testing import assert_error_envelope +from rest_framework import status +from rest_framework.test import APITestCase + +from common.djangoapps.student.tests.factories import AdminFactory, UserFactory +from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory +from openedx.core.djangoapps.user_api.v2 import views as user_api_views +from openedx.core.djangoapps.user_api.v2.paginators import UserApiPagination +from openedx.core.djangolib.testing.utils import skip_unless_lms + +PASSWORD = "Password1234" + +#: Values naming a number of rows no page can carry: none at all, fewer than +#: none, and values that are not a whole number of rows in the first place. +UNHONOURED_PAGE_SIZES = ("0", "-1", "abc", "1.5", "0x10", "%20") + +#: What a caller is told when it asks for one of them. +REFUSAL = ( + "Enter a whole number of rows of at least 1. A larger number than " + f"{UserApiPagination.max_page_size} returns {UserApiPagination.max_page_size} rows." +) + + +@skip_unless_lms +class PageSizeTests(APITestCase): + """ + A page size the collection will not give is refused rather than answered. + + The size asked for is dropped by the framework when it cannot be honoured + and the default page is answered in its place, which a caller reads as the + page it asked for; the links on that page carry the value back, so a + client following them keeps sending a number that is never read. Each + collection below is checked, because the number is read by the paginator + they share and a collection that stopped sharing it would stop refusing. + """ + + def setUp(self): + super().setUp() + self.admin = AdminFactory.create(username="page-admin", password=PASSWORD) + self.learner = UserFactory.create(username="page-learner", password=PASSWORD) + UserPreferenceFactory.create(user=self.learner, key="time_zone", value="UTC") + + def collections(self): + """Return each paginated collection, the caller it admits and how it is asked.""" + return { + "user_list": (self.admin, reverse("user_v2:user_list"), {}), + "user_preference_list": (self.admin, reverse("user_v2:user_preference_list"), {}), + "time_zone_list": (None, reverse("user_v2:time_zone_list"), {}), + "account_list": (self.learner, reverse("user_v2:account_list"), {}), + "account_verification_list": ( + self.learner, + reverse( + "user_v2:account_verification_list", + kwargs={"username": self.learner.username}, + ), + {}, + ), + "email_search_list": ( + self.admin, + reverse("user_v2:email_search_list"), + {"body": {"emails": [self.learner.email]}}, + ), + } + + def ask(self, caller, address, page_size, body=None): + """Ask one collection for a page of ``page_size`` rows as ``caller``.""" + self.client.logout() + if caller is not None: + assert self.client.login(username=caller.username, password=PASSWORD) + query = "" if page_size is None else f"?page_size={page_size}" + if body is None: + return self.client.get(f"{address}{query}") + return self.client.post(f"{address}{query}", body, format="json") + + def test_a_number_of_rows_no_page_can_carry_is_refused(self): + for name, (caller, address, extra) in self.collections().items(): + for page_size in UNHONOURED_PAGE_SIZES: + with self.subTest(collection=name, page_size=page_size): + response = self.ask(caller, address, page_size, **extra) + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"page_size": [REFUSAL]} + + def test_a_value_carrying_nothing_asks_for_nothing_and_is_answered(self): + """An empty value leaves the size alone, as an empty selector leaves the rows alone.""" + for name, (caller, address, extra) in self.collections().items(): + with self.subTest(collection=name): + response = self.ask(caller, address, "", **extra) + assert response.status_code in ( + status.HTTP_200_OK, status.HTTP_201_CREATED, + ), response.content + assert len(response.data["results"]) <= UserApiPagination.page_size + + def test_more_rows_than_a_page_carries_is_answered_with_as_many_as_it_carries(self): + """A number above the cap keeps being answered with the cap rather than refused.""" + for index in range(UserApiPagination.max_page_size + 5): + UserFactory.create(username=f"page-filler{index:03d}") + self.client.logout() + assert self.client.login(username=self.admin.username, password=PASSWORD) + response = self.client.get(reverse("user_v2:user_list"), {"page_size": 100000}) + assert response.status_code == status.HTTP_200_OK, response.content + assert len(response.data["results"]) == UserApiPagination.max_page_size + + def test_the_link_to_the_next_page_carries_a_size_the_next_page_will_give(self): + """ + A refused size cannot reach the links, and an honoured one is carried back unchanged. + + A link carrying a size the collection drops sends the client back with + the same value on every page it follows, and every one of those pages + is a different size from the one it asked for. + """ + for index in range(5): + UserFactory.create(username=f"page-walked{index:03d}") + self.client.logout() + assert self.client.login(username=self.admin.username, password=PASSWORD) + response = self.client.get(reverse("user_v2:user_list"), {"page_size": 2}) + assert response.status_code == status.HTTP_200_OK, response.content + assert len(response.data["results"]) == 2 + assert response.data["next"].endswith("?page=2&page_size=2") + + followed = self.client.get(response.data["next"]) + assert followed.status_code == status.HTTP_200_OK, followed.content + assert len(followed.data["results"]) == 2 + + def test_every_collection_of_this_version_that_pages_is_checked_here(self): + """ + The refusal lives in one paginator, so the check is only worth what it covers. + + A collection added later that pages its rows with the same paginator + is listed above or this fails, rather than the refusal quietly + applying to an address nothing exercises. + """ + paging = { + name + for name, member in inspect.getmembers(user_api_views, inspect.isclass) + if getattr(member, "pagination_class", None) is UserApiPagination + } + assert paging == { + "AccountViewSet", + "CountryTimeZoneViewSet", + "EmailSearchViewSet", + "IDVerificationViewSet", + "UserPreferenceViewSet", + "UserViewSet", + } + assert len(self.collections()) == len(paging) From 33d4d62edb5dc4ba4c93775b7cc0a87f51ba748d Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Fri, 30 Oct 2026 01:01:49 +0500 Subject: [PATCH 14/82] fix: say in words why a request was refused, not in printed data The standard error body carries one line for a person to read beside the list of what each parameter or property was refused for. Where the refusal named parameters, that line was produced by printing the list itself, so a caller read back the brackets, the quoting and the class names that write a data structure out instead of a sentence. Everything that puts the line in front of a person showed it: the message of the exception a generated client raises, a line in an integration log, the notification a page puts up. The line is now written from the same values as a sentence, naming each parameter and what it was refused for; a refusal that names nothing in particular is spelled on its own. The list itself is untouched, and a refusal that never had a list -- an address that matches nothing, a caller that may not read, a method the address does not serve -- keeps the line it had. --- openedx/core/djangoapps/user_api/v2/errors.py | 46 ++++++- .../user_api/v2/tests/test_parity.py | 129 ++++++++++++++++++ 2 files changed, 174 insertions(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/user_api/v2/errors.py b/openedx/core/djangoapps/user_api/v2/errors.py index bbd8b5ab2b60..f21dee73dc4c 100644 --- a/openedx/core/djangoapps/user_api/v2/errors.py +++ b/openedx/core/djangoapps/user_api/v2/errors.py @@ -8,6 +8,7 @@ ParseError, UnsupportedMediaType, ) +from rest_framework.settings import api_settings class RefusedMethod(MethodNotAllowed): @@ -50,7 +51,46 @@ def __init__(self, detail=None, can_cancel_retirement=False, retirement_id=None) register_error_type(RetiredAccount, "user/retired-account", "Retired Account") -class CatalogedRequestErrorsMixin: +def refusal_sentence(refusals): + """ + Return one sentence naming what each parameter or property was refused for. + + A refusal that names nothing in particular is spelled on its own, so the + sentence reads the same whether or not the thing refused has a name. + """ + return " ".join( + reason if name == api_settings.NON_FIELD_ERRORS_KEY else f"{name}: {reason}" + for name, reasons in refusals.items() + for reason in reasons + ) + + +class ReadableErrorDetailMixin: + """ + Writes the one-line message of a refused request as a sentence. + + The standard error body carries a line for a person to read beside the + list of what each parameter or property was refused for, and where the + refusal names them that line was produced by printing the list itself. + Everything that puts the line in front of a person then shows the + brackets, the quoting and the class names that write a data structure + out: the message of the exception a generated client raises, a line in an + integration's log, the notification a page puts on the screen. The list is + left exactly as it was; only the line is rewritten, from the same values. + """ + + def handle_exception(self, exc): + """Answer with the standard body, its one-line message written in words.""" + response = super().handle_exception(exc) + body = getattr(response, "data", None) + if isinstance(body, dict) and isinstance(body.get("errors"), dict): + sentence = refusal_sentence(body["errors"]) + if sentence: + body["detail"] = sentence + return response + + +class CatalogedRequestErrorsMixin(ReadableErrorDetailMixin): """ 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 @@ -61,6 +101,10 @@ class CatalogedRequestErrorsMixin: 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. + + A view that classifies its request failures also wants the one-line + message of each of them to read as a sentence, so this carries what the + class it extends does as well, and a view names one mixin rather than two. """ def handle_exception(self, exc): diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_parity.py b/openedx/core/djangoapps/user_api/v2/tests/test_parity.py index 38a75fe6bd13..f5caac0bb7a5 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_parity.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_parity.py @@ -372,3 +372,132 @@ def test_an_identifier_held_by_a_caller_resolves_to_the_username_the_entry_is_ke entry = self.conforming(reverse("user_v2:user_detail", kwargs={"username": "parity-user"})) assert entry.status_code == status.HTTP_200_OK assert json.loads(entry.content)["id"] == self.user.id + + +@skip_unless_lms +class RefusedRequestMessageTests(APITestCase): + """ + What a person reads when a request of either version is refused. + + The earlier addresses answer a refusal with one sentence, and the + standardized body of this version keeps a member for that sentence beside + the list of what each parameter was refused for. Where the refusal names + parameters, that sentence was being built by printing the list itself, so + the quoting, the bracketing and the class names of the data structure + holding it reached everything that shows the sentence to a person: the + message of an exception a generated client raises, a line in an + integration's log, the notification a page puts on the screen. + """ + + def setUp(self): + super().setUp() + self.admin = AdminFactory.create(username="refusal-admin", password=PASSWORD) + + def refused(self, address, params): + """Call a version 2 address as the administrator it is gated by.""" + assert self.client.login(username=self.admin.username, password=PASSWORD) + response = self.client.get(address, params) + self.client.logout() + return assert_error_envelope( + response, + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + + def test_the_sentence_of_a_refusal_naming_one_parameter_is_the_sentence_that_parameter_got(self): + cases = [ + ( + reverse("user_v2:user_list"), + {"forum_role": "Moderator"}, + {"course_key": ["This parameter is required when forum_role is supplied."]}, + "course_key: This parameter is required when forum_role is supplied.", + ), + ( + reverse("user_v2:user_list"), + {"is_active": "maybe"}, + {"is_active": ["Enter true or false."]}, + "is_active: Enter true or false.", + ), + ( + reverse("user_v2:user_preference_list"), + {"page_size": "0"}, + { + "page_size": [ + "Enter a whole number of rows of at least 1. " + "A larger number than 100 returns 100 rows.", + ], + }, + "page_size: Enter a whole number of rows of at least 1. " + "A larger number than 100 returns 100 rows.", + ), + ] + for address, params, expected_errors, expected_sentence in cases: + with self.subTest(address=address, params=params): + envelope = self.refused(address, params) + assert envelope["errors"] == expected_errors + assert envelope["detail"] == expected_sentence + + def test_the_sentence_of_a_refusal_naming_several_parameters_names_each_of_them(self): + """Every parameter refused is in the sentence, in the order the list carries them.""" + envelope = self.refused(reverse("user_v2:user_list"), {"nope": "1", "alsonope": "2"}) + assert sorted(envelope["errors"]) == ["alsonope", "nope"] + assert envelope["detail"] == " ".join( + f"{name}: {message}" + for name, messages in envelope["errors"].items() + for message in messages + ) + assert envelope["detail"].startswith("alsonope: No such query parameter.") + + def test_a_refusal_that_names_nothing_in_particular_is_spelled_on_its_own(self): + """A body of the wrong shape is refused with no property to hang the refusal on.""" + assert self.client.login(username=self.admin.username, password=PASSWORD) + response = self.client.post(reverse("user_v2:email_search_list"), [], format="json") + self.client.logout() + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "non_field_errors": ["Invalid data. Expected a dictionary, but got list."], + } + assert envelope["detail"] == "Invalid data. Expected a dictionary, but got list." + + def test_a_refusal_naming_no_parameter_says_what_it_has_always_said(self): + """Only the refusals built from a list of parameters change; the rest are untouched.""" + assert self.client.login(username=self.admin.username, password=PASSWORD) + missing = self.client.get(reverse("user_v2:user_detail", kwargs={"username": "nobody"})) + envelope = assert_error_envelope( + missing, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + assert envelope["detail"] == "Not found." + assert "errors" not in envelope + + def test_no_sentence_of_a_refusal_carries_the_printed_form_of_a_list_or_a_mapping(self): + """ + One sweep over the refusals this version produces, rather than a case each. + + A sentence built by printing a data structure carries the brackets, + the quotes and the class names that write it, none of which belong in + something a person reads. + """ + refusals = [ + (reverse("user_v2:user_list"), {"is_active": "maybe"}), + (reverse("user_v2:user_list"), {"forum_role": "Moderator"}), + (reverse("user_v2:user_list"), {"course_key": "course-v1:edX+DemoX+Demo"}), + (reverse("user_v2:user_list"), {"ordering": "password"}), + (reverse("user_v2:user_list"), {"nope": "1"}), + (reverse("user_v2:user_list"), {"page_size": "0"}), + (reverse("user_v2:user_preference_list"), {"ordering": "nope"}), + (reverse("user_v2:user_preference_list"), {"page_size": "-1"}), + (reverse("user_v2:time_zone_list"), {"country_code": "nowhere"}), + ] + for address, params in refusals: + with self.subTest(address=address, params=params): + envelope = self.refused(address, params) + sentence = envelope["detail"] + assert isinstance(sentence, str) + assert not any(mark in sentence for mark in ("{", "}", "[", "]", "ErrorDetail(")), sentence + assert sentence == " ".join( + f"{name}: {message}" + for name, messages in envelope["errors"].items() + for message in messages + ) From 0c76a41cb09b005b2299aae60dc7d7405fdc24ad Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sat, 31 Oct 2026 20:07:03 +0500 Subject: [PATCH 15/82] fix: answer a member account address from the record the lookup resolved it to A member address of the account collection was answered by matching the name it carried against the name each record that came back is stored under, by a reduced form of that name failing that, and anything matching neither was dropped. The database settles which stored records a name resolves to, and the name it resolves to need not be written the way the address wrote it: where it resolved to a record neither comparison matched, the list came back empty, the member handler indexed the first element of it, and an authenticated caller was answered 500. The superseded address answers 200 with that record, so the replacement was worse than the address it stands in for, and a client or MFE that pads or transliterates a name turned a working read into a server fault. A member address is now answered from the records the lookup resolved it to: the one stored under exactly the name given when there is one, and otherwise the single record that came back. An address the lookup resolves to several records, none of them stored under the name given, names no one of them, so it is refused with 400 rather than answered with whichever came back first. A lookup that comes back with nothing is reported as an absent address rather than as a fault. The page of a list keeps the order its names were asked in, which is what the matching is for; a record it resolved to under a name none of them is written as is now carried at the end of the page rather than left out of it in silence. Tests: a member address the lookup resolves to a record stored differently, one it resolves to nothing, one it resolves to several records with and without an exact match among them, and a page carrying a record resolved under another name. Each fails against the previous code, the first three with the 500 this corrects. --- .../user_api/v2/tests/test_account_views.py | 113 +++++++++++++++--- openedx/core/djangoapps/user_api/v2/views.py | 87 ++++++++++---- 2 files changed, 160 insertions(+), 40 deletions(-) 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 index e80a8351f17b..ceee3bd9f957 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py @@ -30,7 +30,7 @@ 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_api.v2.views import EmailSearchViewSet +from openedx.core.djangoapps.user_api.v2.views import AMBIGUOUS_ACCOUNT_ADDRESS, EmailSearchViewSet from openedx.core.djangoapps.user_authn.views.password_reset import PasswordResetFormNoActive from openedx.core.djangolib.testing.utils import skip_unless_lms @@ -79,6 +79,10 @@ #: Where the classes the platform declares record that they read a request. DEFAULT_AUTH_RECORDER = "openedx.core.djangolib.default_auth_classes.set_custom_attribute" +#: Where the account views look an account up, which the database answers. A +#: test that stands in for the database answers it from here. +ACCOUNT_LOOKUP = "openedx.core.djangoapps.user_api.v2.views.get_account_settings" + def counting_cache(location): """ Return a cache that counts what it is given, under a name of its own. @@ -228,6 +232,27 @@ def test_a_name_no_account_holds_is_left_out_rather_than_refused(self): assert response.status_code == status.HTTP_200_OK assert self.usernames_in(response) == ["owner-caller"] + def test_an_account_the_lookup_resolved_a_name_to_is_carried_by_the_page(self): + """ + A page carries every account its lookup came back with. + + The rows are put back in the order the names were asked for by the name + each account is stored under. An account stored under a name none of + them is written as is carried at the end of the page rather than + dropped from it in silence. + """ + def one_account_written_apart(request, usernames=None, **kwargs): # pylint: disable=unused-argument + assert usernames == ["owner-caller"] + return [{"username": "owner-caller ", "email": "owner-caller@example.com"}] + + self.login(self.staff) + with mock.patch(ACCOUNT_LOOKUP, one_account_written_apart): + response = self.client.get(self.list_url, {"username": "owner-caller"}) + assert response.status_code == status.HTTP_200_OK + assert response.data["results"] == [ + {"username": "owner-caller ", "email": "owner-caller@example.com"}, + ] + def test_a_selector_that_names_no_account_answers_an_empty_page(self): """A collection that matched nothing is an empty page, not an absent collection.""" self.login(self.staff) @@ -634,30 +659,90 @@ def test_an_account_named_after_another_collection_is_served_at_its_own_address( 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): + def test_a_member_address_a_stored_account_answers_to_carries_that_account(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. + An address the lookup resolves to a stored account is answered with that account. + + Which stored names an address resolves to is the database's decision, + and the name it resolves to can be written differently from the one the + address carried. The test database resolves a name only to the record + stored under exactly it, so the account service is answered here the + way it answers where an address resolves more widely. """ def stored_under_another_spelling(request, usernames=None, **kwargs): # pylint: disable=unused-argument assert usernames == ["Owner-Caller"] return [{"username": "owner-caller", "email": "owner-caller@example.com"}] self.login(self.staff) - with mock.patch( - "openedx.core.djangoapps.user_api.v2.views.get_account_settings", - stored_under_another_spelling, - ): + with mock.patch(ACCOUNT_LOOKUP, stored_under_another_spelling): response = self.client.get(self.detail_url("Owner-Caller")) assert response.status_code == status.HTTP_200_OK assert response.data == {"username": "owner-caller", "email": "owner-caller@example.com"} + def test_a_member_address_resolving_to_a_name_written_further_apart_carries_that_account(self): + """ + An address resolving to a record whose name shares less with it is answered with that record. + + The name asked for is matched to the record the lookup came back with + so that a page carries its rows in the order they were asked for. At a + member address the record the lookup resolved to is the answer whatever + it is stored under, which is what the superseded version answers. + """ + def stored_under_a_name_written_further_apart(request, usernames=None, **kwargs): # pylint: disable=unused-argument + assert usernames == ["owner-caller "] + return [{"username": "owner-caller", "email": "owner-caller@example.com"}] + + self.login(self.staff) + with mock.patch(ACCOUNT_LOOKUP, stored_under_a_name_written_further_apart): + response = self.client.get(self.detail_url("owner-caller ")) + assert response.status_code == status.HTTP_200_OK + assert response.data == {"username": "owner-caller", "email": "owner-caller@example.com"} + + def test_a_member_address_the_lookup_resolves_to_nothing_is_reported_as_absent(self): + """A lookup that comes back with no account is an absent address rather than a fault.""" + self.login(self.staff) + with mock.patch(ACCOUNT_LOOKUP, lambda *args, **kwargs: []): + response = self.client.get(self.detail_url("owner-caller")) + assert_error_envelope( + response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + + def test_a_member_address_more_than_one_account_answers_to_is_refused(self): + """ + An address resolving to several accounts names none of them, so it is refused. + + Answering with one of them would hand the caller an account they did + not address, and which one it was would be settled by the order the + records came back in. + """ + def two_accounts(request, usernames=None, **kwargs): # pylint: disable=unused-argument + return [ + {"username": "owner-caller", "email": "owner-caller@example.com"}, + {"username": "Owner-Caller", "email": "second@example.com"}, + ] + + self.login(self.staff) + with mock.patch(ACCOUNT_LOOKUP, two_accounts): + response = self.client.get(self.detail_url("owner-caller ")) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == {"non_field_errors": [AMBIGUOUS_ACCOUNT_ADDRESS]} + + def test_a_member_address_one_of_several_accounts_is_stored_under_carries_that_one(self): + """The account stored under exactly the name addressed is the answer, in whatever order they come.""" + def two_accounts(request, usernames=None, **kwargs): # pylint: disable=unused-argument + return [ + {"username": "Owner-Caller", "email": "second@example.com"}, + {"username": "owner-caller", "email": "owner-caller@example.com"}, + ] + + self.login(self.staff) + with mock.patch(ACCOUNT_LOOKUP, two_accounts): + response = self.client.get(self.detail_url("owner-caller")) + assert response.status_code == status.HTTP_200_OK + assert response.data == {"username": "owner-caller", "email": "owner-caller@example.com"} + def test_a_caller_reads_their_own_account_by_name(self): self.login(self.owner) response = self.client.get(self.detail_url("owner-caller")) diff --git a/openedx/core/djangoapps/user_api/v2/views.py b/openedx/core/djangoapps/user_api/v2/views.py index b423b9f42746..8ca6892829a0 100644 --- a/openedx/core/djangoapps/user_api/v2/views.py +++ b/openedx/core/djangoapps/user_api/v2/views.py @@ -162,6 +162,11 @@ #: The member of the account collection that addresses the caller's own account. OWN_ACCOUNT_IDENTIFIER = "me" +#: What a caller is told about a member address that more than one account +#: answers to. One account is addressed there, so an address several of them +#: answer to names none of them in particular. +AMBIGUOUS_ACCOUNT_ADDRESS = "More than one account answers to this address. Name one of them as it is stored." + #: The account actions that answer with an account, and so read the parameters #: that shape one. SHAPED_ACCOUNT_ACTIONS = ("list", "retrieve", "partial_update") @@ -410,6 +415,26 @@ def _ordering_param(sort_fields, default): ) +def _the_one_addressed(username, records, name_of): + """ + Return the one of these records the address names, or refuse the address. + + A name can be answered with more than one record, in which case the record + stored under exactly the name given is the one addressed. Where none of + them is stored under it the address names no one of them, so it is refused + rather than worked on: which record came back first is no basis for + reading or changing an account. + """ + stored_under_it = [record for record in records if name_of(record) == username] + if stored_under_it: + return stored_under_it[0] + if len(records) > 1: + raise ValidationError({api_settings.NON_FIELD_ERRORS_KEY: [AMBIGUOUS_ACCOUNT_ADDRESS]}) + if not records: + raise NotFound + return records[0] + + def _user_named(username): """Return the user an address names, or refuse the address.""" try: @@ -986,11 +1011,11 @@ def list(self, request): def retrieve(self, request, username): """Return one account, shaped as the caller asked.""" - account = self._accounts_named( + account = self._account_addressed( request, - [self._addressed_username(request, username)], + self._addressed_username(request, username), request.query_params.get(VIEW_PARAM), - )[0] + ) return Response(self.shape(account, request)) def partial_update(self, request, username): @@ -1015,9 +1040,9 @@ def partial_update(self, request, username): 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] + account = self._account_addressed( + request, username, request.query_params.get(VIEW_PARAM), + ) except UserNotAuthorized as exc: raise PermissionDenied from exc except UserNotFound as exc: @@ -1039,30 +1064,40 @@ def _accounts_named(self, request, usernames, preset=None): 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 + found = self._accounts_found(request, names, preset) # 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. + # is not the order of the page they were selected for, so each is put + # back under the name it is stored as. Which stored names a selection + # resolves to is the database's decision and need not be the names it + # was given, so an account none of those names is written as is carried + # at the end of the page rather than left out of it. 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) + claimed = {name for name in names if name in by_name} + ordered = [by_name[name] for name in names if name in by_name] + ordered.extend(account for account in found if account["username"] not in claimed) return ordered + def _account_addressed(self, request, username, preset=None): + """ + Return the one account a member address names, as this caller may see it. + + The lookup settles which stored account an address names, so the + account it comes back with is the answer here whatever name that + account is stored under. An address the lookup comes back from with + several accounts and none of them stored under exactly the name given + names no one of them, so it is refused rather than answered with + whichever of them came back first. + """ + found = self._accounts_found(request, [username], preset) + return _the_one_addressed(username, found, lambda account: account["username"]) + + def _accounts_found(self, request, names, preset): + """Return the accounts the lookup comes back with for these names.""" + try: + return get_account_settings(request, names, view=preset if preset == SHARED_VIEW else None) + except UserNotFound as exc: + raise NotFound from exc + def _selected_usernames(self, request, selection): """Return the usernames the selectors name, in the order the page carries them.""" return usernames_among( From 68eba1da546bfbfe0c5da0df2f6a579462f2db37 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sat, 31 Oct 2026 20:09:18 +0500 Subject: [PATCH 16/82] fix: answer every account holding a selected email address The account collection selects by the email address of an owner with a lookup that takes one record and refuses to answer at all where there is more than one. Nothing in the user model keeps one address to one account, so a staff caller looking up an address two accounts hold was answered 500, with nothing in the answer to say why. The superseded address fails the same way, so this was carried forward rather than introduced. The selector now names every account holding the address, and the page carries them all. A collection is what this address answers, and a page of the accounts holding an address is already what the search over addresses beside it answers with, so the page is the answer here whether the address is held once or several times. An address no account holds still leaves the page empty. Tests: the selector asked for an address two accounts hold, and the collection read by a staff caller for the same address, asserting both accounts in the page. Both fail against the previous code, the second with the 500 this corrects. --- .../core/djangoapps/user_api/v2/selectors.py | 15 +++++++++------ .../user_api/v2/tests/test_account_views.py | 18 ++++++++++++++++++ .../user_api/v2/tests/test_selectors.py | 11 ++++++++--- openedx/core/djangoapps/user_api/v2/views.py | 17 +++++++++-------- 4 files changed, 44 insertions(+), 17 deletions(-) diff --git a/openedx/core/djangoapps/user_api/v2/selectors.py b/openedx/core/djangoapps/user_api/v2/selectors.py index 096a7d6eed46..913416cce46d 100644 --- a/openedx/core/djangoapps/user_api/v2/selectors.py +++ b/openedx/core/djangoapps/user_api/v2/selectors.py @@ -32,12 +32,15 @@ def usernames_among(names, ordering): ) -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 usernames_holding_email(email): + """ + Return the names of the accounts holding this address. + + Nothing keeps one email address to one account, so an address can be held + by more than one of them. Every account holding it is named, and nothing + is named for an address no account holds. + """ + return list(accounts_holding_emails([email]).values_list("username", flat=True)) def username_holding_id(identifier): 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 index ceee3bd9f957..cab4f529a91e 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py @@ -462,6 +462,24 @@ def test_the_selectors_a_learner_may_not_use_are_the_ones_the_privilege_is_asked assert documented == {"email", "lms_user_id"} assert set(ACCOUNT_CONTACT_SELECTORS) == documented + def test_a_staff_caller_selecting_an_address_more_than_one_account_holds_receives_them_all(self): + """ + A page carries every account holding the address it was given. + + Nothing keeps one email address to one account, so an address can be + held by more than one of them. The page of accounts holding an address + is what the search over addresses answers with as well, and it is the + answer here whether the address is held once or several times. + """ + UserFactory.create( + username="owner-second", email="owner-caller@example.com", password=PASSWORD, + ) + self.login(self.staff) + response = self.client.get(self.list_url, {"email": "owner-caller@example.com"}) + assert response.status_code == status.HTTP_200_OK, response.content + assert response.data["count"] == 2 + assert self.usernames_in(response) == ["owner-caller", "owner-second"] + def test_a_learner_may_not_select_by_email_address(self): self.login(self.owner) response = self.client.get(self.list_url, {"email": "other-caller@example.com"}) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_selectors.py b/openedx/core/djangoapps/user_api/v2/tests/test_selectors.py index c8ea8a1ed190..e98aa70c86e7 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_selectors.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_selectors.py @@ -10,8 +10,8 @@ from openedx.core.djangoapps.user_api.v2.selectors import ( accounts_holding_emails, cancellable_retirement_for_email, - username_holding_email, username_holding_id, + usernames_holding_email, usernames_among, ) from openedx.core.djangolib.testing.utils import skip_unless_lms @@ -52,10 +52,15 @@ 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" + assert usernames_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 + assert usernames_holding_email("nobody@example.com") == [] + + def test_every_account_holding_one_address_is_named(self): + """Nothing keeps one address to one account, so an address can be held by several.""" + UserFactory.create(username="also-second", email="second@example.com") + assert usernames_holding_email("second@example.com") == ["also-second", "second-holder"] def test_the_account_holding_one_identifier_is_named(self): assert username_holding_id(self.second.id) == "second-holder" diff --git a/openedx/core/djangoapps/user_api/v2/views.py b/openedx/core/djangoapps/user_api/v2/views.py index 8ca6892829a0..828c5c4c05e7 100644 --- a/openedx/core/djangoapps/user_api/v2/views.py +++ b/openedx/core/djangoapps/user_api/v2/views.py @@ -63,9 +63,9 @@ from .selectors import ( accounts_holding_emails, cancellable_retirement_for_email, - username_holding_email, username_holding_id, usernames_among, + usernames_holding_email, ) from .serializers import ( ACCOUNT_DEFAULT_SORT, @@ -803,10 +803,12 @@ def _shareable_account_fields(): 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 " + "Returns a page of accounts. Name them with `username`, or select the accounts " + "holding an `email` address or the one holding an `lms_user_id`; omit all three to " + "receive your own account. A name, an address or an identifier no account holds is " + "left out of the page, which is empty when nothing given is held. An address held by " + "more than one account brings back each of them. " + "Each account carries the properties the caller may " "see: everything for your own account and for a staff caller, and otherwise the " "properties its owner shares. " "A property named in `fields` that an owner does not share is left out of their " @@ -1128,11 +1130,10 @@ def _selected_names(self, request, selection): return [request.user.username] def _usernames_holding_email(self, email): - """Return the account holding this address, and nothing when none does.""" + """Return the accounts holding this address, by name, 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 [] + return usernames_holding_email(email) def _usernames_holding_identifier(self, identifier): """Return the account holding this numeric identifier, and nothing when none does.""" From f8376840d619630366718aa42696c8c9a8c22906 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sat, 31 Oct 2026 20:27:44 +0500 Subject: [PATCH 17/82] fix: admit an operation on one account by the rules of the account resource The accounts are one collection however many addresses reach into them, and six operations that read or change a row of it were served by views standing apart from it: the identity verification summary and attempts, the preference document and one preference of it, and setting and clearing the image, each declaring a flat list of classes of its own, none of them reading what the account resource admits a caller by. A rule declared for that resource therefore did not reach them. Measured with a rule admitting nobody added to the resource: the account read answered 403 while the summary, the attempts and the preferences answered 200, one preference and an upload reached their handlers, and clearing the image answered 204 and cleared the stored image -- a change to an account carried out while every read of the same account was refused. Deactivating an account read nothing of the resource either. The classes the resource admits a caller by are now declared once and carried by every address that works on one of its accounts, which applies them first and the classes of its own address after. The same measurement now refuses all eight, and the stored image is left alone. The declaration is read from the shared tuple rather than from the collection's own attribute because that attribute is added to in place by the first request to reach a view that reads a token, which would otherwise make admission depend on which addresses a process had served already. The account an address names is resolved through the resource as well, by one rule: the record stored under exactly the name given, or the single record the name came back with, and otherwise a refusal. Three operations resolved the record they write to with a lookup that takes one record and fails outright on more, each catching only the not-found case, while the class in front of them had settled the question on the name as text. Making a password unusable, confirming a profile name and recording an image can therefore no longer land on a record nobody addressed: each is refused with 400 and writes nothing. Recording an image is looked up again by the writing helper, which this version does not own, so the refusal is raised where that lookup comes back with more than one record. The preference addresses keep their own two-tier rule -- a read of somebody else's is a privilege, a change of it belongs to the owner -- and declare it through the hook the shared gate calls, so both tiers still apply and the resource's rules apply before them. One test outside this version's own modules moves with it: the preference guard harness handed a bare request to classes that are given the wrapped one by the view, and the classes carried in from the resource read the request through it, so the harness now authenticates the request the way the test client does. Tests: every read and every change of one account measured twice, once as they stand and once with a rule admitting nobody added to the resource, with the stored image asserted untouched after the refusal; a deactivation refused with the privilege for it held, so that the refusal measured is the resource's; and each of the three write targets, refused when the address names no one account and landing on the account stored under the name when it does. Removing the shared gate from the four views, or emptying the shared declaration, turns the refusal tests red. --- .../djangoapps/profile_images/v2/views.py | 18 +- .../core/djangoapps/user_api/v2/selectors.py | 11 + .../user_api/v2/tests/test_account_views.py | 235 ++++++++++++++++++ .../v2/tests/test_name_change_views.py | 65 +++++ .../v2/tests/test_preference_views.py | 16 +- openedx/core/djangoapps/user_api/v2/views.py | 74 +++++- 6 files changed, 402 insertions(+), 17 deletions(-) diff --git a/openedx/core/djangoapps/profile_images/v2/views.py b/openedx/core/djangoapps/profile_images/v2/views.py index 2efcc5a55d03..fc285cfa0496 100644 --- a/openedx/core/djangoapps/profile_images/v2/views.py +++ b/openedx/core/djangoapps/profile_images/v2/views.py @@ -7,6 +7,7 @@ from zoneinfo import ZoneInfo import piexif +from django.core.exceptions import MultipleObjectsReturned from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser @@ -16,12 +17,14 @@ from rest_framework.exceptions import NotFound, ValidationError from rest_framework.parsers import FormParser, MultiPartParser from rest_framework.response import Response +from rest_framework.settings import api_settings 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.views import AMBIGUOUS_ACCOUNT_ADDRESS, AccountResourceMixin from openedx.core.djangoapps.user_api.v2.serializers import ErrorResponse from openedx.core.djangoapps.user_api.v2.view_utils import ( RESP_UNAUTHENTICATED, @@ -106,6 +109,7 @@ def get_filename(self, stream, media_type, parser_context): class ProfileImageView( StandardizedErrorMixin, CatalogedRequestErrorsMixin, + AccountResourceMixin, StrictQueryParametersMixin, APIView, ): @@ -241,12 +245,24 @@ def _read_image(self, username, uploaded_file): uploaded_file.seek(0) def _record_image(self, username, is_uploaded): - """Record on the account whether it carries an image of its own.""" + """ + Record on the account whether it carries an image of its own. + + The account is looked up again by the name in the address to record + this, and a name that answers for more than one account settles on + none of them, so nothing is recorded and the request is refused rather + than written to whichever account came back. + """ 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 + except MultipleObjectsReturned as exc: + log.info("Profile image not recorded for %r: the name answers for more than one account", username) + raise ValidationError({ + api_settings.NON_FIELD_ERRORS_KEY: [AMBIGUOUS_ACCOUNT_ADDRESS], + }) from exc def _unusable_image(self, username, message, reason): """Return the refusal telling the caller what the site cannot do with this image.""" diff --git a/openedx/core/djangoapps/user_api/v2/selectors.py b/openedx/core/djangoapps/user_api/v2/selectors.py index 913416cce46d..d4c75611b71a 100644 --- a/openedx/core/djangoapps/user_api/v2/selectors.py +++ b/openedx/core/djangoapps/user_api/v2/selectors.py @@ -14,6 +14,17 @@ def accounts_holding_emails(emails): return User.objects.filter(email__in=emails).order_by("username") +def accounts_named(name): + """ + Return the accounts this name addresses. + + The database settles which stored names an address answers to, and it need + not be only the one written exactly as the address writes it, so a name + can bring back more than one account. + """ + return list(User.objects.filter(username=name)) + + def usernames_among(names, ordering): """ Return the names these accounts are stored under, in the order asked for. 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 index cab4f529a91e..ffa4776fbba3 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py @@ -13,6 +13,7 @@ from edx_rest_framework_extensions.scoping import FullScopePolicy from edx_rest_framework_extensions.testing import assert_error_envelope from rest_framework import status +from rest_framework.permissions import BasePermission from rest_framework.test import APITestCase from waffle.testutils import override_switch @@ -30,6 +31,7 @@ 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_api.v2 import views from openedx.core.djangoapps.user_api.v2.views import AMBIGUOUS_ACCOUNT_ADDRESS, EmailSearchViewSet from openedx.core.djangoapps.user_authn.views.password_reset import PasswordResetFormNoActive from openedx.core.djangolib.testing.utils import skip_unless_lms @@ -83,6 +85,23 @@ #: test that stands in for the database answers it from here. ACCOUNT_LOOKUP = "openedx.core.djangoapps.user_api.v2.views.get_account_settings" +class RefusesEveryCaller(BasePermission): + """A rule that admits nobody, added to a declaration to see how far it reaches.""" + + def has_permission(self, request, view): + """Refuse every caller.""" + return False + + +def refusing_account_resource(): + """Add a rule admitting nobody to the classes the account resource is read with.""" + return mock.patch.object( + views, + "ACCOUNT_RESOURCE_PERMISSIONS", + (*views.ACCOUNT_RESOURCE_PERMISSIONS, RefusesEveryCaller), + ) + + def counting_cache(location): """ Return a cache that counts what it is given, under a name of its own. @@ -1988,6 +2007,222 @@ def test_the_longest_search_allowed_fits_in_the_largest_page_a_caller_may_ask_fo assert response.data["next"] is None +@skip_unless_lms +class AccountWriteTargetTests(AccountApiV2TestCase): + """ + A change whose address names no one account is refused rather than written somewhere. + + What an address resolves to is the database's answer, and more than one + account can come back for one name. The record the work lands on is + settled from that answer rather than taken as the first of it, so a change + either lands on the account the address names or on nothing at all. The + test database resolves a name only to the record stored under exactly it, + so the lookups are answered here the way they answer where a name resolves + more widely. + """ + + def setUp(self): + super().setUp() + self.deactivator = UserFactory.create(username="target-deactivator", password=PASSWORD) + self.deactivator.user_permissions.add( + PermissionFactory( + codename="can_deactivate_users", + content_type=ContentTypeFactory(app_label="student"), + ), + ) + self.owner.profile.profile_image_uploaded_at = datetime.datetime.now(datetime.UTC) + self.owner.profile.save() + + def test_a_deactivation_whose_address_names_more_than_one_account_deactivates_none(self): + address = reverse("user_v2:account_deactivation", kwargs={"username": self.owner.username}) + self.login(self.deactivator) + with mock.patch( + "openedx.core.djangoapps.user_api.v2.views.accounts_named", + lambda name: [self.other, self.staff], + ): + envelope = assert_error_envelope( + self.client.post(address), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"non_field_errors": [AMBIGUOUS_ACCOUNT_ADDRESS]} + for user in (self.owner, self.other, self.staff): + user.refresh_from_db() + assert user.has_usable_password(), user.username + + def test_a_deactivation_lands_on_the_account_stored_under_the_name_addressed(self): + address = reverse("user_v2:account_deactivation", kwargs={"username": self.owner.username}) + self.login(self.deactivator) + with mock.patch( + "openedx.core.djangoapps.user_api.v2.views.accounts_named", + lambda name: [self.other, self.owner], + ): + assert self.client.post(address).status_code == status.HTTP_204_NO_CONTENT + self.owner.refresh_from_db() + self.other.refresh_from_db() + assert not self.owner.has_usable_password() + assert self.other.has_usable_password() + + def test_an_image_whose_address_names_more_than_one_account_is_left_alone(self): + """ + The image is recorded against the account the address names, or against none. + + The record it is written to is resolved again by the name in the + address when it is written, so a name that answers for more than one + account is refused there and the stored image is left as it stands. + """ + address = reverse( + "user_v2:account_profile_image", kwargs={"username": self.owner.username}, + ) + self.login(self.owner) + with mock.patch( + "openedx.core.djangoapps.profile_images.v2.views.set_has_profile_image", + side_effect=UserProfile.MultipleObjectsReturned, + ): + envelope = assert_error_envelope( + self.client.delete(address), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"non_field_errors": [AMBIGUOUS_ACCOUNT_ADDRESS]} + self.owner.profile.refresh_from_db() + assert self.owner.profile.has_profile_image is True + + +@skip_unless_lms +class AccountResourceGateTests(AccountApiV2TestCase): + """ + A rule the account resource admits a caller by reaches every operation on an account. + + The accounts are one collection however many addresses reach into them, so + a rule declared for the resource has to be applied by every operation that + reads or changes one of its accounts, wherever that operation is served + from. Each one is measured twice: once as it stands, and once with a rule + that admits nobody added to the resource's declaration, which every one of + them must then refuse. Measuring both ways is what makes the second + measurement an answer about the rule rather than about the address. + """ + + def setUp(self): + super().setUp() + UserPreferenceFactory.create(user=self.owner, key="time_zone", value="Asia/Tokyo") + self.owner.profile.profile_image_uploaded_at = datetime.datetime.now(datetime.UTC) + self.owner.profile.save() + self.deactivator = UserFactory.create(username="gate-deactivator", password=PASSWORD) + self.deactivator.user_permissions.add( + PermissionFactory( + codename="can_deactivate_users", + content_type=ContentTypeFactory(app_label="student"), + ), + ) + + def reads(self): + """Return each read of one account, by the address it is sent to.""" + name = self.owner.username + return { + "the account": lambda: self.client.get(self.detail_url(name)), + "the verification summary": lambda: self.client.get( + reverse("user_v2:account_verification_status", kwargs={"username": name}), + ), + "the verification attempts": lambda: self.client.get( + reverse("user_v2:account_verification_list", kwargs={"username": name}), + ), + "the preferences": lambda: self.client.get( + reverse("user_v2:account_preference_list", kwargs={"username": name}), + ), + "one preference": lambda: self.client.get( + reverse( + "user_v2:account_preference_detail", + kwargs={"username": name, "key": "time_zone"}, + ), + ), + } + + def writes(self): + """Return each change to one account, by the address it is sent to.""" + name = self.owner.username + return { + "the preferences": lambda: self.client.patch( + reverse("user_v2:account_preference_list", kwargs={"username": name}), + json.dumps({"time_zone": "Asia/Tokyo"}), + content_type=MERGE_PATCH, + ), + "the image": lambda: self.client.post( + reverse("user_v2:account_profile_image", kwargs={"username": name}), + ), + "the stored image": lambda: self.client.delete( + reverse("user_v2:account_profile_image", kwargs={"username": name}), + ), + } + + def has_image(self): + """Return whether the account carries an image of its own.""" + self.owner.profile.refresh_from_db() + return self.owner.profile.has_profile_image + + def test_every_read_of_one_account_is_answered_while_the_resource_admits_the_caller(self): + self.login(self.owner) + for case, send in self.reads().items(): + assert send().status_code == status.HTTP_200_OK, case + + def test_a_rule_the_resource_refuses_every_caller_by_refuses_every_read_of_one_account(self): + self.login(self.owner) + with refusing_account_resource(): + for case, send in self.reads().items(): + assert_error_envelope( + send(), expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + + def test_every_change_to_one_account_reaches_its_handler_while_the_resource_admits_the_caller(self): + """ + The handler is reached, which is what the refusal below is measured against. + + A change is sent here as a caller sends it wrong, so that what is + measured is the request reaching the handler rather than the change + itself: an empty merge patch and an upload carrying no image are + refused by the handler, and clearing the image is carried out. + """ + self.login(self.owner) + answers = {case: send() for case, send in self.writes().items()} + assert answers["the preferences"].status_code == status.HTTP_204_NO_CONTENT + assert answers["the image"].status_code == status.HTTP_400_BAD_REQUEST + assert answers["the stored image"].status_code == status.HTTP_204_NO_CONTENT + assert self.has_image() is False + + def test_a_rule_the_resource_refuses_every_caller_by_refuses_every_change_to_one_account(self): + """A change refused by the resource leaves the account as it stands.""" + self.login(self.owner) + with refusing_account_resource(): + for case, send in self.writes().items(): + assert_error_envelope( + send(), expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + assert self.has_image() is True + + def test_a_rule_the_resource_refuses_every_caller_by_refuses_a_deactivation(self): + """ + The privilege to deactivate is held and the resource still decides. + + The caller holds the privilege this address asks for, so the refusal + measured here is the resource's rather than that privilege's, and the + password is still usable afterwards. + """ + address = reverse("user_v2:account_deactivation", kwargs={"username": self.owner.username}) + self.login(self.deactivator) + with refusing_account_resource(): + assert_error_envelope( + self.client.post(address), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + self.owner.refresh_from_db() + assert self.owner.has_usable_password() + + assert self.client.post(address).status_code == status.HTTP_204_NO_CONTENT + self.owner.refresh_from_db() + assert not self.owner.has_usable_password() + + @skip_unless_lms class AccountDeactivationTests(AccountApiV2TestCase): """POST /api/user/v2/accounts/{username}/deactivation/""" 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 index 8ff088720cf2..b3e7f89b1956 100644 --- 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 @@ -1,5 +1,7 @@ """Tests for the version 2 User API profile name change views.""" +from unittest import mock + import pytest from django.urls import reverse from edx_rest_framework_extensions.testing import assert_error_envelope @@ -9,6 +11,7 @@ from common.djangoapps.student.models import PendingNameChange from common.djangoapps.student.tests.factories import UserFactory from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user +from openedx.core.djangoapps.user_api.v2.views import AMBIGUOUS_ACCOUNT_ADDRESS from openedx.core.djangolib.testing.utils import skip_unless_lms PASSWORD = "Password1234" @@ -398,6 +401,68 @@ def test_an_unknown_query_parameter_is_refused(self): assert self.profile_name(self.owner) == "Original Name" +@skip_unless_lms +class NameChangeAmbiguousTargetTests(NameChangeTestCase): + """A confirmation whose address names no one account changes no profile.""" + + def setUp(self): + super().setUp() + PendingNameChange.objects.create( + user=self.owner, new_name="Jon Doe", rationale="asked for in a test", + ) + self.url = self.confirmation_url(self.owner.username) + + def test_an_address_naming_more_than_one_account_changes_nothing(self): + """ + The profile the work lands on is the one the address names, or none at all. + + What the address resolves to is the database's answer, and more than + one account can come back for one name. Writing to whichever came back + first would put a name on an account nobody addressed, so the request + is refused and both profiles are left as they stand. The test database + resolves a name only to the record stored under exactly it, so the + lookup is answered here the way it answers where a name resolves more + widely. + """ + sibling = UserFactory.create( + username="name-sibling", email="name-sibling@example.com", password=PASSWORD, + ) + sibling.profile.name = "Sibling Name" + sibling.profile.save() + self.login(self.staff) + with mock.patch( + "openedx.core.djangoapps.user_api.v2.views.accounts_named", + lambda name: [sibling, self.other], + ): + envelope = assert_error_envelope( + self.client.post(self.url), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"non_field_errors": [AMBIGUOUS_ACCOUNT_ADDRESS]} + assert self.profile_name(self.owner) == "Original Name" + assert self.pending_name(self.owner) == "Jon Doe" + sibling.profile.refresh_from_db() + assert sibling.profile.name == "Sibling Name" + + def test_the_account_stored_under_the_name_addressed_is_the_one_changed(self): + """Among several, the account stored under exactly the name addressed is the one written to.""" + sibling = UserFactory.create( + username="name-sibling", email="name-sibling@example.com", password=PASSWORD, + ) + sibling.profile.name = "Sibling Name" + sibling.profile.save() + self.login(self.staff) + with mock.patch( + "openedx.core.djangoapps.user_api.v2.views.accounts_named", + lambda name: [sibling, self.owner], + ): + assert self.client.post(self.url).status_code == status.HTTP_204_NO_CONTENT + assert self.profile_name(self.owner) == "Jon Doe" + sibling.profile.refresh_from_db() + assert sibling.profile.name == "Sibling Name" + + @skip_unless_lms class NameChangeParityTests(NameChangeTestCase): """The same requests through the version 1 and the version 2 addresses.""" diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_preference_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_preference_views.py index f39b20c90f3f..a60bbb838479 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_preference_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_preference_views.py @@ -12,7 +12,8 @@ from django.urls import resolve, reverse from edx_rest_framework_extensions.testing import assert_error_envelope from rest_framework import status -from rest_framework.test import APIClient, APIRequestFactory, APITestCase +from rest_framework.request import Request +from rest_framework.test import APIClient, APIRequestFactory, APITestCase, force_authenticate from common.djangoapps.student.tests.factories import UserFactory from common.djangoapps.util import model_utils @@ -1103,9 +1104,16 @@ def routed_actions(self): return routed def admits(self, action, caller, username, method="get"): - """Return whether every class guarding an action admits one caller at one address.""" - request = getattr(APIRequestFactory(), method)("/") - request.user = caller + """ + Return whether every class guarding an action admits one caller at one address. + + The classes are handed the request as the view is handed it: the + wrapped form, carrying the caller as the one the request was read as, + since a class may ask either how the request was read or who made it. + """ + sent = getattr(APIRequestFactory(), method)("/") + force_authenticate(sent, user=caller) + request = Request(sent) view = AccountPreferenceViewSet() view.action = action view.kwargs = {"username": username} diff --git a/openedx/core/djangoapps/user_api/v2/views.py b/openedx/core/djangoapps/user_api/v2/views.py index 828c5c4c05e7..fe88c6c8aef1 100644 --- a/openedx/core/djangoapps/user_api/v2/views.py +++ b/openedx/core/djangoapps/user_api/v2/views.py @@ -62,6 +62,7 @@ from .schema import ResponseShapes from .selectors import ( accounts_holding_emails, + accounts_named, cancellable_retirement_for_email, username_holding_id, usernames_among, @@ -437,10 +438,7 @@ def _the_one_addressed(username, records, name_of): 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 + return _the_one_addressed(username, accounts_named(username), lambda user: user.username) class _MemberLookupMixin: @@ -798,6 +796,57 @@ def _shareable_account_fields(): return set(settings.ACCOUNT_VISIBILITY_CONFIGURATION["custom_shareable_fields"]) +#: The classes every operation on an account admits a caller by, wherever it is +#: served from. The account collection declares this tuple and every address +#: that works on one of its accounts carries it, so the rules of the resource +#: are declared once rather than restated by each address that reaches into it. +#: It is read from here rather than from the collection's own attribute because +#: that attribute is added to in place, by the first request to reach a view +#: that reads a token, and admission must not depend on which addresses a +#: process has served already. +ACCOUNT_RESOURCE_PERMISSIONS = ( + permissions.IsAuthenticated, + CanGetAccountInfo, + CanSelectAccountsByContact, +) + + +class AccountResourceMixin: + """ + Carries the rules of the account resource into the operations on one account. + + An address beneath an account works on a row of the collection the account + resource answers from, so a caller that resource refuses is a caller the + operation refuses too: the classes the resource declares are applied first + and the ones this address declares after, which keeps an address reached + this way from standing open to a caller the resource is closed to. The + account the address names is resolved here as well, so the record a + permission settled the request on is the record the work lands on. + """ + + def account_resource_permissions(self): + """Return the classes the account resource admits a caller by.""" + return ACCOUNT_RESOURCE_PERMISSIONS + + def declared_permissions(self): + """Return the classes the address being served declares of its own.""" + return super().get_permissions() + + def get_permissions(self): + """Return the collection's classes, then the ones this address declares.""" + declared = self.declared_permissions() + already_carried = {type(permission) for permission in declared} + carried = [ + permission() for permission in self.account_resource_permissions() + if permission not in already_carried + ] + return carried + declared + + def addressed_account(self): + """Return the account this address names, refusing an address that names no one account.""" + return _user_named(self.kwargs["username"]) + + @extend_schema(tags=[SDK_TAG]) @extend_schema_view( list=extend_schema( @@ -868,6 +917,7 @@ class AccountViewSet( StandardizedErrorMixin, CatalogedRequestErrorsMixin, ErrorExtensionsMixin, + AccountResourceMixin, _ActionAddressMixin, _ResponsePresetMixin, StrictQueryParametersMixin, @@ -914,11 +964,7 @@ class AccountViewSet( # pair is built on, named here because a view declaring its own classes is # not one that pair serves. authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser) - permission_classes = ( - permissions.IsAuthenticated, - CanGetAccountInfo, - CanSelectAccountsByContact, - ) + permission_classes = ACCOUNT_RESOURCE_PERMISSIONS #: A change is a merge patch: a body in any other media type is refused. parser_classes = (MergePatchParser,) serializer_class = AccountSerializer @@ -1214,6 +1260,7 @@ def _failed_change(self, username, error): class AccountDeactivationView( StandardizedErrorMixin, CatalogedRequestErrorsMixin, + AccountResourceMixin, StrictQueryParametersMixin, APIView, ): @@ -1234,7 +1281,7 @@ class AccountDeactivationView( def post(self, request, username): """Make the password of the named account unusable and record it as disabled.""" - user = _user_named(username) + user = self.addressed_account() segment.identify(user.id, {"is_disabled": "true"}) user.set_unusable_password() user.save() @@ -1475,6 +1522,7 @@ def _verification_sort_key(attempt): class IDVerificationStatusView( StandardizedErrorMixin, CatalogedRequestErrorsMixin, + AccountResourceMixin, StrictQueryParametersMixin, APIView, ): @@ -1558,6 +1606,7 @@ def get(self, request, username): class IDVerificationViewSet( StandardizedErrorMixin, CatalogedRequestErrorsMixin, + AccountResourceMixin, StrictQueryParametersMixin, IterablePaginationMixin, viewsets.ViewSet, @@ -1671,6 +1720,7 @@ def list(self, request, username): class AccountPreferenceViewSet( StandardizedErrorMixin, CatalogedRequestErrorsMixin, + AccountResourceMixin, StrictQueryParametersMixin, viewsets.ViewSet, ): @@ -1717,8 +1767,8 @@ 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 get_permissions(self): - """Return the classes deciding who may make the request being served.""" + def declared_permissions(self): + """Return the classes this address declares for the request being served.""" reads = self.action in STAFF_READABLE_PREFERENCE_ACTIONS or self._describes_the_address() permission_classes = _PREFERENCE_READ_PERMISSIONS if reads else self.permission_classes return [permission() for permission in permission_classes] From 20382b1dac7086dda029cef1eec5eebcc9293658 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sat, 31 Oct 2026 20:31:12 +0500 Subject: [PATCH 18/82] fix: describe each address by the body and the media types it reads The answer to an OPTIONS request is built from the parsers and the serializer declared on the view, which belong to the view rather than to the address being asked about, and two addresses of this version were described wrongly because of it. The preference document of one account is read and changed as a merge patch and a body in any other media type is refused, which is what the published document says of it. The description of that address named the three media types the platform reads bodies in everywhere else and left out the one it takes, because the parsers follow the action being served and the action recorded for this question is not the change. A caller negotiating from the description -- the one mechanism offered for it -- therefore sent a body in a media type the address refuses and was answered 415, with the two published descriptions of one address contradicting each other. The parsers now follow the methods the address serves, and the description reports the ones it would read a body with. Confirming a requested profile name carries out the change already asked for and reads no body, which is what the published operation says. The description of that address offered a body with a required name in it, built from the view's serializer, so a client or a form driven by it asked the operator for a name before confirming the change and sent a body the address ignores. An action that reads no body is now declared as such and no body is described for it. The address that asks for a name still describes the one it takes. Tests: the media types named by each preference address, a change sent in every media type the document address names being carried out, no body described for a confirmation, and the body still described for asking. Each fails against the previous code, the negotiated change with the 415 this corrects, and each half of the description turns its own tests red when taken back off. --- .../user_api/v2/tests/test_account_views.py | 56 +++++++++++++++++ .../v2/tests/test_name_change_views.py | 33 ++++++++++ openedx/core/djangoapps/user_api/v2/views.py | 61 ++++++++++++++++++- 3 files changed, 148 insertions(+), 2 deletions(-) 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 index ffa4776fbba3..05046290552b 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py @@ -2007,6 +2007,62 @@ def test_the_longest_search_allowed_fits_in_the_largest_page_a_caller_may_ask_fo assert response.data["next"] is None +@skip_unless_lms +class AccountPreferenceAddressDescriptionTests(AccountApiV2TestCase): + """What OPTIONS answers about the preference addresses beneath one account.""" + + def setUp(self): + super().setUp() + self.document_url = reverse( + "user_v2:account_preference_list", kwargs={"username": self.owner.username}, + ) + UserPreferenceFactory.create(user=self.owner, key="time_zone", value="Asia/Tokyo") + self.member_url = reverse( + "user_v2:account_preference_detail", + kwargs={"username": self.owner.username, "key": "time_zone"}, + ) + + def test_the_document_address_names_the_media_type_a_change_to_it_is_sent_in(self): + """ + The media types the answer names are the ones the address reads. + + A change to the whole document is a merge patch and a body in any + other media type is refused, so naming the media types read everywhere + else would tell a caller to send three the address refuses and leave + out the one it takes. + """ + self.login(self.owner) + answer = self.client.options(self.document_url) + assert answer.status_code == status.HTTP_200_OK + assert answer.json()["parses"] == [MERGE_PATCH] + + def test_the_member_address_names_the_media_types_a_change_to_one_preference_is_sent_in(self): + """One preference is sent as the platform reads a body everywhere, which is what is named.""" + self.login(self.owner) + answer = self.client.options(self.member_url) + assert answer.status_code == status.HTTP_200_OK + assert answer.json()["parses"] == [ + "application/json", + "application/x-www-form-urlencoded", + "multipart/form-data", + ] + + def test_a_change_sent_in_a_media_type_the_answer_names_is_read(self): + """ + The answer is taken at its word, which is the only mechanism offered for it. + + A caller that reads the media types from the description and sends one + of them is answered by the change being carried out rather than by a + refusal of what it was told to send. + """ + self.login(self.owner) + for media_type in self.client.options(self.document_url).json()["parses"]: + response = self.client.patch( + self.document_url, json.dumps({"time_zone": "Asia/Tokyo"}), content_type=media_type, + ) + assert response.status_code == status.HTTP_204_NO_CONTENT, media_type + + @skip_unless_lms class AccountWriteTargetTests(AccountApiV2TestCase): """ 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 index b3e7f89b1956..bb62d4a2fb21 100644 --- 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 @@ -401,6 +401,39 @@ def test_an_unknown_query_parameter_is_refused(self): assert self.profile_name(self.owner) == "Original Name" +@skip_unless_lms +class NameChangeAddressDescriptionTests(NameChangeTestCase): + """What OPTIONS answers about the addresses that ask for and confirm a name.""" + + def setUp(self): + super().setUp() + PendingNameChange.objects.create( + user=self.owner, new_name="Jon Doe", rationale="asked for in a test", + ) + self.url = self.confirmation_url(self.owner.username) + + def test_the_confirmation_address_describes_no_body(self): + """ + A confirmation carries out the change already asked for, so there is nothing to send. + + A client or a form built from this description would otherwise ask the + operator for a name before confirming the change, and send a body the + address does not read. + """ + self.login(self.staff) + answer = self.client.options(self.url) + assert answer.status_code == status.HTTP_200_OK + assert "actions" not in answer.json() + + def test_asking_for_a_name_is_still_described_by_the_body_it_takes(self): + """The collection reads a body, and the description of it is not what the above removes.""" + self.login(self.owner) + answer = self.client.options(self.request_url) + assert answer.status_code == status.HTTP_200_OK + assert set(answer.json()["actions"]["POST"]) == {"name"} + assert answer.json()["actions"]["POST"]["name"]["required"] is True + + @skip_unless_lms class NameChangeAmbiguousTargetTests(NameChangeTestCase): """A confirmation whose address names no one account changes no profile.""" diff --git a/openedx/core/djangoapps/user_api/v2/views.py b/openedx/core/djangoapps/user_api/v2/views.py index fe88c6c8aef1..d5690825c307 100644 --- a/openedx/core/djangoapps/user_api/v2/views.py +++ b/openedx/core/djangoapps/user_api/v2/views.py @@ -22,6 +22,7 @@ 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.metadata import SimpleMetadata from rest_framework.response import Response from rest_framework.reverse import reverse from rest_framework.settings import api_settings @@ -209,6 +210,37 @@ ) +class AddressMetadata(SimpleMetadata): + """ + Describes an address by what it reads, rather than by what its view declares. + + The standard description is built from the parsers and the serializer + declared on the view, which belong to the view as a whole rather than to + the address being asked about: an address that reads one media type is + otherwise described as reading every media type the platform reads + anywhere, and a method whose handler reads no body is described as taking + the one the view's serializer carries. A caller that negotiates from this + description sends what it was told to send, so each half of it is answered + from the address and the method being described. + """ + + def determine_metadata(self, request, view): + """Describe the address, naming the media types the bodies it takes are read in.""" + description = super().determine_metadata(request, view) + description["parses"] = [parser.media_type for parser in view.get_parsers()] + return description + + def determine_actions(self, request, view): + """Describe the body of each method that takes one, and leave out the methods that take none.""" + described = super().determine_actions(request, view) + served = getattr(view, "action_map", None) or {} + bodiless = set(getattr(view, "bodiless_actions", ())) + return { + method: fields for method, fields in described.items() + if served.get(method.lower()) not in bodiless + } + + def _ordering_param(sort_fields, default): """Build the sort parameter of a list, naming the fields it accepts.""" return query_param( @@ -1428,6 +1460,12 @@ class NameChangeViewSet( permission_classes = (permissions.IsAuthenticated,) serializer_class = NameChangeSerializer + #: Describes each address by the body and the media types it reads. + metadata_class = AddressMetadata + #: The actions whose handler reads no body, which their address describes + #: none for. Confirming a change carries out the one already asked for, so + #: there is nothing for a caller to send. + bodiless_actions = ("confirmations",) lookup_field = "username" # The router's default lookup pattern excludes dots, which usernames allow. lookup_value_regex = settings.USERNAME_REGEX_PARTIAL @@ -1672,6 +1710,9 @@ def list(self, request, username): #: against an account other than their own. STAFF_READABLE_PREFERENCE_ACTIONS = ("retrieve_all", "retrieve") +#: The action that changes the whole preference document of one account. +DOCUMENT_CHANGE_ACTION = "partial_update_all" + #: The action recorded for a request that asks what an address answers rather #: than for what it holds. Such a request is answered to whoever may read the #: address; the description of a write it carries is built behind a second @@ -1757,6 +1798,8 @@ class AccountPreferenceViewSet( authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser) permission_classes = _PREFERENCE_WRITE_PERMISSIONS serializer_class = AccountPreferenceSerializer + #: Describes each address by the body and the media types it reads. + metadata_class = AddressMetadata #: Action being served, which the parsers follow. It is declared here #: because it is recorded while the request is being built, which is @@ -1798,11 +1841,25 @@ def initialize_request(self, request, *args, **kwargs): return super().initialize_request(request, *args, **kwargs) def get_parsers(self): - """Return the parsers that read the body the action being served takes.""" - if self.action == "partial_update_all": + """Return the parsers that read the bodies the address being served takes.""" + if self.action == DOCUMENT_CHANGE_ACTION or self._describes_an_address_the_document_is_changed_at(): return [parser() for parser in _DOCUMENT_PARSERS] return super().get_parsers() + def _describes_an_address_the_document_is_changed_at(self): + """ + Return whether the request asks what an address that takes the whole document answers. + + Such a request is answered with the media types that address reads, + which are the ones of the change it serves rather than the ones read + everywhere else; the action recorded for it is not the change, so the + methods the address serves are what decide. + """ + return ( + self.action == DESCRIBE_ADDRESS_ACTION + and DOCUMENT_CHANGE_ACTION in (self.action_map or {}).values() + ) + @extend_schema( summary="Get the preferences of one account", description=( From 7d839ab48e0cda921fe0136afa226dea4615fb9e Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sat, 31 Oct 2026 20:37:08 +0500 Subject: [PATCH 19/82] docs: record where the retirement of a re-used address is read differently An address can have been held and left more than once, so more than one retirement of it can sit inside the cooling-off period. This version reports the most recent, being the only one a caller could still undo; the superseded version reads the same window with a lookup that takes a single record and so fails outright on the second. The window boundaries are the same in both. The selection is on a path declared to answer as the superseded one answers, and this is the one input they answer differently for, so the selector now says so where the next reader of it will be. --- .../djangoapps/profile_images/v2/views.py | 2 +- .../core/djangoapps/user_api/v2/selectors.py | 7 ++++++ .../user_api/v2/tests/test_account_views.py | 22 ++++++++++--------- .../v2/tests/test_name_change_views.py | 4 ++-- .../user_api/v2/tests/test_selectors.py | 2 +- openedx/core/djangoapps/user_api/v2/views.py | 2 +- 6 files changed, 24 insertions(+), 15 deletions(-) diff --git a/openedx/core/djangoapps/profile_images/v2/views.py b/openedx/core/djangoapps/profile_images/v2/views.py index fc285cfa0496..6c5cb445a551 100644 --- a/openedx/core/djangoapps/profile_images/v2/views.py +++ b/openedx/core/djangoapps/profile_images/v2/views.py @@ -24,7 +24,6 @@ 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.views import AMBIGUOUS_ACCOUNT_ADDRESS, AccountResourceMixin from openedx.core.djangoapps.user_api.v2.serializers import ErrorResponse from openedx.core.djangoapps.user_api.v2.view_utils import ( RESP_UNAUTHENTICATED, @@ -34,6 +33,7 @@ StrictQueryParametersMixin, path_param, ) +from openedx.core.djangoapps.user_api.v2.views import AMBIGUOUS_ACCOUNT_ADDRESS, AccountResourceMixin from openedx.core.lib.api.parsers import TypedFileUploadParser from ..exceptions import ImageValidationError diff --git a/openedx/core/djangoapps/user_api/v2/selectors.py b/openedx/core/djangoapps/user_api/v2/selectors.py index d4c75611b71a..3216134f0c71 100644 --- a/openedx/core/djangoapps/user_api/v2/selectors.py +++ b/openedx/core/djangoapps/user_api/v2/selectors.py @@ -70,6 +70,13 @@ def cancellable_retirement_for_email(email): period runs, so a retirement older than that is not reported here: the address is gone either way, and only a retirement inside the period can be undone by asking the platform to undo it. + + One address can have been held and left more than once, so more than one + retirement of it can sit inside the period. The most recent is the one + reported, being the only one still open to being undone. This is the one + place where the answer differs from the superseded version, which reads + the same window with a lookup that takes a single record and so fails + outright on the second. """ now = datetime.datetime.now(ZoneInfo("UTC")) return ( 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 index 05046290552b..b5817375aaf3 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py @@ -29,9 +29,9 @@ from openedx.core.djangoapps.user_api.accounts.utils import ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH from openedx.core.djangoapps.user_api.models import RetirementState, UserRetirementStatus from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory +from openedx.core.djangoapps.user_api.v2 import views from openedx.core.djangoapps.user_api.v2.permissions import ACCOUNT_CONTACT_SELECTORS from openedx.core.djangoapps.user_api.v2.serializers import ACCOUNT_DEFAULT_SORT, AccountQuerySerializer -from openedx.core.djangoapps.user_api.v2 import views from openedx.core.djangoapps.user_api.v2.views import AMBIGUOUS_ACCOUNT_ADDRESS, EmailSearchViewSet from openedx.core.djangoapps.user_authn.views.password_reset import PasswordResetFormNoActive from openedx.core.djangolib.testing.utils import skip_unless_lms @@ -738,7 +738,7 @@ def stored_under_a_name_written_further_apart(request, usernames=None, **kwargs) def test_a_member_address_the_lookup_resolves_to_nothing_is_reported_as_absent(self): """A lookup that comes back with no account is an absent address rather than a fault.""" self.login(self.staff) - with mock.patch(ACCOUNT_LOOKUP, lambda *args, **kwargs: []): + with mock.patch(ACCOUNT_LOOKUP, return_value=[]): response = self.client.get(self.detail_url("owner-caller")) assert_error_envelope( response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", @@ -2094,7 +2094,7 @@ def test_a_deactivation_whose_address_names_more_than_one_account_deactivates_no self.login(self.deactivator) with mock.patch( "openedx.core.djangoapps.user_api.v2.views.accounts_named", - lambda name: [self.other, self.staff], + return_value=[self.other, self.staff], ): envelope = assert_error_envelope( self.client.post(address), @@ -2111,7 +2111,7 @@ def test_a_deactivation_lands_on_the_account_stored_under_the_name_addressed(sel self.login(self.deactivator) with mock.patch( "openedx.core.djangoapps.user_api.v2.views.accounts_named", - lambda name: [self.other, self.owner], + return_value=[self.other, self.owner], ): assert self.client.post(address).status_code == status.HTTP_204_NO_CONTENT self.owner.refresh_from_db() @@ -2225,9 +2225,10 @@ def test_a_rule_the_resource_refuses_every_caller_by_refuses_every_read_of_one_a self.login(self.owner) with refusing_account_resource(): for case, send in self.reads().items(): - assert_error_envelope( - send(), expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", - ) + with self.subTest(case=case): + assert_error_envelope( + send(), expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) def test_every_change_to_one_account_reaches_its_handler_while_the_resource_admits_the_caller(self): """ @@ -2250,9 +2251,10 @@ def test_a_rule_the_resource_refuses_every_caller_by_refuses_every_change_to_one self.login(self.owner) with refusing_account_resource(): for case, send in self.writes().items(): - assert_error_envelope( - send(), expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", - ) + with self.subTest(case=case): + assert_error_envelope( + send(), expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) assert self.has_image() is True def test_a_rule_the_resource_refuses_every_caller_by_refuses_a_deactivation(self): 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 index bb62d4a2fb21..4321e5a49e33 100644 --- 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 @@ -465,7 +465,7 @@ def test_an_address_naming_more_than_one_account_changes_nothing(self): self.login(self.staff) with mock.patch( "openedx.core.djangoapps.user_api.v2.views.accounts_named", - lambda name: [sibling, self.other], + return_value=[sibling, self.other], ): envelope = assert_error_envelope( self.client.post(self.url), @@ -488,7 +488,7 @@ def test_the_account_stored_under_the_name_addressed_is_the_one_changed(self): self.login(self.staff) with mock.patch( "openedx.core.djangoapps.user_api.v2.views.accounts_named", - lambda name: [sibling, self.owner], + return_value=[sibling, self.owner], ): assert self.client.post(self.url).status_code == status.HTTP_204_NO_CONTENT assert self.profile_name(self.owner) == "Jon Doe" diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_selectors.py b/openedx/core/djangoapps/user_api/v2/tests/test_selectors.py index e98aa70c86e7..3cb4af2fbc9b 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_selectors.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_selectors.py @@ -11,8 +11,8 @@ accounts_holding_emails, cancellable_retirement_for_email, username_holding_id, - usernames_holding_email, usernames_among, + usernames_holding_email, ) from openedx.core.djangolib.testing.utils import skip_unless_lms diff --git a/openedx/core/djangoapps/user_api/v2/views.py b/openedx/core/djangoapps/user_api/v2/views.py index d5690825c307..40514988ab12 100644 --- a/openedx/core/djangoapps/user_api/v2/views.py +++ b/openedx/core/djangoapps/user_api/v2/views.py @@ -21,8 +21,8 @@ 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.metadata import SimpleMetadata +from rest_framework.parsers import JSONParser from rest_framework.response import Response from rest_framework.reverse import reverse from rest_framework.settings import api_settings From c80f785aabf00d87114d82bd40a48c247b830a09 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sat, 31 Oct 2026 21:22:50 +0500 Subject: [PATCH 20/82] test: guard the record-visibility layer of the two queryset-backed user collections --- .../user_api/v2/tests/test_views.py | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_views.py index e30730c11d8a..1e4b6f7cf3a6 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_views.py @@ -3,8 +3,10 @@ import re from datetime import UTC, datetime from math import ceil +from unittest.mock import patch from django.contrib.auth.models import User # pylint: disable=imported-auth-user +from django.core.exceptions import ImproperlyConfigured from django.db import connection from django.test import SimpleTestCase from django.test.utils import CaptureQueriesContext @@ -22,6 +24,7 @@ from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory from openedx.core.djangoapps.user_api.v2.filters import UserFilterSet from openedx.core.djangoapps.user_api.v2.serializers import UserPreferenceRecordSerializer +from openedx.core.djangoapps.user_api.v2.views import UserPreferenceViewSet, UserViewSet from openedx.core.djangolib.testing.utils import skip_unless_lms PASSWORD = "Password1234" @@ -1489,3 +1492,122 @@ def test_url_is_relative_without_a_request(self): data = UserPreferenceRecordSerializer(preference).data assert data["url"] == "/api/user/v2/user_preferences/alice,pref-lang/" assert data["user"] is None + + +class RecordingScopePolicy: + """A record-visibility policy that drops one owner's rows and records each call.""" + + def __init__(self, hidden_username): + self.hidden_username = hidden_username + self.calls = [] + + def scope(self, queryset, subject): + """Return the rows of ``queryset`` that do not belong to the hidden owner.""" + self.calls.append((queryset.model, subject)) + owner_field = "username" if queryset.model is User else "user__username" + return queryset.exclude(**{owner_field: self.hidden_username}) + + +@skip_unless_lms +class QuerysetScopingSeamTests(UserApiV2TestCase): + """ + The record-visibility layer of the two collections backed by a queryset. + + Each of these collections declares a policy that decides which rows a + caller may see, and the rows it leaves out are absent from the answer + rather than refused. The policy that ships keeps every row, because only + platform administrators reach these collections at all, so these tests + put a narrowing policy in its place and read the answer: a collection + that stopped consulting its policy would hand back the rows the policy + left out, and a collection that no longer declared one would narrow + nothing whatever policy it was given. + """ + + def scoped(self, view_class, hidden_username): + """Run the view class with a policy that hides one owner's rows.""" + policy = RecordingScopePolicy(hidden_username) + return patch.object(view_class, "scoping_policy", policy, create=True), policy + + def test_user_list_leaves_out_the_rows_its_policy_excludes(self): + self.login(self.admin) + patched, policy = self.scoped(UserViewSet, "alice") + with patched: + response = self.client.get(reverse("user_v2:user_list")) + assert response.status_code == status.HTTP_200_OK + assert self.usernames_in(response) == ["admin-caller", "bob", "learner-caller"] + assert policy.calls == [(User, self.admin)] + + def test_user_detail_of_an_excluded_row_is_not_found(self): + self.login(self.admin) + patched, _ = self.scoped(UserViewSet, "alice") + with patched: + response = self.client.get(reverse("user_v2:user_detail", kwargs={"username": "alice"})) + assert_error_envelope( + response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + + def test_user_detail_of_an_included_row_is_returned(self): + self.login(self.admin) + patched, _ = self.scoped(UserViewSet, "alice") + with patched: + response = self.client.get(reverse("user_v2:user_detail", kwargs={"username": "bob"})) + assert response.status_code == status.HTTP_200_OK + assert response.data["username"] == "bob" + + def test_preference_list_leaves_out_the_rows_its_policy_excludes(self): + self.login(self.admin) + patched, policy = self.scoped(UserPreferenceViewSet, "alice") + with patched: + response = self.client.get(reverse("user_v2:user_preference_list")) + assert response.status_code == status.HTTP_200_OK + assert [(row["username"], row["key"]) for row in response.data["results"]] == [("bob", "time_zone")] + assert policy.calls == [(UserPreference, self.admin)] + + def test_preference_detail_of_an_excluded_row_is_not_found(self): + self.login(self.admin) + patched, _ = self.scoped(UserPreferenceViewSet, "alice") + with patched: + response = self.client.get( + reverse("user_v2:user_preference_detail", kwargs={"username": "alice", "key": "pref-lang"}), + ) + assert_error_envelope( + response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + + def test_preference_detail_of_an_included_row_is_returned(self): + self.login(self.admin) + patched, _ = self.scoped(UserPreferenceViewSet, "alice") + with patched: + response = self.client.get( + reverse("user_v2:user_preference_detail", kwargs={"username": "bob", "key": "time_zone"}), + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["value"] == "UTC" + + def test_each_scoped_collection_declares_a_policy_of_its_own(self): + """ + Each of these two views names the policy that governs its rows. + + A view that inherited the default instead of naming one would be left + with no policy at all, so the declaration is read from the view's own + attributes rather than from anything it inherits. + """ + for view_class in (UserViewSet, UserPreferenceViewSet): + declared = vars(view_class).get("scoping_policy") + assert declared is not None, f"{view_class.__name__} declares no record-visibility policy" + assert callable(getattr(declared, "scope", None)), ( + f"{view_class.__name__} declares a record-visibility policy that cannot narrow rows" + ) + + def test_the_rows_are_fetched_through_the_declared_policy(self): + """ + The policy is what the rows are fetched through, not a step that can be skipped. + + A view left without a usable policy hands back no rows at all rather + than every row, which is how a collection that stopped consulting its + policy is told apart from one whose policy happens to keep every row. + """ + for view_class in (UserViewSet, UserPreferenceViewSet): + with patch.object(view_class, "scoping_policy", None, create=True): + with self.assertRaises(ImproperlyConfigured): + view_class().get_queryset() From d822e5f2ed545e11de9b40b5990d7f9832b1dd49 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sat, 31 Oct 2026 21:33:10 +0500 Subject: [PATCH 21/82] fix: describe each address of this version to the caller that asks, not to a maintainer --- .../djangoapps/profile_images/v2/views.py | 22 +- .../user_api/v2/tests/test_views.py | 94 +++++++ openedx/core/djangoapps/user_api/v2/views.py | 235 ++++++++++-------- 3 files changed, 232 insertions(+), 119 deletions(-) diff --git a/openedx/core/djangoapps/profile_images/v2/views.py b/openedx/core/djangoapps/profile_images/v2/views.py index 6c5cb445a551..ed0100958bdb 100644 --- a/openedx/core/djangoapps/profile_images/v2/views.py +++ b/openedx/core/djangoapps/profile_images/v2/views.py @@ -116,18 +116,20 @@ class ProfileImageView( """ 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. + A user sets and clears the image on their own account and nobody else's, + staff included. Setting an image replaces one already set, and clearing an + account that carries no image of its own is not an error. The addresses + the stored sizes are served from are carried by the account itself. """ + # 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 one + # address rather than routed as a collection. Clearing an image is an + # account change rather than a moderation tool, so staff are not admitted + # to somebody else's; 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 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 the session class differs in what it diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_views.py index 1e4b6f7cf3a6..20c25893ba4a 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_views.py @@ -22,6 +22,7 @@ from openedx.core.djangoapps.user_api.models import UserPreference from openedx.core.djangoapps.user_api.preferences.api import get_country_time_zones from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory +from openedx.core.djangoapps.user_api.v2 import urls as v2_urls from openedx.core.djangoapps.user_api.v2.filters import UserFilterSet from openedx.core.djangoapps.user_api.v2.serializers import UserPreferenceRecordSerializer from openedx.core.djangoapps.user_api.v2.views import UserPreferenceViewSet, UserViewSet @@ -1611,3 +1612,96 @@ def test_the_rows_are_fetched_through_the_declared_policy(self): with patch.object(view_class, "scoping_policy", None, create=True): with self.assertRaises(ImproperlyConfigured): view_class().get_queryset() + + +def routed_view_classes(): + """Return every view class this version mounts, by the name it is routed under.""" + classes = {} + for entry in v2_urls.urlpatterns: + view_class = getattr(entry.callback, "cls", None) + if view_class is not None: + classes[view_class.__name__] = view_class + return classes + + +#: Words that name how an address is built rather than what it answers. A +#: description carrying one of these was written for somebody reading this +#: module, and the body of an OPTIONS request is not where they will read it. +IMPLEMENTATION_TERMS = ( + "ViewSet", + "queryset", + "permission_classes", + "serializer", + "filter set", + "filter backend", + "mixin", + "scoping policy", + "router", + "selectors", + "class attribute", +) + +#: The longest description an address gives of itself. The client-facing text +#: is a short paragraph; the maintainer-facing text these replaced ran to four. +DESCRIPTION_LIMIT = 700 + + +@skip_unless_lms +class AddressSelfDescriptionTests(UserApiV2TestCase): + """ + What each address of this version says about itself to a caller that asks. + + An OPTIONS request is answered with the description the view class + carries, so that description is published text and is read by clients and + by documentation browsers alongside the description of the same address in + the schema. These tests hold it to being written for the caller: no markup + that renders as itself in a JSON body, no vocabulary from the + implementation, and no essay. + """ + + def test_every_mounted_address_is_described(self): + """Each view this version mounts says something about itself.""" + described = { + name: view_class().get_view_description() + for name, view_class in routed_view_classes().items() + } + assert len(described) == 13, sorted(described) + assert [name for name, text in described.items() if not text.strip()] == [] + + def test_no_address_describes_itself_in_implementation_terms(self): + offenders = { + name: [term for term in IMPLEMENTATION_TERMS if term in view_class().get_view_description()] + for name, view_class in routed_view_classes().items() + } + assert {name: terms for name, terms in offenders.items() if terms} == {} + + def test_no_address_describes_itself_with_markup_a_caller_sees_as_itself(self): + """Double backticks are markup in this module's own documentation and text in a JSON body.""" + carrying_markup = [ + name for name, view_class in routed_view_classes().items() + if "``" in view_class().get_view_description() + ] + assert carrying_markup == [] + + def test_no_address_describes_itself_at_length(self): + too_long = { + name: len(view_class().get_view_description()) + for name, view_class in routed_view_classes().items() + if len(view_class().get_view_description()) > DESCRIPTION_LIMIT + } + assert too_long == {} + + def test_options_answers_with_the_description_the_view_carries(self): + """ + The description a view carries is the one a caller is handed. + + This is what makes the text above published rather than internal: the + framework answers an OPTIONS request with it verbatim. + """ + self.login(self.admin) + answer = self.client.options(reverse("user_v2:user_list")) + assert answer.status_code == status.HTTP_200_OK + assert answer.json()["description"] == UserViewSet().get_view_description() + assert answer.json()["description"].startswith( + "Directory entries of the platform's users, each addressed by username.", + ) diff --git a/openedx/core/djangoapps/user_api/v2/views.py b/openedx/core/djangoapps/user_api/v2/views.py index 40514988ab12..42280780abfb 100644 --- a/openedx/core/djangoapps/user_api/v2/views.py +++ b/openedx/core/djangoapps/user_api/v2/views.py @@ -623,14 +623,19 @@ class UserViewSet( viewsets.ReadOnlyModelViewSet, ): """ - Read-only directory of platform users, addressed by username. + Directory entries of the platform's users, each 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. + An entry carries the identifying properties of one account together with + the preferences stored against it. Only a platform administrator whose own + account is still active may read this directory. """ + # Who may read the directory is settled in three layers, each of which can + # be replaced on its own: the permission classes below settle who may call + # the collection at all, the record-visibility policy settles which rows + # such a caller may see, and the filter set narrows those rows by the + # parameters the request carries. + permission_classes = (permissions.IsAuthenticated, IsActiveUser, permissions.IsAdminUser) serializer_class = UserDirectorySerializer pagination_class = UserApiPagination @@ -715,14 +720,19 @@ class UserPreferenceViewSet( viewsets.ReadOnlyModelViewSet, ): """ - Read-only list of stored user preferences, each addressed by its owner and key. + Preferences stored for the platform's users, each addressed by its owner and its name. - 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. + A preference is identified by the two together, so a member address + carries the owner's username, a comma and the name the preference is + stored under, as in alice,time_zone. Only a platform administrator whose + own account is still active may read these. """ + # The pair identifies a row, so the member address is built from it rather + # than from a database id, and the route that serves it is declared with + # the collection rather than generated. Who may read these rows is settled + # in the same three layers as the user directory. + permission_classes = (permissions.IsAuthenticated, IsActiveUser, permissions.IsAdminUser) serializer_class = UserPreferenceRecordSerializer pagination_class = UserApiPagination @@ -784,12 +794,14 @@ class CountryTimeZoneViewSet( """ 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. + The list is the same for every caller and is open to anyone. """ + # The rows come from the time-zone database rather than from a table of + # this app's, so there is no queryset to narrow and the one parameter the + # view reads is validated by a serializer instead of a filter set. The + # data is reference data, which is why no caller is turned away. + permission_classes = (permissions.AllowAny,) serializer_class = TimeZoneSerializer #: Validates the query string, and names the parameters this view reads. @@ -957,38 +969,34 @@ class AccountViewSet( 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. + Accounts of the platform's users. Address one by username, or me for your own. + + An account carries the properties the caller may see: all of them for your + own account and for a staff caller, and otherwise the ones its owner + shares. A property an owner does not share is left out of their account + rather than refused, so an account can come back as an empty object. + + Selecting accounts that nothing holds gives an empty page rather than a + refusal. An address an account held before it was retired is refused + instead, and that refusal says whether the retirement can still be + cancelled. + + A property no account of this kind could show this caller is refused + rather than answered as an empty object. """ + # The properties of an account are read from the account service rather + # than from a table of this app's, so there is no queryset to narrow and + # the page is cut from the names the selection resolved to. The service is + # what settles which properties of each account the caller may see; the + # selection settles which accounts the page carries, and is applied in the + # handler rather than declared as a filter set because a filter set has no + # room for the retired-address refusal, which reports rows it cannot carry + # instead of leaving them out. Which properties the answer can carry is + # settled before the address is read, so a request for a property this + # kind of answer never carries is refused without reading or writing + # anything on the way to it. + # 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 the session class differs in @@ -1297,18 +1305,20 @@ class AccountDeactivationView( 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. + Disables the account the address names, so its owner can no longer sign in with it. + + The account and everything belonging to it are kept, and none of it is + removed. Asking for a password reset does not undo this. """ + # Disabling an account is something a privileged caller does to somebody + # else's account rather than a property of that account, and it shares + # neither its callers nor its body with the way an account is read or + # changed: the caller holds the privilege to disable accounts and has + # confirmed their own address, and the body is read as JSON rather than as + # a merge patch. It is therefore a view of its own rather than an action + # of the account collection. + permission_classes = (permissions.IsAuthenticated, IsActiveUser, CanDeactivateUser) def post(self, request, username): @@ -1374,17 +1384,20 @@ class EmailSearchViewSet( """ 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. + The addresses to look for are sent in the request body, so a search is + made with POST, and the links on a page of results are followed by sending + the body to them again rather than by fetching them. Only a platform + administrator may search here; every other caller is told there is nothing + at this address. """ + # The addresses are sent in a body so that they stay out of server logs + # and browser histories, which is what makes the search a POST and what + # makes its pages be followed with a body. The search stands beside the + # accounts rather than under them because what follows the account + # collection in an address is the name of an account, and a user may + # register any name. + permission_classes = ( permissions.IsAuthenticated, IsActiveUser, @@ -1567,18 +1580,19 @@ class IDVerificationStatusView( """ 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. + Every account has a summary, and one belonging to a user who has been + through no check says so; the address is absent only when the user it + names is. A user may read their own summary, and a staff caller whose own + account is still active may read anyone's. """ + # The summary is computed rather than stored and is never listed, so it is + # served from this one address rather than routed as a collection, and it + # is read from the identity verification service rather than from a table + # of this app's. It admits a different set of callers from the account + # collection, which is why it is a view of its own rather than an action + # of that collection. + permission_classes = ( permissions.IsAuthenticated, # Reading somebody else's summary is a privilege, so the staff half @@ -1652,16 +1666,18 @@ class IDVerificationViewSet( """ 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. + 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. """ + # 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 in + # the view rather than by a filter backend. An attempt is reached by no + # route other than this one, which is why the collection is addressed + # beneath the account it belongs to. + permission_classes = ( permissions.IsAuthenticated, # Reading somebody else's attempts is a privilege, so the staff half @@ -1768,27 +1784,26 @@ class AccountPreferenceViewSet( """ The preferences of the one account an address names. - The preferences are read and written through the preference service rather - than from a queryset of this app's, so this is a plain ViewSet. They are - one document: an object of preference name to stored value, read and - merge-patched whole, which is how the pages that keep a user's settings - read and save them. It is not a list of records and carries no pages; the - record-shaped view of the same data, across accounts, is the stored - preference collection of this version. - - Which account the answer is about is settled by the name in the address - alone: a user reads and changes their own preferences, and a staff caller - whose own account is active reads anyone's but changes nobody else's. The - name is read as it is stored, so ``me`` names an account holding that name - rather than the account of the caller. - - A name is read as the whole segment of the address, so the names carrying - punctuation that the platform's own features and the profile pages store - under can be addressed. A name carrying a slash cannot, because the - segment ends there; the document reads and removes such a name, which is - also the only way one arrives. + They are one document: an object of preference name to stored value, read + whole and changed with a merge patch. Each preference is also reached on + its own. A user reads and changes their own preferences, and a staff + caller whose own account is still active reads anyone's but changes + nobody else's. + + A preference name takes the whole of its segment of the address, so the + names carrying punctuation that the platform's own features store under + can be addressed. A name carrying a slash cannot, because the segment ends + there; such a name is read and removed through the whole document, which + is also the only way one arrives. """ + # The preferences are read and written through the preference service + # rather than from a table of this app's, so there is no queryset here. + # Treating them as one document is what the pages that keep a user's + # settings read and save; this collection carries no pages of its own, and + # the record-shaped view of the same data across accounts is the stored + # preference collection of this version. + # The language a user picked is written from every page of the site, and # the settings page these preferences are kept on is reached by users who # have not confirmed their email address yet, whom the platform default @@ -2091,13 +2106,15 @@ class EmailOptInViewSet( The choices users have made about the messages organizations send them. A choice belongs to the user who made it, so it is recorded against the - caller and no username is read from the address, the body or the query - string. The organization it applies to is the one running the course the - body names, which is how the pages a user makes the choice on identify it. - The choice is held per organization rather than per course, and asking for - one course's organization twice records one choice. + caller and no username is named anywhere in the request. The organization + it applies to is the one running the course the body names. The choice is + held per organization rather than per course, so naming two courses of one + organization records one choice. """ + # Naming the course rather than the organization is how the pages a user + # makes this choice on identify the organization they are making it about. + # The choice is made while enrolling, which a user reaches before they have # confirmed their email address, and the platform default refuses a session # belonging to such an account. Only the session class differs in what it @@ -2133,11 +2150,11 @@ def create(self, request): @extend_schema(exclude=True) class UnknownAddressView(StandardizedErrorMixin, APIView): """ - Refuses every address under this version of the API that no route serves. + Refuses an 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. + The refusal carries the same JSON body as every other failure of this + version, rather than the service's HTML error page, which a client that + asked for JSON cannot read. """ permission_classes = (permissions.AllowAny,) From a0304d10c0931fc5a39f03a56bbc2d38a30a6ad3 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sat, 31 Oct 2026 21:36:25 +0500 Subject: [PATCH 22/82] fix: refuse a selected property name that reaches into a property --- .../user_api/v2/tests/test_account_views.py | 51 ++++++++++++++++ .../user_api/v2/tests/test_views.py | 60 +++++++++++++++++++ openedx/core/djangoapps/user_api/v2/views.py | 14 ++++- 3 files changed, 122 insertions(+), 3 deletions(-) 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 index b5817375aaf3..69f3c6b38a01 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py @@ -3026,3 +3026,54 @@ class AccountReadsDoNotWriteTests(AccountReadsDoNotWriteMixin, AccountApiV2TestC @override_switch(ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH, active=True) class AccountReadsWithARecoveryAddressDoNotWriteTests(AccountReadsDoNotWriteMixin, AccountApiV2TestCase): """The same reads on a site that collects a recovery address, which is another row to read.""" + + +@skip_unless_lms +class AccountSelectedPropertyNameTests(AccountApiV2TestCase): + """ + What a selected property name may be at the account addresses. + + An account carries properties that are objects of their own. A name + written with a dot in it reads as a name of no property the account + carries, because the account carries the object whole and the parameter + selects the properties of the account rather than the properties of those + objects. + """ + + def test_a_name_reaching_into_a_property_is_refused_on_one_account(self): + self.login(self.owner) + response = self.client.get(f"{self.own_url}?fields=profile_image.has_image") + assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert response.json()["errors"]["fields"][0].startswith( + "No such field: profile_image.has_image. Select any of: ", + ) + + def test_a_name_reaching_into_a_property_is_refused_on_the_account_list(self): + self.login(self.owner) + response = self.client.get(f"{self.list_url}?fields=profile_image.image_url_full") + assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert response.json()["errors"]["fields"][0].startswith( + "No such field: profile_image.image_url_full. Select any of: ", + ) + + def test_a_name_reaching_into_a_property_is_refused_on_a_change(self): + self.login(self.owner) + response = self.patch(f"{self.own_url}?fields=profile_image.has_image", json.dumps({"bio": "a bio"})) + assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert UserProfile.objects.get(user=self.owner).bio != "a bio" + + def test_naming_the_property_itself_returns_the_whole_object(self): + """The object is what the account carries, so naming it brings all of it back.""" + self.login(self.owner) + response = self.client.get(f"{self.own_url}?fields=profile_image") + assert response.status_code == status.HTTP_200_OK + assert sorted(response.json()) == ["profile_image"] + assert sorted(response.json()["profile_image"]) == [ + "has_image", "image_url_full", "image_url_large", "image_url_medium", "image_url_small", + ] diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_views.py index 20c25893ba4a..803a439650a7 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_views.py @@ -1705,3 +1705,63 @@ def test_options_answers_with_the_description_the_view_carries(self): assert answer.json()["description"].startswith( "Directory entries of the platform's users, each addressed by username.", ) + + +@skip_unless_lms +class SelectedPropertyNameTests(UserApiV2TestCase): + """ + What a selected property name may be, at the collections that take one. + + The parameter takes the names of properties the answer carries. A name + written with a dot in it names no such property: the answer carries the + property the name starts with, whole, and the rest of the name selects + nothing. Reading such a name as its first part would hand a caller the + whole of a property it asked one piece of, and would accept a second part + that names nothing at all. + """ + + def setUp(self): + super().setUp() + self.list_url = reverse("user_v2:user_list") + self.member_url = reverse("user_v2:user_detail", kwargs={"username": "alice"}) + self.preference_list_url = reverse("user_v2:user_preference_list") + self.login(self.admin) + + def test_a_name_written_with_a_dot_is_refused_on_the_directory_list(self): + response = self.client.get(f"{self.list_url}?fields=username.first") + assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert response.json()["errors"]["fields"] == [ + "No such field: username.first. Select any of: email, id, name, preferences, url, " + "username.", + ] + + def test_a_name_written_with_a_dot_is_refused_on_a_directory_entry(self): + response = self.client.get(f"{self.member_url}?fields=preferences.pref-lang") + assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert "No such field: preferences.pref-lang." in response.json()["errors"]["fields"][0] + + def test_a_name_written_with_a_dot_is_refused_on_the_preference_list(self): + response = self.client.get(f"{self.preference_list_url}?fields=user.email") + assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert "No such field: user.email." in response.json()["errors"]["fields"][0] + + def test_a_plain_name_still_selects_that_property_alone(self): + response = self.client.get(f"{self.member_url}?fields=username") + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"username": "alice"} + + def test_a_plain_name_still_selects_that_property_alone_on_a_list(self): + response = self.client.get(f"{self.list_url}?fields=username,email&ordering=username") + assert response.status_code == status.HTTP_200_OK + assert response.json()["results"] == [ + {"username": "admin-caller", "email": self.admin.email}, + {"username": "alice", "email": "alice@example.com"}, + {"username": "bob", "email": "bob@example.com"}, + {"username": "learner-caller", "email": self.learner.email}, + ] diff --git a/openedx/core/djangoapps/user_api/v2/views.py b/openedx/core/djangoapps/user_api/v2/views.py index 42280780abfb..4aaa9e324d38 100644 --- a/openedx/core/djangoapps/user_api/v2/views.py +++ b/openedx/core/djangoapps/user_api/v2/views.py @@ -507,10 +507,18 @@ def reverse_action(self, url_name, *args, **kwargs): def _requested_field_names(raw): - """Return the top-level property names a ``fields`` value asks for.""" + """ + Return the property names a ``fields`` value asks for, each as it was written. + + A name is held against the properties the answer carries whole, so a name + written with a dot in it names no such property and is refused. Reading + only the part before the dot would hand back the whole of a property the + caller asked one piece of, and would take a part after it that names + nothing at all without saying so. + """ if not raw: return [] - return [name.strip().split(".", 1)[0] for name in raw.split(",") if name.strip()] + return [name.strip() for name in raw.split(",") if name.strip()] class _ResponsePresetMixin(MinimalViewMixin): @@ -568,7 +576,7 @@ def check_requested_fields(self, request): 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) + requested_fields = _requested_field_names(request.query_params.get(FIELDS_PARAM)) if not requested_fields: return data if isinstance(data, list): From 225cefa37f396c05e20101126e484ffc145e5478 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sat, 31 Oct 2026 21:39:07 +0500 Subject: [PATCH 23/82] fix: test and describe the rate limit on changing a recovery address --- .../user_api/v2/tests/test_account_views.py | 61 +++++++++++++++++++ openedx/core/djangoapps/user_api/v2/views.py | 11 +++- 2 files changed, 70 insertions(+), 2 deletions(-) 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 index 69f3c6b38a01..75c02e0ff42c 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py @@ -3077,3 +3077,64 @@ def test_naming_the_property_itself_returns_the_whole_object(self): assert sorted(response.json()["profile_image"]) == [ "has_image", "image_url_full", "image_url_large", "image_url_medium", "image_url_small", ] + + +@skip_unless_lms +@override_switch(ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH, active=True) +class RecoveryAddressRateLimitedTests(AccountApiV2TestCase): + """A site that limits how often a recovery address may be changed.""" + + @override_settings( + SECONDARY_EMAIL_RATE_LIMIT="1/d", CACHES=counting_cache("recovery-address-refused"), + ) + def test_changing_a_recovery_address_too_often_is_refused(self): + self.login(self.owner) + first = self.patch(self.own_url, json.dumps({"secondary_email": "owner-recovery@example.com"})) + assert first.status_code == status.HTTP_200_OK + + second = self.patch(self.own_url, json.dumps({"secondary_email": "owner-other@example.com"})) + envelope = assert_error_envelope( + second, expected_status=status.HTTP_429_TOO_MANY_REQUESTS, expected_type_slug="rate-limited", + ) + assert envelope["detail"] == "Wait before changing secondary_email again." + + @override_settings( + SECONDARY_EMAIL_RATE_LIMIT="1/d", CACHES=counting_cache("recovery-address-allowed"), + ) + def test_the_limit_leaves_a_change_of_the_sign_in_address_alone(self): + """Each property is counted under a name of its own, so one limit does not spend another.""" + self.login(self.owner) + assert self.patch( + self.own_url, json.dumps({"secondary_email": "owner-recovery@example.com"}), + ).status_code == status.HTTP_200_OK + later = self.patch(self.own_url, json.dumps({"email": "owner-signin@example.com"})) + assert later.status_code == status.HTTP_200_OK + + +@skip_unless_lms +@override_switch(ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH, active=True) +@override_settings(CACHES=counting_cache("recovery-address-unlimited")) +class RecoveryAddressUnlimitedTests(AccountApiV2TestCase): + """ + A site that has given no limit, which is how the platform ships. + + Neither of the two properties that can be limited carries a limit out of + the box, so the refusal this endpoint publishes for changing one too often + is answered only by a deployment that has given the matching setting a + value. That is recorded here so that the published refusal is not read as + something every deployment produces. + """ + + def test_the_platform_default_gives_no_limit(self): + assert settings.SECONDARY_EMAIL_RATE_LIMIT == "" + assert settings.EMAIL_CHANGE_RATE_LIMIT == "" + + def test_a_recovery_address_may_be_changed_again_and_again(self): + self.login(self.owner) + answered = [] + for attempt in range(3): + response = self.patch( + self.own_url, json.dumps({"secondary_email": f"owner-recovery-{attempt}@example.com"}), + ) + answered.append(response.status_code) + assert answered == [status.HTTP_200_OK] * 3 diff --git a/openedx/core/djangoapps/user_api/v2/views.py b/openedx/core/djangoapps/user_api/v2/views.py index 4aaa9e324d38..4408c5c0f208 100644 --- a/openedx/core/djangoapps/user_api/v2/views.py +++ b/openedx/core/djangoapps/user_api/v2/views.py @@ -174,7 +174,9 @@ SHAPED_ACCOUNT_ACTIONS = ("list", "retrieve", "partial_update") #: The properties whose change is limited to a rate, and the setting carrying -#: the limit of each. +#: the limit of each. Both settings are empty until a deployment gives one of +#: them a value, and an empty limit limits nothing, so a site that has +#: configured neither takes every change to either property. RATE_LIMITED_ACCOUNT_FIELDS = ( ("email", "EMAIL_CHANGE_RATE_LIMIT"), ("secondary_email", "SECONDARY_EMAIL_RATE_LIMIT"), @@ -273,7 +275,12 @@ def _ordering_param(sort_fields, default): ), ) _RESP_RATE_LIMITED = OpenApiResponse( - ErrorResponse, description="The caller has changed this property too often lately and has to wait.", + ErrorResponse, + description=( + "The caller has changed this property too often lately and has to wait. Only a site that " + "has given a limit for the property answers this; a site that has given none, which is " + "how the platform ships, takes every change." + ), ) _RESP_CHANGE_FAILED = OpenApiResponse( ErrorResponse, From 8fa3c51040ea1f431eba9063055c28ddf54e24b8 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sat, 31 Oct 2026 21:47:24 +0500 Subject: [PATCH 24/82] fix: describe the body a search is asked for in, not the one it answers with --- .../user_api/v2/tests/test_account_views.py | 54 +++++++++++++++++++ openedx/core/djangoapps/user_api/v2/views.py | 18 +++++-- 2 files changed, 68 insertions(+), 4 deletions(-) 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 index 75c02e0ff42c..83cfe46f6fdb 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py @@ -2047,6 +2047,17 @@ def test_the_member_address_names_the_media_types_a_change_to_one_preference_is_ "multipart/form-data", ] + def test_a_change_built_from_the_described_body_is_carried_out(self): + """The description is taken at its word: what it names is a body the address reads.""" + self.login(self.owner) + described = self.client.options(self.member_url).json()["actions"]["PUT"] + response = self.client.put( + self.member_url, json.dumps({name: "Europe/Berlin" for name in described}), + content_type="application/json", + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert self.owner.preferences.get(key="time_zone").value == "Europe/Berlin" + def test_a_change_sent_in_a_media_type_the_answer_names_is_read(self): """ The answer is taken at its word, which is the only mechanism offered for it. @@ -3138,3 +3149,46 @@ def test_a_recovery_address_may_be_changed_again_and_again(self): ) answered.append(response.status_code) assert answered == [status.HTTP_200_OK] * 3 + + +@skip_unless_lms +class EmailSearchAddressDescriptionTests(AccountApiV2TestCase): + """What OPTIONS answers about the body a search is asked for in.""" + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:email_search_list") + + def test_the_address_names_the_body_a_search_is_sent_in(self): + """ + The body the answer describes is the one the address reads, not the one it returns. + + A search is asked for by sending the addresses to look for, and is + answered with an account per address found. Describing the answer here + would have a caller build its request out of the shape it is going to + receive, which this address does not read. + """ + self.login(self.staff) + answer = self.client.options(self.url) + assert answer.status_code == status.HTTP_200_OK + assert sorted(answer.json()["actions"]["POST"]) == ["emails"] + + def test_every_name_the_described_body_carries_is_one_the_search_needs(self): + """Leaving any one of the described names out is refused, so the description is the body.""" + self.login(self.staff) + described = self.client.options(self.url).json()["actions"]["POST"] + assert described + for omitted in described: + body = {name: ["owner-caller@example.com"] for name in described if name != omitted} + response = self.client.post(self.url, body, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST, omitted + + def test_a_search_built_from_the_described_body_is_answered(self): + """The description is taken at its word: what it names is a body the address reads.""" + self.login(self.staff) + described = self.client.options(self.url).json()["actions"]["POST"] + response = self.client.post( + self.url, {name: ["owner-caller@example.com"] for name in described}, format="json", + ) + assert response.status_code == status.HTTP_200_OK + assert [row["username"] for row in response.data["results"]] == ["owner-caller"] diff --git a/openedx/core/djangoapps/user_api/v2/views.py b/openedx/core/djangoapps/user_api/v2/views.py index 4408c5c0f208..e7fbeb4a2966 100644 --- a/openedx/core/djangoapps/user_api/v2/views.py +++ b/openedx/core/djangoapps/user_api/v2/views.py @@ -220,10 +220,12 @@ class AddressMetadata(SimpleMetadata): declared on the view, which belong to the view as a whole rather than to the address being asked about: an address that reads one media type is otherwise described as reading every media type the platform reads - anywhere, and a method whose handler reads no body is described as taking - the one the view's serializer carries. A caller that negotiates from this - description sends what it was told to send, so each half of it is answered - from the address and the method being described. + anywhere, a method whose handler reads no body is described as taking the + one the view's serializer carries, and a method whose body differs from + what the view answers with is described as taking the answer. A caller + that negotiates from this description sends what it was told to send, so + each part of it is answered from the address and the method being + described. """ def determine_metadata(self, request, view): @@ -237,6 +239,9 @@ def determine_actions(self, request, view): described = super().determine_actions(request, view) served = getattr(view, "action_map", None) or {} bodiless = set(getattr(view, "bodiless_actions", ())) + taken = getattr(view, "request_serializer_class", None) + if taken is not None: + described = {method: self.get_serializer_info(taken()) for method in described} return { method: fields for method, fields in described.items() if served.get(method.lower()) not in bodiless @@ -1425,7 +1430,12 @@ class EmailSearchViewSet( # table the other collections of this version narrow. scoping_policy = FullScopePolicy() parser_classes = (JSONParser,) + metadata_class = AddressMetadata serializer_class = EmailSearchResultSerializer + #: A search is asked for by sending the addresses to look for and is + #: answered with an account per address found, so the body it takes is not + #: the shape it answers with. + request_serializer_class = EmailSearchSerializer pagination_class = UserApiPagination def get_serializer(self, *args, **kwargs): From 291deb8aad93e18067a2f0e3bf9971c8b993bffb Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sat, 31 Oct 2026 21:49:18 +0500 Subject: [PATCH 25/82] fix: say both things that get a clearing of a profile image refused --- openedx/core/djangoapps/profile_images/v2/views.py | 6 +++++- openedx/core/djangoapps/user_api/v2/tests/test_schema.py | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/openedx/core/djangoapps/profile_images/v2/views.py b/openedx/core/djangoapps/profile_images/v2/views.py index ed0100958bdb..349e4e4189cc 100644 --- a/openedx/core/djangoapps/profile_images/v2/views.py +++ b/openedx/core/djangoapps/profile_images/v2/views.py @@ -204,7 +204,11 @@ def post(self, request, username): 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.", + ErrorResponse, + description=( + "The request carries a query parameter this endpoint does not read, or the " + "address names more than one account and so names none of them in particular." + ), ), 401: RESP_UNAUTHENTICATED, 403: _RESP_ANOTHER_USERS_IMAGE, diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py index 818db84fa7be..201ae2dacd09 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py @@ -1699,7 +1699,8 @@ def test_clearing_the_profile_image_publishes_that_it_reads_nothing(schema): 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." + "The request carries a query parameter this endpoint does not read, or the address names " + "more than one account and so names none of them in particular." ) From 2baf3853d23990261c30132818150dd5ba626f52 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sat, 31 Oct 2026 22:04:52 +0500 Subject: [PATCH 26/82] fix: publish the two refusals every address of this version could already answer --- .../djangoapps/profile_images/v2/views.py | 8 +- .../user_api/v2/tests/test_schema.py | 88 ++++++++++--- .../user_api/v2/tests/test_views.py | 119 +++++++++++++++++- openedx/core/djangoapps/user_api/v2/views.py | 51 ++++++-- 4 files changed, 234 insertions(+), 32 deletions(-) diff --git a/openedx/core/djangoapps/profile_images/v2/views.py b/openedx/core/djangoapps/profile_images/v2/views.py index 349e4e4189cc..864c9b52a9d3 100644 --- a/openedx/core/djangoapps/profile_images/v2/views.py +++ b/openedx/core/djangoapps/profile_images/v2/views.py @@ -33,7 +33,11 @@ StrictQueryParametersMixin, path_param, ) -from openedx.core.djangoapps.user_api.v2.views import AMBIGUOUS_ACCOUNT_ADDRESS, AccountResourceMixin +from openedx.core.djangoapps.user_api.v2.views import ( + AMBIGUOUS_ACCOUNT_ADDRESS, + RESP_NOT_ACCEPTABLE, + AccountResourceMixin, +) from openedx.core.lib.api.parsers import TypedFileUploadParser from ..exceptions import ImageValidationError @@ -169,6 +173,7 @@ class ProfileImageView( 401: RESP_UNAUTHENTICATED, 403: _RESP_ANOTHER_USERS_IMAGE, 404: _RESP_NO_PROFILE, + 406: RESP_NOT_ACCEPTABLE, 415: RESP_UNREADABLE_BODY, }, ) @@ -213,6 +218,7 @@ def post(self, request, username): 401: RESP_UNAUTHENTICATED, 403: _RESP_ANOTHER_USERS_IMAGE, 404: _RESP_NO_PROFILE, + 406: RESP_NOT_ACCEPTABLE, }, ) def delete(self, request, username): diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py index 201ae2dacd09..42bf850b6956 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py @@ -874,11 +874,11 @@ def test_every_list_publishes_the_page_that_does_not_exist(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"], + ACCOUNT_LIST_PATH: ["200", "400", "401", "403", "404", "406"], + VERIFICATION_LIST_PATH: ["200", "400", "401", "403", "404", "406"], + "/api/user/v2/users/": ["200", "400", "401", "403", "404", "406"], + "/api/user/v2/user_preferences/": ["200", "400", "401", "403", "404", "406"], + TIME_ZONE_LIST_PATH: ["200", "400", "404", "406"], } @@ -1196,14 +1196,14 @@ def test_every_property_the_change_publishes_a_null_for_is_cleared_by_one(client @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", + "200", "400", "401", "403", "404", "406", "415", "429", "500", ] assert schema["paths"][ACCOUNT_MEMBER_PATH]["patch"]["responses"]["500"]["description"] == ( "The change was asked for correctly and could not be carried out. Sending it again " "with something else in it does not help; sending the same request later may." ) assert sorted(schema["paths"][ACCOUNT_MEMBER_PATH]["get"]["responses"]) == [ - "200", "400", "401", "403", "404", + "200", "400", "401", "403", "404", "406", ] @@ -1272,7 +1272,7 @@ def test_the_search_publishes_the_addresses_it_reads_and_the_page_it_answers_wit 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"] + assert sorted(operation["responses"]) == ["200", "400", "401", "403", "404", "406", "415"] # The page is the only thing it reads from the query string. assert [parameter["name"] for parameter in operation["parameters"]] == ["page", "page_size"] @@ -1281,7 +1281,7 @@ def test_the_search_publishes_the_addresses_it_reads_and_the_page_it_answers_wit 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 sorted(operation["responses"]) == ["204", "400", "401", "403", "404", "406"] assert "content" not in operation["responses"]["204"] assert operation["responses"]["204"]["description"] == "The account was deactivated." @@ -1583,7 +1583,7 @@ def test_a_name_change_publishes_the_body_it_reads_and_the_one_it_returns(schema assert operation["responses"]["201"]["content"]["application/json"]["schema"] == { "$ref": "#/components/schemas/NameChange", } - assert sorted(operation["responses"]) == ["201", "400", "401", "404", "415"] + assert sorted(operation["responses"]) == ["201", "400", "401", "403", "404", "406", "415"] published = schema["components"]["schemas"]["NameChange"] assert published["required"] == ["name"] assert published["properties"]["name"]["maxLength"] == 255 @@ -1600,7 +1600,7 @@ def test_a_confirmation_publishes_that_it_reads_nothing_and_answers_with_nothing "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"] + assert sorted(operation["responses"]) == ["204", "400", "401", "403", "404", "406"] #: Every form an image may be sent in: the media type, the name a body sent in @@ -1648,7 +1648,7 @@ def test_the_profile_image_publishes_every_media_type_it_reads_the_image_in(sche "site configures." ), } - assert sorted(operation["responses"]) == ["204", "400", "401", "403", "404", "415"] + assert sorted(operation["responses"]) == ["204", "400", "401", "403", "404", "406", "415"] @pytest.mark.django_db @@ -1697,7 +1697,7 @@ def test_clearing_the_profile_image_publishes_that_it_reads_nothing(schema): 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 sorted(operation["responses"]) == ["204", "400", "401", "403", "404", "406"] assert operation["responses"]["400"]["description"] == ( "The request carries a query parameter this endpoint does not read, or the address names " "more than one account and so names none of them in particular." @@ -2137,11 +2137,11 @@ def test_the_name_a_preference_is_stored_under_is_published_under_one_parameter_ #: with the refusal naming an account that does not exist, and none publishes #: it. PREFERENCE_PUBLISHED_ANSWERS = { - (PREFERENCE_DOCUMENT_PATH, "get"): ["200", "400", "401", "403", "404", "500"], - (PREFERENCE_DOCUMENT_PATH, "patch"): ["204", "400", "401", "403", "415", "500"], - (PREFERENCE_NAME_PATH, "get"): ["200", "400", "401", "403", "404", "500"], - (PREFERENCE_NAME_PATH, "put"): ["204", "400", "401", "403", "415", "500"], - (PREFERENCE_NAME_PATH, "delete"): ["204", "400", "401", "403", "404", "500"], + (PREFERENCE_DOCUMENT_PATH, "get"): ["200", "400", "401", "403", "404", "406", "500"], + (PREFERENCE_DOCUMENT_PATH, "patch"): ["204", "400", "401", "403", "406", "415", "500"], + (PREFERENCE_NAME_PATH, "get"): ["200", "400", "401", "403", "404", "406", "500"], + (PREFERENCE_NAME_PATH, "put"): ["204", "400", "401", "403", "406", "415", "500"], + (PREFERENCE_NAME_PATH, "delete"): ["204", "400", "401", "403", "404", "406", "500"], } @@ -2185,7 +2185,7 @@ def test_the_email_choice_publishes_the_body_it_reads_and_that_it_answers_with_n assert carried["schema"] == {"$ref": "#/components/schemas/EmailOptIn"}, media_type assert "content" not in operation["responses"]["204"] assert sorted(operation["responses"]) == [ - "204", "400", "401", "403", "404", "415", "500", + "204", "400", "401", "403", "404", "406", "415", "500", ] choice = schema["components"]["schemas"]["EmailOptIn"] assert sorted(choice["required"]) == ["course_key", "email_opt_in"] @@ -2197,3 +2197,53 @@ def test_the_email_choice_publishes_the_body_it_reads_and_that_it_answers_with_n "Whether this user wants messages from that organization, sent as true or false. A " "value that says neither is refused rather than read as one of them." ) + + +#: What every operation of this version says about a request it can give no +#: answer to. Which representations an answer can be given in is settled as +#: the request enters, before the address it names is read or written, so the +#: refusal reaches an operation that answers with a body and one that does not +#: alike. +UNSATISFIABLE_ACCEPT_DESCRIPTION = ( + "The `Accept` header of the request names no representation this endpoint can answer in." +) + + +@skip_unless_lms +def test_every_operation_publishes_the_refusal_of_an_accept_it_cannot_satisfy(schema): + """ + No operation of this version leaves this refusal unpublished. + + This is asserted over every operation rather than per address, so an + operation added later is held to it without this test being changed. + """ + unpublished = { + f"{method.upper()} {path}" + for path, method, operation in operations(schema) + if path in VERSION_TWO_PATHS and "406" not in operation["responses"] + } + assert unpublished == set() + wrongly_described = { + f"{method.upper()} {path}": operation["responses"]["406"]["description"] + for path, method, operation in operations(schema) + if path in VERSION_TWO_PATHS + and operation["responses"]["406"]["description"] != UNSATISFIABLE_ACCEPT_DESCRIPTION + } + assert wrongly_described == {} + + +@skip_unless_lms +def test_asking_for_a_name_change_publishes_the_refusal_of_a_session_without_its_token(schema): + """ + A write made with a session the site cannot tie to a page it served is refused here too. + + The sibling collection of this version that records a choice for the + caller publishes that refusal; this one answers with it and did not + publish it, so a caller handling failures by the statuses the document + carries fell through to a status it had no model for. + """ + asked = schema["paths"][NAME_CHANGE_PATH]["post"]["responses"] + recorded = schema["paths"][EMAIL_OPT_IN_PATH]["post"]["responses"] + assert "403" in asked + assert asked["403"]["description"] == recorded["403"]["description"] + assert asked["403"]["content"] == recorded["403"]["content"] diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_views.py index 803a439650a7..6ee07542c688 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_views.py @@ -14,11 +14,12 @@ from edx_rest_framework_extensions.testing import assert_error_envelope from opaque_keys.edx.keys import CourseKey from rest_framework import status -from rest_framework.test import APITestCase +from rest_framework.test import APIClient, APITestCase from common.djangoapps.student.tests.factories import AdminFactory, UserFactory from openedx.core.djangoapps.django_comment_common.models import Role from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user +from common.djangoapps.student.models import PendingNameChange, UserProfile from openedx.core.djangoapps.user_api.models import UserPreference from openedx.core.djangoapps.user_api.preferences.api import get_country_time_zones from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory @@ -1765,3 +1766,119 @@ def test_a_plain_name_still_selects_that_property_alone_on_a_list(self): {"username": "bob", "email": "bob@example.com"}, {"username": "learner-caller", "email": self.learner.email}, ] + + +@skip_unless_lms +class UnsatisfiableAcceptTests(UserApiV2TestCase): + """ + What an address answers a request it can give no acceptable answer to. + + Which representations an answer may be given in is settled as the request + enters, so the refusal reaches an address that answers with a body and one + that answers with none alike. It is published by every operation of this + version, and these are the measurements that publication rests on. + """ + + def addresses(self): + """Return a representative request to each kind of address of this version, by method.""" + return [ + ("get", reverse("user_v2:user_list"), self.admin), + ("get", reverse("user_v2:user_detail", kwargs={"username": "alice"}), self.admin), + ("get", reverse("user_v2:user_preference_list"), self.admin), + ("get", reverse("user_v2:time_zone_list"), self.learner), + ("get", reverse("user_v2:account_list"), self.learner), + ("get", reverse("user_v2:account_detail", kwargs={"username": "me"}), self.learner), + ("get", reverse("user_v2:account_preference_list", kwargs={"username": "alice"}), self.alice), + ( + "get", + reverse("user_v2:account_preference_detail", kwargs={"username": "alice", "key": "pref-lang"}), + self.alice, + ), + ("delete", reverse("user_v2:account_profile_image", kwargs={"username": "alice"}), self.alice), + ] + + def test_an_accept_header_naming_nothing_the_address_answers_in_is_refused(self): + answered = {} + for method, url, caller in self.addresses(): + self.client.logout() + self.login(caller) + response = getattr(self.client, method)(url, HTTP_ACCEPT="text/csv") + answered[f"{method.upper()} {url}"] = response.status_code + assert set(answered.values()) == {status.HTTP_406_NOT_ACCEPTABLE}, answered + + def test_the_refusal_comes_back_in_the_body_this_version_answers_with(self): + """The refusal is readable even though the caller asked for a representation it is not in.""" + self.login(self.admin) + response = self.client.get(reverse("user_v2:user_list"), HTTP_ACCEPT="text/csv") + assert_error_envelope( + response, expected_status=status.HTTP_406_NOT_ACCEPTABLE, expected_type_slug="not-acceptable", + ) + + def test_an_address_that_answers_with_no_body_refuses_it_too(self): + """ + Nothing is read or changed on the way to the refusal. + + Clearing an image answers with no body at all, so the representation + asked for could make no difference to what comes back; the refusal is + settled before the handler runs, and the stored image is left alone. + """ + UserProfile.objects.filter(user=self.alice).update(profile_image_uploaded_at=datetime.now(UTC)) + self.login(self.alice) + response = self.client.delete( + reverse("user_v2:account_profile_image", kwargs={"username": "alice"}), + HTTP_ACCEPT="text/csv", + ) + assert response.status_code == status.HTTP_406_NOT_ACCEPTABLE + self.alice.profile.refresh_from_db() + assert self.alice.profile.has_profile_image is True + + def test_an_accept_header_the_address_can_satisfy_is_answered(self): + """The refusal is about what was asked for, not about asking at all.""" + self.login(self.admin) + response = self.client.get(reverse("user_v2:user_list"), HTTP_ACCEPT="application/json") + assert response.status_code == status.HTTP_200_OK + assert self.usernames_in(response) == ["admin-caller", "alice", "bob", "learner-caller"] + + +@skip_unless_lms +class SessionWithoutItsTokenTests(UserApiV2TestCase): + """ + What a write answers a session the site cannot tie to a page it served. + + Both of these collections record something for the caller and are reached + from the site's own pages, and both refuse such a session. Only one of + them published that refusal, so they are measured together here. + """ + + def test_asking_for_a_name_change_with_a_session_without_its_token_is_refused(self): + client = APIClient(enforce_csrf_checks=True) + assert client.login(username=self.alice.username, password=PASSWORD) + assert_error_envelope( + client.post(reverse("user_v2:name_change_list"), {"name": "Alice Example"}, format="json"), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + assert PendingNameChange.objects.filter(user=self.alice).count() == 0 + + def test_recording_an_organizations_messages_with_the_same_session_is_refused_the_same_way(self): + """The refusal the sibling collection publishes is the one this one answers with.""" + client = APIClient(enforce_csrf_checks=True) + assert client.login(username=self.alice.username, password=PASSWORD) + assert_error_envelope( + client.post( + reverse("user_v2:email_opt_in_list"), + {"course_key": str(COURSE_KEY), "email_opt_in": True}, + format="json", + ), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_a_name_change_asked_for_with_the_token_the_session_carries_is_recorded(self): + """The refusal is about the missing token, not about the address.""" + self.login(self.alice) + response = self.client.post( + reverse("user_v2:name_change_list"), {"name": "Alice Example"}, format="json", + ) + assert response.status_code == status.HTTP_201_CREATED + assert PendingNameChange.objects.get(user=self.alice).new_name == "Alice Example" diff --git a/openedx/core/djangoapps/user_api/v2/views.py b/openedx/core/djangoapps/user_api/v2/views.py index e7fbeb4a2966..61d874d09fd6 100644 --- a/openedx/core/djangoapps/user_api/v2/views.py +++ b/openedx/core/djangoapps/user_api/v2/views.py @@ -266,6 +266,25 @@ def _ordering_param(sort_fields, default): _PREFERENCE_ORDERING_PARAM = _ordering_param(PREFERENCE_SORT_FIELDS, PREFERENCE_DEFAULT_SORT) _RESP_FORBIDDEN = OpenApiResponse(ErrorResponse, description="The requester may not use this endpoint.") +#: Published by every operation of this version. Which representations an +#: answer can be given in is settled as the request enters, before the address +#: it names is read or written, so an ``Accept`` header naming none of them is +#: refused at every address whether or not that address answers with a body. +_RESP_SESSION_WITHOUT_ITS_TOKEN = OpenApiResponse( + ErrorResponse, + description=( + "The request was made with a session cookie and carried none of the request token the " + "site hands out with it, so the site cannot tell that the request came from a page it " + "served. Sending that token with the session, or a signed token in place of the " + "session, is answered." + ), +) +RESP_NOT_ACCEPTABLE = OpenApiResponse( + ErrorResponse, + description=( + "The `Accept` header of the request names no representation this endpoint can answer in." + ), +) _RESP_NOT_FOUND = OpenApiResponse(ErrorResponse, description="No such resource.") _RESP_NO_SUCH_PAGE = OpenApiResponse(ErrorResponse, description="There is no such page of results.") _RESP_BAD_REQUEST = OpenApiResponse( @@ -617,6 +636,7 @@ def shape_page(self, response, request): 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, 404: _RESP_NO_SUCH_PAGE, + 406: RESP_NOT_ACCEPTABLE, }, ), retrieve=extend_schema( @@ -629,6 +649,7 @@ def shape_page(self, response, request): 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, 404: _RESP_NOT_FOUND, + 406: RESP_NOT_ACCEPTABLE, }, ), ) @@ -706,6 +727,7 @@ def retrieve(self, request, *args, **kwargs): 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, 404: _RESP_NO_SUCH_PAGE, + 406: RESP_NOT_ACCEPTABLE, }, ), retrieve=extend_schema( @@ -727,6 +749,7 @@ def retrieve(self, request, *args, **kwargs): 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, 404: _RESP_NOT_FOUND, + 406: RESP_NOT_ACCEPTABLE, }, ), ) @@ -845,6 +868,7 @@ def get_serializer(self, *args, **kwargs): 200: TimeZoneSerializer, 400: _RESP_BAD_REQUEST, 404: _RESP_NO_SUCH_PAGE, + 406: RESP_NOT_ACCEPTABLE, }, ) def list(self, request): @@ -934,6 +958,7 @@ def addressed_account(self): 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, 404: _RESP_NO_SUCH_PAGE_OR_RETIRED, + 406: RESP_NOT_ACCEPTABLE, }, ), retrieve=extend_schema( @@ -951,6 +976,7 @@ def addressed_account(self): 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, 404: _RESP_NO_SUCH_ACCOUNT, + 406: RESP_NOT_ACCEPTABLE, }, examples=_ACCOUNT_EXAMPLES, ), @@ -971,6 +997,7 @@ def addressed_account(self): 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, 404: _RESP_CHANGED_ACCOUNT_GONE, + 406: RESP_NOT_ACCEPTABLE, 415: RESP_UNREADABLE_BODY, 429: _RESP_RATE_LIMITED, 500: _RESP_CHANGE_FAILED, @@ -1314,6 +1341,7 @@ def _failed_change(self, username, error): 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, 404: _RESP_NOT_FOUND, + 406: RESP_NOT_ACCEPTABLE, }, ), ) @@ -1388,6 +1416,7 @@ def get_queryset(self): 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, 404: _RESP_NOT_FOUND, + 406: RESP_NOT_ACCEPTABLE, 415: RESP_UNREADABLE_BODY, }, ), @@ -1473,10 +1502,12 @@ def create(self, request): 201: NameChangeSerializer, 400: _RESP_UNUSABLE_BODY, 401: RESP_UNAUTHENTICATED, + 403: _RESP_SESSION_WITHOUT_ITS_TOKEN, 404: OpenApiResponse( ErrorResponse, description="The account of the caller carries no profile to put a name on.", ), + 406: RESP_NOT_ACCEPTABLE, 415: RESP_UNREADABLE_BODY, }, ), @@ -1543,6 +1574,7 @@ def create(self, request): ErrorResponse, description="No such user, or the user has asked for no name change.", ), + 406: RESP_NOT_ACCEPTABLE, }, ) @action( @@ -1644,6 +1676,7 @@ def get_serializer(self, *args, **kwargs): 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, 404: OpenApiResponse(ErrorResponse, description="No such user."), + 406: RESP_NOT_ACCEPTABLE, }, ) def get(self, request, username): @@ -1677,6 +1710,7 @@ def get(self, request, username): 404: OpenApiResponse( ErrorResponse, description="No such user, or no such page of attempts.", ), + 406: RESP_NOT_ACCEPTABLE, }, ), ) @@ -1917,6 +1951,7 @@ def _describes_an_address_the_document_is_changed_at(self): 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, 404: _RESP_NO_SUCH_ACCOUNT, + 406: RESP_NOT_ACCEPTABLE, 500: _RESP_PREFERENCE_SERVICE_FAILED, }, ) @@ -1942,6 +1977,7 @@ def retrieve_all(self, request, username): 400: _RESP_PREFERENCE_NOT_STORED, 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, + 406: RESP_NOT_ACCEPTABLE, 415: RESP_UNREADABLE_BODY, 500: _RESP_PREFERENCE_SERVICE_FAILED, }, @@ -1969,6 +2005,7 @@ def partial_update_all(self, request, username): 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, 404: _RESP_NO_SUCH_PREFERENCE, + 406: RESP_NOT_ACCEPTABLE, 500: _RESP_PREFERENCE_SERVICE_FAILED, }, ) @@ -1997,6 +2034,7 @@ def retrieve(self, request, username, key): 400: _RESP_PREFERENCE_NOT_STORED, 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, + 406: RESP_NOT_ACCEPTABLE, 415: RESP_UNREADABLE_BODY, 500: _RESP_PREFERENCE_SERVICE_FAILED, }, @@ -2026,6 +2064,7 @@ def update(self, request, username, key): 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, 404: _RESP_NO_SUCH_PREFERENCE, + 406: RESP_NOT_ACCEPTABLE, 500: _RESP_PREFERENCE_SERVICE_FAILED, }, ) @@ -2085,17 +2124,6 @@ def _refused_preferences(self, username, error, carried_by=None): return ValidationError(refusals) -_RESP_SESSION_WITHOUT_ITS_TOKEN = OpenApiResponse( - ErrorResponse, - description=( - "The request was made with a session cookie and carried none of the request token the " - "site hands out with it, so the site cannot tell that the request came from a page it " - "served. Sending that token with the session, or a signed token in place of the " - "session, is answered." - ), -) - - @extend_schema(tags=[SDK_TAG]) @extend_schema_view( create=extend_schema( @@ -2116,6 +2144,7 @@ def _refused_preferences(self, username, error, carried_by=None): ErrorResponse, description="The account of the caller carries no profile to read a consent rule from.", ), + 406: RESP_NOT_ACCEPTABLE, 415: RESP_UNREADABLE_BODY, 500: _RESP_PREFERENCE_SERVICE_FAILED, }, From 7e865aadf87a9df7c9b18d22b4f7d9ed9fa4702a Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 06:30:29 +0500 Subject: [PATCH 27/82] fix: stop naming the site's sharing configuration in a refusal about another account --- .../user_api/v2/tests/test_account_views.py | 91 +++++++++++++++++-- openedx/core/djangoapps/user_api/v2/views.py | 42 +++++++-- 2 files changed, 119 insertions(+), 14 deletions(-) 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 index 83cfe46f6fdb..a5ce0b6b634e 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py @@ -59,14 +59,13 @@ SHARED_FIELDS = set(_VISIBILITY.get("bulk_shareable_fields", ())) #: The properties an owner is able to share, and so the only ones another caller -#: may select; the refusal offers them in this order. +#: may select. A refusal about somebody else's account names none of them; one +#: about an answer the caller narrowed itself offers them in this order. SHAREABLE_FIELDS = set(_VISIBILITY.get("custom_shareable_fields", ())) SHAREABLE_FIELDS_OFFERED = ", ".join(sorted(SHAREABLE_FIELDS)) -#: The identifying properties the ``minimal`` preset keeps, and the ones of those -#: another caller may select; the refusal offers them in this order. +#: The identifying properties the ``minimal`` preset keeps. 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" @@ -336,7 +335,8 @@ def test_a_learner_selecting_an_identifying_property_they_may_not_see_is_refused 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}.", + "No such field: email. An account another user holds carries only the " + "properties its owner shares.", ] def test_a_caller_selects_an_identifying_property_of_their_own_account(self): @@ -368,7 +368,8 @@ def test_a_learner_selecting_a_property_of_another_account_they_may_not_see_is_r 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}.", + "No such field: email. An account another user holds carries only the " + "properties its owner shares.", ] def test_a_learner_selecting_a_property_this_owner_does_not_share_receives_the_rest(self): @@ -830,7 +831,8 @@ def test_a_learner_selecting_an_identifying_property_they_may_not_see_is_refused 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}.", + "No such field: email. An account another user holds carries only the " + "properties its owner shares.", ] def test_selected_fields_narrow_the_account(self): @@ -846,7 +848,8 @@ def test_a_learner_selecting_a_property_of_another_account_they_may_not_see_is_r 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}.", + "No such field: email. An account another user holds carries only the " + "properties its owner shares.", ] def test_a_learner_selecting_a_property_this_owner_does_not_share_receives_the_rest(self): @@ -3192,3 +3195,75 @@ def test_a_search_built_from_the_described_body_is_answered(self): ) assert response.status_code == status.HTTP_200_OK assert [row["username"] for row in response.data["results"]] == ["owner-caller"] + + +@skip_unless_lms +class RefusalDisclosureTests(AccountApiV2TestCase): + """ + What a refusal about somebody else's account gives away. + + A caller that selects a property of another user's account it may not see + is told that it cannot. Which properties an owner shares is that owner's + to decide, and the set the site offers them to choose from is the site's + own configuration, so a refusal about an account the caller does not hold + names neither: it would otherwise hand every signed-in caller the whole + configured list, including names nobody on the site shares. + """ + + REFUSAL_ABOUT_ANOTHER_ACCOUNT = ( + "No such field: email. An account another user holds carries only the properties " + "its owner shares." + ) + + def test_a_refusal_about_another_account_names_nothing_the_site_configures(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.client.get(self.detail_url("other-caller"), {"fields": "email"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + message = envelope["errors"]["fields"][0] + assert message == self.REFUSAL_ABOUT_ANOTHER_ACCOUNT + assert sorted(name for name in SHAREABLE_FIELDS if name in message) == [] + + def test_a_refusal_about_another_account_on_the_list_names_nothing_either(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.client.get(self.list_url, {"username": "other-caller", "fields": "email"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + message = envelope["errors"]["fields"][0] + assert message == self.REFUSAL_ABOUT_ANOTHER_ACCOUNT + assert sorted(name for name in SHAREABLE_FIELDS if name in message) == [] + + def test_a_refusal_about_another_account_under_a_preset_names_nothing_either(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.client.get(self.detail_url("other-caller"), {"view": "minimal", "fields": "email"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"]["fields"][0] == self.REFUSAL_ABOUT_ANOTHER_ACCOUNT + + def test_a_refusal_about_the_callers_own_account_still_says_what_may_be_selected(self): + """The caller sees every property of their own account, so naming them gives nothing away.""" + self.login(self.owner) + envelope = assert_error_envelope( + self.client.get(self.own_url, {"fields": "wallet"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + message = envelope["errors"]["fields"][0] + assert message.startswith("No such field: wallet. Select any of: ") + assert "username" in message + + def test_a_refusal_to_a_staff_caller_still_says_what_may_be_selected(self): + """A staff caller reads every property of every account, so the list tells them nothing new.""" + self.login(self.staff) + envelope = assert_error_envelope( + self.client.get(self.detail_url("other-caller"), {"fields": "wallet"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"]["fields"][0].startswith("No such field: wallet. Select any of: ") diff --git a/openedx/core/djangoapps/user_api/v2/views.py b/openedx/core/djangoapps/user_api/v2/views.py index 61d874d09fd6..6200f4efbbca 100644 --- a/openedx/core/djangoapps/user_api/v2/views.py +++ b/openedx/core/djangoapps/user_api/v2/views.py @@ -592,16 +592,26 @@ def shapeable_fields(self): return set(self.minimal_fields) return set(self.get_serializer_class()().fields) + #: What a caller is told in place of the names, at a view that may withhold + #: them. A view that narrows what a response carries by who is asking says + #: here what it tells a caller it has narrowed the answer for. + withheld_fields_message = "" + + def shapeable_fields_may_be_named(self): + """Whether a refusal may list the properties a response of this view can carry.""" + return True + def check_requested_fields(self, request): """Refuse a selected field the response cannot carry, naming the ones it can.""" available = self.shapeable_fields() unknown = sorted(set(_requested_field_names(request.query_params.get(FIELDS_PARAM))) - available) if unknown: + if self.shapeable_fields_may_be_named(): + rest = f"Select any of: {', '.join(sorted(available))}." + else: + rest = self.withheld_fields_message raise ValidationError({ - FIELDS_PARAM: [ - f"No such field: {', '.join(unknown)}. " - f"Select any of: {', '.join(sorted(available))}.", - ], + FIELDS_PARAM: [f"No such field: {', '.join(unknown)}. {rest}"], }) def shape(self, data, request): @@ -1064,6 +1074,7 @@ class AccountViewSet( minimal_fields = ACCOUNT_MINIMAL_FIELDS response_presets = ACCOUNT_PRESETS extra_query_params = RESPONSE_SHAPING_PARAMS + withheld_fields_message = "An account another user holds carries only the properties its owner shares." #: The validated query string of the request being served, read once. _validated_selection = None @@ -1096,13 +1107,32 @@ def shapeable_fields(self): return describable return describable & _shareable_account_fields() + def shapeable_fields_may_be_named(self): + """ + Whether a refusal may list the properties an answer of this kind can carry. + + Which properties an owner shares is that owner's to decide, and the + set the site offers them to choose from is the site's own + configuration. A caller addressing an account it does not hold is + therefore told that it may not select what it selected, and not what + the site holds for other people: the list would otherwise name every + property the site is configured to let an owner share, including ones + nobody on the site shares. A caller that narrowed the answer itself, + by asking for a preset, is still told what that preset carries. + """ + return not self._only_shared_properties_are_readable(self.request) + def _whole_accounts_are_readable(self, request): """Whether every account this request addresses comes back whole to this caller.""" if request.query_params.get(VIEW_PARAM) == SHARED_VIEW: return False + return not self._only_shared_properties_are_readable(request) + + def _only_shared_properties_are_readable(self, request): + """Whether who the caller is keeps them from every property of the accounts addressed.""" if request.user.is_staff: - return True - return self._addressed_names(request) == [request.user.username] + return False + return self._addressed_names(request) != [request.user.username] def _selection(self, request): """ From 4686f140d9805b552df36781e9b3c9249f92fdef Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 06:51:34 +0500 Subject: [PATCH 28/82] fix: fetch rows carried inside another resource through the policy that governs them --- .../user_api/v2/tests/test_url_mount.py | 42 ++++++ .../user_api/v2/tests/test_views.py | 123 +++++++++++++++++- openedx/core/djangoapps/user_api/v2/urls.py | 8 ++ openedx/core/djangoapps/user_api/v2/views.py | 41 +++++- 4 files changed, 206 insertions(+), 8 deletions(-) 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 index 019d3c535434..475cd056e00c 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_url_mount.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_url_mount.py @@ -7,6 +7,7 @@ AUTHN_LOGIN_VIEWS = "openedx.core.djangoapps.user_authn.views.login" AUTHN_REGISTER_VIEWS = "openedx.core.djangoapps.user_authn.views.register" +V2_VIEWS = "openedx.core.djangoapps.user_api.v2.views" @skip_unless_lms @@ -66,3 +67,44 @@ def test_both_shared_prefix_addresses_still_reverse_to_themselves(self): assert reverse( "user_api_login_session", kwargs={"api_version": "v2"}, ) == "/api/user/v2/account/login_session/" + + +@skip_unless_lms +class ClaimedPrefixTests(SimpleTestCase): + """ + Every address under this prefix that no route serves is answered by this version. + + The mount matches the whole prefix, so an address under it that none of + the routes and neither of the two addresses the authentication app keeps + matches is answered here rather than left to anything mounted later. That + is what makes a mistyped address come back as JSON, and it is also what + keeps an address registered under this prefix by anything mounted after + this version from being reached at all. + """ + + def assert_refused_here(self, address): + """Assert an address under the prefix is answered by this version's refusal.""" + match = resolve(address) + view_class = match.func.view_class + assert (view_class.__module__, view_class.__name__) == (V2_VIEWS, "UnknownAddressView"), address + + def test_a_mistyped_address_is_answered_by_this_version(self): + self.assert_refused_here("/api/user/v2/acounts/") + + def test_an_address_below_a_route_of_this_version_is_answered_by_this_version(self): + self.assert_refused_here("/api/user/v2/accounts/alice/not-a-property/") + + def test_a_third_address_of_the_other_apps_shape_is_answered_here(self): + """Only the two addresses spelled out for that app are left to it; a third is not.""" + self.assert_refused_here("/api/user/v2/account/password_reset/") + + def test_an_address_a_later_mount_would_serve_is_answered_here_instead(self): + """ + The prefix is claimed whole, which is the constraint an operator needs. + + Anything mounted after this version that registers an address under + the prefix is unreachable, so the shape of such an address is pinned + here rather than left to be discovered from a 404 in a deployment. + """ + self.assert_refused_here("/api/user/v2/some_plugin/thing/") + self.assert_refused_here("/api/user/v2/badges/") diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_views.py index 6ee07542c688..91f0fe5ddbf7 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_views.py @@ -5,6 +5,7 @@ from math import ceil from unittest.mock import patch +import pytest from django.contrib.auth.models import User # pylint: disable=imported-auth-user from django.core.exceptions import ImproperlyConfigured from django.db import connection @@ -16,10 +17,10 @@ from rest_framework import status from rest_framework.test import APIClient, APITestCase +from common.djangoapps.student.models import PendingNameChange, UserProfile from common.djangoapps.student.tests.factories import AdminFactory, UserFactory from openedx.core.djangoapps.django_comment_common.models import Role from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user -from common.djangoapps.student.models import PendingNameChange, UserProfile from openedx.core.djangoapps.user_api.models import UserPreference from openedx.core.djangoapps.user_api.preferences.api import get_country_time_zones from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory @@ -1611,7 +1612,7 @@ def test_the_rows_are_fetched_through_the_declared_policy(self): """ for view_class in (UserViewSet, UserPreferenceViewSet): with patch.object(view_class, "scoping_policy", None, create=True): - with self.assertRaises(ImproperlyConfigured): + with pytest.raises(ImproperlyConfigured): view_class().get_queryset() @@ -1882,3 +1883,121 @@ def test_a_name_change_asked_for_with_the_token_the_session_carries_is_recorded( ) assert response.status_code == status.HTTP_201_CREATED assert PendingNameChange.objects.get(user=self.alice).new_name == "Alice Example" + + +class HiddenKeyPolicy: + """A record-visibility policy that drops the preferences stored under one name.""" + + def __init__(self, hidden_key): + self.hidden_key = hidden_key + self.calls = 0 + + def scope(self, queryset, subject): # pylint: disable=unused-argument + """Return the rows of ``queryset`` stored under any other name.""" + self.calls += 1 + return queryset.exclude(key=self.hidden_key) + + +@skip_unless_lms +class EmbeddedRowScopingTests(UserApiV2TestCase): + """ + Rows of one resource carried inside another come through the policy that governs them. + + A directory entry carries the preferences stored against its account, and + a stored preference asked for whole carries its owner's entry, which + carries those preferences again. Those rows belong to the other + collection, so the policy that decides which of them a caller may see is + that collection's and not the one the entry came from. Applying it is + arranged where the page is fetched, so it costs no query per row. + """ + + def directory_of(self, rows): + """Create ``rows`` users, each with two stored preferences.""" + made = [] + for index in range(rows): + user = UserFactory.create(username=f"row-{index:03d}", password=PASSWORD) + UserPreferenceFactory.create(user=user, key="pref-lang", value="en") + UserPreferenceFactory.create(user=user, key="time_zone", value="UTC") + made.append(user) + return made + + def test_the_preferences_inside_a_directory_entry_come_through_their_own_policy(self): + policy = HiddenKeyPolicy("pref-lang") + self.login(self.admin) + with patch.object(UserViewSet, "embedded_preference_policy", policy, create=True): + response = self.client.get(reverse("user_v2:user_detail", kwargs={"username": "alice"})) + assert response.status_code == status.HTTP_200_OK + assert response.data["preferences"] == {} + assert policy.calls == 1 + + def test_a_preference_the_embedded_policy_keeps_is_still_carried(self): + policy = HiddenKeyPolicy("pref-lang") + self.login(self.admin) + with patch.object(UserViewSet, "embedded_preference_policy", policy, create=True): + response = self.client.get(reverse("user_v2:user_detail", kwargs={"username": "bob"})) + assert response.status_code == status.HTTP_200_OK + assert response.data["preferences"] == {"time_zone": "UTC"} + + def test_the_preferences_inside_a_page_of_entries_come_through_their_own_policy(self): + self.directory_of(5) + policy = HiddenKeyPolicy("pref-lang") + self.login(self.admin) + with patch.object(UserViewSet, "embedded_preference_policy", policy, create=True): + response = self.client.get(reverse("user_v2:user_list"), {"page_size": 100}) + assert response.status_code == status.HTTP_200_OK + carried = {row["username"]: row["preferences"] for row in response.data["results"]} + assert [name for name, preferences in carried.items() if "pref-lang" in preferences] == [] + assert carried["row-000"] == {"time_zone": "UTC"} + + def test_narrowing_the_embedded_preferences_costs_no_query_per_row(self): + """ + The policy is applied where the page is fetched, so it is run once however many rows there are. + + One row would hide a per-row query that cancels a prefetch, so this is + measured at five rows and again at twenty-five. + """ + self.login(self.admin) + measured = {} + for rows in (5, 25): + User.objects.filter(username__startswith="row-").delete() + self.directory_of(rows) + policy = HiddenKeyPolicy("pref-lang") + with patch.object(UserViewSet, "embedded_preference_policy", policy, create=True): + # The first request of a signed-in client pays for its session, + # which is not part of what is being measured here. + self.client.get(reverse("user_v2:user_list"), {"page_size": 100}) + with CaptureQueriesContext(connection) as captured: + response = self.client.get(reverse("user_v2:user_list"), {"page_size": 100}) + assert response.status_code == status.HTTP_200_OK + assert len(response.data["results"]) == rows + 4 + measured[rows] = len(captured) + assert measured[5] == measured[25], measured + + def test_the_preferences_inside_an_embedded_owner_come_through_their_own_policy(self): + """A preference asked for whole carries its owner, whose own preferences are narrowed too.""" + policy = HiddenKeyPolicy("pref-lang") + self.login(self.admin) + with patch.object(UserPreferenceViewSet, "embedded_preference_policy", policy, create=True): + response = self.client.get( + reverse("user_v2:user_preference_detail", kwargs={"username": "alice", "key": "pref-lang"}), + {"view": "full"}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["user"]["preferences"] == {} + + def test_narrowing_an_embedded_owners_preferences_costs_no_query_per_row(self): + self.login(self.admin) + measured = {} + for rows in (5, 25): + User.objects.filter(username__startswith="row-").delete() + self.directory_of(rows) + policy = HiddenKeyPolicy("pref-lang") + address = reverse("user_v2:user_preference_list") + with patch.object(UserPreferenceViewSet, "embedded_preference_policy", policy, create=True): + self.client.get(address, {"view": "full", "page_size": 100}) + with CaptureQueriesContext(connection) as captured: + response = self.client.get(address, {"view": "full", "page_size": 100}) + assert response.status_code == status.HTTP_200_OK + assert len(response.data["results"]) == rows * 2 + 2 + measured[rows] = len(captured) + assert measured[5] == measured[25], measured diff --git a/openedx/core/djangoapps/user_api/v2/urls.py b/openedx/core/djangoapps/user_api/v2/urls.py index 8fff7cf74c6e..cd156bc6048b 100644 --- a/openedx/core/djangoapps/user_api/v2/urls.py +++ b/openedx/core/djangoapps/user_api/v2/urls.py @@ -150,5 +150,13 @@ class SnakeCaseRouter(SimpleRouter): # 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. + # + # This claims the whole prefix: it matches every address under it that the + # routes above and the addresses named in OTHER_APP_ADDRESSES do not, and + # the service mounts it ahead of the patterns contributed by installed + # plugins. An address a plugin registers under this prefix is therefore + # reached by nothing and answered 404 here. A plugin that needs an address + # of its own serves it under a prefix of its own; one that has to extend + # this version is added to OTHER_APP_ADDRESSES or to the routes above. re_path(rf"^(?!{OTHER_APP_ADDRESSES}$).*$", UnknownAddressView.as_view()), ] diff --git a/openedx/core/djangoapps/user_api/v2/views.py b/openedx/core/djangoapps/user_api/v2/views.py index 6200f4efbbca..e644a92f69b3 100644 --- a/openedx/core/djangoapps/user_api/v2/views.py +++ b/openedx/core/djangoapps/user_api/v2/views.py @@ -6,7 +6,7 @@ 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.db.models import F, Prefetch from django.http import Http404 from django_filters.rest_framework import DjangoFilterBackend from django_ratelimit.core import is_ratelimited @@ -690,11 +690,17 @@ class UserViewSet( permission_classes = (permissions.IsAuthenticated, IsActiveUser, permissions.IsAdminUser) serializer_class = UserDirectorySerializer pagination_class = UserApiPagination - queryset = User.objects.all().prefetch_related("preferences").select_related("profile") + queryset = User.objects.all().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() + #: The preferences carried inside an entry are rows of the stored + #: preference collection, so which of them a caller may see is that + #: collection's to decide and not this one's. It is declared separately for + #: that reason, and applied where the page is fetched rather than once per + #: entry, which would cost a query per row. + embedded_preference_policy = FullScopePolicy() lookup_field = "username" # The router's default lookup pattern excludes dots, which usernames allow. lookup_value_regex = settings.USERNAME_REGEX_PARTIAL @@ -711,6 +717,17 @@ class UserViewSet( response_presets = USER_PRESETS extra_query_params = RESPONSE_SHAPING_PARAMS + def get_queryset(self): + """Fetch the preferences carried inside each entry through the policy that governs them.""" + return super().get_queryset().prefetch_related( + Prefetch( + "preferences", + queryset=self.embedded_preference_policy.scope( + UserPreference.objects.all(), self.request.user, + ), + ), + ) + def list(self, request, *args, **kwargs): """Return a page of directory entries, shaped as the caller asked.""" return self.shape_page(super().list(request, *args, **kwargs), request) @@ -795,6 +812,11 @@ class UserPreferenceViewSet( .annotate(username=F("user__username")) ) scoping_policy = FullScopePolicy() + #: The entry of a preference's owner carries that owner's own preferences, + #: which are rows of this same collection but are reached through the + #: entry rather than as rows of the page, so the policy that governs them + #: is applied where they are fetched. + embedded_preference_policy = FullScopePolicy() filter_backends = (DjangoFilterBackend, StrictOrderingFilter) filterset_class = UserPreferenceFilterSet ordering_fields = PREFERENCE_SORT_FIELDS @@ -806,11 +828,18 @@ class UserPreferenceViewSet( 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) + def get_queryset(self): + """Fetch each owner's own preferences through the policy that governs them, when embedding.""" + queryset = super().get_queryset() if full_view_requested(getattr(self, "request", None)): - queryset = queryset.prefetch_related("user__preferences") + queryset = queryset.prefetch_related( + Prefetch( + "user__preferences", + queryset=self.embedded_preference_policy.scope( + UserPreference.objects.all(), self.request.user, + ), + ), + ) return queryset def get_object(self): From 21633566d53a043b7796a1868f9d7267a5785951 Mon Sep 17 00:00:00 2001 From: Abdul Muqadim Date: Sun, 1 Nov 2026 07:54:03 +0500 Subject: [PATCH 29/82] fix: describe the superseded user addresses the same way in both services' documents --- cms/lib/spectacular.py | 12 +- cms/lib/tests/test_spectacular.py | 195 +++++++++++++++++++++++++++++- lms/lib/spectacular.py | 75 +++++++----- 3 files changed, 252 insertions(+), 30 deletions(-) diff --git a/cms/lib/spectacular.py b/cms/lib/spectacular.py index 662cf80c65f0..53819f34ac00 100644 --- a/cms/lib/spectacular.py +++ b/cms/lib/spectacular.py @@ -4,6 +4,8 @@ from drf_spectacular.openapi import AutoSchema +from lms.lib.spectacular import SupersededUserOperations + # Legacy operations superseded by a conforming address under /api/authoring/. # Each is listed in the truncated spelling as well as the full one: the routes # make the final character optional, so both addresses resolve, and which one @@ -72,7 +74,7 @@ def cms_mark_superseded_paths(result, generator, request, public): # pylint: di return result -class CmsAutoSchema(AutoSchema): +class CmsAutoSchema(SupersededUserOperations, AutoSchema): """ Give a colliding legacy address a distinct operationId. @@ -80,6 +82,14 @@ class CmsAutoSchema(AutoSchema): operationId, 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``. + + This service also serves the user API at the same addresses the service + that replaces it does, and both publish an unauthenticated document of + everything they serve. The shared behaviour describes those addresses the + one way, so that a client generated from either document reads the same + signature and the same success code for the same request; it is taken from + where that API's own schema lives rather than copied, because two copies + of a description drift. """ def get_operation_id(self): diff --git a/cms/lib/tests/test_spectacular.py b/cms/lib/tests/test_spectacular.py index 193f3c98c448..f6e380be9354 100644 --- a/cms/lib/tests/test_spectacular.py +++ b/cms/lib/tests/test_spectacular.py @@ -6,9 +6,10 @@ from django.conf import settings from django.test import SimpleTestCase +from django.test import TestCase as DjangoTestCase from django.urls import path as url_path from drf_spectacular.generators import SchemaGenerator -from drf_spectacular.settings import spectacular_settings +from drf_spectacular.settings import patched_settings, spectacular_settings from rest_framework import serializers, viewsets from rest_framework.response import Response from rest_framework.settings import api_settings @@ -20,6 +21,8 @@ cms_mark_migrated_paths, cms_mark_superseded_paths, ) +from lms.lib.spectacular import SUPERSEDED_USER_PATHS, superseded_bodies +from openedx.core.apidocs import get_api_docs_settings def _endpoint(path): @@ -215,3 +218,193 @@ def test_only_the_colliding_legacy_address_is_suffixed(self): # Already distinct from its conforming twin's id, so it keeps its name. assert ids["/api/authoring/v4/courses/"] == "v4_courses_list" assert ids["/api/contentstore/v4/home/courses/"] == "v4_home_courses_list" + + +#: The preference document a superseded read answers with, spelled out here +#: rather than read from the module that publishes it, so that an edit to that +#: shape is an edit this test sees. +PREFERENCE_DOCUMENT_BODY = { + "type": "object", + "additionalProperties": {"type": "string"}, + "description": ( + "Preferences of one account, as an object of preference name to stored value. Every " + "value is text. Which names an account carries depends on the features it uses." + ), +} + +#: What each superseded user operation this service serves 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 carrying no body at all. +#: +#: The table is spelled out here rather than taken from the tests of the +#: service that serves the replacements, so that a change made to what one of +#: the two documents says about these addresses and not to the other is a +#: failure here rather than a difference a client only finds after generating +#: from both. +SUPERSEDED_USER_EXCHANGES = { + ("/api/user/v1/accounts", "get"): ([], {"200": "Account[]"}), + ("/api/user/v1/accounts/{username}", "get"): ([], {"200": "Account"}), + ("/api/user/v1/accounts/{username}", "patch"): (["application/merge-patch+json"], {"200": "Account"}), + ("/api/user/v1/accounts/search_emails", "post"): ( + ["application/json", "application/merge-patch+json"], {"200": "EmailSearchResult[]"}, + ), + ("/api/user/v1/accounts/{username}/deactivate/", "post"): ([], {"200": "Account"}), + ("/api/user/v1/accounts/name_change/", "post"): ( + ["application/json", "application/x-www-form-urlencoded", "multipart/form-data"], + {"201": None}, + ), + ("/api/user/v1/accounts/name_change/{username}/confirm/", "post"): ([], {"204": None}), + ("/api/user/v1/accounts/{username}/image", "post"): ( + ["image/gif", "image/jpeg", "image/pjpeg", "image/png", "multipart/form-data"], + {"204": None}, + ), + ("/api/user/v1/accounts/{username}/image", "delete"): ([], {"204": None}), + ("/api/user/v1/accounts/{username}/verification_status/", "get"): ([], {"200": "VerificationStatus"}), + ("/api/user/v1/preferences/{username}", "get"): ([], {"200": PREFERENCE_DOCUMENT_BODY}), + ("/api/user/v1/preferences/{username}", "patch"): (["application/merge-patch+json"], {"204": None}), + ("/api/user/v1/preferences/{username}/{preference_key}", "get"): ([], {"200": {"type": "string"}}), + ("/api/user/v1/preferences/{username}/{preference_key}", "put"): (["application/json"], {"204": None}), + ("/api/user/v1/preferences/{username}/{preference_key}", "delete"): ([], {"204": None}), + ("/api/user/v1/preferences/email_opt_in/", "post"): ( + ["application/x-www-form-urlencoded"], {"200": None}, + ), +} + +#: Every operation of a superseded user address this service serves, with the +#: name it is published under. The names are the ones both documents have +#: always carried: a client imports a function per name, so an operation that +#: comes back under a different one has been taken away from it. +SUPERSEDED_USER_OPERATION_IDS = { + ("/api/user/v1/accounts", "get"): "api_user_v1_accounts_retrieve", + ("/api/user/v1/accounts/name_change/", "post"): "api_user_v1_accounts_name_change_create", + ("/api/user/v1/accounts/name_change/{username}/confirm/", "post"): ( + "api_user_v1_accounts_name_change_confirm_create" + ), + ("/api/user/v1/accounts/search_emails", "post"): "api_user_v1_accounts_search_emails_create", + ("/api/user/v1/accounts/{username}", "get"): "api_user_v1_accounts_retrieve_2", + ("/api/user/v1/accounts/{username}", "patch"): "api_user_v1_accounts_partial_update", + ("/api/user/v1/accounts/{username}/deactivate/", "post"): "api_user_v1_accounts_deactivate_create", + ("/api/user/v1/accounts/{username}/image", "delete"): "api_user_v1_accounts_image_destroy", + ("/api/user/v1/accounts/{username}/image", "post"): "api_user_v1_accounts_image_create", + ("/api/user/v1/accounts/{username}/verification_status/", "get"): ( + "api_user_v1_accounts_verification_status_retrieve" + ), + ("/api/user/v1/accounts/{username}/verifications/", "get"): "api_user_v1_accounts_verifications_list", + ("/api/user/v1/forum_roles/{name}/users/", "get"): "api_user_v1_forum_roles_users_list", + ("/api/user/v1/me", "get"): "api_user_v1_me_retrieve", + ("/api/user/v1/preferences/email_opt_in/", "post"): "api_user_v1_preferences_email_opt_in_create", + ("/api/user/v1/preferences/time_zones/", "get"): "api_user_v1_preferences_time_zones_list", + ("/api/user/v1/preferences/{pref_key}/users/", "get"): "api_user_v1_preferences_users_list", + ("/api/user/v1/preferences/{username}", "get"): "api_user_v1_preferences_retrieve", + ("/api/user/v1/preferences/{username}", "patch"): "api_user_v1_preferences_partial_update", + ("/api/user/v1/preferences/{username}/{preference_key}", "delete"): "api_user_v1_preferences_destroy", + ("/api/user/v1/preferences/{username}/{preference_key}", "get"): "api_user_v1_preferences_retrieve_2", + ("/api/user/v1/preferences/{username}/{preference_key}", "put"): "api_user_v1_preferences_update", + ("/api/user/v1/user_prefs/", "get"): "api_user_v1_user_prefs_list", + ("/api/user/v1/user_prefs/{id}/", "get"): "api_user_v1_user_prefs_retrieve", + ("/api/user/v1/users/", "get"): "api_user_v1_users_list", + ("/api/user/v1/users/{id}/", "get"): "api_user_v1_users_retrieve", +} + + +def _named_body(response): + """ + Return what a response carries: the component it names, or the shape itself. + + A component is named by its own name, suffixed ``[]`` where the body is an + array of it; a body described in place comes back as that description, and + a response with no body at all as ``None``. + """ + content = response.get("content") + if not content: + return None + body = content["application/json"]["schema"] + if body.get("type") == "array": + return f"{body['items']['$ref'].rsplit('/', 1)[-1]}[]" + if "$ref" not in body: + return body + return body["$ref"].rsplit("/", 1)[-1] + + +class SupersededUserOperationTest(DjangoTestCase): + """ + The user addresses this service serves beside the service that replaces them. + + Both services mount that API at the same addresses and both publish an + unauthenticated document of everything they serve, so what one of those + documents says about an address is what the other has to say too: the same + body on each side of the exchange, the same success code, the same + published name, and the same mark saying a replacement exists. A client + generated from this service's document would otherwise offer a different + signature, and no mark, for the very same request. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + with patched_settings(get_api_docs_settings()): + cls.schema = SchemaGenerator().get_schema(request=None, public=True) + + def _operations(self): + """Every operation of a superseded address this document carries.""" + return { + (path, method): operation + for path in SUPERSEDED_USER_PATHS if path in self.schema["paths"] + for method, operation in self.schema["paths"][path].items() + if isinstance(operation, dict) and "operationId" in operation + } + + def test_this_service_serves_every_superseded_address(self): + """The marking and the bodies below are claims about all of them, not about some.""" + served = [path for path in SUPERSEDED_USER_PATHS if path in self.schema["paths"]] + assert served == list(SUPERSEDED_USER_PATHS) + + def test_every_operation_of_those_addresses_is_marked_superseded(self): + marked = {key: operation.get("deprecated") for key, operation in self._operations().items()} + assert marked == dict.fromkeys(SUPERSEDED_USER_OPERATION_IDS, True) + + def test_every_operation_keeps_the_name_it_is_published_under(self): + published = {key: operation["operationId"] for key, operation in self._operations().items()} + assert published == SUPERSEDED_USER_OPERATION_IDS + + def test_each_superseded_operation_publishes_what_it_reads_and_what_it_answers_with(self): + published = {} + for (address, method) in SUPERSEDED_USER_EXCHANGES: + operation = self.schema["paths"][address][method] + published[(address, method)] = ( + sorted((operation.get("requestBody") or {}).get("content", {})), + {code: _named_body(response) for code, response in operation["responses"].items()}, + ) + assert published == SUPERSEDED_USER_EXCHANGES + + def test_a_body_the_handler_refuses_to_work_without_is_published_as_needed(self): + """A shape described in place carries no field a mark can be read from.""" + marks = { + address: self.schema["paths"][address][method]["requestBody"].get("required") + for address, method in ( + ("/api/user/v1/preferences/{username}", "patch"), + ("/api/user/v1/preferences/email_opt_in/", "post"), + ) + } + assert marks == { + "/api/user/v1/preferences/{username}": True, + "/api/user/v1/preferences/email_opt_in/": True, + } + + def test_the_write_of_one_preference_is_published_as_a_body_that_may_be_left_out(self): + """That handler takes a request carrying no body and stores what it was sent.""" + body = self.schema["paths"]["/api/user/v1/preferences/{username}/{preference_key}"]["put"]["requestBody"] + assert "required" not in body + + def test_an_image_may_be_sent_as_the_whole_body_or_as_a_form_part(self): + """The forms are alternatives, and a body one of them needs is one the operation needs.""" + image = self.schema["paths"]["/api/user/v1/accounts/{username}/image"]["post"]["requestBody"] + assert image["required"] is True + assert sorted(image["content"]) == [ + "image/gif", "image/jpeg", "image/pjpeg", "image/png", "multipart/form-data", + ] + + def test_the_described_addresses_are_ones_this_document_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 diff --git a/lms/lib/spectacular.py b/lms/lib/spectacular.py index b470d53e2903..2910d895b068 100644 --- a/lms/lib/spectacular.py +++ b/lms/lib/spectacular.py @@ -308,39 +308,29 @@ def lms_api_filter(endpoints): return filtered -class LmsAutoSchema(AutoSchema): +class SupersededUserOperations: """ - 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. - - 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, which says - which of the two addresses sorts first rather than anything about the - operation; 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 address ``RENAMED_OPERATION_IDS`` does not - name 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. + Describe the superseded user addresses wherever a service publishes them. + + Both services mount that API at the same addresses and both publish an + unauthenticated document of everything they serve, so what one of those + documents says about one of these addresses is what the other has to say + about it as well: a client generated from either reads the same body on + each side of the exchange, branches on the same success code, and imports + the same name. The behaviour is shared rather than restated in each + service's schema class, which is what keeps the two documents a deployment + publishes from describing one address two ways. + + Every address ``RENAMED_OPERATION_IDS`` does not name keeps the id the + generator derives for it, because a client generated from a document takes + a module from each operationId, so renaming one drops what a consumer + already imports. """ def get_operation_id(self): - """Keep each address that tokenizes to another one's id distinct from it.""" + """Keep the name an address is published under where describing its bodies would move 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 + return RENAMED_OPERATION_IDS.get((self.path, self.method, operation_id), operation_id) def _get_request_body(self, direction="request"): """ @@ -388,6 +378,35 @@ def _operation_shape(self): return (_PATH_PARAMETER.sub("{}", self.path), self.method) +class LmsAutoSchema(SupersededUserOperations, AutoSchema): + """ + Keep colliding addresses distinct and group each operation under its own area. + + 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, which says + which of the two addresses sorts first rather than anything about the + operation; 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. Each operation keeps the tag the generator derives + for it unless that tag names a version, because a client generated from a + document groups the operations it offers by tag. + """ + + def get_operation_id(self): + """Keep each address that tokenizes to another one's id distinct from it.""" + operation_id = super().get_operation_id() + 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 lms_mark_legacy_paths_deprecated(result, generator, request, public): # pylint: disable=unused-argument """Mark every superseded address ``deprecated: true``.""" for path, path_item in result.get("paths", {}).items(): From 9591e0f009e805047aa08301a498628c402e3671 Mon Sep 17 00:00:00 2001 From: Abdul Muqadim Date: Sun, 1 Nov 2026 08:04:56 +0500 Subject: [PATCH 30/82] fix: mount the prefix that answers unknown addresses after the applications' own --- lms/tests.py | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++- lms/urls.py | 14 ++++++-- 2 files changed, 104 insertions(+), 3 deletions(-) diff --git a/lms/tests.py b/lms/tests.py index 4a45b86a2699..5e5f6ad4f5dd 100644 --- a/lms/tests.py +++ b/lms/tests.py @@ -7,9 +7,18 @@ from django.conf import settings # pylint: disable=unused-import # noqa: F401 from django.core.cache import cache from django.test import TestCase, override_settings -from django.urls import path +from django.urls import Resolver404, URLResolver, path, resolve, reverse +from django.urls.resolvers import RegexPattern +from django.views.generic import View +from edx_django_utils.plugins import get_plugin_url_patterns +import lms.urls from openedx.core.apidocs import cached_schema_view +from openedx.core.djangoapps.plugins.constants import ProjectType +from openedx.core.djangoapps.user_api.v2.views import AccountViewSet as CurrentAccountViewSet +from openedx.core.djangoapps.user_api.v2.views import UnknownAddressView +from openedx.core.djangoapps.user_authn.views.login import LoginSessionView +from openedx.core.djangoapps.user_authn.views.register import RegistrationView log = logging.getLogger(__name__) @@ -86,4 +95,86 @@ def test_api_docs_schema_cache_per_urlconf(self): assert again.content == full.content +class ClaimedPrefixTests(TestCase): + """ + A prefix whose mount answers every address under it that its own routes do not. + + The current version of the user API is mounted that way, so nothing after + it is ever consulted for an address under that prefix. An installed + application contributes its patterns at one point in the URLconf, which + leaves the order between the two as what decides whether an address such an + application registers under the prefix can be served at all. + + The authentication application registers two addresses there, so that order + is measurable against real patterns rather than asserted about a copy. + """ + + LANDMARK = "api/user/v2/account/registration/" + + def _index_of(self, route): + """Where in the URLconf the pattern that answers that address sits.""" + for index, pattern in enumerate(lms.urls.urlpatterns): + try: + if pattern.resolve(route): + return index + except Resolver404: + continue + raise AssertionError(f"nothing in the URLconf answers {route}") + + def test_the_landmark_address_is_one_an_installed_application_contributes(self): + """The order below says nothing unless an application really registers an address here.""" + contributed = URLResolver(RegexPattern(r"^/"), get_plugin_url_patterns(ProjectType.LMS)) + assert contributed.resolve(f"/{self.LANDMARK}").func.cls is RegistrationView + + def test_the_mount_that_claims_the_prefix_is_consulted_after_those_applications(self): + claimed = self._index_of("api/user/v2/nothing_here/") + assert claimed > self._index_of(self.LANDMARK) + + def test_an_address_an_application_registers_under_the_prefix_reaches_its_view(self): + class _Calendars(View): + """Stand-in for a view an installed application serves under that prefix.""" + + patterns = list(lms.urls.urlpatterns) + patterns.insert( + self._index_of(self.LANDMARK) + 1, + path("api/user/v2/calendars/", _Calendars.as_view()), + ) + resolved = URLResolver(RegexPattern(r"^/"), patterns).resolve("/api/user/v2/calendars/") + assert resolved.func.view_class is _Calendars, resolved.func.view_class + + def test_an_address_nothing_registers_is_still_answered_by_the_mount(self): + assert resolve("/api/user/v2/nothing_here/").func.cls is UnknownAddressView + + def test_the_version_serves_its_own_resources(self): + assert resolve("/api/user/v2/accounts/").func.cls is CurrentAccountViewSet + + def test_the_addresses_another_application_serves_under_the_prefix_still_reach_it(self): + reached = { + address: resolve(address).func.cls + for address in ( + "/api/user/v2/account/registration/", + "/api/user/v2/account/login_session/", + ) + } + assert reached == { + "/api/user/v2/account/registration/": RegistrationView, + "/api/user/v2/account/login_session/": LoginSessionView, + } + + def test_those_addresses_keep_the_names_they_are_reversed_by(self): + reversed_names = { + "user_api_registration_v2": reverse("user_api_registration_v2"), + "user_api_login_session": reverse("user_api_login_session", kwargs={"api_version": "v2"}), + } + assert reversed_names == { + "user_api_registration_v2": "/api/user/v2/account/registration/", + "user_api_login_session": "/api/user/v2/account/login_session/", + } + + def test_one_of_them_sent_without_its_trailing_slash_is_still_redirected_to_it(self): + response = self.client.get("/api/user/v2/account/registration") + assert response.status_code == 301 + assert response["Location"] == "/api/user/v2/account/registration/" + + urlpatterns = [path('api-docs/schema/', cached_schema_view())] diff --git a/lms/urls.py b/lms/urls.py index 8a4c1cc929ae..dded010ee06e 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -141,8 +141,8 @@ # Course API path('api/courses/', include('lms.djangoapps.course_api.urls')), - # User API endpoints - path('api/user/v2/', include('openedx.core.djangoapps.user_api.v2.urls')), + # User API endpoints. The current version is mounted at the end of this + # file instead, for the reason given there. 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. @@ -1078,6 +1078,16 @@ def _enterprise_enabled(): urlpatterns.extend(get_plugin_url_patterns(ProjectType.LMS)) +# The current version of the User API, mounted after everything an installed +# application contributes. That mount answers every address under its prefix +# that its own routes do not serve, so an address registered under the prefix +# by anything mounted later is reached by nothing; mounting it here leaves such +# an address to the application that registers it, and leaves the mount only +# the addresses nothing serves. +urlpatterns += [ + path('api/user/v2/', include('openedx.core.djangoapps.user_api.v2.urls')), +] + # Course Home API urls urlpatterns += [ # This is a BFF ("backend for frontend") djangoapp for the Learning MFE (like courseware_api). From 6014f4b7f30d562f349887998d06aab7e77deb47 Mon Sep 17 00:00:00 2001 From: Abdul Muqadim Date: Sun, 1 Nov 2026 08:17:37 +0500 Subject: [PATCH 31/82] fix: serve the document published for client generation from the cache as well --- lms/tests.py | 107 +++++++++++++++++++++++++++++++++++++--- lms/urls.py | 11 ++++- openedx/core/apidocs.py | 38 ++++++++------ 3 files changed, 134 insertions(+), 22 deletions(-) diff --git a/lms/tests.py b/lms/tests.py index 5e5f6ad4f5dd..8738cface569 100644 --- a/lms/tests.py +++ b/lms/tests.py @@ -3,16 +3,19 @@ import logging import mimetypes +from unittest import mock from django.conf import settings # pylint: disable=unused-import # noqa: F401 from django.core.cache import cache +from django.http import HttpResponse from django.test import TestCase, override_settings from django.urls import Resolver404, URLResolver, path, resolve, reverse from django.urls.resolvers import RegexPattern -from django.views.generic import View +from drf_spectacular.generators import SchemaGenerator from edx_django_utils.plugins import get_plugin_url_patterns import lms.urls +from common.djangoapps.student.tests.factories import TEST_PASSWORD, UserFactory from openedx.core.apidocs import cached_schema_view from openedx.core.djangoapps.plugins.constants import ProjectType from openedx.core.djangoapps.user_api.v2.views import AccountViewSet as CurrentAccountViewSet @@ -95,6 +98,101 @@ def test_api_docs_schema_cache_per_urlconf(self): assert again.content == full.content +class NarrowSchemaCacheTests(TestCase): + """ + The document this service publishes for client generation is served from the cache. + + It is public, it costs a full generation pass, and it is the one schema + address with nothing in front of it. The cache may only serve it if every + caller is given the same document, so that is measured here too. + """ + + ADDRESS = '/lms-api/schema/' + + def _generations(self): + """Count the passes the generator makes, leaving it otherwise alone.""" + return mock.patch.object( + SchemaGenerator, + 'get_schema', + autospec=True, + side_effect=SchemaGenerator.get_schema, + ) + + @override_settings( + OPENAPI_CACHE_TIMEOUT=60, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}, + ) + def test_two_requests_generate_the_document_once(self): + cache.clear() + with self._generations() as generated: + first = self.client.get(self.ADDRESS) + second = self.client.get(self.ADDRESS) + assert first.status_code == 200 + assert first.content.startswith(b'openapi:') + assert second.content == first.content + assert second['Content-Type'] == first['Content-Type'] + assert generated.call_count == 1 + + @override_settings( + OPENAPI_CACHE_TIMEOUT=0, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}, + ) + def test_without_a_timeout_each_request_generates_it_again(self): + """A deployment that turns the cache off is served exactly what it was before.""" + cache.clear() + with self._generations() as generated: + first = self.client.get(self.ADDRESS) + second = self.client.get(self.ADDRESS) + assert first.status_code == 200 + assert second.content == first.content + assert generated.call_count == 2 + + @override_settings( + OPENAPI_CACHE_TIMEOUT=60, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}, + ) + def test_the_two_documents_this_service_serves_are_not_stored_under_one_key(self): + cache.clear() + narrow = self.client.get(self.ADDRESS) + wide = self.client.get('/api-docs/schema/') + assert narrow.status_code == 200 and wide.status_code == 200 + assert len(narrow.content) < len(wide.content) + assert self.client.get(self.ADDRESS).content == narrow.content + assert self.client.get('/api-docs/schema/').content == wide.content + + @override_settings( + OPENAPI_CACHE_TIMEOUT=60, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}, + ) + def test_another_language_is_not_served_the_document_already_stored(self): + cache.clear() + english = self.client.get(self.ADDRESS, HTTP_ACCEPT_LANGUAGE='en') + spanish = self.client.get(self.ADDRESS, HTTP_ACCEPT_LANGUAGE='es-419') + assert english.status_code == 200 and spanish.status_code == 200 + assert english.content != spanish.content + assert self.client.get(self.ADDRESS, HTTP_ACCEPT_LANGUAGE='en').content == english.content + + def test_every_caller_is_given_the_same_document(self): + """What the cache may hold: one document, not one per caller.""" + staff = UserFactory.create(username='narrowstaff', password=TEST_PASSWORD, is_staff=True) + root = UserFactory.create( + username='narrowroot', password=TEST_PASSWORD, is_staff=True, is_superuser=True, + ) + anonymous = self.client.get(self.ADDRESS) + served = {'anonymous': anonymous.content} + for user in (staff, root): + assert self.client.login(username=user.username, password=TEST_PASSWORD) + served[user.username] = self.client.get(self.ADDRESS).content + self.client.logout() + assert anonymous.status_code == 200 + assert set(served.values()) == {anonymous.content} + + +def _calendars_of_an_application(request): # pylint: disable=unused-argument + """Stand-in for an address an installed application serves under a claimed prefix.""" + return HttpResponse() + + class ClaimedPrefixTests(TestCase): """ A prefix whose mount answers every address under it that its own routes do not. @@ -131,16 +229,13 @@ def test_the_mount_that_claims_the_prefix_is_consulted_after_those_applications( assert claimed > self._index_of(self.LANDMARK) def test_an_address_an_application_registers_under_the_prefix_reaches_its_view(self): - class _Calendars(View): - """Stand-in for a view an installed application serves under that prefix.""" - patterns = list(lms.urls.urlpatterns) patterns.insert( self._index_of(self.LANDMARK) + 1, - path("api/user/v2/calendars/", _Calendars.as_view()), + path("api/user/v2/calendars/", _calendars_of_an_application), ) resolved = URLResolver(RegexPattern(r"^/"), patterns).resolve("/api/user/v2/calendars/") - assert resolved.func.view_class is _Calendars, resolved.func.view_class + assert resolved.func is _calendars_of_an_application, resolved.func def test_an_address_nothing_registers_is_still_answered_by_the_mount(self): assert resolve("/api/user/v2/nothing_here/").func.cls is UnknownAddressView diff --git a/lms/urls.py b/lms/urls.py index dded010ee06e..d1b78cbecdbd 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -1150,7 +1150,14 @@ def _enterprise_enabled(): path('xqueue/', include((submissions_urls, 'submissions'), namespace='submissions')), ] -# LMS API schema for openedx-platform-sdk generation. +# LMS API schema for openedx-platform-sdk generation. Served from the same +# cache the service-wide document uses: it is public, it is generated from +# scratch on every request, and it grew several times over when this API was +# added to it. urlpatterns += [ - path('lms-api/schema/', SpectacularAPIView.as_view(), name='lms-schema'), + path( + 'lms-api/schema/', + cached_schema_view(SpectacularAPIView.as_view(), resource='lms-api-schema'), + name='lms-schema', + ), ] diff --git a/openedx/core/apidocs.py b/openedx/core/apidocs.py index 05f8f59888f0..1450e7a27c52 100644 --- a/openedx/core/apidocs.py +++ b/openedx/core/apidocs.py @@ -84,9 +84,16 @@ def untag_deprecated_operations(result, generator, request, public): # pylint: return result -def cached_schema_view(): +def cached_schema_view(view=None, resource='apidocs-schema'): """ - Build the ``/api-docs`` schema view, caching the rendered document compressed. + Serve a generated schema document from the cache, stored compressed. + + With no arguments this builds the service-wide ``/api-docs`` view. A view + passed in is served the same way under the ``resource`` name given, which + is what keeps each document a service publishes in a key space of its own: + the narrower document each service serves for client generation is + otherwise regenerated on every anonymous request, and it is the one schema + address with nothing in front of it. ``cache_page`` stores the pickled ``Response``, which for this schema is over memcached's default 1MB item limit. ``CACHES['default']`` sets @@ -95,16 +102,18 @@ def cached_schema_view(): whole schema on every request. Storing the zlib-compressed body instead brings it to roughly a tenth of the limit. - The key covers the URLconf, the path, the negotiated representation and the - active language. The URLconf because the LMS and the CMS serve this view at - the same path and share one cache by default, so without it whichever - service generated first would serve its document at the other's URL. The - language because drf-spectacular renders the document under whatever - language is active, and ``LocaleMiddleware`` sets that from the request's - cookie or ``Accept-Language`` before this runs. Nothing else in it varies by - requester -- ``SERVE_PUBLIC`` defaults to ``True``, ``SERVE_*`` cannot be set - through ``custom_settings``, and ``API_DOCS_SETTINGS`` sets ``'SERVERS': []`` - so no ``servers`` block is emitted. + The key covers the resource, the URLconf, the path, the negotiated + representation and the active language. The URLconf because the LMS and the + CMS serve this view at the same path and share one cache by default, so + without it whichever service generated first would serve its document at + the other's URL. The language because drf-spectacular renders the document + under whatever language is active, and ``LocaleMiddleware`` sets that from + the request's cookie or ``Accept-Language`` before this runs. + + The key does not cover who asked: a document stored here is served to every + later caller, so an address wrapped this way must publish one document for + all of them. The narrower document is held to that by a test that asks for + it anonymously, as staff and as a superuser and compares the three. Only GET and HEAD are cached, as ``cache_page`` did: DRF answers OPTIONS on this view with a 200 metadata document, which would otherwise be stored and @@ -118,7 +127,8 @@ def cached_schema_view(): """ from drf_spectacular.views import SpectacularAPIView # pylint: disable=import-outside-toplevel - view = SpectacularAPIView.as_view(custom_settings=get_api_docs_settings()) + if view is None: + view = SpectacularAPIView.as_view(custom_settings=get_api_docs_settings()) def _response(content_type, body): """Build the response, marking it uncacheable downstream as drf-yasg did.""" @@ -132,7 +142,7 @@ def schema_view(request, *args, **kwargs): return view(request, *args, **kwargs) cache_key = get_cache_key( - resource='apidocs-schema', + resource=resource, urlconf=settings.ROOT_URLCONF, path=request.get_full_path(), accept=request.META.get('HTTP_ACCEPT', ''), From 2a0af320c1decbc28c9ce4fcf1b333e356e01a8e Mon Sep 17 00:00:00 2001 From: Abdul Muqadim Date: Sun, 1 Nov 2026 09:12:21 +0500 Subject: [PATCH 32/82] test: hold the settings module the published reference is generated from --- cms/lib/tests/test_spectacular.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cms/lib/tests/test_spectacular.py b/cms/lib/tests/test_spectacular.py index f6e380be9354..1dccf7a146a7 100644 --- a/cms/lib/tests/test_spectacular.py +++ b/cms/lib/tests/test_spectacular.py @@ -181,6 +181,17 @@ def test_environments_do_not_replace_the_common_settings(self): # A hook is set by its quoted key; a comment that names one is fine. assert not re.search(r"_HOOKS['\"]", source), module + def test_the_module_the_published_reference_is_generated_from_inherits_this_configuration(self): + """ + It takes the hooks and the prefix by inheritance rather than restating them. + + The committed reference for this service is generated under that + module, and assigning the key there would replace this configuration + wholesale, as the two modules above do -- which would publish the whole + service under no title instead of this API's own surface. + """ + assert "SPECTACULAR_SETTINGS" not in self._source("cms/envs/development.py") + class _EmptySerializer(serializers.Serializer): # pylint: disable=abstract-method pass From a76673af8a3ff83e84b6f17a759b367e8ff12618 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 07:58:41 +0500 Subject: [PATCH 33/82] fix: carry inside a preference only the owner entries the caller may be given --- .../djangoapps/user_api/v2/serializers.py | 21 ++- .../user_api/v2/tests/test_query_counts.py | 34 +++- .../user_api/v2/tests/test_views.py | 156 ++++++++++++++++++ openedx/core/djangoapps/user_api/v2/views.py | 31 ++++ 4 files changed, 237 insertions(+), 5 deletions(-) diff --git a/openedx/core/djangoapps/user_api/v2/serializers.py b/openedx/core/djangoapps/user_api/v2/serializers.py index cb1cba5590d7..8101669842b2 100644 --- a/openedx/core/djangoapps/user_api/v2/serializers.py +++ b/openedx/core/djangoapps/user_api/v2/serializers.py @@ -30,6 +30,13 @@ #: Properties the ``minimal`` preset keeps of a stored preference. PREFERENCE_MINIMAL_FIELDS = ("username", "key", "value") +#: Context member naming the users whose whole directory entry a response may +#: carry, by id. A directory entry belongs to the user collection, so which +#: entries a caller may be given is that collection's decision; the view +#: settles it for a whole page and leaves the answer here, so that reading it +#: costs no query per row. +EMBEDDABLE_OWNER_IDS = "embeddable_owner_ids" + class ErrorResponse(ErrorResponseSerializer): # pylint: disable=abstract-method """ @@ -164,8 +171,9 @@ 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. + when the caller asks for the ``full`` response preset and is one the + caller may be given; by default the owner appears as the ``username`` + string alone. """ url = serializers.SerializerMethodField(help_text="Absolute URL of this preference.") @@ -177,7 +185,10 @@ class UserPreferenceRecordSerializer(serializers.ModelSerializer): 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.", + help_text=( + "Directory entry of the owning user; null unless the full response preset is asked " + "for and the entry is one the caller may be given." + ), ) class Meta: @@ -197,9 +208,11 @@ def get_url(self, preference): @extend_schema_field(UserDirectorySerializer(allow_null=True)) def get_user(self, preference): - """Embed the owner only when the caller asked for the full preset.""" + """Embed the owner's entry when the caller asked for the full preset and may be given it.""" if not full_view_requested(self.context.get("request")): return None + if preference.user_id not in (self.context.get(EMBEDDABLE_OWNER_IDS) or ()): + return None return UserDirectorySerializer(preference.user, context=self.context).data 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 index 2b0d8b87c349..0f9afe073fd0 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_query_counts.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_query_counts.py @@ -24,8 +24,15 @@ USER_LIST_QUERIES = 16 USER_DETAIL_QUERIES = 15 PREFERENCE_LIST_QUERIES = 15 -PREFERENCE_LIST_FULL_VIEW_QUERIES = 16 +#: One more than the list costs without the preset: the owners whose entry +#: a page may carry are resolved for the whole page in a single query, +#: whatever the number of rows. +PREFERENCE_LIST_FULL_VIEW_QUERIES = 17 PREFERENCE_DETAIL_QUERIES = 14 +#: What the same address costs with the owner's entry asked for: the entry's +#: own preferences, and the one query that settles which owners' entries may +#: be carried. +PREFERENCE_DETAIL_FULL_VIEW_QUERIES = 16 TIME_ZONE_LIST_QUERIES = 13 #: Address of the stored-preference list this version replaces, and the key it @@ -152,6 +159,31 @@ def test_member_query_counts(client, directory, django_assert_num_queries): assert response.json()["value"] == "en" +@pytest.mark.django_db +@pytest.mark.parametrize("directory", [5], indirect=True) +@skip_unless_lms +def test_the_member_address_pays_a_flat_cost_for_the_entry_it_carries( + client, directory, django_assert_num_queries, +): # pylint: disable=unused-argument + """ + Asking for one preference whole costs the same whatever else is stored. + + The entry the row carries brings the owner's own preferences with it and + the owners whose entry may be carried are resolved once, so the cost is + the member cost plus a fixed amount and does not move with the number of + rows the collection holds. + """ + address = reverse( + "user_v2:user_preference_detail", kwargs={"username": "counted0", "key": "pref-lang"}, + ) + with django_assert_num_queries(PREFERENCE_DETAIL_FULL_VIEW_QUERIES): + response = client.get(address, {"view": "full"}) + body = response.json() + assert body["value"] == "en" + assert body["user"]["username"] == "counted0" + assert body["user"]["preferences"] == {"pref-lang": "en", "time_zone": "UTC"} + + @pytest.mark.django_db @pytest.mark.parametrize("directory", ROW_COUNTS, indirect=True) @skip_unless_lms diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_views.py index 91f0fe5ddbf7..60f931ab9eca 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_views.py @@ -2001,3 +2001,159 @@ def test_narrowing_an_embedded_owners_preferences_costs_no_query_per_row(self): assert len(response.data["results"]) == rows * 2 + 2 measured[rows] = len(captured) assert measured[5] == measured[25], measured + + +class HiddenOwnerPolicy: + """A record-visibility policy over owners that hides one of them and counts its calls.""" + + def __init__(self, hidden_username): + self.hidden_username = hidden_username + self.calls = 0 + + def scope(self, queryset, subject): # pylint: disable=unused-argument + """Return the rows of ``queryset`` that are not the hidden owner.""" + self.calls += 1 + return queryset.exclude(username=self.hidden_username) + + +@skip_unless_lms +class EmbeddedOwnerScopingTests(UserApiV2TestCase): + """ + The owner carried inside a stored preference comes through the policy that governs owners. + + A preference asked for whole carries its owner's directory entry, which is + a row of the directory and not of the preference collection, so which + owners a caller may see is the directory's decision. An entry the caller + may not see is left out of the answer; the preference itself is still + returned, named by the owner it is stored against, because which + preferences come back is the preference collection's own decision. + """ + + def preferences_of(self, rows): + """Create ``rows`` users, each with two stored preferences.""" + for index in range(rows): + user = UserFactory.create(username=f"row-{index:03d}", password=PASSWORD) + UserPreferenceFactory.create(user=user, key="pref-lang", value="en") + UserPreferenceFactory.create(user=user, key="time_zone", value="UTC") + + def test_an_owner_the_embedded_policy_leaves_out_is_not_carried_inside_a_page(self): + policy = HiddenOwnerPolicy("alice") + self.login(self.admin) + with patch.object(UserPreferenceViewSet, "embedded_user_policy", policy, create=True): + response = self.client.get( + reverse("user_v2:user_preference_list"), {"view": "full", "page_size": 100}, + ) + assert response.status_code == status.HTTP_200_OK + carried = {row["username"]: row["user"] for row in response.data["results"]} + assert set(carried) == {"alice", "bob"} + assert carried["alice"] is None + assert carried["bob"]["username"] == "bob" + assert carried["bob"]["email"] == "bob@example.com" + + def test_an_owner_the_embedded_policy_leaves_out_is_not_carried_inside_one_preference(self): + policy = HiddenOwnerPolicy("alice") + self.login(self.admin) + with patch.object(UserPreferenceViewSet, "embedded_user_policy", policy, create=True): + response = self.client.get( + reverse("user_v2:user_preference_detail", kwargs={"username": "alice", "key": "pref-lang"}), + {"view": "full"}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["user"] is None + assert response.data["username"] == "alice" + assert response.data["key"] == "pref-lang" + assert response.data["value"] == "en" + + def test_an_owner_the_embedded_policy_keeps_is_still_carried(self): + """ + An owner the policy keeps comes back whole. + + This is a control rather than a guard: it reads a preference whose + owner the policy keeps, so it is green whether or not the policy is + consulted. It is here to show the answer is not narrowed too far. + """ + policy = HiddenOwnerPolicy("alice") + self.login(self.admin) + with patch.object(UserPreferenceViewSet, "embedded_user_policy", policy, create=True): + response = self.client.get( + reverse("user_v2:user_preference_detail", kwargs={"username": "bob", "key": "time_zone"}), + {"view": "full"}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["user"]["username"] == "bob" + assert response.data["user"]["preferences"] == {"time_zone": "UTC"} + + def test_the_owners_of_a_page_are_resolved_once_however_many_rows_it_carries(self): + """ + The owners a page may carry are settled for the whole page, so the cost does not grow with rows. + + One row would hide a per-row lookup, so this is measured at five rows + and again at twenty-five, and the policy is asked once either way. + """ + self.login(self.admin) + measured = {} + address = reverse("user_v2:user_preference_list") + for rows in (5, 25): + User.objects.filter(username__startswith="row-").delete() + self.preferences_of(rows) + policy = HiddenOwnerPolicy("alice") + with patch.object(UserPreferenceViewSet, "embedded_user_policy", policy, create=True): + # The first request of a signed-in client pays for its session, + # which is not part of what is being measured here. + self.client.get(address, {"view": "full", "page_size": 100}) + with CaptureQueriesContext(connection) as captured: + response = self.client.get(address, {"view": "full", "page_size": 100}) + assert response.status_code == status.HTTP_200_OK + assert len(response.data["results"]) == rows * 2 + 2 + assert policy.calls == 2, "the owners of a page are resolved once per request" + measured[rows] = len(captured) + assert measured[5] == measured[25], measured + + def test_no_owner_is_resolved_for_a_page_that_carries_none(self): + """A page that does not carry its owners does not ask which owners it could carry.""" + self.login(self.admin) + policy = HiddenOwnerPolicy("alice") + with patch.object(UserPreferenceViewSet, "embedded_user_policy", policy, create=True): + response = self.client.get(reverse("user_v2:user_preference_list"), {"page_size": 100}) + assert response.status_code == status.HTTP_200_OK + assert [row["username"] for row in response.data["results"]] == ["alice", "bob"] + assert policy.calls == 0 + + def test_a_policy_answering_with_a_collection_of_its_own_still_narrows_what_is_carried(self): + """ + A policy need not narrow the collection it is handed for its answer to hold. + + The owners of the page are taken out of what the policy answers with, + so a policy that builds its own collection instead of narrowing the + one it is given narrows the page just the same. + """ + class OwnCollectionPolicy: + """A policy that answers with a collection of its own.""" + + def scope(self, queryset, subject): # pylint: disable=unused-argument + """Return the one owner this policy keeps, built without the given collection.""" + return User.objects.filter(username="bob") + + self.login(self.admin) + with patch.object(UserPreferenceViewSet, "embedded_user_policy", OwnCollectionPolicy(), create=True): + response = self.client.get( + reverse("user_v2:user_preference_list"), {"view": "full", "page_size": 100}, + ) + assert response.status_code == status.HTTP_200_OK + carried = {row["username"]: row["user"] for row in response.data["results"]} + assert carried["alice"] is None + assert carried["bob"]["username"] == "bob" + + def test_the_embedded_owner_policy_is_declared_by_the_view_that_embeds(self): + """ + The view names the policy that governs the owners it carries, rather than inheriting one. + + A view that inherited the default would be left with no policy over + the entries it embeds, which is how a view that stopped declaring one + is told apart from a view whose policy happens to keep every owner. + """ + declared = vars(UserPreferenceViewSet).get("embedded_user_policy") + assert declared is not None, "UserPreferenceViewSet declares no policy over the owners it carries" + assert callable(getattr(declared, "scope", None)), ( + "UserPreferenceViewSet declares a policy over the owners it carries that cannot narrow rows" + ) diff --git a/openedx/core/djangoapps/user_api/v2/views.py b/openedx/core/djangoapps/user_api/v2/views.py index e644a92f69b3..068ae77c2e95 100644 --- a/openedx/core/djangoapps/user_api/v2/views.py +++ b/openedx/core/djangoapps/user_api/v2/views.py @@ -76,6 +76,7 @@ ACCOUNT_PREFERENCE_DOCUMENT, ACCOUNT_PREFERENCES_SCHEMA, EMAIL_SEARCH_MAX_ADDRESSES, + EMBEDDABLE_OWNER_IDS, FULL_VIEW, MINIMAL_VIEW, PREFERENCE_MINIMAL_FIELDS, @@ -817,6 +818,15 @@ class UserPreferenceViewSet( #: entry rather than as rows of the page, so the policy that governs them #: is applied where they are fetched. embedded_preference_policy = FullScopePolicy() + #: The owner's entry is itself a row of the user collection rather than of + #: this one, so which owners a caller may be given the entry of is that + #: collection's decision and is declared separately for that reason. It is + #: settled for a whole page in one query, because asking it of each row in + #: turn would cost a query per row. An owner the policy leaves out is left + #: out of the row that carries it; the row itself stays, named by the owner + #: it is stored against, because which rows come back is this collection's + #: own decision and is made by the policy above it. + embedded_user_policy = FullScopePolicy() filter_backends = (DjangoFilterBackend, StrictOrderingFilter) filterset_class = UserPreferenceFilterSet ordering_fields = PREFERENCE_SORT_FIELDS @@ -842,6 +852,27 @@ def get_queryset(self): ) return queryset + def get_serializer(self, *args, **kwargs): + """Build the serializer, having settled which owners the rows it is given may carry.""" + serializer = super().get_serializer(*args, **kwargs) + if args and full_view_requested(getattr(self, "request", None)): + serializer.context[EMBEDDABLE_OWNER_IDS] = self._embeddable_owner_ids(args[0]) + return serializer + + def _embeddable_owner_ids(self, rows): + """Return the ids of the owners of ``rows`` whose entry the caller may be given, in one query.""" + if isinstance(rows, UserPreference): + rows = [rows] + owner_ids = {row.user_id for row in rows} + if not owner_ids: + return frozenset() + # The owners of the page are taken out of the policy's answer rather + # than handed to it: a policy free to answer with a collection of its + # own is then still narrowed to this page, and what comes back is the + # size of the page rather than the size of the collection. + owners = self.embedded_user_policy.scope(User.objects.all(), self.request.user) + return frozenset(owners.filter(pk__in=owner_ids).values_list("pk", flat=True)) + def get_object(self): """Look the preference up by the username and key that identify it.""" try: From 8e7a19c0fd7c94cb4f716fdb92eb4d2e17aa2b55 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 08:00:36 +0500 Subject: [PATCH 34/82] fix: say in the document that a change has to name a preference --- .../djangoapps/user_api/v2/serializers.py | 7 +++-- .../user_api/v2/tests/test_schema.py | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/openedx/core/djangoapps/user_api/v2/serializers.py b/openedx/core/djangoapps/user_api/v2/serializers.py index 8101669842b2..0f0e8e618f27 100644 --- a/openedx/core/djangoapps/user_api/v2/serializers.py +++ b/openedx/core/djangoapps/user_api/v2/serializers.py @@ -873,14 +873,17 @@ def _why_text_cannot_be_carried(text): #: What a merge patch of the preference document carries: the same object as -#: the document, except that a member may also be null. +#: the document, except that a member may also be null, and that it has to +#: name something -- a body naming nothing to change is refused as it is read, +#: so the document says so too. ACCOUNT_PREFERENCE_CHANGES_SCHEMA = { "type": "object", + "minProperties": 1, "additionalProperties": {"type": "string", "nullable": True}, "description": ( "Preferences to change, as an object of preference name to the value to store under it. " "Every value is text, and a null removes the preference. Names the body leaves out are " - "left as they are." + "left as they are. At least one name must be given." ), } diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py index 42bf850b6956..2424ce328d8d 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py @@ -2064,6 +2064,37 @@ def test_a_merge_patch_the_endpoint_carries_out_is_one_the_request_body_takes(cl assert client.get(address).json() == {} +@pytest.mark.django_db +@skip_unless_lms +def test_a_change_that_names_no_preference_is_refused_by_the_document_too(client, schema): + """ + A body naming nothing to change is refused, and the document says so as well as the handler. + + A caller that generates a request from the document should learn that the + body has to name something before it is sent, rather than being told so + by a refusal. + """ + owner = AdminFactory.create(username="empty-patch", password=PASSWORD) + UserPreferenceFactory.create(user=owner, key="time_zone", value="Asia/Tokyo") + assert client.login(username=owner.username, password=PASSWORD) + validator = jsonschema.Draft202012Validator(published_request_schema( + schema, PREFERENCE_DOCUMENT_PATH, "patch", "application/merge-patch+json", + )) + assert [error.message for error in validator.iter_errors({})] == [ + "{} should be non-empty", + ] + assert [error.message for error in validator.iter_errors({"time_zone": "Europe/Paris"})] == [] + refusal = client.patch( + reverse("user_v2:account_preference_list", kwargs={"username": owner.username}), + data=json.dumps({}), + content_type="application/merge-patch+json", + ) + assert refusal.status_code == 400, refusal.content + assert refusal.json()["errors"] == { + "non_field_errors": ["Name at least one preference to change."], + } + + @pytest.mark.django_db @skip_unless_lms def test_a_preference_document_the_endpoint_answers_is_what_the_document_describes(client, schema): From 236e7d7923d875929bbbfb70d3209b7587e42ae4 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 08:04:42 +0500 Subject: [PATCH 35/82] fix: name the refusal of an address more than one account answers to --- openedx/core/djangoapps/profile_images/v2/views.py | 4 ++-- openedx/core/djangoapps/user_api/v2/errors.py | 13 +++++++++++++ .../user_api/v2/tests/test_account_views.py | 7 ++++--- .../user_api/v2/tests/test_name_change_views.py | 2 +- openedx/core/djangoapps/user_api/v2/views.py | 4 ++-- 5 files changed, 22 insertions(+), 8 deletions(-) diff --git a/openedx/core/djangoapps/profile_images/v2/views.py b/openedx/core/djangoapps/profile_images/v2/views.py index 864c9b52a9d3..b46980f68e6b 100644 --- a/openedx/core/djangoapps/profile_images/v2/views.py +++ b/openedx/core/djangoapps/profile_images/v2/views.py @@ -22,7 +22,7 @@ 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.errors import AmbiguousAccountName, 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 ( @@ -272,7 +272,7 @@ def _record_image(self, username, is_uploaded): raise NotFound from exc except MultipleObjectsReturned as exc: log.info("Profile image not recorded for %r: the name answers for more than one account", username) - raise ValidationError({ + raise AmbiguousAccountName({ api_settings.NON_FIELD_ERRORS_KEY: [AMBIGUOUS_ACCOUNT_ADDRESS], }) from exc diff --git a/openedx/core/djangoapps/user_api/v2/errors.py b/openedx/core/djangoapps/user_api/v2/errors.py index f21dee73dc4c..6bd6b184f0cf 100644 --- a/openedx/core/djangoapps/user_api/v2/errors.py +++ b/openedx/core/djangoapps/user_api/v2/errors.py @@ -7,6 +7,7 @@ NotFound, ParseError, UnsupportedMediaType, + ValidationError, ) from rest_framework.settings import api_settings @@ -27,6 +28,17 @@ class MalformedBody(ParseError): """A request body that is not well formed in the media type it was sent as.""" +class AmbiguousAccountName(ValidationError): + """ + An address answered by more than one account, naming no one of them. + + The refusal is one about what the request asked for, so it keeps the + status of the refusals beside it; what it adds is a name for the one + condition, which a caller can tell apart from a value it sent being + wrong and can act on by naming an account as it is stored. + """ + + class RetiredAccount(NotFound): """ An account that has been retired, addressed by something it used to hold. @@ -49,6 +61,7 @@ def __init__(self, detail=None, can_cancel_retirement=False, retirement_id=None) 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") +register_error_type(AmbiguousAccountName, "user/ambiguous-account-name", "Ambiguous Account Name") def refusal_sentence(refusals): 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 index a5ce0b6b634e..76a0a33e4586 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py @@ -763,8 +763,9 @@ def two_accounts(request, usernames=None, **kwargs): # pylint: disable=unused-a with mock.patch(ACCOUNT_LOOKUP, two_accounts): response = self.client.get(self.detail_url("owner-caller ")) envelope = assert_error_envelope( - response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="user/ambiguous-account-name", ) + assert envelope["title"] == "Ambiguous Account Name" assert envelope["errors"] == {"non_field_errors": [AMBIGUOUS_ACCOUNT_ADDRESS]} def test_a_member_address_one_of_several_accounts_is_stored_under_carries_that_one(self): @@ -2113,7 +2114,7 @@ def test_a_deactivation_whose_address_names_more_than_one_account_deactivates_no envelope = assert_error_envelope( self.client.post(address), expected_status=status.HTTP_400_BAD_REQUEST, - expected_type_slug="validation", + expected_type_slug="user/ambiguous-account-name", ) assert envelope["errors"] == {"non_field_errors": [AMBIGUOUS_ACCOUNT_ADDRESS]} for user in (self.owner, self.other, self.staff): @@ -2152,7 +2153,7 @@ def test_an_image_whose_address_names_more_than_one_account_is_left_alone(self): envelope = assert_error_envelope( self.client.delete(address), expected_status=status.HTTP_400_BAD_REQUEST, - expected_type_slug="validation", + expected_type_slug="user/ambiguous-account-name", ) assert envelope["errors"] == {"non_field_errors": [AMBIGUOUS_ACCOUNT_ADDRESS]} self.owner.profile.refresh_from_db() 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 index 4321e5a49e33..cdd8cc294a39 100644 --- 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 @@ -470,7 +470,7 @@ def test_an_address_naming_more_than_one_account_changes_nothing(self): envelope = assert_error_envelope( self.client.post(self.url), expected_status=status.HTTP_400_BAD_REQUEST, - expected_type_slug="validation", + expected_type_slug="user/ambiguous-account-name", ) assert envelope["errors"] == {"non_field_errors": [AMBIGUOUS_ACCOUNT_ADDRESS]} assert self.profile_name(self.owner) == "Original Name" diff --git a/openedx/core/djangoapps/user_api/v2/views.py b/openedx/core/djangoapps/user_api/v2/views.py index 068ae77c2e95..43a21589e99e 100644 --- a/openedx/core/djangoapps/user_api/v2/views.py +++ b/openedx/core/djangoapps/user_api/v2/views.py @@ -56,7 +56,7 @@ ) from openedx.core.lib.api.parsers import MergePatchParser -from .errors import CatalogedRequestErrorsMixin, ErrorExtensionsMixin, RetiredAccount +from .errors import AmbiguousAccountName, CatalogedRequestErrorsMixin, ErrorExtensionsMixin, RetiredAccount from .filters import StrictOrderingFilter, UserFilterSet, UserPreferenceFilterSet from .paginators import UserApiPagination from .permissions import CanSelectAccountsByContact, IsActiveUser, IsAddressedUser, IsStaffOrAbsent @@ -494,7 +494,7 @@ def _the_one_addressed(username, records, name_of): if stored_under_it: return stored_under_it[0] if len(records) > 1: - raise ValidationError({api_settings.NON_FIELD_ERRORS_KEY: [AMBIGUOUS_ACCOUNT_ADDRESS]}) + raise AmbiguousAccountName({api_settings.NON_FIELD_ERRORS_KEY: [AMBIGUOUS_ACCOUNT_ADDRESS]}) if not records: raise NotFound return records[0] From 946aee97d80186bcb6cec426dce5ff41c697e553 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 08:15:59 +0500 Subject: [PATCH 36/82] fix: record a profile image on the account the request was admitted for --- .../profile_images/v2/tests/test_views.py | 62 +++++++++++++++++++ .../djangoapps/profile_images/v2/views.py | 35 +++-------- .../user_api/accounts/image_helpers.py | 14 +++-- .../user_api/v2/tests/test_account_views.py | 22 +++---- .../user_api/v2/tests/test_schema.py | 3 +- 5 files changed, 91 insertions(+), 45 deletions(-) diff --git a/openedx/core/djangoapps/profile_images/v2/tests/test_views.py b/openedx/core/djangoapps/profile_images/v2/tests/test_views.py index b9e5dd5ce49e..b997af657c5e 100644 --- a/openedx/core/djangoapps/profile_images/v2/tests/test_views.py +++ b/openedx/core/djangoapps/profile_images/v2/tests/test_views.py @@ -11,6 +11,7 @@ import freezegun import pytest +from django.contrib.auth.models import User # pylint: disable=imported-auth-user from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse from edx_rest_framework_extensions.testing import assert_error_envelope @@ -23,7 +24,9 @@ from openedx.core.djangoapps.user_api.accounts.image_helpers import ( get_profile_image_names, get_profile_image_storage, + set_has_profile_image, ) +from openedx.core.djangoapps.user_api.errors import UserNotFound from openedx.core.djangolib.testing.utils import skip_unless_lms from ...tests.helpers import make_image_file @@ -861,6 +864,65 @@ def test_both_versions_refuse_another_learners_image(self): assert self.stored_sizes(self.owner.username) == set() +@skip_unless_lms +class ImageRecordingTests(APITestCase): + """ + What an image is recorded on is the account handed over, not a name read again. + + The recording helper takes either the account itself or the name of one. A + caller that has already settled which account it is acting on hands over + that account, and that is the account written to; a caller that has only a + name is served by the name, as before. + """ + + def setUp(self): + super().setUp() + self.owner = UserFactory.create(username="recorded-owner", password=PASSWORD) + self.uploaded_at = datetime.datetime(2026, 3, 4, 5, 6, tzinfo=ZoneInfo("UTC")) + + def test_the_account_handed_over_is_the_account_recorded_on(self): + """ + The account's own row is the one written, so no name is read on the way to it. + + The account is given a different name in the database behind the + record handed over, which a write that read a name would not find. + """ + User.objects.filter(pk=self.owner.pk).update(username="recorded-owner-elsewhere") + set_has_profile_image(self.owner, True, self.uploaded_at) + self.owner.profile.refresh_from_db() + assert self.owner.profile.has_profile_image is True + assert self.owner.profile.profile_image_uploaded_at == self.uploaded_at + + def test_an_account_handed_over_for_clearing_is_the_account_cleared(self): + set_has_profile_image(self.owner, True, self.uploaded_at) + User.objects.filter(pk=self.owner.pk).update(username="recorded-owner-elsewhere") + set_has_profile_image(self.owner, False) + self.owner.profile.refresh_from_db() + assert self.owner.profile.has_profile_image is False + assert self.owner.profile.profile_image_uploaded_at is None + + def test_a_name_handed_over_is_still_read_as_a_name(self): + """A caller holding a name rather than an account is served as it was.""" + set_has_profile_image(self.owner.username, True, self.uploaded_at) + self.owner.profile.refresh_from_db() + assert self.owner.profile.has_profile_image is True + assert self.owner.profile.profile_image_uploaded_at == self.uploaded_at + + def test_a_name_no_account_holds_is_refused(self): + with pytest.raises(UserNotFound): + set_has_profile_image("nobody-holds-this-name", False) + + def test_an_account_carrying_no_profile_is_refused(self): + profileless = UserFactory.create(username="recorded-no-profile", password=PASSWORD) + profileless.profile.delete() + with pytest.raises(UserNotFound): + set_has_profile_image(profileless, False) + + def test_recording_an_image_without_saying_when_is_refused(self): + with pytest.raises(ValueError): + set_has_profile_image(self.owner, True) + + @pytest.fixture(name="image_owner") @skip_unless_lms def image_owner_fixture(client, django_user_model): # pylint: disable=unused-argument diff --git a/openedx/core/djangoapps/profile_images/v2/views.py b/openedx/core/djangoapps/profile_images/v2/views.py index b46980f68e6b..f96de675431e 100644 --- a/openedx/core/djangoapps/profile_images/v2/views.py +++ b/openedx/core/djangoapps/profile_images/v2/views.py @@ -7,7 +7,6 @@ from zoneinfo import ZoneInfo import piexif -from django.core.exceptions import MultipleObjectsReturned from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser @@ -17,12 +16,11 @@ from rest_framework.exceptions import NotFound, ValidationError from rest_framework.parsers import FormParser, MultiPartParser from rest_framework.response import Response -from rest_framework.settings import api_settings 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 AmbiguousAccountName, CatalogedRequestErrorsMixin +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 ( @@ -33,11 +31,7 @@ StrictQueryParametersMixin, path_param, ) -from openedx.core.djangoapps.user_api.v2.views import ( - AMBIGUOUS_ACCOUNT_ADDRESS, - RESP_NOT_ACCEPTABLE, - AccountResourceMixin, -) +from openedx.core.djangoapps.user_api.v2.views import RESP_NOT_ACCEPTABLE, AccountResourceMixin from openedx.core.lib.api.parsers import TypedFileUploadParser from ..exceptions import ImageValidationError @@ -190,7 +184,7 @@ def post(self, request, username): 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) + self._record_image(request.user, True) log.info( "Generated and uploaded images %(image_names)s for user %(user_id)s", {"image_names": list(image_names.values()), "user_id": request.user.id}, @@ -210,10 +204,7 @@ def post(self, request, username): 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, or the " - "address names more than one account and so names none of them in particular." - ), + description="The request carries a query parameter this endpoint does not read.", ), 401: RESP_UNAUTHENTICATED, 403: _RESP_ANOTHER_USERS_IMAGE, @@ -223,7 +214,7 @@ def post(self, request, username): ) def delete(self, request, username): """Remove the stored sizes of the user's image.""" - self._record_image(username, False) + self._record_image(request.user, False) image_names = get_profile_image_names(username) remove_profile_images(image_names) log.info( @@ -256,25 +247,19 @@ def _read_image(self, username, uploaded_file): finally: uploaded_file.seek(0) - def _record_image(self, username, is_uploaded): + def _record_image(self, user, is_uploaded): """ Record on the account whether it carries an image of its own. - The account is looked up again by the name in the address to record - this, and a name that answers for more than one account settles on - none of them, so nothing is recorded and the request is refused rather - than written to whichever account came back. + The account recorded on is the one the request was admitted for, which + is handed over here, so what is recorded lands on that account and on + no other. """ uploaded_at = datetime.datetime.now(ZoneInfo("UTC")) if is_uploaded else None try: - set_has_profile_image(username, is_uploaded, uploaded_at) + set_has_profile_image(user, is_uploaded, uploaded_at) except UserNotFound as exc: raise NotFound from exc - except MultipleObjectsReturned as exc: - log.info("Profile image not recorded for %r: the name answers for more than one account", username) - raise AmbiguousAccountName({ - api_settings.NON_FIELD_ERRORS_KEY: [AMBIGUOUS_ACCOUNT_ADDRESS], - }) from exc def _unusable_image(self, username, message, reason): """Return the refusal telling the caller what the site cannot do with this image.""" diff --git a/openedx/core/djangoapps/user_api/accounts/image_helpers.py b/openedx/core/djangoapps/user_api/accounts/image_helpers.py index bb2ff19321cb..3917a1957ed4 100644 --- a/openedx/core/djangoapps/user_api/accounts/image_helpers.py +++ b/openedx/core/djangoapps/user_api/accounts/image_helpers.py @@ -160,14 +160,17 @@ def _get_default_profile_image_urls(): ) -def set_has_profile_image(username, is_uploaded, upload_dt=None): +def set_has_profile_image(user, is_uploaded, upload_dt=None): """ System (not user-facing) API call used to store whether the user has uploaded a profile image, and if so, when. Used by profile_image API. Arguments: - username (django.contrib.auth.User.username): references the user who - uploaded an image. + user (django.contrib.auth.User or django.contrib.auth.User.username): + the user who uploaded an image, as either that user or the + username of one. A caller that has already settled which user it + is acting for passes that user, so that this records the image on + that user and nothing else is consulted on the way to it. is_uploaded (bool): whether or not the user has an uploaded profile image. @@ -178,14 +181,15 @@ def set_has_profile_image(username, is_uploaded, upload_dt=None): Raises: ValueError: is_uploaded was True, but no upload datetime was supplied. - UserNotFound: no user with username `username` exists. + UserNotFound: there is no such user, or the user has no profile. """ if is_uploaded and upload_dt is None: # pylint: disable=no-else-raise raise ValueError("No upload datetime was supplied.") elif not is_uploaded: upload_dt = None + selected = {'user__username': user} if isinstance(user, str) else {'user': user} try: - profile = UserProfile.objects.get(user__username=username) + profile = UserProfile.objects.get(**selected) except ObjectDoesNotExist: raise UserNotFound() # pylint: disable=raise-missing-from # noqa: B904 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 index 76a0a33e4586..bf82d6295d6c 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py @@ -2134,13 +2134,13 @@ def test_a_deactivation_lands_on_the_account_stored_under_the_name_addressed(sel assert not self.owner.has_usable_password() assert self.other.has_usable_password() - def test_an_image_whose_address_names_more_than_one_account_is_left_alone(self): + def test_an_image_is_recorded_on_the_account_the_request_was_admitted_for(self): """ - The image is recorded against the account the address names, or against none. + What the image is recorded on is the account the request was admitted for. - The record it is written to is resolved again by the name in the - address when it is written, so a name that answers for more than one - account is refused there and the stored image is left as it stands. + The account is handed to the write as a record rather than as a name + for the write to find for itself, which is what keeps the account + written to the account the caller was admitted for. """ address = reverse( "user_v2:account_profile_image", kwargs={"username": self.owner.username}, @@ -2148,14 +2148,10 @@ def test_an_image_whose_address_names_more_than_one_account_is_left_alone(self): self.login(self.owner) with mock.patch( "openedx.core.djangoapps.profile_images.v2.views.set_has_profile_image", - side_effect=UserProfile.MultipleObjectsReturned, - ): - envelope = assert_error_envelope( - self.client.delete(address), - expected_status=status.HTTP_400_BAD_REQUEST, - expected_type_slug="user/ambiguous-account-name", - ) - assert envelope["errors"] == {"non_field_errors": [AMBIGUOUS_ACCOUNT_ADDRESS]} + ) as recorded: + assert self.client.delete(address).status_code == status.HTTP_204_NO_CONTENT + assert recorded.call_args.args[0] == self.owner + assert recorded.call_args.args[1] is False self.owner.profile.refresh_from_db() assert self.owner.profile.has_profile_image is True diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py index 2424ce328d8d..b3ad41b6a959 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py @@ -1699,8 +1699,7 @@ def test_clearing_the_profile_image_publishes_that_it_reads_nothing(schema): assert operation["responses"]["204"]["description"] == "The account carries no image of its own." assert sorted(operation["responses"]) == ["204", "400", "401", "403", "404", "406"] assert operation["responses"]["400"]["description"] == ( - "The request carries a query parameter this endpoint does not read, or the address names " - "more than one account and so names none of them in particular." + "The request carries a query parameter this endpoint does not read." ) From 00c48de9fa87d58f7be0b50cc961f86905ab7ff0 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 09:12:17 +0500 Subject: [PATCH 37/82] fix: read an address held and left more than once through the addresses --- .../user_api/v2/tests/test_account_views.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) 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 index bf82d6295d6c..a61f57d7cf7e 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py @@ -619,6 +619,43 @@ def test_a_retirement_still_inside_the_cooling_off_period_can_be_cancelled(self) assert envelope["can_cancel_retirement"] is True assert envelope["retirement_id"] == retirement.id + def test_an_address_held_and_left_more_than_once_reports_the_most_recent_retirement(self): + """ + Where one address has been held and left more than once, the retirement reported is the last. + + Only the most recent retirement of an address is still open to being + undone, so that is the one the refusal names. The second account is + given the address outright rather than registered with it, because an + address an account held before it was retired is refused to a new one. + Each account here is this test's own: what retirement leaves behind of + an account cannot be shared with another test. + """ + RetirementState.objects.create(state_name="PENDING", state_execution_order=1) + address = "held-and-left-twice@example.com" + first = UserFactory.create(username="first-holder", email=address, password=PASSWORD) + earliest = UserRetirementStatus.create_retirement(first) + first.email = get_retired_email_by_email(address) + first.save() + second = UserFactory.create( + username="second-holder", email="second-holder@example.com", password=PASSWORD, + ) + User.objects.filter(pk=second.pk).update(email=address) + second.refresh_from_db() + latest = UserRetirementStatus.create_retirement(second) + second.email = get_retired_email_by_email(address) + second.save() + assert sorted( + UserRetirementStatus.objects.filter(original_email=address).values_list("id", flat=True), + ) == sorted([earliest.id, latest.id]) + + envelope = assert_error_envelope( + self.client.get(self.list_url, {"email": address}), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="user/retired-account", + ) + assert envelope["can_cancel_retirement"] is True + assert envelope["retirement_id"] == latest.id + def test_a_retirement_older_than_the_cooling_off_period_can_no_longer_be_cancelled(self): RetirementState.objects.create(state_name="PENDING", state_execution_order=1) retirement = UserRetirementStatus.create_retirement(self.other) From 2dd57bd7cd99cd07f6b205c50007963f59884695 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 09:16:31 +0500 Subject: [PATCH 38/82] docs: record beside the name in an address what each address does with it --- .../djangoapps/profile_images/v2/views.py | 6 ++++ .../user_api/v2/tests/test_schema.py | 33 +++++++++++++++++++ openedx/core/djangoapps/user_api/v2/views.py | 7 ++++ 3 files changed, 46 insertions(+) diff --git a/openedx/core/djangoapps/profile_images/v2/views.py b/openedx/core/djangoapps/profile_images/v2/views.py index f96de675431e..5096cfb14ef7 100644 --- a/openedx/core/djangoapps/profile_images/v2/views.py +++ b/openedx/core/djangoapps/profile_images/v2/views.py @@ -40,6 +40,12 @@ log = logging.getLogger(__name__) +# The name in this address is read as the name of a stored account, and the +# shorthand the account collection takes for the caller's own account is not +# translated here: a request sent under that name works on the account +# registered under it. The description below publishes that to a caller; it is +# written here for the reader of the module, because an address serving one +# account from another app follows the same rule. _USERNAME_PATH_PARAM = path_param( "username", "Unique name of the user whose image is addressed. The name is read as it is stored, so " diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py index b3ad41b6a959..02a95017eb30 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py @@ -973,6 +973,39 @@ def test_member_addresses_describe_the_parts_they_are_built_from(schema): } +@skip_unless_lms +def test_the_addresses_that_work_on_one_account_describe_the_name_they_carry(schema): + """ + An address that works on one account publishes what the name in it is read as. + + Neither of these addresses takes the shorthand the account collection + takes for the caller's own account, so what each of them does with a + request sent under that name is published rather than left for a caller to + find out by sending one. + """ + published = { + (path, method): parameter["description"] + for path, method, operation in operations(schema) + if path in (ACCOUNT_DEACTIVATION_PATH, PROFILE_IMAGE_PATH) + for parameter in operation["parameters"] + if parameter["in"] == "path" and parameter["name"] == "username" + } + image_description = ( + "Unique name of the user whose image is addressed. The name is read as it is stored, so " + "`me` here names an account holding that name rather than the account of the caller " + "making the request." + ) + assert published == { + (ACCOUNT_DEACTIVATION_PATH, "post"): ( + "Unique name of the user whose account is deactivated. The name is read as it is " + "stored, `me` included: this action disables the account the address names, never " + "the account of the caller making the request." + ), + (PROFILE_IMAGE_PATH, "post"): image_description, + (PROFILE_IMAGE_PATH, "delete"): image_description, + } + + #: Every part of an address of this version that carries a username. A client #: built from the document holds a value to what the route matches before it #: sends a request, so an address left off this list is one whose caller learns diff --git a/openedx/core/djangoapps/user_api/v2/views.py b/openedx/core/djangoapps/user_api/v2/views.py index 43a21589e99e..b5200f5c54ec 100644 --- a/openedx/core/djangoapps/user_api/v2/views.py +++ b/openedx/core/djangoapps/user_api/v2/views.py @@ -163,6 +163,13 @@ ACCOUNT_PRESETS = (MINIMAL_VIEW, SHARED_VIEW) #: The member of the account collection that addresses the caller's own account. +#: It belongs to that collection, and the addresses beneath one account do not +#: translate it: each of those reads the name it is given as the name of a +#: stored account, so a request sent to one of them under this name works on +#: the account registered under it, whichever account that is. The preference +#: document of an account and the deactivation of one both publish that in the +#: description of the name they carry; it is written here as well because an +#: address added beside them inherits the rule and not the sentence. OWN_ACCOUNT_IDENTIFIER = "me" #: What a caller is told about a member address that more than one account From ce73cb8d048cc52cf025f315fca68d14c9085f61 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 09:22:01 +0500 Subject: [PATCH 39/82] test: say which refusal the recording of an image without a date is expected to be --- openedx/core/djangoapps/profile_images/v2/tests/test_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/profile_images/v2/tests/test_views.py b/openedx/core/djangoapps/profile_images/v2/tests/test_views.py index b997af657c5e..a3511b40aff7 100644 --- a/openedx/core/djangoapps/profile_images/v2/tests/test_views.py +++ b/openedx/core/djangoapps/profile_images/v2/tests/test_views.py @@ -919,7 +919,7 @@ def test_an_account_carrying_no_profile_is_refused(self): set_has_profile_image(profileless, False) def test_recording_an_image_without_saying_when_is_refused(self): - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="No upload datetime was supplied."): set_has_profile_image(self.owner, True) From b06352488d9fb8f1cce8752804c3ccfe814a38bd Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 22:14:15 +0500 Subject: [PATCH 40/82] refactor: hold these addresses, their descriptions and the hook that marks them outside both services The LMS and Studio serve the same user addresses and each publishes a document of everything it serves, so the list of those addresses, the names they keep, the bodies they exchange, the schema behaviour that applies all three and the hook that marks each one as replaced now live with the user API itself. Each service's schema module keeps only what that service decides for its own mounts, and each service's settings name the shared hook rather than the other service's module. The serializers the table of bodies reads are picked up by name while a document is being generated, as the application they belong to is picked up by name in the settings of each service that installs it. Both documents each service generates are unchanged, byte for byte. --- cms/envs/common.py | 2 +- cms/lib/spectacular.py | 2 +- cms/lib/tests/test_spectacular.py | 6 +- lms/envs/common.py | 1 + lms/lib/spectacular.py | 321 +---------------- lms/lib/tests/test_spectacular.py | 33 +- openedx/core/djangoapps/user_api/schema.py | 330 ++++++++++++++++++ .../user_api/v2/tests/test_schema.py | 13 +- openedx/core/tests/test_apidocs.py | 16 +- 9 files changed, 381 insertions(+), 343 deletions(-) create mode 100644 openedx/core/djangoapps/user_api/schema.py diff --git a/cms/envs/common.py b/cms/envs/common.py index 4f43d47e7363..99d97cf88c34 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1345,7 +1345,7 @@ def _should_send_xblock_events(settings): # which is what stops a client generated from Studio's document from # offering an address as current that the LMS document marks as # superseded. - 'lms.lib.spectacular.lms_mark_legacy_paths_deprecated', + 'openedx.core.djangoapps.user_api.schema.mark_superseded_user_paths', 'openedx.core.apidocs.untag_deprecated_operations', ], # Stripped from each path to derive its tags and operationId, so changing diff --git a/cms/lib/spectacular.py b/cms/lib/spectacular.py index 53819f34ac00..2498827d777d 100644 --- a/cms/lib/spectacular.py +++ b/cms/lib/spectacular.py @@ -4,7 +4,7 @@ from drf_spectacular.openapi import AutoSchema -from lms.lib.spectacular import SupersededUserOperations +from openedx.core.djangoapps.user_api.schema import SupersededUserOperations # Legacy operations superseded by a conforming address under /api/authoring/. # Each is listed in the truncated spelling as well as the full one: the routes diff --git a/cms/lib/tests/test_spectacular.py b/cms/lib/tests/test_spectacular.py index 1dccf7a146a7..df7869d7d81a 100644 --- a/cms/lib/tests/test_spectacular.py +++ b/cms/lib/tests/test_spectacular.py @@ -21,8 +21,12 @@ cms_mark_migrated_paths, cms_mark_superseded_paths, ) -from lms.lib.spectacular import SUPERSEDED_USER_PATHS, superseded_bodies from openedx.core.apidocs import get_api_docs_settings +from openedx.core.djangoapps.user_api.schema import ( + SUPERSEDED_USER_PATHS, + SupersededUserOperations, + superseded_bodies, +) def _endpoint(path): diff --git a/lms/envs/common.py b/lms/envs/common.py index 6f27f270965f..00c3aee24785 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2190,6 +2190,7 @@ 'POSTPROCESSING_HOOKS': [ 'drf_spectacular.hooks.postprocess_schema_enums', 'lms.lib.spectacular.lms_mark_legacy_paths_deprecated', + 'openedx.core.djangoapps.user_api.schema.mark_superseded_user_paths', 'openedx.core.apidocs.untag_deprecated_operations', ], # Stripped from each path to derive its tags and operationId, so changing diff --git a/lms/lib/spectacular.py b/lms/lib/spectacular.py index 2910d895b068..51a908c62bc7 100644 --- a/lms/lib/spectacular.py +++ b/lms/lib/spectacular.py @@ -1,11 +1,10 @@ """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 + +from openedx.core.djangoapps.user_api.schema import SupersededUserOperations, is_superseded_user_path LEGACY_V2_PREFIX = "/api/enrollment/v2/" @@ -14,108 +13,6 @@ # 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/{username}", - "/api/user/v1/preferences/{username}/{preference_key}", - "/api/user/v1/preferences/email_opt_in/", - "/api/user/v1/preferences/{pref_key}/users/", - "/api/user/v1/forum_roles/{name}/users/", - "/api/user/v1/preferences/time_zones/", -) - -# 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 the -# order the addresses sort. 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. -# -# The two preference reads tokenize to one id for the same reason. The numeral -# a document hands the second of them says which of the two addresses sorts -# first rather than what the operation reads, so the read of one preference is -# named for what it reads and the read of the whole document keeps the name -# both derive. Of the version 1 pair only the document that trims the area off -# the front of each path is covered: the one that trims nothing already -# publishes that read under the numeral, and a name a client imports by is not -# taken away. Neither document has published either name of the version 2 -# pair, so both are covered there, each through the name the document it -# covers derives; that leaves the five operations of that resource carrying the -# same leading tokens in every document, which is what a client generated from -# one takes its module from. -# -# A stored preference is addressed by the pair that identifies it, and the -# comma between the two parameters is left behind as a token of its own, so -# every document derives an id for that read carrying a bare separator where -# the rest of the resource carries a word. That read is named here in each -# document through the id that document derives, which leaves the collection -# read and the member read of the resource under the same leading tokens -# wherever they are published; neither document has published either name, so -# nothing a client imports is taken away. -RENAMED_OPERATION_IDS = { - ("/api/user/v1/accounts", "GET", "api_user_v1_accounts_list"): "api_user_v1_accounts_retrieve", - ("/api/user/v1/preferences/{username}/{preference_key}", "GET", "v1_preferences_retrieve"): ( - "v1_preferences_retrieve_one" - ), - ( - "/api/user/v2/accounts/{username}/preferences/{key}/", - "GET", - "v2_accounts_preferences_retrieve", - ): "v2_accounts_preferences_retrieve_one", - ( - "/api/user/v2/accounts/{username}/preferences/{key}/", - "GET", - "api_user_v2_accounts_preferences_retrieve", - ): "api_user_v2_accounts_preferences_retrieve_one", - ( - "/api/user/v2/user_preferences/{username},{key}/", - "GET", - "v2_user_preferences_,_retrieve", - ): "v2_user_preferences_retrieve", - ( - "/api/user/v2/user_preferences/{username},{key}/", - "GET", - "api_user_v2_user_preferences_,_retrieve", - ): "api_user_v2_user_preferences_retrieve", -} - -# Operations whose handler answers a request carrying no body with a 400. The -# body of each is described in place rather than through a serializer, and a -# shape given in place carries no field the generator can read requiredness -# from, so it publishes no mark at all and a client generated from the document -# makes the body an argument the caller may leave out. Each address is spelled -# with its parameter names removed, as the table of bodies below is. -# -# An address belongs here only once its handler has been sent a request with -# the form named and no body behind it and has refused it. The write of one -# stored preference is deliberately absent: that handler takes a request with -# no body and answers that the preference now stands as the request asked. -REQUIRED_REQUEST_BODIES = frozenset({ - ("/api/user/v1/preferences/{}", "PATCH"), - ("/api/user/v1/preferences/email_opt_in/", "POST"), - ("/api/user/v2/accounts/{}/preferences/", "PATCH"), -}) - PUBLISHED_PATH_PATTERN = re.compile(r"^/api/enrollment/v\d+/") # The user addresses this schema documents are the ones the version below @@ -139,146 +36,8 @@ 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 ( - ACCOUNT_PREFERENCE_CHANGES_SCHEMA, - ACCOUNT_PREFERENCES_SCHEMA, - AccountSerializer, - AccountUpdateSerializer, - EmailSearchResultSerializer, - EmailSearchSerializer, - NameChangeSerializer, - VerificationStatusSerializer, - ) - from openedx.core.lib.api.parsers import MergePatchParser - - account = {200: AccountSerializer} - # One preference is answered and stored as the bare value, which is what - # the address replacing it carries as a property of an object. - preference_value = {"type": "string"} - preferences_changed = { - 204: OpenApiResponse(description="The preferences now stand as the body asked."), - } - 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}, - ), - ("/api/user/v1/preferences/{username}", "GET"): _SupersededBodies( - None, - { - 200: OpenApiResponse( - ACCOUNT_PREFERENCES_SCHEMA, description="The preferences this account stores.", - ), - }, - ), - ("/api/user/v1/preferences/{username}", "PATCH"): _SupersededBodies( - {MergePatchParser.media_type: ACCOUNT_PREFERENCE_CHANGES_SCHEMA}, preferences_changed, - ), - ("/api/user/v1/preferences/{username}/{preference_key}", "GET"): _SupersededBodies( - None, {200: preference_value}, - ), - ("/api/user/v1/preferences/{username}/{preference_key}", "PUT"): _SupersededBodies( - {"application/json": preference_value}, preferences_changed, - ), - ("/api/user/v1/preferences/{username}/{preference_key}", "DELETE"): _SupersededBodies( - None, {204: OpenApiResponse(description="The account stores no value under that name.")}, - ), - # The handler reads the choice from the submitted form rather than from - # a body of its own, and reads each value as the text a form carries. - ("/api/user/v1/preferences/email_opt_in/", "POST"): _SupersededBodies( - { - "application/x-www-form-urlencoded": { - "type": "object", - "properties": { - "course_id": { - "type": "string", - "description": ( - "Course the organization runs, which is how the organization the " - "choice applies to is identified." - ), - }, - "email_opt_in": { - "type": "string", - "description": ( - "`True` if this user wants messages from that organization. Any " - "other value is read as not wanting them." - ), - }, - }, - "required": ["course_id", "email_opt_in"], - }, - }, - {200: OpenApiResponse(description="The choice was recorded.")}, - ), - } - # Parameter names are spelled differently at different points of - # generation, so an address is keyed by its shape alone. - return { - (_PATH_PARAMETER.sub("{}", path), method): bodies - for (path, method), bodies in described.items() - } - def _is_legacy_path(path): """Conforming routes always end in a slash, so a slashless v2 path is a legacy address.""" @@ -287,7 +46,7 @@ def _is_legacy_path(path): 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: + if is_superseded_user_path(path): return True if not path.startswith(PUBLISHED_USER_PREFIX): return False @@ -308,76 +67,6 @@ def lms_api_filter(endpoints): return filtered -class SupersededUserOperations: - """ - Describe the superseded user addresses wherever a service publishes them. - - Both services mount that API at the same addresses and both publish an - unauthenticated document of everything they serve, so what one of those - documents says about one of these addresses is what the other has to say - about it as well: a client generated from either reads the same body on - each side of the exchange, branches on the same success code, and imports - the same name. The behaviour is shared rather than restated in each - service's schema class, which is what keeps the two documents a deployment - publishes from describing one address two ways. - - Every address ``RENAMED_OPERATION_IDS`` does not name keeps the id the - generator derives for it, because a client generated from a document takes - a module from each operationId, so renaming one drops what a consumer - already imports. - """ - - def get_operation_id(self): - """Keep the name an address is published under where describing its bodies would move it.""" - operation_id = super().get_operation_id() - return RENAMED_OPERATION_IDS.get((self.path, self.method, operation_id), operation_id) - - def _get_request_body(self, direction="request"): - """ - 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. - - An operation whose body is described in place carries no such field - either, in any of its forms, so the ones whose handler refuses a - request that carries no body are named above and marked from there. - """ - body = super()._get_request_body(direction) - if not body: - return body - if self._operation_shape() in REQUIRED_REQUEST_BODIES: - body["required"] = True - return body - shapes = self.get_request_serializer() - if body.get("required") or not isinstance(shapes, dict): - return body - if any(self._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(self._operation_shape()) - - def _operation_shape(self): - """This operation as the tables above key one: its address without parameter names, and its method.""" - return (_PATH_PARAMETER.sub("{}", self.path), self.method) - - class LmsAutoSchema(SupersededUserOperations, AutoSchema): """ Keep colliding addresses distinct and group each operation under its own area. @@ -408,9 +97,9 @@ def get_tags(self): def lms_mark_legacy_paths_deprecated(result, generator, request, public): # pylint: disable=unused-argument - """Mark every superseded address ``deprecated: true``.""" + """Mark every legacy enrollment address ``deprecated: true``.""" for path, path_item in result.get("paths", {}).items(): - if not (_is_legacy_path(path) or path in SUPERSEDED_USER_PATHS): + if not _is_legacy_path(path): 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 ec1109e92636..b8a61440da59 100644 --- a/lms/lib/tests/test_spectacular.py +++ b/lms/lib/tests/test_spectacular.py @@ -22,20 +22,19 @@ from common.djangoapps.student.models_api import do_name_change_request from common.djangoapps.student.tests.factories import TEST_PASSWORD, UserFactory -from lms.lib.spectacular import ( - RENAMED_OPERATION_IDS, - SUPERSEDED_USER_PATHS, - LmsAutoSchema, - lms_api_filter, - lms_mark_legacy_paths_deprecated, - superseded_bodies, -) +from lms.lib.spectacular import LmsAutoSchema, lms_api_filter, lms_mark_legacy_paths_deprecated from openedx.core.apidocs import get_api_docs_settings from openedx.core.djangoapps.profile_images.tests.helpers import make_image_file from openedx.core.djangoapps.user_api.accounts.image_helpers import ( get_profile_image_names, get_profile_image_storage, ) +from openedx.core.djangoapps.user_api.schema import ( + RENAMED_OPERATION_IDS, + SUPERSEDED_USER_PATHS, + mark_superseded_user_paths, + superseded_bodies, +) ENROLLMENT_VIEWS = "openedx.core.djangoapps.enrollments.views" USER_API_V1_VIEWS = "openedx.core.djangoapps.user_api.views" @@ -135,8 +134,17 @@ 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_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) + assert list(result["paths"]) == ["/api/enrollment/v2/enrollments/"] + + +class MarkSupersededUserPathsTest(SimpleTestCase): + """Only the addresses the shared list names are marked, wherever a service publishes them.""" + def test_only_the_listed_user_addresses_are_deprecated(self): - result = lms_mark_legacy_paths_deprecated(_schema( + result = mark_superseded_user_paths(_schema( "/api/user/v1/users/", "/api/user/v1/users/{id}/", "/api/user/v1/preferences/time_zones/", @@ -190,11 +198,6 @@ def test_only_the_listed_user_addresses_are_deprecated(self): ): 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) - assert list(result["paths"]) == ["/api/enrollment/v2/enrollments/"] - class _EmptySerializer(serializers.Serializer): # pylint: disable=abstract-method pass @@ -292,7 +295,7 @@ def _derived_operations(schema_class, patches): telling two addresses that derived one name apart is handed out: a name carrying that numeral was derived without it, and is looked up without it. """ - with mock.patch.dict("lms.lib.spectacular.RENAMED_OPERATION_IDS", {}, clear=True): + with mock.patch.dict("openedx.core.djangoapps.user_api.schema.RENAMED_OPERATION_IDS", {}, clear=True): with patched_settings({"PREPROCESSING_HOOKS": [], **patches}): generator = SchemaGenerator(patterns=_user_routes(schema_class)) return _by_address(generator.parse(None, public=True)) diff --git a/openedx/core/djangoapps/user_api/schema.py b/openedx/core/djangoapps/user_api/schema.py new file mode 100644 index 000000000000..b8325563b8ea --- /dev/null +++ b/openedx/core/djangoapps/user_api/schema.py @@ -0,0 +1,330 @@ +"""How a document describes the addresses of this API that a conforming one replaces.""" + +import re +from collections import namedtuple +from functools import cache +from importlib import import_module + +from drf_spectacular.utils import OpenApiResponse + +# 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/{username}", + "/api/user/v1/preferences/{username}/{preference_key}", + "/api/user/v1/preferences/email_opt_in/", + "/api/user/v1/preferences/{pref_key}/users/", + "/api/user/v1/forum_roles/{name}/users/", + "/api/user/v1/preferences/time_zones/", +) + +# 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 the +# order the addresses sort. 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. +# +# The two preference reads tokenize to one id for the same reason. The numeral +# a document hands the second of them says which of the two addresses sorts +# first rather than what the operation reads, so the read of one preference is +# named for what it reads and the read of the whole document keeps the name +# both derive. Of the version 1 pair only the document that trims the area off +# the front of each path is covered: the one that trims nothing already +# publishes that read under the numeral, and a name a client imports by is not +# taken away. Neither document has published either name of the version 2 +# pair, so both are covered there, each through the name the document it +# covers derives; that leaves the five operations of that resource carrying the +# same leading tokens in every document, which is what a client generated from +# one takes its module from. +# +# A stored preference is addressed by the pair that identifies it, and the +# comma between the two parameters is left behind as a token of its own, so +# every document derives an id for that read carrying a bare separator where +# the rest of the resource carries a word. That read is named here in each +# document through the id that document derives, which leaves the collection +# read and the member read of the resource under the same leading tokens +# wherever they are published; neither document has published either name, so +# nothing a client imports is taken away. +RENAMED_OPERATION_IDS = { + ("/api/user/v1/accounts", "GET", "api_user_v1_accounts_list"): "api_user_v1_accounts_retrieve", + ("/api/user/v1/preferences/{username}/{preference_key}", "GET", "v1_preferences_retrieve"): ( + "v1_preferences_retrieve_one" + ), + ( + "/api/user/v2/accounts/{username}/preferences/{key}/", + "GET", + "v2_accounts_preferences_retrieve", + ): "v2_accounts_preferences_retrieve_one", + ( + "/api/user/v2/accounts/{username}/preferences/{key}/", + "GET", + "api_user_v2_accounts_preferences_retrieve", + ): "api_user_v2_accounts_preferences_retrieve_one", + ( + "/api/user/v2/user_preferences/{username},{key}/", + "GET", + "v2_user_preferences_,_retrieve", + ): "v2_user_preferences_retrieve", + ( + "/api/user/v2/user_preferences/{username},{key}/", + "GET", + "api_user_v2_user_preferences_,_retrieve", + ): "api_user_v2_user_preferences_retrieve", +} + +# Operations whose handler answers a request carrying no body with a 400. The +# body of each is described in place rather than through a serializer, and a +# shape given in place carries no field the generator can read requiredness +# from, so it publishes no mark at all and a client generated from the document +# makes the body an argument the caller may leave out. Each address is spelled +# with its parameter names removed, as the table of bodies below is. +# +# An address belongs here only once its handler has been sent a request with +# the form named and no body behind it and has refused it. The write of one +# stored preference is deliberately absent: that handler takes a request with +# no body and answers that the preference now stands as the request asked. +REQUIRED_REQUEST_BODIES = frozenset({ + ("/api/user/v1/preferences/{}", "PATCH"), + ("/api/user/v1/preferences/email_opt_in/", "POST"), + ("/api/user/v2/accounts/{}/preferences/", "PATCH"), +}) + +_PATH_PARAMETER = re.compile(r"\{([\w-]+)\}") + +# 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) + + +def is_superseded_user_path(path): + """Say whether this address is one of those a conforming address replaces.""" + return _PATH_PARAMETER.sub("{}", path) in _SUPERSEDED_USER_SHAPES + +#: What one superseded operation reads and what it answers with on success. +_SupersededBodies = namedtuple("_SupersededBodies", ("request", "response")) + + +@cache +def superseded_bodies(): + """ + Return the 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, so each module is read inside this one. One of them + # reaches models belonging to a single service, which code shared by both + # services may not import: a service picks that application up by name + # through its settings, and that module is picked up by name here for the + # same reason. + # pylint: disable=import-outside-toplevel + from openedx.core.djangoapps.profile_images.v2.serializers import IMAGE_UPLOAD_REQUEST + from openedx.core.lib.api.parsers import MergePatchParser + + replacing = import_module("openedx.core.djangoapps.user_api.v2.serializers") + + account = {200: replacing.AccountSerializer} + # One preference is answered and stored as the bare value, which is what + # the address replacing it carries as a property of an object. + preference_value = {"type": "string"} + preferences_changed = { + 204: OpenApiResponse(description="The preferences now stand as the body asked."), + } + described = { + ("/api/user/v1/accounts", "GET"): _SupersededBodies(None, {200: replacing.AccountSerializer(many=True)}), + ("/api/user/v1/accounts/{username}", "GET"): _SupersededBodies(None, account), + ("/api/user/v1/accounts/{username}", "PATCH"): _SupersededBodies( + {MergePatchParser.media_type: replacing.AccountUpdateSerializer}, account, + ), + ("/api/user/v1/accounts/search_emails", "POST"): _SupersededBodies( + replacing.EmailSearchSerializer, {200: replacing.EmailSearchResultSerializer(many=True)}, + ), + ("/api/user/v1/accounts/{username}/deactivate/", "POST"): _SupersededBodies(None, account), + ("/api/user/v1/accounts/name_change/", "POST"): _SupersededBodies( + replacing.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: replacing.VerificationStatusSerializer}, + ), + ("/api/user/v1/preferences/{username}", "GET"): _SupersededBodies( + None, + { + 200: OpenApiResponse( + replacing.ACCOUNT_PREFERENCES_SCHEMA, description="The preferences this account stores.", + ), + }, + ), + ("/api/user/v1/preferences/{username}", "PATCH"): _SupersededBodies( + {MergePatchParser.media_type: replacing.ACCOUNT_PREFERENCE_CHANGES_SCHEMA}, preferences_changed, + ), + ("/api/user/v1/preferences/{username}/{preference_key}", "GET"): _SupersededBodies( + None, {200: preference_value}, + ), + ("/api/user/v1/preferences/{username}/{preference_key}", "PUT"): _SupersededBodies( + {"application/json": preference_value}, preferences_changed, + ), + ("/api/user/v1/preferences/{username}/{preference_key}", "DELETE"): _SupersededBodies( + None, {204: OpenApiResponse(description="The account stores no value under that name.")}, + ), + # The handler reads the choice from the submitted form rather than from + # a body of its own, and reads each value as the text a form carries. + ("/api/user/v1/preferences/email_opt_in/", "POST"): _SupersededBodies( + { + "application/x-www-form-urlencoded": { + "type": "object", + "properties": { + "course_id": { + "type": "string", + "description": ( + "Course the organization runs, which is how the organization the " + "choice applies to is identified." + ), + }, + "email_opt_in": { + "type": "string", + "description": ( + "`True` if this user wants messages from that organization. Any " + "other value is read as not wanting them." + ), + }, + }, + "required": ["course_id", "email_opt_in"], + }, + }, + {200: OpenApiResponse(description="The choice was recorded.")}, + ), + } + # Parameter names are spelled differently at different points of + # generation, so an address is keyed by its shape alone. + return { + (_PATH_PARAMETER.sub("{}", path), method): bodies + for (path, method), bodies in described.items() + } + + +class SupersededUserOperations: + """ + Describe the superseded user addresses wherever a service publishes them. + + Both services mount that API at the same addresses and both publish an + unauthenticated document of everything they serve, so what one of those + documents says about one of these addresses is what the other has to say + about it as well: a client generated from either reads the same body on + each side of the exchange, branches on the same success code, and imports + the same name. The behaviour is shared rather than restated in each + service's schema class, which is what keeps the two documents a deployment + publishes from describing one address two ways. + + Every address ``RENAMED_OPERATION_IDS`` does not name keeps the id the + generator derives for it, because a client generated from a document takes + a module from each operationId, so renaming one drops what a consumer + already imports. + """ + + def get_operation_id(self): + """Keep the name an address is published under where describing its bodies would move it.""" + operation_id = super().get_operation_id() + return RENAMED_OPERATION_IDS.get((self.path, self.method, operation_id), operation_id) + + def _get_request_body(self, direction="request"): + """ + 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. + + An operation whose body is described in place carries no such field + either, in any of its forms, so the ones whose handler refuses a + request that carries no body are named above and marked from there. + """ + body = super()._get_request_body(direction) + if not body: + return body + if self._operation_shape() in REQUIRED_REQUEST_BODIES: + body["required"] = True + return body + shapes = self.get_request_serializer() + if body.get("required") or not isinstance(shapes, dict): + return body + if any(self._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(self._operation_shape()) + + def _operation_shape(self): + """This operation as the tables above key one: its address without parameter names, and its method.""" + return (_PATH_PARAMETER.sub("{}", self.path), self.method) + + +def mark_superseded_user_paths(result, generator, request, public): # pylint: disable=unused-argument + """Post-processing hook: mark every address a conforming one replaces ``deprecated: true``.""" + for path, path_item in result.get("paths", {}).items(): + if path not in SUPERSEDED_USER_PATHS: + continue + for operation in path_item.values(): + if isinstance(operation, dict): + operation["deprecated"] = True + return result diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py index 02a95017eb30..7a60a6adc357 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py @@ -17,11 +17,7 @@ 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 lms.lib.spectacular import lms_mark_legacy_paths_deprecated from openedx.core.apidocs import get_api_docs_settings from openedx.core.djangoapps.profile_images.tests.helpers import make_image_file from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration_context @@ -30,6 +26,11 @@ get_profile_image_storage, ) from openedx.core.djangoapps.user_api.accounts.utils import ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH +from openedx.core.djangoapps.user_api.schema import ( + RENAMED_OPERATION_IDS, + SUPERSEDED_USER_PATHS, + mark_superseded_user_paths, +) from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory from openedx.core.djangoapps.user_api.v2.serializers import ( ACCOUNT_DEFAULT_SORT, @@ -616,6 +617,7 @@ def test_enum_post_processing_runs_before_the_deprecation_hook(): hooks = settings.SPECTACULAR_SETTINGS["POSTPROCESSING_HOOKS"] assert hooks[0] == "drf_spectacular.hooks.postprocess_schema_enums" assert "lms.lib.spectacular.lms_mark_legacy_paths_deprecated" in hooks[1:] + assert "openedx.core.djangoapps.user_api.schema.mark_superseded_user_paths" in hooks[1:] @skip_unless_lms @@ -647,6 +649,7 @@ def test_hook_leaves_other_resources_alone(): result = {"paths": {path: {"get": {"operationId": path}} for path in untouched + marked}} lms_mark_legacy_paths_deprecated(result, generator=None, request=None, public=True) + mark_superseded_user_paths(result, generator=None, request=None, public=True) for path in untouched: assert "deprecated" not in result["paths"][path]["get"], path diff --git a/openedx/core/tests/test_apidocs.py b/openedx/core/tests/test_apidocs.py index 4d7d79a49eb2..308c9685cf66 100644 --- a/openedx/core/tests/test_apidocs.py +++ b/openedx/core/tests/test_apidocs.py @@ -44,13 +44,21 @@ class UntagDeprecatedOperationsRegistrationTest(SimpleTestCase): """Every schema configuration runs the hook after the hooks that mark operations deprecated.""" # The other environments inherit the hooks from these two modules. + SHARED_HOOK = "'openedx.core.djangoapps.user_api.schema.mark_superseded_user_paths'," MARKING_HOOKS = { - "cms/envs/common.py": "'cms.lib.spectacular.cms_mark_migrated_paths',", - "lms/envs/common.py": "'lms.lib.spectacular.lms_mark_legacy_paths_deprecated',", + "cms/envs/common.py": ( + "'cms.lib.spectacular.cms_mark_migrated_paths',", + SHARED_HOOK, + ), + "lms/envs/common.py": ( + "'lms.lib.spectacular.lms_mark_legacy_paths_deprecated',", + SHARED_HOOK, + ), } def test_the_hook_runs_last(self): - for module, marking_hook in self.MARKING_HOOKS.items(): + for module, marking_hooks in self.MARKING_HOOKS.items(): source = (REPO_ROOT / module).read_text() assert HOOK in source, module - assert source.index(marking_hook) < source.index(HOOK), module + for marking_hook in marking_hooks: + assert source.index(marking_hook) < source.index(HOOK), (module, marking_hook) From 0612dddee2e7f57417ed37438c851f31028fbe2f Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 22:14:21 +0500 Subject: [PATCH 41/82] test: check that neither schema module reaches into the other service The description of these addresses is held in one place so that the two documents cannot disagree, and either service copying it or reading it out of the other's package would undo that. One test names where the shared behaviour is read from; two read the source of the modules this service's schema class is built out of and refuse an import of either service. --- cms/lib/tests/test_spectacular.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/cms/lib/tests/test_spectacular.py b/cms/lib/tests/test_spectacular.py index df7869d7d81a..0ab779dec574 100644 --- a/cms/lib/tests/test_spectacular.py +++ b/cms/lib/tests/test_spectacular.py @@ -197,6 +197,37 @@ def test_the_module_the_published_reference_is_generated_from_inherits_this_conf assert "SPECTACULAR_SETTINGS" not in self._source("cms/envs/development.py") +class SharedSchemaBehaviourTest(SimpleTestCase): + """ + Where the description this service shares with the one replacing these addresses is kept. + + Both services publish the same user addresses, so what they say about them + is held once, in a module belonging to the API being described rather than + to either service. A service reaching into the other's package for it + would tie the two together, and a copy of it here would drift from the + original the moment one of the two was corrected. + """ + + #: The module holding what the two services say the same way. + SHARED_MODULE = "openedx/core/djangoapps/user_api/schema.py" + + @staticmethod + def _source(module): + return (Path(__file__).resolve().parents[3] / module).read_text() + + def test_the_shared_description_is_read_from_the_api_it_describes(self): + assert SupersededUserOperations.__module__ == "openedx.core.djangoapps.user_api.schema" + assert SupersededUserOperations in CmsAutoSchema.__mro__ + + def test_the_shared_module_reads_no_part_of_either_service(self): + found = re.findall(r"(?m)^\s*(?:from|import) (?:lms|cms)\b", self._source(self.SHARED_MODULE)) + assert found == [] + + def test_the_schema_helpers_of_this_service_read_no_part_of_the_other(self): + found = re.findall(r"(?m)^\s*(?:from|import) lms\b", self._source("cms/lib/spectacular.py")) + assert found == [] + + class _EmptySerializer(serializers.Serializer): # pylint: disable=abstract-method pass From 6a7ceb92d78888d13f3b3da0642b7de06b01be62 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 22:32:59 +0500 Subject: [PATCH 42/82] test: hold each version to the refusal it answers with The parity case for an address spelled with a different capitalisation held both versions to one refusal code. They refuse such a request in different terms, so each is measured against what it answers, and both are still held to handing back nothing of the account that was asked for. --- .../user_api/v2/tests/test_verification_views.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_verification_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_verification_views.py index 12332694864d..e364d7ce1bf6 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_verification_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_verification_views.py @@ -970,6 +970,13 @@ def test_the_attempts_of_a_user_who_has_been_through_no_checks_are_an_empty_page assert self.client.get(self.list_address("nobody-at-all")).status_code == status.HTTP_404_NOT_FOUND def test_both_versions_refuse_an_address_that_is_not_exactly_the_callers_name(self): + """ + Neither version answers an account to a caller who addressed it differently. + + The two refuse such a request in different terms, so each is held to + the code it answers rather than both to one code, and neither hands + back anything of the account that was asked for. + """ self.login(self.owner) shouted = self.owner.username.upper() for earlier_address, later_address in ( @@ -978,8 +985,8 @@ def test_both_versions_refuse_an_address_that_is_not_exactly_the_callers_name(se ): earlier = self.client.get(earlier_address) later = self.client.get(later_address) - assert earlier.status_code == status.HTTP_403_FORBIDDEN - assert earlier.data == {"detail": "You do not have permission to perform this action."} + assert earlier.status_code == status.HTTP_404_NOT_FOUND + assert "verification" not in str(earlier.data) assert_error_envelope(later, expected_status=403, expected_type_slug="authz") def test_an_anonymous_caller_is_refused_by_both_and_told_so_by_this_one(self): From cab976a9103f76433212b972351f5526264533e7 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 21:15:38 +0500 Subject: [PATCH 43/82] style: satisfy the repository's own lint on the lines this work adds Three findings the repository's quality job reports and our own run did not: an unpacked loop name this body never reads, and two assertions that joined two independent claims with and, so a failure named neither. --- lms/lib/tests/test_spectacular.py | 2 +- lms/tests.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/lms/lib/tests/test_spectacular.py b/lms/lib/tests/test_spectacular.py index b8a61440da59..8d1e43815eb9 100644 --- a/lms/lib/tests/test_spectacular.py +++ b/lms/lib/tests/test_spectacular.py @@ -751,7 +751,7 @@ def test_a_handler_that_refuses_a_request_carrying_no_body_is_the_reason_for_the def test_every_document_marks_a_body_the_handler_refuses_to_work_without(self): marks = {} for name, document in self.documents.items(): - for method, shape, _address, _media_type, answer in BODIES_SENT_AS_NOTHING: + for method, shape, _address, _media_type, _answer in BODIES_SENT_AS_NOTHING: address = self._published_address(shape) operation = document["paths"][address][method.lower()] marks[(name, method, address)] = operation["requestBody"].get("required", "") diff --git a/lms/tests.py b/lms/tests.py index 8738cface569..c3c78c8b9e04 100644 --- a/lms/tests.py +++ b/lms/tests.py @@ -155,7 +155,8 @@ def test_the_two_documents_this_service_serves_are_not_stored_under_one_key(self cache.clear() narrow = self.client.get(self.ADDRESS) wide = self.client.get('/api-docs/schema/') - assert narrow.status_code == 200 and wide.status_code == 200 + assert narrow.status_code == 200 + assert wide.status_code == 200 assert len(narrow.content) < len(wide.content) assert self.client.get(self.ADDRESS).content == narrow.content assert self.client.get('/api-docs/schema/').content == wide.content @@ -168,7 +169,8 @@ def test_another_language_is_not_served_the_document_already_stored(self): cache.clear() english = self.client.get(self.ADDRESS, HTTP_ACCEPT_LANGUAGE='en') spanish = self.client.get(self.ADDRESS, HTTP_ACCEPT_LANGUAGE='es-419') - assert english.status_code == 200 and spanish.status_code == 200 + assert english.status_code == 200 + assert spanish.status_code == 200 assert english.content != spanish.content assert self.client.get(self.ADDRESS, HTTP_ACCEPT_LANGUAGE='en').content == english.content From f3e00534746b6c38598a1a8a9c229062280a1ae9 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Tue, 27 Oct 2026 03:27:41 +0500 Subject: [PATCH 44/82] feat: standardize the account retirement pipeline as /api/user/v2/ The eleven operations the retirement driver calls move to this version's conventions, as seven conforming addresses over four viewsets, with the pipeline's own service layer in view_services.py. The permission model is carried forward unchanged, deliberately. Every class, every grant and the force flag behave exactly as they do today: this is a restyling of a destructive, externally driven pipeline, and tightening who may drive it belongs in its own change where it can be reviewed as one. The state machine is still driven from outside the platform. Neither retire address advances state, and the driver continues to bracket each call with the two status writes it already sends. The batch operations answer per record on the new addresses. A replay is a success, reporting every record as already absent, and a batch holding a record in the wrong state is refused whole, with a per-record list of what was wrong and nothing deleted. The superseded addresses keep the single whole-batch refusal they answer today, so a driver migrates by reading a list of results where it used to read one string, and by treating the new refusal as it treats that one. Twelve of the thirteen published bodies described a shape their handler neither reads nor returns, or described none at all; they now describe what the handlers really exchange. The status read was already right and is left untouched, because it is what keeps four of the legacy components referenced. Correcting the two queue reads to the arrays they return moves the identifiers a generator derives for them, so both are pinned to the names already published, and the component left without a reference is pinned where the published document is assembled. --- docs/docs_settings.py | 28 +- lms/lib/tests/test_spectacular.py | 81 + openedx/core/djangoapps/user_api/schema.py | 186 +- openedx/core/djangoapps/user_api/v2/errors.py | 20 +- .../core/djangoapps/user_api/v2/filters.py | 78 +- .../core/djangoapps/user_api/v2/selectors.py | 73 +- .../djangoapps/user_api/v2/serializers.py | 282 +++- .../v2/tests/test_retirement_parity.py | 529 ++++++ .../v2/tests/test_retirement_query_counts.py | 167 ++ .../v2/tests/test_retirement_views.py | 1504 +++++++++++++++++ .../user_api/v2/tests/test_schema.py | 319 +++- .../user_api/v2/tests/test_selectors.py | 170 +- .../djangoapps/user_api/v2/tests/test_urls.py | 205 +++ openedx/core/djangoapps/user_api/v2/urls.py | 30 + .../djangoapps/user_api/v2/view_services.py | 335 ++++ openedx/core/djangoapps/user_api/v2/views.py | 623 ++++++- 16 files changed, 4611 insertions(+), 19 deletions(-) create mode 100644 openedx/core/djangoapps/user_api/v2/tests/test_retirement_parity.py create mode 100644 openedx/core/djangoapps/user_api/v2/tests/test_retirement_query_counts.py create mode 100644 openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py create mode 100644 openedx/core/djangoapps/user_api/v2/view_services.py diff --git a/docs/docs_settings.py b/docs/docs_settings.py index 12e1bca30c9b..2e7ad1c21f1f 100644 --- a/docs/docs_settings.py +++ b/docs/docs_settings.py @@ -82,7 +82,29 @@ # OpenAPI 3 security schemes, consumed by drf-spectacular. "Basic" uses the # OpenAPI 3 http/basic form rather than Swagger 2.0's type: basic. -# +# The retirement-status model a partial update of a version 1 retirement reads +# is the only place this component was ever referenced, and that operation now +# publishes the object its handler really reads. A client generated from this +# document names a class after each component, so the name is kept here rather +# than disappearing with its last reference. +PATCHED_USER_RETIREMENT_STATUS = { + "type": "object", + "description": "Perform serialization for the RetirementStatus model", + "properties": { + "id": {"type": "integer", "readOnly": True}, + "user": {"allOf": [{"$ref": "#/components/schemas/RetirementUser"}], "readOnly": True}, + "current_state": {"allOf": [{"$ref": "#/components/schemas/RetirementState"}], "readOnly": True}, + "last_state": {"allOf": [{"$ref": "#/components/schemas/RetirementState"}], "readOnly": True}, + "created": {"type": "string", "format": "date-time", "readOnly": True}, + "modified": {"type": "string", "format": "date-time", "readOnly": True}, + "original_username": {"type": "string", "maxLength": 150}, + "original_email": {"type": "string", "format": "email", "maxLength": 254}, + "original_name": {"type": "string", "maxLength": 255}, + "retired_username": {"type": "string", "maxLength": 150}, + "retired_email": {"type": "string", "format": "email", "maxLength": 254}, + }, +} + # Whatever the service settings already append is carried through rather than # replaced: that key holds component names the published reference is expected # to carry, and dropping one here takes it out of the generated reference and @@ -90,6 +112,10 @@ # it had gone. SPECTACULAR_SETTINGS["APPEND_COMPONENTS"] = { # noqa: F405 **SPECTACULAR_SETTINGS.get("APPEND_COMPONENTS", {}), # noqa: F405 + "schemas": { + **SPECTACULAR_SETTINGS.get("APPEND_COMPONENTS", {}).get("schemas", {}), # noqa: F405 + "PatchedUserRetirementStatus": PATCHED_USER_RETIREMENT_STATUS, + }, "securitySchemes": { "Basic": { "type": "http", diff --git a/lms/lib/tests/test_spectacular.py b/lms/lib/tests/test_spectacular.py index 8d1e43815eb9..3308b61f458c 100644 --- a/lms/lib/tests/test_spectacular.py +++ b/lms/lib/tests/test_spectacular.py @@ -160,6 +160,18 @@ def test_only_the_listed_user_addresses_are_deprecated(self): "/api/user/v1/preferences/{username}", "/api/user/v1/preferences/{username}/{preference_key}", "/api/user/v1/preferences/email_opt_in/", + "/api/user/v1/accounts/{username}/retirement_status/", + "/api/user/v1/accounts/retirement_queue/", + "/api/user/v1/accounts/retirements_by_status_and_date/", + "/api/user/v1/accounts/update_retirement_status/", + "/api/user/v1/accounts/retirement_cleanup/", + "/api/user/v1/accounts/retirement_partner_report/", + "/api/user/v1/accounts/retirement_partner_report_cleanup/", + "/api/user/v1/accounts/cancel_retirement/", + "/api/user/v1/accounts/retire/", + "/api/user/v1/accounts/retire_misc/", + "/api/user/v1/accounts/deactivate_logout/", + "/api/user/v1/accounts/replace_usernames/", "/api/profile_images/v1/{username}/upload", "/api/user/v2/users/", "/api/user/v2/accounts/{username}/profile_image/", @@ -185,9 +197,23 @@ def test_only_the_listed_user_addresses_are_deprecated(self): "/api/user/v1/preferences/{username}", "/api/user/v1/preferences/{username}/{preference_key}", "/api/user/v1/preferences/email_opt_in/", + "/api/user/v1/accounts/{username}/retirement_status/", + "/api/user/v1/accounts/retirement_queue/", + "/api/user/v1/accounts/retirements_by_status_and_date/", + "/api/user/v1/accounts/update_retirement_status/", + "/api/user/v1/accounts/retirement_cleanup/", + "/api/user/v1/accounts/retirement_partner_report/", + "/api/user/v1/accounts/retirement_partner_report_cleanup/", + "/api/user/v1/accounts/cancel_retirement/", + "/api/user/v1/accounts/retire/", + "/api/user/v1/accounts/retire_misc/", ): assert paths[marked]["get"]["deprecated"] is True, marked for untouched in ( + # The two operations of this area whose caller is not the pipeline + # have no conforming address yet, so neither is marked. + "/api/user/v1/accounts/deactivate_logout/", + "/api/user/v1/accounts/replace_usernames/", "/api/profile_images/v1/{username}/upload", "/api/user/v2/users/", "/api/user/v2/accounts/{username}/profile_image/", @@ -267,6 +293,15 @@ def _user_routes(schema_class): view.as_view({"get": "retrieve"}), ), path("api/user/v1/users/", view.as_view({"get": "list"})), + # Both reads of the retirement queue answer with an array of records, + # which is what the generator derives a list operation id from, while + # the service-wide document has published each under the name derived + # for a single record. + path("api/user/v1/accounts/retirement_queue/", view.as_view({"get": "list"})), + path( + "api/user/v1/accounts/retirements_by_status_and_date/", + view.as_view({"get": "list"}), + ), path("api/user/v2/accounts//preferences/", view.as_view({"get": "retrieve"})), path( "api/user/v2/accounts//preferences//", @@ -335,6 +370,10 @@ def test_the_service_wide_document_keeps_every_name_the_generator_derives(self): "/api/user/v1/preferences/{username}/{preference_key}": ("api_user_v1_preferences_retrieve_2", ("api",)), "/api/user/v1/users/": ("api_user_v1_users_list", ("api",)), + "/api/user/v1/accounts/retirement_queue/": + ("api_user_v1_accounts_retirement_queue_retrieve", ("api",)), + "/api/user/v1/accounts/retirements_by_status_and_date/": + ("api_user_v1_accounts_retirements_by_status_and_date_retrieve", ("api",)), "/api/user/v2/accounts/{username}/preferences/": ("api_user_v2_accounts_preferences_retrieve", ("api",)), "/api/user/v2/accounts/{username}/preferences/{key}/": @@ -360,6 +399,10 @@ def test_a_user_address_is_grouped_by_area_where_the_derived_tag_would_name_a_ve assert _named_operations(LmsAutoSchema, {}) == { "/api/user/v1/accounts": ("v1_accounts_list", ("user",)), "/api/user/v1/accounts/{username}": ("v1_accounts_retrieve", ("user",)), + "/api/user/v1/accounts/retirement_queue/": ("v1_accounts_retirement_queue_list", ("user",)), + "/api/user/v1/accounts/retirements_by_status_and_date/": ( + "v1_accounts_retirements_by_status_and_date_list", ("user",), + ), "/api/user/v1/preferences/{username}": ("v1_preferences_retrieve", ("user",)), "/api/user/v1/preferences/{username}/{preference_key}": ( "v1_preferences_retrieve_one", ("user",), @@ -392,6 +435,18 @@ def test_a_user_address_is_grouped_by_area_where_the_derived_tag_would_name_a_ve ), } +#: What the cancellation answers with, which it published nothing for. +CANCELLATION_ANSWER = { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "True once the retirement request has been undone.", + }, + }, + "required": ["success"], +} + #: 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. @@ -422,6 +477,32 @@ def test_a_user_address_is_grouped_by_area_where_the_derived_tag_would_name_a_ve ("/api/user/v1/preferences/email_opt_in/", "post"): ( ["application/x-www-form-urlencoded"], {"200": None}, ), + # The retirement pipeline. Of its ten addresses only the read of one + # record published what its handler really answers with, and it keeps + # what it has always published. + ("/api/user/v1/accounts/retirement_queue/", "get"): ([], {"200": "UserRetirementStatus[]"}), + ("/api/user/v1/accounts/retirements_by_status_and_date/", "get"): ( + [], {"200": "UserRetirementStatus[]"}, + ), + ("/api/user/v1/accounts/update_retirement_status/", "patch"): ( + ["application/json"], {"204": None}, + ), + ("/api/user/v1/accounts/retirement_cleanup/", "post"): (["application/json"], {"204": None}), + ("/api/user/v1/accounts/retirement_partner_report/", "post"): ( + [], {"200": "UserRetirementPartnerReport[]"}, + ), + ("/api/user/v1/accounts/retirement_partner_report/", "put"): ( + ["application/json"], {"204": None}, + ), + ("/api/user/v1/accounts/retirement_partner_report_cleanup/", "post"): ( + ["application/json"], {"204": None}, + ), + ("/api/user/v1/accounts/cancel_retirement/", "post"): ( + ["application/json", "application/x-www-form-urlencoded", "multipart/form-data"], + {"200": CANCELLATION_ANSWER}, + ), + ("/api/user/v1/accounts/retire/", "post"): (["application/json"], {"204": None}), + ("/api/user/v1/accounts/retire_misc/", "post"): (["application/json"], {"204": None}), } diff --git a/openedx/core/djangoapps/user_api/schema.py b/openedx/core/djangoapps/user_api/schema.py index b8325563b8ea..c3e40db8903e 100644 --- a/openedx/core/djangoapps/user_api/schema.py +++ b/openedx/core/djangoapps/user_api/schema.py @@ -6,6 +6,7 @@ from importlib import import_module from drf_spectacular.utils import OpenApiResponse +from rest_framework.settings import api_settings # 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 @@ -31,8 +32,27 @@ "/api/user/v1/preferences/{pref_key}/users/", "/api/user/v1/forum_roles/{name}/users/", "/api/user/v1/preferences/time_zones/", + "/api/user/v1/accounts/{username}/retirement_status/", + "/api/user/v1/accounts/retirement_queue/", + "/api/user/v1/accounts/retirements_by_status_and_date/", + "/api/user/v1/accounts/update_retirement_status/", + "/api/user/v1/accounts/retirement_cleanup/", + "/api/user/v1/accounts/retirement_partner_report/", + "/api/user/v1/accounts/retirement_partner_report_cleanup/", + "/api/user/v1/accounts/cancel_retirement/", + "/api/user/v1/accounts/retire/", + "/api/user/v1/accounts/retire_misc/", ) +# Two reads of the retirement queue answer with an array, which the generator +# names for a list, while the document that trims nothing off the front of each +# path has always published both under the name derived for a single record -- +# the name each had while the serializer named on their view described one +# record rather than the array the handler returns. Those two names are what a +# client imports by, so they are pinned; the document that trims the area off +# the front of each path has published neither address, so it keeps the names it +# derives for them. +# # 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 the @@ -67,6 +87,16 @@ # nothing a client imports is taken away. RENAMED_OPERATION_IDS = { ("/api/user/v1/accounts", "GET", "api_user_v1_accounts_list"): "api_user_v1_accounts_retrieve", + ( + "/api/user/v1/accounts/retirement_queue/", + "GET", + "api_user_v1_accounts_retirement_queue_list", + ): "api_user_v1_accounts_retirement_queue_retrieve", + ( + "/api/user/v1/accounts/retirements_by_status_and_date/", + "GET", + "api_user_v1_accounts_retirements_by_status_and_date_list", + ): "api_user_v1_accounts_retirements_by_status_and_date_retrieve", ("/api/user/v1/preferences/{username}/{preference_key}", "GET", "v1_preferences_retrieve"): ( "v1_preferences_retrieve_one" ), @@ -137,7 +167,9 @@ def superseded_bodies(): 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. + component it has always published, unless the serializer it names describes + neither what its handler reads nor what its handler returns, in which case + the shapes the handler really exchanges are named here instead. 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 @@ -148,18 +180,53 @@ def superseded_bodies(): 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, so each module is read inside this one. One of them - # reaches models belonging to a single service, which code shared by both + # first imported, so each module is read inside this one. Two of them + # reach models belonging to a single service, which code shared by both # services may not import: a service picks that application up by name - # through its settings, and that module is picked up by name here for the - # same reason. + # through its settings, and those two modules are picked up by name here + # for the same reason. # pylint: disable=import-outside-toplevel from openedx.core.djangoapps.profile_images.v2.serializers import IMAGE_UPLOAD_REQUEST from openedx.core.lib.api.parsers import MergePatchParser replacing = import_module("openedx.core.djangoapps.user_api.v2.serializers") + retirement = import_module("openedx.core.djangoapps.user_api.accounts.serializers") account = {200: replacing.AccountSerializer} + json_type = "application/json" + + def in_every_form(shape): + """Name a shape under each media type a view that declares no parsers reads a body in.""" + return {parser.media_type: shape for parser in api_settings.DEFAULT_PARSER_CLASSES} + + retirement_erased = { + 204: OpenApiResponse(description="The data this step erases is gone."), + } + #: The name of one account, which is how the retirement pipeline's steps + #: say which account they are acting on. + named_account = { + "type": "object", + "properties": { + "username": { + "type": "string", + "description": "Name the account being retired was registered under.", + }, + }, + "required": ["username"], + } + #: The names of several accounts, which is how a step acting on a batch of + #: retirement records says which ones. + named_accounts = { + "type": "object", + "properties": { + "usernames": { + "type": "array", + "items": {"type": "string"}, + "description": "Names the accounts were registered under.", + }, + }, + "required": ["usernames"], + } # One preference is answered and stored as the bare value, which is what # the address replacing it carries as a property of an object. preference_value = {"type": "string"} @@ -215,6 +282,115 @@ def superseded_bodies(): ), # The handler reads the choice from the submitted form rather than from # a body of its own, and reads each value as the text a form carries. + # The queue and the by-date list both answer with an array of the + # shape their view names, which is published for one of them. + ("/api/user/v1/accounts/retirement_queue/", "GET"): _SupersededBodies( + None, {200: retirement.UserRetirementStatusSerializer(many=True)}, + ), + ("/api/user/v1/accounts/retirements_by_status_and_date/", "GET"): _SupersededBodies( + None, {200: retirement.UserRetirementStatusSerializer(many=True)}, + ), + # The move of a retirement to another pipeline step reads the step and + # the log entry alongside the account's name, and refuses a body + # carrying anything else; it answers with nothing at all. + ("/api/user/v1/accounts/update_retirement_status/", "PATCH"): _SupersededBodies( + { + json_type: { + "type": "object", + "properties": { + "username": { + "type": "string", + "description": "Name the account being retired was registered under.", + }, + "new_state": { + "type": "string", + "description": "Name of the pipeline step to move this retirement to.", + }, + "response": { + "type": "string", + "description": "What happened at the step being left, appended to the log.", + }, + "force": { + "type": "boolean", + "description": "Move the retirement even where the step asked for is not a later one.", + }, + }, + "required": ["username", "new_state", "response"], + }, + }, + {204: OpenApiResponse(description="The retirement now stands at the step asked for.")}, + ), + ("/api/user/v1/accounts/retirement_cleanup/", "POST"): _SupersededBodies( + {json_type: named_accounts}, + {204: OpenApiResponse(description="The retirement records named are gone.")}, + ), + # The claim reads no body and answers with the entries it took. + ("/api/user/v1/accounts/retirement_partner_report/", "POST"): _SupersededBodies( + None, {200: retirement.UserRetirementPartnerReportSerializer(many=True)}, + ), + ("/api/user/v1/accounts/retirement_partner_report/", "PUT"): _SupersededBodies( + {json_type: named_account}, + { + 204: OpenApiResponse( + description=( + "The account is queued for reporting to the organizations it studied " + "with, or was enrolled with none and so needs no report." + ), + ), + }, + ), + # This batch is named by a bare array rather than by an object. + ("/api/user/v1/accounts/retirement_partner_report_cleanup/", "POST"): _SupersededBodies( + { + json_type: { + "type": "array", + "items": { + "type": "object", + "properties": { + "original_username": { + "type": "string", + "description": "Name the account was registered under.", + }, + }, + "required": ["original_username"], + }, + }, + }, + {204: OpenApiResponse(description="The partner report entries named are gone.")}, + ), + # The cancellation names the retirement by its own identifier, and its + # view declares no parsers, so the body is read in every form the + # platform reads one in. + ("/api/user/v1/accounts/cancel_retirement/", "POST"): _SupersededBodies( + in_every_form({ + "type": "object", + "properties": { + "retirement_id": { + "type": "integer", + "description": "Identifier of the retirement to cancel.", + }, + }, + "required": ["retirement_id"], + }), + { + 200: { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "True once the retirement request has been undone.", + }, + }, + "required": ["success"], + }, + }, + ), + ("/api/user/v1/accounts/retire/", "POST"): _SupersededBodies( + {json_type: named_account}, retirement_erased, + ), + ("/api/user/v1/accounts/retire_misc/", "POST"): _SupersededBodies( + {json_type: named_account}, retirement_erased, + ), ("/api/user/v1/preferences/email_opt_in/", "POST"): _SupersededBodies( { "application/x-www-form-urlencoded": { diff --git a/openedx/core/djangoapps/user_api/v2/errors.py b/openedx/core/djangoapps/user_api/v2/errors.py index 6bd6b184f0cf..f877e541661d 100644 --- a/openedx/core/djangoapps/user_api/v2/errors.py +++ b/openedx/core/djangoapps/user_api/v2/errors.py @@ -1,6 +1,6 @@ """Error types this version of the User API classifies for itself.""" -from edx_rest_framework_extensions.errors import register_error_type +from edx_rest_framework_extensions.errors import Conflict, register_error_type from rest_framework.exceptions import ( MethodNotAllowed, NotAcceptable, @@ -56,12 +56,30 @@ def __init__(self, detail=None, can_cancel_retirement=False, retirement_id=None) } +class RetirementStateConflict(Conflict): + """ + An operation asked of a retirement whose state does not admit it. + + Which operation a retirement admits depends on how far through the + pipeline it is, and the order of those steps is configured by the + operator rather than fixed here. A refusal names the retirements at fault + and why, so a caller acting on several of them at once learns which ones + to look at; nothing is changed when one is refused. + """ + + def __init__(self, detail=None, errors=None): + super().__init__(detail) + if errors: + self.extension_members = {"errors": errors} + + 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") register_error_type(AmbiguousAccountName, "user/ambiguous-account-name", "Ambiguous Account Name") +register_error_type(RetirementStateConflict, "user/retirement-state-conflict", "Retirement State Conflict") def refusal_sentence(refusals): diff --git a/openedx/core/djangoapps/user_api/v2/filters.py b/openedx/core/djangoapps/user_api/v2/filters.py index 60c35adf072d..9c32dcdccd1b 100644 --- a/openedx/core/djangoapps/user_api/v2/filters.py +++ b/openedx/core/djangoapps/user_api/v2/filters.py @@ -1,6 +1,8 @@ """Filter sets for version 2 of the User API.""" +import datetime import logging +from zoneinfo import ZoneInfo from django import forms from django.contrib.auth.models import User # pylint: disable=imported-auth-user @@ -11,7 +13,7 @@ from rest_framework.exceptions import ValidationError from rest_framework.filters import OrderingFilter -from openedx.core.djangoapps.user_api.models import UserPreference +from openedx.core.djangoapps.user_api.models import RetirementState, UserPreference, UserRetirementStatus from .view_utils import names_in @@ -196,3 +198,77 @@ class Meta: def filter_usernames(self, queryset, name, value): """Keep the preferences of the users the value names, read as a comma-separated list.""" return queryset.filter(**{f"{name}__in": names_in(value)}) + + +class _WholeNumberFilter(filters.NumberFilter): + """Reads a count of whole units, refusing a fraction.""" + + field_class = forms.IntegerField + + +class RetirementFilterSet(filters.FilterSet): + """ + Narrows the retirement pipeline's queue. + + ``cool_off_days`` is kept as a filter of its own rather than expressed as + a point in time, because it is the operator concept the pipeline is + configured in and a caller computing the equivalent timestamp would have + to agree with this server's clock to the second. The two date bounds say + which end they include, which the pair they replace left to the reader. + """ + + state = filters.CharFilter( + method="filter_states", + help_text=( + "Comma-separated names of pipeline steps. Only retirements standing at one of them " + "are returned, and a name no configured step holds is refused." + ), + ) + cool_off_days = _WholeNumberFilter( + method="filter_cool_off_days", + min_value=0, + help_text=( + "Return only retirements requested at least this many days ago. The pipeline uses " + "this to leave a learner time to change their mind." + ), + ) + created_after = filters.DateTimeFilter( + field_name="created", + lookup_expr="gte", + help_text=( + "Return only retirements requested at or after this moment, as a date or a date and " + "time. A date alone means the start of that day." + ), + ) + created_before = filters.DateTimeFilter( + field_name="created", + lookup_expr="lt", + help_text=( + "Return only retirements requested before this moment, as a date or a date and time. " + "A date alone means the start of that day, so that day is left out." + ), + ) + + class Meta: + model = UserRetirementStatus + fields = ("state", "cool_off_days", "created_after", "created_before") + + def filter_states(self, queryset, name, value): # pylint: disable=unused-argument + """Keep the retirements standing at one of the steps the value names.""" + names = names_in(value) + configured = set(RetirementState.get_state_names_list()) + unknown = [name for name in names if name not in configured] + if unknown: + log.info("Retirement queue filtered by unconfigured steps: %r", unknown) + raise ValidationError({ + "state": [ + f"No such pipeline step: {', '.join(unknown)}. " + f"This deployment runs: {', '.join(sorted(configured))}.", + ], + }) + return queryset.filter(current_state__state_name__in=names) + + def filter_cool_off_days(self, queryset, name, value): # pylint: disable=unused-argument + """Keep the retirements requested at least ``value`` days ago.""" + requested_before = datetime.datetime.now(ZoneInfo("UTC")) - datetime.timedelta(days=value) + return queryset.filter(created__lt=requested_before) diff --git a/openedx/core/djangoapps/user_api/v2/selectors.py b/openedx/core/djangoapps/user_api/v2/selectors.py index 3216134f0c71..0c52779a3928 100644 --- a/openedx/core/djangoapps/user_api/v2/selectors.py +++ b/openedx/core/djangoapps/user_api/v2/selectors.py @@ -6,7 +6,11 @@ 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 +from common.djangoapps.student.models import get_potentially_retired_user_by_username +from openedx.core.djangoapps.user_api.models import ( + UserRetirementPartnerReportingStatus, + UserRetirementStatus, +) def accounts_holding_emails(emails): @@ -89,3 +93,70 @@ def cancellable_retirement_for_email(email): .order_by("-created") .first() ) + + +def retirements(): + """Return every retirement, with the account and the steps a retirement record reports.""" + return UserRetirementStatus.objects.select_related( + "user", "user__profile", "current_state", "last_state", + ) + + +def retirement_of_the_account_named(username): + """ + Return the retirement of the account this name addresses, or ``None``. + + A retired account is stored under a hashed name, so the name a caller + holds may be either the one the account was registered under or the one it + was moved to; both address the same account and so the same retirement. + """ + try: + user = get_potentially_retired_user_by_username(username) + except User.DoesNotExist: + return None + return retirements().filter(user=user).first() + + +def retirements_requested_as(usernames): + """ + Return the retirements requested under exactly these names, keyed by name. + + Stored names are compared without regard to case in some deployments, + which once let a learner register a retired name again by changing its + capitalisation, so the rows the database offers are compared again here + character for character. A name no row was requested under is absent from + the result. + """ + asked_for = set(usernames) + return { + row.original_username: row + for row in retirements().filter(original_username__in=usernames) + if row.original_username in asked_for + } + + +def unclaimed_partner_reports(): + """ + Return the partner reports no caller has taken for processing yet. + + The organizations of each are read from the user's enrolments, which are + fetched with the rows so that the cost of the answer does not grow with + the number of rows in it. + """ + return ( + UserRetirementPartnerReportingStatus.objects + .filter(is_being_processed=False) + .select_related("user") + .prefetch_related("user__courseenrollment_set") + .order_by("id") + ) + + +def partner_reports_requested_as(usernames): + """Return the partner reports of the accounts registered under exactly these names, keyed by name.""" + asked_for = set(usernames) + return { + row.original_username: row + for row in UserRetirementPartnerReportingStatus.objects.filter(original_username__in=usernames) + if row.original_username in asked_for + } diff --git a/openedx/core/djangoapps/user_api/v2/serializers.py b/openedx/core/djangoapps/user_api/v2/serializers.py index 0f0e8e618f27..b7a73259ed0c 100644 --- a/openedx/core/djangoapps/user_api/v2/serializers.py +++ b/openedx/core/djangoapps/user_api/v2/serializers.py @@ -12,7 +12,7 @@ from rest_framework.validators import ProhibitSurrogateCharactersValidator from lms.djangoapps.verify_student.models import ManualVerification, SoftwareSecurePhotoVerification -from openedx.core.djangoapps.user_api.models import UserOrgTag, UserPreference +from openedx.core.djangoapps.user_api.models import RetirementState, UserOrgTag, UserPreference from openedx.core.djangoapps.user_authn.views.registration_form import contains_html, contains_url from openedx.core.lib.api.serializers import CourseKeyField @@ -1097,3 +1097,283 @@ def validate_course_key(self, value): if len(value.org) > ORGANIZATION_MAX_LENGTH: raise serializers.ValidationError(ORGANIZATION_TOO_LONG) return value + + +#: Properties the ``minimal`` preset keeps of a retirement. None of them is +#: anything the account's owner entered, so a caller that only needs to know +#: which retirements are waiting reads none of their personal data. +RETIREMENT_MINIMAL_FIELDS = ("id", "original_username", "current_state") + +#: The set of personal data an erasure clears, named by the scope that clears +#: it. The core scope clears what the platform itself holds about a person; +#: the miscellaneous scope clears what the features built on top of it hold. +ERASURE_CORE_SCOPE = "lms_core" +ERASURE_MISC_SCOPE = "lms_misc" +ERASURE_SCOPES = (ERASURE_CORE_SCOPE, ERASURE_MISC_SCOPE) + +#: What a batch operation reports about each name it was given. +OUTCOME_DELETED = "deleted" +OUTCOME_ALREADY_ABSENT = "already_absent" +BATCH_OUTCOMES = (OUTCOME_DELETED, OUTCOME_ALREADY_ABSENT) + +#: The largest number of names one batch may carry, which is the number of +#: rows a page of any collection of this version holds. +BATCH_MAX_NAMES = UserApiPagination.max_page_size + + +def why_a_state_is_not_configured(name): + """Return why this deployment has no such pipeline step, or ``None`` when it has one.""" + if RetirementState.objects.filter(state_name=name).exists(): + return None + configured = ", ".join(sorted(RetirementState.get_state_names_list())) + return f"No such pipeline step. This deployment runs: {configured}." + + +class RetirementAccountProfileSerializer(serializers.Serializer): # pylint: disable=abstract-method + """The part of a retiring user's profile a retirement record carries.""" + + id = serializers.IntegerField(read_only=True, help_text="Database identifier of the profile.") + name = serializers.CharField( + read_only=True, + allow_blank=True, + help_text="Full name recorded on the profile at the time it is read.", + ) + + +class RetirementAccountSerializer(serializers.Serializer): # pylint: disable=abstract-method + """The part of a retiring user's account a retirement record carries.""" + + id = serializers.IntegerField(read_only=True, help_text="Database identifier of the user.") + username = serializers.CharField( + read_only=True, + help_text=( + "Name the account is stored under now, which is the hashed replacement once the " + "erasure of core data has run." + ), + ) + email = serializers.EmailField( + read_only=True, + help_text=( + "Address the account is stored under now, which is the hashed replacement once the " + "account has been locked." + ), + ) + profile = RetirementAccountProfileSerializer( + read_only=True, + help_text="The profile of the account, or null for an account that has none.", + ) + + +class RetirementSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + One account's progress through the retirement pipeline, addressed by the name it held. + + ``current_state`` and ``last_state`` are the names of the steps rather + than objects: a step's execution order and identifier are configured by + the operator and say nothing a caller of this API can act on. The log of + the transitions a retirement has been through grows without bound and is + left out for that reason. + """ + + id = serializers.IntegerField( + read_only=True, + help_text="Database identifier of the retirement. Addresses in this API use the username instead.", + ) + user = RetirementAccountSerializer(read_only=True, help_text="The account being retired, as it stands now.") + original_username = serializers.CharField( + read_only=True, + help_text="Name the account was stored under when retirement was requested.", + ) + original_email = serializers.EmailField( + read_only=True, + help_text="Address the account was stored under when retirement was requested.", + ) + original_name = serializers.CharField( + read_only=True, + allow_blank=True, + help_text="Full name on the profile when retirement was requested.", + ) + retired_username = serializers.CharField( + read_only=True, + allow_blank=True, + help_text="Hashed name the account is moved to once its core data is erased.", + ) + retired_email = serializers.EmailField( + read_only=True, + allow_blank=True, + help_text="Hashed address the account is moved to once it is locked.", + ) + current_state = serializers.CharField( + source="current_state.state_name", + read_only=True, + help_text="Name of the pipeline step this retirement stands at.", + ) + last_state = serializers.CharField( + source="last_state.state_name", + read_only=True, + help_text="Name of the pipeline step this retirement stood at before the one it is at now.", + ) + created = serializers.DateTimeField(read_only=True, help_text="When retirement was requested.") + modified = serializers.DateTimeField(read_only=True, help_text="When this retirement last moved.") + + +class RetirementMinimalSerializer(_KeptFieldsMixin, RetirementSerializer): + """A retirement reduced to which account it is for and where it stands.""" + + kept_fields = RETIREMENT_MINIMAL_FIELDS + + +class RetirementProjectionSerializer(_OptionalFieldsMixin, RetirementSerializer): + """A retirement reduced to the properties the caller selected.""" + + +class RetirementUpdateSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + A move of one retirement to another step of the pipeline. + + Which steps exist and in what order is configured by the operator, so + ``new_state`` is checked against the configured steps rather than against + a fixed list. + """ + + new_state = serializers.CharField(help_text="Name of the pipeline step to move this retirement to.") + response = serializers.CharField( + allow_blank=True, + help_text="What happened at the step being left, appended to the retirement's log.", + ) + force = serializers.BooleanField( + required=False, + default=False, + help_text=( + "Move the retirement even where the step asked for is not a later one. Operators use " + "this to send a retirement back through a step that has to be repeated." + ), + ) + + def validate_new_state(self, value): + """Return the step named, refusing a name no configured step holds.""" + reason = why_a_state_is_not_configured(value) + if reason is not None: + raise serializers.ValidationError(reason) + return value + + +class RetirementErasureSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + Which set of a retiring user's personal data to erase. + + The erasure cannot be undone, and each scope clears a different set, so + the scope is named in the body rather than defaulted. + """ + + scope = serializers.ChoiceField( + choices=[ + (ERASURE_CORE_SCOPE, "Profile, images, certificates, entitlements and the account itself."), + (ERASURE_MISC_SCOPE, "Wiki revisions, enrollment audits, credit and API access records."), + ], + help_text=( + f"`{ERASURE_CORE_SCOPE}` erases the account's own data: its profile, its images, the " + "name on its certificates and entitlement notes, and the username and address on the " + f"account. `{ERASURE_MISC_SCOPE}` erases what the features around it hold: wiki " + "revisions, pending name changes, enrollment audits, credit records and API access " + "requests." + ), + ) + + +class RetirementBatchSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + The accounts a batch operation acts on, named as they were before retirement. + + A name repeated in the list names one account, so it is acted on and + reported once. + """ + + usernames = serializers.ListField( + child=serializers.CharField(), + allow_empty=False, + max_length=BATCH_MAX_NAMES, + help_text=( + "Names the accounts were stored under before retirement was requested. At most " + f"{BATCH_MAX_NAMES} names, compared exactly as spelled." + ), + ) + + def validate_usernames(self, value): + """Return the names given, each once, in the order they were first given.""" + return list(dict.fromkeys(value)) + + +class RetirementBatchResultSerializer(serializers.Serializer): # pylint: disable=abstract-method + """What a batch operation did about one of the names it was given.""" + + username = serializers.CharField(help_text="The name this result is about, as it was given.") + outcome = serializers.ChoiceField( + choices=[ + (OUTCOME_DELETED, "The record was deleted by this request."), + (OUTCOME_ALREADY_ABSENT, "There was no such record, so this request left nothing to do."), + ], + help_text=( + f"`{OUTCOME_DELETED}` where this request deleted the record, and " + f"`{OUTCOME_ALREADY_ABSENT}` where there was none to delete, which is what a repeat " + "of a request that has already been carried out reports for every name." + ), + ) + + +class RetirementBatchOutcomeSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + What a batch operation did, reported for each name it was given. + + Every name is reported, so a caller repeating a request that has already + been carried out can tell that from a request that did the work. + """ + + results = RetirementBatchResultSerializer( + many=True, + help_text="One result per name given, in the order the names were given.", + ) + + +class RetirementPartnerReportRecordSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + What a partner needs in order to forget one retired user. + + ``student_id`` and ``orgs_config`` are carried only by a deployment that + records them, and are left out of the answer otherwise. + """ + + user_id = serializers.IntegerField(help_text="Database identifier of the retired user.") + student_id = serializers.CharField( + required=False, + help_text="Identifier the deployment knows the user by, where it records one.", + ) + original_username = serializers.CharField(help_text="Name the account was stored under before retirement.") + original_email = serializers.EmailField(help_text="Address the account was stored under before retirement.") + original_name = serializers.CharField( + allow_blank=True, + help_text="Full name on the profile before retirement.", + ) + orgs = serializers.ListField( + child=serializers.CharField(), + help_text="Short names of the organizations running the courses this user was enrolled in.", + ) + orgs_config = serializers.ListField( + required=False, + help_text="Per-organization reporting configuration, where the deployment records any.", + ) + created = serializers.DateTimeField(help_text="When this user was added to the partner reporting queue.") + + +class RetirementPartnerReportQuerySerializer(serializers.Serializer): # pylint: disable=abstract-method + """The query string of a request that takes partner reports for processing.""" + + limit = serializers.IntegerField( + required=False, + min_value=1, + help_text=( + "Take at most this many entries. Omit it to take every entry waiting, which is what " + "the pipeline does: nothing releases an entry once it is taken, so a caller that " + "takes fewer than it processes cannot hand the rest back." + ), + ) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_parity.py b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_parity.py new file mode 100644 index 000000000000..dc6f45f5cd7a --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_parity.py @@ -0,0 +1,529 @@ +"""Behaviour parity between the version 1 and version 2 retirement pipeline addresses.""" + +import contextlib +import json +from unittest import mock + +import pytest +from django.core.cache import cache +from django.test import override_settings +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase +from social_django.models import UserSocialAuth +from wiki.models import ArticleRevision +from wiki.models.pluginbase import RevisionPluginRevision + +from common.djangoapps.student.models import ( + AccountRecovery, + CourseEnrollmentAllowed, + ManualEnrollmentAudit, + PendingEmailChange, + PendingNameChange, + PendingSecondaryEmailChange, + SocialLink, + UserProfile, +) +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.api_admin.models import ApiAccessRequest +from openedx.core.djangoapps.course_groups.models import UnregisteredLearnerCohortAssignments +from openedx.core.djangoapps.credit.models import CreditRequest, CreditRequirementStatus +from openedx.core.djangoapps.user_api.accounts import views as legacy_views +from openedx.core.djangoapps.user_api.accounts.serializers import UserRetirementStatusSerializer +from openedx.core.djangoapps.user_api.accounts.signals import ( + USER_RETIRE_LMS_CRITICAL, + USER_RETIRE_LMS_MISC, + USER_RETIRE_MAILINGS, +) +from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import ( # pylint: disable=unused-import + create_retirement_status, + setup_retirement_states, # noqa: F401 +) +from openedx.core.djangoapps.user_api.accounts.views import ( + USER_PROFILE_PII, + AccountRetirementView, + LMSAccountRetirementView, +) +from openedx.core.djangoapps.user_api.models import RetirementState, UserOrgTag, UserRetirementStatus +from openedx.core.djangoapps.user_api.v2 import view_services +from openedx.core.djangolib.testing.utils import skip_unless_lms + +PASSWORD = "Password1234" +WORKER_USERNAME = "parity-retirement-worker" + +#: The step of the pipeline at which the erasures are run. +WORKING_STATE = "RETIRING_LMS" + +#: Each step of the erasure of the account's own data, named by what it clears +#: and by where both versions reach it. ``None`` as the holder means each +#: version calls the step through its own module, so both are recorded. +CORE_STEPS = ( + ("profile", AccountRetirementView, "clear_pii_from_userprofile"), + ("profile_images", AccountRetirementView, "delete_users_profile_images"), + ("country_cache", AccountRetirementView, "delete_users_country_cache"), + ("certificate_names", AccountRetirementView, "clear_pii_from_certificate_records"), + ("entitlement_notes", AccountRetirementView, "retire_entitlement_support_detail"), + ("pending_email_change", PendingEmailChange, "delete_by_user_value"), + ("organization_tags", UserOrgTag, "delete_by_user_value"), + ("pending_secondary_email", PendingSecondaryEmailChange, "redact_and_delete_pending_secondary_email"), + ("recovery_email", AccountRecovery, "retire_recovery_email"), + ("enrollment_invitations", CourseEnrollmentAllowed, "delete_by_user_value"), + ("cohort_assignments", UnregisteredLearnerCohortAssignments, "delete_by_user_value"), +) + +#: Each step of the erasure of what the features around the account hold. +MISC_STEPS = ( + ("wiki_plugin_revisions", RevisionPluginRevision, "retire_user"), + ("wiki_article_revisions", ArticleRevision, "retire_user"), + ("pending_name_change", PendingNameChange, "delete_by_user_value"), + ("manual_enrolments", ManualEnrollmentAudit, "retire_manual_enrollments"), + ("credit_requests", CreditRequest, "retire_user"), + ("api_access_requests", ApiAccessRequest, "retire_user"), + ("credit_requirements", CreditRequirementStatus, "retire_user"), +) + +#: The step each version reaches through its own module rather than through a +#: shared object, so it is patched in both. +HISTORICAL_SOCIAL_AUTH_STEP = ("historical_social_auth", "redact_and_delete_historical_social_auth") + +#: The declared differences between a version 1 retirement record and a +#: version 2 one. Each entry is a (property, reason) pair, and the comparison +#: must find all of them and nothing else. +RECORD_DIFFERENCES = ( + ("current_state", "the step is the name of the step rather than an object carrying its order and its id"), + ("last_state", "the step is the name of the step rather than an object carrying its order and its id"), +) + + +@pytest.mark.usefixtures("setup_retirement_states") +@override_settings(RETIREMENT_SERVICE_WORKER_USERNAME=WORKER_USERNAME) +class RetirementParityTestCase(APITestCase): + """The caller both versions of the pipeline's addresses admit.""" + + def setUp(self): + super().setUp() + self.worker = UserFactory.create(username=WORKER_USERNAME, password=PASSWORD) + assert self.client.login(username=WORKER_USERNAME, password=PASSWORD) + + def retirement_for(self, user): + """Return a retirement of this account, standing where the erasures run.""" + return create_retirement_status(user, state=RetirementState.objects.get(state_name=WORKING_STATE)) + + +@skip_unless_lms +class ErasureStepParityTests(RetirementParityTestCase): + """Both versions clear the same things in the same order.""" + + def steps_taken(self, steps, call, own_module_step=None): + """Return the steps a call takes, in the order it takes them.""" + taken = [] + + def record(label): + def step(*args, **kwargs): # pylint: disable=unused-argument + taken.append(label) + return step + + with contextlib.ExitStack() as patches: + for label, holder, attribute in steps: + patches.enter_context( + mock.patch.object(holder, attribute, side_effect=record(label)), + ) + if own_module_step is not None: + label, attribute = own_module_step + for module in (legacy_views, view_services): + patches.enter_context( + mock.patch.object(module, attribute, side_effect=record(label)), + ) + response = call() + assert response.status_code == status.HTTP_204_NO_CONTENT, response.content + return taken + + def legacy_erasure(self, address_name, user): + """Run a version 1 erasure of this account.""" + return lambda: self.client.post( + reverse(address_name), {"username": user.username}, format="json", + ) + + def conforming_erasure(self, scope, user): + """Run a version 2 erasure of this account.""" + return lambda: self.client.post( + reverse("user_v2:retirement_erasure", kwargs={"username": user.username}), + {"scope": scope}, + format="json", + ) + + def test_the_core_scope_takes_the_same_steps_in_the_same_order(self): + legacy_user = UserFactory.create() + self.retirement_for(legacy_user) + conforming_user = UserFactory.create() + self.retirement_for(conforming_user) + + legacy = self.steps_taken(CORE_STEPS, self.legacy_erasure("accounts_retire", legacy_user)) + conforming = self.steps_taken(CORE_STEPS, self.conforming_erasure("lms_core", conforming_user)) + + assert legacy == [label for label, _holder, _attribute in CORE_STEPS] + assert conforming == legacy + + def test_the_miscellaneous_scope_takes_the_same_steps_in_the_same_order(self): + legacy_user = UserFactory.create() + self.retirement_for(legacy_user) + conforming_user = UserFactory.create() + self.retirement_for(conforming_user) + + legacy = self.steps_taken( + MISC_STEPS, + self.legacy_erasure("accounts_retire_misc", legacy_user), + own_module_step=HISTORICAL_SOCIAL_AUTH_STEP, + ) + conforming = self.steps_taken( + MISC_STEPS, + self.conforming_erasure("lms_misc", conforming_user), + own_module_step=HISTORICAL_SOCIAL_AUTH_STEP, + ) + + assert legacy == [ + *[label for label, _holder, _attribute in MISC_STEPS], + HISTORICAL_SOCIAL_AUTH_STEP[0], + ] + assert conforming == legacy + + def test_neither_scope_takes_a_step_belonging_to_the_other(self): + core_user = UserFactory.create() + self.retirement_for(core_user) + misc_user = UserFactory.create() + self.retirement_for(misc_user) + + assert not self.steps_taken(MISC_STEPS, self.conforming_erasure("lms_core", core_user)) + assert not self.steps_taken(CORE_STEPS, self.conforming_erasure("lms_misc", misc_user)) + + +@skip_unless_lms +class ErasureStateParityTests(RetirementParityTestCase): + """Both versions leave the stored data in the same state.""" + + def furnished_account(self, name): + """Return an account carrying one of everything the erasures clear.""" + user = UserFactory.create(username=name, email=f"{name}@example.com", first_name="A", last_name="B") + user.profile.name = "Learner Name" + user.profile.city = "Somewhere" + user.profile.bio = "A biography." + user.profile.year_of_birth = 1990 + user.profile.save() + SocialLink.objects.create( + user_profile=user.profile, platform="facebook", social_link="https://facebook.com/x", + ) + PendingEmailChange.objects.create( + user=user, new_email=f"new-{name}@example.com", activation_key=f"email-{name}", + ) + UserOrgTag.objects.create(user=user, key="foo", value="bar", org="edX") + PendingSecondaryEmailChange.objects.create( + user=user, new_secondary_email=f"second-{name}@example.com", activation_key=f"second-{name}", + ) + AccountRecovery.objects.create( + user=user, secondary_email=f"recovery-{name}@example.com", is_active=True, + ) + PendingNameChange.objects.create(user=user, new_name="Another Name", rationale="because") + UserSocialAuth.objects.create(user=user, provider="google-oauth2", uid=f"{name}@example.com") + cache.set(UserProfile.country_cache_key_name(user.id), "Timor-leste") + retirement = self.retirement_for(user) + CourseEnrollmentAllowed.objects.create( + email=retirement.original_email, course_id="course-v1:edX+DemoX+Demo", + ) + return user, retirement + + def stored_state(self, user, retirement): + """Return what is stored about this account, described without naming the account.""" + user.refresh_from_db() + profile = UserProfile.objects.get(user=user) + return { + "username_is_the_hashed_one": user.username == retirement.retired_username, + "email_is_the_hashed_one": user.email == retirement.retired_email, + "first_name": user.first_name, + "last_name": user.last_name, + "is_active": user.is_active, + "profile": {field: getattr(profile, field) for field in USER_PROFILE_PII}, + "social_links": profile.social_links.count(), + "pending_email_changes": PendingEmailChange.objects.filter(user=user).count(), + "organization_tags": UserOrgTag.objects.filter(user=user).count(), + "pending_secondary_email_changes": PendingSecondaryEmailChange.objects.filter(user=user).count(), + "recovery_emails": AccountRecovery.objects.filter(user=user).count(), + "enrollment_invitations": CourseEnrollmentAllowed.objects.filter( + email=retirement.original_email, + ).count(), + "cohort_assignments": UnregisteredLearnerCohortAssignments.objects.filter( + email=retirement.original_email, + ).count(), + "country_cache": cache.get(UserProfile.country_cache_key_name(user.id)), + "pending_name_changes": PendingNameChange.objects.filter(user=user).count(), + "social_auths": UserSocialAuth.objects.filter(user=user).count(), + "historical_social_auths": UserSocialAuth.history.model.objects.filter(user_id=user.id).count(), + } + + def test_the_core_scope_leaves_the_same_state_behind(self): + legacy_user, legacy_retirement = self.furnished_account("parity-core-legacy") + conforming_user, conforming_retirement = self.furnished_account("parity-core-conforming") + + assert self.client.post( + reverse("accounts_retire"), {"username": legacy_user.username}, format="json", + ).status_code == status.HTTP_204_NO_CONTENT + assert self.client.post( + reverse("user_v2:retirement_erasure", kwargs={"username": conforming_user.username}), + {"scope": "lms_core"}, + format="json", + ).status_code == status.HTTP_204_NO_CONTENT + + legacy_state = self.stored_state(legacy_user, legacy_retirement) + assert legacy_state == self.stored_state(conforming_user, conforming_retirement) + # The comparison is only worth anything if the erasure really ran. + assert legacy_state["username_is_the_hashed_one"] is True + assert legacy_state["is_active"] is False + assert legacy_state["profile"]["name"] == "" + assert legacy_state["social_links"] == 0 + assert legacy_state["pending_email_changes"] == 0 + assert legacy_state["organization_tags"] == 0 + assert legacy_state["recovery_emails"] == 0 + assert legacy_state["enrollment_invitations"] == 0 + assert legacy_state["country_cache"] is None + + def test_the_miscellaneous_scope_leaves_the_same_state_behind(self): + legacy_user, legacy_retirement = self.furnished_account("parity-misc-legacy") + conforming_user, conforming_retirement = self.furnished_account("parity-misc-conforming") + + assert self.client.post( + reverse("accounts_retire_misc"), {"username": legacy_user.username}, format="json", + ).status_code == status.HTTP_204_NO_CONTENT + assert self.client.post( + reverse("user_v2:retirement_erasure", kwargs={"username": conforming_user.username}), + {"scope": "lms_misc"}, + format="json", + ).status_code == status.HTTP_204_NO_CONTENT + + legacy_state = self.stored_state(legacy_user, legacy_retirement) + assert legacy_state == self.stored_state(conforming_user, conforming_retirement) + assert legacy_state["pending_name_changes"] == 0 + assert legacy_state["historical_social_auths"] == 0 + # This scope clears the history of a sign-in method, not the method itself. + assert legacy_state["social_auths"] == 1 + # The account itself is left alone by this scope. + assert legacy_state["username_is_the_hashed_one"] is False + assert legacy_state["is_active"] is True + + +@skip_unless_lms +class ErasureSignalParityTests(RetirementParityTestCase): + """Both versions announce the same things to the same listeners.""" + + def announcements(self, signal, call, user, retirement): + """Return what a call announced through this signal, described without naming the account.""" + heard = [] + + def listen(sender, **kwargs): + kwargs.pop("signal", None) + described = {"sender": sender} + for key, value in sorted(kwargs.items()): + if getattr(value, "pk", None) == user.pk: + described[key] = "" + elif value == retirement.retired_username: + described[key] = "" + elif value == retirement.retired_email: + described[key] = "" + elif value == retirement.original_email: + described[key] = "" + else: + described[key] = value + heard.append(described) + + signal.connect(listen) + try: + response = call() + finally: + signal.disconnect(listen) + assert response.status_code == status.HTTP_204_NO_CONTENT, response.content + return heard + + def test_the_core_scope_announces_the_same_thing(self): + legacy_user = UserFactory.create() + legacy_retirement = self.retirement_for(legacy_user) + conforming_user = UserFactory.create() + conforming_retirement = self.retirement_for(conforming_user) + + legacy = self.announcements( + USER_RETIRE_LMS_CRITICAL, + lambda: self.client.post( + reverse("accounts_retire"), {"username": legacy_user.username}, format="json", + ), + legacy_user, + legacy_retirement, + ) + conforming = self.announcements( + USER_RETIRE_LMS_CRITICAL, + lambda: self.client.post( + reverse("user_v2:retirement_erasure", kwargs={"username": conforming_user.username}), + {"scope": "lms_core"}, + format="json", + ), + conforming_user, + conforming_retirement, + ) + + assert legacy == [{ + "sender": AccountRetirementView, + "retired_email": "", + "retired_username": "", + "user": "", + }] + assert conforming == legacy + + def test_the_miscellaneous_scope_announces_the_same_two_things(self): + legacy_user = UserFactory.create() + legacy_retirement = self.retirement_for(legacy_user) + conforming_user = UserFactory.create() + conforming_retirement = self.retirement_for(conforming_user) + + for signal, expected in ( + (USER_RETIRE_LMS_MISC, [{"sender": LMSAccountRetirementView, "user": ""}]), + ( + USER_RETIRE_MAILINGS, + [{ + "sender": LMSAccountRetirementView, + "email": "", + "new_email": "", + "user": "", + }], + ), + ): + with self.subTest(signal=signal): + legacy = self.announcements( + signal, + lambda: self.client.post( + reverse("accounts_retire_misc"), + {"username": legacy_user.username}, + format="json", + ), + legacy_user, + legacy_retirement, + ) + conforming = self.announcements( + signal, + lambda: self.client.post( + reverse( + "user_v2:retirement_erasure", + kwargs={"username": conforming_user.username}, + ), + {"scope": "lms_misc"}, + format="json", + ), + conforming_user, + conforming_retirement, + ) + assert legacy == expected + assert conforming == legacy + + +@skip_unless_lms +class StateMachineParityTests(RetirementParityTestCase): + """The moves the pipeline allows and refuses are the same on both versions.""" + + #: Each case is the step a retirement stands at, the step asked for, + #: whether the move is forced, and whether each version carries it out. + MOVES = ( + ("LOCKING_ACCOUNT", "PENDING", False, False), + ("LOCKING_ACCOUNT", "PENDING", True, True), + ("COMPLETE", "PENDING", False, False), + ("COMPLETE", "PENDING", True, True), + ("LOCKING_ACCOUNT", "LOCKING_ACCOUNT", False, False), + ("LOCKING_ACCOUNT", "LOCKING_ACCOUNT", True, True), + ) + + def ask_for_the_move(self, address, body, legacy): + """Ask one of the two versions for a move, and return the status it answered with.""" + if legacy: + return self.client.patch(reverse("accounts_retirement_update"), body, format="json") + return self.client.patch(address, body, format="json") + + def test_both_versions_carry_out_and_refuse_the_same_moves(self): + for standing_at, asked_for, force, moves in self.MOVES: + with self.subTest(standing_at=standing_at, asked_for=asked_for, force=force): + legacy_retirement = self.retirement_for(UserFactory.create()) + conforming_retirement = self.retirement_for(UserFactory.create()) + for retirement in (legacy_retirement, conforming_retirement): + retirement.current_state = RetirementState.objects.get(state_name=standing_at) + retirement.save() + + body = {"new_state": asked_for, "response": "foo"} + if force: + body["force"] = True + + legacy = self.client.patch( + reverse("accounts_retirement_update"), + {**body, "username": legacy_retirement.original_username}, + format="json", + ) + conforming = self.client.patch( + reverse( + "user_v2:retirement_detail", + kwargs={"username": conforming_retirement.original_username}, + ), + body, + format="json", + ) + + if moves: + assert legacy.status_code == status.HTTP_204_NO_CONTENT + assert conforming.status_code == status.HTTP_204_NO_CONTENT + else: + assert legacy.status_code == status.HTTP_400_BAD_REQUEST + assert conforming.status_code == status.HTTP_409_CONFLICT + + legacy_retirement.refresh_from_db() + conforming_retirement.refresh_from_db() + assert ( + conforming_retirement.current_state.state_name + == legacy_retirement.current_state.state_name + ) + assert ( + conforming_retirement.last_state.state_name + == legacy_retirement.last_state.state_name + ) + + +@skip_unless_lms +class RetirementRecordParityTests(RetirementParityTestCase): + """A version 2 retirement record carries the values a version 1 one carries.""" + + def setUp(self): + super().setUp() + self.retirement = self.retirement_for( + UserFactory.create(username="parity-subject", email="parity-subject@example.com"), + ) + + def test_the_record_carries_the_same_values_bar_the_declared_differences(self): + legacy = UserRetirementStatusSerializer(instance=self.retirement).data + conforming = json.loads( + self.client.get( + reverse("user_v2:retirement_detail", kwargs={"username": "parity-subject"}), + ).content, + ) + + assert set(conforming) == set(legacy) + changed = sorted( + name for name in legacy + if json.loads(json.dumps(legacy[name], default=str)) != conforming[name] + ) + assert changed == sorted(name for name, _reason in RECORD_DIFFERENCES) + + def test_the_declared_differences_are_the_step_names(self): + conforming = self.client.get( + reverse("user_v2:retirement_detail", kwargs={"username": "parity-subject"}), + ).data + legacy = UserRetirementStatusSerializer(instance=self.retirement).data + assert conforming["current_state"] == legacy["current_state"]["state_name"] == WORKING_STATE + assert conforming["last_state"] == legacy["last_state"]["state_name"] == WORKING_STATE + + def test_neither_version_carries_the_log_of_the_moves_a_retirement_has_made(self): + conforming = self.client.get( + reverse("user_v2:retirement_detail", kwargs={"username": "parity-subject"}), + ).data + assert "responses" not in conforming + assert "responses" not in UserRetirementStatusSerializer(instance=self.retirement).data + assert UserRetirementStatus.objects.get(id=self.retirement.id).responses != "" diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_query_counts.py b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_query_counts.py new file mode 100644 index 000000000000..6e0e0945e4aa --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_query_counts.py @@ -0,0 +1,167 @@ +"""Query-count guards for the version 2 retirement pipeline addresses.""" + +import pytest +from django.urls import reverse + +from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory +from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import ( # pylint: disable=unused-import + create_retirement_status, + setup_retirement_states, # noqa: F401 +) +from openedx.core.djangoapps.user_api.models import ( + RetirementState, + UserRetirementPartnerReportingStatus, +) +from openedx.core.djangolib.testing.utils import skip_unless_lms + +PASSWORD = "Password1234" +WORKER_USERNAME = "count-retirement-worker" + +#: Row counts a per-row query would show up at. +ROW_COUNTS = [1, 5, 25] + +#: How many courses each account in the partner reporting queue studied, which +#: is what the legacy claim reads one query at a time. +ENROLMENTS_PER_ROW = 3 + +#: The step the rows of the queue stand at, which is one the pipeline works. +WORKING_STATE = "LOCKING_ACCOUNT" + +RETIREMENT_LIST_QUERIES = 15 +RETIREMENT_DETAIL_QUERIES = 15 +PARTNER_REPORT_CLAIM_QUERIES = 16 + +#: Addresses of the pipeline operations this version replaces, so the cost of +#: the two can be compared. +EARLIER_QUEUE = "/api/user/v1/accounts/retirement_queue/" +EARLIER_CLAIM = "/api/user/v1/accounts/retirement_partner_report/" + +#: What the earlier claim costs, measured the same way as the counts above: a +#: constant part plus two queries for every account on it, because both the +#: account and the courses it studied are read one account at a time. +EARLIER_CLAIM_QUERIES = {1: 17, 5: 25, 25: 65} + +#: What the earlier queue costs: a constant part plus one query for every +#: retirement on it, because the profile holding each account's full name is +#: read one retirement at a time. +EARLIER_QUEUE_QUERIES = {1: 16, 5: 20, 25: 40} + + +def sign_the_worker_in(client, settings): + """Bind the pipeline's capability to a caller and sign that caller in.""" + settings.RETIREMENT_SERVICE_WORKER_USERNAME = WORKER_USERNAME + worker = UserFactory.create(username=WORKER_USERNAME, password=PASSWORD) + assert client.login(username=WORKER_USERNAME, password=PASSWORD) + return worker + + +@pytest.fixture(name="queue") +@skip_unless_lms +def queue_fixture(request, client, settings): + """Sign the pipeline's caller in and put ``request.param`` retirements in the queue.""" + request.getfixturevalue("setup_retirement_states") + sign_the_worker_in(client, settings) + state = RetirementState.objects.get(state_name=WORKING_STATE) + rows = [ + create_retirement_status(UserFactory.create(username=f"queued{index}"), state=state) + for index in range(request.param) + ] + # The first authenticated request of a session populates caches the rest of + # the session reuses; the counts below are the steady-state ones. + client.get(reverse("user_v2:retirement_list")) + return rows + + +@pytest.fixture(name="partner_queue") +@skip_unless_lms +def partner_queue_fixture(request, client, settings): + """Sign the pipeline's caller in and queue ``request.param`` accounts for partner reporting.""" + request.getfixturevalue("setup_retirement_states") + sign_the_worker_in(client, settings) + rows = [] + for index in range(request.param): + user = UserFactory.create(username=f"reported{index}") + for course in range(ENROLMENTS_PER_ROW): + CourseEnrollmentFactory.create( + user=user, course_id=f"course-v1:Org{course}+Course{index}+Run", + ) + rows.append( + UserRetirementPartnerReportingStatus.objects.create( + user=user, + original_username=user.username, + original_email=user.email, + original_name=user.profile.name, + ), + ) + client.get(reverse("user_v2:retirement_list")) + return rows + + +@pytest.mark.django_db +@pytest.mark.parametrize("queue", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_the_queue_costs_the_same_however_many_retirements_are_in_it( + client, queue, django_assert_num_queries, +): + with django_assert_num_queries(RETIREMENT_LIST_QUERIES): + response = client.get(reverse("user_v2:retirement_list"), {"page_size": 100}) + assert len(response.json()["results"]) == len(queue) + + +@pytest.mark.django_db +@pytest.mark.parametrize("queue", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_the_minimal_queue_costs_the_same_however_many_retirements_are_in_it( + client, queue, django_assert_num_queries, +): + with django_assert_num_queries(RETIREMENT_LIST_QUERIES): + response = client.get( + reverse("user_v2:retirement_list"), {"page_size": 100, "view": "minimal"}, + ) + assert len(response.json()["results"]) == len(queue) + + +@pytest.mark.django_db +@pytest.mark.parametrize("queue", [1], indirect=True) +@skip_unless_lms +def test_one_retirement_costs_a_fixed_number_of_queries(client, queue, django_assert_num_queries): + address = reverse("user_v2:retirement_detail", kwargs={"username": queue[0].original_username}) + with django_assert_num_queries(RETIREMENT_DETAIL_QUERIES): + response = client.get(address) + assert response.json()["original_username"] == queue[0].original_username + + +@pytest.mark.django_db +@pytest.mark.parametrize("partner_queue", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_the_claim_costs_the_same_however_many_accounts_are_queued( + client, partner_queue, django_assert_num_queries, +): + """The courses each account studied are read with the entries rather than one entry at a time.""" + with django_assert_num_queries(PARTNER_REPORT_CLAIM_QUERIES): + response = client.post(reverse("user_v2:retirement_partner_report_list"), {}, format="json") + body = response.json() + assert len(body) == len(partner_queue) + assert all(len(entry["orgs"]) == ENROLMENTS_PER_ROW for entry in body) + + +@pytest.mark.django_db +@pytest.mark.parametrize("partner_queue", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_the_earlier_claim_reads_the_courses_one_account_at_a_time( + client, partner_queue, django_assert_num_queries, +): + """The baseline the count above is measured against, on the address this version replaces.""" + with django_assert_num_queries(EARLIER_CLAIM_QUERIES[len(partner_queue)]): + response = client.post(EARLIER_CLAIM, {}, format="json", content_type="application/json") + assert len(response.json()) == len(partner_queue) + + +@pytest.mark.django_db +@pytest.mark.parametrize("queue", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_the_earlier_queue_costs_what_it_costs(client, queue, django_assert_num_queries): + """The baseline the queue count is measured against, on the address this version replaces.""" + with django_assert_num_queries(EARLIER_QUEUE_QUERIES[len(queue)]): + response = client.get(EARLIER_QUEUE, {"cool_off_days": 0, "states": WORKING_STATE}) + assert len(response.json()) == len(queue) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py new file mode 100644 index 000000000000..6bd0a028f98a --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py @@ -0,0 +1,1504 @@ +"""Tests for the version 2 User API views over the account retirement pipeline.""" + +import datetime +from unittest import mock +from zoneinfo import ZoneInfo + +import pytest +from django.contrib.auth.models import Permission +from django.core.cache import cache +from django.db import connection +from django.test import SimpleTestCase, override_settings +from django.test.utils import CaptureQueriesContext +from django.urls import reverse +from edx_rest_framework_extensions.errors import Conflict, classify_error +from edx_rest_framework_extensions.testing import assert_error_envelope +from opaque_keys.edx.keys import CourseKey +from rest_framework import permissions, status +from rest_framework.test import APITestCase + +from common.djangoapps.student.models import ( + AccountRecovery, + CourseEnrollmentAllowed, + PendingEmailChange, + PendingNameChange, + UserProfile, + get_retired_username_by_username, +) +from common.djangoapps.student.tests.factories import ( + AdminFactory, + CourseEnrollmentFactory, + SuperuserFactory, + UserFactory, +) +from openedx.core.djangoapps.user_api.accounts.permissions import CanCancelUserRetirement, CanRetireUser +from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import ( # pylint: disable=unused-import + create_retirement_status, + setup_retirement_states, # noqa: F401 +) +from openedx.core.djangoapps.user_api.models import ( + RetirementState, + UserOrgTag, + UserRetirementPartnerReportingStatus, + UserRetirementRequest, + UserRetirementStatus, +) +from openedx.core.djangoapps.user_api.v2 import view_services +from openedx.core.djangoapps.user_api.v2.errors import RetirementStateConflict +from openedx.core.djangoapps.user_api.v2.paginators import UserApiPagination +from openedx.core.djangoapps.user_api.v2.serializers import RETIREMENT_MINIMAL_FIELDS +from openedx.core.djangoapps.user_api.v2.views import ( + RETIREMENT_ACTION_PERMISSIONS, + RetirementCleanupView, + RetirementPartnerReportCleanupView, + RetirementPartnerReportViewSet, + RetirementViewSet, +) +from openedx.core.djangolib.testing.utils import skip_unless_lms + +from .test_views import write_queries + +PASSWORD = "Password1234" + +#: The caller the pipeline runs as, the caller that replaces usernames, and the +#: names the deployment binds each capability to. +WORKER_USERNAME = "retirement-worker" +REPLACEMENT_WORKER_USERNAME = "replacement-worker" + +#: The callers that may carry out pipeline work, and the callers that may not. +PIPELINE_CALLERS = ("worker", "requester", "superuser") +PIPELINE_REFUSED = ("learner", "subject", "staff", "canceller", "replacer") + +#: The callers that may undo a retirement request, and the callers that may +#: not. The pipeline itself is among the refused: carrying a retirement out and +#: calling it off are different capabilities, and one request cannot hold both. +CANCEL_CALLERS = ("canceller", "superuser") +CANCEL_REFUSED = ("learner", "subject", "staff", "requester", "worker", "replacer") + +#: The query parameters each retirement address reads, in the order its refusal +#: lists them. +LIST_QUERY_PARAMS = ( + "cool_off_days, created_after, created_before, fields, format, ordering, page, page_size, state, view" +) +DETAIL_QUERY_PARAMS = "fields, format, view" +WRITE_QUERY_PARAMS = "format" +CLAIM_QUERY_PARAMS = "format, limit" + +#: What the shortest and the longest page of any collection of this version holds. +DEFAULT_PAGE_SIZE = 10 +MAXIMUM_PAGE_SIZE = 100 + +PAGINATION_KEYS = {"count", "num_pages", "current_page", "start", "next", "previous", "results"} + +#: Every property a retirement record carries. +RETIREMENT_FIELDS = { + "id", + "user", + "original_username", + "original_email", + "original_name", + "retired_username", + "retired_email", + "current_state", + "last_state", + "created", + "modified", +} + +#: The properties of a retiring account's own data, which the ``minimal`` +#: preset must not carry. +RETIREMENT_PII_FIELDS = {"original_email", "original_name", "user"} + +STATE_CONFLICT_SLUG = "user/retirement-state-conflict" + +COURSE_KEY = CourseKey.from_string("course-v1:edX+DemoX+Demo") +OTHER_COURSE_KEY = CourseKey.from_string("course-v1:OtherOrg+OtherX+Other") + + +def grant(user, codename, app_label="user_api"): + """Give this user the named Django model permission.""" + user.user_permissions.add( + Permission.objects.get(codename=codename, content_type__app_label=app_label), + ) + + +@override_settings( + RETIREMENT_SERVICE_WORKER_USERNAME=WORKER_USERNAME, + USERNAME_REPLACEMENT_WORKER=REPLACEMENT_WORKER_USERNAME, +) +@pytest.mark.usefixtures("setup_retirement_states") +class RetirementApiTestCase(APITestCase): + """The callers and the fixtures the retirement pipeline tests share.""" + + def setUp(self): + super().setUp() + self.worker = UserFactory.create(username=WORKER_USERNAME, password=PASSWORD) + self.replacer = UserFactory.create(username=REPLACEMENT_WORKER_USERNAME, password=PASSWORD) + self.superuser = SuperuserFactory.create(username="super-caller", password=PASSWORD) + self.staff = AdminFactory.create(username="plain-staff", password=PASSWORD) + self.requester = AdminFactory.create(username="staff-who-requests", password=PASSWORD) + self.canceller = AdminFactory.create(username="staff-who-cancels", password=PASSWORD) + self.learner = UserFactory.create(username="learner-caller", password=PASSWORD) + self.subject = UserFactory.create( + username="subject", email="subject@example.com", password=PASSWORD, + ) + grant(self.requester, "add_userretirementrequest") + grant(self.canceller, "change_userretirementstatus") + + def login(self, caller): + """Sign the named caller in through the session the platform default accepts.""" + user = getattr(self, caller) + assert self.client.login(username=user.username, password=PASSWORD) + + def state(self, state_name): + """Return the configured pipeline step of this name.""" + return RetirementState.objects.get(state_name=state_name) + + def retirement_in(self, state_name, user=None, created=None): + """Return a retirement standing at this pipeline step.""" + return create_retirement_status( + user or UserFactory.create(), state=self.state(state_name), create_datetime=created, + ) + + +class _AccessMatrixMixin: + """ + Every caller the address admits is served, and every other caller is refused. + + The matrix is the proof that the three capabilities of this area are + carried over as they stand: the caller the pipeline runs as, the staff + caller who may add a retirement request and a superuser work the pipeline; + only the staff caller who may change a retirement status and a superuser + call one off; and the caller that replaces usernames reaches neither. + """ + + #: The callers the address admits, and the ones it refuses. + admitted = () + refused = () + + def send(self): + """Make the request this address serves, building whatever it acts on.""" + raise NotImplementedError + + def test_every_admitted_caller_is_served(self): + for caller in self.admitted: + with self.subTest(caller=caller): + self.client.logout() + self.login(caller) + response = self.send() + assert response.status_code in ( + status.HTTP_200_OK, + status.HTTP_204_NO_CONTENT, + ), f"{caller} got {response.status_code}: {getattr(response, 'data', None)!r}" + + def test_every_other_caller_is_forbidden(self): + for caller in self.refused: + with self.subTest(caller=caller): + self.client.logout() + self.login(caller) + assert_error_envelope( + self.send(), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_an_anonymous_caller_is_unauthenticated(self): + self.client.logout() + assert_error_envelope( + self.send(), + expected_status=status.HTTP_401_UNAUTHORIZED, + expected_type_slug="authn", + ) + + +@skip_unless_lms +class RetirementListTests(_AccessMatrixMixin, RetirementApiTestCase): + """GET /api/user/v2/retirements/""" + + admitted = PIPELINE_CALLERS + refused = PIPELINE_REFUSED + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:retirement_list") + day = datetime.datetime(2026, 3, 1, tzinfo=ZoneInfo("UTC")) + self.pending = self.retirement_in("PENDING", created=day) + self.locking = self.retirement_in("LOCKING_ACCOUNT", created=day + datetime.timedelta(days=1)) + self.complete = self.retirement_in("COMPLETE", created=day + datetime.timedelta(days=2)) + self.day = day + + def send(self): + return self.client.get(self.url) + + def names_in(self, response): + """Return the registered names the page carries, in the order it carries them.""" + return [row["original_username"] for row in response.data["results"]] + + def test_the_page_carries_every_retirement_in_the_order_they_were_requested(self): + self.login("worker") + response = self.client.get(self.url) + assert response.status_code == status.HTTP_200_OK + assert set(response.data) == PAGINATION_KEYS + assert self.names_in(response) == [ + self.pending.original_username, + self.locking.original_username, + self.complete.original_username, + ] + assert response.data["count"] == 3 + assert response.data["num_pages"] == 1 + assert response.data["current_page"] == 1 + assert response.data["start"] == 0 + + def test_a_row_carries_the_values_the_record_holds(self): + self.login("worker") + response = self.client.get(self.url, {"state": "LOCKING_ACCOUNT"}) + row = response.data["results"][0] + assert set(row) == RETIREMENT_FIELDS + assert row["id"] == self.locking.id + assert row["original_username"] == self.locking.original_username + assert row["original_email"] == self.locking.original_email + assert row["original_name"] == self.locking.original_name + assert row["retired_username"] == self.locking.retired_username + assert row["retired_email"] == self.locking.retired_email + assert row["current_state"] == "LOCKING_ACCOUNT" + assert row["last_state"] == "LOCKING_ACCOUNT" + assert row["user"] == { + "id": self.locking.user.id, + "username": self.locking.user.username, + "email": self.locking.user.email, + "profile": {"id": self.locking.user.profile.id, "name": self.locking.user.profile.name}, + } + + def test_the_page_holds_ten_rows_unless_a_size_is_asked_for(self): + for _ in range(9): + self.retirement_in("PENDING") + self.login("worker") + response = self.client.get(self.url) + assert len(response.data["results"]) == DEFAULT_PAGE_SIZE + assert response.data["count"] == 12 + assert response.data["num_pages"] == 2 + + def test_a_page_larger_than_the_maximum_holds_the_maximum(self): + for _ in range(MAXIMUM_PAGE_SIZE + 2): + self.retirement_in("PENDING") + self.login("worker") + response = self.client.get(self.url, {"page_size": 500}) + assert len(response.data["results"]) == MAXIMUM_PAGE_SIZE + assert response.data["count"] == MAXIMUM_PAGE_SIZE + 5 + + def test_a_page_beyond_the_last_one_is_not_found(self): + self.login("worker") + assert_error_envelope( + self.client.get(self.url, {"page": 9999}), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_one_step_can_be_asked_for(self): + self.login("worker") + response = self.client.get(self.url, {"state": "COMPLETE"}) + assert self.names_in(response) == [self.complete.original_username] + + def test_several_steps_can_be_asked_for_at_once(self): + self.login("worker") + response = self.client.get(self.url, {"state": "PENDING,COMPLETE"}) + assert self.names_in(response) == [ + self.pending.original_username, + self.complete.original_username, + ] + + def test_a_step_this_deployment_does_not_run_is_refused(self): + self.login("worker") + response = self.client.get(self.url, {"state": "NOPE"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert "No such pipeline step: NOPE." in envelope["errors"]["state"][0] + + def test_retirements_requested_recently_can_be_left_out(self): + recent = self.retirement_in( + "PENDING", created=datetime.datetime.now(ZoneInfo("UTC")) - datetime.timedelta(days=1), + ) + self.login("worker") + response = self.client.get(self.url, {"cool_off_days": 7, "page_size": 100}) + assert recent.original_username not in self.names_in(response) + assert self.pending.original_username in self.names_in(response) + + def test_no_cooling_off_period_leaves_every_retirement_in(self): + recent = self.retirement_in( + "PENDING", created=datetime.datetime.now(ZoneInfo("UTC")) - datetime.timedelta(days=1), + ) + self.login("worker") + response = self.client.get(self.url, {"cool_off_days": 0, "page_size": 100}) + assert recent.original_username in self.names_in(response) + + def test_a_cooling_off_period_that_is_not_a_whole_number_of_days_is_refused(self): + self.login("worker") + for value in ("1.5", "nope"): + with self.subTest(value=value): + envelope = assert_error_envelope( + self.client.get(self.url, {"cool_off_days": value}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "cool_off_days" in envelope["errors"] + + def test_a_negative_cooling_off_period_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.get(self.url, {"cool_off_days": -1}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "cool_off_days" in envelope["errors"] + + def test_the_later_bound_includes_the_moment_it_names(self): + self.login("worker") + response = self.client.get( + self.url, {"created_after": self.locking.created.isoformat(), "page_size": 100}, + ) + assert self.names_in(response) == [ + self.locking.original_username, + self.complete.original_username, + ] + + def test_the_earlier_bound_leaves_out_the_moment_it_names(self): + self.login("worker") + response = self.client.get( + self.url, {"created_before": self.locking.created.isoformat(), "page_size": 100}, + ) + assert self.names_in(response) == [self.pending.original_username] + + def test_a_bound_that_is_not_a_moment_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.get(self.url, {"created_after": "the day before"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "created_after" in envelope["errors"] + + def test_the_page_can_be_sorted_by_every_field_it_offers(self): + self.login("worker") + expected = { + "original_username": sorted( + [r.original_username for r in (self.pending, self.locking, self.complete)], + ), + "created": [ + self.pending.original_username, + self.locking.original_username, + self.complete.original_username, + ], + "modified": [ + self.pending.original_username, + self.locking.original_username, + self.complete.original_username, + ], + "current_state": [ + self.pending.original_username, + self.locking.original_username, + self.complete.original_username, + ], + } + for field, order in expected.items(): + with self.subTest(field=field): + response = self.client.get(self.url, {"ordering": field, "page_size": 100}) + assert self.names_in(response) == order + reversed_response = self.client.get( + self.url, {"ordering": f"-{field}", "page_size": 100}, + ) + assert self.names_in(reversed_response) == list(reversed(order)) + + def test_a_sort_field_the_page_does_not_offer_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.get(self.url, {"ordering": "id"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "ordering" in envelope["errors"] + + def test_a_query_parameter_the_page_does_not_read_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.get(self.url, {"states": "PENDING"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"]["states"] == [ + f"No such query parameter. Send any of: {LIST_QUERY_PARAMS}.", + ] + + def test_the_minimal_preset_carries_no_data_of_the_account_owner(self): + self.login("worker") + response = self.client.get(self.url, {"view": "minimal"}) + row = response.data["results"][0] + assert set(row) == set(RETIREMENT_MINIMAL_FIELDS) + assert set(row) & RETIREMENT_PII_FIELDS == set() + assert row["original_username"] == self.pending.original_username + assert row["current_state"] == "PENDING" + + def test_a_preset_the_page_does_not_answer_to_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.get(self.url, {"view": "everything"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"]["view"] == [ + "Ask for any of: minimal, or omit this parameter for the default response.", + ] + + def test_properties_can_be_selected(self): + self.login("worker") + response = self.client.get(self.url, {"fields": "original_username,current_state"}) + assert response.data["results"][0] == { + "original_username": self.pending.original_username, + "current_state": "PENDING", + } + + def test_a_property_a_row_cannot_carry_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.get(self.url, {"fields": "responses"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"]["fields"][0].startswith("No such field: responses.") + + def test_every_admitted_caller_sees_the_same_rows(self): + """The visibility layer narrows nothing for a caller holding the capability.""" + seen = {} + for caller in PIPELINE_CALLERS: + self.client.logout() + self.login(caller) + seen[caller] = self.names_in(self.client.get(self.url, {"page_size": 100})) + assert seen == { + caller: [ + self.pending.original_username, + self.locking.original_username, + self.complete.original_username, + ] + for caller in PIPELINE_CALLERS + } + + def test_the_page_is_readable_without_a_body(self): + self.login("worker") + response = self.client.head(self.url) + assert response.status_code == status.HTTP_200_OK + + +@skip_unless_lms +class RetirementDetailTests(_AccessMatrixMixin, RetirementApiTestCase): + """GET /api/user/v2/retirements/{username}/""" + + admitted = PIPELINE_CALLERS + refused = PIPELINE_REFUSED + + def setUp(self): + super().setUp() + self.retirement = self.retirement_in("LOCKING_ACCOUNT", user=self.subject) + self.url = reverse("user_v2:retirement_detail", kwargs={"username": "subject"}) + + def send(self): + return self.client.get(self.url) + + def test_the_record_comes_back_whole(self): + self.login("worker") + response = self.client.get(self.url) + assert response.status_code == status.HTTP_200_OK + assert set(response.data) == RETIREMENT_FIELDS + assert response.data["id"] == self.retirement.id + assert response.data["original_username"] == "subject" + assert response.data["original_email"] == "subject@example.com" + assert response.data["current_state"] == "LOCKING_ACCOUNT" + + def test_the_hashed_name_a_retired_account_is_moved_to_addresses_the_same_record(self): + hashed = get_retired_username_by_username("subject") + self.subject.username = hashed + self.subject.save() + self.login("worker") + response = self.client.get( + reverse("user_v2:retirement_detail", kwargs={"username": "subject"}), + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["id"] == self.retirement.id + by_hash = self.client.get( + reverse("user_v2:retirement_detail", kwargs={"username": hashed}), + ) + assert by_hash.status_code == status.HTTP_200_OK + assert by_hash.data["id"] == self.retirement.id + + def test_a_name_no_retirement_is_held_for_is_not_found(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.get(reverse("user_v2:retirement_detail", kwargs={"username": "nobody"})), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + assert envelope["detail"] == view_services.NO_SUCH_RETIREMENT + + def test_an_account_with_no_retirement_behind_it_is_not_found(self): + self.login("worker") + assert_error_envelope( + self.client.get( + reverse("user_v2:retirement_detail", kwargs={"username": "learner-caller"}), + ), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_the_minimal_preset_carries_no_data_of_the_account_owner(self): + self.login("worker") + response = self.client.get(self.url, {"view": "minimal"}) + assert set(response.data) == set(RETIREMENT_MINIMAL_FIELDS) + assert set(response.data) & RETIREMENT_PII_FIELDS == set() + + def test_properties_can_be_selected(self): + self.login("worker") + response = self.client.get(self.url, {"fields": "current_state"}) + assert response.data == {"current_state": "LOCKING_ACCOUNT"} + + def test_the_parameters_that_cut_a_page_are_not_read_at_a_member_address(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.get(self.url, {"page": 2, "state": "PENDING"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert sorted(envelope["errors"]) == ["page", "state"] + assert envelope["errors"]["page"] == [ + f"No such query parameter. Send any of: {DETAIL_QUERY_PARAMS}.", + ] + + def test_a_refusal_is_answered_as_json_rather_than_as_a_page(self): + self.login("worker") + response = self.client.get(reverse("user_v2:retirement_detail", kwargs={"username": "nobody"})) + assert response["Content-Type"] == "application/json" + + def test_the_record_is_readable_without_a_body(self): + self.login("worker") + assert self.client.head(self.url).status_code == status.HTTP_200_OK + + +@skip_unless_lms +class RetirementUpdateTests(_AccessMatrixMixin, RetirementApiTestCase): + """PATCH /api/user/v2/retirements/{username}/""" + + admitted = PIPELINE_CALLERS + refused = PIPELINE_REFUSED + + def setUp(self): + super().setUp() + self.retirement = self.retirement_in("LOCKING_ACCOUNT", user=self.subject) + self.url = reverse("user_v2:retirement_detail", kwargs={"username": "subject"}) + + def send(self): + """Move a retirement of its own, so that one caller's move does not decide the next one's.""" + retirement = self.retirement_in("LOCKING_ACCOUNT") + return self.client.patch( + reverse("user_v2:retirement_detail", kwargs={"username": retirement.original_username}), + {"new_state": "LOCKING_COMPLETE", "response": "done"}, + format="json", + ) + + def patch_subject(self, body, **kwargs): + """Ask for a move of the retirement this address names.""" + return self.client.patch(self.url, body, format="json", **kwargs) + + def test_a_later_step_moves_the_retirement_and_is_logged(self): + self.login("worker") + response = self.patch_subject({"new_state": "LOCKING_COMPLETE", "response": "account locked"}) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert response.data is None + self.retirement.refresh_from_db() + assert self.retirement.current_state.state_name == "LOCKING_COMPLETE" + assert self.retirement.last_state.state_name == "LOCKING_ACCOUNT" + assert "account locked" in self.retirement.responses + + def test_an_earlier_step_is_refused_and_the_retirement_does_not_move(self): + self.login("worker") + envelope = assert_error_envelope( + self.patch_subject({"new_state": "PENDING", "response": "rewind"}), + expected_status=status.HTTP_409_CONFLICT, + expected_type_slug=STATE_CONFLICT_SLUG, + ) + assert envelope["detail"] == view_services.CANNOT_MOVE_TO_STEP + self.retirement.refresh_from_db() + assert self.retirement.current_state.state_name == "LOCKING_ACCOUNT" + + def test_an_earlier_step_moves_the_retirement_when_the_move_is_forced(self): + self.login("worker") + response = self.patch_subject({"new_state": "PENDING", "response": "rewind", "force": True}) + assert response.status_code == status.HTTP_204_NO_CONTENT + self.retirement.refresh_from_db() + assert self.retirement.current_state.state_name == "PENDING" + + def test_a_retirement_that_errored_can_be_moved_on_to_a_later_step(self): + """An operator rewinds and re-runs a retirement that failed, which is what this allows.""" + self.retirement.current_state = self.state("ERRORED") + self.retirement.save() + self.login("worker") + assert self.patch_subject( + {"new_state": "COMPLETE", "response": "finished by hand"}, + ).status_code == status.HTTP_204_NO_CONTENT + self.retirement.refresh_from_db() + assert self.retirement.current_state.state_name == "COMPLETE" + + def test_a_retirement_that_errored_is_not_moved_back_to_an_earlier_step_unless_forced(self): + self.retirement.current_state = self.state("ERRORED") + self.retirement.save() + self.login("worker") + assert_error_envelope( + self.patch_subject({"new_state": "PENDING", "response": "rewind"}), + expected_status=status.HTTP_409_CONFLICT, + expected_type_slug=STATE_CONFLICT_SLUG, + ) + self.retirement.refresh_from_db() + assert self.retirement.current_state.state_name == "ERRORED" + + def test_a_step_this_deployment_does_not_run_is_refused_before_anything_moves(self): + self.login("worker") + envelope = assert_error_envelope( + self.patch_subject({"new_state": "NOPE", "response": "done"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"]["new_state"][0].startswith("No such pipeline step.") + self.retirement.refresh_from_db() + assert self.retirement.current_state.state_name == "LOCKING_ACCOUNT" + + def test_a_forced_move_to_a_step_this_deployment_does_not_run_is_refused_too(self): + self.login("worker") + assert_error_envelope( + self.patch_subject({"new_state": "NOPE", "response": "done", "force": True}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + + def test_a_body_that_names_no_step_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.patch_subject({"response": "done"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "new_state" in envelope["errors"] + + def test_a_body_that_says_nothing_about_the_step_being_left_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.patch_subject({"new_state": "LOCKING_COMPLETE"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "response" in envelope["errors"] + + def test_a_name_spelled_differently_from_the_registered_one_is_not_found(self): + self.login("worker") + assert_error_envelope( + self.client.patch( + reverse("user_v2:retirement_detail", kwargs={"username": "Subject"}), + {"new_state": "LOCKING_COMPLETE", "response": "done"}, + format="json", + ), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + self.retirement.refresh_from_db() + assert self.retirement.current_state.state_name == "LOCKING_ACCOUNT" + + def test_the_hashed_name_does_not_address_a_write(self): + hashed = get_retired_username_by_username("subject") + self.login("worker") + assert_error_envelope( + self.client.patch( + reverse("user_v2:retirement_detail", kwargs={"username": hashed}), + {"new_state": "LOCKING_COMPLETE", "response": "done"}, + format="json", + ), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_a_body_in_a_media_type_this_address_cannot_read_is_refused(self): + self.login("worker") + assert_error_envelope( + self.client.patch(self.url, "new_state=LOCKING_COMPLETE", content_type="text/plain"), + expected_status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + expected_type_slug="unsupported-media-type", + ) + + def test_a_body_that_is_not_well_formed_is_refused(self): + self.login("worker") + assert_error_envelope( + self.client.patch(self.url, "{not json", content_type="application/json"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="malformed-body", + ) + + def test_a_query_parameter_a_write_does_not_read_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.patch_subject({"new_state": "LOCKING_COMPLETE", "response": "done"}, QUERY_STRING="view=minimal"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"]["view"] == [ + f"No such query parameter. Send any of: {WRITE_QUERY_PARAMS}.", + ] + + +@skip_unless_lms +class RetirementCancelTests(_AccessMatrixMixin, RetirementApiTestCase): + """DELETE /api/user/v2/retirements/{username}/""" + + admitted = CANCEL_CALLERS + refused = CANCEL_REFUSED + + def send(self): + user = UserFactory.create(email="to-cancel@example.com") + create_retirement_status(user) + return self.client.delete( + reverse("user_v2:retirement_detail", kwargs={"username": user.username}), + ) + + def test_the_request_is_gone_and_the_account_is_usable_again(self): + retirement = create_retirement_status(self.subject) + self.subject.email = retirement.retired_email + self.subject.save() + self.login("canceller") + response = self.client.delete( + reverse("user_v2:retirement_detail", kwargs={"username": "subject"}), + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not UserRetirementStatus.objects.filter(id=retirement.id).exists() + assert not UserRetirementRequest.objects.filter(user=self.subject).exists() + self.subject.refresh_from_db() + assert self.subject.email == "subject@example.com" + assert not self.subject.check_password(PASSWORD) + + def test_a_retirement_the_pipeline_has_started_on_is_not_cancelled(self): + retirement = self.retirement_in("LOCKING_ACCOUNT", user=self.subject) + self.login("canceller") + envelope = assert_error_envelope( + self.client.delete(reverse("user_v2:retirement_detail", kwargs={"username": "subject"})), + expected_status=status.HTTP_409_CONFLICT, + expected_type_slug=STATE_CONFLICT_SLUG, + ) + assert envelope["detail"] == view_services.ONLY_PENDING_IS_CANCELLED + assert UserRetirementStatus.objects.filter(id=retirement.id).exists() + + def test_a_name_no_retirement_is_requested_under_is_not_found(self): + self.login("canceller") + assert_error_envelope( + self.client.delete(reverse("user_v2:retirement_detail", kwargs={"username": "nobody"})), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_a_name_spelled_differently_from_the_registered_one_is_not_found(self): + retirement = create_retirement_status(self.subject) + self.login("canceller") + assert_error_envelope( + self.client.delete(reverse("user_v2:retirement_detail", kwargs={"username": "SUBJECT"})), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + assert UserRetirementStatus.objects.filter(id=retirement.id).exists() + + +@skip_unless_lms +class RetirementErasureTests(_AccessMatrixMixin, RetirementApiTestCase): + """POST /api/user/v2/retirements/{username}/erasures/""" + + admitted = PIPELINE_CALLERS + refused = PIPELINE_REFUSED + + def setUp(self): + super().setUp() + self.retirement = self.retirement_in("RETIRING_LMS", user=self.subject) + self.url = reverse("user_v2:retirement_erasure", kwargs={"username": "subject"}) + + def send(self, scope="lms_core"): + return self.client.post(self.url, {"scope": scope}, format="json") + + def test_the_core_scope_erases_the_account_and_its_profile(self): + self.subject.profile.name = "Subject Name" + self.subject.profile.city = "Somewhere" + self.subject.profile.save() + PendingEmailChange.objects.create( + user=self.subject, new_email="new@example.com", activation_key="k" * 32, + ) + UserOrgTag.objects.create(user=self.subject, key="foo", value="bar", org="edX") + AccountRecovery.objects.create( + user=self.subject, secondary_email="recovery@example.com", is_active=True, + ) + CourseEnrollmentAllowed.objects.create(email="subject@example.com", course_id=COURSE_KEY) + cache.set(UserProfile.country_cache_key_name(self.subject.id), "Timor-leste") + + self.login("worker") + assert self.send("lms_core").status_code == status.HTTP_204_NO_CONTENT + + self.subject.refresh_from_db() + assert self.subject.username == self.retirement.retired_username + assert self.subject.email == self.retirement.retired_email + assert self.subject.first_name == "" + assert self.subject.last_name == "" + assert self.subject.is_active is False + assert self.subject.profile.name == "" + assert self.subject.profile.city is None + assert not PendingEmailChange.objects.filter(user=self.subject).exists() + assert not UserOrgTag.objects.filter(user=self.subject).exists() + assert not AccountRecovery.objects.filter(user=self.subject).exists() + assert not CourseEnrollmentAllowed.objects.filter(email="subject@example.com").exists() + assert cache.get(UserProfile.country_cache_key_name(self.subject.id)) is None + + def test_the_miscellaneous_scope_erases_what_the_features_around_the_account_hold(self): + PendingNameChange.objects.create(user=self.subject, new_name="Another Name", rationale="x") + self.login("worker") + assert self.send("lms_misc").status_code == status.HTTP_204_NO_CONTENT + assert not PendingNameChange.objects.filter(user=self.subject).exists() + self.subject.refresh_from_db() + # The miscellaneous scope leaves the account itself alone. + assert self.subject.username == "subject" + assert self.subject.is_active is True + + def test_the_erasure_does_not_move_the_retirement(self): + self.login("worker") + self.send("lms_core") + self.retirement.refresh_from_db() + assert self.retirement.current_state.state_name == "RETIRING_LMS" + + def test_a_body_that_names_no_scope_is_refused_and_nothing_is_erased(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.post(self.url, {}, format="json"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "scope" in envelope["errors"] + self.subject.refresh_from_db() + assert self.subject.username == "subject" + + def test_a_scope_this_address_does_not_erase_is_refused_and_nothing_is_erased(self): + self.login("worker") + envelope = assert_error_envelope( + self.send("everything"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "scope" in envelope["errors"] + self.subject.refresh_from_db() + assert self.subject.username == "subject" + + def test_a_retirement_waiting_for_the_learner_is_not_erased(self): + self.retirement.current_state = self.state("PENDING") + self.retirement.save() + self.login("worker") + envelope = assert_error_envelope( + self.send(), + expected_status=status.HTTP_409_CONFLICT, + expected_type_slug=STATE_CONFLICT_SLUG, + ) + assert envelope["detail"] == view_services.NOT_OPEN_TO_ACTION + self.subject.refresh_from_db() + assert self.subject.username == "subject" + + def test_a_retirement_at_a_step_already_recorded_as_finished_is_not_erased(self): + self.retirement.current_state = self.state("LMS_COMPLETE") + self.retirement.save() + self.login("worker") + assert_error_envelope( + self.send(), + expected_status=status.HTTP_409_CONFLICT, + expected_type_slug=STATE_CONFLICT_SLUG, + ) + + def test_a_retirement_at_a_dead_end_step_is_not_erased(self): + self.retirement.current_state = self.state("ERRORED") + self.retirement.save() + self.login("worker") + assert_error_envelope( + self.send(), + expected_status=status.HTTP_409_CONFLICT, + expected_type_slug=STATE_CONFLICT_SLUG, + ) + + def test_a_name_no_retirement_is_requested_under_is_not_found(self): + self.login("worker") + assert_error_envelope( + self.client.post( + reverse("user_v2:retirement_erasure", kwargs={"username": "nobody"}), + {"scope": "lms_core"}, + format="json", + ), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_a_step_that_fails_partway_through_says_nothing_about_why(self): + self.login("worker") + with mock.patch( + "openedx.core.djangoapps.user_api.v2.view_services.AccountRetirementView" + ".clear_pii_from_certificate_records", + side_effect=ValueError("the certificate store is on fire"), + ): + response = self.client.post(self.url, {"scope": "lms_core"}, format="json") + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + body = response.json() + assert body["detail"] == "An unexpected error occurred. Please try again later." + assert "fire" not in str(body) + + +@skip_unless_lms +class PartnerReportClaimTests(_AccessMatrixMixin, RetirementApiTestCase): + """POST /api/user/v2/retirement_partner_reports/""" + + admitted = PIPELINE_CALLERS + refused = PIPELINE_REFUSED + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:retirement_partner_report_list") + self.entry = self.queued(self.subject) + + def queued(self, user): + """Return a partner report entry waiting to be taken, for this user.""" + return UserRetirementPartnerReportingStatus.objects.create( + user=user, + original_username=user.username, + original_email=user.email, + original_name=user.profile.name, + ) + + def send(self): + return self.client.post(self.url, {}, format="json") + + def test_the_entry_carries_what_a_partner_needs(self): + CourseEnrollmentFactory.create(user=self.subject, course_id=COURSE_KEY) + CourseEnrollmentFactory.create(user=self.subject, course_id=OTHER_COURSE_KEY) + self.login("worker") + response = self.client.post(self.url, {}, format="json") + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 1 + entry = response.data[0] + assert set(entry) == { + "user_id", "original_username", "original_email", "original_name", "orgs", "created", + } + assert entry["user_id"] == self.subject.id + assert entry["original_username"] == "subject" + assert entry["original_email"] == "subject@example.com" + assert entry["orgs"] == ["OtherOrg", "edX"] + + def test_an_account_enrolled_with_nobody_carries_no_organizations(self): + self.login("worker") + response = self.client.post(self.url, {}, format="json") + assert response.data[0]["orgs"] == [] + + def test_the_entries_returned_are_exactly_the_entries_taken(self): + other = self.queued(self.learner) + self.login("worker") + response = self.client.post(self.url, {}, format="json") + returned = {entry["original_username"] for entry in response.data} + assert returned == {"subject", "learner-caller"} + taken = set( + UserRetirementPartnerReportingStatus.objects + .filter(is_being_processed=True) + .values_list("original_username", flat=True), + ) + assert taken == returned + assert UserRetirementPartnerReportingStatus.objects.get(id=other.id).is_being_processed + + def test_a_second_request_takes_nothing(self): + self.login("worker") + self.client.post(self.url, {}, format="json") + again = self.client.post(self.url, {}, format="json") + assert again.status_code == status.HTTP_200_OK + assert again.data == [] + + def test_an_entry_added_while_the_answer_is_built_is_left_for_the_next_caller(self): + added = [] + + def add_one_entry_behind_the_reader(report): + if not added: + added.append(self.queued(self.learner)) + return original(report) + + original = view_services._partner_report_of # pylint: disable=protected-access + self.login("worker") + with mock.patch.object( + view_services, "_partner_report_of", side_effect=add_one_entry_behind_the_reader, + ): + response = self.client.post(self.url, {}, format="json") + + assert [entry["original_username"] for entry in response.data] == ["subject"] + latecomer = UserRetirementPartnerReportingStatus.objects.get(id=added[0].id) + assert latecomer.is_being_processed is False + assert UserRetirementPartnerReportingStatus.objects.get(id=self.entry.id).is_being_processed + + def test_at_most_the_number_of_entries_asked_for_is_taken(self): + self.queued(self.learner) + self.login("worker") + response = self.client.post(f"{self.url}?limit=1", {}, format="json") + assert len(response.data) == 1 + assert UserRetirementPartnerReportingStatus.objects.filter(is_being_processed=True).count() == 1 + + def test_taking_no_entries_at_all_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.post(f"{self.url}?limit=0", {}, format="json"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "limit" in envelope["errors"] + assert UserRetirementPartnerReportingStatus.objects.filter(is_being_processed=True).count() == 0 + + def test_a_query_parameter_this_address_does_not_read_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.post(f"{self.url}?count=1", {}, format="json"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"]["count"] == [ + f"No such query parameter. Send any of: {CLAIM_QUERY_PARAMS}.", + ] + + +@skip_unless_lms +class PartnerReportQueueTests(_AccessMatrixMixin, RetirementApiTestCase): + """PUT /api/user/v2/retirement_partner_reports/{username}/""" + + admitted = PIPELINE_CALLERS + refused = PIPELINE_REFUSED + + def setUp(self): + super().setUp() + self.retirement = self.retirement_in("ADDING_TO_PARTNER_QUEUE", user=self.subject) + CourseEnrollmentFactory.create(user=self.subject, course_id=COURSE_KEY) + self.url = reverse("user_v2:retirement_partner_report_detail", kwargs={"username": "subject"}) + + def send(self): + return self.client.put(self.url, {}, format="json") + + def test_an_account_that_studied_with_an_organization_is_queued(self): + self.login("worker") + assert self.send().status_code == status.HTTP_204_NO_CONTENT + entry = UserRetirementPartnerReportingStatus.objects.get(user=self.subject) + assert entry.original_username == "subject" + assert entry.original_email == "subject@example.com" + assert entry.original_name == self.subject.profile.name + assert entry.is_being_processed is False + + def test_an_account_that_studied_with_nobody_is_not_queued(self): + learner_retirement = self.retirement_in("ADDING_TO_PARTNER_QUEUE", user=self.learner) + self.login("worker") + response = self.client.put( + reverse( + "user_v2:retirement_partner_report_detail", + kwargs={"username": learner_retirement.original_username}, + ), + {}, + format="json", + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not UserRetirementPartnerReportingStatus.objects.filter(user=self.learner).exists() + + def test_queueing_the_same_account_twice_leaves_one_entry(self): + self.login("worker") + assert self.send().status_code == status.HTTP_204_NO_CONTENT + assert self.send().status_code == status.HTTP_204_NO_CONTENT + assert UserRetirementPartnerReportingStatus.objects.filter(user=self.subject).count() == 1 + + def test_a_name_no_retirement_is_requested_under_is_not_found(self): + self.login("worker") + assert_error_envelope( + self.client.put( + reverse("user_v2:retirement_partner_report_detail", kwargs={"username": "nobody"}), + {}, + format="json", + ), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_a_retirement_at_a_step_that_admits_no_work_is_refused(self): + self.retirement.current_state = self.state("PENDING") + self.retirement.save() + self.login("worker") + assert_error_envelope( + self.send(), + expected_status=status.HTTP_409_CONFLICT, + expected_type_slug=STATE_CONFLICT_SLUG, + ) + assert not UserRetirementPartnerReportingStatus.objects.filter(user=self.subject).exists() + + def test_a_name_spelled_differently_from_the_registered_one_is_not_found(self): + self.login("worker") + assert_error_envelope( + self.client.put( + reverse("user_v2:retirement_partner_report_detail", kwargs={"username": "Subject"}), + {}, + format="json", + ), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_how_many_entries_to_take_is_not_read_where_none_are_taken(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.put(f"{self.url}?limit=1", {}, format="json"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"]["limit"] == [ + f"No such query parameter. Send any of: {WRITE_QUERY_PARAMS}.", + ] + + +class _BatchTestsMixin(_AccessMatrixMixin): + """What both batch disposals answer, whichever records they act on.""" + + admitted = PIPELINE_CALLERS + refused = PIPELINE_REFUSED + + def post(self, usernames): + """Name these accounts to the address under test.""" + return self.client.post(self.url, {"usernames": usernames}, format="json") + + def test_a_body_naming_no_account_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.post([]), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "usernames" in envelope["errors"] + + def test_a_body_naming_more_accounts_than_a_batch_carries_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.post([f"name-{index}" for index in range(MAXIMUM_PAGE_SIZE + 1)]), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "usernames" in envelope["errors"] + + def test_a_body_that_is_not_an_object_is_refused(self): + self.login("worker") + assert_error_envelope( + self.client.post(self.url, [{"original_username": "subject"}], format="json"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + + def test_a_body_in_a_media_type_this_address_cannot_read_is_refused(self): + self.login("worker") + assert_error_envelope( + self.client.post(self.url, "usernames=subject", content_type="text/plain"), + expected_status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + expected_type_slug="unsupported-media-type", + ) + + def test_a_query_parameter_this_address_does_not_read_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.post(f"{self.url}?limit=1", {"usernames": ["subject"]}, format="json"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"]["limit"] == [ + f"No such query parameter. Send any of: {WRITE_QUERY_PARAMS}.", + ] + + +@skip_unless_lms +class RetirementCleanupTests(_BatchTestsMixin, RetirementApiTestCase): + """POST /api/user/v2/retirement_cleanups/""" + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:retirement_cleanup") + self.finished = self.retirement_in("COMPLETE", user=self.subject) + + def send(self): + retirement = self.retirement_in("COMPLETE") + return self.post([retirement.original_username]) + + def test_a_finished_record_is_redacted_and_then_deleted(self): + self.login("worker") + response = self.post(["subject"]) + assert response.status_code == status.HTTP_200_OK + assert response.data == {"results": [{"username": "subject", "outcome": "deleted"}]} + assert not UserRetirementStatus.objects.filter(id=self.finished.id).exists() + + def test_the_record_carries_the_replacement_before_it_goes(self): + """The redaction and the delete are separate statements, so the replacement is written.""" + self.login("worker") + seen = [] + original = UserRetirementStatus.objects.filter + + def remember_the_values_the_row_holds(*args, **kwargs): + selection = original(*args, **kwargs) + seen.append(list(selection.values_list("original_username", "original_email"))) + return selection + + with mock.patch.object( + UserRetirementStatus.objects, "filter", side_effect=remember_the_values_the_row_holds, + ): + self.post(["subject"]) + # The last selection is the delete, which sees the written-over values. + assert seen[-1] == [(view_services.REDACTED_VALUE, view_services.REDACTED_EMAIL)] + + def test_a_repeat_of_a_request_already_carried_out_is_answered(self): + self.login("worker") + self.post(["subject"]) + again = self.post(["subject"]) + assert again.status_code == status.HTTP_200_OK + assert again.data == {"results": [{"username": "subject", "outcome": "already_absent"}]} + + def test_each_name_is_reported_in_the_order_it_was_given(self): + other = self.retirement_in("COMPLETE") + self.login("worker") + response = self.post([other.original_username, "nobody", "subject"]) + assert response.data == { + "results": [ + {"username": other.original_username, "outcome": "deleted"}, + {"username": "nobody", "outcome": "already_absent"}, + {"username": "subject", "outcome": "deleted"}, + ], + } + + def test_a_name_given_twice_is_acted_on_and_reported_once(self): + self.login("worker") + response = self.post(["subject", "subject"]) + assert response.data == {"results": [{"username": "subject", "outcome": "deleted"}]} + + def test_an_unfinished_record_refuses_the_whole_batch_and_nothing_is_deleted(self): + unfinished = self.retirement_in("LOCKING_ACCOUNT") + self.login("worker") + envelope = assert_error_envelope( + self.post(["subject", unfinished.original_username]), + expected_status=status.HTTP_409_CONFLICT, + expected_type_slug=STATE_CONFLICT_SLUG, + ) + assert envelope["detail"] == view_services.BATCH_LEFT_ALONE + assert envelope["errors"] == { + unfinished.original_username: [ + "This retirement stands at LOCKING_ACCOUNT; only a retirement at COMPLETE is deleted here.", + ], + } + assert UserRetirementStatus.objects.filter(id=self.finished.id).exists() + assert UserRetirementStatus.objects.filter(id=unfinished.id).exists() + + def test_a_name_spelled_differently_from_the_registered_one_leaves_the_record_alone(self): + self.login("worker") + response = self.post(["SUBJECT"]) + assert response.data == {"results": [{"username": "SUBJECT", "outcome": "already_absent"}]} + assert UserRetirementStatus.objects.filter(id=self.finished.id).exists() + + +@skip_unless_lms +class PartnerReportCleanupTests(_BatchTestsMixin, RetirementApiTestCase): + """POST /api/user/v2/retirement_partner_report_cleanups/""" + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:retirement_partner_report_cleanup") + self.reported = self.queued(self.subject, taken=True) + + def queued(self, user, taken=False): + """Return a partner report entry for this user, taken for processing or not.""" + return UserRetirementPartnerReportingStatus.objects.create( + user=user, + original_username=user.username, + original_email=user.email, + original_name=user.profile.name, + is_being_processed=taken, + ) + + def send(self): + entry = self.queued(UserFactory.create(), taken=True) + return self.post([entry.original_username]) + + def test_an_entry_that_has_been_reported_on_is_deleted(self): + self.login("worker") + response = self.post(["subject"]) + assert response.status_code == status.HTTP_200_OK + assert response.data == {"results": [{"username": "subject", "outcome": "deleted"}]} + assert not UserRetirementPartnerReportingStatus.objects.filter(id=self.reported.id).exists() + + def test_a_repeat_of_a_request_already_carried_out_is_answered(self): + self.login("worker") + self.post(["subject"]) + again = self.post(["subject"]) + assert again.data == {"results": [{"username": "subject", "outcome": "already_absent"}]} + + def test_an_entry_nobody_has_taken_refuses_the_whole_batch_and_nothing_is_deleted(self): + waiting = self.queued(self.learner) + self.login("worker") + envelope = assert_error_envelope( + self.post(["subject", waiting.original_username]), + expected_status=status.HTTP_409_CONFLICT, + expected_type_slug=STATE_CONFLICT_SLUG, + ) + assert envelope["errors"] == { + waiting.original_username: [ + "This partner report has not been taken for processing, so it has not been reported on yet.", + ], + } + assert UserRetirementPartnerReportingStatus.objects.filter(id=self.reported.id).exists() + assert UserRetirementPartnerReportingStatus.objects.filter(id=waiting.id).exists() + + def test_each_name_is_reported_in_the_order_it_was_given(self): + other = self.queued(UserFactory.create(username="other-subject"), taken=True) + self.login("worker") + response = self.post(["other-subject", "nobody", "subject"]) + assert response.data == { + "results": [ + {"username": "other-subject", "outcome": "deleted"}, + {"username": "nobody", "outcome": "already_absent"}, + {"username": "subject", "outcome": "deleted"}, + ], + } + assert not UserRetirementPartnerReportingStatus.objects.filter(id=other.id).exists() + + +@skip_unless_lms +class RetirementAccessModelTests(RetirementApiTestCase): + """How the capabilities of this area are combined, and what happens with none of them.""" + + def test_an_action_the_mapping_does_not_name_reaches_no_capability(self): + """A later action cannot inherit the capability every named action shares.""" + viewset = RetirementViewSet() + viewset.action = "an_action_added_later" + granted = [ + permission.has_permission(mock.Mock(user=self.superuser), viewset) + for permission in viewset.get_permissions() + ] + assert granted[-1] is False + + def test_each_named_action_carries_the_capability_it_is_mapped_to(self): + """The classes are read from the view rather than from the mapping, so the mapping can be wrong.""" + expected = { + "list": CanRetireUser, + "retrieve": CanRetireUser, + "partial_update": CanRetireUser, + "erasures": CanRetireUser, + "destroy": CanCancelUserRetirement, + "metadata": CanRetireUser, + } + assert set(expected) == set(RETIREMENT_ACTION_PERMISSIONS) + for action, capability in expected.items(): + with self.subTest(action=action): + viewset = RetirementViewSet() + viewset.action = action + built = [type(permission) for permission in viewset.get_permissions()] + assert built[-1] is capability + assert permissions.IsAuthenticated in built + + def test_the_classes_of_a_view_must_all_admit_a_caller(self): + """Being authenticated is not enough, and holding the capability without being signed in is not either.""" + self.login("staff") + assert_error_envelope( + self.client.get(reverse("user_v2:retirement_list")), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + self.client.logout() + assert_error_envelope( + self.client.get(reverse("user_v2:retirement_list")), + expected_status=status.HTTP_401_UNAUTHORIZED, + expected_type_slug="authn", + ) + + def test_no_retirement_view_declares_its_own_authentication(self): + for view in ( + RetirementViewSet, + RetirementPartnerReportViewSet, + RetirementCleanupView, + RetirementPartnerReportCleanupView, + ): + with self.subTest(view=view.__name__): + assert "authentication_classes" not in vars(view) + + def test_no_retirement_view_declares_its_own_throttles(self): + """The pipeline is unthrottled on both versions alike, which is left as it stands.""" + for view in ( + RetirementViewSet, + RetirementPartnerReportViewSet, + RetirementCleanupView, + RetirementPartnerReportCleanupView, + ): + with self.subTest(view=view.__name__): + assert "throttle_classes" not in vars(view) + + +class RetirementErrorCatalogTests(SimpleTestCase): + """What this area adds to the shared error catalog, and what it leaves alone.""" + + def test_the_state_refusal_of_this_area_is_named_for_this_area(self): + assert classify_error(RetirementStateConflict()) == ( + "user/retirement-state-conflict", "Retirement State Conflict", + ) + + def test_another_area_refusing_a_conflict_is_classified_as_it_was_before(self): + """Registering this area's refusal reclassifies nothing for any other view in the process.""" + assert classify_error(Conflict()) == ("conflict", "Conflict") + + def test_a_refusal_naming_no_records_carries_no_per_record_member(self): + assert not hasattr(RetirementStateConflict("refused"), "extension_members") + + +class RetirementPageSizeTests(SimpleTestCase): + """The page sizes the collections of this version are cut to.""" + + def test_the_default_and_the_maximum_page_are_the_sizes_this_version_publishes(self): + assert UserApiPagination.page_size == DEFAULT_PAGE_SIZE + assert UserApiPagination.max_page_size == MAXIMUM_PAGE_SIZE + assert RetirementViewSet.pagination_class is UserApiPagination + + +@skip_unless_lms +class RetirementReadsAreNotWritesTests(RetirementApiTestCase): + """Reading the queue or one record changes nothing, whichever way it is read.""" + + def setUp(self): + super().setUp() + self.retirement = self.retirement_in("LOCKING_ACCOUNT", user=self.subject) + self.list_url = reverse("user_v2:retirement_list") + self.detail_url = reverse("user_v2:retirement_detail", kwargs={"username": "subject"}) + + def captured_writes(self, method, url, **params): + """Return the statements a read makes that would change the data this API serves.""" + self.login("worker") + with CaptureQueriesContext(connection) as captured: + response = getattr(self.client, method)(url, params) + assert response.status_code == status.HTTP_200_OK + return write_queries(captured.captured_queries) + + def test_reading_the_queue_writes_nothing(self): + assert self.captured_writes("get", self.list_url) == [] + + def test_reading_the_queue_without_a_body_writes_nothing(self): + assert self.captured_writes("head", self.list_url) == [] + + def test_reading_one_record_writes_nothing(self): + assert self.captured_writes("get", self.detail_url) == [] + + def test_reading_one_record_without_a_body_writes_nothing(self): + assert self.captured_writes("head", self.detail_url) == [] + + def test_reading_the_queue_with_every_filter_applied_writes_nothing(self): + assert self.captured_writes( + "get", + self.list_url, + state="LOCKING_ACCOUNT", + cool_off_days=0, + created_after="2020-01-01", + created_before="2099-01-01", + ordering="created", + view="minimal", + ) == [] diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py index 7a60a6adc357..b01b05c30846 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py @@ -2,6 +2,7 @@ import json from importlib import import_module +from pathlib import Path from unittest import mock import jsonschema @@ -35,6 +36,8 @@ from openedx.core.djangoapps.user_api.v2.serializers import ( ACCOUNT_DEFAULT_SORT, ACCOUNT_MINIMAL_FIELDS, + BATCH_OUTCOMES, + ERASURE_SCOPES, EXPIRATION_DAY_PATTERN, PREFERENCE_NAME_MAX_LENGTH, AccountPreferenceChangesField, @@ -43,7 +46,10 @@ TimeZoneQuerySerializer, VerificationQuerySerializer, ) -from openedx.core.djangoapps.user_api.v2.views import PREFERENCE_KEY_PATTERN_PARTIAL +from openedx.core.djangoapps.user_api.v2.views import ( + PREFERENCE_KEY_PATTERN_PARTIAL, + RETIREMENT_SORT_FIELDS, +) from openedx.core.djangolib.testing.utils import skip_unless_lms PASSWORD = "Password1234" @@ -76,6 +82,24 @@ PREFERENCE_LIST_PATH = "/api/user/v2/user_preferences/" PREFERENCE_MEMBER_PATH = "/api/user/v2/user_preferences/{username},{key}/" TIME_ZONE_LIST_PATH = "/api/user/v2/time_zones/" +RETIREMENT_LIST_PATH = "/api/user/v2/retirements/" +RETIREMENT_MEMBER_PATH = "/api/user/v2/retirements/{username}/" +RETIREMENT_ERASURE_PATH = "/api/user/v2/retirements/{username}/erasures/" +RETIREMENT_CLEANUP_PATH = "/api/user/v2/retirement_cleanups/" +PARTNER_REPORT_LIST_PATH = "/api/user/v2/retirement_partner_reports/" +PARTNER_REPORT_MEMBER_PATH = "/api/user/v2/retirement_partner_reports/{username}/" +PARTNER_REPORT_CLEANUP_PATH = "/api/user/v2/retirement_partner_report_cleanups/" + +#: The meaning published for the part of a retirement's address that carries +#: the name the account was registered under. +RETIREMENT_USERNAME_DESCRIPTION = ( + "Name the account being retired was registered under. A retired account is stored under a " + "hashed name, and either name addresses the same retirement when one is read; a write " + "addresses the registered name alone, spelled exactly as it was registered." +) +PARTNER_REPORT_USERNAME_DESCRIPTION = ( + "Name the account was registered under, spelled exactly as it was registered." +) #: The addresses that answer with a page of rows, and the ones that answer with #: a single record. @@ -85,6 +109,7 @@ USER_LIST_PATH, PREFERENCE_LIST_PATH, TIME_ZONE_LIST_PATH, + RETIREMENT_LIST_PATH, } MEMBER_PATHS = { ACCOUNT_MEMBER_PATH, @@ -93,6 +118,8 @@ PREFERENCE_MEMBER_PATH, PREFERENCE_DOCUMENT_PATH, PREFERENCE_NAME_PATH, + RETIREMENT_MEMBER_PATH, + PARTNER_REPORT_MEMBER_PATH, } #: The addresses that act on a resource rather than answer with one. @@ -103,9 +130,16 @@ PROFILE_IMAGE_PATH, NAME_CHANGE_PATH, NAME_CHANGE_CONFIRMATION_PATH, + RETIREMENT_ERASURE_PATH, + RETIREMENT_CLEANUP_PATH, + PARTNER_REPORT_LIST_PATH, + PARTNER_REPORT_CLEANUP_PATH, } -#: The addresses a generated client library is built from. +#: The addresses a generated client library is built from. The retirement +#: pipeline's are deliberately not among them: one service calls them by path +#: literal, and a library for irreversible destruction of personal data invites +#: a caller who has not read what the steps do. SDK_PATHS = { ACCOUNT_LIST_PATH, ACCOUNT_MEMBER_PATH, @@ -113,7 +147,13 @@ VERIFICATION_LIST_PATH, PREFERENCE_DOCUMENT_PATH, PREFERENCE_NAME_PATH, -} | ACTION_PATHS + EMAIL_SEARCH_PATH, + EMAIL_OPT_IN_PATH, + ACCOUNT_DEACTIVATION_PATH, + PROFILE_IMAGE_PATH, + NAME_CHANGE_PATH, + NAME_CHANGE_CONFIRMATION_PATH, +} VERSION_TWO_PATHS = LIST_PATHS | MEMBER_PATHS | ACTION_PATHS @@ -143,6 +183,21 @@ "PaginatedEmailSearchResultList", "PaginatedVerificationList", "PatchedAccountUpdate", + "OutcomeEnum", + "PaginatedRetirementResponseList", + "PatchedRetirementUpdate", + "Retirement", + "RetirementAccount", + "RetirementAccountProfile", + "RetirementBatch", + "RetirementBatchOutcome", + "RetirementBatchResult", + "RetirementErasure", + "RetirementMinimal", + "RetirementPartnerReportRecord", + "RetirementProjection", + "RetirementResponse", + "ScopeEnum", "PaginatedTimeZoneList", "PaginatedUserDirectoryResponseList", "PaginatedUserPreferenceRecordResponseList", @@ -346,6 +401,16 @@ def assert_records_match(schema, body, component, case=None): "/api/user/v1/preferences/{pref_key}/users/": "/api/user/v2/users/", "/api/user/v1/forum_roles/{name}/users/": "/api/user/v2/users/", "/api/user/v1/preferences/time_zones/": "/api/user/v2/time_zones/", + "/api/user/v1/accounts/{username}/retirement_status/": RETIREMENT_MEMBER_PATH, + "/api/user/v1/accounts/retirement_queue/": RETIREMENT_LIST_PATH, + "/api/user/v1/accounts/retirements_by_status_and_date/": RETIREMENT_LIST_PATH, + "/api/user/v1/accounts/update_retirement_status/": RETIREMENT_MEMBER_PATH, + "/api/user/v1/accounts/retirement_cleanup/": RETIREMENT_CLEANUP_PATH, + "/api/user/v1/accounts/retirement_partner_report/": PARTNER_REPORT_LIST_PATH, + "/api/user/v1/accounts/retirement_partner_report_cleanup/": PARTNER_REPORT_CLEANUP_PATH, + "/api/user/v1/accounts/cancel_retirement/": RETIREMENT_MEMBER_PATH, + "/api/user/v1/accounts/retire/": RETIREMENT_ERASURE_PATH, + "/api/user/v1/accounts/retire_misc/": RETIREMENT_ERASURE_PATH, } #: Legacy enrollment addresses this schema already marked before the user API joined it. @@ -445,6 +510,15 @@ def test_every_operation_of_this_version_is_published_under_one_group(schema): "v2_user_preferences_list": ["user"], "v2_user_preferences_retrieve": ["user"], "v2_time_zones_list": ["user"], + "v2_retirements_list": ["user"], + "v2_retirements_retrieve": ["user"], + "v2_retirements_partial_update": ["user"], + "v2_retirements_destroy": ["user"], + "v2_retirements_erasures_create": ["user"], + "v2_retirement_cleanups_create": ["user"], + "v2_retirement_partner_reports_create": ["user"], + "v2_retirement_partner_reports_update": ["user"], + "v2_retirement_partner_report_cleanups_create": ["user"], } @@ -474,7 +548,7 @@ 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/accounts/replace_usernames/", "/api/user/v1/account/registration/", "/api/user/v2/account/registration/", "/api/profile_images/v1/{username}/upload", @@ -487,7 +561,7 @@ def test_addresses_owned_by_other_apps_are_not_published(schema): 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 + assert len(paged) == 7 for component in paged: published = schema["components"]["schemas"][component] assert set(published["properties"]) == PAGINATION_MEMBERS, component @@ -549,6 +623,8 @@ def test_an_operation_that_takes_a_response_preset_publishes_the_refusal(schema) (ACCOUNT_LIST_PATH, "get"), (ACCOUNT_MEMBER_PATH, "get"), (ACCOUNT_MEMBER_PATH, "patch"), + ("/api/user/v2/retirements/", "get"), + ("/api/user/v2/retirements/{username}/", "get"), ("/api/user/v2/user_preferences/", "get"), ("/api/user/v2/user_preferences/{username},{key}/", "get"), ("/api/user/v2/users/", "get"), @@ -882,6 +958,12 @@ def test_every_list_publishes_the_page_that_does_not_exist(schema): "/api/user/v2/users/": ["200", "400", "401", "403", "404", "406"], "/api/user/v2/user_preferences/": ["200", "400", "401", "403", "404", "406"], TIME_ZONE_LIST_PATH: ["200", "400", "404", "406"], + 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"], + RETIREMENT_LIST_PATH: ["200", "400", "401", "403", "404"], } @@ -959,6 +1041,20 @@ def test_member_addresses_describe_the_parts_they_are_built_from(schema): "schema": username_schema, "description": ACCOUNT_PREFERENCES_USERNAME_DESCRIPTION, }, + (RETIREMENT_MEMBER_PATH, "username"): { + "in": "path", + "name": "username", + "required": True, + "schema": username_schema, + "description": RETIREMENT_USERNAME_DESCRIPTION, + }, + (PARTNER_REPORT_MEMBER_PATH, "username"): { + "in": "path", + "name": "username", + "required": True, + "schema": username_schema, + "description": PARTNER_REPORT_USERNAME_DESCRIPTION, + }, (PREFERENCE_NAME_PATH, "key"): { "in": "path", "name": "key", @@ -1024,6 +1120,9 @@ def test_the_addresses_that_work_on_one_account_describe_the_name_they_carry(sch NAME_CHANGE_CONFIRMATION_PATH, USER_MEMBER_PATH, PREFERENCE_MEMBER_PATH, + RETIREMENT_MEMBER_PATH, + RETIREMENT_ERASURE_PATH, + PARTNER_REPORT_MEMBER_PATH, ) @@ -2313,3 +2412,213 @@ def test_asking_for_a_name_change_publishes_the_refusal_of_a_session_without_its assert "403" in asked assert asked["403"]["description"] == recorded["403"]["description"] assert asked["403"]["content"] == recorded["403"]["content"] +def dereferenced(schema, node): + """ + Return a published node with its reference followed, if it carries one. + + A node describing a fixed set of values is published as a component whose + name the generator derives from the field, and that name holds only while + one field of the service claims it, so what the node points at is read + here rather than named. A property carrying a description of its own is + published as the one alternative it may match, which is followed too. + """ + alternatives = node.get("allOf") + if alternatives is not None and len(alternatives) == 1: + node = alternatives[0] + reference = node.get("$ref") + if reference is None: + return node + return schema["components"]["schemas"][reference.rsplit("/", 1)[-1]] + + +def published_property(schema, path, method, name): + """Return the published description of one property of a request body.""" + body = schema["paths"][path][method]["requestBody"]["content"]["application/json"]["schema"] + return dereferenced(schema, dereferenced(schema, body)["properties"][name]) + + +@skip_unless_lms +def test_the_erasure_publishes_the_sets_of_data_it_can_erase(schema): + """A caller picks one of them in the body, so each is named in the document.""" + scope = published_property(schema, RETIREMENT_ERASURE_PATH, "post", "scope") + assert scope["enum"] == list(ERASURE_SCOPES) + body = schema["paths"][RETIREMENT_ERASURE_PATH]["post"]["requestBody"] + assert body["required"] is True + assert dereferenced( + schema, body["content"]["application/json"]["schema"], + )["required"] == ["scope"] + + +@skip_unless_lms +def test_a_batch_publishes_what_it_can_report_about_each_name_it_is_given(schema): + result = schema["components"]["schemas"]["RetirementBatchResult"] + outcome = dereferenced(schema, result["properties"]["outcome"]) + assert outcome["enum"] == list(BATCH_OUTCOMES) + assert sorted(result["properties"]) == ["outcome", "username"] + assert sorted(result["required"]) == ["outcome", "username"] + + +@skip_unless_lms +def test_both_batch_addresses_publish_the_same_exchange(schema): + """They act on different records, and a caller reads and writes the same shapes at both.""" + exchanges = { + path: ( + schema["paths"][path]["post"]["requestBody"]["content"]["application/json"]["schema"], + schema["paths"][path]["post"]["responses"]["200"]["content"]["application/json"]["schema"], + ) + for path in (RETIREMENT_CLEANUP_PATH, PARTNER_REPORT_CLEANUP_PATH) + } + assert exchanges[RETIREMENT_CLEANUP_PATH] == exchanges[PARTNER_REPORT_CLEANUP_PATH] + assert exchanges[RETIREMENT_CLEANUP_PATH] == ( + {"$ref": "#/components/schemas/RetirementBatch"}, + {"$ref": "#/components/schemas/RetirementBatchOutcome"}, + ) + + +@skip_unless_lms +def test_the_queue_publishes_the_filters_it_narrows_by(schema): + parameters = { + parameter["name"]: parameter + for parameter in schema["paths"][RETIREMENT_LIST_PATH]["get"]["parameters"] + } + assert set(parameters) == { + "cool_off_days", "created_after", "created_before", "state", + "fields", "ordering", "page", "page_size", "view", + } + for name in ("cool_off_days", "created_after", "created_before", "state"): + assert parameters[name]["in"] == "query" + assert parameters[name].get("required", False) is False, name + assert parameters[name]["description"], name + assert parameters["cool_off_days"]["schema"]["type"] == "integer" + assert parameters["created_after"]["schema"]["format"] == "date-time" + assert parameters["created_before"]["schema"]["format"] == "date-time" + assert parameters["state"]["schema"]["type"] == "string" + assert parameters["ordering"]["schema"]["items"]["enum"] == sorted( + [*RETIREMENT_SORT_FIELDS, *(f"-{field}" for field in RETIREMENT_SORT_FIELDS)], + ) + assert "default" not in parameters["ordering"]["schema"] + + +@skip_unless_lms +def test_the_claim_publishes_how_many_entries_it_can_be_held_to(schema): + parameters = { + parameter["name"]: parameter + for parameter in schema["paths"][PARTNER_REPORT_LIST_PATH]["post"]["parameters"] + } + assert set(parameters) == {"limit"} + assert parameters["limit"]["schema"]["type"] == "integer" + assert parameters["limit"].get("required", False) is False + assert "requestBody" not in schema["paths"][PARTNER_REPORT_LIST_PATH]["post"] + + +@skip_unless_lms +def test_the_claim_answers_with_an_array_rather_than_a_page(schema): + """The entries answered are the entries taken, so the answer is not cut into pages.""" + body = schema["paths"][PARTNER_REPORT_LIST_PATH]["post"]["responses"]["200"] + assert body["content"]["application/json"]["schema"] == { + "type": "array", + "items": {"$ref": "#/components/schemas/RetirementPartnerReportRecord"}, + } + + +@skip_unless_lms +def test_the_queue_answers_with_a_page(schema): + body = schema["paths"][RETIREMENT_LIST_PATH]["get"]["responses"]["200"] + assert body["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/PaginatedRetirementResponseList", + } + page = schema["components"]["schemas"]["PaginatedRetirementResponseList"] + assert set(page["properties"]) == PAGINATION_MEMBERS + assert page["properties"]["results"] == { + "type": "array", + "items": {"$ref": "#/components/schemas/RetirementResponse"}, + } + + +@skip_unless_lms +def test_a_retirement_record_publishes_the_step_as_a_name(schema): + record = schema["components"]["schemas"]["Retirement"] + for name in ("current_state", "last_state"): + assert record["properties"][name]["type"] == "string", name + assert set(record["properties"]) == { + "id", "user", "original_username", "original_email", "original_name", + "retired_username", "retired_email", "current_state", "last_state", "created", "modified", + } + assert "responses" not in record["properties"] + + +@skip_unless_lms +def test_the_component_the_corrected_request_body_leaves_behind_is_pinned_in_the_document(schema): + """ + The partial update of a version 1 retirement was the component's only reference. + + A client generated from the service-wide document names a class after each + component, so a name that loses its last reference is kept by naming it in + the configuration that document is built with, rather than disappearing + with the body that was wrong. + """ + assert "PatchedUserRetirementStatus" not in schema["components"]["schemas"] + derived = service_wide_schema() + assert "PatchedUserRetirementStatus" not in derived["components"]["schemas"] + + configured = (Path(settings.REPO_ROOT) / "docs" / "docs_settings.py").read_text() + assert '"PatchedUserRetirementStatus": PATCHED_USER_RETIREMENT_STATUS' in configured + assert '"schemas": {' in configured + + pinned = {"PatchedUserRetirementStatus": {"type": "object"}} + with patched_settings({**get_api_docs_settings(), "APPEND_COMPONENTS": {"schemas": pinned}}): + with_the_pin = SchemaGenerator().get_schema(request=None, public=True) + assert with_the_pin["components"]["schemas"]["PatchedUserRetirementStatus"] == { + "type": "object", + } + assert "UserRetirementStatus" in with_the_pin["components"]["schemas"] + + +#: The two addresses whose published name the body correction would otherwise +#: move, with the name the service-wide document publishes for each. +CORRECTED_QUEUE_READS = { + "/api/user/v1/accounts/retirement_queue/": "api_user_v1_accounts_retirement_queue_retrieve", + "/api/user/v1/accounts/retirements_by_status_and_date/": ( + "api_user_v1_accounts_retirements_by_status_and_date_retrieve" + ), +} + + +@skip_unless_lms +def test_the_corrected_queue_reads_keep_the_names_the_service_wide_document_publishes(schema): + """ + Correcting a body to the array it answers with would otherwise move its name. + + The generator names an operation for what it answers with, so an operation + whose body is corrected from one record to the array of them is named for a + list where it was named for a single record. Both of these have been + published under the single-record name for years and a client imports a + module by that name, so each is pinned; the document that has published + neither keeps the name it derives. + """ + derived = service_wide_schema() + published = { + path: derived["paths"][path]["get"]["operationId"] for path in CORRECTED_QUEUE_READS + } + assert published == CORRECTED_QUEUE_READS + + with mock.patch.dict("openedx.core.djangoapps.user_api.schema.RENAMED_OPERATION_IDS", {}, clear=True): + unpinned = service_wide_schema() + assert { + path: unpinned["paths"][path]["get"]["operationId"] for path in CORRECTED_QUEUE_READS + } == { + "/api/user/v1/accounts/retirement_queue/": "api_user_v1_accounts_retirement_queue_list", + "/api/user/v1/accounts/retirements_by_status_and_date/": ( + "api_user_v1_accounts_retirements_by_status_and_date_list" + ), + } + # The document this area's version is published in has carried neither + # address before, so each keeps the name derived there. + assert { + path: schema["paths"][path]["get"]["operationId"] for path in CORRECTED_QUEUE_READS + } == { + "/api/user/v1/accounts/retirement_queue/": "v1_accounts_retirement_queue_list", + "/api/user/v1/accounts/retirements_by_status_and_date/": ( + "v1_accounts_retirements_by_status_and_date_list" + ), + } diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_selectors.py b/openedx/core/djangoapps/user_api/v2/tests/test_selectors.py index 3cb4af2fbc9b..e6402f1221b7 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_selectors.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_selectors.py @@ -1,15 +1,32 @@ """Tests for the selections version 2 of the User API answers from.""" import datetime +from unittest import mock +import pytest 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 common.djangoapps.student.models import get_retired_username_by_username +from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory +from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import ( # pylint: disable=unused-import + create_retirement_status, + setup_retirement_states, # noqa: F401 +) +from openedx.core.djangoapps.user_api.models import ( + RetirementState, + UserRetirementPartnerReportingStatus, + UserRetirementStatus, +) from openedx.core.djangoapps.user_api.v2.selectors import ( accounts_holding_emails, cancellable_retirement_for_email, + partner_reports_requested_as, + retirement_of_the_account_named, + retirements, + retirements_requested_as, + unclaimed_partner_reports, + username_holding_email, username_holding_id, usernames_among, usernames_holding_email, @@ -109,3 +126,152 @@ def test_the_most_recently_asked_for_retirement_of_an_address_is_the_one_reporte found = cancellable_retirement_for_email("leaving@example.com") assert found.pk == later.pk assert found.user_id == again.id + + +@skip_unless_lms +@pytest.mark.usefixtures("setup_retirement_states") +class RetirementPipelineSelectionTests(TestCase): + """What the retirement selections answer, asked for outside a request.""" + + def setUp(self): + super().setUp() + self.subject = UserFactory.create(username="pipeline-subject", email="subject@example.com") + self.retirement = create_retirement_status(self.subject) + + def test_the_queue_carries_the_account_and_both_steps_without_asking_again(self): + selected = retirements().get(id=self.retirement.id) + with self.assertNumQueries(0): + assert selected.user.username == "pipeline-subject" + assert selected.user.profile.name == self.subject.profile.name + assert selected.current_state.state_name == "PENDING" + assert selected.last_state.state_name == "PENDING" + + def test_an_account_is_addressed_by_the_name_it_was_registered_under(self): + assert retirement_of_the_account_named("pipeline-subject").id == self.retirement.id + + def test_an_account_is_addressed_by_the_hashed_name_it_was_moved_to(self): + hashed = get_retired_username_by_username("pipeline-subject") + self.subject.username = hashed + self.subject.save() + assert retirement_of_the_account_named(hashed).id == self.retirement.id + assert retirement_of_the_account_named("pipeline-subject").id == self.retirement.id + + def test_a_name_no_account_holds_addresses_no_retirement(self): + assert retirement_of_the_account_named("nobody") is None + + def test_an_account_with_no_retirement_behind_it_addresses_none(self): + UserFactory.create(username="not-retiring") + assert retirement_of_the_account_named("not-retiring") is None + + def test_a_retirement_is_found_by_the_name_it_was_requested_under(self): + found = retirements_requested_as(["pipeline-subject", "nobody"]) + assert set(found) == {"pipeline-subject"} + assert found["pipeline-subject"].id == self.retirement.id + + def test_a_name_spelled_differently_finds_no_retirement(self): + """Stored names are compared without regard to case in some deployments, so this is exact.""" + assert retirements_requested_as(["Pipeline-Subject"]) == {} + + def test_both_retirements_of_a_pair_differing_only_in_case_are_found_separately(self): + other = UserFactory.create(username="PIPELINE-SUBJECT", email="other@example.com") + other_retirement = create_retirement_status(other) + found = retirements_requested_as(["pipeline-subject", "PIPELINE-SUBJECT"]) + assert found["pipeline-subject"].id == self.retirement.id + assert found["PIPELINE-SUBJECT"].id == other_retirement.id + + def test_the_partner_report_queue_leaves_out_what_has_been_taken(self): + waiting = self.queued(self.subject) + taken_user = UserFactory.create(username="already-taken") + self.queued(taken_user, taken=True) + assert [report.id for report in unclaimed_partner_reports()] == [waiting.id] + + def test_the_partner_report_queue_carries_the_courses_each_account_studied(self): + self.queued(self.subject) + CourseEnrollmentFactory.create(user=self.subject, course_id="course-v1:edX+DemoX+Demo") + report = list(unclaimed_partner_reports())[0] + with self.assertNumQueries(0): + assert [ + enrolment.course_id.org for enrolment in report.user.courseenrollment_set.all() + ] == ["edX"] + + def test_a_partner_report_is_found_by_the_name_it_was_registered_under(self): + report = self.queued(self.subject) + found = partner_reports_requested_as(["pipeline-subject", "nobody"]) + assert set(found) == {"pipeline-subject"} + assert found["pipeline-subject"].id == report.id + + def test_a_partner_report_is_not_found_by_a_name_spelled_differently(self): + self.queued(self.subject) + assert partner_reports_requested_as(["Pipeline-Subject"]) == {} + + def queued(self, user, taken=False): + """Return a partner report entry for this account, taken for processing or not.""" + return UserRetirementPartnerReportingStatus.objects.create( + user=user, + original_username=user.username, + original_email=user.email, + original_name=user.profile.name, + is_being_processed=taken, + ) + + +class _CollationIgnoringCase: + """ + Stands in for a database that ignores case when it compares stored names. + + The deployments this runs on do exactly that, which once let a learner + register a retired name again by changing its capitalisation; the database + the tests run on compares names character for character, so what the + selections do with a second row that differs only in case is settled here + rather than left to the database to decide. + """ + + def __init__(self, rows): + self.rows = rows + + def filter(self, **kwargs): # pylint: disable=unused-argument + """Return every row, whichever names were asked for.""" + return self.rows + + +@skip_unless_lms +@pytest.mark.usefixtures("setup_retirement_states") +class CaseExactSelectionTests(TestCase): + """A name is compared character for character, whatever the database would compare.""" + + def setUp(self): + super().setUp() + self.lower = UserFactory.create(username="exact-subject", email="lower@example.com") + self.upper = UserFactory.create(username="EXACT-SUBJECT", email="upper@example.com") + + def test_a_retirement_is_selected_by_the_spelling_asked_for(self): + wanted = create_retirement_status(self.lower) + create_retirement_status(self.upper) + offered = _CollationIgnoringCase(list(UserRetirementStatus.objects.all())) + with mock.patch( + "openedx.core.djangoapps.user_api.v2.selectors.retirements", return_value=offered, + ): + found = retirements_requested_as(["exact-subject"]) + assert set(found) == {"exact-subject"} + assert found["exact-subject"].id == wanted.id + + def test_a_partner_report_is_selected_by_the_spelling_asked_for(self): + wanted = UserRetirementPartnerReportingStatus.objects.create( + user=self.lower, + original_username=self.lower.username, + original_email=self.lower.email, + original_name=self.lower.profile.name, + ) + UserRetirementPartnerReportingStatus.objects.create( + user=self.upper, + original_username=self.upper.username, + original_email=self.upper.email, + original_name=self.upper.profile.name, + ) + offered = _CollationIgnoringCase(list(UserRetirementPartnerReportingStatus.objects.all())) + with mock.patch.object( + UserRetirementPartnerReportingStatus, "objects", mock.Mock(filter=offered.filter), + ): + found = partner_reports_requested_as(["exact-subject"]) + assert set(found) == {"exact-subject"} + assert found["exact-subject"].id == wanted.id diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_urls.py b/openedx/core/djangoapps/user_api/v2/tests/test_urls.py index 7e1881b1f47e..11ab9ca95232 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_urls.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_urls.py @@ -25,6 +25,10 @@ IDVerificationStatusView, IDVerificationViewSet, NameChangeViewSet, + RetirementCleanupView, + RetirementPartnerReportCleanupView, + RetirementPartnerReportViewSet, + RetirementViewSet, UserPreferenceViewSet, UserViewSet, ) @@ -86,6 +90,22 @@ "/api/user/v2/user_preferences/alice,time_zone/", ), "user_v2:time_zone_list": ({}, "/api/user/v2/time_zones/"), + "user_v2:retirement_list": ({}, "/api/user/v2/retirements/"), + "user_v2:retirement_detail": ({"username": "alice"}, "/api/user/v2/retirements/alice/"), + "user_v2:retirement_erasure": ( + {"username": "alice"}, + "/api/user/v2/retirements/alice/erasures/", + ), + "user_v2:retirement_cleanup": ({}, "/api/user/v2/retirement_cleanups/"), + "user_v2:retirement_partner_report_list": ({}, "/api/user/v2/retirement_partner_reports/"), + "user_v2:retirement_partner_report_detail": ( + {"username": "alice"}, + "/api/user/v2/retirement_partner_reports/alice/", + ), + "user_v2:retirement_partner_report_cleanup": ( + {}, + "/api/user/v2/retirement_partner_report_cleanups/", + ), } @@ -174,6 +194,19 @@ def test_resolved_names_are_the_names_that_reverse(self): ("/api/user/v2/user_preferences/", "user_preference_list"), ("/api/user/v2/user_preferences/alice,time_zone/", "user_preference_detail"), ("/api/user/v2/time_zones/", "time_zone_list"), + ("/api/user/v2/retirements/", "retirement_list"), + ("/api/user/v2/retirements/alice/", "retirement_detail"), + ("/api/user/v2/retirements/alice/erasures/", "retirement_erasure"), + ("/api/user/v2/retirement_cleanups/", "retirement_cleanup"), + ("/api/user/v2/retirement_partner_reports/", "retirement_partner_report_list"), + ( + "/api/user/v2/retirement_partner_reports/alice/", + "retirement_partner_report_detail", + ), + ( + "/api/user/v2/retirement_partner_report_cleanups/", + "retirement_partner_report_cleanup", + ), ): match = resolve(address) assert match.url_name == name, address @@ -433,6 +466,13 @@ def test_every_address_is_named_and_ends_in_a_slash(self): "email_search_list", "name_change_confirmation", "name_change_list", + "retirement_cleanup", + "retirement_detail", + "retirement_erasure", + "retirement_list", + "retirement_partner_report_cleanup", + "retirement_partner_report_detail", + "retirement_partner_report_list", "time_zone_list", "user_detail", "user_list", @@ -471,6 +511,11 @@ def test_the_router_route_that_is_replaced_has_to_exist(self): "user_detail", "user_preference_list", "time_zone_list", + "retirement_list", + "retirement_detail", + "retirement_erasure", + "retirement_partner_report_list", + "retirement_partner_report_detail", ] with pytest.raises(ImproperlyConfigured): v2_urls.without_route(routes, "user_preference_detail_renamed") @@ -739,3 +784,163 @@ def test_every_address_left_to_the_other_app_is_one_that_app_serves(self): } for address in served: assert left_alone.match(address.removeprefix("/api/user/v2/")), address + + +#: The addresses of the retirement pipeline, with the view each is mounted on +#: and the methods it serves. +RETIREMENT_ROUTES = { + "/api/user/v2/retirements/": (RetirementViewSet, {"get": "list"}), + "/api/user/v2/retirements/alice/": ( + RetirementViewSet, + {"get": "retrieve", "patch": "partial_update", "delete": "destroy"}, + ), + "/api/user/v2/retirements/alice/erasures/": (RetirementViewSet, {"post": "erasures"}), + "/api/user/v2/retirement_partner_reports/": ( + RetirementPartnerReportViewSet, {"post": "create"}, + ), + "/api/user/v2/retirement_partner_reports/alice/": ( + RetirementPartnerReportViewSet, {"put": "update"}, + ), +} + +#: The addresses of the version 1 pipeline, with the view class each still +#: reaches and the actions each is still bound to. +LEGACY_RETIREMENT_ROUTES = { + "/api/user/v1/accounts/alice/retirement_status/": ( + "AccountRetirementStatusView", {"get": "retrieve"}, + ), + "/api/user/v1/accounts/retirement_queue/": ( + "AccountRetirementStatusView", {"get": "retirement_queue"}, + ), + "/api/user/v1/accounts/retirements_by_status_and_date/": ( + "AccountRetirementStatusView", {"get": "retirements_by_status_and_date"}, + ), + "/api/user/v1/accounts/update_retirement_status/": ( + "AccountRetirementStatusView", {"patch": "partial_update"}, + ), + "/api/user/v1/accounts/retirement_cleanup/": ( + "AccountRetirementStatusView", {"post": "cleanup"}, + ), + "/api/user/v1/accounts/retirement_partner_report/": ( + "AccountRetirementPartnerReportView", + {"post": "retirement_partner_report", "put": "retirement_partner_status_create"}, + ), + "/api/user/v1/accounts/retirement_partner_report_cleanup/": ( + "AccountRetirementPartnerReportView", {"post": "retirement_partner_cleanup"}, + ), + "/api/user/v1/accounts/cancel_retirement/": ( + "CancelAccountRetirementStatusView", {"post": "cancel_retirement"}, + ), + "/api/user/v1/accounts/retire/": ("AccountRetirementView", {"post": "post"}), + "/api/user/v1/accounts/retire_misc/": ("LMSAccountRetirementView", {"post": "post"}), +} + +#: The names the version 1 pipeline is reversed by, which out-of-tree callers +#: hold, with the address each still builds. +LEGACY_RETIREMENT_NAMES = { + "accounts_retirement_retrieve": ( + {"username": "alice"}, "/api/user/v1/accounts/alice/retirement_status/", + ), + "accounts_retirement_queue": ({}, "/api/user/v1/accounts/retirement_queue/"), + "accounts_retirements_by_status_and_date": ( + {}, "/api/user/v1/accounts/retirements_by_status_and_date/", + ), + "accounts_retirement_update": ({}, "/api/user/v1/accounts/update_retirement_status/"), + "accounts_retirement_cleanup": ({}, "/api/user/v1/accounts/retirement_cleanup/"), + "accounts_retirement_partner_report": ( + {}, "/api/user/v1/accounts/retirement_partner_report/", + ), + "accounts_retirement_partner_report_cleanup": ( + {}, "/api/user/v1/accounts/retirement_partner_report_cleanup/", + ), + "cancel_account_retirement": ({}, "/api/user/v1/accounts/cancel_retirement/"), + "accounts_retire": ({}, "/api/user/v1/accounts/retire/"), + "accounts_retire_misc": ({}, "/api/user/v1/accounts/retire_misc/"), +} + + +@skip_unless_lms +class RetirementRouteTests(APITestCase): + """Where the retirement addresses are mounted, and what each one serves.""" + + def test_each_address_is_mounted_on_the_view_that_serves_it(self): + for address, (view, actions) in RETIREMENT_ROUTES.items(): + match = resolve(address) + assert view_identity(match.func) == (V2_VIEW_MODULE, view.__name__), address + assert routed_actions(match.func) == actions, address + + def test_each_batch_address_is_mounted_on_a_view_of_its_own(self): + for address, view in ( + ("/api/user/v2/retirement_cleanups/", RetirementCleanupView), + ("/api/user/v2/retirement_partner_report_cleanups/", RetirementPartnerReportCleanupView), + ): + match = resolve(address) + assert view_identity(match.func) == (V2_VIEW_MODULE, view.__name__), address + assert served_methods(match.func) == {"POST", "OPTIONS"}, address + + def test_a_name_carrying_every_character_a_username_may_hold_addresses_a_member(self): + for username in ("a.b", "a b", "a@b", "a+b", "a-b", "a_b", "Mixed.Case+1"): + with self.subTest(username=username): + match = resolve(f"/api/user/v2/retirements/{username}/") + assert match.url_name == "retirement_detail" + assert match.kwargs == {"username": username} + erasure = resolve(f"/api/user/v2/retirements/{username}/erasures/") + assert erasure.url_name == "retirement_erasure" + assert erasure.kwargs == {"username": username} + + def test_a_name_that_reads_like_a_batch_address_is_read_as_a_name(self): + """A user may register any name, so no fixed word sits where a name goes.""" + match = resolve("/api/user/v2/retirements/cleanups/") + assert match.url_name == "retirement_detail" + assert match.kwargs == {"username": "cleanups"} + assert resolve("/api/user/v2/retirement_cleanups/").url_name == "retirement_cleanup" + + def test_the_batch_addresses_are_not_members_of_the_collections_they_dispose_of(self): + assert reverse("user_v2:retirement_cleanup") == "/api/user/v2/retirement_cleanups/" + assert reverse("user_v2:retirement_partner_report_cleanup") == ( + "/api/user/v2/retirement_partner_report_cleanups/" + ) + + def test_the_viewsets_build_their_action_addresses_under_this_mount(self): + retirements = RetirementViewSet(basename="retirement") + assert retirements.reverse_action("list") == "/api/user/v2/retirements/" + assert retirements.reverse_action( + "detail", kwargs={"username": "alice"}, + ) == "/api/user/v2/retirements/alice/" + assert retirements.reverse_action( + "erasure", kwargs={"username": "alice"}, + ) == "/api/user/v2/retirements/alice/erasures/" + reports = RetirementPartnerReportViewSet(basename="retirement_partner_report") + assert reports.reverse_action("list") == "/api/user/v2/retirement_partner_reports/" + assert reports.reverse_action( + "detail", kwargs={"username": "alice"}, + ) == "/api/user/v2/retirement_partner_reports/alice/" + + def test_an_address_near_one_this_version_serves_is_refused_in_the_body_it_answers_with(self): + response = self.client.get("/api/user/v2/retirement/") + assert_error_envelope( + response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + assert response["Content-Type"].startswith("application/json") + assert " Date: Tue, 27 Oct 2026 14:04:20 +0500 Subject: [PATCH 45/82] fix: refuse an ambiguous account name, and answer the method a caller sent Five corrections to the retirement pipeline from the review round, all on behaviour rather than shape. A record keeps the name of the account it is for in a column that is indexed but not unique, and in some deployments stored names are compared without regard to case, so one name can carry more than one record. The selectors collapsed such a name to a single record and the batch disposal then reported every record deleted while one of them survived. Names now resolve through one function for the whole surface, which refuses a name carrying more than one record rather than choosing between them, and logs the records it refused. The superseded addresses are untouched and still act on the first match. The model a partial update of a version 1 retirement was published as reading lost its last reference when that operation began publishing what its handler really reads. It was named again where only the documentation build would have read it, so the document this service serves lost the model and a client generated from it would have lost the class. It is named beside the service's own schema settings instead, so every document built from them carries it. The erasure read no parser list, so a form-encoded request reached a handler that destroys personal data irreversibly, where the superseded address refuses one. The list the superseded operations declare is restored, operation by operation. A method an address does not serve was answered as a refusal of the caller's rights, because the permission lookup is keyed on the action and no action is chosen for such a method. The view's own classes answer in that case, so the dispatcher reports the methods the address serves instead. Both reads, and both of their HEADs, write a language preference for a request that carries the language cookie, from middleware every request passes through. The captures proving these reads write nothing never set that cookie and did not execute deferred writes, so the one writing branch was unreachable in all of them. They now set it, execute what was deferred, and the exception is recorded where the reads are described. --- lms/envs/common.py | 29 + openedx/core/djangoapps/user_api/v2/errors.py | 36 +- .../core/djangoapps/user_api/v2/selectors.py | 39 +- .../v2/tests/test_retirement_parity.py | 31 +- .../v2/tests/test_retirement_views.py | 631 +++++++++++++++++- .../user_api/v2/tests/test_schema.py | 58 +- .../user_api/v2/tests/test_selectors.py | 34 +- .../djangoapps/user_api/v2/view_services.py | 65 +- openedx/core/djangoapps/user_api/v2/views.py | 67 +- 9 files changed, 911 insertions(+), 79 deletions(-) diff --git a/lms/envs/common.py b/lms/envs/common.py index 00c3aee24785..3e0ec8ed7d33 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2180,6 +2180,30 @@ add_optional_apps(OPTIONAL_APPS, INSTALLED_APPS) # noqa: F405 ###################### drf-spectacular (LMS API schema) ###################### +# The retirement-status model that a partial update of a version 1 retirement +# was published as reading. That operation now publishes the object its handler +# really reads, which leaves this model with no reference to generate it from, +# and a client generated from a document names a class after each model in it. +# The model is therefore named here, exactly as it was published, so the class +# keeps its name in every document this service builds. +PATCHED_USER_RETIREMENT_STATUS = { + 'type': 'object', + 'description': 'Perform serialization for the RetirementStatus model', + 'properties': { + 'id': {'type': 'integer', 'readOnly': True}, + 'user': {'allOf': [{'$ref': '#/components/schemas/RetirementUser'}], 'readOnly': True}, + 'current_state': {'allOf': [{'$ref': '#/components/schemas/RetirementState'}], 'readOnly': True}, + 'last_state': {'allOf': [{'$ref': '#/components/schemas/RetirementState'}], 'readOnly': True}, + 'created': {'type': 'string', 'format': 'date-time', 'readOnly': True}, + 'modified': {'type': 'string', 'format': 'date-time', 'readOnly': True}, + 'original_username': {'type': 'string', 'maxLength': 150}, + 'original_email': {'type': 'string', 'format': 'email', 'maxLength': 254}, + 'original_name': {'type': 'string', 'maxLength': 255}, + 'retired_username': {'type': 'string', 'maxLength': 150}, + 'retired_email': {'type': 'string', 'format': 'email', 'maxLength': 254}, + }, +} + SPECTACULAR_SETTINGS = { 'TITLE': 'LMS API', 'VERSION': '0.1.0', @@ -2193,6 +2217,11 @@ 'openedx.core.djangoapps.user_api.schema.mark_superseded_user_paths', 'openedx.core.apidocs.untag_deprecated_operations', ], + # Carried by every document built from these settings, including the + # service-wide one, which overlays its own keys over them. + 'APPEND_COMPONENTS': { + 'schemas': {'PatchedUserRetirementStatus': PATCHED_USER_RETIREMENT_STATUS}, + }, # 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': r'/api/(enrollment|user)', diff --git a/openedx/core/djangoapps/user_api/v2/errors.py b/openedx/core/djangoapps/user_api/v2/errors.py index f877e541661d..a59d1ab259bc 100644 --- a/openedx/core/djangoapps/user_api/v2/errors.py +++ b/openedx/core/djangoapps/user_api/v2/errors.py @@ -56,15 +56,13 @@ def __init__(self, detail=None, can_cancel_retirement=False, retirement_id=None) } -class RetirementStateConflict(Conflict): +class _NamesTheRecordsAtFault: """ - An operation asked of a retirement whose state does not admit it. + Carries the records a refusal is about, each with what was wrong with it. - Which operation a retirement admits depends on how far through the - pipeline it is, and the order of those steps is configured by the - operator rather than fixed here. A refusal names the retirements at fault - and why, so a caller acting on several of them at once learns which ones - to look at; nothing is changed when one is refused. + A caller acting on several records at once learns which of them to look + at, rather than being told only that the request as a whole was refused. + Nothing is changed when any one record is refused. """ def __init__(self, detail=None, errors=None): @@ -73,6 +71,29 @@ def __init__(self, detail=None, errors=None): self.extension_members = {"errors": errors} +class RetirementStateConflict(_NamesTheRecordsAtFault, Conflict): + """ + An operation asked of a retirement whose state does not admit it. + + Which operation a retirement admits depends on how far through the + pipeline it is, and the order of those steps is configured by the + operator rather than fixed here. + """ + + +class AmbiguousAccountName(_NamesTheRecordsAtFault, ValidationError): + """ + A name that more than one retirement record was requested under. + + The name a record keeps of the account it is for is not unique, and in + some deployments stored names are compared without regard to case, which + once let a learner register a retired name again by changing its + capitalisation. A request naming such a name addresses several records at + once, and acting on either of them would act on an account the caller did + not ask about, so nothing is done. + """ + + 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") @@ -80,6 +101,7 @@ def __init__(self, detail=None, errors=None): register_error_type(RetiredAccount, "user/retired-account", "Retired Account") register_error_type(AmbiguousAccountName, "user/ambiguous-account-name", "Ambiguous Account Name") register_error_type(RetirementStateConflict, "user/retirement-state-conflict", "Retirement State Conflict") +register_error_type(AmbiguousAccountName, "user/ambiguous-account-name", "Ambiguous Account Name") def refusal_sentence(refusals): diff --git a/openedx/core/djangoapps/user_api/v2/selectors.py b/openedx/core/djangoapps/user_api/v2/selectors.py index 0c52779a3928..327f9a9ff706 100644 --- a/openedx/core/djangoapps/user_api/v2/selectors.py +++ b/openedx/core/djangoapps/user_api/v2/selectors.py @@ -117,22 +117,32 @@ def retirement_of_the_account_named(username): return retirements().filter(user=user).first() -def retirements_requested_as(usernames): +def _grouped_by_requested_name(rows, usernames): """ - Return the retirements requested under exactly these names, keyed by name. + Return these rows under the name each was requested with, every row kept. Stored names are compared without regard to case in some deployments, which once let a learner register a retired name again by changing its capitalisation, so the rows the database offers are compared again here - character for character. A name no row was requested under is absent from - the result. + character for character. The name a row keeps of the account it is for is + indexed but not unique, so a name may carry more than one row; every one + of them is returned, because a caller that acted on one of several would + act on a record it did not ask about. A name no row was requested under is + absent from the result. """ asked_for = set(usernames) - return { - row.original_username: row - for row in retirements().filter(original_username__in=usernames) - if row.original_username in asked_for - } + grouped = {} + for row in rows: + if row.original_username in asked_for: + grouped.setdefault(row.original_username, []).append(row) + return grouped + + +def retirements_requested_as(usernames): + """Return the retirements requested under exactly these names, grouped by name.""" + return _grouped_by_requested_name( + retirements().filter(original_username__in=usernames), usernames, + ) def unclaimed_partner_reports(): @@ -153,10 +163,7 @@ def unclaimed_partner_reports(): def partner_reports_requested_as(usernames): - """Return the partner reports of the accounts registered under exactly these names, keyed by name.""" - asked_for = set(usernames) - return { - row.original_username: row - for row in UserRetirementPartnerReportingStatus.objects.filter(original_username__in=usernames) - if row.original_username in asked_for - } + """Return the partner reports of the accounts registered under exactly these names, grouped by name.""" + return _grouped_by_requested_name( + UserRetirementPartnerReportingStatus.objects.filter(original_username__in=usernames), usernames, + ) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_parity.py b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_parity.py index dc6f45f5cd7a..2a2c09b8157c 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_parity.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_parity.py @@ -6,7 +6,7 @@ import pytest from django.core.cache import cache -from django.test import override_settings +from django.test import SimpleTestCase, override_settings from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase @@ -46,6 +46,12 @@ ) from openedx.core.djangoapps.user_api.models import RetirementState, UserOrgTag, UserRetirementStatus from openedx.core.djangoapps.user_api.v2 import view_services +from openedx.core.djangoapps.user_api.v2.views import ( + RetirementCleanupView, + RetirementPartnerReportCleanupView, + RetirementPartnerReportViewSet, + RetirementViewSet, +) from openedx.core.djangolib.testing.utils import skip_unless_lms PASSWORD = "Password1234" @@ -94,6 +100,16 @@ ("last_state", "the step is the name of the step rather than an object carrying its order and its id"), ) +#: Each view of this version with the view whose operations it takes over, for +#: the media types a body may be sent in. The disposals are part of the +#: earlier version's two pipeline views and are compared against those. +MEDIA_TYPE_PAIRS = ( + (RetirementViewSet, legacy_views.AccountRetirementStatusView), + (RetirementCleanupView, legacy_views.AccountRetirementStatusView), + (RetirementPartnerReportViewSet, legacy_views.AccountRetirementPartnerReportView), + (RetirementPartnerReportCleanupView, legacy_views.AccountRetirementPartnerReportView), +) + @pytest.mark.usefixtures("setup_retirement_states") @override_settings(RETIREMENT_SERVICE_WORKER_USERNAME=WORKER_USERNAME) @@ -527,3 +543,16 @@ def test_neither_version_carries_the_log_of_the_moves_a_retirement_has_made(self assert "responses" not in conforming assert "responses" not in UserRetirementStatusSerializer(instance=self.retirement).data assert UserRetirementStatus.objects.get(id=self.retirement.id).responses != "" + + +class MediaTypeParityTests(SimpleTestCase): + """Each operation reads a body in the media types the operation it replaces reads.""" + + def test_each_view_reads_what_the_view_it_takes_over_from_reads(self): + for conforming, legacy in MEDIA_TYPE_PAIRS: + with self.subTest(view=conforming.__name__): + assert tuple(conforming.parser_classes) == tuple(legacy.parser_classes) + + def test_the_cancellation_is_the_one_operation_whose_view_narrowed_nothing(self): + """It reads no body on either version, so there is nothing for a media type to decide.""" + assert "parser_classes" not in vars(legacy_views.CancelAccountRetirementStatusView) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py index 6bd0a028f98a..162a31b6f240 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py @@ -1,20 +1,34 @@ """Tests for the version 2 User API views over the account retirement pipeline.""" +import contextlib import datetime from unittest import mock from zoneinfo import ZoneInfo import pytest +from crum.signals import current_user_setter +from django.conf import settings from django.contrib.auth.models import Permission +from django.contrib.sessions.models import Session from django.core.cache import cache +from django.core.signals import got_request_exception, request_finished, request_started from django.db import connection +from django.db.backends.signals import connection_created +from django.db.models.signals import class_prepared, post_init, post_save, pre_init, pre_save +from django.dispatch import Signal from django.test import SimpleTestCase, override_settings +from django.test.signals import setting_changed from django.test.utils import CaptureQueriesContext from django.urls import reverse +from edx_django_utils.monitoring.signals import ( + monitoring_support_process_request, + monitoring_support_process_response, +) from edx_rest_framework_extensions.errors import Conflict, classify_error from edx_rest_framework_extensions.testing import assert_error_envelope from opaque_keys.edx.keys import CourseKey from rest_framework import permissions, status +from rest_framework.exceptions import ValidationError from rest_framework.test import APITestCase from common.djangoapps.student.models import ( @@ -31,6 +45,7 @@ SuperuserFactory, UserFactory, ) +from common.djangoapps.util.model_utils import USER_FIELDS_CHANGED from openedx.core.djangoapps.user_api.accounts.permissions import CanCancelUserRetirement, CanRetireUser from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import ( # pylint: disable=unused-import create_retirement_status, @@ -39,12 +54,13 @@ from openedx.core.djangoapps.user_api.models import ( RetirementState, UserOrgTag, + UserPreference, UserRetirementPartnerReportingStatus, UserRetirementRequest, UserRetirementStatus, ) from openedx.core.djangoapps.user_api.v2 import view_services -from openedx.core.djangoapps.user_api.v2.errors import RetirementStateConflict +from openedx.core.djangoapps.user_api.v2.errors import AmbiguousAccountName, RetirementStateConflict from openedx.core.djangoapps.user_api.v2.paginators import UserApiPagination from openedx.core.djangoapps.user_api.v2.serializers import RETIREMENT_MINIMAL_FIELDS from openedx.core.djangoapps.user_api.v2.views import ( @@ -110,6 +126,77 @@ RETIREMENT_PII_FIELDS = {"original_email", "original_name", "user"} STATE_CONFLICT_SLUG = "user/retirement-state-conflict" +AMBIGUOUS_NAME_SLUG = "user/ambiguous-account-name" + +#: Where the language a user picked is carried between requests, the name it is +#: stored under, and the table it is stored in. +LANGUAGE_COOKIE = settings.LANGUAGE_COOKIE_NAME +LANGUAGE_PREFERENCE = "pref-lang" +PREFERENCE_TABLE = "user_api_userpreference" + +#: The signals every request raises because the platform raises them: a row +#: being instantiated, the request beginning and ending, a connection being +#: opened, a model class being prepared, a setting being overridden by a test, +#: the telemetry each request is measured through, and the caller being put +#: where the code below the view reads it from. None of them says anything +#: about what the request did. +FRAMEWORK_SIGNALS = ( + pre_init, + post_init, + class_prepared, + connection_created, + request_started, + request_finished, + got_request_exception, + setting_changed, + monitoring_support_process_request, + monitoring_support_process_response, + current_user_setter, +) + +#: What syncing the language from the cookie announces: the row being stored, +#: and the platform's own announcement that a field of the account changed. +LANGUAGE_SYNC_SIGNALS = [ + (pre_save, UserPreference), + (post_save, UserPreference), + (USER_FIELDS_CHANGED, None), +] + +#: The addresses this version supersedes, used where the point is that both +#: versions behave alike. +EARLIER_RETIREMENT_QUEUE = "/api/user/v1/accounts/retirement_queue/" +EARLIER_RETIREMENT_CLEANUP = "/api/user/v1/accounts/retirement_cleanup/" +EARLIER_PARTNER_REPORT_CLEANUP = "/api/user/v1/accounts/retirement_partner_report_cleanup/" +EARLIER_PARTNER_REPORT = "/api/user/v1/accounts/retirement_partner_report/" +EARLIER_CORE_ERASURE = "/api/user/v1/accounts/retire/" + + +@contextlib.contextmanager +def captured_signals(): + """ + Record the signals sent while this block runs, bar the ones the framework sends itself. + + A signal is recorded by the signal object and the sender it was sent + under, so that a test names the signals it expects rather than counting + them. + """ + sent = [] + originals = {name: getattr(Signal, name) for name in ("send", "send_robust")} + + def recording(original): + """Return a replacement for one of the two ways a signal is sent.""" + def send(self, sender=None, **named): + # The session row is left out for the same reason its statements + # are: it is the platform's record of the caller being served, + # not data this API answers from. + if self not in FRAMEWORK_SIGNALS and sender is not Session: + sent.append((self, sender)) + return original(self, sender=sender, **named) + return send + + with mock.patch.object(Signal, "send", recording(originals["send"])), \ + mock.patch.object(Signal, "send_robust", recording(originals["send_robust"])): + yield sent COURSE_KEY = CourseKey.from_string("course-v1:edX+DemoX+Demo") OTHER_COURSE_KEY = CourseKey.from_string("course-v1:OtherOrg+OtherX+Other") @@ -1448,6 +1535,15 @@ def test_another_area_refusing_a_conflict_is_classified_as_it_was_before(self): """Registering this area's refusal reclassifies nothing for any other view in the process.""" assert classify_error(Conflict()) == ("conflict", "Conflict") + def test_the_refusal_of_an_ambiguous_name_is_named_for_this_area(self): + assert classify_error(AmbiguousAccountName()) == ( + "user/ambiguous-account-name", "Ambiguous Account Name", + ) + + def test_another_area_refusing_an_invalid_request_is_classified_as_it_was_before(self): + """The registry is shared, and this area registers a subclass of a classified error.""" + assert classify_error(ValidationError("anything")) == ("validation", "Validation Error") + def test_a_refusal_naming_no_records_carries_no_per_record_member(self): assert not hasattr(RetirementStateConflict("refused"), "extension_members") @@ -1461,24 +1557,64 @@ def test_the_default_and_the_maximum_page_are_the_sizes_this_version_publishes(s assert RetirementViewSet.pagination_class is UserApiPagination -@skip_unless_lms -class RetirementReadsAreNotWritesTests(RetirementApiTestCase): - """Reading the queue or one record changes nothing, whichever way it is read.""" +class RetirementReadCaptureCase(RetirementApiTestCase): + """ + What a read of the retirement addresses does, measured statement by statement. + + Three channels are captured, because a read that changes stored data need + not do so itself: the statements it ran, the callbacks it deferred to the + end of its transaction, and the signals it sent. A signal is captured + because the platform's own listeners act on one whether or not the read + that sent it wrote anything, and the pipeline announces each of its steps + that way. + """ def setUp(self): super().setUp() self.retirement = self.retirement_in("LOCKING_ACCOUNT", user=self.subject) self.list_url = reverse("user_v2:retirement_list") self.detail_url = reverse("user_v2:retirement_detail", kwargs={"username": "subject"}) + self.deferred = [] + self.signalled = [] def captured_writes(self, method, url, **params): """Return the statements a read makes that would change the data this API serves.""" self.login("worker") - with CaptureQueriesContext(connection) as captured: + with CaptureQueriesContext(connection) as captured, \ + captured_signals() as signalled, \ + self.captureOnCommitCallbacks(execute=True) as callbacks: response = getattr(self.client, method)(url, params) - assert response.status_code == status.HTTP_200_OK + assert response.status_code == status.HTTP_200_OK, response.content + self.deferred = callbacks + self.signalled = signalled return write_queries(captured.captured_queries) + def targets(self, statements): + """Return the table each statement writes to.""" + return [statement.split('"')[1] for statement in statements] + + +@skip_unless_lms +class RetirementReadsAreNotWritesTests(RetirementReadCaptureCase): + """Reading the queue or one record changes nothing, whichever way it is read.""" + + def assert_nothing_was_changed(self, method, url, **params): + """Assert the read wrote nothing, deferred nothing and announced nothing.""" + statements = self.captured_writes(method, url, **params) + assert (statements, self.deferred, self.signalled) == ([], [], []) + + def test_reading_the_queue_announces_nothing_and_defers_nothing(self): + self.assert_nothing_was_changed("get", self.list_url) + + def test_reading_one_record_announces_nothing_and_defers_nothing(self): + self.assert_nothing_was_changed("get", self.detail_url) + + def test_reading_the_queue_without_a_body_announces_nothing_either(self): + self.assert_nothing_was_changed("head", self.list_url) + + def test_reading_one_record_without_a_body_announces_nothing_either(self): + self.assert_nothing_was_changed("head", self.detail_url) + def test_reading_the_queue_writes_nothing(self): assert self.captured_writes("get", self.list_url) == [] @@ -1502,3 +1638,486 @@ def test_reading_the_queue_with_every_filter_applied_writes_nothing(self): ordering="created", view="minimal", ) == [] + + +@skip_unless_lms +class RetirementReadsWithTheLanguageCookieTests(RetirementReadCaptureCase): + """ + A read carrying the language cookie stores the language it carries. + + The platform keeps the language a user picked in a cookie and syncs it into + that user's preferences from middleware, which runs before any view and on + every method, so it is reached by a read of these addresses as it is by a + read of any other. It is the one write a read of this area makes, and it + belongs to the platform rather than to this version: the addresses this + version supersedes make the same write, under the same cookie. The capture + below pins it -- the one row, by the table it is stored in, the one change + it announces, and nothing else. + """ + + def setUp(self): + super().setUp() + self.client.cookies[LANGUAGE_COOKIE] = "eo" + + def language_rows(self): + """Return the stored language of every account, by account.""" + return { + preference.user.username: preference.value + for preference in UserPreference.objects.filter(key=LANGUAGE_PREFERENCE) + } + + def assert_only_the_language_was_stored(self, method, url, **params): + """Assert the one write made, and the one change announced, was the caller's language.""" + statements = self.captured_writes(method, url, **params) + assert (self.targets(statements), self.deferred) == ([PREFERENCE_TABLE], []), statements + assert self.language_rows() == {WORKER_USERNAME: "eo"} + assert self.signalled == LANGUAGE_SYNC_SIGNALS + + def test_reading_the_queue_stores_the_language_the_cookie_carries(self): + assert self.language_rows() == {} + self.assert_only_the_language_was_stored("get", self.list_url) + + def test_reading_one_record_stores_it_too(self): + self.assert_only_the_language_was_stored("get", self.detail_url) + + def test_reading_the_queue_without_a_body_stores_it_as_well(self): + """A write one frame below the handler is reached by every method, this one included.""" + self.assert_only_the_language_was_stored("head", self.list_url) + + def test_reading_one_record_without_a_body_stores_it_as_well(self): + self.assert_only_the_language_was_stored("head", self.detail_url) + + def test_reading_the_queue_with_every_filter_applied_stores_only_that(self): + self.assert_only_the_language_was_stored( + "get", + self.list_url, + state="LOCKING_ACCOUNT", + cool_off_days=0, + created_after="2020-01-01", + created_before="2099-01-01", + ordering="created", + view="minimal", + ) + + def test_the_address_this_version_supersedes_stores_the_same_row(self): + """The write belongs to the platform rather than to either version of this area.""" + self.login("worker") + with CaptureQueriesContext(connection) as captured: + response = self.client.get( + EARLIER_RETIREMENT_QUEUE, {"cool_off_days": 0, "states": "LOCKING_ACCOUNT"}, + ) + assert response.status_code == status.HTTP_200_OK, response.content + assert self.targets(write_queries(captured.captured_queries)) == [PREFERENCE_TABLE] + assert self.language_rows() == {WORKER_USERNAME: "eo"} + + def test_the_row_is_written_once_and_not_once_a_request(self): + """It is the language being synced, not the read recording anything.""" + self.assert_only_the_language_was_stored("get", self.list_url) + statements = self.captured_writes("get", self.list_url) + assert (statements, self.deferred, self.signalled) == ([], [], []) + + +@skip_unless_lms +class NameRequestedTwiceTests(RetirementApiTestCase): + """ + A name more than one record was requested under is refused, and nothing is acted on. + + The name a record keeps of the account it is for is indexed but not + unique, and in some deployments stored names are compared without regard + to case, which once let a learner register a retired name again by + changing its capitalisation. Both records of such a pair are built + directly here: registering a second account under a name a record holds is + refused a frame below this API, so the state cannot be reached through + registration, and deployments hold it from before that refusal existed. + + Every operation that finds its record by that name refuses the request, + rather than acting on whichever of the two the database offered last. + """ + + def setUp(self): + super().setUp() + self.detail_url = reverse("user_v2:retirement_detail", kwargs={"username": "subject"}) + self.erasure_url = reverse("user_v2:retirement_erasure", kwargs={"username": "subject"}) + self.queue_url = reverse( + "user_v2:retirement_partner_report_detail", kwargs={"username": "subject"}, + ) + self.cleanup_url = reverse("user_v2:retirement_cleanup") + self.partner_cleanup_url = reverse("user_v2:retirement_partner_report_cleanup") + + def two_retirements_under_one_name(self, state_name): + """Return two retirement records requested under the one name ``subject``.""" + held = self.retirement_in(state_name, user=self.subject) + twin = self.retirement_in(state_name, user=UserFactory.create(username="subject-twin")) + UserRetirementStatus.objects.filter(id=twin.id).update(original_username="subject") + return held, twin + + def two_partner_reports_under_one_name(self, taken=True): + """Return two partner report entries registered under the one name ``subject``.""" + entries = [ + UserRetirementPartnerReportingStatus.objects.create( + user=user, + original_username=user.username, + original_email=user.email, + original_name=user.profile.name, + is_being_processed=taken, + ) + for user in (self.subject, UserFactory.create(username="subject-twin")) + ] + UserRetirementPartnerReportingStatus.objects.filter(id=entries[1].id).update( + original_username="subject", + ) + return entries + + def assert_the_name_was_refused(self, response): + """Assert the answer is the refusal of an ambiguous name, and return its envelope.""" + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug=AMBIGUOUS_NAME_SLUG, + ) + assert envelope["detail"] == view_services.NAME_IS_AMBIGUOUS + assert envelope["instance"] == response.request["PATH_INFO"] + return envelope + + def test_a_refusal_that_names_one_account_says_so_about_the_request_as_a_whole(self): + """One name is refused at the address that carries it, so there is no record to key it to.""" + self.two_retirements_under_one_name("LOCKING_ACCOUNT") + self.login("worker") + envelope = self.assert_the_name_was_refused( + self.client.patch( + self.detail_url, {"new_state": "LOCKING_COMPLETE", "response": "done"}, format="json", + ), + ) + assert envelope["errors"] == {"non_field_errors": [view_services.NAME_IS_AMBIGUOUS]} + + def test_moving_the_retirement_is_refused_and_neither_record_moves(self): + held, twin = self.two_retirements_under_one_name("LOCKING_ACCOUNT") + self.login("worker") + self.assert_the_name_was_refused( + self.client.patch( + self.detail_url, {"new_state": "LOCKING_COMPLETE", "response": "done"}, format="json", + ), + ) + for record in (held, twin): + record.refresh_from_db() + assert record.current_state.state_name == "LOCKING_ACCOUNT" + + def test_cancelling_is_refused_and_neither_request_is_undone(self): + held, twin = self.two_retirements_under_one_name("PENDING") + self.login("canceller") + self.assert_the_name_was_refused(self.client.delete(self.detail_url)) + assert set(UserRetirementStatus.objects.values_list("id", flat=True)) == {held.id, twin.id} + assert UserRetirementRequest.objects.count() == 2 + + def test_erasing_is_refused_and_nothing_is_erased(self): + self.two_retirements_under_one_name("RETIRING_LMS") + self.subject.profile.city = "Somewhere" + self.subject.profile.save() + self.login("worker") + self.assert_the_name_was_refused( + self.client.post(self.erasure_url, {"scope": "lms_core"}, format="json"), + ) + self.subject.refresh_from_db() + assert (self.subject.username, self.subject.email) == ("subject", "subject@example.com") + assert self.subject.is_active is True + assert UserProfile.objects.get(user=self.subject).city == "Somewhere" + + def test_queueing_for_partner_reporting_is_refused_and_nothing_is_queued(self): + self.two_retirements_under_one_name("RETIRING_LMS") + CourseEnrollmentFactory.create(user=self.subject, course_id=COURSE_KEY) + self.login("worker") + self.assert_the_name_was_refused(self.client.put(self.queue_url, {}, format="json")) + assert UserRetirementPartnerReportingStatus.objects.count() == 0 + + def test_the_batch_disposal_is_refused_whole_and_nothing_is_deleted(self): + held, twin = self.two_retirements_under_one_name("COMPLETE") + self.login("worker") + envelope = self.assert_the_name_was_refused( + self.client.post(self.cleanup_url, {"usernames": ["subject"]}, format="json"), + ) + assert envelope["errors"] == {"subject": [view_services.SEVERAL_RECORDS_HOLD_THIS_NAME]} + for record in (held, twin): + record.refresh_from_db() + assert record.original_username == "subject" + assert record.original_email != view_services.REDACTED_EMAIL + + def test_a_batch_naming_it_alongside_a_name_held_once_deletes_neither(self): + """A batch is acted on whole or not at all, so one ambiguous name holds the rest back.""" + held, twin = self.two_retirements_under_one_name("COMPLETE") + alone = self.retirement_in("COMPLETE", user=UserFactory.create(username="held-once")) + self.login("worker") + self.assert_the_name_was_refused( + self.client.post( + self.cleanup_url, {"usernames": ["subject", "held-once"]}, format="json", + ), + ) + assert set(UserRetirementStatus.objects.values_list("id", flat=True)) == { + held.id, twin.id, alone.id, + } + + def test_the_earlier_batch_disposal_refuses_the_same_name(self): + """The address this one replaces refuses it too, and deletes nothing.""" + held, twin = self.two_retirements_under_one_name("COMPLETE") + self.login("worker") + response = self.client.post( + EARLIER_RETIREMENT_CLEANUP, {"usernames": ["subject"]}, format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST, response.content + assert set(UserRetirementStatus.objects.values_list("id", flat=True)) == {held.id, twin.id} + + def test_the_partner_report_disposal_is_refused_whole_and_nothing_is_deleted(self): + entries = self.two_partner_reports_under_one_name() + self.login("worker") + envelope = self.assert_the_name_was_refused( + self.client.post(self.partner_cleanup_url, {"usernames": ["subject"]}, format="json"), + ) + assert envelope["errors"] == {"subject": [view_services.SEVERAL_RECORDS_HOLD_THIS_NAME]} + assert set( + UserRetirementPartnerReportingStatus.objects.values_list("id", flat=True), + ) == {entry.id for entry in entries} + + def test_the_earlier_partner_report_disposal_refuses_the_same_name(self): + entries = self.two_partner_reports_under_one_name() + self.login("worker") + response = self.client.post( + EARLIER_PARTNER_REPORT_CLEANUP, [{"original_username": "subject"}], format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST, response.content + assert set( + UserRetirementPartnerReportingStatus.objects.values_list("id", flat=True), + ) == {entry.id for entry in entries} + + def test_reading_the_record_of_the_account_addressed_still_answers(self): + """A read addresses the account rather than the name a record keeps, so it is unambiguous.""" + held, _twin = self.two_retirements_under_one_name("LOCKING_ACCOUNT") + self.login("worker") + response = self.client.get(self.detail_url) + assert response.status_code == status.HTTP_200_OK, response.content + assert response.json()["id"] == held.id + + def test_the_queue_carries_both_records(self): + """Nothing is hidden by the refusal: the page answers with every record it holds.""" + held, twin = self.two_retirements_under_one_name("LOCKING_ACCOUNT") + self.login("worker") + response = self.client.get(reverse("user_v2:retirement_list")) + assert [row["id"] for row in response.json()["results"]] == [held.id, twin.id] + + +@skip_unless_lms +class FormEncodedBodyTests(RetirementApiTestCase): + """ + A body sent as a web form is refused wherever a body is read. + + These operations read a body as JSON alone, as the operations they replace + do. The narrowing is kept rather than widened to every media type the + platform can parse because one of them erases personal data for good and + cannot be undone, and a form-encoded request is one a page on another site + can be made to send. + + The operations that read no body are unaffected, which is why the + narrowing is read from the view rather than applied to each method: a + method that never reaches the body never reaches the parser either. + """ + + FORM = "application/x-www-form-urlencoded" + + def setUp(self): + super().setUp() + self.retirement = self.retirement_in("RETIRING_LMS", user=self.subject) + self.detail_url = reverse("user_v2:retirement_detail", kwargs={"username": "subject"}) + self.erasure_url = reverse("user_v2:retirement_erasure", kwargs={"username": "subject"}) + + def assert_the_body_was_unreadable(self, response): + """Assert the answer names the media type as one this endpoint cannot read.""" + assert_error_envelope( + response, + expected_status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + expected_type_slug="unsupported-media-type", + ) + + def test_a_form_body_is_refused_rather_than_erasing_the_account(self): + self.subject.profile.city = "Somewhere" + self.subject.profile.save() + self.login("worker") + self.assert_the_body_was_unreadable( + self.client.post(self.erasure_url, "scope=lms_core", content_type=self.FORM), + ) + self.subject.refresh_from_db() + assert (self.subject.username, self.subject.email) == ("subject", "subject@example.com") + assert UserProfile.objects.get(user=self.subject).city == "Somewhere" + + def test_the_erasure_this_one_replaces_refuses_the_same_body(self): + """Both versions answer the same, which is what keeps a caller's request refused.""" + self.login("worker") + response = self.client.post( + EARLIER_CORE_ERASURE, "username=subject", content_type=self.FORM, + ) + assert response.status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, response.content + self.subject.refresh_from_db() + assert self.subject.username == "subject" + + def test_a_form_body_is_refused_when_moving_a_retirement(self): + self.login("worker") + self.assert_the_body_was_unreadable( + self.client.patch( + self.detail_url, "new_state=RETIRING_LMS_COMPLETE&response=done", + content_type=self.FORM, + ), + ) + self.retirement.refresh_from_db() + assert self.retirement.current_state.state_name == "RETIRING_LMS" + + def test_a_form_body_is_refused_by_each_batch_disposal(self): + self.login("worker") + for name in ("user_v2:retirement_cleanup", "user_v2:retirement_partner_report_cleanup"): + with self.subTest(address=name): + self.assert_the_body_was_unreadable( + self.client.post(reverse(name), "usernames=subject", content_type=self.FORM), + ) + + def test_taking_partner_reports_reads_no_body_and_is_answered(self): + """It takes what is queued and reads nothing from the request, as the earlier one does.""" + self.login("worker") + taken = self.client.post( + reverse("user_v2:retirement_partner_report_list"), "anything=at-all", + content_type=self.FORM, + ) + earlier = self.client.post(EARLIER_PARTNER_REPORT, "anything=at-all", content_type=self.FORM) + assert (taken.status_code, earlier.status_code) == ( + status.HTTP_200_OK, status.HTTP_200_OK, + ), (taken.content, earlier.content) + + def test_cancelling_is_answered_whatever_media_type_is_sent(self): + """It names the retirement in the address and reads no body, so no parser is reached.""" + self.retirement_in("PENDING", user=self.learner) + self.login("canceller") + response = self.client.delete( + reverse("user_v2:retirement_detail", kwargs={"username": "learner-caller"}), + "nothing=here", + content_type=self.FORM, + ) + assert response.status_code == status.HTTP_204_NO_CONTENT, response.content + + def test_queueing_for_partner_reporting_is_answered_whatever_media_type_is_sent(self): + """The name is in the address, where the earlier version read it from the body.""" + CourseEnrollmentFactory.create(user=self.subject, course_id=COURSE_KEY) + self.login("worker") + response = self.client.put( + reverse("user_v2:retirement_partner_report_detail", kwargs={"username": "subject"}), + "nothing=here", + content_type=self.FORM, + ) + assert response.status_code == status.HTTP_204_NO_CONTENT, response.content + assert UserRetirementPartnerReportingStatus.objects.count() == 1 + + +@skip_unless_lms +class UnservedMethodTests(RetirementApiTestCase): + """ + A method an address does not serve is answered as not served, not as refused. + + Which caller may make a request is decided before the method is + dispatched, and at an address whose capability follows the operation there + is no operation to follow for a method it does not serve. The answer is + then the dispatcher's own: the methods the address does serve, and nothing + about a capability. A caller who is not signed in is still told to + authenticate, because being signed in is asked of every method alike. + """ + + #: A method each retirement address of this version does not serve, and the + #: methods it does. The second group's addresses guard every method with + #: the same classes, so they answer a capability refusal before this one. + PER_OPERATION = ( + ("post", "user_v2:retirement_detail", {"username": "subject"}), + ("put", "user_v2:retirement_detail", {"username": "subject"}), + ("delete", "user_v2:retirement_list", None), + ("patch", "user_v2:retirement_list", None), + ("get", "user_v2:retirement_erasure", {"username": "subject"}), + ) + PER_VIEW = ( + ("get", "user_v2:retirement_partner_report_list", None), + ("get", "user_v2:retirement_partner_report_detail", {"username": "subject"}), + ("get", "user_v2:retirement_cleanup", None), + ("get", "user_v2:retirement_partner_report_cleanup", None), + ) + + #: What each address of the collection serves, as its refusal reports it. + SERVED = { + "user_v2:retirement_list": {"GET", "HEAD", "OPTIONS"}, + "user_v2:retirement_detail": {"GET", "HEAD", "PATCH", "DELETE", "OPTIONS"}, + "user_v2:retirement_erasure": {"POST", "OPTIONS"}, + } + + def setUp(self): + super().setUp() + self.retirement_in("LOCKING_ACCOUNT", user=self.subject) + + def send(self, method, name, kwargs): + """Send a method the named address does not serve.""" + return getattr(self.client, method)(reverse(name, kwargs=kwargs)) + + def test_a_caller_holding_the_capability_is_told_the_method_is_not_served(self): + self.login("worker") + for method, name, kwargs in self.PER_OPERATION + self.PER_VIEW: + with self.subTest(method=method, address=name): + assert_error_envelope( + self.send(method, name, kwargs), + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug="method-not-allowed", + ) + + def test_the_refusal_names_the_methods_the_address_does_serve(self): + """The answer comes from the dispatcher, so it says what is served there.""" + self.login("worker") + for method, name, kwargs in self.PER_OPERATION: + with self.subTest(method=method, address=name): + response = self.send(method, name, kwargs) + assert set(response["Allow"].replace(" ", "").split(",")) == self.SERVED[name] + + def test_a_caller_without_the_capability_is_told_the_same_by_the_collection(self): + """There is no operation at that address for a capability to be asked for.""" + self.login("learner") + for method, name, kwargs in self.PER_OPERATION: + with self.subTest(method=method, address=name): + assert_error_envelope( + self.send(method, name, kwargs), + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug="method-not-allowed", + ) + + def test_a_caller_without_the_capability_is_refused_where_every_method_carries_one(self): + """These addresses ask for the capability whatever the method, so that answer comes first.""" + self.login("learner") + for method, name, kwargs in self.PER_VIEW: + with self.subTest(method=method, address=name): + assert_error_envelope( + self.send(method, name, kwargs), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_an_anonymous_caller_is_told_to_authenticate(self): + self.client.logout() + for method, name, kwargs in self.PER_OPERATION + self.PER_VIEW: + with self.subTest(method=method, address=name): + assert_error_envelope( + self.send(method, name, kwargs), + expected_status=status.HTTP_401_UNAUTHORIZED, + expected_type_slug="authn", + ) + + def test_an_action_the_mapping_does_not_name_still_reaches_no_capability(self): + """An action added later is refused; it is an unserved method that is not.""" + viewset = RetirementViewSet() + viewset.action = "an_action_added_later" + assert [ + permission.has_permission(mock.Mock(user=self.superuser), viewset) + for permission in viewset.get_permissions() + ][-1] is False + viewset.action = None + # Read from the view rather than named here: the platform's own JWT + # middleware adds a class to every view it serves, so what the classes + # of this view are is not fixed at import. + assert [type(permission) for permission in viewset.get_permissions()] == list( + RetirementViewSet.permission_classes, + ) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py index b01b05c30846..5683e35d13b8 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py @@ -2,7 +2,6 @@ import json from importlib import import_module -from pathlib import Path from unittest import mock import jsonschema @@ -2547,31 +2546,48 @@ def test_a_retirement_record_publishes_the_step_as_a_name(schema): assert "responses" not in record["properties"] +#: The retirement-status model as it was published while the corrected request +#: body was its only reference. It is written out here rather than read from +#: the configuration, so that a pin which no longer describes what a client +#: already generates from fails this. +PATCHED_RETIREMENT_STATUS_COMPONENT = { + "type": "object", + "description": "Perform serialization for the RetirementStatus model", + "properties": { + "id": {"type": "integer", "readOnly": True}, + "user": {"allOf": [{"$ref": "#/components/schemas/RetirementUser"}], "readOnly": True}, + "current_state": {"allOf": [{"$ref": "#/components/schemas/RetirementState"}], "readOnly": True}, + "last_state": {"allOf": [{"$ref": "#/components/schemas/RetirementState"}], "readOnly": True}, + "created": {"type": "string", "format": "date-time", "readOnly": True}, + "modified": {"type": "string", "format": "date-time", "readOnly": True}, + "original_username": {"type": "string", "maxLength": 150}, + "original_email": {"type": "string", "format": "email", "maxLength": 254}, + "original_name": {"type": "string", "maxLength": 255}, + "retired_username": {"type": "string", "maxLength": 150}, + "retired_email": {"type": "string", "format": "email", "maxLength": 254}, + }, +} + + @skip_unless_lms -def test_the_component_the_corrected_request_body_leaves_behind_is_pinned_in_the_document(schema): +def test_the_component_the_corrected_request_body_leaves_behind_is_carried_by_both_documents(schema): """ The partial update of a version 1 retirement was the component's only reference. - A client generated from the service-wide document names a class after each - component, so a name that loses its last reference is kept by naming it in - the configuration that document is built with, rather than disappearing - with the body that was wrong. + A client generated from a document names a class after each component in + it, so a component that loses its last reference is named in the settings + both documents are built from, rather than disappearing with the body that + was wrong. The document the service serves for browsing its whole surface + overlays its own keys on those settings and keeps this one, and the + narrower document a client library is generated from carries it too. """ - assert "PatchedUserRetirementStatus" not in schema["components"]["schemas"] - derived = service_wide_schema() - assert "PatchedUserRetirementStatus" not in derived["components"]["schemas"] - - configured = (Path(settings.REPO_ROOT) / "docs" / "docs_settings.py").read_text() - assert '"PatchedUserRetirementStatus": PATCHED_USER_RETIREMENT_STATUS' in configured - assert '"schemas": {' in configured - - pinned = {"PatchedUserRetirementStatus": {"type": "object"}} - with patched_settings({**get_api_docs_settings(), "APPEND_COMPONENTS": {"schemas": pinned}}): - with_the_pin = SchemaGenerator().get_schema(request=None, public=True) - assert with_the_pin["components"]["schemas"]["PatchedUserRetirementStatus"] == { - "type": "object", - } - assert "UserRetirementStatus" in with_the_pin["components"]["schemas"] + for document in (schema, service_wide_schema()): + components = document["components"]["schemas"] + assert components["PatchedUserRetirementStatus"] == PATCHED_RETIREMENT_STATUS_COMPONENT + # Nothing the kept component refers to is missing from the document + # that carries it, so no reference in it dangles. + assert "RetirementUser" in components + assert "RetirementState" in components #: The two addresses whose published name the body correction would otherwise diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_selectors.py b/openedx/core/djangoapps/user_api/v2/tests/test_selectors.py index e6402f1221b7..4f565b1ed0fa 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_selectors.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_selectors.py @@ -166,18 +166,27 @@ def test_an_account_with_no_retirement_behind_it_addresses_none(self): def test_a_retirement_is_found_by_the_name_it_was_requested_under(self): found = retirements_requested_as(["pipeline-subject", "nobody"]) assert set(found) == {"pipeline-subject"} - assert found["pipeline-subject"].id == self.retirement.id + assert [row.id for row in found["pipeline-subject"]] == [self.retirement.id] def test_a_name_spelled_differently_finds_no_retirement(self): """Stored names are compared without regard to case in some deployments, so this is exact.""" - assert retirements_requested_as(["Pipeline-Subject"]) == {} + assert set(retirements_requested_as(["Pipeline-Subject"])) == set() def test_both_retirements_of_a_pair_differing_only_in_case_are_found_separately(self): other = UserFactory.create(username="PIPELINE-SUBJECT", email="other@example.com") other_retirement = create_retirement_status(other) found = retirements_requested_as(["pipeline-subject", "PIPELINE-SUBJECT"]) - assert found["pipeline-subject"].id == self.retirement.id - assert found["PIPELINE-SUBJECT"].id == other_retirement.id + assert [row.id for row in found["pipeline-subject"]] == [self.retirement.id] + assert [row.id for row in found["PIPELINE-SUBJECT"]] == [other_retirement.id] + + def test_every_retirement_requested_under_one_name_is_found_under_it(self): + """The stored name is indexed but not unique, so a name may carry several records.""" + twin = create_retirement_status(UserFactory.create(username="pipeline-twin")) + UserRetirementStatus.objects.filter(id=twin.id).update(original_username="pipeline-subject") + found = retirements_requested_as(["pipeline-subject"]) + assert sorted(row.id for row in found["pipeline-subject"]) == sorted( + [self.retirement.id, twin.id], + ) def test_the_partner_report_queue_leaves_out_what_has_been_taken(self): waiting = self.queued(self.subject) @@ -198,11 +207,20 @@ def test_a_partner_report_is_found_by_the_name_it_was_registered_under(self): report = self.queued(self.subject) found = partner_reports_requested_as(["pipeline-subject", "nobody"]) assert set(found) == {"pipeline-subject"} - assert found["pipeline-subject"].id == report.id + assert [row.id for row in found["pipeline-subject"]] == [report.id] + + def test_every_partner_report_registered_under_one_name_is_found_under_it(self): + report = self.queued(self.subject) + twin = self.queued(UserFactory.create(username="pipeline-twin")) + UserRetirementPartnerReportingStatus.objects.filter(id=twin.id).update( + original_username="pipeline-subject", + ) + found = partner_reports_requested_as(["pipeline-subject"]) + assert sorted(row.id for row in found["pipeline-subject"]) == sorted([report.id, twin.id]) def test_a_partner_report_is_not_found_by_a_name_spelled_differently(self): self.queued(self.subject) - assert partner_reports_requested_as(["Pipeline-Subject"]) == {} + assert set(partner_reports_requested_as(["Pipeline-Subject"])) == set() def queued(self, user, taken=False): """Return a partner report entry for this account, taken for processing or not.""" @@ -253,7 +271,7 @@ def test_a_retirement_is_selected_by_the_spelling_asked_for(self): ): found = retirements_requested_as(["exact-subject"]) assert set(found) == {"exact-subject"} - assert found["exact-subject"].id == wanted.id + assert [row.id for row in found["exact-subject"]] == [wanted.id] def test_a_partner_report_is_selected_by_the_spelling_asked_for(self): wanted = UserRetirementPartnerReportingStatus.objects.create( @@ -274,4 +292,4 @@ def test_a_partner_report_is_selected_by_the_spelling_asked_for(self): ): found = partner_reports_requested_as(["exact-subject"]) assert set(found) == {"exact-subject"} - assert found["exact-subject"].id == wanted.id + assert [row.id for row in found["exact-subject"]] == [wanted.id] diff --git a/openedx/core/djangoapps/user_api/v2/view_services.py b/openedx/core/djangoapps/user_api/v2/view_services.py index c60889226091..1f8a5a371794 100644 --- a/openedx/core/djangoapps/user_api/v2/view_services.py +++ b/openedx/core/djangoapps/user_api/v2/view_services.py @@ -40,7 +40,7 @@ UserRetirementStatus, ) -from .errors import RetirementStateConflict +from .errors import AmbiguousAccountName, RetirementStateConflict from .selectors import partner_reports_requested_as, retirements_requested_as from .serializers import ( ERASURE_CORE_SCOPE, @@ -75,6 +75,38 @@ f"Only a retirement still at {PENDING_STATE} can be cancelled, and this one has moved past it." ) BATCH_LEFT_ALONE = "Some of the records named are not in a state this operation acts on, so none of them was changed." +NAME_IS_AMBIGUOUS = "More than one record was requested under that name, so nothing was done." +SEVERAL_RECORDS_HOLD_THIS_NAME = ( + "More than one record was requested under this name, and acting on one of them would act on an " + "account this request did not name." +) + + +def _only_record_named(username, records): + """ + Return the one record requested under this name, or refuse the name. + + Every operation of this pipeline that names one account resolves it here, + so one name addresses the same record whichever operation is asked for. + The name a record keeps is not unique, so a name several records were + requested under is refused: one of them belongs to an account the caller + did not name, and nothing says which. + """ + if not records: + log.info("No record is requested under the name %r.", username) + raise NotFound(NO_SUCH_RETIREMENT) + if len(records) > 1: + log.warning( + "The name %r was refused: %s records are requested under it, %r.", + username, len(records), sorted(record.id for record in records), + ) + raise AmbiguousAccountName(NAME_IS_AMBIGUOUS) + return records[0] + + +def retirement_requested_as(username): + """Return the one retirement requested under exactly this name.""" + return _only_record_named(username, retirements_requested_as([username]).get(username, [])) def retirement_open_to_action(username): @@ -85,14 +117,20 @@ def retirement_open_to_action(username): and the step that records a part of the pipeline as finished, which is what keeps a step from being run twice over the same account. """ + retirement = retirement_requested_as(username) try: - return UserRetirementStatus.get_retirement_for_retirement_action(username) + # Which steps admit work is decided by the pipeline's own model layer, + # where the order of the steps is kept, rather than being written out a + # second time here. It resolves the name as well, and the name resolved + # above addresses one record, so the two agree on which record that is. + UserRetirementStatus.get_retirement_for_retirement_action(username) except UserRetirementStatus.DoesNotExist as exc: log.info("No retirement is requested under the name %r: %s", username, exc) raise NotFound(NO_SUCH_RETIREMENT) from exc except RetirementStateError as exc: log.info("The retirement requested as %r admits no pipeline work: %s", username, exc) raise RetirementStateConflict(NOT_OPEN_TO_ACTION) from exc + return retirement def move_retirement(retirement, new_state, response, force): @@ -276,6 +314,25 @@ def _refuse_batch(refused): raise RetirementStateConflict(BATCH_LEFT_ALONE, errors=refused) +def _one_record_for_each_name(found): + """ + Return the one record each name carries, refusing the batch over any name carrying several. + + A batch is acted on whole or not at all, and a name several records were + requested under names an account the caller did not, so the batch is + refused and nothing is deleted. + """ + ambiguous = { + username: [SEVERAL_RECORDS_HOLD_THIS_NAME] + for username, records in found.items() + if len(records) > 1 + } + if ambiguous: + log.warning("A batch was left alone because these names are ambiguous: %r.", sorted(ambiguous)) + raise AmbiguousAccountName(NAME_IS_AMBIGUOUS, errors=ambiguous) + return {username: records[0] for username, records in found.items()} + + def delete_retirements(usernames): """ Delete the retirement records of the accounts named, once the pipeline has finished with them. @@ -287,7 +344,7 @@ def delete_retirements(usernames): nothing is deleted, because the caller asked for a set of records and a half-applied delete cannot be undone. """ - found = retirements_requested_as(usernames) + found = _one_record_for_each_name(retirements_requested_as(usernames)) refused = { username: [ f"This retirement stands at {record.current_state.state_name}; only a retirement at " @@ -321,7 +378,7 @@ def delete_partner_reports(usernames): all is reported as such, which is what a repeat of a request already carried out reports for every name. """ - found = partner_reports_requested_as(usernames) + found = _one_record_for_each_name(partner_reports_requested_as(usernames)) refused = { username: ["This partner report has not been taken for processing, so it has not been reported on yet."] for username, report in found.items() diff --git a/openedx/core/djangoapps/user_api/v2/views.py b/openedx/core/djangoapps/user_api/v2/views.py index ca9d013bcf9f..86a01f17df70 100644 --- a/openedx/core/djangoapps/user_api/v2/views.py +++ b/openedx/core/djangoapps/user_api/v2/views.py @@ -72,7 +72,6 @@ cancellable_retirement_for_email, retirement_of_the_account_named, retirements, - retirements_requested_as, unclaimed_partner_reports, username_holding_email, username_holding_id, @@ -142,6 +141,7 @@ move_retirement, record_partner_report, retirement_open_to_action, + retirement_requested_as, ) from .view_utils import ( RESP_UNAUTHENTICATED, @@ -2414,6 +2414,26 @@ def initial(self, request, *args, **kwargs): _RESP_NO_SUCH_RETIREMENT = OpenApiResponse( ErrorResponse, description="No retirement was requested under that name.", ) +#: What a refusal says at an address that finds the record it acts on by the +#: name the record was requested under. That name is not unique, so more than +#: one record may answer to it, and the request is refused rather than one of +#: them picked. +_RESP_AMBIGUOUS_OR_BAD_REQUEST = OpenApiResponse( + ErrorResponse, + description=( + "A query parameter is missing, invalid, or not one this endpoint reads, or more than one " + "record was requested under the name given. Nothing was changed." + ), +) +_RESP_AMBIGUOUS_OR_UNUSABLE_BODY = OpenApiResponse( + ErrorResponse, + description=( + "The request body is not well formed in the media type it was sent in, or carries a value " + "this endpoint will not take, or more than one record was requested under a name the " + "request gives. A query parameter this endpoint does not read is refused here too. " + "Nothing was changed." + ), +) _RESP_RETIREMENT_STATE = OpenApiResponse( ErrorResponse, description=( @@ -2508,7 +2528,7 @@ def initial(self, request, *args, **kwargs): request=RetirementUpdateSerializer, responses={ 204: _RESP_RETIREMENT_MOVED, - 400: _RESP_UNUSABLE_BODY, + 400: _RESP_AMBIGUOUS_OR_UNUSABLE_BODY, 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, 404: _RESP_NO_SUCH_RETIREMENT, @@ -2527,7 +2547,7 @@ def initial(self, request, *args, **kwargs): parameters=[_RETIREMENT_PATH_PARAM], responses={ 204: _RESP_RETIREMENT_CANCELLED, - 400: _RESP_BAD_REQUEST, + 400: _RESP_AMBIGUOUS_OR_BAD_REQUEST, 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, 404: _RESP_NO_SUCH_RETIREMENT, @@ -2545,7 +2565,7 @@ def initial(self, request, *args, **kwargs): request=RetirementErasureSerializer, responses={ 204: _RESP_DATA_ERASED, - 400: _RESP_UNUSABLE_BODY, + 400: _RESP_AMBIGUOUS_OR_UNUSABLE_BODY, 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, 404: _RESP_NO_SUCH_RETIREMENT, @@ -2591,6 +2611,10 @@ class RetirementViewSet( """ permission_classes = (permissions.IsAuthenticated,) + # The operations these addresses replace read a body as JSON alone, and + # one of them erases personal data for good, so the same narrowing is kept + # rather than widened to every media type the platform can parse. + parser_classes = (JSONParser,) serializer_class = RetirementSerializer pagination_class = UserApiPagination queryset = retirements() @@ -2615,6 +2639,12 @@ class RetirementViewSet( def get_permissions(self): """Return the classes deciding who may make the request being served.""" + if self.action is None: + # No action serves this method at this address. That is not a + # refusal of the caller, so the request is admitted by the classes + # every action shares and answered as a method this address does + # not serve, which is what the earlier addresses answer. + return [permission() for permission in self.permission_classes] # An action this mapping does not name reaches no capability of this # collection, rather than inheriting the one every other action shares. specific = RETIREMENT_ACTION_PERMISSIONS.get(self.action, (~permissions.AllowAny,)) @@ -2656,7 +2686,7 @@ def partial_update(self, request, username): changes = RetirementUpdateSerializer(data=request.data) changes.is_valid(raise_exception=True) move_retirement( - self._retirement_requested_as(username), + retirement_requested_as(username), changes.validated_data["new_state"], changes.validated_data["response"], changes.validated_data["force"], @@ -2665,7 +2695,7 @@ def partial_update(self, request, username): def destroy(self, request, username): """Undo this retirement request and return the account to its owner.""" - cancel_retirement(self._retirement_requested_as(username)) + cancel_retirement(retirement_requested_as(username)) return Response(status=status.HTTP_204_NO_CONTENT) @action(detail=True, methods=["post"], url_path="erasures", url_name="erasure") @@ -2676,13 +2706,6 @@ def erasures(self, request, username): erase_account_data(retirement_open_to_action(username), erasure.validated_data["scope"]) return Response(status=status.HTTP_204_NO_CONTENT) - def _retirement_requested_as(self, username): - """Return the retirement requested under exactly this name, or report none.""" - retirement = retirements_requested_as([username]).get(username) - if retirement is None: - log.info("No retirement is requested under the name %r.", username) - raise NotFound(NO_SUCH_RETIREMENT) - return retirement class _UnclaimedPartnerReportsMixin: @@ -2747,7 +2770,7 @@ def get_queryset(self): request=None, responses={ 204: _RESP_PARTNER_REPORT_QUEUED, - 400: _RESP_BAD_REQUEST, + 400: _RESP_AMBIGUOUS_OR_BAD_REQUEST, 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, 404: _RESP_NO_SUCH_RETIREMENT, @@ -2783,6 +2806,10 @@ class RetirementPartnerReportViewSet( """ permission_classes = (permissions.IsAuthenticated, CanRetireUser) + # The operations this address replaces read a body as JSON alone, and that + # narrowing is kept rather than widened to every media type the platform + # can parse. + parser_classes = (JSONParser,) serializer_class = RetirementPartnerReportRecordSerializer #: Validates the query string, and names the parameters this view reads. query_serializer_class = RetirementPartnerReportQuerySerializer @@ -2839,7 +2866,7 @@ def update(self, request, username): request=RetirementBatchSerializer, responses={ 200: _RESP_BATCH_OUTCOME, - 400: _RESP_UNUSABLE_BODY, + 400: _RESP_AMBIGUOUS_OR_UNUSABLE_BODY, 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, 409: _RESP_BATCH_STATE, @@ -2867,6 +2894,10 @@ class RetirementCleanupView( """ permission_classes = (permissions.IsAuthenticated, CanRetireUser) + # The operations this address replaces read a body as JSON alone, and that + # narrowing is kept rather than widened to every media type the platform + # can parse: these operations dispose of records for good. + parser_classes = (JSONParser,) serializer_class = RetirementBatchOutcomeSerializer def get_serializer(self, *args, **kwargs): @@ -2893,7 +2924,7 @@ def post(self, request): request=RetirementBatchSerializer, responses={ 200: _RESP_BATCH_OUTCOME, - 400: _RESP_UNUSABLE_BODY, + 400: _RESP_AMBIGUOUS_OR_UNUSABLE_BODY, 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, 409: _RESP_BATCH_STATE, @@ -2920,6 +2951,10 @@ class RetirementPartnerReportCleanupView( """ permission_classes = (permissions.IsAuthenticated, CanRetireUser) + # The operations this address replaces read a body as JSON alone, and that + # narrowing is kept rather than widened to every media type the platform + # can parse: these operations dispose of records for good. + parser_classes = (JSONParser,) serializer_class = RetirementBatchOutcomeSerializer def get_serializer(self, *args, **kwargs): From 0b01a804e0dfd3b40366e95f4296ab8d99d76add Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Tue, 27 Oct 2026 15:25:26 +0500 Subject: [PATCH 46/82] fix: publish what a caller must send, and read every step it names Six corrections to what the /api/user/v2/ retirement work publishes and reads, each measured against the document master serves and against the version 1 address it replaces. A body written out as a shape rather than built from a serializer was never judged to be needed, because drf-spectacular marks such a body optional by construction. Three superseded operations had therefore stopped publishing a body they cannot be called without. The shape is now asked whether it requires a member, so nine operations publish the mark their handlers enforce. The partner report queue is the one address whose published body is gone rather than restored: its handler reads no body at all. The move of a retirement published a change set while its handler reads the whole body, which made it looser than the address it replaces; it now publishes the two values it refuses to run without. A retirement whose account holds no profile answers null for it, which the component denied. The earlier bound of the queue filters leaves out the day it names, where the superseded read took that day in: the behaviour stays, the difference is now declared and measured on both versions. A step named by repeating the parameter was silently narrowed to the last value, where the superseded queue read every repetition. The erasure is the one operation that lets a failure reach its caller, so it is the one that publishes it. --- lms/lib/tests/test_spectacular.py | 27 ++++ openedx/core/djangoapps/user_api/schema.py | 60 +++++++- .../core/djangoapps/user_api/v2/filters.py | 28 +++- .../djangoapps/user_api/v2/serializers.py | 12 ++ .../v2/tests/test_retirement_parity.py | 127 +++++++++++++++ .../v2/tests/test_retirement_views.py | 116 +++++++++++++- .../user_api/v2/tests/test_schema.py | 145 +++++++++++++++++- openedx/core/djangoapps/user_api/v2/views.py | 9 ++ 8 files changed, 514 insertions(+), 10 deletions(-) diff --git a/lms/lib/tests/test_spectacular.py b/lms/lib/tests/test_spectacular.py index 3308b61f458c..d390f1a06d01 100644 --- a/lms/lib/tests/test_spectacular.py +++ b/lms/lib/tests/test_spectacular.py @@ -557,6 +557,33 @@ def test_a_body_one_of_the_published_forms_requires_is_published_as_required(sel "image/gif", "image/jpeg", "image/pjpeg", "image/png", "multipart/form-data", ] + def test_a_body_written_out_as_a_shape_is_needed_where_the_shape_says_it_is(self): + """ + Each form of a body is read for what it says it cannot be sent without. + + A shape written out is published as it stands, so nothing has judged + whether its operation can be called with no body at all: the mark is + read from the members the shape requires, or from the members of its + entries where the shape is a list of them. The addresses here belong + to several resources and are written in every form the table uses -- + an object, a list of objects, a submitted form, and a shape named + under every media type the platform reads a body in. + """ + for address, method in ( + ("/api/user/v1/accounts/retirement_cleanup/", "post"), + ("/api/user/v1/accounts/retirement_partner_report_cleanup/", "post"), + ("/api/user/v1/accounts/update_retirement_status/", "patch"), + ("/api/user/v1/preferences/email_opt_in/", "post"), + ("/api/user/v1/accounts/cancel_retirement/", "post"), + ): + body = self.schema["paths"][address][method]["requestBody"] + assert body["required"] is True, (address, method) + + # The changes to a preference document name no member the body cannot + # be sent without, so that one is not published as needed. + changes = self.schema["paths"]["/api/user/v1/preferences/{username}"]["patch"]["requestBody"] + assert changes.get("required", False) is False + 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 = [ diff --git a/openedx/core/djangoapps/user_api/schema.py b/openedx/core/djangoapps/user_api/schema.py index c3e40db8903e..69507b4c0911 100644 --- a/openedx/core/djangoapps/user_api/schema.py +++ b/openedx/core/djangoapps/user_api/schema.py @@ -5,6 +5,7 @@ from functools import cache from importlib import import_module +from drf_spectacular.plumbing import force_instance from drf_spectacular.utils import OpenApiResponse from rest_framework.settings import api_settings @@ -139,6 +140,12 @@ ("/api/user/v2/accounts/{}/preferences/", "PATCH"), }) +# A serializer carries this attribute, set to true, where the body it describes +# is read whole: every field it requires is needed however the body is sent, +# and a method whose bodies are read as a set of changes to a stored +# representation does not make them optional. +WHOLE_BODY_ATTRIBUTE = "reads_the_whole_body" + _PATH_PARAMETER = re.compile(r"\{([\w-]+)\}") # The superseded addresses with their parameter names removed. The generator @@ -425,9 +432,29 @@ def in_every_form(shape): } +def _names_what_it_needs(shape): + """ + Say whether a body written out as a shape names something it cannot be sent without. + + A shape is published as it is written rather than built from a + serializer's fields, so nothing has judged whether the operation can be + called with no body at all. One that requires a member, or that is a list + whose members require one, cannot be satisfied by an absent body. + """ + if not isinstance(shape, dict): + return False + if shape.get("required"): + return True + return _names_what_it_needs(shape.get("items")) + + class SupersededUserOperations: """ - Describe the superseded user addresses wherever a service publishes them. + Keep colliding addresses distinct, group each operation under its own area, + and describe the superseded user addresses wherever a service publishes + them, marking a body the operation cannot be called without as one it + needs and publishing a body read whole as the whole shape the operation + reads. Both services mount that API at the same addresses and both publish an unauthenticated document of everything they serve, so what one of those @@ -455,9 +482,11 @@ def _get_request_body(self, direction="request"): 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. + that can be called required, or as soon as one of them is named as a + shape rather than by a serializer, which is published as it is written + and so is read for what it says it needs. 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. An operation whose body is described in place carries no such field either, in any of its forms, so the ones whose handler refuses a @@ -472,10 +501,31 @@ def _get_request_body(self, direction="request"): shapes = self.get_request_serializer() if 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()): + if any(self._shape_is_needed(shape, direction) for shape in shapes.values()): body["required"] = True return body + def _shape_is_needed(self, shape, direction): + """Say whether a body sent in one media type is one the operation cannot be called without.""" + return _names_what_it_needs(shape) or self._get_request_for_media_type(shape, direction)[1] + + def _get_request_for_media_type(self, serializer, direction="request"): + """ + Keep a body the operation reads whole whole, under a method read as a change set. + + A ``PATCH`` body usually carries the changes to make to a stored + representation, which leaves every field in it optional and publishes + the shape under a name of its own. A body that is an instruction + instead -- read whole by the operation, which refuses it when a field + the instruction cannot be carried out without is missing -- is not + that, and publishing it as a change set would promise a caller a + request the operation refuses. + """ + if self.method == "PATCH" and getattr(force_instance(serializer), WHOLE_BODY_ATTRIBUTE, False): + with patched_settings({"COMPONENT_SPLIT_PATCH": False}): + return super()._get_request_for_media_type(serializer, direction) + return super()._get_request_for_media_type(serializer, direction) + def get_request_serializer(self): """Return the body this operation reads, named here for the addresses this one supersedes.""" bodies = self._superseded_bodies() diff --git a/openedx/core/djangoapps/user_api/v2/filters.py b/openedx/core/djangoapps/user_api/v2/filters.py index 9c32dcdccd1b..ec905bad726e 100644 --- a/openedx/core/djangoapps/user_api/v2/filters.py +++ b/openedx/core/djangoapps/user_api/v2/filters.py @@ -58,6 +58,25 @@ def remove_invalid_fields(self, queryset, fields, view, request): return allowed +class _RepeatedValuesWidget(forms.TextInput): + """ + Reads every value sent under one parameter name, not only the last of them. + + A query string names several values either as one comma-separated list or + by repeating the parameter, and both idioms are in use by the callers of + this area. The default widget keeps the last value sent and drops the + rest, so a caller naming several by repetition is answered with the rows + of one of them and told nothing about the others. + """ + + def value_from_datadict(self, data, files, name): + """Return every value sent under this name, as the list the filter reads.""" + if not hasattr(data, "getlist"): + return super().value_from_datadict(data, files, name) + sent = data.getlist(name) + return ",".join(sent) if sent else None + + class _BooleanQueryField(forms.NullBooleanField): """Reads a boolean out of the query string, refusing a value that names neither side.""" @@ -215,13 +234,18 @@ class RetirementFilterSet(filters.FilterSet): configured in and a caller computing the equivalent timestamp would have to agree with this server's clock to the second. The two date bounds say which end they include, which the pair they replace left to the reader. + ``state`` takes several steps sent either as one comma-separated list or + as the parameter repeated, because the list that named them before this + one read them as repeated parameters. """ state = filters.CharFilter( method="filter_states", + widget=_RepeatedValuesWidget, help_text=( - "Comma-separated names of pipeline steps. Only retirements standing at one of them " - "are returned, and a name no configured step holds is refused." + "Names of pipeline steps, as one comma-separated list or by repeating the parameter " + "once per step. Only retirements standing at one of them are returned, and a name no " + "configured step holds is refused." ), ) cool_off_days = _WholeNumberFilter( diff --git a/openedx/core/djangoapps/user_api/v2/serializers.py b/openedx/core/djangoapps/user_api/v2/serializers.py index b7a73259ed0c..6f11c87bcf4f 100644 --- a/openedx/core/djangoapps/user_api/v2/serializers.py +++ b/openedx/core/djangoapps/user_api/v2/serializers.py @@ -1160,6 +1160,10 @@ class RetirementAccountSerializer(serializers.Serializer): # pylint: disable=ab ) profile = RetirementAccountProfileSerializer( read_only=True, + # An account need not hold a profile, and one being retired may never + # have held one, so the property is answered as null rather than left + # out: a caller reading it finds it under the same name either way. + allow_null=True, help_text="The profile of the account, or null for an account that has none.", ) @@ -1234,8 +1238,16 @@ class RetirementUpdateSerializer(serializers.Serializer): # pylint: disable=abs Which steps exist and in what order is configured by the operator, so ``new_state`` is checked against the configured steps rather than against a fixed list. + + The body is an instruction rather than a set of changes to the record it + addresses: the step to move to and what happened at the step being left + are both needed to carry the move out and to keep the retirement's log + whole, so it is read whole and a body missing either is refused. """ + #: Read whole, so what this requires is what is published as required. + reads_the_whole_body = True + new_state = serializers.CharField(help_text="Name of the pipeline step to move this retirement to.") response = serializers.CharField( allow_blank=True, diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_parity.py b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_parity.py index 2a2c09b8157c..001831c79b1e 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_parity.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_parity.py @@ -1,8 +1,10 @@ """Behaviour parity between the version 1 and version 2 retirement pipeline addresses.""" import contextlib +import datetime import json from unittest import mock +from zoneinfo import ZoneInfo import pytest from django.core.cache import cache @@ -100,6 +102,19 @@ ("last_state", "the step is the name of the step rather than an object carrying its order and its id"), ) +#: The declared differences between the filters of the two version 1 queue +#: reads and the version 2 list that answers for both. Each entry is a +#: (parameter, reason) pair, and the tests below measure every one of them on +#: both versions, so a parameter that silently selects other rows than the one +#: it is read in place of is either here or failing. +QUEUE_FILTER_DIFFERENCES = ( + ( + "created_before", + "the bound is exclusive, where the end_date it is read in place of took in the whole of " + "the day named; the same rows are asked for by naming the day after", + ), +) + #: Each view of this version with the view whose operations it takes over, for #: the media types a body may be sent in. The disposals are part of the #: earlier version's two pipeline views and are compared against those. @@ -545,6 +560,118 @@ def test_neither_version_carries_the_log_of_the_moves_a_retirement_has_made(self assert UserRetirementStatus.objects.get(id=self.retirement.id).responses != "" +@skip_unless_lms +class QueueFilterParityTests(RetirementParityTestCase): + """ + The filters of the merged list against the two version 1 reads they answer for. + + The callers of these addresses are drivers outside this repository, which + reach the merged list by renaming the parameters they already send. A + parameter that selects other rows than the one it is renamed from is + answered 200 and so cannot be noticed by such a caller, which is why each + is measured on both versions here rather than reasoned about. + """ + + #: A day with retirements requested before it, during it, and after it, so + #: that a bound naming it is measured from both sides. + BOUND = datetime.datetime(2020, 6, 10, tzinfo=ZoneInfo("UTC")) + + def setUp(self): + super().setUp() + self.moments = { + "the-day-before": self.BOUND - datetime.timedelta(days=1), + "the-day-itself": self.BOUND + datetime.timedelta(hours=12), + "the-day-after": self.BOUND + datetime.timedelta(days=1, hours=1), + } + self.requested = { + name: create_retirement_status( + UserFactory.create(username=name), + state=RetirementState.objects.get(state_name="COMPLETE"), + create_datetime=moment, + ) + for name, moment in self.moments.items() + } + + def legacy_by_date(self, start, end): + """Return the names the version 1 by-date read answers for this range.""" + response = self.client.get( + reverse("accounts_retirements_by_status_and_date"), + {"start_date": start, "end_date": end, "state": "COMPLETE"}, + ) + assert response.status_code == status.HTTP_200_OK + return [row["original_username"] for row in response.json()] + + def conforming_by_date(self, after, before): + """Return the names the merged list answers for this range.""" + response = self.client.get( + reverse("user_v2:retirement_list"), + {"state": "COMPLETE", "created_after": after, "created_before": before, "page_size": 100}, + ) + assert response.status_code == status.HTTP_200_OK + return [row["original_username"] for row in response.json()["results"]] + + def test_the_declared_difference_is_the_day_the_earlier_bound_names(self): + """Measured both ways: the day named is in the version 1 answer and out of this one.""" + assert self.legacy_by_date("2020-06-01", "2020-06-10") == ["the-day-before", "the-day-itself"] + assert self.conforming_by_date("2020-06-01", "2020-06-10") == ["the-day-before"] + assert [name for name, _reason in QUEUE_FILTER_DIFFERENCES] == ["created_before"] + + def test_the_rows_the_declared_difference_leaves_out_are_asked_for_by_naming_the_day_after(self): + assert self.conforming_by_date("2020-06-01", "2020-06-11") == [ + "the-day-before", "the-day-itself", + ] + assert self.legacy_by_date("2020-06-01", "2020-06-10") == self.conforming_by_date( + "2020-06-01", "2020-06-11", + ) + + def test_the_later_bound_takes_in_the_day_it_names_on_both_versions(self): + """``start_date`` and ``created_after`` are the one pair that means the same thing.""" + assert self.legacy_by_date("2020-06-10", "2020-06-10") == ["the-day-itself"] + assert self.conforming_by_date("2020-06-10", "2020-06-11") == ["the-day-itself"] + # One day asked for on the earlier version is that day named at both + # ends; on this one it is that day and the day after it. + assert self.legacy_by_date("2020-06-09", "2020-06-09") == ["the-day-before"] + assert self.conforming_by_date("2020-06-09", "2020-06-10") == ["the-day-before"] + + def test_steps_named_by_repeating_the_parameter_answer_the_same_rows_on_both_versions(self): + """The version 1 queue read its steps as repeated parameters, and this list reads them too.""" + create_retirement_status( + UserFactory.create(username="still-pending"), + state=RetirementState.objects.get(state_name="PENDING"), + create_datetime=self.BOUND, + ) + legacy = self.client.get( + reverse("accounts_retirement_queue") + + "?cool_off_days=0&states=COMPLETE&states=PENDING", + ) + assert legacy.status_code == status.HTTP_200_OK + conforming = self.client.get( + reverse("user_v2:retirement_list") + + "?cool_off_days=0&state=COMPLETE&state=PENDING&page_size=100", + ) + assert conforming.status_code == status.HTTP_200_OK + answered = [row["original_username"] for row in legacy.json()] + assert answered == [ + "the-day-before", "the-day-itself", "the-day-after", "still-pending", + ] + assert [row["original_username"] for row in conforming.json()["results"]] == answered + + def test_one_step_named_once_answers_the_same_rows_on_both_versions(self): + """The comma-separated idiom of this list is not the only one it reads.""" + legacy = self.client.get( + reverse("accounts_retirement_queue") + "?cool_off_days=0&states=COMPLETE", + ) + conforming = self.client.get( + reverse("user_v2:retirement_list") + "?cool_off_days=0&state=COMPLETE&page_size=100", + ) + assert [row["original_username"] for row in legacy.json()] == [ + "the-day-before", "the-day-itself", "the-day-after", + ] + assert [row["original_username"] for row in conforming.json()["results"]] == [ + row["original_username"] for row in legacy.json() + ] + + class MediaTypeParityTests(SimpleTestCase): """Each operation reads a body in the media types the operation it replaces reads.""" diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py index 162a31b6f240..50b22f7cde8e 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py @@ -24,7 +24,13 @@ monitoring_support_process_request, monitoring_support_process_response, ) -from edx_rest_framework_extensions.errors import Conflict, classify_error +from edx_rest_framework_extensions.errors import ( + INTERNAL_ERROR_SLUG, + INTERNAL_ERROR_TITLE, + Conflict, + classify_error, + error_type_uri, +) from edx_rest_framework_extensions.testing import assert_error_envelope from opaque_keys.edx.keys import CourseKey from rest_framework import permissions, status @@ -100,6 +106,10 @@ WRITE_QUERY_PARAMS = "format" CLAIM_QUERY_PARAMS = "format, limit" +#: What a move of a retirement cannot be asked for without, which is what the +#: published body of the request says it requires. +MOVE_REQUIRED_VALUES = ["new_state", "response"] + #: What the shortest and the longest page of any collection of this version holds. DEFAULT_PAGE_SIZE = 10 MAXIMUM_PAGE_SIZE = 100 @@ -394,6 +404,33 @@ def test_several_steps_can_be_asked_for_at_once(self): self.complete.original_username, ] + def test_several_steps_can_be_asked_for_by_repeating_the_parameter(self): + """The list these rows were read from before named its steps that way.""" + self.login("worker") + response = self.client.get(f"{self.url}?state=PENDING&state=COMPLETE") + assert self.names_in(response) == [ + self.pending.original_username, + self.complete.original_username, + ] + + def test_a_list_and_a_repetition_name_their_steps_together(self): + self.login("worker") + response = self.client.get(f"{self.url}?state=PENDING,COMPLETE&state=LOCKING_ACCOUNT") + assert self.names_in(response) == [ + self.pending.original_username, + self.locking.original_username, + self.complete.original_username, + ] + + def test_naming_no_step_at_all_leaves_every_retirement_in(self): + self.login("worker") + response = self.client.get(f"{self.url}?state=") + assert self.names_in(response) == [ + self.pending.original_username, + self.locking.original_username, + self.complete.original_username, + ] + def test_a_step_this_deployment_does_not_run_is_refused(self): self.login("worker") response = self.client.get(self.url, {"state": "NOPE"}) @@ -402,6 +439,15 @@ def test_a_step_this_deployment_does_not_run_is_refused(self): ) assert "No such pipeline step: NOPE." in envelope["errors"]["state"][0] + def test_a_step_this_deployment_does_not_run_is_refused_among_repeated_ones(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.get(f"{self.url}?state=PENDING&state=NOPE"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "No such pipeline step: NOPE." in envelope["errors"]["state"][0] + def test_retirements_requested_recently_can_be_left_out(self): recent = self.retirement_in( "PENDING", created=datetime.datetime.now(ZoneInfo("UTC")) - datetime.timedelta(days=1), @@ -456,6 +502,37 @@ def test_the_earlier_bound_leaves_out_the_moment_it_names(self): ) assert self.names_in(response) == [self.pending.original_username] + def test_a_day_as_the_earlier_bound_leaves_that_whole_day_out(self): + """ + A date alone is the first moment of that day, which the bound excludes. + + The retirements requested during the day named are therefore not in the + page, which is the one reading of the bound that holds whether the + value names a day or a moment within one. + """ + midday = self.retirement_in( + "PENDING", created=self.locking.created + datetime.timedelta(hours=12), + ) + self.login("worker") + response = self.client.get( + self.url, + {"created_before": self.locking.created.date().isoformat(), "page_size": 100}, + ) + assert self.names_in(response) == [self.pending.original_username] + assert midday.original_username not in self.names_in(response) + later = self.client.get( + self.url, + { + "created_before": (self.locking.created + datetime.timedelta(days=1)).date().isoformat(), + "page_size": 100, + }, + ) + assert self.names_in(later) == [ + self.pending.original_username, + self.locking.original_username, + midday.original_username, + ] + def test_a_bound_that_is_not_a_moment_is_refused(self): self.login("worker") envelope = assert_error_envelope( @@ -600,6 +677,21 @@ def test_the_record_comes_back_whole(self): assert response.data["original_email"] == "subject@example.com" assert response.data["current_state"] == "LOCKING_ACCOUNT" + def test_an_account_holding_no_profile_is_answered_with_a_null_profile(self): + """The property is answered under its own name either way, as null where there is none.""" + UserProfile.objects.filter(user=self.subject).delete() + self.login("worker") + response = self.client.get(self.url) + assert response.status_code == status.HTTP_200_OK + assert response.data["user"] == { + "id": self.subject.id, + "username": "subject", + "email": "subject@example.com", + "profile": None, + } + page = self.client.get(reverse("user_v2:retirement_list")) + assert [row["user"]["profile"] for row in page.data["results"]] == [None] + def test_the_hashed_name_a_retired_account_is_moved_to_addresses_the_same_record(self): hashed = get_retired_username_by_username("subject") self.subject.username = hashed @@ -781,6 +873,18 @@ def test_a_body_that_says_nothing_about_the_step_being_left_is_refused(self): ) assert "response" in envelope["errors"] + def test_an_empty_body_is_refused_for_both_of_the_values_a_move_needs(self): + """The two it names here are the two the published body says it cannot be sent without.""" + self.login("worker") + envelope = assert_error_envelope( + self.patch_subject({}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert sorted(envelope["errors"]) == MOVE_REQUIRED_VALUES + self.retirement.refresh_from_db() + assert self.retirement.current_state.state_name == "LOCKING_ACCOUNT" + def test_a_name_spelled_differently_from_the_registered_one_is_not_found(self): self.login("worker") assert_error_envelope( @@ -1034,7 +1138,15 @@ def test_a_step_that_fails_partway_through_says_nothing_about_why(self): response = self.client.post(self.url, {"scope": "lms_core"}, format="json") assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR body = response.json() - assert body["detail"] == "An unexpected error occurred. Please try again later." + # The answer carries the same envelope as every other refusal of this + # address, which is what the document publishes for it. + assert body == { + "type": error_type_uri(INTERNAL_ERROR_SLUG), + "title": INTERNAL_ERROR_TITLE, + "status": status.HTTP_500_INTERNAL_SERVER_ERROR, + "detail": "An unexpected error occurred. Please try again later.", + "instance": self.url, + } assert "fire" not in str(body) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py index 5683e35d13b8..c94eb652b2af 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py @@ -156,6 +156,17 @@ VERSION_TWO_PATHS = LIST_PATHS | MEMBER_PATHS | ACTION_PATHS +#: The addresses the retirement pipeline is served at. +RETIREMENT_PATHS = { + RETIREMENT_LIST_PATH, + RETIREMENT_MEMBER_PATH, + RETIREMENT_ERASURE_PATH, + RETIREMENT_CLEANUP_PATH, + PARTNER_REPORT_LIST_PATH, + PARTNER_REPORT_MEMBER_PATH, + PARTNER_REPORT_CLEANUP_PATH, +} + PAGINATION_MEMBERS = {"count", "num_pages", "current_page", "start", "next", "previous", "results"} #: Where this version is mounted in the service's URL configuration. @@ -184,7 +195,6 @@ "PatchedAccountUpdate", "OutcomeEnum", "PaginatedRetirementResponseList", - "PatchedRetirementUpdate", "Retirement", "RetirementAccount", "RetirementAccountProfile", @@ -196,6 +206,7 @@ "RetirementPartnerReportRecord", "RetirementProjection", "RetirementResponse", + "RetirementUpdate", "ScopeEnum", "PaginatedTimeZoneList", "PaginatedUserDirectoryResponseList", @@ -2546,6 +2557,138 @@ def test_a_retirement_record_publishes_the_step_as_a_name(schema): assert "responses" not in record["properties"] +#: What each operation of the retirement pipeline answers, by the address and +#: the method. The erasure is the one operation of this area that lets a +#: failure of the work it drives reach the caller, because a step that fails +#: partway through has already erased part of what it names and the caller +#: decides what to do about that, so it is the one that publishes a 500. +RETIREMENT_PUBLISHED_ANSWERS = { + (RETIREMENT_LIST_PATH, "get"): ["200", "400", "401", "403", "404"], + (RETIREMENT_MEMBER_PATH, "get"): ["200", "400", "401", "403", "404"], + (RETIREMENT_MEMBER_PATH, "patch"): ["204", "400", "401", "403", "404", "409", "415"], + (RETIREMENT_MEMBER_PATH, "delete"): ["204", "400", "401", "403", "404", "409"], + (RETIREMENT_ERASURE_PATH, "post"): ["204", "400", "401", "403", "404", "409", "415", "500"], + (RETIREMENT_CLEANUP_PATH, "post"): ["200", "400", "401", "403", "409", "415"], + (PARTNER_REPORT_LIST_PATH, "post"): ["200", "400", "401", "403"], + (PARTNER_REPORT_MEMBER_PATH, "put"): ["204", "400", "401", "403", "404", "409"], + (PARTNER_REPORT_CLEANUP_PATH, "post"): ["200", "400", "401", "403", "409", "415"], +} + + +@skip_unless_lms +def test_each_retirement_operation_publishes_the_answers_it_can_give(schema): + published = { + (path, method): sorted(operation["responses"]) + for path, method, operation in operations(schema) + if path in RETIREMENT_PATHS + } + assert published == RETIREMENT_PUBLISHED_ANSWERS + + +@skip_unless_lms +def test_the_move_of_a_retirement_publishes_the_whole_body_it_reads(schema): + """ + The move is an instruction read whole, so what it requires is published as required. + + A body carrying changes to a stored record leaves every field optional and + is published under a name of its own. This one is refused when either the + step to move to or what happened at the step being left is missing, so a + caller generating a request from the document is told both are needed, and + the account update beside it -- which really does carry changes -- keeps + the optional form. + """ + body = schema["paths"][RETIREMENT_MEMBER_PATH]["patch"]["requestBody"] + assert body["required"] is True + assert body["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/RetirementUpdate", + } + component = schema["components"]["schemas"]["RetirementUpdate"] + assert component["required"] == ["new_state", "response"] + assert sorted(component["properties"]) == ["force", "new_state", "response"] + assert "PatchedRetirementUpdate" not in schema["components"]["schemas"] + + changes = schema["paths"][ACCOUNT_MEMBER_PATH]["patch"]["requestBody"] + assert changes.get("required", False) is False + assert "required" not in schema["components"]["schemas"]["PatchedAccountUpdate"] + + +@skip_unless_lms +def test_a_retirement_publishes_the_profile_it_answers_as_null_where_there_is_none(schema): + """An account being retired need not hold a profile, and the property is answered either way.""" + account = schema["components"]["schemas"]["RetirementAccount"] + assert account["properties"]["profile"] == { + "allOf": [{"$ref": "#/components/schemas/RetirementAccountProfile"}], + "description": "The profile of the account, or null for an account that has none.", + "readOnly": True, + "nullable": True, + } + assert "profile" in account["required"] + # The profile itself carries no property that may be absent, so nothing + # below it is published as nullable. + profile = schema["components"]["schemas"]["RetirementAccountProfile"] + assert [name for name, published in profile["properties"].items() if published.get("nullable")] == [] + + +#: What the body of a superseded operation requires, for the operations whose +#: body is written out as a shape rather than named by a serializer. The +#: entries are addresses of several resources, because the rule is read from +#: the shape and not from the address it is published at. +SUPERSEDED_NEEDED_BODIES = { + ("/api/user/v1/accounts/retirement_cleanup/", "post"): ["usernames"], + ("/api/user/v1/accounts/retirement_partner_report/", "put"): ["username"], + ("/api/user/v1/accounts/retirement_partner_report_cleanup/", "post"): ["original_username"], + ("/api/user/v1/accounts/retire/", "post"): ["username"], + ("/api/user/v1/accounts/retire_misc/", "post"): ["username"], + ("/api/user/v1/accounts/update_retirement_status/", "patch"): ["username", "new_state", "response"], +} + + +def members_needed(shape): + """Return the members a body shape cannot be sent without, through a list to its members.""" + if "items" in shape: + return members_needed(shape["items"]) + return shape["required"] + + +@skip_unless_lms +def test_a_superseded_operation_publishes_a_body_it_cannot_be_called_without(schema): + """ + A body written out as a shape says what it needs, and the operation needs it. + + An operation whose body is named by a serializer is marked as needing one + by the fields that serializer requires. A body written out as a shape is + published as it stands, so nothing has judged whether the operation can be + called with no body at all: it is read here from what the shape itself + says it cannot be sent without. Both documents are checked, because the + one the service serves for browsing its whole surface has published these + operations for years and a client generated from it stops sending a body + the moment the document stops asking for one. + """ + for document in (schema, service_wide_schema()): + for (path, method), needed in SUPERSEDED_NEEDED_BODIES.items(): + body = document["paths"][path][method]["requestBody"] + assert body["required"] is True, (path, method) + assert members_needed( + body["content"]["application/json"]["schema"], + ) == needed, (path, method) + + +@skip_unless_lms +def test_the_claim_of_the_partner_report_queue_publishes_no_body_on_either_version(schema): + """Neither reads one: the queue it drains is named by nothing the caller sends.""" + for document in (schema, service_wide_schema()): + for path in ("/api/user/v1/accounts/retirement_partner_report/", PARTNER_REPORT_LIST_PATH): + assert "requestBody" not in document["paths"][path]["post"], path + + +@skip_unless_lms +def test_a_body_whose_shape_requires_nothing_is_not_published_as_one_that_is_needed(schema): + """The preference changes are an object of any names at all, so an empty one is a body.""" + body = service_wide_schema()["paths"]["/api/user/v1/preferences/{username}"]["patch"]["requestBody"] + assert body.get("required", False) is False + assert "required" not in body["content"]["application/merge-patch+json"]["schema"] + + #: The retirement-status model as it was published while the corrected request #: body was its only reference. It is written out here rather than read from #: the configuration, so that a pin which no longer describes what a client diff --git a/openedx/core/djangoapps/user_api/v2/views.py b/openedx/core/djangoapps/user_api/v2/views.py index 86a01f17df70..2d23c449bfec 100644 --- a/openedx/core/djangoapps/user_api/v2/views.py +++ b/openedx/core/djangoapps/user_api/v2/views.py @@ -2453,6 +2453,14 @@ def initial(self, request, *args, **kwargs): description="The retirement request is gone and the account it was made for is usable again.", ) _RESP_DATA_ERASED = OpenApiResponse(description="The data named by the scope has been erased.") +_RESP_ERASURE_FAILED = OpenApiResponse( + ErrorResponse, + description=( + "A step of the erasure did not finish. Part of the data named by the scope may already " + "be gone, and the request may be sent again: each step clears what it clears whether or " + "not it has run before." + ), +) _RESP_PARTNER_REPORT_QUEUED = OpenApiResponse( description=( "The account is queued for reporting to the organizations it studied with, or was not " @@ -2571,6 +2579,7 @@ def initial(self, request, *args, **kwargs): 404: _RESP_NO_SUCH_RETIREMENT, 409: _RESP_RETIREMENT_STATE, 415: RESP_UNREADABLE_BODY, + 500: _RESP_ERASURE_FAILED, }, ), ) From d1e47814c58f9c1a184dcb982d9596639249c565 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Tue, 27 Oct 2026 16:29:58 +0500 Subject: [PATCH 47/82] test: pin the claims of /api/user/v2/ that no test could fail Six of this work's most confident claims rested on a test that stayed green when the thing it named was deleted. Each is now falsifiable, and every pin is proved by a mutation that turns it red. The queryset seam, the stable tie-break on each page, and the step a retirement was last moved out of were all unguarded: removing the seam and both of its policies, emptying the tie-break, or pointing the last step at the current one left the suite green. The seam reaches the list of retirements and the drain of the partner-report queue, which is what the new tests assert and no more. The last step went unnoticed because every fixture in the area had been moved no further than the step it was created at; the fixtures and the parity harness now use a record that has been moved. A set of choices is published as a model named after the field carrying it, and only while one field of the service carries that name alone: a second field of the same name renames both, so the plain name vanishes from the document and a client generated from it loses that class. Both names are now fixed in the settings, matched to their choices by value and description, with the choices lifted beside the fields so the two cannot drift. Neither document changes: the names are held at what they already derived to. Two of the six were already closed by an earlier pass and are recorded as measured rather than fixed. --- lms/envs/common.py | 10 + .../djangoapps/user_api/v2/serializers.py | 27 +- .../v2/tests/test_retirement_parity.py | 15 +- .../v2/tests/test_retirement_views.py | 273 ++++++++++++++++++ .../user_api/v2/tests/test_schema.py | 60 +++- 5 files changed, 375 insertions(+), 10 deletions(-) diff --git a/lms/envs/common.py b/lms/envs/common.py index 3e0ec8ed7d33..aed15d39ef0f 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2222,6 +2222,16 @@ 'APPEND_COMPONENTS': { 'schemas': {'PatchedUserRetirementStatus': PATCHED_USER_RETIREMENT_STATUS}, }, + # A set of choices is published as its own model, named after the field + # carrying it while one field of the service carries that name alone. The + # moment a second field of the same name carries a different set, both are + # renamed after the models they sit in, so the plain name disappears and a + # client generated from the document loses that class. The names below are + # fixed here so that the sets keep them whatever else the service grows. + 'ENUM_NAME_OVERRIDES': { + 'ScopeEnum': 'openedx.core.djangoapps.user_api.v2.serializers.ERASURE_SCOPE_CHOICES', + 'OutcomeEnum': 'openedx.core.djangoapps.user_api.v2.serializers.BATCH_OUTCOME_CHOICES', + }, # Stripped from each path to derive its tags and operationId, so changing # it renames every operationId. Paths themselves are emitted in full. 'SCHEMA_PATH_PREFIX': r'/api/(enrollment|user)', diff --git a/openedx/core/djangoapps/user_api/v2/serializers.py b/openedx/core/djangoapps/user_api/v2/serializers.py index 6f11c87bcf4f..82ff5389080b 100644 --- a/openedx/core/djangoapps/user_api/v2/serializers.py +++ b/openedx/core/djangoapps/user_api/v2/serializers.py @@ -1111,11 +1111,28 @@ def validate_course_key(self, value): ERASURE_MISC_SCOPE = "lms_misc" ERASURE_SCOPES = (ERASURE_CORE_SCOPE, ERASURE_MISC_SCOPE) +#: Each erasure scope with what it clears, as the request body offers them. +#: The pair is declared here rather than inside the field so that the name the +#: published set of choices is kept under can be configured from the same +#: values: a set of choices is matched to its configured name by its values +#: and their descriptions together, and a configured name that does not match +#: has no effect. +ERASURE_SCOPE_CHOICES = ( + (ERASURE_CORE_SCOPE, "Profile, images, certificates, entitlements and the account itself."), + (ERASURE_MISC_SCOPE, "Wiki revisions, enrollment audits, credit and API access records."), +) + #: What a batch operation reports about each name it was given. OUTCOME_DELETED = "deleted" OUTCOME_ALREADY_ABSENT = "already_absent" BATCH_OUTCOMES = (OUTCOME_DELETED, OUTCOME_ALREADY_ABSENT) +#: Each outcome with what it means, offered and named as the erasure scopes are. +BATCH_OUTCOME_CHOICES = ( + (OUTCOME_DELETED, "The record was deleted by this request."), + (OUTCOME_ALREADY_ABSENT, "There was no such record, so this request left nothing to do."), +) + #: The largest number of names one batch may carry, which is the number of #: rows a page of any collection of this version holds. BATCH_MAX_NAMES = UserApiPagination.max_page_size @@ -1279,10 +1296,7 @@ class RetirementErasureSerializer(serializers.Serializer): # pylint: disable=ab """ scope = serializers.ChoiceField( - choices=[ - (ERASURE_CORE_SCOPE, "Profile, images, certificates, entitlements and the account itself."), - (ERASURE_MISC_SCOPE, "Wiki revisions, enrollment audits, credit and API access records."), - ], + choices=ERASURE_SCOPE_CHOICES, help_text=( f"`{ERASURE_CORE_SCOPE}` erases the account's own data: its profile, its images, the " "name on its certificates and entitlement notes, and the username and address on the " @@ -1321,10 +1335,7 @@ class RetirementBatchResultSerializer(serializers.Serializer): # pylint: disabl username = serializers.CharField(help_text="The name this result is about, as it was given.") outcome = serializers.ChoiceField( - choices=[ - (OUTCOME_DELETED, "The record was deleted by this request."), - (OUTCOME_ALREADY_ABSENT, "There was no such record, so this request left nothing to do."), - ], + choices=BATCH_OUTCOME_CHOICES, help_text=( f"`{OUTCOME_DELETED}` where this request deleted the record, and " f"`{OUTCOME_ALREADY_ABSENT}` where there was none to delete, which is what a repeat " diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_parity.py b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_parity.py index 001831c79b1e..5d4118368591 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_parity.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_parity.py @@ -62,6 +62,11 @@ #: The step of the pipeline at which the erasures are run. WORKING_STATE = "RETIRING_LMS" +#: The step a record is moved on to, so that the step it stands at and the +#: step it came from are different steps and a record naming one of them +#: twice is not read as naming both. +MOVED_TO_STATE = "LMS_COMPLETE" + #: Each step of the erasure of the account's own data, named by what it clears #: and by where both versions reach it. ``None`` as the holder means each #: version calls the step through its own module, so both are recorded. @@ -527,6 +532,14 @@ def setUp(self): self.retirement = self.retirement_for( UserFactory.create(username="parity-subject", email="parity-subject@example.com"), ) + # Moved on once, so the step the record stands at and the step it came + # from are different steps. + self.retirement.update_state({ + "username": self.retirement.original_username, + "new_state": MOVED_TO_STATE, + "response": "moved on", + }) + assert self.retirement.current_state.state_name != self.retirement.last_state.state_name def test_the_record_carries_the_same_values_bar_the_declared_differences(self): legacy = UserRetirementStatusSerializer(instance=self.retirement).data @@ -548,7 +561,7 @@ def test_the_declared_differences_are_the_step_names(self): reverse("user_v2:retirement_detail", kwargs={"username": "parity-subject"}), ).data legacy = UserRetirementStatusSerializer(instance=self.retirement).data - assert conforming["current_state"] == legacy["current_state"]["state_name"] == WORKING_STATE + assert conforming["current_state"] == legacy["current_state"]["state_name"] == MOVED_TO_STATE assert conforming["last_state"] == legacy["last_state"]["state_name"] == WORKING_STATE def test_neither_version_carries_the_log_of_the_moves_a_retirement_has_made(self): diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py index 50b22f7cde8e..4a5a55a9da88 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py @@ -652,6 +652,219 @@ def test_the_page_is_readable_without_a_body(self): assert response.status_code == status.HTTP_200_OK +class _NarrowingPolicy: + """ + A visibility policy that keeps the rows named and records what it was given. + + It stands in for a deployment's own policy, so that the tests below read + what an address answers with when the policy narrows rather than when it + leaves the selection whole. + """ + + def __init__(self, column, *keep): + self.column = column + self.keep = keep + #: The caller each application was made for, in order. + self.subjects = [] + #: The values the selection held each time, before it was narrowed. + self.offered = [] + + def scope(self, queryset, subject): + """Return the rows named, recording the caller and what was on offer.""" + self.subjects.append(subject) + self.offered.append(sorted(queryset.values_list(self.column, flat=True))) + return queryset.filter(**{f"{self.column}__in": self.keep}) + + +@skip_unless_lms +class RetirementVisibilityTests(RetirementApiTestCase): + """ + Which retirements a caller may see is decided by the view's own visibility policy. + + The policy this deployment runs keeps the queue whole, because every + caller these addresses admit holds the capability to work the whole + pipeline. A deployment that replaces it with one that narrows needs it + applied where the selection is read, so the policy is replaced here with + one that keeps two of three retirements and the page is read. + """ + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:retirement_list") + self.pending = self.retirement_in("PENDING") + self.locking = self.retirement_in("LOCKING_ACCOUNT") + self.hidden = self.retirement_in("COMPLETE") + self.policy = _NarrowingPolicy( + "original_username", self.pending.original_username, self.locking.original_username, + ) + + def names_in(self, response): + """Return the registered names the page carries.""" + return sorted(row["original_username"] for row in response.data["results"]) + + def test_the_page_carries_the_retirements_the_policy_keeps_and_no_others(self): + self.login("worker") + with mock.patch.object(RetirementViewSet, "scoping_policy", self.policy): + response = self.client.get(self.url, {"page_size": 100}) + assert response.status_code == status.HTTP_200_OK + assert self.names_in(response) == sorted( + [self.pending.original_username, self.locking.original_username], + ) + assert response.data["count"] == 2 + + def test_the_policy_is_given_the_caller_and_every_retirement_held(self): + self.login("worker") + with mock.patch.object(RetirementViewSet, "scoping_policy", self.policy): + self.client.get(self.url, {"page_size": 100}) + assert [subject.username for subject in self.policy.subjects] == [WORKER_USERNAME] + assert self.policy.offered == [sorted( + [ + self.pending.original_username, + self.locking.original_username, + self.hidden.original_username, + ], + )] + + def test_what_the_policy_leaves_out_is_left_out_of_a_narrowed_page_too(self): + """A filter narrows what the policy left; it cannot put a row back in.""" + self.login("worker") + with mock.patch.object(RetirementViewSet, "scoping_policy", self.policy): + response = self.client.get(self.url, {"state": "COMPLETE", "page_size": 100}) + assert self.names_in(response) == [] + assert response.data["count"] == 0 + + def test_the_policy_this_deployment_runs_keeps_the_whole_queue(self): + self.login("worker") + response = self.client.get(self.url, {"page_size": 100}) + assert self.names_in(response) == sorted( + [ + self.pending.original_username, + self.locking.original_username, + self.hidden.original_username, + ], + ) + + +@skip_unless_lms +class PartnerReportVisibilityTests(RetirementApiTestCase): + """The entries a caller may take from the partner-report queue are narrowed the same way.""" + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:retirement_partner_report_list") + self.taken = self.queued(self.subject) + self.hidden = self.queued(self.learner) + self.policy = _NarrowingPolicy("original_username", self.subject.username) + + def queued(self, user): + """Return a partner report entry waiting to be taken, for this user.""" + return UserRetirementPartnerReportingStatus.objects.create( + user=user, + original_username=user.username, + original_email=user.email, + original_name=user.profile.name, + ) + + def test_only_the_entries_the_policy_keeps_are_answered_with(self): + self.login("worker") + with mock.patch.object(RetirementPartnerReportViewSet, "scoping_policy", self.policy): + response = self.client.post(self.url, {}, format="json") + assert response.status_code == status.HTTP_200_OK + assert [entry["original_username"] for entry in response.data] == [self.subject.username] + + def test_an_entry_the_policy_leaves_out_is_not_marked_as_taken(self): + self.login("worker") + with mock.patch.object(RetirementPartnerReportViewSet, "scoping_policy", self.policy): + self.client.post(self.url, {}, format="json") + marked = set( + UserRetirementPartnerReportingStatus.objects + .filter(is_being_processed=True) + .values_list("original_username", flat=True), + ) + assert marked == {self.subject.username} + + def test_the_policy_is_given_the_caller_and_the_entries_nobody_has_taken(self): + self.login("worker") + with mock.patch.object(RetirementPartnerReportViewSet, "scoping_policy", self.policy): + self.client.post(self.url, {}, format="json") + assert [subject.username for subject in self.policy.subjects] == [WORKER_USERNAME] + assert self.policy.offered == [ + sorted([self.subject.username, self.learner.username]), + ] + + def test_the_policy_this_deployment_runs_leaves_every_queued_entry_takeable(self): + self.login("worker") + response = self.client.post(self.url, {}, format="json") + assert sorted(entry["original_username"] for entry in response.data) == sorted( + [self.subject.username, self.learner.username], + ) + + +@skip_unless_lms +class RetirementQueueOrderTests(RetirementApiTestCase): + """ + The queue is read in a total order, so that paging through it sees each retirement once. + + Retirements that compare equal on the column a caller sorts by are left + in whatever order the database answers with, and two requests need not + agree on it, so the order the queue is worked in is appended to the sort + asked for. That order is the retirement's own identifier, which rises as + retirements are requested: it settles ties without being distinguishable + from an unsettled read of rows stored in that order, so what these tests + read is the order the database is asked for. + """ + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:retirement_list") + day = datetime.datetime(2026, 3, 1, tzinfo=ZoneInfo("UTC")) + self.shared = [self.retirement_in("PENDING", created=day) for _ in range(3)] + self.login("worker") + + def ordering_asked_for(self, **params): + """Return the order the page of retirements was read in.""" + with CaptureQueriesContext(connection) as captured: + response = self.client.get(self.url, params) + assert response.status_code == status.HTTP_200_OK, response.content + table = UserRetirementStatus._meta.db_table + reads = [ + query["sql"] for query in captured.captured_queries + if query["sql"].startswith("SELECT") and "ORDER BY" in query["sql"] + and connection.ops.quote_name(table) in query["sql"] + ] + assert len(reads) == 1, reads + return reads[0].split("ORDER BY")[1].split("LIMIT")[0].strip() + + def column(self, name, direction="ASC"): + """Return the one column of the retirement table, as a sort names it.""" + quoted = connection.ops.quote_name + table = UserRetirementStatus._meta.db_table + return f"{quoted(table)}.{quoted(name)} {direction}" + + def test_a_sort_a_caller_asks_for_is_completed_by_the_order_the_queue_is_worked_in(self): + assert self.ordering_asked_for(ordering="created", page_size=100) == ", ".join( + [self.column("created"), self.column("id")], + ) + + def test_the_order_the_queue_is_worked_in_is_added_to_a_reversed_sort_as_well(self): + assert self.ordering_asked_for(ordering="-created", page_size=100) == ", ".join( + [self.column("created", "DESC"), self.column("id")], + ) + + def test_a_page_nobody_asked_a_sort_for_is_read_in_the_order_the_queue_is_worked_in(self): + """The order a page falls back to is the tie-break, so it is not asked for twice.""" + assert self.ordering_asked_for(page_size=100) == self.column("id") + + def test_paging_through_retirements_requested_at_one_moment_sees_each_once(self): + listed = [] + for page in (1, 2, 3): + response = self.client.get( + self.url, {"ordering": "created", "page_size": 1, "page": page}, + ) + listed.extend(row["original_username"] for row in response.data["results"]) + assert listed == [retirement.original_username for retirement in self.shared] + + @skip_unless_lms class RetirementDetailTests(_AccessMatrixMixin, RetirementApiTestCase): """GET /api/user/v2/retirements/{username}/""" @@ -940,6 +1153,66 @@ def test_a_query_parameter_a_write_does_not_read_is_refused(self): ] +@skip_unless_lms +class MovedRetirementRecordTests(RetirementApiTestCase): + """ + A record that has moved names the step it stands at and the step it came from. + + They are two different steps once a retirement has moved, and a caller + driving the pipeline reads the second to tell what has already been done, + so the record is read here after a move rather than before one, when both + name the step the retirement was requested at. + """ + + def setUp(self): + super().setUp() + self.retirement = self.retirement_in("LOCKING_ACCOUNT", user=self.subject) + self.detail_url = reverse("user_v2:retirement_detail", kwargs={"username": "subject"}) + self.list_url = reverse("user_v2:retirement_list") + self.login("worker") + moved = self.client.patch( + self.detail_url, + {"new_state": "LOCKING_COMPLETE", "response": "the account was locked"}, + format="json", + ) + assert moved.status_code == status.HTTP_204_NO_CONTENT, moved.content + self.retirement.refresh_from_db() + + def test_the_record_names_the_step_it_stands_at_and_the_one_it_came_from(self): + response = self.client.get(self.detail_url) + assert response.status_code == status.HTTP_200_OK + assert response.data["current_state"] == "LOCKING_COMPLETE" + assert response.data["last_state"] == "LOCKING_ACCOUNT" + + def test_the_row_in_the_queue_names_both_steps_as_the_record_does(self): + response = self.client.get(self.list_url, {"page_size": 100}) + row = next( + row for row in response.data["results"] + if row["original_username"] == self.retirement.original_username + ) + assert row["current_state"] == "LOCKING_COMPLETE" + assert row["last_state"] == "LOCKING_ACCOUNT" + + def test_the_step_a_record_came_from_can_be_asked_for_on_its_own(self): + response = self.client.get(self.detail_url, {"fields": "last_state"}) + assert response.data == {"last_state": "LOCKING_ACCOUNT"} + + def test_the_minimal_record_names_the_step_it_stands_at(self): + """What a reader of the queue needs is where the retirement is now.""" + response = self.client.get(self.detail_url, {"view": "minimal"}) + assert response.data["current_state"] == "LOCKING_COMPLETE" + assert "last_state" not in response.data + + def test_the_queue_is_narrowed_by_the_step_a_retirement_stands_at(self): + """A retirement is found under the step it is at, not under the one it left.""" + by_current = self.client.get(self.list_url, {"state": "LOCKING_COMPLETE", "page_size": 100}) + assert [row["original_username"] for row in by_current.data["results"]] == [ + self.retirement.original_username, + ] + by_previous = self.client.get(self.list_url, {"state": "LOCKING_ACCOUNT", "page_size": 100}) + assert by_previous.data["results"] == [] + + @skip_unless_lms class RetirementCancelTests(_AccessMatrixMixin, RetirementApiTestCase): """DELETE /api/user/v2/retirements/{username}/""" diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py index c94eb652b2af..2912822df49e 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py @@ -11,6 +11,7 @@ from drf_spectacular.generators import SchemaGenerator from drf_spectacular.plumbing import get_override from drf_spectacular.settings import patched_settings +from rest_framework import serializers from rest_framework.test import APIClient from waffle.testutils import override_switch @@ -42,6 +43,7 @@ AccountPreferenceChangesField, AccountPreferenceDocumentField, AccountQuerySerializer, + RetirementBatchSerializer, TimeZoneQuerySerializer, VerificationQuerySerializer, ) @@ -172,7 +174,10 @@ #: 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. +#: The components this version contributes to the document. The two names +#: ending in ``Enum`` describe sets of choices and are configured in the +#: service's settings rather than derived from the fields carrying them, so +#: that another field of the same name cannot move them. VERSION_TWO_COMPONENTS = ( "Account", "AccountExtendedField", @@ -832,6 +837,59 @@ def test_this_version_only_adds_to_the_service_wide_document(): assert "CountryTimeZone" in components +#: The sets of choices this version publishes as a model of their own, with +#: the name each is published under and the values it offers. +PINNED_CHOICE_SETS = { + "ScopeEnum": list(ERASURE_SCOPES), + "OutcomeEnum": list(BATCH_OUTCOMES), +} + +#: A second field of each of those names, with the value it would offer and +#: the name it would be published under: a set of choices sharing a field name +#: with another is named after the body carrying it. +CROWDED_CHOICE_SETS = { + "scope": ("somewhere_else", "RetirementBatchScopeEnum"), + "outcome": ("something_else", "RetirementBatchOutcomeEnum"), +} + + +@pytest.mark.django_db +@skip_unless_lms +def test_each_published_set_of_choices_keeps_its_name_when_another_field_shares_the_name(): + """ + A set of choices is published as a model named for the field that carries it. + + That name holds only while one field of the whole service carries it: a + second field of the same name offering different values renames both + models after the bodies they sit in, so the name a client already + generates a class from disappears. Both documents are therefore read with + such a second field in place, and each set has to still be published + under its own name, with the values it offers. + """ + intruders = { + name: serializers.ChoiceField(choices=[(value, f"Another set of choices, under {name}.")]) + for name, (value, _published_as) in CROWDED_CHOICE_SETS.items() + } + with mock.patch.dict(RetirementBatchSerializer._declared_fields, intruders): # pylint: disable=protected-access + crowded = { + "this version": SchemaGenerator().get_schema(request=None, public=True), + "service-wide": service_wide_schema(), + } + for which, document in crowded.items(): + components = document["components"]["schemas"] + published = { + name: components[name]["enum"] for name in PINNED_CHOICE_SETS if name in components + } + assert published == PINNED_CHOICE_SETS, which + # The second field really was published, so the collision the names + # are held against did happen in this document. + for field, (value, crowded_name) in CROWDED_CHOICE_SETS.items(): + assert components["RetirementBatch"]["properties"][field] == { + "$ref": f"#/components/schemas/{crowded_name}", + }, (which, field) + assert components[crowded_name]["enum"] == [value], (which, field) + + @skip_unless_lms def test_every_response_shape_is_published(schema): """The reduced bodies the presets return are described, not only the full one.""" From c5ef806c4e7c53193666c04f9f981b5961d8984b Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Tue, 27 Oct 2026 18:16:05 +0500 Subject: [PATCH 48/82] fix: resolve every retirement operation through its own resource The two disposals deleted rows of the retirement and partner-report collections from un-routed views standing outside them, each with its own flat list of permission classes and reading neither the records nor the rules its collection reads. A restriction added to the collection therefore did not reach the operation that deletes those same rows, which is measurable: the same rule, in the same place, leaves the collection refusing a caller the disposal still serves. Both disposals are now registered on the same router, at addresses unchanged character for character, and take their rules and their records from the mixins their own resource uses. Neither declares either for itself. One ordering is load-bearing and is pinned by a test: the mixin carrying the queryset must sit after the one that declares a policy, because that one declares an empty policy of its own and shadows anything behind it. The visibility policy now reaches every action rather than the list and the queue drain alone, so a member address resolves the record from the selection the policy left, as the class already said it did. Nothing a caller is answered changes: the policy this deployment runs returns the selection unchanged, and both published documents are byte-identical. The refusal of an ambiguous name and the absence reported for a record outside the selection are each held by a mutation rather than observed. One answer does change, and it is the one the collection already gives: a caller without the capability who sends a method the disposal does not serve is now told which methods it serves, instead of being refused. The address serves one method, and that is already published. --- .../core/djangoapps/user_api/v2/selectors.py | 23 +- .../v2/tests/test_retirement_parity.py | 8 +- .../v2/tests/test_retirement_views.py | 521 +++++++++++++++++- .../user_api/v2/tests/test_selectors.py | 52 +- .../djangoapps/user_api/v2/tests/test_urls.py | 60 +- openedx/core/djangoapps/user_api/v2/urls.py | 32 +- .../djangoapps/user_api/v2/view_services.py | 37 +- openedx/core/djangoapps/user_api/v2/views.py | 231 +++++--- 8 files changed, 791 insertions(+), 173 deletions(-) diff --git a/openedx/core/djangoapps/user_api/v2/selectors.py b/openedx/core/djangoapps/user_api/v2/selectors.py index 327f9a9ff706..2d7567b9f48f 100644 --- a/openedx/core/djangoapps/user_api/v2/selectors.py +++ b/openedx/core/djangoapps/user_api/v2/selectors.py @@ -102,19 +102,21 @@ def retirements(): ) -def retirement_of_the_account_named(username): +def retirement_of_the_account_named(records, username): """ Return the retirement of the account this name addresses, or ``None``. A retired account is stored under a hashed name, so the name a caller holds may be either the one the account was registered under or the one it was moved to; both address the same account and so the same retirement. + The record is taken from the selection given rather than from every record + held, so a record outside it is reported as absent. """ try: user = get_potentially_retired_user_by_username(username) except User.DoesNotExist: return None - return retirements().filter(user=user).first() + return records.filter(user=user).first() def _grouped_by_requested_name(rows, usernames): @@ -138,10 +140,10 @@ def _grouped_by_requested_name(rows, usernames): return grouped -def retirements_requested_as(usernames): - """Return the retirements requested under exactly these names, grouped by name.""" +def retirements_requested_as(records, usernames): + """Return the records of this selection requested under exactly these names, grouped by name.""" return _grouped_by_requested_name( - retirements().filter(original_username__in=usernames), usernames, + records.filter(original_username__in=usernames), usernames, ) @@ -162,8 +164,13 @@ def unclaimed_partner_reports(): ) -def partner_reports_requested_as(usernames): - """Return the partner reports of the accounts registered under exactly these names, grouped by name.""" +def partner_reports(): + """Return every partner report entry, whether or not it has been taken for processing.""" + return UserRetirementPartnerReportingStatus.objects.all() + + +def partner_reports_requested_as(entries, usernames): + """Return the entries of this selection registered under exactly these names, grouped by name.""" return _grouped_by_requested_name( - UserRetirementPartnerReportingStatus.objects.filter(original_username__in=usernames), usernames, + entries.filter(original_username__in=usernames), usernames, ) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_parity.py b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_parity.py index 5d4118368591..cfd4973c2698 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_parity.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_parity.py @@ -49,8 +49,8 @@ from openedx.core.djangoapps.user_api.models import RetirementState, UserOrgTag, UserRetirementStatus from openedx.core.djangoapps.user_api.v2 import view_services from openedx.core.djangoapps.user_api.v2.views import ( - RetirementCleanupView, - RetirementPartnerReportCleanupView, + RetirementCleanupViewSet, + RetirementPartnerReportCleanupViewSet, RetirementPartnerReportViewSet, RetirementViewSet, ) @@ -125,9 +125,9 @@ #: earlier version's two pipeline views and are compared against those. MEDIA_TYPE_PAIRS = ( (RetirementViewSet, legacy_views.AccountRetirementStatusView), - (RetirementCleanupView, legacy_views.AccountRetirementStatusView), + (RetirementCleanupViewSet, legacy_views.AccountRetirementStatusView), (RetirementPartnerReportViewSet, legacy_views.AccountRetirementPartnerReportView), - (RetirementPartnerReportCleanupView, legacy_views.AccountRetirementPartnerReportView), + (RetirementPartnerReportCleanupViewSet, legacy_views.AccountRetirementPartnerReportView), ) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py index 4a5a55a9da88..b8061ebe8c5b 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py @@ -31,6 +31,8 @@ classify_error, error_type_uri, ) +from edx_rest_framework_extensions.permissions import NotJwtRestrictedApplication +from edx_rest_framework_extensions.scoping import FullScopePolicy from edx_rest_framework_extensions.testing import assert_error_envelope from opaque_keys.edx.keys import CourseKey from rest_framework import permissions, status @@ -71,10 +73,13 @@ from openedx.core.djangoapps.user_api.v2.serializers import RETIREMENT_MINIMAL_FIELDS from openedx.core.djangoapps.user_api.v2.views import ( RETIREMENT_ACTION_PERMISSIONS, - RetirementCleanupView, - RetirementPartnerReportCleanupView, + RetirementCleanupViewSet, + RetirementPartnerReportCleanupViewSet, RetirementPartnerReportViewSet, RetirementViewSet, + _PartnerReportAccessMixin, + _RetirementRecordAccessMixin, + _RetirementRecordsMixin, ) from openedx.core.djangolib.testing.utils import skip_unless_lms @@ -676,6 +681,70 @@ def scope(self, queryset, subject): return queryset.filter(**{f"{self.column}__in": self.keep}) +#: Stands for a view class that declares no permission classes of its own. +NOT_DECLARED = object() + + +def _copied_by_the_middleware(view): + """ + Return whether the classes on this view class are the shared ones with the middleware's added. + + The platform's own middleware appends its class to whatever it finds on + every view class it serves, which leaves a copy on the view class itself. + That copy is this and nothing else, so a view that declares classes of its + own is told apart from one that has merely answered a request. + """ + own = vars(view).get("permission_classes") + if own is None: + return False + inherited = next( + vars(base)["permission_classes"] + for base in view.__mro__[1:] + if "permission_classes" in vars(base) + ) + return tuple(own) == (*inherited, NotJwtRestrictedApplication) + + +@contextlib.contextmanager +def layers_read_from_their_own_declaration(*views): + """ + Serve these views with the permission classes the layer they share declares. + + The middleware's copy is dropped here and put back afterwards, so that what + the views read is what is declared for them, as it would be on a service + that has just started. A view declaring classes of its own keeps them, + because that declaration is what these tests are here to see. + """ + on_entry = {view: vars(view).get("permission_classes", NOT_DECLARED) for view in views} + try: + for view in views: + if _copied_by_the_middleware(view): + del view.permission_classes + yield + finally: + # Each view class is left exactly as it was found, whatever the + # middleware wrote onto it while these requests were being served. + for view, declared in on_entry.items(): + if "permission_classes" in vars(view): + del view.permission_classes + if declared is not NOT_DECLARED: + view.permission_classes = declared + + +class _OnlyASuperuser(permissions.BasePermission): + """ + A rule a deployment might add to a resource, admitting one caller and refusing the rest. + + It stands in for whatever a deployment adds to the layer the operations on + a resource share, so the tests below read which of those operations is + asked for it. + """ + + def has_permission(self, request, view): + """Return whether this caller is a superuser.""" + return bool(request.user and request.user.is_superuser) + + @skip_unless_lms class RetirementVisibilityTests(RetirementApiTestCase): """ @@ -1692,7 +1761,7 @@ class RetirementCleanupTests(_BatchTestsMixin, RetirementApiTestCase): def setUp(self): super().setUp() - self.url = reverse("user_v2:retirement_cleanup") + self.url = reverse("user_v2:retirement_cleanup_list") self.finished = self.retirement_in("COMPLETE", user=self.subject) def send(self): @@ -1778,7 +1847,7 @@ class PartnerReportCleanupTests(_BatchTestsMixin, RetirementApiTestCase): def setUp(self): super().setUp() - self.url = reverse("user_v2:retirement_partner_report_cleanup") + self.url = reverse("user_v2:retirement_partner_report_cleanup_list") self.reported = self.queued(self.subject, taken=True) def queued(self, user, taken=False): @@ -1890,8 +1959,10 @@ def test_no_retirement_view_declares_its_own_authentication(self): for view in ( RetirementViewSet, RetirementPartnerReportViewSet, - RetirementCleanupView, - RetirementPartnerReportCleanupView, + RetirementCleanupViewSet, + RetirementPartnerReportCleanupViewSet, + _RetirementRecordAccessMixin, + _PartnerReportAccessMixin, ): with self.subTest(view=view.__name__): assert "authentication_classes" not in vars(view) @@ -1901,13 +1972,430 @@ def test_no_retirement_view_declares_its_own_throttles(self): for view in ( RetirementViewSet, RetirementPartnerReportViewSet, - RetirementCleanupView, - RetirementPartnerReportCleanupView, + RetirementCleanupViewSet, + RetirementPartnerReportCleanupViewSet, + _RetirementRecordAccessMixin, + _PartnerReportAccessMixin, ): with self.subTest(view=view.__name__): assert "throttle_classes" not in vars(view) +@skip_unless_lms +class RetirementRecordAccessReachTests(RetirementApiTestCase): + """ + What is asked of the operations on the retirement records is asked of their disposal. + + The records the collection lists, moves and cancels are the records the + batch disposal deletes, so both resolve who may act on them and which of + them a caller acts on through one layer. A rule added to that layer is + read here from the answers each of them gives, rather than from where the + rule is written. + """ + + def setUp(self): + super().setUp() + self.list_url = reverse("user_v2:retirement_list") + self.disposal_url = reverse("user_v2:retirement_cleanup_list") + self.finished = self.retirement_in("COMPLETE", user=self.subject) + self.other = self.retirement_in("COMPLETE") + + def dispose_of(self, usernames): + """Ask the disposal to delete the records of these accounts.""" + return self.client.post(self.disposal_url, {"usernames": usernames}, format="json") + + @contextlib.contextmanager + def a_rule_added_to_the_records(self): + """Add a rule to the layer the operations on these records share.""" + with layers_read_from_their_own_declaration(RetirementViewSet, RetirementCleanupViewSet): + with mock.patch.object( + _RetirementRecordAccessMixin, "permission_classes", + (permissions.IsAuthenticated, _OnlyASuperuser), + ): + yield + + def test_a_rule_added_to_the_records_is_asked_of_their_disposal(self): + self.login("worker") + with self.a_rule_added_to_the_records(): + refused = self.dispose_of(["subject"]) + assert_error_envelope( + refused, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + assert UserRetirementStatus.objects.filter(id=self.finished.id).exists() + + def test_the_same_rule_is_asked_of_the_collection_those_records_are_listed_by(self): + """The rule is added in one place, so what obeys it is what resolves it there.""" + self.login("worker") + with self.a_rule_added_to_the_records(): + assert_error_envelope( + self.client.get(self.list_url), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_the_caller_the_rule_admits_is_served_by_both(self): + """The refusals above are the rule's doing, and the rule admits somebody.""" + self.login("superuser") + with self.a_rule_added_to_the_records(): + assert self.client.get(self.list_url).status_code == status.HTTP_200_OK + assert self.dispose_of(["subject"]).status_code == status.HTTP_200_OK + + def test_the_same_caller_is_served_by_the_disposal_without_that_rule(self): + """The refusal above is the rule's doing and not the caller's.""" + self.login("worker") + answered = self.dispose_of(["subject"]) + assert answered.status_code == status.HTTP_200_OK + assert answered.data == {"results": [{"username": "subject", "outcome": "deleted"}]} + + def test_a_record_the_visibility_policy_leaves_out_is_not_deleted_by_the_disposal(self): + self.login("worker") + policy = _NarrowingPolicy("original_username", self.other.original_username) + with mock.patch.object(_RetirementRecordAccessMixin, "scoping_policy", policy): + answered = self.dispose_of(["subject", self.other.original_username]) + assert answered.data == { + "results": [ + {"username": "subject", "outcome": "already_absent"}, + {"username": self.other.original_username, "outcome": "deleted"}, + ], + } + assert UserRetirementStatus.objects.filter(id=self.finished.id).exists() + assert not UserRetirementStatus.objects.filter(id=self.other.id).exists() + + def test_the_policy_is_given_the_caller_and_every_record_the_disposal_could_delete(self): + self.login("worker") + policy = _NarrowingPolicy("original_username", self.other.original_username) + with mock.patch.object(_RetirementRecordAccessMixin, "scoping_policy", policy): + self.dispose_of(["subject"]) + assert [subject.username for subject in policy.subjects] == [WORKER_USERNAME] + assert policy.offered == [sorted(["subject", self.other.original_username])] + + def test_the_disposal_declares_none_of_these_layers_itself(self): + """Declaring one again here is what would leave a rule added to the records behind.""" + with layers_read_from_their_own_declaration(RetirementViewSet, RetirementCleanupViewSet): + for name, declared_by in ( + ("permission_classes", _RetirementRecordAccessMixin), + ("scoping_policy", _RetirementRecordAccessMixin), + ("get_permissions", _RetirementRecordAccessMixin), + ("get_queryset", _RetirementRecordsMixin), + ): + with self.subTest(declaration=name): + assert name not in vars(RetirementCleanupViewSet) + assert name not in vars(RetirementViewSet) + assert name in vars(declared_by) + + def test_the_classes_each_view_resolves_are_the_ones_the_shared_layer_declares(self): + """Neither view declares them itself, so these are the classes the layer resolves to.""" + resolved = {} + with layers_read_from_their_own_declaration(RetirementViewSet, RetirementCleanupViewSet): + for view, action in ( + (RetirementViewSet, "list"), + (RetirementViewSet, "destroy"), + (RetirementCleanupViewSet, "create"), + (RetirementCleanupViewSet, "metadata"), + ): + viewset = view() + viewset.action = action + resolved[(view.__name__, action)] = [ + type(permission) for permission in viewset.get_permissions() + ] + assert resolved == { + ("RetirementViewSet", "list"): [permissions.IsAuthenticated, CanRetireUser], + ("RetirementViewSet", "destroy"): [permissions.IsAuthenticated, CanCancelUserRetirement], + ("RetirementCleanupViewSet", "create"): [permissions.IsAuthenticated, CanRetireUser], + ("RetirementCleanupViewSet", "metadata"): [permissions.IsAuthenticated, CanRetireUser], + } + + def test_the_policy_declared_for_these_records_is_the_one_each_view_resolves(self): + """The library mixin declares the same attribute, so where it stands in the order matters.""" + for view in (RetirementViewSet, RetirementCleanupViewSet): + with self.subTest(view=view.__name__): + assert isinstance(view().scoping_policy, FullScopePolicy) + + def test_the_disposal_asks_for_the_capability_the_rest_of_the_pipeline_work_asks_for(self): + assert RetirementCleanupViewSet.action_permissions == { + "create": (CanRetireUser,), + "metadata": (CanRetireUser,), + } + for action in RetirementCleanupViewSet.action_permissions: + with self.subTest(action=action): + viewset = RetirementCleanupViewSet() + viewset.action = action + built = [type(permission) for permission in viewset.get_permissions()] + assert built[-1] is CanRetireUser + assert permissions.IsAuthenticated in built + + def test_asking_the_disposal_what_it_serves_asks_for_the_same_capability(self): + """Describing an address is reading it, and this address is read by whoever may work it.""" + self.login("worker") + described = self.client.options(self.disposal_url) + assert described.status_code == status.HTTP_200_OK + assert described.data["renders"] == ["application/json"] + self.client.logout() + self.login("learner") + assert_error_envelope( + self.client.options(self.disposal_url), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_an_action_added_to_the_disposal_later_reaches_no_capability(self): + viewset = RetirementCleanupViewSet() + viewset.action = "an_action_added_later" + assert [ + permission.has_permission(mock.Mock(user=self.superuser), viewset) + for permission in viewset.get_permissions() + ][-1] is False + + +@skip_unless_lms +class PartnerReportAccessReachTests(RetirementApiTestCase): + """What is asked of the operations on the partner report entries is asked of their disposal.""" + + def setUp(self): + super().setUp() + self.queue_url = reverse("user_v2:retirement_partner_report_list") + self.disposal_url = reverse("user_v2:retirement_partner_report_cleanup_list") + self.reported = self.queued(self.subject, taken=True) + self.also_reported = self.queued(self.learner, taken=True) + + def queued(self, user, taken=False): + """Return a partner report entry for this user, taken for processing or not.""" + return UserRetirementPartnerReportingStatus.objects.create( + user=user, + original_username=user.username, + original_email=user.email, + original_name=user.profile.name, + is_being_processed=taken, + ) + + def dispose_of(self, usernames): + """Ask the disposal to delete the entries of these accounts.""" + return self.client.post(self.disposal_url, {"usernames": usernames}, format="json") + + def test_a_rule_added_to_the_entries_is_asked_of_their_disposal(self): + self.login("worker") + with layers_read_from_their_own_declaration( + RetirementPartnerReportViewSet, RetirementPartnerReportCleanupViewSet, + ), mock.patch.object( + _PartnerReportAccessMixin, "permission_classes", + (permissions.IsAuthenticated, _OnlyASuperuser), + ): + refused = self.dispose_of(["subject"]) + taking = self.client.post(self.queue_url, {}, format="json") + for response in (refused, taking): + assert_error_envelope( + response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + assert UserRetirementPartnerReportingStatus.objects.filter(id=self.reported.id).exists() + + def test_the_same_caller_is_served_by_the_disposal_without_that_rule(self): + self.login("worker") + answered = self.dispose_of(["subject"]) + assert answered.status_code == status.HTTP_200_OK + assert answered.data == {"results": [{"username": "subject", "outcome": "deleted"}]} + + def test_an_entry_the_visibility_policy_leaves_out_is_not_deleted_by_the_disposal(self): + self.login("worker") + policy = _NarrowingPolicy("original_username", self.learner.username) + with mock.patch.object(_PartnerReportAccessMixin, "scoping_policy", policy): + answered = self.dispose_of(["subject", self.learner.username]) + assert answered.data == { + "results": [ + {"username": "subject", "outcome": "already_absent"}, + {"username": self.learner.username, "outcome": "deleted"}, + ], + } + assert UserRetirementPartnerReportingStatus.objects.filter(id=self.reported.id).exists() + assert not UserRetirementPartnerReportingStatus.objects.filter( + id=self.also_reported.id, + ).exists() + + def test_the_disposal_resolves_the_entries_the_queue_leaves_out(self): + """An entry is disposed of once it has been taken, which is when the queue stops offering it.""" + self.login("worker") + policy = _NarrowingPolicy("original_username", "nobody") + with mock.patch.object(_PartnerReportAccessMixin, "scoping_policy", policy): + self.dispose_of(["subject"]) + self.client.post(self.queue_url, {}, format="json") + assert policy.offered == [ + sorted([self.subject.username, self.learner.username]), + [], + ] + + def test_the_disposal_declares_neither_layer_itself(self): + with layers_read_from_their_own_declaration( + RetirementPartnerReportViewSet, RetirementPartnerReportCleanupViewSet, + ): + for name in ("permission_classes", "scoping_policy"): + with self.subTest(declaration=name): + assert name not in vars(RetirementPartnerReportCleanupViewSet) + assert name not in vars(RetirementPartnerReportViewSet) + assert name in vars(_PartnerReportAccessMixin) + + def test_the_classes_each_view_resolves_are_the_ones_the_shared_layer_declares(self): + """Neither view declares them itself, so these are the classes the layer resolves to.""" + with layers_read_from_their_own_declaration( + RetirementPartnerReportViewSet, RetirementPartnerReportCleanupViewSet, + ): + resolved = { + view.__name__: [type(permission) for permission in view().get_permissions()] + for view in ( + RetirementPartnerReportViewSet, RetirementPartnerReportCleanupViewSet, + ) + } + assert resolved == { + "RetirementPartnerReportViewSet": [permissions.IsAuthenticated, CanRetireUser], + "RetirementPartnerReportCleanupViewSet": [permissions.IsAuthenticated, CanRetireUser], + } + + def test_the_policy_declared_for_these_entries_is_the_one_each_view_resolves(self): + """The library mixin declares the same attribute, so where it stands in the order matters.""" + for view in (RetirementPartnerReportViewSet, RetirementPartnerReportCleanupViewSet): + with self.subTest(view=view.__name__): + assert isinstance(view().scoping_policy, FullScopePolicy) + + +@skip_unless_lms +class RetirementMemberVisibilityTests(RetirementApiTestCase): + """ + A retirement the visibility policy leaves out is absent from every member address too. + + The policy this deployment runs keeps the records whole, because every + caller these addresses admit holds the capability to work the whole + pipeline, so it is replaced here with one that keeps one record of two and + each member address is asked about the other. + """ + + def setUp(self): + super().setUp() + self.kept = self.retirement_in("RETIRING_LMS", user=self.learner) + self.left_out = self.retirement_in("RETIRING_LMS", user=self.subject) + self.policy = _NarrowingPolicy("original_username", self.learner.username) + self.left_out_url = reverse("user_v2:retirement_detail", kwargs={"username": "subject"}) + self.kept_url = reverse( + "user_v2:retirement_detail", kwargs={"username": self.learner.username}, + ) + self.erasure_url = reverse("user_v2:retirement_erasure", kwargs={"username": "subject"}) + + @contextlib.contextmanager + def policy_in_force(self): + """Serve the next requests under a policy that keeps one of the two records.""" + with mock.patch.object(_RetirementRecordAccessMixin, "scoping_policy", self.policy): + yield + + def assert_reported_absent(self, response): + """Assert the record was reported as absent rather than the caller refused.""" + envelope = assert_error_envelope( + response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + assert envelope["detail"] == view_services.NO_SUCH_RETIREMENT + + def test_a_record_the_policy_leaves_out_is_absent_from_its_member_address(self): + self.login("worker") + with self.policy_in_force(): + self.assert_reported_absent(self.client.get(self.left_out_url)) + + def test_the_record_the_policy_keeps_is_answered_at_its_own_address(self): + self.login("worker") + with self.policy_in_force(): + answered = self.client.get(self.kept_url) + assert answered.status_code == status.HTTP_200_OK + assert answered.data["original_username"] == self.learner.username + + def test_a_record_the_policy_leaves_out_cannot_be_moved(self): + self.login("worker") + with self.policy_in_force(): + self.assert_reported_absent( + self.client.patch( + self.left_out_url, + {"new_state": "LMS_COMPLETE", "response": "done"}, + format="json", + ), + ) + self.left_out.refresh_from_db() + assert self.left_out.current_state.state_name == "RETIRING_LMS" + + def test_a_record_the_policy_leaves_out_cannot_be_cancelled(self): + pending = self.retirement_in("PENDING", user=UserFactory.create(username="pending-one")) + self.login("canceller") + with mock.patch.object( + _RetirementRecordAccessMixin, "scoping_policy", + _NarrowingPolicy("original_username", self.learner.username), + ): + self.assert_reported_absent( + self.client.delete( + reverse("user_v2:retirement_detail", kwargs={"username": "pending-one"}), + ), + ) + assert UserRetirementStatus.objects.filter(id=pending.id).exists() + + def test_a_record_the_policy_leaves_out_has_nothing_erased(self): + self.subject.profile.city = "Somewhere" + self.subject.profile.save() + self.login("worker") + with self.policy_in_force(): + self.assert_reported_absent( + self.client.post(self.erasure_url, {"scope": "lms_core"}, format="json"), + ) + self.subject.profile.refresh_from_db() + assert self.subject.profile.city == "Somewhere" + self.subject.refresh_from_db() + assert self.subject.username == "subject" + + def test_the_policy_is_given_the_caller_and_every_record_held(self): + self.login("worker") + with self.policy_in_force(): + self.client.get(self.kept_url) + assert [subject.username for subject in self.policy.subjects] == [WORKER_USERNAME] + assert self.policy.offered == [ + sorted([self.learner.username, self.left_out.original_username]), + ] + + def test_a_name_more_than_one_record_is_held_under_is_still_refused(self): + """The policy narrows what the name is resolved against; it does not resolve the name.""" + twin = self.retirement_in("RETIRING_LMS", user=UserFactory.create(username="learner-twin")) + UserRetirementStatus.objects.filter(id=twin.id).update( + original_username=self.learner.username, + ) + self.login("worker") + with self.policy_in_force(): + envelope = assert_error_envelope( + self.client.patch( + self.kept_url, + {"new_state": "LMS_COMPLETE", "response": "done"}, + format="json", + ), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug=AMBIGUOUS_NAME_SLUG, + ) + assert envelope["detail"] == view_services.NAME_IS_AMBIGUOUS + for record in (self.kept, twin): + record.refresh_from_db() + assert record.current_state.state_name == "RETIRING_LMS" + + def test_a_policy_leaving_one_record_of_such_a_pair_leaves_one_record_for_the_name(self): + """The record left out belongs to an account this caller may not act on, so it is not weighed.""" + twin = self.retirement_in("RETIRING_LMS", user=UserFactory.create(username="learner-twin")) + UserRetirementStatus.objects.filter(id=twin.id).update( + original_username=self.learner.username, + ) + self.login("worker") + with mock.patch.object( + _RetirementRecordAccessMixin, "scoping_policy", + _NarrowingPolicy("id", self.kept.id), + ): + answered = self.client.patch( + self.kept_url, + {"new_state": "LMS_COMPLETE", "response": "done"}, + format="json", + ) + assert answered.status_code == status.HTTP_204_NO_CONTENT + self.kept.refresh_from_db() + twin.refresh_from_db() + assert self.kept.current_state.state_name == "LMS_COMPLETE" + assert twin.current_state.state_name == "RETIRING_LMS" + + class RetirementErrorCatalogTests(SimpleTestCase): """What this area adds to the shared error catalog, and what it leaves alone.""" @@ -2126,8 +2614,8 @@ def setUp(self): self.queue_url = reverse( "user_v2:retirement_partner_report_detail", kwargs={"username": "subject"}, ) - self.cleanup_url = reverse("user_v2:retirement_cleanup") - self.partner_cleanup_url = reverse("user_v2:retirement_partner_report_cleanup") + self.cleanup_url = reverse("user_v2:retirement_cleanup_list") + self.partner_cleanup_url = reverse("user_v2:retirement_partner_report_cleanup_list") def two_retirements_under_one_name(self, state_name): """Return two retirement records requested under the one name ``subject``.""" @@ -2354,7 +2842,10 @@ def test_a_form_body_is_refused_when_moving_a_retirement(self): def test_a_form_body_is_refused_by_each_batch_disposal(self): self.login("worker") - for name in ("user_v2:retirement_cleanup", "user_v2:retirement_partner_report_cleanup"): + for name in ( + "user_v2:retirement_cleanup_list", + "user_v2:retirement_partner_report_cleanup_list", + ): with self.subTest(address=name): self.assert_the_body_was_unreadable( self.client.post(reverse(name), "usernames=subject", content_type=self.FORM), @@ -2418,19 +2909,21 @@ class UnservedMethodTests(RetirementApiTestCase): ("delete", "user_v2:retirement_list", None), ("patch", "user_v2:retirement_list", None), ("get", "user_v2:retirement_erasure", {"username": "subject"}), + ("get", "user_v2:retirement_cleanup_list", None), ) PER_VIEW = ( ("get", "user_v2:retirement_partner_report_list", None), ("get", "user_v2:retirement_partner_report_detail", {"username": "subject"}), - ("get", "user_v2:retirement_cleanup", None), - ("get", "user_v2:retirement_partner_report_cleanup", None), + ("get", "user_v2:retirement_partner_report_cleanup_list", None), ) - #: What each address of the collection serves, as its refusal reports it. + #: What each address whose capability follows the operation serves, as its + #: refusal reports it. SERVED = { "user_v2:retirement_list": {"GET", "HEAD", "OPTIONS"}, "user_v2:retirement_detail": {"GET", "HEAD", "PATCH", "DELETE", "OPTIONS"}, "user_v2:retirement_erasure": {"POST", "OPTIONS"}, + "user_v2:retirement_cleanup_list": {"POST", "OPTIONS"}, } def setUp(self): diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_selectors.py b/openedx/core/djangoapps/user_api/v2/tests/test_selectors.py index 4f565b1ed0fa..5aee066e0de4 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_selectors.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_selectors.py @@ -1,7 +1,6 @@ """Tests for the selections version 2 of the User API answers from.""" import datetime -from unittest import mock import pytest from django.conf import settings @@ -21,6 +20,7 @@ from openedx.core.djangoapps.user_api.v2.selectors import ( accounts_holding_emails, cancellable_retirement_for_email, + partner_reports, partner_reports_requested_as, retirement_of_the_account_named, retirements, @@ -147,35 +147,45 @@ def test_the_queue_carries_the_account_and_both_steps_without_asking_again(self) assert selected.last_state.state_name == "PENDING" def test_an_account_is_addressed_by_the_name_it_was_registered_under(self): - assert retirement_of_the_account_named("pipeline-subject").id == self.retirement.id + assert retirement_of_the_account_named(retirements(), "pipeline-subject").id == ( + self.retirement.id + ) def test_an_account_is_addressed_by_the_hashed_name_it_was_moved_to(self): hashed = get_retired_username_by_username("pipeline-subject") self.subject.username = hashed self.subject.save() - assert retirement_of_the_account_named(hashed).id == self.retirement.id - assert retirement_of_the_account_named("pipeline-subject").id == self.retirement.id + assert retirement_of_the_account_named(retirements(), hashed).id == self.retirement.id + assert retirement_of_the_account_named(retirements(), "pipeline-subject").id == ( + self.retirement.id + ) def test_a_name_no_account_holds_addresses_no_retirement(self): - assert retirement_of_the_account_named("nobody") is None + assert retirement_of_the_account_named(retirements(), "nobody") is None def test_an_account_with_no_retirement_behind_it_addresses_none(self): UserFactory.create(username="not-retiring") - assert retirement_of_the_account_named("not-retiring") is None + assert retirement_of_the_account_named(retirements(), "not-retiring") is None + + def test_a_record_the_selection_given_leaves_out_addresses_no_retirement(self): + """Each selection is asked of the records the caller may act on, not of every record held.""" + assert retirement_of_the_account_named( + retirements().exclude(id=self.retirement.id), "pipeline-subject", + ) is None def test_a_retirement_is_found_by_the_name_it_was_requested_under(self): - found = retirements_requested_as(["pipeline-subject", "nobody"]) + found = retirements_requested_as(retirements(), ["pipeline-subject", "nobody"]) assert set(found) == {"pipeline-subject"} assert [row.id for row in found["pipeline-subject"]] == [self.retirement.id] def test_a_name_spelled_differently_finds_no_retirement(self): """Stored names are compared without regard to case in some deployments, so this is exact.""" - assert set(retirements_requested_as(["Pipeline-Subject"])) == set() + assert set(retirements_requested_as(retirements(), ["Pipeline-Subject"])) == set() def test_both_retirements_of_a_pair_differing_only_in_case_are_found_separately(self): other = UserFactory.create(username="PIPELINE-SUBJECT", email="other@example.com") other_retirement = create_retirement_status(other) - found = retirements_requested_as(["pipeline-subject", "PIPELINE-SUBJECT"]) + found = retirements_requested_as(retirements(), ["pipeline-subject", "PIPELINE-SUBJECT"]) assert [row.id for row in found["pipeline-subject"]] == [self.retirement.id] assert [row.id for row in found["PIPELINE-SUBJECT"]] == [other_retirement.id] @@ -183,7 +193,7 @@ def test_every_retirement_requested_under_one_name_is_found_under_it(self): """The stored name is indexed but not unique, so a name may carry several records.""" twin = create_retirement_status(UserFactory.create(username="pipeline-twin")) UserRetirementStatus.objects.filter(id=twin.id).update(original_username="pipeline-subject") - found = retirements_requested_as(["pipeline-subject"]) + found = retirements_requested_as(retirements(), ["pipeline-subject"]) assert sorted(row.id for row in found["pipeline-subject"]) == sorted( [self.retirement.id, twin.id], ) @@ -194,6 +204,12 @@ def test_the_partner_report_queue_leaves_out_what_has_been_taken(self): self.queued(taken_user, taken=True) assert [report.id for report in unclaimed_partner_reports()] == [waiting.id] + def test_every_entry_is_offered_for_disposal_whether_it_has_been_taken_or_not(self): + """An entry is disposed of once it has been taken, which is when the queue stops offering it.""" + waiting = self.queued(self.subject) + taken = self.queued(UserFactory.create(username="already-taken"), taken=True) + assert sorted(report.id for report in partner_reports()) == sorted([waiting.id, taken.id]) + def test_the_partner_report_queue_carries_the_courses_each_account_studied(self): self.queued(self.subject) CourseEnrollmentFactory.create(user=self.subject, course_id="course-v1:edX+DemoX+Demo") @@ -205,7 +221,7 @@ def test_the_partner_report_queue_carries_the_courses_each_account_studied(self) def test_a_partner_report_is_found_by_the_name_it_was_registered_under(self): report = self.queued(self.subject) - found = partner_reports_requested_as(["pipeline-subject", "nobody"]) + found = partner_reports_requested_as(partner_reports(), ["pipeline-subject", "nobody"]) assert set(found) == {"pipeline-subject"} assert [row.id for row in found["pipeline-subject"]] == [report.id] @@ -215,12 +231,12 @@ def test_every_partner_report_registered_under_one_name_is_found_under_it(self): UserRetirementPartnerReportingStatus.objects.filter(id=twin.id).update( original_username="pipeline-subject", ) - found = partner_reports_requested_as(["pipeline-subject"]) + found = partner_reports_requested_as(partner_reports(), ["pipeline-subject"]) assert sorted(row.id for row in found["pipeline-subject"]) == sorted([report.id, twin.id]) def test_a_partner_report_is_not_found_by_a_name_spelled_differently(self): self.queued(self.subject) - assert set(partner_reports_requested_as(["Pipeline-Subject"])) == set() + assert set(partner_reports_requested_as(partner_reports(), ["Pipeline-Subject"])) == set() def queued(self, user, taken=False): """Return a partner report entry for this account, taken for processing or not.""" @@ -266,10 +282,7 @@ def test_a_retirement_is_selected_by_the_spelling_asked_for(self): wanted = create_retirement_status(self.lower) create_retirement_status(self.upper) offered = _CollationIgnoringCase(list(UserRetirementStatus.objects.all())) - with mock.patch( - "openedx.core.djangoapps.user_api.v2.selectors.retirements", return_value=offered, - ): - found = retirements_requested_as(["exact-subject"]) + found = retirements_requested_as(offered, ["exact-subject"]) assert set(found) == {"exact-subject"} assert [row.id for row in found["exact-subject"]] == [wanted.id] @@ -287,9 +300,6 @@ def test_a_partner_report_is_selected_by_the_spelling_asked_for(self): original_name=self.upper.profile.name, ) offered = _CollationIgnoringCase(list(UserRetirementPartnerReportingStatus.objects.all())) - with mock.patch.object( - UserRetirementPartnerReportingStatus, "objects", mock.Mock(filter=offered.filter), - ): - found = partner_reports_requested_as(["exact-subject"]) + found = partner_reports_requested_as(offered, ["exact-subject"]) assert set(found) == {"exact-subject"} assert [row.id for row in found["exact-subject"]] == [wanted.id] diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_urls.py b/openedx/core/djangoapps/user_api/v2/tests/test_urls.py index 11ab9ca95232..355cb9917bac 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_urls.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_urls.py @@ -25,8 +25,8 @@ IDVerificationStatusView, IDVerificationViewSet, NameChangeViewSet, - RetirementCleanupView, - RetirementPartnerReportCleanupView, + RetirementCleanupViewSet, + RetirementPartnerReportCleanupViewSet, RetirementPartnerReportViewSet, RetirementViewSet, UserPreferenceViewSet, @@ -96,13 +96,13 @@ {"username": "alice"}, "/api/user/v2/retirements/alice/erasures/", ), - "user_v2:retirement_cleanup": ({}, "/api/user/v2/retirement_cleanups/"), + "user_v2:retirement_cleanup_list": ({}, "/api/user/v2/retirement_cleanups/"), "user_v2:retirement_partner_report_list": ({}, "/api/user/v2/retirement_partner_reports/"), "user_v2:retirement_partner_report_detail": ( {"username": "alice"}, "/api/user/v2/retirement_partner_reports/alice/", ), - "user_v2:retirement_partner_report_cleanup": ( + "user_v2:retirement_partner_report_cleanup_list": ( {}, "/api/user/v2/retirement_partner_report_cleanups/", ), @@ -197,7 +197,7 @@ def test_resolved_names_are_the_names_that_reverse(self): ("/api/user/v2/retirements/", "retirement_list"), ("/api/user/v2/retirements/alice/", "retirement_detail"), ("/api/user/v2/retirements/alice/erasures/", "retirement_erasure"), - ("/api/user/v2/retirement_cleanups/", "retirement_cleanup"), + ("/api/user/v2/retirement_cleanups/", "retirement_cleanup_list"), ("/api/user/v2/retirement_partner_reports/", "retirement_partner_report_list"), ( "/api/user/v2/retirement_partner_reports/alice/", @@ -205,7 +205,7 @@ def test_resolved_names_are_the_names_that_reverse(self): ), ( "/api/user/v2/retirement_partner_report_cleanups/", - "retirement_partner_report_cleanup", + "retirement_partner_report_cleanup_list", ), ): match = resolve(address) @@ -466,11 +466,11 @@ def test_every_address_is_named_and_ends_in_a_slash(self): "email_search_list", "name_change_confirmation", "name_change_list", - "retirement_cleanup", + "retirement_cleanup_list", "retirement_detail", "retirement_erasure", "retirement_list", - "retirement_partner_report_cleanup", + "retirement_partner_report_cleanup_list", "retirement_partner_report_detail", "retirement_partner_report_list", "time_zone_list", @@ -514,8 +514,10 @@ def test_the_router_route_that_is_replaced_has_to_exist(self): "retirement_list", "retirement_detail", "retirement_erasure", + "retirement_cleanup_list", "retirement_partner_report_list", "retirement_partner_report_detail", + "retirement_partner_report_cleanup_list", ] with pytest.raises(ImproperlyConfigured): v2_urls.without_route(routes, "user_preference_detail_renamed") @@ -801,6 +803,10 @@ def test_every_address_left_to_the_other_app_is_one_that_app_serves(self): "/api/user/v2/retirement_partner_reports/alice/": ( RetirementPartnerReportViewSet, {"put": "update"}, ), + "/api/user/v2/retirement_cleanups/": (RetirementCleanupViewSet, {"post": "create"}), + "/api/user/v2/retirement_partner_report_cleanups/": ( + RetirementPartnerReportCleanupViewSet, {"post": "create"}, + ), } #: The addresses of the version 1 pipeline, with the view class each still @@ -869,14 +875,32 @@ def test_each_address_is_mounted_on_the_view_that_serves_it(self): assert view_identity(match.func) == (V2_VIEW_MODULE, view.__name__), address assert routed_actions(match.func) == actions, address - def test_each_batch_address_is_mounted_on_a_view_of_its_own(self): - for address, view in ( - ("/api/user/v2/retirement_cleanups/", RetirementCleanupView), - ("/api/user/v2/retirement_partner_report_cleanups/", RetirementPartnerReportCleanupView), + def test_neither_disposal_is_served_by_the_collection_whose_records_it_deletes(self): + """Each disposal has a view of its own, because it acts on a set and under its own invariant.""" + assert { + view_identity(resolve(address).func) + for address in ( + "/api/user/v2/retirements/", + "/api/user/v2/retirement_cleanups/", + "/api/user/v2/retirement_partner_reports/", + "/api/user/v2/retirement_partner_report_cleanups/", + ) + } == { + (V2_VIEW_MODULE, "RetirementViewSet"), + (V2_VIEW_MODULE, "RetirementCleanupViewSet"), + (V2_VIEW_MODULE, "RetirementPartnerReportViewSet"), + (V2_VIEW_MODULE, "RetirementPartnerReportCleanupViewSet"), + } + + def test_neither_disposal_has_a_member_address(self): + """A collection with no member operation is given no member route by the router.""" + for address in ( + "/api/user/v2/retirement_cleanups/", + "/api/user/v2/retirement_partner_report_cleanups/", ): - match = resolve(address) - assert view_identity(match.func) == (V2_VIEW_MODULE, view.__name__), address - assert served_methods(match.func) == {"POST", "OPTIONS"}, address + assert view_identity(resolve(f"{address}alice/").func) == ( + V2_VIEW_MODULE, "UnknownAddressView", + ), address def test_a_name_carrying_every_character_a_username_may_hold_addresses_a_member(self): for username in ("a.b", "a b", "a@b", "a+b", "a-b", "a_b", "Mixed.Case+1"): @@ -893,11 +917,11 @@ def test_a_name_that_reads_like_a_batch_address_is_read_as_a_name(self): match = resolve("/api/user/v2/retirements/cleanups/") assert match.url_name == "retirement_detail" assert match.kwargs == {"username": "cleanups"} - assert resolve("/api/user/v2/retirement_cleanups/").url_name == "retirement_cleanup" + assert resolve("/api/user/v2/retirement_cleanups/").url_name == "retirement_cleanup_list" def test_the_batch_addresses_are_not_members_of_the_collections_they_dispose_of(self): - assert reverse("user_v2:retirement_cleanup") == "/api/user/v2/retirement_cleanups/" - assert reverse("user_v2:retirement_partner_report_cleanup") == ( + assert reverse("user_v2:retirement_cleanup_list") == "/api/user/v2/retirement_cleanups/" + assert reverse("user_v2:retirement_partner_report_cleanup_list") == ( "/api/user/v2/retirement_partner_report_cleanups/" ) diff --git a/openedx/core/djangoapps/user_api/v2/urls.py b/openedx/core/djangoapps/user_api/v2/urls.py index 0dedd2bb4fd6..afb25bac3819 100644 --- a/openedx/core/djangoapps/user_api/v2/urls.py +++ b/openedx/core/djangoapps/user_api/v2/urls.py @@ -18,8 +18,8 @@ IDVerificationStatusView, IDVerificationViewSet, NameChangeViewSet, - RetirementCleanupView, - RetirementPartnerReportCleanupView, + RetirementCleanupViewSet, + RetirementPartnerReportCleanupViewSet, RetirementPartnerReportViewSet, RetirementViewSet, UnknownAddressView, @@ -99,6 +99,12 @@ class SnakeCaseRouter(SimpleRouter): # across accounts, so it stands beside them rather than under one of them; the # name the account was registered under identifies it. router.register(r"retirements", RetirementViewSet, basename="retirement") +# Disposing of the records a finished retirement leaves behind is one +# operation over a set of them, with no member address of its own, so it +# stands beside the collections rather than under one: a fixed word directly +# after a collection would occupy the place a member's name goes, and a user +# may register any name. +router.register(r"retirement_cleanups", RetirementCleanupViewSet, basename="retirement_cleanup") # What is owed to the organizations an account studied with outlives the # account, so it is recorded on its own and reached without naming one. router.register( @@ -106,6 +112,13 @@ class SnakeCaseRouter(SimpleRouter): RetirementPartnerReportViewSet, basename="retirement_partner_report", ) +# The entries that have been reported on are disposed of in the same way, and +# at a collection of their own for the same reason. +router.register( + r"retirement_partner_report_cleanups", + RetirementPartnerReportCleanupViewSet, + basename="retirement_partner_report_cleanup", +) urlpatterns = [ @@ -161,21 +174,6 @@ class SnakeCaseRouter(SimpleRouter): UserPreferenceViewSet.as_view({"get": "retrieve"}), name="user_preference_detail", ), - # Disposing of the records a finished retirement leaves behind is one - # operation over a set of them, with no member address of its own, so it - # stands beside the collections rather than under one: a fixed word - # directly after a collection would occupy the place a member's name goes, - # and a user may register any name. - re_path( - r"^retirement_cleanups/$", - RetirementCleanupView.as_view(), - name="retirement_cleanup", - ), - re_path( - r"^retirement_partner_report_cleanups/$", - RetirementPartnerReportCleanupView.as_view(), - name="retirement_partner_report_cleanup", - ), # 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 diff --git a/openedx/core/djangoapps/user_api/v2/view_services.py b/openedx/core/djangoapps/user_api/v2/view_services.py index 1f8a5a371794..ca94961d88d2 100644 --- a/openedx/core/djangoapps/user_api/v2/view_services.py +++ b/openedx/core/djangoapps/user_api/v2/view_services.py @@ -104,12 +104,14 @@ def _only_record_named(username, records): return records[0] -def retirement_requested_as(username): - """Return the one retirement requested under exactly this name.""" - return _only_record_named(username, retirements_requested_as([username]).get(username, [])) +def retirement_requested_as(records, username): + """Return the one record of this selection requested under exactly this name.""" + return _only_record_named( + username, retirements_requested_as(records, [username]).get(username, []), + ) -def retirement_open_to_action(username): +def retirement_open_to_action(records, username): """ Return the retirement requested under this name while it admits pipeline work. @@ -117,7 +119,7 @@ def retirement_open_to_action(username): and the step that records a part of the pipeline as finished, which is what keeps a step from being run twice over the same account. """ - retirement = retirement_requested_as(username) + retirement = retirement_requested_as(records, username) try: # Which steps admit work is decided by the pipeline's own model layer, # where the order of the steps is kept, rather than being written out a @@ -333,18 +335,20 @@ def _one_record_for_each_name(found): return {username: records[0] for username, records in found.items()} -def delete_retirements(usernames): +def delete_retirements(records, usernames): """ Delete the retirement records of the accounts named, once the pipeline has finished with them. - A name no record was requested under is reported as such rather than - refused, so a caller repeating a request that has already been carried out - is answered rather than left to tell "already done" from "never existed". + The records are resolved from the selection given, so a record outside it + is reported as absent and left alone. A name no record was requested under + is reported as such rather than refused, so a caller repeating a request + that has already been carried out is answered rather than left to tell + "already done" from "never existed". A record the pipeline has not finished with refuses the whole batch and nothing is deleted, because the caller asked for a set of records and a half-applied delete cannot be undone. """ - found = _one_record_for_each_name(retirements_requested_as(usernames)) + found = _one_record_for_each_name(retirements_requested_as(records, usernames)) refused = { username: [ f"This retirement stands at {record.current_state.state_name}; only a retirement at " @@ -369,16 +373,17 @@ def delete_retirements(usernames): return _batch_outcomes(usernames, found) -def delete_partner_reports(usernames): +def delete_partner_reports(entries, usernames): """ Delete the partner report entries of the accounts named, once they have been reported on. - An entry nobody has taken for processing has not been reported on yet, so - it refuses the whole batch and nothing is deleted; a name with no entry at - all is reported as such, which is what a repeat of a request already - carried out reports for every name. + The entries are resolved from the selection given, so an entry outside it + is reported as absent and left alone. An entry nobody has taken for + processing has not been reported on yet, so it refuses the whole batch and + nothing is deleted; a name with no entry at all is reported as such, which + is what a repeat of a request already carried out reports for every name. """ - found = _one_record_for_each_name(partner_reports_requested_as(usernames)) + found = _one_record_for_each_name(partner_reports_requested_as(entries, usernames)) refused = { username: ["This partner report has not been taken for processing, so it has not been reported on yet."] for username, report in found.items() diff --git a/openedx/core/djangoapps/user_api/v2/views.py b/openedx/core/djangoapps/user_api/v2/views.py index 2d23c449bfec..d755adda3147 100644 --- a/openedx/core/djangoapps/user_api/v2/views.py +++ b/openedx/core/djangoapps/user_api/v2/views.py @@ -70,6 +70,7 @@ accounts_holding_emails, accounts_named, cancellable_retirement_for_email, + partner_reports, retirement_of_the_account_named, retirements, unclaimed_partner_reports, @@ -2468,6 +2469,63 @@ def initial(self, request, *args, **kwargs): ), ) +class _RetirementRecordAccessMixin: + """ + Who may act on the retirement records, and which of those records a caller acts on. + + The records this version lists, moves and cancels are the records its + batch disposal deletes, so the capability each operation asks for and the + policy deciding which records a caller acts on are declared here, where + all of them resolve them, rather than once per view. A rule added here is + asked of the disposal as well as of the operations on one record. + + These are declared in a class resolved before the library mixin that + reads the policy, which declares a policy of its own for a view that has + none; the selection the policy narrows is declared in a class resolved + after it instead. + """ + + permission_classes = (permissions.IsAuthenticated,) + # The operations these addresses replace read a body as JSON alone, and + # one of them erases personal data for good, so the same narrowing is kept + # rather than widened to every media type the platform can parse. + parser_classes = (JSONParser,) + # Any caller holding the pipeline's capability may act on every + # retirement, so the policy keeps the records whole; a narrower policy can + # replace it here and is applied by every operation below. + scoping_policy = FullScopePolicy() + #: The capability each action asks for beyond the classes above. + action_permissions = {} + + def get_permissions(self): + """Return the classes deciding who may make the request being served.""" + if self.action is None: + # No action serves this method at this address. That is not a + # refusal of the caller, so the request is admitted by the classes + # every action shares and answered as a method this address does + # not serve, which is what the earlier addresses answer. + return [permission() for permission in self.permission_classes] + # An action this mapping does not name reaches no capability of this + # resource, rather than inheriting the one every other action shares. + specific = self.action_permissions.get(self.action, (~permissions.AllowAny,)) + return [permission() for permission in (*self.permission_classes, *specific)] + + +class _RetirementRecordsMixin: + """ + The retirement records, before they are narrowed to the ones a caller may act on. + + A view's policy is applied on top of the selection the classes resolved + after it answer with, so the unnarrowed selection is declared here rather + than on the view, where it would stand in front of the layer that narrows + it and leave the policy with nothing to do. + """ + + def get_queryset(self): + """Return the retirement records, before the policy narrows them to the caller's.""" + return retirements() + + #: Who may make each request the retirement collection serves. Cancelling a #: retirement is the one operation of this collection that is not pipeline #: work: it undoes a request rather than carrying it out, so it is open to @@ -2590,7 +2648,9 @@ class RetirementViewSet( _ActionAddressMixin, _ResponsePresetMixin, StrictQueryParametersMixin, + _RetirementRecordAccessMixin, ScopedQuerysetMixin, + _RetirementRecordsMixin, viewsets.GenericViewSet, ): """ @@ -2605,8 +2665,11 @@ class RetirementViewSet( run. Access is layered: the permission classes decide who may call an action at - all, the scoping policy decides which retirements such a caller may see, - and the filter set narrows those further by the caller's query string. + all, the visibility policy decides which retirements such a caller may act + on, and the filter set narrows the page a list carries further by the + caller's query string. Every action resolves the records it reads or + changes from the selection the policy left, a member address included, so a + retirement the policy leaves out is reported as absent rather than refused. Cancelling a retirement carries a different capability from the rest, because it undoes a request rather than carrying one out. @@ -2619,18 +2682,9 @@ class RetirementViewSet( must not reach the other account of such a pair. """ - permission_classes = (permissions.IsAuthenticated,) - # The operations these addresses replace read a body as JSON alone, and - # one of them erases personal data for good, so the same narrowing is kept - # rather than widened to every media type the platform can parse. - parser_classes = (JSONParser,) serializer_class = RetirementSerializer pagination_class = UserApiPagination - queryset = retirements() - # Any caller holding the pipeline's capability may read every retirement, - # so the policy keeps the queue whole; a narrower policy can replace it - # without touching the filtering below. - scoping_policy = FullScopePolicy() + action_permissions = RETIREMENT_ACTION_PERMISSIONS lookup_field = "username" # The router's default lookup pattern excludes dots, which usernames allow. lookup_value_regex = settings.USERNAME_REGEX_PARTIAL @@ -2646,19 +2700,6 @@ class RetirementViewSet( response_presets = RETIREMENT_PRESETS extra_query_params = RESPONSE_SHAPING_PARAMS - def get_permissions(self): - """Return the classes deciding who may make the request being served.""" - if self.action is None: - # No action serves this method at this address. That is not a - # refusal of the caller, so the request is admitted by the classes - # every action shares and answered as a method this address does - # not serve, which is what the earlier addresses answer. - return [permission() for permission in self.permission_classes] - # An action this mapping does not name reaches no capability of this - # collection, rather than inheriting the one every other action shares. - specific = RETIREMENT_ACTION_PERMISSIONS.get(self.action, (~permissions.AllowAny,)) - return [permission() for permission in (*self.permission_classes, *specific)] - def known_query_params(self): """Return the query parameters the action being served reads.""" names = super().known_query_params() @@ -2684,7 +2725,7 @@ def list(self, request, *args, **kwargs): def retrieve(self, request, username): """Return the retirement of the account this name addresses.""" - retirement = retirement_of_the_account_named(username) + retirement = retirement_of_the_account_named(self.get_queryset(), username) if retirement is None: log.info("No retirement is held for an account addressed as %r.", username) raise NotFound(NO_SUCH_RETIREMENT) @@ -2695,7 +2736,7 @@ def partial_update(self, request, username): changes = RetirementUpdateSerializer(data=request.data) changes.is_valid(raise_exception=True) move_retirement( - retirement_requested_as(username), + retirement_requested_as(self.get_queryset(), username), changes.validated_data["new_state"], changes.validated_data["response"], changes.validated_data["force"], @@ -2704,7 +2745,7 @@ def partial_update(self, request, username): def destroy(self, request, username): """Undo this retirement request and return the account to its owner.""" - cancel_retirement(retirement_requested_as(username)) + cancel_retirement(retirement_requested_as(self.get_queryset(), username)) return Response(status=status.HTTP_204_NO_CONTENT) @action(detail=True, methods=["post"], url_path="erasures", url_name="erasure") @@ -2712,11 +2753,36 @@ def erasures(self, request, username): """Erase one set of the retiring account's personal data.""" erasure = RetirementErasureSerializer(data=request.data) erasure.is_valid(raise_exception=True) - erase_account_data(retirement_open_to_action(username), erasure.validated_data["scope"]) + erase_account_data( + retirement_open_to_action(self.get_queryset(), username), + erasure.validated_data["scope"], + ) return Response(status=status.HTTP_204_NO_CONTENT) +class _PartnerReportAccessMixin: + """ + Who may act on the partner report entries, and which of those entries a caller acts on. + + The entries this version queues and takes for processing are the entries + its batch disposal deletes, so the capability every operation of them asks + for and the policy deciding which of them a caller acts on are declared + here, where all of them resolve them. A rule added here is asked of the + disposal as well as of the queue. Which entries each operation starts from + differs -- one takes those nobody has taken, the other disposes of those + that have been -- so that selection is declared by each of them, in a + class resolved after the library mixin that reads the policy. + """ + + permission_classes = (permissions.IsAuthenticated, CanRetireUser) + # The operations these addresses replace read a body as JSON alone, and + # that narrowing is kept rather than widened to every media type the + # platform can parse. + parser_classes = (JSONParser,) + scoping_policy = FullScopePolicy() + + class _UnclaimedPartnerReportsMixin: """ The partner reports waiting to be processed, before they are narrowed to the ones a caller may see. @@ -2732,6 +2798,20 @@ def get_queryset(self): return unclaimed_partner_reports() +class _EveryPartnerReportMixin: + """ + Every partner report entry, before they are narrowed to the ones a caller may act on. + + Declared in a class of its own for the same reason as the queue's own + selection, and separately from it because an entry is disposed of once it + has been taken for processing, which is what the queue leaves out. + """ + + def get_queryset(self): + """Return every partner report entry, taken for processing or not.""" + return partner_reports() + + _RESP_PARTNER_REPORTS_TAKEN = OpenApiResponse( RetirementPartnerReportRecordSerializer(many=True), description=( @@ -2793,6 +2873,7 @@ class RetirementPartnerReportViewSet( ErrorExtensionsMixin, _ActionAddressMixin, StrictQueryParametersMixin, + _PartnerReportAccessMixin, ScopedQuerysetMixin, _UnclaimedPartnerReportsMixin, viewsets.ViewSet, @@ -2814,15 +2895,9 @@ class RetirementPartnerReportViewSet( so that the cost of the answer does not grow with the number of entries. """ - permission_classes = (permissions.IsAuthenticated, CanRetireUser) - # The operations this address replaces read a body as JSON alone, and that - # narrowing is kept rather than widened to every media type the platform - # can parse. - parser_classes = (JSONParser,) serializer_class = RetirementPartnerReportRecordSerializer #: Validates the query string, and names the parameters this view reads. query_serializer_class = RetirementPartnerReportQuerySerializer - scoping_policy = FullScopePolicy() lookup_field = "username" # The router's default lookup pattern excludes dots, which usernames allow. lookup_value_regex = settings.USERNAME_REGEX_PARTIAL @@ -2848,7 +2923,10 @@ def create(self, request): def update(self, request, username): """Queue the retiring account this name addresses for partner reporting.""" - record_partner_report(retirement_open_to_action(username)) + # The record this queues belongs to the retirement collection rather + # than to this one, whose own selection is of queue entries, so the + # records it is resolved from are named here. + record_partner_report(retirement_open_to_action(retirements(), username)) return Response(status=status.HTTP_204_NO_CONTENT) @@ -2863,7 +2941,7 @@ def update(self, request, username): @extend_schema(tags=[USER_TAG]) @extend_schema_view( - post=extend_schema( + create=extend_schema( summary="Delete finished retirement records", description=( "Deletes the retirement records of the accounts named, once the pipeline has " @@ -2883,47 +2961,51 @@ def update(self, request, username): }, ), ) -class RetirementCleanupView( +class RetirementCleanupViewSet( StandardizedErrorMixin, CatalogedRequestErrorsMixin, ErrorExtensionsMixin, StrictQueryParametersMixin, - APIView, + _RetirementRecordAccessMixin, + ScopedQuerysetMixin, + _RetirementRecordsMixin, + viewsets.ViewSet, ): """ - Deletes a set of finished retirement records. - - This is a plain view rather than part of the retirement collection: it is - one operation over a set of records named in its body, with an invariant - that covers the whole set, and it has no member address of its own. It is - not the collection's own delete either, which undoes a single request that - has not been acted on; this one disposes of records the pipeline has - finished with, and the two carry different capabilities and act on - retirements at opposite ends of the pipeline. + Disposes of a set of finished retirement records. + + The operation is one over a set of records named in its body, with an + invariant that covers the whole set, and it has no member address of its + own, so it stands at a collection of its own rather than among the + operations on one record. It is not the retirement collection's own + delete, which undoes a single request that has not been acted on; this one + disposes of records the pipeline has finished with, and the two carry + different capabilities and act on retirements at opposite ends of the + pipeline. Who may call it and which records it deletes are resolved + through the layers those operations resolve them through, so a rule added + to the records is asked of their disposal too. """ - permission_classes = (permissions.IsAuthenticated, CanRetireUser) - # The operations this address replaces read a body as JSON alone, and that - # narrowing is kept rather than widened to every media type the platform - # can parse: these operations dispose of records for good. - parser_classes = (JSONParser,) serializer_class = RetirementBatchOutcomeSerializer + #: Disposing of finished records is pipeline work, so it asks for the + #: capability the rest of that work asks for. + action_permissions = {"create": (CanRetireUser,), "metadata": (CanRetireUser,)} def get_serializer(self, *args, **kwargs): - """Build the output serializer; a plain view has no generic machinery to do it.""" + """Build the output serializer; a plain ViewSet has no generic machinery to do it.""" return self.serializer_class(*args, **kwargs) - def post(self, request): + def create(self, request): """Delete the retirement records of the accounts the body names.""" batch = RetirementBatchSerializer(data=request.data) batch.is_valid(raise_exception=True) - results = delete_retirements(batch.validated_data["usernames"]) + results = delete_retirements(self.get_queryset(), batch.validated_data["usernames"]) return Response(self.get_serializer({"results": results}).data) @extend_schema(tags=[USER_TAG]) @extend_schema_view( - post=extend_schema( + create=extend_schema( summary="Delete reported partner report entries", description=( "Deletes the partner report entries of the accounts named, once they have been " @@ -2941,38 +3023,37 @@ def post(self, request): }, ), ) -class RetirementPartnerReportCleanupView( +class RetirementPartnerReportCleanupViewSet( StandardizedErrorMixin, CatalogedRequestErrorsMixin, ErrorExtensionsMixin, StrictQueryParametersMixin, - APIView, + _PartnerReportAccessMixin, + ScopedQuerysetMixin, + _EveryPartnerReportMixin, + viewsets.ViewSet, ): """ - Deletes a set of partner report entries that have been reported on. - - This is a plain view rather than part of the partner report collection, - for the same reason as the retirement records' own disposal: one operation - over a set of records named in its body, with an invariant that covers the - whole set, and no member address of its own. The two disposals are kept - apart because they act on different records in different tables, under - different conditions. + Disposes of a set of partner report entries that have been reported on. + + The operation stands at a collection of its own for the same reason as the + retirement records' own disposal: one operation over a set of records + named in its body, with an invariant that covers the whole set, and no + member address of its own. The two disposals are kept apart because they + act on different records in different tables, under different conditions. + Who may call this one and which entries it deletes are resolved through + the layers the operations on those entries resolve them through. """ - permission_classes = (permissions.IsAuthenticated, CanRetireUser) - # The operations this address replaces read a body as JSON alone, and that - # narrowing is kept rather than widened to every media type the platform - # can parse: these operations dispose of records for good. - parser_classes = (JSONParser,) serializer_class = RetirementBatchOutcomeSerializer def get_serializer(self, *args, **kwargs): - """Build the output serializer; a plain view has no generic machinery to do it.""" + """Build the output serializer; a plain ViewSet has no generic machinery to do it.""" return self.serializer_class(*args, **kwargs) - def post(self, request): + def create(self, request): """Delete the partner report entries of the accounts the body names.""" batch = RetirementBatchSerializer(data=request.data) batch.is_valid(raise_exception=True) - results = delete_partner_reports(batch.validated_data["usernames"]) + results = delete_partner_reports(self.get_queryset(), batch.validated_data["usernames"]) return Response(self.get_serializer({"results": results}).data) From ae21ab503a861cd0ba1dfbcad064282e20175a8d Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Wed, 28 Oct 2026 13:04:42 +0500 Subject: [PATCH 49/82] fix: publish the partner-report entry the claim can actually produce The record published two properties the handler has no way to answer: it is built from the queue entry and from the enrolments of the account it names, and neither carries an identifier the deployment knows the user by or a per-organization reporting configuration. Both are removed, the class docstring no longer promises them, and a test holds the published property set equal to the set a claim really answers, so the two cannot drift apart again. Selecting properties reaches the top level only: a name written as a path, such as `user.email`, is read as `user` and the whole embedded account comes back. That is the shaping helper's own behaviour, shared with addresses already published, so it is stated in the parameter's description and pinned by a test rather than changed under a caller who is relying on it. One test literal named a step this deployment does not run, so the refusal it asserted could only ever have come from the media type it sends. It now names a step that is configured, which is what makes the assertion say what it claims to. --- .../djangoapps/user_api/v2/serializers.py | 13 ++------ .../v2/tests/test_retirement_views.py | 17 ++++++++-- .../user_api/v2/tests/test_schema.py | 33 +++++++++++++++++++ openedx/core/djangoapps/user_api/v2/views.py | 9 +++-- 4 files changed, 55 insertions(+), 17 deletions(-) diff --git a/openedx/core/djangoapps/user_api/v2/serializers.py b/openedx/core/djangoapps/user_api/v2/serializers.py index 82ff5389080b..7c1b369730e7 100644 --- a/openedx/core/djangoapps/user_api/v2/serializers.py +++ b/openedx/core/djangoapps/user_api/v2/serializers.py @@ -1362,15 +1362,12 @@ class RetirementPartnerReportRecordSerializer(serializers.Serializer): # pylint """ What a partner needs in order to forget one retired user. - ``student_id`` and ``orgs_config`` are carried only by a deployment that - records them, and are left out of the answer otherwise. + Every property is answered for every entry: the record is built from the + queue entry and from the enrolments of the account it names, so there is + nothing in it that a deployment may or may not supply. """ user_id = serializers.IntegerField(help_text="Database identifier of the retired user.") - student_id = serializers.CharField( - required=False, - help_text="Identifier the deployment knows the user by, where it records one.", - ) original_username = serializers.CharField(help_text="Name the account was stored under before retirement.") original_email = serializers.EmailField(help_text="Address the account was stored under before retirement.") original_name = serializers.CharField( @@ -1381,10 +1378,6 @@ class RetirementPartnerReportRecordSerializer(serializers.Serializer): # pylint child=serializers.CharField(), help_text="Short names of the organizations running the courses this user was enrolled in.", ) - orgs_config = serializers.ListField( - required=False, - help_text="Per-organization reporting configuration, where the deployment records any.", - ) created = serializers.DateTimeField(help_text="When this user was added to the partner reporting queue.") diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py index b8061ebe8c5b..a47f62c9e64e 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py @@ -70,7 +70,10 @@ from openedx.core.djangoapps.user_api.v2 import view_services from openedx.core.djangoapps.user_api.v2.errors import AmbiguousAccountName, RetirementStateConflict from openedx.core.djangoapps.user_api.v2.paginators import UserApiPagination -from openedx.core.djangoapps.user_api.v2.serializers import RETIREMENT_MINIMAL_FIELDS +from openedx.core.djangoapps.user_api.v2.serializers import ( + RETIREMENT_MINIMAL_FIELDS, + RetirementAccountSerializer, +) from openedx.core.djangoapps.user_api.v2.views import ( RETIREMENT_ACTION_PERMISSIONS, RetirementCleanupViewSet, @@ -1020,6 +1023,15 @@ def test_properties_can_be_selected(self): response = self.client.get(self.url, {"fields": "current_state"}) assert response.data == {"current_state": "LOCKING_ACCOUNT"} + def test_a_property_asked_for_as_a_path_answers_the_whole_of_its_first_part(self): + """Selection reaches the top level only, which is what the parameter's description says.""" + self.login("worker") + response = self.client.get(self.url, {"fields": "user.email"}) + assert set(response.data) == {"user"} + assert set(response.data["user"]) == set( + RetirementAccountSerializer().fields, + ), "the whole embedded account comes back, not the one property asked for" + def test_the_parameters_that_cut_a_page_are_not_read_at_a_member_address(self): self.login("worker") envelope = assert_error_envelope( @@ -2830,10 +2842,11 @@ def test_the_erasure_this_one_replaces_refuses_the_same_body(self): assert self.subject.username == "subject" def test_a_form_body_is_refused_when_moving_a_retirement(self): + """The body names a step this deployment runs, so only its media type can refuse it.""" self.login("worker") self.assert_the_body_was_unreadable( self.client.patch( - self.detail_url, "new_state=RETIRING_LMS_COMPLETE&response=done", + self.detail_url, "new_state=LMS_COMPLETE&response=done", content_type=self.FORM, ), ) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py index 2912822df49e..aca7f6de9fe8 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py @@ -27,6 +27,7 @@ get_profile_image_storage, ) from openedx.core.djangoapps.user_api.accounts.utils import ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH +from openedx.core.djangoapps.user_api.models import UserRetirementPartnerReportingStatus from openedx.core.djangoapps.user_api.schema import ( RENAMED_OPERATION_IDS, SUPERSEDED_USER_PATHS, @@ -47,6 +48,7 @@ TimeZoneQuerySerializer, VerificationQuerySerializer, ) +from openedx.core.djangoapps.user_api.v2.view_services import claim_partner_reports from openedx.core.djangoapps.user_api.v2.views import ( PREFERENCE_KEY_PATTERN_PARTIAL, RETIREMENT_SORT_FIELDS, @@ -2589,6 +2591,37 @@ def test_the_claim_answers_with_an_array_rather_than_a_page(schema): } +@skip_unless_lms +def test_selecting_properties_publishes_how_far_a_name_reaches(schema): + """A caller is told that a dotted name selects the whole of its first part.""" + for path, method in ( + (RETIREMENT_MEMBER_PATH, "get"), + (RETIREMENT_LIST_PATH, "get"), + ): + described = { + parameter["name"]: parameter["description"] + for parameter in schema["paths"][path][method]["parameters"] + }["fields"] + assert "`user.email`" in described, (path, method) + assert "the whole of that property is returned" in described, (path, method) + + +@skip_unless_lms +def test_the_claim_publishes_exactly_the_properties_an_entry_carries(schema): + """An entry is published with the properties it is answered with, and with no others.""" + user = UserFactory.create() + UserRetirementPartnerReportingStatus.objects.create( + user=user, + original_username=user.username, + original_email=user.email, + original_name=user.profile.name, + ) + answered = set(claim_partner_reports(UserRetirementPartnerReportingStatus.objects.all())[0]) + record = schema["components"]["schemas"]["RetirementPartnerReportRecord"] + assert set(record["properties"]) == answered + assert set(record["required"]) == answered + + @skip_unless_lms def test_the_queue_answers_with_a_page(schema): body = schema["paths"][RETIREMENT_LIST_PATH]["get"]["responses"]["200"] diff --git a/openedx/core/djangoapps/user_api/v2/views.py b/openedx/core/djangoapps/user_api/v2/views.py index d755adda3147..a1d9e159bd6d 100644 --- a/openedx/core/djangoapps/user_api/v2/views.py +++ b/openedx/core/djangoapps/user_api/v2/views.py @@ -230,7 +230,9 @@ _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.", + "A name the response cannot carry is refused. Selection reaches the top level only: " + "a name written as a path, such as `user.email`, is read as `user`, and the whole of " + "that property is returned.", ) #: The presets each list answers to, published as the parameter's enum and #: accepted by the view from the same declaration. @@ -2676,10 +2678,7 @@ class RetirementViewSet( How a name is resolved follows the operation. A read accepts either the name the account was registered under or the hashed name it was moved to, because both address the same account. A write accepts the registered name - alone and compares it character for character: stored names are compared - without regard to case in some deployments, which once let a learner - register a retired name again by changing its capitalisation, and a write - must not reach the other account of such a pair. + alone, matched exactly, so that it reaches one account and no other. """ serializer_class = RetirementSerializer From feb923052a315422621e7ced292861d4c23cede5 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Wed, 28 Oct 2026 18:40:33 +0500 Subject: [PATCH 50/82] fix: judge the record a request resolved, and let a document keep its own settings Two defects, both of which a test could not have caught as the tests stood. The gate that decides whether a retirement admits pipeline work was asked about the name a caller sent rather than about the record that name had already resolved to, by a model-layer call that reads the whole table. Where the records a view may act on are narrower than the table -- which the class invites, and says is applied by every operation it serves -- the row judged and the row worked on are different rows, and an erasure destroys an account the gate exists to refuse. The step check is now a method on the record, with the predicate, the message and the exception it always had, and the classmethod calls it after resolving a name, so the superseded version behaves exactly as before. The queue write that takes a record from another collection resolves it through the same narrowing rather than from the whole table. Two smaller losses of the superseded behaviour go with them: the delete that disposes of finished retirements states the step again, because a record moved off it between the redaction and the delete is one the caller was never answered about; and the name a second erasure falls back to is the name the account was registered under rather than the one it holds by then, which is a hash of it. The hold this schema takes on one generation setting no longer runs through the library's shared patch table, which restores and forgets every patch in it rather than its own. A document generated under settings of its own -- its title, its version and the address to write to about it -- lost all of them the moment a body of this kind was built, and the service answered with the fallback title and version while the file checked in beside it kept the real ones. A setting nothing had asked for is left unasked for afterwards, so a later change to a deployment's settings still reaches it. --- lms/lib/tests/test_spectacular.py | 162 ++++-------- openedx/core/djangoapps/user_api/models.py | 22 +- openedx/core/djangoapps/user_api/schema.py | 32 ++- .../v2/tests/test_retirement_views.py | 248 ++++++++++++++++++ .../user_api/v2/tests/test_selectors.py | 1 - .../djangoapps/user_api/v2/view_services.py | 25 +- openedx/core/djangoapps/user_api/v2/views.py | 15 +- 7 files changed, 376 insertions(+), 129 deletions(-) diff --git a/lms/lib/tests/test_spectacular.py b/lms/lib/tests/test_spectacular.py index d390f1a06d01..957a8438bf95 100644 --- a/lms/lib/tests/test_spectacular.py +++ b/lms/lib/tests/test_spectacular.py @@ -160,18 +160,6 @@ def test_only_the_listed_user_addresses_are_deprecated(self): "/api/user/v1/preferences/{username}", "/api/user/v1/preferences/{username}/{preference_key}", "/api/user/v1/preferences/email_opt_in/", - "/api/user/v1/accounts/{username}/retirement_status/", - "/api/user/v1/accounts/retirement_queue/", - "/api/user/v1/accounts/retirements_by_status_and_date/", - "/api/user/v1/accounts/update_retirement_status/", - "/api/user/v1/accounts/retirement_cleanup/", - "/api/user/v1/accounts/retirement_partner_report/", - "/api/user/v1/accounts/retirement_partner_report_cleanup/", - "/api/user/v1/accounts/cancel_retirement/", - "/api/user/v1/accounts/retire/", - "/api/user/v1/accounts/retire_misc/", - "/api/user/v1/accounts/deactivate_logout/", - "/api/user/v1/accounts/replace_usernames/", "/api/profile_images/v1/{username}/upload", "/api/user/v2/users/", "/api/user/v2/accounts/{username}/profile_image/", @@ -197,23 +185,9 @@ def test_only_the_listed_user_addresses_are_deprecated(self): "/api/user/v1/preferences/{username}", "/api/user/v1/preferences/{username}/{preference_key}", "/api/user/v1/preferences/email_opt_in/", - "/api/user/v1/accounts/{username}/retirement_status/", - "/api/user/v1/accounts/retirement_queue/", - "/api/user/v1/accounts/retirements_by_status_and_date/", - "/api/user/v1/accounts/update_retirement_status/", - "/api/user/v1/accounts/retirement_cleanup/", - "/api/user/v1/accounts/retirement_partner_report/", - "/api/user/v1/accounts/retirement_partner_report_cleanup/", - "/api/user/v1/accounts/cancel_retirement/", - "/api/user/v1/accounts/retire/", - "/api/user/v1/accounts/retire_misc/", ): assert paths[marked]["get"]["deprecated"] is True, marked for untouched in ( - # The two operations of this area whose caller is not the pipeline - # have no conforming address yet, so neither is marked. - "/api/user/v1/accounts/deactivate_logout/", - "/api/user/v1/accounts/replace_usernames/", "/api/profile_images/v1/{username}/upload", "/api/user/v2/users/", "/api/user/v2/accounts/{username}/profile_image/", @@ -293,15 +267,6 @@ def _user_routes(schema_class): view.as_view({"get": "retrieve"}), ), path("api/user/v1/users/", view.as_view({"get": "list"})), - # Both reads of the retirement queue answer with an array of records, - # which is what the generator derives a list operation id from, while - # the service-wide document has published each under the name derived - # for a single record. - path("api/user/v1/accounts/retirement_queue/", view.as_view({"get": "list"})), - path( - "api/user/v1/accounts/retirements_by_status_and_date/", - view.as_view({"get": "list"}), - ), path("api/user/v2/accounts//preferences/", view.as_view({"get": "retrieve"})), path( "api/user/v2/accounts//preferences//", @@ -370,10 +335,6 @@ def test_the_service_wide_document_keeps_every_name_the_generator_derives(self): "/api/user/v1/preferences/{username}/{preference_key}": ("api_user_v1_preferences_retrieve_2", ("api",)), "/api/user/v1/users/": ("api_user_v1_users_list", ("api",)), - "/api/user/v1/accounts/retirement_queue/": - ("api_user_v1_accounts_retirement_queue_retrieve", ("api",)), - "/api/user/v1/accounts/retirements_by_status_and_date/": - ("api_user_v1_accounts_retirements_by_status_and_date_retrieve", ("api",)), "/api/user/v2/accounts/{username}/preferences/": ("api_user_v2_accounts_preferences_retrieve", ("api",)), "/api/user/v2/accounts/{username}/preferences/{key}/": @@ -399,10 +360,6 @@ def test_a_user_address_is_grouped_by_area_where_the_derived_tag_would_name_a_ve assert _named_operations(LmsAutoSchema, {}) == { "/api/user/v1/accounts": ("v1_accounts_list", ("user",)), "/api/user/v1/accounts/{username}": ("v1_accounts_retrieve", ("user",)), - "/api/user/v1/accounts/retirement_queue/": ("v1_accounts_retirement_queue_list", ("user",)), - "/api/user/v1/accounts/retirements_by_status_and_date/": ( - "v1_accounts_retirements_by_status_and_date_list", ("user",), - ), "/api/user/v1/preferences/{username}": ("v1_preferences_retrieve", ("user",)), "/api/user/v1/preferences/{username}/{preference_key}": ( "v1_preferences_retrieve_one", ("user",), @@ -435,18 +392,6 @@ def test_a_user_address_is_grouped_by_area_where_the_derived_tag_would_name_a_ve ), } -#: What the cancellation answers with, which it published nothing for. -CANCELLATION_ANSWER = { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "description": "True once the retirement request has been undone.", - }, - }, - "required": ["success"], -} - #: 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. @@ -477,32 +422,6 @@ def test_a_user_address_is_grouped_by_area_where_the_derived_tag_would_name_a_ve ("/api/user/v1/preferences/email_opt_in/", "post"): ( ["application/x-www-form-urlencoded"], {"200": None}, ), - # The retirement pipeline. Of its ten addresses only the read of one - # record published what its handler really answers with, and it keeps - # what it has always published. - ("/api/user/v1/accounts/retirement_queue/", "get"): ([], {"200": "UserRetirementStatus[]"}), - ("/api/user/v1/accounts/retirements_by_status_and_date/", "get"): ( - [], {"200": "UserRetirementStatus[]"}, - ), - ("/api/user/v1/accounts/update_retirement_status/", "patch"): ( - ["application/json"], {"204": None}, - ), - ("/api/user/v1/accounts/retirement_cleanup/", "post"): (["application/json"], {"204": None}), - ("/api/user/v1/accounts/retirement_partner_report/", "post"): ( - [], {"200": "UserRetirementPartnerReport[]"}, - ), - ("/api/user/v1/accounts/retirement_partner_report/", "put"): ( - ["application/json"], {"204": None}, - ), - ("/api/user/v1/accounts/retirement_partner_report_cleanup/", "post"): ( - ["application/json"], {"204": None}, - ), - ("/api/user/v1/accounts/cancel_retirement/", "post"): ( - ["application/json", "application/x-www-form-urlencoded", "multipart/form-data"], - {"200": CANCELLATION_ANSWER}, - ), - ("/api/user/v1/accounts/retire/", "post"): (["application/json"], {"204": None}), - ("/api/user/v1/accounts/retire_misc/", "post"): (["application/json"], {"204": None}), } @@ -557,33 +476,6 @@ def test_a_body_one_of_the_published_forms_requires_is_published_as_required(sel "image/gif", "image/jpeg", "image/pjpeg", "image/png", "multipart/form-data", ] - def test_a_body_written_out_as_a_shape_is_needed_where_the_shape_says_it_is(self): - """ - Each form of a body is read for what it says it cannot be sent without. - - A shape written out is published as it stands, so nothing has judged - whether its operation can be called with no body at all: the mark is - read from the members the shape requires, or from the members of its - entries where the shape is a list of them. The addresses here belong - to several resources and are written in every form the table uses -- - an object, a list of objects, a submitted form, and a shape named - under every media type the platform reads a body in. - """ - for address, method in ( - ("/api/user/v1/accounts/retirement_cleanup/", "post"), - ("/api/user/v1/accounts/retirement_partner_report_cleanup/", "post"), - ("/api/user/v1/accounts/update_retirement_status/", "patch"), - ("/api/user/v1/preferences/email_opt_in/", "post"), - ("/api/user/v1/accounts/cancel_retirement/", "post"), - ): - body = self.schema["paths"][address][method]["requestBody"] - assert body["required"] is True, (address, method) - - # The changes to a preference document name no member the body cannot - # be sent without, so that one is not published as needed. - changes = self.schema["paths"]["/api/user/v1/preferences/{username}"]["patch"]["requestBody"] - assert changes.get("required", False) is False - 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 = [ @@ -870,6 +762,60 @@ def test_every_document_marks_a_body_the_handler_refuses_to_work_without(self): } +class ServiceWideDocumentSettingsTest(TestCase): + """ + The document covering the whole service keeps the settings it is generated under. + + Its title, its version and its description are given to the generator for + the length of the generation rather than configured for the deployment, + and a part of the document is built under a setting held for that part + alone. What the document says about itself is read here beside that part, + because a hold let go of too widely takes the rest with it and the + document would describe itself as something else. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.given = get_api_docs_settings() + with patched_settings(cls.given): + cls.schema = SchemaGenerator().get_schema(request=None, public=True) + + def test_the_document_says_of_itself_what_it_was_generated_with(self): + expected = { + "title": self.given["TITLE"], + "version": self.given["VERSION"], + "description": self.given["DESCRIPTION"], + } + if self.given.get("CONTACT"): + expected["contact"] = self.given["CONTACT"] + assert self.schema["info"] == expected + + def test_the_document_names_no_server_to_send_callers_to(self): + """It is generated with none named, and a document naming one points at that host.""" + assert "servers" not in self.schema + + def test_the_body_read_whole_is_published_whole_in_that_document_too(self): + """Building this is what holds a setting while the rest of the document is built.""" + body = self.schema["paths"]["/api/user/v2/retirements/{username}/"]["patch"]["requestBody"] + assert body["required"] is True + assert body["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/RetirementUpdate", + } + assert self.schema["components"]["schemas"]["RetirementUpdate"]["required"] == [ + "new_state", "response", + ] + assert "PatchedRetirementUpdate" not in self.schema["components"]["schemas"] + + def test_a_body_carrying_changes_keeps_the_optional_form_there(self): + """The setting held for the body above is let go of, so the next body is built without it.""" + changes = self.schema["paths"]["/api/user/v1/accounts/{username}"]["patch"]["requestBody"] + assert changes.get("required", False) is False + assert "required" not in self.schema["components"]["schemas"]["PatchedAccountUpdate"] + + + + class PublishedDocumentTest(TestCase): """What the service actually publishes: distinct ids, and one area per tag group.""" diff --git a/openedx/core/djangoapps/user_api/models.py b/openedx/core/djangoapps/user_api/models.py index 7fd08dcbe110..3b8a464061a7 100644 --- a/openedx/core/djangoapps/user_api/models.py +++ b/openedx/core/djangoapps/user_api/models.py @@ -382,6 +382,22 @@ def update_state(self, update): self.responses += "\n Moved from {} to {}:\n{}\n".format(old_state, self.current_state, update['response']) self.save() + def check_state_allows_retirement_action(self): + """ + Raise RetirementStateError unless this row's state is acceptable to be acted upon. + + The row should be in a "working state" (not a dead end state, PENDING, or *_COMPLETE), + which is what keeps a step of the retirement from being run twice over one account. + + A caller that has already resolved the row it means to act on gates that row with this, + rather than resolving the name a second time, so that the row judged is the row acted on. + """ + state = self.current_state + + if state.required or state.state_name.endswith('_COMPLETE'): + raise RetirementStateError('{} is in {}, not a valid state to perform retirement ' # noqa: UP032 + 'actions on.'.format(self, state.state_name)) + @classmethod def get_retirement_for_retirement_action(cls, username): """ @@ -410,11 +426,7 @@ def get_retirement_for_retirement_action(cls, username): raise UserRetirementStatus.DoesNotExist('{} does not have an exact match in UserRetirementStatus. ' # noqa: UP032 # pylint: disable=line-too-long '{} similar rows found.'.format(username, len(retirements))) - state = retirement.current_state - - if state.required or state.state_name.endswith('_COMPLETE'): - raise RetirementStateError('{} is in {}, not a valid state to perform retirement ' # noqa: UP032 - 'actions on.'.format(retirement, state.state_name)) + retirement.check_state_allows_retirement_action() return retirement diff --git a/openedx/core/djangoapps/user_api/schema.py b/openedx/core/djangoapps/user_api/schema.py index 69507b4c0911..a5744dd1fd19 100644 --- a/openedx/core/djangoapps/user_api/schema.py +++ b/openedx/core/djangoapps/user_api/schema.py @@ -2,10 +2,12 @@ import re from collections import namedtuple +from contextlib import contextmanager from functools import cache from importlib import import_module from drf_spectacular.plumbing import force_instance +from drf_spectacular.settings import spectacular_settings from drf_spectacular.utils import OpenApiResponse from rest_framework.settings import api_settings @@ -146,6 +148,34 @@ # representation does not make them optional. WHOLE_BODY_ATTRIBUTE = "reads_the_whole_body" +#: Stands for a generation setting that has not been asked for a value yet. +_NOT_HELD = object() + + +@contextmanager +def _generation_setting(name, value): + """ + Hold one schema generation setting at this value for the length of the block. + + Only this setting is touched, and only this block's hold on it is let go + of, so a document being generated under settings of its own -- its title, + its version and the address to write to about it are given that way -- + keeps every one of them while a part of it is built under this setting. A + setting nothing had asked for is left unasked for afterwards rather than + fixed at whatever it stood at here, so that a later change to the + deployment's settings still reaches it. + """ + held = spectacular_settings.__dict__.get(name, _NOT_HELD) + setattr(spectacular_settings, name, value) + try: + yield + finally: + if held is _NOT_HELD: + spectacular_settings.__dict__.pop(name, None) + else: + setattr(spectacular_settings, name, held) + + _PATH_PARAMETER = re.compile(r"\{([\w-]+)\}") # The superseded addresses with their parameter names removed. The generator @@ -522,7 +552,7 @@ def _get_request_for_media_type(self, serializer, direction="request"): request the operation refuses. """ if self.method == "PATCH" and getattr(force_instance(serializer), WHOLE_BODY_ATTRIBUTE, False): - with patched_settings({"COMPONENT_SPLIT_PATCH": False}): + with _generation_setting("COMPONENT_SPLIT_PATCH", False): return super()._get_request_for_media_type(serializer, direction) return super()._get_request_for_media_type(serializer, direction) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py index a47f62c9e64e..1c345d837c83 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py @@ -55,6 +55,7 @@ ) from common.djangoapps.util.model_utils import USER_FIELDS_CHANGED from openedx.core.djangoapps.user_api.accounts.permissions import CanCancelUserRetirement, CanRetireUser +from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_LMS_CRITICAL from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import ( # pylint: disable=unused-import create_retirement_status, setup_retirement_states, # noqa: F401 @@ -1409,6 +1410,33 @@ def test_the_miscellaneous_scope_erases_what_the_features_around_the_account_hol assert self.subject.username == "subject" assert self.subject.is_active is True + def test_a_second_run_works_the_replacement_name_out_from_the_registered_name(self): + """ + The record holds no replacement name of its own, so the step works one out. + + It works it out from the name the account was registered under, which the record keeps, + rather than from the name the account holds, which this very step has already replaced. + """ + UserRetirementStatus.objects.filter(id=self.retirement.id).update(retired_username="") + announced = [] + USER_RETIRE_LMS_CRITICAL.connect( + lambda sender, **kwargs: announced.append(kwargs["retired_username"]), + weak=False, + dispatch_uid="replacement-name-of-each-run", + ) + self.addCleanup( + USER_RETIRE_LMS_CRITICAL.disconnect, dispatch_uid="replacement-name-of-each-run", + ) + self.login("worker") + assert self.send("lms_core").status_code == status.HTTP_204_NO_CONTENT + self.subject.refresh_from_db() + written = self.subject.username + assert written == get_retired_username_by_username(self.retirement.original_username) + assert self.send("lms_core").status_code == status.HTTP_204_NO_CONTENT + self.subject.refresh_from_db() + assert self.subject.username == written + assert announced == [written, written] + def test_the_erasure_does_not_move_the_retirement(self): self.login("worker") self.send("lms_core") @@ -1805,6 +1833,30 @@ def remember_the_values_the_row_holds(*args, **kwargs): # The last selection is the delete, which sees the written-over values. assert seen[-1] == [(view_services.REDACTED_VALUE, view_services.REDACTED_EMAIL)] + def test_a_record_moved_off_that_step_between_the_two_statements_is_left(self): + """The caller was answered about a finished record, so only a finished record goes.""" + self.login("worker") + selections = [] + original = UserRetirementStatus.objects.filter + + def move_the_record_before_the_delete_runs(*args, **kwargs): + selection = original(*args, **kwargs) + selections.append(selection) + if len(selections) == 2: + forced = UserRetirementStatus.objects.get(id=self.finished.id) + forced.current_state = self.state("ERRORED") + forced.save() + return selection + + with mock.patch.object( + UserRetirementStatus.objects, "filter", + side_effect=move_the_record_before_the_delete_runs, + ): + answered = self.post(["subject"]) + assert answered.status_code == status.HTTP_200_OK + left = UserRetirementStatus.objects.get(id=self.finished.id) + assert left.current_state.state_name == "ERRORED" + def test_a_repeat_of_a_request_already_carried_out_is_answered(self): self.login("worker") self.post(["subject"]) @@ -2408,6 +2460,189 @@ def test_a_policy_leaving_one_record_of_such_a_pair_leaves_one_record_for_the_na assert twin.current_state.state_name == "RETIRING_LMS" +@skip_unless_lms +class SharedNameUnderANarrowingPolicyTests(RetirementApiTestCase): + """ + Which of two records requested under one name an operation weighs, and which it acts on. + + The name a record keeps of the account it was requested for is not unique, + so two records can stand under one name. A policy that keeps one of them + leaves the name addressing a single record, and that record is the one the + operation is weighed against: the record the policy left out belongs to an + account this caller does not act on, so the step it stands at decides + nothing about the account that does. + """ + + SHARED_NAME = "sharedname" + + def setUp(self): + super().setUp() + self.in_scope = UserFactory.create( + username="in-scope-holder", email="in-scope@example.com", + ) + self.out_of_scope = UserFactory.create( + username="out-of-scope-holder", email="out-of-scope@example.com", + ) + for user, name in ((self.in_scope, "In Scope"), (self.out_of_scope, "Out Of Scope")): + user.profile.name = name + user.profile.city = f"City of {user.username}" + user.profile.save() + self.url = reverse("user_v2:retirement_erasure", kwargs={"username": self.SHARED_NAME}) + + def pair(self, in_scope_step, out_of_scope_step): + """Request a retirement for each account under the one name, standing at these steps.""" + # The record the policy leaves out is requested first, so that it is + # the one a resolution that reads every record held settles on. + self.out_of_scope_record = self.retirement_in(out_of_scope_step, user=self.out_of_scope) + self.in_scope_record = self.retirement_in(in_scope_step, user=self.in_scope) + UserRetirementStatus.objects.filter( + id__in=[self.out_of_scope_record.id, self.in_scope_record.id], + ).update(original_username=self.SHARED_NAME) + self.in_scope_before = self.account(self.in_scope) + self.out_of_scope_before = self.account(self.out_of_scope) + + def account(self, user): + """Return the stored values the core erasure writes over.""" + user.refresh_from_db() + profile = UserProfile.objects.get(user=user) + return { + "username": user.username, + "email": user.email, + "is_active": user.is_active, + "profile_name": profile.name, + "profile_city": profile.city, + } + + @contextlib.contextmanager + def policy_keeping(self, record): + """Serve the next requests under a policy that keeps this record alone.""" + with mock.patch.object( + _RetirementRecordAccessMixin, "scoping_policy", _NarrowingPolicy("id", record.id), + ): + yield + + def erase(self): + """Ask for the core personal data of the account this name addresses to be erased.""" + return self.client.post(self.url, {"scope": "lms_core"}, format="json") + + def test_the_step_the_record_the_policy_keeps_stands_at_refuses_the_erasure(self): + """The other record admits work, which says nothing about the account being acted on.""" + self.pair(in_scope_step="PENDING", out_of_scope_step="RETIRING_LMS") + self.login("worker") + with self.policy_keeping(self.in_scope_record): + answered = self.erase() + # What survives is read first, so that a failure says what was destroyed. + assert self.account(self.in_scope) == self.in_scope_before + assert self.account(self.out_of_scope) == self.out_of_scope_before + envelope = assert_error_envelope( + answered, + expected_status=status.HTTP_409_CONFLICT, + expected_type_slug=STATE_CONFLICT_SLUG, + ) + assert envelope["detail"] == view_services.NOT_OPEN_TO_ACTION + + def test_the_step_the_record_the_policy_keeps_stands_at_admits_the_erasure(self): + """The other record admits no work, which says nothing about the account being acted on.""" + self.pair(in_scope_step="RETIRING_LMS", out_of_scope_step="COMPLETE") + self.login("worker") + with self.policy_keeping(self.in_scope_record): + answered = self.erase() + # What was erased is read first, so that a failure says what was left. + assert self.account(self.in_scope) == { + "username": self.in_scope_record.retired_username, + "email": self.in_scope_record.retired_email, + "is_active": False, + "profile_name": "", + "profile_city": None, + } + assert self.account(self.out_of_scope) == self.out_of_scope_before + assert answered.status_code == status.HTTP_204_NO_CONTENT + + def test_the_account_erased_is_the_one_whose_record_the_policy_keeps(self): + """Both records admit work, so what decides is which of them the name addresses here.""" + self.pair(in_scope_step="RETIRING_LMS", out_of_scope_step="RETIRING_LMS") + self.login("worker") + with self.policy_keeping(self.in_scope_record): + assert self.erase().status_code == status.HTTP_204_NO_CONTENT + assert self.account(self.in_scope)["username"] == self.in_scope_record.retired_username + assert self.account(self.out_of_scope) == self.out_of_scope_before + + def test_the_policy_this_deployment_runs_refuses_the_name_and_erases_nothing(self): + """It keeps the records whole, so the name is carried by two of them and addresses neither.""" + self.pair(in_scope_step="PENDING", out_of_scope_step="RETIRING_LMS") + self.login("worker") + envelope = assert_error_envelope( + self.erase(), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug=AMBIGUOUS_NAME_SLUG, + ) + assert envelope["detail"] == view_services.NAME_IS_AMBIGUOUS + assert self.account(self.in_scope) == self.in_scope_before + assert self.account(self.out_of_scope) == self.out_of_scope_before + + +@skip_unless_lms +class PartnerQueueRecordVisibilityTests(RetirementApiTestCase): + """The retirement a queue write resolves is narrowed by the policy this collection declares.""" + + def setUp(self): + super().setUp() + self.in_scope = self.retirement_in("ADDING_TO_PARTNER_QUEUE", user=self.subject) + self.out_of_scope = self.retirement_in("ADDING_TO_PARTNER_QUEUE", user=self.learner) + for user in (self.subject, self.learner): + CourseEnrollmentFactory.create(user=user, course_id=COURSE_KEY) + self.policy = _NarrowingPolicy("original_username", self.subject.username) + + def queue(self, username): + """Ask for the retiring account this name addresses to be queued for partner reporting.""" + return self.client.put( + reverse("user_v2:retirement_partner_report_detail", kwargs={"username": username}), + {}, + format="json", + ) + + def queued_names(self): + """Return the names every entry of the partner report queue carries.""" + return set( + UserRetirementPartnerReportingStatus.objects.values_list("original_username", flat=True), + ) + + def test_a_record_the_policy_leaves_out_is_not_queued_for_partner_reporting(self): + self.login("worker") + with mock.patch.object(_PartnerReportAccessMixin, "scoping_policy", self.policy): + answered = self.queue(self.learner.username) + # What the queue holds is read first, so that a failure names whose + # personal data a partner would have been handed. + assert self.queued_names() == set() + envelope = assert_error_envelope( + answered, + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + assert envelope["detail"] == view_services.NO_SUCH_RETIREMENT + + def test_the_record_the_policy_keeps_is_queued(self): + self.login("worker") + with mock.patch.object(_PartnerReportAccessMixin, "scoping_policy", self.policy): + answered = self.queue(self.subject.username) + assert answered.status_code == status.HTTP_204_NO_CONTENT + assert self.queued_names() == {self.subject.username} + + def test_the_policy_is_given_the_caller_and_the_retirement_records_it_resolves_from(self): + self.login("worker") + with mock.patch.object(_PartnerReportAccessMixin, "scoping_policy", self.policy): + self.queue(self.subject.username) + assert [caller.username for caller in self.policy.subjects] == [WORKER_USERNAME] + assert self.policy.offered == [ + sorted([self.subject.username, self.learner.username]), + ] + + def test_the_policy_this_deployment_runs_leaves_every_record_queueable(self): + self.login("worker") + assert self.queue(self.learner.username).status_code == status.HTTP_204_NO_CONTENT + assert self.queued_names() == {self.learner.username} + + class RetirementErrorCatalogTests(SimpleTestCase): """What this area adds to the shared error catalog, and what it leaves alone.""" @@ -2987,6 +3222,19 @@ def test_a_caller_without_the_capability_is_refused_where_every_method_carries_o expected_type_slug="authz", ) + def test_an_address_that_serves_one_method_refuses_the_same_caller_instead(self): + """Whoever may call is decided before the method is, so where every method carries the + same capability the caller without it is refused rather than told what is served.""" + self.login("learner") + assert self.client.put( + reverse("accounts_retirement_cleanup"), + ).status_code == status.HTTP_403_FORBIDDEN + assert_error_envelope( + self.send("put", "user_v2:retirement_detail", {"username": "subject"}), + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug="method-not-allowed", + ) + def test_an_anonymous_caller_is_told_to_authenticate(self): self.client.logout() for method, name, kwargs in self.PER_OPERATION + self.PER_VIEW: diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_selectors.py b/openedx/core/djangoapps/user_api/v2/tests/test_selectors.py index 5aee066e0de4..c64f945d4f66 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_selectors.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_selectors.py @@ -26,7 +26,6 @@ retirements, retirements_requested_as, unclaimed_partner_reports, - username_holding_email, username_holding_id, usernames_among, usernames_holding_email, diff --git a/openedx/core/djangoapps/user_api/v2/view_services.py b/openedx/core/djangoapps/user_api/v2/view_services.py index ca94961d88d2..f789e4b68f52 100644 --- a/openedx/core/djangoapps/user_api/v2/view_services.py +++ b/openedx/core/djangoapps/user_api/v2/view_services.py @@ -123,12 +123,10 @@ def retirement_open_to_action(records, username): try: # Which steps admit work is decided by the pipeline's own model layer, # where the order of the steps is kept, rather than being written out a - # second time here. It resolves the name as well, and the name resolved - # above addresses one record, so the two agree on which record that is. - UserRetirementStatus.get_retirement_for_retirement_action(username) - except UserRetirementStatus.DoesNotExist as exc: - log.info("No retirement is requested under the name %r: %s", username, exc) - raise NotFound(NO_SUCH_RETIREMENT) from exc + # second time here. It is asked about the record resolved above rather + # than about the name it was resolved under, so the record this answers + # for is the record the caller is handed to work on. + retirement.check_state_allows_retirement_action() except RetirementStateError as exc: log.info("The retirement requested as %r admits no pipeline work: %s", username, exc) raise RetirementStateConflict(NOT_OPEN_TO_ACTION) from exc @@ -168,7 +166,12 @@ def cancel_retirement(retirement): def _erase_core_data(retirement): """Erase what the platform itself holds about the account being retired.""" user = retirement.user - retired_username = retirement.retired_username or get_retired_username_by_username(user.username) + # The name the account was registered under, rather than the name it holds + # now: this step replaces the one it holds, so a run of it that follows one + # that got that far would otherwise hash a hash. + retired_username = retirement.retired_username or get_retired_username_by_username( + retirement.original_username, + ) retired_email = retirement.retired_email or get_retired_email_by_email(user.email) original_email = retirement.original_email @@ -369,7 +372,13 @@ def delete_retirements(records, usernames): original_email=REDACTED_EMAIL, original_name=REDACTED_VALUE, ) - UserRetirementStatus.objects.filter(id__in=doomed).delete() + # The step is stated again on the delete, because the write above has + # left the records with nothing of the account on them and a record + # moved off that step between the two statements is one the caller was + # never answered about. + UserRetirementStatus.objects.filter( + id__in=doomed, current_state__state_name=COMPLETE_STATE, + ).delete() return _batch_outcomes(usernames, found) diff --git a/openedx/core/djangoapps/user_api/v2/views.py b/openedx/core/djangoapps/user_api/v2/views.py index a1d9e159bd6d..37ea7414bc82 100644 --- a/openedx/core/djangoapps/user_api/v2/views.py +++ b/openedx/core/djangoapps/user_api/v2/views.py @@ -74,7 +74,6 @@ retirement_of_the_account_named, retirements, unclaimed_partner_reports, - username_holding_email, username_holding_id, usernames_among, usernames_holding_email, @@ -2502,10 +2501,12 @@ class _RetirementRecordAccessMixin: def get_permissions(self): """Return the classes deciding who may make the request being served.""" if self.action is None: - # No action serves this method at this address. That is not a - # refusal of the caller, so the request is admitted by the classes + # No action serves this method at this address, so there is no + # capability to ask for: the request is admitted by the classes # every action shares and answered as a method this address does - # not serve, which is what the earlier addresses answer. + # not serve. Each address this replaces serves one method and asks + # its capability of every caller it is given, so a caller without + # that capability is refused there before the method is weighed. return [permission() for permission in self.permission_classes] # An action this mapping does not name reaches no capability of this # resource, rather than inheriting the one every other action shares. @@ -2924,8 +2925,10 @@ def update(self, request, username): """Queue the retiring account this name addresses for partner reporting.""" # The record this queues belongs to the retirement collection rather # than to this one, whose own selection is of queue entries, so the - # records it is resolved from are named here. - record_partner_report(retirement_open_to_action(retirements(), username)) + # records it is resolved from are named here and the policy this view + # declares is applied to them, as it is to everything else it acts on. + records = self.scoping_policy.scope(retirements(), request.user) + record_partner_report(retirement_open_to_action(records, username)) return Response(status=status.HTTP_204_NO_CONTENT) From bdd03bb59e50cc1bdc2589accc18859f8d645aed Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 15:15:20 +0500 Subject: [PATCH 51/82] fix: name the retirement component once, and cover its queue reads in the pin table The documentation settings carried a second copy of the retirement-status component the service settings already append, which two places had to keep identical, and the settings they are built from were asked for it under a key that replaced the service's own. They now name the one definition the service holds and merge the component names under it, so a name appended on either side reaches the published reference, and the check of what those settings append covers both names rather than one. The table of route stand-ins the derived operation names are read from gained the two reads of the retirement queue, so the check that every pinned name replaces a derived one can see the names those two addresses pin. --- docs/docs_settings.py | 40 ++++++++++--------------------- lms/lib/tests/test_spectacular.py | 22 ++++++++++++++++- 2 files changed, 33 insertions(+), 29 deletions(-) diff --git a/docs/docs_settings.py b/docs/docs_settings.py index 2e7ad1c21f1f..5806a532527e 100644 --- a/docs/docs_settings.py +++ b/docs/docs_settings.py @@ -82,39 +82,23 @@ # OpenAPI 3 security schemes, consumed by drf-spectacular. "Basic" uses the # OpenAPI 3 http/basic form rather than Swagger 2.0's type: basic. -# The retirement-status model a partial update of a version 1 retirement reads -# is the only place this component was ever referenced, and that operation now -# publishes the object its handler really reads. A client generated from this -# document names a class after each component, so the name is kept here rather -# than disappearing with its last reference. -PATCHED_USER_RETIREMENT_STATUS = { - "type": "object", - "description": "Perform serialization for the RetirementStatus model", - "properties": { - "id": {"type": "integer", "readOnly": True}, - "user": {"allOf": [{"$ref": "#/components/schemas/RetirementUser"}], "readOnly": True}, - "current_state": {"allOf": [{"$ref": "#/components/schemas/RetirementState"}], "readOnly": True}, - "last_state": {"allOf": [{"$ref": "#/components/schemas/RetirementState"}], "readOnly": True}, - "created": {"type": "string", "format": "date-time", "readOnly": True}, - "modified": {"type": "string", "format": "date-time", "readOnly": True}, - "original_username": {"type": "string", "maxLength": 150}, - "original_email": {"type": "string", "format": "email", "maxLength": 254}, - "original_name": {"type": "string", "maxLength": 255}, - "retired_username": {"type": "string", "maxLength": 150}, - "retired_email": {"type": "string", "format": "email", "maxLength": 254}, - }, -} - +# # Whatever the service settings already append is carried through rather than -# replaced: that key holds component names the published reference is expected -# to carry, and dropping one here takes it out of the generated reference and -# out of every client built from it without changing any count that would show -# it had gone. +# replaced, both the keys beside "schemas" and the component names under it: +# those names are what the published reference is expected to carry, and one +# dropped here goes out of the generated reference, and out of every client +# built from it, without changing any count that would show it had gone. +# +# The retirement-status model is named again here, through the one definition +# the service settings hold, because the reference is expected to carry that +# component whatever else the service appends: the partial update of a version +# 1 retirement was its only reference, and that operation now publishes the +# object its handler really reads. SPECTACULAR_SETTINGS["APPEND_COMPONENTS"] = { # noqa: F405 **SPECTACULAR_SETTINGS.get("APPEND_COMPONENTS", {}), # noqa: F405 "schemas": { **SPECTACULAR_SETTINGS.get("APPEND_COMPONENTS", {}).get("schemas", {}), # noqa: F405 - "PatchedUserRetirementStatus": PATCHED_USER_RETIREMENT_STATUS, + "PatchedUserRetirementStatus": PATCHED_USER_RETIREMENT_STATUS, # noqa: F405 }, "securitySchemes": { "Basic": { diff --git a/lms/lib/tests/test_spectacular.py b/lms/lib/tests/test_spectacular.py index 957a8438bf95..38b9cded3028 100644 --- a/lms/lib/tests/test_spectacular.py +++ b/lms/lib/tests/test_spectacular.py @@ -267,6 +267,15 @@ def _user_routes(schema_class): view.as_view({"get": "retrieve"}), ), path("api/user/v1/users/", view.as_view({"get": "list"})), + # Two reads of the retirement queue that answer with an array, which + # is what the generator derives a list operation id from, and which + # the document that trims nothing off the front has published under + # the name derived for a single record. + path("api/user/v1/accounts/retirement_queue/", view.as_view({"get": "list"})), + path( + "api/user/v1/accounts/retirements_by_status_and_date/", + view.as_view({"get": "list"}), + ), path("api/user/v2/accounts//preferences/", view.as_view({"get": "retrieve"})), path( "api/user/v2/accounts//preferences//", @@ -335,6 +344,10 @@ def test_the_service_wide_document_keeps_every_name_the_generator_derives(self): "/api/user/v1/preferences/{username}/{preference_key}": ("api_user_v1_preferences_retrieve_2", ("api",)), "/api/user/v1/users/": ("api_user_v1_users_list", ("api",)), + "/api/user/v1/accounts/retirement_queue/": + ("api_user_v1_accounts_retirement_queue_retrieve", ("api",)), + "/api/user/v1/accounts/retirements_by_status_and_date/": + ("api_user_v1_accounts_retirements_by_status_and_date_retrieve", ("api",)), "/api/user/v2/accounts/{username}/preferences/": ("api_user_v2_accounts_preferences_retrieve", ("api",)), "/api/user/v2/accounts/{username}/preferences/{key}/": @@ -365,6 +378,10 @@ def test_a_user_address_is_grouped_by_area_where_the_derived_tag_would_name_a_ve "v1_preferences_retrieve_one", ("user",), ), "/api/user/v1/users/": ("v1_users_list", ("user",)), + "/api/user/v1/accounts/retirement_queue/": ("v1_accounts_retirement_queue_list", ("user",)), + "/api/user/v1/accounts/retirements_by_status_and_date/": ( + "v1_accounts_retirements_by_status_and_date_list", ("user",), + ), "/api/user/v2/accounts/{username}/preferences/": ( "v2_accounts_preferences_retrieve", ("user",), ), @@ -673,7 +690,10 @@ def test_a_component_the_service_appends_reaches_the_published_reference(self): assert len(reported) == 1, finished.stdout[-2000:] assert json.loads(reported[0].split(" ", 1)[1]) == { "keys": ["schemas", "securitySchemes"], - "schemas": ["ServiceComponent"], + # The component the child stands in for the service with, beside + # the one the documentation settings name themselves: a component + # named on either side reaches the reference. + "schemas": ["PatchedUserRetirementStatus", "ServiceComponent"], "security": ["Basic", "csrf", "jwt"], } From cdcf5716af7db6fbfe4122508b6b5d830174e6f5 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 15:26:29 +0500 Subject: [PATCH 52/82] fix: keep the line a refused batch wrote about itself, and name the condition once Two definitions of the name for a request that names more than one account stood in the same module, one shadowing the other, and the name was registered with the shared catalog twice. There is one of each now, describing both the address several accounts answer to and the name several records were requested under. A refusal that arrives carrying the records at fault wrote its own line about the request as a whole -- that a set of records was left alone and nothing deleted -- and that line was being replaced by one written from the list of records, which cannot be read to say it. A caller of a batch disposal read which record was at fault and no longer read that the rest were left alone. That line is now kept, and the records stay in the list beside it; a refusal whose line was printed from its list is written out as a sentence as before. --- openedx/core/djangoapps/user_api/v2/errors.py | 47 +++++++++++-------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/openedx/core/djangoapps/user_api/v2/errors.py b/openedx/core/djangoapps/user_api/v2/errors.py index a59d1ab259bc..39ca5fe252d3 100644 --- a/openedx/core/djangoapps/user_api/v2/errors.py +++ b/openedx/core/djangoapps/user_api/v2/errors.py @@ -28,17 +28,6 @@ class MalformedBody(ParseError): """A request body that is not well formed in the media type it was sent as.""" -class AmbiguousAccountName(ValidationError): - """ - An address answered by more than one account, naming no one of them. - - The refusal is one about what the request asked for, so it keeps the - status of the refusals beside it; what it adds is a name for the one - condition, which a caller can tell apart from a value it sent being - wrong and can act on by naming an account as it is stored. - """ - - class RetiredAccount(NotFound): """ An account that has been retired, addressed by something it used to hold. @@ -83,14 +72,16 @@ class RetirementStateConflict(_NamesTheRecordsAtFault, Conflict): class AmbiguousAccountName(_NamesTheRecordsAtFault, ValidationError): """ - A name that more than one retirement record was requested under. + An address or a name answered by more than one account, naming no one of them. - The name a record keeps of the account it is for is not unique, and in - some deployments stored names are compared without regard to case, which - once let a learner register a retired name again by changing its - capitalisation. A request naming such a name addresses several records at - once, and acting on either of them would act on an account the caller did - not ask about, so nothing is done. + The name an account was registered under is kept on the records of this + area without being unique to one of them, so a request naming such a name + reaches several records at once and acting on any of them would act on an + account the caller did not ask about. Nothing is done. The refusal is one + about what the request asked for, so it keeps the status of the refusals + beside it; what it adds is a name for this one condition, which a caller + can tell apart from a value it sent being wrong and can act on by naming + an account as it is stored. """ @@ -101,7 +92,6 @@ class AmbiguousAccountName(_NamesTheRecordsAtFault, ValidationError): register_error_type(RetiredAccount, "user/retired-account", "Retired Account") register_error_type(AmbiguousAccountName, "user/ambiguous-account-name", "Ambiguous Account Name") register_error_type(RetirementStateConflict, "user/retirement-state-conflict", "Retirement State Conflict") -register_error_type(AmbiguousAccountName, "user/ambiguous-account-name", "Ambiguous Account Name") def refusal_sentence(refusals): @@ -118,6 +108,18 @@ def refusal_sentence(refusals): ) +def _came_with_the_records_at_fault(exc): + """ + Whether this refusal arrived carrying the records at fault, and a line of its own. + + Such a refusal was raised with both: a sentence about the request as a + whole, and the record-by-record list that goes beside it. A refusal that + arrived with a line alone has its list derived from that line, and the + line written back from the list says the same thing. + """ + return isinstance(getattr(exc, "extension_members", {}).get("errors"), dict) + + class ReadableErrorDetailMixin: """ Writes the one-line message of a refused request as a sentence. @@ -130,11 +132,18 @@ class ReadableErrorDetailMixin: out: the message of the exception a generated client raises, a line in an integration's log, the notification a page puts on the screen. The list is left exactly as it was; only the line is rewritten, from the same values. + + A refusal that names the records at fault wrote that line itself, as a + sentence about the request as a whole -- that a set of records was left + alone, and nothing deleted -- which the list of records cannot be read to + say. That line is kept, and the records stay in the list beside it. """ def handle_exception(self, exc): """Answer with the standard body, its one-line message written in words.""" response = super().handle_exception(exc) + if _came_with_the_records_at_fault(exc): + return response body = getattr(response, "data", None) if isinstance(body, dict) and isinstance(body.get("errors"), dict): sentence = refusal_sentence(body["errors"]) From 03b9f2a422f6f4b0462370df921ff7f28c330aee Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 15:27:49 +0500 Subject: [PATCH 53/82] fix: say that a selected name reaching into a property is refused, and hold the records to it The parameter that selects which properties a response carries refuses a name written as a path, because reading it as the property it starts with hands a caller the whole of a property it asked one piece of. Its published description still said such a name is read as its first part and the whole of that property returned, so the document promised the behaviour the handler had stopped having. The retirement records are held to the refusal as the rest of this version is: asking for one property of the embedded account by path is refused with the names that can be selected, rather than answering with the account whole, which is personal data of the account being retired. --- .../v2/tests/test_retirement_views.py | 26 ++++++++++++++----- openedx/core/djangoapps/user_api/v2/views.py | 4 +-- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py index 1c345d837c83..2bc4173fb79d 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py @@ -73,7 +73,7 @@ from openedx.core.djangoapps.user_api.v2.paginators import UserApiPagination from openedx.core.djangoapps.user_api.v2.serializers import ( RETIREMENT_MINIMAL_FIELDS, - RetirementAccountSerializer, + RetirementSerializer, ) from openedx.core.djangoapps.user_api.v2.views import ( RETIREMENT_ACTION_PERMISSIONS, @@ -1024,14 +1024,26 @@ def test_properties_can_be_selected(self): response = self.client.get(self.url, {"fields": "current_state"}) assert response.data == {"current_state": "LOCKING_ACCOUNT"} - def test_a_property_asked_for_as_a_path_answers_the_whole_of_its_first_part(self): - """Selection reaches the top level only, which is what the parameter's description says.""" + def test_a_property_asked_for_as_a_path_is_refused(self): + """ + Selection reaches the top level only, which is what the parameter's description says. + + A name written with a dot in it names no property of the record, so it + is refused rather than read as the property it starts with: answering + that would hand back the whole of the embedded account to a caller + that asked for one property of it, which here is personal data of the + account being retired. + """ self.login("worker") response = self.client.get(self.url, {"fields": "user.email"}) - assert set(response.data) == {"user"} - assert set(response.data["user"]) == set( - RetirementAccountSerializer().fields, - ), "the whole embedded account comes back, not the one property asked for" + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"]["fields"] == [ + "No such field: user.email. Select any of: " + f"{', '.join(sorted(RetirementSerializer().fields))}.", + ] + assert "user" not in response.data def test_the_parameters_that_cut_a_page_are_not_read_at_a_member_address(self): self.login("worker") diff --git a/openedx/core/djangoapps/user_api/v2/views.py b/openedx/core/djangoapps/user_api/v2/views.py index 37ea7414bc82..97767e95f26d 100644 --- a/openedx/core/djangoapps/user_api/v2/views.py +++ b/openedx/core/djangoapps/user_api/v2/views.py @@ -230,8 +230,8 @@ FIELDS_PARAM, "Comma-separated list of top-level field names to keep in each returned object. " "A name the response cannot carry is refused. Selection reaches the top level only: " - "a name written as a path, such as `user.email`, is read as `user`, and the whole of " - "that property is returned.", + "a name written as a path, such as `user.email`, names no field a response carries " + "and is refused with the names that can be selected.", ) #: The presets each list answers to, published as the parameter's enum and #: accepted by the view from the same declaration. From 76974804fcc19981d8a7924342b3b99b54c2d876 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 15:35:14 +0500 Subject: [PATCH 54/82] fix: publish and measure the refusal every retirement address already answers Which representations an answer may be given in is settled as a request enters, before the record it names is read or written, so every address of the retirement pipeline already refused a request asking for one it cannot answer in and none of them published that refusal. Each operation publishes it now, and a sweep sends every method of every one of those addresses such a request: each answers the refusal, the covered methods are read from the module that routes them so an address mounted later is covered too, and the records the batch disposals delete, the data the erasure destroys and the request the cancellation undoes are all still there afterwards. The retirement queue joins the collections the page-size refusal is measured on, which is tied to the views that share the paginator. The check of what each list publishes about a page past its end carried two sets of expectations at once, the second quietly replacing the first; there is one set now, and it covers the queue. The write of one stored preference, which answers a request carrying no body, is where a body that needs nothing is read from; the change of a whole preference document is published as needing one because its shape names a member it cannot be sent without, and both are held here. --- .../user_api/v2/tests/test_pagination.py | 7 +- .../v2/tests/test_retirement_views.py | 164 +++++++++++++++++- .../user_api/v2/tests/test_schema.py | 55 +++--- openedx/core/djangoapps/user_api/v2/views.py | 9 + 4 files changed, 213 insertions(+), 22 deletions(-) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_pagination.py b/openedx/core/djangoapps/user_api/v2/tests/test_pagination.py index 667417184f4c..971b4cfd8e51 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_pagination.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_pagination.py @@ -7,7 +7,7 @@ from rest_framework import status from rest_framework.test import APITestCase -from common.djangoapps.student.tests.factories import AdminFactory, UserFactory +from common.djangoapps.student.tests.factories import AdminFactory, SuperuserFactory, UserFactory from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory from openedx.core.djangoapps.user_api.v2 import views as user_api_views from openedx.core.djangoapps.user_api.v2.paginators import UserApiPagination @@ -43,6 +43,9 @@ def setUp(self): super().setUp() self.admin = AdminFactory.create(username="page-admin", password=PASSWORD) self.learner = UserFactory.create(username="page-learner", password=PASSWORD) + # The retirement queue is read by a caller holding the capability the + # pipeline runs with, which a superuser holds along with every other. + self.pipeline = SuperuserFactory.create(username="page-pipeline", password=PASSWORD) UserPreferenceFactory.create(user=self.learner, key="time_zone", value="UTC") def collections(self): @@ -65,6 +68,7 @@ def collections(self): reverse("user_v2:email_search_list"), {"body": {"emails": [self.learner.email]}}, ), + "retirement_list": (self.pipeline, reverse("user_v2:retirement_list"), {}), } def ask(self, caller, address, page_size, body=None): @@ -148,6 +152,7 @@ def test_every_collection_of_this_version_that_pages_is_checked_here(self): "CountryTimeZoneViewSet", "EmailSearchViewSet", "IDVerificationViewSet", + "RetirementViewSet", "UserPreferenceViewSet", "UserViewSet", } diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py index 2bc4173fb79d..b10dd62ebd07 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py @@ -68,7 +68,7 @@ UserRetirementRequest, UserRetirementStatus, ) -from openedx.core.djangoapps.user_api.v2 import view_services +from openedx.core.djangoapps.user_api.v2 import urls as v2_urls, view_services from openedx.core.djangoapps.user_api.v2.errors import AmbiguousAccountName, RetirementStateConflict from openedx.core.djangoapps.user_api.v2.paginators import UserApiPagination from openedx.core.djangoapps.user_api.v2.serializers import ( @@ -3035,6 +3035,168 @@ def test_the_queue_carries_both_records(self): assert [row["id"] for row in response.json()["results"]] == [held.id, twin.id] +#: The view classes this pipeline is served by, read from the module that +#: routes them so that an address mounted later is covered by the sweep below. +RETIREMENT_VIEW_CLASSES = ( + RetirementViewSet, + RetirementPartnerReportViewSet, + RetirementCleanupViewSet, + RetirementPartnerReportCleanupViewSet, +) + + +def mounted_retirement_operations(): + """Return every address of this pipeline with the methods it serves, by the name it is routed under.""" + return { + (entry.name, method) + for entry in v2_urls.urlpatterns + if getattr(entry.callback, "cls", None) in RETIREMENT_VIEW_CLASSES + for method in getattr(entry.callback, "actions", {}) + } + + +@skip_unless_lms +class UnsatisfiableAcceptTests(RetirementApiTestCase): + """ + What each address of this pipeline answers a request it can give no answer to. + + Which representations an answer may be given in is settled as the request + enters, before the record it names is read or written, so the refusal + reaches an address that answers with a body and one that answers with none + alike. Every operation here publishes that refusal, and these are the + measurements it rests on: each address answers it, and the records each of + them would otherwise have acted on -- including the ones two of them + delete and the account data one of them erases -- are left as they were. + """ + + UNANSWERABLE = "text/csv" + + def setUp(self): + super().setUp() + self.finished = self.retirement_in("COMPLETE", user=self.subject) + self.working = self.retirement_in( + "RETIRING_LMS", user=UserFactory.create(username="working-subject"), + ) + self.pending = self.retirement_in( + "PENDING", user=UserFactory.create(username="pending-subject"), + ) + self.subject.profile.city = "Somewhere" + self.subject.profile.save() + self.entry = UserRetirementPartnerReportingStatus.objects.create( + user=self.working.user, + original_username=self.working.user.username, + original_email=self.working.user.email, + original_name=self.working.user.profile.name, + is_being_processed=True, + ) + + def requests(self): + """Return a request to every method of every address, by the caller that address admits.""" + return { + ("retirement_list", "get"): ("worker", reverse("user_v2:retirement_list"), None), + ("retirement_detail", "get"): ( + "worker", reverse("user_v2:retirement_detail", kwargs={"username": "subject"}), None, + ), + # Both reads are mounted for a request that asks for the answer + # without its body as well, and the refusal is settled before + # either is read, so each is sent that request too. + ("retirement_list", "head"): ("worker", reverse("user_v2:retirement_list"), None), + ("retirement_detail", "head"): ( + "worker", reverse("user_v2:retirement_detail", kwargs={"username": "subject"}), None, + ), + ("retirement_detail", "patch"): ( + "worker", + reverse("user_v2:retirement_detail", kwargs={"username": "working-subject"}), + {"new_state": "RETIRING_LMS_COMPLETE", "response": "done"}, + ), + ("retirement_detail", "delete"): ( + "canceller", + reverse("user_v2:retirement_detail", kwargs={"username": "pending-subject"}), + None, + ), + ("retirement_erasure", "post"): ( + "worker", + reverse("user_v2:retirement_erasure", kwargs={"username": "working-subject"}), + {"scope": "lms_core"}, + ), + ("retirement_cleanup_list", "post"): ( + "worker", reverse("user_v2:retirement_cleanup_list"), {"usernames": ["subject"]}, + ), + ("retirement_partner_report_list", "post"): ( + "worker", reverse("user_v2:retirement_partner_report_list"), None, + ), + ("retirement_partner_report_detail", "put"): ( + "worker", + reverse( + "user_v2:retirement_partner_report_detail", + kwargs={"username": "working-subject"}, + ), + {}, + ), + ("retirement_partner_report_cleanup_list", "post"): ( + "worker", + reverse("user_v2:retirement_partner_report_cleanup_list"), + {"usernames": ["working-subject"]}, + ), + } + + def send_each(self): + """Send every request of the table asking for a representation none can answer in.""" + answered = {} + for (name, method), (caller, url, body) in self.requests().items(): + self.client.logout() + self.login(caller) + send = getattr(self.client, method) + if body is None: + response = send(url, HTTP_ACCEPT=self.UNANSWERABLE) + else: + response = send(url, body, format="json", HTTP_ACCEPT=self.UNANSWERABLE) + answered[(name, method)] = response.status_code + return answered + + def test_every_address_of_this_pipeline_is_covered_here(self): + """A method mounted later is sent one of these requests, or this fails.""" + assert set(self.requests()) == mounted_retirement_operations() + + def test_each_address_refuses_an_accept_it_cannot_satisfy(self): + assert self.send_each() == { + address: status.HTTP_406_NOT_ACCEPTABLE for address in self.requests() + } + + def test_the_refusal_comes_back_in_the_body_this_version_answers_with(self): + """The refusal is readable even though the caller asked for a representation it is not in.""" + self.login("worker") + assert_error_envelope( + self.client.get(reverse("user_v2:retirement_list"), HTTP_ACCEPT=self.UNANSWERABLE), + expected_status=status.HTTP_406_NOT_ACCEPTABLE, + expected_type_slug="not-acceptable", + ) + + def test_nothing_is_moved_erased_or_deleted_on_the_way_to_the_refusal(self): + self.send_each() + assert set(UserRetirementStatus.objects.values_list("id", flat=True)) == { + self.finished.id, self.working.id, self.pending.id, + } + assert UserRetirementRequest.objects.count() == 3 + for record, state_name in ( + (self.finished, "COMPLETE"), + (self.working, "RETIRING_LMS"), + (self.pending, "PENDING"), + ): + record.refresh_from_db() + assert record.current_state.state_name == state_name + assert record.original_email != view_services.REDACTED_EMAIL + self.subject.refresh_from_db() + assert (self.subject.username, self.subject.email) == ("subject", "subject@example.com") + assert self.subject.is_active is True + assert UserProfile.objects.get(user=self.subject).city == "Somewhere" + assert set( + UserRetirementPartnerReportingStatus.objects.values_list("id", flat=True), + ) == {self.entry.id} + self.entry.refresh_from_db() + assert self.entry.is_being_processed is True + + @skip_unless_lms class FormEncodedBodyTests(RetirementApiTestCase): """ diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py index aca7f6de9fe8..8c02ee8b1b71 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py @@ -1028,12 +1028,7 @@ def test_every_list_publishes_the_page_that_does_not_exist(schema): "/api/user/v2/users/": ["200", "400", "401", "403", "404", "406"], "/api/user/v2/user_preferences/": ["200", "400", "401", "403", "404", "406"], TIME_ZONE_LIST_PATH: ["200", "400", "404", "406"], - 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"], - RETIREMENT_LIST_PATH: ["200", "400", "401", "403", "404"], + RETIREMENT_LIST_PATH: ["200", "400", "401", "403", "404", "406"], } @@ -2593,7 +2588,7 @@ def test_the_claim_answers_with_an_array_rather_than_a_page(schema): @skip_unless_lms def test_selecting_properties_publishes_how_far_a_name_reaches(schema): - """A caller is told that a dotted name selects the whole of its first part.""" + """A caller is told that a dotted name selects nothing and is refused.""" for path, method in ( (RETIREMENT_MEMBER_PATH, "get"), (RETIREMENT_LIST_PATH, "get"), @@ -2603,7 +2598,8 @@ def test_selecting_properties_publishes_how_far_a_name_reaches(schema): for parameter in schema["paths"][path][method]["parameters"] }["fields"] assert "`user.email`" in described, (path, method) - assert "the whole of that property is returned" in described, (path, method) + assert "names no field a response carries and is refused" in described, (path, method) + assert "the whole of that property is returned" not in described, (path, method) @skip_unless_lms @@ -2654,15 +2650,17 @@ def test_a_retirement_record_publishes_the_step_as_a_name(schema): #: partway through has already erased part of what it names and the caller #: decides what to do about that, so it is the one that publishes a 500. RETIREMENT_PUBLISHED_ANSWERS = { - (RETIREMENT_LIST_PATH, "get"): ["200", "400", "401", "403", "404"], - (RETIREMENT_MEMBER_PATH, "get"): ["200", "400", "401", "403", "404"], - (RETIREMENT_MEMBER_PATH, "patch"): ["204", "400", "401", "403", "404", "409", "415"], - (RETIREMENT_MEMBER_PATH, "delete"): ["204", "400", "401", "403", "404", "409"], - (RETIREMENT_ERASURE_PATH, "post"): ["204", "400", "401", "403", "404", "409", "415", "500"], - (RETIREMENT_CLEANUP_PATH, "post"): ["200", "400", "401", "403", "409", "415"], - (PARTNER_REPORT_LIST_PATH, "post"): ["200", "400", "401", "403"], - (PARTNER_REPORT_MEMBER_PATH, "put"): ["204", "400", "401", "403", "404", "409"], - (PARTNER_REPORT_CLEANUP_PATH, "post"): ["200", "400", "401", "403", "409", "415"], + (RETIREMENT_LIST_PATH, "get"): ["200", "400", "401", "403", "404", "406"], + (RETIREMENT_MEMBER_PATH, "get"): ["200", "400", "401", "403", "404", "406"], + (RETIREMENT_MEMBER_PATH, "patch"): ["204", "400", "401", "403", "404", "406", "409", "415"], + (RETIREMENT_MEMBER_PATH, "delete"): ["204", "400", "401", "403", "404", "406", "409"], + (RETIREMENT_ERASURE_PATH, "post"): [ + "204", "400", "401", "403", "404", "406", "409", "415", "500", + ], + (RETIREMENT_CLEANUP_PATH, "post"): ["200", "400", "401", "403", "406", "409", "415"], + (PARTNER_REPORT_LIST_PATH, "post"): ["200", "400", "401", "403", "406"], + (PARTNER_REPORT_MEMBER_PATH, "put"): ["204", "400", "401", "403", "404", "406", "409"], + (PARTNER_REPORT_CLEANUP_PATH, "post"): ["200", "400", "401", "403", "406", "409", "415"], } @@ -2774,10 +2772,27 @@ def test_the_claim_of_the_partner_report_queue_publishes_no_body_on_either_versi @skip_unless_lms def test_a_body_whose_shape_requires_nothing_is_not_published_as_one_that_is_needed(schema): - """The preference changes are an object of any names at all, so an empty one is a body.""" - body = service_wide_schema()["paths"]["/api/user/v1/preferences/{username}"]["patch"]["requestBody"] + """ + The value of one stored preference is a bare string, so the body names nothing it needs. + + The write of one preference takes a request carrying no body at all and + answers that the preference now stands as the request asked, so the shape + it publishes names nothing it cannot be sent without and the operation is + not published as needing a body. The change of a whole preference + document is the other way round: that shape names at least one member, and + the operation is published as needing one. + """ + document = service_wide_schema() + body = document["paths"]["/api/user/v1/preferences/{username}/{preference_key}"]["put"]["requestBody"] assert body.get("required", False) is False - assert "required" not in body["content"]["application/merge-patch+json"]["schema"] + assert "required" not in body["content"]["application/json"]["schema"] + assert body["content"]["application/json"]["schema"]["type"] == "string" + + changes = document["paths"]["/api/user/v1/preferences/{username}"]["patch"] + assert changes["requestBody"]["required"] is True + assert changes["requestBody"]["content"]["application/merge-patch+json"]["schema"][ + "minProperties" + ] == 1 #: The retirement-status model as it was published while the corrected request diff --git a/openedx/core/djangoapps/user_api/v2/views.py b/openedx/core/djangoapps/user_api/v2/views.py index 97767e95f26d..66b157b8bb52 100644 --- a/openedx/core/djangoapps/user_api/v2/views.py +++ b/openedx/core/djangoapps/user_api/v2/views.py @@ -2567,6 +2567,7 @@ def get_queryset(self): 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, 404: _RESP_NO_SUCH_PAGE, + 406: RESP_NOT_ACCEPTABLE, }, ), retrieve=extend_schema( @@ -2583,6 +2584,7 @@ def get_queryset(self): 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, 404: _RESP_NO_SUCH_RETIREMENT, + 406: RESP_NOT_ACCEPTABLE, }, ), partial_update=extend_schema( @@ -2601,6 +2603,7 @@ def get_queryset(self): 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, 404: _RESP_NO_SUCH_RETIREMENT, + 406: RESP_NOT_ACCEPTABLE, 409: _RESP_RETIREMENT_STATE, 415: RESP_UNREADABLE_BODY, }, @@ -2620,6 +2623,7 @@ def get_queryset(self): 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, 404: _RESP_NO_SUCH_RETIREMENT, + 406: RESP_NOT_ACCEPTABLE, 409: _RESP_RETIREMENT_STATE, }, ), @@ -2638,6 +2642,7 @@ def get_queryset(self): 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, 404: _RESP_NO_SUCH_RETIREMENT, + 406: RESP_NOT_ACCEPTABLE, 409: _RESP_RETIREMENT_STATE, 415: RESP_UNREADABLE_BODY, 500: _RESP_ERASURE_FAILED, @@ -2845,6 +2850,7 @@ def get_queryset(self): 400: _RESP_BAD_REQUEST, 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, + 406: RESP_NOT_ACCEPTABLE, }, ), update=extend_schema( @@ -2863,6 +2869,7 @@ def get_queryset(self): 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, 404: _RESP_NO_SUCH_RETIREMENT, + 406: RESP_NOT_ACCEPTABLE, 409: _RESP_RETIREMENT_STATE, }, ), @@ -2958,6 +2965,7 @@ def update(self, request, username): 400: _RESP_AMBIGUOUS_OR_UNUSABLE_BODY, 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, + 406: RESP_NOT_ACCEPTABLE, 409: _RESP_BATCH_STATE, 415: RESP_UNREADABLE_BODY, }, @@ -3020,6 +3028,7 @@ def create(self, request): 400: _RESP_AMBIGUOUS_OR_UNUSABLE_BODY, 401: RESP_UNAUTHENTICATED, 403: _RESP_FORBIDDEN, + 406: RESP_NOT_ACCEPTABLE, 409: _RESP_BATCH_STATE, 415: RESP_UNREADABLE_BODY, }, From 4e56d40a2e657e789f4cfda722b200d32e6a5d02 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 15:38:20 +0500 Subject: [PATCH 55/82] docs: describe each retirement address to the caller that asks, not to a maintainer A request asking an address what it serves is answered with the description its view class carries, so that description is published text. The four addresses of the retirement pipeline described themselves to somebody reading the module: why the resource is modelled as it is, which layer resolves what, and in one case a word for the machinery that narrows a page. Each now says what a caller can do there, in a paragraph or two, and the rest is written beside the class where nothing serves it. Asking the queue and the batch disposal what they serve is answered with exactly the text each class carries, so the descriptions above are measured as published rather than assumed to be, and the sweep over every mounted address counts the four this pipeline adds. --- .../v2/tests/test_retirement_views.py | 31 +++++ .../user_api/v2/tests/test_views.py | 2 +- openedx/core/djangoapps/user_api/v2/views.py | 113 ++++++++++-------- 3 files changed, 95 insertions(+), 51 deletions(-) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py index b10dd62ebd07..731b3a37672b 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py @@ -3035,6 +3035,37 @@ def test_the_queue_carries_both_records(self): assert [row["id"] for row in response.json()["results"]] == [held.id, twin.id] +@skip_unless_lms +class RetirementSelfDescriptionTests(RetirementApiTestCase): + """ + What each address of this pipeline says about itself to a caller that asks. + + The description a view class carries is what an OPTIONS request is + answered with, so it is published text: a client and a documentation + browser read it beside the description of the same address in the schema. + These hold two of them to being the text the class carries, which the + checks on that text elsewhere hold to being written for the caller. + """ + + def test_the_queue_answers_with_the_description_it_carries(self): + self.login("worker") + answer = self.client.options(reverse("user_v2:retirement_list")) + assert answer.status_code == status.HTTP_200_OK + assert answer.json()["description"] == RetirementViewSet().get_view_description() + assert answer.json()["description"].startswith( + "The accounts being retired, addressed by the name each was registered under.", + ) + + def test_the_disposal_answers_with_the_description_it_carries(self): + self.login("worker") + answer = self.client.options(reverse("user_v2:retirement_cleanup_list")) + assert answer.status_code == status.HTTP_200_OK + assert answer.json()["description"] == RetirementCleanupViewSet().get_view_description() + assert answer.json()["description"].startswith( + "Disposes of a set of finished retirement records.", + ) + + #: The view classes this pipeline is served by, read from the module that #: routes them so that an address mounted later is covered by the sweep below. RETIREMENT_VIEW_CLASSES = ( diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_views.py index 60f931ab9eca..0ce55b56aabf 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_views.py @@ -1667,7 +1667,7 @@ def test_every_mounted_address_is_described(self): name: view_class().get_view_description() for name, view_class in routed_view_classes().items() } - assert len(described) == 13, sorted(described) + assert len(described) == 17, sorted(described) assert [name for name, text in described.items() if not text.strip()] == [] def test_no_address_describes_itself_in_implementation_terms(self): diff --git a/openedx/core/djangoapps/user_api/v2/views.py b/openedx/core/djangoapps/user_api/v2/views.py index 66b157b8bb52..e6474e892203 100644 --- a/openedx/core/djangoapps/user_api/v2/views.py +++ b/openedx/core/djangoapps/user_api/v2/views.py @@ -2545,6 +2545,18 @@ def get_queryset(self): } +# How this collection is built, which is written here rather than in the +# description the class carries: that description is what an OPTIONS request +# is answered with, so it is published text and belongs to the caller. +# +# A retirement is not written through generic machinery -- every change goes +# through the pipeline's own model layer, which is what decides whether a step +# may run. Access is layered: the permission classes decide who may call an +# action at all, the visibility policy decides which retirements such a caller +# may act on, and the filters narrow the page a list carries further. Every +# action resolves the records it reads or changes from the selection the policy +# left, a member address included, so a retirement the policy leaves out is +# reported as absent rather than refused. @extend_schema(tags=[USER_TAG]) @extend_schema_view( list=extend_schema( @@ -2664,27 +2676,17 @@ class RetirementViewSet( """ The accounts being retired, addressed by the name each was registered under. - A retirement is a record of one account's progress through a pipeline - whose steps are configured by the operator and driven from outside this - service: the steps here carry out the work and record nothing about where - the account stands, and the caller records that separately. No generic - write machinery is mixed in for that reason -- every change goes through - the pipeline's own model layer, which is what decides whether a step may - run. - - Access is layered: the permission classes decide who may call an action at - all, the visibility policy decides which retirements such a caller may act - on, and the filter set narrows the page a list carries further by the - caller's query string. Every action resolves the records it reads or - changes from the selection the policy left, a member address included, so a - retirement the policy leaves out is reported as absent rather than refused. - Cancelling a retirement carries a different capability from the rest, - because it undoes a request rather than carrying one out. - - How a name is resolved follows the operation. A read accepts either the - name the account was registered under or the hashed name it was moved to, - because both address the same account. A write accepts the registered name - alone, matched exactly, so that it reaches one account and no other. + A retirement records one account's progress through the steps the operator + has configured. The requests here carry that work out; where an account + stands is recorded separately, by the caller driving them. + + A read answers for either the name the account was registered under or the + hashed name it was given when retired: both address the same account. A + write takes the registered name alone, spelled exactly as it was + registered, so it reaches one account and no other. + + Cancelling a retirement is open to a different caller from the requests + that carry one out. """ serializer_class = RetirementSerializer @@ -2888,18 +2890,14 @@ class RetirementPartnerReportViewSet( """ The accounts whose retirement still has to be reported to the organizations they studied with. - Taking entries for processing is a write, so the collection is added to by - one caller -- the pipeline, as it finishes an account -- and drained by - another, which takes a set of entries and marks them. The entries a - request answers with are exactly the entries it marks: they are read - first and marked by the identifiers that were read, so an entry added - while the answer is being built is left for the next caller instead of - being marked by one that never saw it. Nothing releases an entry. - - An entry carries the account owner's name and address as they were before - retirement, and the short names of the organizations running the courses - the account was enrolled in; those enrolments are fetched with the entries - so that the cost of the answer does not grow with the number of entries. + An entry is added as the pipeline finishes an account, and carries the name + and address that account held before it was retired together with the short + names of the organizations running the courses it was enrolled in. + + Taking entries is a write rather than a read: a request is answered with + exactly the entries it has marked as taken, so an entry added while that + answer is being built is left for the next caller rather than reported to + this one. Nothing releases an entry once it has been taken. """ serializer_class = RetirementPartnerReportRecordSerializer @@ -2939,6 +2937,13 @@ def update(self, request, username): return Response(status=status.HTTP_204_NO_CONTENT) +# The entries a request answers with are read first and marked by the +# identifiers that were read, which is what keeps an entry added during the +# read from being marked by a caller that never saw it; the enrolments behind +# the organization names are fetched with the entries so that the cost of the +# answer does not grow with the number of entries. Written here rather than in +# the description the class carries, which is published to every caller that +# asks an address what it does. _RESP_BATCH_OUTCOME = OpenApiResponse( RetirementBatchOutcomeSerializer, description=( @@ -2948,6 +2953,13 @@ def update(self, request, username): ) +# One operation over a set of records named in its body, with a condition that +# covers the whole set and no member address of its own, so it stands at a +# collection of its own rather than among the operations on one record. Who may +# call it and which records it deletes are resolved through the layers those +# operations resolve them through, so a rule added to the records is asked of +# their disposal too. Written here rather than in the description the class +# carries, which is published to every caller that asks what this address does. @extend_schema(tags=[USER_TAG]) @extend_schema_view( create=extend_schema( @@ -2984,16 +2996,13 @@ class RetirementCleanupViewSet( """ Disposes of a set of finished retirement records. - The operation is one over a set of records named in its body, with an - invariant that covers the whole set, and it has no member address of its - own, so it stands at a collection of its own rather than among the - operations on one record. It is not the retirement collection's own - delete, which undoes a single request that has not been acted on; this one - disposes of records the pipeline has finished with, and the two carry - different capabilities and act on retirements at opposite ends of the - pipeline. Who may call it and which records it deletes are resolved - through the layers those operations resolve them through, so a rule added - to the records is asked of their disposal too. + The accounts to dispose of are named in the body, and every one of them has + to name a retirement the pipeline has finished with: if any does not, the + whole batch is refused and nothing is deleted. + + This is not how a retirement request is called off. That works on a request + the pipeline has not started on, is asked for one account at a time, and is + open to a different caller. """ serializer_class = RetirementBatchOutcomeSerializer @@ -3013,6 +3022,13 @@ def create(self, request): return Response(self.get_serializer({"results": results}).data) +# This disposal stands at a collection of its own for the same reason the +# retirement records' own disposal does, and the two are kept apart because +# they act on different records in different tables under different conditions. +# Who may call this one and which entries it deletes are resolved through the +# layers the operations on those entries resolve them through. Written here +# rather than in the description the class carries, which is published to every +# caller that asks what this address does. @extend_schema(tags=[USER_TAG]) @extend_schema_view( create=extend_schema( @@ -3047,13 +3063,10 @@ class RetirementPartnerReportCleanupViewSet( """ Disposes of a set of partner report entries that have been reported on. - The operation stands at a collection of its own for the same reason as the - retirement records' own disposal: one operation over a set of records - named in its body, with an invariant that covers the whole set, and no - member address of its own. The two disposals are kept apart because they - act on different records in different tables, under different conditions. - Who may call this one and which entries it deletes are resolved through - the layers the operations on those entries resolve them through. + The accounts whose entries are to be disposed of are named in the body, and + every one of them has to name an entry that was taken for reporting: an + entry nobody has taken has not been reported on yet, so it refuses the + whole batch and nothing is deleted. """ serializer_class = RetirementBatchOutcomeSerializer From d3b941855856cd8776613be15aab0b460a92f2fa Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 15:44:05 +0500 Subject: [PATCH 56/82] style: import the two modules of this version on a line each --- .../core/djangoapps/user_api/v2/tests/test_retirement_views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py index 731b3a37672b..6b08594e886a 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py @@ -68,7 +68,8 @@ UserRetirementRequest, UserRetirementStatus, ) -from openedx.core.djangoapps.user_api.v2 import urls as v2_urls, view_services +from openedx.core.djangoapps.user_api.v2 import urls as v2_urls +from openedx.core.djangoapps.user_api.v2 import view_services from openedx.core.djangoapps.user_api.v2.errors import AmbiguousAccountName, RetirementStateConflict from openedx.core.djangoapps.user_api.v2.paginators import UserApiPagination from openedx.core.djangoapps.user_api.v2.serializers import ( From 4b1bfae6e479224264ec5c5ae26bc4da30711b99 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 22:35:52 +0500 Subject: [PATCH 57/82] test: hold the superseded names Studio publishes for the retirement addresses too Studio's name pin covered the addresses this version replaced before the retirement pipeline joined them; it now covers those as well, each under the name both documents already carry -- checked against the other service's document, where none of them is named differently. --- cms/lib/tests/test_spectacular.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/cms/lib/tests/test_spectacular.py b/cms/lib/tests/test_spectacular.py index 0ab779dec574..98cdc5e809e3 100644 --- a/cms/lib/tests/test_spectacular.py +++ b/cms/lib/tests/test_spectacular.py @@ -350,6 +350,27 @@ def test_only_the_colliding_legacy_address_is_suffixed(self): ("/api/user/v1/user_prefs/{id}/", "get"): "api_user_v1_user_prefs_retrieve", ("/api/user/v1/users/", "get"): "api_user_v1_users_list", ("/api/user/v1/users/{id}/", "get"): "api_user_v1_users_retrieve", + ("/api/user/v1/accounts/cancel_retirement/", "post"): "api_user_v1_accounts_cancel_retirement_create", + ("/api/user/v1/accounts/retire/", "post"): "api_user_v1_accounts_retire_create", + ("/api/user/v1/accounts/retire_misc/", "post"): "api_user_v1_accounts_retire_misc_create", + ("/api/user/v1/accounts/retirement_cleanup/", "post"): "api_user_v1_accounts_retirement_cleanup_create", + ("/api/user/v1/accounts/retirement_partner_report/", "post"): ( + "api_user_v1_accounts_retirement_partner_report_create" + ), + ("/api/user/v1/accounts/retirement_partner_report/", "put"): ( + "api_user_v1_accounts_retirement_partner_report_update" + ), + ("/api/user/v1/accounts/retirement_partner_report_cleanup/", "post"): ( + "api_user_v1_accounts_retirement_partner_report_cleanup_create" + ), + ("/api/user/v1/accounts/retirement_queue/", "get"): "api_user_v1_accounts_retirement_queue_retrieve", + ("/api/user/v1/accounts/retirements_by_status_and_date/", "get"): ( + "api_user_v1_accounts_retirements_by_status_and_date_retrieve" + ), + ("/api/user/v1/accounts/update_retirement_status/", "patch"): ( + "api_user_v1_accounts_update_retirement_status_partial_update" + ), + ("/api/user/v1/accounts/{username}/retirement_status/", "get"): "api_user_v1_accounts_retirement_status_retrieve", } From f01a160802f72bb4e6d4cb49c2440fec11494b00 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 23:15:09 +0500 Subject: [PATCH 58/82] style: declare the attributes a helper fills before it fills them The repository's pylint reports four attributes of one retirement test set outside the constructor. They are declared empty where the rest of the case is set up, so the names exist before the helper that pairs two records assigns them, and the helper is unchanged. --- .../djangoapps/user_api/v2/tests/test_retirement_views.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py index 6b08594e886a..09b4cc971bb4 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py @@ -2501,6 +2501,11 @@ def setUp(self): user.profile.city = f"City of {user.username}" user.profile.save() self.url = reverse("user_v2:retirement_erasure", kwargs={"username": self.SHARED_NAME}) + # Declared here so the attributes exist before pair() fills them in. + self.out_of_scope_record = None + self.in_scope_record = None + self.in_scope_before = None + self.out_of_scope_before = None def pair(self, in_scope_step, out_of_scope_step): """Request a retirement for each account under the one name, standing at these steps.""" From ed83beab3d9aff6dff2ff239c2ac363800a6bd7a Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Mon, 2 Nov 2026 00:16:23 +0500 Subject: [PATCH 59/82] fix: append the component Studio's document had stopped carrying The partial update of a version 1 retirement was the only reference to the retirement-status model, and that operation now publishes the object its handler really reads, so nothing generates the model any more. The service serving the newer version of this API appends the model to every document it builds; the service serving only the older one did not, so a client generated from its document of everything it serves lost that class. The definition now sits beside the rest of this API's schema behaviour and is read from there by both services and by the published reference. It is kept in a module that imports nothing, because a settings module reads it while the settings are still being assembled, which is too early for any module that reads a setting of its own. --- cms/envs/common.py | 9 +++++ cms/lib/tests/test_spectacular.py | 24 +++++++++++++ docs/docs_settings.py | 11 +++--- lms/envs/common.py | 31 +++------------- .../djangoapps/user_api/schema_components.py | 35 +++++++++++++++++++ 5 files changed, 78 insertions(+), 32 deletions(-) create mode 100644 openedx/core/djangoapps/user_api/schema_components.py diff --git a/cms/envs/common.py b/cms/envs/common.py index 99d97cf88c34..806be5552ba3 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -49,6 +49,7 @@ from cms.lib.xblock.authoring_mixin import AuthoringMixin from cms.lib.xblock.upstream_sync import UpstreamSyncMixin +from openedx.core.djangoapps.user_api.schema_components import KEPT_COMPONENT_SCHEMAS from openedx.core.lib.derived import Derived from openedx.core.lib.features_setting_proxy import FeaturesProxy from openedx.envs.common import * # pylint: disable=wildcard-import # noqa: F403 @@ -1348,6 +1349,14 @@ def _should_send_xblock_events(settings): 'openedx.core.djangoapps.user_api.schema.mark_superseded_user_paths', 'openedx.core.apidocs.untag_deprecated_operations', ], + # This service also serves the user API, which names a component no + # operation of it refers to any more. It is appended here, from the + # definition the other service appends as well, so that a client generated + # from either service's document carries that class. Every document built + # from these settings carries it, the service-wide one included. + 'APPEND_COMPONENTS': { + 'schemas': dict(KEPT_COMPONENT_SCHEMAS), + }, # Stripped from each path to derive its tags and operationId, so changing # it renames every operationId. Paths are published in full, from the # service root, so /api/contentstore and /api/authoring resolve against the diff --git a/cms/lib/tests/test_spectacular.py b/cms/lib/tests/test_spectacular.py index 98cdc5e809e3..f3fd8b1195d4 100644 --- a/cms/lib/tests/test_spectacular.py +++ b/cms/lib/tests/test_spectacular.py @@ -27,6 +27,7 @@ SupersededUserOperations, superseded_bodies, ) +from openedx.core.djangoapps.user_api.schema_components import PATCHED_USER_RETIREMENT_STATUS def _endpoint(path): @@ -475,3 +476,26 @@ def test_the_described_addresses_are_ones_this_document_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_the_component_no_operation_refers_to_any_more_is_still_published_here(self): + """ + A client generated from this document names a class after each component in it. + + One of these addresses was the only reference to the retirement-status + model, and it now publishes the object its handler really reads, so + nothing in either service's document generates that model any more. It + is appended by this service as well, under the name it has always been + published under and with the definition the other service appends, so + that a client generated from this document still carries the class. + """ + components = self.schema["components"]["schemas"] + assert components["PatchedUserRetirementStatus"] == PATCHED_USER_RETIREMENT_STATUS + referred_to = { + member["allOf"][0]["$ref"].rsplit("/", 1)[-1] + for member in components["PatchedUserRetirementStatus"]["properties"].values() + if "allOf" in member + } + assert referred_to == {"RetirementUser", "RetirementState"} + # The appended component is described in place, so nothing checks that + # what it points at is published beside it. + assert referred_to - set(components) == set() diff --git a/docs/docs_settings.py b/docs/docs_settings.py index 5806a532527e..19d8ea2eb525 100644 --- a/docs/docs_settings.py +++ b/docs/docs_settings.py @@ -19,6 +19,7 @@ ) from lms.envs.common import * # pylint: disable=wildcard-import # noqa: F403 from openedx.core.apidocs import get_api_docs_settings +from openedx.core.djangoapps.user_api.schema_components import KEPT_COMPONENT_SCHEMAS from openedx.core.lib.derived import derive_settings # Turn on all the boolean feature flags, so that conditionally included @@ -89,16 +90,14 @@ # dropped here goes out of the generated reference, and out of every client # built from it, without changing any count that would show it had gone. # -# The retirement-status model is named again here, through the one definition -# the service settings hold, because the reference is expected to carry that -# component whatever else the service appends: the partial update of a version -# 1 retirement was its only reference, and that operation now publishes the -# object its handler really reads. +# The components a service appends although nothing refers to them are named +# again here, through the one definition both services read, because the +# reference is expected to carry them whatever else a service appends. SPECTACULAR_SETTINGS["APPEND_COMPONENTS"] = { # noqa: F405 **SPECTACULAR_SETTINGS.get("APPEND_COMPONENTS", {}), # noqa: F405 "schemas": { **SPECTACULAR_SETTINGS.get("APPEND_COMPONENTS", {}).get("schemas", {}), # noqa: F405 - "PatchedUserRetirementStatus": PATCHED_USER_RETIREMENT_STATUS, # noqa: F405 + **KEPT_COMPONENT_SCHEMAS, }, "securitySchemes": { "Basic": { diff --git a/lms/envs/common.py b/lms/envs/common.py index aed15d39ef0f..4da88f64777a 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -64,6 +64,7 @@ from path import Path as path from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType +from openedx.core.djangoapps.user_api.schema_components import KEPT_COMPONENT_SCHEMAS from openedx.core.lib.derived import Derived from openedx.core.lib.features_setting_proxy import FeaturesProxy from openedx.envs.common import * # pylint: disable=wildcard-import # noqa: F403 @@ -2180,30 +2181,6 @@ add_optional_apps(OPTIONAL_APPS, INSTALLED_APPS) # noqa: F405 ###################### drf-spectacular (LMS API schema) ###################### -# The retirement-status model that a partial update of a version 1 retirement -# was published as reading. That operation now publishes the object its handler -# really reads, which leaves this model with no reference to generate it from, -# and a client generated from a document names a class after each model in it. -# The model is therefore named here, exactly as it was published, so the class -# keeps its name in every document this service builds. -PATCHED_USER_RETIREMENT_STATUS = { - 'type': 'object', - 'description': 'Perform serialization for the RetirementStatus model', - 'properties': { - 'id': {'type': 'integer', 'readOnly': True}, - 'user': {'allOf': [{'$ref': '#/components/schemas/RetirementUser'}], 'readOnly': True}, - 'current_state': {'allOf': [{'$ref': '#/components/schemas/RetirementState'}], 'readOnly': True}, - 'last_state': {'allOf': [{'$ref': '#/components/schemas/RetirementState'}], 'readOnly': True}, - 'created': {'type': 'string', 'format': 'date-time', 'readOnly': True}, - 'modified': {'type': 'string', 'format': 'date-time', 'readOnly': True}, - 'original_username': {'type': 'string', 'maxLength': 150}, - 'original_email': {'type': 'string', 'format': 'email', 'maxLength': 254}, - 'original_name': {'type': 'string', 'maxLength': 255}, - 'retired_username': {'type': 'string', 'maxLength': 150}, - 'retired_email': {'type': 'string', 'format': 'email', 'maxLength': 254}, - }, -} - SPECTACULAR_SETTINGS = { 'TITLE': 'LMS API', 'VERSION': '0.1.0', @@ -2218,9 +2195,11 @@ 'openedx.core.apidocs.untag_deprecated_operations', ], # Carried by every document built from these settings, including the - # service-wide one, which overlays its own keys over them. + # service-wide one, which overlays its own keys over them. The other + # service that serves this API appends the same components, read from the + # same definition, so neither document names one the other does not. 'APPEND_COMPONENTS': { - 'schemas': {'PatchedUserRetirementStatus': PATCHED_USER_RETIREMENT_STATUS}, + 'schemas': dict(KEPT_COMPONENT_SCHEMAS), }, # A set of choices is published as its own model, named after the field # carrying it while one field of the service carries that name alone. The diff --git a/openedx/core/djangoapps/user_api/schema_components.py b/openedx/core/djangoapps/user_api/schema_components.py new file mode 100644 index 000000000000..92250c44b2d5 --- /dev/null +++ b/openedx/core/djangoapps/user_api/schema_components.py @@ -0,0 +1,35 @@ +"""Components of this API that a document carries although nothing in it refers to them.""" + +# Nothing is imported in this module. A settings module reads it while the +# settings are still being assembled, which is too early for any module that +# reads a setting of its own. + +# The retirement-status model that a partial update of a version 1 retirement +# was published as reading. That operation now publishes the object its handler +# really reads, which leaves this model with no reference to generate it from, +# and a client generated from a document names a class after each model in it. +# The model is therefore named here, exactly as it was published, and appended +# by each service that serves this API, so the class keeps its name in every +# document either of them builds. +PATCHED_USER_RETIREMENT_STATUS = { + 'type': 'object', + 'description': 'Perform serialization for the RetirementStatus model', + 'properties': { + 'id': {'type': 'integer', 'readOnly': True}, + 'user': {'allOf': [{'$ref': '#/components/schemas/RetirementUser'}], 'readOnly': True}, + 'current_state': {'allOf': [{'$ref': '#/components/schemas/RetirementState'}], 'readOnly': True}, + 'last_state': {'allOf': [{'$ref': '#/components/schemas/RetirementState'}], 'readOnly': True}, + 'created': {'type': 'string', 'format': 'date-time', 'readOnly': True}, + 'modified': {'type': 'string', 'format': 'date-time', 'readOnly': True}, + 'original_username': {'type': 'string', 'maxLength': 150}, + 'original_email': {'type': 'string', 'format': 'email', 'maxLength': 254}, + 'original_name': {'type': 'string', 'maxLength': 255}, + 'retired_username': {'type': 'string', 'maxLength': 150}, + 'retired_email': {'type': 'string', 'format': 'email', 'maxLength': 254}, + }, +} + +#: Keyed by the name each component is published under, for a service to append. +KEPT_COMPONENT_SCHEMAS = { + 'PatchedUserRetirementStatus': PATCHED_USER_RETIREMENT_STATUS, +} From a92f81cad8ea76e0d43795d1438402d84207e892 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 18:17:48 +0500 Subject: [PATCH 60/82] feat: serve registration, sign-in and password reset under the user API's version 2 Registering, signing in and resetting a password are reached today at addresses the rest of the user API no longer uses: a singular `account` segment with a second collection nested under it and no identifier between them, a verb (`token/validate`), a route without a trailing slash, and one route answering two versions through a `{api_version}` parameter. This adds the five addresses version 2 names them by: /api/user/v2/registrations/ GET, POST /api/user/v2/registration_validations/ POST /api/user/v2/login_sessions/ GET, POST /api/user/v2/password_resets/ GET /api/user/v2/password_reset_token_validations/ POST Nothing changes for any caller of the existing addresses: every route, URL name and view of the authentication app stays exactly where it was, and no file of that app is edited. Each new address is served by a subclass of the view already serving the older one. The subclass adds `permission_classes = (AllowAny,)`, which is what those views already allow, and the description it is published with, and nothing else. Every request is answered by the inherited, decorated method, so authentication, CSRF handling, cookies, bodies, status codes and the third-party sign-in flow are the older address's. That includes the limits on how often a request may be sent: those counters are keyed on the decorated method and on the view class's name, so the subclasses keep their parents' names and the counters stay shared between old and new addresses (spend the limit at one, be refused at the other). Sign-in is bound to version 2 by its route, and registration answers as the version 2 registration address does, `confirm_email` included. The bodies are published as shapes written in place, since none of these views uses a serializer: the form descriptions, the registration and sign-in success and refusal bodies, the token and validation results, and DRF's `{detail}` refusals, each only for the codes the handler is seen to produce. Sign-in publishes the version 2 fields it really reads and the same security block as the other addresses here. The new tests send every request to the older address and to the new one and compare status, media type, cookies and body; cover the callers each view refuses (CSRF, malformed credentials, rate limits with a real cache, the switches that close registration or require another provider, a deployment's own filter steps); pin query counts and that reading a form writes nothing but the session; and hold every captured body against the shape the service-wide document publishes for it. --- .../djangoapps/user_api/v2/authn_schemas.py | 363 +++++ .../djangoapps/user_api/v2/authn_views.py | 130 ++ .../user_api/v2/tests/test_authn_addresses.py | 1394 +++++++++++++++++ .../user_api/v2/tests/test_schema.py | 14 +- .../djangoapps/user_api/v2/tests/test_urls.py | 10 + .../user_api/v2/tests/test_views.py | 2 +- openedx/core/djangoapps/user_api/v2/urls.py | 25 + 7 files changed, 1936 insertions(+), 2 deletions(-) create mode 100644 openedx/core/djangoapps/user_api/v2/authn_schemas.py create mode 100644 openedx/core/djangoapps/user_api/v2/authn_views.py create mode 100644 openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py diff --git a/openedx/core/djangoapps/user_api/v2/authn_schemas.py b/openedx/core/djangoapps/user_api/v2/authn_schemas.py new file mode 100644 index 000000000000..39eecc21f29e --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/authn_schemas.py @@ -0,0 +1,363 @@ +"""Published bodies of the registration, sign-in and password reset endpoints of version 2 of the User API.""" + +from drf_spectacular.utils import OpenApiResponse +from rest_framework.settings import api_settings + +FORM_MEDIA_TYPE = "application/x-www-form-urlencoded" +MULTIPART_MEDIA_TYPE = "multipart/form-data" + + +def read_as_a_form(shape): + """ + Name a shape under the two media types a handler reading the submitted form accepts. + + Such a handler reads the fields a form carries and nothing else, so a body + sent as JSON reaches it as an empty form and is refused for what it lacks. + """ + return {FORM_MEDIA_TYPE: shape, MULTIPART_MEDIA_TYPE: shape} + + +def read_in_every_form(shape): + """Name a shape under each media type a view that declares no parsers reads a body in.""" + return {parser.media_type: shape for parser in api_settings.DEFAULT_PARSER_CLASSES} + + +_TEXT = {"type": "string"} + +FORM_FIELD = { + "type": "object", + "description": "One input of the form.", + "properties": { + "name": {"type": "string", "description": "Name the value is submitted under."}, + "label": {"type": "string", "description": "Label to show beside the input."}, + "type": { + "type": "string", + "description": ( + "Kind of input, such as `text`, `email`, `password`, `select`, `textarea`, `checkbox` " + "or `plaintext`." + ), + }, + "defaultValue": {"description": "Value the input starts with: text, or true or false for a checkbox."}, + "placeholder": {"type": "string", "description": "Hint to show in the empty input."}, + "instructions": {"type": "string", "description": "Help text to show with the input."}, + "exposed": {"type": "boolean", "description": "Whether to show the input although it is optional."}, + "required": {"type": "boolean", "description": "Whether the form cannot be submitted without a value."}, + "restrictions": { + "type": "object", + "description": "Limits the value is held to, such as `min_length` and `max_length`.", + }, + "errorMessages": { + "type": "object", + "additionalProperties": _TEXT, + "description": "Messages to show for a refused value, by the reason it was refused.", + }, + "supplementalLink": {"type": "string", "description": "Address of further help, or an empty string."}, + "supplementalText": {"type": "string", "description": "Text of the link to further help."}, + "loginIssueSupportLink": {"type": "string", "description": "Address of help with signing in."}, + "options": { + "type": "array", + "description": "Choices a `select` input offers. Absent from every other kind of input.", + "items": { + "type": "object", + "properties": { + "value": {"type": "string", "description": "Value submitted for this choice."}, + "name": {"type": "string", "description": "Text to show for this choice."}, + "default": {"type": "boolean", "description": "Whether this choice starts selected."}, + }, + "required": ["value", "name", "default"], + }, + }, + }, + "required": [ + "name", + "label", + "type", + "defaultValue", + "placeholder", + "instructions", + "exposed", + "required", + "restrictions", + "errorMessages", + "supplementalLink", + "supplementalText", + "loginIssueSupportLink", + ], +} + +FORM_DESCRIPTION = { + "type": "object", + "description": "A form a client builds its page from, field by field.", + "properties": { + "method": {"type": "string", "description": "HTTP method the form is submitted with."}, + "submit_url": {"type": "string", "description": "Address the form is submitted to."}, + "fields": {"type": "array", "items": FORM_FIELD, "description": "The inputs, in the order to show them."}, + }, + "required": ["method", "submit_url", "fields"], +} + +#: A body any DRF view answers a refused request with. +REFUSAL = { + "type": "object", + "properties": {"detail": {"type": "string", "description": "Why the request was refused."}}, + "required": ["detail"], +} + +REGISTRATION_FORM_RESPONSES = { + 200: OpenApiResponse( + FORM_DESCRIPTION, + description=( + "The registration form. Which fields it lists, and which of them are required, depend on how the " + "site is configured." + ), + ), +} + +REGISTRATION_FIELDS = { + "type": "object", + "description": ( + "The fields of the registration form, each under its name. The fields listed as required are the ones " + "a site requires unless it is configured otherwise; the form description lists the ones this site asks " + "for, and every field it lists is accepted here." + ), + "properties": { + "name": {"type": "string", "description": "Full name of the person registering."}, + "username": {"type": "string", "description": "Name to register the account under."}, + "email": {"type": "string", "description": "Email address of the account."}, + "confirm_email": { + "type": "string", + "description": "The email address again, where the site asks for it.", + }, + "password": {"type": "string", "description": "Password of the account.", "format": "password"}, + "honor_code": {"type": "string", "description": "`true` once the site's terms are accepted."}, + "terms_of_service": { + "type": "string", + "description": "`true` once the terms of service are accepted. Taken from `honor_code` when absent.", + }, + "country": {"type": "string", "description": "Two-letter code of the country of residence."}, + }, + "required": ["name", "username", "email", "password", "honor_code"], + "additionalProperties": _TEXT, +} + +REGISTRATION_REQUEST = read_as_a_form(REGISTRATION_FIELDS) + +REGISTERED = { + "type": "object", + "properties": { + "authenticated_user": { + "type": "object", + "description": "The account created, which the caller is now signed in as.", + "properties": { + "username": {"type": "string", "description": "Name the account was registered under."}, + "full_name": {"type": "string", "description": "Full name on the account's profile."}, + "user_id": {"type": "integer", "description": "Identifier of the account."}, + }, + "required": ["username", "full_name", "user_id"], + }, + "success": {"type": "boolean", "description": "Always true."}, + "redirect_url": {"type": "string", "description": "Where to send the person next."}, + }, + "required": ["authenticated_user", "success", "redirect_url"], +} + +REGISTRATION_REFUSAL = { + "type": "object", + "description": ( + "Each field that was refused, under its name, with the messages to show for it, and what the " + "refusal was." + ), + "properties": { + "error_code": { + "type": "string", + "description": ( + "What the refusal was, such as `validation-error`, `invalid-country`, `duplicate-email`, " + "`duplicate-username`, `duplicate-email-username` or `forbidden-request`." + ), + }, + "username_suggestions": { + "type": "array", + "items": _TEXT, + "description": "Usernames still free to register, offered when the one sent is taken.", + }, + }, + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": {"user_message": {"type": "string", "description": "Message to show for the field."}}, + "required": ["user_message"], + }, + }, +} + +REGISTRATION_RESPONSES = { + 200: OpenApiResponse(REGISTERED, description="The account was created and the caller is signed in as it."), + 400: OpenApiResponse( + REGISTRATION_REFUSAL, + description=( + "A field is missing or not acceptable, the country is not one the site knows, or a sign-in through " + "another provider the registration relied on has expired." + ), + ), + 403: OpenApiResponse( + REGISTRATION_REFUSAL, + description=( + "Too many accounts were registered from the address the request came from (`error_code` is " + "`forbidden-request`), or a step the site adds to registration refused it. Where the site requires " + "signing in through another provider, or creates no accounts here, the refusal is an HTML page " + "instead of this body." + ), + ), + 409: OpenApiResponse( + REGISTRATION_REFUSAL, + description="An account already has that email address or that username.", + ), +} + +PASSWORD_RESET_FORM_RESPONSES = { + 200: OpenApiResponse( + FORM_DESCRIPTION, + description="The form a password reset is asked for with, which asks for the email address of the account.", + ), +} + +TOKEN_VALIDATION_REQUEST = read_in_every_form({ + "type": "object", + "properties": { + "token": {"type": "string", "description": "The token the password reset email carries."}, + }, +}) + +TOKEN_VALIDATION_RESPONSES = { + 200: OpenApiResponse( + { + "type": "object", + "properties": {"is_valid": {"type": "boolean", "description": "Whether the token is valid."}}, + "required": ["is_valid"], + }, + description="Whether the token is valid. A token that cannot be read is answered as not valid.", + ), + 400: OpenApiResponse(REFUSAL, description="The body could not be parsed."), + 401: OpenApiResponse(REFUSAL, description="The credentials sent could not be read."), + 403: OpenApiResponse(REFUSAL, description="A caller signed in with a session sent no valid CSRF token."), + 429: OpenApiResponse(REFUSAL, description="Too many tokens were checked; `detail` says when to try again."), +} + +REGISTRATION_VALIDATION_REQUEST = read_in_every_form({ + "type": "object", + "description": "The values to check, each under the name of the field it would be registered in.", + "properties": { + "name": _TEXT, + "username": _TEXT, + "email": _TEXT, + "confirm_email": _TEXT, + "password": _TEXT, + "country": _TEXT, + "form_field_key": { + "type": "string", + "description": "Name of the one field to check, leaving the others sent unchecked.", + }, + "reset_password_page": { + "type": "string", + "description": "`true` where the password is checked for a password reset rather than a registration.", + }, + }, +}) + +REGISTRATION_VALIDATION_RESPONSES = { + 200: OpenApiResponse( + { + "type": "object", + "properties": { + "validation_decisions": { + "type": "object", + "additionalProperties": _TEXT, + "description": ( + "The decision on each field checked: an empty string where the value is acceptable, " + "otherwise the message to show." + ), + }, + "username_suggestions": { + "type": "array", + "items": _TEXT, + "description": "Usernames still free to register, offered for a taken username or a name.", + }, + }, + "required": ["validation_decisions"], + }, + description="The decision on each value sent.", + ), + 400: OpenApiResponse(REFUSAL, description="The body could not be parsed."), + 403: OpenApiResponse( + REFUSAL, description="Too many checks were sent from the address the request came from.", + ), +} + +LOGIN_FORM_RESPONSES = { + 200: OpenApiResponse(FORM_DESCRIPTION, description="The form the site's sign-in page is built from."), +} + +LOGIN_REQUEST = read_as_a_form({ + "type": "object", + "properties": { + "email_or_username": { + "type": "string", + "description": "The account to sign in as, by its email address or its username.", + }, + "password": {"type": "string", "description": "Password of the account.", "format": "password"}, + }, + "required": ["email_or_username", "password"], +}) + +LOGIN_REFUSAL = { + "type": "object", + "properties": { + "success": {"type": "boolean", "description": "Always false."}, + "value": {"type": "string", "description": "Message to show."}, + "error_code": {"type": "string", "description": "What the refusal was, where it has a name."}, + "email": {"type": "string", "nullable": True, "description": "The account the request named, if any."}, + "context": {"type": "object", "description": "Details of the refusal for the message, by name."}, + "redirect": {"type": "string", "description": "Where to send the person instead."}, + "redirect_url": { + "type": "string", + "nullable": True, + "description": "Where to send the person instead, such as to change a password.", + }, + }, + "required": ["success"], +} + +LOGIN_RESPONSES = { + 200: OpenApiResponse( + { + "type": "object", + "properties": { + "success": {"type": "boolean", "description": "Always true."}, + "redirect_url": { + "type": "string", + "nullable": True, + "description": "Where to send the person next, or null to leave that to the client.", + }, + }, + "required": ["success", "redirect_url"], + }, + description="The caller is signed in.", + ), + 400: OpenApiResponse( + LOGIN_REFUSAL, + description=( + "Signing in failed: the account or the password is wrong, the account is not active, a field is " + "missing, a step the site adds to signing in refused it, or signing in is refused for a while after " + "too many failed attempts." + ), + ), + 403: OpenApiResponse( + LOGIN_REFUSAL, + description=( + "The caller signed in through another provider with an account that is not linked to one here. " + "A request carrying no valid CSRF token, or a password sent where the site requires signing in " + "through another provider, is refused with an HTML page instead of this body." + ), + ), +} diff --git a/openedx/core/djangoapps/user_api/v2/authn_views.py b/openedx/core/djangoapps/user_api/v2/authn_views.py new file mode 100644 index 000000000000..0d221f88aa4d --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/authn_views.py @@ -0,0 +1,130 @@ +"""Registration, sign-in and password reset endpoints of version 2 of the User API.""" + +from drf_spectacular.utils import extend_schema, extend_schema_view +from rest_framework.permissions import AllowAny + +from openedx.core.djangoapps.user_authn.views import login, password_reset, register + +from . import authn_schemas + +# Each view below is the authentication app's own view, published under a +# description of its own and nothing else. A request is answered by the method +# that view defines, with every decorator on it, so the limits on how often a +# request may be sent are counted together with the app's older addresses. +# Those limits are counted per decorated method and per the name of the class +# it runs on: a method written here would start counting afresh, and so would +# a class renamed here, which is why each keeps the name of the one it extends. + + +@extend_schema_view( + get=extend_schema( + summary="Describe the registration form", + description=( + "Returns the fields an account is registered with on this site, in the order to show them, with the " + "label, kind, limits and choices of each, and the address the form is submitted to. Sets the CSRF " + "cookie." + ), + responses=authn_schemas.REGISTRATION_FORM_RESPONSES, + ), + post=extend_schema( + summary="Register an account", + description=( + "Creates an account from the fields the registration form lists and signs the caller in as it, " + "setting the session cookies. The body is read as a submitted form; a body sent as JSON is read as " + "an empty one. No CSRF token is needed, and a session the caller already holds is not consulted. " + "Refused once too many accounts were registered from the address the request comes from." + ), + request=authn_schemas.REGISTRATION_REQUEST, + responses=authn_schemas.REGISTRATION_RESPONSES, + ), +) +class RegistrationView(register.RegistrationView): + """Describes the registration form and registers an account from it, for any caller.""" + + permission_classes = (AllowAny,) + + +@extend_schema_view( + get=extend_schema( + summary="Describe the password reset form", + description=( + "Returns the form a password reset is asked for with and the address it is submitted to. Sets the " + "CSRF cookie." + ), + responses=authn_schemas.PASSWORD_RESET_FORM_RESPONSES, + ), +) +class PasswordResetView(password_reset.PasswordResetView): + """Describes the form a password reset is asked for with, for any caller.""" + + permission_classes = (AllowAny,) + + +@extend_schema_view( + post=extend_schema( + summary="Check a password reset token", + description=( + "Says whether a token from a password reset email is still valid. A caller who is signed in is told " + "a token is valid only when it belongs to their own account. A valid token for an inactive account " + "activates the account. Callers who are not signed in may check only a few tokens in each period, " + "and a caller signed in with a session sends the CSRF token." + ), + request=authn_schemas.TOKEN_VALIDATION_REQUEST, + responses=authn_schemas.TOKEN_VALIDATION_RESPONSES, + ), +) +class PasswordResetTokenValidation(password_reset.PasswordResetTokenValidation): + """Says whether a password reset token is valid, to any caller.""" + + permission_classes = (AllowAny,) + + +@extend_schema_view( + post=extend_schema( + summary="Check registration values", + description=( + "Checks the values a person is about to register with and answers with a decision for each field " + "sent: an empty string where the value is acceptable, otherwise the message to show. Naming a field " + "in `form_field_key` checks that field alone. No CSRF token is needed. Refused once too many checks " + "were sent from the address the request comes from." + ), + request=authn_schemas.REGISTRATION_VALIDATION_REQUEST, + responses=authn_schemas.REGISTRATION_VALIDATION_RESPONSES, + ), +) +class RegistrationValidationView(register.RegistrationValidationView): + """Checks values a person is about to register with, for any caller.""" + + permission_classes = (AllowAny,) + + +@extend_schema_view( + get=extend_schema( + summary="Describe the sign-in form", + description=( + "Returns the form the site's sign-in page is built from and sets the CSRF cookie that signing in " + "needs." + ), + responses=authn_schemas.LOGIN_FORM_RESPONSES, + ), + post=extend_schema( + summary="Sign in", + description=( + "Signs the caller in with an account's password and sets the session cookies. The account is named " + "in `email_or_username`, by its email address or its username. The body is read as a submitted " + "form; a body sent as JSON is read as an empty one. The CSRF token is needed, in the `X-CSRFToken` " + "header or the `csrfmiddlewaretoken` field, together with the cookie the sign-in form sets. A " + "session the caller already holds is not consulted. After too many failed attempts, signing in is " + "refused for a while." + ), + request=authn_schemas.LOGIN_REQUEST, + responses=authn_schemas.LOGIN_RESPONSES, + # The view it extends names a scheme for the CSRF token that no + # published document declares; the token is described above instead. + auth=[{}], + ), +) +class LoginSessionView(login.LoginSessionView): + """Describes the sign-in form and signs a caller in, for any caller.""" + + permission_classes = (AllowAny,) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py new file mode 100644 index 000000000000..a678368e0f6f --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py @@ -0,0 +1,1394 @@ +"""Tests for the registration, sign-in and password reset addresses of version 2 of the User API.""" + +import json +import re +from types import SimpleNamespace +from unittest import mock + +import jsonschema +import pytest +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.tokens import default_token_generator +from django.core.cache import cache +from django.db import connection, transaction +from django.test import Client, TestCase +from django.test.utils import CaptureQueriesContext, override_settings +from django.urls import resolve, reverse +from django.utils.http import int_to_base36 +from drf_spectacular.generators import SchemaGenerator +from drf_spectacular.settings import patched_settings +from openedx_filters import PipelineStep +from openedx_filters.learning.filters import StudentLoginRequested, StudentRegistrationRequested +from rest_framework.permissions import AllowAny +from rest_framework.settings import api_settings + +from common.djangoapps.student.models import Registration +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.apidocs import get_api_docs_settings +from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user +from openedx.core.djangoapps.user_api.v2.tests.test_schema import as_json_schema +from openedx.core.djangoapps.user_authn.views import login, password_reset, register +from openedx.core.djangolib.testing.utils import skip_unless_lms + +User = get_user_model() + +PASSWORD = "Password1234" +REGISTRATION_PASSWORD = "Password1234!x" +JSON = "application/json" +FORM = "application/x-www-form-urlencoded" +MULTIPART = "multipart/form-data" + +AUTHN_VIEW_MODULE = "openedx.core.djangoapps.user_api.v2.authn_views" + +REGISTRATIONS = "/api/user/v2/registrations/" +PASSWORD_RESETS = "/api/user/v2/password_resets/" +TOKEN_VALIDATIONS = "/api/user/v2/password_reset_token_validations/" +REGISTRATION_VALIDATIONS = "/api/user/v2/registration_validations/" +LOGIN_SESSIONS = "/api/user/v2/login_sessions/" + +V1_REGISTRATION = "/api/user/v1/account/registration/" +V2_REGISTRATION = "/api/user/v2/account/registration/" +V1_PASSWORD_RESET = "/api/user/v1/account/password_reset/" +V1_TOKEN_VALIDATION = "/api/user/v1/account/password_reset/token/validate/" +V1_REGISTRATION_VALIDATION = "/api/user/v1/validation/registration" +V1_LOGIN_SESSION = "/api/user/v1/account/login_session/" +V2_LOGIN_SESSION = "/api/user/v2/account/login_session/" +LOGIN_AJAX = "/login_ajax" +CREATE_ACCOUNT = "/create_account" + +#: Each address this version serves for the authentication app, with its +#: route name, the view of that app it is answered by, and the arguments its +#: route hands the view. +CONFORMING_ROUTES = { + REGISTRATIONS: ("user_v2:registration_list", register.RegistrationView, {}), + PASSWORD_RESETS: ("user_v2:password_reset_list", password_reset.PasswordResetView, {}), + TOKEN_VALIDATIONS: ( + "user_v2:password_reset_token_validation_list", + password_reset.PasswordResetTokenValidation, + {}, + ), + REGISTRATION_VALIDATIONS: ( + "user_v2:registration_validation_list", + register.RegistrationValidationView, + {}, + ), + LOGIN_SESSIONS: ("user_v2:login_session_list", login.LoginSessionView, {"api_version": "v2"}), +} + +#: Every name the authentication app's addresses are reversed by, with the +#: arguments, the address and the view it reaches. None of them moves. +LEGACY_ROUTES = ( + ("user_api_registration", {}, V1_REGISTRATION, register.RegistrationView), + ("user_api_registration_legacy", {}, "/user_api/v1/account/registration/", register.RegistrationView), + ("user_api_registration_v2", {}, V2_REGISTRATION, register.RegistrationView), + ("user_api_registration_v2_legacy", {}, "/user_api/v2/account/registration/", register.RegistrationView), + ("create_account", {}, CREATE_ACCOUNT, register.RegistrationView), + ("registration_validation", {}, V1_REGISTRATION_VALIDATION, register.RegistrationValidationView), + ("user_api_login_session", {"api_version": "v1"}, V1_LOGIN_SESSION, login.LoginSessionView), + ("user_api_login_session", {"api_version": "v2"}, V2_LOGIN_SESSION, login.LoginSessionView), + ( + "user_api_login_session_legacy", + {"api_version": "v1"}, + "/user_api/v1/account/login_session/", + login.LoginSessionView, + ), + ( + "user_api_login_session_legacy", + {"api_version": "v2"}, + "/user_api/v2/account/login_session/", + login.LoginSessionView, + ), + ("user_api_password_reset", {}, V1_PASSWORD_RESET, password_reset.PasswordResetView), + ( + "user_api_password_reset_legacy", + {}, + "/user_api/v1/account/password_reset/", + password_reset.PasswordResetView, + ), + ( + "user_api_password_reset_token_validate", + {}, + V1_TOKEN_VALIDATION, + password_reset.PasswordResetTokenValidation, + ), + ( + "user_api_password_reset_token_validate_legacy", + {}, + "/user_api/v1/account/password_reset/token/validate/", + password_reset.PasswordResetTokenValidation, + ), +) + +#: A real cache, so the limits on how often these operations may be sent take +#: effect; the stock test settings use one that stores nothing. +COUNTING_CACHE = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "user_api_v2_authn_addresses", + }, +} + +#: What the only intended difference between an address and the one it +#: replaces is: the description each view's OPTIONS answer carries. +OPTIONS_DIFFERENCES = [ + ("description", "each view describes itself, and the view at each address is a different class"), +] + +#: The cookies a registration or a sign-in sets in the stock test settings. +SIGNED_IN_COOKIES = {"csrftoken", "edx-user-info", "edxloggedin", "openedx-language-preference", "sessionid"} + +TOO_MANY_LOGINS = "Too many failed login attempts. Try again later." +MISSING_LOGIN_FIELDS = "There was an error receiving your login information. Please email us." + + +class RefuseRegistrationStep(PipelineStep): + """A deployment's own step refusing every registration.""" + + def run_filter(self, form_data): # pylint: disable=arguments-differ + raise StudentRegistrationRequested.PreventRegistration( + "Registration is closed.", status_code=403, error_code="registration-closed", + ) + + +class RefuseLoginStep(PipelineStep): + """A deployment's own step refusing every sign-in.""" + + def run_filter(self, user): # pylint: disable=arguments-differ + raise StudentLoginRequested.PreventLogin( + "Sign-in is closed.", redirect_to="/closed", error_code="sign-in-closed", context={"reason": "closed"}, + ) + + +REFUSE_REGISTRATION = { + "org.openedx.learning.student.registration.requested.v1": { + "pipeline": [f"{__name__}.RefuseRegistrationStep"], + "fail_silently": False, + }, +} +REFUSE_LOGIN = { + "org.openedx.learning.student.login.requested.v1": { + "pipeline": [f"{__name__}.RefuseLoginStep"], + "fail_silently": False, + }, +} + + +def registration_form(name): + """Return a registration the stock settings accept, for an account called ``name``.""" + return { + "email": f"{name}@example.com", + "name": name.replace("_", " ").title(), + "username": name, + "password": REGISTRATION_PASSWORD, + "honor_code": "true", + "country": "PK", + } + + +def reset_token(user): + """Return the token a password reset email carries for ``user``.""" + return f"{int_to_base36(user.id)}-{default_token_generator.make_token(user)}" + + +def signed_in(user, enforce_csrf_checks=False): + """Return a client holding a session for ``user``.""" + client = Client(enforce_csrf_checks=enforce_csrf_checks) + assert client.login(username=user.username, password=PASSWORD) + return client + + +def cookies_set(response): + """Return the names of the cookies a response sets.""" + return set(response.cookies) + + +def flatten(body, volatile=(), path=""): + """ + Return a body as a map of member paths to values, members sorted. + + A member whose value is chosen per request -- a generated username, an + account's id -- is kept under its path with only its type, so two + responses are compared on every member they carry and on every value that + does not depend on which account a request created. + """ + flat = {} + if isinstance(body, dict): + for key in sorted(body): + flat.update(flatten(body[key], volatile, f"{path}.{key}" if path else key)) + elif isinstance(body, list): + if path in volatile: + return {path: ("list", len(body), sorted({type(item).__name__ for item in body}))} + for index, item in enumerate(body): + flat.update(flatten(item, volatile, f"{path}[{index}]")) + else: + flat[path] = type(body).__name__ if path in volatile else body + return flat + + +def differences(legacy, conforming, intentional=(), volatile=()): + """ + Return the member paths two bodies differ on, and those of them not declared intentional. + + Every declared difference has to occur, or the list of them says more than + the two addresses really differ by. + """ + old, new = flatten(legacy, volatile), flatten(conforming, volatile) + changed = {key for key in set(old) | set(new) if old.get(key, "") != new.get(key, "")} + declared = {path for path, _reason in intentional} + unexpected = { + key for key in changed + if not any(key == path or key.startswith((f"{path}.", f"{path}[")) for path in declared) + } + for path, reason in intentional: + assert any(key == path or key.startswith((f"{path}.", f"{path}[")) for key in changed), ( + f"declared difference did not occur: {path} ({reason})" + ) + return changed, unexpected + + +def assert_same_answer(legacy, conforming, status, volatile=(), cookies=None): + """ + Assert two addresses answered one request the same way. + + The status, the media type, the cookies set and every member of the body + are compared; members chosen per request are compared by type. + """ + assert legacy.status_code == status, legacy.content + assert conforming.status_code == status, conforming.content + assert conforming["Content-Type"] == legacy["Content-Type"] + assert cookies_set(conforming) == cookies_set(legacy) + if cookies is not None: + assert cookies_set(conforming) == cookies + if legacy["Content-Type"].startswith(JSON): + _changed, unexpected = differences(json.loads(legacy.content), json.loads(conforming.content), (), volatile) + assert not unexpected, (sorted(unexpected), legacy.content, conforming.content) + else: + assert conforming.content == legacy.content + + +def assert_byte_equal(legacy, conforming, status=200): + """Assert two addresses answered with the same bytes, media type and cookies.""" + assert legacy.status_code == status, legacy.content + assert conforming.status_code == status, conforming.content + assert conforming["Content-Type"] == legacy["Content-Type"] + assert cookies_set(conforming) == cookies_set(legacy) + assert conforming.content == legacy.content + + +def spend(address, data, times, **extra): + """Send ``times`` requests to ``address`` from new clients and return their statuses.""" + return [Client().post(address, data, **extra).status_code for _ in range(times)] + + +def limit_of(rate): + """Return the number of requests a rate setting allows in its period.""" + return int(rate.split("/")[0]) + + +def narrow_document(): + """Return the document the service publishes for the APIs it standardizes.""" + return SchemaGenerator().get_schema(request=None, public=True) + + +def service_wide_document(): + """Return the document the service publishes for browsing its whole surface.""" + with patched_settings(get_api_docs_settings()): + return SchemaGenerator().get_schema(request=None, public=True) + + +@skip_unless_lms +class ConformingAddressTests(TestCase): + """Each address reverses, resolves to its view, and leaves the older addresses where they were.""" + + def test_each_name_reverses_to_its_address(self): + reversed_addresses = { + address: reverse(name, kwargs={}) for address, (name, _view, _kwargs) in CONFORMING_ROUTES.items() + } + assert reversed_addresses == {address: address for address in CONFORMING_ROUTES} + + def test_each_address_is_answered_by_a_view_extending_the_one_serving_the_older_address(self): + for address, (name, legacy_view, route_kwargs) in CONFORMING_ROUTES.items(): + match = resolve(address) + view = match.func.view_class + assert match.view_name == name, address + assert view.__module__ == AUTHN_VIEW_MODULE, address + assert view.__name__ == legacy_view.__name__, address + assert view is not legacy_view, address + assert issubclass(view, legacy_view), address + assert match.kwargs == route_kwargs, address + + def test_every_older_name_still_reverses_to_its_address_and_reaches_the_same_view(self): + for name, kwargs, address, view in LEGACY_ROUTES: + assert reverse(name, kwargs=kwargs) == address, name + match = resolve(address) + assert getattr(match.func, "view_class", None) is view, (name, address) + + def test_the_sign_in_function_keeps_its_own_address(self): + assert reverse("login_api") == LOGIN_AJAX + assert resolve(LOGIN_AJAX).func is login.login_user + + def test_an_address_sent_without_its_slash_is_refused_in_json(self): + """These addresses are served only with the slash, and the version answers the rest.""" + for address in CONFORMING_ROUTES: + response = Client().get(address.rstrip("/"), HTTP_ACCEPT=JSON) + assert response.status_code == 404, address + assert response["Content-Type"].startswith(JSON), address + assert resolve(address.rstrip("/")).func.view_class.__name__ == "UnknownAddressView", address + + def test_the_older_addresses_sent_without_their_slash_are_still_redirected(self): + for address in (V1_REGISTRATION, V2_REGISTRATION, V1_LOGIN_SESSION, V2_LOGIN_SESSION, V1_PASSWORD_RESET): + response = Client().get(address.rstrip("/")) + assert response.status_code == 301, address + assert response["Location"] == address, address + + def test_the_older_validation_address_is_still_served_only_without_a_slash(self): + assert Client().post(f"{V1_REGISTRATION_VALIDATION}/", {}).status_code == 404 + assert Client().post(V1_REGISTRATION_VALIDATION, {}).status_code == 200 + + +@skip_unless_lms +class ViewShapeTests(TestCase): + """ + The views add a declaration of who may call them and the description they are published with. + + Everything else -- how a caller is identified, CSRF handling, the limits + on how often a request may be sent, the bodies and codes -- is the view + they extend, reached through the method that view defines. + """ + + #: What a view class carries of its own besides its methods. + OWN_ATTRIBUTES = {"__module__", "__doc__", "permission_classes"} + + def test_each_view_declares_only_who_may_call_it(self): + for address, (_name, legacy_view, _kwargs) in CONFORMING_ROUTES.items(): + view = resolve(address).func.view_class + own = set(vars(view)) - {"__firstlineno__", "__static_attributes__"} + methods = {name for name in own if name in view.http_method_names} + assert own - methods == self.OWN_ATTRIBUTES, address + assert view.permission_classes == (AllowAny,), address + assert methods == {name for name in vars(legacy_view) if name in view.http_method_names}, address + + #: The methods each address is served with. + SERVED_METHODS = { + REGISTRATIONS: {"get", "post"}, + PASSWORD_RESETS: {"get"}, + TOKEN_VALIDATIONS: {"post"}, + REGISTRATION_VALIDATIONS: {"post"}, + LOGIN_SESSIONS: {"get", "post"}, + } + + def test_each_method_hands_the_request_to_the_one_it_extends(self): + """A method of its own would run without the decorators the extended method carries.""" + for address, (_name, legacy_view, _kwargs) in CONFORMING_ROUTES.items(): + view = resolve(address).func.view_class + methods = {name for name in vars(view) if name in view.http_method_names} + assert methods == self.SERVED_METHODS[address], address + for method in methods: + assert vars(view)[method].__wrapped__ is vars(legacy_view)[method], (address, method) + + def test_how_a_caller_is_identified_is_the_extended_views(self): + for address, (_name, legacy_view, _kwargs) in CONFORMING_ROUTES.items(): + view = resolve(address).func.view_class + assert view.authentication_classes is legacy_view.authentication_classes, address + assert view.throttle_classes is legacy_view.throttle_classes, address + assert resolve(TOKEN_VALIDATIONS).func.view_class.authentication_classes == ( + api_settings.DEFAULT_AUTHENTICATION_CLASSES + ) + for address in (REGISTRATIONS, PASSWORD_RESETS, REGISTRATION_VALIDATIONS, LOGIN_SESSIONS): + assert resolve(address).func.view_class.authentication_classes == [], address + + def test_the_extended_views_are_left_as_they_were(self): + """Describing a method here changes nothing the older addresses publish or run.""" + for legacy_view in ( + register.RegistrationView, + register.RegistrationValidationView, + password_reset.PasswordResetView, + password_reset.PasswordResetTokenValidation, + login.LoginSessionView, + ): + assert "permission_classes" not in vars(legacy_view), legacy_view + for legacy_method in ( + register.RegistrationView.get, + register.RegistrationView.post, + register.RegistrationValidationView.post, + password_reset.PasswordResetView.get, + password_reset.PasswordResetTokenValidation.post, + login.LoginSessionView.get, + ): + assert not hasattr(legacy_method, "kwargs"), legacy_method + assert set(login.LoginSessionView.post.kwargs) == {"schema"} + assert login.LoginSessionView.post.kwargs["schema"] is not ( + resolve(LOGIN_SESSIONS).func.view_class.post.kwargs["schema"] + ) + + +@skip_unless_lms +class RegistrationParityTests(TestCase): + """The registration address answers every request as the version 2 address it replaces does.""" + + def test_the_form_is_the_same_bytes(self): + legacy, conforming = Client().get(V2_REGISTRATION), Client().get(REGISTRATIONS) + assert_byte_equal(legacy, conforming) + assert {"csrftoken", "sessionid"} <= cookies_set(conforming) + assert json.loads(conforming.content)["submit_url"] == V2_REGISTRATION + + def test_a_configured_confirmation_field_is_offered_as_version_two_offers_it(self): + extra = {**settings.REGISTRATION_EXTRA_FIELDS, "confirm_email": "required"} + with override_settings(REGISTRATION_EXTRA_FIELDS=extra): + legacy, conforming = Client().get(V2_REGISTRATION), Client().get(REGISTRATIONS) + version_one = Client().get(V1_REGISTRATION) + assert_byte_equal(legacy, conforming) + offered = [field["name"] for field in json.loads(conforming.content)["fields"]] + assert "confirm_email" in offered + assert "confirm_email" not in [field["name"] for field in json.loads(version_one.content)["fields"]] + + def test_a_configured_confirmation_field_is_required_as_version_two_requires_it(self): + extra = {**settings.REGISTRATION_EXTRA_FIELDS, "confirm_email": "required"} + with override_settings(REGISTRATION_EXTRA_FIELDS=extra): + legacy = Client().post(V2_REGISTRATION, registration_form("confirm_old")) + conforming = Client().post(REGISTRATIONS, registration_form("confirm_new")) + version_one = Client().post(V1_REGISTRATION, registration_form("confirm_v_one")) + assert_same_answer(legacy, conforming, 400) + assert json.loads(conforming.content) == { + "confirm_email": [{"user_message": "You are missing one or more required fields"}], + "error_code": "validation-error", + } + assert version_one.status_code == 200 + + def test_an_account_is_created_and_the_caller_signed_in(self): + legacy = Client().post(V2_REGISTRATION, registration_form("parity_old")) + conforming_client = Client() + conforming = conforming_client.post(REGISTRATIONS, registration_form("parity_new")) + assert_same_answer( + legacy, + conforming, + 200, + volatile={"authenticated_user.username", "authenticated_user.full_name", "authenticated_user.user_id"}, + cookies=SIGNED_IN_COOKIES, + ) + created = User.objects.get(username="parity_new") + assert json.loads(conforming.content)["authenticated_user"] == { + "username": "parity_new", "full_name": "Parity New", "user_id": created.id, + } + assert int(conforming_client.session["_auth_user_id"]) == created.id + + def test_a_form_sent_as_multipart_is_read_as_well(self): + conforming = Client().post(REGISTRATIONS, registration_form("multipart_new")) + assert conforming.status_code == 200, conforming.content + sent_as_form = Client().post( + REGISTRATIONS, + "&".join(f"{key}={value}" for key, value in registration_form("urlencoded_new").items()), + content_type=FORM, + ) + assert sent_as_form.status_code == 200, sent_as_form.content + + def test_a_signed_in_caller_ends_up_signed_in_as_the_account_it_registered(self): + UserFactory.create(username="already_in", password=PASSWORD) + legacy_client = signed_in(User.objects.get(username="already_in")) + conforming_client = signed_in(User.objects.get(username="already_in")) + legacy = legacy_client.post(V2_REGISTRATION, registration_form("switch_old")) + conforming = conforming_client.post(REGISTRATIONS, registration_form("switch_new")) + assert legacy.status_code == conforming.status_code == 200 + assert int(conforming_client.session["_auth_user_id"]) == User.objects.get(username="switch_new").id + assert int(legacy_client.session["_auth_user_id"]) == User.objects.get(username="switch_old").id + + def test_no_csrf_token_is_asked_for(self): + legacy = Client(enforce_csrf_checks=True).post(V2_REGISTRATION, registration_form("csrf_old")) + conforming = Client(enforce_csrf_checks=True).post(REGISTRATIONS, registration_form("csrf_new")) + assert legacy.status_code == conforming.status_code == 200 + + def test_credentials_in_a_header_are_not_read(self): + """No authenticator runs, so a header that would be refused anywhere else is ignored.""" + legacy = Client().post(V2_REGISTRATION, {}, HTTP_AUTHORIZATION="JWT not-a-jwt") + conforming = Client().post(REGISTRATIONS, {}, HTTP_AUTHORIZATION="JWT not-a-jwt") + assert_same_answer(legacy, conforming, 400) + assert Client().get(REGISTRATIONS, HTTP_AUTHORIZATION="JWT not-a-jwt").status_code == 200 + + def test_an_empty_registration_names_every_field_it_needs(self): + """No registration address takes a request without a body, which is why each publishes it as needed.""" + legacy, conforming = Client().post(V2_REGISTRATION, {}), Client().post(REGISTRATIONS, {}) + assert_same_answer(legacy, conforming, 400) + needed = {"email", "error_code", "honor_code", "name", "password", "terms_of_service", "username"} + assert set(json.loads(conforming.content)) == needed + version_one = Client().post(V1_REGISTRATION, {}) + assert version_one.status_code == 400 + assert set(json.loads(version_one.content)) == needed + + def test_a_json_body_is_read_as_empty(self): + form = registration_form("json_new") + legacy = Client().post(V2_REGISTRATION, json.dumps(form), content_type=JSON) + conforming = Client().post(REGISTRATIONS, json.dumps(form), content_type=JSON) + assert_same_answer(legacy, conforming, 400) + assert not User.objects.filter(username="json_new").exists() + + def test_a_taken_name_is_refused_with_suggestions(self): + Client().post(REGISTRATIONS, registration_form("taken_name")) + legacy = Client().post(V2_REGISTRATION, registration_form("taken_name")) + conforming = Client().post(REGISTRATIONS, registration_form("taken_name")) + assert_same_answer(legacy, conforming, 409, volatile={"username_suggestions"}) + body = json.loads(conforming.content) + assert body["error_code"] == "duplicate-email-username" + assert len(body["username_suggestions"]) == 3 + + def test_a_country_the_site_does_not_know_is_refused(self): + legacy = Client().post(V2_REGISTRATION, {**registration_form("country_old"), "country": "ZZ"}) + conforming = Client().post(REGISTRATIONS, {**registration_form("country_new"), "country": "ZZ"}) + assert_same_answer(legacy, conforming, 400) + assert json.loads(conforming.content)["error_code"] == "invalid-country" + + def test_a_site_that_requires_another_provider_refuses_a_password(self): + with override_settings(ENABLE_REQUIRE_THIRD_PARTY_AUTH=True): + legacy = Client().post(V2_REGISTRATION, registration_form("provider_old")) + conforming = Client().post(REGISTRATIONS, registration_form("provider_new")) + assert_same_answer(legacy, conforming, 403) + assert conforming["Content-Type"].startswith("text/html") + + def test_a_site_that_creates_no_accounts_here_refuses_the_registration(self): + with override_settings(ALLOW_PUBLIC_ACCOUNT_CREATION=False): + legacy = Client().post(V2_REGISTRATION, registration_form("closed_old")) + conforming = Client().post(REGISTRATIONS, registration_form("closed_new")) + assert_same_answer(legacy, conforming, 403) + assert conforming.content == b"Account creation not allowed." + + @override_settings(OPEN_EDX_FILTERS_CONFIG=REFUSE_REGISTRATION) + def test_a_step_the_deployment_adds_refuses_it_at_both(self): + legacy = Client().post(V2_REGISTRATION, registration_form("step_old")) + conforming = Client().post(REGISTRATIONS, registration_form("step_new")) + assert_same_answer(legacy, conforming, 403) + assert json.loads(conforming.content) == { + "error_message": [{"user_message": "Registration is closed."}], + "error_code": "registration-closed", + } + + def test_the_methods_it_does_not_serve_are_refused_alike(self): + # This view runs outside the transaction a request is otherwise given, + # so a refusal marks the transaction it runs in for rollback; each + # request is given one of its own here, as a deployment gives it one. + with transaction.atomic(): + legacy = Client().put(V2_REGISTRATION) + with transaction.atomic(): + conforming = Client().put(REGISTRATIONS) + assert_same_answer(legacy, conforming, 405) + assert json.loads(conforming.content) == {"detail": 'Method "PUT" not allowed.'} + + def test_head_is_answered_as_get_is(self): + legacy, conforming = Client().head(V2_REGISTRATION), Client().head(REGISTRATIONS) + assert legacy.status_code == conforming.status_code == 200 + assert conforming["Content-Type"] == legacy["Content-Type"] == JSON + assert conforming.content == legacy.content == b"" + + def test_options_differ_only_in_the_description(self): + legacy, conforming = Client().options(V2_REGISTRATION), Client().options(REGISTRATIONS) + assert legacy.status_code == conforming.status_code == 200 + _changed, unexpected = differences( + json.loads(legacy.content), json.loads(conforming.content), OPTIONS_DIFFERENCES, + ) + assert not unexpected + assert json.loads(conforming.content)["name"] == "Registration" + + +@skip_unless_lms +@override_settings(CACHES=COUNTING_CACHE) +class RegistrationLimitTests(TestCase): + """The registrations counted against an address are counted at every address registering accounts.""" + + def setUp(self): + super().setUp() + cache.clear() + self.addCleanup(cache.clear) + self.allowed = limit_of(settings.REGISTRATION_RATELIMIT) + + def test_registrations_sent_to_the_older_address_count_here(self): + assert spend(V2_REGISTRATION, {}, self.allowed) == [400] * self.allowed + refused = Client().post(REGISTRATIONS, {}) + assert refused.status_code == 403 + assert json.loads(refused.content) == {"error_code": "forbidden-request"} + assert Client().post(REGISTRATIONS, {}, REMOTE_ADDR="10.0.0.9").status_code == 400 + + def test_registrations_sent_here_count_at_every_older_address(self): + assert spend(REGISTRATIONS, {}, self.allowed) == [400] * self.allowed + for address in (V1_REGISTRATION, V2_REGISTRATION, CREATE_ACCOUNT): + refused = Client().post(address, {}) + assert refused.status_code == 403, address + assert json.loads(refused.content) == {"error_code": "forbidden-request"}, address + + def test_reading_the_form_is_not_counted(self): + assert [Client().get(REGISTRATIONS).status_code for _ in range(self.allowed + 2)] == [200] * ( + self.allowed + 2 + ) + + +@skip_unless_lms +class PasswordResetFormParityTests(TestCase): + """The password reset form is the one the older address describes.""" + + def test_the_form_is_the_same_bytes(self): + legacy, conforming = Client().get(V1_PASSWORD_RESET), Client().get(PASSWORD_RESETS) + assert_byte_equal(legacy, conforming) + assert cookies_set(conforming) == {"csrftoken"} + assert json.loads(conforming.content)["submit_url"] == reverse("password_change_request") + + def test_a_signed_in_caller_reads_the_same_form(self): + UserFactory.create(username="reset_reader", password=PASSWORD) + reader = User.objects.get(username="reset_reader") + assert_byte_equal(signed_in(reader).get(V1_PASSWORD_RESET), signed_in(reader).get(PASSWORD_RESETS)) + + def test_head_is_answered_as_get_is(self): + legacy, conforming = Client().head(V1_PASSWORD_RESET), Client().head(PASSWORD_RESETS) + assert legacy.status_code == conforming.status_code == 200 + assert conforming.content == legacy.content == b"" + + def test_the_methods_it_does_not_serve_are_refused_alike(self): + assert_same_answer(Client().post(V1_PASSWORD_RESET), Client().post(PASSWORD_RESETS), 405) + + def test_options_differ_only_in_the_description(self): + legacy, conforming = Client().options(V1_PASSWORD_RESET), Client().options(PASSWORD_RESETS) + _changed, unexpected = differences( + json.loads(legacy.content), json.loads(conforming.content), OPTIONS_DIFFERENCES, + ) + assert not unexpected + + +@skip_unless_lms +class TokenValidationParityTests(TestCase): + """A reset token is checked here exactly as it is at the older address.""" + + def setUp(self): + super().setUp() + self.owner = UserFactory.create(username="token_owner", password=PASSWORD) + self.other = UserFactory.create(username="token_other", password=PASSWORD) + + def both(self, client_factory, data, content_type=None, **extra): + """Send one check to the older address and to this one, each from a new client.""" + sent = {"content_type": content_type} if content_type else {} + return ( + client_factory().post(V1_TOKEN_VALIDATION, data, **sent, **extra), + client_factory().post(TOKEN_VALIDATIONS, data, **sent, **extra), + ) + + def test_a_valid_token_is_valid(self): + legacy, conforming = self.both(Client, {"token": reset_token(self.owner)}) + assert_same_answer(legacy, conforming, 200) + assert json.loads(conforming.content) == {"is_valid": True} + + def test_a_token_sent_as_json_is_read(self): + legacy, conforming = self.both(Client, json.dumps({"token": reset_token(self.owner)}), JSON) + assert_same_answer(legacy, conforming, 200) + assert json.loads(conforming.content) == {"is_valid": True} + + def test_a_token_that_cannot_be_read_or_is_absent_is_not_valid(self): + for data in ({"token": "zz-zz"}, {}): + legacy, conforming = self.both(Client, data) + assert_same_answer(legacy, conforming, 200) + assert json.loads(conforming.content) == {"is_valid": False}, data + + def test_a_valid_token_activates_an_inactive_account(self): + for address, username in ((V1_TOKEN_VALIDATION, "inactive_old"), (TOKEN_VALIDATIONS, "inactive_new")): + inactive = UserFactory.create(username=username, password=PASSWORD, is_active=False) + response = Client().post(address, {"token": reset_token(inactive)}) + assert json.loads(response.content) == {"is_valid": True}, address + inactive.refresh_from_db() + assert inactive.is_active, address + + def test_a_signed_in_caller_is_told_only_about_its_own_account(self): + # Signing in changes what a token is made from, so each token is made + # once both sessions exist. + sessions = (signed_in(self.owner), signed_in(self.owner)) + self.owner.refresh_from_db() + for token_of, valid in ((self.owner, True), (self.other, False)): + data = {"token": reset_token(token_of)} + legacy = sessions[0].post(V1_TOKEN_VALIDATION, data) + conforming = sessions[1].post(TOKEN_VALIDATIONS, data) + assert_same_answer(legacy, conforming, 200) + assert json.loads(conforming.content) == {"is_valid": valid}, token_of.username + + def test_a_session_caller_sending_no_csrf_token_is_refused(self): + legacy, conforming = self.both( + lambda: signed_in(self.owner, enforce_csrf_checks=True), {"token": reset_token(self.owner)}, + ) + assert_same_answer(legacy, conforming, 403) + assert json.loads(conforming.content) == {"detail": "CSRF Failed: CSRF cookie not set."} + + def test_a_caller_presenting_a_token_of_its_own_needs_no_csrf_token(self): + jwt = create_jwt_for_user(self.owner) + own = self.both( + lambda: Client(enforce_csrf_checks=True), + {"token": reset_token(self.owner)}, + HTTP_AUTHORIZATION=f"JWT {jwt}", + ) + assert_same_answer(*own, 200) + assert json.loads(own[1].content) == {"is_valid": True} + other = self.both(Client, {"token": reset_token(self.other)}, HTTP_AUTHORIZATION=f"JWT {jwt}") + assert json.loads(other[1].content) == {"is_valid": False} + + def test_credentials_that_cannot_be_read_are_refused(self): + legacy, conforming = self.both(Client, {"token": "zz"}, HTTP_AUTHORIZATION="JWT not-a-jwt") + assert_same_answer(legacy, conforming, 401) + assert json.loads(conforming.content) == {"detail": "Invalid token."} + + def test_a_body_that_cannot_be_parsed_is_refused(self): + legacy, conforming = self.both(Client, "{not json", JSON) + assert_same_answer(legacy, conforming, 400) + assert json.loads(conforming.content)["detail"].startswith("JSON parse error") + + def test_the_methods_it_does_not_serve_are_refused_alike(self): + assert_same_answer(Client().get(V1_TOKEN_VALIDATION), Client().get(TOKEN_VALIDATIONS), 405) + + +@skip_unless_lms +@override_settings(CACHES=COUNTING_CACHE) +class TokenValidationLimitTests(TestCase): + """The checks counted against a caller are counted at both addresses.""" + + def setUp(self): + super().setUp() + cache.clear() + self.addCleanup(cache.clear) + self.allowed = limit_of(settings.RESET_PASSWORD_TOKEN_VALIDATE_API_RATELIMIT) + self.owner = UserFactory.create(username="limited_owner", password=PASSWORD) + + def test_checks_sent_to_the_older_address_count_here(self): + assert spend(V1_TOKEN_VALIDATION, {"token": "zz"}, self.allowed) == [200] * self.allowed + refused = Client().post(TOKEN_VALIDATIONS, {"token": "zz"}) + assert refused.status_code == 429 + assert json.loads(refused.content)["detail"].startswith("Request was throttled.") + + def test_checks_sent_here_count_at_the_older_address(self): + assert spend(TOKEN_VALIDATIONS, {"token": "zz"}, self.allowed) == [200] * self.allowed + assert Client().post(V1_TOKEN_VALIDATION, {"token": "zz"}).status_code == 429 + + def test_a_signed_in_caller_is_not_counted_with_the_others(self): + spend(TOKEN_VALIDATIONS, {"token": "zz"}, self.allowed + 1) + session = signed_in(self.owner) + assert [session.post(TOKEN_VALIDATIONS, {"token": "zz"}).status_code for _ in range(3)] == [200] * 3 + + +@skip_unless_lms +class RegistrationValidationParityTests(TestCase): + """Candidate registration values are judged here as they are at the older address.""" + + def both(self, client_factory, data, content_type=JSON): + """Send one check to the older address and to this one, each from a new client.""" + body = json.dumps(data) if content_type == JSON and not isinstance(data, str) else data + return ( + client_factory().post(V1_REGISTRATION_VALIDATION, body, content_type=content_type), + client_factory().post(REGISTRATION_VALIDATIONS, body, content_type=content_type), + ) + + def test_a_taken_username_is_judged_with_suggestions(self): + UserFactory.create(username="judged_name", password=PASSWORD) + legacy, conforming = self.both(Client, {"username": "judged_name", "email": "free@example.com"}) + assert_same_answer(legacy, conforming, 200, volatile={"username_suggestions"}) + body = json.loads(conforming.content) + assert body["validation_decisions"] == { + "username": "It looks like this username is already taken", "email": "", + } + assert len(body["username_suggestions"]) == 3 + + def test_one_field_named_is_the_only_one_judged(self): + legacy, conforming = self.both( + Client, {"form_field_key": "password", "password": "short", "username": "x"}, + ) + assert_same_answer(legacy, conforming, 200) + assert set(json.loads(conforming.content)["validation_decisions"]) == {"password"} + + def test_values_sent_as_a_form_are_read(self): + legacy = Client().post(V1_REGISTRATION_VALIDATION, {"username": "free_name"}) + conforming = Client().post(REGISTRATION_VALIDATIONS, {"username": "free_name"}) + assert_same_answer(legacy, conforming, 200) + assert json.loads(conforming.content) == {"validation_decisions": {"username": ""}} + + def test_nothing_sent_is_nothing_judged(self): + legacy, conforming = self.both(Client, {}) + assert_same_answer(legacy, conforming, 200) + assert json.loads(conforming.content) == {"validation_decisions": {}} + + def test_no_csrf_token_is_asked_for_of_any_caller(self): + UserFactory.create(username="validating_caller", password=PASSWORD) + caller = User.objects.get(username="validating_caller") + for client_factory in ( + lambda: Client(enforce_csrf_checks=True), + lambda: signed_in(caller, enforce_csrf_checks=True), + ): + legacy, conforming = self.both(client_factory, {"username": "x"}) + assert_same_answer(legacy, conforming, 200) + + def test_a_body_that_cannot_be_parsed_is_refused(self): + legacy, conforming = self.both(Client, "{not json") + assert_same_answer(legacy, conforming, 400) + assert json.loads(conforming.content)["detail"].startswith("JSON parse error") + + def test_the_methods_it_does_not_serve_are_refused_alike(self): + legacy = Client().get(V1_REGISTRATION_VALIDATION) + conforming = Client().get(REGISTRATION_VALIDATIONS) + assert_same_answer(legacy, conforming, 405) + + +@skip_unless_lms +@override_settings(CACHES=COUNTING_CACHE) +class RegistrationValidationLimitTests(TestCase): + """The checks counted against an address are counted at both.""" + + def setUp(self): + super().setUp() + cache.clear() + self.addCleanup(cache.clear) + self.allowed = limit_of(settings.REGISTRATION_VALIDATION_RATELIMIT) + + def test_checks_sent_to_the_older_address_count_here(self): + assert spend(V1_REGISTRATION_VALIDATION, {}, self.allowed) == [200] * self.allowed + refused = Client().post(REGISTRATION_VALIDATIONS, {}) + assert refused.status_code == 403 + assert json.loads(refused.content) == {"detail": "You do not have permission to perform this action."} + + def test_checks_sent_here_count_at_the_older_address(self): + assert spend(REGISTRATION_VALIDATIONS, {}, self.allowed) == [200] * self.allowed + assert Client().post(V1_REGISTRATION_VALIDATION, {}).status_code == 403 + + +@skip_unless_lms +class LoginParityTests(TestCase): + """Signing in here is signing in at the version 2 address it replaces.""" + + def setUp(self): + super().setUp() + self.user = UserFactory.create(username="signing_in", email="signing_in@example.com", password=PASSWORD) + + def both(self, client_factory, data, **extra): + """Send one sign-in to the version 2 address and to this one, each from a new client.""" + return ( + client_factory().post(V2_LOGIN_SESSION, data, **extra), + client_factory().post(LOGIN_SESSIONS, data, **extra), + ) + + def test_the_form_is_the_same_bytes_as_at_either_older_version(self): + conforming = Client().get(LOGIN_SESSIONS) + for address in (V1_LOGIN_SESSION, V2_LOGIN_SESSION): + assert_byte_equal(Client().get(address), conforming) + assert {"csrftoken", "sessionid"} <= cookies_set(conforming) + + def test_head_is_answered_as_get_is(self): + legacy, conforming = Client().head(V2_LOGIN_SESSION), Client().head(LOGIN_SESSIONS) + assert legacy.status_code == conforming.status_code == 200 + assert conforming.content == legacy.content == b"" + + def test_options_differ_only_in_the_description(self): + legacy, conforming = Client().options(V2_LOGIN_SESSION), Client().options(LOGIN_SESSIONS) + _changed, unexpected = differences( + json.loads(legacy.content), json.loads(conforming.content), OPTIONS_DIFFERENCES, + ) + assert not unexpected + + def test_an_account_is_named_by_its_username_or_its_email(self): + for named in ("signing_in", "signing_in@example.com"): + legacy, conforming = self.both(Client, {"email_or_username": named, "password": PASSWORD}) + assert_same_answer(legacy, conforming, 200, cookies=SIGNED_IN_COOKIES) + assert json.loads(conforming.content) == {"success": True, "redirect_url": None}, named + + def test_the_version_one_field_name_is_not_read(self): + """The address answers as version 2 does, which reads the account from another field.""" + legacy, conforming = self.both(Client, {"email": "signing_in@example.com", "password": PASSWORD}) + assert_same_answer(legacy, conforming, 400) + assert json.loads(conforming.content) == {"success": False, "value": MISSING_LOGIN_FIELDS, "email": None} + version_one = Client().post(V1_LOGIN_SESSION, {"email": "signing_in@example.com", "password": PASSWORD}) + assert version_one.status_code == 200 + + def test_a_wrong_password_is_refused(self): + legacy, conforming = self.both(Client, {"email_or_username": "signing_in", "password": "wrong"}) + assert_same_answer(legacy, conforming, 400) + body = json.loads(conforming.content) + assert body["error_code"] == "incorrect-email-or-password" + assert body["context"] == {"failure_count": 0} + + def test_an_inactive_account_is_refused(self): + inactive = UserFactory.create(username="not_active", password=PASSWORD, is_active=False) + Registration().register(inactive) + legacy, conforming = self.both(Client, {"email_or_username": "not_active", "password": PASSWORD}) + assert_same_answer(legacy, conforming, 400) + assert json.loads(conforming.content)["error_code"] == "inactive-user" + + def test_a_signed_in_caller_ends_up_signed_in_as_the_account_it_named(self): + other = UserFactory.create(username="signed_in_first", password=PASSWORD) + clients = (signed_in(other), signed_in(other)) + legacy = clients[0].post(V2_LOGIN_SESSION, {"email_or_username": "signing_in", "password": PASSWORD}) + conforming = clients[1].post(LOGIN_SESSIONS, {"email_or_username": "signing_in", "password": PASSWORD}) + assert legacy.status_code == conforming.status_code == 200 + assert int(clients[1].session["_auth_user_id"]) == self.user.id + assert int(clients[0].session["_auth_user_id"]) == self.user.id + + def test_a_sign_in_without_a_csrf_token_is_refused(self): + legacy, conforming = self.both( + lambda: Client(enforce_csrf_checks=True), {"email_or_username": "signing_in", "password": PASSWORD}, + ) + assert_same_answer(legacy, conforming, 403) + assert conforming["Content-Type"].startswith("text/html") + + def test_the_token_the_form_sets_lets_the_caller_sign_in(self): + for form_address, address in ((V2_LOGIN_SESSION, V2_LOGIN_SESSION), (LOGIN_SESSIONS, LOGIN_SESSIONS)): + client = Client(enforce_csrf_checks=True) + client.get(form_address) + response = client.post( + address, + {"email_or_username": "signing_in", "password": PASSWORD}, + HTTP_X_CSRFTOKEN=client.cookies["csrftoken"].value, + ) + assert response.status_code == 200, (address, response.content) + assert json.loads(response.content) == {"success": True, "redirect_url": None} + + def test_credentials_in_a_header_are_not_read(self): + legacy, conforming = self.both( + Client, {"email_or_username": "signing_in", "password": "wrong"}, HTTP_AUTHORIZATION="JWT not-a-jwt", + ) + assert_same_answer(legacy, conforming, 400) + + def test_a_site_that_requires_another_provider_refuses_a_password(self): + with override_settings(ENABLE_REQUIRE_THIRD_PARTY_AUTH=True): + legacy, conforming = self.both(Client, {"email_or_username": "signing_in", "password": PASSWORD}) + assert_same_answer(legacy, conforming, 403) + assert conforming["Content-Type"].startswith("text/html") + + def test_a_provider_account_linked_to_no_account_here_is_refused(self): + running = {"backend": "oa2-provider", "kwargs": {"username": "elsewhere", "uid": "elsewhere"}} + with mock.patch.object(login.pipeline, "running", return_value=True), \ + mock.patch.object(login.pipeline, "get", return_value=running), \ + mock.patch.object( + login.provider.Registry, "get_from_pipeline", return_value=SimpleNamespace(name="Provider"), + ), \ + mock.patch.object(login.pipeline, "get_authenticated_user", side_effect=User.DoesNotExist): + legacy, conforming = self.both(Client, {}) + assert_same_answer(legacy, conforming, 403) + body = json.loads(conforming.content) + assert body["success"] is False + assert body["error_code"] == "third-party-auth-with-no-linked-account" + + @override_settings(OPEN_EDX_FILTERS_CONFIG=REFUSE_LOGIN) + def test_a_step_the_deployment_adds_refuses_it_at_both(self): + legacy, conforming = self.both(Client, {"email_or_username": "signing_in", "password": PASSWORD}) + assert_same_answer(legacy, conforming, 400) + assert json.loads(conforming.content) == { + "success": False, + "value": "Sign-in is closed.", + "redirect_url": "/closed", + "error_code": "sign-in-closed", + "context": {"reason": "closed"}, + "email": "signing_in@example.com", + } + + def test_the_methods_it_does_not_serve_are_refused_alike(self): + assert_same_answer(Client().put(V2_LOGIN_SESSION), Client().put(LOGIN_SESSIONS), 405) + + +@skip_unless_lms +@override_settings(CACHES=COUNTING_CACHE) +class LoginLimitTests(TestCase): + """The failed sign-ins counted against a caller are counted wherever it signs in.""" + + def setUp(self): + super().setUp() + cache.clear() + self.addCleanup(cache.clear) + self.allowed = limit_of(settings.LOGISTRATION_RATELIMIT_RATE) + self.user = UserFactory.create(username="counted", email="counted@example.com", password=PASSWORD) + + def assert_refused_for_a_while(self, response): + """The refusal is asserted on its code and message alone.""" + assert response.status_code == 400 + body = json.loads(response.content) + assert body["success"] is False + assert body["value"] == TOO_MANY_LOGINS + + def test_failures_at_the_older_address_count_here(self): + failures = spend(V2_LOGIN_SESSION, {"email_or_username": "counted", "password": "wrong"}, self.allowed) + assert failures == [400] * self.allowed + self.assert_refused_for_a_while( + Client().post(LOGIN_SESSIONS, {"email_or_username": "counted", "password": PASSWORD}), + ) + + def test_failures_here_count_at_every_older_address(self): + failures = spend(LOGIN_SESSIONS, {"email_or_username": "counted", "password": "wrong"}, self.allowed) + assert failures == [400] * self.allowed + for address in (V1_LOGIN_SESSION, LOGIN_AJAX): + self.assert_refused_for_a_while( + Client().post(address, {"email": "counted@example.com", "password": PASSWORD}), + ) + + +def written_tables(captured): + """Return the (statement, table) of every write a captured block sent to the database.""" + writes = [] + for query in captured: + match = re.match(r'\s*(INSERT INTO|UPDATE|DELETE FROM)\s+[`"]?(\w+)', query["sql"], re.IGNORECASE) + if match: + writes.append((match.group(1).upper(), match.group(2))) + return writes + + +class _FormReadTests: + """Reading a form writes nothing but the caller's session, at either address.""" + + #: The tables a read may write to in this configuration, by the address it reads. + EXPECTED_WRITES = {} + + PAIRS = ( + (V2_REGISTRATION, REGISTRATIONS), + (V1_PASSWORD_RESET, PASSWORD_RESETS), + (V2_LOGIN_SESSION, LOGIN_SESSIONS), + ) + + def captured(self, method, address): + """Return the writes one request to ``address`` sends to the database.""" + with CaptureQueriesContext(connection) as queries: + response = getattr(Client(), method)(address) + assert response.status_code == 200, (method, address) + return written_tables(queries.captured_queries) + + def test_a_read_writes_only_the_session_and_the_same_way_at_both_addresses(self): + for method in ("get", "head"): + for legacy, conforming in self.PAIRS: + legacy_writes, conforming_writes = self.captured(method, legacy), self.captured(method, conforming) + assert conforming_writes == legacy_writes, (method, conforming) + assert {table for _statement, table in conforming_writes} == self.EXPECTED_WRITES[conforming], ( + method, conforming, + ) + + +@skip_unless_lms +@override_settings(ENABLE_THIRD_PARTY_AUTH=True) +class FormReadWithThirdPartyAuthTests(_FormReadTests, TestCase): + """With sign-in through other providers on, the forms that offer it note it in the session.""" + + EXPECTED_WRITES = { + REGISTRATIONS: {"django_session"}, + PASSWORD_RESETS: set(), + LOGIN_SESSIONS: {"django_session"}, + } + + +@skip_unless_lms +@override_settings(ENABLE_THIRD_PARTY_AUTH=False) +class FormReadWithoutThirdPartyAuthTests(_FormReadTests, TestCase): + """With it off, reading a form writes nothing at all.""" + + EXPECTED_WRITES = {REGISTRATIONS: set(), PASSWORD_RESETS: set(), LOGIN_SESSIONS: set()} + + +def query_count(send): + """Return the response ``send`` produces and the number of queries it took.""" + with CaptureQueriesContext(connection) as queries: + response = send() + return response, len(queries.captured_queries) + + +@pytest.mark.django_db +@skip_unless_lms +@pytest.mark.parametrize(("legacy", "conforming"), [ + (V2_REGISTRATION, REGISTRATIONS), + (V1_PASSWORD_RESET, PASSWORD_RESETS), + (V2_LOGIN_SESSION, LOGIN_SESSIONS), +]) +def test_reading_a_form_takes_as_many_queries_at_either_address(django_assert_num_queries, legacy, conforming): + Client().get(legacy) + Client().get(conforming) + _response, expected = query_count(lambda: Client().get(legacy)) + with django_assert_num_queries(expected): + assert Client().get(conforming).status_code == 200 + + +@pytest.mark.django_db +@skip_unless_lms +def test_a_registration_takes_as_many_queries_at_either_address(django_assert_num_queries): + Client().post(V2_REGISTRATION, registration_form("warm_old")) + Client().post(REGISTRATIONS, registration_form("warm_new")) + response, expected = query_count(lambda: Client().post(V2_REGISTRATION, registration_form("count_old"))) + assert response.status_code == 200 + with django_assert_num_queries(expected): + assert Client().post(REGISTRATIONS, registration_form("count_new")).status_code == 200 + + +@pytest.mark.django_db +@skip_unless_lms +@pytest.mark.parametrize(("password", "status"), [(PASSWORD, 200), ("wrong", 400)]) +def test_a_sign_in_takes_as_many_queries_at_either_address(django_assert_num_queries, password, status): + UserFactory.create(username="query_counted", password=PASSWORD) + data = {"email_or_username": "query_counted", "password": password} + Client().post(V2_LOGIN_SESSION, data) + Client().post(LOGIN_SESSIONS, data) + response, expected = query_count(lambda: Client().post(V2_LOGIN_SESSION, data)) + assert response.status_code == status + with django_assert_num_queries(expected): + assert Client().post(LOGIN_SESSIONS, data).status_code == status + + +@pytest.mark.django_db +@skip_unless_lms +@pytest.mark.parametrize(("legacy", "conforming", "data"), [ + (V1_TOKEN_VALIDATION, TOKEN_VALIDATIONS, {"token": "zz-zz"}), + (V1_REGISTRATION_VALIDATION, REGISTRATION_VALIDATIONS, {"username": "query_free", "email": "q@example.com"}), +]) +def test_a_check_takes_as_many_queries_at_either_address(django_assert_num_queries, legacy, conforming, data): + Client().post(legacy, data) + Client().post(conforming, data) + response, expected = query_count(lambda: Client().post(legacy, data)) + assert response.status_code == 200 + with django_assert_num_queries(expected): + assert Client().post(conforming, data).status_code == 200 + + +#: Each operation this version adds for the authentication app, with the name +#: it is published under in the document trimming the area off each path and +#: in the one trimming nothing, and the codes it is published with. +CONFORMING_OPERATIONS = { + (REGISTRATIONS, "get"): ("v2_registrations_retrieve", "api_user_v2_registrations_retrieve", ["200"]), + (REGISTRATIONS, "post"): ( + "v2_registrations_create", "api_user_v2_registrations_create", ["200", "400", "403", "409"], + ), + (PASSWORD_RESETS, "get"): ("v2_password_resets_retrieve", "api_user_v2_password_resets_retrieve", ["200"]), + (TOKEN_VALIDATIONS, "post"): ( + "v2_password_reset_token_validations_create", + "api_user_v2_password_reset_token_validations_create", + ["200", "400", "401", "403", "429"], + ), + (REGISTRATION_VALIDATIONS, "post"): ( + "v2_registration_validations_create", "api_user_v2_registration_validations_create", ["200", "400", "403"], + ), + (LOGIN_SESSIONS, "get"): ("v2_login_sessions_retrieve", "api_user_v2_login_sessions_retrieve", ["200"]), + (LOGIN_SESSIONS, "post"): ( + "v2_login_sessions_create", "api_user_v2_login_sessions_create", ["200", "400", "403"], + ), +} + +#: The media types each body is published in, and whether it is published as one the operation needs. +CONFORMING_REQUESTS = { + (REGISTRATIONS, "post"): ([FORM, MULTIPART], True), + (TOKEN_VALIDATIONS, "post"): ([JSON, FORM, MULTIPART], False), + (REGISTRATION_VALIDATIONS, "post"): ([JSON, FORM, MULTIPART], False), + (LOGIN_SESSIONS, "post"): ([FORM, MULTIPART], True), +} + + +def published_operations(document, keys): + """Return the operation each (path, method) in ``keys`` is published as.""" + return {key: document["paths"][key[0]][key[1]] for key in keys} + + +def schema_nodes(node): + """Yield every mapping inside a published node.""" + if isinstance(node, dict): + yield node + for value in node.values(): + yield from schema_nodes(value) + elif isinstance(node, list): + for value in node: + yield from schema_nodes(value) + + +@skip_unless_lms +class ConformingDocumentTests(TestCase): + """What the two documents of this service publish about each address.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.documents = {"narrow": narrow_document(), "service-wide": service_wide_document()} + + def operations(self, which): + return published_operations(self.documents[which], CONFORMING_OPERATIONS) + + def test_each_operation_is_published_under_its_name_in_both_documents(self): + for which, position in (("narrow", 0), ("service-wide", 1)): + published = {key: operation["operationId"] for key, operation in self.operations(which).items()} + assert published == {key: names[position] for key, names in CONFORMING_OPERATIONS.items()}, which + + def test_each_operation_is_grouped_with_its_area_and_none_is_marked_for_the_client_library(self): + expected = {"narrow": ["user"], "service-wide": ["api"]} + for which, tags in expected.items(): + published = {key: operation["tags"] for key, operation in self.operations(which).items()} + assert published == dict.fromkeys(CONFORMING_OPERATIONS, tags), which + + def test_no_operation_is_marked_superseded(self): + for which in self.documents: + marked = {key: operation.get("deprecated", False) for key, operation in self.operations(which).items()} + assert marked == dict.fromkeys(CONFORMING_OPERATIONS, False), which + + def test_each_operation_publishes_the_codes_it_answers_with(self): + for which in self.documents: + published = {key: sorted(operation["responses"]) for key, operation in self.operations(which).items()} + assert published == {key: codes for key, (_n, _s, codes) in CONFORMING_OPERATIONS.items()}, which + + def test_each_body_is_published_in_the_forms_it_is_read_in(self): + for which in self.documents: + published = { + key: (sorted(operation["requestBody"]["content"]), operation["requestBody"].get("required", False)) + for key, operation in self.operations(which).items() + if key in CONFORMING_REQUESTS + } + expected = {key: (sorted(types), needed) for key, (types, needed) in CONFORMING_REQUESTS.items()} + assert published == expected, which + readers = {key for key, operation in self.operations(which).items() if "requestBody" in operation} + assert readers == set(CONFORMING_REQUESTS), which + + def test_signing_in_publishes_the_fields_version_two_reads(self): + for which in self.documents: + body = self.documents[which]["paths"][LOGIN_SESSIONS]["post"]["requestBody"]["content"] + for media_type in (FORM, MULTIPART): + shape = body[media_type]["schema"] + assert sorted(shape["properties"]) == ["email_or_username", "password"], which + assert shape["required"] == ["email_or_username", "password"], which + + def test_signing_in_publishes_the_security_every_other_address_here_publishes(self): + for which in self.documents: + security = {key: operation.get("security") for key, operation in self.operations(which).items()} + assert security == dict.fromkeys(CONFORMING_OPERATIONS, [{}]), which + schemes = self.documents[which].get("components", {}).get("securitySchemes", {}) + assert "csrf" not in schemes, which + + def test_no_operation_publishes_a_choice_list(self): + for which in self.documents: + for key, operation in self.operations(which).items(): + assert not any("enum" in node for node in schema_nodes(operation)), (which, key) + + def test_the_older_sign_in_still_publishes_what_it_always_has(self): + """Describing the sign-in here leaves the description of the older address untouched.""" + operation = self.documents["service-wide"]["paths"]["/api/user/{api_version}/account/login_session/"]["post"] + for media_type in operation["requestBody"]["content"].values(): + assert media_type["schema"] == {"$ref": "#/components/schemas/LoginUserRequest"} + assert {code: response["content"][JSON]["schema"] for code, response in operation["responses"].items()} == ( + dict.fromkeys(["200", "400", "403"], {"$ref": "#/components/schemas/LoginUserResponse"}) + ) + assert operation["security"] == [{"csrf": []}] + + +def published_response_schema(document, path, method, status): + """Return what a body answered at ``path`` with ``status`` must satisfy, components included.""" + node = document["paths"][path][method]["responses"][str(status)]["content"][JSON]["schema"] + return as_json_schema({**node, "components": document["components"]}) + + +def undeclared_members(body, node, where="$"): + """Return the members of ``body`` an object of ``node`` that lists its members does not declare.""" + if "anyOf" in node: + if body is None: + return [] + node = node["anyOf"][0] + found = [] + if isinstance(body, dict) and "properties" in node: + extra = node.get("additionalProperties") + if extra is None: + found += [f"{where}.{key}" for key in body if key not in node["properties"]] + for key, value in body.items(): + member = node["properties"].get(key, extra if isinstance(extra, dict) else None) + if member: + found += undeclared_members(value, member, f"{where}.{key}") + elif isinstance(body, list) and "items" in node: + for index, item in enumerate(body): + found += undeclared_members(item, node["items"], f"{where}[{index}]") + return found + + +@skip_unless_lms +class PublishedShapeTests(TestCase): + """ + Every body these addresses really answer with is the one the document publishes for it. + + Each response below is sent to the running address, then held against the + shape published for that operation and code: every member published as + always present is there, each value has the published type, and an object + that lists its members carries no other. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.document = service_wide_document() + + def assert_published(self, path, method, response): + """Assert ``response`` is a body the document publishes for that operation and code.""" + assert response["Content-Type"].startswith(JSON), (path, response.status_code) + body = json.loads(response.content) + schema = published_response_schema(self.document, path, method, response.status_code) + jsonschema.validate(body, schema) + assert undeclared_members(body, schema) == [], (path, method, response.status_code) + + def test_the_forms(self): + extra = {**settings.REGISTRATION_EXTRA_FIELDS, "confirm_email": "required", "country": "required"} + with override_settings(REGISTRATION_EXTRA_FIELDS=extra): + self.assert_published(REGISTRATIONS, "get", Client().get(REGISTRATIONS)) + self.assert_published(REGISTRATIONS, "get", Client().get(REGISTRATIONS)) + self.assert_published(PASSWORD_RESETS, "get", Client().get(PASSWORD_RESETS)) + self.assert_published(LOGIN_SESSIONS, "get", Client().get(LOGIN_SESSIONS)) + + def test_a_registration_and_its_refusals(self): + self.assert_published(REGISTRATIONS, "post", Client().post(REGISTRATIONS, registration_form("shape_new"))) + self.assert_published(REGISTRATIONS, "post", Client().post(REGISTRATIONS, registration_form("shape_new"))) + self.assert_published(REGISTRATIONS, "post", Client().post(REGISTRATIONS, {})) + self.assert_published( + REGISTRATIONS, "post", Client().post(REGISTRATIONS, {**registration_form("shape_x"), "country": "ZZ"}), + ) + with override_settings(OPEN_EDX_FILTERS_CONFIG=REFUSE_REGISTRATION): + self.assert_published(REGISTRATIONS, "post", Client().post(REGISTRATIONS, registration_form("shape_y"))) + with override_settings(CACHES=COUNTING_CACHE): + cache.clear() + spend(REGISTRATIONS, {}, limit_of(settings.REGISTRATION_RATELIMIT)) + self.assert_published(REGISTRATIONS, "post", Client().post(REGISTRATIONS, {})) + cache.clear() + + def test_a_token_check_and_its_refusals(self): + owner = UserFactory.create(username="shape_owner", password=PASSWORD) + valid = Client().post(TOKEN_VALIDATIONS, {"token": reset_token(owner)}) + self.assert_published(TOKEN_VALIDATIONS, "post", valid) + self.assert_published(TOKEN_VALIDATIONS, "post", Client().post(TOKEN_VALIDATIONS, "{x", content_type=JSON)) + self.assert_published( + TOKEN_VALIDATIONS, "post", Client().post(TOKEN_VALIDATIONS, {}, HTTP_AUTHORIZATION="JWT not-a-jwt"), + ) + self.assert_published( + TOKEN_VALIDATIONS, "post", signed_in(owner, enforce_csrf_checks=True).post(TOKEN_VALIDATIONS, {}), + ) + with override_settings(CACHES=COUNTING_CACHE): + cache.clear() + spend(TOKEN_VALIDATIONS, {}, limit_of(settings.RESET_PASSWORD_TOKEN_VALIDATE_API_RATELIMIT)) + self.assert_published(TOKEN_VALIDATIONS, "post", Client().post(TOKEN_VALIDATIONS, {})) + cache.clear() + + def test_a_validation_and_its_refusals(self): + UserFactory.create(username="shape_taken", password=PASSWORD) + for data in ({"username": "shape_taken", "email": "e@example.com"}, {"name": "Shape Name"}, {}): + self.assert_published( + REGISTRATION_VALIDATIONS, + "post", + Client().post(REGISTRATION_VALIDATIONS, json.dumps(data), content_type=JSON), + ) + self.assert_published( + REGISTRATION_VALIDATIONS, "post", Client().post(REGISTRATION_VALIDATIONS, "{x", content_type=JSON), + ) + with override_settings(CACHES=COUNTING_CACHE): + cache.clear() + spend(REGISTRATION_VALIDATIONS, {}, limit_of(settings.REGISTRATION_VALIDATION_RATELIMIT)) + self.assert_published(REGISTRATION_VALIDATIONS, "post", Client().post(REGISTRATION_VALIDATIONS, {})) + cache.clear() + + def test_a_sign_in_and_its_refusals(self): + UserFactory.create(username="shape_signer", password=PASSWORD) + inactive = UserFactory.create(username="shape_inactive", password=PASSWORD, is_active=False) + Registration().register(inactive) + for data in ( + {"email_or_username": "shape_signer", "password": PASSWORD}, + {"email_or_username": "shape_signer", "password": "wrong"}, + {"email_or_username": "shape_inactive", "password": PASSWORD}, + {"password": PASSWORD}, + ): + self.assert_published(LOGIN_SESSIONS, "post", Client().post(LOGIN_SESSIONS, data)) + with override_settings(OPEN_EDX_FILTERS_CONFIG=REFUSE_LOGIN): + self.assert_published( + LOGIN_SESSIONS, + "post", + Client().post(LOGIN_SESSIONS, {"email_or_username": "shape_signer", "password": PASSWORD}), + ) + running = {"backend": "oa2-provider", "kwargs": {"username": "elsewhere", "uid": "elsewhere"}} + with mock.patch.object(login.pipeline, "running", return_value=True), \ + mock.patch.object(login.pipeline, "get", return_value=running), \ + mock.patch.object( + login.provider.Registry, "get_from_pipeline", return_value=SimpleNamespace(name="Provider"), + ), \ + mock.patch.object(login.pipeline, "get_authenticated_user", side_effect=User.DoesNotExist): + self.assert_published(LOGIN_SESSIONS, "post", Client().post(LOGIN_SESSIONS, {})) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py index 8c02ee8b1b71..ca9f2182c972 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py @@ -160,6 +160,18 @@ VERSION_TWO_PATHS = LIST_PATHS | MEMBER_PATHS | ACTION_PATHS +#: The addresses this version serves for the authentication app. They answer +#: with the bodies that app has always answered with rather than with the +#: envelope the rest of this version uses, so the checks made over the rest of +#: it leave them out; their own tests are in ``test_authn_addresses``. +AUTHN_PATHS = { + "/api/user/v2/registrations/", + "/api/user/v2/registration_validations/", + "/api/user/v2/login_sessions/", + "/api/user/v2/password_resets/", + "/api/user/v2/password_reset_token_validations/", +} + #: The addresses the retirement pipeline is served at. RETIREMENT_PATHS = { RETIREMENT_LIST_PATH, @@ -557,7 +569,7 @@ def test_only_superseded_addresses_are_marked_deprecated(schema): 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) + assert published == VERSION_TWO_PATHS | AUTHN_PATHS | set(SUPERSEDED_USER_ADDRESSES) @skip_unless_lms diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_urls.py b/openedx/core/djangoapps/user_api/v2/tests/test_urls.py index 355cb9917bac..56fb8e150445 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_urls.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_urls.py @@ -106,6 +106,11 @@ {}, "/api/user/v2/retirement_partner_report_cleanups/", ), + "user_v2:registration_list": ({}, "/api/user/v2/registrations/"), + "user_v2:registration_validation_list": ({}, "/api/user/v2/registration_validations/"), + "user_v2:login_session_list": ({}, "/api/user/v2/login_sessions/"), + "user_v2:password_reset_list": ({}, "/api/user/v2/password_resets/"), + "user_v2:password_reset_token_validation_list": ({}, "/api/user/v2/password_reset_token_validations/"), } @@ -464,8 +469,13 @@ def test_every_address_is_named_and_ends_in_a_slash(self): "account_verification_status", "email_opt_in_list", "email_search_list", + "login_session_list", "name_change_confirmation", "name_change_list", + "password_reset_list", + "password_reset_token_validation_list", + "registration_list", + "registration_validation_list", "retirement_cleanup_list", "retirement_detail", "retirement_erasure", diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_views.py index 0ce55b56aabf..ace80c63ad58 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_views.py @@ -1667,7 +1667,7 @@ def test_every_mounted_address_is_described(self): name: view_class().get_view_description() for name, view_class in routed_view_classes().items() } - assert len(described) == 17, sorted(described) + assert len(described) == 22, sorted(described) assert [name for name, text in described.items() if not text.strip()] == [] def test_no_address_describes_itself_in_implementation_terms(self): diff --git a/openedx/core/djangoapps/user_api/v2/urls.py b/openedx/core/djangoapps/user_api/v2/urls.py index afb25bac3819..be9c3f5f7f1b 100644 --- a/openedx/core/djangoapps/user_api/v2/urls.py +++ b/openedx/core/djangoapps/user_api/v2/urls.py @@ -7,6 +7,7 @@ from openedx.core.djangoapps.profile_images.v2.views import ProfileImageView +from . import authn_views from .views import ( PREFERENCE_KEY_PATTERN_PARTIAL, AccountDeactivationView, @@ -174,6 +175,30 @@ class SnakeCaseRouter(SimpleRouter): UserPreferenceViewSet.as_view({"get": "retrieve"}), name="user_preference_detail", ), + # Registering an account, signing in and resetting a password each create + # something no address here reads back, so each is a collection that is + # only ever posted to or described. Signing in answers as the version this + # API is at, which the view reads from its route. + re_path(r"^registrations/$", authn_views.RegistrationView.as_view(), name="registration_list"), + re_path( + r"^registration_validations/$", + authn_views.RegistrationValidationView.as_view(), + name="registration_validation_list", + ), + re_path( + r"^login_sessions/$", + authn_views.LoginSessionView.as_view(), + {"api_version": "v2"}, + name="login_session_list", + ), + re_path(r"^password_resets/$", authn_views.PasswordResetView.as_view(), name="password_reset_list"), + # The token is a secret, so it is sent in the body rather than in the + # address, where it would be written to every log the request passes. + re_path( + r"^password_reset_token_validations/$", + authn_views.PasswordResetTokenValidation.as_view(), + name="password_reset_token_validation_list", + ), # 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 From d619b1e4786939e648b0df94067422b4b3e6fbe4 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 18:27:59 +0500 Subject: [PATCH 61/82] feat: mark the older registration, sign-in and password reset addresses superseded Each of the nine operations the new version 2 addresses replace is now published with `deprecated: true`, in the service's own document and in the service-wide documents of the LMS and of Studio, which both serve them: /api/user/v1/account/registration/ GET, POST /api/user/v2/account/registration/ GET, POST /api/user/v1/account/password_reset/ GET /api/user/v1/account/password_reset/token/validate/ POST /api/user/v1/validation/registration POST /api/user/{api_version}/account/login_session/ GET, POST The six published keys are added to the existing superseded-address tuple, spelled exactly as the generator publishes them, which both admits them to the service's own document and lets the existing hook mark them. No route, URL name, operationId or component changes, and nothing is written on the views themselves. The unversioned `/user_api/...` twins and `/create_account` are left unmarked, as the twins of every other superseded user address are. Eight of the nine operations published no body at all, or only a 200 with none. They now publish the same bodies and codes as the address replacing each, from the same shapes, so the two cannot drift: the form descriptions, the registration request and its 400/403/409 refusals, the token and validation checks with their refusals. The schema gate therefore flags two lines of each service-wide document as a request body newly required: the two registration POSTs. Neither published any request body before; both now publish the registration body as required, because their handler refuses an empty body with a 400. The posted sign-in keeps the description its handler has always carried, since one key serves two versions with different request fields, and a description on the handler is published in place of any table entry; its two components stay referenced. Tests pin the marks in every document, the published names and groups, that each superseded operation publishes what its replacement does, and that the hook and the filter leave every neighbour alone: the twins, `/create_account`, `/login_ajax`, unpublished spellings of the keys, the new addresses and the version's other addresses. --- cms/lib/tests/test_spectacular.py | 91 +++++++++++++++ lms/lib/tests/test_spectacular.py | 70 +++++++++++- openedx/core/djangoapps/user_api/schema.py | 35 ++++++ .../user_api/v2/tests/test_authn_addresses.py | 108 ++++++++++++++++++ .../user_api/v2/tests/test_schema.py | 11 +- 5 files changed, 310 insertions(+), 5 deletions(-) diff --git a/cms/lib/tests/test_spectacular.py b/cms/lib/tests/test_spectacular.py index f3fd8b1195d4..265c44b87d9a 100644 --- a/cms/lib/tests/test_spectacular.py +++ b/cms/lib/tests/test_spectacular.py @@ -28,6 +28,7 @@ superseded_bodies, ) from openedx.core.djangoapps.user_api.schema_components import PATCHED_USER_RETIREMENT_STATUS +from openedx.core.djangoapps.user_api.v2 import authn_schemas def _endpoint(path): @@ -279,6 +280,11 @@ def test_only_the_colliding_legacy_address_is_suffixed(self): ), } +def published_shapes(responses): + """Return the shape each code of a table of responses is published with, by code.""" + return {str(code): response.response for code, response in responses.items()} + + #: What each superseded user operation this service serves 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 carrying no body at all. @@ -315,6 +321,40 @@ def test_only_the_colliding_legacy_address_is_suffixed(self): ("/api/user/v1/preferences/email_opt_in/", "post"): ( ["application/x-www-form-urlencoded"], {"200": None}, ), + **{ + (address, "get"): ([], published_shapes(responses)) + for address, responses in ( + ("/api/user/v1/account/registration/", authn_schemas.REGISTRATION_FORM_RESPONSES), + ("/api/user/v2/account/registration/", authn_schemas.REGISTRATION_FORM_RESPONSES), + ("/api/user/v1/account/password_reset/", authn_schemas.PASSWORD_RESET_FORM_RESPONSES), + ("/api/user/{api_version}/account/login_session/", authn_schemas.LOGIN_FORM_RESPONSES), + ) + }, + **{ + (address, "post"): (sorted(request), published_shapes(responses)) + for address, request, responses in ( + ( + "/api/user/v1/account/registration/", + authn_schemas.REGISTRATION_REQUEST, + authn_schemas.REGISTRATION_RESPONSES, + ), + ( + "/api/user/v2/account/registration/", + authn_schemas.REGISTRATION_REQUEST, + authn_schemas.REGISTRATION_RESPONSES, + ), + ( + "/api/user/v1/account/password_reset/token/validate/", + authn_schemas.TOKEN_VALIDATION_REQUEST, + authn_schemas.TOKEN_VALIDATION_RESPONSES, + ), + ( + "/api/user/v1/validation/registration", + authn_schemas.REGISTRATION_VALIDATION_REQUEST, + authn_schemas.REGISTRATION_VALIDATION_RESPONSES, + ), + ) + }, } #: Every operation of a superseded user address this service serves, with the @@ -372,6 +412,17 @@ def test_only_the_colliding_legacy_address_is_suffixed(self): "api_user_v1_accounts_update_retirement_status_partial_update" ), ("/api/user/v1/accounts/{username}/retirement_status/", "get"): "api_user_v1_accounts_retirement_status_retrieve", + ("/api/user/v1/account/registration/", "get"): "api_user_v1_account_registration_retrieve", + ("/api/user/v1/account/registration/", "post"): "api_user_v1_account_registration_create", + ("/api/user/v2/account/registration/", "get"): "api_user_v2_account_registration_retrieve", + ("/api/user/v2/account/registration/", "post"): "api_user_v2_account_registration_create", + ("/api/user/v1/account/password_reset/", "get"): "api_user_v1_account_password_reset_retrieve", + ("/api/user/v1/account/password_reset/token/validate/", "post"): ( + "api_user_v1_account_password_reset_token_validate_create" + ), + ("/api/user/v1/validation/registration", "post"): "api_user_v1_validation_registration_create", + ("/api/user/{api_version}/account/login_session/", "get"): "api_user_account_login_session_retrieve", + ("/api/user/{api_version}/account/login_session/", "post"): "api_user_account_login_session_create", } @@ -472,6 +523,46 @@ def test_an_image_may_be_sent_as_the_whole_body_or_as_a_form_part(self): "image/gif", "image/jpeg", "image/pjpeg", "image/png", "multipart/form-data", ] + def test_a_registration_body_is_needed_and_a_check_body_is_not(self): + """An empty registration is refused, while an empty check is answered.""" + marks = { + (address, method): self.schema["paths"][address][method]["requestBody"].get("required", False) + for address, method in ( + ("/api/user/v1/account/registration/", "post"), + ("/api/user/v2/account/registration/", "post"), + ("/api/user/v1/account/password_reset/token/validate/", "post"), + ("/api/user/v1/validation/registration", "post"), + ) + } + assert marks == { + ("/api/user/v1/account/registration/", "post"): True, + ("/api/user/v2/account/registration/", "post"): True, + ("/api/user/v1/account/password_reset/token/validate/", "post"): False, + ("/api/user/v1/validation/registration", "post"): False, + } + + def test_signing_in_by_posting_keeps_what_it_has_always_published(self): + """Its handler is described where it is defined, and nothing here replaces that.""" + operation = self.schema["paths"]["/api/user/{api_version}/account/login_session/"]["post"] + assert {media_type: body["schema"] for media_type, body in operation["requestBody"]["content"].items()} == ( + dict.fromkeys( + ["application/json", "application/x-www-form-urlencoded", "multipart/form-data"], + {"$ref": "#/components/schemas/LoginUserRequest"}, + ) + ) + assert {code: _named_body(response) for code, response in operation["responses"].items()} == ( + dict.fromkeys(["200", "400", "403"], "LoginUserResponse") + ) + assert operation["security"] == [{"csrf": []}] + + def test_no_address_replacing_a_superseded_one_is_published_here(self): + """This service serves none of them, so its document describes only what it serves.""" + replacing = { + path for path in self.schema["paths"] + if path.startswith("/api/user/v2/") and not path.startswith("/api/user/v2/account/") + } + assert replacing == set() + def test_the_described_addresses_are_ones_this_document_marks_superseded(self): described = {address for address, _method in superseded_bodies()} marked = {re.sub(r"\{[\w-]+\}", "{}", address) for address in SUPERSEDED_USER_PATHS} diff --git a/lms/lib/tests/test_spectacular.py b/lms/lib/tests/test_spectacular.py index 38b9cded3028..6f758b3119a1 100644 --- a/lms/lib/tests/test_spectacular.py +++ b/lms/lib/tests/test_spectacular.py @@ -78,10 +78,11 @@ def test_keeps_enrollment_this_version_and_the_addresses_it_supersedes(self): "/api/user/v1/preferences/{username}/{preference_key}", USER_API_V1_VIEWS, ), _endpoint("/api/user/v1/preferences/email_opt_in/", USER_API_V1_VIEWS), + _endpoint("/api/user/v2/account/registration/", USER_AUTHN_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("/user_api/v2/account/registration/", USER_AUTHN_VIEWS), _endpoint("/api/profile_images/v1/{username}/upload", PROFILE_IMAGE_V1_VIEWS), _endpoint("/api/courses/v1/courses/", COURSE_VIEWS), _endpoint("/api/user_tours/v1/tours/"), @@ -103,18 +104,48 @@ def test_keeps_enrollment_this_version_and_the_addresses_it_supersedes(self): "/api/user/v1/preferences/{username}", "/api/user/v1/preferences/{username}/{preference_key}", "/api/user/v1/preferences/email_opt_in/", + "/api/user/v2/account/registration/", "/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/" + route = "/api/user/v2/login_sessions/" 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]} + def test_keeps_the_authentication_addresses_this_version_supersedes_and_none_beside_them(self): + """Each is admitted by its exact published key, whatever app serves it.""" + superseded = [ + "/api/user/v1/account/registration/", + "/api/user/v2/account/registration/", + "/api/user/v1/account/password_reset/", + "/api/user/v1/account/password_reset/token/validate/", + "/api/user/v1/validation/registration", + "/api/user/{api_version}/account/login_session/", + ] + neighbours = [ + # The same app's addresses that keep resolving and are not superseded here. + "/user_api/v1/account/registration/", + "/user_api/v2/account/registration/", + "/user_api/{api_version}/account/login_session/", + "/user_api/v1/account/password_reset/", + "/user_api/v1/account/password_reset/token/validate/", + "/create_account", + "/login_ajax", + # Spellings of the keys above that no route publishes. + "/api/user/v1/account/login_session/", + "/api/user/v1/validation/registration/", + # The addresses replacing them, which only their own views admit. + "/api/user/v2/registrations/", + "/api/user/v2/login_sessions/", + ] + kept = lms_api_filter([_endpoint(route, USER_AUTHN_VIEWS) for route in superseded + neighbours]) + assert [path for path, *_ in kept] == superseded + class LmsMarkLegacyPathsDeprecatedTest(SimpleTestCase): """Slashless v2 paths are legacy and deprecated; slashed ones are conforming.""" @@ -198,6 +229,38 @@ def test_only_the_listed_user_addresses_are_deprecated(self): ): assert "deprecated" not in paths[untouched]["get"], untouched + def test_the_authentication_addresses_are_deprecated_and_their_neighbours_are_not(self): + marked = ( + "/api/user/v1/account/registration/", + "/api/user/v2/account/registration/", + "/api/user/v1/account/password_reset/", + "/api/user/v1/account/password_reset/token/validate/", + "/api/user/v1/validation/registration", + "/api/user/{api_version}/account/login_session/", + ) + untouched = ( + "/user_api/v1/account/registration/", + "/user_api/v2/account/registration/", + "/user_api/{api_version}/account/login_session/", + "/user_api/v1/account/password_reset/", + "/user_api/v1/account/password_reset/token/validate/", + "/create_account", + "/login_ajax", + "/api/user/v1/account/login_session/", + "/api/user/v2/account/login_session/", + "/api/user/v1/validation/registration/", + "/api/user/v2/registrations/", + "/api/user/v2/registration_validations/", + "/api/user/v2/login_sessions/", + "/api/user/v2/password_resets/", + "/api/user/v2/password_reset_token_validations/", + "/api/user/v2/users/", + "/api/user/v2/accounts/{username}/", + ) + paths = mark_superseded_user_paths(_schema(*marked, *untouched), None, None, False)["paths"] + assert {path: paths[path]["get"].get("deprecated") for path in marked} == dict.fromkeys(marked, True) + assert {path: paths[path]["get"].get("deprecated") for path in untouched} == dict.fromkeys(untouched) + class _EmptySerializer(serializers.Serializer): # pylint: disable=abstract-method pass @@ -918,4 +981,7 @@ def test_a_tag_group_holds_one_area_unless_it_marks_the_client_library(self): "v1": ["enrollment"], "openedx-platform-sdk": ["enrollment", "user"], "user": ["user"], + # The one user address that names its version as a parameter: the + # tag is the first segment after it, which is an area, not a version. + "account": ["user"], } diff --git a/openedx/core/djangoapps/user_api/schema.py b/openedx/core/djangoapps/user_api/schema.py index a5744dd1fd19..58bac2cc35ca 100644 --- a/openedx/core/djangoapps/user_api/schema.py +++ b/openedx/core/djangoapps/user_api/schema.py @@ -45,6 +45,12 @@ "/api/user/v1/accounts/cancel_retirement/", "/api/user/v1/accounts/retire/", "/api/user/v1/accounts/retire_misc/", + "/api/user/v1/account/registration/", + "/api/user/v2/account/registration/", + "/api/user/v1/account/password_reset/", + "/api/user/v1/account/password_reset/token/validate/", + "/api/user/v1/validation/registration", + "/api/user/{api_version}/account/login_session/", ) # Two reads of the retirement queue answer with an array, which the generator @@ -224,6 +230,7 @@ def superseded_bodies(): # for the same reason. # pylint: disable=import-outside-toplevel from openedx.core.djangoapps.profile_images.v2.serializers import IMAGE_UPLOAD_REQUEST + from openedx.core.djangoapps.user_api.v2 import authn_schemas from openedx.core.lib.api.parsers import MergePatchParser replacing = import_module("openedx.core.djangoapps.user_api.v2.serializers") @@ -453,6 +460,34 @@ def in_every_form(shape): }, {200: OpenApiResponse(description="The choice was recorded.")}, ), + # Registering, signing in and resetting a password are described with + # the bodies of the addresses replacing them. Signing in by posting has + # no entry: its handler is described where it is defined, which is + # published in place of anything named here. + ("/api/user/v1/account/registration/", "GET"): _SupersededBodies( + None, authn_schemas.REGISTRATION_FORM_RESPONSES, + ), + ("/api/user/v1/account/registration/", "POST"): _SupersededBodies( + authn_schemas.REGISTRATION_REQUEST, authn_schemas.REGISTRATION_RESPONSES, + ), + ("/api/user/v2/account/registration/", "GET"): _SupersededBodies( + None, authn_schemas.REGISTRATION_FORM_RESPONSES, + ), + ("/api/user/v2/account/registration/", "POST"): _SupersededBodies( + authn_schemas.REGISTRATION_REQUEST, authn_schemas.REGISTRATION_RESPONSES, + ), + ("/api/user/v1/account/password_reset/", "GET"): _SupersededBodies( + None, authn_schemas.PASSWORD_RESET_FORM_RESPONSES, + ), + ("/api/user/v1/account/password_reset/token/validate/", "POST"): _SupersededBodies( + authn_schemas.TOKEN_VALIDATION_REQUEST, authn_schemas.TOKEN_VALIDATION_RESPONSES, + ), + ("/api/user/v1/validation/registration", "POST"): _SupersededBodies( + authn_schemas.REGISTRATION_VALIDATION_REQUEST, authn_schemas.REGISTRATION_VALIDATION_RESPONSES, + ), + ("/api/user/{api_version}/account/login_session/", "GET"): _SupersededBodies( + None, authn_schemas.LOGIN_FORM_RESPONSES, + ), } # Parameter names are spelled differently at different points of # generation, so an address is keyed by its shape alone. diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py index a678368e0f6f..28f85f5d92de 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py @@ -1259,6 +1259,114 @@ def test_the_older_sign_in_still_publishes_what_it_always_has(self): assert operation["security"] == [{"csrf": []}] +LOGIN_SESSION_KEY = "/api/user/{api_version}/account/login_session/" + +#: Each operation the addresses above supersede, with the operation replacing +#: it and the name it is published under in the document trimming the area off +#: each path and in the one trimming nothing. +SUPERSEDED_OPERATIONS = { + (V1_REGISTRATION, "get"): ( + (REGISTRATIONS, "get"), "v1_account_registration_retrieve", "api_user_v1_account_registration_retrieve", + ), + (V1_REGISTRATION, "post"): ( + (REGISTRATIONS, "post"), "v1_account_registration_create", "api_user_v1_account_registration_create", + ), + (V2_REGISTRATION, "get"): ( + (REGISTRATIONS, "get"), "v2_account_registration_retrieve", "api_user_v2_account_registration_retrieve", + ), + (V2_REGISTRATION, "post"): ( + (REGISTRATIONS, "post"), "v2_account_registration_create", "api_user_v2_account_registration_create", + ), + (V1_PASSWORD_RESET, "get"): ( + (PASSWORD_RESETS, "get"), + "v1_account_password_reset_retrieve", + "api_user_v1_account_password_reset_retrieve", + ), + (V1_TOKEN_VALIDATION, "post"): ( + (TOKEN_VALIDATIONS, "post"), + "v1_account_password_reset_token_validate_create", + "api_user_v1_account_password_reset_token_validate_create", + ), + (V1_REGISTRATION_VALIDATION, "post"): ( + (REGISTRATION_VALIDATIONS, "post"), + "v1_validation_registration_create", + "api_user_v1_validation_registration_create", + ), + (LOGIN_SESSION_KEY, "get"): ( + (LOGIN_SESSIONS, "get"), "account_login_session_retrieve", "api_user_account_login_session_retrieve", + ), + (LOGIN_SESSION_KEY, "post"): ( + (LOGIN_SESSIONS, "post"), "account_login_session_create", "api_user_account_login_session_create", + ), +} + +#: The one superseded operation whose handler is described where it is +#: defined; it keeps that description rather than taking its replacement's. +SIGN_IN_BY_POSTING = (LOGIN_SESSION_KEY, "post") + + +@skip_unless_lms +class SupersededDocumentTests(TestCase): + """What the two documents of this service publish about each address these replace.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.documents = {"narrow": narrow_document(), "service-wide": service_wide_document()} + + def operations(self, which): + return published_operations(self.documents[which], SUPERSEDED_OPERATIONS) + + def test_each_operation_is_marked_superseded_in_both_documents(self): + for which in self.documents: + marked = {key: operation.get("deprecated") for key, operation in self.operations(which).items()} + assert marked == dict.fromkeys(SUPERSEDED_OPERATIONS, True), which + + def test_each_operation_keeps_the_name_it_is_published_under(self): + for which, position in (("narrow", 1), ("service-wide", 2)): + published = {key: operation["operationId"] for key, operation in self.operations(which).items()} + assert published == {key: names[position] for key, names in SUPERSEDED_OPERATIONS.items()}, which + + def test_each_operation_is_grouped_with_its_area(self): + """Once the version parameter is dropped, the sign-in addresses lead with the segment after it.""" + narrow = {key: operation["tags"] for key, operation in self.operations("narrow").items()} + assert narrow == { + key: ["account"] if key[0] == LOGIN_SESSION_KEY else ["user"] for key in SUPERSEDED_OPERATIONS + } + service_wide = {key: operation["tags"] for key, operation in self.operations("service-wide").items()} + assert service_wide == dict.fromkeys(SUPERSEDED_OPERATIONS, ["api"]) + + def test_each_operation_publishes_what_the_one_replacing_it_publishes(self): + for which in self.documents: + for key, ((path, method), _narrow, _service_wide) in SUPERSEDED_OPERATIONS.items(): + if key == SIGN_IN_BY_POSTING: + continue + superseded = self.documents[which]["paths"][key[0]][key[1]] + replacing = self.documents[which]["paths"][path][method] + assert superseded.get("requestBody") == replacing.get("requestBody"), (which, key) + assert superseded["responses"] == replacing["responses"], (which, key) + + def test_signing_in_by_posting_keeps_what_it_has_always_published(self): + for which, document in self.documents.items(): + operation = document["paths"][LOGIN_SESSION_KEY]["post"] + assert {media_type: body["schema"] for media_type, body in operation["requestBody"]["content"].items()} == ( + dict.fromkeys([JSON, FORM, MULTIPART], {"$ref": "#/components/schemas/LoginUserRequest"}) + ), which + assert {code: response["content"][JSON]["schema"] for code, response in operation["responses"].items()} == ( + dict.fromkeys(["200", "400", "403"], {"$ref": "#/components/schemas/LoginUserResponse"}) + ), which + assert operation["security"] == [{"csrf": []}], which + assert {"LoginUserRequest", "LoginUserResponse"} <= set(document["components"]["schemas"]), which + + def test_only_the_token_check_publishes_an_unauthenticated_or_throttled_refusal(self): + token_checks = {(TOKEN_VALIDATIONS, "post"), (V1_TOKEN_VALIDATION, "post")} + for which, document in self.documents.items(): + keys = set(CONFORMING_OPERATIONS) | set(SUPERSEDED_OPERATIONS) + for key, operation in published_operations(document, keys).items(): + published = set(operation["responses"]) & {"401", "429"} + assert published == ({"401", "429"} if key in token_checks else set()), (which, key) + + def published_response_schema(document, path, method, status): """Return what a body answered at ``path`` with ``status`` must satisfy, components included.""" node = document["paths"][path][method]["responses"][str(status)]["content"][JSON]["schema"] diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py index ca9f2182c972..c5f1b70101df 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py @@ -440,6 +440,12 @@ def assert_records_match(schema, body, component, case=None): "/api/user/v1/accounts/cancel_retirement/": RETIREMENT_MEMBER_PATH, "/api/user/v1/accounts/retire/": RETIREMENT_ERASURE_PATH, "/api/user/v1/accounts/retire_misc/": RETIREMENT_ERASURE_PATH, + "/api/user/v1/account/registration/": "/api/user/v2/registrations/", + "/api/user/v2/account/registration/": "/api/user/v2/registrations/", + "/api/user/v1/account/password_reset/": "/api/user/v2/password_resets/", + "/api/user/v1/account/password_reset/token/validate/": "/api/user/v2/password_reset_token_validations/", + "/api/user/v1/validation/registration": "/api/user/v2/registration_validations/", + "/api/user/{api_version}/account/login_session/": "/api/user/v2/login_sessions/", } #: Legacy enrollment addresses this schema already marked before the user API joined it. @@ -578,8 +584,6 @@ def test_addresses_owned_by_other_apps_are_not_published(schema): for path in ( "/api/user/v1/accounts/deactivate_logout/", "/api/user/v1/accounts/replace_usernames/", - "/api/user/v1/account/registration/", - "/api/user/v2/account/registration/", "/api/profile_images/v1/{username}/upload", "/api/profile_images/v1/{username}/remove", ): @@ -732,7 +736,7 @@ def test_hook_leaves_other_resources_alone(): "/api/profile_images/v1/{username}/upload", "/api/profile_images/v1/{username}/remove", "/api/user/v1/accounts/deactivate_logout/", - "/api/user/v2/account/registration/", + "/user_api/v2/account/registration/", "/api/user/v2/accounts/{username}/preferences/", "/user_api/v1/users/", "/user_api/v1/accounts/{username}", @@ -749,6 +753,7 @@ def test_hook_leaves_other_resources_alone(): "/api/user/v1/preferences/{username}", "/api/user/v1/preferences/{username}/{preference_key}", "/api/user/v1/preferences/email_opt_in/", + "/api/user/v2/account/registration/", "/api/enrollment/v2/enrollments", ] result = {"paths": {path: {"get": {"operationId": path}} for path in untouched + marked}} From c6d0bdc9bab93b68aaf9a83c0fd4f5f619257cd9 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 18:29:25 +0500 Subject: [PATCH 62/82] docs: describe the order this version's mount is matched in as it now is Two comments in the version's URLconf still said the mount is matched ahead of the patterns installed plugins contribute, and that the authentication app's addresses under the prefix are therefore reached through this mount. Since the mount moved after the plugin patterns, the authentication app, which is itself contributed as a plugin, answers its own addresses first, and an address any plugin registers under the prefix reaches that plugin. The comments now say so: naming the app's addresses keeps the fallback from matching them, which is what leaves an address sent without its slash to be redirected rather than refused. No route changes. --- openedx/core/djangoapps/user_api/v2/urls.py | 24 ++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/openedx/core/djangoapps/user_api/v2/urls.py b/openedx/core/djangoapps/user_api/v2/urls.py index be9c3f5f7f1b..854dfe3959c6 100644 --- a/openedx/core/djangoapps/user_api/v2/urls.py +++ b/openedx/core/djangoapps/user_api/v2/urls.py @@ -35,12 +35,14 @@ #: 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. +#: The addresses under this prefix that belong to the authentication app. That +#: app's routes are matched before this mount, so it answers these addresses +#: itself; naming them here keeps the fallback below from matching them too. +#: 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)/?" @@ -205,11 +207,9 @@ class SnakeCaseRouter(SimpleRouter): # route answers rather than reverses, so it carries no name. # # This claims the whole prefix: it matches every address under it that the - # routes above and the addresses named in OTHER_APP_ADDRESSES do not, and - # the service mounts it ahead of the patterns contributed by installed - # plugins. An address a plugin registers under this prefix is therefore - # reached by nothing and answered 404 here. A plugin that needs an address - # of its own serves it under a prefix of its own; one that has to extend - # this version is added to OTHER_APP_ADDRESSES or to the routes above. + # routes above and the addresses named in OTHER_APP_ADDRESSES do not. The + # service mounts it after the patterns contributed by installed plugins, + # so an address a plugin registers under this prefix is answered by that + # plugin, and only an address nothing serves reaches this fallback. re_path(rf"^(?!{OTHER_APP_ADDRESSES}$).*$", UnknownAddressView.as_view()), ] From 4c360de481e17dc16b563d8d484ab25270f7aaa5 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 19:53:29 +0500 Subject: [PATCH 63/82] test: read what each sign-in, registration and reset view declares before any request runs The token check is answered by views that read signed tokens, and the platform's middleware adds a permission to such a view's class, in place, the first time one is requested. Two checks read the classes after the fact, so they failed whenever a token check had run earlier in the same process: under a random test order, or with the token test named first. They now read the classes as the test module is imported, which pytest does for every module before the first test runs. What they guard is unchanged: the version 2 views declare only who may call them, and the views they extend declare no permission of their own. Both still fail when either declaration changes. --- .../user_api/v2/tests/test_authn_addresses.py | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py index 28f85f5d92de..91f60db9b066 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py @@ -27,6 +27,7 @@ from common.djangoapps.student.tests.factories import UserFactory from openedx.core.apidocs import get_api_docs_settings from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user +from openedx.core.djangoapps.user_api.v2 import authn_views from openedx.core.djangoapps.user_api.v2.tests.test_schema import as_json_schema from openedx.core.djangoapps.user_authn.views import login, password_reset, register from openedx.core.djangolib.testing.utils import skip_unless_lms @@ -347,6 +348,28 @@ def test_the_older_validation_address_is_still_served_only_without_a_slash(self) assert Client().post(V1_REGISTRATION_VALIDATION, {}).status_code == 200 +#: What each view these addresses are answered by holds of its own, read as +#: this module is imported, before any request is served. The first request a +#: view that reads signed tokens serves adds a permission to that view's class +#: in place, so a class read once such a request has run no longer shows what +#: it declares. +DECLARED_ATTRIBUTES = { + view: dict(vars(view)) + for view in ( + authn_views.RegistrationView, + authn_views.PasswordResetView, + authn_views.PasswordResetTokenValidation, + authn_views.RegistrationValidationView, + authn_views.LoginSessionView, + register.RegistrationView, + register.RegistrationValidationView, + password_reset.PasswordResetView, + password_reset.PasswordResetTokenValidation, + login.LoginSessionView, + ) +} + + @skip_unless_lms class ViewShapeTests(TestCase): """ @@ -363,11 +386,13 @@ class ViewShapeTests(TestCase): def test_each_view_declares_only_who_may_call_it(self): for address, (_name, legacy_view, _kwargs) in CONFORMING_ROUTES.items(): view = resolve(address).func.view_class - own = set(vars(view)) - {"__firstlineno__", "__static_attributes__"} + declared = DECLARED_ATTRIBUTES[view] + own = set(declared) - {"__firstlineno__", "__static_attributes__"} methods = {name for name in own if name in view.http_method_names} assert own - methods == self.OWN_ATTRIBUTES, address - assert view.permission_classes == (AllowAny,), address - assert methods == {name for name in vars(legacy_view) if name in view.http_method_names}, address + assert declared["permission_classes"] == (AllowAny,), address + legacy_methods = {name for name in DECLARED_ATTRIBUTES[legacy_view] if name in view.http_method_names} + assert methods == legacy_methods, address #: The methods each address is served with. SERVED_METHODS = { @@ -407,7 +432,7 @@ def test_the_extended_views_are_left_as_they_were(self): password_reset.PasswordResetTokenValidation, login.LoginSessionView, ): - assert "permission_classes" not in vars(legacy_view), legacy_view + assert "permission_classes" not in DECLARED_ATTRIBUTES[legacy_view], legacy_view for legacy_method in ( register.RegistrationView.get, register.RegistrationView.post, From 1e5cb056964cf97420e1f120d6e4bf6cebb7a63f Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 19:56:48 +0500 Subject: [PATCH 64/82] fix: publish the label and the hint of a registration input as possibly null A site can add fields to the registration form through a form it names in its settings. Each such field is described with that form field's label and starting value, and a Django form field has neither unless it is given them, so the form description then carries null for both. The published input shape said both were always text, so on such a site the registration form at version 2 and at the addresses it replaces failed the shape every document publishes for it, and a client generated from the document would reject the whole form. Both members are now published as text or null, and the starting value's description says it is null in that case. The new test names an extension form, reads the form at every address that serves it, and holds each body against the shape both documents of the service publish. --- .../djangoapps/user_api/v2/authn_schemas.py | 22 +++++++-- .../user_api/v2/tests/test_authn_addresses.py | 49 +++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/openedx/core/djangoapps/user_api/v2/authn_schemas.py b/openedx/core/djangoapps/user_api/v2/authn_schemas.py index 39eecc21f29e..5578306d36ee 100644 --- a/openedx/core/djangoapps/user_api/v2/authn_schemas.py +++ b/openedx/core/djangoapps/user_api/v2/authn_schemas.py @@ -29,7 +29,11 @@ def read_in_every_form(shape): "description": "One input of the form.", "properties": { "name": {"type": "string", "description": "Name the value is submitted under."}, - "label": {"type": "string", "description": "Label to show beside the input."}, + "label": { + "type": "string", + "nullable": True, + "description": "Label to show beside the input, or null for an input the site adds that is given none.", + }, "type": { "type": "string", "description": ( @@ -37,8 +41,20 @@ def read_in_every_form(shape): "or `plaintext`." ), }, - "defaultValue": {"description": "Value the input starts with: text, or true or false for a checkbox."}, - "placeholder": {"type": "string", "description": "Hint to show in the empty input."}, + "defaultValue": { + "description": ( + "Value the input starts with: text, or true or false for a checkbox. Null for an input the site " + "adds that is given none." + ), + }, + "placeholder": { + "type": "string", + "nullable": True, + "description": ( + "Hint to show in the empty input. An input the site adds carries the value it starts with here, " + "or null where it is given none." + ), + }, "instructions": {"type": "string", "description": "Help text to show with the input."}, "exposed": {"type": "boolean", "description": "Whether to show the input although it is optional."}, "required": {"type": "boolean", "description": "Whether the form cannot be submitted without a value."}, diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py index 91f60db9b066..aff239c7b392 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py @@ -7,6 +7,7 @@ import jsonschema import pytest +from django import forms from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.tokens import default_token_generator @@ -1525,3 +1526,51 @@ def test_a_sign_in_and_its_refusals(self): ), \ mock.patch.object(login.pipeline, "get_authenticated_user", side_effect=User.DoesNotExist): self.assert_published(LOGIN_SESSIONS, "post", Client().post(LOGIN_SESSIONS, {})) + + +class UnlabelledExtensionForm(forms.Form): + """A field a site adds to registration with a name and nothing else: no label, no starting value.""" + + nickname = forms.CharField() + + +#: Forms a site can name to add fields to registration, each with the field +#: it adds and the label that field is described with. +EXTENSION_FORMS = { + "openedx.core.djangoapps.user_api.tests.test_helpers.TestCaseForm": ("favorite_movie", "Fav Flick"), + f"{UnlabelledExtensionForm.__module__}.UnlabelledExtensionForm": ("nickname", None), +} + + +@skip_unless_lms +class ExtensionFormShapeTests(TestCase): + """ + A registration form carrying the fields a site adds is a body both documents publish. + + Such a field holds what the field of the site's form holds, and a field + of a Django form has no label and no starting value unless it is given + them, so the form describes it with neither. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.documents = {"narrow": narrow_document(), "service-wide": service_wide_document()} + + def test_a_field_the_site_adds_is_described_as_published(self): + for form, (name, label) in EXTENSION_FORMS.items(): + # Reading the form appends the added fields to the configured order in place. + order = list(settings.REGISTRATION_FIELD_ORDER) + with override_settings(PROFILE_EXTENSION_FORM=form, REGISTRATION_FIELD_ORDER=order): + for address in (REGISTRATIONS, V1_REGISTRATION, V2_REGISTRATION): + response = Client().get(address) + body = json.loads(response.content) + added = [ + (field["label"], field["placeholder"], field["defaultValue"]) + for field in body["fields"] if field["name"] == name + ] + assert added == [(label, None, None)], (form, address) + for which, document in self.documents.items(): + schema = published_response_schema(document, address, "get", response.status_code) + jsonschema.validate(body, schema) + assert undeclared_members(body, schema) == [], (which, form, address) From 1ca0e9483614fe12e09465977f37830daf5d27d4 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 20:10:23 +0500 Subject: [PATCH 65/82] fix: publish the refusals of an unusable Accept header and an unreadable body Every registration, sign-in and password reset operation, at version 2 and at the addresses it replaces, refuses a request whose Accept header names nothing it can answer in (406), and every one of them that reads a body refuses one in a media type it cannot read (415). Both are settled before the handler runs and answered as {"detail": ...}. None of them published either code, although every other operation of version 2 publishes 406 and every other one reading a body publishes 415, so a client generated from any document met a status it was never told about. Each response table now carries both codes, in the body these views really answer with and with the descriptions the rest of the version uses. The tables are shared with the descriptions of the older addresses, so Studio's document gains the same codes for them. The new test sends each refusal to every address and holds the answer against what each document publishes for that code, so a code the handler produces that is missing from a table fails it even where the pinned list of codes was changed to match. A second test reads which fields the stock registration form requires and requires the published registration body to name exactly those, and Studio's test pins the same list and codes. --- cms/lib/tests/test_spectacular.py | 39 ++++++++ .../djangoapps/user_api/v2/authn_schemas.py | 21 +++++ .../user_api/v2/tests/test_authn_addresses.py | 88 +++++++++++++++++-- 3 files changed, 141 insertions(+), 7 deletions(-) diff --git a/cms/lib/tests/test_spectacular.py b/cms/lib/tests/test_spectacular.py index 265c44b87d9a..4759ea2ffe0f 100644 --- a/cms/lib/tests/test_spectacular.py +++ b/cms/lib/tests/test_spectacular.py @@ -541,6 +541,45 @@ def test_a_registration_body_is_needed_and_a_check_body_is_not(self): ("/api/user/v1/validation/registration", "post"): False, } + def test_a_registration_names_the_fields_the_stock_form_requires(self): + for address in ("/api/user/v1/account/registration/", "/api/user/v2/account/registration/"): + content = self.schema["paths"][address]["post"]["requestBody"]["content"] + assert {media_type: sorted(body["schema"]["required"]) for media_type, body in content.items()} == ( + dict.fromkeys( + ["application/x-www-form-urlencoded", "multipart/form-data"], + ["email", "honor_code", "name", "password", "username"], + ) + ), address + + def test_each_registration_sign_in_and_reset_operation_publishes_its_refusals_of_a_media_type(self): + """Each refuses an answer it cannot give, and each reading a body refuses one it cannot read.""" + login_session = "/api/user/{api_version}/account/login_session/" + published = { + (address, method): sorted(self.schema["paths"][address][method]["responses"]) + for address, method in ( + ("/api/user/v1/account/registration/", "get"), + ("/api/user/v1/account/registration/", "post"), + ("/api/user/v2/account/registration/", "get"), + ("/api/user/v2/account/registration/", "post"), + ("/api/user/v1/account/password_reset/", "get"), + ("/api/user/v1/account/password_reset/token/validate/", "post"), + ("/api/user/v1/validation/registration", "post"), + (login_session, "get"), + ) + } + assert published == { + ("/api/user/v1/account/registration/", "get"): ["200", "406"], + ("/api/user/v1/account/registration/", "post"): ["200", "400", "403", "406", "409", "415"], + ("/api/user/v2/account/registration/", "get"): ["200", "406"], + ("/api/user/v2/account/registration/", "post"): ["200", "400", "403", "406", "409", "415"], + ("/api/user/v1/account/password_reset/", "get"): ["200", "406"], + ("/api/user/v1/account/password_reset/token/validate/", "post"): [ + "200", "400", "401", "403", "406", "415", "429", + ], + ("/api/user/v1/validation/registration", "post"): ["200", "400", "403", "406", "415"], + (login_session, "get"): ["200", "406"], + } + def test_signing_in_by_posting_keeps_what_it_has_always_published(self): """Its handler is described where it is defined, and nothing here replaces that.""" operation = self.schema["paths"]["/api/user/{api_version}/account/login_session/"]["post"] diff --git a/openedx/core/djangoapps/user_api/v2/authn_schemas.py b/openedx/core/djangoapps/user_api/v2/authn_schemas.py index 5578306d36ee..5146c9871d98 100644 --- a/openedx/core/djangoapps/user_api/v2/authn_schemas.py +++ b/openedx/core/djangoapps/user_api/v2/authn_schemas.py @@ -119,6 +119,16 @@ def read_in_every_form(shape): "required": ["detail"], } +#: Published by every operation here. Which representations an answer can be +#: given in is settled as the request enters, before the handler runs. +NOT_ACCEPTABLE = OpenApiResponse( + REFUSAL, + description="The `Accept` header of the request names no representation this endpoint can answer in.", +) + +#: Published by every operation here that reads a body, which is parsed before the handler reads it. +UNREADABLE_BODY = OpenApiResponse(REFUSAL, description="The request body is in a media type this endpoint cannot read.") + REGISTRATION_FORM_RESPONSES = { 200: OpenApiResponse( FORM_DESCRIPTION, @@ -127,6 +137,7 @@ def read_in_every_form(shape): "site is configured." ), ), + 406: NOT_ACCEPTABLE, } REGISTRATION_FIELDS = { @@ -225,10 +236,12 @@ def read_in_every_form(shape): "instead of this body." ), ), + 406: NOT_ACCEPTABLE, 409: OpenApiResponse( REGISTRATION_REFUSAL, description="An account already has that email address or that username.", ), + 415: UNREADABLE_BODY, } PASSWORD_RESET_FORM_RESPONSES = { @@ -236,6 +249,7 @@ def read_in_every_form(shape): FORM_DESCRIPTION, description="The form a password reset is asked for with, which asks for the email address of the account.", ), + 406: NOT_ACCEPTABLE, } TOKEN_VALIDATION_REQUEST = read_in_every_form({ @@ -257,6 +271,8 @@ def read_in_every_form(shape): 400: OpenApiResponse(REFUSAL, description="The body could not be parsed."), 401: OpenApiResponse(REFUSAL, description="The credentials sent could not be read."), 403: OpenApiResponse(REFUSAL, description="A caller signed in with a session sent no valid CSRF token."), + 406: NOT_ACCEPTABLE, + 415: UNREADABLE_BODY, 429: OpenApiResponse(REFUSAL, description="Too many tokens were checked; `detail` says when to try again."), } @@ -308,10 +324,13 @@ def read_in_every_form(shape): 403: OpenApiResponse( REFUSAL, description="Too many checks were sent from the address the request came from.", ), + 406: NOT_ACCEPTABLE, + 415: UNREADABLE_BODY, } LOGIN_FORM_RESPONSES = { 200: OpenApiResponse(FORM_DESCRIPTION, description="The form the site's sign-in page is built from."), + 406: NOT_ACCEPTABLE, } LOGIN_REQUEST = read_as_a_form({ @@ -376,4 +395,6 @@ def read_in_every_form(shape): "through another provider, is refused with an HTML page instead of this body." ), ), + 406: NOT_ACCEPTABLE, + 415: UNREADABLE_BODY, } diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py index aff239c7b392..f319ddf42dfa 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py @@ -32,6 +32,7 @@ from openedx.core.djangoapps.user_api.v2.tests.test_schema import as_json_schema from openedx.core.djangoapps.user_authn.views import login, password_reset, register from openedx.core.djangolib.testing.utils import skip_unless_lms +from openedx.envs.common import REGISTRATION_EXTRA_FIELDS as STOCK_REGISTRATION_EXTRA_FIELDS User = get_user_model() @@ -1165,22 +1166,26 @@ def test_a_check_takes_as_many_queries_at_either_address(django_assert_num_queri #: it is published under in the document trimming the area off each path and #: in the one trimming nothing, and the codes it is published with. CONFORMING_OPERATIONS = { - (REGISTRATIONS, "get"): ("v2_registrations_retrieve", "api_user_v2_registrations_retrieve", ["200"]), + (REGISTRATIONS, "get"): ("v2_registrations_retrieve", "api_user_v2_registrations_retrieve", ["200", "406"]), (REGISTRATIONS, "post"): ( - "v2_registrations_create", "api_user_v2_registrations_create", ["200", "400", "403", "409"], + "v2_registrations_create", "api_user_v2_registrations_create", ["200", "400", "403", "406", "409", "415"], + ), + (PASSWORD_RESETS, "get"): ( + "v2_password_resets_retrieve", "api_user_v2_password_resets_retrieve", ["200", "406"], ), - (PASSWORD_RESETS, "get"): ("v2_password_resets_retrieve", "api_user_v2_password_resets_retrieve", ["200"]), (TOKEN_VALIDATIONS, "post"): ( "v2_password_reset_token_validations_create", "api_user_v2_password_reset_token_validations_create", - ["200", "400", "401", "403", "429"], + ["200", "400", "401", "403", "406", "415", "429"], ), (REGISTRATION_VALIDATIONS, "post"): ( - "v2_registration_validations_create", "api_user_v2_registration_validations_create", ["200", "400", "403"], + "v2_registration_validations_create", + "api_user_v2_registration_validations_create", + ["200", "400", "403", "406", "415"], ), - (LOGIN_SESSIONS, "get"): ("v2_login_sessions_retrieve", "api_user_v2_login_sessions_retrieve", ["200"]), + (LOGIN_SESSIONS, "get"): ("v2_login_sessions_retrieve", "api_user_v2_login_sessions_retrieve", ["200", "406"]), (LOGIN_SESSIONS, "post"): ( - "v2_login_sessions_create", "api_user_v2_login_sessions_create", ["200", "400", "403"], + "v2_login_sessions_create", "api_user_v2_login_sessions_create", ["200", "400", "403", "406", "415"], ), } @@ -1574,3 +1579,72 @@ def test_a_field_the_site_adds_is_described_as_published(self): schema = published_response_schema(document, address, "get", response.status_code) jsonschema.validate(body, schema) assert undeclared_members(body, schema) == [], (which, form, address) + + +#: Where an operation is requested, for one published under a key that is not an address. +REQUESTED_AT = {LOGIN_SESSION_KEY: V2_LOGIN_SESSION} + +#: Each operation that reads a body, at the addresses replacing others and at the ones they replace. +BODY_READERS = set(CONFORMING_REQUESTS) | { + superseded for superseded, (replacing, _narrow, _service_wide) in SUPERSEDED_OPERATIONS.items() + if replacing in CONFORMING_REQUESTS +} + +#: The operations each document is held to here. Signing in by posting at the +#: older address keeps the description its handler carries. +REFUSING_OPERATIONS = { + "narrow": set(CONFORMING_OPERATIONS) | set(SUPERSEDED_OPERATIONS) - {SIGN_IN_BY_POSTING}, + "service-wide": set(CONFORMING_OPERATIONS) | set(SUPERSEDED_OPERATIONS) - {SIGN_IN_BY_POSTING}, +} + + +@skip_unless_lms +class RefusedRepresentationTests(TestCase): + """ + Each operation publishes the refusal of an answer it cannot give and of a body it cannot read. + + Both are settled as the request enters, before the handler runs, so every + operation refuses an ``Accept`` header naming nothing it answers in, and + every operation reading a body refuses one in a media type it cannot + read. Each refusal is sent to the running address and held against the + shape both documents publish for that operation and code. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.documents = {"narrow": narrow_document(), "service-wide": service_wide_document()} + + def test_each_operation_publishes_the_refusals_it_answers_with(self): + for which, keys in REFUSING_OPERATIONS.items(): + for key in sorted(keys): + path, method = key + address = REQUESTED_AT.get(path, path) + requests = [(406, {"HTTP_ACCEPT": "text/html"})] + if key in BODY_READERS: + requests.append((415, {"data": "x", "content_type": "text/plain"})) + for status, extra in requests: + # Registration runs outside the transaction a request is + # otherwise given, so a refusal there marks the one it runs + # in for rollback; each request is given one of its own. + with transaction.atomic(): + response = getattr(Client(), method)(address, **extra) + assert response.status_code == status, (which, key) + assert response["Content-Type"].startswith(JSON), (which, key, status) + body = json.loads(response.content) + schema = published_response_schema(self.documents[which], path, method, status) + jsonschema.validate(body, schema) + assert undeclared_members(body, schema) == [], (which, key, status) + + def test_the_fields_published_as_required_are_the_ones_the_stock_form_requires(self): + """An empty registration is refused for each of them, and for the terms taken from `honor_code`.""" + with override_settings(REGISTRATION_EXTRA_FIELDS=dict(STOCK_REGISTRATION_EXTRA_FIELDS)): + form = json.loads(Client().get(REGISTRATIONS).content) + refused = set(json.loads(Client().post(REGISTRATIONS, {}).content)) - {"error_code"} + required = sorted(field["name"] for field in form["fields"] if field["required"]) + assert refused == set(required) | {"terms_of_service"} + for which, document in self.documents.items(): + for path in (REGISTRATIONS, V1_REGISTRATION, V2_REGISTRATION): + content = document["paths"][path]["post"]["requestBody"]["content"] + published = {media_type: sorted(body["schema"]["required"]) for media_type, body in content.items()} + assert published == dict.fromkeys([FORM, MULTIPART], required), (which, path) From c1ffa84f927a365e0b8ac678519adbec8b3c7219 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 20:35:31 +0500 Subject: [PATCH 66/82] fix: keep the sign-in address that takes its version as a parameter out of the standardized document Listing /api/user/{api_version}/account/login_session/ among the superseded addresses both marks it deprecated wherever it is published and admits it into the LMS document of the standardized APIs, where it had never been. Signing in by posting there is published with the description its handler carries: a request naming `email`, which version 2 does not read, and a success body requiring members the sign-in never answers with. Nothing in the schema module can replace a description a handler carries, so admitting the address put a description known to be wrong into that document. The address is now left out of that document, as it was before, while both service-wide documents still mark both of its operations deprecated and still carry the two components its description names. The address replacing it is published in all three as before. The tests pin that the address and its two components stay out of the standardized document, and that each superseded operation is marked in the documents that publish it. --- lms/lib/spectacular.py | 11 +++- lms/lib/tests/test_spectacular.py | 6 +- .../user_api/v2/tests/test_authn_addresses.py | 61 +++++++++++-------- .../user_api/v2/tests/test_schema.py | 10 ++- 4 files changed, 57 insertions(+), 31 deletions(-) diff --git a/lms/lib/spectacular.py b/lms/lib/spectacular.py index 51a908c62bc7..2f9903b7f3a8 100644 --- a/lms/lib/spectacular.py +++ b/lms/lib/spectacular.py @@ -38,6 +38,13 @@ _VERSION_TAG = re.compile(r"v\d+") +# An address that takes its version as a parameter is left out of the +# superseded addresses this schema admits, though it is still marked wherever +# it is published: posting there to sign in publishes the description its +# handler carries, which is not what that sign-in reads or answers with, and +# nothing here can replace it. +_VERSION_PARAMETER = "{api_version}" + def _is_legacy_path(path): """Conforming routes always end in a slash, so a slashless v2 path is a legacy address.""" @@ -45,8 +52,8 @@ def _is_legacy_path(path): def _is_published_user_path(path, callback): - """A user address is documented when this version serves it, or when this version replaces it.""" - if is_superseded_user_path(path): + """A user address is documented when this version serves it, or when it is a superseded one admitted above.""" + if is_superseded_user_path(path) and _VERSION_PARAMETER not in path: return True if not path.startswith(PUBLISHED_USER_PREFIX): return False diff --git a/lms/lib/tests/test_spectacular.py b/lms/lib/tests/test_spectacular.py index 6f758b3119a1..0c41d6bfdda4 100644 --- a/lms/lib/tests/test_spectacular.py +++ b/lms/lib/tests/test_spectacular.py @@ -125,9 +125,10 @@ def test_keeps_the_authentication_addresses_this_version_supersedes_and_none_bes "/api/user/v1/account/password_reset/", "/api/user/v1/account/password_reset/token/validate/", "/api/user/v1/validation/registration", - "/api/user/{api_version}/account/login_session/", ] neighbours = [ + # Superseded as well, but it takes its version as a parameter. + "/api/user/{api_version}/account/login_session/", # The same app's addresses that keep resolving and are not superseded here. "/user_api/v1/account/registration/", "/user_api/v2/account/registration/", @@ -981,7 +982,4 @@ def test_a_tag_group_holds_one_area_unless_it_marks_the_client_library(self): "v1": ["enrollment"], "openedx-platform-sdk": ["enrollment", "user"], "user": ["user"], - # The one user address that names its version as a parameter: the - # tag is the first segment after it, which is an area, not a version. - "account": ["user"], } diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py index f319ddf42dfa..15872c712579 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py @@ -1294,7 +1294,7 @@ def test_the_older_sign_in_still_publishes_what_it_always_has(self): #: Each operation the addresses above supersede, with the operation replacing #: it and the name it is published under in the document trimming the area off -#: each path and in the one trimming nothing. +#: each path, where that document publishes it, and in the one trimming nothing. SUPERSEDED_OPERATIONS = { (V1_REGISTRATION, "get"): ( (REGISTRATIONS, "get"), "v1_account_registration_retrieve", "api_user_v1_account_registration_retrieve", @@ -1324,10 +1324,10 @@ def test_the_older_sign_in_still_publishes_what_it_always_has(self): "api_user_v1_validation_registration_create", ), (LOGIN_SESSION_KEY, "get"): ( - (LOGIN_SESSIONS, "get"), "account_login_session_retrieve", "api_user_account_login_session_retrieve", + (LOGIN_SESSIONS, "get"), None, "api_user_account_login_session_retrieve", ), (LOGIN_SESSION_KEY, "post"): ( - (LOGIN_SESSIONS, "post"), "account_login_session_create", "api_user_account_login_session_create", + (LOGIN_SESSIONS, "post"), None, "api_user_account_login_session_create", ), } @@ -1335,6 +1335,13 @@ def test_the_older_sign_in_still_publishes_what_it_always_has(self): #: defined; it keeps that description rather than taking its replacement's. SIGN_IN_BY_POSTING = (LOGIN_SESSION_KEY, "post") +#: What each document publishes of the operations above. The narrow document +#: admits no address that takes its version as a parameter. +PUBLISHED_SUPERSEDED_OPERATIONS = { + "narrow": {key for key in SUPERSEDED_OPERATIONS if key[0] != LOGIN_SESSION_KEY}, + "service-wide": set(SUPERSEDED_OPERATIONS), +} + @skip_unless_lms class SupersededDocumentTests(TestCase): @@ -1346,31 +1353,31 @@ def setUpClass(cls): cls.documents = {"narrow": narrow_document(), "service-wide": service_wide_document()} def operations(self, which): - return published_operations(self.documents[which], SUPERSEDED_OPERATIONS) + return published_operations(self.documents[which], PUBLISHED_SUPERSEDED_OPERATIONS[which]) def test_each_operation_is_marked_superseded_in_both_documents(self): for which in self.documents: marked = {key: operation.get("deprecated") for key, operation in self.operations(which).items()} - assert marked == dict.fromkeys(SUPERSEDED_OPERATIONS, True), which + assert marked == dict.fromkeys(PUBLISHED_SUPERSEDED_OPERATIONS[which], True), which def test_each_operation_keeps_the_name_it_is_published_under(self): for which, position in (("narrow", 1), ("service-wide", 2)): published = {key: operation["operationId"] for key, operation in self.operations(which).items()} - assert published == {key: names[position] for key, names in SUPERSEDED_OPERATIONS.items()}, which + assert published == { + key: names[position] for key, names in SUPERSEDED_OPERATIONS.items() + if key in PUBLISHED_SUPERSEDED_OPERATIONS[which] + }, which def test_each_operation_is_grouped_with_its_area(self): - """Once the version parameter is dropped, the sign-in addresses lead with the segment after it.""" narrow = {key: operation["tags"] for key, operation in self.operations("narrow").items()} - assert narrow == { - key: ["account"] if key[0] == LOGIN_SESSION_KEY else ["user"] for key in SUPERSEDED_OPERATIONS - } + assert narrow == dict.fromkeys(PUBLISHED_SUPERSEDED_OPERATIONS["narrow"], ["user"]) service_wide = {key: operation["tags"] for key, operation in self.operations("service-wide").items()} assert service_wide == dict.fromkeys(SUPERSEDED_OPERATIONS, ["api"]) def test_each_operation_publishes_what_the_one_replacing_it_publishes(self): for which in self.documents: for key, ((path, method), _narrow, _service_wide) in SUPERSEDED_OPERATIONS.items(): - if key == SIGN_IN_BY_POSTING: + if key == SIGN_IN_BY_POSTING or key not in PUBLISHED_SUPERSEDED_OPERATIONS[which]: continue superseded = self.documents[which]["paths"][key[0]][key[1]] replacing = self.documents[which]["paths"][path][method] @@ -1378,21 +1385,27 @@ def test_each_operation_publishes_what_the_one_replacing_it_publishes(self): assert superseded["responses"] == replacing["responses"], (which, key) def test_signing_in_by_posting_keeps_what_it_has_always_published(self): - for which, document in self.documents.items(): - operation = document["paths"][LOGIN_SESSION_KEY]["post"] - assert {media_type: body["schema"] for media_type, body in operation["requestBody"]["content"].items()} == ( - dict.fromkeys([JSON, FORM, MULTIPART], {"$ref": "#/components/schemas/LoginUserRequest"}) - ), which - assert {code: response["content"][JSON]["schema"] for code, response in operation["responses"].items()} == ( - dict.fromkeys(["200", "400", "403"], {"$ref": "#/components/schemas/LoginUserResponse"}) - ), which - assert operation["security"] == [{"csrf": []}], which - assert {"LoginUserRequest", "LoginUserResponse"} <= set(document["components"]["schemas"]), which + document = self.documents["service-wide"] + operation = document["paths"][LOGIN_SESSION_KEY]["post"] + assert {media_type: body["schema"] for media_type, body in operation["requestBody"]["content"].items()} == ( + dict.fromkeys([JSON, FORM, MULTIPART], {"$ref": "#/components/schemas/LoginUserRequest"}) + ) + assert {code: response["content"][JSON]["schema"] for code, response in operation["responses"].items()} == ( + dict.fromkeys(["200", "400", "403"], {"$ref": "#/components/schemas/LoginUserResponse"}) + ) + assert operation["security"] == [{"csrf": []}] + assert {"LoginUserRequest", "LoginUserResponse"} <= set(document["components"]["schemas"]) + + def test_the_narrow_document_leaves_out_the_address_that_takes_its_version_as_a_parameter(self): + """Signing in by posting there is described by its handler, and that description is not what it does.""" + narrow = self.documents["narrow"] + assert LOGIN_SESSION_KEY not in narrow["paths"] + assert {"LoginUserRequest", "LoginUserResponse"} & set(narrow["components"]["schemas"]) == set() def test_only_the_token_check_publishes_an_unauthenticated_or_throttled_refusal(self): token_checks = {(TOKEN_VALIDATIONS, "post"), (V1_TOKEN_VALIDATION, "post")} for which, document in self.documents.items(): - keys = set(CONFORMING_OPERATIONS) | set(SUPERSEDED_OPERATIONS) + keys = set(CONFORMING_OPERATIONS) | PUBLISHED_SUPERSEDED_OPERATIONS[which] for key, operation in published_operations(document, keys).items(): published = set(operation["responses"]) & {"401", "429"} assert published == ({"401", "429"} if key in token_checks else set()), (which, key) @@ -1593,8 +1606,8 @@ def test_a_field_the_site_adds_is_described_as_published(self): #: The operations each document is held to here. Signing in by posting at the #: older address keeps the description its handler carries. REFUSING_OPERATIONS = { - "narrow": set(CONFORMING_OPERATIONS) | set(SUPERSEDED_OPERATIONS) - {SIGN_IN_BY_POSTING}, - "service-wide": set(CONFORMING_OPERATIONS) | set(SUPERSEDED_OPERATIONS) - {SIGN_IN_BY_POSTING}, + which: set(CONFORMING_OPERATIONS) | (published - {SIGN_IN_BY_POSTING}) + for which, published in PUBLISHED_SUPERSEDED_OPERATIONS.items() } diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py index c5f1b70101df..73518608e7a2 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py @@ -445,6 +445,11 @@ def assert_records_match(schema, body, component, case=None): "/api/user/v1/account/password_reset/": "/api/user/v2/password_resets/", "/api/user/v1/account/password_reset/token/validate/": "/api/user/v2/password_reset_token_validations/", "/api/user/v1/validation/registration": "/api/user/v2/registration_validations/", +} + +#: The superseded address the document leaves out, though the hook marks it +#: wherever it is published: it takes its version as a parameter. +SUPERSEDED_USER_ADDRESSES_LEFT_OUT = { "/api/user/{api_version}/account/login_session/": "/api/user/v2/login_sessions/", } @@ -769,10 +774,13 @@ def test_hook_leaves_other_resources_alone(): @skip_unless_lms def test_every_superseded_address_has_a_published_replacement(schema): - assert set(SUPERSEDED_USER_ADDRESSES) == set(SUPERSEDED_USER_PATHS) + assert set(SUPERSEDED_USER_ADDRESSES) | set(SUPERSEDED_USER_ADDRESSES_LEFT_OUT) == set(SUPERSEDED_USER_PATHS) for superseded, replacement in SUPERSEDED_USER_ADDRESSES.items(): assert superseded in schema["paths"] assert replacement in schema["paths"] + for superseded, replacement in SUPERSEDED_USER_ADDRESSES_LEFT_OUT.items(): + assert superseded not in schema["paths"] + assert replacement in schema["paths"] #: The media types each superseded body is published in, by address and method. From 7f188cd408e0ba4f27d2e9dd55f548657f3e9429 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 20:21:33 +0500 Subject: [PATCH 67/82] fix: publish the new sign-in with the CSRF requirement the older sign-in publishes The sign-in at /api/user/v2/login_sessions/ needs the CSRF token exactly as the older address does, and the older one publishes that need as a `csrf` security requirement. The new one published no requirement at all, behind a comment saying no published document declares that scheme. The reference generated from docs.docs_settings, which docs/lms-openapi.yaml is built from, does declare it (the X-CSRFToken header), so a client generated from it would send the token to the older sign-in and not to the new one, and its migrated call would be refused. The new sign-in now keeps the requirement of the view it extends, so every document publishes the same security for both sign-in addresses. The documents the services serve still do not declare the scheme, as before for the older address. The test requires the new sign-in's security to be the older sign-in's in both of the service's documents, and every other address here to need nothing. --- .../core/djangoapps/user_api/v2/authn_views.py | 3 --- .../user_api/v2/tests/test_authn_addresses.py | 16 ++++++++++++---- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/openedx/core/djangoapps/user_api/v2/authn_views.py b/openedx/core/djangoapps/user_api/v2/authn_views.py index 0d221f88aa4d..124e3b5c00a2 100644 --- a/openedx/core/djangoapps/user_api/v2/authn_views.py +++ b/openedx/core/djangoapps/user_api/v2/authn_views.py @@ -119,9 +119,6 @@ class RegistrationValidationView(register.RegistrationValidationView): ), request=authn_schemas.LOGIN_REQUEST, responses=authn_schemas.LOGIN_RESPONSES, - # The view it extends names a scheme for the CSRF token that no - # published document declares; the token is described above instead. - auth=[{}], ), ) class LoginSessionView(login.LoginSessionView): diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py index 15872c712579..da61ac0d61e4 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py @@ -1267,12 +1267,20 @@ def test_signing_in_publishes_the_fields_version_two_reads(self): assert sorted(shape["properties"]) == ["email_or_username", "password"], which assert shape["required"] == ["email_or_username", "password"], which - def test_signing_in_publishes_the_security_every_other_address_here_publishes(self): + def test_signing_in_publishes_the_security_the_older_sign_in_publishes(self): + """ + A client generated from either document treats the two sign-in addresses alike. + + Both need the CSRF token, which the older one publishes as a scheme of + its own; a document declaring that scheme says how the token is sent. + No other address here needs anything. + """ + older = self.documents["service-wide"]["paths"][LOGIN_SESSION_KEY]["post"]["security"] + assert older == [{"csrf": []}] + expected = {**dict.fromkeys(CONFORMING_OPERATIONS, [{}]), (LOGIN_SESSIONS, "post"): older} for which in self.documents: security = {key: operation.get("security") for key, operation in self.operations(which).items()} - assert security == dict.fromkeys(CONFORMING_OPERATIONS, [{}]), which - schemes = self.documents[which].get("components", {}).get("securitySchemes", {}) - assert "csrf" not in schemes, which + assert security == expected, which def test_no_operation_publishes_a_choice_list(self): for which in self.documents: From ae1e8e318c8d7821f8305c9e4c6ac7eb05af0181 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 20:54:07 +0500 Subject: [PATCH 68/82] test: count a database write by its first keyword The check that reading a form writes nothing recognised only statements starting INSERT INTO, UPDATE or DELETE FROM. A bulk create that ignores conflicts sends INSERT OR IGNORE INTO on SQLite and INSERT IGNORE INTO on MySQL, so a form read doing one was never counted and passed. A statement now counts as a write when it starts with INSERT, UPDATE, DELETE or REPLACE, whatever follows, and a write whose table cannot be read is still listed. --- .../user_api/v2/tests/test_authn_addresses.py | 55 ++++++++++++++++++- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py index da61ac0d61e4..784e7b7fa677 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py @@ -1041,16 +1041,65 @@ def test_failures_here_count_at_every_older_address(self): ) +#: A statement that changes rows, told by its first keyword whatever follows +#: it, so ``INSERT OR IGNORE INTO`` and ``INSERT IGNORE INTO`` (what a bulk +#: create that ignores conflicts sends) are writes as plain ``INSERT INTO`` is. +WRITE_STATEMENT = re.compile( + r"""\s*(?PINSERT|UPDATE|DELETE|REPLACE)\b + (?:\s+(?:OR\s+\w+|IGNORE|LOW_PRIORITY|DELAYED|HIGH_PRIORITY|QUICK|ONLY|INTO|FROM)\b)* + \s*[`"]?(?P\w+)?""", + re.IGNORECASE | re.VERBOSE, +) + + def written_tables(captured): - """Return the (statement, table) of every write a captured block sent to the database.""" + """ + Return the (statement, table) of every write a captured block sent to the database. + + A write whose table cannot be read is still returned, with the whole + statement in place of the table, so it is never left out. + """ writes = [] for query in captured: - match = re.match(r'\s*(INSERT INTO|UPDATE|DELETE FROM)\s+[`"]?(\w+)', query["sql"], re.IGNORECASE) + match = WRITE_STATEMENT.match(query["sql"]) if match: - writes.append((match.group(1).upper(), match.group(2))) + writes.append((match.group("statement").upper(), match.group("table") or query["sql"])) return writes +@skip_unless_lms +def test_a_write_is_told_by_its_first_keyword_whatever_follows_it(): + captured = [{"sql": sql} for sql in ( + 'INSERT INTO "django_site" ("domain", "name") VALUES (\'a\', \'a\')', + 'INSERT OR IGNORE INTO "django_site" ("domain", "name") VALUES (\'b\', \'b\')', + "INSERT IGNORE INTO `django_site` (`domain`, `name`) VALUES ('c', 'c')", + "REPLACE INTO `django_site` (`domain`, `name`) VALUES ('d', 'd')", + 'UPDATE "django_session" SET "expire_date" = 1', + 'UPDATE OR REPLACE "django_session" SET "expire_date" = 1', + 'DELETE FROM "django_session" WHERE 1', + "DELETE LOW_PRIORITY QUICK IGNORE FROM `django_session` WHERE 1", + " insert into auth_user (username) values ('e')", + "DELETE FROM quick_links WHERE 1", + 'SELECT "id" FROM "django_site" FOR UPDATE', + 'SAVEPOINT "s1"', + 'RELEASE SAVEPOINT "s1"', + "INSERT (", + )] + assert written_tables(captured) == [ + ("INSERT", "django_site"), + ("INSERT", "django_site"), + ("INSERT", "django_site"), + ("REPLACE", "django_site"), + ("UPDATE", "django_session"), + ("UPDATE", "django_session"), + ("DELETE", "django_session"), + ("DELETE", "django_session"), + ("INSERT", "auth_user"), + ("DELETE", "quick_links"), + ("INSERT", "INSERT ("), + ] + + class _FormReadTests: """Reading a form writes nothing but the caller's session, at either address.""" From de99ae451e19b902790355924069c15c34c0515b Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 21:02:20 +0500 Subject: [PATCH 69/82] test: check that reading a form sends no signal and no event Reading a form was guarded only by a capture of the statements it sends to the database. A Django signal or an Open edX event sent from a form read, whose receivers do their work elsewhere, sent no statement and so passed. Each read of the registration, sign-in and password reset forms, GET and HEAD, at the older address and at this one, now records every signal sent and every event sent, turned on or not, and expects none beyond what every request sends and the saving of the caller's session. --- .../user_api/v2/tests/test_authn_addresses.py | 86 ++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py index 784e7b7fa677..ed8fd6da8d16 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py @@ -1,5 +1,6 @@ """Tests for the registration, sign-in and password reset addresses of version 2 of the User API.""" +import contextlib import json import re from types import SimpleNamespace @@ -7,18 +8,30 @@ import jsonschema import pytest +from crum.signals import current_user_setter from django import forms from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.tokens import default_token_generator +from django.contrib.sessions.models import Session from django.core.cache import cache +from django.core.signals import request_finished, request_started from django.db import connection, transaction +from django.db.models.signals import post_init, post_save, pre_init, pre_save +from django.dispatch import Signal from django.test import Client, TestCase from django.test.utils import CaptureQueriesContext, override_settings from django.urls import resolve, reverse from django.utils.http import int_to_base36 from drf_spectacular.generators import SchemaGenerator from drf_spectacular.settings import patched_settings +from edx_django_utils.monitoring.signals import ( + monitoring_support_process_exception, + monitoring_support_process_request, + monitoring_support_process_response, +) +from openedx_events.learning.signals import STUDENT_REGISTRATION_COMPLETED +from openedx_events.tooling import OpenEdxPublicSignal from openedx_filters import PipelineStep from openedx_filters.learning.filters import StudentLoginRequested, StudentRegistrationRequested from rest_framework.permissions import AllowAny @@ -1100,8 +1113,65 @@ def test_a_write_is_told_by_its_first_keyword_whatever_follows_it(): ] +#: What every request sends whatever it reads: the request starting and +#: finishing, the monitoring and current-user middleware around it, and a +#: model instance being built. +SIGNALS_OF_EVERY_REQUEST = ( + request_started, + request_finished, + monitoring_support_process_request, + monitoring_support_process_response, + monitoring_support_process_exception, + current_user_setter, + pre_init, + post_init, +) + + +@contextlib.contextmanager +def signals_sent(): + """ + Record each Django signal and Open edX event sent while this block runs. + + What every request sends is left out, and so is the saving of the + caller's session, which the capture of writes already accounts for. An + event is recorded as it is sent, whether or not events are turned on, and + an event that is turned on is recorded again by the signal it is sent as. + """ + sent = [] + + def recording(send): + """Return a replacement for one of the ways a signal or an event is sent.""" + def spy(signal, *args, **kwargs): + sender = kwargs.get("sender", args[0] if args else None) + saves_the_session = signal in (pre_save, post_save) and sender is Session + if signal not in SIGNALS_OF_EVERY_REQUEST and not saves_the_session: + sent.append((send.__name__, getattr(signal, "event_type", signal), sender)) + return send(signal, *args, **kwargs) + return spy + + with mock.patch.object(Signal, "send", recording(Signal.send)), \ + mock.patch.object(Signal, "send_robust", recording(Signal.send_robust)), \ + mock.patch.object(OpenEdxPublicSignal, "send_event", recording(OpenEdxPublicSignal.send_event)): + yield sent + + +@skip_unless_lms +def test_each_way_of_sending_is_recorded_even_with_events_turned_off(): + signal = Signal() + with mock.patch.object(STUDENT_REGISTRATION_COMPLETED, "_allow_events", False), signals_sent() as sent: + signal.send(sender=User) + signal.send_robust(User) + STUDENT_REGISTRATION_COMPLETED.send_event() + assert sent == [ + ("send", signal, User), + ("send_robust", signal, User), + ("send_event", "org.openedx.learning.student.registration.completed.v1", None), + ] + + class _FormReadTests: - """Reading a form writes nothing but the caller's session, at either address.""" + """Reading a form writes nothing but the caller's session, and sends nothing, at either address.""" #: The tables a read may write to in this configuration, by the address it reads. EXPECTED_WRITES = {} @@ -1119,6 +1189,13 @@ def captured(self, method, address): assert response.status_code == 200, (method, address) return written_tables(queries.captured_queries) + def signalled(self, method, address): + """Return the signals and events one request to ``address`` sends.""" + with signals_sent() as sent: + response = getattr(Client(), method)(address) + assert response.status_code == 200, (method, address) + return sent + def test_a_read_writes_only_the_session_and_the_same_way_at_both_addresses(self): for method in ("get", "head"): for legacy, conforming in self.PAIRS: @@ -1128,6 +1205,13 @@ def test_a_read_writes_only_the_session_and_the_same_way_at_both_addresses(self) method, conforming, ) + def test_a_read_sends_no_signal_or_event_at_either_address(self): + for method in ("get", "head"): + for legacy, conforming in self.PAIRS: + legacy_sent, conforming_sent = self.signalled(method, legacy), self.signalled(method, conforming) + assert conforming_sent == legacy_sent, (method, conforming) + assert not conforming_sent, (method, conforming) + @skip_unless_lms @override_settings(ENABLE_THIRD_PARTY_AUTH=True) From 65a20c31202b9c5544ee5e9bee79fe31b29bfb25 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 21:07:33 +0500 Subject: [PATCH 70/82] test: read the forms while a sign-in through another provider is under way Every capture of what reading a form writes or sends used a new caller with no sign-in through another provider under way, so the part of the registration and sign-in form builders that runs only partway through such a sign-in never ran under it. A write added there passed. Three more cases now read each form with a sign-in under way: through a provider that fills the profile in, through one that does not, and through one this site does not know. Each asserts the same writes and signals at both addresses, and that the registration form was really built for that sign-in. --- .../user_api/v2/tests/test_authn_addresses.py | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py index ed8fd6da8d16..0a1d5d4464e4 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py @@ -39,6 +39,7 @@ from common.djangoapps.student.models import Registration from common.djangoapps.student.tests.factories import UserFactory +from common.djangoapps.third_party_auth.models import OAuth2ProviderConfig from openedx.core.apidocs import get_api_docs_settings from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user from openedx.core.djangoapps.user_api.v2 import authn_views @@ -1233,6 +1234,102 @@ class FormReadWithoutThirdPartyAuthTests(_FormReadTests, TestCase): EXPECTED_WRITES = {REGISTRATIONS: set(), PASSWORD_RESETS: set(), LOGIN_SESSIONS: set()} +#: What a sign-in through another provider that is under way holds: the +#: provider's backend and what the provider has said about the person. +RUNNING_PIPELINE = { + "backend": "google-oauth2", + "kwargs": { + "username": "elsewhere", + "details": {"fullname": "Else Where", "email": "elsewhere@example.com"}, + }, +} + + +class _FormReadDuringThirdPartySignIn(_FormReadTests): + """ + Reading a form while a sign-in through another provider is under way writes nothing and sends nothing. + + The sign-in under way and its provider are handed to the forms in place + of the ones the session would point to, so the session is not read for + them and nothing is written to it. + """ + + EXPECTED_WRITES = {REGISTRATIONS: set(), PASSWORD_RESETS: set(), LOGIN_SESSIONS: set()} + + #: How the registration form shows the fields a provider can fill in: type and default. + FIELDS_SHOWN = {} + + def provider(self): + """Return the provider the sign-in under way runs through, as this site knows it.""" + return OAuth2ProviderConfig(name="Provider", backend_name="google-oauth2", sync_learner_profile_data=True) + + def setUp(self): + super().setUp() + reading = mock.patch.object(login.pipeline, "get", return_value=RUNNING_PIPELINE) + looking_up = mock.patch.object(login.provider.Registry, "get_from_pipeline", return_value=self.provider()) + reading.start() + self.addCleanup(reading.stop) + self.found = looking_up.start() + self.addCleanup(looking_up.stop) + + def test_each_form_is_built_for_the_sign_in_under_way(self): + for legacy, conforming in ((V2_REGISTRATION, REGISTRATIONS), (V2_LOGIN_SESSION, LOGIN_SESSIONS)): + self.found.reset_mock() + assert_byte_equal(Client().get(legacy), Client().get(conforming)) + assert self.found.call_args_list == [mock.call(RUNNING_PIPELINE)] * 2, conforming + fields = json.loads(Client().get(REGISTRATIONS).content)["fields"] + shown = {field["name"]: (field["type"], field["defaultValue"]) for field in fields} + assert {name: shown.get(name) for name in self.FIELDS_SHOWN} == self.FIELDS_SHOWN + + +@skip_unless_lms +@override_settings(ENABLE_THIRD_PARTY_AUTH=True) +class FormReadDuringThirdPartySignInTests(_FormReadDuringThirdPartySignIn, TestCase): + """The provider is known and fills the profile in, so the form hides what it filled in.""" + + FIELDS_SHOWN = { + "username": ("hidden", "elsewhere"), + "name": ("hidden", "Else Where"), + "email": ("hidden", "elsewhere@example.com"), + "password": ("hidden", ""), + "social_auth_provider": ("hidden", "Provider"), + } + + +@skip_unless_lms +@override_settings(ENABLE_THIRD_PARTY_AUTH=True) +class FormReadDuringThirdPartySignInWithoutProfileFillingTests(_FormReadDuringThirdPartySignIn, TestCase): + """The provider is known and does not fill the profile in, so the form offers what it said, to change.""" + + FIELDS_SHOWN = { + "username": ("text", "elsewhere"), + "name": ("text", "Else Where"), + "email": ("email", "elsewhere@example.com"), + "password": ("hidden", ""), + "social_auth_provider": ("hidden", "Provider"), + } + + def provider(self): + return OAuth2ProviderConfig(name="Provider", backend_name="google-oauth2", sync_learner_profile_data=False) + + +@skip_unless_lms +@override_settings(ENABLE_THIRD_PARTY_AUTH=True) +class FormReadDuringThirdPartySignInThroughAnUnknownProviderTests(_FormReadDuringThirdPartySignIn, TestCase): + """The provider is not one this site knows, so the form is the stock one.""" + + FIELDS_SHOWN = { + "username": ("text", ""), + "name": ("text", ""), + "email": ("email", ""), + "password": ("password", ""), + "social_auth_provider": None, + } + + def provider(self): + return None + + def query_count(send): """Return the response ``send`` produces and the number of queries it took.""" with CaptureQueriesContext(connection) as queries: From 8876b0df012fce8a0584a72243ea8e7cecf37087 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 21:12:18 +0500 Subject: [PATCH 71/82] fix: publish every refusal the password reset token check answers with 403 The token check is identified by the platform's JWT authentication, so the platform's middleware adds the permission that refuses a JWT issued to a restricted application, at the older address and at this one alike. The published 403 named only the missing CSRF token, and no test sent such a JWT. The 403 now names both causes, and two tests run at both addresses: a JWT issued to a restricted application is refused with the same body, and a session whose account was since made inactive is answered as a caller who is not signed in. --- .../core/djangoapps/user_api/v2/authn_schemas.py | 8 +++++++- .../user_api/v2/tests/test_authn_addresses.py | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/user_api/v2/authn_schemas.py b/openedx/core/djangoapps/user_api/v2/authn_schemas.py index 5146c9871d98..7b6b4977da1d 100644 --- a/openedx/core/djangoapps/user_api/v2/authn_schemas.py +++ b/openedx/core/djangoapps/user_api/v2/authn_schemas.py @@ -270,7 +270,13 @@ def read_in_every_form(shape): ), 400: OpenApiResponse(REFUSAL, description="The body could not be parsed."), 401: OpenApiResponse(REFUSAL, description="The credentials sent could not be read."), - 403: OpenApiResponse(REFUSAL, description="A caller signed in with a session sent no valid CSRF token."), + 403: OpenApiResponse( + REFUSAL, + description=( + "A caller signed in with a session sent no valid CSRF token, or the JWT sent was issued to a " + "restricted application." + ), + ), 406: NOT_ACCEPTABLE, 415: UNREADABLE_BODY, 429: OpenApiResponse(REFUSAL, description="Too many tokens were checked; `detail` says when to try again."), diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py index 0a1d5d4464e4..a10061ba77c4 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py @@ -763,6 +763,22 @@ def test_a_caller_presenting_a_token_of_its_own_needs_no_csrf_token(self): other = self.both(Client, {"token": reset_token(self.other)}, HTTP_AUTHORIZATION=f"JWT {jwt}") assert json.loads(other[1].content) == {"is_valid": False} + def test_a_caller_presenting_a_token_issued_to_a_restricted_application_is_refused(self): + jwt = create_jwt_for_user(self.owner, additional_claims={"is_restricted": True}) + legacy, conforming = self.both(Client, {"token": reset_token(self.owner)}, HTTP_AUTHORIZATION=f"JWT {jwt}") + assert_same_answer(legacy, conforming, 403) + assert json.loads(conforming.content) == {"detail": "You do not have permission to perform this action."} + + def test_a_session_whose_account_was_made_inactive_is_answered_as_no_session(self): + """Another account's token is valid to it, and no CSRF token is asked for, as for a caller not signed in.""" + sessions = (signed_in(self.other, enforce_csrf_checks=True), signed_in(self.other, enforce_csrf_checks=True)) + User.objects.filter(pk=self.other.pk).update(is_active=False) + data = {"token": reset_token(self.owner)} + legacy = sessions[0].post(V1_TOKEN_VALIDATION, data) + conforming = sessions[1].post(TOKEN_VALIDATIONS, data) + assert_same_answer(legacy, conforming, 200) + assert json.loads(conforming.content) == {"is_valid": True} + def test_credentials_that_cannot_be_read_are_refused(self): legacy, conforming = self.both(Client, {"token": "zz"}, HTTP_AUTHORIZATION="JWT not-a-jwt") assert_same_answer(legacy, conforming, 401) From f6eca9d552beb9481b8a6675429fd0da3d9b7945 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 21:23:12 +0500 Subject: [PATCH 72/82] test: show both sign-in limits shared in every direction The sign-in limit tests spent failures from one client, so only the limit per client was ever exceeded; nothing showed that the limit per account is counted together at the new sign-in address and the older one. Failures naming one account, each from a client of its own, are now spent at one address and refused at the other, both ways, while another account is still let in. The directions not yet sent are added too: failures at the version one sign-in and at the older sign-in function count at the new address, failures there count at the older version two address, and registrations sent to the version one address or to the older registration function count at the new one. --- .../user_api/v2/tests/test_authn_addresses.py | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py index a10061ba77c4..c2e5aecc1912 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py @@ -156,6 +156,7 @@ SIGNED_IN_COOKIES = {"csrftoken", "edx-user-info", "edxloggedin", "openedx-language-preference", "sessionid"} TOO_MANY_LOGINS = "Too many failed login attempts. Try again later." +WRONG_LOGIN = "Email or password is incorrect." MISSING_LOGIN_FIELDS = "There was an error receiving your login information. Please email us." @@ -647,6 +648,14 @@ def test_registrations_sent_to_the_older_address_count_here(self): assert json.loads(refused.content) == {"error_code": "forbidden-request"} assert Client().post(REGISTRATIONS, {}, REMOTE_ADDR="10.0.0.9").status_code == 400 + def test_registrations_sent_to_the_other_older_addresses_count_here(self): + for address in (V1_REGISTRATION, CREATE_ACCOUNT): + cache.clear() + assert spend(address, {}, self.allowed) == [400] * self.allowed, address + refused = Client().post(REGISTRATIONS, {}) + assert refused.status_code == 403, address + assert json.loads(refused.content) == {"error_code": "forbidden-request"}, address + def test_registrations_sent_here_count_at_every_older_address(self): assert spend(REGISTRATIONS, {}, self.allowed) == [400] * self.allowed for address in (V1_REGISTRATION, V2_REGISTRATION, CREATE_ACCOUNT): @@ -1039,7 +1048,7 @@ def test_the_methods_it_does_not_serve_are_refused_alike(self): @skip_unless_lms @override_settings(CACHES=COUNTING_CACHE) class LoginLimitTests(TestCase): - """The failed sign-ins counted against a caller are counted wherever it signs in.""" + """The failed sign-ins counted against a caller, or against the account named, are counted wherever it signs in.""" def setUp(self): super().setUp() @@ -1062,6 +1071,15 @@ def test_failures_at_the_older_address_count_here(self): Client().post(LOGIN_SESSIONS, {"email_or_username": "counted", "password": PASSWORD}), ) + def test_failures_at_version_one_or_the_sign_in_function_count_here(self): + for address in (V1_LOGIN_SESSION, LOGIN_AJAX): + cache.clear() + failures = spend(address, {"email": "counted@example.com", "password": "wrong"}, self.allowed) + assert failures == [400] * self.allowed, address + self.assert_refused_for_a_while( + Client().post(LOGIN_SESSIONS, {"email_or_username": "counted", "password": PASSWORD}), + ) + def test_failures_here_count_at_every_older_address(self): failures = spend(LOGIN_SESSIONS, {"email_or_username": "counted", "password": "wrong"}, self.allowed) assert failures == [400] * self.allowed @@ -1069,6 +1087,39 @@ def test_failures_here_count_at_every_older_address(self): self.assert_refused_for_a_while( Client().post(address, {"email": "counted@example.com", "password": PASSWORD}), ) + self.assert_refused_for_a_while( + Client().post(V2_LOGIN_SESSION, {"email_or_username": "counted", "password": PASSWORD}), + ) + + def assert_counted_for_the_account_at_both(self, spent_at, refused_at): + """ + Failures naming one account, each from a client at an address of its own, are counted for the account. + + No client sends enough of them to be refused for its own address, so + the refusal that follows, from yet another client, is the one counted + for the account. + """ + allowed = limit_of(settings.LOGISTRATION_PER_EMAIL_RATELIMIT_RATE) + failures = [ + Client().post(spent_at, {"email_or_username": "counted", "password": "wrong"}, REMOTE_ADDR=f"10.0.1.{n}") + for n in range(allowed) + ] + assert [(failure.status_code, json.loads(failure.content)["value"]) for failure in failures] == ( + [(400, WRONG_LOGIN)] * allowed + ) + UserFactory.create(username="not_counted", password=PASSWORD) + assert Client().post( + refused_at, {"email_or_username": "not_counted", "password": PASSWORD}, REMOTE_ADDR="10.0.2.1", + ).status_code == 200 + self.assert_refused_for_a_while( + Client().post(refused_at, {"email_or_username": "counted", "password": PASSWORD}, REMOTE_ADDR="10.0.2.1"), + ) + + def test_failures_for_one_account_at_the_older_address_count_here_from_any_client(self): + self.assert_counted_for_the_account_at_both(V2_LOGIN_SESSION, LOGIN_SESSIONS) + + def test_failures_for_one_account_here_count_at_the_older_address_from_any_client(self): + self.assert_counted_for_the_account_at_both(LOGIN_SESSIONS, V2_LOGIN_SESSION) #: A statement that changes rows, told by its first keyword whatever follows From 023201d700afee71f899628f7308392104b15fbe Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 22:00:12 +0500 Subject: [PATCH 73/82] fix: publish the registrations that need no password A registration that completes a sign-in through another provider carries no password: the server generates one. The authentication front end leaves the password out while such a sign-in is under way in the session, and a client that signed the person in with the provider itself sends the provider and its access token instead. The published request required a password of every registration, so a validator or a strict client built from either document refused both. The shape now requires the fields every registration needs and, beside them, one of three things: a password, the provider of the sign-in under way, or a provider with its access token. An ordinary registration is still published as needing a password, and the tests send each body to every registration address after holding it against both documents. --- cms/lib/tests/test_spectacular.py | 17 ++- .../djangoapps/user_api/v2/authn_schemas.py | 39 +++++- .../user_api/v2/tests/test_authn_addresses.py | 126 +++++++++++++++++- 3 files changed, 171 insertions(+), 11 deletions(-) diff --git a/cms/lib/tests/test_spectacular.py b/cms/lib/tests/test_spectacular.py index 4759ea2ffe0f..e1261da52623 100644 --- a/cms/lib/tests/test_spectacular.py +++ b/cms/lib/tests/test_spectacular.py @@ -542,13 +542,22 @@ def test_a_registration_body_is_needed_and_a_check_body_is_not(self): } def test_a_registration_names_the_fields_the_stock_form_requires(self): + """The password is required unless the registration completes a sign-in through another provider.""" for address in ("/api/user/v1/account/registration/", "/api/user/v2/account/registration/"): content = self.schema["paths"][address]["post"]["requestBody"]["content"] - assert {media_type: sorted(body["schema"]["required"]) for media_type, body in content.items()} == ( - dict.fromkeys( - ["application/x-www-form-urlencoded", "multipart/form-data"], - ["email", "honor_code", "name", "password", "username"], + published = { + media_type: ( + sorted(body["schema"]["required"]), + [sorted(way["required"]) for way in body["schema"]["anyOf"]], ) + for media_type, body in content.items() + } + assert published == dict.fromkeys( + ["application/x-www-form-urlencoded", "multipart/form-data"], + ( + ["email", "honor_code", "name", "username"], + [["password"], ["social_auth_provider"], ["access_token", "provider"]], + ), ), address def test_each_registration_sign_in_and_reset_operation_publishes_its_refusals_of_a_media_type(self): diff --git a/openedx/core/djangoapps/user_api/v2/authn_schemas.py b/openedx/core/djangoapps/user_api/v2/authn_schemas.py index 7b6b4977da1d..890f006406fb 100644 --- a/openedx/core/djangoapps/user_api/v2/authn_schemas.py +++ b/openedx/core/djangoapps/user_api/v2/authn_schemas.py @@ -145,7 +145,11 @@ def read_in_every_form(shape): "description": ( "The fields of the registration form, each under its name. The fields listed as required are the ones " "a site requires unless it is configured otherwise; the form description lists the ones this site asks " - "for, and every field it lists is accepted here." + "for, and every field it lists is accepted here. A registration carries a password, unless it completes " + "a sign-in through another provider, for which the server generates the password: the sign-in under way " + "in the caller's session, named in `social_auth_provider`, or one the client made with the provider " + "itself, named in `provider` with the access token the provider issued. Both ways apply only where the " + "site lets people sign in through other providers." ), "properties": { "name": {"type": "string", "description": "Full name of the person registering."}, @@ -155,7 +159,31 @@ def read_in_every_form(shape): "type": "string", "description": "The email address again, where the site asks for it.", }, - "password": {"type": "string", "description": "Password of the account.", "format": "password"}, + "password": { + "type": "string", + "description": ( + "Password of the account. Left out of a registration that completes a sign-in through another " + "provider, for which the server generates one and ignores any value sent." + ), + "format": "password", + }, + "social_auth_provider": { + "type": "string", + "description": ( + "Name of the provider of the sign-in under way in the caller's session, which the form lists " + "while one is under way. A registration naming one after that sign-in has expired is refused." + ), + }, + "provider": { + "type": "string", + "description": ( + "Backend of the provider the client signed the person in with itself, such as `google-oauth2`." + ), + }, + "access_token": { + "type": "string", + "description": "Access token that provider issued, through which the new account is linked to it.", + }, "honor_code": {"type": "string", "description": "`true` once the site's terms are accepted."}, "terms_of_service": { "type": "string", @@ -163,7 +191,12 @@ def read_in_every_form(shape): }, "country": {"type": "string", "description": "Two-letter code of the country of residence."}, }, - "required": ["name", "username", "email", "password", "honor_code"], + "required": ["name", "username", "email", "honor_code"], + "anyOf": [ + {"required": ["password"]}, + {"required": ["social_auth_provider"]}, + {"required": ["provider", "access_token"]}, + ], "additionalProperties": _TEXT, } diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py index c2e5aecc1912..ec2496fbde04 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py @@ -1722,6 +1722,12 @@ def published_response_schema(document, path, method, status): return as_json_schema({**node, "components": document["components"]}) +def published_request_schema(document, path, media_type): + """Return what a body posted to ``path`` in ``media_type`` must satisfy, components included.""" + node = document["paths"][path]["post"]["requestBody"]["content"][media_type]["schema"] + return as_json_schema({**node, "components": document["components"]}) + + def undeclared_members(body, node, where="$"): """Return the members of ``body`` an object of ``node`` that lists its members does not declare.""" if "anyOf" in node: @@ -1955,14 +1961,126 @@ def test_each_operation_publishes_the_refusals_it_answers_with(self): assert undeclared_members(body, schema) == [], (which, key, status) def test_the_fields_published_as_required_are_the_ones_the_stock_form_requires(self): - """An empty registration is refused for each of them, and for the terms taken from `honor_code`.""" + """ + An empty registration is refused for each of them, and for the terms taken from `honor_code`. + + The password is published as the first of the ways a registration can + be completed, beside the two that complete a sign-in through another + provider, so every other field the stock form requires is required + whichever way is taken. + """ with override_settings(REGISTRATION_EXTRA_FIELDS=dict(STOCK_REGISTRATION_EXTRA_FIELDS)): form = json.loads(Client().get(REGISTRATIONS).content) refused = set(json.loads(Client().post(REGISTRATIONS, {}).content)) - {"error_code"} required = sorted(field["name"] for field in form["fields"] if field["required"]) assert refused == set(required) | {"terms_of_service"} + assert "password" in required + expected = (sorted(set(required) - {"password"}), WAYS_OF_REGISTERING) for which, document in self.documents.items(): - for path in (REGISTRATIONS, V1_REGISTRATION, V2_REGISTRATION): + for path in REGISTRATION_ADDRESSES: content = document["paths"][path]["post"]["requestBody"]["content"] - published = {media_type: sorted(body["schema"]["required"]) for media_type, body in content.items()} - assert published == dict.fromkeys([FORM, MULTIPART], required), (which, path) + published = { + media_type: ( + sorted(body["schema"]["required"]), + [sorted(way["required"]) for way in body["schema"]["anyOf"]], + ) + for media_type, body in content.items() + } + assert published == dict.fromkeys([FORM, MULTIPART], expected), (which, path) + + +#: Where an account is registered, here and at the addresses this one replaces. +REGISTRATION_ADDRESSES = (REGISTRATIONS, V1_REGISTRATION, V2_REGISTRATION) + +#: What a registration carries besides the fields every registration needs: +#: a password, or what completes a sign-in through another provider. +WAYS_OF_REGISTERING = [["password"], ["social_auth_provider"], ["access_token", "provider"]] + + +def without_password(body): + """Return ``body`` with the password left out.""" + return {name: value for name, value in body.items() if name != "password"} + + +@skip_unless_lms +@override_settings(ENABLE_THIRD_PARTY_AUTH=True) +class RegistrationRequestShapeTests(TestCase): + """ + The registration both documents publish is the one each registration address takes. + + A registration completing a sign-in through another provider carries no + password, because the server generates one, and every other registration + carries one. Each body below is held against the shape both documents + publish at each registration address, in each form it is read in, and is + then sent to that address. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.documents = {"narrow": narrow_document(), "service-wide": service_wide_document()} + + def accepted(self, body): + """Return whether ``body`` satisfies the published shape, by document, address and media type.""" + verdicts = {} + for which, document in self.documents.items(): + for address in REGISTRATION_ADDRESSES: + for media_type in (FORM, MULTIPART): + schema = published_request_schema(document, address, media_type) + validator = jsonschema.validators.validator_for(schema)(schema) + verdicts[(which, address, media_type)] = validator.is_valid(body) + return verdicts + + def everywhere(self, verdict): + """Return ``verdict`` for every document, address and media type.""" + return { + (which, address, media_type): verdict + for which in self.documents for address in REGISTRATION_ADDRESSES for media_type in (FORM, MULTIPART) + } + + def assert_registered(self, response, body): + """Assert ``response`` created the account ``body`` names and signed the caller in as it.""" + assert response.status_code == 200, response.content + assert json.loads(response.content)["authenticated_user"]["username"] == body["username"] + assert User.objects.get(username=body["username"]).email == body["email"] + + def test_the_front_end_registers_through_another_provider_without_a_password(self): + """ + The authentication front end names the provider the form lists and leaves the password out. + + The sign-in under way is handed to the view in place of the one the + session would point to. + """ + provider = OAuth2ProviderConfig(name="Provider", backend_name="google-oauth2") + for index, address in enumerate(REGISTRATION_ADDRESSES): + body = {**without_password(registration_form(f"elsewhere_{index}")), "social_auth_provider": "Provider"} + assert self.accepted(body) == self.everywhere(True), address + with mock.patch.object(register.pipeline, "get", return_value=RUNNING_PIPELINE), \ + mock.patch.object(register.provider.Registry, "get_from_pipeline", return_value=provider): + self.assert_registered(Client().post(address, body), body) + + def test_a_client_registers_with_a_providers_access_token_without_a_password(self): + """The provider's check of the token is stood in for by one that accepts it.""" + backend = SimpleNamespace(do_auth=lambda token, user=None: user) + for index, address in enumerate(REGISTRATION_ADDRESSES): + body = { + **without_password(registration_form(f"token_{index}")), + "provider": "google-oauth2", + "access_token": "token", + } + assert self.accepted(body) == self.everywhere(True), address + with mock.patch.object(register.social_utils, "load_backend", return_value=backend): + self.assert_registered(Client().post(address, body), body) + + def test_any_other_registration_needs_a_password_in_both_documents_and_at_every_address(self): + assert self.accepted(registration_form("with_password")) == self.everywhere(True) + body = without_password(registration_form("no_password")) + assert self.accepted(body) == self.everywhere(False) + assert self.accepted({**body, "provider": "google-oauth2"}) == self.everywhere(False) + for address in REGISTRATION_ADDRESSES: + response = Client().post(address, body) + assert response.status_code == 400, address + assert json.loads(response.content) == { + "password": [{"user_message": "This field is required."}], + "error_code": "validation-error", + }, address From c9fec71ba4e5824ae64d25ebdd3763167453b4c7 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 22:06:12 +0500 Subject: [PATCH 74/82] fix: publish the refusal of a body that cannot be parsed Registration and signing in read a submitted form, which is parsed when the handler first reads it. A body that cannot be parsed in the media type it is sent as, such as a multipart body with no boundary, is refused with 400 and a body holding `detail` alone, before the handler can answer with its own. The 400 of both was published with the handler's body only, so a client typed from either document could not read that answer: the registration body needs each member to be a list and the sign-in body needs `success`. Each 400 now offers the handler's body or the `{detail}` body, and says when the second is sent. The older registration addresses share the description. The test helper that looks for undeclared members now holds a body to the one shape it satisfies, so the existing checks of these 400s keep their strength. --- .../djangoapps/user_api/v2/authn_schemas.py | 21 +++++++-- .../user_api/v2/tests/test_authn_addresses.py | 46 ++++++++++++++++++- 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/openedx/core/djangoapps/user_api/v2/authn_schemas.py b/openedx/core/djangoapps/user_api/v2/authn_schemas.py index 890f006406fb..fd0b40a24aaf 100644 --- a/openedx/core/djangoapps/user_api/v2/authn_schemas.py +++ b/openedx/core/djangoapps/user_api/v2/authn_schemas.py @@ -129,6 +129,17 @@ def read_in_every_form(shape): #: Published by every operation here that reads a body, which is parsed before the handler reads it. UNREADABLE_BODY = OpenApiResponse(REFUSAL, description="The request body is in a media type this endpoint cannot read.") + +def or_unparsed(shape): + """ + Offer ``shape`` beside the body a request whose body cannot be parsed is refused with. + + The body is parsed when the handler first reads it, so one that cannot be + parsed in the media type it is sent as is refused with ``detail`` alone, + before the handler can answer with a body of its own. + """ + return {"oneOf": [shape, REFUSAL]} + REGISTRATION_FORM_RESPONSES = { 200: OpenApiResponse( FORM_DESCRIPTION, @@ -254,10 +265,11 @@ def read_in_every_form(shape): REGISTRATION_RESPONSES = { 200: OpenApiResponse(REGISTERED, description="The account was created and the caller is signed in as it."), 400: OpenApiResponse( - REGISTRATION_REFUSAL, + or_unparsed(REGISTRATION_REFUSAL), description=( "A field is missing or not acceptable, the country is not one the site knows, or a sign-in through " - "another provider the registration relied on has expired." + "another provider the registration relied on has expired. A body that cannot be parsed in the media " + "type it is sent as is refused with `detail` alone." ), ), 403: OpenApiResponse( @@ -419,11 +431,12 @@ def read_in_every_form(shape): description="The caller is signed in.", ), 400: OpenApiResponse( - LOGIN_REFUSAL, + or_unparsed(LOGIN_REFUSAL), description=( "Signing in failed: the account or the password is wrong, the account is not active, a field is " "missing, a step the site adds to signing in refused it, or signing in is refused for a while after " - "too many failed attempts." + "too many failed attempts. A body that cannot be parsed in the media type it is sent as is refused " + "with `detail` alone." ), ), 403: OpenApiResponse( diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py index ec2496fbde04..01308a253549 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py @@ -1729,11 +1729,23 @@ def published_request_schema(document, path, media_type): def undeclared_members(body, node, where="$"): - """Return the members of ``body`` an object of ``node`` that lists its members does not declare.""" + """ + Return the members of ``body`` an object of ``node`` that lists its members does not declare. + + Where ``node`` offers bodies of different shapes, ``body`` is held to the + one it satisfies. + """ if "anyOf" in node: if body is None: return [] node = node["anyOf"][0] + if "oneOf" in node: + satisfied = [ + option for option in node["oneOf"] if jsonschema.validators.validator_for(option)(option).is_valid(body) + ] + if len(satisfied) != 1: + return [f"{where} satisfies {len(satisfied)} of the shapes offered"] + node = satisfied[0] found = [] if isinstance(body, dict) and "properties" in node: extra = node.get("additionalProperties") @@ -1749,6 +1761,18 @@ def undeclared_members(body, node, where="$"): return found +def test_a_body_offered_two_shapes_is_held_to_the_one_it_satisfies(): + listed = {"type": "object", "properties": {"detail": {"type": "string"}}, "required": ["detail"]} + keyed = {"type": "object", "properties": {"code": {"type": "string"}}, "additionalProperties": {"type": "array"}} + offered = {"oneOf": [keyed, listed]} + assert undeclared_members({"detail": "x"}, offered) == [] + assert undeclared_members({"detail": "x", "extra": "y"}, offered) == ["$.extra"] + assert undeclared_members({"code": "c", "field": []}, offered) == [] + assert undeclared_members({"detail": "x", "code": "c"}, offered) == ["$.code"] + assert undeclared_members({"detail": []}, offered) == [] + assert undeclared_members({"detail": 1}, offered) == ["$ satisfies 0 of the shapes offered"] + + @skip_unless_lms class PublishedShapeTests(TestCase): """ @@ -1922,6 +1946,11 @@ def test_a_field_the_site_adds_is_described_as_published(self): } +#: The operations reading the body as a submitted form, at the addresses +#: replacing others and at the ones they replace. +FORM_READERS = (REGISTRATIONS, V1_REGISTRATION, V2_REGISTRATION, LOGIN_SESSIONS) + + @skip_unless_lms class RefusedRepresentationTests(TestCase): """ @@ -1960,6 +1989,21 @@ def test_each_operation_publishes_the_refusals_it_answers_with(self): jsonschema.validate(body, schema) assert undeclared_members(body, schema) == [], (which, key, status) + def test_each_operation_reading_a_form_publishes_the_refusal_of_one_it_cannot_parse(self): + """A multipart body without the boundary its parts are split by is refused before the handler reads it.""" + for which, document in self.documents.items(): + for path in FORM_READERS: + with transaction.atomic(): + response = Client().post(path, "x", content_type=MULTIPART) + assert response.status_code == 400, (which, path) + assert response["Content-Type"].startswith(JSON), (which, path) + body = json.loads(response.content) + assert list(body) == ["detail"], (which, path) + assert body["detail"].startswith("Multipart form parse error"), (which, path) + schema = published_response_schema(document, path, "post", 400) + jsonschema.validate(body, schema) + assert undeclared_members(body, schema) == [], (which, path) + def test_the_fields_published_as_required_are_the_ones_the_stock_form_requires(self): """ An empty registration is refused for each of them, and for the terms taken from `honor_code`. From 9bd578ecf4aa962e150fba65772d9fb9e36a9921 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 22:08:22 +0500 Subject: [PATCH 75/82] docs: say that refused registrations count towards the registration limit The registration limit counts the requests to register sent from an address, not the accounts they created: a request refused for a missing field counts as much as one that registers an account. The published 403 and the operation description said the refusal came after too many accounts were registered, so an integrator budgeting on accounts created would be refused sooner than the document said. The existing limit tests already reach the 403 after refused registrations alone. --- openedx/core/djangoapps/user_api/v2/authn_schemas.py | 8 ++++---- openedx/core/djangoapps/user_api/v2/authn_views.py | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/openedx/core/djangoapps/user_api/v2/authn_schemas.py b/openedx/core/djangoapps/user_api/v2/authn_schemas.py index fd0b40a24aaf..9c35feab7b45 100644 --- a/openedx/core/djangoapps/user_api/v2/authn_schemas.py +++ b/openedx/core/djangoapps/user_api/v2/authn_schemas.py @@ -275,10 +275,10 @@ def or_unparsed(shape): 403: OpenApiResponse( REGISTRATION_REFUSAL, description=( - "Too many accounts were registered from the address the request came from (`error_code` is " - "`forbidden-request`), or a step the site adds to registration refused it. Where the site requires " - "signing in through another provider, or creates no accounts here, the refusal is an HTML page " - "instead of this body." + "Too many requests to register were sent from the address the request came from, whether or not " + "they created an account (`error_code` is `forbidden-request`), or a step the site adds to " + "registration refused it. Where the site requires signing in through another provider, or creates " + "no accounts here, the refusal is an HTML page instead of this body." ), ), 406: NOT_ACCEPTABLE, diff --git a/openedx/core/djangoapps/user_api/v2/authn_views.py b/openedx/core/djangoapps/user_api/v2/authn_views.py index 124e3b5c00a2..b4eb1fcf77a4 100644 --- a/openedx/core/djangoapps/user_api/v2/authn_views.py +++ b/openedx/core/djangoapps/user_api/v2/authn_views.py @@ -32,7 +32,8 @@ "Creates an account from the fields the registration form lists and signs the caller in as it, " "setting the session cookies. The body is read as a submitted form; a body sent as JSON is read as " "an empty one. No CSRF token is needed, and a session the caller already holds is not consulted. " - "Refused once too many accounts were registered from the address the request comes from." + "Refused once too many requests to register were sent from the address the request comes from, " + "whether or not they created an account." ), request=authn_schemas.REGISTRATION_REQUEST, responses=authn_schemas.REGISTRATION_RESPONSES, From e2d0b5a9d5c13008e386124ba456c758eab7ae04 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 22:14:33 +0500 Subject: [PATCH 76/82] docs: describe the sign-in answered with 400 after a correct password Where a site checks passwords against lists of exposed passwords, signing in with a correct password can still be answered with 400. A password found often must be changed first and the caller is not signed in. One found less often is answered with `nudge-password-change` after the caller has been signed in: the session cookie is set, but not the cookies a successful sign-in adds. The published 400 said only that signing in had failed, so a client following it would treat a signed-in caller as signed out. The 400 now names both cases and what each leaves behind, and `redirect_url` says what it holds in the second. The new tests reach each side of the two thresholds and hold the answer against both documents. --- .../djangoapps/user_api/v2/authn_schemas.py | 13 ++++- .../user_api/v2/tests/test_authn_addresses.py | 56 +++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/openedx/core/djangoapps/user_api/v2/authn_schemas.py b/openedx/core/djangoapps/user_api/v2/authn_schemas.py index 9c35feab7b45..f6ac4862946b 100644 --- a/openedx/core/djangoapps/user_api/v2/authn_schemas.py +++ b/openedx/core/djangoapps/user_api/v2/authn_schemas.py @@ -408,7 +408,10 @@ def or_unparsed(shape): "redirect_url": { "type": "string", "nullable": True, - "description": "Where to send the person instead, such as to change a password.", + "description": ( + "Where to send the person instead, such as to change a password, or, for a caller signed in " + "although answered with a failure, where to send them next." + ), }, }, "required": ["success"], @@ -435,8 +438,12 @@ def or_unparsed(shape): description=( "Signing in failed: the account or the password is wrong, the account is not active, a field is " "missing, a step the site adds to signing in refused it, or signing in is refused for a while after " - "too many failed attempts. A body that cannot be parsed in the media type it is sent as is refused " - "with `detail` alone." + "too many failed attempts. Where the site checks passwords against lists of exposed passwords, a " + "password found often enough has to be changed before signing in (`error_code` is " + "`require-password-change`), and one found less often is answered with `error_code` " + "`nudge-password-change` after the caller has been signed in: the session cookie is set, but not the " + "cookies a successful sign-in adds to it, such as `edxloggedin`. A body that cannot be parsed in the " + "media type it is sent as is refused with `detail` alone." ), ), 403: OpenApiResponse( diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py index 01308a253549..b96aebd59919 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py @@ -42,6 +42,7 @@ from common.djangoapps.third_party_auth.models import OAuth2ProviderConfig from openedx.core.apidocs import get_api_docs_settings from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user +from openedx.core.djangoapps.user_api import accounts from openedx.core.djangoapps.user_api.v2 import authn_views from openedx.core.djangoapps.user_api.v2.tests.test_schema import as_json_schema from openedx.core.djangoapps.user_authn.views import login, password_reset, register @@ -2128,3 +2129,58 @@ def test_any_other_registration_needs_a_password_in_both_documents_and_at_every_ "password": [{"user_message": "This field is required."}], "error_code": "validation-error", }, address + + +@skip_unless_lms +class SignInWithAnExposedPasswordTests(TestCase): + """ + Where the site checks passwords against lists of exposed ones, a 400 can follow a correct password. + + The check is stood in for by one that reports how often the password was + found, so each side of the site's two thresholds is reached. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.documents = {"narrow": narrow_document(), "service-wide": service_wide_document()} + + def sign_in(self, client, frequency): + """Sign in at the address replacing the older sign-in, with a password found ``frequency`` times over.""" + UserFactory.create(username="exposed", password=PASSWORD) + found = {"frequency": frequency} + with mock.patch.object(login, "check_pwned_password_and_send_track_event", return_value=found): + response = client.post(LOGIN_SESSIONS, {"email_or_username": "exposed", "password": PASSWORD}) + assert response.status_code == 400 + body = json.loads(response.content) + for which, document in self.documents.items(): + schema = published_response_schema(document, LOGIN_SESSIONS, "post", 400) + jsonschema.validate(body, schema) + assert undeclared_members(body, schema) == [], which + return response, body + + @override_settings(ENABLE_AUTHN_LOGIN_NUDGE_HIBP_POLICY=True) + def test_a_password_found_rarely_is_answered_400_after_signing_the_caller_in(self): + client = Client() + response, body = self.sign_in(client, settings.HIBP_LOGIN_NUDGE_PASSWORD_FREQUENCY_THRESHOLD) + assert body == { + "success": False, + "value": str(accounts.AUTHN_LOGIN_NUDGE_HIBP_POLICY_MSG), + "error_code": "nudge-password-change", + "redirect_url": None, + } + assert client.session["_auth_user_id"] == str(User.objects.get(username="exposed").id) + assert cookies_set(response) == SIGNED_IN_COOKIES - {"edxloggedin", "edx-user-info"} + + @override_settings(ENABLE_AUTHN_LOGIN_BLOCK_HIBP_POLICY=True) + def test_a_password_found_often_is_refused_without_signing_the_caller_in(self): + client = Client() + response, body = self.sign_in(client, settings.HIBP_LOGIN_BLOCK_PASSWORD_FREQUENCY_THRESHOLD) + assert body == { + "success": False, + "value": str(accounts.AUTHN_LOGIN_BLOCK_HIBP_POLICY_MSG), + "error_code": "require-password-change", + "redirect_url": None, + } + assert "_auth_user_id" not in client.session + assert not cookies_set(response) & {"edxloggedin", "edx-user-info"} From e10079201c88e3ee26eea802409e144b9768ba63 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 22:18:33 +0500 Subject: [PATCH 77/82] test: describe the order the service really matches the user addresses in Two docstrings said the mount for version 2 is matched before the rest of the user surface and before the authentication app's patterns, so that an address that app adds under the prefix is answered by this version. The service matches both before the mount: the include for the rest of the user surface comes first, then the patterns installed applications contribute, then the mount. An address the authentication app adds reaches it either way; only its spelling without the closing slash is answered by the fallback rather than redirected, until it is named. The docstrings now say that, and a new test reads the order from the resolver: the authentication app's routes and the rest of the user surface come before the mount, and each of the two addresses is matched first by the app. --- .../user_api/v2/tests/test_url_mount.py | 39 ++++++++++++++++--- .../djangoapps/user_api/v2/tests/test_urls.py | 13 ++++--- 2 files changed, 41 insertions(+), 11 deletions(-) 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 index 475cd056e00c..1c2c1d7dfafe 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_url_mount.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_url_mount.py @@ -1,13 +1,16 @@ """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 django.urls import Resolver404, get_resolver, 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" V2_VIEWS = "openedx.core.djangoapps.user_api.v2.views" +AUTHN_URLS = "openedx.core.djangoapps.user_authn.urls" +V2_URLS = "openedx.core.djangoapps.user_api.v2.urls" +USER_API_URLS = "openedx.core.djangoapps.user_api.urls" @skip_unless_lms @@ -15,13 +18,37 @@ 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. + The service matches the include that carries the rest of the user surface + and the patterns installed applications contribute, the authentication + app's among them, before the mount for this version, so those two + addresses reach that app before the mount is tried. The mount's fallback + leaves them alone as well, so that each sent without its closing slash is + redirected to the slashed form the app serves rather than refused here. """ + def test_the_authentication_app_and_the_rest_of_the_user_surface_are_matched_before_this_mount(self): + patterns = get_resolver().url_patterns + + def position(urlconf): + return next( + index for index, pattern in enumerate(patterns) + if getattr(getattr(pattern, "urlconf_name", None), "__name__", None) == urlconf + ) + + def first_match(address): + for index, pattern in enumerate(patterns): + try: + if pattern.resolve(address.removeprefix("/")): + return index + except Resolver404: + continue + return None + + mount = position(V2_URLS) + assert position(USER_API_URLS) < mount + for address in ("/api/user/v2/account/registration/", "/api/user/v2/account/login_session/"): + assert first_match(address) == position(AUTHN_URLS) < mount, address + 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) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_urls.py b/openedx/core/djangoapps/user_api/v2/tests/test_urls.py index 56fb8e150445..a37795560f41 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_urls.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_urls.py @@ -779,11 +779,14 @@ def test_an_address_of_the_other_app_sent_without_its_slash_is_still_redirected( def test_every_address_left_to_the_other_app_is_one_that_app_serves(self): """ - An address that app adds under this prefix is answered here until it is named. - - The two are matched by the pattern that keeps them out of the fallback - and are read back from the routes serving them, so neither side can be - edited alone. + An address that app adds under this prefix is refused here, sent without its slash, until it is named. + + The app's routes are matched before this mount, so the slashed form + reaches the app either way, while the form without the slash is + matched by the fallback instead of being redirected unless the address + is named. The addresses the app serves are read back from its routes + and matched against the pattern that keeps them out of the fallback, + so neither side can be edited alone. """ left_alone = re.compile(rf"{v2_urls.OTHER_APP_ADDRESSES}\Z") served = { From 155cfc6652712d8f24c40db4bf7c2cb3e8b37fea Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 22:21:06 +0500 Subject: [PATCH 78/82] docs: give the true reasons the sign-in and registration routes are collections The comment above the routes said registering an account creates something no address here reads back. The account a registration creates is read at accounts/{username}/ like any other. The real reasons are that each route is answered by a subclass of the authentication app's own view, so a request runs that view's one code path with its decorators, its CSRF handling and the limits it shares with the older addresses, and that registering and signing in answer with a session and where to go next rather than with the account. The comment now says that, and a test registers an account and reads it back at its own address with the session the registration set. --- .../user_api/v2/tests/test_authn_addresses.py | 9 +++++++++ openedx/core/djangoapps/user_api/v2/urls.py | 14 ++++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py index b96aebd59919..5e361cfbfd7e 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py @@ -630,6 +630,15 @@ def test_options_differ_only_in_the_description(self): assert not unexpected assert json.loads(conforming.content)["name"] == "Registration" + def test_the_account_registered_is_read_where_every_account_is(self): + """A registration answers with a session and where to go next; the account is read at its own address.""" + client = Client() + registered = client.post(REGISTRATIONS, registration_form("read_back")) + assert set(json.loads(registered.content)) == {"authenticated_user", "success", "redirect_url"} + account = client.get(reverse("user_v2:account_detail", kwargs={"username": "read_back"})) + assert account.status_code == 200 + assert json.loads(account.content)["email"] == "read_back@example.com" + @skip_unless_lms @override_settings(CACHES=COUNTING_CACHE) diff --git a/openedx/core/djangoapps/user_api/v2/urls.py b/openedx/core/djangoapps/user_api/v2/urls.py index 854dfe3959c6..90bddf8eaa93 100644 --- a/openedx/core/djangoapps/user_api/v2/urls.py +++ b/openedx/core/djangoapps/user_api/v2/urls.py @@ -177,10 +177,16 @@ class SnakeCaseRouter(SimpleRouter): UserPreferenceViewSet.as_view({"get": "retrieve"}), name="user_preference_detail", ), - # Registering an account, signing in and resetting a password each create - # something no address here reads back, so each is a collection that is - # only ever posted to or described. Signing in answers as the version this - # API is at, which the view reads from its route. + # Registering an account, checking what it would be registered with, + # signing in and asking for a password reset are each answered by a + # subclass of the authentication app's own view that adds only its + # description, so a request runs that view's one code path, with its + # decorators, its CSRF handling and any limit it counts together with the + # app's older addresses. Each is a collection that is only ever posted to + # or described: registering and signing in answer with a session and where + # to go next rather than with the account, which is read at + # accounts/{username}/ like any other. Signing in answers as the version + # this API is at, which the view reads from its route. re_path(r"^registrations/$", authn_views.RegistrationView.as_view(), name="registration_list"), re_path( r"^registration_validations/$", From 7325fef195b84c1da6292e832ac737c873b4f49e Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Sun, 1 Nov 2026 22:22:30 +0500 Subject: [PATCH 79/82] docs: say which error body the fallback for unknown addresses shares The fallback that refuses an unknown address under version 2 was described as answering with the same JSON body as every other failure of this version. The registration, sign-in and password reset addresses of this version are answered by the authentication app's views, with the bodies those views have always used, so that is no longer so. The view's docstring and the comment above its route now say the fallback answers with the JSON error body most views of this version use, and that those addresses answer with their own. --- openedx/core/djangoapps/user_api/v2/urls.py | 5 +++-- openedx/core/djangoapps/user_api/v2/views.py | 8 +++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/openedx/core/djangoapps/user_api/v2/urls.py b/openedx/core/djangoapps/user_api/v2/urls.py index 90bddf8eaa93..7463c74145d6 100644 --- a/openedx/core/djangoapps/user_api/v2/urls.py +++ b/openedx/core/djangoapps/user_api/v2/urls.py @@ -209,8 +209,9 @@ class SnakeCaseRouter(SimpleRouter): ), # 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. + # which is refused with the JSON error body most views of this version + # answer with. The route answers rather than reverses, so it carries no + # name. # # This claims the whole prefix: it matches every address under it that the # routes above and the addresses named in OTHER_APP_ADDRESSES do not. The diff --git a/openedx/core/djangoapps/user_api/v2/views.py b/openedx/core/djangoapps/user_api/v2/views.py index e6474e892203..cc5f0c6c286f 100644 --- a/openedx/core/djangoapps/user_api/v2/views.py +++ b/openedx/core/djangoapps/user_api/v2/views.py @@ -2336,9 +2336,11 @@ class UnknownAddressView(StandardizedErrorMixin, APIView): """ Refuses an address under this version of the API that no route serves. - The refusal carries the same JSON body as every other failure of this - version, rather than the service's HTML error page, which a client that - asked for JSON cannot read. + The refusal carries the JSON error body most views of this version answer + with, rather than the service's HTML error page, which a client that asked + for JSON cannot read. The registration, sign-in and password reset + addresses answer with the bodies of the authentication app's views they + extend instead. """ permission_classes = (permissions.AllowAny,) From 024dd40a745bd18bd2cd5976240c3acc141ac0a1 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Mon, 2 Nov 2026 01:58:12 +0500 Subject: [PATCH 80/82] fix: publish what the one sign-in refusal answered with 403 always carries The version 2 sign-in answers 403 with a JSON body in one case only: the caller signed in through another provider with an account linked to none here. That body always carries a message (`value`) and a name (`error_code`, which is `third-party-auth-with-no-linked-account`), but the documents published it with the shape of the 400 refusals, which marks only `success` as always present. A client generated from them would treat both members as optional. The 403 now marks `success`, `value` and `error_code` as always present. Nothing changes in what the endpoint answers. The 400 refusals keep their shape, because each of those members is left out of one of them in some case. --- openedx/core/djangoapps/user_api/v2/authn_schemas.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/user_api/v2/authn_schemas.py b/openedx/core/djangoapps/user_api/v2/authn_schemas.py index f6ac4862946b..8e5248513835 100644 --- a/openedx/core/djangoapps/user_api/v2/authn_schemas.py +++ b/openedx/core/djangoapps/user_api/v2/authn_schemas.py @@ -447,7 +447,8 @@ def or_unparsed(shape): ), ), 403: OpenApiResponse( - LOGIN_REFUSAL, + # Unlike the refusals answered with 400, the one answered with this body always has a message and a name. + {**LOGIN_REFUSAL, "required": ["success", "value", "error_code"]}, description=( "The caller signed in through another provider with an account that is not linked to one here. " "A request carrying no valid CSRF token, or a password sent where the site requires signing in " From 47925143f3c33fa26c4ec3d78d515c6bffa8339b Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Mon, 2 Nov 2026 01:58:12 +0500 Subject: [PATCH 81/82] test: hold the members each response promises to the bodies really sent Checking a real response against the document catches a wrong type or a missing member, but not a member the document stops promising, because the body still satisfies a schema that requires less. Dropping `user_id` from a registration's 200 body, or `is_valid` from the token check's 200 body, left every test green. Every real body the suite receives from these addresses is now also held to the members its operation and code publish as always present. Those must be exactly the members the body carries, less the ones a table lists as carried in some cases only, each with its case named. Each listed member is shown carried by one real body and left out of another, so the table cannot hide a member that is always there. Requests are added for the cases that leave a member out: a step the site adds that refuses without naming the refusal or without a message, a username held back for retired accounts, and a password found on lists of exposed passwords. --- .../user_api/v2/tests/test_authn_addresses.py | 196 +++++++++++++++++- 1 file changed, 187 insertions(+), 9 deletions(-) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py index 5e361cfbfd7e..7600567fa478 100644 --- a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py +++ b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py @@ -3,6 +3,7 @@ import contextlib import json import re +from collections import defaultdict from types import SimpleNamespace from unittest import mock @@ -179,6 +180,28 @@ def run_filter(self, user): # pylint: disable=arguments-differ ) +class RefuseRegistrationUnnamedStep(PipelineStep): + """A deployment's own step refusing every registration with 403, without naming the refusal.""" + + status_code = 403 + + def run_filter(self, form_data): # pylint: disable=arguments-differ + raise StudentRegistrationRequested.PreventRegistration("Registration is closed.", status_code=self.status_code) + + +class RefuseRegistrationUnnamedAsInvalidStep(RefuseRegistrationUnnamedStep): + """The same step refusing with 400.""" + + status_code = 400 + + +class RefuseLoginSilentlyStep(PipelineStep): + """A deployment's own step refusing every sign-in with no message, name, details or address.""" + + def run_filter(self, user): # pylint: disable=arguments-differ + raise StudentLoginRequested.PreventLogin("") + + REFUSE_REGISTRATION = { "org.openedx.learning.student.registration.requested.v1": { "pipeline": [f"{__name__}.RefuseRegistrationStep"], @@ -193,6 +216,11 @@ def run_filter(self, user): # pylint: disable=arguments-differ } +def running_only(step, filter_class): + """Return the filter configuration running ``step`` alone wherever ``filter_class`` runs.""" + return {filter_class.filter_type: {"pipeline": [f"{__name__}.{step.__name__}"], "fail_silently": False}} + + def registration_form(name): """Return a registration the stock settings accept, for an account called ``name``.""" return { @@ -1783,6 +1811,116 @@ def test_a_body_offered_two_shapes_is_held_to_the_one_it_satisfies(): assert undeclared_members({"detail": 1}, offered) == ["$ satisfies 0 of the shapes offered"] +#: Members a body carries in some cases only, which the document therefore +#: does not publish as always present. They are listed by operation and code, +#: then by where they sit in the body (``[]`` stands for every item of a list), +#: each with the case it is carried in. A member a body carries that is not +#: listed here must be published as always present, and none listed may be. +SOMETIMES_CARRIED = { + (REGISTRATIONS, "get", 200): { + "$.fields[]": {"options": "Only an input offering a choice of values lists them."}, + }, + (REGISTRATIONS, "post", 400): { + "$": {"error_code": "Left out where a step the site adds refuses the registration without naming why."}, + }, + (REGISTRATIONS, "post", 403): { + "$": {"error_code": "Left out where a step the site adds refuses the registration without naming why."}, + }, + (REGISTRATIONS, "post", 409): { + "$": { + "error_code": "Left out where the username or the email address is held back for retired accounts.", + "username_suggestions": "Offered only where the username sent is taken.", + }, + }, + (REGISTRATION_VALIDATIONS, "post", 200): { + "$": {"username_suggestions": "Offered only for a taken username or for a name."}, + }, + (LOGIN_SESSIONS, "post", 400): { + "$": { + "value": "Left out where a step the site adds refuses the sign-in without a message.", + "error_code": "Left out of a refusal that has no name, such as one for a missing field.", + "email": "Left out of the refusal of a password found on lists of exposed passwords.", + "context": "Only a refusal that has details for its message carries them.", + "redirect_url": ( + "Only for a password found on lists of exposed passwords, or where a step the site adds names " + "where to send the person." + ), + }, + }, +} + + +def members_carried(body, node, where="$"): + """ + Yield, for each object in ``body`` whose node lists its members, where it sits, the listed members it + carries and the ones published as always present. + + Every item of a list is named by ``[]``. Where ``node`` offers bodies of + different shapes, ``body`` is held to the first one it satisfies. + """ + if "anyOf" in node: + if body is None: + return + node = node["anyOf"][0] + if "oneOf" in node: + node = [ + option for option in node["oneOf"] if jsonschema.validators.validator_for(option)(option).is_valid(body) + ][0] + if isinstance(body, dict) and "properties" in node: + yield where, set(body) & set(node["properties"]), set(node.get("required", ())) + extra = node.get("additionalProperties") + for key, value in body.items(): + member = node["properties"].get(key, extra if isinstance(extra, dict) else None) + if member: + yield from members_carried(value, member, f"{where}.{key}") + elif isinstance(body, list) and "items" in node: + for item in body: + yield from members_carried(item, node["items"], f"{where}[]") + + +def unkept_promises(path, method, status, body, node): + """ + Return each object of ``body`` whose members published as always present are not the ones it always carries. + + An operation another one replaces is held to what is listed for its + replacement, which publishes the same bodies. + """ + replacing = SUPERSEDED_OPERATIONS.get((path, method), ((path, method),))[0] + sometimes = SOMETIMES_CARRIED.get((*replacing, status), {}) + found = [] + for where, carried, promised in members_carried(body, node): + always = carried - set(sometimes.get(where, ())) + if promised != always: + found.append(f"{where} is published as always carrying {sorted(promised)}, and carries {sorted(always)}") + return found + + +def test_the_members_published_as_always_present_are_held_to_the_ones_each_body_carries(): + text = {"type": "string"} + item = {"type": "object", "properties": {"name": text, "options": {"type": "array"}}, "required": ["name"]} + node = {"oneOf": [ + {"type": "object", "properties": {"detail": text}, "required": ["detail"]}, + {"type": "object", "properties": {"fields": {"type": "array", "items": item}}, "required": ["fields"]}, + ]} + body = {"fields": [{"name": "a"}, {"name": "b", "options": []}]} + assert list(members_carried(body, node)) == [ + ("$", {"fields"}, {"fields"}), + ("$.fields[]", {"name"}, {"name"}), + ("$.fields[]", {"name", "options"}, {"name"}), + ] + assert list(members_carried({"detail": "x", "other": 1}, node)) == [("$", {"detail"}, {"detail"})] + form = (REGISTRATIONS, "get", 200) + assert not unkept_promises(*form, body, node) + assert unkept_promises(PASSWORD_RESETS, "get", 200, body, node) == [ + "$.fields[] is published as always carrying ['name'], and carries ['name', 'options']", + ] + assert not unkept_promises(V1_REGISTRATION, "get", 200, body, node) + loosened = {"oneOf": [node["oneOf"][0], {**node["oneOf"][1], "required": []}]} + assert unkept_promises(*form, body, loosened) == [ + "$ is published as always carrying [], and carries ['fields']", + ] + + @skip_unless_lms class PublishedShapeTests(TestCase): """ @@ -1791,7 +1929,10 @@ class PublishedShapeTests(TestCase): Each response below is sent to the running address, then held against the shape published for that operation and code: every member published as always present is there, each value has the published type, and an object - that lists its members carries no other. + that lists its members carries no other. The members published as always + present are also exactly the ones the body carries, less those + ``SOMETIMES_CARRIED`` lists, and each of those is carried by one body + below and left out of another. """ @classmethod @@ -1799,13 +1940,31 @@ def setUpClass(cls): super().setUpClass() cls.document = service_wide_document() + def setUp(self): + super().setUp() + self.carried = defaultdict(list) + def assert_published(self, path, method, response): """Assert ``response`` is a body the document publishes for that operation and code.""" assert response["Content-Type"].startswith(JSON), (path, response.status_code) body = json.loads(response.content) - schema = published_response_schema(self.document, path, method, response.status_code) + status = response.status_code + schema = published_response_schema(self.document, path, method, status) jsonschema.validate(body, schema) - assert undeclared_members(body, schema) == [], (path, method, response.status_code) + assert undeclared_members(body, schema) == [], (path, method, status) + assert not unkept_promises(path, method, status, body, schema), (path, method, status) + for where, carried, _promised in members_carried(body, schema): + self.carried[(path, method, status, where)].append(carried) + + def assert_each_member_listed_was_carried_and_left_out(self, path, method): + """Assert each member listed for the operation as carried in some cases was carried and left out above.""" + listed = [(key, places) for key, places in SOMETIMES_CARRIED.items() if key[:2] == (path, method)] + assert listed, (path, method) + for (_path, _method, status), places in listed: + for where, members in places.items(): + for member in members: + seen = {member in carried for carried in self.carried[(path, method, status, where)]} + assert seen == {True, False}, (path, method, status, where, member) def test_the_forms(self): extra = {**settings.REGISTRATION_EXTRA_FIELDS, "confirm_email": "required", "country": "required"} @@ -1814,6 +1973,7 @@ def test_the_forms(self): self.assert_published(REGISTRATIONS, "get", Client().get(REGISTRATIONS)) self.assert_published(PASSWORD_RESETS, "get", Client().get(PASSWORD_RESETS)) self.assert_published(LOGIN_SESSIONS, "get", Client().get(LOGIN_SESSIONS)) + self.assert_each_member_listed_was_carried_and_left_out(REGISTRATIONS, "get") def test_a_registration_and_its_refusals(self): self.assert_published(REGISTRATIONS, "post", Client().post(REGISTRATIONS, registration_form("shape_new"))) @@ -1824,11 +1984,19 @@ def test_a_registration_and_its_refusals(self): ) with override_settings(OPEN_EDX_FILTERS_CONFIG=REFUSE_REGISTRATION): self.assert_published(REGISTRATIONS, "post", Client().post(REGISTRATIONS, registration_form("shape_y"))) + for step in (RefuseRegistrationUnnamedStep, RefuseRegistrationUnnamedAsInvalidStep): + with override_settings(OPEN_EDX_FILTERS_CONFIG=running_only(step, StudentRegistrationRequested)): + self.assert_published( + REGISTRATIONS, "post", Client().post(REGISTRATIONS, registration_form("shape_z")), + ) + retired = registration_form(f"{settings.RETIRED_USERNAME_PREFIX}shape") + self.assert_published(REGISTRATIONS, "post", Client().post(REGISTRATIONS, retired)) with override_settings(CACHES=COUNTING_CACHE): cache.clear() spend(REGISTRATIONS, {}, limit_of(settings.REGISTRATION_RATELIMIT)) self.assert_published(REGISTRATIONS, "post", Client().post(REGISTRATIONS, {})) cache.clear() + self.assert_each_member_listed_was_carried_and_left_out(REGISTRATIONS, "post") def test_a_token_check_and_its_refusals(self): owner = UserFactory.create(username="shape_owner", password=PASSWORD) @@ -1863,6 +2031,7 @@ def test_a_validation_and_its_refusals(self): spend(REGISTRATION_VALIDATIONS, {}, limit_of(settings.REGISTRATION_VALIDATION_RATELIMIT)) self.assert_published(REGISTRATION_VALIDATIONS, "post", Client().post(REGISTRATION_VALIDATIONS, {})) cache.clear() + self.assert_each_member_listed_was_carried_and_left_out(REGISTRATION_VALIDATIONS, "post") def test_a_sign_in_and_its_refusals(self): UserFactory.create(username="shape_signer", password=PASSWORD) @@ -1875,12 +2044,14 @@ def test_a_sign_in_and_its_refusals(self): {"password": PASSWORD}, ): self.assert_published(LOGIN_SESSIONS, "post", Client().post(LOGIN_SESSIONS, data)) - with override_settings(OPEN_EDX_FILTERS_CONFIG=REFUSE_LOGIN): - self.assert_published( - LOGIN_SESSIONS, - "post", - Client().post(LOGIN_SESSIONS, {"email_or_username": "shape_signer", "password": PASSWORD}), - ) + signing_in = {"email_or_username": "shape_signer", "password": PASSWORD} + for config in (REFUSE_LOGIN, running_only(RefuseLoginSilentlyStep, StudentLoginRequested)): + with override_settings(OPEN_EDX_FILTERS_CONFIG=config): + self.assert_published(LOGIN_SESSIONS, "post", Client().post(LOGIN_SESSIONS, signing_in)) + found = {"frequency": settings.HIBP_LOGIN_BLOCK_PASSWORD_FREQUENCY_THRESHOLD} + with override_settings(ENABLE_AUTHN_LOGIN_BLOCK_HIBP_POLICY=True), \ + mock.patch.object(login, "check_pwned_password_and_send_track_event", return_value=found): + self.assert_published(LOGIN_SESSIONS, "post", Client().post(LOGIN_SESSIONS, signing_in)) running = {"backend": "oa2-provider", "kwargs": {"username": "elsewhere", "uid": "elsewhere"}} with mock.patch.object(login.pipeline, "running", return_value=True), \ mock.patch.object(login.pipeline, "get", return_value=running), \ @@ -1889,6 +2060,7 @@ def test_a_sign_in_and_its_refusals(self): ), \ mock.patch.object(login.pipeline, "get_authenticated_user", side_effect=User.DoesNotExist): self.assert_published(LOGIN_SESSIONS, "post", Client().post(LOGIN_SESSIONS, {})) + self.assert_each_member_listed_was_carried_and_left_out(LOGIN_SESSIONS, "post") class UnlabelledExtensionForm(forms.Form): @@ -1937,6 +2109,9 @@ def test_a_field_the_site_adds_is_described_as_published(self): schema = published_response_schema(document, address, "get", response.status_code) jsonschema.validate(body, schema) assert undeclared_members(body, schema) == [], (which, form, address) + assert not unkept_promises(address, "get", response.status_code, body, schema), ( + which, form, address, + ) #: Where an operation is requested, for one published under a key that is not an address. @@ -1998,6 +2173,7 @@ def test_each_operation_publishes_the_refusals_it_answers_with(self): schema = published_response_schema(self.documents[which], path, method, status) jsonschema.validate(body, schema) assert undeclared_members(body, schema) == [], (which, key, status) + assert not unkept_promises(path, method, status, body, schema), (which, key, status) def test_each_operation_reading_a_form_publishes_the_refusal_of_one_it_cannot_parse(self): """A multipart body without the boundary its parts are split by is refused before the handler reads it.""" @@ -2013,6 +2189,7 @@ def test_each_operation_reading_a_form_publishes_the_refusal_of_one_it_cannot_pa schema = published_response_schema(document, path, "post", 400) jsonschema.validate(body, schema) assert undeclared_members(body, schema) == [], (which, path) + assert not unkept_promises(path, "post", 400, body, schema), (which, path) def test_the_fields_published_as_required_are_the_ones_the_stock_form_requires(self): """ @@ -2166,6 +2343,7 @@ def sign_in(self, client, frequency): schema = published_response_schema(document, LOGIN_SESSIONS, "post", 400) jsonschema.validate(body, schema) assert undeclared_members(body, schema) == [], which + assert not unkept_promises(LOGIN_SESSIONS, "post", 400, body, schema), which return response, body @override_settings(ENABLE_AUTHN_LOGIN_NUDGE_HIBP_POLICY=True) From be57050badd424172a6fb0d32560e213900d704a Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Mon, 2 Nov 2026 21:45:30 +0500 Subject: [PATCH 82/82] fix: describe the CSRF token in every document whose sign-in names it The sign-in operations publish a security requirement named `csrf`, but no document either service serves declared a scheme by that name; only the settings the published reference is generated from did. A document naming a credential it does not describe is invalid, and a client generated from it has no way to send that credential. The older sign-in has named the token this way in both services' service-wide documents since before this version, and the new sign-in repeats it in both LMS documents. The scheme is now defined once in the user API, beside the components both services already append, and both services append it: the value of the `csrftoken` cookie the forms set, sent in the `X-CSRFToken` header. Studio's document of its own API carries the scheme as well, because every document built from those settings carries what they append. Studio appends that one scheme rather than the whole set, since no operation it serves names any other credential. The tests request each document at the address it is served from, hold every credential its operations name to a scheme it declares, and pin how the token is described with the text written out. --- cms/envs/common.py | 11 ++- cms/lib/tests/test_spectacular.py | 77 ++++++++++++++++++- lms/envs/common.py | 11 ++- lms/lib/tests/test_spectacular.py | 62 +++++++++++++++ .../djangoapps/user_api/schema_components.py | 21 ++++- 5 files changed, 178 insertions(+), 4 deletions(-) diff --git a/cms/envs/common.py b/cms/envs/common.py index 806be5552ba3..ee257f07d39b 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -49,7 +49,10 @@ from cms.lib.xblock.authoring_mixin import AuthoringMixin from cms.lib.xblock.upstream_sync import UpstreamSyncMixin -from openedx.core.djangoapps.user_api.schema_components import KEPT_COMPONENT_SCHEMAS +from openedx.core.djangoapps.user_api.schema_components import ( + CREDENTIAL_SCHEMES, + KEPT_COMPONENT_SCHEMAS, +) from openedx.core.lib.derived import Derived from openedx.core.lib.features_setting_proxy import FeaturesProxy from openedx.envs.common import * # pylint: disable=wildcard-import # noqa: F403 @@ -1356,6 +1359,12 @@ def _should_send_xblock_events(settings): # from these settings carries it, the service-wide one included. 'APPEND_COMPONENTS': { 'schemas': dict(KEPT_COMPONENT_SCHEMAS), + # The sign-in this service serves at the user API's addresses names the + # CSRF token, so the token is described here from the definition the + # other service appends, and a client generated from either service's + # document can send it. Only that credential is described: no + # operation of this service names any other. + 'securitySchemes': {'csrf': CREDENTIAL_SCHEMES['csrf']}, }, # Stripped from each path to derive its tags and operationId, so changing # it renames every operationId. Paths are published in full, from the diff --git a/cms/lib/tests/test_spectacular.py b/cms/lib/tests/test_spectacular.py index e1261da52623..ef0d2476a531 100644 --- a/cms/lib/tests/test_spectacular.py +++ b/cms/lib/tests/test_spectacular.py @@ -1,11 +1,13 @@ """Tests for the CMS drf-spectacular hooks and schema class.""" +import json import re +from collections import defaultdict from pathlib import Path from unittest import TestCase, mock from django.conf import settings -from django.test import SimpleTestCase +from django.test import Client, SimpleTestCase from django.test import TestCase as DjangoTestCase from django.urls import path as url_path from drf_spectacular.generators import SchemaGenerator @@ -638,3 +640,76 @@ def test_the_component_no_operation_refers_to_any_more_is_still_published_here(s # The appended component is described in place, so nothing checks that # what it points at is published beside it. assert referred_to - set(components) == set() + + +#: How each served document is expected to describe the CSRF token, written out +#: rather than read from the definition the service appends, so that a change +#: to that definition shows up here as a change to what callers are told. +CSRF_SCHEME = { + "type": "apiKey", + "name": "X-CSRFToken", + "in": "header", + "description": ( + "The value of the `csrftoken` cookie the sign-in, registration and password reset forms set, " + "sent in this header along with that cookie." + ), +} + + +def credentials_named(document): + """Return each credential an operation of a document names, with the operations naming it.""" + named = defaultdict(set) + for route, path_item in document["paths"].items(): + for method, operation in path_item.items(): + if isinstance(operation, dict): + for alternative in operation.get("security", ()): + for credential in alternative: + named[credential].add((method, route)) + return named + + +class ServedCredentialSchemesTest(DjangoTestCase): + """ + Each document this service serves describes every credential its operations name. + + A client is generated from one document, and it can only offer the caller + a way to send a credential the document describes; an operation naming one + the document does not describe makes the document invalid. This service serves the user API's older sign-in, which + names the CSRF token, so the token is described here as well as by the + other service. The documents are requested at the addresses they are served + from, because what each carries depends on the settings it is generated + under. + """ + + ADDRESSES = ("/authoring-api/schema/", "/api-docs/schema/") + + @classmethod + def setUpClass(cls): + super().setUpClass() + client = Client() + cls.documents = {} + for address in cls.ADDRESSES: + response = client.get(address, {"format": "json"}, HTTP_ACCEPT="application/vnd.oai.openapi+json") + assert response.status_code == 200, (address, response.status_code) + cls.documents[address] = json.loads(response.content) + + def test_every_credential_an_operation_names_is_described_in_the_same_document(self): + for address, document in self.documents.items(): + described = set(document["components"].get("securitySchemes", {})) + named = credentials_named(document) + assert {name: sorted(named[name]) for name in set(named) - described} == {}, address + + def test_the_csrf_token_is_described_where_the_older_sign_in_names_it(self): + document = self.documents["/api-docs/schema/"] + sign_in = ("post", "/api/user/{api_version}/account/login_session/") + assert sign_in in credentials_named(document)["csrf"] + assert document["components"].get("securitySchemes", {}).get("csrf") == CSRF_SCHEME + + def test_every_document_describing_the_csrf_token_describes_it_alike(self): + described = { + address: document["components"].get("securitySchemes", {}).get("csrf") + for address, document in self.documents.items() + } + assert {address: scheme for address, scheme in described.items() if scheme is not None} == { + address: CSRF_SCHEME for address, scheme in described.items() if scheme is not None + } diff --git a/lms/envs/common.py b/lms/envs/common.py index 4da88f64777a..d42658d4c212 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -64,7 +64,10 @@ from path import Path as path from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType -from openedx.core.djangoapps.user_api.schema_components import KEPT_COMPONENT_SCHEMAS +from openedx.core.djangoapps.user_api.schema_components import ( + CREDENTIAL_SCHEMES, + KEPT_COMPONENT_SCHEMAS, +) from openedx.core.lib.derived import Derived from openedx.core.lib.features_setting_proxy import FeaturesProxy from openedx.envs.common import * # pylint: disable=wildcard-import # noqa: F403 @@ -2200,6 +2203,12 @@ # same definition, so neither document names one the other does not. 'APPEND_COMPONENTS': { 'schemas': dict(KEPT_COMPONENT_SCHEMAS), + # How a caller sends each credential the operations of this service + # take beside the session cookie. The generator describes the cookie + # and nothing else, so an operation taking a token names these and a + # client generated from the document can offer the caller a way to + # send it. + 'securitySchemes': dict(CREDENTIAL_SCHEMES), }, # A set of choices is published as its own model, named after the field # carrying it while one field of the service carries that name alone. The diff --git a/lms/lib/tests/test_spectacular.py b/lms/lib/tests/test_spectacular.py index 0c41d6bfdda4..2cc53cc4b712 100644 --- a/lms/lib/tests/test_spectacular.py +++ b/lms/lib/tests/test_spectacular.py @@ -983,3 +983,65 @@ def test_a_tag_group_holds_one_area_unless_it_marks_the_client_library(self): "openedx-platform-sdk": ["enrollment", "user"], "user": ["user"], } + + +#: How each served document is expected to describe the CSRF token, written out +#: rather than read from the definition the service appends, so that a change +#: to that definition shows up here as a change to what callers are told. +CSRF_SCHEME = { + "type": "apiKey", + "name": "X-CSRFToken", + "in": "header", + "description": ( + "The value of the `csrftoken` cookie the sign-in, registration and password reset forms set, " + "sent in this header along with that cookie." + ), +} + + +def credentials_named(document): + """Return each credential an operation of a document names, with the operations naming it.""" + named = defaultdict(set) + for route, path_item in document["paths"].items(): + for method, operation in path_item.items(): + if isinstance(operation, dict): + for alternative in operation.get("security", ()): + for credential in alternative: + named[credential].add((method, route)) + return named + + +class ServedCredentialSchemesTest(TestCase): + """ + Each document this service serves describes every credential its operations name. + + A client is generated from one document, and it can only offer the caller + a way to send a credential the document describes; an operation naming one + the document does not describe makes the document invalid. The documents are requested at the addresses they are + served from, because what each carries depends on the settings it is + generated under. + """ + + ADDRESSES = ("/lms-api/schema/", "/api-docs/schema/") + + @classmethod + def setUpClass(cls): + super().setUpClass() + client = Client() + cls.documents = {} + for address in cls.ADDRESSES: + response = client.get(address, {"format": "json"}, HTTP_ACCEPT="application/vnd.oai.openapi+json") + assert response.status_code == 200, (address, response.status_code) + cls.documents[address] = json.loads(response.content) + + def test_every_credential_an_operation_names_is_described_in_the_same_document(self): + for address, document in self.documents.items(): + described = set(document["components"].get("securitySchemes", {})) + named = credentials_named(document) + assert {name: sorted(named[name]) for name in set(named) - described} == {}, address + + def test_the_csrf_token_is_described_where_the_sign_in_names_it(self): + """Both documents carry a sign-in that needs the token, and say how it is sent.""" + for address, document in self.documents.items(): + assert ("post", "/api/user/v2/login_sessions/") in credentials_named(document)["csrf"], address + assert document["components"].get("securitySchemes", {}).get("csrf") == CSRF_SCHEME, address diff --git a/openedx/core/djangoapps/user_api/schema_components.py b/openedx/core/djangoapps/user_api/schema_components.py index 92250c44b2d5..5d628b33218d 100644 --- a/openedx/core/djangoapps/user_api/schema_components.py +++ b/openedx/core/djangoapps/user_api/schema_components.py @@ -1,4 +1,4 @@ -"""Components of this API that a document carries although nothing in it refers to them.""" +"""Parts of this API's documents that are written out rather than generated.""" # Nothing is imported in this module. A settings module reads it while the # settings are still being assembled, which is too early for any module that @@ -33,3 +33,22 @@ KEPT_COMPONENT_SCHEMAS = { 'PatchedUserRetirementStatus': PATCHED_USER_RETIREMENT_STATUS, } + + +# How a caller sends each credential this API takes beside the session cookie. +# The generator describes the cookie by itself, from the class that reads it, +# and knows nothing of the CSRF token the sign-in names, so the token is +# written out here and appended by each service that serves this API. An +# operation naming a credential its document does not describe makes that +# document invalid, and a client generated from it has no way to send it. +CREDENTIAL_SCHEMES = { + 'csrf': { + 'type': 'apiKey', + 'name': 'X-CSRFToken', + 'in': 'header', + 'description': ( + 'The value of the `csrftoken` cookie the sign-in, registration and password reset forms set, ' + 'sent in this header along with that cookie.' + ), + }, +}