From bb01afc0d6447e6643d4c7fa0128db2f9339efd2 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Thu, 15 Oct 2026 03:41:09 +0500 Subject: [PATCH 1/4] 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 35a1b6cdabcd..82526e9a83ef 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -142,6 +142,7 @@ path('api/courses/', include('lms.djangoapps.course_api.urls')), # User API endpoints + path('api/user/v2/', include('openedx.core.djangoapps.user_api.v2.urls')), path('api/user/', include('openedx.core.djangoapps.user_api.urls')), # Note: these are older versions of the User API that will eventually be # subsumed by api/user listed above. diff --git a/openedx/core/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 3c1c8572809914781d2678bf1dca367862093345 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Thu, 15 Oct 2026 03:41:09 +0500 Subject: [PATCH 2/4] 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 1fc662d26e15..e01819bc7581 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2157,22 +2157,21 @@ add_optional_apps(OPTIONAL_APPS, INSTALLED_APPS) # noqa: F405 -###################### drf-spectacular (LMS enrollment schema) ###################### +###################### drf-spectacular (LMS API schema) ###################### SPECTACULAR_SETTINGS = { - 'TITLE': 'LMS Enrollment API', + 'TITLE': 'LMS API', 'VERSION': '0.1.0', 'SERVE_INCLUDE_SCHEMA': False, 'PREPROCESSING_HOOKS': ['lms.lib.spectacular.lms_api_filter'], - # Mark legacy slashless enrollment addresses deprecated. The enum hook is - # drf-spectacular's default, restated because setting this key replaces - # the default list. + # Mark superseded addresses deprecated. The enum hook is drf-spectacular's + # default, restated because setting this key replaces the default list. 'POSTPROCESSING_HOOKS': [ 'drf_spectacular.hooks.postprocess_schema_enums', 'lms.lib.spectacular.lms_mark_legacy_paths_deprecated', ], # Stripped from each path to derive its tags and operationId, so changing # it renames every operationId. Paths themselves are emitted in full. - 'SCHEMA_PATH_PREFIX': '/api/enrollment', + 'SCHEMA_PATH_PREFIX': r'/api/(enrollment|user)', # Serve the Swagger UI and ReDoc assets from drf-spectacular-sidecar rather # than drf-spectacular's default unpinned jsdelivr CDN URLs, keeping them # self-hosted and version-pinned as the drf-yasg bundles were. diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index 789945a32c40..e74a18aaf8ce 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -583,7 +583,7 @@ def should_show_debug_toolbar(request): # pylint: disable=missing-function-docs 'COMPLETE', ] -###################### drf-spectacular (LMS enrollment schema) ###################### +###################### drf-spectacular (LMS API schema) ###################### SPECTACULAR_SETTINGS['SERVERS'] = [ # noqa: F405 {'url': LMS_ROOT_URL, 'description': 'Local'}, # noqa: F405 ] diff --git a/lms/envs/production.py b/lms/envs/production.py index 24350824c391..1defebe32992 100644 --- a/lms/envs/production.py +++ b/lms/envs/production.py @@ -398,7 +398,7 @@ def get_env_setting(setting): MIDDLEWARE.extend(_YAML_TOKENS.get('EXTRA_MIDDLEWARE_CLASSES', [])) # noqa: F405 -###################### drf-spectacular (LMS enrollment schema) ###################### +###################### drf-spectacular (LMS API schema) ###################### SPECTACULAR_SETTINGS['SERVERS'] = [ # noqa: F405 {'url': LMS_ROOT_URL, 'description': 'Local'}, # noqa: F405 ] diff --git a/lms/lib/spectacular.py b/lms/lib/spectacular.py index 942199deac69..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 4c96c8cce109..acf4de94ce16 100644 --- a/openedx/core/apidocs.py +++ b/openedx/core/apidocs.py @@ -21,7 +21,7 @@ # These are passed as ``custom_settings`` to SpectacularAPIView rather than # living in ``SPECTACULAR_SETTINGS``, because that global is already claimed by # a deliberately narrow schema in each service -- the Authoring API -# (``/authoring-api/``) in CMS and the Enrollment API (``/lms-api/``) in LMS. +# (``/authoring-api/``) in CMS and the LMS API (``/lms-api/``) in LMS. # Both filter the surface down via ``PREPROCESSING_HOOKS`` and trim a path # prefix, so ``/api-docs`` must switch that filtering off explicitly to cover # the whole service. From 29d8bb2e0a69b073231ea5e6e721998c7d1a7e14 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Fri, 23 Oct 2026 11:37:22 +0500 Subject: [PATCH 3/4] 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 9494211d04f8f29f053ea343e6dedc24175d7547 Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft Date: Fri, 23 Oct 2026 11:37:35 +0500 Subject: [PATCH 4/4] 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]})