diff --git a/cms/envs/common.py b/cms/envs/common.py index ed0d2b0cbf3c..ee257f07d39b 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -49,6 +49,10 @@ from cms.lib.xblock.authoring_mixin import AuthoringMixin from cms.lib.xblock.upstream_sync import UpstreamSyncMixin +from openedx.core.djangoapps.user_api.schema_components import ( + CREDENTIAL_SCHEMES, + KEPT_COMPONENT_SCHEMAS, +) from openedx.core.lib.derived import Derived from openedx.core.lib.features_setting_proxy import FeaturesProxy from openedx.envs.common import * # pylint: disable=wildcard-import # noqa: F403 @@ -1338,8 +1342,30 @@ def _should_send_xblock_events(settings): 'drf_spectacular.hooks.postprocess_schema_enums', 'cms.lib.spectacular.cms_mark_superseded_paths', 'cms.lib.spectacular.cms_mark_migrated_paths', + # Studio mounts the user API at the same addresses the LMS does, and + # both services publish an unauthenticated document at /api-docs/, so + # an address superseded in one is superseded in the other. The list of + # them and the hook that reads it are shared rather than restated here, + # which is what stops a client generated from Studio's document from + # offering an address as current that the LMS document marks as + # superseded. + 'openedx.core.djangoapps.user_api.schema.mark_superseded_user_paths', 'openedx.core.apidocs.untag_deprecated_operations', ], + # This service also serves the user API, which names a component no + # operation of it refers to any more. It is appended here, from the + # definition the other service appends as well, so that a client generated + # from either service's document carries that class. Every document built + # from these settings carries it, the service-wide one included. + 'APPEND_COMPONENTS': { + 'schemas': dict(KEPT_COMPONENT_SCHEMAS), + # The sign-in this service serves at the user API's addresses names the + # CSRF token, so the token is described here from the definition the + # other service appends, and a client generated from either service's + # document can send it. Only that credential is described: no + # operation of this service names any other. + 'securitySchemes': {'csrf': CREDENTIAL_SCHEMES['csrf']}, + }, # Stripped from each path to derive its tags and operationId, so changing # it renames every operationId. Paths are published in full, from the # service root, so /api/contentstore and /api/authoring resolve against the diff --git a/cms/lib/spectacular.py b/cms/lib/spectacular.py index 662cf80c65f0..2498827d777d 100644 --- a/cms/lib/spectacular.py +++ b/cms/lib/spectacular.py @@ -4,6 +4,8 @@ from drf_spectacular.openapi import AutoSchema +from openedx.core.djangoapps.user_api.schema import SupersededUserOperations + # Legacy operations superseded by a conforming address under /api/authoring/. # Each is listed in the truncated spelling as well as the full one: the routes # make the final character optional, so both addresses resolve, and which one @@ -72,7 +74,7 @@ def cms_mark_superseded_paths(result, generator, request, public): # pylint: di return result -class CmsAutoSchema(AutoSchema): +class CmsAutoSchema(SupersededUserOperations, AutoSchema): """ Give a colliding legacy address a distinct operationId. @@ -80,6 +82,14 @@ class CmsAutoSchema(AutoSchema): operationId, drf-spectacular would otherwise break the tie with a numeral suffix in registration order. The conforming address keeps the clean id; the deprecated one is suffixed ``_legacy``. + + This service also serves the user API at the same addresses the service + that replaces it does, and both publish an unauthenticated document of + everything they serve. The shared behaviour describes those addresses the + one way, so that a client generated from either document reads the same + signature and the same success code for the same request; it is taken from + where that API's own schema lives rather than copied, because two copies + of a description drift. """ def get_operation_id(self): diff --git a/cms/lib/tests/test_spectacular.py b/cms/lib/tests/test_spectacular.py index 193f3c98c448..ef0d2476a531 100644 --- a/cms/lib/tests/test_spectacular.py +++ b/cms/lib/tests/test_spectacular.py @@ -1,14 +1,17 @@ """Tests for the CMS drf-spectacular hooks and schema class.""" +import json import re +from collections import defaultdict from pathlib import Path from unittest import TestCase, mock from django.conf import settings -from django.test import SimpleTestCase +from django.test import Client, SimpleTestCase +from django.test import TestCase as DjangoTestCase from django.urls import path as url_path from drf_spectacular.generators import SchemaGenerator -from drf_spectacular.settings import spectacular_settings +from drf_spectacular.settings import patched_settings, spectacular_settings from rest_framework import serializers, viewsets from rest_framework.response import Response from rest_framework.settings import api_settings @@ -20,6 +23,14 @@ cms_mark_migrated_paths, cms_mark_superseded_paths, ) +from openedx.core.apidocs import get_api_docs_settings +from openedx.core.djangoapps.user_api.schema import ( + SUPERSEDED_USER_PATHS, + SupersededUserOperations, + superseded_bodies, +) +from openedx.core.djangoapps.user_api.schema_components import PATCHED_USER_RETIREMENT_STATUS +from openedx.core.djangoapps.user_api.v2 import authn_schemas def _endpoint(path): @@ -178,6 +189,48 @@ def test_environments_do_not_replace_the_common_settings(self): # A hook is set by its quoted key; a comment that names one is fine. assert not re.search(r"_HOOKS['\"]", source), module + def test_the_module_the_published_reference_is_generated_from_inherits_this_configuration(self): + """ + It takes the hooks and the prefix by inheritance rather than restating them. + + The committed reference for this service is generated under that + module, and assigning the key there would replace this configuration + wholesale, as the two modules above do -- which would publish the whole + service under no title instead of this API's own surface. + """ + assert "SPECTACULAR_SETTINGS" not in self._source("cms/envs/development.py") + + +class SharedSchemaBehaviourTest(SimpleTestCase): + """ + Where the description this service shares with the one replacing these addresses is kept. + + Both services publish the same user addresses, so what they say about them + is held once, in a module belonging to the API being described rather than + to either service. A service reaching into the other's package for it + would tie the two together, and a copy of it here would drift from the + original the moment one of the two was corrected. + """ + + #: The module holding what the two services say the same way. + SHARED_MODULE = "openedx/core/djangoapps/user_api/schema.py" + + @staticmethod + def _source(module): + return (Path(__file__).resolve().parents[3] / module).read_text() + + def test_the_shared_description_is_read_from_the_api_it_describes(self): + assert SupersededUserOperations.__module__ == "openedx.core.djangoapps.user_api.schema" + assert SupersededUserOperations in CmsAutoSchema.__mro__ + + def test_the_shared_module_reads_no_part_of_either_service(self): + found = re.findall(r"(?m)^\s*(?:from|import) (?:lms|cms)\b", self._source(self.SHARED_MODULE)) + assert found == [] + + def test_the_schema_helpers_of_this_service_read_no_part_of_the_other(self): + found = re.findall(r"(?m)^\s*(?:from|import) lms\b", self._source("cms/lib/spectacular.py")) + assert found == [] + class _EmptySerializer(serializers.Serializer): # pylint: disable=abstract-method pass @@ -215,3 +268,448 @@ def test_only_the_colliding_legacy_address_is_suffixed(self): # Already distinct from its conforming twin's id, so it keeps its name. assert ids["/api/authoring/v4/courses/"] == "v4_courses_list" assert ids["/api/contentstore/v4/home/courses/"] == "v4_home_courses_list" + + +#: The preference document a superseded read answers with, spelled out here +#: rather than read from the module that publishes it, so that an edit to that +#: shape is an edit this test sees. +PREFERENCE_DOCUMENT_BODY = { + "type": "object", + "additionalProperties": {"type": "string"}, + "description": ( + "Preferences of one account, as an object of preference name to stored value. Every " + "value is text. Which names an account carries depends on the features it uses." + ), +} + +def published_shapes(responses): + """Return the shape each code of a table of responses is published with, by code.""" + return {str(code): response.response for code, response in responses.items()} + + +#: What each superseded user operation this service serves is expected to +#: exchange: the media types of the body it reads, and the success code with +#: the body it answers with. ``None`` is a response carrying no body at all. +#: +#: The table is spelled out here rather than taken from the tests of the +#: service that serves the replacements, so that a change made to what one of +#: the two documents says about these addresses and not to the other is a +#: failure here rather than a difference a client only finds after generating +#: from both. +SUPERSEDED_USER_EXCHANGES = { + ("/api/user/v1/accounts", "get"): ([], {"200": "Account[]"}), + ("/api/user/v1/accounts/{username}", "get"): ([], {"200": "Account"}), + ("/api/user/v1/accounts/{username}", "patch"): (["application/merge-patch+json"], {"200": "Account"}), + ("/api/user/v1/accounts/search_emails", "post"): ( + ["application/json", "application/merge-patch+json"], {"200": "EmailSearchResult[]"}, + ), + ("/api/user/v1/accounts/{username}/deactivate/", "post"): ([], {"200": "Account"}), + ("/api/user/v1/accounts/name_change/", "post"): ( + ["application/json", "application/x-www-form-urlencoded", "multipart/form-data"], + {"201": None}, + ), + ("/api/user/v1/accounts/name_change/{username}/confirm/", "post"): ([], {"204": None}), + ("/api/user/v1/accounts/{username}/image", "post"): ( + ["image/gif", "image/jpeg", "image/pjpeg", "image/png", "multipart/form-data"], + {"204": None}, + ), + ("/api/user/v1/accounts/{username}/image", "delete"): ([], {"204": None}), + ("/api/user/v1/accounts/{username}/verification_status/", "get"): ([], {"200": "VerificationStatus"}), + ("/api/user/v1/preferences/{username}", "get"): ([], {"200": PREFERENCE_DOCUMENT_BODY}), + ("/api/user/v1/preferences/{username}", "patch"): (["application/merge-patch+json"], {"204": None}), + ("/api/user/v1/preferences/{username}/{preference_key}", "get"): ([], {"200": {"type": "string"}}), + ("/api/user/v1/preferences/{username}/{preference_key}", "put"): (["application/json"], {"204": None}), + ("/api/user/v1/preferences/{username}/{preference_key}", "delete"): ([], {"204": None}), + ("/api/user/v1/preferences/email_opt_in/", "post"): ( + ["application/x-www-form-urlencoded"], {"200": None}, + ), + **{ + (address, "get"): ([], published_shapes(responses)) + for address, responses in ( + ("/api/user/v1/account/registration/", authn_schemas.REGISTRATION_FORM_RESPONSES), + ("/api/user/v2/account/registration/", authn_schemas.REGISTRATION_FORM_RESPONSES), + ("/api/user/v1/account/password_reset/", authn_schemas.PASSWORD_RESET_FORM_RESPONSES), + ("/api/user/{api_version}/account/login_session/", authn_schemas.LOGIN_FORM_RESPONSES), + ) + }, + **{ + (address, "post"): (sorted(request), published_shapes(responses)) + for address, request, responses in ( + ( + "/api/user/v1/account/registration/", + authn_schemas.REGISTRATION_REQUEST, + authn_schemas.REGISTRATION_RESPONSES, + ), + ( + "/api/user/v2/account/registration/", + authn_schemas.REGISTRATION_REQUEST, + authn_schemas.REGISTRATION_RESPONSES, + ), + ( + "/api/user/v1/account/password_reset/token/validate/", + authn_schemas.TOKEN_VALIDATION_REQUEST, + authn_schemas.TOKEN_VALIDATION_RESPONSES, + ), + ( + "/api/user/v1/validation/registration", + authn_schemas.REGISTRATION_VALIDATION_REQUEST, + authn_schemas.REGISTRATION_VALIDATION_RESPONSES, + ), + ) + }, +} + +#: Every operation of a superseded user address this service serves, with the +#: name it is published under. The names are the ones both documents have +#: always carried: a client imports a function per name, so an operation that +#: comes back under a different one has been taken away from it. +SUPERSEDED_USER_OPERATION_IDS = { + ("/api/user/v1/accounts", "get"): "api_user_v1_accounts_retrieve", + ("/api/user/v1/accounts/name_change/", "post"): "api_user_v1_accounts_name_change_create", + ("/api/user/v1/accounts/name_change/{username}/confirm/", "post"): ( + "api_user_v1_accounts_name_change_confirm_create" + ), + ("/api/user/v1/accounts/search_emails", "post"): "api_user_v1_accounts_search_emails_create", + ("/api/user/v1/accounts/{username}", "get"): "api_user_v1_accounts_retrieve_2", + ("/api/user/v1/accounts/{username}", "patch"): "api_user_v1_accounts_partial_update", + ("/api/user/v1/accounts/{username}/deactivate/", "post"): "api_user_v1_accounts_deactivate_create", + ("/api/user/v1/accounts/{username}/image", "delete"): "api_user_v1_accounts_image_destroy", + ("/api/user/v1/accounts/{username}/image", "post"): "api_user_v1_accounts_image_create", + ("/api/user/v1/accounts/{username}/verification_status/", "get"): ( + "api_user_v1_accounts_verification_status_retrieve" + ), + ("/api/user/v1/accounts/{username}/verifications/", "get"): "api_user_v1_accounts_verifications_list", + ("/api/user/v1/forum_roles/{name}/users/", "get"): "api_user_v1_forum_roles_users_list", + ("/api/user/v1/me", "get"): "api_user_v1_me_retrieve", + ("/api/user/v1/preferences/email_opt_in/", "post"): "api_user_v1_preferences_email_opt_in_create", + ("/api/user/v1/preferences/time_zones/", "get"): "api_user_v1_preferences_time_zones_list", + ("/api/user/v1/preferences/{pref_key}/users/", "get"): "api_user_v1_preferences_users_list", + ("/api/user/v1/preferences/{username}", "get"): "api_user_v1_preferences_retrieve", + ("/api/user/v1/preferences/{username}", "patch"): "api_user_v1_preferences_partial_update", + ("/api/user/v1/preferences/{username}/{preference_key}", "delete"): "api_user_v1_preferences_destroy", + ("/api/user/v1/preferences/{username}/{preference_key}", "get"): "api_user_v1_preferences_retrieve_2", + ("/api/user/v1/preferences/{username}/{preference_key}", "put"): "api_user_v1_preferences_update", + ("/api/user/v1/user_prefs/", "get"): "api_user_v1_user_prefs_list", + ("/api/user/v1/user_prefs/{id}/", "get"): "api_user_v1_user_prefs_retrieve", + ("/api/user/v1/users/", "get"): "api_user_v1_users_list", + ("/api/user/v1/users/{id}/", "get"): "api_user_v1_users_retrieve", + ("/api/user/v1/accounts/cancel_retirement/", "post"): "api_user_v1_accounts_cancel_retirement_create", + ("/api/user/v1/accounts/retire/", "post"): "api_user_v1_accounts_retire_create", + ("/api/user/v1/accounts/retire_misc/", "post"): "api_user_v1_accounts_retire_misc_create", + ("/api/user/v1/accounts/retirement_cleanup/", "post"): "api_user_v1_accounts_retirement_cleanup_create", + ("/api/user/v1/accounts/retirement_partner_report/", "post"): ( + "api_user_v1_accounts_retirement_partner_report_create" + ), + ("/api/user/v1/accounts/retirement_partner_report/", "put"): ( + "api_user_v1_accounts_retirement_partner_report_update" + ), + ("/api/user/v1/accounts/retirement_partner_report_cleanup/", "post"): ( + "api_user_v1_accounts_retirement_partner_report_cleanup_create" + ), + ("/api/user/v1/accounts/retirement_queue/", "get"): "api_user_v1_accounts_retirement_queue_retrieve", + ("/api/user/v1/accounts/retirements_by_status_and_date/", "get"): ( + "api_user_v1_accounts_retirements_by_status_and_date_retrieve" + ), + ("/api/user/v1/accounts/update_retirement_status/", "patch"): ( + "api_user_v1_accounts_update_retirement_status_partial_update" + ), + ("/api/user/v1/accounts/{username}/retirement_status/", "get"): "api_user_v1_accounts_retirement_status_retrieve", + ("/api/user/v1/account/registration/", "get"): "api_user_v1_account_registration_retrieve", + ("/api/user/v1/account/registration/", "post"): "api_user_v1_account_registration_create", + ("/api/user/v2/account/registration/", "get"): "api_user_v2_account_registration_retrieve", + ("/api/user/v2/account/registration/", "post"): "api_user_v2_account_registration_create", + ("/api/user/v1/account/password_reset/", "get"): "api_user_v1_account_password_reset_retrieve", + ("/api/user/v1/account/password_reset/token/validate/", "post"): ( + "api_user_v1_account_password_reset_token_validate_create" + ), + ("/api/user/v1/validation/registration", "post"): "api_user_v1_validation_registration_create", + ("/api/user/{api_version}/account/login_session/", "get"): "api_user_account_login_session_retrieve", + ("/api/user/{api_version}/account/login_session/", "post"): "api_user_account_login_session_create", +} + + +def _named_body(response): + """ + Return what a response carries: the component it names, or the shape itself. + + A component is named by its own name, suffixed ``[]`` where the body is an + array of it; a body described in place comes back as that description, and + a response with no body at all as ``None``. + """ + content = response.get("content") + if not content: + return None + body = content["application/json"]["schema"] + if body.get("type") == "array": + return f"{body['items']['$ref'].rsplit('/', 1)[-1]}[]" + if "$ref" not in body: + return body + return body["$ref"].rsplit("/", 1)[-1] + + +class SupersededUserOperationTest(DjangoTestCase): + """ + The user addresses this service serves beside the service that replaces them. + + Both services mount that API at the same addresses and both publish an + unauthenticated document of everything they serve, so what one of those + documents says about an address is what the other has to say too: the same + body on each side of the exchange, the same success code, the same + published name, and the same mark saying a replacement exists. A client + generated from this service's document would otherwise offer a different + signature, and no mark, for the very same request. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + with patched_settings(get_api_docs_settings()): + cls.schema = SchemaGenerator().get_schema(request=None, public=True) + + def _operations(self): + """Every operation of a superseded address this document carries.""" + return { + (path, method): operation + for path in SUPERSEDED_USER_PATHS if path in self.schema["paths"] + for method, operation in self.schema["paths"][path].items() + if isinstance(operation, dict) and "operationId" in operation + } + + def test_this_service_serves_every_superseded_address(self): + """The marking and the bodies below are claims about all of them, not about some.""" + served = [path for path in SUPERSEDED_USER_PATHS if path in self.schema["paths"]] + assert served == list(SUPERSEDED_USER_PATHS) + + def test_every_operation_of_those_addresses_is_marked_superseded(self): + marked = {key: operation.get("deprecated") for key, operation in self._operations().items()} + assert marked == dict.fromkeys(SUPERSEDED_USER_OPERATION_IDS, True) + + def test_every_operation_keeps_the_name_it_is_published_under(self): + published = {key: operation["operationId"] for key, operation in self._operations().items()} + assert published == SUPERSEDED_USER_OPERATION_IDS + + def test_each_superseded_operation_publishes_what_it_reads_and_what_it_answers_with(self): + published = {} + for (address, method) in SUPERSEDED_USER_EXCHANGES: + operation = self.schema["paths"][address][method] + published[(address, method)] = ( + sorted((operation.get("requestBody") or {}).get("content", {})), + {code: _named_body(response) for code, response in operation["responses"].items()}, + ) + assert published == SUPERSEDED_USER_EXCHANGES + + def test_a_body_the_handler_refuses_to_work_without_is_published_as_needed(self): + """A shape described in place carries no field a mark can be read from.""" + marks = { + address: self.schema["paths"][address][method]["requestBody"].get("required") + for address, method in ( + ("/api/user/v1/preferences/{username}", "patch"), + ("/api/user/v1/preferences/email_opt_in/", "post"), + ) + } + assert marks == { + "/api/user/v1/preferences/{username}": True, + "/api/user/v1/preferences/email_opt_in/": True, + } + + def test_the_write_of_one_preference_is_published_as_a_body_that_may_be_left_out(self): + """That handler takes a request carrying no body and stores what it was sent.""" + body = self.schema["paths"]["/api/user/v1/preferences/{username}/{preference_key}"]["put"]["requestBody"] + assert "required" not in body + + def test_an_image_may_be_sent_as_the_whole_body_or_as_a_form_part(self): + """The forms are alternatives, and a body one of them needs is one the operation needs.""" + image = self.schema["paths"]["/api/user/v1/accounts/{username}/image"]["post"]["requestBody"] + assert image["required"] is True + assert sorted(image["content"]) == [ + "image/gif", "image/jpeg", "image/pjpeg", "image/png", "multipart/form-data", + ] + + def test_a_registration_body_is_needed_and_a_check_body_is_not(self): + """An empty registration is refused, while an empty check is answered.""" + marks = { + (address, method): self.schema["paths"][address][method]["requestBody"].get("required", False) + for address, method in ( + ("/api/user/v1/account/registration/", "post"), + ("/api/user/v2/account/registration/", "post"), + ("/api/user/v1/account/password_reset/token/validate/", "post"), + ("/api/user/v1/validation/registration", "post"), + ) + } + assert marks == { + ("/api/user/v1/account/registration/", "post"): True, + ("/api/user/v2/account/registration/", "post"): True, + ("/api/user/v1/account/password_reset/token/validate/", "post"): False, + ("/api/user/v1/validation/registration", "post"): False, + } + + def test_a_registration_names_the_fields_the_stock_form_requires(self): + """The password is required unless the registration completes a sign-in through another provider.""" + for address in ("/api/user/v1/account/registration/", "/api/user/v2/account/registration/"): + content = self.schema["paths"][address]["post"]["requestBody"]["content"] + published = { + media_type: ( + sorted(body["schema"]["required"]), + [sorted(way["required"]) for way in body["schema"]["anyOf"]], + ) + for media_type, body in content.items() + } + assert published == dict.fromkeys( + ["application/x-www-form-urlencoded", "multipart/form-data"], + ( + ["email", "honor_code", "name", "username"], + [["password"], ["social_auth_provider"], ["access_token", "provider"]], + ), + ), address + + def test_each_registration_sign_in_and_reset_operation_publishes_its_refusals_of_a_media_type(self): + """Each refuses an answer it cannot give, and each reading a body refuses one it cannot read.""" + login_session = "/api/user/{api_version}/account/login_session/" + published = { + (address, method): sorted(self.schema["paths"][address][method]["responses"]) + for address, method in ( + ("/api/user/v1/account/registration/", "get"), + ("/api/user/v1/account/registration/", "post"), + ("/api/user/v2/account/registration/", "get"), + ("/api/user/v2/account/registration/", "post"), + ("/api/user/v1/account/password_reset/", "get"), + ("/api/user/v1/account/password_reset/token/validate/", "post"), + ("/api/user/v1/validation/registration", "post"), + (login_session, "get"), + ) + } + assert published == { + ("/api/user/v1/account/registration/", "get"): ["200", "406"], + ("/api/user/v1/account/registration/", "post"): ["200", "400", "403", "406", "409", "415"], + ("/api/user/v2/account/registration/", "get"): ["200", "406"], + ("/api/user/v2/account/registration/", "post"): ["200", "400", "403", "406", "409", "415"], + ("/api/user/v1/account/password_reset/", "get"): ["200", "406"], + ("/api/user/v1/account/password_reset/token/validate/", "post"): [ + "200", "400", "401", "403", "406", "415", "429", + ], + ("/api/user/v1/validation/registration", "post"): ["200", "400", "403", "406", "415"], + (login_session, "get"): ["200", "406"], + } + + def test_signing_in_by_posting_keeps_what_it_has_always_published(self): + """Its handler is described where it is defined, and nothing here replaces that.""" + operation = self.schema["paths"]["/api/user/{api_version}/account/login_session/"]["post"] + assert {media_type: body["schema"] for media_type, body in operation["requestBody"]["content"].items()} == ( + dict.fromkeys( + ["application/json", "application/x-www-form-urlencoded", "multipart/form-data"], + {"$ref": "#/components/schemas/LoginUserRequest"}, + ) + ) + assert {code: _named_body(response) for code, response in operation["responses"].items()} == ( + dict.fromkeys(["200", "400", "403"], "LoginUserResponse") + ) + assert operation["security"] == [{"csrf": []}] + + def test_no_address_replacing_a_superseded_one_is_published_here(self): + """This service serves none of them, so its document describes only what it serves.""" + replacing = { + path for path in self.schema["paths"] + if path.startswith("/api/user/v2/") and not path.startswith("/api/user/v2/account/") + } + assert replacing == set() + + def test_the_described_addresses_are_ones_this_document_marks_superseded(self): + described = {address for address, _method in superseded_bodies()} + marked = {re.sub(r"\{[\w-]+\}", "{}", address) for address in SUPERSEDED_USER_PATHS} + assert described <= marked, described - marked + + def test_the_component_no_operation_refers_to_any_more_is_still_published_here(self): + """ + A client generated from this document names a class after each component in it. + + One of these addresses was the only reference to the retirement-status + model, and it now publishes the object its handler really reads, so + nothing in either service's document generates that model any more. It + is appended by this service as well, under the name it has always been + published under and with the definition the other service appends, so + that a client generated from this document still carries the class. + """ + components = self.schema["components"]["schemas"] + assert components["PatchedUserRetirementStatus"] == PATCHED_USER_RETIREMENT_STATUS + referred_to = { + member["allOf"][0]["$ref"].rsplit("/", 1)[-1] + for member in components["PatchedUserRetirementStatus"]["properties"].values() + if "allOf" in member + } + assert referred_to == {"RetirementUser", "RetirementState"} + # The appended component is described in place, so nothing checks that + # what it points at is published beside it. + assert referred_to - set(components) == set() + + +#: How each served document is expected to describe the CSRF token, written out +#: rather than read from the definition the service appends, so that a change +#: to that definition shows up here as a change to what callers are told. +CSRF_SCHEME = { + "type": "apiKey", + "name": "X-CSRFToken", + "in": "header", + "description": ( + "The value of the `csrftoken` cookie the sign-in, registration and password reset forms set, " + "sent in this header along with that cookie." + ), +} + + +def credentials_named(document): + """Return each credential an operation of a document names, with the operations naming it.""" + named = defaultdict(set) + for route, path_item in document["paths"].items(): + for method, operation in path_item.items(): + if isinstance(operation, dict): + for alternative in operation.get("security", ()): + for credential in alternative: + named[credential].add((method, route)) + return named + + +class ServedCredentialSchemesTest(DjangoTestCase): + """ + Each document this service serves describes every credential its operations name. + + A client is generated from one document, and it can only offer the caller + a way to send a credential the document describes; an operation naming one + the document does not describe makes the document invalid. This service serves the user API's older sign-in, which + names the CSRF token, so the token is described here as well as by the + other service. The documents are requested at the addresses they are served + from, because what each carries depends on the settings it is generated + under. + """ + + ADDRESSES = ("/authoring-api/schema/", "/api-docs/schema/") + + @classmethod + def setUpClass(cls): + super().setUpClass() + client = Client() + cls.documents = {} + for address in cls.ADDRESSES: + response = client.get(address, {"format": "json"}, HTTP_ACCEPT="application/vnd.oai.openapi+json") + assert response.status_code == 200, (address, response.status_code) + cls.documents[address] = json.loads(response.content) + + def test_every_credential_an_operation_names_is_described_in_the_same_document(self): + for address, document in self.documents.items(): + described = set(document["components"].get("securitySchemes", {})) + named = credentials_named(document) + assert {name: sorted(named[name]) for name in set(named) - described} == {}, address + + def test_the_csrf_token_is_described_where_the_older_sign_in_names_it(self): + document = self.documents["/api-docs/schema/"] + sign_in = ("post", "/api/user/{api_version}/account/login_session/") + assert sign_in in credentials_named(document)["csrf"] + assert document["components"].get("securitySchemes", {}).get("csrf") == CSRF_SCHEME + + def test_every_document_describing_the_csrf_token_describes_it_alike(self): + described = { + address: document["components"].get("securitySchemes", {}).get("csrf") + for address, document in self.documents.items() + } + assert {address: scheme for address, scheme in described.items() if scheme is not None} == { + address: CSRF_SCHEME for address, scheme in described.items() if scheme is not None + } diff --git a/docs/docs_settings.py b/docs/docs_settings.py index 48ec4a5cc63e..19d8ea2eb525 100644 --- a/docs/docs_settings.py +++ b/docs/docs_settings.py @@ -19,6 +19,7 @@ ) from lms.envs.common import * # pylint: disable=wildcard-import # noqa: F403 from openedx.core.apidocs import get_api_docs_settings +from openedx.core.djangoapps.user_api.schema_components import KEPT_COMPONENT_SCHEMAS from openedx.core.lib.derived import derive_settings # Turn on all the boolean feature flags, so that conditionally included @@ -82,7 +83,22 @@ # OpenAPI 3 security schemes, consumed by drf-spectacular. "Basic" uses the # OpenAPI 3 http/basic form rather than Swagger 2.0's type: basic. +# +# Whatever the service settings already append is carried through rather than +# replaced, both the keys beside "schemas" and the component names under it: +# those names are what the published reference is expected to carry, and one +# dropped here goes out of the generated reference, and out of every client +# built from it, without changing any count that would show it had gone. +# +# The components a service appends although nothing refers to them are named +# again here, through the one definition both services read, because the +# reference is expected to carry them whatever else a service appends. SPECTACULAR_SETTINGS["APPEND_COMPONENTS"] = { # noqa: F405 + **SPECTACULAR_SETTINGS.get("APPEND_COMPONENTS", {}), # noqa: F405 + "schemas": { + **SPECTACULAR_SETTINGS.get("APPEND_COMPONENTS", {}).get("schemas", {}), # noqa: F405 + **KEPT_COMPONENT_SCHEMAS, + }, "securitySchemes": { "Basic": { "type": "http", diff --git a/lms/envs/common.py b/lms/envs/common.py index 7ce78ae83ae0..d42658d4c212 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -64,6 +64,10 @@ from path import Path as path from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType +from openedx.core.djangoapps.user_api.schema_components import ( + CREDENTIAL_SCHEMES, + KEPT_COMPONENT_SCHEMAS, +) from openedx.core.lib.derived import Derived from openedx.core.lib.features_setting_proxy import FeaturesProxy from openedx.envs.common import * # pylint: disable=wildcard-import # noqa: F403 @@ -2179,23 +2183,46 @@ add_optional_apps(OPTIONAL_APPS, INSTALLED_APPS) # noqa: F405 -###################### drf-spectacular (LMS enrollment schema) ###################### +###################### drf-spectacular (LMS API schema) ###################### SPECTACULAR_SETTINGS = { - 'TITLE': 'LMS Enrollment API', + 'TITLE': 'LMS API', 'VERSION': '0.1.0', 'SERVE_INCLUDE_SCHEMA': False, 'PREPROCESSING_HOOKS': ['lms.lib.spectacular.lms_api_filter'], - # Mark legacy slashless enrollment addresses deprecated. The enum hook is - # drf-spectacular's default, restated because setting this key replaces - # the default list. + # Mark superseded addresses deprecated. The enum hook is drf-spectacular's + # default, restated because setting this key replaces the default list. 'POSTPROCESSING_HOOKS': [ 'drf_spectacular.hooks.postprocess_schema_enums', 'lms.lib.spectacular.lms_mark_legacy_paths_deprecated', + 'openedx.core.djangoapps.user_api.schema.mark_superseded_user_paths', 'openedx.core.apidocs.untag_deprecated_operations', ], + # Carried by every document built from these settings, including the + # service-wide one, which overlays its own keys over them. The other + # service that serves this API appends the same components, read from the + # same definition, so neither document names one the other does not. + 'APPEND_COMPONENTS': { + 'schemas': dict(KEPT_COMPONENT_SCHEMAS), + # How a caller sends each credential the operations of this service + # take beside the session cookie. The generator describes the cookie + # and nothing else, so an operation taking a token names these and a + # client generated from the document can offer the caller a way to + # send it. + 'securitySchemes': dict(CREDENTIAL_SCHEMES), + }, + # A set of choices is published as its own model, named after the field + # carrying it while one field of the service carries that name alone. The + # moment a second field of the same name carries a different set, both are + # renamed after the models they sit in, so the plain name disappears and a + # client generated from the document loses that class. The names below are + # fixed here so that the sets keep them whatever else the service grows. + 'ENUM_NAME_OVERRIDES': { + 'ScopeEnum': 'openedx.core.djangoapps.user_api.v2.serializers.ERASURE_SCOPE_CHOICES', + 'OutcomeEnum': 'openedx.core.djangoapps.user_api.v2.serializers.BATCH_OUTCOME_CHOICES', + }, # Stripped from each path to derive its tags and operationId, so changing # it renames every operationId. Paths themselves are emitted in full. - 'SCHEMA_PATH_PREFIX': '/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..2f9903b7f3a8 100644 --- a/lms/lib/spectacular.py +++ b/lms/lib/spectacular.py @@ -4,6 +4,8 @@ from drf_spectacular.openapi import AutoSchema +from openedx.core.djangoapps.user_api.schema import SupersededUserOperations, is_superseded_user_path + LEGACY_V2_PREFIX = "/api/enrollment/v2/" # The one legacy address that differs from its conforming twin only by the @@ -11,46 +13,98 @@ # addresses already have distinct ids and keep them. COLLIDING_LEGACY_PATH = "/api/enrollment/v2/enrollments" +PUBLISHED_PATH_PATTERN = re.compile(r"^/api/enrollment/v\d+/") + +# The user addresses this schema documents are the ones the version below +# serves, and beside each of those the address it supersedes. Apps older than +# that version are mounted under the same ``/api/user/v2/`` prefix, so matching +# the prefix alone would publish endpoints described nowhere here; the packages +# a view may come from decide instead. One resource of this version lives in +# the app that owns it rather than in the user API's own package. +PUBLISHED_USER_PREFIX = "/api/user/v2/" +PUBLISHED_USER_VIEW_MODULES = ( + "openedx.core.djangoapps.user_api.v2.", + "openedx.core.djangoapps.profile_images.v2.", +) + +# Every user address, whatever its version, belongs to one tag group. A tag is +# the first path segment left once the configured prefix is removed, which in a +# document that trims ``/api/user`` is the version, so user operations would be +# grouped with the enrollment operations that carry the same version. A document +# that trims nothing already derives a tag naming an area, and regrouping its +# operations would move them in every client generated from it. +USER_PATH_PREFIX = "/api/user/" +USER_TAG = "user" + +_VERSION_TAG = re.compile(r"v\d+") + +# An address that takes its version as a parameter is left out of the +# superseded addresses this schema admits, though it is still marked wherever +# it is published: posting there to sign in publishes the description its +# handler carries, which is not what that sign-in reads or answers with, and +# nothing here can replace it. +_VERSION_PARAMETER = "{api_version}" + def _is_legacy_path(path): """Conforming routes always end in a slash, so a slashless v2 path is a legacy address.""" 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 it is a superseded one admitted above.""" + if is_superseded_user_path(path) and _VERSION_PARAMETER not in path: + return True + if not path.startswith(PUBLISHED_USER_PREFIX): + return False + view = getattr(callback, "cls", None) + return getattr(view, "__module__", "").startswith(PUBLISHED_USER_VIEW_MODULES) + + def lms_api_filter(endpoints): """ - Pre-processing hook: keep only enrollment v2 endpoints tagged for the SDK. + Pre-processing hook: keep only the endpoints this schema documents. """ filtered = [] - ENROLLMENT_PATH_PATTERN = re.compile(r"^/api/enrollment/v\d+/") for path, path_regex, method, callback in endpoints: - if ENROLLMENT_PATH_PATTERN.match(path): + if PUBLISHED_PATH_PATTERN.match(path) or _is_published_user_path(path, callback): filtered.append((path, path_regex, method, callback)) return filtered -class LmsAutoSchema(AutoSchema): +class LmsAutoSchema(SupersededUserOperations, AutoSchema): """ - Give the colliding legacy Enrollment v2 address a distinct operationId. - - 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 colliding addresses distinct and group each operation under its own area. + + The slashless Enrollment v2 address tokenizes to the id of its conforming + twin, so the two would be told apart only by a numeral suffix, which says + which of the two addresses sorts first rather than anything about the + operation; it is suffixed ``_legacy`` instead, leaving the conforming + address the clean id, and the address listed above keeps the name it is + already published under. Each operation keeps the tag the generator derives + for it unless that tag names a version, because a client generated from a + document groups the operations it offers by tag. """ def get_operation_id(self): - """Suffix the colliding legacy address so the pair never shares an operationId.""" + """Keep each address that tokenizes to another one's id distinct from it.""" operation_id = super().get_operation_id() 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 legacy enrollment address ``deprecated: true``.""" for path, path_item in result.get("paths", {}).items(): if not _is_legacy_path(path): continue diff --git a/lms/lib/tests/test_spectacular.py b/lms/lib/tests/test_spectacular.py index b611c8ad7776..2cc53cc4b712 100644 --- a/lms/lib/tests/test_spectacular.py +++ b/lms/lib/tests/test_spectacular.py @@ -1,20 +1,54 @@ """Tests for the LMS drf-spectacular hooks and schema class.""" +import json +import os +import re +import subprocess +import sys +from collections import defaultdict +from pathlib import Path +from types import SimpleNamespace from unittest import mock -from django.test import SimpleTestCase +from django.test import Client, SimpleTestCase, TestCase +from django.test.client import FakePayload 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 common.djangoapps.student.models_api import do_name_change_request +from common.djangoapps.student.tests.factories import TEST_PASSWORD, UserFactory from lms.lib.spectacular import LmsAutoSchema, lms_api_filter, lms_mark_legacy_paths_deprecated +from openedx.core.apidocs import get_api_docs_settings +from openedx.core.djangoapps.profile_images.tests.helpers import make_image_file +from openedx.core.djangoapps.user_api.accounts.image_helpers import ( + get_profile_image_names, + get_profile_image_storage, +) +from openedx.core.djangoapps.user_api.schema import ( + RENAMED_OPERATION_IDS, + SUPERSEDED_USER_PATHS, + mark_superseded_user_paths, + superseded_bodies, +) +ENROLLMENT_VIEWS = "openedx.core.djangoapps.enrollments.views" +USER_API_V1_VIEWS = "openedx.core.djangoapps.user_api.views" +USER_API_V2_VIEWS = "openedx.core.djangoapps.user_api.v2.views" +PROFILE_IMAGE_V1_VIEWS = "openedx.core.djangoapps.profile_images.views" +PROFILE_IMAGE_V2_VIEWS = "openedx.core.djangoapps.profile_images.v2.views" +USER_AUTHN_VIEWS = "openedx.core.djangoapps.user_authn.views.register" +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,18 +56,96 @@ def _schema(*routes): class LmsApiFilterTest(SimpleTestCase): - """The pre-processing hook keeps only enrollment endpoints.""" + """The hook keeps the enrollment mounts, this version of the user mounts, and what they supersede.""" - def test_keeps_enrollment_drops_the_rest(self): + def test_keeps_enrollment_this_version_and_the_addresses_it_supersedes(self): kept = lms_api_filter([ - _endpoint("/api/enrollment/v2/enrollments/"), - _endpoint("/api/enrollment/v1/enrollment"), - _endpoint("/api/user/v1/accounts/"), + _endpoint("/api/enrollment/v2/enrollments/", ENROLLMENT_VIEWS), + _endpoint("/api/enrollment/v1/enrollment", ENROLLMENT_VIEWS), + _endpoint("/api/user/v2/users/", USER_API_V2_VIEWS), + _endpoint("/api/user/v2/user_preferences/{username},{key}/", USER_API_V2_VIEWS), + # Superseded, and spelled with the parameter name the generator has + # not renamed to ``{id}`` yet. + _endpoint("/api/user/v1/users/{pk}/", USER_API_V1_VIEWS), + _endpoint("/api/user/v1/preferences/time_zones/", USER_API_V1_VIEWS), + _endpoint("/api/user/v1/accounts", USER_API_V1_VIEWS), + _endpoint("/api/user/v1/accounts/{username}", USER_API_V1_VIEWS), + _endpoint("/api/user/v1/me", USER_API_V1_VIEWS), + _endpoint("/api/user/v1/accounts/{username}/image", USER_API_V1_VIEWS), + _endpoint("/api/user/v1/accounts/name_change/", USER_API_V1_VIEWS), + _endpoint("/api/user/v1/preferences/{username}", USER_API_V1_VIEWS), + _endpoint( + "/api/user/v1/preferences/{username}/{preference_key}", USER_API_V1_VIEWS, + ), + _endpoint("/api/user/v1/preferences/email_opt_in/", USER_API_V1_VIEWS), + _endpoint("/api/user/v2/account/registration/", USER_AUTHN_VIEWS), + # Served by this version from the app that owns the resource. + _endpoint("/api/user/v2/accounts/{username}/profile_image/", PROFILE_IMAGE_V2_VIEWS), + # Neither served nor superseded by this version. + _endpoint("/user_api/v2/account/registration/", USER_AUTHN_VIEWS), + _endpoint("/api/profile_images/v1/{username}/upload", PROFILE_IMAGE_V1_VIEWS), + _endpoint("/api/courses/v1/courses/", COURSE_VIEWS), + _endpoint("/api/user_tours/v1/tours/"), + _endpoint("/user_api/v1/users/", USER_API_V1_VIEWS), + _endpoint("/user_api/v1/preferences/email_opt_in/", USER_API_V1_VIEWS), ]) assert [path for path, *_ in kept] == [ "/api/enrollment/v2/enrollments/", "/api/enrollment/v1/enrollment", + "/api/user/v2/users/", + "/api/user/v2/user_preferences/{username},{key}/", + "/api/user/v1/users/{pk}/", + "/api/user/v1/preferences/time_zones/", + "/api/user/v1/accounts", + "/api/user/v1/accounts/{username}", + "/api/user/v1/me", + "/api/user/v1/accounts/{username}/image", + "/api/user/v1/accounts/name_change/", + "/api/user/v1/preferences/{username}", + "/api/user/v1/preferences/{username}/{preference_key}", + "/api/user/v1/preferences/email_opt_in/", + "/api/user/v2/account/registration/", + "/api/user/v2/accounts/{username}/profile_image/", + ] + + def test_under_the_version_prefix_the_view_package_decides(self): + """Other apps are mounted under the same prefix, so the path alone cannot admit an address.""" + route = "/api/user/v2/login_sessions/" + admitted = { + view_module: [path for path, *_ in lms_api_filter([_endpoint(route, view_module)])] + for view_module in (USER_AUTHN_VIEWS, USER_API_V2_VIEWS) + } + assert admitted == {USER_AUTHN_VIEWS: [], USER_API_V2_VIEWS: [route]} + + def test_keeps_the_authentication_addresses_this_version_supersedes_and_none_beside_them(self): + """Each is admitted by its exact published key, whatever app serves it.""" + superseded = [ + "/api/user/v1/account/registration/", + "/api/user/v2/account/registration/", + "/api/user/v1/account/password_reset/", + "/api/user/v1/account/password_reset/token/validate/", + "/api/user/v1/validation/registration", ] + neighbours = [ + # Superseded as well, but it takes its version as a parameter. + "/api/user/{api_version}/account/login_session/", + # The same app's addresses that keep resolving and are not superseded here. + "/user_api/v1/account/registration/", + "/user_api/v2/account/registration/", + "/user_api/{api_version}/account/login_session/", + "/user_api/v1/account/password_reset/", + "/user_api/v1/account/password_reset/token/validate/", + "/create_account", + "/login_ajax", + # Spellings of the keys above that no route publishes. + "/api/user/v1/account/login_session/", + "/api/user/v1/validation/registration/", + # The addresses replacing them, which only their own views admit. + "/api/user/v2/registrations/", + "/api/user/v2/login_sessions/", + ] + kept = lms_api_filter([_endpoint(route, USER_AUTHN_VIEWS) for route in superseded + neighbours]) + assert [path for path, *_ in kept] == superseded class LmsMarkLegacyPathsDeprecatedTest(SimpleTestCase): @@ -60,6 +172,97 @@ def test_paths_are_left_as_full_urls(self): assert list(result["paths"]) == ["/api/enrollment/v2/enrollments/"] +class MarkSupersededUserPathsTest(SimpleTestCase): + """Only the addresses the shared list names are marked, wherever a service publishes them.""" + + def test_only_the_listed_user_addresses_are_deprecated(self): + result = mark_superseded_user_paths(_schema( + "/api/user/v1/users/", + "/api/user/v1/users/{id}/", + "/api/user/v1/preferences/time_zones/", + "/api/user/v1/accounts", + "/api/user/v1/accounts/{username}", + "/api/user/v1/accounts/{username}/deactivate/", + "/api/user/v1/me", + "/api/user/v1/accounts/{username}/image", + "/api/user/v1/accounts/name_change/", + "/api/user/v1/accounts/name_change/{username}/confirm/", + "/api/user/v1/accounts/{username}/verification_status/", + "/api/user/v1/accounts/{username}/verifications/", + "/api/user/v1/preferences/{username}", + "/api/user/v1/preferences/{username}/{preference_key}", + "/api/user/v1/preferences/email_opt_in/", + "/api/profile_images/v1/{username}/upload", + "/api/user/v2/users/", + "/api/user/v2/accounts/{username}/profile_image/", + "/api/user/v2/accounts/{username}/preferences/", + "/user_api/v1/users/", + "/user_api/v1/accounts/{username}", + "/user_api/v1/preferences/email_opt_in/", + ), None, None, False) + paths = result["paths"] + for marked in ( + "/api/user/v1/users/", + "/api/user/v1/users/{id}/", + "/api/user/v1/preferences/time_zones/", + "/api/user/v1/accounts", + "/api/user/v1/accounts/{username}", + "/api/user/v1/accounts/{username}/deactivate/", + "/api/user/v1/me", + "/api/user/v1/accounts/{username}/image", + "/api/user/v1/accounts/name_change/", + "/api/user/v1/accounts/name_change/{username}/confirm/", + "/api/user/v1/accounts/{username}/verification_status/", + "/api/user/v1/accounts/{username}/verifications/", + "/api/user/v1/preferences/{username}", + "/api/user/v1/preferences/{username}/{preference_key}", + "/api/user/v1/preferences/email_opt_in/", + ): + assert paths[marked]["get"]["deprecated"] is True, marked + for untouched in ( + "/api/profile_images/v1/{username}/upload", + "/api/user/v2/users/", + "/api/user/v2/accounts/{username}/profile_image/", + "/api/user/v2/accounts/{username}/preferences/", + "/user_api/v1/users/", + "/user_api/v1/accounts/{username}", + "/user_api/v1/preferences/email_opt_in/", + ): + assert "deprecated" not in paths[untouched]["get"], untouched + + def test_the_authentication_addresses_are_deprecated_and_their_neighbours_are_not(self): + marked = ( + "/api/user/v1/account/registration/", + "/api/user/v2/account/registration/", + "/api/user/v1/account/password_reset/", + "/api/user/v1/account/password_reset/token/validate/", + "/api/user/v1/validation/registration", + "/api/user/{api_version}/account/login_session/", + ) + untouched = ( + "/user_api/v1/account/registration/", + "/user_api/v2/account/registration/", + "/user_api/{api_version}/account/login_session/", + "/user_api/v1/account/password_reset/", + "/user_api/v1/account/password_reset/token/validate/", + "/create_account", + "/login_ajax", + "/api/user/v1/account/login_session/", + "/api/user/v2/account/login_session/", + "/api/user/v1/validation/registration/", + "/api/user/v2/registrations/", + "/api/user/v2/registration_validations/", + "/api/user/v2/login_sessions/", + "/api/user/v2/password_resets/", + "/api/user/v2/password_reset_token_validations/", + "/api/user/v2/users/", + "/api/user/v2/accounts/{username}/", + ) + paths = mark_superseded_user_paths(_schema(*marked, *untouched), None, None, False)["paths"] + assert {path: paths[path]["get"].get("deprecated") for path in marked} == dict.fromkeys(marked, True) + assert {path: paths[path]["get"].get("deprecated") for path in untouched} == dict.fromkeys(untouched) + + class _EmptySerializer(serializers.Serializer): # pylint: disable=abstract-method pass @@ -78,7 +281,7 @@ def retrieve(self, request, pk=None): class LmsAutoSchemaTest(SimpleTestCase): - """Dual mounts get deterministic operationIds instead of registration-order numeral suffixes.""" + """Dual mounts get deterministic operationIds instead of numeral suffixes.""" def test_is_the_default_schema_class(self): """Views without their own ``schema`` pick this up via REST_FRAMEWORK settings.""" @@ -99,3 +302,746 @@ def test_only_the_colliding_legacy_address_is_suffixed(self): assert ids["/api/enrollment/v2/courses/{id}/"] == "v2_courses_retrieve" # Already distinct from every conforming id, so it keeps its name. assert ids["/api/enrollment/v2/enrollment/{id}"] == "v2_enrollment_retrieve" + + +class _UserApiViewSet(viewsets.GenericViewSet): + """Stand-in for a user view read both as a collection and as a member.""" + + serializer_class = _EmptySerializer + permission_classes = (AllowAny,) + + def list(self, request): + return Response([]) + + def retrieve(self, request, **kwargs): + return Response({}) + + +def _user_routes(schema_class): + """User, enrollment and non-``/api/`` addresses, inspected by ``schema_class``.""" + view = type("_InspectedViewSet", (_UserApiViewSet,), {"schema": schema_class()}) + return [ + # The published collection answers with a list of accounts, which is + # what the generator derives a list operation id from. + path("api/user/v1/accounts", view.as_view({"get": "list"})), + path("api/user/v1/accounts/", view.as_view({"get": "retrieve"})), + path("api/user/v1/preferences/", view.as_view({"get": "retrieve"})), + path( + "api/user/v1/preferences//", + view.as_view({"get": "retrieve"}), + ), + path("api/user/v1/users/", view.as_view({"get": "list"})), + # Two reads of the retirement queue that answer with an array, which + # is what the generator derives a list operation id from, and which + # the document that trims nothing off the front has published under + # the name derived for a single record. + path("api/user/v1/accounts/retirement_queue/", view.as_view({"get": "list"})), + path( + "api/user/v1/accounts/retirements_by_status_and_date/", + view.as_view({"get": "list"}), + ), + path("api/user/v2/accounts//preferences/", view.as_view({"get": "retrieve"})), + path( + "api/user/v2/accounts//preferences//", + view.as_view({"get": "retrieve"}), + ), + path("api/user/v2/users/", view.as_view({"get": "list"})), + # A stored preference is addressed by its owner and its name together, + # and the comma between them is a token of the address like any other, + # so the generator leaves it in the name it derives. + path( + "api/user/v2/user_preferences/,/", + view.as_view({"get": "retrieve"}), + ), + path("api/enrollment/v1/enrollments/", view.as_view({"get": "list"})), + # Outside ``/api/``, so a document that derives its prefix from the + # routes it covers derives the one the service-wide document has. + path("user_api/v1/preferences/", view.as_view({"get": "retrieve"})), + ] + + +def _derived_operations(schema_class, patches): + """ + The names ``schema_class`` produces with nothing pinned, which is what the generator derives. + + The paths are read as they are assembled, which is before the numeral + telling two addresses that derived one name apart is handed out: a name + carrying that numeral was derived without it, and is looked up without it. + """ + with mock.patch.dict("openedx.core.djangoapps.user_api.schema.RENAMED_OPERATION_IDS", {}, clear=True): + with patched_settings({"PREPROCESSING_HOOKS": [], **patches}): + generator = SchemaGenerator(patterns=_user_routes(schema_class)) + return _by_address(generator.parse(None, public=True)) + + +def _named_operations(schema_class, patches): + """The ``{address: (operationId, tags)}`` ``schema_class`` produces under ``patches``.""" + with patched_settings({"PREPROCESSING_HOOKS": [], **patches}): + schema = SchemaGenerator(patterns=_user_routes(schema_class)).get_schema(request=None, public=True) + return _by_address(schema["paths"]) + + +def _by_address(paths): + """The ``{address: (operationId, tags)}`` the paths of a document carry.""" + return { + route: (operation["operationId"], tuple(operation["tags"])) + for route, path_item in paths.items() + for operation in path_item.values() + } + + +class UserOperationIdTest(SimpleTestCase): + """A user address keeps the name the generator derives unless one is pinned for it.""" + + def test_the_service_wide_document_keeps_every_name_the_generator_derives(self): + """ + Each of these is the name that document publishes, and a client imports it by name. + + An address that document has published for years keeps the name it + derived there, numeral and all; an address of a version it has not + published yet carries the name pinned for it. + """ + assert _named_operations(LmsAutoSchema, get_api_docs_settings()) == { + "/api/user/v1/accounts": ("api_user_v1_accounts_retrieve", ("api",)), + "/api/user/v1/accounts/{username}": ("api_user_v1_accounts_retrieve_2", ("api",)), + "/api/user/v1/preferences/{username}": ("api_user_v1_preferences_retrieve", ("api",)), + "/api/user/v1/preferences/{username}/{preference_key}": + ("api_user_v1_preferences_retrieve_2", ("api",)), + "/api/user/v1/users/": ("api_user_v1_users_list", ("api",)), + "/api/user/v1/accounts/retirement_queue/": + ("api_user_v1_accounts_retirement_queue_retrieve", ("api",)), + "/api/user/v1/accounts/retirements_by_status_and_date/": + ("api_user_v1_accounts_retirements_by_status_and_date_retrieve", ("api",)), + "/api/user/v2/accounts/{username}/preferences/": + ("api_user_v2_accounts_preferences_retrieve", ("api",)), + "/api/user/v2/accounts/{username}/preferences/{key}/": + ("api_user_v2_accounts_preferences_retrieve_one", ("api",)), + "/api/user/v2/users/": ("api_user_v2_users_list", ("api",)), + "/api/user/v2/user_preferences/{username},{key}/": + ("api_user_v2_user_preferences_retrieve", ("api",)), + "/api/enrollment/v1/enrollments/": ("api_enrollment_v1_enrollments_list", ("api",)), + "/user_api/v1/preferences/{username}": ("user_api_v1_preferences_retrieve", ("user_api",)), + } + + def test_every_pinned_name_replaces_one_a_document_derives(self): + """An entry keyed on an id no document derives is never reached, and pins nothing.""" + derived = { + (address, "GET", operation_id) + for patches in ({}, get_api_docs_settings()) + for address, (operation_id, _tags) in _derived_operations(LmsAutoSchema, patches).items() + } + assert set(RENAMED_OPERATION_IDS) - derived == set() + + def test_a_user_address_is_grouped_by_area_where_the_derived_tag_would_name_a_version(self): + """In the document that trims the area off, the derived tag is the version, and ids are untouched.""" + assert _named_operations(LmsAutoSchema, {}) == { + "/api/user/v1/accounts": ("v1_accounts_list", ("user",)), + "/api/user/v1/accounts/{username}": ("v1_accounts_retrieve", ("user",)), + "/api/user/v1/preferences/{username}": ("v1_preferences_retrieve", ("user",)), + "/api/user/v1/preferences/{username}/{preference_key}": ( + "v1_preferences_retrieve_one", ("user",), + ), + "/api/user/v1/users/": ("v1_users_list", ("user",)), + "/api/user/v1/accounts/retirement_queue/": ("v1_accounts_retirement_queue_list", ("user",)), + "/api/user/v1/accounts/retirements_by_status_and_date/": ( + "v1_accounts_retirements_by_status_and_date_list", ("user",), + ), + "/api/user/v2/accounts/{username}/preferences/": ( + "v2_accounts_preferences_retrieve", ("user",), + ), + "/api/user/v2/accounts/{username}/preferences/{key}/": ( + "v2_accounts_preferences_retrieve_one", ("user",), + ), + "/api/user/v2/users/": ("v2_users_list", ("user",)), + "/api/user/v2/user_preferences/{username},{key}/": ( + "v2_user_preferences_retrieve", ("user",), + ), + "/api/enrollment/v1/enrollments/": ("v1_enrollments_list", ("v1",)), + "/user_api/v1/preferences/{username}": ("user_api_v1_preferences_retrieve", ("user_api",)), + } + + +#: The preference document a superseded read answers with, spelled out here +#: rather than read from the module that publishes it: an edit to the shape +#: that module publishes is then an edit this test sees. +PREFERENCE_DOCUMENT_BODY = { + "type": "object", + "additionalProperties": {"type": "string"}, + "description": ( + "Preferences of one account, as an object of preference name to stored value. Every " + "value is text. Which names an account carries depends on the features it uses." + ), +} + +#: What each superseded operation the schema describes is expected to exchange: +#: the media types of the body it reads, and the success code with the body it +#: answers with. ``None`` is a response code carrying no body at all. +SUPERSEDED_EXCHANGES = { + ("/api/user/v1/accounts", "get"): ([], {"200": "Account[]"}), + ("/api/user/v1/accounts/{username}", "get"): ([], {"200": "Account"}), + ("/api/user/v1/accounts/{username}", "patch"): (["application/merge-patch+json"], {"200": "Account"}), + ("/api/user/v1/accounts/search_emails", "post"): ( + ["application/json", "application/merge-patch+json"], {"200": "EmailSearchResult[]"}, + ), + ("/api/user/v1/accounts/{username}/deactivate/", "post"): ([], {"200": "Account"}), + ("/api/user/v1/accounts/name_change/", "post"): ( + ["application/json", "application/x-www-form-urlencoded", "multipart/form-data"], + {"201": None}, + ), + ("/api/user/v1/accounts/name_change/{username}/confirm/", "post"): ([], {"204": None}), + ("/api/user/v1/accounts/{username}/image", "post"): ( + ["image/gif", "image/jpeg", "image/pjpeg", "image/png", "multipart/form-data"], + {"204": None}, + ), + ("/api/user/v1/accounts/{username}/image", "delete"): ([], {"204": None}), + ("/api/user/v1/accounts/{username}/verification_status/", "get"): ([], {"200": "VerificationStatus"}), + ("/api/user/v1/preferences/{username}", "get"): ([], {"200": PREFERENCE_DOCUMENT_BODY}), + ("/api/user/v1/preferences/{username}", "patch"): (["application/merge-patch+json"], {"204": None}), + ("/api/user/v1/preferences/{username}/{preference_key}", "get"): ([], {"200": {"type": "string"}}), + ("/api/user/v1/preferences/{username}/{preference_key}", "put"): (["application/json"], {"204": None}), + ("/api/user/v1/preferences/{username}/{preference_key}", "delete"): ([], {"204": None}), + ("/api/user/v1/preferences/email_opt_in/", "post"): ( + ["application/x-www-form-urlencoded"], {"200": None}, + ), +} + + +def _named_body(response): + """ + Return what a response carries: the component it names, or the shape itself. + + A component is named by its own name, suffixed ``[]`` where the body is an + array of it; a body described in place comes back as that description, and + a response with no body at all as ``None``. + """ + content = response.get("content") + if not content: + return None + body = content["application/json"]["schema"] + if body.get("type") == "array": + return f"{body['items']['$ref'].rsplit('/', 1)[-1]}[]" + if "$ref" not in body: + return body + return body["$ref"].rsplit("/", 1)[-1] + + +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 SupersededBodyInTheServiceWideDocumentTest(TestCase): + """ + The same bodies describe these addresses in the document that covers the whole service. + + That document publishes every endpoint rather than this area alone, and it + already carried these addresses before they were described, so the table + changes what they publish there instead of adding them. Two of them + answered with no body all along and are published with the code they + really answer with. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + with patched_settings(get_api_docs_settings()): + cls.schema = SchemaGenerator().get_schema(request=None, public=True) + + def test_each_superseded_operation_publishes_the_same_exchange_there(self): + published = {} + for (address, method), _expected in SUPERSEDED_EXCHANGES.items(): + operation = self.schema["paths"][address][method] + request_media_types = sorted((operation.get("requestBody") or {}).get("content", {})) + published[(address, method)] = ( + request_media_types, + {code: _named_body(response) for code, response in operation["responses"].items()}, + ) + assert published == SUPERSEDED_EXCHANGES + + def test_the_twin_of_the_opt_in_is_left_as_that_document_always_published_it(self): + """It keeps resolving, is described by nothing here, and is not marked superseded.""" + twin = self.schema["paths"]["/user_api/v1/preferences/email_opt_in/"]["post"] + assert "requestBody" not in twin + assert sorted(twin["responses"]) == ["200"] + assert "content" not in twin["responses"]["200"] + assert twin.get("deprecated", False) is False + + +#: Each address this document already published before its body was described, +#: and the success code its handler really answers. That document carried a 200 +#: with no body for all four, which no handler answers; the code each is +#: published under now is driven below. +CORRECTED_SUCCESS_CODES = ( + ("post", "/api/user/v1/accounts/name_change/{username}/confirm/", "204"), + ("post", "/api/user/v1/accounts/{username}/image", "204"), + ("patch", "/api/user/v1/preferences/{username}", "204"), + ("put", "/api/user/v1/preferences/{username}/{preference_key}", "204"), +) + + +class CorrectedSuccessCodeTest(TestCase): + """ + Four addresses both documents already published answer a success code neither carried. + + These are not new addresses: a client generated before this change branches + on the code the document gave them, so each is pinned here in both + documents and each handler is driven, so that the published code can only + change again together with the handler that answers it. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.documents = {"published": SchemaGenerator().get_schema(request=None, public=True)} + with patched_settings(get_api_docs_settings()): + cls.documents["service-wide"] = SchemaGenerator().get_schema(request=None, public=True) + + def setUp(self): + super().setUp() + self.owner = UserFactory.create(username="codeowner", password=TEST_PASSWORD) + self.owner.profile.year_of_birth = 1980 + self.owner.profile.save() + self.staff = UserFactory.create(username="codestaff", password=TEST_PASSWORD, is_staff=True) + self.storage = get_profile_image_storage() + + def tearDown(self): + for name in get_profile_image_names(self.owner.username).values(): + self.storage.delete(name) + super().tearDown() + + def _as(self, user): + """A client signed in as that account.""" + client = Client() + assert client.login(username=user.username, password=TEST_PASSWORD) + return client + + def _answered(self): + """Drive each of the four on its success path and return the code it answers.""" + owner, staff = self._as(self.owner), self._as(self.staff) + do_name_change_request(self.owner, "Another Name", "a reason") + answered = { + ("post", "/api/user/v1/accounts/name_change/{username}/confirm/"): staff.post( + f"/api/user/v1/accounts/name_change/{self.owner.username}/confirm/", + ).status_code, + ("patch", "/api/user/v1/preferences/{username}"): owner.patch( + f"/api/user/v1/preferences/{self.owner.username}", + data='{"pref-lang": "en"}', + content_type=MERGE_PATCH, + ).status_code, + ("put", "/api/user/v1/preferences/{username}/{preference_key}"): owner.put( + f"/api/user/v1/preferences/{self.owner.username}/account_privacy", + data='"private"', + content_type="application/json", + ).status_code, + } + with make_image_file() as image_file: + answered[("post", "/api/user/v1/accounts/{username}/image")] = owner.post( + f"/api/user/v1/accounts/{self.owner.username}/image", {"file": image_file}, + ).status_code + return answered + + def test_each_handler_answers_the_code_it_is_published_under(self): + assert self._answered() == { + (method, address): int(code) for method, address, code in CORRECTED_SUCCESS_CODES + } + + def test_every_document_publishes_that_code_and_nothing_beside_it(self): + published = {} + for name, document in self.documents.items(): + for method, address, _code in CORRECTED_SUCCESS_CODES: + responses = document["paths"][address][method]["responses"] + published[(name, method, address)] = ( + sorted(responses), + ["content" in response for response in responses.values()], + ) + assert published == { + (name, method, address): ([code], [False]) + for name in self.documents + for method, address, code in CORRECTED_SUCCESS_CODES + } + + +#: Loaded in a process of its own below. The service settings append a +#: component, and the documentation settings are then asked what they append, +#: so that a key one of them carries is seen to reach the other. +DOCUMENTATION_SETTINGS_CHILD = """ +import json +import os + +import lms.envs.common as service_settings + +service_settings.SPECTACULAR_SETTINGS["APPEND_COMPONENTS"] = { + "schemas": {"ServiceComponent": {"type": "object"}}, +} + +os.environ["DJANGO_SETTINGS_MODULE"] = "docs.docs_settings" +from django.conf import settings + +appended = settings.SPECTACULAR_SETTINGS.get("APPEND_COMPONENTS", {}) +print("APPENDED " + json.dumps({ + "keys": sorted(appended), + "schemas": sorted(appended.get("schemas", {})), + "security": sorted(appended.get("securitySchemes", {})), +})) +""" + + +class DocumentationSettingsTest(SimpleTestCase): + """ + The settings the published reference is generated from keep what the service appends. + + They are loaded in a process of their own because they turn every feature + flag on and add Studio's applications to the settings object this process + is already running under, so loading them here would reconfigure the + service for every test that follows. What they append holds component + names the published reference is expected to carry, and one dropped there + leaves the reference and every client built from it without that name + while no count moves. + """ + + def test_a_component_the_service_appends_reaches_the_published_reference(self): + repository = Path(__file__).resolve().parents[3] + environment = dict(os.environ, PYTHONPATH=str(repository)) + environment.pop("DJANGO_SETTINGS_MODULE", None) + finished = subprocess.run( + [sys.executable, "-c", DOCUMENTATION_SETTINGS_CHILD], + cwd=str(repository), + env=environment, + capture_output=True, + text=True, + check=False, + ) + assert finished.returncode == 0, finished.stderr[-2000:] + reported = [line for line in finished.stdout.splitlines() if line.startswith("APPENDED ")] + assert len(reported) == 1, finished.stdout[-2000:] + assert json.loads(reported[0].split(" ", 1)[1]) == { + "keys": ["schemas", "securitySchemes"], + # The component the child stands in for the service with, beside + # the one the documentation settings name themselves: a component + # named on either side reaches the reference. + "schemas": ["PatchedUserRetirementStatus", "ServiceComponent"], + "security": ["Basic", "csrf", "jwt"], + } + + +MERGE_PATCH = "application/merge-patch+json" + +#: Each address that describes its body in place, the form it reads it in, and +#: what the handler answers a request carrying no body at all. +BODIES_SENT_AS_NOTHING = ( + ("PATCH", "/api/user/v1/preferences/{}", "/api/user/v1/preferences/bodyowner", MERGE_PATCH, 400), + ("POST", "/api/user/v1/preferences/email_opt_in/", "/api/user/v1/preferences/email_opt_in/", + "application/x-www-form-urlencoded", 400), + ("PATCH", "/api/user/v2/accounts/{}/preferences/", "/api/user/v2/accounts/bodyowner/preferences/", + MERGE_PATCH, 400), + ("PUT", "/api/user/v1/preferences/{}/{}", "/api/user/v1/preferences/bodyowner/account_privacy", + "application/json", 204), +) + + +class RequiredRequestBodyTest(TestCase): + """ + A body no request may leave out is published as one the operation needs. + + Every address below describes its body in place rather than through a + serializer, which leaves the generator no field to read that from, so it + publishes no mark at all and a client makes the body optional. Each is + driven here with no body, in both documents, so the mark one carries is + held to what the handler answers. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.documents = {"published": SchemaGenerator().get_schema(request=None, public=True)} + with patched_settings(get_api_docs_settings()): + cls.documents["service-wide"] = SchemaGenerator().get_schema(request=None, public=True) + + def setUp(self): + super().setUp() + self.owner = UserFactory.create(username="bodyowner", password=TEST_PASSWORD) + assert self.client.login(username=self.owner.username, password=TEST_PASSWORD) + + @staticmethod + def _published_address(shape): + """The address as a document spells it, from the shape its parameters are removed from.""" + return { + "/api/user/v1/preferences/{}": "/api/user/v1/preferences/{username}", + "/api/user/v1/preferences/{}/{}": "/api/user/v1/preferences/{username}/{preference_key}", + "/api/user/v2/accounts/{}/preferences/": "/api/user/v2/accounts/{username}/preferences/", + }.get(shape, shape) + + def _send_nothing(self, method, address, media_type): + """Send the request with its form named and no body behind it.""" + return self.client.generic( + method, + address, + data=b"", + content_type=media_type, + CONTENT_TYPE=media_type, + CONTENT_LENGTH="0", + **{"wsgi.input": FakePayload(b"")}, + ) + + def test_a_handler_that_refuses_a_request_carrying_no_body_is_the_reason_for_the_mark(self): + """What each handler answers, which is what the mark below is held to.""" + answered = { + (method, address): self._send_nothing(method, address, media_type).status_code + for method, _shape, address, media_type, _expected in BODIES_SENT_AS_NOTHING + } + assert answered == { + (method, address): expected + for method, _shape, address, media_type, expected in BODIES_SENT_AS_NOTHING + } + + def test_every_document_marks_a_body_the_handler_refuses_to_work_without(self): + marks = {} + for name, document in self.documents.items(): + for method, shape, _address, _media_type, _answer in BODIES_SENT_AS_NOTHING: + address = self._published_address(shape) + operation = document["paths"][address][method.lower()] + marks[(name, method, address)] = operation["requestBody"].get("required", "") + assert marks == { + (name, method, self._published_address(shape)): (True if answer == 400 else "") + for name in self.documents + for method, shape, _address, _media_type, answer in BODIES_SENT_AS_NOTHING + } + + +class ServiceWideDocumentSettingsTest(TestCase): + """ + The document covering the whole service keeps the settings it is generated under. + + Its title, its version and its description are given to the generator for + the length of the generation rather than configured for the deployment, + and a part of the document is built under a setting held for that part + alone. What the document says about itself is read here beside that part, + because a hold let go of too widely takes the rest with it and the + document would describe itself as something else. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.given = get_api_docs_settings() + with patched_settings(cls.given): + cls.schema = SchemaGenerator().get_schema(request=None, public=True) + + def test_the_document_says_of_itself_what_it_was_generated_with(self): + expected = { + "title": self.given["TITLE"], + "version": self.given["VERSION"], + "description": self.given["DESCRIPTION"], + } + if self.given.get("CONTACT"): + expected["contact"] = self.given["CONTACT"] + assert self.schema["info"] == expected + + def test_the_document_names_no_server_to_send_callers_to(self): + """It is generated with none named, and a document naming one points at that host.""" + assert "servers" not in self.schema + + def test_the_body_read_whole_is_published_whole_in_that_document_too(self): + """Building this is what holds a setting while the rest of the document is built.""" + body = self.schema["paths"]["/api/user/v2/retirements/{username}/"]["patch"]["requestBody"] + assert body["required"] is True + assert body["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/RetirementUpdate", + } + assert self.schema["components"]["schemas"]["RetirementUpdate"]["required"] == [ + "new_state", "response", + ] + assert "PatchedRetirementUpdate" not in self.schema["components"]["schemas"] + + def test_a_body_carrying_changes_keeps_the_optional_form_there(self): + """The setting held for the body above is let go of, so the next body is built without it.""" + changes = self.schema["paths"]["/api/user/v1/accounts/{username}"]["patch"]["requestBody"] + assert changes.get("required", False) is False + assert "required" not in self.schema["components"]["schemas"]["PatchedAccountUpdate"] + + + + +class PublishedDocumentTest(TestCase): + """What the service actually publishes: distinct ids, and one area per tag group.""" + + @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_the_addresses_told_apart_by_a_numeral_suffix_are_the_ones_that_tokenize_alike(self): + """ + Two addresses differing only in a path parameter derive one name, and one takes a numeral. + + The numeral is handed out once the whole document is assembled, to + every address but the first of those whose names came out alike, so it + tells a reader which address sorts first rather than what the + operation reads. Every pair this document publishes is named apart + instead, and the one below keeps the numeral it is published under. + """ + suffixed = { + route: operation["operationId"] + for route, operation in self.operations() + if re.fullmatch(r".+_\d+", operation["operationId"]) + } + assert suffixed == { + "/api/enrollment/v1/enrollment/{course_id}": "v1_enrollment_retrieve_2", + } + + def test_a_read_of_one_preference_is_named_for_the_one_preference_it_reads(self): + """ + The read of a whole preference document and the read of one of its names are named apart. + + Both addresses derive one name, which the assembled document would + tell apart with a numeral, so the name a client imports for the read + of one preference would say which of the two addresses sorts first + rather than what it answers with. Each version names that read for + what it reads, and the read of the whole document keeps the name both + addresses derive. + """ + ids = { + (route, method): operation["operationId"] + for route, path_item in self.schema["paths"].items() + for method, operation in path_item.items() + if isinstance(operation, dict) and "operationId" in operation + } + assert ids[("/api/user/v1/preferences/{username}", "get")] == "v1_preferences_retrieve" + assert ids[("/api/user/v1/preferences/{username}/{preference_key}", "get")] == ( + "v1_preferences_retrieve_one" + ) + assert ids[("/api/user/v2/accounts/{username}/preferences/", "get")] == ( + "v2_accounts_preferences_retrieve" + ) + assert ids[("/api/user/v2/accounts/{username}/preferences/{key}/", "get")] == ( + "v2_accounts_preferences_retrieve_one" + ) + + def test_a_tag_group_holds_one_area_unless_it_marks_the_client_library(self): + """ + The group a client library is generated from marks operations in any area. + + Every other group names an area, and gathering two of them under one + name would move operations in every client generated from the document. + """ + areas = defaultdict(set) + for route, operation in self.operations(): + for tag in operation.get("tags", []): + areas[tag].add(route.split("/")[2]) + assert {tag: sorted(members) for tag, members in areas.items()} == { + "v1": ["enrollment"], + "openedx-platform-sdk": ["enrollment", "user"], + "user": ["user"], + } + + +#: How each served document is expected to describe the CSRF token, written out +#: rather than read from the definition the service appends, so that a change +#: to that definition shows up here as a change to what callers are told. +CSRF_SCHEME = { + "type": "apiKey", + "name": "X-CSRFToken", + "in": "header", + "description": ( + "The value of the `csrftoken` cookie the sign-in, registration and password reset forms set, " + "sent in this header along with that cookie." + ), +} + + +def credentials_named(document): + """Return each credential an operation of a document names, with the operations naming it.""" + named = defaultdict(set) + for route, path_item in document["paths"].items(): + for method, operation in path_item.items(): + if isinstance(operation, dict): + for alternative in operation.get("security", ()): + for credential in alternative: + named[credential].add((method, route)) + return named + + +class ServedCredentialSchemesTest(TestCase): + """ + Each document this service serves describes every credential its operations name. + + A client is generated from one document, and it can only offer the caller + a way to send a credential the document describes; an operation naming one + the document does not describe makes the document invalid. The documents are requested at the addresses they are + served from, because what each carries depends on the settings it is + generated under. + """ + + ADDRESSES = ("/lms-api/schema/", "/api-docs/schema/") + + @classmethod + def setUpClass(cls): + super().setUpClass() + client = Client() + cls.documents = {} + for address in cls.ADDRESSES: + response = client.get(address, {"format": "json"}, HTTP_ACCEPT="application/vnd.oai.openapi+json") + assert response.status_code == 200, (address, response.status_code) + cls.documents[address] = json.loads(response.content) + + def test_every_credential_an_operation_names_is_described_in_the_same_document(self): + for address, document in self.documents.items(): + described = set(document["components"].get("securitySchemes", {})) + named = credentials_named(document) + assert {name: sorted(named[name]) for name in set(named) - described} == {}, address + + def test_the_csrf_token_is_described_where_the_sign_in_names_it(self): + """Both documents carry a sign-in that needs the token, and say how it is sent.""" + for address, document in self.documents.items(): + assert ("post", "/api/user/v2/login_sessions/") in credentials_named(document)["csrf"], address + assert document["components"].get("securitySchemes", {}).get("csrf") == CSRF_SCHEME, address diff --git a/lms/tests.py b/lms/tests.py index 4a45b86a2699..c3c78c8b9e04 100644 --- a/lms/tests.py +++ b/lms/tests.py @@ -3,13 +3,25 @@ import logging import mimetypes +from unittest import mock from django.conf import settings # pylint: disable=unused-import # noqa: F401 from django.core.cache import cache +from django.http import HttpResponse from django.test import TestCase, override_settings -from django.urls import path +from django.urls import Resolver404, URLResolver, path, resolve, reverse +from django.urls.resolvers import RegexPattern +from drf_spectacular.generators import SchemaGenerator +from edx_django_utils.plugins import get_plugin_url_patterns +import lms.urls +from common.djangoapps.student.tests.factories import TEST_PASSWORD, UserFactory from openedx.core.apidocs import cached_schema_view +from openedx.core.djangoapps.plugins.constants import ProjectType +from openedx.core.djangoapps.user_api.v2.views import AccountViewSet as CurrentAccountViewSet +from openedx.core.djangoapps.user_api.v2.views import UnknownAddressView +from openedx.core.djangoapps.user_authn.views.login import LoginSessionView +from openedx.core.djangoapps.user_authn.views.register import RegistrationView log = logging.getLogger(__name__) @@ -86,4 +98,180 @@ def test_api_docs_schema_cache_per_urlconf(self): assert again.content == full.content +class NarrowSchemaCacheTests(TestCase): + """ + The document this service publishes for client generation is served from the cache. + + It is public, it costs a full generation pass, and it is the one schema + address with nothing in front of it. The cache may only serve it if every + caller is given the same document, so that is measured here too. + """ + + ADDRESS = '/lms-api/schema/' + + def _generations(self): + """Count the passes the generator makes, leaving it otherwise alone.""" + return mock.patch.object( + SchemaGenerator, + 'get_schema', + autospec=True, + side_effect=SchemaGenerator.get_schema, + ) + + @override_settings( + OPENAPI_CACHE_TIMEOUT=60, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}, + ) + def test_two_requests_generate_the_document_once(self): + cache.clear() + with self._generations() as generated: + first = self.client.get(self.ADDRESS) + second = self.client.get(self.ADDRESS) + assert first.status_code == 200 + assert first.content.startswith(b'openapi:') + assert second.content == first.content + assert second['Content-Type'] == first['Content-Type'] + assert generated.call_count == 1 + + @override_settings( + OPENAPI_CACHE_TIMEOUT=0, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}, + ) + def test_without_a_timeout_each_request_generates_it_again(self): + """A deployment that turns the cache off is served exactly what it was before.""" + cache.clear() + with self._generations() as generated: + first = self.client.get(self.ADDRESS) + second = self.client.get(self.ADDRESS) + assert first.status_code == 200 + assert second.content == first.content + assert generated.call_count == 2 + + @override_settings( + OPENAPI_CACHE_TIMEOUT=60, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}, + ) + def test_the_two_documents_this_service_serves_are_not_stored_under_one_key(self): + cache.clear() + narrow = self.client.get(self.ADDRESS) + wide = self.client.get('/api-docs/schema/') + assert narrow.status_code == 200 + assert wide.status_code == 200 + assert len(narrow.content) < len(wide.content) + assert self.client.get(self.ADDRESS).content == narrow.content + assert self.client.get('/api-docs/schema/').content == wide.content + + @override_settings( + OPENAPI_CACHE_TIMEOUT=60, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}, + ) + def test_another_language_is_not_served_the_document_already_stored(self): + cache.clear() + english = self.client.get(self.ADDRESS, HTTP_ACCEPT_LANGUAGE='en') + spanish = self.client.get(self.ADDRESS, HTTP_ACCEPT_LANGUAGE='es-419') + assert english.status_code == 200 + assert spanish.status_code == 200 + assert english.content != spanish.content + assert self.client.get(self.ADDRESS, HTTP_ACCEPT_LANGUAGE='en').content == english.content + + def test_every_caller_is_given_the_same_document(self): + """What the cache may hold: one document, not one per caller.""" + staff = UserFactory.create(username='narrowstaff', password=TEST_PASSWORD, is_staff=True) + root = UserFactory.create( + username='narrowroot', password=TEST_PASSWORD, is_staff=True, is_superuser=True, + ) + anonymous = self.client.get(self.ADDRESS) + served = {'anonymous': anonymous.content} + for user in (staff, root): + assert self.client.login(username=user.username, password=TEST_PASSWORD) + served[user.username] = self.client.get(self.ADDRESS).content + self.client.logout() + assert anonymous.status_code == 200 + assert set(served.values()) == {anonymous.content} + + +def _calendars_of_an_application(request): # pylint: disable=unused-argument + """Stand-in for an address an installed application serves under a claimed prefix.""" + return HttpResponse() + + +class ClaimedPrefixTests(TestCase): + """ + A prefix whose mount answers every address under it that its own routes do not. + + The current version of the user API is mounted that way, so nothing after + it is ever consulted for an address under that prefix. An installed + application contributes its patterns at one point in the URLconf, which + leaves the order between the two as what decides whether an address such an + application registers under the prefix can be served at all. + + The authentication application registers two addresses there, so that order + is measurable against real patterns rather than asserted about a copy. + """ + + LANDMARK = "api/user/v2/account/registration/" + + def _index_of(self, route): + """Where in the URLconf the pattern that answers that address sits.""" + for index, pattern in enumerate(lms.urls.urlpatterns): + try: + if pattern.resolve(route): + return index + except Resolver404: + continue + raise AssertionError(f"nothing in the URLconf answers {route}") + + def test_the_landmark_address_is_one_an_installed_application_contributes(self): + """The order below says nothing unless an application really registers an address here.""" + contributed = URLResolver(RegexPattern(r"^/"), get_plugin_url_patterns(ProjectType.LMS)) + assert contributed.resolve(f"/{self.LANDMARK}").func.cls is RegistrationView + + def test_the_mount_that_claims_the_prefix_is_consulted_after_those_applications(self): + claimed = self._index_of("api/user/v2/nothing_here/") + assert claimed > self._index_of(self.LANDMARK) + + def test_an_address_an_application_registers_under_the_prefix_reaches_its_view(self): + patterns = list(lms.urls.urlpatterns) + patterns.insert( + self._index_of(self.LANDMARK) + 1, + path("api/user/v2/calendars/", _calendars_of_an_application), + ) + resolved = URLResolver(RegexPattern(r"^/"), patterns).resolve("/api/user/v2/calendars/") + assert resolved.func is _calendars_of_an_application, resolved.func + + def test_an_address_nothing_registers_is_still_answered_by_the_mount(self): + assert resolve("/api/user/v2/nothing_here/").func.cls is UnknownAddressView + + def test_the_version_serves_its_own_resources(self): + assert resolve("/api/user/v2/accounts/").func.cls is CurrentAccountViewSet + + def test_the_addresses_another_application_serves_under_the_prefix_still_reach_it(self): + reached = { + address: resolve(address).func.cls + for address in ( + "/api/user/v2/account/registration/", + "/api/user/v2/account/login_session/", + ) + } + assert reached == { + "/api/user/v2/account/registration/": RegistrationView, + "/api/user/v2/account/login_session/": LoginSessionView, + } + + def test_those_addresses_keep_the_names_they_are_reversed_by(self): + reversed_names = { + "user_api_registration_v2": reverse("user_api_registration_v2"), + "user_api_login_session": reverse("user_api_login_session", kwargs={"api_version": "v2"}), + } + assert reversed_names == { + "user_api_registration_v2": "/api/user/v2/account/registration/", + "user_api_login_session": "/api/user/v2/account/login_session/", + } + + def test_one_of_them_sent_without_its_trailing_slash_is_still_redirected_to_it(self): + response = self.client.get("/api/user/v2/account/registration") + assert response.status_code == 301 + assert response["Location"] == "/api/user/v2/account/registration/" + + urlpatterns = [path('api-docs/schema/', cached_schema_view())] diff --git a/lms/urls.py b/lms/urls.py index 0e624d44981a..d1b78cbecdbd 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -141,7 +141,8 @@ # Course API path('api/courses/', include('lms.djangoapps.course_api.urls')), - # User API endpoints + # User API endpoints. The current version is mounted at the end of this + # file instead, for the reason given there. path('api/user/', include('openedx.core.djangoapps.user_api.urls')), # Note: these are older versions of the User API that will eventually be # subsumed by api/user listed above. @@ -1077,6 +1078,16 @@ def _enterprise_enabled(): urlpatterns.extend(get_plugin_url_patterns(ProjectType.LMS)) +# The current version of the User API, mounted after everything an installed +# application contributes. That mount answers every address under its prefix +# that its own routes do not serve, so an address registered under the prefix +# by anything mounted later is reached by nothing; mounting it here leaves such +# an address to the application that registers it, and leaves the mount only +# the addresses nothing serves. +urlpatterns += [ + path('api/user/v2/', include('openedx.core.djangoapps.user_api.v2.urls')), +] + # Course Home API urls urlpatterns += [ # This is a BFF ("backend for frontend") djangoapp for the Learning MFE (like courseware_api). @@ -1139,7 +1150,14 @@ def _enterprise_enabled(): path('xqueue/', include((submissions_urls, 'submissions'), namespace='submissions')), ] -# LMS API schema for openedx-platform-sdk generation. +# LMS API schema for openedx-platform-sdk generation. Served from the same +# cache the service-wide document uses: it is public, it is generated from +# scratch on every request, and it grew several times over when this API was +# added to it. urlpatterns += [ - path('lms-api/schema/', SpectacularAPIView.as_view(), name='lms-schema'), + path( + 'lms-api/schema/', + cached_schema_view(SpectacularAPIView.as_view(), resource='lms-api-schema'), + name='lms-schema', + ), ] diff --git a/openedx/core/apidocs.py b/openedx/core/apidocs.py index 838c61b6bb56..1450e7a27c52 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. @@ -84,9 +84,16 @@ def untag_deprecated_operations(result, generator, request, public): # pylint: return result -def cached_schema_view(): +def cached_schema_view(view=None, resource='apidocs-schema'): """ - Build the ``/api-docs`` schema view, caching the rendered document compressed. + Serve a generated schema document from the cache, stored compressed. + + With no arguments this builds the service-wide ``/api-docs`` view. A view + passed in is served the same way under the ``resource`` name given, which + is what keeps each document a service publishes in a key space of its own: + the narrower document each service serves for client generation is + otherwise regenerated on every anonymous request, and it is the one schema + address with nothing in front of it. ``cache_page`` stores the pickled ``Response``, which for this schema is over memcached's default 1MB item limit. ``CACHES['default']`` sets @@ -95,16 +102,18 @@ def cached_schema_view(): whole schema on every request. Storing the zlib-compressed body instead brings it to roughly a tenth of the limit. - The key covers the URLconf, the path, the negotiated representation and the - active language. The URLconf because the LMS and the CMS serve this view at - the same path and share one cache by default, so without it whichever - service generated first would serve its document at the other's URL. The - language because drf-spectacular renders the document under whatever - language is active, and ``LocaleMiddleware`` sets that from the request's - cookie or ``Accept-Language`` before this runs. Nothing else in it varies by - requester -- ``SERVE_PUBLIC`` defaults to ``True``, ``SERVE_*`` cannot be set - through ``custom_settings``, and ``API_DOCS_SETTINGS`` sets ``'SERVERS': []`` - so no ``servers`` block is emitted. + The key covers the resource, the URLconf, the path, the negotiated + representation and the active language. The URLconf because the LMS and the + CMS serve this view at the same path and share one cache by default, so + without it whichever service generated first would serve its document at + the other's URL. The language because drf-spectacular renders the document + under whatever language is active, and ``LocaleMiddleware`` sets that from + the request's cookie or ``Accept-Language`` before this runs. + + The key does not cover who asked: a document stored here is served to every + later caller, so an address wrapped this way must publish one document for + all of them. The narrower document is held to that by a test that asks for + it anonymously, as staff and as a superuser and compares the three. Only GET and HEAD are cached, as ``cache_page`` did: DRF answers OPTIONS on this view with a 200 metadata document, which would otherwise be stored and @@ -118,7 +127,8 @@ def cached_schema_view(): """ from drf_spectacular.views import SpectacularAPIView # pylint: disable=import-outside-toplevel - view = SpectacularAPIView.as_view(custom_settings=get_api_docs_settings()) + if view is None: + view = SpectacularAPIView.as_view(custom_settings=get_api_docs_settings()) def _response(content_type, body): """Build the response, marking it uncacheable downstream as drf-yasg did.""" @@ -132,7 +142,7 @@ def schema_view(request, *args, **kwargs): return view(request, *args, **kwargs) cache_key = get_cache_key( - resource='apidocs-schema', + resource=resource, urlconf=settings.ROOT_URLCONF, path=request.get_full_path(), accept=request.META.get('HTTP_ACCEPT', ''), 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..a3511b40aff7 --- /dev/null +++ b/openedx/core/djangoapps/profile_images/v2/tests/test_views.py @@ -0,0 +1,978 @@ +"""Tests for the version 2 profile image views.""" + +import datetime +import os +import struct +import zlib +from contextlib import closing +from io import BytesIO +from unittest import mock +from zoneinfo import ZoneInfo + +import freezegun +import pytest +from django.contrib.auth.models import User # pylint: disable=imported-auth-user +from django.core.files.uploadedfile import SimpleUploadedFile +from django.urls import reverse +from edx_rest_framework_extensions.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, + set_has_profile_image, +) +from openedx.core.djangoapps.user_api.errors import UserNotFound +from openedx.core.djangolib.testing.utils import skip_unless_lms + +from ...tests.helpers import make_image_file + +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_here_without_recording_the_address_as_one_on_the_defaults(self): + """ + This address names its own classes, so it is not one the default pair serves. + + The pair the platform configures records every request it reads a + token for, so that an operator can list the addresses it serves. This + one replaces the session half of it and is therefore not among them, + and nothing here is recorded as if it were. The address read afterwards + names no classes of its own and is recorded, which is what shows the + record is reachable from a request made this way at all. + """ + with mock.patch(DEFAULT_AUTH_RECORDER) as recorded: + with make_image_file() as image_file: + response = self.client.post( + self.url, {"file": image_file}, format="multipart", **self.token_headers(self.owner), + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert self.stored_sizes(self.owner.username) == self.all_sizes(self.owner.username) + assert recorded.call_args_list == [] + + with mock.patch(DEFAULT_AUTH_RECORDER) as recorded: + on_the_default_pair = self.client.get( + reverse("user_v2:user_preference_list"), **self.token_headers(self.staff), + ) + assert on_the_default_pair.status_code == status.HTTP_200_OK + assert [call.args for call in recorded.call_args_list] == [("using_default_auth_classes", True)] + + def test_a_body_in_a_media_type_this_endpoint_cannot_read_is_refused(self): + 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() + + +@skip_unless_lms +class ImageRecordingTests(APITestCase): + """ + What an image is recorded on is the account handed over, not a name read again. + + The recording helper takes either the account itself or the name of one. A + caller that has already settled which account it is acting on hands over + that account, and that is the account written to; a caller that has only a + name is served by the name, as before. + """ + + def setUp(self): + super().setUp() + self.owner = UserFactory.create(username="recorded-owner", password=PASSWORD) + self.uploaded_at = datetime.datetime(2026, 3, 4, 5, 6, tzinfo=ZoneInfo("UTC")) + + def test_the_account_handed_over_is_the_account_recorded_on(self): + """ + The account's own row is the one written, so no name is read on the way to it. + + The account is given a different name in the database behind the + record handed over, which a write that read a name would not find. + """ + User.objects.filter(pk=self.owner.pk).update(username="recorded-owner-elsewhere") + set_has_profile_image(self.owner, True, self.uploaded_at) + self.owner.profile.refresh_from_db() + assert self.owner.profile.has_profile_image is True + assert self.owner.profile.profile_image_uploaded_at == self.uploaded_at + + def test_an_account_handed_over_for_clearing_is_the_account_cleared(self): + set_has_profile_image(self.owner, True, self.uploaded_at) + User.objects.filter(pk=self.owner.pk).update(username="recorded-owner-elsewhere") + set_has_profile_image(self.owner, False) + self.owner.profile.refresh_from_db() + assert self.owner.profile.has_profile_image is False + assert self.owner.profile.profile_image_uploaded_at is None + + def test_a_name_handed_over_is_still_read_as_a_name(self): + """A caller holding a name rather than an account is served as it was.""" + set_has_profile_image(self.owner.username, True, self.uploaded_at) + self.owner.profile.refresh_from_db() + assert self.owner.profile.has_profile_image is True + assert self.owner.profile.profile_image_uploaded_at == self.uploaded_at + + def test_a_name_no_account_holds_is_refused(self): + with pytest.raises(UserNotFound): + set_has_profile_image("nobody-holds-this-name", False) + + def test_an_account_carrying_no_profile_is_refused(self): + profileless = UserFactory.create(username="recorded-no-profile", password=PASSWORD) + profileless.profile.delete() + with pytest.raises(UserNotFound): + set_has_profile_image(profileless, False) + + def test_recording_an_image_without_saying_when_is_refused(self): + with pytest.raises(ValueError, match="No upload datetime was supplied."): + set_has_profile_image(self.owner, True) + + +@pytest.fixture(name="image_owner") +@skip_unless_lms +def image_owner_fixture(client, django_user_model): # pylint: disable=unused-argument + """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..5096cfb14ef7 --- /dev/null +++ b/openedx/core/djangoapps/profile_images/v2/views.py @@ -0,0 +1,273 @@ +"""Views for version 2 of the profile image endpoints.""" + +import datetime +import logging +import struct +from contextlib import closing +from zoneinfo import ZoneInfo + +import piexif +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser +from edx_rest_framework_extensions.mixins import StandardizedErrorMixin +from PIL import Image +from rest_framework import permissions, status +from rest_framework.exceptions import NotFound, ValidationError +from rest_framework.parsers import FormParser, MultiPartParser +from rest_framework.response import Response +from rest_framework.views import APIView + +from 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, + USERNAME_PATTERN, + StrictQueryParametersMixin, + path_param, +) +from openedx.core.djangoapps.user_api.v2.views import RESP_NOT_ACCEPTABLE, AccountResourceMixin +from openedx.core.lib.api.parsers import TypedFileUploadParser + +from ..exceptions import ImageValidationError +from ..images import create_profile_images, remove_profile_images, validate_uploaded_image +from .serializers import IMAGE_UPLOAD_REQUEST, UPLOAD_MEDIA_TYPES, ProfileImageUploadSerializer + +log = logging.getLogger(__name__) + +# The name in this address is read as the name of a stored account, and the +# shorthand the account collection takes for the caller's own account is not +# translated here: a request sent under that name works on the account +# registered under it. The description below publishes that to a caller; it is +# written here for the reader of the module, because an address serving one +# account from another app follows the same rule. +_USERNAME_PATH_PARAM = path_param( + "username", + "Unique name of the user whose image is addressed. The name is read as it is stored, so " + "`me` here names an account holding that name rather than the account of the caller " + "making the request.", + USERNAME_PATTERN, +) + +_RESP_ANOTHER_USERS_IMAGE = OpenApiResponse( + ErrorResponse, description="The image belongs to another user, which nobody else may change.", +) +_RESP_NO_PROFILE = OpenApiResponse( + ErrorResponse, + description=( + "The account at this address carries no profile to record an image on. A name no account " + "holds is refused as somebody else's image instead of being reported here." + ), +) + +_CONTENT_DISPOSITION_HEADER_PARAM = OpenApiParameter( + name="Content-Disposition", + description=( + "Name the image is sent under, as `attachment; filename=\"picture.png\"`. Required of a " + "request that sends the image as the whole body, because the extension of that name is " + "what is held against the media type the body was sent in. A form part carries its own " + "name and needs no header." + ), + required=False, + location=OpenApiParameter.HEADER, + type=str, +) + +#: What a caller is told about a file that is shaped like an image the site +#: takes but holds nothing the site can draw. +UNREADABLE_IMAGE_MESSAGE = "The image could not be read. The file may be incomplete or corrupted." + +#: What a caller is told about a file whose header declares a picture far +#: larger than any this site draws, which is never decoded. +OVERSIZED_IMAGE_MESSAGE = "The image declares more pixels than this site draws." + +#: What the imaging library raises when the bytes handed to it hold no picture +#: it can draw: a file whose bytes stop early, or metadata that does not parse. +UNREADABLE_IMAGE_ERRORS = (OSError, struct.error) + + +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, + AccountResourceMixin, + StrictQueryParametersMixin, + APIView, +): + """ + The image shown beside a user's name, in each size the site renders. + + A user sets and clears the image on their own account and nobody else's, + staff included. Setting an image replaces one already set, and clearing an + account that carries no image of its own is not an error. The addresses + the stored sizes are served from are carried by the account itself. + """ + + # An account has one image, set and cleared whole and never listed or + # addressed by an identifier of its own, so it is served from this one + # address rather than routed as a collection. Clearing an image is an + # account change rather than a moderation tool, so staff are not admitted + # to somebody else's; that is not what the account collection admits, + # which is the other reason this is a view of its own rather than an + # action of that collection. + + # The site's own pages let a user who has not confirmed their email + # address set a picture, and the platform default refuses a session + # belonging to such an account. Only the session class differs in what it + # admits: a token is read by the library class the platform's own pair is + # built on, named here because a view declaring its own classes is not one + # that pair serves. + authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser) + permission_classes = (permissions.IsAuthenticated, IsAddressedUser) + parser_classes = (MultiPartParser, FormParser, NamedImageUploadParser) + serializer_class = ProfileImageUploadSerializer + upload_media_types = UPLOAD_MEDIA_TYPES + + @extend_schema( + summary="Set the profile image", + description=( + "Stores the uploaded image in each size the site renders, cropped to a square, and " + "records on the account that it has one. An image already set is replaced. The image " + "may be sent as a form field named `file` or as the whole body, with the media type " + "of the image." + ), + parameters=[_USERNAME_PATH_PARAM, _CONTENT_DISPOSITION_HEADER_PARAM], + request=IMAGE_UPLOAD_REQUEST, + responses={ + 204: OpenApiResponse(description="The account now carries this image."), + 400: OpenApiResponse( + ErrorResponse, + description=( + "The request carries no usable image: none was sent, the body is not well " + "formed in the media type it was sent in, one sent as the whole body was sent " + "without a name or under a name whose extension names another media type, the " + "image is of a type or a size this site does not take, or no picture can be " + "drawn from the file, whose bytes may stop early, carry metadata that does " + "not parse, or declare more pixels than this site draws. A query parameter " + "this endpoint does not read is refused here too." + ), + ), + 401: RESP_UNAUTHENTICATED, + 403: _RESP_ANOTHER_USERS_IMAGE, + 404: _RESP_NO_PROFILE, + 406: RESP_NOT_ACCEPTABLE, + 415: RESP_UNREADABLE_BODY, + }, + ) + def post(self, request, username): + """Store the uploaded image in each size the site renders.""" + upload = ProfileImageUploadSerializer(data=request.data) + upload.is_valid(raise_exception=True) + uploaded_file = upload.validated_data["file"] + with closing(uploaded_file): + try: + validate_uploaded_image(uploaded_file) + except ImageValidationError as error: + raise self._unusable_image(username, error.user_message, error) from error + self._read_image(username, uploaded_file) + image_names = get_profile_image_names(username) + create_profile_images(uploaded_file, image_names) + self._record_image(request.user, True) + log.info( + "Generated and uploaded images %(image_names)s for user %(user_id)s", + {"image_names": list(image_names.values()), "user_id": request.user.id}, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + @extend_schema( + summary="Clear the profile image", + description=( + "Removes the stored sizes of the user's image and records on the account that it has " + "none, which is what the default image is shown for. Clearing an account that has no " + "image of its own is not an error." + ), + parameters=[_USERNAME_PATH_PARAM], + request=None, + responses={ + 204: OpenApiResponse(description="The account carries no image of its own."), + 400: OpenApiResponse( + ErrorResponse, + description="The request carries a query parameter this endpoint does not read.", + ), + 401: RESP_UNAUTHENTICATED, + 403: _RESP_ANOTHER_USERS_IMAGE, + 404: _RESP_NO_PROFILE, + 406: RESP_NOT_ACCEPTABLE, + }, + ) + def delete(self, request, username): + """Remove the stored sizes of the user's image.""" + self._record_image(request.user, False) + image_names = get_profile_image_names(username) + remove_profile_images(image_names) + log.info( + "Deleted images %(image_names)s for user %(user_id)s", + {"image_names": list(image_names.values()), "user_id": request.user.id}, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + def _read_image(self, username, uploaded_file): + """ + Read the uploaded image once, refusing bytes no picture can be drawn from. + + Every stored size is drawn from these bytes and carries the metadata + found in them, 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, user, is_uploaded): + """ + Record on the account whether it carries an image of its own. + + The account recorded on is the one the request was admitted for, which + is handed over here, so what is recorded lands on that account and on + no other. + """ + uploaded_at = datetime.datetime.now(ZoneInfo("UTC")) if is_uploaded else None + try: + set_has_profile_image(user, is_uploaded, uploaded_at) + except UserNotFound as exc: + raise NotFound from exc + + def _unusable_image(self, username, message, reason): + """Return the refusal telling the caller what the site cannot do with this image.""" + log.info("Profile image refused for %r: %r", username, reason) + return ValidationError({"file": [message]}) diff --git a/openedx/core/djangoapps/user_api/accounts/image_helpers.py b/openedx/core/djangoapps/user_api/accounts/image_helpers.py index bb2ff19321cb..3917a1957ed4 100644 --- a/openedx/core/djangoapps/user_api/accounts/image_helpers.py +++ b/openedx/core/djangoapps/user_api/accounts/image_helpers.py @@ -160,14 +160,17 @@ def _get_default_profile_image_urls(): ) -def set_has_profile_image(username, is_uploaded, upload_dt=None): +def set_has_profile_image(user, is_uploaded, upload_dt=None): """ System (not user-facing) API call used to store whether the user has uploaded a profile image, and if so, when. Used by profile_image API. Arguments: - username (django.contrib.auth.User.username): references the user who - uploaded an image. + user (django.contrib.auth.User or django.contrib.auth.User.username): + the user who uploaded an image, as either that user or the + username of one. A caller that has already settled which user it + is acting for passes that user, so that this records the image on + that user and nothing else is consulted on the way to it. is_uploaded (bool): whether or not the user has an uploaded profile image. @@ -178,14 +181,15 @@ def set_has_profile_image(username, is_uploaded, upload_dt=None): Raises: ValueError: is_uploaded was True, but no upload datetime was supplied. - UserNotFound: no user with username `username` exists. + UserNotFound: there is no such user, or the user has no profile. """ if is_uploaded and upload_dt is None: # pylint: disable=no-else-raise raise ValueError("No upload datetime was supplied.") elif not is_uploaded: upload_dt = None + selected = {'user__username': user} if isinstance(user, str) else {'user': user} try: - profile = UserProfile.objects.get(user__username=username) + profile = UserProfile.objects.get(**selected) except ObjectDoesNotExist: raise UserNotFound() # pylint: disable=raise-missing-from # noqa: B904 diff --git a/openedx/core/djangoapps/user_api/models.py b/openedx/core/djangoapps/user_api/models.py index 7fd08dcbe110..3b8a464061a7 100644 --- a/openedx/core/djangoapps/user_api/models.py +++ b/openedx/core/djangoapps/user_api/models.py @@ -382,6 +382,22 @@ def update_state(self, update): self.responses += "\n Moved from {} to {}:\n{}\n".format(old_state, self.current_state, update['response']) self.save() + def check_state_allows_retirement_action(self): + """ + Raise RetirementStateError unless this row's state is acceptable to be acted upon. + + The row should be in a "working state" (not a dead end state, PENDING, or *_COMPLETE), + which is what keeps a step of the retirement from being run twice over one account. + + A caller that has already resolved the row it means to act on gates that row with this, + rather than resolving the name a second time, so that the row judged is the row acted on. + """ + state = self.current_state + + if state.required or state.state_name.endswith('_COMPLETE'): + raise RetirementStateError('{} is in {}, not a valid state to perform retirement ' # noqa: UP032 + 'actions on.'.format(self, state.state_name)) + @classmethod def get_retirement_for_retirement_action(cls, username): """ @@ -410,11 +426,7 @@ def get_retirement_for_retirement_action(cls, username): raise UserRetirementStatus.DoesNotExist('{} does not have an exact match in UserRetirementStatus. ' # noqa: UP032 # pylint: disable=line-too-long '{} similar rows found.'.format(username, len(retirements))) - state = retirement.current_state - - if state.required or state.state_name.endswith('_COMPLETE'): - raise RetirementStateError('{} is in {}, not a valid state to perform retirement ' # noqa: UP032 - 'actions on.'.format(retirement, state.state_name)) + retirement.check_state_allows_retirement_action() return retirement diff --git a/openedx/core/djangoapps/user_api/schema.py b/openedx/core/djangoapps/user_api/schema.py new file mode 100644 index 000000000000..58bac2cc35ca --- /dev/null +++ b/openedx/core/djangoapps/user_api/schema.py @@ -0,0 +1,621 @@ +"""How a document describes the addresses of this API that a conforming one replaces.""" + +import re +from collections import namedtuple +from contextlib import contextmanager +from functools import cache +from importlib import import_module + +from drf_spectacular.plumbing import force_instance +from drf_spectacular.settings import spectacular_settings +from drf_spectacular.utils import OpenApiResponse +from rest_framework.settings import api_settings + +# Addresses listed here are superseded by a conforming address this schema also +# publishes. Each entry is spelled out rather than matched by prefix, so an +# address whose replacement has not been written yet is never marked. +SUPERSEDED_USER_PATHS = ( + "/api/user/v1/me", + "/api/user/v1/accounts", + "/api/user/v1/accounts/search_emails", + "/api/user/v1/accounts/{username}", + "/api/user/v1/accounts/{username}/deactivate/", + "/api/user/v1/accounts/{username}/image", + "/api/user/v1/accounts/{username}/verification_status/", + "/api/user/v1/accounts/{username}/verifications/", + "/api/user/v1/accounts/name_change/", + "/api/user/v1/accounts/name_change/{username}/confirm/", + "/api/user/v1/users/", + "/api/user/v1/users/{id}/", + "/api/user/v1/user_prefs/", + "/api/user/v1/user_prefs/{id}/", + "/api/user/v1/preferences/{username}", + "/api/user/v1/preferences/{username}/{preference_key}", + "/api/user/v1/preferences/email_opt_in/", + "/api/user/v1/preferences/{pref_key}/users/", + "/api/user/v1/forum_roles/{name}/users/", + "/api/user/v1/preferences/time_zones/", + "/api/user/v1/accounts/{username}/retirement_status/", + "/api/user/v1/accounts/retirement_queue/", + "/api/user/v1/accounts/retirements_by_status_and_date/", + "/api/user/v1/accounts/update_retirement_status/", + "/api/user/v1/accounts/retirement_cleanup/", + "/api/user/v1/accounts/retirement_partner_report/", + "/api/user/v1/accounts/retirement_partner_report_cleanup/", + "/api/user/v1/accounts/cancel_retirement/", + "/api/user/v1/accounts/retire/", + "/api/user/v1/accounts/retire_misc/", + "/api/user/v1/account/registration/", + "/api/user/v2/account/registration/", + "/api/user/v1/account/password_reset/", + "/api/user/v1/account/password_reset/token/validate/", + "/api/user/v1/validation/registration", + "/api/user/{api_version}/account/login_session/", +) + +# Two reads of the retirement queue answer with an array, which the generator +# names for a list, while the document that trims nothing off the front of each +# path has always published both under the name derived for a single record -- +# the name each had while the serializer named on their view described one +# record rather than the array the handler returns. Those two names are what a +# client imports by, so they are pinned; the document that trims the area off +# the front of each path has published neither address, so it keeps the names it +# derives for them. +# +# A path parameter leaves no token behind, and the version 1 account collection +# has no trailing slash to tell it from the member address, so the two tokenize +# to one operation id and are told apart only by a numeral handed out in the +# order the addresses sort. The collection answers with a list of accounts, which the +# generator names for a list, while the document that trims nothing off the +# front of each path has always published that address under the name derived +# for a single account -- the name that leaves the member address the numeral it +# carries there -- so that one is pinned. The key carries the id the generator +# derives, so an entry applies only to the document that derives it; a key no +# document derives would never be reached. +# +# The two preference reads tokenize to one id for the same reason. The numeral +# a document hands the second of them says which of the two addresses sorts +# first rather than what the operation reads, so the read of one preference is +# named for what it reads and the read of the whole document keeps the name +# both derive. Of the version 1 pair only the document that trims the area off +# the front of each path is covered: the one that trims nothing already +# publishes that read under the numeral, and a name a client imports by is not +# taken away. Neither document has published either name of the version 2 +# pair, so both are covered there, each through the name the document it +# covers derives; that leaves the five operations of that resource carrying the +# same leading tokens in every document, which is what a client generated from +# one takes its module from. +# +# A stored preference is addressed by the pair that identifies it, and the +# comma between the two parameters is left behind as a token of its own, so +# every document derives an id for that read carrying a bare separator where +# the rest of the resource carries a word. That read is named here in each +# document through the id that document derives, which leaves the collection +# read and the member read of the resource under the same leading tokens +# wherever they are published; neither document has published either name, so +# nothing a client imports is taken away. +RENAMED_OPERATION_IDS = { + ("/api/user/v1/accounts", "GET", "api_user_v1_accounts_list"): "api_user_v1_accounts_retrieve", + ( + "/api/user/v1/accounts/retirement_queue/", + "GET", + "api_user_v1_accounts_retirement_queue_list", + ): "api_user_v1_accounts_retirement_queue_retrieve", + ( + "/api/user/v1/accounts/retirements_by_status_and_date/", + "GET", + "api_user_v1_accounts_retirements_by_status_and_date_list", + ): "api_user_v1_accounts_retirements_by_status_and_date_retrieve", + ("/api/user/v1/preferences/{username}/{preference_key}", "GET", "v1_preferences_retrieve"): ( + "v1_preferences_retrieve_one" + ), + ( + "/api/user/v2/accounts/{username}/preferences/{key}/", + "GET", + "v2_accounts_preferences_retrieve", + ): "v2_accounts_preferences_retrieve_one", + ( + "/api/user/v2/accounts/{username}/preferences/{key}/", + "GET", + "api_user_v2_accounts_preferences_retrieve", + ): "api_user_v2_accounts_preferences_retrieve_one", + ( + "/api/user/v2/user_preferences/{username},{key}/", + "GET", + "v2_user_preferences_,_retrieve", + ): "v2_user_preferences_retrieve", + ( + "/api/user/v2/user_preferences/{username},{key}/", + "GET", + "api_user_v2_user_preferences_,_retrieve", + ): "api_user_v2_user_preferences_retrieve", +} + +# Operations whose handler answers a request carrying no body with a 400. The +# body of each is described in place rather than through a serializer, and a +# shape given in place carries no field the generator can read requiredness +# from, so it publishes no mark at all and a client generated from the document +# makes the body an argument the caller may leave out. Each address is spelled +# with its parameter names removed, as the table of bodies below is. +# +# An address belongs here only once its handler has been sent a request with +# the form named and no body behind it and has refused it. The write of one +# stored preference is deliberately absent: that handler takes a request with +# no body and answers that the preference now stands as the request asked. +REQUIRED_REQUEST_BODIES = frozenset({ + ("/api/user/v1/preferences/{}", "PATCH"), + ("/api/user/v1/preferences/email_opt_in/", "POST"), + ("/api/user/v2/accounts/{}/preferences/", "PATCH"), +}) + +# A serializer carries this attribute, set to true, where the body it describes +# is read whole: every field it requires is needed however the body is sent, +# and a method whose bodies are read as a set of changes to a stored +# representation does not make them optional. +WHOLE_BODY_ATTRIBUTE = "reads_the_whole_body" + +#: Stands for a generation setting that has not been asked for a value yet. +_NOT_HELD = object() + + +@contextmanager +def _generation_setting(name, value): + """ + Hold one schema generation setting at this value for the length of the block. + + Only this setting is touched, and only this block's hold on it is let go + of, so a document being generated under settings of its own -- its title, + its version and the address to write to about it are given that way -- + keeps every one of them while a part of it is built under this setting. A + setting nothing had asked for is left unasked for afterwards rather than + fixed at whatever it stood at here, so that a later change to the + deployment's settings still reaches it. + """ + held = spectacular_settings.__dict__.get(name, _NOT_HELD) + setattr(spectacular_settings, name, value) + try: + yield + finally: + if held is _NOT_HELD: + spectacular_settings.__dict__.pop(name, None) + else: + setattr(spectacular_settings, name, held) + + +_PATH_PARAMETER = re.compile(r"\{([\w-]+)\}") + +# The superseded addresses with their parameter names removed. The generator +# renames a ``{pk}`` parameter to ``{id}`` only after this filter has run, so +# the comparison cannot depend on how a parameter is spelled. +_SUPERSEDED_USER_SHAPES = frozenset(_PATH_PARAMETER.sub("{}", path) for path in SUPERSEDED_USER_PATHS) + + +def is_superseded_user_path(path): + """Say whether this address is one of those a conforming address replaces.""" + return _PATH_PARAMETER.sub("{}", path) in _SUPERSEDED_USER_SHAPES + +#: What one superseded operation reads and what it answers with on success. +_SupersededBodies = namedtuple("_SupersededBodies", ("request", "response")) + + +@cache +def superseded_bodies(): + """ + Return the bodies of each superseded operation, keyed by address and method. + + The views serving those addresses name no serializer, so an address + published beside the one that replaces it would carry nothing on either + side of the exchange. Each entry below names the shape the handler reads or + returns with the serializer describing that same shape at the replacing + address, which the parity tests of this area hold to the same values; a + request of ``None`` is an operation that reads no body. An address whose + view names a serializer of its own is absent from the table and keeps the + component it has always published, unless the serializer it names describes + neither what its handler reads nor what its handler returns, in which case + the shapes the handler really exchanges are named here instead. + + A request is keyed by media type, because a shape named on its own is + published under every type the view's parsers accept: where that is more + types than the handler reads, a client generated from the document would + pick one it refuses, and where the handler reads a body no serializer + describes, such as an image sent as the whole body, the document would + leave that form out. An address whose body is also read at the address + replacing it takes the same mapping from there, so the two cannot drift. + """ + # The serializers read models, which are not loaded when this module is + # first imported, so each module is read inside this one. Two of them + # reach models belonging to a single service, which code shared by both + # services may not import: a service picks that application up by name + # through its settings, and those two modules are picked up by name here + # for the same reason. + # pylint: disable=import-outside-toplevel + from openedx.core.djangoapps.profile_images.v2.serializers import IMAGE_UPLOAD_REQUEST + from openedx.core.djangoapps.user_api.v2 import authn_schemas + from openedx.core.lib.api.parsers import MergePatchParser + + replacing = import_module("openedx.core.djangoapps.user_api.v2.serializers") + retirement = import_module("openedx.core.djangoapps.user_api.accounts.serializers") + + account = {200: replacing.AccountSerializer} + json_type = "application/json" + + def in_every_form(shape): + """Name a shape under each media type a view that declares no parsers reads a body in.""" + return {parser.media_type: shape for parser in api_settings.DEFAULT_PARSER_CLASSES} + + retirement_erased = { + 204: OpenApiResponse(description="The data this step erases is gone."), + } + #: The name of one account, which is how the retirement pipeline's steps + #: say which account they are acting on. + named_account = { + "type": "object", + "properties": { + "username": { + "type": "string", + "description": "Name the account being retired was registered under.", + }, + }, + "required": ["username"], + } + #: The names of several accounts, which is how a step acting on a batch of + #: retirement records says which ones. + named_accounts = { + "type": "object", + "properties": { + "usernames": { + "type": "array", + "items": {"type": "string"}, + "description": "Names the accounts were registered under.", + }, + }, + "required": ["usernames"], + } + # One preference is answered and stored as the bare value, which is what + # the address replacing it carries as a property of an object. + preference_value = {"type": "string"} + preferences_changed = { + 204: OpenApiResponse(description="The preferences now stand as the body asked."), + } + described = { + ("/api/user/v1/accounts", "GET"): _SupersededBodies(None, {200: replacing.AccountSerializer(many=True)}), + ("/api/user/v1/accounts/{username}", "GET"): _SupersededBodies(None, account), + ("/api/user/v1/accounts/{username}", "PATCH"): _SupersededBodies( + {MergePatchParser.media_type: replacing.AccountUpdateSerializer}, account, + ), + ("/api/user/v1/accounts/search_emails", "POST"): _SupersededBodies( + replacing.EmailSearchSerializer, {200: replacing.EmailSearchResultSerializer(many=True)}, + ), + ("/api/user/v1/accounts/{username}/deactivate/", "POST"): _SupersededBodies(None, account), + ("/api/user/v1/accounts/name_change/", "POST"): _SupersededBodies( + replacing.NameChangeSerializer, + {201: OpenApiResponse(description="The change was recorded and waits to be confirmed.")}, + ), + ("/api/user/v1/accounts/name_change/{username}/confirm/", "POST"): _SupersededBodies( + None, {204: OpenApiResponse(description="The profile carries the name that was asked for.")}, + ), + ("/api/user/v1/accounts/{username}/image", "POST"): _SupersededBodies( + IMAGE_UPLOAD_REQUEST, + {204: OpenApiResponse(description="The account now carries this image.")}, + ), + ("/api/user/v1/accounts/{username}/image", "DELETE"): _SupersededBodies( + None, {204: OpenApiResponse(description="The account carries no image of its own.")}, + ), + ("/api/user/v1/accounts/{username}/verification_status/", "GET"): _SupersededBodies( + None, {200: replacing.VerificationStatusSerializer}, + ), + ("/api/user/v1/preferences/{username}", "GET"): _SupersededBodies( + None, + { + 200: OpenApiResponse( + replacing.ACCOUNT_PREFERENCES_SCHEMA, description="The preferences this account stores.", + ), + }, + ), + ("/api/user/v1/preferences/{username}", "PATCH"): _SupersededBodies( + {MergePatchParser.media_type: replacing.ACCOUNT_PREFERENCE_CHANGES_SCHEMA}, preferences_changed, + ), + ("/api/user/v1/preferences/{username}/{preference_key}", "GET"): _SupersededBodies( + None, {200: preference_value}, + ), + ("/api/user/v1/preferences/{username}/{preference_key}", "PUT"): _SupersededBodies( + {"application/json": preference_value}, preferences_changed, + ), + ("/api/user/v1/preferences/{username}/{preference_key}", "DELETE"): _SupersededBodies( + None, {204: OpenApiResponse(description="The account stores no value under that name.")}, + ), + # The handler reads the choice from the submitted form rather than from + # a body of its own, and reads each value as the text a form carries. + # The queue and the by-date list both answer with an array of the + # shape their view names, which is published for one of them. + ("/api/user/v1/accounts/retirement_queue/", "GET"): _SupersededBodies( + None, {200: retirement.UserRetirementStatusSerializer(many=True)}, + ), + ("/api/user/v1/accounts/retirements_by_status_and_date/", "GET"): _SupersededBodies( + None, {200: retirement.UserRetirementStatusSerializer(many=True)}, + ), + # The move of a retirement to another pipeline step reads the step and + # the log entry alongside the account's name, and refuses a body + # carrying anything else; it answers with nothing at all. + ("/api/user/v1/accounts/update_retirement_status/", "PATCH"): _SupersededBodies( + { + json_type: { + "type": "object", + "properties": { + "username": { + "type": "string", + "description": "Name the account being retired was registered under.", + }, + "new_state": { + "type": "string", + "description": "Name of the pipeline step to move this retirement to.", + }, + "response": { + "type": "string", + "description": "What happened at the step being left, appended to the log.", + }, + "force": { + "type": "boolean", + "description": "Move the retirement even where the step asked for is not a later one.", + }, + }, + "required": ["username", "new_state", "response"], + }, + }, + {204: OpenApiResponse(description="The retirement now stands at the step asked for.")}, + ), + ("/api/user/v1/accounts/retirement_cleanup/", "POST"): _SupersededBodies( + {json_type: named_accounts}, + {204: OpenApiResponse(description="The retirement records named are gone.")}, + ), + # The claim reads no body and answers with the entries it took. + ("/api/user/v1/accounts/retirement_partner_report/", "POST"): _SupersededBodies( + None, {200: retirement.UserRetirementPartnerReportSerializer(many=True)}, + ), + ("/api/user/v1/accounts/retirement_partner_report/", "PUT"): _SupersededBodies( + {json_type: named_account}, + { + 204: OpenApiResponse( + description=( + "The account is queued for reporting to the organizations it studied " + "with, or was enrolled with none and so needs no report." + ), + ), + }, + ), + # This batch is named by a bare array rather than by an object. + ("/api/user/v1/accounts/retirement_partner_report_cleanup/", "POST"): _SupersededBodies( + { + json_type: { + "type": "array", + "items": { + "type": "object", + "properties": { + "original_username": { + "type": "string", + "description": "Name the account was registered under.", + }, + }, + "required": ["original_username"], + }, + }, + }, + {204: OpenApiResponse(description="The partner report entries named are gone.")}, + ), + # The cancellation names the retirement by its own identifier, and its + # view declares no parsers, so the body is read in every form the + # platform reads one in. + ("/api/user/v1/accounts/cancel_retirement/", "POST"): _SupersededBodies( + in_every_form({ + "type": "object", + "properties": { + "retirement_id": { + "type": "integer", + "description": "Identifier of the retirement to cancel.", + }, + }, + "required": ["retirement_id"], + }), + { + 200: { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "True once the retirement request has been undone.", + }, + }, + "required": ["success"], + }, + }, + ), + ("/api/user/v1/accounts/retire/", "POST"): _SupersededBodies( + {json_type: named_account}, retirement_erased, + ), + ("/api/user/v1/accounts/retire_misc/", "POST"): _SupersededBodies( + {json_type: named_account}, retirement_erased, + ), + ("/api/user/v1/preferences/email_opt_in/", "POST"): _SupersededBodies( + { + "application/x-www-form-urlencoded": { + "type": "object", + "properties": { + "course_id": { + "type": "string", + "description": ( + "Course the organization runs, which is how the organization the " + "choice applies to is identified." + ), + }, + "email_opt_in": { + "type": "string", + "description": ( + "`True` if this user wants messages from that organization. Any " + "other value is read as not wanting them." + ), + }, + }, + "required": ["course_id", "email_opt_in"], + }, + }, + {200: OpenApiResponse(description="The choice was recorded.")}, + ), + # Registering, signing in and resetting a password are described with + # the bodies of the addresses replacing them. Signing in by posting has + # no entry: its handler is described where it is defined, which is + # published in place of anything named here. + ("/api/user/v1/account/registration/", "GET"): _SupersededBodies( + None, authn_schemas.REGISTRATION_FORM_RESPONSES, + ), + ("/api/user/v1/account/registration/", "POST"): _SupersededBodies( + authn_schemas.REGISTRATION_REQUEST, authn_schemas.REGISTRATION_RESPONSES, + ), + ("/api/user/v2/account/registration/", "GET"): _SupersededBodies( + None, authn_schemas.REGISTRATION_FORM_RESPONSES, + ), + ("/api/user/v2/account/registration/", "POST"): _SupersededBodies( + authn_schemas.REGISTRATION_REQUEST, authn_schemas.REGISTRATION_RESPONSES, + ), + ("/api/user/v1/account/password_reset/", "GET"): _SupersededBodies( + None, authn_schemas.PASSWORD_RESET_FORM_RESPONSES, + ), + ("/api/user/v1/account/password_reset/token/validate/", "POST"): _SupersededBodies( + authn_schemas.TOKEN_VALIDATION_REQUEST, authn_schemas.TOKEN_VALIDATION_RESPONSES, + ), + ("/api/user/v1/validation/registration", "POST"): _SupersededBodies( + authn_schemas.REGISTRATION_VALIDATION_REQUEST, authn_schemas.REGISTRATION_VALIDATION_RESPONSES, + ), + ("/api/user/{api_version}/account/login_session/", "GET"): _SupersededBodies( + None, authn_schemas.LOGIN_FORM_RESPONSES, + ), + } + # Parameter names are spelled differently at different points of + # generation, so an address is keyed by its shape alone. + return { + (_PATH_PARAMETER.sub("{}", path), method): bodies + for (path, method), bodies in described.items() + } + + +def _names_what_it_needs(shape): + """ + Say whether a body written out as a shape names something it cannot be sent without. + + A shape is published as it is written rather than built from a + serializer's fields, so nothing has judged whether the operation can be + called with no body at all. One that requires a member, or that is a list + whose members require one, cannot be satisfied by an absent body. + """ + if not isinstance(shape, dict): + return False + if shape.get("required"): + return True + return _names_what_it_needs(shape.get("items")) + + +class SupersededUserOperations: + """ + Keep colliding addresses distinct, group each operation under its own area, + and describe the superseded user addresses wherever a service publishes + them, marking a body the operation cannot be called without as one it + needs and publishing a body read whole as the whole shape the operation + reads. + + Both services mount that API at the same addresses and both publish an + unauthenticated document of everything they serve, so what one of those + documents says about one of these addresses is what the other has to say + about it as well: a client generated from either reads the same body on + each side of the exchange, branches on the same success code, and imports + the same name. The behaviour is shared rather than restated in each + service's schema class, which is what keeps the two documents a deployment + publishes from describing one address two ways. + + Every address ``RENAMED_OPERATION_IDS`` does not name keeps the id the + generator derives for it, because a client generated from a document takes + a module from each operationId, so renaming one drops what a consumer + already imports. + """ + + def get_operation_id(self): + """Keep the name an address is published under where describing its bodies would move it.""" + operation_id = super().get_operation_id() + return RENAMED_OPERATION_IDS.get((self.path, self.method, operation_id), operation_id) + + def _get_request_body(self, direction="request"): + """ + Publish a body as one the operation needs when any form it may be sent in is. + + An operation that names the media types it reads loses that mark as + soon as one of them is the bare body of a file, which carries no field + that can be called required, or as soon as one of them is named as a + shape rather than by a serializer, which is published as it is written + and so is read for what it says it needs. The forms are alternatives + rather than parts of one request, so a body that is required in any of + them is a body the operation cannot be called without. + + An operation whose body is described in place carries no such field + either, in any of its forms, so the ones whose handler refuses a + request that carries no body are named above and marked from there. + """ + body = super()._get_request_body(direction) + if not body: + return body + if self._operation_shape() in REQUIRED_REQUEST_BODIES: + body["required"] = True + return body + shapes = self.get_request_serializer() + if body.get("required") or not isinstance(shapes, dict): + return body + if any(self._shape_is_needed(shape, direction) for shape in shapes.values()): + body["required"] = True + return body + + def _shape_is_needed(self, shape, direction): + """Say whether a body sent in one media type is one the operation cannot be called without.""" + return _names_what_it_needs(shape) or self._get_request_for_media_type(shape, direction)[1] + + def _get_request_for_media_type(self, serializer, direction="request"): + """ + Keep a body the operation reads whole whole, under a method read as a change set. + + A ``PATCH`` body usually carries the changes to make to a stored + representation, which leaves every field in it optional and publishes + the shape under a name of its own. A body that is an instruction + instead -- read whole by the operation, which refuses it when a field + the instruction cannot be carried out without is missing -- is not + that, and publishing it as a change set would promise a caller a + request the operation refuses. + """ + if self.method == "PATCH" and getattr(force_instance(serializer), WHOLE_BODY_ATTRIBUTE, False): + with _generation_setting("COMPONENT_SPLIT_PATCH", False): + return super()._get_request_for_media_type(serializer, direction) + return super()._get_request_for_media_type(serializer, direction) + + def get_request_serializer(self): + """Return the body this operation reads, named here for the addresses this one supersedes.""" + bodies = self._superseded_bodies() + return super().get_request_serializer() if bodies is None else bodies.request + + def get_response_serializers(self): + """Return the body this operation answers with, named here for the addresses this one supersedes.""" + bodies = self._superseded_bodies() + return super().get_response_serializers() if bodies is None else bodies.response + + def _superseded_bodies(self): + """Return the bodies named for this operation, or ``None`` where none are named.""" + return superseded_bodies().get(self._operation_shape()) + + def _operation_shape(self): + """This operation as the tables above key one: its address without parameter names, and its method.""" + return (_PATH_PARAMETER.sub("{}", self.path), self.method) + + +def mark_superseded_user_paths(result, generator, request, public): # pylint: disable=unused-argument + """Post-processing hook: mark every address a conforming one replaces ``deprecated: true``.""" + for path, path_item in result.get("paths", {}).items(): + if path not in SUPERSEDED_USER_PATHS: + continue + for operation in path_item.values(): + if isinstance(operation, dict): + operation["deprecated"] = True + return result diff --git a/openedx/core/djangoapps/user_api/schema_components.py b/openedx/core/djangoapps/user_api/schema_components.py new file mode 100644 index 000000000000..5d628b33218d --- /dev/null +++ b/openedx/core/djangoapps/user_api/schema_components.py @@ -0,0 +1,54 @@ +"""Parts of this API's documents that are written out rather than generated.""" + +# Nothing is imported in this module. A settings module reads it while the +# settings are still being assembled, which is too early for any module that +# reads a setting of its own. + +# The retirement-status model that a partial update of a version 1 retirement +# was published as reading. That operation now publishes the object its handler +# really reads, which leaves this model with no reference to generate it from, +# and a client generated from a document names a class after each model in it. +# The model is therefore named here, exactly as it was published, and appended +# by each service that serves this API, so the class keeps its name in every +# document either of them builds. +PATCHED_USER_RETIREMENT_STATUS = { + 'type': 'object', + 'description': 'Perform serialization for the RetirementStatus model', + 'properties': { + 'id': {'type': 'integer', 'readOnly': True}, + 'user': {'allOf': [{'$ref': '#/components/schemas/RetirementUser'}], 'readOnly': True}, + 'current_state': {'allOf': [{'$ref': '#/components/schemas/RetirementState'}], 'readOnly': True}, + 'last_state': {'allOf': [{'$ref': '#/components/schemas/RetirementState'}], 'readOnly': True}, + 'created': {'type': 'string', 'format': 'date-time', 'readOnly': True}, + 'modified': {'type': 'string', 'format': 'date-time', 'readOnly': True}, + 'original_username': {'type': 'string', 'maxLength': 150}, + 'original_email': {'type': 'string', 'format': 'email', 'maxLength': 254}, + 'original_name': {'type': 'string', 'maxLength': 255}, + 'retired_username': {'type': 'string', 'maxLength': 150}, + 'retired_email': {'type': 'string', 'format': 'email', 'maxLength': 254}, + }, +} + +#: Keyed by the name each component is published under, for a service to append. +KEPT_COMPONENT_SCHEMAS = { + 'PatchedUserRetirementStatus': PATCHED_USER_RETIREMENT_STATUS, +} + + +# How a caller sends each credential this API takes beside the session cookie. +# The generator describes the cookie by itself, from the class that reads it, +# and knows nothing of the CSRF token the sign-in names, so the token is +# written out here and appended by each service that serves this API. An +# operation naming a credential its document does not describe makes that +# document invalid, and a client generated from it has no way to send it. +CREDENTIAL_SCHEMES = { + 'csrf': { + 'type': 'apiKey', + 'name': 'X-CSRFToken', + 'in': 'header', + 'description': ( + 'The value of the `csrftoken` cookie the sign-in, registration and password reset forms set, ' + 'sent in this header along with that cookie.' + ), + }, +} 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/authn_schemas.py b/openedx/core/djangoapps/user_api/v2/authn_schemas.py new file mode 100644 index 000000000000..8e5248513835 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/authn_schemas.py @@ -0,0 +1,460 @@ +"""Published bodies of the registration, sign-in and password reset endpoints of version 2 of the User API.""" + +from drf_spectacular.utils import OpenApiResponse +from rest_framework.settings import api_settings + +FORM_MEDIA_TYPE = "application/x-www-form-urlencoded" +MULTIPART_MEDIA_TYPE = "multipart/form-data" + + +def read_as_a_form(shape): + """ + Name a shape under the two media types a handler reading the submitted form accepts. + + Such a handler reads the fields a form carries and nothing else, so a body + sent as JSON reaches it as an empty form and is refused for what it lacks. + """ + return {FORM_MEDIA_TYPE: shape, MULTIPART_MEDIA_TYPE: shape} + + +def read_in_every_form(shape): + """Name a shape under each media type a view that declares no parsers reads a body in.""" + return {parser.media_type: shape for parser in api_settings.DEFAULT_PARSER_CLASSES} + + +_TEXT = {"type": "string"} + +FORM_FIELD = { + "type": "object", + "description": "One input of the form.", + "properties": { + "name": {"type": "string", "description": "Name the value is submitted under."}, + "label": { + "type": "string", + "nullable": True, + "description": "Label to show beside the input, or null for an input the site adds that is given none.", + }, + "type": { + "type": "string", + "description": ( + "Kind of input, such as `text`, `email`, `password`, `select`, `textarea`, `checkbox` " + "or `plaintext`." + ), + }, + "defaultValue": { + "description": ( + "Value the input starts with: text, or true or false for a checkbox. Null for an input the site " + "adds that is given none." + ), + }, + "placeholder": { + "type": "string", + "nullable": True, + "description": ( + "Hint to show in the empty input. An input the site adds carries the value it starts with here, " + "or null where it is given none." + ), + }, + "instructions": {"type": "string", "description": "Help text to show with the input."}, + "exposed": {"type": "boolean", "description": "Whether to show the input although it is optional."}, + "required": {"type": "boolean", "description": "Whether the form cannot be submitted without a value."}, + "restrictions": { + "type": "object", + "description": "Limits the value is held to, such as `min_length` and `max_length`.", + }, + "errorMessages": { + "type": "object", + "additionalProperties": _TEXT, + "description": "Messages to show for a refused value, by the reason it was refused.", + }, + "supplementalLink": {"type": "string", "description": "Address of further help, or an empty string."}, + "supplementalText": {"type": "string", "description": "Text of the link to further help."}, + "loginIssueSupportLink": {"type": "string", "description": "Address of help with signing in."}, + "options": { + "type": "array", + "description": "Choices a `select` input offers. Absent from every other kind of input.", + "items": { + "type": "object", + "properties": { + "value": {"type": "string", "description": "Value submitted for this choice."}, + "name": {"type": "string", "description": "Text to show for this choice."}, + "default": {"type": "boolean", "description": "Whether this choice starts selected."}, + }, + "required": ["value", "name", "default"], + }, + }, + }, + "required": [ + "name", + "label", + "type", + "defaultValue", + "placeholder", + "instructions", + "exposed", + "required", + "restrictions", + "errorMessages", + "supplementalLink", + "supplementalText", + "loginIssueSupportLink", + ], +} + +FORM_DESCRIPTION = { + "type": "object", + "description": "A form a client builds its page from, field by field.", + "properties": { + "method": {"type": "string", "description": "HTTP method the form is submitted with."}, + "submit_url": {"type": "string", "description": "Address the form is submitted to."}, + "fields": {"type": "array", "items": FORM_FIELD, "description": "The inputs, in the order to show them."}, + }, + "required": ["method", "submit_url", "fields"], +} + +#: A body any DRF view answers a refused request with. +REFUSAL = { + "type": "object", + "properties": {"detail": {"type": "string", "description": "Why the request was refused."}}, + "required": ["detail"], +} + +#: Published by every operation here. Which representations an answer can be +#: given in is settled as the request enters, before the handler runs. +NOT_ACCEPTABLE = OpenApiResponse( + REFUSAL, + description="The `Accept` header of the request names no representation this endpoint can answer in.", +) + +#: Published by every operation here that reads a body, which is parsed before the handler reads it. +UNREADABLE_BODY = OpenApiResponse(REFUSAL, description="The request body is in a media type this endpoint cannot read.") + + +def or_unparsed(shape): + """ + Offer ``shape`` beside the body a request whose body cannot be parsed is refused with. + + The body is parsed when the handler first reads it, so one that cannot be + parsed in the media type it is sent as is refused with ``detail`` alone, + before the handler can answer with a body of its own. + """ + return {"oneOf": [shape, REFUSAL]} + +REGISTRATION_FORM_RESPONSES = { + 200: OpenApiResponse( + FORM_DESCRIPTION, + description=( + "The registration form. Which fields it lists, and which of them are required, depend on how the " + "site is configured." + ), + ), + 406: NOT_ACCEPTABLE, +} + +REGISTRATION_FIELDS = { + "type": "object", + "description": ( + "The fields of the registration form, each under its name. The fields listed as required are the ones " + "a site requires unless it is configured otherwise; the form description lists the ones this site asks " + "for, and every field it lists is accepted here. A registration carries a password, unless it completes " + "a sign-in through another provider, for which the server generates the password: the sign-in under way " + "in the caller's session, named in `social_auth_provider`, or one the client made with the provider " + "itself, named in `provider` with the access token the provider issued. Both ways apply only where the " + "site lets people sign in through other providers." + ), + "properties": { + "name": {"type": "string", "description": "Full name of the person registering."}, + "username": {"type": "string", "description": "Name to register the account under."}, + "email": {"type": "string", "description": "Email address of the account."}, + "confirm_email": { + "type": "string", + "description": "The email address again, where the site asks for it.", + }, + "password": { + "type": "string", + "description": ( + "Password of the account. Left out of a registration that completes a sign-in through another " + "provider, for which the server generates one and ignores any value sent." + ), + "format": "password", + }, + "social_auth_provider": { + "type": "string", + "description": ( + "Name of the provider of the sign-in under way in the caller's session, which the form lists " + "while one is under way. A registration naming one after that sign-in has expired is refused." + ), + }, + "provider": { + "type": "string", + "description": ( + "Backend of the provider the client signed the person in with itself, such as `google-oauth2`." + ), + }, + "access_token": { + "type": "string", + "description": "Access token that provider issued, through which the new account is linked to it.", + }, + "honor_code": {"type": "string", "description": "`true` once the site's terms are accepted."}, + "terms_of_service": { + "type": "string", + "description": "`true` once the terms of service are accepted. Taken from `honor_code` when absent.", + }, + "country": {"type": "string", "description": "Two-letter code of the country of residence."}, + }, + "required": ["name", "username", "email", "honor_code"], + "anyOf": [ + {"required": ["password"]}, + {"required": ["social_auth_provider"]}, + {"required": ["provider", "access_token"]}, + ], + "additionalProperties": _TEXT, +} + +REGISTRATION_REQUEST = read_as_a_form(REGISTRATION_FIELDS) + +REGISTERED = { + "type": "object", + "properties": { + "authenticated_user": { + "type": "object", + "description": "The account created, which the caller is now signed in as.", + "properties": { + "username": {"type": "string", "description": "Name the account was registered under."}, + "full_name": {"type": "string", "description": "Full name on the account's profile."}, + "user_id": {"type": "integer", "description": "Identifier of the account."}, + }, + "required": ["username", "full_name", "user_id"], + }, + "success": {"type": "boolean", "description": "Always true."}, + "redirect_url": {"type": "string", "description": "Where to send the person next."}, + }, + "required": ["authenticated_user", "success", "redirect_url"], +} + +REGISTRATION_REFUSAL = { + "type": "object", + "description": ( + "Each field that was refused, under its name, with the messages to show for it, and what the " + "refusal was." + ), + "properties": { + "error_code": { + "type": "string", + "description": ( + "What the refusal was, such as `validation-error`, `invalid-country`, `duplicate-email`, " + "`duplicate-username`, `duplicate-email-username` or `forbidden-request`." + ), + }, + "username_suggestions": { + "type": "array", + "items": _TEXT, + "description": "Usernames still free to register, offered when the one sent is taken.", + }, + }, + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": {"user_message": {"type": "string", "description": "Message to show for the field."}}, + "required": ["user_message"], + }, + }, +} + +REGISTRATION_RESPONSES = { + 200: OpenApiResponse(REGISTERED, description="The account was created and the caller is signed in as it."), + 400: OpenApiResponse( + or_unparsed(REGISTRATION_REFUSAL), + description=( + "A field is missing or not acceptable, the country is not one the site knows, or a sign-in through " + "another provider the registration relied on has expired. A body that cannot be parsed in the media " + "type it is sent as is refused with `detail` alone." + ), + ), + 403: OpenApiResponse( + REGISTRATION_REFUSAL, + description=( + "Too many requests to register were sent from the address the request came from, whether or not " + "they created an account (`error_code` is `forbidden-request`), or a step the site adds to " + "registration refused it. Where the site requires signing in through another provider, or creates " + "no accounts here, the refusal is an HTML page instead of this body." + ), + ), + 406: NOT_ACCEPTABLE, + 409: OpenApiResponse( + REGISTRATION_REFUSAL, + description="An account already has that email address or that username.", + ), + 415: UNREADABLE_BODY, +} + +PASSWORD_RESET_FORM_RESPONSES = { + 200: OpenApiResponse( + FORM_DESCRIPTION, + description="The form a password reset is asked for with, which asks for the email address of the account.", + ), + 406: NOT_ACCEPTABLE, +} + +TOKEN_VALIDATION_REQUEST = read_in_every_form({ + "type": "object", + "properties": { + "token": {"type": "string", "description": "The token the password reset email carries."}, + }, +}) + +TOKEN_VALIDATION_RESPONSES = { + 200: OpenApiResponse( + { + "type": "object", + "properties": {"is_valid": {"type": "boolean", "description": "Whether the token is valid."}}, + "required": ["is_valid"], + }, + description="Whether the token is valid. A token that cannot be read is answered as not valid.", + ), + 400: OpenApiResponse(REFUSAL, description="The body could not be parsed."), + 401: OpenApiResponse(REFUSAL, description="The credentials sent could not be read."), + 403: OpenApiResponse( + REFUSAL, + description=( + "A caller signed in with a session sent no valid CSRF token, or the JWT sent was issued to a " + "restricted application." + ), + ), + 406: NOT_ACCEPTABLE, + 415: UNREADABLE_BODY, + 429: OpenApiResponse(REFUSAL, description="Too many tokens were checked; `detail` says when to try again."), +} + +REGISTRATION_VALIDATION_REQUEST = read_in_every_form({ + "type": "object", + "description": "The values to check, each under the name of the field it would be registered in.", + "properties": { + "name": _TEXT, + "username": _TEXT, + "email": _TEXT, + "confirm_email": _TEXT, + "password": _TEXT, + "country": _TEXT, + "form_field_key": { + "type": "string", + "description": "Name of the one field to check, leaving the others sent unchecked.", + }, + "reset_password_page": { + "type": "string", + "description": "`true` where the password is checked for a password reset rather than a registration.", + }, + }, +}) + +REGISTRATION_VALIDATION_RESPONSES = { + 200: OpenApiResponse( + { + "type": "object", + "properties": { + "validation_decisions": { + "type": "object", + "additionalProperties": _TEXT, + "description": ( + "The decision on each field checked: an empty string where the value is acceptable, " + "otherwise the message to show." + ), + }, + "username_suggestions": { + "type": "array", + "items": _TEXT, + "description": "Usernames still free to register, offered for a taken username or a name.", + }, + }, + "required": ["validation_decisions"], + }, + description="The decision on each value sent.", + ), + 400: OpenApiResponse(REFUSAL, description="The body could not be parsed."), + 403: OpenApiResponse( + REFUSAL, description="Too many checks were sent from the address the request came from.", + ), + 406: NOT_ACCEPTABLE, + 415: UNREADABLE_BODY, +} + +LOGIN_FORM_RESPONSES = { + 200: OpenApiResponse(FORM_DESCRIPTION, description="The form the site's sign-in page is built from."), + 406: NOT_ACCEPTABLE, +} + +LOGIN_REQUEST = read_as_a_form({ + "type": "object", + "properties": { + "email_or_username": { + "type": "string", + "description": "The account to sign in as, by its email address or its username.", + }, + "password": {"type": "string", "description": "Password of the account.", "format": "password"}, + }, + "required": ["email_or_username", "password"], +}) + +LOGIN_REFUSAL = { + "type": "object", + "properties": { + "success": {"type": "boolean", "description": "Always false."}, + "value": {"type": "string", "description": "Message to show."}, + "error_code": {"type": "string", "description": "What the refusal was, where it has a name."}, + "email": {"type": "string", "nullable": True, "description": "The account the request named, if any."}, + "context": {"type": "object", "description": "Details of the refusal for the message, by name."}, + "redirect": {"type": "string", "description": "Where to send the person instead."}, + "redirect_url": { + "type": "string", + "nullable": True, + "description": ( + "Where to send the person instead, such as to change a password, or, for a caller signed in " + "although answered with a failure, where to send them next." + ), + }, + }, + "required": ["success"], +} + +LOGIN_RESPONSES = { + 200: OpenApiResponse( + { + "type": "object", + "properties": { + "success": {"type": "boolean", "description": "Always true."}, + "redirect_url": { + "type": "string", + "nullable": True, + "description": "Where to send the person next, or null to leave that to the client.", + }, + }, + "required": ["success", "redirect_url"], + }, + description="The caller is signed in.", + ), + 400: OpenApiResponse( + or_unparsed(LOGIN_REFUSAL), + description=( + "Signing in failed: the account or the password is wrong, the account is not active, a field is " + "missing, a step the site adds to signing in refused it, or signing in is refused for a while after " + "too many failed attempts. Where the site checks passwords against lists of exposed passwords, a " + "password found often enough has to be changed before signing in (`error_code` is " + "`require-password-change`), and one found less often is answered with `error_code` " + "`nudge-password-change` after the caller has been signed in: the session cookie is set, but not the " + "cookies a successful sign-in adds to it, such as `edxloggedin`. A body that cannot be parsed in the " + "media type it is sent as is refused with `detail` alone." + ), + ), + 403: OpenApiResponse( + # Unlike the refusals answered with 400, the one answered with this body always has a message and a name. + {**LOGIN_REFUSAL, "required": ["success", "value", "error_code"]}, + description=( + "The caller signed in through another provider with an account that is not linked to one here. " + "A request carrying no valid CSRF token, or a password sent where the site requires signing in " + "through another provider, is refused with an HTML page instead of this body." + ), + ), + 406: NOT_ACCEPTABLE, + 415: UNREADABLE_BODY, +} diff --git a/openedx/core/djangoapps/user_api/v2/authn_views.py b/openedx/core/djangoapps/user_api/v2/authn_views.py new file mode 100644 index 000000000000..b4eb1fcf77a4 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/authn_views.py @@ -0,0 +1,128 @@ +"""Registration, sign-in and password reset endpoints of version 2 of the User API.""" + +from drf_spectacular.utils import extend_schema, extend_schema_view +from rest_framework.permissions import AllowAny + +from openedx.core.djangoapps.user_authn.views import login, password_reset, register + +from . import authn_schemas + +# Each view below is the authentication app's own view, published under a +# description of its own and nothing else. A request is answered by the method +# that view defines, with every decorator on it, so the limits on how often a +# request may be sent are counted together with the app's older addresses. +# Those limits are counted per decorated method and per the name of the class +# it runs on: a method written here would start counting afresh, and so would +# a class renamed here, which is why each keeps the name of the one it extends. + + +@extend_schema_view( + get=extend_schema( + summary="Describe the registration form", + description=( + "Returns the fields an account is registered with on this site, in the order to show them, with the " + "label, kind, limits and choices of each, and the address the form is submitted to. Sets the CSRF " + "cookie." + ), + responses=authn_schemas.REGISTRATION_FORM_RESPONSES, + ), + post=extend_schema( + summary="Register an account", + description=( + "Creates an account from the fields the registration form lists and signs the caller in as it, " + "setting the session cookies. The body is read as a submitted form; a body sent as JSON is read as " + "an empty one. No CSRF token is needed, and a session the caller already holds is not consulted. " + "Refused once too many requests to register were sent from the address the request comes from, " + "whether or not they created an account." + ), + request=authn_schemas.REGISTRATION_REQUEST, + responses=authn_schemas.REGISTRATION_RESPONSES, + ), +) +class RegistrationView(register.RegistrationView): + """Describes the registration form and registers an account from it, for any caller.""" + + permission_classes = (AllowAny,) + + +@extend_schema_view( + get=extend_schema( + summary="Describe the password reset form", + description=( + "Returns the form a password reset is asked for with and the address it is submitted to. Sets the " + "CSRF cookie." + ), + responses=authn_schemas.PASSWORD_RESET_FORM_RESPONSES, + ), +) +class PasswordResetView(password_reset.PasswordResetView): + """Describes the form a password reset is asked for with, for any caller.""" + + permission_classes = (AllowAny,) + + +@extend_schema_view( + post=extend_schema( + summary="Check a password reset token", + description=( + "Says whether a token from a password reset email is still valid. A caller who is signed in is told " + "a token is valid only when it belongs to their own account. A valid token for an inactive account " + "activates the account. Callers who are not signed in may check only a few tokens in each period, " + "and a caller signed in with a session sends the CSRF token." + ), + request=authn_schemas.TOKEN_VALIDATION_REQUEST, + responses=authn_schemas.TOKEN_VALIDATION_RESPONSES, + ), +) +class PasswordResetTokenValidation(password_reset.PasswordResetTokenValidation): + """Says whether a password reset token is valid, to any caller.""" + + permission_classes = (AllowAny,) + + +@extend_schema_view( + post=extend_schema( + summary="Check registration values", + description=( + "Checks the values a person is about to register with and answers with a decision for each field " + "sent: an empty string where the value is acceptable, otherwise the message to show. Naming a field " + "in `form_field_key` checks that field alone. No CSRF token is needed. Refused once too many checks " + "were sent from the address the request comes from." + ), + request=authn_schemas.REGISTRATION_VALIDATION_REQUEST, + responses=authn_schemas.REGISTRATION_VALIDATION_RESPONSES, + ), +) +class RegistrationValidationView(register.RegistrationValidationView): + """Checks values a person is about to register with, for any caller.""" + + permission_classes = (AllowAny,) + + +@extend_schema_view( + get=extend_schema( + summary="Describe the sign-in form", + description=( + "Returns the form the site's sign-in page is built from and sets the CSRF cookie that signing in " + "needs." + ), + responses=authn_schemas.LOGIN_FORM_RESPONSES, + ), + post=extend_schema( + summary="Sign in", + description=( + "Signs the caller in with an account's password and sets the session cookies. The account is named " + "in `email_or_username`, by its email address or its username. The body is read as a submitted " + "form; a body sent as JSON is read as an empty one. The CSRF token is needed, in the `X-CSRFToken` " + "header or the `csrfmiddlewaretoken` field, together with the cookie the sign-in form sets. A " + "session the caller already holds is not consulted. After too many failed attempts, signing in is " + "refused for a while." + ), + request=authn_schemas.LOGIN_REQUEST, + responses=authn_schemas.LOGIN_RESPONSES, + ), +) +class LoginSessionView(login.LoginSessionView): + """Describes the sign-in form and signs a caller in, for any caller.""" + + permission_classes = (AllowAny,) diff --git a/openedx/core/djangoapps/user_api/v2/errors.py b/openedx/core/djangoapps/user_api/v2/errors.py new file mode 100644 index 000000000000..39ca5fe252d3 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/errors.py @@ -0,0 +1,202 @@ +"""Error types this version of the User API classifies for itself.""" + +from edx_rest_framework_extensions.errors import Conflict, register_error_type +from rest_framework.exceptions import ( + MethodNotAllowed, + NotAcceptable, + NotFound, + ParseError, + UnsupportedMediaType, + ValidationError, +) +from rest_framework.settings import api_settings + + +class RefusedMethod(MethodNotAllowed): + """A request method this API does not serve at the address it was sent to.""" + + +class UnsatisfiableAccept(NotAcceptable): + """An ``Accept`` header naming no representation this API can produce.""" + + +class UnreadableRepresentation(UnsupportedMediaType): + """A request body in a media type this API cannot read.""" + + +class MalformedBody(ParseError): + """A request body that is not well formed in the media type it was sent as.""" + + +class RetiredAccount(NotFound): + """ + An account that has been retired, addressed by something it used to hold. + + The account is gone, so the answer is the same as for an address that was + never held; what is left of it is the retirement record, which the members + below name so that a caller able to reverse a retirement can find it. + """ + + def __init__(self, detail=None, can_cancel_retirement=False, retirement_id=None): + super().__init__(detail) + self.extension_members = { + "can_cancel_retirement": can_cancel_retirement, + "retirement_id": retirement_id, + } + + +class _NamesTheRecordsAtFault: + """ + Carries the records a refusal is about, each with what was wrong with it. + + A caller acting on several records at once learns which of them to look + at, rather than being told only that the request as a whole was refused. + Nothing is changed when any one record is refused. + """ + + def __init__(self, detail=None, errors=None): + super().__init__(detail) + if errors: + self.extension_members = {"errors": errors} + + +class RetirementStateConflict(_NamesTheRecordsAtFault, Conflict): + """ + An operation asked of a retirement whose state does not admit it. + + Which operation a retirement admits depends on how far through the + pipeline it is, and the order of those steps is configured by the + operator rather than fixed here. + """ + + +class AmbiguousAccountName(_NamesTheRecordsAtFault, ValidationError): + """ + An address or a name answered by more than one account, naming no one of them. + + The name an account was registered under is kept on the records of this + area without being unique to one of them, so a request naming such a name + reaches several records at once and acting on any of them would act on an + account the caller did not ask about. Nothing is done. The refusal is one + about what the request asked for, so it keeps the status of the refusals + beside it; what it adds is a name for this one condition, which a caller + can tell apart from a value it sent being wrong and can act on by naming + an account as it is stored. + """ + + +register_error_type(RefusedMethod, "method-not-allowed", "Method Not Allowed") +register_error_type(UnsatisfiableAccept, "not-acceptable", "Not Acceptable") +register_error_type(UnreadableRepresentation, "unsupported-media-type", "Unsupported Media Type") +register_error_type(MalformedBody, "malformed-body", "Malformed Request Body") +register_error_type(RetiredAccount, "user/retired-account", "Retired Account") +register_error_type(AmbiguousAccountName, "user/ambiguous-account-name", "Ambiguous Account Name") +register_error_type(RetirementStateConflict, "user/retirement-state-conflict", "Retirement State Conflict") + + +def refusal_sentence(refusals): + """ + Return one sentence naming what each parameter or property was refused for. + + A refusal that names nothing in particular is spelled on its own, so the + sentence reads the same whether or not the thing refused has a name. + """ + return " ".join( + reason if name == api_settings.NON_FIELD_ERRORS_KEY else f"{name}: {reason}" + for name, reasons in refusals.items() + for reason in reasons + ) + + +def _came_with_the_records_at_fault(exc): + """ + Whether this refusal arrived carrying the records at fault, and a line of its own. + + Such a refusal was raised with both: a sentence about the request as a + whole, and the record-by-record list that goes beside it. A refusal that + arrived with a line alone has its list derived from that line, and the + line written back from the list says the same thing. + """ + return isinstance(getattr(exc, "extension_members", {}).get("errors"), dict) + + +class ReadableErrorDetailMixin: + """ + Writes the one-line message of a refused request as a sentence. + + The standard error body carries a line for a person to read beside the + list of what each parameter or property was refused for, and where the + refusal names them that line was produced by printing the list itself. + Everything that puts the line in front of a person then shows the + brackets, the quoting and the class names that write a data structure + out: the message of the exception a generated client raises, a line in an + integration's log, the notification a page puts on the screen. The list is + left exactly as it was; only the line is rewritten, from the same values. + + A refusal that names the records at fault wrote that line itself, as a + sentence about the request as a whole -- that a set of records was left + alone, and nothing deleted -- which the list of records cannot be read to + say. That line is kept, and the records stay in the list beside it. + """ + + def handle_exception(self, exc): + """Answer with the standard body, its one-line message written in words.""" + response = super().handle_exception(exc) + if _came_with_the_records_at_fault(exc): + return response + body = getattr(response, "data", None) + if isinstance(body, dict) and isinstance(body.get("errors"), dict): + sentence = refusal_sentence(body["errors"]) + if sentence: + body["detail"] = sentence + return response + + +class CatalogedRequestErrorsMixin(ReadableErrorDetailMixin): + """ + Names the error type of a refused method, an unanswerable ``Accept`` + header, a body in a media type this API cannot read and a body that is not + well formed in the media type it was sent as. + + The shared catalog classifies none of them, so each would reach the caller + described as a server fault at a 4xx status. Raising this API's own + subclasses of them names each error without changing how the same failure + is classified for any other API in the process; once the shared catalog + covers them, the subclasses can give way to it. + + A view that classifies its request failures also wants the one-line + message of each of them to read as a sentence, so this carries what the + class it extends does as well, and a view names one mixin rather than two. + """ + + def handle_exception(self, exc): + """Classify the request failures the shared catalog leaves unnamed.""" + if isinstance(exc, MethodNotAllowed) and not isinstance(exc, RefusedMethod): + exc = RefusedMethod(self.request.method, detail=exc.detail) + elif isinstance(exc, NotAcceptable) and not isinstance(exc, UnsatisfiableAccept): + exc = UnsatisfiableAccept(detail=exc.detail) + elif isinstance(exc, UnsupportedMediaType) and not isinstance(exc, UnreadableRepresentation): + exc = UnreadableRepresentation(self.request.content_type, detail=exc.detail) + elif isinstance(exc, ParseError) and not isinstance(exc, MalformedBody): + exc = MalformedBody(detail=exc.detail) + return super().handle_exception(exc) + + +class ErrorExtensionsMixin: + """ + Adds the members an exception declares to the error body it produces. + + The shared envelope describes a failure with a fixed set of members, so a + failure that has more to say about the resource -- what can still be done + about it, which record holds the rest -- has nowhere to put it. An error + body may carry members beside the standard ones, and the ones an exception + declares are put there. + """ + + def handle_exception(self, exc): + """Answer with the standard body, extended by the members the exception declares.""" + response = super().handle_exception(exc) + members = getattr(exc, "extension_members", None) + if members and isinstance(getattr(response, "data", None), dict): + response.data.update(members) + return response diff --git a/openedx/core/djangoapps/user_api/v2/filters.py b/openedx/core/djangoapps/user_api/v2/filters.py new file mode 100644 index 000000000000..ec905bad726e --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/filters.py @@ -0,0 +1,298 @@ +"""Filter sets for version 2 of the User API.""" + +import datetime +import logging +from zoneinfo import ZoneInfo + +from django import forms +from django.contrib.auth.models import User # pylint: disable=imported-auth-user +from django.core.exceptions import ValidationError as FormValidationError +from django_filters import rest_framework as filters +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey +from rest_framework.exceptions import ValidationError +from rest_framework.filters import OrderingFilter + +from openedx.core.djangoapps.user_api.models import RetirementState, UserPreference, UserRetirementStatus + +from .view_utils import names_in + +log = logging.getLogger(__name__) + + +class StrictOrderingFilter(OrderingFilter): + """ + Refuses a sort field the view does not offer, and completes the sort it accepts. + + The default backend drops an unusable field silently and answers 200 in + the default order, which a caller reads as a page sorted the way it asked. + It also replaces the view's own order rather than adding to it, so a sort + on a column several rows share leaves their relative order to the database + and the pages of one listing need not agree on it; the fields named by the + view's ``ordering_tiebreak`` are appended to settle those rows. + """ + + def get_ordering(self, request, queryset, view): + """Return the requested sort, followed by the fields that make it total.""" + ordering = super().get_ordering(request, queryset, view) + if not ordering: + return ordering + chosen = {field.lstrip("-") for field in ordering} + tiebreak = [field for field in getattr(view, "ordering_tiebreak", ()) if field not in chosen] + return [*ordering, *tiebreak] + + def remove_invalid_fields(self, queryset, fields, view, request): + """Return the requested sort fields, refusing the request when one is not offered.""" + allowed = super().remove_invalid_fields(queryset, fields, view, request) + rejected = [field for field in fields if field not in allowed] + if rejected: + log.info("Sort refused on %s: %r", type(view).__name__, rejected) + offered = sorted( + name for name, _label in self.get_valid_fields(queryset, view, {"request": request}) + ) + raise ValidationError({ + "ordering": [ + f"Sort by any of: {', '.join(offered)}, each optionally prefixed with '-'.", + ], + }) + return allowed + + +class _RepeatedValuesWidget(forms.TextInput): + """ + Reads every value sent under one parameter name, not only the last of them. + + A query string names several values either as one comma-separated list or + by repeating the parameter, and both idioms are in use by the callers of + this area. The default widget keeps the last value sent and drops the + rest, so a caller naming several by repetition is answered with the rows + of one of them and told nothing about the others. + """ + + def value_from_datadict(self, data, files, name): + """Return every value sent under this name, as the list the filter reads.""" + if not hasattr(data, "getlist"): + return super().value_from_datadict(data, files, name) + sent = data.getlist(name) + return ",".join(sent) if sent else None + + +class _BooleanQueryField(forms.NullBooleanField): + """Reads a boolean out of the query string, refusing a value that names neither side.""" + + def to_python(self, value): + """Return the boolean this value names, or refuse the request.""" + if value in self.empty_values: + return None + parsed = super().to_python(value) + if parsed is None: + raise FormValidationError("Enter true or false.", code="invalid") + return parsed + + +class StrictBooleanFilter(filters.BooleanFilter): + """ + Refuses a value that names neither true nor false instead of ignoring it. + + The default field reads an unrecognised value as "no preference" and + answers 200 with the filter dropped, handing the caller the rows it asked + to leave out, which is the opposite of what it asked for. + """ + + field_class = _BooleanQueryField + + def __init__(self, *args, **kwargs): + """Pass the raw value through to the field, which is what judges it.""" + # The default widget maps anything it does not recognise to "no + # preference" before the field ever sees it; a text widget hands the + # value over untouched. + kwargs.setdefault("widget", forms.TextInput) + super().__init__(*args, **kwargs) + + +class UserFilterSet(filters.FilterSet): + """ + Narrows the user directory. + + ``forum_role`` and ``course_key`` name a role within one course and are + only meaningful together, so either on its own is rejected. A role that no + course has defined matches no users and is not created. + """ + + username = filters.CharFilter( + method="filter_usernames", + help_text=( + "Comma-separated usernames. Only these users are returned, and a name no user holds " + "is left out of the page rather than refused." + ), + ) + email = filters.CharFilter(help_text="Return the user with exactly this email address.") + is_active = StrictBooleanFilter( + help_text="Return only users whose account is active, or only inactive ones. true or false.", + ) + preference_key = filters.CharFilter( + method="filter_preference_key", + help_text="Return only users who have stored a preference with this key.", + ) + forum_role = filters.CharFilter( + method="filter_forum_role", + help_text="Return only users holding this course forum role. Requires course_key.", + ) + course_key = filters.CharFilter( + method="filter_course_key", + help_text=( + "Course the forum role belongs to, percent-encoded, for example " + "course-v1:edX%2BDemoX%2BDemo. A plus sign that is not encoded arrives as a " + "space and the key is refused. Requires forum_role." + ), + ) + + class Meta: + model = User + fields = ("username", "email", "is_active", "preference_key", "forum_role", "course_key") + + def filter_queryset(self, queryset): + """Reject a forum role without its course, or a course without its role, before filtering.""" + cleaned = self.form.cleaned_data + forum_role = cleaned.get("forum_role") + course_key = cleaned.get("course_key") + if forum_role and not course_key: + raise ValidationError({"course_key": ["This parameter is required when forum_role is supplied."]}) + if course_key and not forum_role: + raise ValidationError({"forum_role": ["This parameter is required when course_key is supplied."]}) + return super().filter_queryset(queryset) + + def filter_usernames(self, queryset, name, value): + """Keep the users the value names, which it carries as a comma-separated list.""" + return queryset.filter(**{f"{name}__in": names_in(value)}) + + def filter_preference_key(self, queryset, name, value): # pylint: disable=unused-argument + """Keep users who have stored a preference under ``value``.""" + return queryset.filter(preferences__key=value) + + def filter_forum_role(self, queryset, name, value): # pylint: disable=unused-argument + """ + Keep users holding the role ``value`` in the course named by ``course_key``. + + Nothing stops a course from carrying the same role under several rows, + and a user may hold any number of them, so the join is collapsed to one + entry per user. + """ + raw_course_key = self.form.cleaned_data.get("course_key") + try: + course_key = CourseKey.from_string(raw_course_key) + except InvalidKeyError as exc: + log.info("User directory filtered with an unparsable course key: %r", raw_course_key) + raise ValidationError({"course_key": ["Enter a valid course key."]}) from exc + return queryset.filter(roles__name=value, roles__course_id=course_key).distinct() + + def filter_course_key(self, queryset, name, value): # pylint: disable=unused-argument + """Narrowing happens in ``filter_forum_role``, which needs both values at once.""" + return queryset + + +class UserPreferenceFilterSet(filters.FilterSet): + """ + Narrows the stored-preference list by preference name or by owner. + + An owner is named by username: the list is keyed the way the addresses of + its members are, and publishes no database identifier for a caller to + filter on. + """ + + key = filters.CharFilter(help_text="Return only preferences stored under exactly this key.") + username = filters.CharFilter( + field_name="user__username", + method="filter_usernames", + help_text=( + "Comma-separated usernames. Only these users' preferences are returned, and a name " + "no user holds is left out of the page rather than refused." + ), + ) + + class Meta: + model = UserPreference + fields = ("key", "username") + + def filter_usernames(self, queryset, name, value): + """Keep the preferences of the users the value names, read as a comma-separated list.""" + return queryset.filter(**{f"{name}__in": names_in(value)}) + + +class _WholeNumberFilter(filters.NumberFilter): + """Reads a count of whole units, refusing a fraction.""" + + field_class = forms.IntegerField + + +class RetirementFilterSet(filters.FilterSet): + """ + Narrows the retirement pipeline's queue. + + ``cool_off_days`` is kept as a filter of its own rather than expressed as + a point in time, because it is the operator concept the pipeline is + configured in and a caller computing the equivalent timestamp would have + to agree with this server's clock to the second. The two date bounds say + which end they include, which the pair they replace left to the reader. + ``state`` takes several steps sent either as one comma-separated list or + as the parameter repeated, because the list that named them before this + one read them as repeated parameters. + """ + + state = filters.CharFilter( + method="filter_states", + widget=_RepeatedValuesWidget, + help_text=( + "Names of pipeline steps, as one comma-separated list or by repeating the parameter " + "once per step. Only retirements standing at one of them are returned, and a name no " + "configured step holds is refused." + ), + ) + cool_off_days = _WholeNumberFilter( + method="filter_cool_off_days", + min_value=0, + help_text=( + "Return only retirements requested at least this many days ago. The pipeline uses " + "this to leave a learner time to change their mind." + ), + ) + created_after = filters.DateTimeFilter( + field_name="created", + lookup_expr="gte", + help_text=( + "Return only retirements requested at or after this moment, as a date or a date and " + "time. A date alone means the start of that day." + ), + ) + created_before = filters.DateTimeFilter( + field_name="created", + lookup_expr="lt", + help_text=( + "Return only retirements requested before this moment, as a date or a date and time. " + "A date alone means the start of that day, so that day is left out." + ), + ) + + class Meta: + model = UserRetirementStatus + fields = ("state", "cool_off_days", "created_after", "created_before") + + def filter_states(self, queryset, name, value): # pylint: disable=unused-argument + """Keep the retirements standing at one of the steps the value names.""" + names = names_in(value) + configured = set(RetirementState.get_state_names_list()) + unknown = [name for name in names if name not in configured] + if unknown: + log.info("Retirement queue filtered by unconfigured steps: %r", unknown) + raise ValidationError({ + "state": [ + f"No such pipeline step: {', '.join(unknown)}. " + f"This deployment runs: {', '.join(sorted(configured))}.", + ], + }) + return queryset.filter(current_state__state_name__in=names) + + def filter_cool_off_days(self, queryset, name, value): # pylint: disable=unused-argument + """Keep the retirements requested at least ``value`` days ago.""" + requested_before = datetime.datetime.now(ZoneInfo("UTC")) - datetime.timedelta(days=value) + return queryset.filter(created__lt=requested_before) diff --git a/openedx/core/djangoapps/user_api/v2/paginators.py b/openedx/core/djangoapps/user_api/v2/paginators.py new file mode 100644 index 000000000000..8251e1c3f52d --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/paginators.py @@ -0,0 +1,92 @@ +"""Pagination for version 2 of the User API.""" + +import logging + +from edx_rest_framework_extensions.paginators import DefaultPagination +from rest_framework.exceptions import ValidationError + +log = logging.getLogger(__name__) + + +class UserApiPagination(DefaultPagination): + """ + Page-number pagination whose published schema matches the body it returns, + and which refuses a number of rows no page of it can carry. + + ``DefaultPagination`` answers with ``count``, ``num_pages``, + ``current_page``, ``start``, ``next``, ``previous`` and ``results`` but + inherits the four-member schema of DRF's ``PageNumberPagination``, so a + generated client would not know about the three page-position members. + The response schema below only restates them; the body is unchanged. + """ + + def get_page_size(self, request): + """ + Return how many rows a page carries, refusing a number no page can have. + + The number asked for is otherwise dropped where it cannot be honoured + and the default page answered in its place, which a caller reads as + the page it asked for; worse, the links on that page carry the value + back, so a client that follows them keeps sending a number that is + never read. A number above what a page carries is still answered with + as many rows as it carries, because asking for more rows than a page + holds is asking for a full one. + """ + asked = request.query_params.get(self.page_size_query_param) + if asked is None or asked == "": + return self.page_size + try: + rows = int(asked) + except ValueError: + rows = 0 + if rows < 1: + log.info("Page size refused at %s: %r", request.path, asked) + raise ValidationError({ + self.page_size_query_param: [ + "Enter a whole number of rows of at least 1. A larger number than " + f"{self.max_page_size} returns {self.max_page_size} rows.", + ], + }) + return min(rows, self.max_page_size) + + def get_paginated_response_schema(self, schema): + """Describe the full envelope the paginator returns.""" + return { + "type": "object", + "required": ["count", "num_pages", "current_page", "start", "results"], + "properties": { + "count": { + "type": "integer", + "example": 123, + "description": "Total number of items across every page.", + }, + "num_pages": { + "type": "integer", + "example": 13, + "description": "Total number of pages.", + }, + "current_page": { + "type": "integer", + "example": 2, + "description": "Number of the page in this response, counting from one.", + }, + "start": { + "type": "integer", + "example": 10, + "description": "Index of the first item of this page within the full result set.", + }, + "next": { + "type": "string", + "nullable": True, + "format": "uri", + "description": "URL of the next page, or null on the last page.", + }, + "previous": { + "type": "string", + "nullable": True, + "format": "uri", + "description": "URL of the previous page, or null on the first page.", + }, + "results": schema, + }, + } diff --git a/openedx/core/djangoapps/user_api/v2/permissions.py b/openedx/core/djangoapps/user_api/v2/permissions.py new file mode 100644 index 000000000000..33fd8d8c75a3 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/permissions.py @@ -0,0 +1,79 @@ +"""Permission classes for version 2 of the User API.""" + +from rest_framework.exceptions import NotFound +from rest_framework.permissions import BasePermission + + +class IsActiveUser(BasePermission): + """ + Admits only a caller whose account is still active. + + Neither the authenticated check nor the administrator check reads + ``is_active``, so a view whose answer turns on the state of the caller's + own account declares this class alongside them. edx-drf-extensions ships + no equivalent class, so this one stands in for it. + """ + + def has_permission(self, request, view): + """Return whether the caller's account is still active.""" + return bool(request.user and request.user.is_active) + + +#: Query parameters that pick an account out by something about its owner +#: that the owner has not published. +ACCOUNT_CONTACT_SELECTORS = ("email", "lms_user_id") + + +class CanSelectAccountsByContact(BasePermission): + """ + Admits a caller selecting accounts by an owner's address or identifier while their account is active. + + An account selected by the email address or the numeric identifier of its + owner is reached without its owner having published either, which is a + privilege, so it is refused to a caller whose own account is no longer + active. A request that selects accounts no such way is admitted whatever + state the caller's account is in, because a user reaches their own account + before they have confirmed its address. + """ + + def has_permission(self, request, view): + """Return whether this request selects no account by contact, or comes from an active account.""" + if all(request.query_params.get(name) is None for name in ACCOUNT_CONTACT_SELECTORS): + return True + return bool(request.user and request.user.is_active) + + +class IsAddressedUser(BasePermission): + """ + Admits a caller whose username is exactly the one the address carries. + + The handlers behind this class select a record by the username in the + address, so that is the value compared here, character for character, and + the address is the only place a username is read from. Where usernames are + stored case-sensitively, two names differing in case alone are two + different names, so the comparison is exact. + """ + + def has_permission(self, request, view): + """Return whether the address names this caller, and nobody else.""" + user = request.user + if not (user and user.is_authenticated): + return False + return getattr(view, "kwargs", {}).get("username") == user.username + + +class IsStaffOrAbsent(BasePermission): + """ + Admits a platform administrator and reports the address as absent to anyone else. + + The endpoints this guards answer questions about other people's accounts. + Refusing a caller who may not ask them with a refusal would confirm that + the address exists and what it is for, so they are told instead that there + is nothing at it. + """ + + def has_permission(self, request, view): + """Admit an administrator, and report the address as absent to every other caller.""" + if request.user and request.user.is_staff: + return True + raise NotFound diff --git a/openedx/core/djangoapps/user_api/v2/schema.py b/openedx/core/djangoapps/user_api/v2/schema.py new file mode 100644 index 000000000000..1db961213b16 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/schema.py @@ -0,0 +1,31 @@ +"""Schema helpers for version 2 of the User API.""" + +from drf_spectacular.serializers import PolymorphicProxySerializerExtension +from drf_spectacular.utils import PolymorphicProxySerializer + + +class ResponseShapes(PolymorphicProxySerializer): + """ + The set of bodies one operation returns for the same status code. + + An endpoint whose caller can reduce the response answers with the default + object, with a preset, or with a selection of properties. A reduced body + satisfies more than one of those descriptions, so they are published as + alternatives a body may match in any combination. + """ + + def __init__(self, component_name, serializers, **kwargs): + # The generator re-instantiates a declaration from the arguments it + # retained, so the discriminator is only supplied when it is missing. + kwargs.setdefault("resource_type_field_name", None) + super().__init__(component_name=component_name, serializers=serializers, **kwargs) + + +class ResponseShapesExtension(PolymorphicProxySerializerExtension): + """Publishes the alternatives of a ``ResponseShapes`` declaration.""" + + target_class = ResponseShapes + + def map_serializer(self, auto_schema, direction): + """Return the alternatives as a set a body may match more than one of.""" + return {"anyOf": super().map_serializer(auto_schema, direction)["oneOf"]} diff --git a/openedx/core/djangoapps/user_api/v2/selectors.py b/openedx/core/djangoapps/user_api/v2/selectors.py new file mode 100644 index 000000000000..2d7567b9f48f --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/selectors.py @@ -0,0 +1,176 @@ +"""Selections over the user records version 2 of the User API answers from.""" + +import datetime +from zoneinfo import ZoneInfo + +from django.conf import settings +from django.contrib.auth.models import User # pylint: disable=imported-auth-user + +from common.djangoapps.student.models import get_potentially_retired_user_by_username +from openedx.core.djangoapps.user_api.models import ( + UserRetirementPartnerReportingStatus, + UserRetirementStatus, +) + + +def accounts_holding_emails(emails): + """Return the accounts holding any of these email addresses, by name.""" + return User.objects.filter(email__in=emails).order_by("username") + + +def accounts_named(name): + """ + Return the accounts this name addresses. + + The database settles which stored names an address answers to, and it need + not be only the one written exactly as the address writes it, so a name + can bring back more than one account. + """ + return list(User.objects.filter(username=name)) + + +def usernames_among(names, ordering): + """ + Return the names these accounts are stored under, in the order asked for. + + The accounts an address or a query string names are the only ones the + answer carries, and a name no account holds matches nothing. The order is + settled here because it decides which rows fall on which page, and the + database compares and sorts the stored names: an account can therefore + come back under a spelling other than the one asked for. + """ + return ( + User.objects + .filter(username__in=names) + .order_by(ordering) + .values_list("username", flat=True) + ) + + +def usernames_holding_email(email): + """ + Return the names of the accounts holding this address. + + Nothing keeps one email address to one account, so an address can be held + by more than one of them. Every account holding it is named, and nothing + is named for an address no account holds. + """ + return list(accounts_holding_emails([email]).values_list("username", flat=True)) + + +def username_holding_id(identifier): + """Return the name of the account holding this numeric identifier, or ``None``.""" + try: + return User.objects.get(id=identifier).username + except User.DoesNotExist: + return None + + +def cancellable_retirement_for_email(email): + """ + Return the retirement of the account that held this address, while it can still be cancelled. + + A retirement is cancellable for as long as the platform's cooling-off + period runs, so a retirement older than that is not reported here: the + address is gone either way, and only a retirement inside the period can be + undone by asking the platform to undo it. + + One address can have been held and left more than once, so more than one + retirement of it can sit inside the period. The most recent is the one + reported, being the only one still open to being undone. This is the one + place where the answer differs from the superseded version, which reads + the same window with a lookup that takes a single record and so fails + outright on the second. + """ + now = datetime.datetime.now(ZoneInfo("UTC")) + return ( + UserRetirementStatus.objects + .filter( + created__gt=now - datetime.timedelta(days=settings.COOL_OFF_DAYS), + created__lt=now, + original_email=email, + ) + .order_by("-created") + .first() + ) + + +def retirements(): + """Return every retirement, with the account and the steps a retirement record reports.""" + return UserRetirementStatus.objects.select_related( + "user", "user__profile", "current_state", "last_state", + ) + + +def retirement_of_the_account_named(records, username): + """ + Return the retirement of the account this name addresses, or ``None``. + + A retired account is stored under a hashed name, so the name a caller + holds may be either the one the account was registered under or the one it + was moved to; both address the same account and so the same retirement. + The record is taken from the selection given rather than from every record + held, so a record outside it is reported as absent. + """ + try: + user = get_potentially_retired_user_by_username(username) + except User.DoesNotExist: + return None + return records.filter(user=user).first() + + +def _grouped_by_requested_name(rows, usernames): + """ + Return these rows under the name each was requested with, every row kept. + + Stored names are compared without regard to case in some deployments, + which once let a learner register a retired name again by changing its + capitalisation, so the rows the database offers are compared again here + character for character. The name a row keeps of the account it is for is + indexed but not unique, so a name may carry more than one row; every one + of them is returned, because a caller that acted on one of several would + act on a record it did not ask about. A name no row was requested under is + absent from the result. + """ + asked_for = set(usernames) + grouped = {} + for row in rows: + if row.original_username in asked_for: + grouped.setdefault(row.original_username, []).append(row) + return grouped + + +def retirements_requested_as(records, usernames): + """Return the records of this selection requested under exactly these names, grouped by name.""" + return _grouped_by_requested_name( + records.filter(original_username__in=usernames), usernames, + ) + + +def unclaimed_partner_reports(): + """ + Return the partner reports no caller has taken for processing yet. + + The organizations of each are read from the user's enrolments, which are + fetched with the rows so that the cost of the answer does not grow with + the number of rows in it. + """ + return ( + UserRetirementPartnerReportingStatus.objects + .filter(is_being_processed=False) + .select_related("user") + .prefetch_related("user__courseenrollment_set") + .order_by("id") + ) + + +def partner_reports(): + """Return every partner report entry, whether or not it has been taken for processing.""" + return UserRetirementPartnerReportingStatus.objects.all() + + +def partner_reports_requested_as(entries, usernames): + """Return the entries of this selection registered under exactly these names, grouped by name.""" + return _grouped_by_requested_name( + entries.filter(original_username__in=usernames), usernames, + ) diff --git a/openedx/core/djangoapps/user_api/v2/serializers.py b/openedx/core/djangoapps/user_api/v2/serializers.py new file mode 100644 index 000000000000..7c1b369730e7 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/serializers.py @@ -0,0 +1,1395 @@ +"""Serializers for version 2 of the User API.""" + +import re + +from django.contrib.auth.models import User # pylint: disable=imported-auth-user +from django.urls import reverse +from drf_spectacular.utils import extend_schema_field +from edx_rest_framework_extensions.errors import ErrorResponseSerializer +from rest_framework import serializers +from rest_framework.fields import empty +from rest_framework.settings import api_settings +from rest_framework.validators import ProhibitSurrogateCharactersValidator + +from lms.djangoapps.verify_student.models import ManualVerification, SoftwareSecurePhotoVerification +from openedx.core.djangoapps.user_api.models import RetirementState, UserOrgTag, UserPreference +from openedx.core.djangoapps.user_authn.views.registration_form import contains_html, contains_url +from openedx.core.lib.api.serializers import CourseKeyField + +from .paginators import UserApiPagination + +#: Value of the ``view`` query parameter that embeds the owning user in each preference. +FULL_VIEW = "full" + +#: Value of the ``view`` query parameter that reduces a user to its identifying fields. +MINIMAL_VIEW = "minimal" + +#: Properties the ``minimal`` preset keeps of a directory entry. +USER_MINIMAL_FIELDS = ("id", "username", "email") + +#: Properties the ``minimal`` preset keeps of a stored preference. +PREFERENCE_MINIMAL_FIELDS = ("username", "key", "value") + +#: Context member naming the users whose whole directory entry a response may +#: carry, by id. A directory entry belongs to the user collection, so which +#: entries a caller may be given is that collection's decision; the view +#: settles it for a whole page and leaves the answer here, so that reading it +#: costs no query per row. +EMBEDDABLE_OWNER_IDS = "embeddable_owner_ids" + + +class ErrorResponse(ErrorResponseSerializer): # pylint: disable=abstract-method + """ + The body of every unsuccessful response. + + ``type`` is a URI naming the kind of failure, ``title`` names it in one + line, ``status`` repeats the HTTP status code and ``detail`` explains what + happened; ``errors`` lists the per-field messages of a rejected parameter. + """ + + +class RetiredAccountErrorResponse(ErrorResponse): # pylint: disable=abstract-method + """ + The body of a refusal that also reports what is left of a retired account. + + The two members below are carried only when the account was selected by an + email address it held before being retired; every other refusal at this + status carries the standard members alone. + """ + + can_cancel_retirement = serializers.BooleanField( + required=False, + help_text=( + "Whether the retirement can still be reversed, which it can only while it is inside " + "the cooling-off period." + ), + ) + retirement_id = serializers.IntegerField( + required=False, + allow_null=True, + help_text=( + "Identifies the retirement to a caller able to reverse it, or null when there is " + "nothing left to reverse." + ), + ) + + +class _KeptFieldsMixin: + """ + Describes an object reduced to the properties named in ``kept_fields``. + + The response presets reduce an already serialized object, so a preset's + shape has a describing serializer of its own rather than a serializing one. + """ + + #: Names of the properties the preset returns. + kept_fields = () + + def get_fields(self): + """Return the declared fields narrowed to the ones the preset returns.""" + fields = super().get_fields() + return {name: field for name, field in fields.items() if name in self.kept_fields} + + +class _OptionalFieldsMixin: + """ + Describes an object whose every property is optional. + + A caller that selects properties with ``fields`` decides which ones come + back, so none of them is guaranteed to be present. + """ + + def get_fields(self): + """Return the declared fields, none of them mandatory.""" + fields = super().get_fields() + for field in fields.values(): + field.required = False + field.read_only = False + return fields + + +def full_view_requested(request): + """Return True when the caller asked for the embedded-user response preset.""" + if request is None: + return False + params = getattr(request, "query_params", None) + if params is None: + params = getattr(request, "GET", {}) + return params.get("view") == FULL_VIEW + + +class UserDirectorySerializer(serializers.ModelSerializer): + """A user of the platform directory, addressed by username.""" + + id = serializers.IntegerField( + read_only=True, + help_text="Database identifier of the user. Addresses in this API use the username instead.", + ) + url = serializers.HyperlinkedIdentityField( + view_name="user_v2:user_detail", + lookup_field="username", + help_text="Absolute URL of this user's directory entry.", + ) + email = serializers.EmailField(read_only=True, help_text="Registered email address of the user.") + username = serializers.CharField(read_only=True, help_text="Unique name the user is addressed by.") + name = serializers.SerializerMethodField( + help_text="Full name recorded on the user's profile, or null when the user has no profile.", + ) + preferences = serializers.SerializerMethodField( + help_text="The user's stored preferences, as a mapping of preference key to value.", + ) + + class Meta: + model = User + fields = ("id", "url", "email", "name", "username", "preferences") + read_only_fields = fields + + @extend_schema_field(serializers.CharField(allow_null=True)) + def get_name(self, user): + """Return the profile name, or None for a user who has no profile row.""" + profile = getattr(user, "profile", None) + return profile.name if profile is not None else None + + @extend_schema_field(serializers.DictField(child=serializers.CharField())) + def get_preferences(self, user): + """Return the user's preferences as a key-to-value mapping.""" + return UserPreference.get_all_preferences(user) + + +class UserDirectoryMinimalSerializer(_KeptFieldsMixin, UserDirectorySerializer): + """A directory entry reduced to the properties that identify the user.""" + + kept_fields = USER_MINIMAL_FIELDS + + +class UserDirectoryProjectionSerializer(_OptionalFieldsMixin, UserDirectorySerializer): + """A directory entry reduced to the properties the caller selected.""" + + +class UserPreferenceRecordSerializer(serializers.ModelSerializer): + """ + A stored preference, identified by the user that owns it and its key. + + ``user`` carries the owner's whole directory entry and is included only + when the caller asks for the ``full`` response preset and is one the + caller may be given; by default the owner appears as the ``username`` + string alone. + """ + + url = serializers.SerializerMethodField(help_text="Absolute URL of this preference.") + username = serializers.CharField( + source="user.username", + read_only=True, + help_text="Username of the user this preference belongs to.", + ) + key = serializers.CharField(read_only=True, help_text="Name of the preference.") + value = serializers.CharField(read_only=True, help_text="Stored value of the preference.") + user = serializers.SerializerMethodField( + help_text=( + "Directory entry of the owning user; null unless the full response preset is asked " + "for and the entry is one the caller may be given." + ), + ) + + class Meta: + model = UserPreference + fields = ("username", "key", "value", "url", "user") + read_only_fields = fields + + @extend_schema_field(serializers.URLField()) + def get_url(self, preference): + """Return the member address of this preference.""" + path = reverse( + "user_v2:user_preference_detail", + kwargs={"username": preference.user.username, "key": preference.key}, + ) + request = self.context.get("request") + return request.build_absolute_uri(path) if request is not None else path + + @extend_schema_field(UserDirectorySerializer(allow_null=True)) + def get_user(self, preference): + """Embed the owner's entry when the caller asked for the full preset and may be given it.""" + if not full_view_requested(self.context.get("request")): + return None + if preference.user_id not in (self.context.get(EMBEDDABLE_OWNER_IDS) or ()): + return None + return UserDirectorySerializer(preference.user, context=self.context).data + + +class UserPreferenceRecordMinimalSerializer(_KeptFieldsMixin, UserPreferenceRecordSerializer): + """A stored preference reduced to its owner, its name and its value.""" + + kept_fields = PREFERENCE_MINIMAL_FIELDS + + +class UserPreferenceRecordProjectionSerializer(_OptionalFieldsMixin, UserPreferenceRecordSerializer): + """A stored preference reduced to the properties the caller selected.""" + + +class TimeZoneSerializer(serializers.Serializer): # pylint: disable=abstract-method + """A time zone and the way it is displayed to a learner.""" + + time_zone = serializers.CharField(help_text="Name of the time zone, for example ``US/Pacific``.") + description = serializers.CharField( + help_text="Display form of the time zone, for example ``US/Pacific (PST, UTC-0800)``.", + ) + + +class TimeZoneQuerySerializer(serializers.Serializer): # pylint: disable=abstract-method + """Query parameters accepted by the time-zone list.""" + + country_code = serializers.RegexField( + r"^[A-Za-z]{2}$", + required=False, + # An empty value narrows nothing, the way an empty value of any other + # parameter does, and is accepted rather than held to the pattern. + allow_blank=True, + error_messages={ + "invalid": "Enter a two-letter ISO 3166-1 alpha-2 country code, for example FR.", + }, + help_text=( + "Two-letter ISO 3166-1 alpha-2 country code, case-insensitive. " + "Every time zone is returned when it is omitted or left empty, and when " + "the code names a country with no time zones of its own." + ), + ) + + +#: Value of the ``view`` query parameter that returns only the fields the +#: account's owner shares with everybody. +SHARED_VIEW = "shared" + +#: Properties the ``minimal`` preset keeps of an account. +ACCOUNT_MINIMAL_FIELDS = ("username", "email", "name", "profile_image") + +#: Properties the ``ordering`` parameter of the account list accepts. +ACCOUNT_SORT_FIELDS = ("username",) + +#: The order the account list uses when it is asked for none. Usernames are +#: unique, so sorting by one settles every row: the same selection asked for +#: twice is cut into the same pages. +ACCOUNT_DEFAULT_SORT = ACCOUNT_SORT_FIELDS[0] + + +class AccountProfileImageSerializer(serializers.Serializer): # pylint: disable=abstract-method + """Where a user's profile image can be fetched, in each size the site renders.""" + + has_image = serializers.BooleanField( + help_text="Whether the user has uploaded an image. The URLs below are a default image when it is false.", + ) + image_url_full = serializers.URLField(required=False, help_text="Address of the image at its largest size.") + image_url_large = serializers.URLField(required=False, help_text="Address of the image at its large size.") + image_url_medium = serializers.URLField(required=False, help_text="Address of the image at its medium size.") + image_url_small = serializers.URLField(required=False, help_text="Address of the image at its smallest size.") + + +class AccountLanguageSerializer(serializers.Serializer): # pylint: disable=abstract-method + """A language the user has told the site they speak.""" + + code = serializers.CharField(help_text="Language code, for example ``en`` or ``pt-br``.") + + +class AccountSocialLinkSerializer(serializers.Serializer): # pylint: disable=abstract-method + """A link to one of the user's profiles on another site.""" + + platform = serializers.CharField(help_text="Name of the site, for example ``facebook`` or ``linkedin``.") + social_link = serializers.CharField( + allow_blank=True, help_text="Address of the user's profile on that site.", + ) + + +class AccountExtendedFieldSerializer(serializers.Serializer): # pylint: disable=abstract-method + """One of the extra profile fields this site collects.""" + + field_name = serializers.CharField(help_text="Name the site configured the field under.") + field_value = serializers.JSONField( + help_text=( + "Value the user gave, in the shape this site stores it in: a string for a field " + "collected as text, and whatever the field holds otherwise. It is an empty string " + "when the field is unanswered and null where the site stores no value at all." + ), + ) + + +class AccountSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + A user's account and profile. + + Which properties a response carries depends on who asks: a user reading + their own account, and a staff caller reading anyone's, receive every + property below; anyone else receives the ones the account's owner shares, + which is why all but ``username``, ``profile_image`` and ``account_privacy`` + are marked optional here. + """ + + username = serializers.CharField(help_text="Unique name the user is addressed by.") + profile_image = AccountProfileImageSerializer( + allow_null=True, + help_text="Where the user's profile image can be fetched, null for an account without a profile.", + ) + account_privacy = serializers.CharField( + help_text=( + "How much of this account other learners may see: `all_users`, `private`, or `custom` " + "when the owner chose field by field." + ), + ) + email = serializers.EmailField(required=False, help_text="Address the site writes to the user at.") + id = serializers.IntegerField( + required=False, + help_text="Numeric identifier of the user. Addresses in this API use the username instead.", + ) + name = serializers.CharField( + required=False, allow_null=True, help_text="Full name the user gave, as it appears on certificates.", + ) + bio = serializers.CharField(required=False, allow_null=True, help_text="Short self-description.") + country = serializers.CharField( + required=False, allow_null=True, help_text="Two-letter ISO 3166-1 alpha-2 code of the user's country.", + ) + state = serializers.CharField( + required=False, allow_null=True, help_text="Code of the state or territory, recorded for the United States.", + ) + date_joined = serializers.DateTimeField(required=False, help_text="When the account was created.") + last_login = serializers.DateTimeField( + required=False, allow_null=True, help_text="When the user last signed in, null if they never have.", + ) + is_active = serializers.BooleanField( + required=False, help_text="Whether the user has confirmed their email address.", + ) + gender = serializers.CharField( + required=False, + allow_null=True, + help_text="Gender the user selected: `m`, `f`, `o`, or null when unanswered.", + ) + year_of_birth = serializers.IntegerField( + required=False, allow_null=True, help_text="Year the user was born, null when unanswered.", + ) + level_of_education = serializers.CharField( + required=False, + allow_null=True, + help_text=( + "Highest level of education the user selected: `p` for a doctorate, `m` for a " + "master's or professional degree, `b` for a bachelor's degree, `a` for an associate " + "degree, `hs` for secondary or high school, `jhs` for junior secondary or middle " + "school, `el` for elementary or primary school, `none` for no formal education, or " + "`other`. Null when unanswered, and an account registered long ago may carry a code " + "the site no longer offers." + ), + ) + goals = serializers.CharField( + required=False, allow_null=True, help_text="What the user said they want from the site.", + ) + mailing_address = serializers.CharField(required=False, allow_null=True, help_text="Postal address the user gave.") + phone_number = serializers.CharField( + required=False, allow_null=True, help_text="Digits, optionally led by a `+`, or null when unanswered.", + ) + language_proficiencies = AccountLanguageSerializer( + many=True, + required=False, + allow_null=True, + help_text=( + "Languages the user speaks, ordered by language code, null for an account without a profile." + ), + ) + social_links = AccountSocialLinkSerializer( + many=True, + required=False, + allow_null=True, + help_text=( + "The user's profiles on other sites, ordered by platform, null for an account without a profile." + ), + ) + extended_profile = AccountExtendedFieldSerializer( + many=True, + required=False, + allow_null=True, + help_text=( + "The extra profile fields this site collects, in configured order, null for an " + "account without a profile." + ), + ) + requires_parental_consent = serializers.BooleanField( + required=False, + allow_null=True, + help_text=( + "Whether the user is too young to share a profile without a guardian's consent, null " + "for an account without a profile." + ), + ) + pending_name_change = serializers.CharField( + required=False, allow_null=True, help_text="Name the user has asked to change to, null when none is pending.", + ) + verified_name = serializers.CharField( + required=False, allow_null=True, help_text="Name verified against the user's photo identification.", + ) + secondary_email = serializers.EmailField( + required=False, allow_null=True, help_text="Recovery address, present only where the site collects one.", + ) + secondary_email_enabled = serializers.BooleanField( + required=False, + allow_null=True, + help_text="Whether this site collects a recovery address, null where it collects none.", + ) + course_certificates = serializers.DictField( + required=False, + allow_null=True, + help_text="Always null here; certificates are served by the certificates API.", + ) + time_zone = serializers.CharField( + required=False, + allow_null=True, + help_text="Always null here; the time-zone preference is served by the preferences API.", + ) + + +class AccountMinimalSerializer(_KeptFieldsMixin, AccountSerializer): + """An account reduced to the properties that identify its owner.""" + + kept_fields = ACCOUNT_MINIMAL_FIELDS + + +class AccountProjectionSerializer(_OptionalFieldsMixin, AccountSerializer): + """An account reduced to the properties the caller selected.""" + + +class AccountQuerySerializer(serializers.Serializer): # pylint: disable=abstract-method + """Query parameters accepted by the account list.""" + + username = serializers.CharField( + required=False, + allow_blank=True, + 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." + ), + ) + + +#: Longest name a preference can be stored under, taken from the column that +#: stores it. +PREFERENCE_NAME_MAX_LENGTH = UserPreference._meta.get_field("key").max_length + +#: Refusal of a name longer than a preference can be stored under. +PREFERENCE_NAME_TOO_LONG = ( + f"A preference name is at most {PREFERENCE_NAME_MAX_LENGTH} characters long." +) + +#: What a name must carry to be stored under, taken from the column that stores +#: it. The column looks for this anywhere in the name rather than over the +#: whole of it, so the same search is made here and the names refused are the +#: names the store refuses. +PREFERENCE_NAME_PATTERN = UserPreference.KEY_REGEX + +#: Refusal of a name nothing can be stored under at all. +PREFERENCE_NAME_UNUSABLE = ( + "A preference name carries at least one letter, digit, hyphen or underscore." +) + + +def why_a_name_cannot_be_stored(name): + """Return why nothing can be stored under this name, or nothing if something can.""" + if len(name) > PREFERENCE_NAME_MAX_LENGTH: + return [PREFERENCE_NAME_TOO_LONG] + if not re.search(PREFERENCE_NAME_PATTERN, name): + return [PREFERENCE_NAME_UNUSABLE] + return [] + +#: Refuses text that has no encoding of its own, which is text that can neither +#: be stored nor be carried back in an answer. +_REFUSE_UNENCODABLE_TEXT = ProhibitSurrogateCharactersValidator() + + +def _members_at_fault(refusal): + """Return a refusal keyed by the body member it is about, or against the body itself.""" + if isinstance(refusal, dict): + return refusal + return {api_settings.NON_FIELD_ERRORS_KEY: refusal} + + +def _why_text_cannot_be_carried(text): + """Return why this text can be neither stored nor answered with, or nothing if it can.""" + try: + _REFUSE_UNENCODABLE_TEXT(text) + except serializers.ValidationError as refused: + return list(refused.detail) + return [] + + +#: What the preference document of one account looks like: an object whose +#: every member is a preference name and the text stored under it. The +#: document has no fixed set of members -- any name an account's own features +#: store a value under is one of them -- so the shape of its members is named +#: here rather than declared as properties, and the fields below carry it: +#: a body named by a field alone is published as an object of no stated shape. +ACCOUNT_PREFERENCES_SCHEMA = { + "type": "object", + "additionalProperties": {"type": "string"}, + "description": ( + "Preferences of one account, as an object of preference name to stored value. Every " + "value is text. Which names an account carries depends on the features it uses." + ), +} + + +#: What a merge patch of the preference document carries: the same object as +#: the document, except that a member may also be null, and that it has to +#: name something -- a body naming nothing to change is refused as it is read, +#: so the document says so too. +ACCOUNT_PREFERENCE_CHANGES_SCHEMA = { + "type": "object", + "minProperties": 1, + "additionalProperties": {"type": "string", "nullable": True}, + "description": ( + "Preferences to change, as an object of preference name to the value to store under it. " + "Every value is text, and a null removes the preference. Names the body leaves out are " + "left as they are. At least one name must be given." + ), +} + + +@extend_schema_field(ACCOUNT_PREFERENCES_SCHEMA) +class AccountPreferenceDocumentField(serializers.DictField): + """ + The preferences of one account, as the body they are answered in. + + Every stored value is text and the names are answered sorted by name, + character by character and by the code of each character, rather than in + the order they were stored. Both are settled here so that the body cannot + say something the shape above does not. + """ + + child = serializers.CharField( + help_text="Value stored under the preference name this member is, which is always text.", + ) + + def to_representation(self, value): + """Return the stored preferences as the document, by name.""" + return super().to_representation(dict(sorted(value.items()))) + + +class _PreferenceChangeField(_TextField): + """The value to store under one preference name, which a null removes instead.""" + + default_error_messages = {"invalid": "Send a value as text, or null to remove the preference."} + + +@extend_schema_field(ACCOUNT_PREFERENCE_CHANGES_SCHEMA) +class AccountPreferenceChangesField(serializers.DictField): + """ + The members a merge patch of a preference document carries. + + Each value is read as the text to store under the name carrying it, and a + null is read as a removal. An empty value is read rather than refused, + because what a preference does with one is the preference service's to + decide and it answers for every address storing a value. + + A body that is not an object, and one that names nothing to change, are + refused by the field as part of reading it: only the wording of those two + refusals is settled here, and it is the wording this version answers with + for the other body read as a merge patch. + """ + + default_error_messages = { + "not_a_dict": "Send the preferences to change as an object.", + "empty": "Name at least one preference to change.", + } + + child = _PreferenceChangeField( + allow_null=True, + allow_blank=True, + help_text="Value to store under the preference name this member is, or null to remove it.", + ) + + +#: Formats the preference document of one account into the body it is answered in. +ACCOUNT_PREFERENCE_DOCUMENT = AccountPreferenceDocumentField() + +#: Reads the members a merge patch of a preference document carries. A body +#: naming nothing is refused rather than read as a change that does nothing, +#: since a caller who sent one has asked for something that did not arrive. +ACCOUNT_PREFERENCE_CHANGES = AccountPreferenceChangesField(allow_empty=False) + + +class AccountPreferencesUpdateSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + The changes a merge patch of an account's preference document carries. + + The document has no fixed set of members, so the body is read member by + member against the field describing one change instead of against + declared fields. Refusals are reported under the preference name that + carried the value at fault, which is the member of the body a caller + would correct; a name that could not itself be carried back is reported + against the body, since the answer could not name it. + """ + + def to_internal_value(self, data): + """Return the changes sent, refusing a body, a name or a value that cannot be stored.""" + names = data.keys() if isinstance(data, dict) else () + unnameable = sorted({ + message for name in names for message in _why_text_cannot_be_carried(name) + }) + if unnameable: + raise serializers.ValidationError({api_settings.NON_FIELD_ERRORS_KEY: unnameable}) + # A name longer than one can be stored under is reported under itself, + # ahead of whatever the value carried by it is refused for: the name is + # the part of that member a caller has to change either way. + too_long = { + name: [PREFERENCE_NAME_TOO_LONG] + for name in names + if len(name) > PREFERENCE_NAME_MAX_LENGTH + } + try: + changes = ACCOUNT_PREFERENCE_CHANGES.run_validation(data) + except serializers.ValidationError as refused: + raise serializers.ValidationError({ + **_members_at_fault(refused.detail), **too_long, + }) from refused + if too_long: + raise serializers.ValidationError(too_long) + return changes + + +class AccountPreferenceSerializer(serializers.Serializer): # pylint: disable=abstract-method + """One preference of one account.""" + + key = serializers.CharField(help_text="Name the preference is stored under.") + value = serializers.CharField(help_text="Value stored under that name, which is always text.") + + +class AccountPreferenceValueSerializer(serializers.Serializer): # pylint: disable=abstract-method + """The value to store under one preference of one account.""" + + value = _TextField( + help_text=( + "Value to store, as text. A preference cannot hold an empty value; remove the " + "preference instead." + ), + ) + + +@extend_schema_field(serializers.CharField) +class _CourseKeyField(CourseKeyField): + """ + A course, read from and published as the text that writes its key. + + The platform's field turns that text into the key object the services + behind these endpoints take, and describes itself to no document, so the + text form is named here. It reads a value of any other type by splitting + it, which no value but text can be, so one is refused instead. + """ + + default_error_messages = {"invalid": "Send the course key as the text that writes it."} + + def to_internal_value(self, data): + """Return the course the text names, refusing a value that is not text.""" + if not isinstance(data, str): + self.fail("invalid") + return super().to_internal_value(data) + + +#: Longest organization name a choice can be recorded against, taken from the +#: column the choice is recorded in. +ORGANIZATION_MAX_LENGTH = UserOrgTag._meta.get_field("org").max_length + +#: Refusal of a key that names something other than a course. Some keys of +#: other kinds are read by the platform's course key reader for historical +#: reasons and say themselves that they name no course. +NOT_A_COURSE = ( + "This key names something other than a course, and the organization a choice is recorded " + "against is the one running a course." +) + +#: Refusal of a course whose organization no choice can be recorded against. +ORGANIZATION_TOO_LONG = ( + f"The organization running this course is named by more than {ORGANIZATION_MAX_LENGTH} " + "characters, which is more than a choice can be recorded against." +) + +#: What the choice itself is published and read as. +EMAIL_OPT_IN_HELP = ( + "Whether this user wants messages from that organization, sent as true or false. A value " + "that says neither is refused rather than read as one of them." +) + + +class _EmailOptInChoiceField(serializers.BooleanField): + """ + A yes or no about messages, read from a boolean or from the word for one. + + The value records consent, so it is read strictly: a boolean, or the text + ``true`` or ``false`` in any case. The further spellings a boolean field + admits (``1``, ``on``, ``yes``) are refused rather than read, because a + caller who sends one of them has not said which choice they mean and a + choice recorded the wrong way round is not something they can see. For the + same reason a body that leaves the choice out is refused instead of being + read as an unticked checkbox, which is what an absent form member would + otherwise mean. + """ + + default_empty_html = empty + default_error_messages = {"invalid": "Send the choice as true or false."} + + def to_internal_value(self, data): + """Return the choice sent, refusing a value that does not say which choice it is.""" + if isinstance(data, bool): + return data + if isinstance(data, str) and data.lower() in ("true", "false"): + return data.lower() == "true" + self.fail("invalid") + + +class EmailOptInSerializer(serializers.Serializer): # pylint: disable=abstract-method + """A user's choice about messages from the organization running a course.""" + + course_key = _CourseKeyField( + help_text=( + "Course the organization runs, which is how the organization the choice applies to " + "is identified." + ), + ) + email_opt_in = _EmailOptInChoiceField(help_text=EMAIL_OPT_IN_HELP) + + def validate_course_key(self, value): + """Return the course, refusing a key that names none and one whose organization is too long.""" + if not value.is_course: + raise serializers.ValidationError(NOT_A_COURSE) + if len(value.org) > ORGANIZATION_MAX_LENGTH: + raise serializers.ValidationError(ORGANIZATION_TOO_LONG) + return value + + +#: Properties the ``minimal`` preset keeps of a retirement. None of them is +#: anything the account's owner entered, so a caller that only needs to know +#: which retirements are waiting reads none of their personal data. +RETIREMENT_MINIMAL_FIELDS = ("id", "original_username", "current_state") + +#: The set of personal data an erasure clears, named by the scope that clears +#: it. The core scope clears what the platform itself holds about a person; +#: the miscellaneous scope clears what the features built on top of it hold. +ERASURE_CORE_SCOPE = "lms_core" +ERASURE_MISC_SCOPE = "lms_misc" +ERASURE_SCOPES = (ERASURE_CORE_SCOPE, ERASURE_MISC_SCOPE) + +#: Each erasure scope with what it clears, as the request body offers them. +#: The pair is declared here rather than inside the field so that the name the +#: published set of choices is kept under can be configured from the same +#: values: a set of choices is matched to its configured name by its values +#: and their descriptions together, and a configured name that does not match +#: has no effect. +ERASURE_SCOPE_CHOICES = ( + (ERASURE_CORE_SCOPE, "Profile, images, certificates, entitlements and the account itself."), + (ERASURE_MISC_SCOPE, "Wiki revisions, enrollment audits, credit and API access records."), +) + +#: What a batch operation reports about each name it was given. +OUTCOME_DELETED = "deleted" +OUTCOME_ALREADY_ABSENT = "already_absent" +BATCH_OUTCOMES = (OUTCOME_DELETED, OUTCOME_ALREADY_ABSENT) + +#: Each outcome with what it means, offered and named as the erasure scopes are. +BATCH_OUTCOME_CHOICES = ( + (OUTCOME_DELETED, "The record was deleted by this request."), + (OUTCOME_ALREADY_ABSENT, "There was no such record, so this request left nothing to do."), +) + +#: The largest number of names one batch may carry, which is the number of +#: rows a page of any collection of this version holds. +BATCH_MAX_NAMES = UserApiPagination.max_page_size + + +def why_a_state_is_not_configured(name): + """Return why this deployment has no such pipeline step, or ``None`` when it has one.""" + if RetirementState.objects.filter(state_name=name).exists(): + return None + configured = ", ".join(sorted(RetirementState.get_state_names_list())) + return f"No such pipeline step. This deployment runs: {configured}." + + +class RetirementAccountProfileSerializer(serializers.Serializer): # pylint: disable=abstract-method + """The part of a retiring user's profile a retirement record carries.""" + + id = serializers.IntegerField(read_only=True, help_text="Database identifier of the profile.") + name = serializers.CharField( + read_only=True, + allow_blank=True, + help_text="Full name recorded on the profile at the time it is read.", + ) + + +class RetirementAccountSerializer(serializers.Serializer): # pylint: disable=abstract-method + """The part of a retiring user's account a retirement record carries.""" + + id = serializers.IntegerField(read_only=True, help_text="Database identifier of the user.") + username = serializers.CharField( + read_only=True, + help_text=( + "Name the account is stored under now, which is the hashed replacement once the " + "erasure of core data has run." + ), + ) + email = serializers.EmailField( + read_only=True, + help_text=( + "Address the account is stored under now, which is the hashed replacement once the " + "account has been locked." + ), + ) + profile = RetirementAccountProfileSerializer( + read_only=True, + # An account need not hold a profile, and one being retired may never + # have held one, so the property is answered as null rather than left + # out: a caller reading it finds it under the same name either way. + allow_null=True, + help_text="The profile of the account, or null for an account that has none.", + ) + + +class RetirementSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + One account's progress through the retirement pipeline, addressed by the name it held. + + ``current_state`` and ``last_state`` are the names of the steps rather + than objects: a step's execution order and identifier are configured by + the operator and say nothing a caller of this API can act on. The log of + the transitions a retirement has been through grows without bound and is + left out for that reason. + """ + + id = serializers.IntegerField( + read_only=True, + help_text="Database identifier of the retirement. Addresses in this API use the username instead.", + ) + user = RetirementAccountSerializer(read_only=True, help_text="The account being retired, as it stands now.") + original_username = serializers.CharField( + read_only=True, + help_text="Name the account was stored under when retirement was requested.", + ) + original_email = serializers.EmailField( + read_only=True, + help_text="Address the account was stored under when retirement was requested.", + ) + original_name = serializers.CharField( + read_only=True, + allow_blank=True, + help_text="Full name on the profile when retirement was requested.", + ) + retired_username = serializers.CharField( + read_only=True, + allow_blank=True, + help_text="Hashed name the account is moved to once its core data is erased.", + ) + retired_email = serializers.EmailField( + read_only=True, + allow_blank=True, + help_text="Hashed address the account is moved to once it is locked.", + ) + current_state = serializers.CharField( + source="current_state.state_name", + read_only=True, + help_text="Name of the pipeline step this retirement stands at.", + ) + last_state = serializers.CharField( + source="last_state.state_name", + read_only=True, + help_text="Name of the pipeline step this retirement stood at before the one it is at now.", + ) + created = serializers.DateTimeField(read_only=True, help_text="When retirement was requested.") + modified = serializers.DateTimeField(read_only=True, help_text="When this retirement last moved.") + + +class RetirementMinimalSerializer(_KeptFieldsMixin, RetirementSerializer): + """A retirement reduced to which account it is for and where it stands.""" + + kept_fields = RETIREMENT_MINIMAL_FIELDS + + +class RetirementProjectionSerializer(_OptionalFieldsMixin, RetirementSerializer): + """A retirement reduced to the properties the caller selected.""" + + +class RetirementUpdateSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + A move of one retirement to another step of the pipeline. + + Which steps exist and in what order is configured by the operator, so + ``new_state`` is checked against the configured steps rather than against + a fixed list. + + The body is an instruction rather than a set of changes to the record it + addresses: the step to move to and what happened at the step being left + are both needed to carry the move out and to keep the retirement's log + whole, so it is read whole and a body missing either is refused. + """ + + #: Read whole, so what this requires is what is published as required. + reads_the_whole_body = True + + new_state = serializers.CharField(help_text="Name of the pipeline step to move this retirement to.") + response = serializers.CharField( + allow_blank=True, + help_text="What happened at the step being left, appended to the retirement's log.", + ) + force = serializers.BooleanField( + required=False, + default=False, + help_text=( + "Move the retirement even where the step asked for is not a later one. Operators use " + "this to send a retirement back through a step that has to be repeated." + ), + ) + + def validate_new_state(self, value): + """Return the step named, refusing a name no configured step holds.""" + reason = why_a_state_is_not_configured(value) + if reason is not None: + raise serializers.ValidationError(reason) + return value + + +class RetirementErasureSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + Which set of a retiring user's personal data to erase. + + The erasure cannot be undone, and each scope clears a different set, so + the scope is named in the body rather than defaulted. + """ + + scope = serializers.ChoiceField( + choices=ERASURE_SCOPE_CHOICES, + help_text=( + f"`{ERASURE_CORE_SCOPE}` erases the account's own data: its profile, its images, the " + "name on its certificates and entitlement notes, and the username and address on the " + f"account. `{ERASURE_MISC_SCOPE}` erases what the features around it hold: wiki " + "revisions, pending name changes, enrollment audits, credit records and API access " + "requests." + ), + ) + + +class RetirementBatchSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + The accounts a batch operation acts on, named as they were before retirement. + + A name repeated in the list names one account, so it is acted on and + reported once. + """ + + usernames = serializers.ListField( + child=serializers.CharField(), + allow_empty=False, + max_length=BATCH_MAX_NAMES, + help_text=( + "Names the accounts were stored under before retirement was requested. At most " + f"{BATCH_MAX_NAMES} names, compared exactly as spelled." + ), + ) + + def validate_usernames(self, value): + """Return the names given, each once, in the order they were first given.""" + return list(dict.fromkeys(value)) + + +class RetirementBatchResultSerializer(serializers.Serializer): # pylint: disable=abstract-method + """What a batch operation did about one of the names it was given.""" + + username = serializers.CharField(help_text="The name this result is about, as it was given.") + outcome = serializers.ChoiceField( + choices=BATCH_OUTCOME_CHOICES, + help_text=( + f"`{OUTCOME_DELETED}` where this request deleted the record, and " + f"`{OUTCOME_ALREADY_ABSENT}` where there was none to delete, which is what a repeat " + "of a request that has already been carried out reports for every name." + ), + ) + + +class RetirementBatchOutcomeSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + What a batch operation did, reported for each name it was given. + + Every name is reported, so a caller repeating a request that has already + been carried out can tell that from a request that did the work. + """ + + results = RetirementBatchResultSerializer( + many=True, + help_text="One result per name given, in the order the names were given.", + ) + + +class RetirementPartnerReportRecordSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + What a partner needs in order to forget one retired user. + + Every property is answered for every entry: the record is built from the + queue entry and from the enrolments of the account it names, so there is + nothing in it that a deployment may or may not supply. + """ + + user_id = serializers.IntegerField(help_text="Database identifier of the retired user.") + original_username = serializers.CharField(help_text="Name the account was stored under before retirement.") + original_email = serializers.EmailField(help_text="Address the account was stored under before retirement.") + original_name = serializers.CharField( + allow_blank=True, + help_text="Full name on the profile before retirement.", + ) + orgs = serializers.ListField( + child=serializers.CharField(), + help_text="Short names of the organizations running the courses this user was enrolled in.", + ) + created = serializers.DateTimeField(help_text="When this user was added to the partner reporting queue.") + + +class RetirementPartnerReportQuerySerializer(serializers.Serializer): # pylint: disable=abstract-method + """The query string of a request that takes partner reports for processing.""" + + limit = serializers.IntegerField( + required=False, + min_value=1, + help_text=( + "Take at most this many entries. Omit it to take every entry waiting, which is what " + "the pipeline does: nothing releases an entry once it is taken, so a caller that " + "takes fewer than it processes cannot hand the rest back." + ), + ) diff --git a/openedx/core/djangoapps/user_api/v2/tests/__init__.py b/openedx/core/djangoapps/user_api/v2/tests/__init__.py new file mode 100644 index 000000000000..61ab32b09c15 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for version 2 of the User API.""" diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_account_parity.py b/openedx/core/djangoapps/user_api/v2/tests/test_account_parity.py new file mode 100644 index 000000000000..54560629d99a --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_account_parity.py @@ -0,0 +1,574 @@ +"""Behaviour parity between the version 1 and version 2 account addresses.""" + +import json + +from django.urls import reverse +from edx_rest_framework_extensions.testing import assert_error_envelope +from rest_framework import status +from rest_framework.test import APITestCase + +from common.djangoapps.student.models import get_retired_email_by_email +from common.djangoapps.student.tests.factories import ( + ContentTypeFactory, + PermissionFactory, + UserFactory, +) +from openedx.core.djangolib.testing.utils import skip_unless_lms + +from .test_parity import diff, unexpected + +PASSWORD = "Password1234" +MERGE_PATCH = "application/merge-patch+json" + +EARLIER_ME = "/api/user/v1/me" +EARLIER_LIST = "/api/user/v1/accounts" +EARLIER_SEARCH = "/api/user/v1/accounts/search_emails" + + +def earlier_detail(username): + """Return the version 1 member address of one account.""" + return f"/api/user/v1/accounts/{username}" + + +def by_username(rows): + """ + Return the given rows in username order. + + The earlier addresses answer in whatever order the database returns the + accounts, so rows are brought into one order before their properties are + compared with this version's. The order a page comes back in is a + difference of its own and is pinned by the tests named for it. + """ + return sorted(rows, key=lambda row: row["username"]) + + +#: Differences each case is expected to show, as (json path, reason) pairs: the +#: diff must carry all of them and nothing else. A difference in the shape of a +#: body, or in the order of the rows it carries, is not a path and is pinned by +#: the test named for it instead. +INTENTIONAL_DIFFERENCES = { + "account_list": [], + "account_detail": [], + "shared_account": [], + "another_account": [], + "changed_account": [], + "email_search": [], +} + +#: Names the member address of this version reads as something other than a +#: username, with what each one names instead and what that address answers for +#: an account holding it. The earlier member address answers every one of them +#: with that account; here each is read through the collection, with +#: ``username=`` naming it. +RESERVED_MEMBER_IDENTIFIERS = ( + ("me", "the account of the caller", status.HTTP_200_OK), +) + + +@skip_unless_lms +class AccountParityTests(APITestCase): + """The same fixtures through the version 1 and the version 2 account addresses.""" + + def setUp(self): + super().setUp() + self.staff = UserFactory.create( + username="parity-staff", email="parity-staff@example.com", password=PASSWORD, is_staff=True, + ) + self.owner = UserFactory.create( + username="parity-owner", email="parity-owner@example.com", password=PASSWORD, + ) + self.other = UserFactory.create( + username="parity-other", email="parity-other@example.com", password=PASSWORD, + ) + + def login(self, user): + """Sign the given user in, which both versions authenticate the same way.""" + assert self.client.login(username=user.username, password=PASSWORD) + + def bodies(self, user, earlier_address, address, params=None): + """ + Return the decoded body each version answers the same request with. + + Both are read inside one session, because signing in again would stamp + a new last-login time on the account and so on the second body. + """ + self.login(user) + earlier = self.client.get(earlier_address, params or {}) + current = self.client.get(address, params or {}) + self.client.logout() + assert earlier.status_code == status.HTTP_200_OK, earlier.content + assert current.status_code == status.HTTP_200_OK, current.content + return json.loads(earlier.content), json.loads(current.content) + + def assert_parity(self, earlier, current, case): + """Assert the two bodies differ only where the case declares they do.""" + changed = diff(earlier, current) + intentional = INTENTIONAL_DIFFERENCES[case] + assert not unexpected(changed, intentional), ( + f"undeclared differences: {sorted(unexpected(changed, intentional))}\n" + f"earlier={earlier}\ncurrent={current}" + ) + for path, reason in intentional: + assert any( + key == path or key.startswith(path + ".") or key.startswith(path + "[") + for key in changed + ), f"declared difference did not occur: {path} ({reason})" + + def test_the_callers_own_address_answers_with_the_whole_account(self): + """Declared difference: the earlier address answers with the username alone.""" + self.login(self.owner) + earlier = json.loads(self.client.get(EARLIER_ME).content) + current = json.loads( + self.client.get(reverse("user_v2:account_detail", kwargs={"username": "me"})).content, + ) + named = json.loads( + self.client.get( + reverse("user_v2:account_detail", kwargs={"username": "parity-owner"}), + ).content, + ) + self.client.logout() + + assert earlier == {"username": "parity-owner"} + assert current["username"] == earlier["username"] + assert current["email"] == "parity-owner@example.com" + assert current == named + assert set(current) > set(earlier) + + def test_the_earlier_own_address_body_is_the_selection_of_one_property(self): + """The property the earlier address answered with is one selection of this one.""" + earlier, current = self.bodies( + self.owner, + EARLIER_ME, + reverse("user_v2:account_detail", kwargs={"username": "me"}), + {"fields": "username"}, + ) + assert earlier == current == {"username": "parity-owner"} + + def test_account_list_parity(self): + earlier, current = self.bodies( + self.staff, EARLIER_LIST, reverse("user_v2:account_list"), + {"username": "parity-other,parity-owner"}, + ) + assert isinstance(earlier, list) + assert len(earlier) == 2 + self.assert_parity(earlier, current["results"], "account_list") + + def test_the_page_carries_the_rows_the_earlier_list_returned_unbounded(self): + """ + Differences this version declares: the rows come in a page, and in username order. + + The earlier address answered with a bare array of every account named, + in the order the database returned them; here they are the page of the + envelope each list of this version answers with, ordered by username, + so the same selection asked for twice is cut into the same pages. + """ + earlier, current = self.bodies( + self.staff, EARLIER_LIST, reverse("user_v2:account_list"), + {"username": "parity-other,parity-owner"}, + ) + assert isinstance(earlier, list) + assert {account["username"] for account in earlier} == {"parity-other", "parity-owner"} + assert set(current) == { + "count", "num_pages", "current_page", "start", "next", "previous", "results", + } + assert current["count"] == len(earlier) + assert [account["username"] for account in current["results"]] == [ + "parity-other", "parity-owner", + ] + + def test_an_account_whose_name_this_version_reserves_is_read_through_the_collection(self): + """Declared difference: a reserved name addresses something else here.""" + self.login(self.staff) + for name, _names_instead, member_status in RESERVED_MEMBER_IDENTIFIERS: + held = UserFactory.create( + username=name, email=f"{name}@example.com", password=PASSWORD, + ) + earlier = self.client.get(earlier_detail(name)) + assert earlier.status_code == status.HTTP_200_OK, earlier.content + assert json.loads(earlier.content)["username"] == name + + member = self.client.get(reverse("user_v2:account_detail", kwargs={"username": name})) + assert member.status_code == member_status + assert json.loads(member.content).get("username") != name + + page = self.client.get(reverse("user_v2:account_list"), {"username": name}) + assert page.status_code == status.HTTP_200_OK + assert [row["username"] for row in json.loads(page.content)["results"]] == [name] + assert json.loads(page.content)["results"][0]["email"] == held.email + self.client.logout() + + def test_a_selection_that_matches_no_account_is_an_empty_page(self): + """Declared difference: a collection that matched nothing is empty rather than absent.""" + self.login(self.staff) + earlier = self.client.get(EARLIER_LIST, {"username": "nobody"}) + current = self.client.get(reverse("user_v2:account_list"), {"username": "nobody"}) + self.client.logout() + + assert earlier.status_code == status.HTTP_404_NOT_FOUND + assert earlier.content == b"" + assert current.status_code == status.HTTP_200_OK + assert json.loads(current.content)["count"] == 0 + assert json.loads(current.content)["results"] == [] + + def test_account_detail_parity_for_the_owner(self): + earlier, current = self.bodies( + self.owner, + earlier_detail("parity-owner"), + reverse("user_v2:account_detail", kwargs={"username": "parity-owner"}), + ) + self.assert_parity(earlier, current, "account_detail") + assert current["email"] == "parity-owner@example.com" + + def test_account_detail_parity_for_a_staff_caller(self): + earlier, current = self.bodies( + self.staff, + earlier_detail("parity-owner"), + reverse("user_v2:account_detail", kwargs={"username": "parity-owner"}), + ) + self.assert_parity(earlier, current, "account_detail") + + def test_the_properties_another_learner_may_see_are_unchanged(self): + """The field-level visibility is the whole of the record protection on this resource.""" + earlier, current = self.bodies( + self.owner, + earlier_detail("parity-other"), + reverse("user_v2:account_detail", kwargs={"username": "parity-other"}), + ) + assert set(earlier) == {"account_privacy", "profile_image", "username"} + self.assert_parity(earlier, current, "another_account") + + def test_the_shared_preset_is_unchanged(self): + earlier, current = self.bodies( + self.owner, + earlier_detail("parity-owner"), + reverse("user_v2:account_detail", kwargs={"username": "parity-owner"}), + {"view": "shared"}, + ) + assert set(earlier) == {"account_privacy", "profile_image", "username"} + self.assert_parity(earlier, current, "shared_account") + + def test_the_account_a_change_answers_with_is_unchanged(self): + self.login(self.owner) + earlier = self.client.patch( + earlier_detail("parity-owner"), json.dumps({"bio": "parity bio"}), content_type=MERGE_PATCH, + ) + current = self.client.patch( + reverse("user_v2:account_detail", kwargs={"username": "parity-owner"}), + json.dumps({"bio": "parity bio"}), + content_type=MERGE_PATCH, + ) + assert earlier.status_code == status.HTTP_200_OK, earlier.content + assert current.status_code == status.HTTP_200_OK, current.content + self.assert_parity(json.loads(earlier.content), json.loads(current.content), "changed_account") + assert json.loads(current.content)["bio"] == "parity bio" + + def test_email_search_parity(self): + emails = {"emails": ["parity-other@example.com", "parity-owner@example.com"]} + self.login(self.staff) + earlier = self.client.post(EARLIER_SEARCH, emails, format="json") + current = self.client.post(reverse("user_v2:email_search_list"), emails, format="json") + assert earlier.status_code == status.HTTP_200_OK, earlier.content + assert current.status_code == status.HTTP_200_OK, current.content + earlier_rows = by_username(json.loads(earlier.content)) + self.assert_parity(earlier_rows, json.loads(current.content)["results"], "email_search") + assert earlier_rows[0]["email"] == "parity-other@example.com" + + def test_the_search_page_carries_the_rows_the_earlier_search_returned_unbounded(self): + """ + Differences this version declares: the rows come in a page, and in username order. + + The earlier address answered with a bare array of every account it + found, in the order the database returned them; here they are the page + of the envelope each list of this version answers with, ordered by + username, so the same search asked for twice is cut into the same + pages. + """ + emails = {"emails": [ + "parity-other@example.com", "parity-owner@example.com", "parity-staff@example.com", + ]} + self.login(self.staff) + earlier = json.loads(self.client.post(EARLIER_SEARCH, emails, format="json").content) + current = json.loads( + self.client.post(reverse("user_v2:email_search_list"), emails, format="json").content, + ) + self.client.logout() + + assert isinstance(earlier, list) + assert {row["username"] for row in earlier} == { + "parity-other", "parity-owner", "parity-staff", + } + assert set(current) == { + "count", "num_pages", "current_page", "start", "next", "previous", "results", + } + assert current["count"] == len(earlier) + assert [row["username"] for row in current["results"]] == [ + "parity-other", "parity-owner", "parity-staff", + ] + assert current["next"] is None + + +@skip_unless_lms +class AccountRefusalParityTests(APITestCase): + """The refusals each version answers with, recorded beside each other.""" + + def setUp(self): + super().setUp() + self.staff = UserFactory.create( + username="refusal-staff", email="refusal-staff@example.com", password=PASSWORD, is_staff=True, + ) + self.owner = UserFactory.create( + username="refusal-owner", email="refusal-owner@example.com", password=PASSWORD, + ) + self.other = UserFactory.create( + username="refusal-other", email="refusal-other@example.com", password=PASSWORD, + ) + self.member = reverse("user_v2:account_detail", kwargs={"username": "refusal-owner"}) + + def login(self, user): + """Sign the given user in.""" + assert self.client.login(username=user.username, password=PASSWORD) + + def test_an_unauthenticated_refusal_keeps_its_status_and_changes_its_body(self): + earlier = self.client.get(EARLIER_LIST) + assert earlier.status_code == status.HTTP_401_UNAUTHORIZED + assert json.loads(earlier.content) == { + "detail": "Authentication credentials were not provided.", + } + + current = self.client.get(reverse("user_v2:account_list")) + envelope = assert_error_envelope( + current, expected_status=status.HTTP_401_UNAUTHORIZED, expected_type_slug="authn", + ) + assert envelope["title"] == "Authentication Required" + assert envelope["instance"] == "/api/user/v2/accounts/" + + def test_a_selection_no_account_matches_is_a_page_here_and_was_a_refusal_before(self): + """ + Selecting by an address nobody holds narrows the page to nothing instead of refusing. + + The earlier address answered one collection two ways: a name it found + nothing behind left the page empty, while an address it found nothing + behind was refused. Here every selector narrows the same way, and the + refusal is kept for an address an account held before it was retired. + """ + self.login(self.staff) + selection = {"email": "nobody@example.com"} + earlier = self.client.get(EARLIER_LIST, selection) + assert earlier.status_code == status.HTTP_404_NOT_FOUND + + current = self.client.get(reverse("user_v2:account_list"), selection) + assert current.status_code == status.HTTP_200_OK + body = json.loads(current.content) + assert body["count"] == 0 + assert body["results"] == [] + + def test_a_name_no_account_holds_keeps_its_status_and_gains_a_body(self): + self.login(self.staff) + earlier = self.client.get(earlier_detail("nobody")) + assert earlier.status_code == status.HTTP_404_NOT_FOUND + assert earlier.content == b"" + + current = self.client.get(reverse("user_v2:account_detail", kwargs={"username": "nobody"})) + envelope = assert_error_envelope( + current, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + assert envelope["detail"] == "Not found." + + def test_changing_another_account_keeps_its_status_and_gains_a_body(self): + self.login(self.owner) + earlier = self.client.patch( + earlier_detail("refusal-other"), json.dumps({"bio": "x"}), content_type=MERGE_PATCH, + ) + assert earlier.status_code == status.HTTP_403_FORBIDDEN + assert earlier.content == b"" + + current = self.client.patch( + reverse("user_v2:account_detail", kwargs={"username": "refusal-other"}), + json.dumps({"bio": "x"}), + content_type=MERGE_PATCH, + ) + assert_error_envelope( + current, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + + def test_a_refused_change_names_the_same_property_in_a_body_of_another_shape(self): + self.login(self.owner) + body = json.dumps({"year_of_birth": "recently"}) + earlier = self.client.patch(earlier_detail("refusal-owner"), body, content_type=MERGE_PATCH) + assert earlier.status_code == status.HTTP_400_BAD_REQUEST + assert json.loads(earlier.content) == { + "field_errors": { + "year_of_birth": { + "developer_message": "Value 'recently' is not valid for field 'year_of_birth': " + "['A valid integer is required.']", + "user_message": "This value is invalid.", + }, + }, + } + + current = self.client.patch(self.member, body, content_type=MERGE_PATCH) + envelope = assert_error_envelope( + current, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + # Both answers name the property and say the value is not a year. The + # earlier one says why inside its developer message and keeps a fixed + # sentence for the reader; this one says it once, where a reader looks. + assert envelope["errors"] == {"year_of_birth": ["A valid integer is required."]} + + def test_a_body_in_another_media_type_keeps_its_status_and_gains_a_body(self): + self.login(self.owner) + earlier = self.client.patch( + earlier_detail("refusal-owner"), json.dumps({"bio": "x"}), content_type="application/json", + ) + assert earlier.status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE + + current = self.client.patch(self.member, json.dumps({"bio": "x"}), content_type="application/json") + assert_error_envelope( + current, + expected_status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + expected_type_slug="unsupported-media-type", + ) + + def test_the_search_hides_itself_from_a_learner_in_both_versions(self): + self.login(self.owner) + emails = {"emails": ["refusal-owner@example.com"]} + earlier = self.client.post(EARLIER_SEARCH, emails, format="json") + assert earlier.status_code == status.HTTP_404_NOT_FOUND + assert json.loads(earlier.content) == { + "developer_message": "not_found", "user_message": "Not Found", + } + + current = self.client.post(reverse("user_v2:email_search_list"), emails, format="json") + assert_error_envelope( + current, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + + def test_a_search_without_addresses_keeps_its_status_and_names_the_property(self): + self.login(self.staff) + earlier = self.client.post(EARLIER_SEARCH, {}, format="json") + assert earlier.status_code == status.HTTP_400_BAD_REQUEST + assert json.loads(earlier.content) == { + "developer_message": "'emails' field is required", + "user_message": "'emails' field is required", + } + + current = self.client.post(reverse("user_v2:email_search_list"), {}, format="json") + envelope = assert_error_envelope( + current, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == {"emails": ["This field is required."]} + + def test_a_search_longer_than_a_page_is_refused_here_and_was_answered_before(self): + """ + A difference this version declares: a search names at most a hundred addresses. + + The earlier address took as many addresses as a body could carry and + read them in one query; here a search is held to what the largest page + a caller may ask for can answer with. + """ + self.login(self.staff) + emails = {"emails": [f"crowd-{index:03d}@example.com" for index in range(101)]} + + earlier = self.client.post(EARLIER_SEARCH, emails, format="json") + assert earlier.status_code == status.HTTP_200_OK, earlier.content + assert json.loads(earlier.content) == [] + + current = self.client.post(reverse("user_v2:email_search_list"), emails, format="json") + envelope = assert_error_envelope( + current, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "emails": ["Ensure this field has no more than 100 elements."], + } + + def test_the_address_of_a_retired_account_keeps_what_the_earlier_body_carried(self): + self.login(self.staff) + retired_email = self.other.email + self.other.email = get_retired_email_by_email(retired_email) + self.other.save() + + earlier = self.client.get(EARLIER_LIST, {"email": retired_email}) + assert earlier.status_code == status.HTTP_404_NOT_FOUND + earlier_body = json.loads(earlier.content) + assert set(earlier_body) == {"error_msg", "can_cancel_retirement", "retirement_id"} + + current = self.client.get(reverse("user_v2:account_list"), {"email": retired_email}) + envelope = assert_error_envelope( + current, + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="user/retired-account", + ) + assert envelope["detail"] == earlier_body["error_msg"] + assert envelope["can_cancel_retirement"] == earlier_body["can_cancel_retirement"] + assert envelope["retirement_id"] == earlier_body["retirement_id"] + + +@skip_unless_lms +class DeactivationParityTests(APITestCase): + """The deactivation of an account, through each version.""" + + def setUp(self): + super().setUp() + self.deactivator = UserFactory.create(username="parity-deactivator", password=PASSWORD) + self.deactivator.user_permissions.add( + PermissionFactory( + codename="can_deactivate_users", + content_type=ContentTypeFactory(app_label="student"), + ), + ) + self.earlier_target = UserFactory.create(username="parity-target-one", password=PASSWORD) + self.target = UserFactory.create(username="parity-target-two", password=PASSWORD) + assert self.client.login(username=self.deactivator.username, password=PASSWORD) + + def test_both_versions_make_the_password_unusable(self): + earlier = self.client.post(f"/api/user/v1/accounts/{self.earlier_target.username}/deactivate/") + assert earlier.status_code == status.HTTP_200_OK + assert json.loads(earlier.content)["username"] == "parity-target-one" + self.earlier_target.refresh_from_db() + assert not self.earlier_target.has_usable_password() + + current = self.client.post( + reverse("user_v2:account_deactivation", kwargs={"username": self.target.username}), + ) + assert current.status_code == status.HTTP_204_NO_CONTENT + assert current.content == b"" + self.target.refresh_from_db() + assert not self.target.has_usable_password() + + def test_the_reserved_identifier_names_the_same_account_on_both_addresses(self): + """``me`` is a stored username on this action in both versions, not a name for the caller.""" + held = UserFactory.create(username="me", password=PASSWORD) + earlier = self.client.post("/api/user/v1/accounts/me/deactivate/") + assert earlier.status_code == status.HTTP_200_OK + assert json.loads(earlier.content)["username"] == "me" + held.refresh_from_db() + assert not held.has_usable_password() + + held.set_password(PASSWORD) + held.save() + current = self.client.post(reverse("user_v2:account_deactivation", kwargs={"username": "me"})) + assert current.status_code == status.HTTP_204_NO_CONTENT + held.refresh_from_db() + assert not held.has_usable_password() + self.deactivator.refresh_from_db() + assert self.deactivator.has_usable_password() + + def test_the_account_is_readable_after_it_is_deactivated(self): + """The account the earlier version answered with is one read away.""" + self.client.post( + reverse("user_v2:account_deactivation", kwargs={"username": self.target.username}), + ) + account = self.client.get( + reverse("user_v2:account_detail", kwargs={"username": self.target.username}), + ) + assert account.status_code == status.HTTP_200_OK + assert json.loads(account.content)["username"] == "parity-target-two" + + def test_a_name_no_account_holds_is_not_found(self): + """An address naming no account is refused with the envelope every refusal carries.""" + current = self.client.post( + reverse("user_v2:account_deactivation", kwargs={"username": "nobody"}), + ) + assert_error_envelope( + current, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_account_query_counts.py b/openedx/core/djangoapps/user_api/v2/tests/test_account_query_counts.py new file mode 100644 index 000000000000..ab67874f3bb1 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_account_query_counts.py @@ -0,0 +1,410 @@ +"""Query-count guards for the version 2 User API account addresses.""" + +import json + +import pytest +from django.urls import reverse + +from common.djangoapps.student.tests.factories import ( + ContentTypeFactory, + PermissionFactory, + UserFactory, +) +from openedx.core.djangoapps.user_api.accounts import ( + ACCOUNT_VISIBILITY_PREF_KEY, + ALL_USERS_VISIBILITY, + CUSTOM_VISIBILITY, + VISIBILITY_PREFIX, +) +from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory +from openedx.core.djangolib.testing.utils import skip_unless_lms + +PASSWORD = "Password1234" +MERGE_PATCH = "application/merge-patch+json" + +#: Collection sizes a per-account query would show up at. +ROW_COUNTS = [1, 5, 25] + +#: Page sizes, so that what a page costs can be told apart from what the +#: collection behind it costs. +PAGE_SIZES = [1, 5, 10] + +#: Year of birth the measured accounts carry. An account that records none +#: counts as a minor, and what a caller may see of it is settled by that +#: before any preference of its owner is read, which is fewer reads than a +#: populated account costs. +MEASURED_YEAR_OF_BIRTH = 1990 + +#: What one page of accounts costs a staff caller, by the number of accounts +#: on it: a constant part of sixteen, and six reads for each account the page +#: carries. The account service selects the accounts in one query and then +#: builds each one on its own, reading its recovery address, its languages, +#: its links, its pending name change, one stored preference and the switch +#: that says whether a recovery address is offered at all. The page pays none +#: of that for the accounts it leaves out, which is what these numbers pin. +ACCOUNT_LIST_QUERIES = {1: 22, 5: 46, 10: 76} + +#: The same page read by a learner, who may see of each account only what its +#: owner shares: the six reads above and the stored preference that says how +#: much that is, seven an account. +LEARNER_LIST_QUERIES = {1: 23, 5: 51, 10: 86} + +#: The same page where every owner has chosen property by property what they +#: share: the seven reads above and the preferences that choice is written in, +#: read once for the account rather than once for each property, eight an +#: account. A page carries at most a hundred accounts, so this is the dearest +#: page the list answers with. +CHOSEN_VISIBILITY_LIST_QUERIES = {1: 24, 5: 56, 10: 96} + +ACCOUNT_DETAIL_QUERIES = 20 +OWN_ACCOUNT_QUERIES = 20 + +#: Address of the one this version answers with the caller's own account. It +#: answered with the username alone and read nothing of the account to do it, +#: so what the whole account costs is what this version's answer costs more. +#: Selecting the one property the earlier body carried shapes that answer; it +#: does not make it cheaper, because the account is built before it is shaped. +#: The earlier address is read first below, so the reads a request pays once +#: are counted against it and the difference measured is the smaller one. +EARLIER_OWN_ACCOUNT = "/api/user/v1/me" +EARLIER_OWN_ACCOUNT_QUERIES = 13 +#: A search is answered a page at a time, which costs one read for the page of +#: rows and one for the number of rows there are to page through, whatever the +#: addresses asked about. +EMAIL_SEARCH_QUERIES = 15 + +#: Address of the account list this version replaces, which has no pages: it +#: serves every account named in one unbounded response and pays the per-account +#: cost above for all of them. +EARLIER_ACCOUNT_LIST = "/api/user/v1/accounts" +EARLIER_ACCOUNT_LIST_QUERIES = {1: 20, 5: 44, 25: 164} + +#: What a change of one property costs, measured on the version 1 address +#: first. Both versions do the same work and answer with the account as it now +#: stands, so the one number is asserted on both addresses. +CHANGE_QUERIES = 30 + +#: What a deactivation costs on each address. The earlier one answers with the +#: account it deactivated and pays for reading it back; this one answers with +#: nothing, so it is cheaper by exactly those reads. +EARLIER_DEACTIVATION_QUERIES = 24 +DEACTIVATION_QUERIES = 18 + + +def measured_accounts(count): + """Return ``count`` accounts carrying the profile data a populated account holds.""" + return [ + UserFactory.create( + username=f"counted{index:03d}", + email=f"counted{index:03d}@example.com", + profile__year_of_birth=MEASURED_YEAR_OF_BIRTH, + ) + for index in range(count) + ] + + +def warm_up(client): + """ + Spend what a session pays once, so the counts are the steady-state ones. + + The first authenticated request of a session populates caches the rest of + the session reuses. + """ + client.get(reverse("user_v2:account_list")) + + +@pytest.fixture(name="accounts") +@skip_unless_lms +def accounts_fixture(request, client, django_user_model): # pylint: disable=unused-argument + """Sign a staff caller in and give the site ``request.param`` other accounts.""" + staff = UserFactory.create( + username="count-staff", + password=PASSWORD, + is_staff=True, + profile__year_of_birth=MEASURED_YEAR_OF_BIRTH, + ) + users = measured_accounts(request.param) + assert client.login(username=staff.username, password=PASSWORD) + warm_up(client) + return users + + +@pytest.fixture(name="accounts_read_by_a_learner") +@skip_unless_lms +def learner_accounts_fixture(request, client, django_user_model): # pylint: disable=unused-argument + """Sign a learner in beside ``request.param`` accounts belonging to other people.""" + learner = UserFactory.create( + username="count-learner", + password=PASSWORD, + profile__year_of_birth=MEASURED_YEAR_OF_BIRTH, + ) + users = measured_accounts(request.param) + assert client.login(username=learner.username, password=PASSWORD) + warm_up(client) + return users + + +@pytest.fixture(name="accounts_shared_by_choice") +@skip_unless_lms +def chosen_visibility_fixture(request, client, django_user_model): # pylint: disable=unused-argument + """Sign a learner in beside accounts whose owners chose property by property what they share.""" + learner = UserFactory.create( + username="count-learner", + password=PASSWORD, + profile__year_of_birth=MEASURED_YEAR_OF_BIRTH, + ) + users = measured_accounts(request.param) + for user in users: + UserPreferenceFactory.create( + user=user, key=ACCOUNT_VISIBILITY_PREF_KEY, value=CUSTOM_VISIBILITY, + ) + UserPreferenceFactory.create( + user=user, key=f"{VISIBILITY_PREFIX}bio", value=ALL_USERS_VISIBILITY, + ) + assert client.login(username=learner.username, password=PASSWORD) + warm_up(client) + return users + + +def selected(users): + """Return the selector naming every account of the fixture.""" + return ",".join(user.username for user in users) + + +@pytest.mark.django_db +@pytest.mark.parametrize("accounts", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_account_list_query_count_does_not_grow_with_the_accounts_selected( + client, accounts, django_assert_num_queries, +): + url = reverse("user_v2:account_list") + with django_assert_num_queries(ACCOUNT_LIST_QUERIES[1]): + response = client.get(url, {"username": selected(accounts), "page_size": 1}) + assert response.json()["count"] == len(accounts) + assert len(response.json()["results"]) == 1 + + +@pytest.mark.django_db +@pytest.mark.parametrize("page_size", PAGE_SIZES) +@pytest.mark.parametrize("accounts", [25], indirect=True) +@skip_unless_lms +def test_the_page_costs_what_it_carries_and_not_what_the_collection_holds( + client, accounts, page_size, django_assert_num_queries, +): + url = reverse("user_v2:account_list") + with django_assert_num_queries(ACCOUNT_LIST_QUERIES[page_size]): + response = client.get(url, {"username": selected(accounts), "page_size": page_size}) + assert response.json()["count"] == 25 + assert len(response.json()["results"]) == page_size + + +@pytest.mark.django_db +@pytest.mark.parametrize("page_size", PAGE_SIZES) +@pytest.mark.parametrize("accounts_read_by_a_learner", [25], indirect=True) +@skip_unless_lms +def test_a_learner_reading_other_accounts_pays_for_the_page_and_not_the_collection( + client, accounts_read_by_a_learner, page_size, django_assert_num_queries, +): + """What a caller may see of an account is worked out for the accounts of the page alone.""" + url = reverse("user_v2:account_list") + with django_assert_num_queries(LEARNER_LIST_QUERIES[page_size]): + response = client.get( + url, {"username": selected(accounts_read_by_a_learner), "page_size": page_size}, + ) + assert response.json()["count"] == 25 + assert len(response.json()["results"]) == page_size + assert "email" not in response.json()["results"][0] + + +@pytest.mark.django_db +@pytest.mark.parametrize("page_size", PAGE_SIZES) +@pytest.mark.parametrize("accounts_shared_by_choice", [25], indirect=True) +@skip_unless_lms +def test_an_account_shared_property_by_property_costs_one_read_more( + client, accounts_shared_by_choice, page_size, django_assert_num_queries, +): + """An owner who chose what they share is asked for that choice once, not once per property.""" + url = reverse("user_v2:account_list") + with django_assert_num_queries(CHOSEN_VISIBILITY_LIST_QUERIES[page_size]): + response = client.get( + url, {"username": selected(accounts_shared_by_choice), "page_size": page_size}, + ) + assert response.json()["count"] == 25 + assert len(response.json()["results"]) == page_size + assert "bio" in response.json()["results"][0] + + +@pytest.mark.django_db +@pytest.mark.parametrize("accounts", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_the_earlier_account_list_pays_for_every_account_named( + client, accounts, django_assert_num_queries, +): + """ + Both lists are measured over the same accounts, so the saving cannot drift apart. + + The earlier address serves every account named in one response and pays + the per-account cost for all of them; this one pays it for the accounts of + the page alone. + """ + # The two pins together say that the earlier cost grows with the accounts + # named while the page's does not, so neither can be changed on its own. + assert EARLIER_ACCOUNT_LIST_QUERIES[25] > EARLIER_ACCOUNT_LIST_QUERIES[1] + assert ACCOUNT_LIST_QUERIES[1] < EARLIER_ACCOUNT_LIST_QUERIES[25] + + with django_assert_num_queries(EARLIER_ACCOUNT_LIST_QUERIES[len(accounts)]): + earlier = client.get(EARLIER_ACCOUNT_LIST, {"username": selected(accounts)}) + assert len(earlier.json()) == len(accounts) + + with django_assert_num_queries(ACCOUNT_LIST_QUERIES[1]): + current = client.get( + reverse("user_v2:account_list"), {"username": selected(accounts), "page_size": 1}, + ) + assert current.json()["count"] == len(accounts) + + +@pytest.mark.django_db +@pytest.mark.parametrize("accounts", [5], indirect=True) +@skip_unless_lms +def test_member_query_counts(client, accounts, django_assert_num_queries): + with django_assert_num_queries(ACCOUNT_DETAIL_QUERIES): + response = client.get(reverse("user_v2:account_detail", kwargs={"username": "counted000"})) + assert response.json()["username"] == "counted000" + + with django_assert_num_queries(OWN_ACCOUNT_QUERIES): + own = client.get(reverse("user_v2:account_detail", kwargs={"username": "me"})) + assert own.json()["username"] == "count-staff" + + +@pytest.mark.django_db +@pytest.mark.parametrize("accounts", [1], indirect=True) +@skip_unless_lms +def test_the_callers_own_account_costs_more_here_than_the_name_alone_did( + client, accounts, django_assert_num_queries, +): + """ + Declared difference: this address answers with the whole account. + + The earlier address answered with the username, which it had already, and + asking this one for that property alone shapes the answer rather than the + work behind it: the account is built and then reduced, so the selection + costs what the whole account costs. + """ + with django_assert_num_queries(EARLIER_OWN_ACCOUNT_QUERIES): + earlier = client.get(EARLIER_OWN_ACCOUNT) + assert earlier.json() == {"username": "count-staff"} + + with django_assert_num_queries(OWN_ACCOUNT_QUERIES): + whole = client.get(reverse("user_v2:account_detail", kwargs={"username": "me"})) + assert whole.json()["username"] == "count-staff" + assert set(whole.json()) > set(earlier.json()) + + with django_assert_num_queries(OWN_ACCOUNT_QUERIES): + one_property = client.get( + reverse("user_v2:account_detail", kwargs={"username": "me"}), {"fields": "username"}, + ) + assert one_property.json() == earlier.json() + assert OWN_ACCOUNT_QUERIES > EARLIER_OWN_ACCOUNT_QUERIES + + +@pytest.mark.django_db +@pytest.mark.parametrize("accounts", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_email_search_query_count_does_not_grow_with_the_addresses_searched( + client, accounts, django_assert_num_queries, +): + with django_assert_num_queries(EMAIL_SEARCH_QUERIES): + response = client.post( + reverse("user_v2:email_search_list"), + {"emails": [user.email for user in accounts]}, + content_type="application/json", + ) + assert response.json()["count"] == len(accounts) + + +@pytest.fixture(name="account_owner") +@skip_unless_lms +def account_owner_fixture(client, django_user_model): # pylint: disable=unused-argument + """Sign a learner in whose own account is the one changed.""" + owner = UserFactory.create( + username="count-change-owner", + password=PASSWORD, + profile__year_of_birth=MEASURED_YEAR_OF_BIRTH, + ) + assert client.login(username=owner.username, password=PASSWORD) + warm_up(client) + return owner + + +@pytest.fixture(name="deactivation_targets") +@skip_unless_lms +def deactivation_fixture(client, django_user_model): # pylint: disable=unused-argument + """Sign a caller in who may deactivate accounts, beside one account per version to deactivate.""" + deactivator = UserFactory.create(username="count-deactivator", password=PASSWORD) + deactivator.user_permissions.add( + PermissionFactory( + codename="can_deactivate_users", + content_type=ContentTypeFactory(app_label="student"), + ), + ) + targets = ( + UserFactory.create(username="count-target-one", password=PASSWORD), + UserFactory.create(username="count-target-two", password=PASSWORD), + ) + assert client.login(username=deactivator.username, password=PASSWORD) + warm_up(client) + return targets + + +@pytest.mark.django_db +@skip_unless_lms +def test_a_change_costs_what_the_earlier_address_costs( + client, account_owner, django_assert_num_queries, +): + """A property is changed for the same reads on both addresses, and each answers with the account.""" + with django_assert_num_queries(CHANGE_QUERIES): + earlier = client.patch( + f"/api/user/v1/accounts/{account_owner.username}", + json.dumps({"bio": "Earlier bio"}), + content_type=MERGE_PATCH, + ) + assert earlier.json()["bio"] == "Earlier bio" + + with django_assert_num_queries(CHANGE_QUERIES): + later = client.patch( + reverse("user_v2:account_detail", kwargs={"username": account_owner.username}), + json.dumps({"bio": "Later bio"}), + content_type=MERGE_PATCH, + ) + assert later.json()["bio"] == "Later bio" + + +@pytest.mark.django_db +@skip_unless_lms +def test_a_deactivation_costs_what_the_earlier_address_costs( + client, deactivation_targets, django_assert_num_queries, +): + """ + Each version is measured over an account of its own, so neither pays for the other's work. + + The earlier address answers with the account it deactivated; this one + answers with nothing, which is the difference between the two numbers. + """ + earlier_target, target = deactivation_targets + # The saving is the account the earlier address reads back to answer with, + # so neither number can be changed without the other. + assert DEACTIVATION_QUERIES < EARLIER_DEACTIVATION_QUERIES + + with django_assert_num_queries(EARLIER_DEACTIVATION_QUERIES): + earlier = client.post(f"/api/user/v1/accounts/{earlier_target.username}/deactivate/") + assert earlier.status_code == 200 + earlier_target.refresh_from_db() + assert not earlier_target.has_usable_password() + + with django_assert_num_queries(DEACTIVATION_QUERIES): + later = client.post( + reverse("user_v2:account_deactivation", kwargs={"username": target.username}), + ) + assert later.status_code == 204 + target.refresh_from_db() + assert not target.has_usable_password() diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py new file mode 100644 index 000000000000..a61f57d7cf7e --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_account_views.py @@ -0,0 +1,3303 @@ +"""Tests for the version 2 User API account views.""" + +import datetime +import json +from unittest import mock +from urllib.parse import parse_qs, urlparse + +from django.conf import settings +from django.contrib.auth.models import User # pylint: disable=imported-auth-user +from django.db import DatabaseError, connection +from django.test.utils import CaptureQueriesContext, override_settings +from django.urls import reverse +from edx_rest_framework_extensions.scoping import FullScopePolicy +from edx_rest_framework_extensions.testing import assert_error_envelope +from rest_framework import status +from rest_framework.permissions import BasePermission +from rest_framework.test import APITestCase +from waffle.testutils import override_switch + +from common.djangoapps.student.models import PendingNameChange, UserProfile, get_retired_email_by_email +from common.djangoapps.student.tests.factories import ( + ContentTypeFactory, + PermissionFactory, + SuperuserFactory, + UserFactory, +) +from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user +from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration_context +from openedx.core.djangoapps.user_api.accounts.utils import ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH +from openedx.core.djangoapps.user_api.models import RetirementState, UserRetirementStatus +from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory +from openedx.core.djangoapps.user_api.v2 import views +from openedx.core.djangoapps.user_api.v2.permissions import ACCOUNT_CONTACT_SELECTORS +from openedx.core.djangoapps.user_api.v2.serializers import ACCOUNT_DEFAULT_SORT, AccountQuerySerializer +from openedx.core.djangoapps.user_api.v2.views import AMBIGUOUS_ACCOUNT_ADDRESS, EmailSearchViewSet +from openedx.core.djangoapps.user_authn.views.password_reset import PasswordResetFormNoActive +from openedx.core.djangolib.testing.utils import skip_unless_lms + +from .test_schema import name_affirmation_answering +from .test_views import PAGINATION_KEYS, write_queries, write_target + +PASSWORD = "Password1234" + +#: The properties every caller may see, whoever they are. +PUBLIC_FIELDS = {"account_privacy", "profile_image", "username"} + +#: ``ACCOUNT_VISIBILITY_CONFIGURATION`` is an LMS setting, and this package is +#: shared with the CMS. Every test below that reads these sets is +#: ``@skip_unless_lms``, but a module body runs before any skip can apply, so a +#: missing setting is tolerated here instead of breaking collection under the +#: CMS settings. The same guard is used for the same setting in +#: ``user_api/accounts/tests/test_views.py``. +_VISIBILITY = getattr(settings, "ACCOUNT_VISIBILITY_CONFIGURATION", {}) + +#: The properties a user sees of their own account, and a staff caller of any. +FULL_FIELDS = set(_VISIBILITY.get("admin_fields", ())) + +#: The properties a learner sees of an account whose owner shares it with everybody. +SHARED_FIELDS = set(_VISIBILITY.get("bulk_shareable_fields", ())) + +#: The properties an owner is able to share, and so the only ones another caller +#: may select. A refusal about somebody else's account names none of them; one +#: about an answer the caller narrowed itself offers them in this order. +SHAREABLE_FIELDS = set(_VISIBILITY.get("custom_shareable_fields", ())) +SHAREABLE_FIELDS_OFFERED = ", ".join(sorted(SHAREABLE_FIELDS)) + +#: The identifying properties the ``minimal`` preset keeps. +MINIMAL_FIELDS = {"username", "email", "name", "profile_image"} + +#: The query parameters each account action reads, in the order its refusal lists them. +LIST_QUERY_PARAMS = "email, fields, format, lms_user_id, ordering, page, page_size, username, view" +MEMBER_QUERY_PARAMS = "fields, format, view" +ACTION_QUERY_PARAMS = "format, page, page_size" + +#: A deactivation carries no page, so it reads only the representation parameter. +DEACTIVATION_QUERY_PARAMS = "format" + +MERGE_PATCH = "application/merge-patch+json" + +#: Where the classes the platform declares record that they read a request. +DEFAULT_AUTH_RECORDER = "openedx.core.djangolib.default_auth_classes.set_custom_attribute" + +#: Where the account views look an account up, which the database answers. A +#: test that stands in for the database answers it from here. +ACCOUNT_LOOKUP = "openedx.core.djangoapps.user_api.v2.views.get_account_settings" + +class RefusesEveryCaller(BasePermission): + """A rule that admits nobody, added to a declaration to see how far it reaches.""" + + def has_permission(self, request, view): + """Refuse every caller.""" + return False + + +def refusing_account_resource(): + """Add a rule admitting nobody to the classes the account resource is read with.""" + return mock.patch.object( + views, + "ACCOUNT_RESOURCE_PERMISSIONS", + (*views.ACCOUNT_RESOURCE_PERMISSIONS, RefusesEveryCaller), + ) + + +def counting_cache(location): + """ + Return a cache that counts what it is given, under a name of its own. + + The cache the test settings configure discards everything, so a rate limit + never counts past its first request and the path it guards is never + reached. Each test names its own cache, because the counts outlive the + test that made them. + """ + return { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": location, + }, + } + + +class AccountApiV2TestCase(APITestCase): + """Fixtures shared by the version 2 account tests.""" + + def setUp(self): + super().setUp() + self.staff = UserFactory.create( + username="staff-caller", email="staff-caller@example.com", password=PASSWORD, is_staff=True, + ) + self.owner = UserFactory.create( + username="owner-caller", email="owner-caller@example.com", password=PASSWORD, + ) + self.other = UserFactory.create( + username="other-caller", email="other-caller@example.com", password=PASSWORD, + ) + self.list_url = reverse("user_v2:account_list") + self.own_url = reverse("user_v2:account_detail", kwargs={"username": "me"}) + + def login(self, user): + """Sign the given user in through the session these views accept.""" + assert self.client.login(username=user.username, password=PASSWORD) + + def detail_url(self, username): + """Return the member address of one account.""" + return reverse("user_v2:account_detail", kwargs={"username": username}) + + def token_headers(self, user): + """Return the request headers that carry a token identifying the given user.""" + return {"HTTP_AUTHORIZATION": f"JWT {create_jwt_for_user(user)}"} + + def usernames_in(self, response): + """Return the usernames carried by a page of accounts.""" + return [row["username"] for row in response.data["results"]] + + def patch(self, address, body, **kwargs): + """Send a merge patch, the only body these views read.""" + return self.client.patch(address, body, content_type=MERGE_PATCH, **kwargs) + + +@skip_unless_lms +class AccountListViewTests(AccountApiV2TestCase): + """GET /api/user/v2/accounts/""" + + def test_a_caller_with_no_selector_receives_their_own_account(self): + self.login(self.owner) + response = self.client.get(self.list_url) + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] == 1 + assert self.usernames_in(response) == ["owner-caller"] + assert set(response.data["results"][0]) == FULL_FIELDS + + def test_the_page_carries_the_whole_envelope(self): + self.login(self.staff) + response = self.client.get(self.list_url) + assert set(response.data) == PAGINATION_KEYS + assert response.data["count"] == 1 + assert response.data["num_pages"] == 1 + assert response.data["current_page"] == 1 + assert response.data["start"] == 0 + assert response.data["next"] is None + assert response.data["previous"] is None + + def test_named_accounts_come_back_in_one_page(self): + self.login(self.staff) + response = self.client.get(self.list_url, {"username": "other-caller,owner-caller"}) + assert response.status_code == status.HTTP_200_OK + assert self.usernames_in(response) == ["other-caller", "owner-caller"] + + def test_the_page_is_ordered_by_username_and_the_order_can_be_reversed(self): + self.login(self.staff) + selection = {"username": "owner-caller,other-caller,staff-caller"} + ascending = self.client.get(self.list_url, selection) + assert self.usernames_in(ascending) == ["other-caller", "owner-caller", "staff-caller"] + + descending = self.client.get(self.list_url, {**selection, "ordering": "-username"}) + assert self.usernames_in(descending) == ["staff-caller", "owner-caller", "other-caller"] + + def test_the_page_is_sorted_by_username_when_no_order_is_asked_for(self): + """The order the parameter publishes as its default is the one a page comes back in.""" + self.login(self.staff) + selection = {"username": "owner-caller,other-caller,staff-caller"} + omitted = self.client.get(self.list_url, selection) + named = self.client.get(self.list_url, {**selection, "ordering": ACCOUNT_DEFAULT_SORT}) + blank = self.client.get(self.list_url, {**selection, "ordering": ""}) + assert self.usernames_in(omitted) == ["other-caller", "owner-caller", "staff-caller"] + assert self.usernames_in(named) == self.usernames_in(omitted) + assert self.usernames_in(blank) == self.usernames_in(omitted) + + def test_a_property_the_list_does_not_sort_by_is_refused(self): + """ + A sort the list does not offer is refused rather than quietly applied. + + Any other column of the user table would otherwise order the page, so + a caller who may read nothing but usernames could read the order of a + column they may not be answered with. + """ + self.login(self.staff) + for asked in ("bogus", "password", "date_joined", "-date_joined", "username,-username"): + with self.subTest(ordering=asked): + response = self.client.get( + self.list_url, {"username": "owner-caller,other-caller", "ordering": asked}, + ) + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "ordering": ["Sort by any of: username, each optionally prefixed with '-'."], + } + assert "results" not in envelope + + def test_a_name_sent_with_spaces_around_it_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_an_account_the_lookup_resolved_a_name_to_is_carried_by_the_page(self): + """ + A page carries every account its lookup came back with. + + The rows are put back in the order the names were asked for by the name + each account is stored under. An account stored under a name none of + them is written as is carried at the end of the page rather than + dropped from it in silence. + """ + def one_account_written_apart(request, usernames=None, **kwargs): # pylint: disable=unused-argument + assert usernames == ["owner-caller"] + return [{"username": "owner-caller ", "email": "owner-caller@example.com"}] + + self.login(self.staff) + with mock.patch(ACCOUNT_LOOKUP, one_account_written_apart): + response = self.client.get(self.list_url, {"username": "owner-caller"}) + assert response.status_code == status.HTTP_200_OK + assert response.data["results"] == [ + {"username": "owner-caller ", "email": "owner-caller@example.com"}, + ] + + def test_a_selector_that_names_no_account_answers_an_empty_page(self): + """A collection that matched nothing is an empty page, not an absent collection.""" + self.login(self.staff) + response = self.client.get(self.list_url, {"username": "nobody"}) + assert response.status_code == status.HTTP_200_OK + assert set(response.data) == PAGINATION_KEYS + assert response.data["count"] == 0 + assert response.data["results"] == [] + assert response.data["num_pages"] == 1 + assert response.data["next"] is None + assert response.data["previous"] is None + + def test_an_empty_page_reads_no_account(self): + """Nothing was selected, so no account is read to fill the page.""" + self.login(self.owner) + with CaptureQueriesContext(connection) as captured: + response = self.client.get(self.list_url, {"username": "nobody"}) + assert response.data["results"] == [] + assert [query for query in captured.captured_queries if "auth_userprofile" in query["sql"]] == [] + + def test_a_learner_sees_only_the_shared_properties_of_another_account(self): + self.login(self.owner) + response = self.client.get(self.list_url, {"username": "other-caller"}) + assert response.status_code == status.HTTP_200_OK + assert set(response.data["results"][0]) == PUBLIC_FIELDS + assert response.data["results"][0]["username"] == "other-caller" + + def test_a_staff_caller_sees_every_property_of_another_account(self): + self.login(self.staff) + response = self.client.get(self.list_url, {"username": "other-caller"}) + assert set(response.data["results"][0]) == FULL_FIELDS + assert response.data["results"][0]["email"] == "other-caller@example.com" + + def test_a_page_mixing_the_caller_and_others_shapes_each_row_for_its_own_owner(self): + self.login(self.owner) + response = self.client.get(self.list_url, {"username": "owner-caller,other-caller"}) + rows = {row["username"]: set(row) for row in response.data["results"]} + assert rows == {"other-caller": PUBLIC_FIELDS, "owner-caller": FULL_FIELDS} + + def test_the_shared_preset_narrows_the_callers_own_account(self): + self.login(self.owner) + response = self.client.get(self.list_url, {"view": "shared"}) + assert set(response.data["results"][0]) == PUBLIC_FIELDS + + def test_the_minimal_preset_keeps_the_identifying_properties(self): + self.login(self.owner) + response = self.client.get(self.list_url, {"view": "minimal"}) + assert set(response.data["results"][0]) == {"username", "email", "name", "profile_image"} + + def test_the_minimal_preset_cannot_hand_out_a_property_the_caller_may_not_see(self): + self.login(self.owner) + response = self.client.get(self.list_url, {"username": "other-caller", "view": "minimal"}) + assert set(response.data["results"][0]) == {"username", "profile_image"} + + def test_a_learner_selecting_an_identifying_property_they_may_not_see_is_refused(self): + """The preset keeps the identifying properties; this caller may be answered with some of them.""" + self.login(self.owner) + response = self.client.get( + self.list_url, {"username": "other-caller", "view": "minimal", "fields": "email"}, + ) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"]["fields"] == [ + "No such field: email. An account another user holds carries only the " + "properties its owner shares.", + ] + + def test_a_caller_selects_an_identifying_property_of_their_own_account(self): + self.login(self.owner) + response = self.client.get(self.list_url, {"view": "minimal", "fields": "email"}) + assert response.status_code == status.HTTP_200_OK + assert response.data["results"] == [{"email": "owner-caller@example.com"}] + + def test_selected_fields_narrow_each_row(self): + self.login(self.owner) + response = self.client.get(self.list_url, {"fields": "username,email"}) + assert response.data["results"] == [ + {"username": "owner-caller", "email": "owner-caller@example.com"}, + ] + + def test_a_field_the_response_cannot_carry_is_refused(self): + self.login(self.owner) + response = self.client.get(self.list_url, {"fields": "username,wallet"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"]["fields"][0].startswith("No such field: wallet.") + + def test_a_learner_selecting_a_property_of_another_account_they_may_not_see_is_refused(self): + """A property this caller's page cannot carry is refused, not answered as an empty object.""" + self.login(self.owner) + response = self.client.get(self.list_url, {"username": "other-caller", "fields": "email"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"]["fields"] == [ + "No such field: email. An account another user holds carries only the " + "properties its owner shares.", + ] + + def test_a_learner_selecting_a_property_this_owner_does_not_share_receives_the_rest(self): + """ + A property an owner is able to share can be selected; whether they share it is their own choice. + + This owner shares nothing beyond the properties every caller may see, + so the row carries the rest of the selection rather than the property + asked for. + """ + self.login(self.owner) + response = self.client.get(self.list_url, {"username": "other-caller", "fields": "bio,username"}) + assert response.status_code == status.HTTP_200_OK + assert response.data["results"] == [{"username": "other-caller"}] + + def test_a_staff_caller_still_selects_a_property_only_they_may_see(self): + self.login(self.staff) + response = self.client.get(self.list_url, {"username": "other-caller", "fields": "email"}) + assert response.status_code == status.HTTP_200_OK + assert response.data["results"] == [{"email": "other-caller@example.com"}] + + def test_a_caller_still_selects_a_property_only_they_may_see_of_their_own_account(self): + self.login(self.owner) + response = self.client.get(self.list_url, {"username": "owner-caller", "fields": "email"}) + assert response.status_code == status.HTTP_200_OK + assert response.data["results"] == [{"email": "owner-caller@example.com"}] + + def test_a_property_the_shared_preset_leaves_out_is_refused_with_the_preset_asked_for(self): + """``shared`` answers with what the owner shares, so only those properties can be selected.""" + self.login(self.staff) + response = self.client.get( + self.list_url, {"username": "other-caller", "view": "shared", "fields": "email"}, + ) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"]["fields"] == [ + f"No such field: email. Select any of: {SHAREABLE_FIELDS_OFFERED}.", + ] + + def test_a_preset_the_endpoint_does_not_offer_is_refused(self): + self.login(self.owner) + response = self.client.get(self.list_url, {"view": "everything"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "view": ["Ask for any of: minimal, shared, or omit this parameter for the default response."], + } + + def test_a_query_parameter_the_endpoint_does_not_read_is_refused(self): + self.login(self.owner) + response = self.client.get(self.list_url, {"usernames": "owner-caller"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "usernames": [f"No such query parameter. Send any of: {LIST_QUERY_PARAMS}."], + } + + def test_a_page_that_does_not_exist_is_not_found(self): + self.login(self.staff) + response = self.client.get(self.list_url, {"page": 7}) + assert_error_envelope( + response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + + def test_the_collection_is_split_into_pages(self): + self.login(self.staff) + selection = {"username": "owner-caller,other-caller,staff-caller", "page_size": 2} + first = self.client.get(self.list_url, selection) + assert first.data["count"] == 3 + assert first.data["num_pages"] == 2 + assert self.usernames_in(first) == ["other-caller", "owner-caller"] + assert "page=2" in first.data["next"] + + second = self.client.get(self.list_url, {**selection, "page": 2}) + assert self.usernames_in(second) == ["staff-caller"] + assert second.data["start"] == 2 + + def test_an_anonymous_caller_is_unauthenticated(self): + response = self.client.get(self.list_url) + assert_error_envelope( + response, expected_status=status.HTTP_401_UNAUTHORIZED, expected_type_slug="authn", + ) + + +@skip_unless_lms +class AccountListSelectorTests(AccountApiV2TestCase): + """The selectors of GET /api/user/v2/accounts/ that only staff may use.""" + + def test_a_staff_caller_selects_an_account_by_email_address(self): + self.login(self.staff) + response = self.client.get(self.list_url, {"email": "other-caller@example.com"}) + assert response.status_code == status.HTTP_200_OK + assert self.usernames_in(response) == ["other-caller"] + + def test_a_staff_caller_selects_an_account_by_identifier(self): + self.login(self.staff) + response = self.client.get(self.list_url, {"lms_user_id": self.other.id}) + assert response.status_code == status.HTTP_200_OK + assert self.usernames_in(response) == ["other-caller"] + + def test_the_selectors_a_learner_may_not_use_are_the_ones_the_privilege_is_asked_for(self): + """Every selector the list documents as staff-only is one the permission classes gate.""" + documented = { + name for name, field in AccountQuerySerializer().fields.items() + if "Only staff" in (field.help_text or "") + } + assert documented == {"email", "lms_user_id"} + assert set(ACCOUNT_CONTACT_SELECTORS) == documented + + def test_a_staff_caller_selecting_an_address_more_than_one_account_holds_receives_them_all(self): + """ + A page carries every account holding the address it was given. + + Nothing keeps one email address to one account, so an address can be + held by more than one of them. The page of accounts holding an address + is what the search over addresses answers with as well, and it is the + answer here whether the address is held once or several times. + """ + UserFactory.create( + username="owner-second", email="owner-caller@example.com", password=PASSWORD, + ) + self.login(self.staff) + response = self.client.get(self.list_url, {"email": "owner-caller@example.com"}) + assert response.status_code == status.HTTP_200_OK, response.content + assert response.data["count"] == 2 + assert self.usernames_in(response) == ["owner-caller", "owner-second"] + + def test_a_learner_may_not_select_by_email_address(self): + self.login(self.owner) + response = self.client.get(self.list_url, {"email": "other-caller@example.com"}) + assert_error_envelope( + response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + + def test_a_learner_may_not_select_by_identifier(self): + self.login(self.owner) + response = self.client.get(self.list_url, {"lms_user_id": self.other.id}) + assert_error_envelope( + response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + + def test_a_learner_may_not_select_their_own_account_by_email_address_either(self): + """The selector is the staff-only part, whoever the account it names belongs to.""" + self.login(self.owner) + response = self.client.get(self.list_url, {"email": "owner-caller@example.com"}) + assert_error_envelope( + response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + + def test_every_selector_that_finds_no_account_answers_the_same_empty_page(self): + """ + A selection no account matches is an empty page, whichever selector was given. + + A caller filtering this collection reads one answer for a selection + that matched nothing, instead of having to tell an empty page from a + refusal by which parameter it sent. + """ + self.login(self.staff) + selections = ( + {"username": "nobody"}, + {"email": "nobody@example.com"}, + {"lms_user_id": 999999}, + ) + for selection in selections: + with self.subTest(**selection): + response = self.client.get(self.list_url, selection) + assert response.status_code == status.HTTP_200_OK, response.content + assert set(response.data) == PAGINATION_KEYS + assert response.data["count"] == 0 + assert response.data["results"] == [] + assert response.data["next"] is None + + def test_an_identifier_that_is_not_a_number_is_refused(self): + self.login(self.staff) + response = self.client.get(self.list_url, {"lms_user_id": "nope"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == {"lms_user_id": ["A valid integer is required."]} + + def test_an_address_that_is_not_an_email_address_is_refused(self): + self.login(self.staff) + response = self.client.get(self.list_url, {"email": "not-an-address"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == {"email": ["Enter a valid email address."]} + + def test_names_are_read_before_the_other_selectors(self): + self.login(self.staff) + response = self.client.get( + self.list_url, {"username": "owner-caller", "email": "other-caller@example.com"}, + ) + assert self.usernames_in(response) == ["owner-caller"] + + +@skip_unless_lms +class RetiredAccountTests(AccountApiV2TestCase): + """An email address an account held before it was retired.""" + + def setUp(self): + super().setUp() + self.login(self.staff) + self.retired_email = self.other.email + + def retire(self): + """Replace the account's address with its retired form, as retirement does.""" + self.other.email = get_retired_email_by_email(self.retired_email) + self.other.save() + + def test_the_address_of_a_retired_account_is_not_found(self): + self.retire() + response = self.client.get(self.list_url, {"email": self.retired_email}) + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="user/retired-account", + ) + assert envelope["title"] == "Retired Account" + assert envelope["can_cancel_retirement"] is False + assert envelope["retirement_id"] is None + + def test_reading_the_address_of_a_retired_account_writes_nothing(self): + """The record the refusal names is read, and the refusal leaves it as it was.""" + RetirementState.objects.create(state_name="PENDING", state_execution_order=1) + UserRetirementStatus.create_retirement(self.other) + self.retire() + with CaptureQueriesContext(connection) as captured: + with self.captureOnCommitCallbacks(execute=True): + response = self.client.get(self.list_url, {"email": self.retired_email}) + assert response.status_code == status.HTTP_404_NOT_FOUND + assert write_queries(captured.captured_queries) == [] + + def test_a_retirement_still_inside_the_cooling_off_period_can_be_cancelled(self): + RetirementState.objects.create(state_name="PENDING", state_execution_order=1) + retirement = UserRetirementStatus.create_retirement(self.other) + self.retire() + response = self.client.get(self.list_url, {"email": self.retired_email}) + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="user/retired-account", + ) + assert envelope["can_cancel_retirement"] is True + assert envelope["retirement_id"] == retirement.id + + def test_an_address_held_and_left_more_than_once_reports_the_most_recent_retirement(self): + """ + Where one address has been held and left more than once, the retirement reported is the last. + + Only the most recent retirement of an address is still open to being + undone, so that is the one the refusal names. The second account is + given the address outright rather than registered with it, because an + address an account held before it was retired is refused to a new one. + Each account here is this test's own: what retirement leaves behind of + an account cannot be shared with another test. + """ + RetirementState.objects.create(state_name="PENDING", state_execution_order=1) + address = "held-and-left-twice@example.com" + first = UserFactory.create(username="first-holder", email=address, password=PASSWORD) + earliest = UserRetirementStatus.create_retirement(first) + first.email = get_retired_email_by_email(address) + first.save() + second = UserFactory.create( + username="second-holder", email="second-holder@example.com", password=PASSWORD, + ) + User.objects.filter(pk=second.pk).update(email=address) + second.refresh_from_db() + latest = UserRetirementStatus.create_retirement(second) + second.email = get_retired_email_by_email(address) + second.save() + assert sorted( + UserRetirementStatus.objects.filter(original_email=address).values_list("id", flat=True), + ) == sorted([earliest.id, latest.id]) + + envelope = assert_error_envelope( + self.client.get(self.list_url, {"email": address}), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="user/retired-account", + ) + assert envelope["can_cancel_retirement"] is True + assert envelope["retirement_id"] == latest.id + + def test_a_retirement_older_than_the_cooling_off_period_can_no_longer_be_cancelled(self): + RetirementState.objects.create(state_name="PENDING", state_execution_order=1) + retirement = UserRetirementStatus.create_retirement(self.other) + long_ago = datetime.datetime.now(datetime.UTC) - datetime.timedelta( + days=settings.COOL_OFF_DAYS + 1, + ) + UserRetirementStatus.objects.filter(pk=retirement.pk).update(created=long_ago) + self.retire() + envelope = assert_error_envelope( + self.client.get(self.list_url, {"email": self.retired_email}), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="user/retired-account", + ) + assert envelope["can_cancel_retirement"] is False + assert envelope["retirement_id"] is None + + +@skip_unless_lms +class AccountDetailViewTests(AccountApiV2TestCase): + """GET /api/user/v2/accounts/{username}/ and its ``me`` form.""" + + def test_a_caller_reads_their_own_account_through_the_reserved_identifier(self): + self.login(self.owner) + response = self.client.get(self.own_url) + assert response.status_code == status.HTTP_200_OK + assert response.data["username"] == "owner-caller" + assert set(response.data) == FULL_FIELDS + + def test_the_reserved_identifier_names_the_caller_and_not_the_account_holding_it(self): + """``me`` addresses the caller, so an account holding that name is read through the list.""" + UserFactory.create(username="me", email="me@example.com", password=PASSWORD) + self.login(self.staff) + assert self.client.get(self.own_url).data["username"] == "staff-caller" + + named = self.client.get(self.list_url, {"username": "me"}) + assert named.status_code == status.HTTP_200_OK + assert self.usernames_in(named) == ["me"] + assert named.data["results"][0]["email"] == "me@example.com" + + def test_an_account_named_after_another_collection_is_served_at_its_own_address(self): + """ + A username is whatever its owner registered, and the collections beside this one take none away. + + ``email_searches`` is a name a user may hold, so the member address + carrying it belongs to that account: its owner reads and changes it + there, a staff caller reads it there, and another learner is answered + the properties that account shares rather than told it is absent. + """ + held = UserFactory.create( + username="email_searches", email="email-searches@example.com", password=PASSWORD, + ) + address = self.detail_url("email_searches") + + self.login(held) + own = self.client.get(address) + assert own.status_code == status.HTTP_200_OK + assert own.data["username"] == "email_searches" + assert own.data["email"] == "email-searches@example.com" + changed = self.patch(address, json.dumps({"bio": "named for a search"})) + assert changed.status_code == status.HTTP_200_OK + assert changed.data["bio"] == "named for a search" + self.client.logout() + + self.login(self.staff) + by_staff = self.client.get(address) + assert by_staff.status_code == status.HTTP_200_OK + assert by_staff.data["username"] == "email_searches" + named = self.client.get(self.list_url, {"username": "email_searches"}) + assert self.usernames_in(named) == ["email_searches"] + assert named.data["results"][0]["email"] == "email-searches@example.com" + self.client.logout() + + self.login(self.other) + by_a_learner = self.client.get(address) + assert by_a_learner.status_code == status.HTTP_200_OK + assert set(by_a_learner.data) == PUBLIC_FIELDS + assert by_a_learner.data["username"] == "email_searches" + + def test_a_member_address_a_stored_account_answers_to_carries_that_account(self): + """ + An address the lookup resolves to a stored account is answered with that account. + + Which stored names an address resolves to is the database's decision, + and the name it resolves to can be written differently from the one the + address carried. The test database resolves a name only to the record + stored under exactly it, so the account service is answered here the + way it answers where an address resolves more widely. + """ + def stored_under_another_spelling(request, usernames=None, **kwargs): # pylint: disable=unused-argument + assert usernames == ["Owner-Caller"] + return [{"username": "owner-caller", "email": "owner-caller@example.com"}] + + self.login(self.staff) + with mock.patch(ACCOUNT_LOOKUP, stored_under_another_spelling): + response = self.client.get(self.detail_url("Owner-Caller")) + assert response.status_code == status.HTTP_200_OK + assert response.data == {"username": "owner-caller", "email": "owner-caller@example.com"} + + def test_a_member_address_resolving_to_a_name_written_further_apart_carries_that_account(self): + """ + An address resolving to a record whose name shares less with it is answered with that record. + + The name asked for is matched to the record the lookup came back with + so that a page carries its rows in the order they were asked for. At a + member address the record the lookup resolved to is the answer whatever + it is stored under, which is what the superseded version answers. + """ + def stored_under_a_name_written_further_apart(request, usernames=None, **kwargs): # pylint: disable=unused-argument + assert usernames == ["owner-caller "] + return [{"username": "owner-caller", "email": "owner-caller@example.com"}] + + self.login(self.staff) + with mock.patch(ACCOUNT_LOOKUP, stored_under_a_name_written_further_apart): + response = self.client.get(self.detail_url("owner-caller ")) + assert response.status_code == status.HTTP_200_OK + assert response.data == {"username": "owner-caller", "email": "owner-caller@example.com"} + + def test_a_member_address_the_lookup_resolves_to_nothing_is_reported_as_absent(self): + """A lookup that comes back with no account is an absent address rather than a fault.""" + self.login(self.staff) + with mock.patch(ACCOUNT_LOOKUP, return_value=[]): + response = self.client.get(self.detail_url("owner-caller")) + assert_error_envelope( + response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + + def test_a_member_address_more_than_one_account_answers_to_is_refused(self): + """ + An address resolving to several accounts names none of them, so it is refused. + + Answering with one of them would hand the caller an account they did + not address, and which one it was would be settled by the order the + records came back in. + """ + def two_accounts(request, usernames=None, **kwargs): # pylint: disable=unused-argument + return [ + {"username": "owner-caller", "email": "owner-caller@example.com"}, + {"username": "Owner-Caller", "email": "second@example.com"}, + ] + + self.login(self.staff) + with mock.patch(ACCOUNT_LOOKUP, two_accounts): + response = self.client.get(self.detail_url("owner-caller ")) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="user/ambiguous-account-name", + ) + assert envelope["title"] == "Ambiguous Account Name" + assert envelope["errors"] == {"non_field_errors": [AMBIGUOUS_ACCOUNT_ADDRESS]} + + def test_a_member_address_one_of_several_accounts_is_stored_under_carries_that_one(self): + """The account stored under exactly the name addressed is the answer, in whatever order they come.""" + def two_accounts(request, usernames=None, **kwargs): # pylint: disable=unused-argument + return [ + {"username": "Owner-Caller", "email": "second@example.com"}, + {"username": "owner-caller", "email": "owner-caller@example.com"}, + ] + + self.login(self.staff) + with mock.patch(ACCOUNT_LOOKUP, two_accounts): + response = self.client.get(self.detail_url("owner-caller")) + assert response.status_code == status.HTTP_200_OK + assert response.data == {"username": "owner-caller", "email": "owner-caller@example.com"} + + def test_a_caller_reads_their_own_account_by_name(self): + self.login(self.owner) + response = self.client.get(self.detail_url("owner-caller")) + assert set(response.data) == FULL_FIELDS + assert response.data["email"] == "owner-caller@example.com" + + def test_a_learner_sees_only_the_shared_properties_of_another_account(self): + self.login(self.owner) + response = self.client.get(self.detail_url("other-caller")) + assert response.status_code == status.HTTP_200_OK + assert set(response.data) == PUBLIC_FIELDS + + def test_a_staff_caller_sees_every_property_of_another_account(self): + self.login(self.staff) + response = self.client.get(self.detail_url("owner-caller")) + assert set(response.data) == FULL_FIELDS + assert response.data["email"] == "owner-caller@example.com" + + def test_the_shared_preset_narrows_the_callers_own_account(self): + self.login(self.owner) + response = self.client.get(self.detail_url("owner-caller"), {"view": "shared"}) + assert set(response.data) == PUBLIC_FIELDS + + def test_the_minimal_preset_keeps_the_identifying_properties(self): + self.login(self.owner) + response = self.client.get(self.own_url, {"view": "minimal"}) + assert set(response.data) == {"username", "email", "name", "profile_image"} + + def test_the_empty_preset_narrows_nothing_at_any_address_that_takes_one(self): + """ + A query string built from an empty variable carries a preset with no + value, which is answered with everything the caller may see. + """ + self.login(self.owner) + assert set(self.client.get(self.own_url, {"view": ""}).data) == FULL_FIELDS + assert set(self.client.get(self.list_url, {"view": ""}).data["results"][0]) == FULL_FIELDS + changed = self.patch(self.own_url, json.dumps({"bio": "unnarrowed"}), QUERY_STRING="view=") + assert changed.status_code == status.HTTP_200_OK + assert set(changed.data) == FULL_FIELDS + + def test_a_learner_selecting_an_identifying_property_they_may_not_see_is_refused(self): + """The preset keeps the identifying properties; this caller may be answered with some of them.""" + self.login(self.owner) + response = self.client.get( + self.detail_url("other-caller"), {"view": "minimal", "fields": "email"}, + ) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"]["fields"] == [ + "No such field: email. An account another user holds carries only the " + "properties its owner shares.", + ] + + def test_selected_fields_narrow_the_account(self): + self.login(self.owner) + response = self.client.get(self.own_url, {"fields": "username,email"}) + assert response.data == {"username": "owner-caller", "email": "owner-caller@example.com"} + + def test_a_learner_selecting_a_property_of_another_account_they_may_not_see_is_refused(self): + """A property this caller's response cannot carry is refused, not answered as an empty object.""" + self.login(self.owner) + response = self.client.get(self.detail_url("other-caller"), {"fields": "email"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"]["fields"] == [ + "No such field: email. An account another user holds carries only the " + "properties its owner shares.", + ] + + def test_a_learner_selecting_a_property_this_owner_does_not_share_receives_the_rest(self): + """ + A property an owner is able to share can be selected; whether they share it is their own choice. + + This owner shares nothing beyond the properties every caller may see, + so the account carries the rest of the selection rather than the + property asked for. + """ + self.login(self.owner) + response = self.client.get(self.detail_url("other-caller"), {"fields": "bio,username"}) + assert response.status_code == status.HTTP_200_OK + assert response.data == {"username": "other-caller"} + + def test_a_staff_caller_still_selects_a_property_only_they_may_see(self): + self.login(self.staff) + response = self.client.get(self.detail_url("other-caller"), {"fields": "email"}) + assert response.data == {"email": "other-caller@example.com"} + + def test_the_reserved_identifier_selects_the_properties_of_the_callers_own_account(self): + self.login(self.owner) + response = self.client.get(self.own_url, {"fields": "email"}) + assert response.data == {"email": "owner-caller@example.com"} + + def test_a_name_no_account_holds_is_not_found(self): + self.login(self.staff) + response = self.client.get(self.detail_url("nobody")) + assert_error_envelope( + response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + + def test_an_anonymous_caller_is_unauthenticated(self): + response = self.client.get(self.detail_url("owner-caller")) + assert_error_envelope( + response, expected_status=status.HTTP_401_UNAUTHORIZED, expected_type_slug="authn", + ) + + def test_a_selector_of_the_list_is_not_a_parameter_of_a_member(self): + self.login(self.staff) + response = self.client.get(self.detail_url("owner-caller"), {"ordering": "username"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "ordering": [f"No such query parameter. Send any of: {MEMBER_QUERY_PARAMS}."], + } + + def test_a_member_carries_no_page_so_the_parameters_that_cut_one_are_refused(self): + """ + One account is not a page of them, and a parameter that cuts a page is refused here. + + A caller that mistyped a parameter of the collection and sent it to a + member would otherwise be answered the whole account as though the + parameter had been read. + """ + self.login(self.owner) + for address in (self.own_url, self.detail_url("owner-caller")): + envelope = assert_error_envelope( + self.client.get(address, {"page": 9, "page_size": 3}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "page": [f"No such query parameter. Send any of: {MEMBER_QUERY_PARAMS}."], + "page_size": [f"No such query parameter. Send any of: {MEMBER_QUERY_PARAMS}."], + } + + def test_a_change_carries_no_page_either(self): + """A change answers with the one account it changed, so it reads no page parameter.""" + self.login(self.owner) + envelope = assert_error_envelope( + self.patch(f"{self.own_url}?page=2", json.dumps({"bio": "paged"})), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "page": [f"No such query parameter. Send any of: {MEMBER_QUERY_PARAMS}."], + } + self.owner.profile.refresh_from_db() + assert self.owner.profile.bio is None + + +@skip_unless_lms +class UnconfirmedAccountTests(AccountApiV2TestCase): + """ + A user who has not confirmed their email address still reaches their own account. + + The site sends them to account settings to change the address, so the + session class these views accept is the one that admits them. + """ + + def setUp(self): + super().setUp() + self.owner.is_active = False + self.owner.save() + + def test_an_unconfirmed_account_is_readable_by_its_owner(self): + self.login(self.owner) + response = self.client.get(self.own_url) + assert response.status_code == status.HTTP_200_OK + assert response.data["username"] == "owner-caller" + assert response.data["is_active"] is False + + def test_an_unconfirmed_account_is_changeable_by_its_owner(self): + self.login(self.owner) + response = self.patch(self.own_url, json.dumps({"bio": "still unconfirmed"})) + assert response.status_code == status.HTTP_200_OK + assert response.data["bio"] == "still unconfirmed" + + def test_a_token_held_by_a_disabled_staff_account_may_not_select_accounts_by_contact(self): + """A privilege does not survive the deactivation of the account that held it.""" + headers = self.token_headers(self.staff) + for query in ({"email": "other-caller@example.com"}, {"lms_user_id": self.other.id}): + assert self.client.get(self.list_url, query, **headers).status_code == status.HTTP_200_OK + self.staff.is_active = False + self.staff.save() + for query in ({"email": "other-caller@example.com"}, {"lms_user_id": self.other.id}): + assert_error_envelope( + self.client.get(self.list_url, query, **headers), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_a_disabled_staff_account_still_reads_the_accounts_it_names(self): + """Naming an account is not the privilege a disabled account loses.""" + self.staff.is_active = False + self.staff.save() + headers = self.token_headers(self.staff) + page = self.client.get(self.list_url, {"username": "other-caller"}, **headers) + assert page.status_code == status.HTTP_200_OK + assert self.usernames_in(page) == ["other-caller"] + + def test_a_token_is_read_here_without_recording_the_address_as_one_on_the_defaults(self): + """ + This address names its own classes, so it is not one the default pair serves. + + The pair the platform configures records every request it reads a + token for, so that an operator can list the addresses it serves. This + one replaces the session half of it and is therefore not among them, + and nothing here is recorded as if it were. The address read afterwards + names no classes of its own and is recorded, which is what shows the + record is reachable from a request made this way at all. + """ + with mock.patch(DEFAULT_AUTH_RECORDER) as recorded: + answer = self.client.get(self.detail_url("other-caller"), **self.token_headers(self.other)) + assert answer.status_code == status.HTTP_200_OK + assert answer.json()["username"] == "other-caller" + assert recorded.call_args_list == [] + + with mock.patch(DEFAULT_AUTH_RECORDER) as recorded: + on_the_default_pair = self.client.get( + reverse("user_v2:user_preference_list"), **self.token_headers(self.staff), + ) + assert on_the_default_pair.status_code == status.HTTP_200_OK + assert [call.args for call in recorded.call_args_list] == [("using_default_auth_classes", True)] + + def test_an_unconfirmed_account_still_may_not_search_for_others(self): + 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_the_page_carries_the_accounts_the_visibility_layer_leaves(self): + """ + The page is answered from a selection the view's policy has narrowed. + + The policy this view declares keeps every matched account, and the + only caller admitted may read them all, so what is held here is the + layer rather than a narrowing: a policy that keeps a row back is one + the page is answered under, not one the search reads past. The rows + come from the same table the collections of this version narrow, so + the same policy applies to the search as to them. + """ + class _WithoutTheOtherAccount: + """Keeps one account back, standing for a policy narrower than the declared one.""" + + def scope(self, queryset, subject): # pylint: disable=unused-argument + """Return the accounts of this selection other than the other caller's.""" + return queryset.exclude(username="other-caller") + + self.login(self.staff) + with mock.patch.object(EmailSearchViewSet, "scoping_policy", _WithoutTheOtherAccount()): + response = self.search(["owner-caller@example.com", "other-caller@example.com"]) + assert response.status_code == status.HTTP_200_OK + assert [row["username"] for row in response.data["results"]] == ["owner-caller"] + assert response.data["count"] == 1 + + def test_the_search_declares_which_of_the_accounts_it_finds_a_caller_may_see(self): + """Every account the addresses match is answered, because that is what the policy keeps.""" + assert isinstance(EmailSearchViewSet.scoping_policy, FullScopePolicy) + self.login(self.staff) + response = self.search(["owner-caller@example.com", "other-caller@example.com"]) + assert [row["username"] for row in response.data["results"]] == ["other-caller", "owner-caller"] + + def test_an_empty_search_finds_nothing(self): + self.login(self.staff) + response = self.search([]) + assert response.status_code == status.HTTP_200_OK + assert response.data["results"] == [] + assert response.data["count"] == 0 + + def test_a_body_without_the_addresses_is_refused(self): + self.login(self.staff) + response = self.client.post(self.url, {}, format="json") + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == {"emails": ["This field is required."]} + + def test_an_entry_that_is_not_an_address_is_refused_with_a_message(self): + """ + A refusal names the entries at fault in a sentence a client can show. + + An entry reported under the position it holds would reach the caller + as a mapping nested inside the member naming the list, which nothing + can display. + """ + self.login(self.staff) + response = self.client.post( + self.url, {"emails": [None, "owner-caller@example.com", {"address": "x"}]}, format="json", + ) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "emails": ["Every entry must be text, and the entries at these positions are not: 0, 2."], + } + + def test_addresses_that_are_not_a_list_are_refused(self): + self.login(self.staff) + response = self.client.post(self.url, {"emails": "owner-caller@example.com"}, format="json") + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == {"emails": ['Expected a list of items but got type "str".']} + + def test_a_body_that_is_not_well_formed_is_refused_as_the_callers_fault(self): + self.login(self.staff) + response = self.client.post(self.url, "{not json", content_type="application/json") + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="malformed-body", + ) + 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 AccountPreferenceAddressDescriptionTests(AccountApiV2TestCase): + """What OPTIONS answers about the preference addresses beneath one account.""" + + def setUp(self): + super().setUp() + self.document_url = reverse( + "user_v2:account_preference_list", kwargs={"username": self.owner.username}, + ) + UserPreferenceFactory.create(user=self.owner, key="time_zone", value="Asia/Tokyo") + self.member_url = reverse( + "user_v2:account_preference_detail", + kwargs={"username": self.owner.username, "key": "time_zone"}, + ) + + def test_the_document_address_names_the_media_type_a_change_to_it_is_sent_in(self): + """ + The media types the answer names are the ones the address reads. + + A change to the whole document is a merge patch and a body in any + other media type is refused, so naming the media types read everywhere + else would tell a caller to send three the address refuses and leave + out the one it takes. + """ + self.login(self.owner) + answer = self.client.options(self.document_url) + assert answer.status_code == status.HTTP_200_OK + assert answer.json()["parses"] == [MERGE_PATCH] + + def test_the_member_address_names_the_media_types_a_change_to_one_preference_is_sent_in(self): + """One preference is sent as the platform reads a body everywhere, which is what is named.""" + self.login(self.owner) + answer = self.client.options(self.member_url) + assert answer.status_code == status.HTTP_200_OK + assert answer.json()["parses"] == [ + "application/json", + "application/x-www-form-urlencoded", + "multipart/form-data", + ] + + def test_a_change_built_from_the_described_body_is_carried_out(self): + """The description is taken at its word: what it names is a body the address reads.""" + self.login(self.owner) + described = self.client.options(self.member_url).json()["actions"]["PUT"] + response = self.client.put( + self.member_url, json.dumps({name: "Europe/Berlin" for name in described}), + content_type="application/json", + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert self.owner.preferences.get(key="time_zone").value == "Europe/Berlin" + + def test_a_change_sent_in_a_media_type_the_answer_names_is_read(self): + """ + The answer is taken at its word, which is the only mechanism offered for it. + + A caller that reads the media types from the description and sends one + of them is answered by the change being carried out rather than by a + refusal of what it was told to send. + """ + self.login(self.owner) + for media_type in self.client.options(self.document_url).json()["parses"]: + response = self.client.patch( + self.document_url, json.dumps({"time_zone": "Asia/Tokyo"}), content_type=media_type, + ) + assert response.status_code == status.HTTP_204_NO_CONTENT, media_type + + +@skip_unless_lms +class AccountWriteTargetTests(AccountApiV2TestCase): + """ + A change whose address names no one account is refused rather than written somewhere. + + What an address resolves to is the database's answer, and more than one + account can come back for one name. The record the work lands on is + settled from that answer rather than taken as the first of it, so a change + either lands on the account the address names or on nothing at all. The + test database resolves a name only to the record stored under exactly it, + so the lookups are answered here the way they answer where a name resolves + more widely. + """ + + def setUp(self): + super().setUp() + self.deactivator = UserFactory.create(username="target-deactivator", password=PASSWORD) + self.deactivator.user_permissions.add( + PermissionFactory( + codename="can_deactivate_users", + content_type=ContentTypeFactory(app_label="student"), + ), + ) + self.owner.profile.profile_image_uploaded_at = datetime.datetime.now(datetime.UTC) + self.owner.profile.save() + + def test_a_deactivation_whose_address_names_more_than_one_account_deactivates_none(self): + address = reverse("user_v2:account_deactivation", kwargs={"username": self.owner.username}) + self.login(self.deactivator) + with mock.patch( + "openedx.core.djangoapps.user_api.v2.views.accounts_named", + return_value=[self.other, self.staff], + ): + envelope = assert_error_envelope( + self.client.post(address), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="user/ambiguous-account-name", + ) + assert envelope["errors"] == {"non_field_errors": [AMBIGUOUS_ACCOUNT_ADDRESS]} + for user in (self.owner, self.other, self.staff): + user.refresh_from_db() + assert user.has_usable_password(), user.username + + def test_a_deactivation_lands_on_the_account_stored_under_the_name_addressed(self): + address = reverse("user_v2:account_deactivation", kwargs={"username": self.owner.username}) + self.login(self.deactivator) + with mock.patch( + "openedx.core.djangoapps.user_api.v2.views.accounts_named", + return_value=[self.other, self.owner], + ): + assert self.client.post(address).status_code == status.HTTP_204_NO_CONTENT + self.owner.refresh_from_db() + self.other.refresh_from_db() + assert not self.owner.has_usable_password() + assert self.other.has_usable_password() + + def test_an_image_is_recorded_on_the_account_the_request_was_admitted_for(self): + """ + What the image is recorded on is the account the request was admitted for. + + The account is handed to the write as a record rather than as a name + for the write to find for itself, which is what keeps the account + written to the account the caller was admitted for. + """ + address = reverse( + "user_v2:account_profile_image", kwargs={"username": self.owner.username}, + ) + self.login(self.owner) + with mock.patch( + "openedx.core.djangoapps.profile_images.v2.views.set_has_profile_image", + ) as recorded: + assert self.client.delete(address).status_code == status.HTTP_204_NO_CONTENT + assert recorded.call_args.args[0] == self.owner + assert recorded.call_args.args[1] is False + self.owner.profile.refresh_from_db() + assert self.owner.profile.has_profile_image is True + + +@skip_unless_lms +class AccountResourceGateTests(AccountApiV2TestCase): + """ + A rule the account resource admits a caller by reaches every operation on an account. + + The accounts are one collection however many addresses reach into them, so + a rule declared for the resource has to be applied by every operation that + reads or changes one of its accounts, wherever that operation is served + from. Each one is measured twice: once as it stands, and once with a rule + that admits nobody added to the resource's declaration, which every one of + them must then refuse. Measuring both ways is what makes the second + measurement an answer about the rule rather than about the address. + """ + + def setUp(self): + super().setUp() + UserPreferenceFactory.create(user=self.owner, key="time_zone", value="Asia/Tokyo") + self.owner.profile.profile_image_uploaded_at = datetime.datetime.now(datetime.UTC) + self.owner.profile.save() + self.deactivator = UserFactory.create(username="gate-deactivator", password=PASSWORD) + self.deactivator.user_permissions.add( + PermissionFactory( + codename="can_deactivate_users", + content_type=ContentTypeFactory(app_label="student"), + ), + ) + + def reads(self): + """Return each read of one account, by the address it is sent to.""" + name = self.owner.username + return { + "the account": lambda: self.client.get(self.detail_url(name)), + "the verification summary": lambda: self.client.get( + reverse("user_v2:account_verification_status", kwargs={"username": name}), + ), + "the verification attempts": lambda: self.client.get( + reverse("user_v2:account_verification_list", kwargs={"username": name}), + ), + "the preferences": lambda: self.client.get( + reverse("user_v2:account_preference_list", kwargs={"username": name}), + ), + "one preference": lambda: self.client.get( + reverse( + "user_v2:account_preference_detail", + kwargs={"username": name, "key": "time_zone"}, + ), + ), + } + + def writes(self): + """Return each change to one account, by the address it is sent to.""" + name = self.owner.username + return { + "the preferences": lambda: self.client.patch( + reverse("user_v2:account_preference_list", kwargs={"username": name}), + json.dumps({"time_zone": "Asia/Tokyo"}), + content_type=MERGE_PATCH, + ), + "the image": lambda: self.client.post( + reverse("user_v2:account_profile_image", kwargs={"username": name}), + ), + "the stored image": lambda: self.client.delete( + reverse("user_v2:account_profile_image", kwargs={"username": name}), + ), + } + + def has_image(self): + """Return whether the account carries an image of its own.""" + self.owner.profile.refresh_from_db() + return self.owner.profile.has_profile_image + + def test_every_read_of_one_account_is_answered_while_the_resource_admits_the_caller(self): + self.login(self.owner) + for case, send in self.reads().items(): + assert send().status_code == status.HTTP_200_OK, case + + def test_a_rule_the_resource_refuses_every_caller_by_refuses_every_read_of_one_account(self): + self.login(self.owner) + with refusing_account_resource(): + for case, send in self.reads().items(): + with self.subTest(case=case): + assert_error_envelope( + send(), expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + + def test_every_change_to_one_account_reaches_its_handler_while_the_resource_admits_the_caller(self): + """ + The handler is reached, which is what the refusal below is measured against. + + A change is sent here as a caller sends it wrong, so that what is + measured is the request reaching the handler rather than the change + itself: an empty merge patch and an upload carrying no image are + refused by the handler, and clearing the image is carried out. + """ + self.login(self.owner) + answers = {case: send() for case, send in self.writes().items()} + assert answers["the preferences"].status_code == status.HTTP_204_NO_CONTENT + assert answers["the image"].status_code == status.HTTP_400_BAD_REQUEST + assert answers["the stored image"].status_code == status.HTTP_204_NO_CONTENT + assert self.has_image() is False + + def test_a_rule_the_resource_refuses_every_caller_by_refuses_every_change_to_one_account(self): + """A change refused by the resource leaves the account as it stands.""" + self.login(self.owner) + with refusing_account_resource(): + for case, send in self.writes().items(): + with self.subTest(case=case): + assert_error_envelope( + send(), expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + assert self.has_image() is True + + def test_a_rule_the_resource_refuses_every_caller_by_refuses_a_deactivation(self): + """ + The privilege to deactivate is held and the resource still decides. + + The caller holds the privilege this address asks for, so the refusal + measured here is the resource's rather than that privilege's, and the + password is still usable afterwards. + """ + address = reverse("user_v2:account_deactivation", kwargs={"username": self.owner.username}) + self.login(self.deactivator) + with refusing_account_resource(): + assert_error_envelope( + self.client.post(address), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + self.owner.refresh_from_db() + assert self.owner.has_usable_password() + + assert self.client.post(address).status_code == status.HTTP_204_NO_CONTENT + self.owner.refresh_from_db() + assert not self.owner.has_usable_password() + + +@skip_unless_lms +class AccountDeactivationTests(AccountApiV2TestCase): + """POST /api/user/v2/accounts/{username}/deactivation/""" + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:account_deactivation", kwargs={"username": "other-caller"}) + self.deactivator = UserFactory.create(username="deactivator", password=PASSWORD) + self.deactivator.user_permissions.add( + PermissionFactory( + codename="can_deactivate_users", + content_type=ContentTypeFactory(app_label="student"), + ), + ) + + def test_a_caller_holding_the_privilege_deactivates_an_account(self): + self.login(self.deactivator) + assert self.other.has_usable_password() + response = self.client.post(self.url) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not response.content + self.other.refresh_from_db() + assert not self.other.has_usable_password() + + def test_asking_for_a_password_reset_does_not_bring_a_deactivated_account_back(self): + """ + The reset flow refuses an account whose password is unusable, which is what this leaves. + + The description of this operation tells a support caller what a + deactivation can and cannot be undone by, so the refusal it names is + pinned here: a reset asked for on the deactivated address is refused, + and the account still has no usable password afterwards. + """ + self.login(self.deactivator) + assert self.client.post(self.url).status_code == status.HTTP_204_NO_CONTENT + form = PasswordResetFormNoActive(data={"email": self.other.email}) + assert form.is_valid() is False + assert form.errors["email"] == [ + "The user account associated with this e-mail address cannot reset the password.", + ] + self.other.refresh_from_db() + assert not self.other.has_usable_password() + + def test_a_deactivated_account_keeps_its_data(self): + self.login(self.deactivator) + self.client.post(self.url) + self.other.refresh_from_db() + assert self.other.is_active is True + assert self.other.username == "other-caller" + assert self.other.email == "other-caller@example.com" + + def test_a_superuser_deactivates_an_account(self): + superuser = SuperuserFactory(username="super-caller", password=PASSWORD) + self.login(superuser) + assert self.client.post(self.url).status_code == status.HTTP_204_NO_CONTENT + + def test_a_staff_caller_without_the_privilege_is_refused(self): + self.login(self.staff) + assert_error_envelope( + self.client.post(self.url), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + self.other.refresh_from_db() + assert self.other.has_usable_password() + + def test_a_learner_is_refused(self): + self.login(self.owner) + assert_error_envelope( + self.client.post(self.url), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_a_learner_may_not_deactivate_their_own_account_either(self): + self.login(self.owner) + response = self.client.post( + reverse("user_v2:account_deactivation", kwargs={"username": "owner-caller"}), + ) + assert_error_envelope( + response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + self.owner.refresh_from_db() + assert self.owner.has_usable_password() + + def test_the_reserved_identifier_names_the_account_holding_it_and_not_the_caller(self): + """This action names the account to disable, so ``me`` is the username it looks like.""" + held = UserFactory.create(username="me", password=PASSWORD) + self.login(self.deactivator) + response = self.client.post( + reverse("user_v2:account_deactivation", kwargs={"username": "me"}), + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + held.refresh_from_db() + assert not held.has_usable_password() + self.deactivator.refresh_from_db() + assert self.deactivator.has_usable_password() + + def test_the_reserved_identifier_is_not_found_when_no_account_holds_it(self): + """Nobody is named ``me`` here, so there is no account at that address to disable.""" + self.login(self.deactivator) + response = self.client.post( + reverse("user_v2:account_deactivation", kwargs={"username": "me"}), + ) + assert_error_envelope( + response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + self.deactivator.refresh_from_db() + assert self.deactivator.has_usable_password() + + def test_an_anonymous_caller_is_unauthenticated(self): + assert_error_envelope( + self.client.post(self.url), + expected_status=status.HTTP_401_UNAUTHORIZED, + expected_type_slug="authn", + ) + + def test_a_query_parameter_is_not_read_by_a_deactivation(self): + """A parameter this action reads nothing from is refused before the account is touched.""" + self.login(self.deactivator) + response = self.client.post(f"{self.url}?view=minimal") + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "view": [f"No such query parameter. Send any of: {DEACTIVATION_QUERY_PARAMS}."], + } + self.other.refresh_from_db() + assert self.other.has_usable_password() + + def test_a_name_no_account_holds_is_not_found(self): + self.login(self.deactivator) + response = self.client.post( + reverse("user_v2:account_deactivation", kwargs={"username": "nobody"}), + ) + assert_error_envelope( + response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + + def test_a_token_held_by_a_deactivated_caller_is_refused(self): + headers = self.token_headers(self.deactivator) + self.deactivator.is_active = False + self.deactivator.save() + assert_error_envelope( + self.client.post(self.url, **headers), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + self.other.refresh_from_db() + assert self.other.has_usable_password() + + def test_a_token_held_by_an_active_caller_reaches_the_action(self): + response = self.client.post(self.url, **self.token_headers(self.deactivator)) + assert response.status_code == status.HTTP_204_NO_CONTENT + + def test_the_account_is_not_deactivated_by_reading_it(self): + self.login(self.deactivator) + response = self.client.get(self.url) + assert_error_envelope( + response, + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug="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.""" + + +@skip_unless_lms +class AccountSelectedPropertyNameTests(AccountApiV2TestCase): + """ + What a selected property name may be at the account addresses. + + An account carries properties that are objects of their own. A name + written with a dot in it reads as a name of no property the account + carries, because the account carries the object whole and the parameter + selects the properties of the account rather than the properties of those + objects. + """ + + def test_a_name_reaching_into_a_property_is_refused_on_one_account(self): + self.login(self.owner) + response = self.client.get(f"{self.own_url}?fields=profile_image.has_image") + assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert response.json()["errors"]["fields"][0].startswith( + "No such field: profile_image.has_image. Select any of: ", + ) + + def test_a_name_reaching_into_a_property_is_refused_on_the_account_list(self): + self.login(self.owner) + response = self.client.get(f"{self.list_url}?fields=profile_image.image_url_full") + assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert response.json()["errors"]["fields"][0].startswith( + "No such field: profile_image.image_url_full. Select any of: ", + ) + + def test_a_name_reaching_into_a_property_is_refused_on_a_change(self): + self.login(self.owner) + response = self.patch(f"{self.own_url}?fields=profile_image.has_image", json.dumps({"bio": "a bio"})) + assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert UserProfile.objects.get(user=self.owner).bio != "a bio" + + def test_naming_the_property_itself_returns_the_whole_object(self): + """The object is what the account carries, so naming it brings all of it back.""" + self.login(self.owner) + response = self.client.get(f"{self.own_url}?fields=profile_image") + assert response.status_code == status.HTTP_200_OK + assert sorted(response.json()) == ["profile_image"] + assert sorted(response.json()["profile_image"]) == [ + "has_image", "image_url_full", "image_url_large", "image_url_medium", "image_url_small", + ] + + +@skip_unless_lms +@override_switch(ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH, active=True) +class RecoveryAddressRateLimitedTests(AccountApiV2TestCase): + """A site that limits how often a recovery address may be changed.""" + + @override_settings( + SECONDARY_EMAIL_RATE_LIMIT="1/d", CACHES=counting_cache("recovery-address-refused"), + ) + def test_changing_a_recovery_address_too_often_is_refused(self): + self.login(self.owner) + first = self.patch(self.own_url, json.dumps({"secondary_email": "owner-recovery@example.com"})) + assert first.status_code == status.HTTP_200_OK + + second = self.patch(self.own_url, json.dumps({"secondary_email": "owner-other@example.com"})) + envelope = assert_error_envelope( + second, expected_status=status.HTTP_429_TOO_MANY_REQUESTS, expected_type_slug="rate-limited", + ) + assert envelope["detail"] == "Wait before changing secondary_email again." + + @override_settings( + SECONDARY_EMAIL_RATE_LIMIT="1/d", CACHES=counting_cache("recovery-address-allowed"), + ) + def test_the_limit_leaves_a_change_of_the_sign_in_address_alone(self): + """Each property is counted under a name of its own, so one limit does not spend another.""" + self.login(self.owner) + assert self.patch( + self.own_url, json.dumps({"secondary_email": "owner-recovery@example.com"}), + ).status_code == status.HTTP_200_OK + later = self.patch(self.own_url, json.dumps({"email": "owner-signin@example.com"})) + assert later.status_code == status.HTTP_200_OK + + +@skip_unless_lms +@override_switch(ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH, active=True) +@override_settings(CACHES=counting_cache("recovery-address-unlimited")) +class RecoveryAddressUnlimitedTests(AccountApiV2TestCase): + """ + A site that has given no limit, which is how the platform ships. + + Neither of the two properties that can be limited carries a limit out of + the box, so the refusal this endpoint publishes for changing one too often + is answered only by a deployment that has given the matching setting a + value. That is recorded here so that the published refusal is not read as + something every deployment produces. + """ + + def test_the_platform_default_gives_no_limit(self): + assert settings.SECONDARY_EMAIL_RATE_LIMIT == "" + assert settings.EMAIL_CHANGE_RATE_LIMIT == "" + + def test_a_recovery_address_may_be_changed_again_and_again(self): + self.login(self.owner) + answered = [] + for attempt in range(3): + response = self.patch( + self.own_url, json.dumps({"secondary_email": f"owner-recovery-{attempt}@example.com"}), + ) + answered.append(response.status_code) + assert answered == [status.HTTP_200_OK] * 3 + + +@skip_unless_lms +class EmailSearchAddressDescriptionTests(AccountApiV2TestCase): + """What OPTIONS answers about the body a search is asked for in.""" + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:email_search_list") + + def test_the_address_names_the_body_a_search_is_sent_in(self): + """ + The body the answer describes is the one the address reads, not the one it returns. + + A search is asked for by sending the addresses to look for, and is + answered with an account per address found. Describing the answer here + would have a caller build its request out of the shape it is going to + receive, which this address does not read. + """ + self.login(self.staff) + answer = self.client.options(self.url) + assert answer.status_code == status.HTTP_200_OK + assert sorted(answer.json()["actions"]["POST"]) == ["emails"] + + def test_every_name_the_described_body_carries_is_one_the_search_needs(self): + """Leaving any one of the described names out is refused, so the description is the body.""" + self.login(self.staff) + described = self.client.options(self.url).json()["actions"]["POST"] + assert described + for omitted in described: + body = {name: ["owner-caller@example.com"] for name in described if name != omitted} + response = self.client.post(self.url, body, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST, omitted + + def test_a_search_built_from_the_described_body_is_answered(self): + """The description is taken at its word: what it names is a body the address reads.""" + self.login(self.staff) + described = self.client.options(self.url).json()["actions"]["POST"] + response = self.client.post( + self.url, {name: ["owner-caller@example.com"] for name in described}, format="json", + ) + assert response.status_code == status.HTTP_200_OK + assert [row["username"] for row in response.data["results"]] == ["owner-caller"] + + +@skip_unless_lms +class RefusalDisclosureTests(AccountApiV2TestCase): + """ + What a refusal about somebody else's account gives away. + + A caller that selects a property of another user's account it may not see + is told that it cannot. Which properties an owner shares is that owner's + to decide, and the set the site offers them to choose from is the site's + own configuration, so a refusal about an account the caller does not hold + names neither: it would otherwise hand every signed-in caller the whole + configured list, including names nobody on the site shares. + """ + + REFUSAL_ABOUT_ANOTHER_ACCOUNT = ( + "No such field: email. An account another user holds carries only the properties " + "its owner shares." + ) + + def test_a_refusal_about_another_account_names_nothing_the_site_configures(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.client.get(self.detail_url("other-caller"), {"fields": "email"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + message = envelope["errors"]["fields"][0] + assert message == self.REFUSAL_ABOUT_ANOTHER_ACCOUNT + assert sorted(name for name in SHAREABLE_FIELDS if name in message) == [] + + def test_a_refusal_about_another_account_on_the_list_names_nothing_either(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.client.get(self.list_url, {"username": "other-caller", "fields": "email"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + message = envelope["errors"]["fields"][0] + assert message == self.REFUSAL_ABOUT_ANOTHER_ACCOUNT + assert sorted(name for name in SHAREABLE_FIELDS if name in message) == [] + + def test_a_refusal_about_another_account_under_a_preset_names_nothing_either(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.client.get(self.detail_url("other-caller"), {"view": "minimal", "fields": "email"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"]["fields"][0] == self.REFUSAL_ABOUT_ANOTHER_ACCOUNT + + def test_a_refusal_about_the_callers_own_account_still_says_what_may_be_selected(self): + """The caller sees every property of their own account, so naming them gives nothing away.""" + self.login(self.owner) + envelope = assert_error_envelope( + self.client.get(self.own_url, {"fields": "wallet"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + message = envelope["errors"]["fields"][0] + assert message.startswith("No such field: wallet. Select any of: ") + assert "username" in message + + def test_a_refusal_to_a_staff_caller_still_says_what_may_be_selected(self): + """A staff caller reads every property of every account, so the list tells them nothing new.""" + self.login(self.staff) + envelope = assert_error_envelope( + self.client.get(self.detail_url("other-caller"), {"fields": "wallet"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"]["fields"][0].startswith("No such field: wallet. Select any of: ") diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py new file mode 100644 index 000000000000..7600567fa478 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_authn_addresses.py @@ -0,0 +1,2373 @@ +"""Tests for the registration, sign-in and password reset addresses of version 2 of the User API.""" + +import contextlib +import json +import re +from collections import defaultdict +from types import SimpleNamespace +from unittest import mock + +import jsonschema +import pytest +from crum.signals import current_user_setter +from django import forms +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.tokens import default_token_generator +from django.contrib.sessions.models import Session +from django.core.cache import cache +from django.core.signals import request_finished, request_started +from django.db import connection, transaction +from django.db.models.signals import post_init, post_save, pre_init, pre_save +from django.dispatch import Signal +from django.test import Client, TestCase +from django.test.utils import CaptureQueriesContext, override_settings +from django.urls import resolve, reverse +from django.utils.http import int_to_base36 +from drf_spectacular.generators import SchemaGenerator +from drf_spectacular.settings import patched_settings +from edx_django_utils.monitoring.signals import ( + monitoring_support_process_exception, + monitoring_support_process_request, + monitoring_support_process_response, +) +from openedx_events.learning.signals import STUDENT_REGISTRATION_COMPLETED +from openedx_events.tooling import OpenEdxPublicSignal +from openedx_filters import PipelineStep +from openedx_filters.learning.filters import StudentLoginRequested, StudentRegistrationRequested +from rest_framework.permissions import AllowAny +from rest_framework.settings import api_settings + +from common.djangoapps.student.models import Registration +from common.djangoapps.student.tests.factories import UserFactory +from common.djangoapps.third_party_auth.models import OAuth2ProviderConfig +from openedx.core.apidocs import get_api_docs_settings +from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user +from openedx.core.djangoapps.user_api import accounts +from openedx.core.djangoapps.user_api.v2 import authn_views +from openedx.core.djangoapps.user_api.v2.tests.test_schema import as_json_schema +from openedx.core.djangoapps.user_authn.views import login, password_reset, register +from openedx.core.djangolib.testing.utils import skip_unless_lms +from openedx.envs.common import REGISTRATION_EXTRA_FIELDS as STOCK_REGISTRATION_EXTRA_FIELDS + +User = get_user_model() + +PASSWORD = "Password1234" +REGISTRATION_PASSWORD = "Password1234!x" +JSON = "application/json" +FORM = "application/x-www-form-urlencoded" +MULTIPART = "multipart/form-data" + +AUTHN_VIEW_MODULE = "openedx.core.djangoapps.user_api.v2.authn_views" + +REGISTRATIONS = "/api/user/v2/registrations/" +PASSWORD_RESETS = "/api/user/v2/password_resets/" +TOKEN_VALIDATIONS = "/api/user/v2/password_reset_token_validations/" +REGISTRATION_VALIDATIONS = "/api/user/v2/registration_validations/" +LOGIN_SESSIONS = "/api/user/v2/login_sessions/" + +V1_REGISTRATION = "/api/user/v1/account/registration/" +V2_REGISTRATION = "/api/user/v2/account/registration/" +V1_PASSWORD_RESET = "/api/user/v1/account/password_reset/" +V1_TOKEN_VALIDATION = "/api/user/v1/account/password_reset/token/validate/" +V1_REGISTRATION_VALIDATION = "/api/user/v1/validation/registration" +V1_LOGIN_SESSION = "/api/user/v1/account/login_session/" +V2_LOGIN_SESSION = "/api/user/v2/account/login_session/" +LOGIN_AJAX = "/login_ajax" +CREATE_ACCOUNT = "/create_account" + +#: Each address this version serves for the authentication app, with its +#: route name, the view of that app it is answered by, and the arguments its +#: route hands the view. +CONFORMING_ROUTES = { + REGISTRATIONS: ("user_v2:registration_list", register.RegistrationView, {}), + PASSWORD_RESETS: ("user_v2:password_reset_list", password_reset.PasswordResetView, {}), + TOKEN_VALIDATIONS: ( + "user_v2:password_reset_token_validation_list", + password_reset.PasswordResetTokenValidation, + {}, + ), + REGISTRATION_VALIDATIONS: ( + "user_v2:registration_validation_list", + register.RegistrationValidationView, + {}, + ), + LOGIN_SESSIONS: ("user_v2:login_session_list", login.LoginSessionView, {"api_version": "v2"}), +} + +#: Every name the authentication app's addresses are reversed by, with the +#: arguments, the address and the view it reaches. None of them moves. +LEGACY_ROUTES = ( + ("user_api_registration", {}, V1_REGISTRATION, register.RegistrationView), + ("user_api_registration_legacy", {}, "/user_api/v1/account/registration/", register.RegistrationView), + ("user_api_registration_v2", {}, V2_REGISTRATION, register.RegistrationView), + ("user_api_registration_v2_legacy", {}, "/user_api/v2/account/registration/", register.RegistrationView), + ("create_account", {}, CREATE_ACCOUNT, register.RegistrationView), + ("registration_validation", {}, V1_REGISTRATION_VALIDATION, register.RegistrationValidationView), + ("user_api_login_session", {"api_version": "v1"}, V1_LOGIN_SESSION, login.LoginSessionView), + ("user_api_login_session", {"api_version": "v2"}, V2_LOGIN_SESSION, login.LoginSessionView), + ( + "user_api_login_session_legacy", + {"api_version": "v1"}, + "/user_api/v1/account/login_session/", + login.LoginSessionView, + ), + ( + "user_api_login_session_legacy", + {"api_version": "v2"}, + "/user_api/v2/account/login_session/", + login.LoginSessionView, + ), + ("user_api_password_reset", {}, V1_PASSWORD_RESET, password_reset.PasswordResetView), + ( + "user_api_password_reset_legacy", + {}, + "/user_api/v1/account/password_reset/", + password_reset.PasswordResetView, + ), + ( + "user_api_password_reset_token_validate", + {}, + V1_TOKEN_VALIDATION, + password_reset.PasswordResetTokenValidation, + ), + ( + "user_api_password_reset_token_validate_legacy", + {}, + "/user_api/v1/account/password_reset/token/validate/", + password_reset.PasswordResetTokenValidation, + ), +) + +#: A real cache, so the limits on how often these operations may be sent take +#: effect; the stock test settings use one that stores nothing. +COUNTING_CACHE = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "user_api_v2_authn_addresses", + }, +} + +#: What the only intended difference between an address and the one it +#: replaces is: the description each view's OPTIONS answer carries. +OPTIONS_DIFFERENCES = [ + ("description", "each view describes itself, and the view at each address is a different class"), +] + +#: The cookies a registration or a sign-in sets in the stock test settings. +SIGNED_IN_COOKIES = {"csrftoken", "edx-user-info", "edxloggedin", "openedx-language-preference", "sessionid"} + +TOO_MANY_LOGINS = "Too many failed login attempts. Try again later." +WRONG_LOGIN = "Email or password is incorrect." +MISSING_LOGIN_FIELDS = "There was an error receiving your login information. Please email us." + + +class RefuseRegistrationStep(PipelineStep): + """A deployment's own step refusing every registration.""" + + def run_filter(self, form_data): # pylint: disable=arguments-differ + raise StudentRegistrationRequested.PreventRegistration( + "Registration is closed.", status_code=403, error_code="registration-closed", + ) + + +class RefuseLoginStep(PipelineStep): + """A deployment's own step refusing every sign-in.""" + + def run_filter(self, user): # pylint: disable=arguments-differ + raise StudentLoginRequested.PreventLogin( + "Sign-in is closed.", redirect_to="/closed", error_code="sign-in-closed", context={"reason": "closed"}, + ) + + +class RefuseRegistrationUnnamedStep(PipelineStep): + """A deployment's own step refusing every registration with 403, without naming the refusal.""" + + status_code = 403 + + def run_filter(self, form_data): # pylint: disable=arguments-differ + raise StudentRegistrationRequested.PreventRegistration("Registration is closed.", status_code=self.status_code) + + +class RefuseRegistrationUnnamedAsInvalidStep(RefuseRegistrationUnnamedStep): + """The same step refusing with 400.""" + + status_code = 400 + + +class RefuseLoginSilentlyStep(PipelineStep): + """A deployment's own step refusing every sign-in with no message, name, details or address.""" + + def run_filter(self, user): # pylint: disable=arguments-differ + raise StudentLoginRequested.PreventLogin("") + + +REFUSE_REGISTRATION = { + "org.openedx.learning.student.registration.requested.v1": { + "pipeline": [f"{__name__}.RefuseRegistrationStep"], + "fail_silently": False, + }, +} +REFUSE_LOGIN = { + "org.openedx.learning.student.login.requested.v1": { + "pipeline": [f"{__name__}.RefuseLoginStep"], + "fail_silently": False, + }, +} + + +def running_only(step, filter_class): + """Return the filter configuration running ``step`` alone wherever ``filter_class`` runs.""" + return {filter_class.filter_type: {"pipeline": [f"{__name__}.{step.__name__}"], "fail_silently": False}} + + +def registration_form(name): + """Return a registration the stock settings accept, for an account called ``name``.""" + return { + "email": f"{name}@example.com", + "name": name.replace("_", " ").title(), + "username": name, + "password": REGISTRATION_PASSWORD, + "honor_code": "true", + "country": "PK", + } + + +def reset_token(user): + """Return the token a password reset email carries for ``user``.""" + return f"{int_to_base36(user.id)}-{default_token_generator.make_token(user)}" + + +def signed_in(user, enforce_csrf_checks=False): + """Return a client holding a session for ``user``.""" + client = Client(enforce_csrf_checks=enforce_csrf_checks) + assert client.login(username=user.username, password=PASSWORD) + return client + + +def cookies_set(response): + """Return the names of the cookies a response sets.""" + return set(response.cookies) + + +def flatten(body, volatile=(), path=""): + """ + Return a body as a map of member paths to values, members sorted. + + A member whose value is chosen per request -- a generated username, an + account's id -- is kept under its path with only its type, so two + responses are compared on every member they carry and on every value that + does not depend on which account a request created. + """ + flat = {} + if isinstance(body, dict): + for key in sorted(body): + flat.update(flatten(body[key], volatile, f"{path}.{key}" if path else key)) + elif isinstance(body, list): + if path in volatile: + return {path: ("list", len(body), sorted({type(item).__name__ for item in body}))} + for index, item in enumerate(body): + flat.update(flatten(item, volatile, f"{path}[{index}]")) + else: + flat[path] = type(body).__name__ if path in volatile else body + return flat + + +def differences(legacy, conforming, intentional=(), volatile=()): + """ + Return the member paths two bodies differ on, and those of them not declared intentional. + + Every declared difference has to occur, or the list of them says more than + the two addresses really differ by. + """ + old, new = flatten(legacy, volatile), flatten(conforming, volatile) + changed = {key for key in set(old) | set(new) if old.get(key, "") != new.get(key, "")} + declared = {path for path, _reason in intentional} + unexpected = { + key for key in changed + if not any(key == path or key.startswith((f"{path}.", f"{path}[")) for path in declared) + } + for path, reason in intentional: + assert any(key == path or key.startswith((f"{path}.", f"{path}[")) for key in changed), ( + f"declared difference did not occur: {path} ({reason})" + ) + return changed, unexpected + + +def assert_same_answer(legacy, conforming, status, volatile=(), cookies=None): + """ + Assert two addresses answered one request the same way. + + The status, the media type, the cookies set and every member of the body + are compared; members chosen per request are compared by type. + """ + assert legacy.status_code == status, legacy.content + assert conforming.status_code == status, conforming.content + assert conforming["Content-Type"] == legacy["Content-Type"] + assert cookies_set(conforming) == cookies_set(legacy) + if cookies is not None: + assert cookies_set(conforming) == cookies + if legacy["Content-Type"].startswith(JSON): + _changed, unexpected = differences(json.loads(legacy.content), json.loads(conforming.content), (), volatile) + assert not unexpected, (sorted(unexpected), legacy.content, conforming.content) + else: + assert conforming.content == legacy.content + + +def assert_byte_equal(legacy, conforming, status=200): + """Assert two addresses answered with the same bytes, media type and cookies.""" + assert legacy.status_code == status, legacy.content + assert conforming.status_code == status, conforming.content + assert conforming["Content-Type"] == legacy["Content-Type"] + assert cookies_set(conforming) == cookies_set(legacy) + assert conforming.content == legacy.content + + +def spend(address, data, times, **extra): + """Send ``times`` requests to ``address`` from new clients and return their statuses.""" + return [Client().post(address, data, **extra).status_code for _ in range(times)] + + +def limit_of(rate): + """Return the number of requests a rate setting allows in its period.""" + return int(rate.split("/")[0]) + + +def narrow_document(): + """Return the document the service publishes for the APIs it standardizes.""" + return SchemaGenerator().get_schema(request=None, public=True) + + +def service_wide_document(): + """Return the document the service publishes for browsing its whole surface.""" + with patched_settings(get_api_docs_settings()): + return SchemaGenerator().get_schema(request=None, public=True) + + +@skip_unless_lms +class ConformingAddressTests(TestCase): + """Each address reverses, resolves to its view, and leaves the older addresses where they were.""" + + def test_each_name_reverses_to_its_address(self): + reversed_addresses = { + address: reverse(name, kwargs={}) for address, (name, _view, _kwargs) in CONFORMING_ROUTES.items() + } + assert reversed_addresses == {address: address for address in CONFORMING_ROUTES} + + def test_each_address_is_answered_by_a_view_extending_the_one_serving_the_older_address(self): + for address, (name, legacy_view, route_kwargs) in CONFORMING_ROUTES.items(): + match = resolve(address) + view = match.func.view_class + assert match.view_name == name, address + assert view.__module__ == AUTHN_VIEW_MODULE, address + assert view.__name__ == legacy_view.__name__, address + assert view is not legacy_view, address + assert issubclass(view, legacy_view), address + assert match.kwargs == route_kwargs, address + + def test_every_older_name_still_reverses_to_its_address_and_reaches_the_same_view(self): + for name, kwargs, address, view in LEGACY_ROUTES: + assert reverse(name, kwargs=kwargs) == address, name + match = resolve(address) + assert getattr(match.func, "view_class", None) is view, (name, address) + + def test_the_sign_in_function_keeps_its_own_address(self): + assert reverse("login_api") == LOGIN_AJAX + assert resolve(LOGIN_AJAX).func is login.login_user + + def test_an_address_sent_without_its_slash_is_refused_in_json(self): + """These addresses are served only with the slash, and the version answers the rest.""" + for address in CONFORMING_ROUTES: + response = Client().get(address.rstrip("/"), HTTP_ACCEPT=JSON) + assert response.status_code == 404, address + assert response["Content-Type"].startswith(JSON), address + assert resolve(address.rstrip("/")).func.view_class.__name__ == "UnknownAddressView", address + + def test_the_older_addresses_sent_without_their_slash_are_still_redirected(self): + for address in (V1_REGISTRATION, V2_REGISTRATION, V1_LOGIN_SESSION, V2_LOGIN_SESSION, V1_PASSWORD_RESET): + response = Client().get(address.rstrip("/")) + assert response.status_code == 301, address + assert response["Location"] == address, address + + def test_the_older_validation_address_is_still_served_only_without_a_slash(self): + assert Client().post(f"{V1_REGISTRATION_VALIDATION}/", {}).status_code == 404 + assert Client().post(V1_REGISTRATION_VALIDATION, {}).status_code == 200 + + +#: What each view these addresses are answered by holds of its own, read as +#: this module is imported, before any request is served. The first request a +#: view that reads signed tokens serves adds a permission to that view's class +#: in place, so a class read once such a request has run no longer shows what +#: it declares. +DECLARED_ATTRIBUTES = { + view: dict(vars(view)) + for view in ( + authn_views.RegistrationView, + authn_views.PasswordResetView, + authn_views.PasswordResetTokenValidation, + authn_views.RegistrationValidationView, + authn_views.LoginSessionView, + register.RegistrationView, + register.RegistrationValidationView, + password_reset.PasswordResetView, + password_reset.PasswordResetTokenValidation, + login.LoginSessionView, + ) +} + + +@skip_unless_lms +class ViewShapeTests(TestCase): + """ + The views add a declaration of who may call them and the description they are published with. + + Everything else -- how a caller is identified, CSRF handling, the limits + on how often a request may be sent, the bodies and codes -- is the view + they extend, reached through the method that view defines. + """ + + #: What a view class carries of its own besides its methods. + OWN_ATTRIBUTES = {"__module__", "__doc__", "permission_classes"} + + def test_each_view_declares_only_who_may_call_it(self): + for address, (_name, legacy_view, _kwargs) in CONFORMING_ROUTES.items(): + view = resolve(address).func.view_class + declared = DECLARED_ATTRIBUTES[view] + own = set(declared) - {"__firstlineno__", "__static_attributes__"} + methods = {name for name in own if name in view.http_method_names} + assert own - methods == self.OWN_ATTRIBUTES, address + assert declared["permission_classes"] == (AllowAny,), address + legacy_methods = {name for name in DECLARED_ATTRIBUTES[legacy_view] if name in view.http_method_names} + assert methods == legacy_methods, address + + #: The methods each address is served with. + SERVED_METHODS = { + REGISTRATIONS: {"get", "post"}, + PASSWORD_RESETS: {"get"}, + TOKEN_VALIDATIONS: {"post"}, + REGISTRATION_VALIDATIONS: {"post"}, + LOGIN_SESSIONS: {"get", "post"}, + } + + def test_each_method_hands_the_request_to_the_one_it_extends(self): + """A method of its own would run without the decorators the extended method carries.""" + for address, (_name, legacy_view, _kwargs) in CONFORMING_ROUTES.items(): + view = resolve(address).func.view_class + methods = {name for name in vars(view) if name in view.http_method_names} + assert methods == self.SERVED_METHODS[address], address + for method in methods: + assert vars(view)[method].__wrapped__ is vars(legacy_view)[method], (address, method) + + def test_how_a_caller_is_identified_is_the_extended_views(self): + for address, (_name, legacy_view, _kwargs) in CONFORMING_ROUTES.items(): + view = resolve(address).func.view_class + assert view.authentication_classes is legacy_view.authentication_classes, address + assert view.throttle_classes is legacy_view.throttle_classes, address + assert resolve(TOKEN_VALIDATIONS).func.view_class.authentication_classes == ( + api_settings.DEFAULT_AUTHENTICATION_CLASSES + ) + for address in (REGISTRATIONS, PASSWORD_RESETS, REGISTRATION_VALIDATIONS, LOGIN_SESSIONS): + assert resolve(address).func.view_class.authentication_classes == [], address + + def test_the_extended_views_are_left_as_they_were(self): + """Describing a method here changes nothing the older addresses publish or run.""" + for legacy_view in ( + register.RegistrationView, + register.RegistrationValidationView, + password_reset.PasswordResetView, + password_reset.PasswordResetTokenValidation, + login.LoginSessionView, + ): + assert "permission_classes" not in DECLARED_ATTRIBUTES[legacy_view], legacy_view + for legacy_method in ( + register.RegistrationView.get, + register.RegistrationView.post, + register.RegistrationValidationView.post, + password_reset.PasswordResetView.get, + password_reset.PasswordResetTokenValidation.post, + login.LoginSessionView.get, + ): + assert not hasattr(legacy_method, "kwargs"), legacy_method + assert set(login.LoginSessionView.post.kwargs) == {"schema"} + assert login.LoginSessionView.post.kwargs["schema"] is not ( + resolve(LOGIN_SESSIONS).func.view_class.post.kwargs["schema"] + ) + + +@skip_unless_lms +class RegistrationParityTests(TestCase): + """The registration address answers every request as the version 2 address it replaces does.""" + + def test_the_form_is_the_same_bytes(self): + legacy, conforming = Client().get(V2_REGISTRATION), Client().get(REGISTRATIONS) + assert_byte_equal(legacy, conforming) + assert {"csrftoken", "sessionid"} <= cookies_set(conforming) + assert json.loads(conforming.content)["submit_url"] == V2_REGISTRATION + + def test_a_configured_confirmation_field_is_offered_as_version_two_offers_it(self): + extra = {**settings.REGISTRATION_EXTRA_FIELDS, "confirm_email": "required"} + with override_settings(REGISTRATION_EXTRA_FIELDS=extra): + legacy, conforming = Client().get(V2_REGISTRATION), Client().get(REGISTRATIONS) + version_one = Client().get(V1_REGISTRATION) + assert_byte_equal(legacy, conforming) + offered = [field["name"] for field in json.loads(conforming.content)["fields"]] + assert "confirm_email" in offered + assert "confirm_email" not in [field["name"] for field in json.loads(version_one.content)["fields"]] + + def test_a_configured_confirmation_field_is_required_as_version_two_requires_it(self): + extra = {**settings.REGISTRATION_EXTRA_FIELDS, "confirm_email": "required"} + with override_settings(REGISTRATION_EXTRA_FIELDS=extra): + legacy = Client().post(V2_REGISTRATION, registration_form("confirm_old")) + conforming = Client().post(REGISTRATIONS, registration_form("confirm_new")) + version_one = Client().post(V1_REGISTRATION, registration_form("confirm_v_one")) + assert_same_answer(legacy, conforming, 400) + assert json.loads(conforming.content) == { + "confirm_email": [{"user_message": "You are missing one or more required fields"}], + "error_code": "validation-error", + } + assert version_one.status_code == 200 + + def test_an_account_is_created_and_the_caller_signed_in(self): + legacy = Client().post(V2_REGISTRATION, registration_form("parity_old")) + conforming_client = Client() + conforming = conforming_client.post(REGISTRATIONS, registration_form("parity_new")) + assert_same_answer( + legacy, + conforming, + 200, + volatile={"authenticated_user.username", "authenticated_user.full_name", "authenticated_user.user_id"}, + cookies=SIGNED_IN_COOKIES, + ) + created = User.objects.get(username="parity_new") + assert json.loads(conforming.content)["authenticated_user"] == { + "username": "parity_new", "full_name": "Parity New", "user_id": created.id, + } + assert int(conforming_client.session["_auth_user_id"]) == created.id + + def test_a_form_sent_as_multipart_is_read_as_well(self): + conforming = Client().post(REGISTRATIONS, registration_form("multipart_new")) + assert conforming.status_code == 200, conforming.content + sent_as_form = Client().post( + REGISTRATIONS, + "&".join(f"{key}={value}" for key, value in registration_form("urlencoded_new").items()), + content_type=FORM, + ) + assert sent_as_form.status_code == 200, sent_as_form.content + + def test_a_signed_in_caller_ends_up_signed_in_as_the_account_it_registered(self): + UserFactory.create(username="already_in", password=PASSWORD) + legacy_client = signed_in(User.objects.get(username="already_in")) + conforming_client = signed_in(User.objects.get(username="already_in")) + legacy = legacy_client.post(V2_REGISTRATION, registration_form("switch_old")) + conforming = conforming_client.post(REGISTRATIONS, registration_form("switch_new")) + assert legacy.status_code == conforming.status_code == 200 + assert int(conforming_client.session["_auth_user_id"]) == User.objects.get(username="switch_new").id + assert int(legacy_client.session["_auth_user_id"]) == User.objects.get(username="switch_old").id + + def test_no_csrf_token_is_asked_for(self): + legacy = Client(enforce_csrf_checks=True).post(V2_REGISTRATION, registration_form("csrf_old")) + conforming = Client(enforce_csrf_checks=True).post(REGISTRATIONS, registration_form("csrf_new")) + assert legacy.status_code == conforming.status_code == 200 + + def test_credentials_in_a_header_are_not_read(self): + """No authenticator runs, so a header that would be refused anywhere else is ignored.""" + legacy = Client().post(V2_REGISTRATION, {}, HTTP_AUTHORIZATION="JWT not-a-jwt") + conforming = Client().post(REGISTRATIONS, {}, HTTP_AUTHORIZATION="JWT not-a-jwt") + assert_same_answer(legacy, conforming, 400) + assert Client().get(REGISTRATIONS, HTTP_AUTHORIZATION="JWT not-a-jwt").status_code == 200 + + def test_an_empty_registration_names_every_field_it_needs(self): + """No registration address takes a request without a body, which is why each publishes it as needed.""" + legacy, conforming = Client().post(V2_REGISTRATION, {}), Client().post(REGISTRATIONS, {}) + assert_same_answer(legacy, conforming, 400) + needed = {"email", "error_code", "honor_code", "name", "password", "terms_of_service", "username"} + assert set(json.loads(conforming.content)) == needed + version_one = Client().post(V1_REGISTRATION, {}) + assert version_one.status_code == 400 + assert set(json.loads(version_one.content)) == needed + + def test_a_json_body_is_read_as_empty(self): + form = registration_form("json_new") + legacy = Client().post(V2_REGISTRATION, json.dumps(form), content_type=JSON) + conforming = Client().post(REGISTRATIONS, json.dumps(form), content_type=JSON) + assert_same_answer(legacy, conforming, 400) + assert not User.objects.filter(username="json_new").exists() + + def test_a_taken_name_is_refused_with_suggestions(self): + Client().post(REGISTRATIONS, registration_form("taken_name")) + legacy = Client().post(V2_REGISTRATION, registration_form("taken_name")) + conforming = Client().post(REGISTRATIONS, registration_form("taken_name")) + assert_same_answer(legacy, conforming, 409, volatile={"username_suggestions"}) + body = json.loads(conforming.content) + assert body["error_code"] == "duplicate-email-username" + assert len(body["username_suggestions"]) == 3 + + def test_a_country_the_site_does_not_know_is_refused(self): + legacy = Client().post(V2_REGISTRATION, {**registration_form("country_old"), "country": "ZZ"}) + conforming = Client().post(REGISTRATIONS, {**registration_form("country_new"), "country": "ZZ"}) + assert_same_answer(legacy, conforming, 400) + assert json.loads(conforming.content)["error_code"] == "invalid-country" + + def test_a_site_that_requires_another_provider_refuses_a_password(self): + with override_settings(ENABLE_REQUIRE_THIRD_PARTY_AUTH=True): + legacy = Client().post(V2_REGISTRATION, registration_form("provider_old")) + conforming = Client().post(REGISTRATIONS, registration_form("provider_new")) + assert_same_answer(legacy, conforming, 403) + assert conforming["Content-Type"].startswith("text/html") + + def test_a_site_that_creates_no_accounts_here_refuses_the_registration(self): + with override_settings(ALLOW_PUBLIC_ACCOUNT_CREATION=False): + legacy = Client().post(V2_REGISTRATION, registration_form("closed_old")) + conforming = Client().post(REGISTRATIONS, registration_form("closed_new")) + assert_same_answer(legacy, conforming, 403) + assert conforming.content == b"Account creation not allowed." + + @override_settings(OPEN_EDX_FILTERS_CONFIG=REFUSE_REGISTRATION) + def test_a_step_the_deployment_adds_refuses_it_at_both(self): + legacy = Client().post(V2_REGISTRATION, registration_form("step_old")) + conforming = Client().post(REGISTRATIONS, registration_form("step_new")) + assert_same_answer(legacy, conforming, 403) + assert json.loads(conforming.content) == { + "error_message": [{"user_message": "Registration is closed."}], + "error_code": "registration-closed", + } + + def test_the_methods_it_does_not_serve_are_refused_alike(self): + # This view runs outside the transaction a request is otherwise given, + # so a refusal marks the transaction it runs in for rollback; each + # request is given one of its own here, as a deployment gives it one. + with transaction.atomic(): + legacy = Client().put(V2_REGISTRATION) + with transaction.atomic(): + conforming = Client().put(REGISTRATIONS) + assert_same_answer(legacy, conforming, 405) + assert json.loads(conforming.content) == {"detail": 'Method "PUT" not allowed.'} + + def test_head_is_answered_as_get_is(self): + legacy, conforming = Client().head(V2_REGISTRATION), Client().head(REGISTRATIONS) + assert legacy.status_code == conforming.status_code == 200 + assert conforming["Content-Type"] == legacy["Content-Type"] == JSON + assert conforming.content == legacy.content == b"" + + def test_options_differ_only_in_the_description(self): + legacy, conforming = Client().options(V2_REGISTRATION), Client().options(REGISTRATIONS) + assert legacy.status_code == conforming.status_code == 200 + _changed, unexpected = differences( + json.loads(legacy.content), json.loads(conforming.content), OPTIONS_DIFFERENCES, + ) + assert not unexpected + assert json.loads(conforming.content)["name"] == "Registration" + + def test_the_account_registered_is_read_where_every_account_is(self): + """A registration answers with a session and where to go next; the account is read at its own address.""" + client = Client() + registered = client.post(REGISTRATIONS, registration_form("read_back")) + assert set(json.loads(registered.content)) == {"authenticated_user", "success", "redirect_url"} + account = client.get(reverse("user_v2:account_detail", kwargs={"username": "read_back"})) + assert account.status_code == 200 + assert json.loads(account.content)["email"] == "read_back@example.com" + + +@skip_unless_lms +@override_settings(CACHES=COUNTING_CACHE) +class RegistrationLimitTests(TestCase): + """The registrations counted against an address are counted at every address registering accounts.""" + + def setUp(self): + super().setUp() + cache.clear() + self.addCleanup(cache.clear) + self.allowed = limit_of(settings.REGISTRATION_RATELIMIT) + + def test_registrations_sent_to_the_older_address_count_here(self): + assert spend(V2_REGISTRATION, {}, self.allowed) == [400] * self.allowed + refused = Client().post(REGISTRATIONS, {}) + assert refused.status_code == 403 + assert json.loads(refused.content) == {"error_code": "forbidden-request"} + assert Client().post(REGISTRATIONS, {}, REMOTE_ADDR="10.0.0.9").status_code == 400 + + def test_registrations_sent_to_the_other_older_addresses_count_here(self): + for address in (V1_REGISTRATION, CREATE_ACCOUNT): + cache.clear() + assert spend(address, {}, self.allowed) == [400] * self.allowed, address + refused = Client().post(REGISTRATIONS, {}) + assert refused.status_code == 403, address + assert json.loads(refused.content) == {"error_code": "forbidden-request"}, address + + def test_registrations_sent_here_count_at_every_older_address(self): + assert spend(REGISTRATIONS, {}, self.allowed) == [400] * self.allowed + for address in (V1_REGISTRATION, V2_REGISTRATION, CREATE_ACCOUNT): + refused = Client().post(address, {}) + assert refused.status_code == 403, address + assert json.loads(refused.content) == {"error_code": "forbidden-request"}, address + + def test_reading_the_form_is_not_counted(self): + assert [Client().get(REGISTRATIONS).status_code for _ in range(self.allowed + 2)] == [200] * ( + self.allowed + 2 + ) + + +@skip_unless_lms +class PasswordResetFormParityTests(TestCase): + """The password reset form is the one the older address describes.""" + + def test_the_form_is_the_same_bytes(self): + legacy, conforming = Client().get(V1_PASSWORD_RESET), Client().get(PASSWORD_RESETS) + assert_byte_equal(legacy, conforming) + assert cookies_set(conforming) == {"csrftoken"} + assert json.loads(conforming.content)["submit_url"] == reverse("password_change_request") + + def test_a_signed_in_caller_reads_the_same_form(self): + UserFactory.create(username="reset_reader", password=PASSWORD) + reader = User.objects.get(username="reset_reader") + assert_byte_equal(signed_in(reader).get(V1_PASSWORD_RESET), signed_in(reader).get(PASSWORD_RESETS)) + + def test_head_is_answered_as_get_is(self): + legacy, conforming = Client().head(V1_PASSWORD_RESET), Client().head(PASSWORD_RESETS) + assert legacy.status_code == conforming.status_code == 200 + assert conforming.content == legacy.content == b"" + + def test_the_methods_it_does_not_serve_are_refused_alike(self): + assert_same_answer(Client().post(V1_PASSWORD_RESET), Client().post(PASSWORD_RESETS), 405) + + def test_options_differ_only_in_the_description(self): + legacy, conforming = Client().options(V1_PASSWORD_RESET), Client().options(PASSWORD_RESETS) + _changed, unexpected = differences( + json.loads(legacy.content), json.loads(conforming.content), OPTIONS_DIFFERENCES, + ) + assert not unexpected + + +@skip_unless_lms +class TokenValidationParityTests(TestCase): + """A reset token is checked here exactly as it is at the older address.""" + + def setUp(self): + super().setUp() + self.owner = UserFactory.create(username="token_owner", password=PASSWORD) + self.other = UserFactory.create(username="token_other", password=PASSWORD) + + def both(self, client_factory, data, content_type=None, **extra): + """Send one check to the older address and to this one, each from a new client.""" + sent = {"content_type": content_type} if content_type else {} + return ( + client_factory().post(V1_TOKEN_VALIDATION, data, **sent, **extra), + client_factory().post(TOKEN_VALIDATIONS, data, **sent, **extra), + ) + + def test_a_valid_token_is_valid(self): + legacy, conforming = self.both(Client, {"token": reset_token(self.owner)}) + assert_same_answer(legacy, conforming, 200) + assert json.loads(conforming.content) == {"is_valid": True} + + def test_a_token_sent_as_json_is_read(self): + legacy, conforming = self.both(Client, json.dumps({"token": reset_token(self.owner)}), JSON) + assert_same_answer(legacy, conforming, 200) + assert json.loads(conforming.content) == {"is_valid": True} + + def test_a_token_that_cannot_be_read_or_is_absent_is_not_valid(self): + for data in ({"token": "zz-zz"}, {}): + legacy, conforming = self.both(Client, data) + assert_same_answer(legacy, conforming, 200) + assert json.loads(conforming.content) == {"is_valid": False}, data + + def test_a_valid_token_activates_an_inactive_account(self): + for address, username in ((V1_TOKEN_VALIDATION, "inactive_old"), (TOKEN_VALIDATIONS, "inactive_new")): + inactive = UserFactory.create(username=username, password=PASSWORD, is_active=False) + response = Client().post(address, {"token": reset_token(inactive)}) + assert json.loads(response.content) == {"is_valid": True}, address + inactive.refresh_from_db() + assert inactive.is_active, address + + def test_a_signed_in_caller_is_told_only_about_its_own_account(self): + # Signing in changes what a token is made from, so each token is made + # once both sessions exist. + sessions = (signed_in(self.owner), signed_in(self.owner)) + self.owner.refresh_from_db() + for token_of, valid in ((self.owner, True), (self.other, False)): + data = {"token": reset_token(token_of)} + legacy = sessions[0].post(V1_TOKEN_VALIDATION, data) + conforming = sessions[1].post(TOKEN_VALIDATIONS, data) + assert_same_answer(legacy, conforming, 200) + assert json.loads(conforming.content) == {"is_valid": valid}, token_of.username + + def test_a_session_caller_sending_no_csrf_token_is_refused(self): + legacy, conforming = self.both( + lambda: signed_in(self.owner, enforce_csrf_checks=True), {"token": reset_token(self.owner)}, + ) + assert_same_answer(legacy, conforming, 403) + assert json.loads(conforming.content) == {"detail": "CSRF Failed: CSRF cookie not set."} + + def test_a_caller_presenting_a_token_of_its_own_needs_no_csrf_token(self): + jwt = create_jwt_for_user(self.owner) + own = self.both( + lambda: Client(enforce_csrf_checks=True), + {"token": reset_token(self.owner)}, + HTTP_AUTHORIZATION=f"JWT {jwt}", + ) + assert_same_answer(*own, 200) + assert json.loads(own[1].content) == {"is_valid": True} + other = self.both(Client, {"token": reset_token(self.other)}, HTTP_AUTHORIZATION=f"JWT {jwt}") + assert json.loads(other[1].content) == {"is_valid": False} + + def test_a_caller_presenting_a_token_issued_to_a_restricted_application_is_refused(self): + jwt = create_jwt_for_user(self.owner, additional_claims={"is_restricted": True}) + legacy, conforming = self.both(Client, {"token": reset_token(self.owner)}, HTTP_AUTHORIZATION=f"JWT {jwt}") + assert_same_answer(legacy, conforming, 403) + assert json.loads(conforming.content) == {"detail": "You do not have permission to perform this action."} + + def test_a_session_whose_account_was_made_inactive_is_answered_as_no_session(self): + """Another account's token is valid to it, and no CSRF token is asked for, as for a caller not signed in.""" + sessions = (signed_in(self.other, enforce_csrf_checks=True), signed_in(self.other, enforce_csrf_checks=True)) + User.objects.filter(pk=self.other.pk).update(is_active=False) + data = {"token": reset_token(self.owner)} + legacy = sessions[0].post(V1_TOKEN_VALIDATION, data) + conforming = sessions[1].post(TOKEN_VALIDATIONS, data) + assert_same_answer(legacy, conforming, 200) + assert json.loads(conforming.content) == {"is_valid": True} + + def test_credentials_that_cannot_be_read_are_refused(self): + legacy, conforming = self.both(Client, {"token": "zz"}, HTTP_AUTHORIZATION="JWT not-a-jwt") + assert_same_answer(legacy, conforming, 401) + assert json.loads(conforming.content) == {"detail": "Invalid token."} + + def test_a_body_that_cannot_be_parsed_is_refused(self): + legacy, conforming = self.both(Client, "{not json", JSON) + assert_same_answer(legacy, conforming, 400) + assert json.loads(conforming.content)["detail"].startswith("JSON parse error") + + def test_the_methods_it_does_not_serve_are_refused_alike(self): + assert_same_answer(Client().get(V1_TOKEN_VALIDATION), Client().get(TOKEN_VALIDATIONS), 405) + + +@skip_unless_lms +@override_settings(CACHES=COUNTING_CACHE) +class TokenValidationLimitTests(TestCase): + """The checks counted against a caller are counted at both addresses.""" + + def setUp(self): + super().setUp() + cache.clear() + self.addCleanup(cache.clear) + self.allowed = limit_of(settings.RESET_PASSWORD_TOKEN_VALIDATE_API_RATELIMIT) + self.owner = UserFactory.create(username="limited_owner", password=PASSWORD) + + def test_checks_sent_to_the_older_address_count_here(self): + assert spend(V1_TOKEN_VALIDATION, {"token": "zz"}, self.allowed) == [200] * self.allowed + refused = Client().post(TOKEN_VALIDATIONS, {"token": "zz"}) + assert refused.status_code == 429 + assert json.loads(refused.content)["detail"].startswith("Request was throttled.") + + def test_checks_sent_here_count_at_the_older_address(self): + assert spend(TOKEN_VALIDATIONS, {"token": "zz"}, self.allowed) == [200] * self.allowed + assert Client().post(V1_TOKEN_VALIDATION, {"token": "zz"}).status_code == 429 + + def test_a_signed_in_caller_is_not_counted_with_the_others(self): + spend(TOKEN_VALIDATIONS, {"token": "zz"}, self.allowed + 1) + session = signed_in(self.owner) + assert [session.post(TOKEN_VALIDATIONS, {"token": "zz"}).status_code for _ in range(3)] == [200] * 3 + + +@skip_unless_lms +class RegistrationValidationParityTests(TestCase): + """Candidate registration values are judged here as they are at the older address.""" + + def both(self, client_factory, data, content_type=JSON): + """Send one check to the older address and to this one, each from a new client.""" + body = json.dumps(data) if content_type == JSON and not isinstance(data, str) else data + return ( + client_factory().post(V1_REGISTRATION_VALIDATION, body, content_type=content_type), + client_factory().post(REGISTRATION_VALIDATIONS, body, content_type=content_type), + ) + + def test_a_taken_username_is_judged_with_suggestions(self): + UserFactory.create(username="judged_name", password=PASSWORD) + legacy, conforming = self.both(Client, {"username": "judged_name", "email": "free@example.com"}) + assert_same_answer(legacy, conforming, 200, volatile={"username_suggestions"}) + body = json.loads(conforming.content) + assert body["validation_decisions"] == { + "username": "It looks like this username is already taken", "email": "", + } + assert len(body["username_suggestions"]) == 3 + + def test_one_field_named_is_the_only_one_judged(self): + legacy, conforming = self.both( + Client, {"form_field_key": "password", "password": "short", "username": "x"}, + ) + assert_same_answer(legacy, conforming, 200) + assert set(json.loads(conforming.content)["validation_decisions"]) == {"password"} + + def test_values_sent_as_a_form_are_read(self): + legacy = Client().post(V1_REGISTRATION_VALIDATION, {"username": "free_name"}) + conforming = Client().post(REGISTRATION_VALIDATIONS, {"username": "free_name"}) + assert_same_answer(legacy, conforming, 200) + assert json.loads(conforming.content) == {"validation_decisions": {"username": ""}} + + def test_nothing_sent_is_nothing_judged(self): + legacy, conforming = self.both(Client, {}) + assert_same_answer(legacy, conforming, 200) + assert json.loads(conforming.content) == {"validation_decisions": {}} + + def test_no_csrf_token_is_asked_for_of_any_caller(self): + UserFactory.create(username="validating_caller", password=PASSWORD) + caller = User.objects.get(username="validating_caller") + for client_factory in ( + lambda: Client(enforce_csrf_checks=True), + lambda: signed_in(caller, enforce_csrf_checks=True), + ): + legacy, conforming = self.both(client_factory, {"username": "x"}) + assert_same_answer(legacy, conforming, 200) + + def test_a_body_that_cannot_be_parsed_is_refused(self): + legacy, conforming = self.both(Client, "{not json") + assert_same_answer(legacy, conforming, 400) + assert json.loads(conforming.content)["detail"].startswith("JSON parse error") + + def test_the_methods_it_does_not_serve_are_refused_alike(self): + legacy = Client().get(V1_REGISTRATION_VALIDATION) + conforming = Client().get(REGISTRATION_VALIDATIONS) + assert_same_answer(legacy, conforming, 405) + + +@skip_unless_lms +@override_settings(CACHES=COUNTING_CACHE) +class RegistrationValidationLimitTests(TestCase): + """The checks counted against an address are counted at both.""" + + def setUp(self): + super().setUp() + cache.clear() + self.addCleanup(cache.clear) + self.allowed = limit_of(settings.REGISTRATION_VALIDATION_RATELIMIT) + + def test_checks_sent_to_the_older_address_count_here(self): + assert spend(V1_REGISTRATION_VALIDATION, {}, self.allowed) == [200] * self.allowed + refused = Client().post(REGISTRATION_VALIDATIONS, {}) + assert refused.status_code == 403 + assert json.loads(refused.content) == {"detail": "You do not have permission to perform this action."} + + def test_checks_sent_here_count_at_the_older_address(self): + assert spend(REGISTRATION_VALIDATIONS, {}, self.allowed) == [200] * self.allowed + assert Client().post(V1_REGISTRATION_VALIDATION, {}).status_code == 403 + + +@skip_unless_lms +class LoginParityTests(TestCase): + """Signing in here is signing in at the version 2 address it replaces.""" + + def setUp(self): + super().setUp() + self.user = UserFactory.create(username="signing_in", email="signing_in@example.com", password=PASSWORD) + + def both(self, client_factory, data, **extra): + """Send one sign-in to the version 2 address and to this one, each from a new client.""" + return ( + client_factory().post(V2_LOGIN_SESSION, data, **extra), + client_factory().post(LOGIN_SESSIONS, data, **extra), + ) + + def test_the_form_is_the_same_bytes_as_at_either_older_version(self): + conforming = Client().get(LOGIN_SESSIONS) + for address in (V1_LOGIN_SESSION, V2_LOGIN_SESSION): + assert_byte_equal(Client().get(address), conforming) + assert {"csrftoken", "sessionid"} <= cookies_set(conforming) + + def test_head_is_answered_as_get_is(self): + legacy, conforming = Client().head(V2_LOGIN_SESSION), Client().head(LOGIN_SESSIONS) + assert legacy.status_code == conforming.status_code == 200 + assert conforming.content == legacy.content == b"" + + def test_options_differ_only_in_the_description(self): + legacy, conforming = Client().options(V2_LOGIN_SESSION), Client().options(LOGIN_SESSIONS) + _changed, unexpected = differences( + json.loads(legacy.content), json.loads(conforming.content), OPTIONS_DIFFERENCES, + ) + assert not unexpected + + def test_an_account_is_named_by_its_username_or_its_email(self): + for named in ("signing_in", "signing_in@example.com"): + legacy, conforming = self.both(Client, {"email_or_username": named, "password": PASSWORD}) + assert_same_answer(legacy, conforming, 200, cookies=SIGNED_IN_COOKIES) + assert json.loads(conforming.content) == {"success": True, "redirect_url": None}, named + + def test_the_version_one_field_name_is_not_read(self): + """The address answers as version 2 does, which reads the account from another field.""" + legacy, conforming = self.both(Client, {"email": "signing_in@example.com", "password": PASSWORD}) + assert_same_answer(legacy, conforming, 400) + assert json.loads(conforming.content) == {"success": False, "value": MISSING_LOGIN_FIELDS, "email": None} + version_one = Client().post(V1_LOGIN_SESSION, {"email": "signing_in@example.com", "password": PASSWORD}) + assert version_one.status_code == 200 + + def test_a_wrong_password_is_refused(self): + legacy, conforming = self.both(Client, {"email_or_username": "signing_in", "password": "wrong"}) + assert_same_answer(legacy, conforming, 400) + body = json.loads(conforming.content) + assert body["error_code"] == "incorrect-email-or-password" + assert body["context"] == {"failure_count": 0} + + def test_an_inactive_account_is_refused(self): + inactive = UserFactory.create(username="not_active", password=PASSWORD, is_active=False) + Registration().register(inactive) + legacy, conforming = self.both(Client, {"email_or_username": "not_active", "password": PASSWORD}) + assert_same_answer(legacy, conforming, 400) + assert json.loads(conforming.content)["error_code"] == "inactive-user" + + def test_a_signed_in_caller_ends_up_signed_in_as_the_account_it_named(self): + other = UserFactory.create(username="signed_in_first", password=PASSWORD) + clients = (signed_in(other), signed_in(other)) + legacy = clients[0].post(V2_LOGIN_SESSION, {"email_or_username": "signing_in", "password": PASSWORD}) + conforming = clients[1].post(LOGIN_SESSIONS, {"email_or_username": "signing_in", "password": PASSWORD}) + assert legacy.status_code == conforming.status_code == 200 + assert int(clients[1].session["_auth_user_id"]) == self.user.id + assert int(clients[0].session["_auth_user_id"]) == self.user.id + + def test_a_sign_in_without_a_csrf_token_is_refused(self): + legacy, conforming = self.both( + lambda: Client(enforce_csrf_checks=True), {"email_or_username": "signing_in", "password": PASSWORD}, + ) + assert_same_answer(legacy, conforming, 403) + assert conforming["Content-Type"].startswith("text/html") + + def test_the_token_the_form_sets_lets_the_caller_sign_in(self): + for form_address, address in ((V2_LOGIN_SESSION, V2_LOGIN_SESSION), (LOGIN_SESSIONS, LOGIN_SESSIONS)): + client = Client(enforce_csrf_checks=True) + client.get(form_address) + response = client.post( + address, + {"email_or_username": "signing_in", "password": PASSWORD}, + HTTP_X_CSRFTOKEN=client.cookies["csrftoken"].value, + ) + assert response.status_code == 200, (address, response.content) + assert json.loads(response.content) == {"success": True, "redirect_url": None} + + def test_credentials_in_a_header_are_not_read(self): + legacy, conforming = self.both( + Client, {"email_or_username": "signing_in", "password": "wrong"}, HTTP_AUTHORIZATION="JWT not-a-jwt", + ) + assert_same_answer(legacy, conforming, 400) + + def test_a_site_that_requires_another_provider_refuses_a_password(self): + with override_settings(ENABLE_REQUIRE_THIRD_PARTY_AUTH=True): + legacy, conforming = self.both(Client, {"email_or_username": "signing_in", "password": PASSWORD}) + assert_same_answer(legacy, conforming, 403) + assert conforming["Content-Type"].startswith("text/html") + + def test_a_provider_account_linked_to_no_account_here_is_refused(self): + running = {"backend": "oa2-provider", "kwargs": {"username": "elsewhere", "uid": "elsewhere"}} + with mock.patch.object(login.pipeline, "running", return_value=True), \ + mock.patch.object(login.pipeline, "get", return_value=running), \ + mock.patch.object( + login.provider.Registry, "get_from_pipeline", return_value=SimpleNamespace(name="Provider"), + ), \ + mock.patch.object(login.pipeline, "get_authenticated_user", side_effect=User.DoesNotExist): + legacy, conforming = self.both(Client, {}) + assert_same_answer(legacy, conforming, 403) + body = json.loads(conforming.content) + assert body["success"] is False + assert body["error_code"] == "third-party-auth-with-no-linked-account" + + @override_settings(OPEN_EDX_FILTERS_CONFIG=REFUSE_LOGIN) + def test_a_step_the_deployment_adds_refuses_it_at_both(self): + legacy, conforming = self.both(Client, {"email_or_username": "signing_in", "password": PASSWORD}) + assert_same_answer(legacy, conforming, 400) + assert json.loads(conforming.content) == { + "success": False, + "value": "Sign-in is closed.", + "redirect_url": "/closed", + "error_code": "sign-in-closed", + "context": {"reason": "closed"}, + "email": "signing_in@example.com", + } + + def test_the_methods_it_does_not_serve_are_refused_alike(self): + assert_same_answer(Client().put(V2_LOGIN_SESSION), Client().put(LOGIN_SESSIONS), 405) + + +@skip_unless_lms +@override_settings(CACHES=COUNTING_CACHE) +class LoginLimitTests(TestCase): + """The failed sign-ins counted against a caller, or against the account named, are counted wherever it signs in.""" + + def setUp(self): + super().setUp() + cache.clear() + self.addCleanup(cache.clear) + self.allowed = limit_of(settings.LOGISTRATION_RATELIMIT_RATE) + self.user = UserFactory.create(username="counted", email="counted@example.com", password=PASSWORD) + + def assert_refused_for_a_while(self, response): + """The refusal is asserted on its code and message alone.""" + assert response.status_code == 400 + body = json.loads(response.content) + assert body["success"] is False + assert body["value"] == TOO_MANY_LOGINS + + def test_failures_at_the_older_address_count_here(self): + failures = spend(V2_LOGIN_SESSION, {"email_or_username": "counted", "password": "wrong"}, self.allowed) + assert failures == [400] * self.allowed + self.assert_refused_for_a_while( + Client().post(LOGIN_SESSIONS, {"email_or_username": "counted", "password": PASSWORD}), + ) + + def test_failures_at_version_one_or_the_sign_in_function_count_here(self): + for address in (V1_LOGIN_SESSION, LOGIN_AJAX): + cache.clear() + failures = spend(address, {"email": "counted@example.com", "password": "wrong"}, self.allowed) + assert failures == [400] * self.allowed, address + self.assert_refused_for_a_while( + Client().post(LOGIN_SESSIONS, {"email_or_username": "counted", "password": PASSWORD}), + ) + + def test_failures_here_count_at_every_older_address(self): + failures = spend(LOGIN_SESSIONS, {"email_or_username": "counted", "password": "wrong"}, self.allowed) + assert failures == [400] * self.allowed + for address in (V1_LOGIN_SESSION, LOGIN_AJAX): + self.assert_refused_for_a_while( + Client().post(address, {"email": "counted@example.com", "password": PASSWORD}), + ) + self.assert_refused_for_a_while( + Client().post(V2_LOGIN_SESSION, {"email_or_username": "counted", "password": PASSWORD}), + ) + + def assert_counted_for_the_account_at_both(self, spent_at, refused_at): + """ + Failures naming one account, each from a client at an address of its own, are counted for the account. + + No client sends enough of them to be refused for its own address, so + the refusal that follows, from yet another client, is the one counted + for the account. + """ + allowed = limit_of(settings.LOGISTRATION_PER_EMAIL_RATELIMIT_RATE) + failures = [ + Client().post(spent_at, {"email_or_username": "counted", "password": "wrong"}, REMOTE_ADDR=f"10.0.1.{n}") + for n in range(allowed) + ] + assert [(failure.status_code, json.loads(failure.content)["value"]) for failure in failures] == ( + [(400, WRONG_LOGIN)] * allowed + ) + UserFactory.create(username="not_counted", password=PASSWORD) + assert Client().post( + refused_at, {"email_or_username": "not_counted", "password": PASSWORD}, REMOTE_ADDR="10.0.2.1", + ).status_code == 200 + self.assert_refused_for_a_while( + Client().post(refused_at, {"email_or_username": "counted", "password": PASSWORD}, REMOTE_ADDR="10.0.2.1"), + ) + + def test_failures_for_one_account_at_the_older_address_count_here_from_any_client(self): + self.assert_counted_for_the_account_at_both(V2_LOGIN_SESSION, LOGIN_SESSIONS) + + def test_failures_for_one_account_here_count_at_the_older_address_from_any_client(self): + self.assert_counted_for_the_account_at_both(LOGIN_SESSIONS, V2_LOGIN_SESSION) + + +#: A statement that changes rows, told by its first keyword whatever follows +#: it, so ``INSERT OR IGNORE INTO`` and ``INSERT IGNORE INTO`` (what a bulk +#: create that ignores conflicts sends) are writes as plain ``INSERT INTO`` is. +WRITE_STATEMENT = re.compile( + r"""\s*(?PINSERT|UPDATE|DELETE|REPLACE)\b + (?:\s+(?:OR\s+\w+|IGNORE|LOW_PRIORITY|DELAYED|HIGH_PRIORITY|QUICK|ONLY|INTO|FROM)\b)* + \s*[`"]?(?P\w+)?""", + re.IGNORECASE | re.VERBOSE, +) + + +def written_tables(captured): + """ + Return the (statement, table) of every write a captured block sent to the database. + + A write whose table cannot be read is still returned, with the whole + statement in place of the table, so it is never left out. + """ + writes = [] + for query in captured: + match = WRITE_STATEMENT.match(query["sql"]) + if match: + writes.append((match.group("statement").upper(), match.group("table") or query["sql"])) + return writes + + +@skip_unless_lms +def test_a_write_is_told_by_its_first_keyword_whatever_follows_it(): + captured = [{"sql": sql} for sql in ( + 'INSERT INTO "django_site" ("domain", "name") VALUES (\'a\', \'a\')', + 'INSERT OR IGNORE INTO "django_site" ("domain", "name") VALUES (\'b\', \'b\')', + "INSERT IGNORE INTO `django_site` (`domain`, `name`) VALUES ('c', 'c')", + "REPLACE INTO `django_site` (`domain`, `name`) VALUES ('d', 'd')", + 'UPDATE "django_session" SET "expire_date" = 1', + 'UPDATE OR REPLACE "django_session" SET "expire_date" = 1', + 'DELETE FROM "django_session" WHERE 1', + "DELETE LOW_PRIORITY QUICK IGNORE FROM `django_session` WHERE 1", + " insert into auth_user (username) values ('e')", + "DELETE FROM quick_links WHERE 1", + 'SELECT "id" FROM "django_site" FOR UPDATE', + 'SAVEPOINT "s1"', + 'RELEASE SAVEPOINT "s1"', + "INSERT (", + )] + assert written_tables(captured) == [ + ("INSERT", "django_site"), + ("INSERT", "django_site"), + ("INSERT", "django_site"), + ("REPLACE", "django_site"), + ("UPDATE", "django_session"), + ("UPDATE", "django_session"), + ("DELETE", "django_session"), + ("DELETE", "django_session"), + ("INSERT", "auth_user"), + ("DELETE", "quick_links"), + ("INSERT", "INSERT ("), + ] + + +#: What every request sends whatever it reads: the request starting and +#: finishing, the monitoring and current-user middleware around it, and a +#: model instance being built. +SIGNALS_OF_EVERY_REQUEST = ( + request_started, + request_finished, + monitoring_support_process_request, + monitoring_support_process_response, + monitoring_support_process_exception, + current_user_setter, + pre_init, + post_init, +) + + +@contextlib.contextmanager +def signals_sent(): + """ + Record each Django signal and Open edX event sent while this block runs. + + What every request sends is left out, and so is the saving of the + caller's session, which the capture of writes already accounts for. An + event is recorded as it is sent, whether or not events are turned on, and + an event that is turned on is recorded again by the signal it is sent as. + """ + sent = [] + + def recording(send): + """Return a replacement for one of the ways a signal or an event is sent.""" + def spy(signal, *args, **kwargs): + sender = kwargs.get("sender", args[0] if args else None) + saves_the_session = signal in (pre_save, post_save) and sender is Session + if signal not in SIGNALS_OF_EVERY_REQUEST and not saves_the_session: + sent.append((send.__name__, getattr(signal, "event_type", signal), sender)) + return send(signal, *args, **kwargs) + return spy + + with mock.patch.object(Signal, "send", recording(Signal.send)), \ + mock.patch.object(Signal, "send_robust", recording(Signal.send_robust)), \ + mock.patch.object(OpenEdxPublicSignal, "send_event", recording(OpenEdxPublicSignal.send_event)): + yield sent + + +@skip_unless_lms +def test_each_way_of_sending_is_recorded_even_with_events_turned_off(): + signal = Signal() + with mock.patch.object(STUDENT_REGISTRATION_COMPLETED, "_allow_events", False), signals_sent() as sent: + signal.send(sender=User) + signal.send_robust(User) + STUDENT_REGISTRATION_COMPLETED.send_event() + assert sent == [ + ("send", signal, User), + ("send_robust", signal, User), + ("send_event", "org.openedx.learning.student.registration.completed.v1", None), + ] + + +class _FormReadTests: + """Reading a form writes nothing but the caller's session, and sends nothing, at either address.""" + + #: The tables a read may write to in this configuration, by the address it reads. + EXPECTED_WRITES = {} + + PAIRS = ( + (V2_REGISTRATION, REGISTRATIONS), + (V1_PASSWORD_RESET, PASSWORD_RESETS), + (V2_LOGIN_SESSION, LOGIN_SESSIONS), + ) + + def captured(self, method, address): + """Return the writes one request to ``address`` sends to the database.""" + with CaptureQueriesContext(connection) as queries: + response = getattr(Client(), method)(address) + assert response.status_code == 200, (method, address) + return written_tables(queries.captured_queries) + + def signalled(self, method, address): + """Return the signals and events one request to ``address`` sends.""" + with signals_sent() as sent: + response = getattr(Client(), method)(address) + assert response.status_code == 200, (method, address) + return sent + + def test_a_read_writes_only_the_session_and_the_same_way_at_both_addresses(self): + for method in ("get", "head"): + for legacy, conforming in self.PAIRS: + legacy_writes, conforming_writes = self.captured(method, legacy), self.captured(method, conforming) + assert conforming_writes == legacy_writes, (method, conforming) + assert {table for _statement, table in conforming_writes} == self.EXPECTED_WRITES[conforming], ( + method, conforming, + ) + + def test_a_read_sends_no_signal_or_event_at_either_address(self): + for method in ("get", "head"): + for legacy, conforming in self.PAIRS: + legacy_sent, conforming_sent = self.signalled(method, legacy), self.signalled(method, conforming) + assert conforming_sent == legacy_sent, (method, conforming) + assert not conforming_sent, (method, conforming) + + +@skip_unless_lms +@override_settings(ENABLE_THIRD_PARTY_AUTH=True) +class FormReadWithThirdPartyAuthTests(_FormReadTests, TestCase): + """With sign-in through other providers on, the forms that offer it note it in the session.""" + + EXPECTED_WRITES = { + REGISTRATIONS: {"django_session"}, + PASSWORD_RESETS: set(), + LOGIN_SESSIONS: {"django_session"}, + } + + +@skip_unless_lms +@override_settings(ENABLE_THIRD_PARTY_AUTH=False) +class FormReadWithoutThirdPartyAuthTests(_FormReadTests, TestCase): + """With it off, reading a form writes nothing at all.""" + + EXPECTED_WRITES = {REGISTRATIONS: set(), PASSWORD_RESETS: set(), LOGIN_SESSIONS: set()} + + +#: What a sign-in through another provider that is under way holds: the +#: provider's backend and what the provider has said about the person. +RUNNING_PIPELINE = { + "backend": "google-oauth2", + "kwargs": { + "username": "elsewhere", + "details": {"fullname": "Else Where", "email": "elsewhere@example.com"}, + }, +} + + +class _FormReadDuringThirdPartySignIn(_FormReadTests): + """ + Reading a form while a sign-in through another provider is under way writes nothing and sends nothing. + + The sign-in under way and its provider are handed to the forms in place + of the ones the session would point to, so the session is not read for + them and nothing is written to it. + """ + + EXPECTED_WRITES = {REGISTRATIONS: set(), PASSWORD_RESETS: set(), LOGIN_SESSIONS: set()} + + #: How the registration form shows the fields a provider can fill in: type and default. + FIELDS_SHOWN = {} + + def provider(self): + """Return the provider the sign-in under way runs through, as this site knows it.""" + return OAuth2ProviderConfig(name="Provider", backend_name="google-oauth2", sync_learner_profile_data=True) + + def setUp(self): + super().setUp() + reading = mock.patch.object(login.pipeline, "get", return_value=RUNNING_PIPELINE) + looking_up = mock.patch.object(login.provider.Registry, "get_from_pipeline", return_value=self.provider()) + reading.start() + self.addCleanup(reading.stop) + self.found = looking_up.start() + self.addCleanup(looking_up.stop) + + def test_each_form_is_built_for_the_sign_in_under_way(self): + for legacy, conforming in ((V2_REGISTRATION, REGISTRATIONS), (V2_LOGIN_SESSION, LOGIN_SESSIONS)): + self.found.reset_mock() + assert_byte_equal(Client().get(legacy), Client().get(conforming)) + assert self.found.call_args_list == [mock.call(RUNNING_PIPELINE)] * 2, conforming + fields = json.loads(Client().get(REGISTRATIONS).content)["fields"] + shown = {field["name"]: (field["type"], field["defaultValue"]) for field in fields} + assert {name: shown.get(name) for name in self.FIELDS_SHOWN} == self.FIELDS_SHOWN + + +@skip_unless_lms +@override_settings(ENABLE_THIRD_PARTY_AUTH=True) +class FormReadDuringThirdPartySignInTests(_FormReadDuringThirdPartySignIn, TestCase): + """The provider is known and fills the profile in, so the form hides what it filled in.""" + + FIELDS_SHOWN = { + "username": ("hidden", "elsewhere"), + "name": ("hidden", "Else Where"), + "email": ("hidden", "elsewhere@example.com"), + "password": ("hidden", ""), + "social_auth_provider": ("hidden", "Provider"), + } + + +@skip_unless_lms +@override_settings(ENABLE_THIRD_PARTY_AUTH=True) +class FormReadDuringThirdPartySignInWithoutProfileFillingTests(_FormReadDuringThirdPartySignIn, TestCase): + """The provider is known and does not fill the profile in, so the form offers what it said, to change.""" + + FIELDS_SHOWN = { + "username": ("text", "elsewhere"), + "name": ("text", "Else Where"), + "email": ("email", "elsewhere@example.com"), + "password": ("hidden", ""), + "social_auth_provider": ("hidden", "Provider"), + } + + def provider(self): + return OAuth2ProviderConfig(name="Provider", backend_name="google-oauth2", sync_learner_profile_data=False) + + +@skip_unless_lms +@override_settings(ENABLE_THIRD_PARTY_AUTH=True) +class FormReadDuringThirdPartySignInThroughAnUnknownProviderTests(_FormReadDuringThirdPartySignIn, TestCase): + """The provider is not one this site knows, so the form is the stock one.""" + + FIELDS_SHOWN = { + "username": ("text", ""), + "name": ("text", ""), + "email": ("email", ""), + "password": ("password", ""), + "social_auth_provider": None, + } + + def provider(self): + return None + + +def query_count(send): + """Return the response ``send`` produces and the number of queries it took.""" + with CaptureQueriesContext(connection) as queries: + response = send() + return response, len(queries.captured_queries) + + +@pytest.mark.django_db +@skip_unless_lms +@pytest.mark.parametrize(("legacy", "conforming"), [ + (V2_REGISTRATION, REGISTRATIONS), + (V1_PASSWORD_RESET, PASSWORD_RESETS), + (V2_LOGIN_SESSION, LOGIN_SESSIONS), +]) +def test_reading_a_form_takes_as_many_queries_at_either_address(django_assert_num_queries, legacy, conforming): + Client().get(legacy) + Client().get(conforming) + _response, expected = query_count(lambda: Client().get(legacy)) + with django_assert_num_queries(expected): + assert Client().get(conforming).status_code == 200 + + +@pytest.mark.django_db +@skip_unless_lms +def test_a_registration_takes_as_many_queries_at_either_address(django_assert_num_queries): + Client().post(V2_REGISTRATION, registration_form("warm_old")) + Client().post(REGISTRATIONS, registration_form("warm_new")) + response, expected = query_count(lambda: Client().post(V2_REGISTRATION, registration_form("count_old"))) + assert response.status_code == 200 + with django_assert_num_queries(expected): + assert Client().post(REGISTRATIONS, registration_form("count_new")).status_code == 200 + + +@pytest.mark.django_db +@skip_unless_lms +@pytest.mark.parametrize(("password", "status"), [(PASSWORD, 200), ("wrong", 400)]) +def test_a_sign_in_takes_as_many_queries_at_either_address(django_assert_num_queries, password, status): + UserFactory.create(username="query_counted", password=PASSWORD) + data = {"email_or_username": "query_counted", "password": password} + Client().post(V2_LOGIN_SESSION, data) + Client().post(LOGIN_SESSIONS, data) + response, expected = query_count(lambda: Client().post(V2_LOGIN_SESSION, data)) + assert response.status_code == status + with django_assert_num_queries(expected): + assert Client().post(LOGIN_SESSIONS, data).status_code == status + + +@pytest.mark.django_db +@skip_unless_lms +@pytest.mark.parametrize(("legacy", "conforming", "data"), [ + (V1_TOKEN_VALIDATION, TOKEN_VALIDATIONS, {"token": "zz-zz"}), + (V1_REGISTRATION_VALIDATION, REGISTRATION_VALIDATIONS, {"username": "query_free", "email": "q@example.com"}), +]) +def test_a_check_takes_as_many_queries_at_either_address(django_assert_num_queries, legacy, conforming, data): + Client().post(legacy, data) + Client().post(conforming, data) + response, expected = query_count(lambda: Client().post(legacy, data)) + assert response.status_code == 200 + with django_assert_num_queries(expected): + assert Client().post(conforming, data).status_code == 200 + + +#: Each operation this version adds for the authentication app, with the name +#: it is published under in the document trimming the area off each path and +#: in the one trimming nothing, and the codes it is published with. +CONFORMING_OPERATIONS = { + (REGISTRATIONS, "get"): ("v2_registrations_retrieve", "api_user_v2_registrations_retrieve", ["200", "406"]), + (REGISTRATIONS, "post"): ( + "v2_registrations_create", "api_user_v2_registrations_create", ["200", "400", "403", "406", "409", "415"], + ), + (PASSWORD_RESETS, "get"): ( + "v2_password_resets_retrieve", "api_user_v2_password_resets_retrieve", ["200", "406"], + ), + (TOKEN_VALIDATIONS, "post"): ( + "v2_password_reset_token_validations_create", + "api_user_v2_password_reset_token_validations_create", + ["200", "400", "401", "403", "406", "415", "429"], + ), + (REGISTRATION_VALIDATIONS, "post"): ( + "v2_registration_validations_create", + "api_user_v2_registration_validations_create", + ["200", "400", "403", "406", "415"], + ), + (LOGIN_SESSIONS, "get"): ("v2_login_sessions_retrieve", "api_user_v2_login_sessions_retrieve", ["200", "406"]), + (LOGIN_SESSIONS, "post"): ( + "v2_login_sessions_create", "api_user_v2_login_sessions_create", ["200", "400", "403", "406", "415"], + ), +} + +#: The media types each body is published in, and whether it is published as one the operation needs. +CONFORMING_REQUESTS = { + (REGISTRATIONS, "post"): ([FORM, MULTIPART], True), + (TOKEN_VALIDATIONS, "post"): ([JSON, FORM, MULTIPART], False), + (REGISTRATION_VALIDATIONS, "post"): ([JSON, FORM, MULTIPART], False), + (LOGIN_SESSIONS, "post"): ([FORM, MULTIPART], True), +} + + +def published_operations(document, keys): + """Return the operation each (path, method) in ``keys`` is published as.""" + return {key: document["paths"][key[0]][key[1]] for key in keys} + + +def schema_nodes(node): + """Yield every mapping inside a published node.""" + if isinstance(node, dict): + yield node + for value in node.values(): + yield from schema_nodes(value) + elif isinstance(node, list): + for value in node: + yield from schema_nodes(value) + + +@skip_unless_lms +class ConformingDocumentTests(TestCase): + """What the two documents of this service publish about each address.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.documents = {"narrow": narrow_document(), "service-wide": service_wide_document()} + + def operations(self, which): + return published_operations(self.documents[which], CONFORMING_OPERATIONS) + + def test_each_operation_is_published_under_its_name_in_both_documents(self): + for which, position in (("narrow", 0), ("service-wide", 1)): + published = {key: operation["operationId"] for key, operation in self.operations(which).items()} + assert published == {key: names[position] for key, names in CONFORMING_OPERATIONS.items()}, which + + def test_each_operation_is_grouped_with_its_area_and_none_is_marked_for_the_client_library(self): + expected = {"narrow": ["user"], "service-wide": ["api"]} + for which, tags in expected.items(): + published = {key: operation["tags"] for key, operation in self.operations(which).items()} + assert published == dict.fromkeys(CONFORMING_OPERATIONS, tags), which + + def test_no_operation_is_marked_superseded(self): + for which in self.documents: + marked = {key: operation.get("deprecated", False) for key, operation in self.operations(which).items()} + assert marked == dict.fromkeys(CONFORMING_OPERATIONS, False), which + + def test_each_operation_publishes_the_codes_it_answers_with(self): + for which in self.documents: + published = {key: sorted(operation["responses"]) for key, operation in self.operations(which).items()} + assert published == {key: codes for key, (_n, _s, codes) in CONFORMING_OPERATIONS.items()}, which + + def test_each_body_is_published_in_the_forms_it_is_read_in(self): + for which in self.documents: + published = { + key: (sorted(operation["requestBody"]["content"]), operation["requestBody"].get("required", False)) + for key, operation in self.operations(which).items() + if key in CONFORMING_REQUESTS + } + expected = {key: (sorted(types), needed) for key, (types, needed) in CONFORMING_REQUESTS.items()} + assert published == expected, which + readers = {key for key, operation in self.operations(which).items() if "requestBody" in operation} + assert readers == set(CONFORMING_REQUESTS), which + + def test_signing_in_publishes_the_fields_version_two_reads(self): + for which in self.documents: + body = self.documents[which]["paths"][LOGIN_SESSIONS]["post"]["requestBody"]["content"] + for media_type in (FORM, MULTIPART): + shape = body[media_type]["schema"] + assert sorted(shape["properties"]) == ["email_or_username", "password"], which + assert shape["required"] == ["email_or_username", "password"], which + + def test_signing_in_publishes_the_security_the_older_sign_in_publishes(self): + """ + A client generated from either document treats the two sign-in addresses alike. + + Both need the CSRF token, which the older one publishes as a scheme of + its own; a document declaring that scheme says how the token is sent. + No other address here needs anything. + """ + older = self.documents["service-wide"]["paths"][LOGIN_SESSION_KEY]["post"]["security"] + assert older == [{"csrf": []}] + expected = {**dict.fromkeys(CONFORMING_OPERATIONS, [{}]), (LOGIN_SESSIONS, "post"): older} + for which in self.documents: + security = {key: operation.get("security") for key, operation in self.operations(which).items()} + assert security == expected, which + + def test_no_operation_publishes_a_choice_list(self): + for which in self.documents: + for key, operation in self.operations(which).items(): + assert not any("enum" in node for node in schema_nodes(operation)), (which, key) + + def test_the_older_sign_in_still_publishes_what_it_always_has(self): + """Describing the sign-in here leaves the description of the older address untouched.""" + operation = self.documents["service-wide"]["paths"]["/api/user/{api_version}/account/login_session/"]["post"] + for media_type in operation["requestBody"]["content"].values(): + assert media_type["schema"] == {"$ref": "#/components/schemas/LoginUserRequest"} + assert {code: response["content"][JSON]["schema"] for code, response in operation["responses"].items()} == ( + dict.fromkeys(["200", "400", "403"], {"$ref": "#/components/schemas/LoginUserResponse"}) + ) + assert operation["security"] == [{"csrf": []}] + + +LOGIN_SESSION_KEY = "/api/user/{api_version}/account/login_session/" + +#: Each operation the addresses above supersede, with the operation replacing +#: it and the name it is published under in the document trimming the area off +#: each path, where that document publishes it, and in the one trimming nothing. +SUPERSEDED_OPERATIONS = { + (V1_REGISTRATION, "get"): ( + (REGISTRATIONS, "get"), "v1_account_registration_retrieve", "api_user_v1_account_registration_retrieve", + ), + (V1_REGISTRATION, "post"): ( + (REGISTRATIONS, "post"), "v1_account_registration_create", "api_user_v1_account_registration_create", + ), + (V2_REGISTRATION, "get"): ( + (REGISTRATIONS, "get"), "v2_account_registration_retrieve", "api_user_v2_account_registration_retrieve", + ), + (V2_REGISTRATION, "post"): ( + (REGISTRATIONS, "post"), "v2_account_registration_create", "api_user_v2_account_registration_create", + ), + (V1_PASSWORD_RESET, "get"): ( + (PASSWORD_RESETS, "get"), + "v1_account_password_reset_retrieve", + "api_user_v1_account_password_reset_retrieve", + ), + (V1_TOKEN_VALIDATION, "post"): ( + (TOKEN_VALIDATIONS, "post"), + "v1_account_password_reset_token_validate_create", + "api_user_v1_account_password_reset_token_validate_create", + ), + (V1_REGISTRATION_VALIDATION, "post"): ( + (REGISTRATION_VALIDATIONS, "post"), + "v1_validation_registration_create", + "api_user_v1_validation_registration_create", + ), + (LOGIN_SESSION_KEY, "get"): ( + (LOGIN_SESSIONS, "get"), None, "api_user_account_login_session_retrieve", + ), + (LOGIN_SESSION_KEY, "post"): ( + (LOGIN_SESSIONS, "post"), None, "api_user_account_login_session_create", + ), +} + +#: The one superseded operation whose handler is described where it is +#: defined; it keeps that description rather than taking its replacement's. +SIGN_IN_BY_POSTING = (LOGIN_SESSION_KEY, "post") + +#: What each document publishes of the operations above. The narrow document +#: admits no address that takes its version as a parameter. +PUBLISHED_SUPERSEDED_OPERATIONS = { + "narrow": {key for key in SUPERSEDED_OPERATIONS if key[0] != LOGIN_SESSION_KEY}, + "service-wide": set(SUPERSEDED_OPERATIONS), +} + + +@skip_unless_lms +class SupersededDocumentTests(TestCase): + """What the two documents of this service publish about each address these replace.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.documents = {"narrow": narrow_document(), "service-wide": service_wide_document()} + + def operations(self, which): + return published_operations(self.documents[which], PUBLISHED_SUPERSEDED_OPERATIONS[which]) + + def test_each_operation_is_marked_superseded_in_both_documents(self): + for which in self.documents: + marked = {key: operation.get("deprecated") for key, operation in self.operations(which).items()} + assert marked == dict.fromkeys(PUBLISHED_SUPERSEDED_OPERATIONS[which], True), which + + def test_each_operation_keeps_the_name_it_is_published_under(self): + for which, position in (("narrow", 1), ("service-wide", 2)): + published = {key: operation["operationId"] for key, operation in self.operations(which).items()} + assert published == { + key: names[position] for key, names in SUPERSEDED_OPERATIONS.items() + if key in PUBLISHED_SUPERSEDED_OPERATIONS[which] + }, which + + def test_each_operation_is_grouped_with_its_area(self): + narrow = {key: operation["tags"] for key, operation in self.operations("narrow").items()} + assert narrow == dict.fromkeys(PUBLISHED_SUPERSEDED_OPERATIONS["narrow"], ["user"]) + service_wide = {key: operation["tags"] for key, operation in self.operations("service-wide").items()} + assert service_wide == dict.fromkeys(SUPERSEDED_OPERATIONS, ["api"]) + + def test_each_operation_publishes_what_the_one_replacing_it_publishes(self): + for which in self.documents: + for key, ((path, method), _narrow, _service_wide) in SUPERSEDED_OPERATIONS.items(): + if key == SIGN_IN_BY_POSTING or key not in PUBLISHED_SUPERSEDED_OPERATIONS[which]: + continue + superseded = self.documents[which]["paths"][key[0]][key[1]] + replacing = self.documents[which]["paths"][path][method] + assert superseded.get("requestBody") == replacing.get("requestBody"), (which, key) + assert superseded["responses"] == replacing["responses"], (which, key) + + def test_signing_in_by_posting_keeps_what_it_has_always_published(self): + document = self.documents["service-wide"] + operation = document["paths"][LOGIN_SESSION_KEY]["post"] + assert {media_type: body["schema"] for media_type, body in operation["requestBody"]["content"].items()} == ( + dict.fromkeys([JSON, FORM, MULTIPART], {"$ref": "#/components/schemas/LoginUserRequest"}) + ) + assert {code: response["content"][JSON]["schema"] for code, response in operation["responses"].items()} == ( + dict.fromkeys(["200", "400", "403"], {"$ref": "#/components/schemas/LoginUserResponse"}) + ) + assert operation["security"] == [{"csrf": []}] + assert {"LoginUserRequest", "LoginUserResponse"} <= set(document["components"]["schemas"]) + + def test_the_narrow_document_leaves_out_the_address_that_takes_its_version_as_a_parameter(self): + """Signing in by posting there is described by its handler, and that description is not what it does.""" + narrow = self.documents["narrow"] + assert LOGIN_SESSION_KEY not in narrow["paths"] + assert {"LoginUserRequest", "LoginUserResponse"} & set(narrow["components"]["schemas"]) == set() + + def test_only_the_token_check_publishes_an_unauthenticated_or_throttled_refusal(self): + token_checks = {(TOKEN_VALIDATIONS, "post"), (V1_TOKEN_VALIDATION, "post")} + for which, document in self.documents.items(): + keys = set(CONFORMING_OPERATIONS) | PUBLISHED_SUPERSEDED_OPERATIONS[which] + for key, operation in published_operations(document, keys).items(): + published = set(operation["responses"]) & {"401", "429"} + assert published == ({"401", "429"} if key in token_checks else set()), (which, key) + + +def published_response_schema(document, path, method, status): + """Return what a body answered at ``path`` with ``status`` must satisfy, components included.""" + node = document["paths"][path][method]["responses"][str(status)]["content"][JSON]["schema"] + return as_json_schema({**node, "components": document["components"]}) + + +def published_request_schema(document, path, media_type): + """Return what a body posted to ``path`` in ``media_type`` must satisfy, components included.""" + node = document["paths"][path]["post"]["requestBody"]["content"][media_type]["schema"] + return as_json_schema({**node, "components": document["components"]}) + + +def undeclared_members(body, node, where="$"): + """ + Return the members of ``body`` an object of ``node`` that lists its members does not declare. + + Where ``node`` offers bodies of different shapes, ``body`` is held to the + one it satisfies. + """ + if "anyOf" in node: + if body is None: + return [] + node = node["anyOf"][0] + if "oneOf" in node: + satisfied = [ + option for option in node["oneOf"] if jsonschema.validators.validator_for(option)(option).is_valid(body) + ] + if len(satisfied) != 1: + return [f"{where} satisfies {len(satisfied)} of the shapes offered"] + node = satisfied[0] + found = [] + if isinstance(body, dict) and "properties" in node: + extra = node.get("additionalProperties") + if extra is None: + found += [f"{where}.{key}" for key in body if key not in node["properties"]] + for key, value in body.items(): + member = node["properties"].get(key, extra if isinstance(extra, dict) else None) + if member: + found += undeclared_members(value, member, f"{where}.{key}") + elif isinstance(body, list) and "items" in node: + for index, item in enumerate(body): + found += undeclared_members(item, node["items"], f"{where}[{index}]") + return found + + +def test_a_body_offered_two_shapes_is_held_to_the_one_it_satisfies(): + listed = {"type": "object", "properties": {"detail": {"type": "string"}}, "required": ["detail"]} + keyed = {"type": "object", "properties": {"code": {"type": "string"}}, "additionalProperties": {"type": "array"}} + offered = {"oneOf": [keyed, listed]} + assert undeclared_members({"detail": "x"}, offered) == [] + assert undeclared_members({"detail": "x", "extra": "y"}, offered) == ["$.extra"] + assert undeclared_members({"code": "c", "field": []}, offered) == [] + assert undeclared_members({"detail": "x", "code": "c"}, offered) == ["$.code"] + assert undeclared_members({"detail": []}, offered) == [] + assert undeclared_members({"detail": 1}, offered) == ["$ satisfies 0 of the shapes offered"] + + +#: Members a body carries in some cases only, which the document therefore +#: does not publish as always present. They are listed by operation and code, +#: then by where they sit in the body (``[]`` stands for every item of a list), +#: each with the case it is carried in. A member a body carries that is not +#: listed here must be published as always present, and none listed may be. +SOMETIMES_CARRIED = { + (REGISTRATIONS, "get", 200): { + "$.fields[]": {"options": "Only an input offering a choice of values lists them."}, + }, + (REGISTRATIONS, "post", 400): { + "$": {"error_code": "Left out where a step the site adds refuses the registration without naming why."}, + }, + (REGISTRATIONS, "post", 403): { + "$": {"error_code": "Left out where a step the site adds refuses the registration without naming why."}, + }, + (REGISTRATIONS, "post", 409): { + "$": { + "error_code": "Left out where the username or the email address is held back for retired accounts.", + "username_suggestions": "Offered only where the username sent is taken.", + }, + }, + (REGISTRATION_VALIDATIONS, "post", 200): { + "$": {"username_suggestions": "Offered only for a taken username or for a name."}, + }, + (LOGIN_SESSIONS, "post", 400): { + "$": { + "value": "Left out where a step the site adds refuses the sign-in without a message.", + "error_code": "Left out of a refusal that has no name, such as one for a missing field.", + "email": "Left out of the refusal of a password found on lists of exposed passwords.", + "context": "Only a refusal that has details for its message carries them.", + "redirect_url": ( + "Only for a password found on lists of exposed passwords, or where a step the site adds names " + "where to send the person." + ), + }, + }, +} + + +def members_carried(body, node, where="$"): + """ + Yield, for each object in ``body`` whose node lists its members, where it sits, the listed members it + carries and the ones published as always present. + + Every item of a list is named by ``[]``. Where ``node`` offers bodies of + different shapes, ``body`` is held to the first one it satisfies. + """ + if "anyOf" in node: + if body is None: + return + node = node["anyOf"][0] + if "oneOf" in node: + node = [ + option for option in node["oneOf"] if jsonschema.validators.validator_for(option)(option).is_valid(body) + ][0] + if isinstance(body, dict) and "properties" in node: + yield where, set(body) & set(node["properties"]), set(node.get("required", ())) + extra = node.get("additionalProperties") + for key, value in body.items(): + member = node["properties"].get(key, extra if isinstance(extra, dict) else None) + if member: + yield from members_carried(value, member, f"{where}.{key}") + elif isinstance(body, list) and "items" in node: + for item in body: + yield from members_carried(item, node["items"], f"{where}[]") + + +def unkept_promises(path, method, status, body, node): + """ + Return each object of ``body`` whose members published as always present are not the ones it always carries. + + An operation another one replaces is held to what is listed for its + replacement, which publishes the same bodies. + """ + replacing = SUPERSEDED_OPERATIONS.get((path, method), ((path, method),))[0] + sometimes = SOMETIMES_CARRIED.get((*replacing, status), {}) + found = [] + for where, carried, promised in members_carried(body, node): + always = carried - set(sometimes.get(where, ())) + if promised != always: + found.append(f"{where} is published as always carrying {sorted(promised)}, and carries {sorted(always)}") + return found + + +def test_the_members_published_as_always_present_are_held_to_the_ones_each_body_carries(): + text = {"type": "string"} + item = {"type": "object", "properties": {"name": text, "options": {"type": "array"}}, "required": ["name"]} + node = {"oneOf": [ + {"type": "object", "properties": {"detail": text}, "required": ["detail"]}, + {"type": "object", "properties": {"fields": {"type": "array", "items": item}}, "required": ["fields"]}, + ]} + body = {"fields": [{"name": "a"}, {"name": "b", "options": []}]} + assert list(members_carried(body, node)) == [ + ("$", {"fields"}, {"fields"}), + ("$.fields[]", {"name"}, {"name"}), + ("$.fields[]", {"name", "options"}, {"name"}), + ] + assert list(members_carried({"detail": "x", "other": 1}, node)) == [("$", {"detail"}, {"detail"})] + form = (REGISTRATIONS, "get", 200) + assert not unkept_promises(*form, body, node) + assert unkept_promises(PASSWORD_RESETS, "get", 200, body, node) == [ + "$.fields[] is published as always carrying ['name'], and carries ['name', 'options']", + ] + assert not unkept_promises(V1_REGISTRATION, "get", 200, body, node) + loosened = {"oneOf": [node["oneOf"][0], {**node["oneOf"][1], "required": []}]} + assert unkept_promises(*form, body, loosened) == [ + "$ is published as always carrying [], and carries ['fields']", + ] + + +@skip_unless_lms +class PublishedShapeTests(TestCase): + """ + Every body these addresses really answer with is the one the document publishes for it. + + Each response below is sent to the running address, then held against the + shape published for that operation and code: every member published as + always present is there, each value has the published type, and an object + that lists its members carries no other. The members published as always + present are also exactly the ones the body carries, less those + ``SOMETIMES_CARRIED`` lists, and each of those is carried by one body + below and left out of another. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.document = service_wide_document() + + def setUp(self): + super().setUp() + self.carried = defaultdict(list) + + def assert_published(self, path, method, response): + """Assert ``response`` is a body the document publishes for that operation and code.""" + assert response["Content-Type"].startswith(JSON), (path, response.status_code) + body = json.loads(response.content) + status = response.status_code + schema = published_response_schema(self.document, path, method, status) + jsonschema.validate(body, schema) + assert undeclared_members(body, schema) == [], (path, method, status) + assert not unkept_promises(path, method, status, body, schema), (path, method, status) + for where, carried, _promised in members_carried(body, schema): + self.carried[(path, method, status, where)].append(carried) + + def assert_each_member_listed_was_carried_and_left_out(self, path, method): + """Assert each member listed for the operation as carried in some cases was carried and left out above.""" + listed = [(key, places) for key, places in SOMETIMES_CARRIED.items() if key[:2] == (path, method)] + assert listed, (path, method) + for (_path, _method, status), places in listed: + for where, members in places.items(): + for member in members: + seen = {member in carried for carried in self.carried[(path, method, status, where)]} + assert seen == {True, False}, (path, method, status, where, member) + + def test_the_forms(self): + extra = {**settings.REGISTRATION_EXTRA_FIELDS, "confirm_email": "required", "country": "required"} + with override_settings(REGISTRATION_EXTRA_FIELDS=extra): + self.assert_published(REGISTRATIONS, "get", Client().get(REGISTRATIONS)) + self.assert_published(REGISTRATIONS, "get", Client().get(REGISTRATIONS)) + self.assert_published(PASSWORD_RESETS, "get", Client().get(PASSWORD_RESETS)) + self.assert_published(LOGIN_SESSIONS, "get", Client().get(LOGIN_SESSIONS)) + self.assert_each_member_listed_was_carried_and_left_out(REGISTRATIONS, "get") + + def test_a_registration_and_its_refusals(self): + self.assert_published(REGISTRATIONS, "post", Client().post(REGISTRATIONS, registration_form("shape_new"))) + self.assert_published(REGISTRATIONS, "post", Client().post(REGISTRATIONS, registration_form("shape_new"))) + self.assert_published(REGISTRATIONS, "post", Client().post(REGISTRATIONS, {})) + self.assert_published( + REGISTRATIONS, "post", Client().post(REGISTRATIONS, {**registration_form("shape_x"), "country": "ZZ"}), + ) + with override_settings(OPEN_EDX_FILTERS_CONFIG=REFUSE_REGISTRATION): + self.assert_published(REGISTRATIONS, "post", Client().post(REGISTRATIONS, registration_form("shape_y"))) + for step in (RefuseRegistrationUnnamedStep, RefuseRegistrationUnnamedAsInvalidStep): + with override_settings(OPEN_EDX_FILTERS_CONFIG=running_only(step, StudentRegistrationRequested)): + self.assert_published( + REGISTRATIONS, "post", Client().post(REGISTRATIONS, registration_form("shape_z")), + ) + retired = registration_form(f"{settings.RETIRED_USERNAME_PREFIX}shape") + self.assert_published(REGISTRATIONS, "post", Client().post(REGISTRATIONS, retired)) + with override_settings(CACHES=COUNTING_CACHE): + cache.clear() + spend(REGISTRATIONS, {}, limit_of(settings.REGISTRATION_RATELIMIT)) + self.assert_published(REGISTRATIONS, "post", Client().post(REGISTRATIONS, {})) + cache.clear() + self.assert_each_member_listed_was_carried_and_left_out(REGISTRATIONS, "post") + + def test_a_token_check_and_its_refusals(self): + owner = UserFactory.create(username="shape_owner", password=PASSWORD) + valid = Client().post(TOKEN_VALIDATIONS, {"token": reset_token(owner)}) + self.assert_published(TOKEN_VALIDATIONS, "post", valid) + self.assert_published(TOKEN_VALIDATIONS, "post", Client().post(TOKEN_VALIDATIONS, "{x", content_type=JSON)) + self.assert_published( + TOKEN_VALIDATIONS, "post", Client().post(TOKEN_VALIDATIONS, {}, HTTP_AUTHORIZATION="JWT not-a-jwt"), + ) + self.assert_published( + TOKEN_VALIDATIONS, "post", signed_in(owner, enforce_csrf_checks=True).post(TOKEN_VALIDATIONS, {}), + ) + with override_settings(CACHES=COUNTING_CACHE): + cache.clear() + spend(TOKEN_VALIDATIONS, {}, limit_of(settings.RESET_PASSWORD_TOKEN_VALIDATE_API_RATELIMIT)) + self.assert_published(TOKEN_VALIDATIONS, "post", Client().post(TOKEN_VALIDATIONS, {})) + cache.clear() + + def test_a_validation_and_its_refusals(self): + UserFactory.create(username="shape_taken", password=PASSWORD) + for data in ({"username": "shape_taken", "email": "e@example.com"}, {"name": "Shape Name"}, {}): + self.assert_published( + REGISTRATION_VALIDATIONS, + "post", + Client().post(REGISTRATION_VALIDATIONS, json.dumps(data), content_type=JSON), + ) + self.assert_published( + REGISTRATION_VALIDATIONS, "post", Client().post(REGISTRATION_VALIDATIONS, "{x", content_type=JSON), + ) + with override_settings(CACHES=COUNTING_CACHE): + cache.clear() + spend(REGISTRATION_VALIDATIONS, {}, limit_of(settings.REGISTRATION_VALIDATION_RATELIMIT)) + self.assert_published(REGISTRATION_VALIDATIONS, "post", Client().post(REGISTRATION_VALIDATIONS, {})) + cache.clear() + self.assert_each_member_listed_was_carried_and_left_out(REGISTRATION_VALIDATIONS, "post") + + def test_a_sign_in_and_its_refusals(self): + UserFactory.create(username="shape_signer", password=PASSWORD) + inactive = UserFactory.create(username="shape_inactive", password=PASSWORD, is_active=False) + Registration().register(inactive) + for data in ( + {"email_or_username": "shape_signer", "password": PASSWORD}, + {"email_or_username": "shape_signer", "password": "wrong"}, + {"email_or_username": "shape_inactive", "password": PASSWORD}, + {"password": PASSWORD}, + ): + self.assert_published(LOGIN_SESSIONS, "post", Client().post(LOGIN_SESSIONS, data)) + signing_in = {"email_or_username": "shape_signer", "password": PASSWORD} + for config in (REFUSE_LOGIN, running_only(RefuseLoginSilentlyStep, StudentLoginRequested)): + with override_settings(OPEN_EDX_FILTERS_CONFIG=config): + self.assert_published(LOGIN_SESSIONS, "post", Client().post(LOGIN_SESSIONS, signing_in)) + found = {"frequency": settings.HIBP_LOGIN_BLOCK_PASSWORD_FREQUENCY_THRESHOLD} + with override_settings(ENABLE_AUTHN_LOGIN_BLOCK_HIBP_POLICY=True), \ + mock.patch.object(login, "check_pwned_password_and_send_track_event", return_value=found): + self.assert_published(LOGIN_SESSIONS, "post", Client().post(LOGIN_SESSIONS, signing_in)) + running = {"backend": "oa2-provider", "kwargs": {"username": "elsewhere", "uid": "elsewhere"}} + with mock.patch.object(login.pipeline, "running", return_value=True), \ + mock.patch.object(login.pipeline, "get", return_value=running), \ + mock.patch.object( + login.provider.Registry, "get_from_pipeline", return_value=SimpleNamespace(name="Provider"), + ), \ + mock.patch.object(login.pipeline, "get_authenticated_user", side_effect=User.DoesNotExist): + self.assert_published(LOGIN_SESSIONS, "post", Client().post(LOGIN_SESSIONS, {})) + self.assert_each_member_listed_was_carried_and_left_out(LOGIN_SESSIONS, "post") + + +class UnlabelledExtensionForm(forms.Form): + """A field a site adds to registration with a name and nothing else: no label, no starting value.""" + + nickname = forms.CharField() + + +#: Forms a site can name to add fields to registration, each with the field +#: it adds and the label that field is described with. +EXTENSION_FORMS = { + "openedx.core.djangoapps.user_api.tests.test_helpers.TestCaseForm": ("favorite_movie", "Fav Flick"), + f"{UnlabelledExtensionForm.__module__}.UnlabelledExtensionForm": ("nickname", None), +} + + +@skip_unless_lms +class ExtensionFormShapeTests(TestCase): + """ + A registration form carrying the fields a site adds is a body both documents publish. + + Such a field holds what the field of the site's form holds, and a field + of a Django form has no label and no starting value unless it is given + them, so the form describes it with neither. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.documents = {"narrow": narrow_document(), "service-wide": service_wide_document()} + + def test_a_field_the_site_adds_is_described_as_published(self): + for form, (name, label) in EXTENSION_FORMS.items(): + # Reading the form appends the added fields to the configured order in place. + order = list(settings.REGISTRATION_FIELD_ORDER) + with override_settings(PROFILE_EXTENSION_FORM=form, REGISTRATION_FIELD_ORDER=order): + for address in (REGISTRATIONS, V1_REGISTRATION, V2_REGISTRATION): + response = Client().get(address) + body = json.loads(response.content) + added = [ + (field["label"], field["placeholder"], field["defaultValue"]) + for field in body["fields"] if field["name"] == name + ] + assert added == [(label, None, None)], (form, address) + for which, document in self.documents.items(): + schema = published_response_schema(document, address, "get", response.status_code) + jsonschema.validate(body, schema) + assert undeclared_members(body, schema) == [], (which, form, address) + assert not unkept_promises(address, "get", response.status_code, body, schema), ( + which, form, address, + ) + + +#: Where an operation is requested, for one published under a key that is not an address. +REQUESTED_AT = {LOGIN_SESSION_KEY: V2_LOGIN_SESSION} + +#: Each operation that reads a body, at the addresses replacing others and at the ones they replace. +BODY_READERS = set(CONFORMING_REQUESTS) | { + superseded for superseded, (replacing, _narrow, _service_wide) in SUPERSEDED_OPERATIONS.items() + if replacing in CONFORMING_REQUESTS +} + +#: The operations each document is held to here. Signing in by posting at the +#: older address keeps the description its handler carries. +REFUSING_OPERATIONS = { + which: set(CONFORMING_OPERATIONS) | (published - {SIGN_IN_BY_POSTING}) + for which, published in PUBLISHED_SUPERSEDED_OPERATIONS.items() +} + + +#: The operations reading the body as a submitted form, at the addresses +#: replacing others and at the ones they replace. +FORM_READERS = (REGISTRATIONS, V1_REGISTRATION, V2_REGISTRATION, LOGIN_SESSIONS) + + +@skip_unless_lms +class RefusedRepresentationTests(TestCase): + """ + Each operation publishes the refusal of an answer it cannot give and of a body it cannot read. + + Both are settled as the request enters, before the handler runs, so every + operation refuses an ``Accept`` header naming nothing it answers in, and + every operation reading a body refuses one in a media type it cannot + read. Each refusal is sent to the running address and held against the + shape both documents publish for that operation and code. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.documents = {"narrow": narrow_document(), "service-wide": service_wide_document()} + + def test_each_operation_publishes_the_refusals_it_answers_with(self): + for which, keys in REFUSING_OPERATIONS.items(): + for key in sorted(keys): + path, method = key + address = REQUESTED_AT.get(path, path) + requests = [(406, {"HTTP_ACCEPT": "text/html"})] + if key in BODY_READERS: + requests.append((415, {"data": "x", "content_type": "text/plain"})) + for status, extra in requests: + # Registration runs outside the transaction a request is + # otherwise given, so a refusal there marks the one it runs + # in for rollback; each request is given one of its own. + with transaction.atomic(): + response = getattr(Client(), method)(address, **extra) + assert response.status_code == status, (which, key) + assert response["Content-Type"].startswith(JSON), (which, key, status) + body = json.loads(response.content) + schema = published_response_schema(self.documents[which], path, method, status) + jsonschema.validate(body, schema) + assert undeclared_members(body, schema) == [], (which, key, status) + assert not unkept_promises(path, method, status, body, schema), (which, key, status) + + def test_each_operation_reading_a_form_publishes_the_refusal_of_one_it_cannot_parse(self): + """A multipart body without the boundary its parts are split by is refused before the handler reads it.""" + for which, document in self.documents.items(): + for path in FORM_READERS: + with transaction.atomic(): + response = Client().post(path, "x", content_type=MULTIPART) + assert response.status_code == 400, (which, path) + assert response["Content-Type"].startswith(JSON), (which, path) + body = json.loads(response.content) + assert list(body) == ["detail"], (which, path) + assert body["detail"].startswith("Multipart form parse error"), (which, path) + schema = published_response_schema(document, path, "post", 400) + jsonschema.validate(body, schema) + assert undeclared_members(body, schema) == [], (which, path) + assert not unkept_promises(path, "post", 400, body, schema), (which, path) + + def test_the_fields_published_as_required_are_the_ones_the_stock_form_requires(self): + """ + An empty registration is refused for each of them, and for the terms taken from `honor_code`. + + The password is published as the first of the ways a registration can + be completed, beside the two that complete a sign-in through another + provider, so every other field the stock form requires is required + whichever way is taken. + """ + with override_settings(REGISTRATION_EXTRA_FIELDS=dict(STOCK_REGISTRATION_EXTRA_FIELDS)): + form = json.loads(Client().get(REGISTRATIONS).content) + refused = set(json.loads(Client().post(REGISTRATIONS, {}).content)) - {"error_code"} + required = sorted(field["name"] for field in form["fields"] if field["required"]) + assert refused == set(required) | {"terms_of_service"} + assert "password" in required + expected = (sorted(set(required) - {"password"}), WAYS_OF_REGISTERING) + for which, document in self.documents.items(): + for path in REGISTRATION_ADDRESSES: + content = document["paths"][path]["post"]["requestBody"]["content"] + published = { + media_type: ( + sorted(body["schema"]["required"]), + [sorted(way["required"]) for way in body["schema"]["anyOf"]], + ) + for media_type, body in content.items() + } + assert published == dict.fromkeys([FORM, MULTIPART], expected), (which, path) + + +#: Where an account is registered, here and at the addresses this one replaces. +REGISTRATION_ADDRESSES = (REGISTRATIONS, V1_REGISTRATION, V2_REGISTRATION) + +#: What a registration carries besides the fields every registration needs: +#: a password, or what completes a sign-in through another provider. +WAYS_OF_REGISTERING = [["password"], ["social_auth_provider"], ["access_token", "provider"]] + + +def without_password(body): + """Return ``body`` with the password left out.""" + return {name: value for name, value in body.items() if name != "password"} + + +@skip_unless_lms +@override_settings(ENABLE_THIRD_PARTY_AUTH=True) +class RegistrationRequestShapeTests(TestCase): + """ + The registration both documents publish is the one each registration address takes. + + A registration completing a sign-in through another provider carries no + password, because the server generates one, and every other registration + carries one. Each body below is held against the shape both documents + publish at each registration address, in each form it is read in, and is + then sent to that address. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.documents = {"narrow": narrow_document(), "service-wide": service_wide_document()} + + def accepted(self, body): + """Return whether ``body`` satisfies the published shape, by document, address and media type.""" + verdicts = {} + for which, document in self.documents.items(): + for address in REGISTRATION_ADDRESSES: + for media_type in (FORM, MULTIPART): + schema = published_request_schema(document, address, media_type) + validator = jsonschema.validators.validator_for(schema)(schema) + verdicts[(which, address, media_type)] = validator.is_valid(body) + return verdicts + + def everywhere(self, verdict): + """Return ``verdict`` for every document, address and media type.""" + return { + (which, address, media_type): verdict + for which in self.documents for address in REGISTRATION_ADDRESSES for media_type in (FORM, MULTIPART) + } + + def assert_registered(self, response, body): + """Assert ``response`` created the account ``body`` names and signed the caller in as it.""" + assert response.status_code == 200, response.content + assert json.loads(response.content)["authenticated_user"]["username"] == body["username"] + assert User.objects.get(username=body["username"]).email == body["email"] + + def test_the_front_end_registers_through_another_provider_without_a_password(self): + """ + The authentication front end names the provider the form lists and leaves the password out. + + The sign-in under way is handed to the view in place of the one the + session would point to. + """ + provider = OAuth2ProviderConfig(name="Provider", backend_name="google-oauth2") + for index, address in enumerate(REGISTRATION_ADDRESSES): + body = {**without_password(registration_form(f"elsewhere_{index}")), "social_auth_provider": "Provider"} + assert self.accepted(body) == self.everywhere(True), address + with mock.patch.object(register.pipeline, "get", return_value=RUNNING_PIPELINE), \ + mock.patch.object(register.provider.Registry, "get_from_pipeline", return_value=provider): + self.assert_registered(Client().post(address, body), body) + + def test_a_client_registers_with_a_providers_access_token_without_a_password(self): + """The provider's check of the token is stood in for by one that accepts it.""" + backend = SimpleNamespace(do_auth=lambda token, user=None: user) + for index, address in enumerate(REGISTRATION_ADDRESSES): + body = { + **without_password(registration_form(f"token_{index}")), + "provider": "google-oauth2", + "access_token": "token", + } + assert self.accepted(body) == self.everywhere(True), address + with mock.patch.object(register.social_utils, "load_backend", return_value=backend): + self.assert_registered(Client().post(address, body), body) + + def test_any_other_registration_needs_a_password_in_both_documents_and_at_every_address(self): + assert self.accepted(registration_form("with_password")) == self.everywhere(True) + body = without_password(registration_form("no_password")) + assert self.accepted(body) == self.everywhere(False) + assert self.accepted({**body, "provider": "google-oauth2"}) == self.everywhere(False) + for address in REGISTRATION_ADDRESSES: + response = Client().post(address, body) + assert response.status_code == 400, address + assert json.loads(response.content) == { + "password": [{"user_message": "This field is required."}], + "error_code": "validation-error", + }, address + + +@skip_unless_lms +class SignInWithAnExposedPasswordTests(TestCase): + """ + Where the site checks passwords against lists of exposed ones, a 400 can follow a correct password. + + The check is stood in for by one that reports how often the password was + found, so each side of the site's two thresholds is reached. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.documents = {"narrow": narrow_document(), "service-wide": service_wide_document()} + + def sign_in(self, client, frequency): + """Sign in at the address replacing the older sign-in, with a password found ``frequency`` times over.""" + UserFactory.create(username="exposed", password=PASSWORD) + found = {"frequency": frequency} + with mock.patch.object(login, "check_pwned_password_and_send_track_event", return_value=found): + response = client.post(LOGIN_SESSIONS, {"email_or_username": "exposed", "password": PASSWORD}) + assert response.status_code == 400 + body = json.loads(response.content) + for which, document in self.documents.items(): + schema = published_response_schema(document, LOGIN_SESSIONS, "post", 400) + jsonschema.validate(body, schema) + assert undeclared_members(body, schema) == [], which + assert not unkept_promises(LOGIN_SESSIONS, "post", 400, body, schema), which + return response, body + + @override_settings(ENABLE_AUTHN_LOGIN_NUDGE_HIBP_POLICY=True) + def test_a_password_found_rarely_is_answered_400_after_signing_the_caller_in(self): + client = Client() + response, body = self.sign_in(client, settings.HIBP_LOGIN_NUDGE_PASSWORD_FREQUENCY_THRESHOLD) + assert body == { + "success": False, + "value": str(accounts.AUTHN_LOGIN_NUDGE_HIBP_POLICY_MSG), + "error_code": "nudge-password-change", + "redirect_url": None, + } + assert client.session["_auth_user_id"] == str(User.objects.get(username="exposed").id) + assert cookies_set(response) == SIGNED_IN_COOKIES - {"edxloggedin", "edx-user-info"} + + @override_settings(ENABLE_AUTHN_LOGIN_BLOCK_HIBP_POLICY=True) + def test_a_password_found_often_is_refused_without_signing_the_caller_in(self): + client = Client() + response, body = self.sign_in(client, settings.HIBP_LOGIN_BLOCK_PASSWORD_FREQUENCY_THRESHOLD) + assert body == { + "success": False, + "value": str(accounts.AUTHN_LOGIN_BLOCK_HIBP_POLICY_MSG), + "error_code": "require-password-change", + "redirect_url": None, + } + assert "_auth_user_id" not in client.session + assert not cookies_set(response) & {"edxloggedin", "edx-user-info"} diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_email_opt_in_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_email_opt_in_views.py new file mode 100644 index 000000000000..d13db76b3827 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_email_opt_in_views.py @@ -0,0 +1,650 @@ +"""Tests for the version 2 User API email opt-in address.""" + +import datetime +from unittest import mock +from urllib.parse import urlencode + +from django.conf import settings +from django.urls import reverse +from edx_rest_framework_extensions.testing import assert_error_envelope +from rest_framework import status +from rest_framework.test import APIClient, APITestCase + +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user +from openedx.core.djangoapps.user_api.errors import UserAPIInternalError +from openedx.core.djangoapps.user_api.models import UserOrgTag +from openedx.core.djangoapps.user_api.v2.serializers import ( + NOT_A_COURSE, + ORGANIZATION_MAX_LENGTH, + ORGANIZATION_TOO_LONG, +) +from openedx.core.djangolib.testing.utils import skip_unless_lms + +from .test_preference_views import DEFAULT_AUTH_RECORDER, requests_served_outside_a_transaction + +PASSWORD = "Password1234" + +#: The address the choice is recorded at, and the one it supersedes. +EARLIER_ADDRESS = "/api/user/v1/preferences/email_opt_in/" +UNVERSIONED_EARLIER_ADDRESS = "/user_api/v1/preferences/email_opt_in/" + +COURSE_KEY = "course-v1:edX+DemoX+Demo" +OTHER_COURSE_KEY = "course-v1:edX+OtherX+Other" +OTHER_ORG_COURSE_KEY = "course-v1:MITx+6.002x+2026" + +#: The form a course key was written in before the current one, which the +#: platform still reads. +EARLIER_COURSE_KEY = "edX/DemoX/Demo" + +#: A key the platform's course key reader reads, and that names a library +#: rather than a course. It carries an organization like a course key does, +#: which is the whole of what this address takes a key for. +LIBRARY_KEY = "library-v1:edX+lib" + +#: Where the choice is stored: one row for each user and organization. +OPT_IN_KEY = "email-optin" + +#: What such a row holds when it was opened and no choice was written into it. +NO_CHOICE_AT_ALL = "" + +#: An age below the one a guardian's consent is needed under. +MINOR_AGE = 10 + +#: Values that suggest a choice without saying which one, each of which the +#: earlier address recorded as a choice not to receive messages. +SUGGESTED_CHOICES = ("1", "0", "on", "off", "yes", "no", "t", "f", "y", "n", 1, 0) + +#: Refusal of a value that does not say which choice it is. +CHOICE_REFUSAL = "Send the choice as true or false." + +#: A course whose organization is as long as the column that records a choice +#: against it holds, and one whose organization is a character longer. +LONGEST_ORG = "o" * ORGANIZATION_MAX_LENGTH +TOO_LONG_ORG = "o" * (ORGANIZATION_MAX_LENGTH + 1) + + +@skip_unless_lms +class EmailOptInTestCase(APITestCase): + """Fixtures shared by the version 2 email opt-in tests.""" + + def setUp(self): + super().setUp() + self.user = UserFactory.create( + username="optin-user", email="optin-user@example.com", password=PASSWORD, + ) + self.other = UserFactory.create( + username="optin-other", email="optin-other@example.com", password=PASSWORD, + ) + self.url = reverse("user_v2:email_opt_in_list") + + def login(self, user): + """Sign the given user in through the session this view accepts.""" + assert self.client.login(username=user.username, password=PASSWORD) + + def token_headers(self, user): + """Return the request headers that carry a token identifying the given user.""" + return {"HTTP_AUTHORIZATION": f"JWT {create_jwt_for_user(user)}"} + + def choose(self, course_key=COURSE_KEY, opt_in=True, address=None, **kwargs): + """Record a choice at the conforming address.""" + return self.client.post( + address or self.url, + {"course_key": course_key, "email_opt_in": opt_in}, + format="json", + **kwargs, + ) + + def choices(self, user=None): + """Return the choices one account has made, by organization.""" + return { + tag.org: tag.value + for tag in UserOrgTag.objects.filter(user=user or self.user, key=OPT_IN_KEY) + } + + def make_minor(self, user): + """Record the given account as belonging to someone a guardian answers for.""" + user.profile.year_of_birth = datetime.date.today().year - MINOR_AGE + user.profile.save() + return user + + +class EmailOptInViewTests(EmailOptInTestCase): + """POST /api/user/v2/email_opt_ins/""" + + def test_a_choice_to_receive_messages_is_recorded_for_the_organization(self): + self.login(self.user) + response = self.choose() + assert response.status_code == status.HTTP_204_NO_CONTENT + assert response.content == b"" + assert self.choices() == {"edX": "True"} + + def test_a_choice_not_to_receive_them_is_recorded_too(self): + self.login(self.user) + assert self.choose(opt_in=False).status_code == status.HTTP_204_NO_CONTENT + assert self.choices() == {"edX": "False"} + + def test_a_choice_replaces_the_one_that_account_made_before(self): + self.login(self.user) + self.choose(opt_in=True) + assert self.choose(opt_in=False).status_code == status.HTTP_204_NO_CONTENT + assert self.choices() == {"edX": "False"} + assert UserOrgTag.objects.filter(user=self.user, key=OPT_IN_KEY).count() == 1 + + def test_the_choice_covers_every_course_the_organization_runs(self): + self.login(self.user) + self.choose(course_key=COURSE_KEY, opt_in=True) + assert self.choose( + course_key=OTHER_COURSE_KEY, opt_in=False, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.choices() == {"edX": "False"} + + def test_a_choice_is_kept_for_each_organization_on_its_own(self): + self.login(self.user) + self.choose(course_key=COURSE_KEY, opt_in=True) + self.choose(course_key=OTHER_ORG_COURSE_KEY, opt_in=False) + assert self.choices() == {"edX": "True", "MITx": "False"} + + def test_a_course_key_written_in_the_earlier_form_is_still_read(self): + self.login(self.user) + assert self.choose( + course_key=EARLIER_COURSE_KEY, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.choices() == {"edX": "True"} + + def test_a_choice_submitted_as_a_form_is_read(self): + """The page a learner makes this choice on submits a form.""" + self.login(self.user) + response = self.client.post( + self.url, {"course_key": COURSE_KEY, "email_opt_in": "true"}, format="multipart", + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert self.choices() == {"edX": "True"} + + def test_an_account_a_guardian_answers_for_is_recorded_as_not_receiving_them(self): + """Whatever the body asks for, consent decides, as it does at the earlier address.""" + self.make_minor(self.user) + self.login(self.user) + assert self.choose(opt_in=True).status_code == status.HTTP_204_NO_CONTENT + assert self.choices() == {"edX": "False"} + + def test_the_choice_is_recorded_for_the_caller_and_nobody_else(self): + """No account is named in the address or read from the body.""" + self.login(self.user) + response = self.client.post( + self.url, + { + "course_key": COURSE_KEY, + "email_opt_in": True, + "username": self.other.username, + "user": self.other.id, + }, + format="json", + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert self.choices() == {"edX": "True"} + assert self.choices(self.other) == {} + + def test_a_value_that_is_neither_true_nor_false_is_refused(self): + """The earlier address read anything other than ``True`` as a choice not to receive them.""" + self.login(self.user) + envelope = assert_error_envelope( + self.choose(opt_in="Banana"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"email_opt_in": [CHOICE_REFUSAL]} + assert self.choices() == {} + + def test_the_choice_is_read_from_the_word_for_it_in_any_case(self): + self.login(self.user) + for sent, recorded in ( + ("true", "True"), ("True", "True"), ("TRUE", "True"), + ("false", "False"), ("False", "False"), ("FALSE", "False"), + ): + assert self.choose(opt_in=sent).status_code == status.HTTP_204_NO_CONTENT, sent + assert self.choices() == {"edX": recorded}, sent + + def test_a_value_that_only_suggests_a_choice_is_refused_rather_than_read(self): + """ + A value a boolean field would read one way and the earlier address the + other records nothing: it is refused, and a choice already made stands. + """ + self.login(self.user) + assert self.choose(opt_in=True).status_code == status.HTTP_204_NO_CONTENT + for suggestion in SUGGESTED_CHOICES: + envelope = assert_error_envelope( + self.choose(opt_in=suggestion), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"email_opt_in": [CHOICE_REFUSAL]}, suggestion + assert self.choices() == {"edX": "True"}, suggestion + + def test_a_form_that_leaves_the_choice_out_is_refused(self): + """An absent form member is a missing choice here, not an unticked checkbox.""" + self.login(self.user) + assert self.choose(opt_in=True).status_code == status.HTTP_204_NO_CONTENT + bodies = ( + self.client.post(self.url, {"course_key": COURSE_KEY}, format="multipart"), + self.client.post( + self.url, + urlencode({"course_key": COURSE_KEY}), + content_type="application/x-www-form-urlencoded", + ), + ) + for answer in bodies: + envelope = assert_error_envelope( + answer, + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"email_opt_in": ["This field is required."]} + assert self.choices() == {"edX": "True"} + + def test_a_form_carrying_the_choice_empty_is_refused(self): + self.login(self.user) + envelope = assert_error_envelope( + self.client.post( + self.url, {"course_key": COURSE_KEY, "email_opt_in": ""}, format="multipart", + ), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"email_opt_in": [CHOICE_REFUSAL]} + assert self.choices() == {} + + def test_a_course_whose_organization_fills_the_column_is_recorded(self): + self.login(self.user) + assert self.choose( + course_key=f"course-v1:{LONGEST_ORG}+DemoX+Demo", + ).status_code == status.HTTP_204_NO_CONTENT + assert self.choices() == {LONGEST_ORG: "True"} + + def test_a_course_whose_organization_is_longer_than_the_column_is_refused(self): + """The organization is the one part of the body no key reader bounds.""" + self.login(self.user) + envelope = assert_error_envelope( + self.choose(course_key=f"course-v1:{TOO_LONG_ORG}+DemoX+Demo"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"course_key": [ORGANIZATION_TOO_LONG]} + assert str(ORGANIZATION_MAX_LENGTH) in envelope["errors"]["course_key"][0] + assert self.choices() == {} + + def test_a_course_key_that_cannot_be_read_is_refused(self): + self.login(self.user) + envelope = assert_error_envelope( + self.choose(course_key="not a key"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"course_key": ["Invalid course key"]} + assert self.choices() == {} + + def test_a_key_that_names_something_other_than_a_course_is_refused(self): + """A choice is recorded against the organization running a course, so a key names one.""" + self.login(self.user) + envelope = assert_error_envelope( + self.choose(course_key=LIBRARY_KEY), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"course_key": [NOT_A_COURSE]} + assert self.choices() == {} + + def test_a_course_named_by_something_other_than_text_is_refused(self): + """A key is read by splitting it, which only text can be, so it is refused as a body fault.""" + self.login(self.user) + for named_by in (5, 2.5, True, [COURSE_KEY], {"id": COURSE_KEY}): + envelope = assert_error_envelope( + self.choose(course_key=named_by), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "course_key": ["Send the course key as the text that writes it."], + }, named_by + assert self.choices() == {} + + def test_a_course_named_by_nothing_at_all_is_refused(self): + self.login(self.user) + envelope = assert_error_envelope( + self.choose(course_key=None), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"course_key": ["This field may not be null."]} + assert self.choices() == {} + + def test_a_body_missing_either_half_of_the_choice_is_refused(self): + self.login(self.user) + envelope = assert_error_envelope( + self.client.post(self.url, {}, format="json"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "course_key": ["This field is required."], + "email_opt_in": ["This field is required."], + } + + def test_a_body_in_a_media_type_this_address_does_not_read_is_refused(self): + self.login(self.user) + assert_error_envelope( + self.client.post( + self.url, "{}", content_type="application/merge-patch+json", + ), + expected_status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + expected_type_slug="unsupported-media-type", + ) + + def test_a_body_that_is_not_well_formed_is_refused(self): + self.login(self.user) + assert_error_envelope( + self.client.post(self.url, "{not json", content_type="application/json"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="malformed-body", + ) + + def test_the_choices_made_are_not_listed_here(self): + """This collection records a choice; nothing reads it back.""" + self.login(self.user) + for method in ("get", "put", "patch", "delete"): + assert_error_envelope( + getattr(self.client, method)(self.url), + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug="method-not-allowed", + ) + + def test_a_query_parameter_this_address_does_not_read_is_refused(self): + self.login(self.user) + envelope = assert_error_envelope( + self.choose(address=f"{self.url}?username={self.other.username}"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "username": ["No such query parameter. Send any of: format."], + } + assert self.choices() == {} + assert self.choices(self.other) == {} + + def test_an_account_that_carries_no_profile_is_reported_absent(self): + """ + The consent rule is read from the profile, and there is nothing to read it from. + + The row a choice is kept in is opened before that rule is read, and + the refusal undoes it: nothing is stored where each request is served + in a transaction, which is how the platform serves them. + """ + self.login(self.user) + self.user.profile.delete() + assert_error_envelope( + self.choose(), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + assert self.choices() == {} + + def test_the_row_a_refusal_opened_stands_where_a_statement_stands_on_its_own(self): + """ + A refused choice leaves a row behind on a database that commits each statement. + + The row is opened before the consent rule is read, so where the + request is not served in a transaction there is nothing to undo it and + the account is left holding a row carrying no choice at all. What such + a row holds is pinned because a reader of it compares it against the + word for wanting the messages and so reads it as not wanting them. + """ + self.login(self.user) + self.user.profile.delete() + with requests_served_outside_a_transaction(): + assert_error_envelope( + self.choose(), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + assert self.choices() == {"edX": NO_CHOICE_AT_ALL} + + def test_a_choice_that_could_not_be_recorded_is_a_server_fault(self): + self.login(self.user) + with mock.patch( + "openedx.core.djangoapps.user_api.v2.views.update_email_opt_in", + side_effect=UserAPIInternalError("the tag table is gone"), + ): + response = self.choose() + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_500_INTERNAL_SERVER_ERROR, + expected_type_slug="internal", + ) + assert envelope["detail"] == "The choice could not be recorded." + assert "tag table is gone" not in response.content.decode() + + +class EmailOptInAccessTests(EmailOptInTestCase): + """Who may record a choice, and whose choice is recorded.""" + + def test_an_anonymous_caller_is_refused_with_the_standard_body(self): + """The earlier address refuses them as somebody else's request instead.""" + assert_error_envelope( + self.choose(), + expected_status=status.HTTP_401_UNAUTHORIZED, + expected_type_slug="authn", + ) + assert self.choices() == {} + + def test_a_choice_presented_with_a_token_is_recorded_for_the_account_it_names(self): + assert self.choose(**self.token_headers(self.other)).status_code == ( + status.HTTP_204_NO_CONTENT + ) + assert self.choices(self.other) == {"edX": "True"} + assert self.choices() == {} + + def test_a_token_is_read_here_without_recording_the_address_as_one_on_the_defaults(self): + """ + This address names its own classes, so it is not one the default pair serves. + + The pair the platform configures records every request it reads a + token for, so that an operator can list the addresses it serves. This + one replaces the session half of it and is therefore not among them, + and nothing here is recorded as if it were. The address read afterwards + names no classes of its own and is recorded, which is what shows the + record is reachable from a request made this way at all. + """ + with mock.patch(DEFAULT_AUTH_RECORDER) as recorded: + answer = self.choose(**self.token_headers(self.other)) + assert answer.status_code == status.HTTP_204_NO_CONTENT + assert self.choices(self.other) == {"edX": "True"} + assert recorded.call_args_list == [] + + administrator = UserFactory.create( + username="optin-admin", email="optin-admin@example.com", password=PASSWORD, is_staff=True, + ) + with mock.patch(DEFAULT_AUTH_RECORDER) as recorded: + on_the_default_pair = self.client.get( + reverse("user_v2:user_preference_list"), **self.token_headers(administrator), + ) + assert on_the_default_pair.status_code == status.HTTP_200_OK + assert [call.args for call in recorded.call_args_list] == [ + ("using_default_auth_classes", True), + ] + + def test_an_account_with_an_unconfirmed_address_records_a_choice(self): + """A learner makes this choice while enrolling, before confirming their address.""" + self.user.is_active = False + self.user.save() + self.login(self.user) + assert self.choose().status_code == status.HTTP_204_NO_CONTENT + assert self.choices() == {"edX": "True"} + + def test_a_session_without_the_token_that_goes_with_it_is_refused(self): + """The session class still holds a request to the token the site hands out with it.""" + client = APIClient(enforce_csrf_checks=True) + assert client.login(username=self.user.username, password=PASSWORD) + assert_error_envelope( + client.post( + self.url, {"course_key": COURSE_KEY, "email_opt_in": True}, format="json", + ), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + assert self.choices() == {} + + def test_a_session_carrying_the_token_the_sign_in_handed_out_records_the_choice(self): + """ + The token a session is held to comes from signing in, not from this address. + + A page reaches this address with a session it got by signing in, so the + sign-in is what hands out the token the session is held to. The whole + way round is driven here, because nothing this version serves sets that + cookie and a caller presenting only a session would otherwise be + refused at every write. + """ + client = APIClient(enforce_csrf_checks=True) + sign_in_address = reverse("user_api_login_session", kwargs={"api_version": "v2"}) + form = client.get(sign_in_address) + assert form.status_code == status.HTTP_200_OK, form.content + assert settings.CSRF_COOKIE_NAME in form.cookies + signed_in = client.post( + sign_in_address, + {"email_or_username": self.user.email, "password": PASSWORD}, + HTTP_X_CSRFTOKEN=client.cookies[settings.CSRF_COOKIE_NAME].value, + ) + assert signed_in.status_code == status.HTTP_200_OK, signed_in.content + recorded = client.post( + self.url, + {"course_key": COURSE_KEY, "email_opt_in": True}, + format="json", + HTTP_X_CSRFTOKEN=client.cookies[settings.CSRF_COOKIE_NAME].value, + ) + assert recorded.status_code == status.HTTP_204_NO_CONTENT, recorded.content + assert self.choices() == {"edX": "True"} + + +class EmailOptInParityTests(EmailOptInTestCase): + """The same choice through the earlier address and through this one.""" + + def earlier(self, course_id=COURSE_KEY, email_opt_in="True", address=EARLIER_ADDRESS): + """Record a choice at the address this version supersedes, as its clients do.""" + return self.client.post( + address, {"course_id": course_id, "email_opt_in": email_opt_in}, + ) + + def test_both_addresses_record_the_same_row(self): + self.login(self.user) + assert self.earlier().status_code == status.HTTP_200_OK + earlier = self.choices() + UserOrgTag.objects.filter(user=self.user, key=OPT_IN_KEY).delete() + assert self.choose().status_code == status.HTTP_204_NO_CONTENT + assert self.choices() == earlier == {"edX": "True"} + + def test_both_addresses_record_the_same_row_for_an_account_a_guardian_answers_for(self): + self.make_minor(self.user) + self.login(self.user) + self.earlier(email_opt_in="True") + earlier = self.choices() + UserOrgTag.objects.filter(user=self.user, key=OPT_IN_KEY).delete() + self.choose(opt_in=True) + assert self.choices() == earlier == {"edX": "False"} + + def test_both_addresses_read_a_course_key_written_in_the_earlier_form(self): + self.login(self.user) + self.earlier(course_id=EARLIER_COURSE_KEY) + earlier = self.choices() + UserOrgTag.objects.filter(user=self.user, key=OPT_IN_KEY).delete() + self.choose(course_key=EARLIER_COURSE_KEY) + assert self.choices() == earlier == {"edX": "True"} + + def test_the_earlier_address_answers_with_an_empty_body_and_this_one_with_no_body(self): + """Declared difference: the choice is recorded, and there is nothing to return.""" + self.login(self.user) + earlier = self.earlier() + assert earlier.status_code == status.HTTP_200_OK + assert earlier.content == b"" + current = self.choose() + assert current.status_code == status.HTTP_204_NO_CONTENT + assert current.content == b"" + + def test_a_value_the_earlier_address_read_as_a_refusal_is_refused_here(self): + """Declared difference: a value that is neither true nor false is not read as false.""" + self.login(self.user) + assert self.earlier(email_opt_in="Banana").status_code == status.HTTP_200_OK + assert self.choices() == {"edX": "False"} + UserOrgTag.objects.filter(user=self.user, key=OPT_IN_KEY).delete() + assert self.choose(opt_in="Banana").status_code == status.HTTP_400_BAD_REQUEST + assert self.choices() == {} + + def test_a_value_that_only_suggests_a_choice_is_refused_here_and_not_read_as_one(self): + """ + Declared difference: the earlier address recorded a choice not to + receive messages for each of these, and a boolean field would have + recorded the opposite for half of them, so they are refused instead. + """ + self.login(self.user) + for suggestion in SUGGESTED_CHOICES: + assert self.earlier(email_opt_in=suggestion).status_code == status.HTTP_200_OK + assert self.choices() == {"edX": "False"}, suggestion + UserOrgTag.objects.filter(user=self.user, key=OPT_IN_KEY).delete() + assert self.choose(opt_in=suggestion).status_code == ( + status.HTTP_400_BAD_REQUEST + ), suggestion + assert self.choices() == {}, suggestion + + def test_a_form_that_leaves_the_choice_out_is_refused_by_both_addresses(self): + """Both answer a form body that names a course and no choice as a fault to correct.""" + self.login(self.user) + self.choose(opt_in=True) + earlier = self.client.post(EARLIER_ADDRESS, {"course_id": COURSE_KEY}) + assert earlier.status_code == status.HTTP_400_BAD_REQUEST + assert self.choices() == {"edX": "True"} + current = self.client.post(self.url, {"course_key": COURSE_KEY}, format="multipart") + assert current.status_code == status.HTTP_400_BAD_REQUEST + assert self.choices() == {"edX": "True"} + + def test_a_key_that_names_no_course_is_refused_by_both_addresses(self): + """Neither address records a choice for the organization of something that is not a course.""" + self.login(self.user) + earlier = self.earlier(course_id=LIBRARY_KEY) + assert earlier.status_code == status.HTTP_400_BAD_REQUEST + assert earlier.content == f"No course '{LIBRARY_KEY}' found".encode() + assert self.choices() == {} + current = self.choose(course_key=LIBRARY_KEY) + assert current.status_code == status.HTTP_400_BAD_REQUEST + assert self.choices() == {} + + def test_neither_address_records_a_choice_for_an_account_carrying_no_profile(self): + """ + Declared difference: the earlier address fails outright where this one reports it absent. + + Neither address writes a choice, and each leaves the same thing behind + on the same database: nothing where the request is served in a + transaction, and a row carrying no choice where each statement stands + on its own. The row is opened before the consent rule is read, which + is why it is there to be left at all. + """ + self.login(self.user) + self.user.profile.delete() + self.client.raise_request_exception = False + + assert self.earlier().status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + assert self.choices() == {} + assert self.choose().status_code == status.HTTP_404_NOT_FOUND + assert self.choices() == {} + + with requests_served_outside_a_transaction(): + assert self.earlier().status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + left_by_the_earlier_address = self.choices() + UserOrgTag.objects.filter(user=self.user, key=OPT_IN_KEY).delete() + assert self.choose().status_code == status.HTTP_404_NOT_FOUND + assert self.choices() == left_by_the_earlier_address == {"edX": NO_CHOICE_AT_ALL} + + def test_the_unversioned_earlier_address_records_the_same_row(self): + """It keeps serving the clients that call it, and this version adds no third address.""" + self.login(self.user) + assert self.earlier( + address=UNVERSIONED_EARLIER_ADDRESS, + ).status_code == status.HTTP_200_OK + assert self.choices() == {"edX": "True"} diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_name_change_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_name_change_views.py new file mode 100644 index 000000000000..cdd8cc294a39 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_name_change_views.py @@ -0,0 +1,665 @@ +"""Tests for the version 2 User API profile name change views.""" + +from unittest import mock + +import pytest +from django.urls import reverse +from edx_rest_framework_extensions.testing import assert_error_envelope +from rest_framework import status +from rest_framework.test import APITestCase + +from common.djangoapps.student.models import PendingNameChange +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user +from openedx.core.djangoapps.user_api.v2.views import AMBIGUOUS_ACCOUNT_ADDRESS +from openedx.core.djangolib.testing.utils import skip_unless_lms + +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 NameChangeAddressDescriptionTests(NameChangeTestCase): + """What OPTIONS answers about the addresses that ask for and confirm a name.""" + + def setUp(self): + super().setUp() + PendingNameChange.objects.create( + user=self.owner, new_name="Jon Doe", rationale="asked for in a test", + ) + self.url = self.confirmation_url(self.owner.username) + + def test_the_confirmation_address_describes_no_body(self): + """ + A confirmation carries out the change already asked for, so there is nothing to send. + + A client or a form built from this description would otherwise ask the + operator for a name before confirming the change, and send a body the + address does not read. + """ + self.login(self.staff) + answer = self.client.options(self.url) + assert answer.status_code == status.HTTP_200_OK + assert "actions" not in answer.json() + + def test_asking_for_a_name_is_still_described_by_the_body_it_takes(self): + """The collection reads a body, and the description of it is not what the above removes.""" + self.login(self.owner) + answer = self.client.options(self.request_url) + assert answer.status_code == status.HTTP_200_OK + assert set(answer.json()["actions"]["POST"]) == {"name"} + assert answer.json()["actions"]["POST"]["name"]["required"] is True + + +@skip_unless_lms +class NameChangeAmbiguousTargetTests(NameChangeTestCase): + """A confirmation whose address names no one account changes no profile.""" + + def setUp(self): + super().setUp() + PendingNameChange.objects.create( + user=self.owner, new_name="Jon Doe", rationale="asked for in a test", + ) + self.url = self.confirmation_url(self.owner.username) + + def test_an_address_naming_more_than_one_account_changes_nothing(self): + """ + The profile the work lands on is the one the address names, or none at all. + + What the address resolves to is the database's answer, and more than + one account can come back for one name. Writing to whichever came back + first would put a name on an account nobody addressed, so the request + is refused and both profiles are left as they stand. The test database + resolves a name only to the record stored under exactly it, so the + lookup is answered here the way it answers where a name resolves more + widely. + """ + sibling = UserFactory.create( + username="name-sibling", email="name-sibling@example.com", password=PASSWORD, + ) + sibling.profile.name = "Sibling Name" + sibling.profile.save() + self.login(self.staff) + with mock.patch( + "openedx.core.djangoapps.user_api.v2.views.accounts_named", + return_value=[sibling, self.other], + ): + envelope = assert_error_envelope( + self.client.post(self.url), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="user/ambiguous-account-name", + ) + assert envelope["errors"] == {"non_field_errors": [AMBIGUOUS_ACCOUNT_ADDRESS]} + assert self.profile_name(self.owner) == "Original Name" + assert self.pending_name(self.owner) == "Jon Doe" + sibling.profile.refresh_from_db() + assert sibling.profile.name == "Sibling Name" + + def test_the_account_stored_under_the_name_addressed_is_the_one_changed(self): + """Among several, the account stored under exactly the name addressed is the one written to.""" + sibling = UserFactory.create( + username="name-sibling", email="name-sibling@example.com", password=PASSWORD, + ) + sibling.profile.name = "Sibling Name" + sibling.profile.save() + self.login(self.staff) + with mock.patch( + "openedx.core.djangoapps.user_api.v2.views.accounts_named", + return_value=[sibling, self.owner], + ): + assert self.client.post(self.url).status_code == status.HTTP_204_NO_CONTENT + assert self.profile_name(self.owner) == "Jon Doe" + sibling.profile.refresh_from_db() + assert sibling.profile.name == "Sibling Name" + + +@skip_unless_lms +class NameChangeParityTests(NameChangeTestCase): + """The same requests through the version 1 and the version 2 addresses.""" + + def test_both_versions_record_the_same_request(self): + self.login(self.owner) + earlier = self.client.post(EARLIER_REQUEST, {"name": "Jon Doe"}, format="json") + assert earlier.status_code == status.HTTP_201_CREATED + assert earlier.data is None + earlier_pending = PendingNameChange.objects.get(user=self.owner) + earlier_name, earlier_rationale = earlier_pending.new_name, earlier_pending.rationale + earlier_pending.delete() + + later = self.request_name("Jon Doe") + later_pending = PendingNameChange.objects.get(user=self.owner) + assert (later_pending.new_name, later_pending.rationale) == (earlier_name, earlier_rationale) + assert later.status_code == earlier.status_code + # The one declared difference: the created request is returned as the + # body where version 1 answered with none. + assert later.data == {"name": "Jon Doe"} + + def test_neither_version_records_a_request_from_an_unconfirmed_session(self): + """Both addresses read a session the way the platform reads one, and so refuse the same ones.""" + self.login(self.owner) + self.owner.is_active = False + self.owner.save() + earlier = self.client.post(EARLIER_REQUEST, {"name": "Jon Doe"}, format="json") + later = self.request_name("Jon Doe") + assert earlier.status_code == status.HTTP_401_UNAUTHORIZED + assert later.status_code == earlier.status_code + assert_error_envelope(later, expected_status=401, expected_type_slug="authn") + assert PendingNameChange.objects.count() == 0 + + def test_both_versions_read_a_name_sent_as_a_web_form(self): + """A form posts its fields encoded rather than as JSON, and both addresses take them.""" + self.login(self.owner) + body, encoded = "name=Jon+Doe", "application/x-www-form-urlencoded" + earlier = self.client.post(EARLIER_REQUEST, body, content_type=encoded) + assert earlier.status_code == status.HTTP_201_CREATED + assert self.pending_name(self.owner) == "Jon Doe" + PendingNameChange.objects.get(user=self.owner).delete() + + later = self.client.post(self.request_url, body, content_type=encoded) + assert later.status_code == earlier.status_code + assert self.pending_name(self.owner) == "Jon Doe" + + def test_both_versions_refuse_the_name_the_profile_already_carries(self): + self.login(self.owner) + earlier = self.client.post(EARLIER_REQUEST, {"name": "Original Name"}, format="json") + assert earlier.status_code == status.HTTP_400_BAD_REQUEST + assert earlier.data == { + "new_name": "The profile name given was identical to the current name.", + } + + later = self.request_name("Original Name") + assert later.status_code == earlier.status_code + assert later.data["errors"] == {"name": ["The profile already carries this name."]} + + def test_both_versions_refuse_a_name_carrying_markup(self): + self.login(self.owner) + earlier = self.client.post(EARLIER_REQUEST, {"name": "Jon Doe"}, format="json") + assert earlier.status_code == status.HTTP_400_BAD_REQUEST + assert earlier.data == { + "new_name": ["Name cannot contain the following characters: < >"], + } + + later = self.request_name("Jon Doe") + assert later.status_code == earlier.status_code + assert later.data["errors"] == { + "name": ["Name cannot contain the following characters: < >"], + } + assert PendingNameChange.objects.count() == 0 + + def test_both_versions_confirm_a_request_the_same_way(self): + PendingNameChange.objects.create(user=self.owner, new_name="Jon Doe", rationale="a test") + self.login(self.staff) + earlier = self.client.post(earlier_confirmation(self.owner.username)) + assert earlier.status_code == status.HTTP_204_NO_CONTENT + assert self.profile_name(self.owner) == "Jon Doe" + earlier_history = self.owner.profile.get_meta()["old_names"] + + self.owner.profile.name = "Original Name" + self.owner.profile.save() + PendingNameChange.objects.create(user=self.owner, new_name="Jon Doe", rationale="a test") + later = self.client.post(self.confirmation_url(self.owner.username)) + assert later.status_code == earlier.status_code + assert self.profile_name(self.owner) == "Jon Doe" + later_history = self.owner.profile.get_meta()["old_names"] + assert [entry[:2] for entry in later_history] == [entry[:2] for entry in earlier_history] * 2 + + def test_both_versions_refuse_a_confirmation_from_a_caller_who_is_not_staff(self): + PendingNameChange.objects.create(user=self.owner, new_name="Jon Doe", rationale="a test") + self.login(self.other) + earlier = self.client.post(earlier_confirmation(self.owner.username)) + assert earlier.status_code == status.HTTP_403_FORBIDDEN + assert earlier.data is None + + later = self.client.post(self.confirmation_url(self.owner.username)) + assert later.status_code == earlier.status_code + # The one declared difference: version 1 answers with an empty body. + assert_error_envelope(later, expected_status=403, expected_type_slug="authz") + assert self.profile_name(self.owner) == "Original Name" + + def test_a_name_no_account_holds_is_not_found(self): + """A confirmation addressed to no account is refused before anything is looked up.""" + self.login(self.staff) + later = self.client.post(self.confirmation_url("nobody-at-all")) + assert_error_envelope( + later, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + + +#: What each address costs, measured on the version 1 address first and +#: asserted on both. Neither version reads anything per row: the request reads +#: the caller's profile and stores the name asked for, and the confirmation +#: reads the user, their request and their profile before writing the new name +#: and deleting the request. +REQUEST_QUERIES = 20 +CONFIRMATION_QUERIES = 20 + + +@pytest.fixture(name="name_change") +@skip_unless_lms +def name_change_fixture(client, django_user_model): # pylint: disable=unused-argument + """Sign a staff caller in beside the learner whose profile is renamed.""" + staff = UserFactory.create(username="count-name-staff", password=PASSWORD, is_staff=True) + owner = UserFactory.create(username="count-name-owner", password=PASSWORD) + owner.profile.name = "Original Name" + owner.profile.save() + assert client.login(username=staff.username, password=PASSWORD) + # The first authenticated request of a session populates caches the rest + # of the session reuses; the counts below are the steady-state ones. + client.post(reverse("user_v2:name_change_list"), {"name": "Warm Up"}, content_type="application/json") + PendingNameChange.objects.all().delete() + return staff, owner + + +@pytest.mark.django_db +@skip_unless_lms +def test_a_request_costs_what_the_earlier_address_costs(client, name_change, django_assert_num_queries): + body = {"name": "Jon Doe"} + with django_assert_num_queries(REQUEST_QUERIES): + earlier = client.post(EARLIER_REQUEST, body, content_type="application/json") + assert earlier.status_code == status.HTTP_201_CREATED + PendingNameChange.objects.all().delete() + + with django_assert_num_queries(REQUEST_QUERIES): + later = client.post(reverse("user_v2:name_change_list"), body, content_type="application/json") + assert later.status_code == status.HTTP_201_CREATED + + +@pytest.mark.django_db +@skip_unless_lms +def test_a_confirmation_costs_what_the_earlier_address_costs(client, name_change, django_assert_num_queries): + _staff, owner = name_change + + PendingNameChange.objects.create(user=owner, new_name="Jon Doe", rationale="a test") + with django_assert_num_queries(CONFIRMATION_QUERIES): + earlier = client.post(earlier_confirmation(owner.username)) + assert earlier.status_code == status.HTTP_204_NO_CONTENT + + PendingNameChange.objects.create(user=owner, new_name="Jon Doe Again", rationale="a test") + with django_assert_num_queries(CONFIRMATION_QUERIES): + later = client.post( + reverse("user_v2:name_change_confirmation", kwargs={"username": owner.username}), + ) + assert later.status_code == status.HTTP_204_NO_CONTENT diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_pagination.py b/openedx/core/djangoapps/user_api/v2/tests/test_pagination.py new file mode 100644 index 000000000000..971b4cfd8e51 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_pagination.py @@ -0,0 +1,159 @@ +"""Page-size handling on the paginated collections of version 2 of the User API.""" + +import inspect + +from django.urls import reverse +from edx_rest_framework_extensions.testing import assert_error_envelope +from rest_framework import status +from rest_framework.test import APITestCase + +from common.djangoapps.student.tests.factories import AdminFactory, SuperuserFactory, UserFactory +from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory +from openedx.core.djangoapps.user_api.v2 import views as user_api_views +from openedx.core.djangoapps.user_api.v2.paginators import UserApiPagination +from openedx.core.djangolib.testing.utils import skip_unless_lms + +PASSWORD = "Password1234" + +#: Values naming a number of rows no page can carry: none at all, fewer than +#: none, and values that are not a whole number of rows in the first place. +UNHONOURED_PAGE_SIZES = ("0", "-1", "abc", "1.5", "0x10", "%20") + +#: What a caller is told when it asks for one of them. +REFUSAL = ( + "Enter a whole number of rows of at least 1. A larger number than " + f"{UserApiPagination.max_page_size} returns {UserApiPagination.max_page_size} rows." +) + + +@skip_unless_lms +class PageSizeTests(APITestCase): + """ + A page size the collection will not give is refused rather than answered. + + The size asked for is dropped by the framework when it cannot be honoured + and the default page is answered in its place, which a caller reads as the + page it asked for; the links on that page carry the value back, so a + client following them keeps sending a number that is never read. Each + collection below is checked, because the number is read by the paginator + they share and a collection that stopped sharing it would stop refusing. + """ + + def setUp(self): + super().setUp() + self.admin = AdminFactory.create(username="page-admin", password=PASSWORD) + self.learner = UserFactory.create(username="page-learner", password=PASSWORD) + # The retirement queue is read by a caller holding the capability the + # pipeline runs with, which a superuser holds along with every other. + self.pipeline = SuperuserFactory.create(username="page-pipeline", password=PASSWORD) + UserPreferenceFactory.create(user=self.learner, key="time_zone", value="UTC") + + def collections(self): + """Return each paginated collection, the caller it admits and how it is asked.""" + return { + "user_list": (self.admin, reverse("user_v2:user_list"), {}), + "user_preference_list": (self.admin, reverse("user_v2:user_preference_list"), {}), + "time_zone_list": (None, reverse("user_v2:time_zone_list"), {}), + "account_list": (self.learner, reverse("user_v2:account_list"), {}), + "account_verification_list": ( + self.learner, + reverse( + "user_v2:account_verification_list", + kwargs={"username": self.learner.username}, + ), + {}, + ), + "email_search_list": ( + self.admin, + reverse("user_v2:email_search_list"), + {"body": {"emails": [self.learner.email]}}, + ), + "retirement_list": (self.pipeline, reverse("user_v2:retirement_list"), {}), + } + + def ask(self, caller, address, page_size, body=None): + """Ask one collection for a page of ``page_size`` rows as ``caller``.""" + self.client.logout() + if caller is not None: + assert self.client.login(username=caller.username, password=PASSWORD) + query = "" if page_size is None else f"?page_size={page_size}" + if body is None: + return self.client.get(f"{address}{query}") + return self.client.post(f"{address}{query}", body, format="json") + + def test_a_number_of_rows_no_page_can_carry_is_refused(self): + for name, (caller, address, extra) in self.collections().items(): + for page_size in UNHONOURED_PAGE_SIZES: + with self.subTest(collection=name, page_size=page_size): + response = self.ask(caller, address, page_size, **extra) + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"page_size": [REFUSAL]} + + def test_a_value_carrying_nothing_asks_for_nothing_and_is_answered(self): + """An empty value leaves the size alone, as an empty selector leaves the rows alone.""" + for name, (caller, address, extra) in self.collections().items(): + with self.subTest(collection=name): + response = self.ask(caller, address, "", **extra) + assert response.status_code in ( + status.HTTP_200_OK, status.HTTP_201_CREATED, + ), response.content + assert len(response.data["results"]) <= UserApiPagination.page_size + + def test_more_rows_than_a_page_carries_is_answered_with_as_many_as_it_carries(self): + """A number above the cap keeps being answered with the cap rather than refused.""" + for index in range(UserApiPagination.max_page_size + 5): + UserFactory.create(username=f"page-filler{index:03d}") + self.client.logout() + assert self.client.login(username=self.admin.username, password=PASSWORD) + response = self.client.get(reverse("user_v2:user_list"), {"page_size": 100000}) + assert response.status_code == status.HTTP_200_OK, response.content + assert len(response.data["results"]) == UserApiPagination.max_page_size + + def test_the_link_to_the_next_page_carries_a_size_the_next_page_will_give(self): + """ + A refused size cannot reach the links, and an honoured one is carried back unchanged. + + A link carrying a size the collection drops sends the client back with + the same value on every page it follows, and every one of those pages + is a different size from the one it asked for. + """ + for index in range(5): + UserFactory.create(username=f"page-walked{index:03d}") + self.client.logout() + assert self.client.login(username=self.admin.username, password=PASSWORD) + response = self.client.get(reverse("user_v2:user_list"), {"page_size": 2}) + assert response.status_code == status.HTTP_200_OK, response.content + assert len(response.data["results"]) == 2 + assert response.data["next"].endswith("?page=2&page_size=2") + + followed = self.client.get(response.data["next"]) + assert followed.status_code == status.HTTP_200_OK, followed.content + assert len(followed.data["results"]) == 2 + + def test_every_collection_of_this_version_that_pages_is_checked_here(self): + """ + The refusal lives in one paginator, so the check is only worth what it covers. + + A collection added later that pages its rows with the same paginator + is listed above or this fails, rather than the refusal quietly + applying to an address nothing exercises. + """ + paging = { + name + for name, member in inspect.getmembers(user_api_views, inspect.isclass) + if getattr(member, "pagination_class", None) is UserApiPagination + } + assert paging == { + "AccountViewSet", + "CountryTimeZoneViewSet", + "EmailSearchViewSet", + "IDVerificationViewSet", + "RetirementViewSet", + "UserPreferenceViewSet", + "UserViewSet", + } + assert len(self.collections()) == len(paging) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_parity.py b/openedx/core/djangoapps/user_api/v2/tests/test_parity.py new file mode 100644 index 000000000000..f5caac0bb7a5 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_parity.py @@ -0,0 +1,503 @@ +"""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 + + +@skip_unless_lms +class RefusedRequestMessageTests(APITestCase): + """ + What a person reads when a request of either version is refused. + + The earlier addresses answer a refusal with one sentence, and the + standardized body of this version keeps a member for that sentence beside + the list of what each parameter was refused for. Where the refusal names + parameters, that sentence was being built by printing the list itself, so + the quoting, the bracketing and the class names of the data structure + holding it reached everything that shows the sentence to a person: the + message of an exception a generated client raises, a line in an + integration's log, the notification a page puts on the screen. + """ + + def setUp(self): + super().setUp() + self.admin = AdminFactory.create(username="refusal-admin", password=PASSWORD) + + def refused(self, address, params): + """Call a version 2 address as the administrator it is gated by.""" + assert self.client.login(username=self.admin.username, password=PASSWORD) + response = self.client.get(address, params) + self.client.logout() + return assert_error_envelope( + response, + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + + def test_the_sentence_of_a_refusal_naming_one_parameter_is_the_sentence_that_parameter_got(self): + cases = [ + ( + reverse("user_v2:user_list"), + {"forum_role": "Moderator"}, + {"course_key": ["This parameter is required when forum_role is supplied."]}, + "course_key: This parameter is required when forum_role is supplied.", + ), + ( + reverse("user_v2:user_list"), + {"is_active": "maybe"}, + {"is_active": ["Enter true or false."]}, + "is_active: Enter true or false.", + ), + ( + reverse("user_v2:user_preference_list"), + {"page_size": "0"}, + { + "page_size": [ + "Enter a whole number of rows of at least 1. " + "A larger number than 100 returns 100 rows.", + ], + }, + "page_size: Enter a whole number of rows of at least 1. " + "A larger number than 100 returns 100 rows.", + ), + ] + for address, params, expected_errors, expected_sentence in cases: + with self.subTest(address=address, params=params): + envelope = self.refused(address, params) + assert envelope["errors"] == expected_errors + assert envelope["detail"] == expected_sentence + + def test_the_sentence_of_a_refusal_naming_several_parameters_names_each_of_them(self): + """Every parameter refused is in the sentence, in the order the list carries them.""" + envelope = self.refused(reverse("user_v2:user_list"), {"nope": "1", "alsonope": "2"}) + assert sorted(envelope["errors"]) == ["alsonope", "nope"] + assert envelope["detail"] == " ".join( + f"{name}: {message}" + for name, messages in envelope["errors"].items() + for message in messages + ) + assert envelope["detail"].startswith("alsonope: No such query parameter.") + + def test_a_refusal_that_names_nothing_in_particular_is_spelled_on_its_own(self): + """A body of the wrong shape is refused with no property to hang the refusal on.""" + assert self.client.login(username=self.admin.username, password=PASSWORD) + response = self.client.post(reverse("user_v2:email_search_list"), [], format="json") + self.client.logout() + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "non_field_errors": ["Invalid data. Expected a dictionary, but got list."], + } + assert envelope["detail"] == "Invalid data. Expected a dictionary, but got list." + + def test_a_refusal_naming_no_parameter_says_what_it_has_always_said(self): + """Only the refusals built from a list of parameters change; the rest are untouched.""" + assert self.client.login(username=self.admin.username, password=PASSWORD) + missing = self.client.get(reverse("user_v2:user_detail", kwargs={"username": "nobody"})) + envelope = assert_error_envelope( + missing, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + assert envelope["detail"] == "Not found." + assert "errors" not in envelope + + def test_no_sentence_of_a_refusal_carries_the_printed_form_of_a_list_or_a_mapping(self): + """ + One sweep over the refusals this version produces, rather than a case each. + + A sentence built by printing a data structure carries the brackets, + the quotes and the class names that write it, none of which belong in + something a person reads. + """ + refusals = [ + (reverse("user_v2:user_list"), {"is_active": "maybe"}), + (reverse("user_v2:user_list"), {"forum_role": "Moderator"}), + (reverse("user_v2:user_list"), {"course_key": "course-v1:edX+DemoX+Demo"}), + (reverse("user_v2:user_list"), {"ordering": "password"}), + (reverse("user_v2:user_list"), {"nope": "1"}), + (reverse("user_v2:user_list"), {"page_size": "0"}), + (reverse("user_v2:user_preference_list"), {"ordering": "nope"}), + (reverse("user_v2:user_preference_list"), {"page_size": "-1"}), + (reverse("user_v2:time_zone_list"), {"country_code": "nowhere"}), + ] + for address, params in refusals: + with self.subTest(address=address, params=params): + envelope = self.refused(address, params) + sentence = envelope["detail"] + assert isinstance(sentence, str) + assert not any(mark in sentence for mark in ("{", "}", "[", "]", "ErrorDetail(")), sentence + assert sentence == " ".join( + f"{name}: {message}" + for name, messages in envelope["errors"].items() + for message in messages + ) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_preference_parity.py b/openedx/core/djangoapps/user_api/v2/tests/test_preference_parity.py new file mode 100644 index 000000000000..a2dfab367b8c --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_preference_parity.py @@ -0,0 +1,409 @@ +"""Behaviour parity between the version 1 and version 2 account preference addresses.""" + +import json + +from rest_framework import status + +from openedx.core.djangoapps.user_api.models import UserPreference +from openedx.core.djangolib.testing.utils import skip_unless_lms + +from .test_email_opt_in_views import COURSE_KEY, EARLIER_ADDRESS, UNVERSIONED_EARLIER_ADDRESS +from .test_parity import diff, unexpected +from .test_preference_views import ( + MERGE_PATCH, + SORTED_NAMES, + UNREACHABLE_KEYS, + UNSORTED_NAMES, + PreferenceApiTestCase, + earlier_document_address, + earlier_member_address, +) + +#: Preference names a filled-in account stores, in the numbers the document is +#: compared at: none, one, and enough that a body would be paged if it were a +#: list of them. +STORED_COUNTS = (0, 1, 25) + +#: The members every refusal of this version carries, which the earlier +#: addresses answer with bodies of their own instead. +ENVELOPE_MEMBERS = {"type", "title", "status", "detail", "instance"} + +#: Differences each case is expected to show, as (json path, reason) pairs: the +#: body compared must carry all of them and nothing else. The empty path is the +#: whole body, which is a value rather than an object at one of the addresses +#: compared. A difference in the order of the names a body carries is not a +#: path and is pinned by the test named for it instead. +INTENTIONAL_DIFFERENCES = { + "document": [], + "staff_document": [], + "sorted_document": [], + "member": [ + ("", "the earlier address answers with the stored value and nothing else"), + ("key", "the body names the preference the value belongs to"), + ("value", "the value is carried under a property of an object"), + ], + "anonymous_refusal": [ + ("type", "a refusal names the kind of fault it reports"), + ("title", "a refusal carries a summary of the kind of fault it reports"), + ("status", "a refusal carries the status it was answered with"), + ], +} + + +def assert_parity(earlier, current, case): + """Assert the two bodies differ only where the case declares they do.""" + changed = diff(earlier, current) + intentional = INTENTIONAL_DIFFERENCES[case] + assert not unexpected(changed, intentional), ( + f"undeclared differences: {sorted(unexpected(changed, intentional))}\n" + f"earlier={earlier}\ncurrent={current}" + ) + for path, reason in intentional: + assert any( + key == path or key.startswith(path + ".") or key.startswith(path + "[") + for key in changed + ), f"declared difference did not occur: {path} ({reason})" + + +@skip_unless_lms +class PreferenceDocumentParityTests(PreferenceApiTestCase): + """The document each version answers with for the same account.""" + + def documents(self, user, username=None): + """Return the document each version answers, read in one session.""" + username = username or user.username + self.login(user) + earlier = self.client.get(earlier_document_address(username)) + current = self.client.get(self.document_address(username)) + assert earlier.status_code == status.HTTP_200_OK, earlier.content + assert current.status_code == status.HTTP_200_OK, current.content + return json.loads(earlier.content), json.loads(current.content) + + def test_the_document_carries_the_same_names_and_values_however_many_are_stored(self): + for count in STORED_COUNTS: + UserPreference.objects.filter(user=self.owner).delete() + for index in range(count): + self.prefer(f"counted_{index:03d}", f"value {index}") + earlier, current = self.documents(self.owner) + assert_parity(earlier, current, "document") + assert current == { + f"counted_{index:03d}": f"value {index}" for index in range(count) + }, count + + def test_a_staff_caller_reads_the_same_document_through_either_address(self): + earlier, current = self.documents(self.staff, username=self.owner.username) + assert_parity(earlier, current, "staff_document") + assert current == {"time_zone": "Asia/Tokyo"} + + def test_the_document_this_version_answers_is_sorted_and_carries_the_same_names(self): + """ + Declared difference: the names come back in one order rather than the stored order. + + The names stored include ones that sort differently by the code of + each character than they would with the case folded, so the order + pinned here is the one order the document promises rather than any + order that happens to agree with it. + """ + for key in UNSORTED_NAMES: + self.prefer(key, "stored") + earlier, current = self.documents(self.owner) + assert_parity(earlier, current, "sorted_document") + assert list(current) == sorted(earlier) == [*SORTED_NAMES, "time_zone"] + assert current == { + **{name: "stored" for name in UNSORTED_NAMES}, "time_zone": "Asia/Tokyo", + } + + def test_the_same_merge_patch_leaves_both_versions_storing_the_same_rows(self): + self.login(self.owner) + changes = json.dumps({"account_privacy": "private", "time_zone": None}) + assert self.client.patch( + earlier_document_address(self.owner.username), changes, content_type=MERGE_PATCH, + ).status_code == status.HTTP_204_NO_CONTENT + after_earlier = self.stored() + + UserPreference.objects.filter(user=self.owner).delete() + self.prefer("time_zone", "Asia/Tokyo") + assert self.client.patch( + self.document_url, changes, content_type=MERGE_PATCH, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == after_earlier == {"account_privacy": "private"} + + def test_both_versions_refuse_a_change_sent_in_another_media_type(self): + self.login(self.owner) + body = json.dumps({"time_zone": "Europe/Paris"}) + for address in (earlier_document_address(self.owner.username), self.document_url): + assert self.client.patch( + address, body, content_type="application/json", + ).status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, address + + def test_both_versions_refuse_a_value_the_preference_cannot_hold(self): + """The message a user is shown comes from the same place in both.""" + self.login(self.owner) + body = json.dumps({"time_zone": "Asia/Africa"}) + earlier = self.client.patch( + earlier_document_address(self.owner.username), body, content_type=MERGE_PATCH, + ) + current = self.client.patch(self.document_url, body, content_type=MERGE_PATCH) + assert earlier.status_code == current.status_code == status.HTTP_400_BAD_REQUEST + message = "Value 'Asia/Africa' is not a valid time zone selection." + assert json.loads(earlier.content)["field_errors"]["time_zone"]["user_message"] == message + assert json.loads(current.content)["errors"] == {"time_zone": [message]} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_value_that_is_not_text_was_spelled_out_and_stored_and_is_now_refused(self): + """Declared difference: a value of another type is the caller's mistake, not a string.""" + self.login(self.owner) + body = json.dumps({"account_privacy": 5}) + assert self.client.patch( + earlier_document_address(self.owner.username), body, content_type=MERGE_PATCH, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored()["account_privacy"] == "5" + + UserPreference.objects.filter(user=self.owner, key="account_privacy").delete() + assert self.client.patch( + self.document_url, body, content_type=MERGE_PATCH, + ).status_code == status.HTTP_400_BAD_REQUEST + assert "account_privacy" not in self.stored() + + def test_a_body_naming_nothing_is_refused_by_both_versions(self): + self.login(self.owner) + for address in (earlier_document_address(self.owner.username), self.document_url): + assert self.client.patch( + address, json.dumps({}), content_type=MERGE_PATCH, + ).status_code == status.HTTP_400_BAD_REQUEST, address + + +@skip_unless_lms +class PreferenceMemberParityTests(PreferenceApiTestCase): + """One preference through either version's member address.""" + + def test_the_value_is_the_same_and_this_version_names_it(self): + """Declared difference: the body is the name and the value rather than the value alone.""" + self.login(self.owner) + earlier = self.client.get(earlier_member_address(self.owner.username, "time_zone")) + current = self.client.get(self.member_url) + assert earlier.status_code == current.status_code == status.HTTP_200_OK + assert_parity(json.loads(earlier.content), json.loads(current.content), "member") + assert json.loads(earlier.content) == "Asia/Tokyo" + assert json.loads(current.content) == {"key": "time_zone", "value": "Asia/Tokyo"} + + def test_both_versions_report_a_name_the_account_stores_no_value_under_as_absent(self): + self.login(self.owner) + for address in ( + earlier_member_address(self.owner.username, "account_privacy"), + self.member_address(self.owner.username, "account_privacy"), + ): + assert self.client.get(address).status_code == status.HTTP_404_NOT_FOUND, address + assert self.client.delete(address).status_code == status.HTTP_404_NOT_FOUND, address + + def test_the_names_the_earlier_member_route_cannot_carry_are_addressed_here(self): + """Declared difference: the key half of the address takes the whole segment.""" + self.login(self.owner) + for key in UNREACHABLE_KEYS: + self.prefer(key, "stored") + earlier = self.client.get(earlier_member_address(self.owner.username, key)) + assert earlier.status_code == status.HTTP_404_NOT_FOUND, key + assert earlier["Content-Type"].startswith("text/html"), key + current = self.client.get(self.member_address(self.owner.username, key)) + assert current.status_code == status.HTTP_200_OK, key + assert json.loads(current.content) == {"key": key, "value": "stored"}, key + + def test_the_value_sent_is_stored_by_both_versions(self): + self.login(self.owner) + assert self.client.put( + earlier_member_address(self.owner.username, "time_zone"), + json.dumps("Europe/Paris"), + content_type="application/json", + ).status_code == status.HTTP_204_NO_CONTENT + after_earlier = self.stored() + + assert self.put_value( + self.member_url, {"value": "Europe/Paris"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == after_earlier == {"time_zone": "Europe/Paris"} + + def test_a_value_sent_as_a_bare_string_is_no_longer_read(self): + """Declared difference: the value travels as a property of an object.""" + self.login(self.owner) + refused = self.client.put( + self.member_url, json.dumps("Europe/Paris"), content_type="application/json", + ) + assert refused.status_code == status.HTTP_400_BAD_REQUEST + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_both_versions_refuse_an_empty_value_with_the_same_message(self): + self.login(self.owner) + earlier = self.client.put( + earlier_member_address(self.owner.username, "account_privacy"), + json.dumps(""), + content_type="application/json", + ) + assert earlier.status_code == status.HTTP_400_BAD_REQUEST + assert json.loads(earlier.content)["user_message"] == ( + "Preference 'account_privacy' cannot be set to an empty value." + ) + current = self.put_value( + self.member_address(self.owner.username, "account_privacy"), {"value": ""}, + ) + assert current.status_code == status.HTTP_400_BAD_REQUEST + assert json.loads(current.content)["errors"] == {"value": ["This field may not be blank."]} + + def test_a_preference_is_removed_by_both_versions(self): + self.login(self.owner) + assert self.client.delete( + earlier_member_address(self.owner.username, "time_zone"), + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {} + self.prefer("time_zone", "Asia/Tokyo") + assert self.client.delete(self.member_url).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {} + + +@skip_unless_lms +class PreferenceRefusalParityTests(PreferenceApiTestCase): + """How each version refuses the same caller.""" + + def addresses(self, username): + """Return the document and member address of one account in each version.""" + return ( + (earlier_document_address(username), self.document_address(username)), + ( + earlier_member_address(username, "time_zone"), + self.member_address(username, "time_zone"), + ), + ) + + def test_both_versions_refuse_a_learner_reading_another_account(self): + self.login(self.other) + for earlier_address, address in self.addresses(self.owner.username): + earlier = self.client.get(earlier_address) + current = self.client.get(address) + assert earlier.status_code == current.status_code == status.HTTP_403_FORBIDDEN + assert earlier.content == b"" + assert set(json.loads(current.content)) >= ENVELOPE_MEMBERS + + def test_both_versions_refuse_a_learner_changing_another_account(self): + self.login(self.other) + body = json.dumps({"time_zone": "Europe/Paris"}) + earlier_document, document = self.addresses(self.owner.username)[0] + for address in (earlier_document, document): + assert self.client.patch( + address, body, content_type=MERGE_PATCH, + ).status_code == status.HTTP_403_FORBIDDEN, address + earlier_member, member = self.addresses(self.owner.username)[1] + for address in (earlier_member, member): + assert self.client.delete(address).status_code == status.HTTP_403_FORBIDDEN, address + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_both_versions_refuse_a_staff_caller_changing_another_account(self): + self.login(self.staff) + earlier = self.client.patch( + earlier_document_address(self.owner.username), + json.dumps({"time_zone": "Europe/Paris"}), + content_type=MERGE_PATCH, + ) + current = self.client.patch( + self.document_url, json.dumps({"time_zone": "Europe/Paris"}), content_type=MERGE_PATCH, + ) + assert earlier.status_code == current.status_code == status.HTTP_403_FORBIDDEN + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_both_versions_refuse_an_address_that_is_not_exactly_the_callers_name(self): + caller = self.sign_in_as_a_case_variant_of(self.owner) + assert caller.username == self.owner.username.upper() + for earlier_address, address in self.addresses(self.owner.username): + assert self.client.get(earlier_address).status_code == status.HTTP_403_FORBIDDEN + assert self.client.get(address).status_code == status.HTTP_403_FORBIDDEN + + def test_a_staff_caller_is_told_about_a_name_nobody_holds_by_both_versions(self): + self.login(self.staff) + for earlier_address, address in self.addresses("nobody"): + assert self.client.get(earlier_address).status_code == status.HTTP_404_NOT_FOUND + assert self.client.get(address).status_code == status.HTTP_404_NOT_FOUND + + def test_a_learner_is_refused_a_name_nobody_holds_by_both_versions(self): + """Neither version lets a caller learn whether a name is held from these addresses.""" + self.login(self.other) + for earlier_address, address in self.addresses("nobody"): + assert self.client.get(earlier_address).status_code == status.HTTP_403_FORBIDDEN + assert self.client.get(address).status_code == status.HTTP_403_FORBIDDEN + + def test_an_anonymous_caller_is_refused_by_both_versions(self): + """Declared difference: the refusal carries the standard body.""" + for earlier_address, address in self.addresses(self.owner.username): + earlier = self.client.get(earlier_address) + current = self.client.get(address) + assert earlier.status_code == current.status_code == status.HTTP_401_UNAUTHORIZED + assert_parity( + json.loads(earlier.content), json.loads(current.content), "anonymous_refusal", + ) + assert set(json.loads(earlier.content)) == {"detail"} + assert set(json.loads(current.content)) >= ENVELOPE_MEMBERS + + def test_a_query_parameter_neither_version_reads_is_ignored_by_one_and_refused_by_the_other(self): + """Declared difference: a parameter this version does not read is refused.""" + self.login(self.owner) + for earlier_address, address in self.addresses(self.owner.username): + assert self.client.get(earlier_address, {"page": "2"}).status_code == ( + status.HTTP_200_OK + ) + assert self.client.get(address, {"page": "2"}).status_code == ( + status.HTTP_400_BAD_REQUEST + ) + + +@skip_unless_lms +class SupersededAddressAnnouncementTests(PreferenceApiTestCase): + """ + What the superseded addresses say about being superseded: nothing, in any header. + + Which addresses are superseded is published in the document and carried by + the removal ticket. The addresses themselves answer exactly as they always + have, because a response header would be a change to a version already in + use. That is pinned here so the declaration and the running code cannot + drift apart. + """ + + #: Headers an answer would carry an announcement of its own removal in. + ANNOUNCING_HEADERS = ("Deprecation", "Sunset", "Warning", "Link") + + def assert_nothing_was_announced(self, response, expected_status, address): + """Assert the answer is the one that address gives, and announces nothing.""" + assert response.status_code == expected_status, (address, response.content) + announced = [header for header in self.ANNOUNCING_HEADERS if header in response] + assert announced == [], address + + def test_no_superseded_preference_address_announces_its_own_removal(self): + self.login(self.owner) + document = earlier_document_address(self.owner.username) + member = earlier_member_address(self.owner.username, "time_zone") + exchanges = ( + (f"GET {document}", self.client.get(document), status.HTTP_200_OK), + ( + f"PATCH {document}", + self.client.patch( + document, json.dumps({"zoneinfo": "Europe/Paris"}), content_type=MERGE_PATCH, + ), + status.HTTP_204_NO_CONTENT, + ), + (f"GET {member}", self.client.get(member), status.HTTP_200_OK), + ( + f"PUT {member}", + self.client.put(member, json.dumps("Asia/Tokyo"), content_type="application/json"), + status.HTTP_204_NO_CONTENT, + ), + (f"DELETE {member}", self.client.delete(member), status.HTTP_204_NO_CONTENT), + ) + for address, response, expected_status in exchanges: + with self.subTest(address=address): + self.assert_nothing_was_announced(response, expected_status, address) + + def test_neither_address_of_the_superseded_email_choice_announces_one(self): + self.login(self.owner) + for address in (EARLIER_ADDRESS, UNVERSIONED_EARLIER_ADDRESS): + with self.subTest(address=address): + self.assert_nothing_was_announced( + self.client.post(address, {"course_id": COURSE_KEY, "email_opt_in": "True"}), + status.HTTP_200_OK, + address, + ) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_preference_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_preference_views.py new file mode 100644 index 000000000000..a60bbb838479 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_preference_views.py @@ -0,0 +1,1737 @@ +"""Tests for the version 2 User API account preference addresses.""" + +import contextlib +import json +from unittest import mock +from urllib.parse import quote + +from django.conf import settings +from django.contrib.auth.models import AnonymousUser +from django.db import DatabaseError, connection +from django.test.utils import CaptureQueriesContext +from django.urls import resolve, reverse +from edx_rest_framework_extensions.testing import assert_error_envelope +from rest_framework import status +from rest_framework.request import Request +from rest_framework.test import APIClient, APIRequestFactory, APITestCase, force_authenticate + +from common.djangoapps.student.tests.factories import UserFactory +from common.djangoapps.util import model_utils +from common.djangoapps.util.model_utils import USER_FIELDS_CHANGED, USER_SETTINGS_CHANGED_EVENT_NAME +from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user +from openedx.core.djangoapps.user_api.errors import ( + PreferenceUpdateError, + PreferenceValidationError, + UserAPIInternalError, +) +from openedx.core.djangoapps.user_api.models import UserPreference +from openedx.core.djangoapps.user_api.preferences.api import set_user_preference +from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory +from openedx.core.djangoapps.user_api.v2.serializers import ( + PREFERENCE_NAME_MAX_LENGTH, + PREFERENCE_NAME_TOO_LONG, + PREFERENCE_NAME_UNUSABLE, +) +from openedx.core.djangoapps.user_api.v2.views import DESCRIBE_ADDRESS_ACTION, AccountPreferenceViewSet +from openedx.core.djangolib.testing.utils import skip_unless_lms + +from .test_views import write_queries + +PASSWORD = "Password1234" +MERGE_PATCH = "application/merge-patch+json" + +#: The query parameters these addresses read, in the order their refusal lists them. +PREFERENCE_QUERY_PARAMS = "format" + +#: The table the preferences of an account are stored in. +PREFERENCE_TABLE = "user_api_userpreference" + +#: Where the classes the platform configures record that they read a request. +DEFAULT_AUTH_RECORDER = "openedx.core.djangolib.default_auth_classes.set_custom_attribute" + +#: What the platform's own record of a request carries for a read of one of +#: these addresses: the query string it was made with and the form body it +#: carried, both empty. +REQUEST_RECORD_DATA = '{"GET": {}, "POST": {}}' + +#: Where the language a user picked is carried between requests, and the name +#: the platform stores it under once it is carried. +LANGUAGE_COOKIE = settings.LANGUAGE_COOKIE_NAME +LANGUAGE_PREFERENCE = "pref-lang" + +#: Names a preference may be stored under that the version 1 member address +#: cannot carry: the language the platform itself stores, the names the profile +#: pages store a sharing choice under, and a name ending in a separator. +UNREACHABLE_KEYS = (LANGUAGE_PREFERENCE, "visibility.bio", "visibility.") + +#: Names a preference may be stored under that no member address can carry, +#: because the name is read from one segment and a segment ends at a slash. +SLASHED_NAMES = ("a/b", "x/", "/x", "a//b") + +#: Names that address another collection at version 1 and are ordinary +#: preference names here. +COLLECTION_NAMES = ("email_opt_in", "time_zones") + +#: The longest name a preference can be stored under, and one character more. +LONGEST_NAME = "k" * PREFERENCE_NAME_MAX_LENGTH +TOO_LONG_NAME = "k" * (PREFERENCE_NAME_MAX_LENGTH + 1) + +#: Names that differ in more than their letters, stored in an order none of the +#: orders they could come back in would put them in. +UNSORTED_NAMES = ("Zeta", "alpha", "Beta", "_under", "9nine") + +#: Those names in the order they are answered in: by the code of each +#: character, which puts digits, capitals and the underscore ahead of lower +#: case. A sort that folded case would answer them in another order. +SORTED_NAMES = ["9nine", "Beta", "Zeta", "_under", "alpha"] + +#: Text carrying a character that stands for half of a pair, which has no +#: encoding of its own: it can be neither stored nor carried back in an answer. +UNENCODABLE_TEXT = "x\ud800y" + +#: How such a character is reported, by the code point that was sent. +UNENCODABLE_REFUSAL = "Surrogate characters are not allowed: U+D800." + + +def earlier_document_address(username): + """Return the version 1 address of every preference of one account.""" + return f"/api/user/v1/preferences/{username}" + + +def earlier_member_address(username, key): + """Return the version 1 address of one preference of one account.""" + return f"/api/user/v1/preferences/{username}/{key}" + + +@contextlib.contextmanager +def requests_served_outside_a_transaction(): + """Serve requests the way a database that commits each statement on its own would.""" + connected_as = connection.settings_dict + each_request_in_a_transaction = connected_as["ATOMIC_REQUESTS"] + connected_as["ATOMIC_REQUESTS"] = False + try: + yield + finally: + connected_as["ATOMIC_REQUESTS"] = each_request_in_a_transaction + + +class PreferenceApiTestCase(APITestCase): + """Fixtures shared by the version 2 account preference tests.""" + + def setUp(self): + super().setUp() + self.owner = UserFactory.create( + username="pref-owner", email="pref-owner@example.com", password=PASSWORD, + ) + self.other = UserFactory.create( + username="pref-other", email="pref-other@example.com", password=PASSWORD, + ) + self.staff = UserFactory.create( + username="pref-staff", email="pref-staff@example.com", password=PASSWORD, is_staff=True, + ) + self.prefer("time_zone", "Asia/Tokyo") + self.document_url = self.document_address(self.owner.username) + self.member_url = self.member_address(self.owner.username, "time_zone") + + def document_address(self, username): + """Return the address of every preference of one account.""" + return reverse("user_v2:account_preference_list", kwargs={"username": username}) + + def member_address(self, username, key): + """Return the address of one preference of one account.""" + return reverse( + "user_v2:account_preference_detail", + kwargs={"username": username, "key": key}, + ) + + def login(self, user): + """Sign the given user in through the session these views accept.""" + assert self.client.login(username=user.username, password=PASSWORD) + + def token_headers(self, user): + """Return the request headers that carry a token identifying the given user.""" + return {"HTTP_AUTHORIZATION": f"JWT {create_jwt_for_user(user)}"} + + def prefer(self, key, value, user=None): + """Store one preference for the given account, the owner's by default.""" + return UserPreferenceFactory.create(user=user or self.owner, key=key, value=value) + + def stored(self, user=None): + """Return the preferences an account holds, as the names and values stored.""" + return { + preference.key: preference.value + for preference in UserPreference.objects.filter(user=user or self.owner) + } + + def sign_in_as_a_case_variant_of(self, user): + """ + Sign in a caller whose own name is the given user's, in upper case. + + The variant is set in memory only, since a site that stores usernames + case-insensitively has no room for both names at once. + """ + caller = UserFactory.create( + username="pref-variant", email="pref-variant@example.com", password=PASSWORD, + ) + caller.username = user.username.upper() + self.client.force_authenticate(user=caller) + return caller + + def patch_document(self, address, body, content_type=MERGE_PATCH, client=None, **kwargs): + """Send a merge patch of the preference document.""" + return (client or self.client).patch( + address, json.dumps(body), content_type=content_type, **kwargs, + ) + + def put_value(self, address, body, client=None, **kwargs): + """Send the value one preference is to hold.""" + return (client or self.client).put( + address, json.dumps(body), content_type="application/json", **kwargs, + ) + + def read_requests(self, username, client=None, **headers): + """Return the requests that read the preferences of one account, by name.""" + sender = client or self.client + return { + "document": lambda: sender.get(self.document_address(username), **headers), + "member": lambda: sender.get( + self.member_address(username, "time_zone"), **headers, + ), + } + + def write_requests(self, username, client=None, **headers): + """Return the requests that change the preferences of one account, by name.""" + sender = client or self.client + return { + "document": lambda: self.patch_document( + self.document_address(username), + {"time_zone": "Europe/Paris"}, + client=sender, + **headers, + ), + "member": lambda: self.put_value( + self.member_address(username, "time_zone"), + {"value": "Europe/Paris"}, + client=sender, + **headers, + ), + "member removal": lambda: sender.delete( + self.member_address(username, "time_zone"), **headers, + ), + } + + def assert_all_refused(self, requests, expected_status, expected_type_slug): + """ + Assert every request named is refused the same way. + + Each request is named in the failure as well as in the subtest it is + run under, because a subtest name reaches the report only where + something reads it, and the name of the one refusal that stopped + happening is the first thing a reader of the failure needs. + """ + for case, send in dict(requests).items(): + with self.subTest(case=case): + refused = send() + assert refused.status_code == expected_status, (case, refused.content) + assert_error_envelope( + refused, + expected_status=expected_status, + expected_type_slug=expected_type_slug, + ) + + +@skip_unless_lms +class PreferenceDocumentTests(PreferenceApiTestCase): + """GET and PATCH /api/user/v2/accounts/{username}/preferences/""" + + def test_an_owner_reads_every_preference_the_account_stores(self): + self.prefer("account_privacy", "private") + self.login(self.owner) + response = self.client.get(self.document_url) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"account_privacy": "private", "time_zone": "Asia/Tokyo"} + + def test_an_account_that_stores_nothing_is_answered_with_an_empty_document(self): + self.login(self.other) + response = self.client.get(self.document_address(self.other.username)) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {} + + def test_the_names_come_back_in_one_order_whatever_order_they_were_stored_in(self): + for key in ("zoneinfo", "account_privacy", LANGUAGE_PREFERENCE, "visibility.bio"): + self.prefer(key, "stored") + self.login(self.owner) + names = list(self.client.get(self.document_url).json()) + assert names == ["account_privacy", LANGUAGE_PREFERENCE, "time_zone", "visibility.bio", "zoneinfo"] + + def test_the_order_the_names_come_back_in_is_the_code_of_each_character(self): + """Which is what the document says, and is not the order a reader of English expects.""" + for key in UNSORTED_NAMES: + self.prefer(key, "stored") + self.login(self.owner) + assert list(self.client.get(self.document_url).json()) == [*SORTED_NAMES, "time_zone"] + + def test_a_staff_caller_reads_another_accounts_preferences(self): + self.login(self.staff) + response = self.client.get(self.document_url) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"time_zone": "Asia/Tokyo"} + + def test_an_owner_changes_the_names_the_body_carries_and_leaves_the_rest(self): + self.prefer("account_privacy", "private") + self.login(self.owner) + response = self.patch_document(self.document_url, {"time_zone": "Europe/Paris"}) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert response.content == b"" + assert self.stored() == {"account_privacy": "private", "time_zone": "Europe/Paris"} + + def test_a_name_the_account_does_not_store_is_added(self): + self.login(self.owner) + assert self.patch_document( + self.document_url, {"account_privacy": "private"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {"account_privacy": "private", "time_zone": "Asia/Tokyo"} + + def test_a_null_removes_the_name_from_the_account_that_was_addressed(self): + self.prefer("time_zone", "Europe/Paris", user=self.other) + self.login(self.owner) + assert self.patch_document( + self.document_url, {"time_zone": None}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {} + assert self.stored(self.other) == {"time_zone": "Europe/Paris"} + + def test_the_document_read_back_and_sent_again_changes_nothing(self): + self.prefer("account_privacy", "private") + self.login(self.owner) + document = self.client.get(self.document_url).json() + assert self.patch_document( + self.document_url, document, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.client.get(self.document_url).json() == document + + def test_the_names_the_earlier_member_address_cannot_carry_are_changed_here(self): + self.login(self.owner) + changes = {key: "stored" for key in UNREACHABLE_KEYS} + assert self.patch_document( + self.document_url, changes, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.client.get(self.document_url).json() == { + "time_zone": "Asia/Tokyo", **changes, + } + + def test_a_value_that_is_not_text_is_refused_under_the_name_that_carried_it(self): + """The earlier address spelled a number out and stored it.""" + self.login(self.owner) + response = self.patch_document(self.document_url, {"time_zone": 5, "account_privacy": "private"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "time_zone": ["Send a value as text, or null to remove the preference."], + } + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_what_a_value_of_spaces_alone_means_is_settled_where_it_is_stored(self): + """A value arrives as text; trimming it and refusing an empty one are the service's.""" + self.login(self.owner) + assert self.patch_document( + self.document_url, {"notes": " draft "}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {"notes": "draft", "time_zone": "Asia/Tokyo"} + envelope = assert_error_envelope( + self.patch_document(self.document_url, {"other": " "}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "other": ["Preference 'other' cannot be set to an empty value."], + } + assert self.stored() == {"notes": "draft", "time_zone": "Asia/Tokyo"} + + def test_an_empty_value_is_refused_under_the_name_that_carried_it(self): + self.login(self.owner) + response = self.patch_document(self.document_url, {"account_privacy": ""}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "account_privacy": ["Preference 'account_privacy' cannot be set to an empty value."], + } + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_nothing_is_stored_unless_every_name_in_the_body_can_be(self): + self.login(self.owner) + response = self.patch_document( + self.document_url, {"account_privacy": "private", "time_zone": "Asia/Africa"}, + ) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == { + "time_zone": ["Value 'Asia/Africa' is not a valid time zone selection."], + } + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_name_as_long_as_the_column_holds_is_stored(self): + self.login(self.owner) + assert self.patch_document( + self.document_url, {LONGEST_NAME: "stored"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {"time_zone": "Asia/Tokyo", LONGEST_NAME: "stored"} + + def test_a_name_longer_than_the_column_holds_is_refused_under_itself(self): + self.login(self.owner) + response = self.patch_document(self.document_url, {TOO_LONG_NAME: "stored"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == {TOO_LONG_NAME: [PREFERENCE_NAME_TOO_LONG]} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_value_that_cannot_be_encoded_is_refused_under_the_name_that_carried_it(self): + self.login(self.owner) + response = self.patch_document(self.document_url, {"account_privacy": UNENCODABLE_TEXT}) + assert response["Content-Type"] == "application/json" + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == {"account_privacy": [UNENCODABLE_REFUSAL]} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_name_that_cannot_be_encoded_is_refused_against_the_body(self): + """The answer could not carry the name back, so it names the body instead of the member.""" + self.login(self.owner) + for body in ( + {"account_privacy\ud800": "private"}, + {"\ud800": "private"}, + {"account_privacy\ud800": 5}, + {"account_privacy\ud800": None}, + ): + response = self.patch_document(self.document_url, body) + assert response["Content-Type"] == "application/json" + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"non_field_errors": [UNENCODABLE_REFUSAL]}, body + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_body_that_is_not_an_object_is_refused(self): + self.login(self.owner) + for body in ([], "time_zone", 5): + envelope = assert_error_envelope( + self.patch_document(self.document_url, body), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "non_field_errors": ["Send the preferences to change as an object."], + }, body + + def test_a_body_that_names_no_preference_is_refused(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.patch_document(self.document_url, {}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "non_field_errors": ["Name at least one preference to change."], + } + + def test_a_body_that_is_not_well_formed_is_refused(self): + self.login(self.owner) + assert_error_envelope( + self.client.patch(self.document_url, "{not json", content_type=MERGE_PATCH), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="malformed-body", + ) + + def test_a_change_sent_in_another_media_type_is_refused(self): + """The document is changed with a merge patch, in which a null removes a name.""" + self.login(self.owner) + for content_type in ("application/json", "application/x-www-form-urlencoded"): + assert_error_envelope( + self.patch_document( + self.document_url, {"time_zone": "Europe/Paris"}, content_type=content_type, + ), + expected_status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + expected_type_slug="unsupported-media-type", + ) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_the_document_is_not_written_whole_or_added_to(self): + self.login(self.owner) + for method in ("put", "post", "delete"): + response = getattr(self.client, method)( + self.document_url, json.dumps({}), content_type="application/json", + ) + assert_error_envelope( + response, + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug="method-not-allowed", + ) + + +@skip_unless_lms +class PreferenceMemberTests(PreferenceApiTestCase): + """GET, PUT and DELETE /api/user/v2/accounts/{username}/preferences/{key}/""" + + def test_an_owner_reads_one_preference_as_its_name_and_its_value(self): + self.login(self.owner) + response = self.client.get(self.member_url) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"key": "time_zone", "value": "Asia/Tokyo"} + + def test_a_staff_caller_reads_one_preference_of_another_account(self): + self.login(self.staff) + assert self.client.get(self.member_url).json() == { + "key": "time_zone", "value": "Asia/Tokyo", + } + + def test_a_name_the_account_stores_no_value_under_is_absent(self): + self.login(self.owner) + assert_error_envelope( + self.client.get(self.member_address(self.owner.username, "account_privacy")), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_every_name_one_segment_can_carry_is_readable_and_writable(self): + self.login(self.owner) + for key in UNREACHABLE_KEYS + COLLECTION_NAMES: + address = self.member_address(self.owner.username, key) + assert self.put_value(address, {"value": "stored"}).status_code == ( + status.HTTP_204_NO_CONTENT + ), key + assert self.client.get(address).json() == {"key": key, "value": "stored"}, key + assert self.client.delete(address).status_code == status.HTTP_204_NO_CONTENT, key + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_name_as_long_as_the_column_holds_is_given_a_value(self): + self.login(self.owner) + assert self.put_value( + self.member_address(self.owner.username, LONGEST_NAME), {"value": "stored"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {"time_zone": "Asia/Tokyo", LONGEST_NAME: "stored"} + + def test_a_longer_name_is_refused_under_the_part_of_the_address_that_carried_it(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.put_value( + self.member_address(self.owner.username, TOO_LONG_NAME), {"value": "stored"}, + ), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"key": [PREFERENCE_NAME_TOO_LONG]} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_name_nothing_can_be_stored_under_is_refused_under_that_part_too(self): + """The fault is in the address, so changing the value sent would not mend it.""" + self.login(self.owner) + envelope = assert_error_envelope( + self.put_value( + self.member_address(self.owner.username, "!!!"), {"value": "stored"}, + ), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"key": [PREFERENCE_NAME_UNUSABLE]} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_the_names_this_address_refuses_are_the_ones_the_store_refuses(self): + """ + Which names are held is the store's to decide, so each is offered to it as well. + + A name is checked before the value is read, against what the store + holds rather than against a list kept here, so a change to what it + holds shows up as this address keeping a name the store would refuse or + refusing one it would keep. + """ + self.login(self.owner) + for name in ("a!!!", "!!!", "...", "9", "visibility.bio", "pref-lang"): + UserPreference.objects.filter(user=self.owner, key=name).delete() + try: + set_user_preference(self.owner, name, "stored") + except PreferenceValidationError: + the_store_refuses_it = True + else: + the_store_refuses_it = False + UserPreference.objects.filter(user=self.owner, key=name).delete() + answered = self.put_value( + self.member_address(self.owner.username, name), {"value": "stored"}, + ) + assert (answered.status_code == status.HTTP_400_BAD_REQUEST) == the_store_refuses_it, ( + f"{name}: answered {answered.status_code}" + ) + assert (name in self.stored()) == (not the_store_refuses_it), name + + def test_a_name_no_value_can_be_stored_under_is_read_and_removed_as_absent(self): + """Nothing is stored under a name that long, so both report it missing.""" + self.login(self.owner) + address = self.member_address(self.owner.username, TOO_LONG_NAME) + for response in (self.client.get(address), self.client.delete(address)): + assert_error_envelope( + response, + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_a_value_that_cannot_be_encoded_is_refused_under_the_property_that_carried_it(self): + self.login(self.owner) + response = self.put_value(self.member_url, {"value": UNENCODABLE_TEXT}) + assert response["Content-Type"] == "application/json" + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"] == {"value": [UNENCODABLE_REFUSAL]} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_name_carrying_a_slash_is_stored_read_and_removed_through_the_document(self): + """ + A name is read from one segment, which ends at the slash, so no member address serves it. + + Encoding the slash does not reach one either: an address is read in its + decoded form, which puts the slash back. The name the document stores + is therefore left as it was by every method the member address serves, + and the document is the only place it is read and removed. + """ + self.login(self.owner) + for name in SLASHED_NAMES: + with self.subTest(name=name): + held = {name: "stored", "time_zone": "Asia/Tokyo"} + assert self.patch_document( + self.document_url, {name: "stored"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == held + assert self.client.get(self.document_url).json() == held + for spelling in (name, quote(name, safe="")): + address = f"{self.document_url}{spelling}/" + attempts = ( + ("get", self.client.get(address)), + ("put", self.put_value(address, {"value": "other"})), + ("delete", self.client.delete(address)), + ) + for method, response in attempts: + assert response["Content-Type"] == "application/json", (spelling, method) + assert_error_envelope( + response, + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + assert self.stored() == held + assert self.patch_document( + self.document_url, {name: None}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_value_is_stored_whether_or_not_the_name_already_held_one(self): + self.login(self.owner) + new = self.member_address(self.owner.username, "account_privacy") + assert self.put_value(new, {"value": "private"}).status_code == status.HTTP_204_NO_CONTENT + assert self.put_value( + self.member_url, {"value": "Europe/Paris"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {"account_privacy": "private", "time_zone": "Europe/Paris"} + + def test_a_value_that_is_not_text_is_refused_under_the_property_that_carried_it(self): + """The earlier address spelled a number out and stored it.""" + self.login(self.owner) + envelope = assert_error_envelope( + self.put_value(self.member_url, {"value": 5}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"value": ["Not a valid string."]} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_an_empty_value_is_refused(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.put_value(self.member_url, {"value": ""}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"value": ["This field may not be blank."]} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_body_carrying_no_value_is_refused(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.put_value(self.member_url, {}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == {"value": ["This field is required."]} + + def test_a_value_the_preference_cannot_hold_is_refused_with_the_reason(self): + self.login(self.owner) + envelope = assert_error_envelope( + self.put_value(self.member_url, {"value": "Asia/Africa"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "value": ["Value 'Asia/Africa' is not a valid time zone selection."], + } + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_time_zone_the_zone_database_carries_is_stored(self): + """Which zones there are is decided by the database of them, not by a list kept here.""" + self.login(self.owner) + assert self.put_value( + self.member_url, {"value": "America/Argentina/Buenos_Aires"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {"time_zone": "America/Argentina/Buenos_Aires"} + + def test_a_preference_is_removed(self): + self.login(self.owner) + assert self.client.delete(self.member_url).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {} + + def test_removing_a_name_the_account_stores_no_value_under_reports_it_absent(self): + self.login(self.owner) + assert_error_envelope( + self.client.delete(self.member_address(self.owner.username, "account_privacy")), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_one_preference_is_not_merge_patched(self): + self.login(self.owner) + assert_error_envelope( + self.client.patch( + self.member_url, json.dumps({"value": "Europe/Paris"}), content_type=MERGE_PATCH, + ), + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug="method-not-allowed", + ) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_value_sent_in_a_media_type_this_address_does_not_read_is_refused(self): + self.login(self.owner) + assert_error_envelope( + self.client.put( + self.member_url, json.dumps({"value": "Europe/Paris"}), content_type=MERGE_PATCH, + ), + expected_status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + expected_type_slug="unsupported-media-type", + ) + + def test_a_value_submitted_as_a_form_is_read(self): + """These addresses read the media types the platform reads everywhere.""" + self.login(self.owner) + assert self.client.put( + self.member_url, "value=Europe/Paris", + content_type="application/x-www-form-urlencoded", + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {"time_zone": "Europe/Paris"} + + +@skip_unless_lms +class PreferenceAccessTests(PreferenceApiTestCase): + """Who may read and who may change the preferences of the account an address names.""" + + def test_an_anonymous_caller_is_refused_with_the_standard_body(self): + self.assert_all_refused( + {**self.read_requests(self.owner.username), **self.write_requests(self.owner.username)}, + status.HTTP_401_UNAUTHORIZED, + "authn", + ) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_learner_may_not_read_another_accounts_preferences(self): + self.login(self.other) + self.assert_all_refused( + self.read_requests(self.owner.username), status.HTTP_403_FORBIDDEN, "authz", + ) + + def test_a_learner_may_not_change_another_accounts_preferences(self): + self.login(self.other) + self.assert_all_refused( + self.write_requests(self.owner.username), status.HTTP_403_FORBIDDEN, "authz", + ) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_staff_caller_reads_but_may_not_change_another_accounts_preferences(self): + self.login(self.staff) + for case, send in self.read_requests(self.owner.username).items(): + assert send().status_code == status.HTTP_200_OK, case + self.assert_all_refused( + self.write_requests(self.owner.username), status.HTTP_403_FORBIDDEN, "authz", + ) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_staff_caller_reads_and_changes_their_own_preferences(self): + self.prefer("time_zone", "Europe/Paris", user=self.staff) + self.login(self.staff) + own = self.staff.username + for case, send in self.read_requests(own).items(): + assert send().status_code == status.HTTP_200_OK, case + assert self.write_requests(own)["document"]().status_code == status.HTTP_204_NO_CONTENT + assert self.write_requests(own)["member"]().status_code == status.HTTP_204_NO_CONTENT + assert self.write_requests(own)["member removal"]().status_code == ( + status.HTTP_204_NO_CONTENT + ) + + def test_a_token_held_by_a_disabled_staff_account_may_not_read_another_accounts_preferences(self): + """A privilege does not survive the deactivation of the account that held it.""" + headers = self.token_headers(self.staff) + assert self.client.get(self.document_url, **headers).status_code == status.HTTP_200_OK + assert self.client.get(self.member_url, **headers).status_code == status.HTTP_200_OK + self.staff.is_active = False + self.staff.save() + for address in (self.document_url, self.member_url): + assert_error_envelope( + self.client.get(address, **headers), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_a_disabled_account_still_reads_and_changes_its_own_preferences(self): + """The staff half of the check is the one a disabled account loses.""" + self.owner.is_active = False + self.owner.save() + headers = self.token_headers(self.owner) + assert self.client.get(self.document_url, **headers).json() == {"time_zone": "Asia/Tokyo"} + assert self.patch_document( + self.document_url, {"time_zone": "Europe/Paris"}, **headers, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored() == {"time_zone": "Europe/Paris"} + + def test_a_caller_named_for_the_address_in_case_alone_is_refused(self): + """Two names differing in case alone are two names, and only one of them is addressed.""" + self.sign_in_as_a_case_variant_of(self.owner) + self.assert_all_refused( + { + **self.read_requests(self.owner.username), + **self.write_requests(self.owner.username), + }, + status.HTTP_403_FORBIDDEN, + "authz", + ) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_learner_naming_an_account_that_does_not_exist_is_refused_without_learning_that(self): + self.login(self.other) + self.assert_all_refused( + {**self.read_requests("nobody"), **self.write_requests("nobody")}, + status.HTTP_403_FORBIDDEN, + "authz", + ) + + def test_an_administrator_naming_an_account_that_does_not_exist_is_told_so(self): + self.login(self.staff) + self.assert_all_refused( + self.read_requests("nobody"), status.HTTP_404_NOT_FOUND, "not-found", + ) + + def test_an_administrator_may_not_change_the_preferences_of_a_name_nobody_holds(self): + """The refusal is the one every change to another account gets, missing name or not.""" + self.login(self.staff) + self.assert_all_refused( + self.write_requests("nobody"), status.HTTP_403_FORBIDDEN, "authz", + ) + + def test_an_account_with_an_unconfirmed_address_reads_and_changes_its_own_preferences(self): + """The site sends such a user to the pages that read and write these preferences.""" + self.owner.is_active = False + self.owner.save() + self.login(self.owner) + assert self.client.get(self.document_url).json() == {"time_zone": "Asia/Tokyo"} + assert self.client.get(self.member_url).status_code == status.HTTP_200_OK + assert self.patch_document( + self.document_url, {"account_privacy": "private"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.put_value( + self.member_url, {"value": "Europe/Paris"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.client.delete(self.member_url).status_code == status.HTTP_204_NO_CONTENT + + def test_a_session_without_the_token_that_goes_with_it_is_refused_every_change(self): + """ + The session class still holds a change to the token the site hands out with it. + + A read is not held to it, because only a change can be provoked from + another site. The pages that keep a user's settings reach these + addresses with the session they signed in with, so the refusal a + browser meets here is the one pinned. + """ + held_to_its_token = APIClient(enforce_csrf_checks=True) + assert held_to_its_token.login(username=self.owner.username, password=PASSWORD) + self.assert_all_refused( + self.write_requests(self.owner.username, client=held_to_its_token), + status.HTTP_403_FORBIDDEN, + "authz", + ) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + for case, send in self.read_requests( + self.owner.username, client=held_to_its_token, + ).items(): + assert send().status_code == status.HTTP_200_OK, case + + def test_an_administrator_asking_what_another_accounts_addresses_answer_is_told(self): + """ + What an address does with an account is answered to whoever may read it. + + The description of a change the answer carries is built behind a check + of its own, which is made with the method of that change, so a caller + who may not make it is not told what it would carry. + """ + self.login(self.staff) + for address in (self.document_url, self.member_url): + answer = self.client.options(address) + assert answer.status_code == status.HTTP_200_OK, address + assert answer.json()["name"] == "Account Preference", address + assert "actions" not in answer.json(), address + + def test_the_addressed_account_is_told_what_a_change_at_their_own_address_carries(self): + """ + The answer describes the body this address serves, which carries the name as well. + + What a change carries is named in the published document, which gives + the value alone; the description assembled here is built from the body + the address answers with, so it names both halves of a stored + preference. + """ + self.login(self.owner) + answer = self.client.options(self.member_url) + assert answer.status_code == status.HTTP_200_OK + assert set(answer.json()["actions"]["PUT"]) == {"key", "value"} + + def test_a_learner_asking_what_another_accounts_addresses_answer_is_refused(self): + self.login(self.other) + for address in (self.document_url, self.member_url): + assert_error_envelope( + self.client.options(address), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_an_anonymous_caller_asking_what_these_addresses_answer_is_refused(self): + for address in (self.document_url, self.member_url): + assert_error_envelope( + self.client.options(address), + expected_status=status.HTTP_401_UNAUTHORIZED, + expected_type_slug="authn", + ) + + def test_a_token_is_read_here_without_recording_the_address_as_one_on_the_defaults(self): + """ + These addresses name their own classes, so they are not the ones the default pair serves. + + The pair the platform configures records every request it reads a + token for, so that an operator can list the addresses it serves. These + replace the session half of it and are therefore not among them, and + nothing here is recorded as if they were. The address read afterwards + names no classes of its own and is recorded, which is what shows the + record is reachable from a request made this way at all. + """ + with mock.patch(DEFAULT_AUTH_RECORDER) as recorded: + answer = self.client.get(self.document_url, **self.token_headers(self.owner)) + assert answer.status_code == status.HTTP_200_OK + assert answer.json() == {"time_zone": "Asia/Tokyo"} + assert recorded.call_args_list == [] + + with mock.patch(DEFAULT_AUTH_RECORDER) as recorded: + on_the_default_pair = self.client.get( + reverse("user_v2:user_preference_list"), **self.token_headers(self.staff), + ) + assert on_the_default_pair.status_code == status.HTTP_200_OK + assert [call.args for call in recorded.call_args_list] == [ + ("using_default_auth_classes", True), + ] + + +@skip_unless_lms +class PreferenceRefusedWithNothingBehindThemTests(PreferenceApiTestCase): + """ + Who these addresses refuse when nothing behind them refuses anybody. + + The preference service applies the same owner-or-administrator rule a + second time, one frame below the handler, so an answer produced with it in + place does not say which of the two refused a caller. Every request here is + made with the service calls replaced by stand-ins that serve whoever asks, + which leaves the classes guarding the actions as the only thing that can + refuse: each refusal below is one of theirs, and a caller they started + admitting would be answered here rather than refused. + """ + + #: What every preference the stand-ins serve is stored under, and its value. + SERVED = {"time_zone": "Asia/Tokyo"} + + def setUp(self): + super().setUp() + self.disabled_staff = UserFactory.create( + username="pref-disabled-staff", + email="pref-disabled-staff@example.com", + password=PASSWORD, + is_staff=True, + is_active=False, + ) + + @contextlib.contextmanager + def nothing_refusing_behind_the_handler(self): + """ + Serve every preference call, so only the classes guarding an action can refuse. + + The stand-ins are handed to the caller of this so a refusal can be + held to having happened in front of them: a request they were never + asked to serve was refused before anything behind the handler saw it. + """ + stand_ins = { + "get_user_preferences": mock.Mock(return_value=dict(self.SERVED)), + "get_user_preference": mock.Mock(return_value=self.SERVED["time_zone"]), + "update_user_preferences": mock.Mock(return_value=None), + "set_user_preference": mock.Mock(return_value=None), + "delete_user_preference": mock.Mock(return_value=True), + } + with mock.patch.multiple("openedx.core.djangoapps.user_api.v2.views", **stand_ins): + yield stand_ins + + def assert_nothing_behind_the_handler_was_asked(self, stand_ins): + """Assert no preference call was reached, so the refusal was the classes' own.""" + assert [name for name, stand_in in stand_ins.items() if stand_in.called] == [] + + def test_the_addressed_account_is_served_at_every_address(self): + """The stand-ins refuse nobody, which is what makes each refusal below the classes' own.""" + self.login(self.owner) + with self.nothing_refusing_behind_the_handler() as stand_ins: + reads = self.read_requests(self.owner.username) + assert reads["document"]().json() == self.SERVED + assert reads["member"]().json() == {"key": "time_zone", "value": "Asia/Tokyo"} + for case, send in self.write_requests(self.owner.username).items(): + assert send().status_code == status.HTTP_204_NO_CONTENT, case + assert sorted(name for name, stand_in in stand_ins.items() if stand_in.called) == [ + "delete_user_preference", + "get_user_preference", + "get_user_preferences", + "set_user_preference", + "update_user_preferences", + ] + + def test_a_learner_reaching_another_account_is_refused_at_every_address(self): + self.login(self.other) + with self.nothing_refusing_behind_the_handler() as stand_ins: + self.assert_all_refused( + { + **self.read_requests(self.owner.username), + **self.write_requests(self.owner.username), + }, + status.HTTP_403_FORBIDDEN, + "authz", + ) + self.assert_nothing_behind_the_handler_was_asked(stand_ins) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_an_administrator_reads_another_account_and_is_refused_every_change_to_it(self): + self.login(self.staff) + with self.nothing_refusing_behind_the_handler() as stand_ins: + for case, send in self.read_requests(self.owner.username).items(): + assert send().status_code == status.HTTP_200_OK, case + self.assert_all_refused( + self.write_requests(self.owner.username), status.HTTP_403_FORBIDDEN, "authz", + ) + assert sorted(name for name, stand_in in stand_ins.items() if stand_in.called) == [ + "get_user_preference", + "get_user_preferences", + ] + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_a_caller_named_for_the_address_in_case_alone_is_refused_at_every_address(self): + self.sign_in_as_a_case_variant_of(self.owner) + with self.nothing_refusing_behind_the_handler() as stand_ins: + self.assert_all_refused( + { + **self.read_requests(self.owner.username), + **self.write_requests(self.owner.username), + }, + status.HTTP_403_FORBIDDEN, + "authz", + ) + self.assert_nothing_behind_the_handler_was_asked(stand_ins) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_an_administrator_whose_account_is_no_longer_active_is_refused_every_address(self): + headers = self.token_headers(self.disabled_staff) + with self.nothing_refusing_behind_the_handler() as stand_ins: + self.assert_all_refused( + { + **self.read_requests(self.owner.username, **headers), + **self.write_requests(self.owner.username, **headers), + }, + status.HTTP_403_FORBIDDEN, + "authz", + ) + self.assert_nothing_behind_the_handler_was_asked(stand_ins) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + +@skip_unless_lms +class PreferenceGuardTests(PreferenceApiTestCase): + """ + Which callers the classes guarding each action admit, read from the view itself. + + The preference service refuses the same callers a second time, from behind + these classes, so a request refused at either point is refused the same + way. What each action is declared to admit is therefore asserted here, on + the declarations themselves, as well as through the answers these + addresses give when nothing behind them refuses anybody. + """ + + def setUp(self): + super().setUp() + self.disabled_staff = UserFactory.create( + username="pref-disabled-staff", + email="pref-disabled-staff@example.com", + password=PASSWORD, + is_staff=True, + is_active=False, + ) + + def routed_actions(self): + """Return every action the two preference addresses route a method to.""" + routed = set() + for address in (self.document_url, self.member_url): + routed.update( + action + for method, action in resolve(address).func.actions.items() + if method != "head" + ) + return routed + + def admits(self, action, caller, username, method="get"): + """ + Return whether every class guarding an action admits one caller at one address. + + The classes are handed the request as the view is handed it: the + wrapped form, carrying the caller as the one the request was read as, + since a class may ask either how the request was read or who made it. + """ + sent = getattr(APIRequestFactory(), method)("/") + force_authenticate(sent, user=caller) + request = Request(sent) + view = AccountPreferenceViewSet() + view.action = action + view.kwargs = {"username": username} + view.request = request + return all( + permission.has_permission(request, view) for permission in view.get_permissions() + ) + + def test_the_reads_admit_the_addressed_account_and_an_active_administrator(self): + for action in ("retrieve_all", "retrieve"): + assert self.admits(action, self.owner, self.owner.username), action + assert self.admits(action, self.staff, self.owner.username), action + assert not self.admits(action, self.other, self.owner.username), action + assert not self.admits(action, self.disabled_staff, self.owner.username), action + assert not self.admits(action, AnonymousUser(), self.owner.username), action + + def test_a_change_admits_the_addressed_account_and_nobody_else(self): + for action in ("partial_update_all", "update", "destroy"): + assert self.admits(action, self.owner, self.owner.username), action + assert not self.admits(action, self.staff, self.owner.username), action + assert not self.admits(action, self.other, self.owner.username), action + assert not self.admits(action, self.disabled_staff, self.owner.username), action + assert not self.admits(action, AnonymousUser(), self.owner.username), action + + def test_the_address_is_the_only_place_a_name_is_read_from(self): + """A name differing in case alone is a different name, whatever the action.""" + for action in self.routed_actions(): + assert not self.admits(action, self.owner, self.owner.username.upper()), action + assert not self.admits(action, self.owner, self.other.username), action + assert not self.admits(action, self.owner, "nobody"), action + + def test_the_actions_an_administrator_may_call_against_another_account_are_the_two_reads(self): + """ + Read from the actions the addresses route, so a new one is not admitted unnoticed. + + The actions come from the mounts rather than from a list of their own, + which is what makes this an answer about who is admitted rather than a + restatement of the names the view holds them under. + """ + admitted = { + action + for action in self.routed_actions() + if self.admits(action, self.staff, self.owner.username) + } + assert admitted == {"retrieve_all", "retrieve"} + assert self.routed_actions() - admitted == {"partial_update_all", "update", "destroy"} + + def test_asking_what_an_address_answers_is_guarded_as_a_read_of_it(self): + """ + The action recorded for that question is named by no mount, so it is asserted apart. + + The same action is recorded again while the description of a change is + built, with the method of that change rather than the one the caller + sent, and is guarded as the change itself. + """ + for caller, admitted in ( + (self.owner, True), + (self.staff, True), + (self.other, False), + (self.disabled_staff, False), + (AnonymousUser(), False), + ): + assert self.admits( + DESCRIBE_ADDRESS_ACTION, caller, self.owner.username, method="options", + ) is admitted, caller + assert self.admits( + DESCRIBE_ADDRESS_ACTION, self.owner, self.owner.username, method="put", + ) + assert not self.admits( + DESCRIBE_ADDRESS_ACTION, self.staff, self.owner.username, method="put", + ) + + +@skip_unless_lms +class PreferenceAddressedByMeTests(PreferenceApiTestCase): + """``me`` beneath an account is read as a stored name on these addresses.""" + + def setUp(self): + super().setUp() + self.me_document = self.document_address("me") + self.me_member = self.member_address("me", "time_zone") + + def changes(self): + """Return each change these addresses serve, as what it changes and how it is sent.""" + return ( + ("the whole document", lambda: self.patch_document( + self.me_document, {"time_zone": "Europe/Paris"}, + )), + ("one preference", lambda: self.put_value(self.me_member, {"value": "Europe/Paris"})), + ("the removal of one preference", lambda: self.client.delete(self.me_member)), + ) + + def reads(self): + """Return each read these addresses serve, as what it reads and how it is sent.""" + return ( + ("the whole document", lambda: self.client.get(self.me_document)), + ("one preference", lambda: self.client.get(self.me_member)), + ) + + def test_a_learner_addressing_me_is_refused(self): + self.login(self.owner) + self.assert_all_refused(self.reads(), status.HTTP_403_FORBIDDEN, "authz") + + def test_an_administrator_addressing_me_is_told_no_account_holds_that_name(self): + self.login(self.staff) + self.assert_all_refused(self.reads(), status.HTTP_404_NOT_FOUND, "not-found") + + def test_a_learner_changing_the_preferences_at_me_is_refused(self): + self.login(self.owner) + self.assert_all_refused(self.changes(), status.HTTP_403_FORBIDDEN, "authz") + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_an_administrator_changing_them_is_refused_rather_than_told_the_name_is_free(self): + self.login(self.staff) + self.assert_all_refused(self.changes(), status.HTTP_403_FORBIDDEN, "authz") + assert self.stored() == {"time_zone": "Asia/Tokyo"} + assert self.stored(self.staff) == {} + + def test_the_account_holding_that_name_is_served(self): + held = UserFactory.create(username="me", email="me@example.com", password=PASSWORD) + self.prefer("time_zone", "Europe/Paris", user=held) + self.login(held) + assert self.client.get(self.me_document).json() == {"time_zone": "Europe/Paris"} + assert self.client.get(self.me_member).json() == { + "key": "time_zone", "value": "Europe/Paris", + } + assert self.put_value( + self.me_member, {"value": "Asia/Tokyo"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored(held) == {"time_zone": "Asia/Tokyo"} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + assert self.patch_document( + self.me_document, {"time_zone": "America/Lima"}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.stored(held) == {"time_zone": "America/Lima"} + assert self.client.delete(self.me_member).status_code == status.HTTP_204_NO_CONTENT + assert self.stored(held) == {} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + +@skip_unless_lms +class PreferenceQueryParameterTests(PreferenceApiTestCase): + """These addresses read no query parameter, and refuse the ones they are sent.""" + + def test_a_parameter_none_of_these_addresses_reads_is_refused(self): + self.login(self.owner) + for address in (self.document_url, self.member_url): + envelope = assert_error_envelope( + self.client.get(address, {"foo": "1"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"] == { + "foo": [f"No such query parameter. Send any of: {PREFERENCE_QUERY_PARAMS}."], + }, address + + def test_the_document_carries_no_pages(self): + """It is one account's preferences, read and changed whole.""" + self.login(self.owner) + for parameter in ("page", "page_size"): + envelope = assert_error_envelope( + self.client.get(self.document_url, {parameter: "1"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert list(envelope["errors"]) == [parameter], parameter + + def test_no_preference_address_shapes_what_it_answers_with(self): + """A caller wanting one name has the address of it.""" + self.login(self.owner) + for address in (self.document_url, self.member_url): + for parameter in ("view", "fields"): + assert_error_envelope( + self.client.get(address, {parameter: "minimal"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + + def test_a_change_carrying_a_parameter_it_does_not_read_is_refused(self): + self.login(self.owner) + assert_error_envelope( + self.patch_document( + f"{self.document_url}?foo=1", {"time_zone": "Europe/Paris"}, + ), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + +@skip_unless_lms +class PreferenceServiceFailureTests(PreferenceApiTestCase): + """Work that was asked for correctly and failed anyway is not the caller's fault.""" + + def test_a_change_that_could_not_be_carried_out_is_a_server_fault(self): + """The earlier address answered a failed save as a bad request.""" + self.login(self.owner) + failure = PreferenceUpdateError( + developer_message="Save failed for user preference 'time_zone': disk full", + user_message="Save failed.", + ) + with mock.patch( + "openedx.core.djangoapps.user_api.v2.views.update_user_preferences", side_effect=failure, + ): + response = self.patch_document(self.document_url, {"time_zone": "Europe/Paris"}) + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_500_INTERNAL_SERVER_ERROR, + expected_type_slug="internal", + ) + assert envelope["detail"] == "The request could not be carried out." + assert "disk full" not in json.dumps(envelope) + + def test_a_change_the_database_refused_is_a_server_fault(self): + """ + A failure the caller cannot correct stays a server fault, whatever the body carried. + + The failure here is one the service did not make anything of, which is + answered by the standard handling rather than by what this endpoint + makes of the failures the service reports. + """ + self.login(self.owner) + with mock.patch( + "openedx.core.djangoapps.user_api.v2.views.update_user_preferences", + side_effect=DatabaseError("the table is gone"), + ): + response = self.patch_document(self.document_url, {"time_zone": "Europe/Paris"}) + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_500_INTERNAL_SERVER_ERROR, + expected_type_slug="internal", + ) + assert "the table is gone" not in json.dumps(envelope) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def send_a_change_whose_removal_fails(self): + """Send a merge patch whose first name is stored and whose second cannot be removed.""" + with mock.patch( + "openedx.core.djangoapps.user_api.models.UserPreference.delete", + side_effect=DatabaseError("the row is locked"), + ): + return self.patch_document( + self.document_url, + {"account_privacy": "all_users", "time_zone": None, "pref-lang": "eo"}, + ) + + def test_a_change_that_fails_part_way_through_stores_none_of_it(self): + """A merge patch is one change: a name that cannot be changed leaves every other alone.""" + self.login(self.owner) + self.prefer("account_privacy", "private") + self.prefer("pref-lang", "en") + envelope = assert_error_envelope( + self.send_a_change_whose_removal_fails(), + expected_status=status.HTTP_500_INTERNAL_SERVER_ERROR, + expected_type_slug="internal", + ) + assert "the row is locked" not in json.dumps(envelope) + assert self.stored() == { + "account_privacy": "private", "pref-lang": "en", "time_zone": "Asia/Tokyo", + } + + def test_a_change_that_fails_stores_none_of_it_where_a_statement_stands_on_its_own(self): + """ + The change is carried out as one piece by the endpoint itself. + + The platform serves every request inside a transaction, which would + undo the names stored before the one that failed on its own, so the + promise is held here to a database configured without one. + """ + self.login(self.owner) + self.prefer("account_privacy", "private") + self.prefer("pref-lang", "en") + with requests_served_outside_a_transaction(): + response = self.send_a_change_whose_removal_fails() + assert_error_envelope( + response, + expected_status=status.HTTP_500_INTERNAL_SERVER_ERROR, + expected_type_slug="internal", + ) + assert self.stored() == { + "account_privacy": "private", "pref-lang": "en", "time_zone": "Asia/Tokyo", + } + + def test_a_read_that_could_not_be_served_is_a_server_fault(self): + self.login(self.owner) + with mock.patch( + "openedx.core.djangoapps.user_api.v2.views.get_user_preferences", + side_effect=UserAPIInternalError("the preference table is gone"), + ): + response = self.client.get(self.document_url) + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_500_INTERNAL_SERVER_ERROR, + expected_type_slug="internal", + ) + assert "preference table is gone" not in json.dumps(envelope) + + def test_a_removal_that_could_not_be_carried_out_is_a_server_fault(self): + """ + The failure is provoked at the row, so it arrives as the service reports it. + + What the service makes of a failed removal is the thing this endpoint + turns into an answer, so the failure is raised where the row is + removed rather than handed to the handler ready-made. + """ + self.login(self.owner) + with mock.patch( + "openedx.core.djangoapps.user_api.models.UserPreference.delete", + side_effect=DatabaseError("the row is locked"), + ): + response = self.client.delete(self.member_url) + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_500_INTERNAL_SERVER_ERROR, + expected_type_slug="internal", + ) + assert envelope["detail"] == "The request could not be carried out." + assert "the row is locked" not in json.dumps(envelope) + assert "Delete failed" not in json.dumps(envelope) + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + +class PreferenceReadWriteCaptureCase(PreferenceApiTestCase): + """ + What a read of these addresses does, measured statement by statement. + + Three channels are captured, because a read that changes stored data need + not do so itself: the statements it ran, the callbacks it deferred to the + end of its transaction, and what it announced. An announcement is captured + because the platform's own listeners act on one, and a change announced + from a read reaches those listeners whether or not the read wrote + anything: the field-change signal is captured wherever it is sent from, + and the analytics channel it is sent alongside with it. + """ + + def setUp(self): + super().setUp() + self.deferred = [] + self.announced = [] + self.tracked = [] + + def captured_writes(self, method, address, expect=status.HTTP_200_OK, **headers): + """Return the statements a request made that would change stored data.""" + with CaptureQueriesContext(connection) as captured, \ + mock.patch.object(USER_FIELDS_CHANGED, "send") as announced, \ + mock.patch.object(model_utils.tracker, "emit") as tracked, \ + self.captureOnCommitCallbacks(execute=True) as callbacks: + answer = getattr(self.client, method)(address, **headers) + assert answer.status_code == expect, answer.content + self.deferred = callbacks + self.announced = [call.kwargs for call in announced.call_args_list] + self.tracked = [self.emitted(call) for call in tracked.call_args_list] + return write_queries(captured.captured_queries) + + @staticmethod + def emitted(call): + """Return the name and the payload of one analytics event, however it was passed.""" + passed = (*call.args, None, None) + return call.kwargs.get("name", passed[0]), call.kwargs.get("data", passed[1]) + + def targets(self, statements): + """Return the table each statement writes to.""" + return [statement.split('"')[1] for statement in statements] + + def assert_only_the_request_itself_was_recorded(self, address): + """Assert the one event the request emitted was the platform's record of the request.""" + assert self.tracked == [(address, REQUEST_RECORD_DATA)] + + def assert_nothing_was_changed_or_announced(self, statements, address): + """Assert the request wrote nothing, deferred nothing and announced no change.""" + assert (statements, self.deferred, self.announced) == ([], [], []) + self.assert_only_the_request_itself_was_recorded(address) + + def assert_the_language_change_was_announced(self, address, caller=None): + """Assert the one change announced was the language row the middleware stored.""" + caller = caller or self.owner + assert self.announced == [{ + "sender": None, + "user": caller, + "table": PREFERENCE_TABLE, + "changed_fields": {LANGUAGE_PREFERENCE: (None, "eo")}, + }] + assert self.tracked == [ + (address, REQUEST_RECORD_DATA), + ( + USER_SETTINGS_CHANGED_EVENT_NAME, + { + "old": None, + "new": "eo", + "truncated": [], + "setting": LANGUAGE_PREFERENCE, + "user_id": caller.id, + "table": PREFERENCE_TABLE, + }, + ), + ] + + +@skip_unless_lms +class PreferenceReadsWithoutTheLanguageCookieTests(PreferenceReadWriteCaptureCase): + """A read of these addresses changes nothing of what they serve, and announces nothing.""" + + def assert_the_read_changed_nothing(self, method, address, expect=status.HTTP_200_OK, **headers): + """Assert a read of this address wrote nothing, deferred nothing and announced nothing.""" + self.assert_nothing_was_changed_or_announced( + self.captured_writes(method, address, expect=expect, **headers), address, + ) + + def test_reading_the_document_writes_nothing(self): + self.login(self.owner) + self.assert_the_read_changed_nothing("get", self.document_url) + + def test_reading_one_preference_writes_nothing(self): + self.login(self.owner) + self.assert_the_read_changed_nothing("get", self.member_url) + + def test_a_staff_caller_reading_another_account_writes_nothing(self): + self.login(self.staff) + for address in (self.document_url, self.member_url): + self.assert_the_read_changed_nothing("get", address) + + def test_a_read_presented_with_a_token_writes_nothing(self): + for address in (self.document_url, self.member_url): + self.assert_the_read_changed_nothing("get", address, **self.token_headers(self.owner)) + + def test_heading_either_address_writes_nothing(self): + self.login(self.owner) + for address in (self.document_url, self.member_url): + self.assert_the_read_changed_nothing("head", address) + + def test_the_earlier_addresses_write_nothing_either(self): + self.login(self.owner) + for address in ( + earlier_document_address(self.owner.username), + earlier_member_address(self.owner.username, "time_zone"), + ): + self.assert_the_read_changed_nothing("get", address) + + def test_reading_a_name_the_account_stores_no_value_under_writes_nothing(self): + """Being told nothing is stored under a name is no reason to store anything.""" + self.login(self.owner) + self.assert_the_read_changed_nothing( + "get", + self.member_address(self.owner.username, "account_privacy"), + expect=status.HTTP_404_NOT_FOUND, + ) + + def test_a_staff_caller_told_another_account_stores_no_such_name_writes_nothing(self): + self.login(self.staff) + self.assert_the_read_changed_nothing( + "get", + self.member_address(self.other.username, "account_privacy"), + expect=status.HTTP_404_NOT_FOUND, + ) + + def test_a_read_of_an_account_nobody_holds_writes_nothing(self): + """The other branch that answers nothing: no such account, rather than no such name.""" + self.login(self.staff) + for address in ( + self.document_address("pref-nobody"), + self.member_address("pref-nobody", "time_zone"), + ): + self.assert_the_read_changed_nothing( + "get", address, expect=status.HTTP_404_NOT_FOUND, + ) + + def test_a_read_leaves_the_stored_preferences_as_they_were(self): + self.login(self.owner) + before = self.stored() + self.client.get(self.document_url) + self.client.get(self.member_url) + assert self.stored() == before == {"time_zone": "Asia/Tokyo"} + + +@skip_unless_lms +class PreferenceReadsWithTheLanguageCookieTests(PreferenceReadWriteCaptureCase): + """ + A request carrying the language cookie stores the language it carries. + + The platform keeps the language a user picked in a cookie and syncs it into + that user's preferences from middleware, which runs before any view and on + every method, so it is reached by a read of these addresses as it is by a + read of any other. The capture below pins what it writes: the one row, by + the table it is stored in, and nothing else. The same request to the + address this version supersedes writes the same row, which is what shows + the write belongs to the platform rather than to either version. + """ + + def setUp(self): + super().setUp() + self.client.cookies[LANGUAGE_COOKIE] = "eo" + + def language_rows(self): + """Return the stored language of every account, by account.""" + return { + preference.user.username: preference.value + for preference in UserPreference.objects.filter(key=LANGUAGE_PREFERENCE) + } + + def assert_only_the_language_was_stored( + self, method, address, expect=status.HTTP_200_OK, **headers, + ): + """Assert the one write made, and the one change announced, was the caller's language.""" + statements = self.captured_writes(method, address, expect=expect, **headers) + assert (self.targets(statements), self.deferred) == ([PREFERENCE_TABLE], []), statements + assert self.language_rows() == {self.owner.username: "eo"} + self.assert_the_language_change_was_announced(address) + + def test_reading_the_document_stores_the_language_the_cookie_carries(self): + self.login(self.owner) + assert self.language_rows() == {} + self.assert_only_the_language_was_stored("get", self.document_url) + + def test_reading_one_preference_stores_it_too(self): + self.login(self.owner) + self.assert_only_the_language_was_stored("get", self.member_url) + + def test_the_address_this_version_supersedes_stores_the_same_row(self): + self.login(self.owner) + self.assert_only_the_language_was_stored( + "get", earlier_document_address(self.owner.username), + ) + + def test_the_earlier_member_address_stores_the_same_row(self): + self.login(self.owner) + self.assert_only_the_language_was_stored( + "get", earlier_member_address(self.owner.username, "time_zone"), + ) + + def test_heading_the_document_stores_it_as_well(self): + """A write one frame below the handler is reached by every method, this one included.""" + self.login(self.owner) + self.assert_only_the_language_was_stored("head", self.document_url) + + def test_a_read_presented_with_a_token_stores_it_as_well(self): + self.assert_only_the_language_was_stored( + "get", self.document_url, **self.token_headers(self.owner), + ) + + def test_reading_a_name_the_account_stores_no_value_under_stores_only_the_language(self): + """The refusal writes nothing of its own: the row below it is still the only write.""" + self.login(self.owner) + self.assert_only_the_language_was_stored( + "get", + self.member_address(self.owner.username, "account_privacy"), + expect=status.HTTP_404_NOT_FOUND, + ) + + def test_the_row_is_written_once_and_not_once_a_request(self): + """It is the language being synced, not the read recording anything.""" + self.login(self.owner) + self.assert_only_the_language_was_stored("get", self.document_url) + for address in (self.document_url, self.member_url): + self.assert_nothing_was_changed_or_announced( + self.captured_writes("get", address), address, + ) + + def test_the_language_a_staff_caller_carries_is_stored_against_their_own_account(self): + """The row is the caller's, whichever account the address names.""" + self.login(self.staff) + statements = self.captured_writes("get", self.document_url) + assert self.targets(statements) == [PREFERENCE_TABLE], statements + assert self.language_rows() == {self.staff.username: "eo"} + self.assert_the_language_change_was_announced(self.document_url, self.staff) + + def test_removing_the_language_removes_the_row_the_middleware_had_just_stored(self): + """ + The row below the handler is written first and removed by the handler itself. + + A client that removes the language it is carrying is told the account + stores no value under that name, and none does: the write one frame + below is not a second row and does not outlast the request that made + it. + """ + self.login(self.owner) + statements = self.captured_writes( + "delete", + self.member_address(self.owner.username, LANGUAGE_PREFERENCE), + expect=status.HTTP_204_NO_CONTENT, + ) + assert self.targets(statements) == [PREFERENCE_TABLE, PREFERENCE_TABLE], statements + assert self.language_rows() == {} + assert self.stored() == {"time_zone": "Asia/Tokyo"} + + def test_the_document_removing_the_language_leaves_it_removed_as_well(self): + self.login(self.owner) + assert self.patch_document( + self.document_url, {LANGUAGE_PREFERENCE: None}, + ).status_code == status.HTTP_204_NO_CONTENT + assert self.language_rows() == {} + + def test_removing_another_name_stores_the_language_the_cookie_carries(self): + """The write one frame below is made whatever the method, a removal included.""" + self.login(self.owner) + statements = self.captured_writes( + "delete", self.member_url, expect=status.HTTP_204_NO_CONTENT, + ) + assert self.targets(statements) == [PREFERENCE_TABLE, PREFERENCE_TABLE], statements + assert self.language_rows() == {self.owner.username: "eo"} + assert self.stored() == {LANGUAGE_PREFERENCE: "eo"} + + def test_a_name_stored_only_by_the_middleware_is_removed_rather_than_reported_absent(self): + """ + The row is there to remove by the time the handler looks, so the removal is answered. + + Without the cookie the same request is answered with the refusal a + name no value is stored under gets, which is what the two assertions + below stand on either side of. + """ + self.login(self.owner) + address = self.member_address(self.owner.username, LANGUAGE_PREFERENCE) + assert self.language_rows() == {} + assert self.client.delete(address).status_code == status.HTTP_204_NO_CONTENT + del self.client.cookies[LANGUAGE_COOKIE] + assert self.client.delete(address).status_code == status.HTTP_404_NOT_FOUND + + def test_the_document_answered_carries_the_language_that_was_stored(self): + self.login(self.owner) + assert self.client.get(self.document_url).json() == { + LANGUAGE_PREFERENCE: "eo", "time_zone": "Asia/Tokyo", + } diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_query_counts.py b/openedx/core/djangoapps/user_api/v2/tests/test_query_counts.py new file mode 100644 index 000000000000..0f9afe073fd0 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_query_counts.py @@ -0,0 +1,348 @@ +"""Query-count guards for the version 2 User API list and member addresses.""" + +import pytest +from django.db import connection +from django.test.utils import CaptureQueriesContext, override_settings +from django.urls import reverse + +from common.djangoapps.student.tests.factories import AdminFactory, UserFactory +from openedx.core.djangoapps.django_comment_common.models import Role +from openedx.core.djangoapps.user_api.models import UserOrgTag +from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory +from openedx.core.djangolib.testing.utils import skip_unless_lms + +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 +#: One more than the list costs without the preset: the owners whose entry +#: a page may carry are resolved for the whole page in a single query, +#: whatever the number of rows. +PREFERENCE_LIST_FULL_VIEW_QUERIES = 17 +PREFERENCE_DETAIL_QUERIES = 14 +#: What the same address costs with the owner's entry asked for: the entry's +#: own preferences, and the one query that settles which owners' entries may +#: be carried. +PREFERENCE_DETAIL_FULL_VIEW_QUERIES = 16 +TIME_ZONE_LIST_QUERIES = 13 + +#: Address of the stored-preference list this version replaces, and the key it +#: 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", [5], indirect=True) +@skip_unless_lms +def test_the_member_address_pays_a_flat_cost_for_the_entry_it_carries( + client, directory, django_assert_num_queries, +): # pylint: disable=unused-argument + """ + Asking for one preference whole costs the same whatever else is stored. + + The entry the row carries brings the owner's own preferences with it and + the owners whose entry may be carried are resolved once, so the cost is + the member cost plus a fixed amount and does not move with the number of + rows the collection holds. + """ + address = reverse( + "user_v2:user_preference_detail", kwargs={"username": "counted0", "key": "pref-lang"}, + ) + with django_assert_num_queries(PREFERENCE_DETAIL_FULL_VIEW_QUERIES): + response = client.get(address, {"view": "full"}) + body = response.json() + assert body["value"] == "en" + assert body["user"]["username"] == "counted0" + assert body["user"]["preferences"] == {"pref-lang": "en", "time_zone": "UTC"} + + +@pytest.mark.django_db +@pytest.mark.parametrize("directory", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_the_earlier_preference_list_pays_per_row_where_this_one_does_not( + client, directory, django_assert_num_queries, +): + """ + Both lists are measured over the same rows, so the saving cannot drift apart. + + The earlier row embeds its owner without a join and re-reads that owner's + own preferences, so a page of it costs five queries per user; this one + reads the same page at a count that does not move with it. + """ + with django_assert_num_queries(PREFERENCE_LIST_QUERIES): + current = client.get(reverse("user_v2:user_preference_list"), {"page_size": 100}) + assert len(current.json()["results"]) == 2 * len(directory) + + 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) + + +#: Address of one account's preferences in the version this one supersedes. +#: Both versions call the same service over the same rows, so what each of the +#: addresses below costs is measured at the superseded address in the same run +#: and this version is held to that measurement rather than to a number +#: written down here. A change in what every request of the site costs then +#: moves both measurements together instead of turning these red. +EARLIER_PREFERENCE_DOCUMENT = "/api/user/v1/preferences/{username}" + +#: How many more preferences are stored before a cost is measured again, so +#: that a query paid for each preference stored shows up as a rise. +MORE_PREFERENCES = 5 + + +def cost_of(request): + """Return what one request cost in queries, and what it answered with.""" + with CaptureQueriesContext(connection) as queries: + answer = request() + return len(queries), answer + + +@pytest.fixture(name="account_with_preferences") +@skip_unless_lms +def account_with_preferences_fixture(request, client, django_user_model): # pylint: disable=unused-argument + """Sign a learner in and give their own account ``request.param`` preferences besides a time zone.""" + owner = UserFactory.create(username="count-owner", password=PASSWORD) + UserPreferenceFactory.create(user=owner, key="time_zone", value="UTC") + for index in range(request.param): + UserPreferenceFactory.create(user=owner, key=f"counted_{index:03d}", value="stored") + assert client.login(username=owner.username, password=PASSWORD) + # The first authenticated request of a session populates caches the rest + # of the session reuses; the counts below are the steady-state ones. + client.get(reverse("user_v2:account_preference_list", kwargs={"username": owner.username})) + return owner, request.param + + +@pytest.mark.django_db +@pytest.mark.parametrize("account_with_preferences", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_the_preference_document_costs_the_same_at_either_address( + client, account_with_preferences, +): + """Neither version pays per preference: only the body grows with them.""" + owner, stored = account_with_preferences + address = reverse("user_v2:account_preference_list", kwargs={"username": owner.username}) + earlier_cost, earlier = cost_of( + lambda: client.get(EARLIER_PREFERENCE_DOCUMENT.format(username=owner.username)), + ) + current_cost, current = cost_of(lambda: client.get(address)) + assert current_cost == earlier_cost + assert earlier.json() == current.json() + assert set(current.json()) == {"time_zone"} | { + f"counted_{index:03d}" for index in range(stored) + } + + for index in range(stored, stored + MORE_PREFERENCES): + UserPreferenceFactory.create(user=owner, key=f"counted_{index:03d}", value="stored") + with_more_cost, with_more = cost_of(lambda: client.get(address)) + assert with_more_cost == current_cost + assert len(with_more.json()) == len(current.json()) + MORE_PREFERENCES + + +@pytest.mark.django_db +@pytest.mark.parametrize("account_with_preferences", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_one_preference_costs_the_same_at_either_address(client, account_with_preferences): + """Reading one preference costs what it costs at the superseded address, stored rows aside.""" + owner, stored = account_with_preferences + address = reverse( + "user_v2:account_preference_detail", + kwargs={"username": owner.username, "key": "time_zone"}, + ) + earlier_cost, earlier = cost_of( + lambda: client.get( + f"{EARLIER_PREFERENCE_DOCUMENT.format(username=owner.username)}/time_zone", + ), + ) + current_cost, current = cost_of(lambda: client.get(address)) + assert current_cost == earlier_cost + assert earlier.json() == "UTC" + assert current.json() == {"key": "time_zone", "value": "UTC"} + + for index in range(stored, stored + MORE_PREFERENCES): + UserPreferenceFactory.create(user=owner, key=f"counted_{index:03d}", value="stored") + with_more_cost, with_more = cost_of(lambda: client.get(address)) + assert with_more_cost == current_cost + assert with_more.json() == {"key": "time_zone", "value": "UTC"} + + +@pytest.mark.django_db +@pytest.mark.parametrize("account_with_preferences", [1], indirect=True) +@skip_unless_lms +def test_recording_an_email_choice_costs_what_the_earlier_address_costs( + client, account_with_preferences, +): + """ + Each address is measured from the same starting state as the other. + + Recording the first choice creates the row it is kept in and recording a + later one updates it, so a count taken after the other address has already + recorded the choice would be the cheaper path measured against the dearer + one. Both are measured here, and the rise between them is what makes the + starting state matter. + """ + owner, _stored = account_with_preferences + choices = UserOrgTag.objects.filter(user=owner, key="email-optin") + earlier_body = {"course_id": COURSE_KEY, "email_opt_in": "True"} + current_body = {"course_key": COURSE_KEY, "email_opt_in": True} + + def record_at_the_earlier_address(): + """Record the choice the way the pages calling the earlier address do.""" + return client.post("/api/user/v1/preferences/email_opt_in/", earlier_body) + + def record(): + """Record the choice at this version's address.""" + return client.post( + reverse("user_v2:email_opt_in_list"), current_body, content_type="application/json", + ) + + costs = {} + for address, record_choice, expected_status in ( + ("the earlier address", record_at_the_earlier_address, 200), + ("this version", record, 204), + ): + choices.delete() + opening_cost, opened = cost_of(record_choice) + assert opened.status_code == expected_status, address + changing_cost, changed = cost_of(record_choice) + assert changed.status_code == expected_status, address + assert choices.count() == 1, address + costs[address] = (opening_cost, changing_cost) + + assert costs["this version"] == costs["the earlier address"] + opening_cost, changing_cost = costs["this version"] + assert opening_cost > changing_cost diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_parity.py b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_parity.py new file mode 100644 index 000000000000..cfd4973c2698 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_parity.py @@ -0,0 +1,698 @@ +"""Behaviour parity between the version 1 and version 2 retirement pipeline addresses.""" + +import contextlib +import datetime +import json +from unittest import mock +from zoneinfo import ZoneInfo + +import pytest +from django.core.cache import cache +from django.test import SimpleTestCase, override_settings +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase +from social_django.models import UserSocialAuth +from wiki.models import ArticleRevision +from wiki.models.pluginbase import RevisionPluginRevision + +from common.djangoapps.student.models import ( + AccountRecovery, + CourseEnrollmentAllowed, + ManualEnrollmentAudit, + PendingEmailChange, + PendingNameChange, + PendingSecondaryEmailChange, + SocialLink, + UserProfile, +) +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.api_admin.models import ApiAccessRequest +from openedx.core.djangoapps.course_groups.models import UnregisteredLearnerCohortAssignments +from openedx.core.djangoapps.credit.models import CreditRequest, CreditRequirementStatus +from openedx.core.djangoapps.user_api.accounts import views as legacy_views +from openedx.core.djangoapps.user_api.accounts.serializers import UserRetirementStatusSerializer +from openedx.core.djangoapps.user_api.accounts.signals import ( + USER_RETIRE_LMS_CRITICAL, + USER_RETIRE_LMS_MISC, + USER_RETIRE_MAILINGS, +) +from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import ( # pylint: disable=unused-import + create_retirement_status, + setup_retirement_states, # noqa: F401 +) +from openedx.core.djangoapps.user_api.accounts.views import ( + USER_PROFILE_PII, + AccountRetirementView, + LMSAccountRetirementView, +) +from openedx.core.djangoapps.user_api.models import RetirementState, UserOrgTag, UserRetirementStatus +from openedx.core.djangoapps.user_api.v2 import view_services +from openedx.core.djangoapps.user_api.v2.views import ( + RetirementCleanupViewSet, + RetirementPartnerReportCleanupViewSet, + RetirementPartnerReportViewSet, + RetirementViewSet, +) +from openedx.core.djangolib.testing.utils import skip_unless_lms + +PASSWORD = "Password1234" +WORKER_USERNAME = "parity-retirement-worker" + +#: The step of the pipeline at which the erasures are run. +WORKING_STATE = "RETIRING_LMS" + +#: The step a record is moved on to, so that the step it stands at and the +#: step it came from are different steps and a record naming one of them +#: twice is not read as naming both. +MOVED_TO_STATE = "LMS_COMPLETE" + +#: Each step of the erasure of the account's own data, named by what it clears +#: and by where both versions reach it. ``None`` as the holder means each +#: version calls the step through its own module, so both are recorded. +CORE_STEPS = ( + ("profile", AccountRetirementView, "clear_pii_from_userprofile"), + ("profile_images", AccountRetirementView, "delete_users_profile_images"), + ("country_cache", AccountRetirementView, "delete_users_country_cache"), + ("certificate_names", AccountRetirementView, "clear_pii_from_certificate_records"), + ("entitlement_notes", AccountRetirementView, "retire_entitlement_support_detail"), + ("pending_email_change", PendingEmailChange, "delete_by_user_value"), + ("organization_tags", UserOrgTag, "delete_by_user_value"), + ("pending_secondary_email", PendingSecondaryEmailChange, "redact_and_delete_pending_secondary_email"), + ("recovery_email", AccountRecovery, "retire_recovery_email"), + ("enrollment_invitations", CourseEnrollmentAllowed, "delete_by_user_value"), + ("cohort_assignments", UnregisteredLearnerCohortAssignments, "delete_by_user_value"), +) + +#: Each step of the erasure of what the features around the account hold. +MISC_STEPS = ( + ("wiki_plugin_revisions", RevisionPluginRevision, "retire_user"), + ("wiki_article_revisions", ArticleRevision, "retire_user"), + ("pending_name_change", PendingNameChange, "delete_by_user_value"), + ("manual_enrolments", ManualEnrollmentAudit, "retire_manual_enrollments"), + ("credit_requests", CreditRequest, "retire_user"), + ("api_access_requests", ApiAccessRequest, "retire_user"), + ("credit_requirements", CreditRequirementStatus, "retire_user"), +) + +#: The step each version reaches through its own module rather than through a +#: shared object, so it is patched in both. +HISTORICAL_SOCIAL_AUTH_STEP = ("historical_social_auth", "redact_and_delete_historical_social_auth") + +#: The declared differences between a version 1 retirement record and a +#: version 2 one. Each entry is a (property, reason) pair, and the comparison +#: must find all of them and nothing else. +RECORD_DIFFERENCES = ( + ("current_state", "the step is the name of the step rather than an object carrying its order and its id"), + ("last_state", "the step is the name of the step rather than an object carrying its order and its id"), +) + +#: The declared differences between the filters of the two version 1 queue +#: reads and the version 2 list that answers for both. Each entry is a +#: (parameter, reason) pair, and the tests below measure every one of them on +#: both versions, so a parameter that silently selects other rows than the one +#: it is read in place of is either here or failing. +QUEUE_FILTER_DIFFERENCES = ( + ( + "created_before", + "the bound is exclusive, where the end_date it is read in place of took in the whole of " + "the day named; the same rows are asked for by naming the day after", + ), +) + +#: Each view of this version with the view whose operations it takes over, for +#: the media types a body may be sent in. The disposals are part of the +#: earlier version's two pipeline views and are compared against those. +MEDIA_TYPE_PAIRS = ( + (RetirementViewSet, legacy_views.AccountRetirementStatusView), + (RetirementCleanupViewSet, legacy_views.AccountRetirementStatusView), + (RetirementPartnerReportViewSet, legacy_views.AccountRetirementPartnerReportView), + (RetirementPartnerReportCleanupViewSet, legacy_views.AccountRetirementPartnerReportView), +) + + +@pytest.mark.usefixtures("setup_retirement_states") +@override_settings(RETIREMENT_SERVICE_WORKER_USERNAME=WORKER_USERNAME) +class RetirementParityTestCase(APITestCase): + """The caller both versions of the pipeline's addresses admit.""" + + def setUp(self): + super().setUp() + self.worker = UserFactory.create(username=WORKER_USERNAME, password=PASSWORD) + assert self.client.login(username=WORKER_USERNAME, password=PASSWORD) + + def retirement_for(self, user): + """Return a retirement of this account, standing where the erasures run.""" + return create_retirement_status(user, state=RetirementState.objects.get(state_name=WORKING_STATE)) + + +@skip_unless_lms +class ErasureStepParityTests(RetirementParityTestCase): + """Both versions clear the same things in the same order.""" + + def steps_taken(self, steps, call, own_module_step=None): + """Return the steps a call takes, in the order it takes them.""" + taken = [] + + def record(label): + def step(*args, **kwargs): # pylint: disable=unused-argument + taken.append(label) + return step + + with contextlib.ExitStack() as patches: + for label, holder, attribute in steps: + patches.enter_context( + mock.patch.object(holder, attribute, side_effect=record(label)), + ) + if own_module_step is not None: + label, attribute = own_module_step + for module in (legacy_views, view_services): + patches.enter_context( + mock.patch.object(module, attribute, side_effect=record(label)), + ) + response = call() + assert response.status_code == status.HTTP_204_NO_CONTENT, response.content + return taken + + def legacy_erasure(self, address_name, user): + """Run a version 1 erasure of this account.""" + return lambda: self.client.post( + reverse(address_name), {"username": user.username}, format="json", + ) + + def conforming_erasure(self, scope, user): + """Run a version 2 erasure of this account.""" + return lambda: self.client.post( + reverse("user_v2:retirement_erasure", kwargs={"username": user.username}), + {"scope": scope}, + format="json", + ) + + def test_the_core_scope_takes_the_same_steps_in_the_same_order(self): + legacy_user = UserFactory.create() + self.retirement_for(legacy_user) + conforming_user = UserFactory.create() + self.retirement_for(conforming_user) + + legacy = self.steps_taken(CORE_STEPS, self.legacy_erasure("accounts_retire", legacy_user)) + conforming = self.steps_taken(CORE_STEPS, self.conforming_erasure("lms_core", conforming_user)) + + assert legacy == [label for label, _holder, _attribute in CORE_STEPS] + assert conforming == legacy + + def test_the_miscellaneous_scope_takes_the_same_steps_in_the_same_order(self): + legacy_user = UserFactory.create() + self.retirement_for(legacy_user) + conforming_user = UserFactory.create() + self.retirement_for(conforming_user) + + legacy = self.steps_taken( + MISC_STEPS, + self.legacy_erasure("accounts_retire_misc", legacy_user), + own_module_step=HISTORICAL_SOCIAL_AUTH_STEP, + ) + conforming = self.steps_taken( + MISC_STEPS, + self.conforming_erasure("lms_misc", conforming_user), + own_module_step=HISTORICAL_SOCIAL_AUTH_STEP, + ) + + assert legacy == [ + *[label for label, _holder, _attribute in MISC_STEPS], + HISTORICAL_SOCIAL_AUTH_STEP[0], + ] + assert conforming == legacy + + def test_neither_scope_takes_a_step_belonging_to_the_other(self): + core_user = UserFactory.create() + self.retirement_for(core_user) + misc_user = UserFactory.create() + self.retirement_for(misc_user) + + assert not self.steps_taken(MISC_STEPS, self.conforming_erasure("lms_core", core_user)) + assert not self.steps_taken(CORE_STEPS, self.conforming_erasure("lms_misc", misc_user)) + + +@skip_unless_lms +class ErasureStateParityTests(RetirementParityTestCase): + """Both versions leave the stored data in the same state.""" + + def furnished_account(self, name): + """Return an account carrying one of everything the erasures clear.""" + user = UserFactory.create(username=name, email=f"{name}@example.com", first_name="A", last_name="B") + user.profile.name = "Learner Name" + user.profile.city = "Somewhere" + user.profile.bio = "A biography." + user.profile.year_of_birth = 1990 + user.profile.save() + SocialLink.objects.create( + user_profile=user.profile, platform="facebook", social_link="https://facebook.com/x", + ) + PendingEmailChange.objects.create( + user=user, new_email=f"new-{name}@example.com", activation_key=f"email-{name}", + ) + UserOrgTag.objects.create(user=user, key="foo", value="bar", org="edX") + PendingSecondaryEmailChange.objects.create( + user=user, new_secondary_email=f"second-{name}@example.com", activation_key=f"second-{name}", + ) + AccountRecovery.objects.create( + user=user, secondary_email=f"recovery-{name}@example.com", is_active=True, + ) + PendingNameChange.objects.create(user=user, new_name="Another Name", rationale="because") + UserSocialAuth.objects.create(user=user, provider="google-oauth2", uid=f"{name}@example.com") + cache.set(UserProfile.country_cache_key_name(user.id), "Timor-leste") + retirement = self.retirement_for(user) + CourseEnrollmentAllowed.objects.create( + email=retirement.original_email, course_id="course-v1:edX+DemoX+Demo", + ) + return user, retirement + + def stored_state(self, user, retirement): + """Return what is stored about this account, described without naming the account.""" + user.refresh_from_db() + profile = UserProfile.objects.get(user=user) + return { + "username_is_the_hashed_one": user.username == retirement.retired_username, + "email_is_the_hashed_one": user.email == retirement.retired_email, + "first_name": user.first_name, + "last_name": user.last_name, + "is_active": user.is_active, + "profile": {field: getattr(profile, field) for field in USER_PROFILE_PII}, + "social_links": profile.social_links.count(), + "pending_email_changes": PendingEmailChange.objects.filter(user=user).count(), + "organization_tags": UserOrgTag.objects.filter(user=user).count(), + "pending_secondary_email_changes": PendingSecondaryEmailChange.objects.filter(user=user).count(), + "recovery_emails": AccountRecovery.objects.filter(user=user).count(), + "enrollment_invitations": CourseEnrollmentAllowed.objects.filter( + email=retirement.original_email, + ).count(), + "cohort_assignments": UnregisteredLearnerCohortAssignments.objects.filter( + email=retirement.original_email, + ).count(), + "country_cache": cache.get(UserProfile.country_cache_key_name(user.id)), + "pending_name_changes": PendingNameChange.objects.filter(user=user).count(), + "social_auths": UserSocialAuth.objects.filter(user=user).count(), + "historical_social_auths": UserSocialAuth.history.model.objects.filter(user_id=user.id).count(), + } + + def test_the_core_scope_leaves_the_same_state_behind(self): + legacy_user, legacy_retirement = self.furnished_account("parity-core-legacy") + conforming_user, conforming_retirement = self.furnished_account("parity-core-conforming") + + assert self.client.post( + reverse("accounts_retire"), {"username": legacy_user.username}, format="json", + ).status_code == status.HTTP_204_NO_CONTENT + assert self.client.post( + reverse("user_v2:retirement_erasure", kwargs={"username": conforming_user.username}), + {"scope": "lms_core"}, + format="json", + ).status_code == status.HTTP_204_NO_CONTENT + + legacy_state = self.stored_state(legacy_user, legacy_retirement) + assert legacy_state == self.stored_state(conforming_user, conforming_retirement) + # The comparison is only worth anything if the erasure really ran. + assert legacy_state["username_is_the_hashed_one"] is True + assert legacy_state["is_active"] is False + assert legacy_state["profile"]["name"] == "" + assert legacy_state["social_links"] == 0 + assert legacy_state["pending_email_changes"] == 0 + assert legacy_state["organization_tags"] == 0 + assert legacy_state["recovery_emails"] == 0 + assert legacy_state["enrollment_invitations"] == 0 + assert legacy_state["country_cache"] is None + + def test_the_miscellaneous_scope_leaves_the_same_state_behind(self): + legacy_user, legacy_retirement = self.furnished_account("parity-misc-legacy") + conforming_user, conforming_retirement = self.furnished_account("parity-misc-conforming") + + assert self.client.post( + reverse("accounts_retire_misc"), {"username": legacy_user.username}, format="json", + ).status_code == status.HTTP_204_NO_CONTENT + assert self.client.post( + reverse("user_v2:retirement_erasure", kwargs={"username": conforming_user.username}), + {"scope": "lms_misc"}, + format="json", + ).status_code == status.HTTP_204_NO_CONTENT + + legacy_state = self.stored_state(legacy_user, legacy_retirement) + assert legacy_state == self.stored_state(conforming_user, conforming_retirement) + assert legacy_state["pending_name_changes"] == 0 + assert legacy_state["historical_social_auths"] == 0 + # This scope clears the history of a sign-in method, not the method itself. + assert legacy_state["social_auths"] == 1 + # The account itself is left alone by this scope. + assert legacy_state["username_is_the_hashed_one"] is False + assert legacy_state["is_active"] is True + + +@skip_unless_lms +class ErasureSignalParityTests(RetirementParityTestCase): + """Both versions announce the same things to the same listeners.""" + + def announcements(self, signal, call, user, retirement): + """Return what a call announced through this signal, described without naming the account.""" + heard = [] + + def listen(sender, **kwargs): + kwargs.pop("signal", None) + described = {"sender": sender} + for key, value in sorted(kwargs.items()): + if getattr(value, "pk", None) == user.pk: + described[key] = "" + elif value == retirement.retired_username: + described[key] = "" + elif value == retirement.retired_email: + described[key] = "" + elif value == retirement.original_email: + described[key] = "" + else: + described[key] = value + heard.append(described) + + signal.connect(listen) + try: + response = call() + finally: + signal.disconnect(listen) + assert response.status_code == status.HTTP_204_NO_CONTENT, response.content + return heard + + def test_the_core_scope_announces_the_same_thing(self): + legacy_user = UserFactory.create() + legacy_retirement = self.retirement_for(legacy_user) + conforming_user = UserFactory.create() + conforming_retirement = self.retirement_for(conforming_user) + + legacy = self.announcements( + USER_RETIRE_LMS_CRITICAL, + lambda: self.client.post( + reverse("accounts_retire"), {"username": legacy_user.username}, format="json", + ), + legacy_user, + legacy_retirement, + ) + conforming = self.announcements( + USER_RETIRE_LMS_CRITICAL, + lambda: self.client.post( + reverse("user_v2:retirement_erasure", kwargs={"username": conforming_user.username}), + {"scope": "lms_core"}, + format="json", + ), + conforming_user, + conforming_retirement, + ) + + assert legacy == [{ + "sender": AccountRetirementView, + "retired_email": "", + "retired_username": "", + "user": "", + }] + assert conforming == legacy + + def test_the_miscellaneous_scope_announces_the_same_two_things(self): + legacy_user = UserFactory.create() + legacy_retirement = self.retirement_for(legacy_user) + conforming_user = UserFactory.create() + conforming_retirement = self.retirement_for(conforming_user) + + for signal, expected in ( + (USER_RETIRE_LMS_MISC, [{"sender": LMSAccountRetirementView, "user": ""}]), + ( + USER_RETIRE_MAILINGS, + [{ + "sender": LMSAccountRetirementView, + "email": "", + "new_email": "", + "user": "", + }], + ), + ): + with self.subTest(signal=signal): + legacy = self.announcements( + signal, + lambda: self.client.post( + reverse("accounts_retire_misc"), + {"username": legacy_user.username}, + format="json", + ), + legacy_user, + legacy_retirement, + ) + conforming = self.announcements( + signal, + lambda: self.client.post( + reverse( + "user_v2:retirement_erasure", + kwargs={"username": conforming_user.username}, + ), + {"scope": "lms_misc"}, + format="json", + ), + conforming_user, + conforming_retirement, + ) + assert legacy == expected + assert conforming == legacy + + +@skip_unless_lms +class StateMachineParityTests(RetirementParityTestCase): + """The moves the pipeline allows and refuses are the same on both versions.""" + + #: Each case is the step a retirement stands at, the step asked for, + #: whether the move is forced, and whether each version carries it out. + MOVES = ( + ("LOCKING_ACCOUNT", "PENDING", False, False), + ("LOCKING_ACCOUNT", "PENDING", True, True), + ("COMPLETE", "PENDING", False, False), + ("COMPLETE", "PENDING", True, True), + ("LOCKING_ACCOUNT", "LOCKING_ACCOUNT", False, False), + ("LOCKING_ACCOUNT", "LOCKING_ACCOUNT", True, True), + ) + + def ask_for_the_move(self, address, body, legacy): + """Ask one of the two versions for a move, and return the status it answered with.""" + if legacy: + return self.client.patch(reverse("accounts_retirement_update"), body, format="json") + return self.client.patch(address, body, format="json") + + def test_both_versions_carry_out_and_refuse_the_same_moves(self): + for standing_at, asked_for, force, moves in self.MOVES: + with self.subTest(standing_at=standing_at, asked_for=asked_for, force=force): + legacy_retirement = self.retirement_for(UserFactory.create()) + conforming_retirement = self.retirement_for(UserFactory.create()) + for retirement in (legacy_retirement, conforming_retirement): + retirement.current_state = RetirementState.objects.get(state_name=standing_at) + retirement.save() + + body = {"new_state": asked_for, "response": "foo"} + if force: + body["force"] = True + + legacy = self.client.patch( + reverse("accounts_retirement_update"), + {**body, "username": legacy_retirement.original_username}, + format="json", + ) + conforming = self.client.patch( + reverse( + "user_v2:retirement_detail", + kwargs={"username": conforming_retirement.original_username}, + ), + body, + format="json", + ) + + if moves: + assert legacy.status_code == status.HTTP_204_NO_CONTENT + assert conforming.status_code == status.HTTP_204_NO_CONTENT + else: + assert legacy.status_code == status.HTTP_400_BAD_REQUEST + assert conforming.status_code == status.HTTP_409_CONFLICT + + legacy_retirement.refresh_from_db() + conforming_retirement.refresh_from_db() + assert ( + conforming_retirement.current_state.state_name + == legacy_retirement.current_state.state_name + ) + assert ( + conforming_retirement.last_state.state_name + == legacy_retirement.last_state.state_name + ) + + +@skip_unless_lms +class RetirementRecordParityTests(RetirementParityTestCase): + """A version 2 retirement record carries the values a version 1 one carries.""" + + def setUp(self): + super().setUp() + self.retirement = self.retirement_for( + UserFactory.create(username="parity-subject", email="parity-subject@example.com"), + ) + # Moved on once, so the step the record stands at and the step it came + # from are different steps. + self.retirement.update_state({ + "username": self.retirement.original_username, + "new_state": MOVED_TO_STATE, + "response": "moved on", + }) + assert self.retirement.current_state.state_name != self.retirement.last_state.state_name + + def test_the_record_carries_the_same_values_bar_the_declared_differences(self): + legacy = UserRetirementStatusSerializer(instance=self.retirement).data + conforming = json.loads( + self.client.get( + reverse("user_v2:retirement_detail", kwargs={"username": "parity-subject"}), + ).content, + ) + + assert set(conforming) == set(legacy) + changed = sorted( + name for name in legacy + if json.loads(json.dumps(legacy[name], default=str)) != conforming[name] + ) + assert changed == sorted(name for name, _reason in RECORD_DIFFERENCES) + + def test_the_declared_differences_are_the_step_names(self): + conforming = self.client.get( + reverse("user_v2:retirement_detail", kwargs={"username": "parity-subject"}), + ).data + legacy = UserRetirementStatusSerializer(instance=self.retirement).data + assert conforming["current_state"] == legacy["current_state"]["state_name"] == MOVED_TO_STATE + assert conforming["last_state"] == legacy["last_state"]["state_name"] == WORKING_STATE + + def test_neither_version_carries_the_log_of_the_moves_a_retirement_has_made(self): + conforming = self.client.get( + reverse("user_v2:retirement_detail", kwargs={"username": "parity-subject"}), + ).data + assert "responses" not in conforming + assert "responses" not in UserRetirementStatusSerializer(instance=self.retirement).data + assert UserRetirementStatus.objects.get(id=self.retirement.id).responses != "" + + +@skip_unless_lms +class QueueFilterParityTests(RetirementParityTestCase): + """ + The filters of the merged list against the two version 1 reads they answer for. + + The callers of these addresses are drivers outside this repository, which + reach the merged list by renaming the parameters they already send. A + parameter that selects other rows than the one it is renamed from is + answered 200 and so cannot be noticed by such a caller, which is why each + is measured on both versions here rather than reasoned about. + """ + + #: A day with retirements requested before it, during it, and after it, so + #: that a bound naming it is measured from both sides. + BOUND = datetime.datetime(2020, 6, 10, tzinfo=ZoneInfo("UTC")) + + def setUp(self): + super().setUp() + self.moments = { + "the-day-before": self.BOUND - datetime.timedelta(days=1), + "the-day-itself": self.BOUND + datetime.timedelta(hours=12), + "the-day-after": self.BOUND + datetime.timedelta(days=1, hours=1), + } + self.requested = { + name: create_retirement_status( + UserFactory.create(username=name), + state=RetirementState.objects.get(state_name="COMPLETE"), + create_datetime=moment, + ) + for name, moment in self.moments.items() + } + + def legacy_by_date(self, start, end): + """Return the names the version 1 by-date read answers for this range.""" + response = self.client.get( + reverse("accounts_retirements_by_status_and_date"), + {"start_date": start, "end_date": end, "state": "COMPLETE"}, + ) + assert response.status_code == status.HTTP_200_OK + return [row["original_username"] for row in response.json()] + + def conforming_by_date(self, after, before): + """Return the names the merged list answers for this range.""" + response = self.client.get( + reverse("user_v2:retirement_list"), + {"state": "COMPLETE", "created_after": after, "created_before": before, "page_size": 100}, + ) + assert response.status_code == status.HTTP_200_OK + return [row["original_username"] for row in response.json()["results"]] + + def test_the_declared_difference_is_the_day_the_earlier_bound_names(self): + """Measured both ways: the day named is in the version 1 answer and out of this one.""" + assert self.legacy_by_date("2020-06-01", "2020-06-10") == ["the-day-before", "the-day-itself"] + assert self.conforming_by_date("2020-06-01", "2020-06-10") == ["the-day-before"] + assert [name for name, _reason in QUEUE_FILTER_DIFFERENCES] == ["created_before"] + + def test_the_rows_the_declared_difference_leaves_out_are_asked_for_by_naming_the_day_after(self): + assert self.conforming_by_date("2020-06-01", "2020-06-11") == [ + "the-day-before", "the-day-itself", + ] + assert self.legacy_by_date("2020-06-01", "2020-06-10") == self.conforming_by_date( + "2020-06-01", "2020-06-11", + ) + + def test_the_later_bound_takes_in_the_day_it_names_on_both_versions(self): + """``start_date`` and ``created_after`` are the one pair that means the same thing.""" + assert self.legacy_by_date("2020-06-10", "2020-06-10") == ["the-day-itself"] + assert self.conforming_by_date("2020-06-10", "2020-06-11") == ["the-day-itself"] + # One day asked for on the earlier version is that day named at both + # ends; on this one it is that day and the day after it. + assert self.legacy_by_date("2020-06-09", "2020-06-09") == ["the-day-before"] + assert self.conforming_by_date("2020-06-09", "2020-06-10") == ["the-day-before"] + + def test_steps_named_by_repeating_the_parameter_answer_the_same_rows_on_both_versions(self): + """The version 1 queue read its steps as repeated parameters, and this list reads them too.""" + create_retirement_status( + UserFactory.create(username="still-pending"), + state=RetirementState.objects.get(state_name="PENDING"), + create_datetime=self.BOUND, + ) + legacy = self.client.get( + reverse("accounts_retirement_queue") + + "?cool_off_days=0&states=COMPLETE&states=PENDING", + ) + assert legacy.status_code == status.HTTP_200_OK + conforming = self.client.get( + reverse("user_v2:retirement_list") + + "?cool_off_days=0&state=COMPLETE&state=PENDING&page_size=100", + ) + assert conforming.status_code == status.HTTP_200_OK + answered = [row["original_username"] for row in legacy.json()] + assert answered == [ + "the-day-before", "the-day-itself", "the-day-after", "still-pending", + ] + assert [row["original_username"] for row in conforming.json()["results"]] == answered + + def test_one_step_named_once_answers_the_same_rows_on_both_versions(self): + """The comma-separated idiom of this list is not the only one it reads.""" + legacy = self.client.get( + reverse("accounts_retirement_queue") + "?cool_off_days=0&states=COMPLETE", + ) + conforming = self.client.get( + reverse("user_v2:retirement_list") + "?cool_off_days=0&state=COMPLETE&page_size=100", + ) + assert [row["original_username"] for row in legacy.json()] == [ + "the-day-before", "the-day-itself", "the-day-after", + ] + assert [row["original_username"] for row in conforming.json()["results"]] == [ + row["original_username"] for row in legacy.json() + ] + + +class MediaTypeParityTests(SimpleTestCase): + """Each operation reads a body in the media types the operation it replaces reads.""" + + def test_each_view_reads_what_the_view_it_takes_over_from_reads(self): + for conforming, legacy in MEDIA_TYPE_PAIRS: + with self.subTest(view=conforming.__name__): + assert tuple(conforming.parser_classes) == tuple(legacy.parser_classes) + + def test_the_cancellation_is_the_one_operation_whose_view_narrowed_nothing(self): + """It reads no body on either version, so there is nothing for a media type to decide.""" + assert "parser_classes" not in vars(legacy_views.CancelAccountRetirementStatusView) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_query_counts.py b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_query_counts.py new file mode 100644 index 000000000000..6e0e0945e4aa --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_query_counts.py @@ -0,0 +1,167 @@ +"""Query-count guards for the version 2 retirement pipeline addresses.""" + +import pytest +from django.urls import reverse + +from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory +from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import ( # pylint: disable=unused-import + create_retirement_status, + setup_retirement_states, # noqa: F401 +) +from openedx.core.djangoapps.user_api.models import ( + RetirementState, + UserRetirementPartnerReportingStatus, +) +from openedx.core.djangolib.testing.utils import skip_unless_lms + +PASSWORD = "Password1234" +WORKER_USERNAME = "count-retirement-worker" + +#: Row counts a per-row query would show up at. +ROW_COUNTS = [1, 5, 25] + +#: How many courses each account in the partner reporting queue studied, which +#: is what the legacy claim reads one query at a time. +ENROLMENTS_PER_ROW = 3 + +#: The step the rows of the queue stand at, which is one the pipeline works. +WORKING_STATE = "LOCKING_ACCOUNT" + +RETIREMENT_LIST_QUERIES = 15 +RETIREMENT_DETAIL_QUERIES = 15 +PARTNER_REPORT_CLAIM_QUERIES = 16 + +#: Addresses of the pipeline operations this version replaces, so the cost of +#: the two can be compared. +EARLIER_QUEUE = "/api/user/v1/accounts/retirement_queue/" +EARLIER_CLAIM = "/api/user/v1/accounts/retirement_partner_report/" + +#: What the earlier claim costs, measured the same way as the counts above: a +#: constant part plus two queries for every account on it, because both the +#: account and the courses it studied are read one account at a time. +EARLIER_CLAIM_QUERIES = {1: 17, 5: 25, 25: 65} + +#: What the earlier queue costs: a constant part plus one query for every +#: retirement on it, because the profile holding each account's full name is +#: read one retirement at a time. +EARLIER_QUEUE_QUERIES = {1: 16, 5: 20, 25: 40} + + +def sign_the_worker_in(client, settings): + """Bind the pipeline's capability to a caller and sign that caller in.""" + settings.RETIREMENT_SERVICE_WORKER_USERNAME = WORKER_USERNAME + worker = UserFactory.create(username=WORKER_USERNAME, password=PASSWORD) + assert client.login(username=WORKER_USERNAME, password=PASSWORD) + return worker + + +@pytest.fixture(name="queue") +@skip_unless_lms +def queue_fixture(request, client, settings): + """Sign the pipeline's caller in and put ``request.param`` retirements in the queue.""" + request.getfixturevalue("setup_retirement_states") + sign_the_worker_in(client, settings) + state = RetirementState.objects.get(state_name=WORKING_STATE) + rows = [ + create_retirement_status(UserFactory.create(username=f"queued{index}"), state=state) + for index in range(request.param) + ] + # The first authenticated request of a session populates caches the rest of + # the session reuses; the counts below are the steady-state ones. + client.get(reverse("user_v2:retirement_list")) + return rows + + +@pytest.fixture(name="partner_queue") +@skip_unless_lms +def partner_queue_fixture(request, client, settings): + """Sign the pipeline's caller in and queue ``request.param`` accounts for partner reporting.""" + request.getfixturevalue("setup_retirement_states") + sign_the_worker_in(client, settings) + rows = [] + for index in range(request.param): + user = UserFactory.create(username=f"reported{index}") + for course in range(ENROLMENTS_PER_ROW): + CourseEnrollmentFactory.create( + user=user, course_id=f"course-v1:Org{course}+Course{index}+Run", + ) + rows.append( + UserRetirementPartnerReportingStatus.objects.create( + user=user, + original_username=user.username, + original_email=user.email, + original_name=user.profile.name, + ), + ) + client.get(reverse("user_v2:retirement_list")) + return rows + + +@pytest.mark.django_db +@pytest.mark.parametrize("queue", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_the_queue_costs_the_same_however_many_retirements_are_in_it( + client, queue, django_assert_num_queries, +): + with django_assert_num_queries(RETIREMENT_LIST_QUERIES): + response = client.get(reverse("user_v2:retirement_list"), {"page_size": 100}) + assert len(response.json()["results"]) == len(queue) + + +@pytest.mark.django_db +@pytest.mark.parametrize("queue", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_the_minimal_queue_costs_the_same_however_many_retirements_are_in_it( + client, queue, django_assert_num_queries, +): + with django_assert_num_queries(RETIREMENT_LIST_QUERIES): + response = client.get( + reverse("user_v2:retirement_list"), {"page_size": 100, "view": "minimal"}, + ) + assert len(response.json()["results"]) == len(queue) + + +@pytest.mark.django_db +@pytest.mark.parametrize("queue", [1], indirect=True) +@skip_unless_lms +def test_one_retirement_costs_a_fixed_number_of_queries(client, queue, django_assert_num_queries): + address = reverse("user_v2:retirement_detail", kwargs={"username": queue[0].original_username}) + with django_assert_num_queries(RETIREMENT_DETAIL_QUERIES): + response = client.get(address) + assert response.json()["original_username"] == queue[0].original_username + + +@pytest.mark.django_db +@pytest.mark.parametrize("partner_queue", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_the_claim_costs_the_same_however_many_accounts_are_queued( + client, partner_queue, django_assert_num_queries, +): + """The courses each account studied are read with the entries rather than one entry at a time.""" + with django_assert_num_queries(PARTNER_REPORT_CLAIM_QUERIES): + response = client.post(reverse("user_v2:retirement_partner_report_list"), {}, format="json") + body = response.json() + assert len(body) == len(partner_queue) + assert all(len(entry["orgs"]) == ENROLMENTS_PER_ROW for entry in body) + + +@pytest.mark.django_db +@pytest.mark.parametrize("partner_queue", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_the_earlier_claim_reads_the_courses_one_account_at_a_time( + client, partner_queue, django_assert_num_queries, +): + """The baseline the count above is measured against, on the address this version replaces.""" + with django_assert_num_queries(EARLIER_CLAIM_QUERIES[len(partner_queue)]): + response = client.post(EARLIER_CLAIM, {}, format="json", content_type="application/json") + assert len(response.json()) == len(partner_queue) + + +@pytest.mark.django_db +@pytest.mark.parametrize("queue", ROW_COUNTS, indirect=True) +@skip_unless_lms +def test_the_earlier_queue_costs_what_it_costs(client, queue, django_assert_num_queries): + """The baseline the queue count is measured against, on the address this version replaces.""" + with django_assert_num_queries(EARLIER_QUEUE_QUERIES[len(queue)]): + response = client.get(EARLIER_QUEUE, {"cool_off_days": 0, "states": WORKING_STATE}) + assert len(response.json()) == len(queue) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py new file mode 100644 index 000000000000..09b4cc971bb4 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_retirement_views.py @@ -0,0 +1,3473 @@ +"""Tests for the version 2 User API views over the account retirement pipeline.""" + +import contextlib +import datetime +from unittest import mock +from zoneinfo import ZoneInfo + +import pytest +from crum.signals import current_user_setter +from django.conf import settings +from django.contrib.auth.models import Permission +from django.contrib.sessions.models import Session +from django.core.cache import cache +from django.core.signals import got_request_exception, request_finished, request_started +from django.db import connection +from django.db.backends.signals import connection_created +from django.db.models.signals import class_prepared, post_init, post_save, pre_init, pre_save +from django.dispatch import Signal +from django.test import SimpleTestCase, override_settings +from django.test.signals import setting_changed +from django.test.utils import CaptureQueriesContext +from django.urls import reverse +from edx_django_utils.monitoring.signals import ( + monitoring_support_process_request, + monitoring_support_process_response, +) +from edx_rest_framework_extensions.errors import ( + INTERNAL_ERROR_SLUG, + INTERNAL_ERROR_TITLE, + Conflict, + classify_error, + error_type_uri, +) +from edx_rest_framework_extensions.permissions import NotJwtRestrictedApplication +from edx_rest_framework_extensions.scoping import FullScopePolicy +from edx_rest_framework_extensions.testing import assert_error_envelope +from opaque_keys.edx.keys import CourseKey +from rest_framework import permissions, status +from rest_framework.exceptions import ValidationError +from rest_framework.test import APITestCase + +from common.djangoapps.student.models import ( + AccountRecovery, + CourseEnrollmentAllowed, + PendingEmailChange, + PendingNameChange, + UserProfile, + get_retired_username_by_username, +) +from common.djangoapps.student.tests.factories import ( + AdminFactory, + CourseEnrollmentFactory, + SuperuserFactory, + UserFactory, +) +from common.djangoapps.util.model_utils import USER_FIELDS_CHANGED +from openedx.core.djangoapps.user_api.accounts.permissions import CanCancelUserRetirement, CanRetireUser +from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_LMS_CRITICAL +from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import ( # pylint: disable=unused-import + create_retirement_status, + setup_retirement_states, # noqa: F401 +) +from openedx.core.djangoapps.user_api.models import ( + RetirementState, + UserOrgTag, + UserPreference, + UserRetirementPartnerReportingStatus, + UserRetirementRequest, + UserRetirementStatus, +) +from openedx.core.djangoapps.user_api.v2 import urls as v2_urls +from openedx.core.djangoapps.user_api.v2 import view_services +from openedx.core.djangoapps.user_api.v2.errors import AmbiguousAccountName, RetirementStateConflict +from openedx.core.djangoapps.user_api.v2.paginators import UserApiPagination +from openedx.core.djangoapps.user_api.v2.serializers import ( + RETIREMENT_MINIMAL_FIELDS, + RetirementSerializer, +) +from openedx.core.djangoapps.user_api.v2.views import ( + RETIREMENT_ACTION_PERMISSIONS, + RetirementCleanupViewSet, + RetirementPartnerReportCleanupViewSet, + RetirementPartnerReportViewSet, + RetirementViewSet, + _PartnerReportAccessMixin, + _RetirementRecordAccessMixin, + _RetirementRecordsMixin, +) +from openedx.core.djangolib.testing.utils import skip_unless_lms + +from .test_views import write_queries + +PASSWORD = "Password1234" + +#: The caller the pipeline runs as, the caller that replaces usernames, and the +#: names the deployment binds each capability to. +WORKER_USERNAME = "retirement-worker" +REPLACEMENT_WORKER_USERNAME = "replacement-worker" + +#: The callers that may carry out pipeline work, and the callers that may not. +PIPELINE_CALLERS = ("worker", "requester", "superuser") +PIPELINE_REFUSED = ("learner", "subject", "staff", "canceller", "replacer") + +#: The callers that may undo a retirement request, and the callers that may +#: not. The pipeline itself is among the refused: carrying a retirement out and +#: calling it off are different capabilities, and one request cannot hold both. +CANCEL_CALLERS = ("canceller", "superuser") +CANCEL_REFUSED = ("learner", "subject", "staff", "requester", "worker", "replacer") + +#: The query parameters each retirement address reads, in the order its refusal +#: lists them. +LIST_QUERY_PARAMS = ( + "cool_off_days, created_after, created_before, fields, format, ordering, page, page_size, state, view" +) +DETAIL_QUERY_PARAMS = "fields, format, view" +WRITE_QUERY_PARAMS = "format" +CLAIM_QUERY_PARAMS = "format, limit" + +#: What a move of a retirement cannot be asked for without, which is what the +#: published body of the request says it requires. +MOVE_REQUIRED_VALUES = ["new_state", "response"] + +#: What the shortest and the longest page of any collection of this version holds. +DEFAULT_PAGE_SIZE = 10 +MAXIMUM_PAGE_SIZE = 100 + +PAGINATION_KEYS = {"count", "num_pages", "current_page", "start", "next", "previous", "results"} + +#: Every property a retirement record carries. +RETIREMENT_FIELDS = { + "id", + "user", + "original_username", + "original_email", + "original_name", + "retired_username", + "retired_email", + "current_state", + "last_state", + "created", + "modified", +} + +#: The properties of a retiring account's own data, which the ``minimal`` +#: preset must not carry. +RETIREMENT_PII_FIELDS = {"original_email", "original_name", "user"} + +STATE_CONFLICT_SLUG = "user/retirement-state-conflict" +AMBIGUOUS_NAME_SLUG = "user/ambiguous-account-name" + +#: Where the language a user picked is carried between requests, the name it is +#: stored under, and the table it is stored in. +LANGUAGE_COOKIE = settings.LANGUAGE_COOKIE_NAME +LANGUAGE_PREFERENCE = "pref-lang" +PREFERENCE_TABLE = "user_api_userpreference" + +#: The signals every request raises because the platform raises them: a row +#: being instantiated, the request beginning and ending, a connection being +#: opened, a model class being prepared, a setting being overridden by a test, +#: the telemetry each request is measured through, and the caller being put +#: where the code below the view reads it from. None of them says anything +#: about what the request did. +FRAMEWORK_SIGNALS = ( + pre_init, + post_init, + class_prepared, + connection_created, + request_started, + request_finished, + got_request_exception, + setting_changed, + monitoring_support_process_request, + monitoring_support_process_response, + current_user_setter, +) + +#: What syncing the language from the cookie announces: the row being stored, +#: and the platform's own announcement that a field of the account changed. +LANGUAGE_SYNC_SIGNALS = [ + (pre_save, UserPreference), + (post_save, UserPreference), + (USER_FIELDS_CHANGED, None), +] + +#: The addresses this version supersedes, used where the point is that both +#: versions behave alike. +EARLIER_RETIREMENT_QUEUE = "/api/user/v1/accounts/retirement_queue/" +EARLIER_RETIREMENT_CLEANUP = "/api/user/v1/accounts/retirement_cleanup/" +EARLIER_PARTNER_REPORT_CLEANUP = "/api/user/v1/accounts/retirement_partner_report_cleanup/" +EARLIER_PARTNER_REPORT = "/api/user/v1/accounts/retirement_partner_report/" +EARLIER_CORE_ERASURE = "/api/user/v1/accounts/retire/" + + +@contextlib.contextmanager +def captured_signals(): + """ + Record the signals sent while this block runs, bar the ones the framework sends itself. + + A signal is recorded by the signal object and the sender it was sent + under, so that a test names the signals it expects rather than counting + them. + """ + sent = [] + originals = {name: getattr(Signal, name) for name in ("send", "send_robust")} + + def recording(original): + """Return a replacement for one of the two ways a signal is sent.""" + def send(self, sender=None, **named): + # The session row is left out for the same reason its statements + # are: it is the platform's record of the caller being served, + # not data this API answers from. + if self not in FRAMEWORK_SIGNALS and sender is not Session: + sent.append((self, sender)) + return original(self, sender=sender, **named) + return send + + with mock.patch.object(Signal, "send", recording(originals["send"])), \ + mock.patch.object(Signal, "send_robust", recording(originals["send_robust"])): + yield sent + +COURSE_KEY = CourseKey.from_string("course-v1:edX+DemoX+Demo") +OTHER_COURSE_KEY = CourseKey.from_string("course-v1:OtherOrg+OtherX+Other") + + +def grant(user, codename, app_label="user_api"): + """Give this user the named Django model permission.""" + user.user_permissions.add( + Permission.objects.get(codename=codename, content_type__app_label=app_label), + ) + + +@override_settings( + RETIREMENT_SERVICE_WORKER_USERNAME=WORKER_USERNAME, + USERNAME_REPLACEMENT_WORKER=REPLACEMENT_WORKER_USERNAME, +) +@pytest.mark.usefixtures("setup_retirement_states") +class RetirementApiTestCase(APITestCase): + """The callers and the fixtures the retirement pipeline tests share.""" + + def setUp(self): + super().setUp() + self.worker = UserFactory.create(username=WORKER_USERNAME, password=PASSWORD) + self.replacer = UserFactory.create(username=REPLACEMENT_WORKER_USERNAME, password=PASSWORD) + self.superuser = SuperuserFactory.create(username="super-caller", password=PASSWORD) + self.staff = AdminFactory.create(username="plain-staff", password=PASSWORD) + self.requester = AdminFactory.create(username="staff-who-requests", password=PASSWORD) + self.canceller = AdminFactory.create(username="staff-who-cancels", password=PASSWORD) + self.learner = UserFactory.create(username="learner-caller", password=PASSWORD) + self.subject = UserFactory.create( + username="subject", email="subject@example.com", password=PASSWORD, + ) + grant(self.requester, "add_userretirementrequest") + grant(self.canceller, "change_userretirementstatus") + + def login(self, caller): + """Sign the named caller in through the session the platform default accepts.""" + user = getattr(self, caller) + assert self.client.login(username=user.username, password=PASSWORD) + + def state(self, state_name): + """Return the configured pipeline step of this name.""" + return RetirementState.objects.get(state_name=state_name) + + def retirement_in(self, state_name, user=None, created=None): + """Return a retirement standing at this pipeline step.""" + return create_retirement_status( + user or UserFactory.create(), state=self.state(state_name), create_datetime=created, + ) + + +class _AccessMatrixMixin: + """ + Every caller the address admits is served, and every other caller is refused. + + The matrix is the proof that the three capabilities of this area are + carried over as they stand: the caller the pipeline runs as, the staff + caller who may add a retirement request and a superuser work the pipeline; + only the staff caller who may change a retirement status and a superuser + call one off; and the caller that replaces usernames reaches neither. + """ + + #: The callers the address admits, and the ones it refuses. + admitted = () + refused = () + + def send(self): + """Make the request this address serves, building whatever it acts on.""" + raise NotImplementedError + + def test_every_admitted_caller_is_served(self): + for caller in self.admitted: + with self.subTest(caller=caller): + self.client.logout() + self.login(caller) + response = self.send() + assert response.status_code in ( + status.HTTP_200_OK, + status.HTTP_204_NO_CONTENT, + ), f"{caller} got {response.status_code}: {getattr(response, 'data', None)!r}" + + def test_every_other_caller_is_forbidden(self): + for caller in self.refused: + with self.subTest(caller=caller): + self.client.logout() + self.login(caller) + assert_error_envelope( + self.send(), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_an_anonymous_caller_is_unauthenticated(self): + self.client.logout() + assert_error_envelope( + self.send(), + expected_status=status.HTTP_401_UNAUTHORIZED, + expected_type_slug="authn", + ) + + +@skip_unless_lms +class RetirementListTests(_AccessMatrixMixin, RetirementApiTestCase): + """GET /api/user/v2/retirements/""" + + admitted = PIPELINE_CALLERS + refused = PIPELINE_REFUSED + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:retirement_list") + day = datetime.datetime(2026, 3, 1, tzinfo=ZoneInfo("UTC")) + self.pending = self.retirement_in("PENDING", created=day) + self.locking = self.retirement_in("LOCKING_ACCOUNT", created=day + datetime.timedelta(days=1)) + self.complete = self.retirement_in("COMPLETE", created=day + datetime.timedelta(days=2)) + self.day = day + + def send(self): + return self.client.get(self.url) + + def names_in(self, response): + """Return the registered names the page carries, in the order it carries them.""" + return [row["original_username"] for row in response.data["results"]] + + def test_the_page_carries_every_retirement_in_the_order_they_were_requested(self): + self.login("worker") + response = self.client.get(self.url) + assert response.status_code == status.HTTP_200_OK + assert set(response.data) == PAGINATION_KEYS + assert self.names_in(response) == [ + self.pending.original_username, + self.locking.original_username, + self.complete.original_username, + ] + assert response.data["count"] == 3 + assert response.data["num_pages"] == 1 + assert response.data["current_page"] == 1 + assert response.data["start"] == 0 + + def test_a_row_carries_the_values_the_record_holds(self): + self.login("worker") + response = self.client.get(self.url, {"state": "LOCKING_ACCOUNT"}) + row = response.data["results"][0] + assert set(row) == RETIREMENT_FIELDS + assert row["id"] == self.locking.id + assert row["original_username"] == self.locking.original_username + assert row["original_email"] == self.locking.original_email + assert row["original_name"] == self.locking.original_name + assert row["retired_username"] == self.locking.retired_username + assert row["retired_email"] == self.locking.retired_email + assert row["current_state"] == "LOCKING_ACCOUNT" + assert row["last_state"] == "LOCKING_ACCOUNT" + assert row["user"] == { + "id": self.locking.user.id, + "username": self.locking.user.username, + "email": self.locking.user.email, + "profile": {"id": self.locking.user.profile.id, "name": self.locking.user.profile.name}, + } + + def test_the_page_holds_ten_rows_unless_a_size_is_asked_for(self): + for _ in range(9): + self.retirement_in("PENDING") + self.login("worker") + response = self.client.get(self.url) + assert len(response.data["results"]) == DEFAULT_PAGE_SIZE + assert response.data["count"] == 12 + assert response.data["num_pages"] == 2 + + def test_a_page_larger_than_the_maximum_holds_the_maximum(self): + for _ in range(MAXIMUM_PAGE_SIZE + 2): + self.retirement_in("PENDING") + self.login("worker") + response = self.client.get(self.url, {"page_size": 500}) + assert len(response.data["results"]) == MAXIMUM_PAGE_SIZE + assert response.data["count"] == MAXIMUM_PAGE_SIZE + 5 + + def test_a_page_beyond_the_last_one_is_not_found(self): + self.login("worker") + assert_error_envelope( + self.client.get(self.url, {"page": 9999}), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_one_step_can_be_asked_for(self): + self.login("worker") + response = self.client.get(self.url, {"state": "COMPLETE"}) + assert self.names_in(response) == [self.complete.original_username] + + def test_several_steps_can_be_asked_for_at_once(self): + self.login("worker") + response = self.client.get(self.url, {"state": "PENDING,COMPLETE"}) + assert self.names_in(response) == [ + self.pending.original_username, + self.complete.original_username, + ] + + def test_several_steps_can_be_asked_for_by_repeating_the_parameter(self): + """The list these rows were read from before named its steps that way.""" + self.login("worker") + response = self.client.get(f"{self.url}?state=PENDING&state=COMPLETE") + assert self.names_in(response) == [ + self.pending.original_username, + self.complete.original_username, + ] + + def test_a_list_and_a_repetition_name_their_steps_together(self): + self.login("worker") + response = self.client.get(f"{self.url}?state=PENDING,COMPLETE&state=LOCKING_ACCOUNT") + assert self.names_in(response) == [ + self.pending.original_username, + self.locking.original_username, + self.complete.original_username, + ] + + def test_naming_no_step_at_all_leaves_every_retirement_in(self): + self.login("worker") + response = self.client.get(f"{self.url}?state=") + assert self.names_in(response) == [ + self.pending.original_username, + self.locking.original_username, + self.complete.original_username, + ] + + def test_a_step_this_deployment_does_not_run_is_refused(self): + self.login("worker") + response = self.client.get(self.url, {"state": "NOPE"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert "No such pipeline step: NOPE." in envelope["errors"]["state"][0] + + def test_a_step_this_deployment_does_not_run_is_refused_among_repeated_ones(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.get(f"{self.url}?state=PENDING&state=NOPE"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "No such pipeline step: NOPE." in envelope["errors"]["state"][0] + + def test_retirements_requested_recently_can_be_left_out(self): + recent = self.retirement_in( + "PENDING", created=datetime.datetime.now(ZoneInfo("UTC")) - datetime.timedelta(days=1), + ) + self.login("worker") + response = self.client.get(self.url, {"cool_off_days": 7, "page_size": 100}) + assert recent.original_username not in self.names_in(response) + assert self.pending.original_username in self.names_in(response) + + def test_no_cooling_off_period_leaves_every_retirement_in(self): + recent = self.retirement_in( + "PENDING", created=datetime.datetime.now(ZoneInfo("UTC")) - datetime.timedelta(days=1), + ) + self.login("worker") + response = self.client.get(self.url, {"cool_off_days": 0, "page_size": 100}) + assert recent.original_username in self.names_in(response) + + def test_a_cooling_off_period_that_is_not_a_whole_number_of_days_is_refused(self): + self.login("worker") + for value in ("1.5", "nope"): + with self.subTest(value=value): + envelope = assert_error_envelope( + self.client.get(self.url, {"cool_off_days": value}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "cool_off_days" in envelope["errors"] + + def test_a_negative_cooling_off_period_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.get(self.url, {"cool_off_days": -1}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "cool_off_days" in envelope["errors"] + + def test_the_later_bound_includes_the_moment_it_names(self): + self.login("worker") + response = self.client.get( + self.url, {"created_after": self.locking.created.isoformat(), "page_size": 100}, + ) + assert self.names_in(response) == [ + self.locking.original_username, + self.complete.original_username, + ] + + def test_the_earlier_bound_leaves_out_the_moment_it_names(self): + self.login("worker") + response = self.client.get( + self.url, {"created_before": self.locking.created.isoformat(), "page_size": 100}, + ) + assert self.names_in(response) == [self.pending.original_username] + + def test_a_day_as_the_earlier_bound_leaves_that_whole_day_out(self): + """ + A date alone is the first moment of that day, which the bound excludes. + + The retirements requested during the day named are therefore not in the + page, which is the one reading of the bound that holds whether the + value names a day or a moment within one. + """ + midday = self.retirement_in( + "PENDING", created=self.locking.created + datetime.timedelta(hours=12), + ) + self.login("worker") + response = self.client.get( + self.url, + {"created_before": self.locking.created.date().isoformat(), "page_size": 100}, + ) + assert self.names_in(response) == [self.pending.original_username] + assert midday.original_username not in self.names_in(response) + later = self.client.get( + self.url, + { + "created_before": (self.locking.created + datetime.timedelta(days=1)).date().isoformat(), + "page_size": 100, + }, + ) + assert self.names_in(later) == [ + self.pending.original_username, + self.locking.original_username, + midday.original_username, + ] + + def test_a_bound_that_is_not_a_moment_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.get(self.url, {"created_after": "the day before"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "created_after" in envelope["errors"] + + def test_the_page_can_be_sorted_by_every_field_it_offers(self): + self.login("worker") + expected = { + "original_username": sorted( + [r.original_username for r in (self.pending, self.locking, self.complete)], + ), + "created": [ + self.pending.original_username, + self.locking.original_username, + self.complete.original_username, + ], + "modified": [ + self.pending.original_username, + self.locking.original_username, + self.complete.original_username, + ], + "current_state": [ + self.pending.original_username, + self.locking.original_username, + self.complete.original_username, + ], + } + for field, order in expected.items(): + with self.subTest(field=field): + response = self.client.get(self.url, {"ordering": field, "page_size": 100}) + assert self.names_in(response) == order + reversed_response = self.client.get( + self.url, {"ordering": f"-{field}", "page_size": 100}, + ) + assert self.names_in(reversed_response) == list(reversed(order)) + + def test_a_sort_field_the_page_does_not_offer_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.get(self.url, {"ordering": "id"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "ordering" in envelope["errors"] + + def test_a_query_parameter_the_page_does_not_read_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.get(self.url, {"states": "PENDING"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"]["states"] == [ + f"No such query parameter. Send any of: {LIST_QUERY_PARAMS}.", + ] + + def test_the_minimal_preset_carries_no_data_of_the_account_owner(self): + self.login("worker") + response = self.client.get(self.url, {"view": "minimal"}) + row = response.data["results"][0] + assert set(row) == set(RETIREMENT_MINIMAL_FIELDS) + assert set(row) & RETIREMENT_PII_FIELDS == set() + assert row["original_username"] == self.pending.original_username + assert row["current_state"] == "PENDING" + + def test_a_preset_the_page_does_not_answer_to_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.get(self.url, {"view": "everything"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"]["view"] == [ + "Ask for any of: minimal, or omit this parameter for the default response.", + ] + + def test_properties_can_be_selected(self): + self.login("worker") + response = self.client.get(self.url, {"fields": "original_username,current_state"}) + assert response.data["results"][0] == { + "original_username": self.pending.original_username, + "current_state": "PENDING", + } + + def test_a_property_a_row_cannot_carry_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.get(self.url, {"fields": "responses"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"]["fields"][0].startswith("No such field: responses.") + + def test_every_admitted_caller_sees_the_same_rows(self): + """The visibility layer narrows nothing for a caller holding the capability.""" + seen = {} + for caller in PIPELINE_CALLERS: + self.client.logout() + self.login(caller) + seen[caller] = self.names_in(self.client.get(self.url, {"page_size": 100})) + assert seen == { + caller: [ + self.pending.original_username, + self.locking.original_username, + self.complete.original_username, + ] + for caller in PIPELINE_CALLERS + } + + def test_the_page_is_readable_without_a_body(self): + self.login("worker") + response = self.client.head(self.url) + assert response.status_code == status.HTTP_200_OK + + +class _NarrowingPolicy: + """ + A visibility policy that keeps the rows named and records what it was given. + + It stands in for a deployment's own policy, so that the tests below read + what an address answers with when the policy narrows rather than when it + leaves the selection whole. + """ + + def __init__(self, column, *keep): + self.column = column + self.keep = keep + #: The caller each application was made for, in order. + self.subjects = [] + #: The values the selection held each time, before it was narrowed. + self.offered = [] + + def scope(self, queryset, subject): + """Return the rows named, recording the caller and what was on offer.""" + self.subjects.append(subject) + self.offered.append(sorted(queryset.values_list(self.column, flat=True))) + return queryset.filter(**{f"{self.column}__in": self.keep}) + + +#: Stands for a view class that declares no permission classes of its own. +NOT_DECLARED = object() + + +def _copied_by_the_middleware(view): + """ + Return whether the classes on this view class are the shared ones with the middleware's added. + + The platform's own middleware appends its class to whatever it finds on + every view class it serves, which leaves a copy on the view class itself. + That copy is this and nothing else, so a view that declares classes of its + own is told apart from one that has merely answered a request. + """ + own = vars(view).get("permission_classes") + if own is None: + return False + inherited = next( + vars(base)["permission_classes"] + for base in view.__mro__[1:] + if "permission_classes" in vars(base) + ) + return tuple(own) == (*inherited, NotJwtRestrictedApplication) + + +@contextlib.contextmanager +def layers_read_from_their_own_declaration(*views): + """ + Serve these views with the permission classes the layer they share declares. + + The middleware's copy is dropped here and put back afterwards, so that what + the views read is what is declared for them, as it would be on a service + that has just started. A view declaring classes of its own keeps them, + because that declaration is what these tests are here to see. + """ + on_entry = {view: vars(view).get("permission_classes", NOT_DECLARED) for view in views} + try: + for view in views: + if _copied_by_the_middleware(view): + del view.permission_classes + yield + finally: + # Each view class is left exactly as it was found, whatever the + # middleware wrote onto it while these requests were being served. + for view, declared in on_entry.items(): + if "permission_classes" in vars(view): + del view.permission_classes + if declared is not NOT_DECLARED: + view.permission_classes = declared + + +class _OnlyASuperuser(permissions.BasePermission): + """ + A rule a deployment might add to a resource, admitting one caller and refusing the rest. + + It stands in for whatever a deployment adds to the layer the operations on + a resource share, so the tests below read which of those operations is + asked for it. + """ + + def has_permission(self, request, view): + """Return whether this caller is a superuser.""" + return bool(request.user and request.user.is_superuser) + + +@skip_unless_lms +class RetirementVisibilityTests(RetirementApiTestCase): + """ + Which retirements a caller may see is decided by the view's own visibility policy. + + The policy this deployment runs keeps the queue whole, because every + caller these addresses admit holds the capability to work the whole + pipeline. A deployment that replaces it with one that narrows needs it + applied where the selection is read, so the policy is replaced here with + one that keeps two of three retirements and the page is read. + """ + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:retirement_list") + self.pending = self.retirement_in("PENDING") + self.locking = self.retirement_in("LOCKING_ACCOUNT") + self.hidden = self.retirement_in("COMPLETE") + self.policy = _NarrowingPolicy( + "original_username", self.pending.original_username, self.locking.original_username, + ) + + def names_in(self, response): + """Return the registered names the page carries.""" + return sorted(row["original_username"] for row in response.data["results"]) + + def test_the_page_carries_the_retirements_the_policy_keeps_and_no_others(self): + self.login("worker") + with mock.patch.object(RetirementViewSet, "scoping_policy", self.policy): + response = self.client.get(self.url, {"page_size": 100}) + assert response.status_code == status.HTTP_200_OK + assert self.names_in(response) == sorted( + [self.pending.original_username, self.locking.original_username], + ) + assert response.data["count"] == 2 + + def test_the_policy_is_given_the_caller_and_every_retirement_held(self): + self.login("worker") + with mock.patch.object(RetirementViewSet, "scoping_policy", self.policy): + self.client.get(self.url, {"page_size": 100}) + assert [subject.username for subject in self.policy.subjects] == [WORKER_USERNAME] + assert self.policy.offered == [sorted( + [ + self.pending.original_username, + self.locking.original_username, + self.hidden.original_username, + ], + )] + + def test_what_the_policy_leaves_out_is_left_out_of_a_narrowed_page_too(self): + """A filter narrows what the policy left; it cannot put a row back in.""" + self.login("worker") + with mock.patch.object(RetirementViewSet, "scoping_policy", self.policy): + response = self.client.get(self.url, {"state": "COMPLETE", "page_size": 100}) + assert self.names_in(response) == [] + assert response.data["count"] == 0 + + def test_the_policy_this_deployment_runs_keeps_the_whole_queue(self): + self.login("worker") + response = self.client.get(self.url, {"page_size": 100}) + assert self.names_in(response) == sorted( + [ + self.pending.original_username, + self.locking.original_username, + self.hidden.original_username, + ], + ) + + +@skip_unless_lms +class PartnerReportVisibilityTests(RetirementApiTestCase): + """The entries a caller may take from the partner-report queue are narrowed the same way.""" + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:retirement_partner_report_list") + self.taken = self.queued(self.subject) + self.hidden = self.queued(self.learner) + self.policy = _NarrowingPolicy("original_username", self.subject.username) + + def queued(self, user): + """Return a partner report entry waiting to be taken, for this user.""" + return UserRetirementPartnerReportingStatus.objects.create( + user=user, + original_username=user.username, + original_email=user.email, + original_name=user.profile.name, + ) + + def test_only_the_entries_the_policy_keeps_are_answered_with(self): + self.login("worker") + with mock.patch.object(RetirementPartnerReportViewSet, "scoping_policy", self.policy): + response = self.client.post(self.url, {}, format="json") + assert response.status_code == status.HTTP_200_OK + assert [entry["original_username"] for entry in response.data] == [self.subject.username] + + def test_an_entry_the_policy_leaves_out_is_not_marked_as_taken(self): + self.login("worker") + with mock.patch.object(RetirementPartnerReportViewSet, "scoping_policy", self.policy): + self.client.post(self.url, {}, format="json") + marked = set( + UserRetirementPartnerReportingStatus.objects + .filter(is_being_processed=True) + .values_list("original_username", flat=True), + ) + assert marked == {self.subject.username} + + def test_the_policy_is_given_the_caller_and_the_entries_nobody_has_taken(self): + self.login("worker") + with mock.patch.object(RetirementPartnerReportViewSet, "scoping_policy", self.policy): + self.client.post(self.url, {}, format="json") + assert [subject.username for subject in self.policy.subjects] == [WORKER_USERNAME] + assert self.policy.offered == [ + sorted([self.subject.username, self.learner.username]), + ] + + def test_the_policy_this_deployment_runs_leaves_every_queued_entry_takeable(self): + self.login("worker") + response = self.client.post(self.url, {}, format="json") + assert sorted(entry["original_username"] for entry in response.data) == sorted( + [self.subject.username, self.learner.username], + ) + + +@skip_unless_lms +class RetirementQueueOrderTests(RetirementApiTestCase): + """ + The queue is read in a total order, so that paging through it sees each retirement once. + + Retirements that compare equal on the column a caller sorts by are left + in whatever order the database answers with, and two requests need not + agree on it, so the order the queue is worked in is appended to the sort + asked for. That order is the retirement's own identifier, which rises as + retirements are requested: it settles ties without being distinguishable + from an unsettled read of rows stored in that order, so what these tests + read is the order the database is asked for. + """ + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:retirement_list") + day = datetime.datetime(2026, 3, 1, tzinfo=ZoneInfo("UTC")) + self.shared = [self.retirement_in("PENDING", created=day) for _ in range(3)] + self.login("worker") + + def ordering_asked_for(self, **params): + """Return the order the page of retirements was read in.""" + with CaptureQueriesContext(connection) as captured: + response = self.client.get(self.url, params) + assert response.status_code == status.HTTP_200_OK, response.content + table = UserRetirementStatus._meta.db_table + reads = [ + query["sql"] for query in captured.captured_queries + if query["sql"].startswith("SELECT") and "ORDER BY" in query["sql"] + and connection.ops.quote_name(table) in query["sql"] + ] + assert len(reads) == 1, reads + return reads[0].split("ORDER BY")[1].split("LIMIT")[0].strip() + + def column(self, name, direction="ASC"): + """Return the one column of the retirement table, as a sort names it.""" + quoted = connection.ops.quote_name + table = UserRetirementStatus._meta.db_table + return f"{quoted(table)}.{quoted(name)} {direction}" + + def test_a_sort_a_caller_asks_for_is_completed_by_the_order_the_queue_is_worked_in(self): + assert self.ordering_asked_for(ordering="created", page_size=100) == ", ".join( + [self.column("created"), self.column("id")], + ) + + def test_the_order_the_queue_is_worked_in_is_added_to_a_reversed_sort_as_well(self): + assert self.ordering_asked_for(ordering="-created", page_size=100) == ", ".join( + [self.column("created", "DESC"), self.column("id")], + ) + + def test_a_page_nobody_asked_a_sort_for_is_read_in_the_order_the_queue_is_worked_in(self): + """The order a page falls back to is the tie-break, so it is not asked for twice.""" + assert self.ordering_asked_for(page_size=100) == self.column("id") + + def test_paging_through_retirements_requested_at_one_moment_sees_each_once(self): + listed = [] + for page in (1, 2, 3): + response = self.client.get( + self.url, {"ordering": "created", "page_size": 1, "page": page}, + ) + listed.extend(row["original_username"] for row in response.data["results"]) + assert listed == [retirement.original_username for retirement in self.shared] + + +@skip_unless_lms +class RetirementDetailTests(_AccessMatrixMixin, RetirementApiTestCase): + """GET /api/user/v2/retirements/{username}/""" + + admitted = PIPELINE_CALLERS + refused = PIPELINE_REFUSED + + def setUp(self): + super().setUp() + self.retirement = self.retirement_in("LOCKING_ACCOUNT", user=self.subject) + self.url = reverse("user_v2:retirement_detail", kwargs={"username": "subject"}) + + def send(self): + return self.client.get(self.url) + + def test_the_record_comes_back_whole(self): + self.login("worker") + response = self.client.get(self.url) + assert response.status_code == status.HTTP_200_OK + assert set(response.data) == RETIREMENT_FIELDS + assert response.data["id"] == self.retirement.id + assert response.data["original_username"] == "subject" + assert response.data["original_email"] == "subject@example.com" + assert response.data["current_state"] == "LOCKING_ACCOUNT" + + def test_an_account_holding_no_profile_is_answered_with_a_null_profile(self): + """The property is answered under its own name either way, as null where there is none.""" + UserProfile.objects.filter(user=self.subject).delete() + self.login("worker") + response = self.client.get(self.url) + assert response.status_code == status.HTTP_200_OK + assert response.data["user"] == { + "id": self.subject.id, + "username": "subject", + "email": "subject@example.com", + "profile": None, + } + page = self.client.get(reverse("user_v2:retirement_list")) + assert [row["user"]["profile"] for row in page.data["results"]] == [None] + + def test_the_hashed_name_a_retired_account_is_moved_to_addresses_the_same_record(self): + hashed = get_retired_username_by_username("subject") + self.subject.username = hashed + self.subject.save() + self.login("worker") + response = self.client.get( + reverse("user_v2:retirement_detail", kwargs={"username": "subject"}), + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["id"] == self.retirement.id + by_hash = self.client.get( + reverse("user_v2:retirement_detail", kwargs={"username": hashed}), + ) + assert by_hash.status_code == status.HTTP_200_OK + assert by_hash.data["id"] == self.retirement.id + + def test_a_name_no_retirement_is_held_for_is_not_found(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.get(reverse("user_v2:retirement_detail", kwargs={"username": "nobody"})), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + assert envelope["detail"] == view_services.NO_SUCH_RETIREMENT + + def test_an_account_with_no_retirement_behind_it_is_not_found(self): + self.login("worker") + assert_error_envelope( + self.client.get( + reverse("user_v2:retirement_detail", kwargs={"username": "learner-caller"}), + ), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_the_minimal_preset_carries_no_data_of_the_account_owner(self): + self.login("worker") + response = self.client.get(self.url, {"view": "minimal"}) + assert set(response.data) == set(RETIREMENT_MINIMAL_FIELDS) + assert set(response.data) & RETIREMENT_PII_FIELDS == set() + + def test_properties_can_be_selected(self): + self.login("worker") + response = self.client.get(self.url, {"fields": "current_state"}) + assert response.data == {"current_state": "LOCKING_ACCOUNT"} + + def test_a_property_asked_for_as_a_path_is_refused(self): + """ + Selection reaches the top level only, which is what the parameter's description says. + + A name written with a dot in it names no property of the record, so it + is refused rather than read as the property it starts with: answering + that would hand back the whole of the embedded account to a caller + that asked for one property of it, which here is personal data of the + account being retired. + """ + self.login("worker") + response = self.client.get(self.url, {"fields": "user.email"}) + envelope = assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert envelope["errors"]["fields"] == [ + "No such field: user.email. Select any of: " + f"{', '.join(sorted(RetirementSerializer().fields))}.", + ] + assert "user" not in response.data + + def test_the_parameters_that_cut_a_page_are_not_read_at_a_member_address(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.get(self.url, {"page": 2, "state": "PENDING"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert sorted(envelope["errors"]) == ["page", "state"] + assert envelope["errors"]["page"] == [ + f"No such query parameter. Send any of: {DETAIL_QUERY_PARAMS}.", + ] + + def test_a_refusal_is_answered_as_json_rather_than_as_a_page(self): + self.login("worker") + response = self.client.get(reverse("user_v2:retirement_detail", kwargs={"username": "nobody"})) + assert response["Content-Type"] == "application/json" + + def test_the_record_is_readable_without_a_body(self): + self.login("worker") + assert self.client.head(self.url).status_code == status.HTTP_200_OK + + +@skip_unless_lms +class RetirementUpdateTests(_AccessMatrixMixin, RetirementApiTestCase): + """PATCH /api/user/v2/retirements/{username}/""" + + admitted = PIPELINE_CALLERS + refused = PIPELINE_REFUSED + + def setUp(self): + super().setUp() + self.retirement = self.retirement_in("LOCKING_ACCOUNT", user=self.subject) + self.url = reverse("user_v2:retirement_detail", kwargs={"username": "subject"}) + + def send(self): + """Move a retirement of its own, so that one caller's move does not decide the next one's.""" + retirement = self.retirement_in("LOCKING_ACCOUNT") + return self.client.patch( + reverse("user_v2:retirement_detail", kwargs={"username": retirement.original_username}), + {"new_state": "LOCKING_COMPLETE", "response": "done"}, + format="json", + ) + + def patch_subject(self, body, **kwargs): + """Ask for a move of the retirement this address names.""" + return self.client.patch(self.url, body, format="json", **kwargs) + + def test_a_later_step_moves_the_retirement_and_is_logged(self): + self.login("worker") + response = self.patch_subject({"new_state": "LOCKING_COMPLETE", "response": "account locked"}) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert response.data is None + self.retirement.refresh_from_db() + assert self.retirement.current_state.state_name == "LOCKING_COMPLETE" + assert self.retirement.last_state.state_name == "LOCKING_ACCOUNT" + assert "account locked" in self.retirement.responses + + def test_an_earlier_step_is_refused_and_the_retirement_does_not_move(self): + self.login("worker") + envelope = assert_error_envelope( + self.patch_subject({"new_state": "PENDING", "response": "rewind"}), + expected_status=status.HTTP_409_CONFLICT, + expected_type_slug=STATE_CONFLICT_SLUG, + ) + assert envelope["detail"] == view_services.CANNOT_MOVE_TO_STEP + self.retirement.refresh_from_db() + assert self.retirement.current_state.state_name == "LOCKING_ACCOUNT" + + def test_an_earlier_step_moves_the_retirement_when_the_move_is_forced(self): + self.login("worker") + response = self.patch_subject({"new_state": "PENDING", "response": "rewind", "force": True}) + assert response.status_code == status.HTTP_204_NO_CONTENT + self.retirement.refresh_from_db() + assert self.retirement.current_state.state_name == "PENDING" + + def test_a_retirement_that_errored_can_be_moved_on_to_a_later_step(self): + """An operator rewinds and re-runs a retirement that failed, which is what this allows.""" + self.retirement.current_state = self.state("ERRORED") + self.retirement.save() + self.login("worker") + assert self.patch_subject( + {"new_state": "COMPLETE", "response": "finished by hand"}, + ).status_code == status.HTTP_204_NO_CONTENT + self.retirement.refresh_from_db() + assert self.retirement.current_state.state_name == "COMPLETE" + + def test_a_retirement_that_errored_is_not_moved_back_to_an_earlier_step_unless_forced(self): + self.retirement.current_state = self.state("ERRORED") + self.retirement.save() + self.login("worker") + assert_error_envelope( + self.patch_subject({"new_state": "PENDING", "response": "rewind"}), + expected_status=status.HTTP_409_CONFLICT, + expected_type_slug=STATE_CONFLICT_SLUG, + ) + self.retirement.refresh_from_db() + assert self.retirement.current_state.state_name == "ERRORED" + + def test_a_step_this_deployment_does_not_run_is_refused_before_anything_moves(self): + self.login("worker") + envelope = assert_error_envelope( + self.patch_subject({"new_state": "NOPE", "response": "done"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"]["new_state"][0].startswith("No such pipeline step.") + self.retirement.refresh_from_db() + assert self.retirement.current_state.state_name == "LOCKING_ACCOUNT" + + def test_a_forced_move_to_a_step_this_deployment_does_not_run_is_refused_too(self): + self.login("worker") + assert_error_envelope( + self.patch_subject({"new_state": "NOPE", "response": "done", "force": True}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + + def test_a_body_that_names_no_step_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.patch_subject({"response": "done"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "new_state" in envelope["errors"] + + def test_a_body_that_says_nothing_about_the_step_being_left_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.patch_subject({"new_state": "LOCKING_COMPLETE"}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "response" in envelope["errors"] + + def test_an_empty_body_is_refused_for_both_of_the_values_a_move_needs(self): + """The two it names here are the two the published body says it cannot be sent without.""" + self.login("worker") + envelope = assert_error_envelope( + self.patch_subject({}), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert sorted(envelope["errors"]) == MOVE_REQUIRED_VALUES + self.retirement.refresh_from_db() + assert self.retirement.current_state.state_name == "LOCKING_ACCOUNT" + + def test_a_name_spelled_differently_from_the_registered_one_is_not_found(self): + self.login("worker") + assert_error_envelope( + self.client.patch( + reverse("user_v2:retirement_detail", kwargs={"username": "Subject"}), + {"new_state": "LOCKING_COMPLETE", "response": "done"}, + format="json", + ), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + self.retirement.refresh_from_db() + assert self.retirement.current_state.state_name == "LOCKING_ACCOUNT" + + def test_the_hashed_name_does_not_address_a_write(self): + hashed = get_retired_username_by_username("subject") + self.login("worker") + assert_error_envelope( + self.client.patch( + reverse("user_v2:retirement_detail", kwargs={"username": hashed}), + {"new_state": "LOCKING_COMPLETE", "response": "done"}, + format="json", + ), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_a_body_in_a_media_type_this_address_cannot_read_is_refused(self): + self.login("worker") + assert_error_envelope( + self.client.patch(self.url, "new_state=LOCKING_COMPLETE", content_type="text/plain"), + expected_status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + expected_type_slug="unsupported-media-type", + ) + + def test_a_body_that_is_not_well_formed_is_refused(self): + self.login("worker") + assert_error_envelope( + self.client.patch(self.url, "{not json", content_type="application/json"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="malformed-body", + ) + + def test_a_query_parameter_a_write_does_not_read_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.patch_subject({"new_state": "LOCKING_COMPLETE", "response": "done"}, QUERY_STRING="view=minimal"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"]["view"] == [ + f"No such query parameter. Send any of: {WRITE_QUERY_PARAMS}.", + ] + + +@skip_unless_lms +class MovedRetirementRecordTests(RetirementApiTestCase): + """ + A record that has moved names the step it stands at and the step it came from. + + They are two different steps once a retirement has moved, and a caller + driving the pipeline reads the second to tell what has already been done, + so the record is read here after a move rather than before one, when both + name the step the retirement was requested at. + """ + + def setUp(self): + super().setUp() + self.retirement = self.retirement_in("LOCKING_ACCOUNT", user=self.subject) + self.detail_url = reverse("user_v2:retirement_detail", kwargs={"username": "subject"}) + self.list_url = reverse("user_v2:retirement_list") + self.login("worker") + moved = self.client.patch( + self.detail_url, + {"new_state": "LOCKING_COMPLETE", "response": "the account was locked"}, + format="json", + ) + assert moved.status_code == status.HTTP_204_NO_CONTENT, moved.content + self.retirement.refresh_from_db() + + def test_the_record_names_the_step_it_stands_at_and_the_one_it_came_from(self): + response = self.client.get(self.detail_url) + assert response.status_code == status.HTTP_200_OK + assert response.data["current_state"] == "LOCKING_COMPLETE" + assert response.data["last_state"] == "LOCKING_ACCOUNT" + + def test_the_row_in_the_queue_names_both_steps_as_the_record_does(self): + response = self.client.get(self.list_url, {"page_size": 100}) + row = next( + row for row in response.data["results"] + if row["original_username"] == self.retirement.original_username + ) + assert row["current_state"] == "LOCKING_COMPLETE" + assert row["last_state"] == "LOCKING_ACCOUNT" + + def test_the_step_a_record_came_from_can_be_asked_for_on_its_own(self): + response = self.client.get(self.detail_url, {"fields": "last_state"}) + assert response.data == {"last_state": "LOCKING_ACCOUNT"} + + def test_the_minimal_record_names_the_step_it_stands_at(self): + """What a reader of the queue needs is where the retirement is now.""" + response = self.client.get(self.detail_url, {"view": "minimal"}) + assert response.data["current_state"] == "LOCKING_COMPLETE" + assert "last_state" not in response.data + + def test_the_queue_is_narrowed_by_the_step_a_retirement_stands_at(self): + """A retirement is found under the step it is at, not under the one it left.""" + by_current = self.client.get(self.list_url, {"state": "LOCKING_COMPLETE", "page_size": 100}) + assert [row["original_username"] for row in by_current.data["results"]] == [ + self.retirement.original_username, + ] + by_previous = self.client.get(self.list_url, {"state": "LOCKING_ACCOUNT", "page_size": 100}) + assert by_previous.data["results"] == [] + + +@skip_unless_lms +class RetirementCancelTests(_AccessMatrixMixin, RetirementApiTestCase): + """DELETE /api/user/v2/retirements/{username}/""" + + admitted = CANCEL_CALLERS + refused = CANCEL_REFUSED + + def send(self): + user = UserFactory.create(email="to-cancel@example.com") + create_retirement_status(user) + return self.client.delete( + reverse("user_v2:retirement_detail", kwargs={"username": user.username}), + ) + + def test_the_request_is_gone_and_the_account_is_usable_again(self): + retirement = create_retirement_status(self.subject) + self.subject.email = retirement.retired_email + self.subject.save() + self.login("canceller") + response = self.client.delete( + reverse("user_v2:retirement_detail", kwargs={"username": "subject"}), + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not UserRetirementStatus.objects.filter(id=retirement.id).exists() + assert not UserRetirementRequest.objects.filter(user=self.subject).exists() + self.subject.refresh_from_db() + assert self.subject.email == "subject@example.com" + assert not self.subject.check_password(PASSWORD) + + def test_a_retirement_the_pipeline_has_started_on_is_not_cancelled(self): + retirement = self.retirement_in("LOCKING_ACCOUNT", user=self.subject) + self.login("canceller") + envelope = assert_error_envelope( + self.client.delete(reverse("user_v2:retirement_detail", kwargs={"username": "subject"})), + expected_status=status.HTTP_409_CONFLICT, + expected_type_slug=STATE_CONFLICT_SLUG, + ) + assert envelope["detail"] == view_services.ONLY_PENDING_IS_CANCELLED + assert UserRetirementStatus.objects.filter(id=retirement.id).exists() + + def test_a_name_no_retirement_is_requested_under_is_not_found(self): + self.login("canceller") + assert_error_envelope( + self.client.delete(reverse("user_v2:retirement_detail", kwargs={"username": "nobody"})), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_a_name_spelled_differently_from_the_registered_one_is_not_found(self): + retirement = create_retirement_status(self.subject) + self.login("canceller") + assert_error_envelope( + self.client.delete(reverse("user_v2:retirement_detail", kwargs={"username": "SUBJECT"})), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + assert UserRetirementStatus.objects.filter(id=retirement.id).exists() + + +@skip_unless_lms +class RetirementErasureTests(_AccessMatrixMixin, RetirementApiTestCase): + """POST /api/user/v2/retirements/{username}/erasures/""" + + admitted = PIPELINE_CALLERS + refused = PIPELINE_REFUSED + + def setUp(self): + super().setUp() + self.retirement = self.retirement_in("RETIRING_LMS", user=self.subject) + self.url = reverse("user_v2:retirement_erasure", kwargs={"username": "subject"}) + + def send(self, scope="lms_core"): + return self.client.post(self.url, {"scope": scope}, format="json") + + def test_the_core_scope_erases_the_account_and_its_profile(self): + self.subject.profile.name = "Subject Name" + self.subject.profile.city = "Somewhere" + self.subject.profile.save() + PendingEmailChange.objects.create( + user=self.subject, new_email="new@example.com", activation_key="k" * 32, + ) + UserOrgTag.objects.create(user=self.subject, key="foo", value="bar", org="edX") + AccountRecovery.objects.create( + user=self.subject, secondary_email="recovery@example.com", is_active=True, + ) + CourseEnrollmentAllowed.objects.create(email="subject@example.com", course_id=COURSE_KEY) + cache.set(UserProfile.country_cache_key_name(self.subject.id), "Timor-leste") + + self.login("worker") + assert self.send("lms_core").status_code == status.HTTP_204_NO_CONTENT + + self.subject.refresh_from_db() + assert self.subject.username == self.retirement.retired_username + assert self.subject.email == self.retirement.retired_email + assert self.subject.first_name == "" + assert self.subject.last_name == "" + assert self.subject.is_active is False + assert self.subject.profile.name == "" + assert self.subject.profile.city is None + assert not PendingEmailChange.objects.filter(user=self.subject).exists() + assert not UserOrgTag.objects.filter(user=self.subject).exists() + assert not AccountRecovery.objects.filter(user=self.subject).exists() + assert not CourseEnrollmentAllowed.objects.filter(email="subject@example.com").exists() + assert cache.get(UserProfile.country_cache_key_name(self.subject.id)) is None + + def test_the_miscellaneous_scope_erases_what_the_features_around_the_account_hold(self): + PendingNameChange.objects.create(user=self.subject, new_name="Another Name", rationale="x") + self.login("worker") + assert self.send("lms_misc").status_code == status.HTTP_204_NO_CONTENT + assert not PendingNameChange.objects.filter(user=self.subject).exists() + self.subject.refresh_from_db() + # The miscellaneous scope leaves the account itself alone. + assert self.subject.username == "subject" + assert self.subject.is_active is True + + def test_a_second_run_works_the_replacement_name_out_from_the_registered_name(self): + """ + The record holds no replacement name of its own, so the step works one out. + + It works it out from the name the account was registered under, which the record keeps, + rather than from the name the account holds, which this very step has already replaced. + """ + UserRetirementStatus.objects.filter(id=self.retirement.id).update(retired_username="") + announced = [] + USER_RETIRE_LMS_CRITICAL.connect( + lambda sender, **kwargs: announced.append(kwargs["retired_username"]), + weak=False, + dispatch_uid="replacement-name-of-each-run", + ) + self.addCleanup( + USER_RETIRE_LMS_CRITICAL.disconnect, dispatch_uid="replacement-name-of-each-run", + ) + self.login("worker") + assert self.send("lms_core").status_code == status.HTTP_204_NO_CONTENT + self.subject.refresh_from_db() + written = self.subject.username + assert written == get_retired_username_by_username(self.retirement.original_username) + assert self.send("lms_core").status_code == status.HTTP_204_NO_CONTENT + self.subject.refresh_from_db() + assert self.subject.username == written + assert announced == [written, written] + + def test_the_erasure_does_not_move_the_retirement(self): + self.login("worker") + self.send("lms_core") + self.retirement.refresh_from_db() + assert self.retirement.current_state.state_name == "RETIRING_LMS" + + def test_a_body_that_names_no_scope_is_refused_and_nothing_is_erased(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.post(self.url, {}, format="json"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "scope" in envelope["errors"] + self.subject.refresh_from_db() + assert self.subject.username == "subject" + + def test_a_scope_this_address_does_not_erase_is_refused_and_nothing_is_erased(self): + self.login("worker") + envelope = assert_error_envelope( + self.send("everything"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "scope" in envelope["errors"] + self.subject.refresh_from_db() + assert self.subject.username == "subject" + + def test_a_retirement_waiting_for_the_learner_is_not_erased(self): + self.retirement.current_state = self.state("PENDING") + self.retirement.save() + self.login("worker") + envelope = assert_error_envelope( + self.send(), + expected_status=status.HTTP_409_CONFLICT, + expected_type_slug=STATE_CONFLICT_SLUG, + ) + assert envelope["detail"] == view_services.NOT_OPEN_TO_ACTION + self.subject.refresh_from_db() + assert self.subject.username == "subject" + + def test_a_retirement_at_a_step_already_recorded_as_finished_is_not_erased(self): + self.retirement.current_state = self.state("LMS_COMPLETE") + self.retirement.save() + self.login("worker") + assert_error_envelope( + self.send(), + expected_status=status.HTTP_409_CONFLICT, + expected_type_slug=STATE_CONFLICT_SLUG, + ) + + def test_a_retirement_at_a_dead_end_step_is_not_erased(self): + self.retirement.current_state = self.state("ERRORED") + self.retirement.save() + self.login("worker") + assert_error_envelope( + self.send(), + expected_status=status.HTTP_409_CONFLICT, + expected_type_slug=STATE_CONFLICT_SLUG, + ) + + def test_a_name_no_retirement_is_requested_under_is_not_found(self): + self.login("worker") + assert_error_envelope( + self.client.post( + reverse("user_v2:retirement_erasure", kwargs={"username": "nobody"}), + {"scope": "lms_core"}, + format="json", + ), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_a_step_that_fails_partway_through_says_nothing_about_why(self): + self.login("worker") + with mock.patch( + "openedx.core.djangoapps.user_api.v2.view_services.AccountRetirementView" + ".clear_pii_from_certificate_records", + side_effect=ValueError("the certificate store is on fire"), + ): + response = self.client.post(self.url, {"scope": "lms_core"}, format="json") + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + body = response.json() + # The answer carries the same envelope as every other refusal of this + # address, which is what the document publishes for it. + assert body == { + "type": error_type_uri(INTERNAL_ERROR_SLUG), + "title": INTERNAL_ERROR_TITLE, + "status": status.HTTP_500_INTERNAL_SERVER_ERROR, + "detail": "An unexpected error occurred. Please try again later.", + "instance": self.url, + } + assert "fire" not in str(body) + + +@skip_unless_lms +class PartnerReportClaimTests(_AccessMatrixMixin, RetirementApiTestCase): + """POST /api/user/v2/retirement_partner_reports/""" + + admitted = PIPELINE_CALLERS + refused = PIPELINE_REFUSED + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:retirement_partner_report_list") + self.entry = self.queued(self.subject) + + def queued(self, user): + """Return a partner report entry waiting to be taken, for this user.""" + return UserRetirementPartnerReportingStatus.objects.create( + user=user, + original_username=user.username, + original_email=user.email, + original_name=user.profile.name, + ) + + def send(self): + return self.client.post(self.url, {}, format="json") + + def test_the_entry_carries_what_a_partner_needs(self): + CourseEnrollmentFactory.create(user=self.subject, course_id=COURSE_KEY) + CourseEnrollmentFactory.create(user=self.subject, course_id=OTHER_COURSE_KEY) + self.login("worker") + response = self.client.post(self.url, {}, format="json") + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 1 + entry = response.data[0] + assert set(entry) == { + "user_id", "original_username", "original_email", "original_name", "orgs", "created", + } + assert entry["user_id"] == self.subject.id + assert entry["original_username"] == "subject" + assert entry["original_email"] == "subject@example.com" + assert entry["orgs"] == ["OtherOrg", "edX"] + + def test_an_account_enrolled_with_nobody_carries_no_organizations(self): + self.login("worker") + response = self.client.post(self.url, {}, format="json") + assert response.data[0]["orgs"] == [] + + def test_the_entries_returned_are_exactly_the_entries_taken(self): + other = self.queued(self.learner) + self.login("worker") + response = self.client.post(self.url, {}, format="json") + returned = {entry["original_username"] for entry in response.data} + assert returned == {"subject", "learner-caller"} + taken = set( + UserRetirementPartnerReportingStatus.objects + .filter(is_being_processed=True) + .values_list("original_username", flat=True), + ) + assert taken == returned + assert UserRetirementPartnerReportingStatus.objects.get(id=other.id).is_being_processed + + def test_a_second_request_takes_nothing(self): + self.login("worker") + self.client.post(self.url, {}, format="json") + again = self.client.post(self.url, {}, format="json") + assert again.status_code == status.HTTP_200_OK + assert again.data == [] + + def test_an_entry_added_while_the_answer_is_built_is_left_for_the_next_caller(self): + added = [] + + def add_one_entry_behind_the_reader(report): + if not added: + added.append(self.queued(self.learner)) + return original(report) + + original = view_services._partner_report_of # pylint: disable=protected-access + self.login("worker") + with mock.patch.object( + view_services, "_partner_report_of", side_effect=add_one_entry_behind_the_reader, + ): + response = self.client.post(self.url, {}, format="json") + + assert [entry["original_username"] for entry in response.data] == ["subject"] + latecomer = UserRetirementPartnerReportingStatus.objects.get(id=added[0].id) + assert latecomer.is_being_processed is False + assert UserRetirementPartnerReportingStatus.objects.get(id=self.entry.id).is_being_processed + + def test_at_most_the_number_of_entries_asked_for_is_taken(self): + self.queued(self.learner) + self.login("worker") + response = self.client.post(f"{self.url}?limit=1", {}, format="json") + assert len(response.data) == 1 + assert UserRetirementPartnerReportingStatus.objects.filter(is_being_processed=True).count() == 1 + + def test_taking_no_entries_at_all_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.post(f"{self.url}?limit=0", {}, format="json"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "limit" in envelope["errors"] + assert UserRetirementPartnerReportingStatus.objects.filter(is_being_processed=True).count() == 0 + + def test_a_query_parameter_this_address_does_not_read_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.post(f"{self.url}?count=1", {}, format="json"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"]["count"] == [ + f"No such query parameter. Send any of: {CLAIM_QUERY_PARAMS}.", + ] + + +@skip_unless_lms +class PartnerReportQueueTests(_AccessMatrixMixin, RetirementApiTestCase): + """PUT /api/user/v2/retirement_partner_reports/{username}/""" + + admitted = PIPELINE_CALLERS + refused = PIPELINE_REFUSED + + def setUp(self): + super().setUp() + self.retirement = self.retirement_in("ADDING_TO_PARTNER_QUEUE", user=self.subject) + CourseEnrollmentFactory.create(user=self.subject, course_id=COURSE_KEY) + self.url = reverse("user_v2:retirement_partner_report_detail", kwargs={"username": "subject"}) + + def send(self): + return self.client.put(self.url, {}, format="json") + + def test_an_account_that_studied_with_an_organization_is_queued(self): + self.login("worker") + assert self.send().status_code == status.HTTP_204_NO_CONTENT + entry = UserRetirementPartnerReportingStatus.objects.get(user=self.subject) + assert entry.original_username == "subject" + assert entry.original_email == "subject@example.com" + assert entry.original_name == self.subject.profile.name + assert entry.is_being_processed is False + + def test_an_account_that_studied_with_nobody_is_not_queued(self): + learner_retirement = self.retirement_in("ADDING_TO_PARTNER_QUEUE", user=self.learner) + self.login("worker") + response = self.client.put( + reverse( + "user_v2:retirement_partner_report_detail", + kwargs={"username": learner_retirement.original_username}, + ), + {}, + format="json", + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not UserRetirementPartnerReportingStatus.objects.filter(user=self.learner).exists() + + def test_queueing_the_same_account_twice_leaves_one_entry(self): + self.login("worker") + assert self.send().status_code == status.HTTP_204_NO_CONTENT + assert self.send().status_code == status.HTTP_204_NO_CONTENT + assert UserRetirementPartnerReportingStatus.objects.filter(user=self.subject).count() == 1 + + def test_a_name_no_retirement_is_requested_under_is_not_found(self): + self.login("worker") + assert_error_envelope( + self.client.put( + reverse("user_v2:retirement_partner_report_detail", kwargs={"username": "nobody"}), + {}, + format="json", + ), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_a_retirement_at_a_step_that_admits_no_work_is_refused(self): + self.retirement.current_state = self.state("PENDING") + self.retirement.save() + self.login("worker") + assert_error_envelope( + self.send(), + expected_status=status.HTTP_409_CONFLICT, + expected_type_slug=STATE_CONFLICT_SLUG, + ) + assert not UserRetirementPartnerReportingStatus.objects.filter(user=self.subject).exists() + + def test_a_name_spelled_differently_from_the_registered_one_is_not_found(self): + self.login("worker") + assert_error_envelope( + self.client.put( + reverse("user_v2:retirement_partner_report_detail", kwargs={"username": "Subject"}), + {}, + format="json", + ), + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + + def test_how_many_entries_to_take_is_not_read_where_none_are_taken(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.put(f"{self.url}?limit=1", {}, format="json"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"]["limit"] == [ + f"No such query parameter. Send any of: {WRITE_QUERY_PARAMS}.", + ] + + +class _BatchTestsMixin(_AccessMatrixMixin): + """What both batch disposals answer, whichever records they act on.""" + + admitted = PIPELINE_CALLERS + refused = PIPELINE_REFUSED + + def post(self, usernames): + """Name these accounts to the address under test.""" + return self.client.post(self.url, {"usernames": usernames}, format="json") + + def test_a_body_naming_no_account_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.post([]), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "usernames" in envelope["errors"] + + def test_a_body_naming_more_accounts_than_a_batch_carries_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.post([f"name-{index}" for index in range(MAXIMUM_PAGE_SIZE + 1)]), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert "usernames" in envelope["errors"] + + def test_a_body_that_is_not_an_object_is_refused(self): + self.login("worker") + assert_error_envelope( + self.client.post(self.url, [{"original_username": "subject"}], format="json"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + + def test_a_body_in_a_media_type_this_address_cannot_read_is_refused(self): + self.login("worker") + assert_error_envelope( + self.client.post(self.url, "usernames=subject", content_type="text/plain"), + expected_status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + expected_type_slug="unsupported-media-type", + ) + + def test_a_query_parameter_this_address_does_not_read_is_refused(self): + self.login("worker") + envelope = assert_error_envelope( + self.client.post(f"{self.url}?limit=1", {"usernames": ["subject"]}, format="json"), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug="validation", + ) + assert envelope["errors"]["limit"] == [ + f"No such query parameter. Send any of: {WRITE_QUERY_PARAMS}.", + ] + + +@skip_unless_lms +class RetirementCleanupTests(_BatchTestsMixin, RetirementApiTestCase): + """POST /api/user/v2/retirement_cleanups/""" + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:retirement_cleanup_list") + self.finished = self.retirement_in("COMPLETE", user=self.subject) + + def send(self): + retirement = self.retirement_in("COMPLETE") + return self.post([retirement.original_username]) + + def test_a_finished_record_is_redacted_and_then_deleted(self): + self.login("worker") + response = self.post(["subject"]) + assert response.status_code == status.HTTP_200_OK + assert response.data == {"results": [{"username": "subject", "outcome": "deleted"}]} + assert not UserRetirementStatus.objects.filter(id=self.finished.id).exists() + + def test_the_record_carries_the_replacement_before_it_goes(self): + """The redaction and the delete are separate statements, so the replacement is written.""" + self.login("worker") + seen = [] + original = UserRetirementStatus.objects.filter + + def remember_the_values_the_row_holds(*args, **kwargs): + selection = original(*args, **kwargs) + seen.append(list(selection.values_list("original_username", "original_email"))) + return selection + + with mock.patch.object( + UserRetirementStatus.objects, "filter", side_effect=remember_the_values_the_row_holds, + ): + self.post(["subject"]) + # The last selection is the delete, which sees the written-over values. + assert seen[-1] == [(view_services.REDACTED_VALUE, view_services.REDACTED_EMAIL)] + + def test_a_record_moved_off_that_step_between_the_two_statements_is_left(self): + """The caller was answered about a finished record, so only a finished record goes.""" + self.login("worker") + selections = [] + original = UserRetirementStatus.objects.filter + + def move_the_record_before_the_delete_runs(*args, **kwargs): + selection = original(*args, **kwargs) + selections.append(selection) + if len(selections) == 2: + forced = UserRetirementStatus.objects.get(id=self.finished.id) + forced.current_state = self.state("ERRORED") + forced.save() + return selection + + with mock.patch.object( + UserRetirementStatus.objects, "filter", + side_effect=move_the_record_before_the_delete_runs, + ): + answered = self.post(["subject"]) + assert answered.status_code == status.HTTP_200_OK + left = UserRetirementStatus.objects.get(id=self.finished.id) + assert left.current_state.state_name == "ERRORED" + + def test_a_repeat_of_a_request_already_carried_out_is_answered(self): + self.login("worker") + self.post(["subject"]) + again = self.post(["subject"]) + assert again.status_code == status.HTTP_200_OK + assert again.data == {"results": [{"username": "subject", "outcome": "already_absent"}]} + + def test_each_name_is_reported_in_the_order_it_was_given(self): + other = self.retirement_in("COMPLETE") + self.login("worker") + response = self.post([other.original_username, "nobody", "subject"]) + assert response.data == { + "results": [ + {"username": other.original_username, "outcome": "deleted"}, + {"username": "nobody", "outcome": "already_absent"}, + {"username": "subject", "outcome": "deleted"}, + ], + } + + def test_a_name_given_twice_is_acted_on_and_reported_once(self): + self.login("worker") + response = self.post(["subject", "subject"]) + assert response.data == {"results": [{"username": "subject", "outcome": "deleted"}]} + + def test_an_unfinished_record_refuses_the_whole_batch_and_nothing_is_deleted(self): + unfinished = self.retirement_in("LOCKING_ACCOUNT") + self.login("worker") + envelope = assert_error_envelope( + self.post(["subject", unfinished.original_username]), + expected_status=status.HTTP_409_CONFLICT, + expected_type_slug=STATE_CONFLICT_SLUG, + ) + assert envelope["detail"] == view_services.BATCH_LEFT_ALONE + assert envelope["errors"] == { + unfinished.original_username: [ + "This retirement stands at LOCKING_ACCOUNT; only a retirement at COMPLETE is deleted here.", + ], + } + assert UserRetirementStatus.objects.filter(id=self.finished.id).exists() + assert UserRetirementStatus.objects.filter(id=unfinished.id).exists() + + def test_a_name_spelled_differently_from_the_registered_one_leaves_the_record_alone(self): + self.login("worker") + response = self.post(["SUBJECT"]) + assert response.data == {"results": [{"username": "SUBJECT", "outcome": "already_absent"}]} + assert UserRetirementStatus.objects.filter(id=self.finished.id).exists() + + +@skip_unless_lms +class PartnerReportCleanupTests(_BatchTestsMixin, RetirementApiTestCase): + """POST /api/user/v2/retirement_partner_report_cleanups/""" + + def setUp(self): + super().setUp() + self.url = reverse("user_v2:retirement_partner_report_cleanup_list") + self.reported = self.queued(self.subject, taken=True) + + def queued(self, user, taken=False): + """Return a partner report entry for this user, taken for processing or not.""" + return UserRetirementPartnerReportingStatus.objects.create( + user=user, + original_username=user.username, + original_email=user.email, + original_name=user.profile.name, + is_being_processed=taken, + ) + + def send(self): + entry = self.queued(UserFactory.create(), taken=True) + return self.post([entry.original_username]) + + def test_an_entry_that_has_been_reported_on_is_deleted(self): + self.login("worker") + response = self.post(["subject"]) + assert response.status_code == status.HTTP_200_OK + assert response.data == {"results": [{"username": "subject", "outcome": "deleted"}]} + assert not UserRetirementPartnerReportingStatus.objects.filter(id=self.reported.id).exists() + + def test_a_repeat_of_a_request_already_carried_out_is_answered(self): + self.login("worker") + self.post(["subject"]) + again = self.post(["subject"]) + assert again.data == {"results": [{"username": "subject", "outcome": "already_absent"}]} + + def test_an_entry_nobody_has_taken_refuses_the_whole_batch_and_nothing_is_deleted(self): + waiting = self.queued(self.learner) + self.login("worker") + envelope = assert_error_envelope( + self.post(["subject", waiting.original_username]), + expected_status=status.HTTP_409_CONFLICT, + expected_type_slug=STATE_CONFLICT_SLUG, + ) + assert envelope["errors"] == { + waiting.original_username: [ + "This partner report has not been taken for processing, so it has not been reported on yet.", + ], + } + assert UserRetirementPartnerReportingStatus.objects.filter(id=self.reported.id).exists() + assert UserRetirementPartnerReportingStatus.objects.filter(id=waiting.id).exists() + + def test_each_name_is_reported_in_the_order_it_was_given(self): + other = self.queued(UserFactory.create(username="other-subject"), taken=True) + self.login("worker") + response = self.post(["other-subject", "nobody", "subject"]) + assert response.data == { + "results": [ + {"username": "other-subject", "outcome": "deleted"}, + {"username": "nobody", "outcome": "already_absent"}, + {"username": "subject", "outcome": "deleted"}, + ], + } + assert not UserRetirementPartnerReportingStatus.objects.filter(id=other.id).exists() + + +@skip_unless_lms +class RetirementAccessModelTests(RetirementApiTestCase): + """How the capabilities of this area are combined, and what happens with none of them.""" + + def test_an_action_the_mapping_does_not_name_reaches_no_capability(self): + """A later action cannot inherit the capability every named action shares.""" + viewset = RetirementViewSet() + viewset.action = "an_action_added_later" + granted = [ + permission.has_permission(mock.Mock(user=self.superuser), viewset) + for permission in viewset.get_permissions() + ] + assert granted[-1] is False + + def test_each_named_action_carries_the_capability_it_is_mapped_to(self): + """The classes are read from the view rather than from the mapping, so the mapping can be wrong.""" + expected = { + "list": CanRetireUser, + "retrieve": CanRetireUser, + "partial_update": CanRetireUser, + "erasures": CanRetireUser, + "destroy": CanCancelUserRetirement, + "metadata": CanRetireUser, + } + assert set(expected) == set(RETIREMENT_ACTION_PERMISSIONS) + for action, capability in expected.items(): + with self.subTest(action=action): + viewset = RetirementViewSet() + viewset.action = action + built = [type(permission) for permission in viewset.get_permissions()] + assert built[-1] is capability + assert permissions.IsAuthenticated in built + + def test_the_classes_of_a_view_must_all_admit_a_caller(self): + """Being authenticated is not enough, and holding the capability without being signed in is not either.""" + self.login("staff") + assert_error_envelope( + self.client.get(reverse("user_v2:retirement_list")), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + self.client.logout() + assert_error_envelope( + self.client.get(reverse("user_v2:retirement_list")), + expected_status=status.HTTP_401_UNAUTHORIZED, + expected_type_slug="authn", + ) + + def test_no_retirement_view_declares_its_own_authentication(self): + for view in ( + RetirementViewSet, + RetirementPartnerReportViewSet, + RetirementCleanupViewSet, + RetirementPartnerReportCleanupViewSet, + _RetirementRecordAccessMixin, + _PartnerReportAccessMixin, + ): + with self.subTest(view=view.__name__): + assert "authentication_classes" not in vars(view) + + def test_no_retirement_view_declares_its_own_throttles(self): + """The pipeline is unthrottled on both versions alike, which is left as it stands.""" + for view in ( + RetirementViewSet, + RetirementPartnerReportViewSet, + RetirementCleanupViewSet, + RetirementPartnerReportCleanupViewSet, + _RetirementRecordAccessMixin, + _PartnerReportAccessMixin, + ): + with self.subTest(view=view.__name__): + assert "throttle_classes" not in vars(view) + + +@skip_unless_lms +class RetirementRecordAccessReachTests(RetirementApiTestCase): + """ + What is asked of the operations on the retirement records is asked of their disposal. + + The records the collection lists, moves and cancels are the records the + batch disposal deletes, so both resolve who may act on them and which of + them a caller acts on through one layer. A rule added to that layer is + read here from the answers each of them gives, rather than from where the + rule is written. + """ + + def setUp(self): + super().setUp() + self.list_url = reverse("user_v2:retirement_list") + self.disposal_url = reverse("user_v2:retirement_cleanup_list") + self.finished = self.retirement_in("COMPLETE", user=self.subject) + self.other = self.retirement_in("COMPLETE") + + def dispose_of(self, usernames): + """Ask the disposal to delete the records of these accounts.""" + return self.client.post(self.disposal_url, {"usernames": usernames}, format="json") + + @contextlib.contextmanager + def a_rule_added_to_the_records(self): + """Add a rule to the layer the operations on these records share.""" + with layers_read_from_their_own_declaration(RetirementViewSet, RetirementCleanupViewSet): + with mock.patch.object( + _RetirementRecordAccessMixin, "permission_classes", + (permissions.IsAuthenticated, _OnlyASuperuser), + ): + yield + + def test_a_rule_added_to_the_records_is_asked_of_their_disposal(self): + self.login("worker") + with self.a_rule_added_to_the_records(): + refused = self.dispose_of(["subject"]) + assert_error_envelope( + refused, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + assert UserRetirementStatus.objects.filter(id=self.finished.id).exists() + + def test_the_same_rule_is_asked_of_the_collection_those_records_are_listed_by(self): + """The rule is added in one place, so what obeys it is what resolves it there.""" + self.login("worker") + with self.a_rule_added_to_the_records(): + assert_error_envelope( + self.client.get(self.list_url), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_the_caller_the_rule_admits_is_served_by_both(self): + """The refusals above are the rule's doing, and the rule admits somebody.""" + self.login("superuser") + with self.a_rule_added_to_the_records(): + assert self.client.get(self.list_url).status_code == status.HTTP_200_OK + assert self.dispose_of(["subject"]).status_code == status.HTTP_200_OK + + def test_the_same_caller_is_served_by_the_disposal_without_that_rule(self): + """The refusal above is the rule's doing and not the caller's.""" + self.login("worker") + answered = self.dispose_of(["subject"]) + assert answered.status_code == status.HTTP_200_OK + assert answered.data == {"results": [{"username": "subject", "outcome": "deleted"}]} + + def test_a_record_the_visibility_policy_leaves_out_is_not_deleted_by_the_disposal(self): + self.login("worker") + policy = _NarrowingPolicy("original_username", self.other.original_username) + with mock.patch.object(_RetirementRecordAccessMixin, "scoping_policy", policy): + answered = self.dispose_of(["subject", self.other.original_username]) + assert answered.data == { + "results": [ + {"username": "subject", "outcome": "already_absent"}, + {"username": self.other.original_username, "outcome": "deleted"}, + ], + } + assert UserRetirementStatus.objects.filter(id=self.finished.id).exists() + assert not UserRetirementStatus.objects.filter(id=self.other.id).exists() + + def test_the_policy_is_given_the_caller_and_every_record_the_disposal_could_delete(self): + self.login("worker") + policy = _NarrowingPolicy("original_username", self.other.original_username) + with mock.patch.object(_RetirementRecordAccessMixin, "scoping_policy", policy): + self.dispose_of(["subject"]) + assert [subject.username for subject in policy.subjects] == [WORKER_USERNAME] + assert policy.offered == [sorted(["subject", self.other.original_username])] + + def test_the_disposal_declares_none_of_these_layers_itself(self): + """Declaring one again here is what would leave a rule added to the records behind.""" + with layers_read_from_their_own_declaration(RetirementViewSet, RetirementCleanupViewSet): + for name, declared_by in ( + ("permission_classes", _RetirementRecordAccessMixin), + ("scoping_policy", _RetirementRecordAccessMixin), + ("get_permissions", _RetirementRecordAccessMixin), + ("get_queryset", _RetirementRecordsMixin), + ): + with self.subTest(declaration=name): + assert name not in vars(RetirementCleanupViewSet) + assert name not in vars(RetirementViewSet) + assert name in vars(declared_by) + + def test_the_classes_each_view_resolves_are_the_ones_the_shared_layer_declares(self): + """Neither view declares them itself, so these are the classes the layer resolves to.""" + resolved = {} + with layers_read_from_their_own_declaration(RetirementViewSet, RetirementCleanupViewSet): + for view, action in ( + (RetirementViewSet, "list"), + (RetirementViewSet, "destroy"), + (RetirementCleanupViewSet, "create"), + (RetirementCleanupViewSet, "metadata"), + ): + viewset = view() + viewset.action = action + resolved[(view.__name__, action)] = [ + type(permission) for permission in viewset.get_permissions() + ] + assert resolved == { + ("RetirementViewSet", "list"): [permissions.IsAuthenticated, CanRetireUser], + ("RetirementViewSet", "destroy"): [permissions.IsAuthenticated, CanCancelUserRetirement], + ("RetirementCleanupViewSet", "create"): [permissions.IsAuthenticated, CanRetireUser], + ("RetirementCleanupViewSet", "metadata"): [permissions.IsAuthenticated, CanRetireUser], + } + + def test_the_policy_declared_for_these_records_is_the_one_each_view_resolves(self): + """The library mixin declares the same attribute, so where it stands in the order matters.""" + for view in (RetirementViewSet, RetirementCleanupViewSet): + with self.subTest(view=view.__name__): + assert isinstance(view().scoping_policy, FullScopePolicy) + + def test_the_disposal_asks_for_the_capability_the_rest_of_the_pipeline_work_asks_for(self): + assert RetirementCleanupViewSet.action_permissions == { + "create": (CanRetireUser,), + "metadata": (CanRetireUser,), + } + for action in RetirementCleanupViewSet.action_permissions: + with self.subTest(action=action): + viewset = RetirementCleanupViewSet() + viewset.action = action + built = [type(permission) for permission in viewset.get_permissions()] + assert built[-1] is CanRetireUser + assert permissions.IsAuthenticated in built + + def test_asking_the_disposal_what_it_serves_asks_for_the_same_capability(self): + """Describing an address is reading it, and this address is read by whoever may work it.""" + self.login("worker") + described = self.client.options(self.disposal_url) + assert described.status_code == status.HTTP_200_OK + assert described.data["renders"] == ["application/json"] + self.client.logout() + self.login("learner") + assert_error_envelope( + self.client.options(self.disposal_url), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_an_action_added_to_the_disposal_later_reaches_no_capability(self): + viewset = RetirementCleanupViewSet() + viewset.action = "an_action_added_later" + assert [ + permission.has_permission(mock.Mock(user=self.superuser), viewset) + for permission in viewset.get_permissions() + ][-1] is False + + +@skip_unless_lms +class PartnerReportAccessReachTests(RetirementApiTestCase): + """What is asked of the operations on the partner report entries is asked of their disposal.""" + + def setUp(self): + super().setUp() + self.queue_url = reverse("user_v2:retirement_partner_report_list") + self.disposal_url = reverse("user_v2:retirement_partner_report_cleanup_list") + self.reported = self.queued(self.subject, taken=True) + self.also_reported = self.queued(self.learner, taken=True) + + def queued(self, user, taken=False): + """Return a partner report entry for this user, taken for processing or not.""" + return UserRetirementPartnerReportingStatus.objects.create( + user=user, + original_username=user.username, + original_email=user.email, + original_name=user.profile.name, + is_being_processed=taken, + ) + + def dispose_of(self, usernames): + """Ask the disposal to delete the entries of these accounts.""" + return self.client.post(self.disposal_url, {"usernames": usernames}, format="json") + + def test_a_rule_added_to_the_entries_is_asked_of_their_disposal(self): + self.login("worker") + with layers_read_from_their_own_declaration( + RetirementPartnerReportViewSet, RetirementPartnerReportCleanupViewSet, + ), mock.patch.object( + _PartnerReportAccessMixin, "permission_classes", + (permissions.IsAuthenticated, _OnlyASuperuser), + ): + refused = self.dispose_of(["subject"]) + taking = self.client.post(self.queue_url, {}, format="json") + for response in (refused, taking): + assert_error_envelope( + response, expected_status=status.HTTP_403_FORBIDDEN, expected_type_slug="authz", + ) + assert UserRetirementPartnerReportingStatus.objects.filter(id=self.reported.id).exists() + + def test_the_same_caller_is_served_by_the_disposal_without_that_rule(self): + self.login("worker") + answered = self.dispose_of(["subject"]) + assert answered.status_code == status.HTTP_200_OK + assert answered.data == {"results": [{"username": "subject", "outcome": "deleted"}]} + + def test_an_entry_the_visibility_policy_leaves_out_is_not_deleted_by_the_disposal(self): + self.login("worker") + policy = _NarrowingPolicy("original_username", self.learner.username) + with mock.patch.object(_PartnerReportAccessMixin, "scoping_policy", policy): + answered = self.dispose_of(["subject", self.learner.username]) + assert answered.data == { + "results": [ + {"username": "subject", "outcome": "already_absent"}, + {"username": self.learner.username, "outcome": "deleted"}, + ], + } + assert UserRetirementPartnerReportingStatus.objects.filter(id=self.reported.id).exists() + assert not UserRetirementPartnerReportingStatus.objects.filter( + id=self.also_reported.id, + ).exists() + + def test_the_disposal_resolves_the_entries_the_queue_leaves_out(self): + """An entry is disposed of once it has been taken, which is when the queue stops offering it.""" + self.login("worker") + policy = _NarrowingPolicy("original_username", "nobody") + with mock.patch.object(_PartnerReportAccessMixin, "scoping_policy", policy): + self.dispose_of(["subject"]) + self.client.post(self.queue_url, {}, format="json") + assert policy.offered == [ + sorted([self.subject.username, self.learner.username]), + [], + ] + + def test_the_disposal_declares_neither_layer_itself(self): + with layers_read_from_their_own_declaration( + RetirementPartnerReportViewSet, RetirementPartnerReportCleanupViewSet, + ): + for name in ("permission_classes", "scoping_policy"): + with self.subTest(declaration=name): + assert name not in vars(RetirementPartnerReportCleanupViewSet) + assert name not in vars(RetirementPartnerReportViewSet) + assert name in vars(_PartnerReportAccessMixin) + + def test_the_classes_each_view_resolves_are_the_ones_the_shared_layer_declares(self): + """Neither view declares them itself, so these are the classes the layer resolves to.""" + with layers_read_from_their_own_declaration( + RetirementPartnerReportViewSet, RetirementPartnerReportCleanupViewSet, + ): + resolved = { + view.__name__: [type(permission) for permission in view().get_permissions()] + for view in ( + RetirementPartnerReportViewSet, RetirementPartnerReportCleanupViewSet, + ) + } + assert resolved == { + "RetirementPartnerReportViewSet": [permissions.IsAuthenticated, CanRetireUser], + "RetirementPartnerReportCleanupViewSet": [permissions.IsAuthenticated, CanRetireUser], + } + + def test_the_policy_declared_for_these_entries_is_the_one_each_view_resolves(self): + """The library mixin declares the same attribute, so where it stands in the order matters.""" + for view in (RetirementPartnerReportViewSet, RetirementPartnerReportCleanupViewSet): + with self.subTest(view=view.__name__): + assert isinstance(view().scoping_policy, FullScopePolicy) + + +@skip_unless_lms +class RetirementMemberVisibilityTests(RetirementApiTestCase): + """ + A retirement the visibility policy leaves out is absent from every member address too. + + The policy this deployment runs keeps the records whole, because every + caller these addresses admit holds the capability to work the whole + pipeline, so it is replaced here with one that keeps one record of two and + each member address is asked about the other. + """ + + def setUp(self): + super().setUp() + self.kept = self.retirement_in("RETIRING_LMS", user=self.learner) + self.left_out = self.retirement_in("RETIRING_LMS", user=self.subject) + self.policy = _NarrowingPolicy("original_username", self.learner.username) + self.left_out_url = reverse("user_v2:retirement_detail", kwargs={"username": "subject"}) + self.kept_url = reverse( + "user_v2:retirement_detail", kwargs={"username": self.learner.username}, + ) + self.erasure_url = reverse("user_v2:retirement_erasure", kwargs={"username": "subject"}) + + @contextlib.contextmanager + def policy_in_force(self): + """Serve the next requests under a policy that keeps one of the two records.""" + with mock.patch.object(_RetirementRecordAccessMixin, "scoping_policy", self.policy): + yield + + def assert_reported_absent(self, response): + """Assert the record was reported as absent rather than the caller refused.""" + envelope = assert_error_envelope( + response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + assert envelope["detail"] == view_services.NO_SUCH_RETIREMENT + + def test_a_record_the_policy_leaves_out_is_absent_from_its_member_address(self): + self.login("worker") + with self.policy_in_force(): + self.assert_reported_absent(self.client.get(self.left_out_url)) + + def test_the_record_the_policy_keeps_is_answered_at_its_own_address(self): + self.login("worker") + with self.policy_in_force(): + answered = self.client.get(self.kept_url) + assert answered.status_code == status.HTTP_200_OK + assert answered.data["original_username"] == self.learner.username + + def test_a_record_the_policy_leaves_out_cannot_be_moved(self): + self.login("worker") + with self.policy_in_force(): + self.assert_reported_absent( + self.client.patch( + self.left_out_url, + {"new_state": "LMS_COMPLETE", "response": "done"}, + format="json", + ), + ) + self.left_out.refresh_from_db() + assert self.left_out.current_state.state_name == "RETIRING_LMS" + + def test_a_record_the_policy_leaves_out_cannot_be_cancelled(self): + pending = self.retirement_in("PENDING", user=UserFactory.create(username="pending-one")) + self.login("canceller") + with mock.patch.object( + _RetirementRecordAccessMixin, "scoping_policy", + _NarrowingPolicy("original_username", self.learner.username), + ): + self.assert_reported_absent( + self.client.delete( + reverse("user_v2:retirement_detail", kwargs={"username": "pending-one"}), + ), + ) + assert UserRetirementStatus.objects.filter(id=pending.id).exists() + + def test_a_record_the_policy_leaves_out_has_nothing_erased(self): + self.subject.profile.city = "Somewhere" + self.subject.profile.save() + self.login("worker") + with self.policy_in_force(): + self.assert_reported_absent( + self.client.post(self.erasure_url, {"scope": "lms_core"}, format="json"), + ) + self.subject.profile.refresh_from_db() + assert self.subject.profile.city == "Somewhere" + self.subject.refresh_from_db() + assert self.subject.username == "subject" + + def test_the_policy_is_given_the_caller_and_every_record_held(self): + self.login("worker") + with self.policy_in_force(): + self.client.get(self.kept_url) + assert [subject.username for subject in self.policy.subjects] == [WORKER_USERNAME] + assert self.policy.offered == [ + sorted([self.learner.username, self.left_out.original_username]), + ] + + def test_a_name_more_than_one_record_is_held_under_is_still_refused(self): + """The policy narrows what the name is resolved against; it does not resolve the name.""" + twin = self.retirement_in("RETIRING_LMS", user=UserFactory.create(username="learner-twin")) + UserRetirementStatus.objects.filter(id=twin.id).update( + original_username=self.learner.username, + ) + self.login("worker") + with self.policy_in_force(): + envelope = assert_error_envelope( + self.client.patch( + self.kept_url, + {"new_state": "LMS_COMPLETE", "response": "done"}, + format="json", + ), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug=AMBIGUOUS_NAME_SLUG, + ) + assert envelope["detail"] == view_services.NAME_IS_AMBIGUOUS + for record in (self.kept, twin): + record.refresh_from_db() + assert record.current_state.state_name == "RETIRING_LMS" + + def test_a_policy_leaving_one_record_of_such_a_pair_leaves_one_record_for_the_name(self): + """The record left out belongs to an account this caller may not act on, so it is not weighed.""" + twin = self.retirement_in("RETIRING_LMS", user=UserFactory.create(username="learner-twin")) + UserRetirementStatus.objects.filter(id=twin.id).update( + original_username=self.learner.username, + ) + self.login("worker") + with mock.patch.object( + _RetirementRecordAccessMixin, "scoping_policy", + _NarrowingPolicy("id", self.kept.id), + ): + answered = self.client.patch( + self.kept_url, + {"new_state": "LMS_COMPLETE", "response": "done"}, + format="json", + ) + assert answered.status_code == status.HTTP_204_NO_CONTENT + self.kept.refresh_from_db() + twin.refresh_from_db() + assert self.kept.current_state.state_name == "LMS_COMPLETE" + assert twin.current_state.state_name == "RETIRING_LMS" + + +@skip_unless_lms +class SharedNameUnderANarrowingPolicyTests(RetirementApiTestCase): + """ + Which of two records requested under one name an operation weighs, and which it acts on. + + The name a record keeps of the account it was requested for is not unique, + so two records can stand under one name. A policy that keeps one of them + leaves the name addressing a single record, and that record is the one the + operation is weighed against: the record the policy left out belongs to an + account this caller does not act on, so the step it stands at decides + nothing about the account that does. + """ + + SHARED_NAME = "sharedname" + + def setUp(self): + super().setUp() + self.in_scope = UserFactory.create( + username="in-scope-holder", email="in-scope@example.com", + ) + self.out_of_scope = UserFactory.create( + username="out-of-scope-holder", email="out-of-scope@example.com", + ) + for user, name in ((self.in_scope, "In Scope"), (self.out_of_scope, "Out Of Scope")): + user.profile.name = name + user.profile.city = f"City of {user.username}" + user.profile.save() + self.url = reverse("user_v2:retirement_erasure", kwargs={"username": self.SHARED_NAME}) + # Declared here so the attributes exist before pair() fills them in. + self.out_of_scope_record = None + self.in_scope_record = None + self.in_scope_before = None + self.out_of_scope_before = None + + def pair(self, in_scope_step, out_of_scope_step): + """Request a retirement for each account under the one name, standing at these steps.""" + # The record the policy leaves out is requested first, so that it is + # the one a resolution that reads every record held settles on. + self.out_of_scope_record = self.retirement_in(out_of_scope_step, user=self.out_of_scope) + self.in_scope_record = self.retirement_in(in_scope_step, user=self.in_scope) + UserRetirementStatus.objects.filter( + id__in=[self.out_of_scope_record.id, self.in_scope_record.id], + ).update(original_username=self.SHARED_NAME) + self.in_scope_before = self.account(self.in_scope) + self.out_of_scope_before = self.account(self.out_of_scope) + + def account(self, user): + """Return the stored values the core erasure writes over.""" + user.refresh_from_db() + profile = UserProfile.objects.get(user=user) + return { + "username": user.username, + "email": user.email, + "is_active": user.is_active, + "profile_name": profile.name, + "profile_city": profile.city, + } + + @contextlib.contextmanager + def policy_keeping(self, record): + """Serve the next requests under a policy that keeps this record alone.""" + with mock.patch.object( + _RetirementRecordAccessMixin, "scoping_policy", _NarrowingPolicy("id", record.id), + ): + yield + + def erase(self): + """Ask for the core personal data of the account this name addresses to be erased.""" + return self.client.post(self.url, {"scope": "lms_core"}, format="json") + + def test_the_step_the_record_the_policy_keeps_stands_at_refuses_the_erasure(self): + """The other record admits work, which says nothing about the account being acted on.""" + self.pair(in_scope_step="PENDING", out_of_scope_step="RETIRING_LMS") + self.login("worker") + with self.policy_keeping(self.in_scope_record): + answered = self.erase() + # What survives is read first, so that a failure says what was destroyed. + assert self.account(self.in_scope) == self.in_scope_before + assert self.account(self.out_of_scope) == self.out_of_scope_before + envelope = assert_error_envelope( + answered, + expected_status=status.HTTP_409_CONFLICT, + expected_type_slug=STATE_CONFLICT_SLUG, + ) + assert envelope["detail"] == view_services.NOT_OPEN_TO_ACTION + + def test_the_step_the_record_the_policy_keeps_stands_at_admits_the_erasure(self): + """The other record admits no work, which says nothing about the account being acted on.""" + self.pair(in_scope_step="RETIRING_LMS", out_of_scope_step="COMPLETE") + self.login("worker") + with self.policy_keeping(self.in_scope_record): + answered = self.erase() + # What was erased is read first, so that a failure says what was left. + assert self.account(self.in_scope) == { + "username": self.in_scope_record.retired_username, + "email": self.in_scope_record.retired_email, + "is_active": False, + "profile_name": "", + "profile_city": None, + } + assert self.account(self.out_of_scope) == self.out_of_scope_before + assert answered.status_code == status.HTTP_204_NO_CONTENT + + def test_the_account_erased_is_the_one_whose_record_the_policy_keeps(self): + """Both records admit work, so what decides is which of them the name addresses here.""" + self.pair(in_scope_step="RETIRING_LMS", out_of_scope_step="RETIRING_LMS") + self.login("worker") + with self.policy_keeping(self.in_scope_record): + assert self.erase().status_code == status.HTTP_204_NO_CONTENT + assert self.account(self.in_scope)["username"] == self.in_scope_record.retired_username + assert self.account(self.out_of_scope) == self.out_of_scope_before + + def test_the_policy_this_deployment_runs_refuses_the_name_and_erases_nothing(self): + """It keeps the records whole, so the name is carried by two of them and addresses neither.""" + self.pair(in_scope_step="PENDING", out_of_scope_step="RETIRING_LMS") + self.login("worker") + envelope = assert_error_envelope( + self.erase(), + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug=AMBIGUOUS_NAME_SLUG, + ) + assert envelope["detail"] == view_services.NAME_IS_AMBIGUOUS + assert self.account(self.in_scope) == self.in_scope_before + assert self.account(self.out_of_scope) == self.out_of_scope_before + + +@skip_unless_lms +class PartnerQueueRecordVisibilityTests(RetirementApiTestCase): + """The retirement a queue write resolves is narrowed by the policy this collection declares.""" + + def setUp(self): + super().setUp() + self.in_scope = self.retirement_in("ADDING_TO_PARTNER_QUEUE", user=self.subject) + self.out_of_scope = self.retirement_in("ADDING_TO_PARTNER_QUEUE", user=self.learner) + for user in (self.subject, self.learner): + CourseEnrollmentFactory.create(user=user, course_id=COURSE_KEY) + self.policy = _NarrowingPolicy("original_username", self.subject.username) + + def queue(self, username): + """Ask for the retiring account this name addresses to be queued for partner reporting.""" + return self.client.put( + reverse("user_v2:retirement_partner_report_detail", kwargs={"username": username}), + {}, + format="json", + ) + + def queued_names(self): + """Return the names every entry of the partner report queue carries.""" + return set( + UserRetirementPartnerReportingStatus.objects.values_list("original_username", flat=True), + ) + + def test_a_record_the_policy_leaves_out_is_not_queued_for_partner_reporting(self): + self.login("worker") + with mock.patch.object(_PartnerReportAccessMixin, "scoping_policy", self.policy): + answered = self.queue(self.learner.username) + # What the queue holds is read first, so that a failure names whose + # personal data a partner would have been handed. + assert self.queued_names() == set() + envelope = assert_error_envelope( + answered, + expected_status=status.HTTP_404_NOT_FOUND, + expected_type_slug="not-found", + ) + assert envelope["detail"] == view_services.NO_SUCH_RETIREMENT + + def test_the_record_the_policy_keeps_is_queued(self): + self.login("worker") + with mock.patch.object(_PartnerReportAccessMixin, "scoping_policy", self.policy): + answered = self.queue(self.subject.username) + assert answered.status_code == status.HTTP_204_NO_CONTENT + assert self.queued_names() == {self.subject.username} + + def test_the_policy_is_given_the_caller_and_the_retirement_records_it_resolves_from(self): + self.login("worker") + with mock.patch.object(_PartnerReportAccessMixin, "scoping_policy", self.policy): + self.queue(self.subject.username) + assert [caller.username for caller in self.policy.subjects] == [WORKER_USERNAME] + assert self.policy.offered == [ + sorted([self.subject.username, self.learner.username]), + ] + + def test_the_policy_this_deployment_runs_leaves_every_record_queueable(self): + self.login("worker") + assert self.queue(self.learner.username).status_code == status.HTTP_204_NO_CONTENT + assert self.queued_names() == {self.learner.username} + + +class RetirementErrorCatalogTests(SimpleTestCase): + """What this area adds to the shared error catalog, and what it leaves alone.""" + + def test_the_state_refusal_of_this_area_is_named_for_this_area(self): + assert classify_error(RetirementStateConflict()) == ( + "user/retirement-state-conflict", "Retirement State Conflict", + ) + + def test_another_area_refusing_a_conflict_is_classified_as_it_was_before(self): + """Registering this area's refusal reclassifies nothing for any other view in the process.""" + assert classify_error(Conflict()) == ("conflict", "Conflict") + + def test_the_refusal_of_an_ambiguous_name_is_named_for_this_area(self): + assert classify_error(AmbiguousAccountName()) == ( + "user/ambiguous-account-name", "Ambiguous Account Name", + ) + + def test_another_area_refusing_an_invalid_request_is_classified_as_it_was_before(self): + """The registry is shared, and this area registers a subclass of a classified error.""" + assert classify_error(ValidationError("anything")) == ("validation", "Validation Error") + + def test_a_refusal_naming_no_records_carries_no_per_record_member(self): + assert not hasattr(RetirementStateConflict("refused"), "extension_members") + + +class RetirementPageSizeTests(SimpleTestCase): + """The page sizes the collections of this version are cut to.""" + + def test_the_default_and_the_maximum_page_are_the_sizes_this_version_publishes(self): + assert UserApiPagination.page_size == DEFAULT_PAGE_SIZE + assert UserApiPagination.max_page_size == MAXIMUM_PAGE_SIZE + assert RetirementViewSet.pagination_class is UserApiPagination + + +class RetirementReadCaptureCase(RetirementApiTestCase): + """ + What a read of the retirement addresses does, measured statement by statement. + + Three channels are captured, because a read that changes stored data need + not do so itself: the statements it ran, the callbacks it deferred to the + end of its transaction, and the signals it sent. A signal is captured + because the platform's own listeners act on one whether or not the read + that sent it wrote anything, and the pipeline announces each of its steps + that way. + """ + + def setUp(self): + super().setUp() + self.retirement = self.retirement_in("LOCKING_ACCOUNT", user=self.subject) + self.list_url = reverse("user_v2:retirement_list") + self.detail_url = reverse("user_v2:retirement_detail", kwargs={"username": "subject"}) + self.deferred = [] + self.signalled = [] + + def captured_writes(self, method, url, **params): + """Return the statements a read makes that would change the data this API serves.""" + self.login("worker") + with CaptureQueriesContext(connection) as captured, \ + captured_signals() as signalled, \ + self.captureOnCommitCallbacks(execute=True) as callbacks: + response = getattr(self.client, method)(url, params) + assert response.status_code == status.HTTP_200_OK, response.content + self.deferred = callbacks + self.signalled = signalled + return write_queries(captured.captured_queries) + + def targets(self, statements): + """Return the table each statement writes to.""" + return [statement.split('"')[1] for statement in statements] + + +@skip_unless_lms +class RetirementReadsAreNotWritesTests(RetirementReadCaptureCase): + """Reading the queue or one record changes nothing, whichever way it is read.""" + + def assert_nothing_was_changed(self, method, url, **params): + """Assert the read wrote nothing, deferred nothing and announced nothing.""" + statements = self.captured_writes(method, url, **params) + assert (statements, self.deferred, self.signalled) == ([], [], []) + + def test_reading_the_queue_announces_nothing_and_defers_nothing(self): + self.assert_nothing_was_changed("get", self.list_url) + + def test_reading_one_record_announces_nothing_and_defers_nothing(self): + self.assert_nothing_was_changed("get", self.detail_url) + + def test_reading_the_queue_without_a_body_announces_nothing_either(self): + self.assert_nothing_was_changed("head", self.list_url) + + def test_reading_one_record_without_a_body_announces_nothing_either(self): + self.assert_nothing_was_changed("head", self.detail_url) + + def test_reading_the_queue_writes_nothing(self): + assert self.captured_writes("get", self.list_url) == [] + + def test_reading_the_queue_without_a_body_writes_nothing(self): + assert self.captured_writes("head", self.list_url) == [] + + def test_reading_one_record_writes_nothing(self): + assert self.captured_writes("get", self.detail_url) == [] + + def test_reading_one_record_without_a_body_writes_nothing(self): + assert self.captured_writes("head", self.detail_url) == [] + + def test_reading_the_queue_with_every_filter_applied_writes_nothing(self): + assert self.captured_writes( + "get", + self.list_url, + state="LOCKING_ACCOUNT", + cool_off_days=0, + created_after="2020-01-01", + created_before="2099-01-01", + ordering="created", + view="minimal", + ) == [] + + +@skip_unless_lms +class RetirementReadsWithTheLanguageCookieTests(RetirementReadCaptureCase): + """ + A read carrying the language cookie stores the language it carries. + + The platform keeps the language a user picked in a cookie and syncs it into + that user's preferences from middleware, which runs before any view and on + every method, so it is reached by a read of these addresses as it is by a + read of any other. It is the one write a read of this area makes, and it + belongs to the platform rather than to this version: the addresses this + version supersedes make the same write, under the same cookie. The capture + below pins it -- the one row, by the table it is stored in, the one change + it announces, and nothing else. + """ + + def setUp(self): + super().setUp() + self.client.cookies[LANGUAGE_COOKIE] = "eo" + + def language_rows(self): + """Return the stored language of every account, by account.""" + return { + preference.user.username: preference.value + for preference in UserPreference.objects.filter(key=LANGUAGE_PREFERENCE) + } + + def assert_only_the_language_was_stored(self, method, url, **params): + """Assert the one write made, and the one change announced, was the caller's language.""" + statements = self.captured_writes(method, url, **params) + assert (self.targets(statements), self.deferred) == ([PREFERENCE_TABLE], []), statements + assert self.language_rows() == {WORKER_USERNAME: "eo"} + assert self.signalled == LANGUAGE_SYNC_SIGNALS + + def test_reading_the_queue_stores_the_language_the_cookie_carries(self): + assert self.language_rows() == {} + self.assert_only_the_language_was_stored("get", self.list_url) + + def test_reading_one_record_stores_it_too(self): + self.assert_only_the_language_was_stored("get", self.detail_url) + + def test_reading_the_queue_without_a_body_stores_it_as_well(self): + """A write one frame below the handler is reached by every method, this one included.""" + self.assert_only_the_language_was_stored("head", self.list_url) + + def test_reading_one_record_without_a_body_stores_it_as_well(self): + self.assert_only_the_language_was_stored("head", self.detail_url) + + def test_reading_the_queue_with_every_filter_applied_stores_only_that(self): + self.assert_only_the_language_was_stored( + "get", + self.list_url, + state="LOCKING_ACCOUNT", + cool_off_days=0, + created_after="2020-01-01", + created_before="2099-01-01", + ordering="created", + view="minimal", + ) + + def test_the_address_this_version_supersedes_stores_the_same_row(self): + """The write belongs to the platform rather than to either version of this area.""" + self.login("worker") + with CaptureQueriesContext(connection) as captured: + response = self.client.get( + EARLIER_RETIREMENT_QUEUE, {"cool_off_days": 0, "states": "LOCKING_ACCOUNT"}, + ) + assert response.status_code == status.HTTP_200_OK, response.content + assert self.targets(write_queries(captured.captured_queries)) == [PREFERENCE_TABLE] + assert self.language_rows() == {WORKER_USERNAME: "eo"} + + def test_the_row_is_written_once_and_not_once_a_request(self): + """It is the language being synced, not the read recording anything.""" + self.assert_only_the_language_was_stored("get", self.list_url) + statements = self.captured_writes("get", self.list_url) + assert (statements, self.deferred, self.signalled) == ([], [], []) + + +@skip_unless_lms +class NameRequestedTwiceTests(RetirementApiTestCase): + """ + A name more than one record was requested under is refused, and nothing is acted on. + + The name a record keeps of the account it is for is indexed but not + unique, and in some deployments stored names are compared without regard + to case, which once let a learner register a retired name again by + changing its capitalisation. Both records of such a pair are built + directly here: registering a second account under a name a record holds is + refused a frame below this API, so the state cannot be reached through + registration, and deployments hold it from before that refusal existed. + + Every operation that finds its record by that name refuses the request, + rather than acting on whichever of the two the database offered last. + """ + + def setUp(self): + super().setUp() + self.detail_url = reverse("user_v2:retirement_detail", kwargs={"username": "subject"}) + self.erasure_url = reverse("user_v2:retirement_erasure", kwargs={"username": "subject"}) + self.queue_url = reverse( + "user_v2:retirement_partner_report_detail", kwargs={"username": "subject"}, + ) + self.cleanup_url = reverse("user_v2:retirement_cleanup_list") + self.partner_cleanup_url = reverse("user_v2:retirement_partner_report_cleanup_list") + + def two_retirements_under_one_name(self, state_name): + """Return two retirement records requested under the one name ``subject``.""" + held = self.retirement_in(state_name, user=self.subject) + twin = self.retirement_in(state_name, user=UserFactory.create(username="subject-twin")) + UserRetirementStatus.objects.filter(id=twin.id).update(original_username="subject") + return held, twin + + def two_partner_reports_under_one_name(self, taken=True): + """Return two partner report entries registered under the one name ``subject``.""" + entries = [ + UserRetirementPartnerReportingStatus.objects.create( + user=user, + original_username=user.username, + original_email=user.email, + original_name=user.profile.name, + is_being_processed=taken, + ) + for user in (self.subject, UserFactory.create(username="subject-twin")) + ] + UserRetirementPartnerReportingStatus.objects.filter(id=entries[1].id).update( + original_username="subject", + ) + return entries + + def assert_the_name_was_refused(self, response): + """Assert the answer is the refusal of an ambiguous name, and return its envelope.""" + envelope = assert_error_envelope( + response, + expected_status=status.HTTP_400_BAD_REQUEST, + expected_type_slug=AMBIGUOUS_NAME_SLUG, + ) + assert envelope["detail"] == view_services.NAME_IS_AMBIGUOUS + assert envelope["instance"] == response.request["PATH_INFO"] + return envelope + + def test_a_refusal_that_names_one_account_says_so_about_the_request_as_a_whole(self): + """One name is refused at the address that carries it, so there is no record to key it to.""" + self.two_retirements_under_one_name("LOCKING_ACCOUNT") + self.login("worker") + envelope = self.assert_the_name_was_refused( + self.client.patch( + self.detail_url, {"new_state": "LOCKING_COMPLETE", "response": "done"}, format="json", + ), + ) + assert envelope["errors"] == {"non_field_errors": [view_services.NAME_IS_AMBIGUOUS]} + + def test_moving_the_retirement_is_refused_and_neither_record_moves(self): + held, twin = self.two_retirements_under_one_name("LOCKING_ACCOUNT") + self.login("worker") + self.assert_the_name_was_refused( + self.client.patch( + self.detail_url, {"new_state": "LOCKING_COMPLETE", "response": "done"}, format="json", + ), + ) + for record in (held, twin): + record.refresh_from_db() + assert record.current_state.state_name == "LOCKING_ACCOUNT" + + def test_cancelling_is_refused_and_neither_request_is_undone(self): + held, twin = self.two_retirements_under_one_name("PENDING") + self.login("canceller") + self.assert_the_name_was_refused(self.client.delete(self.detail_url)) + assert set(UserRetirementStatus.objects.values_list("id", flat=True)) == {held.id, twin.id} + assert UserRetirementRequest.objects.count() == 2 + + def test_erasing_is_refused_and_nothing_is_erased(self): + self.two_retirements_under_one_name("RETIRING_LMS") + self.subject.profile.city = "Somewhere" + self.subject.profile.save() + self.login("worker") + self.assert_the_name_was_refused( + self.client.post(self.erasure_url, {"scope": "lms_core"}, format="json"), + ) + self.subject.refresh_from_db() + assert (self.subject.username, self.subject.email) == ("subject", "subject@example.com") + assert self.subject.is_active is True + assert UserProfile.objects.get(user=self.subject).city == "Somewhere" + + def test_queueing_for_partner_reporting_is_refused_and_nothing_is_queued(self): + self.two_retirements_under_one_name("RETIRING_LMS") + CourseEnrollmentFactory.create(user=self.subject, course_id=COURSE_KEY) + self.login("worker") + self.assert_the_name_was_refused(self.client.put(self.queue_url, {}, format="json")) + assert UserRetirementPartnerReportingStatus.objects.count() == 0 + + def test_the_batch_disposal_is_refused_whole_and_nothing_is_deleted(self): + held, twin = self.two_retirements_under_one_name("COMPLETE") + self.login("worker") + envelope = self.assert_the_name_was_refused( + self.client.post(self.cleanup_url, {"usernames": ["subject"]}, format="json"), + ) + assert envelope["errors"] == {"subject": [view_services.SEVERAL_RECORDS_HOLD_THIS_NAME]} + for record in (held, twin): + record.refresh_from_db() + assert record.original_username == "subject" + assert record.original_email != view_services.REDACTED_EMAIL + + def test_a_batch_naming_it_alongside_a_name_held_once_deletes_neither(self): + """A batch is acted on whole or not at all, so one ambiguous name holds the rest back.""" + held, twin = self.two_retirements_under_one_name("COMPLETE") + alone = self.retirement_in("COMPLETE", user=UserFactory.create(username="held-once")) + self.login("worker") + self.assert_the_name_was_refused( + self.client.post( + self.cleanup_url, {"usernames": ["subject", "held-once"]}, format="json", + ), + ) + assert set(UserRetirementStatus.objects.values_list("id", flat=True)) == { + held.id, twin.id, alone.id, + } + + def test_the_earlier_batch_disposal_refuses_the_same_name(self): + """The address this one replaces refuses it too, and deletes nothing.""" + held, twin = self.two_retirements_under_one_name("COMPLETE") + self.login("worker") + response = self.client.post( + EARLIER_RETIREMENT_CLEANUP, {"usernames": ["subject"]}, format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST, response.content + assert set(UserRetirementStatus.objects.values_list("id", flat=True)) == {held.id, twin.id} + + def test_the_partner_report_disposal_is_refused_whole_and_nothing_is_deleted(self): + entries = self.two_partner_reports_under_one_name() + self.login("worker") + envelope = self.assert_the_name_was_refused( + self.client.post(self.partner_cleanup_url, {"usernames": ["subject"]}, format="json"), + ) + assert envelope["errors"] == {"subject": [view_services.SEVERAL_RECORDS_HOLD_THIS_NAME]} + assert set( + UserRetirementPartnerReportingStatus.objects.values_list("id", flat=True), + ) == {entry.id for entry in entries} + + def test_the_earlier_partner_report_disposal_refuses_the_same_name(self): + entries = self.two_partner_reports_under_one_name() + self.login("worker") + response = self.client.post( + EARLIER_PARTNER_REPORT_CLEANUP, [{"original_username": "subject"}], format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST, response.content + assert set( + UserRetirementPartnerReportingStatus.objects.values_list("id", flat=True), + ) == {entry.id for entry in entries} + + def test_reading_the_record_of_the_account_addressed_still_answers(self): + """A read addresses the account rather than the name a record keeps, so it is unambiguous.""" + held, _twin = self.two_retirements_under_one_name("LOCKING_ACCOUNT") + self.login("worker") + response = self.client.get(self.detail_url) + assert response.status_code == status.HTTP_200_OK, response.content + assert response.json()["id"] == held.id + + def test_the_queue_carries_both_records(self): + """Nothing is hidden by the refusal: the page answers with every record it holds.""" + held, twin = self.two_retirements_under_one_name("LOCKING_ACCOUNT") + self.login("worker") + response = self.client.get(reverse("user_v2:retirement_list")) + assert [row["id"] for row in response.json()["results"]] == [held.id, twin.id] + + +@skip_unless_lms +class RetirementSelfDescriptionTests(RetirementApiTestCase): + """ + What each address of this pipeline says about itself to a caller that asks. + + The description a view class carries is what an OPTIONS request is + answered with, so it is published text: a client and a documentation + browser read it beside the description of the same address in the schema. + These hold two of them to being the text the class carries, which the + checks on that text elsewhere hold to being written for the caller. + """ + + def test_the_queue_answers_with_the_description_it_carries(self): + self.login("worker") + answer = self.client.options(reverse("user_v2:retirement_list")) + assert answer.status_code == status.HTTP_200_OK + assert answer.json()["description"] == RetirementViewSet().get_view_description() + assert answer.json()["description"].startswith( + "The accounts being retired, addressed by the name each was registered under.", + ) + + def test_the_disposal_answers_with_the_description_it_carries(self): + self.login("worker") + answer = self.client.options(reverse("user_v2:retirement_cleanup_list")) + assert answer.status_code == status.HTTP_200_OK + assert answer.json()["description"] == RetirementCleanupViewSet().get_view_description() + assert answer.json()["description"].startswith( + "Disposes of a set of finished retirement records.", + ) + + +#: The view classes this pipeline is served by, read from the module that +#: routes them so that an address mounted later is covered by the sweep below. +RETIREMENT_VIEW_CLASSES = ( + RetirementViewSet, + RetirementPartnerReportViewSet, + RetirementCleanupViewSet, + RetirementPartnerReportCleanupViewSet, +) + + +def mounted_retirement_operations(): + """Return every address of this pipeline with the methods it serves, by the name it is routed under.""" + return { + (entry.name, method) + for entry in v2_urls.urlpatterns + if getattr(entry.callback, "cls", None) in RETIREMENT_VIEW_CLASSES + for method in getattr(entry.callback, "actions", {}) + } + + +@skip_unless_lms +class UnsatisfiableAcceptTests(RetirementApiTestCase): + """ + What each address of this pipeline answers a request it can give no answer to. + + Which representations an answer may be given in is settled as the request + enters, before the record it names is read or written, so the refusal + reaches an address that answers with a body and one that answers with none + alike. Every operation here publishes that refusal, and these are the + measurements it rests on: each address answers it, and the records each of + them would otherwise have acted on -- including the ones two of them + delete and the account data one of them erases -- are left as they were. + """ + + UNANSWERABLE = "text/csv" + + def setUp(self): + super().setUp() + self.finished = self.retirement_in("COMPLETE", user=self.subject) + self.working = self.retirement_in( + "RETIRING_LMS", user=UserFactory.create(username="working-subject"), + ) + self.pending = self.retirement_in( + "PENDING", user=UserFactory.create(username="pending-subject"), + ) + self.subject.profile.city = "Somewhere" + self.subject.profile.save() + self.entry = UserRetirementPartnerReportingStatus.objects.create( + user=self.working.user, + original_username=self.working.user.username, + original_email=self.working.user.email, + original_name=self.working.user.profile.name, + is_being_processed=True, + ) + + def requests(self): + """Return a request to every method of every address, by the caller that address admits.""" + return { + ("retirement_list", "get"): ("worker", reverse("user_v2:retirement_list"), None), + ("retirement_detail", "get"): ( + "worker", reverse("user_v2:retirement_detail", kwargs={"username": "subject"}), None, + ), + # Both reads are mounted for a request that asks for the answer + # without its body as well, and the refusal is settled before + # either is read, so each is sent that request too. + ("retirement_list", "head"): ("worker", reverse("user_v2:retirement_list"), None), + ("retirement_detail", "head"): ( + "worker", reverse("user_v2:retirement_detail", kwargs={"username": "subject"}), None, + ), + ("retirement_detail", "patch"): ( + "worker", + reverse("user_v2:retirement_detail", kwargs={"username": "working-subject"}), + {"new_state": "RETIRING_LMS_COMPLETE", "response": "done"}, + ), + ("retirement_detail", "delete"): ( + "canceller", + reverse("user_v2:retirement_detail", kwargs={"username": "pending-subject"}), + None, + ), + ("retirement_erasure", "post"): ( + "worker", + reverse("user_v2:retirement_erasure", kwargs={"username": "working-subject"}), + {"scope": "lms_core"}, + ), + ("retirement_cleanup_list", "post"): ( + "worker", reverse("user_v2:retirement_cleanup_list"), {"usernames": ["subject"]}, + ), + ("retirement_partner_report_list", "post"): ( + "worker", reverse("user_v2:retirement_partner_report_list"), None, + ), + ("retirement_partner_report_detail", "put"): ( + "worker", + reverse( + "user_v2:retirement_partner_report_detail", + kwargs={"username": "working-subject"}, + ), + {}, + ), + ("retirement_partner_report_cleanup_list", "post"): ( + "worker", + reverse("user_v2:retirement_partner_report_cleanup_list"), + {"usernames": ["working-subject"]}, + ), + } + + def send_each(self): + """Send every request of the table asking for a representation none can answer in.""" + answered = {} + for (name, method), (caller, url, body) in self.requests().items(): + self.client.logout() + self.login(caller) + send = getattr(self.client, method) + if body is None: + response = send(url, HTTP_ACCEPT=self.UNANSWERABLE) + else: + response = send(url, body, format="json", HTTP_ACCEPT=self.UNANSWERABLE) + answered[(name, method)] = response.status_code + return answered + + def test_every_address_of_this_pipeline_is_covered_here(self): + """A method mounted later is sent one of these requests, or this fails.""" + assert set(self.requests()) == mounted_retirement_operations() + + def test_each_address_refuses_an_accept_it_cannot_satisfy(self): + assert self.send_each() == { + address: status.HTTP_406_NOT_ACCEPTABLE for address in self.requests() + } + + def test_the_refusal_comes_back_in_the_body_this_version_answers_with(self): + """The refusal is readable even though the caller asked for a representation it is not in.""" + self.login("worker") + assert_error_envelope( + self.client.get(reverse("user_v2:retirement_list"), HTTP_ACCEPT=self.UNANSWERABLE), + expected_status=status.HTTP_406_NOT_ACCEPTABLE, + expected_type_slug="not-acceptable", + ) + + def test_nothing_is_moved_erased_or_deleted_on_the_way_to_the_refusal(self): + self.send_each() + assert set(UserRetirementStatus.objects.values_list("id", flat=True)) == { + self.finished.id, self.working.id, self.pending.id, + } + assert UserRetirementRequest.objects.count() == 3 + for record, state_name in ( + (self.finished, "COMPLETE"), + (self.working, "RETIRING_LMS"), + (self.pending, "PENDING"), + ): + record.refresh_from_db() + assert record.current_state.state_name == state_name + assert record.original_email != view_services.REDACTED_EMAIL + self.subject.refresh_from_db() + assert (self.subject.username, self.subject.email) == ("subject", "subject@example.com") + assert self.subject.is_active is True + assert UserProfile.objects.get(user=self.subject).city == "Somewhere" + assert set( + UserRetirementPartnerReportingStatus.objects.values_list("id", flat=True), + ) == {self.entry.id} + self.entry.refresh_from_db() + assert self.entry.is_being_processed is True + + +@skip_unless_lms +class FormEncodedBodyTests(RetirementApiTestCase): + """ + A body sent as a web form is refused wherever a body is read. + + These operations read a body as JSON alone, as the operations they replace + do. The narrowing is kept rather than widened to every media type the + platform can parse because one of them erases personal data for good and + cannot be undone, and a form-encoded request is one a page on another site + can be made to send. + + The operations that read no body are unaffected, which is why the + narrowing is read from the view rather than applied to each method: a + method that never reaches the body never reaches the parser either. + """ + + FORM = "application/x-www-form-urlencoded" + + def setUp(self): + super().setUp() + self.retirement = self.retirement_in("RETIRING_LMS", user=self.subject) + self.detail_url = reverse("user_v2:retirement_detail", kwargs={"username": "subject"}) + self.erasure_url = reverse("user_v2:retirement_erasure", kwargs={"username": "subject"}) + + def assert_the_body_was_unreadable(self, response): + """Assert the answer names the media type as one this endpoint cannot read.""" + assert_error_envelope( + response, + expected_status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + expected_type_slug="unsupported-media-type", + ) + + def test_a_form_body_is_refused_rather_than_erasing_the_account(self): + self.subject.profile.city = "Somewhere" + self.subject.profile.save() + self.login("worker") + self.assert_the_body_was_unreadable( + self.client.post(self.erasure_url, "scope=lms_core", content_type=self.FORM), + ) + self.subject.refresh_from_db() + assert (self.subject.username, self.subject.email) == ("subject", "subject@example.com") + assert UserProfile.objects.get(user=self.subject).city == "Somewhere" + + def test_the_erasure_this_one_replaces_refuses_the_same_body(self): + """Both versions answer the same, which is what keeps a caller's request refused.""" + self.login("worker") + response = self.client.post( + EARLIER_CORE_ERASURE, "username=subject", content_type=self.FORM, + ) + assert response.status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, response.content + self.subject.refresh_from_db() + assert self.subject.username == "subject" + + def test_a_form_body_is_refused_when_moving_a_retirement(self): + """The body names a step this deployment runs, so only its media type can refuse it.""" + self.login("worker") + self.assert_the_body_was_unreadable( + self.client.patch( + self.detail_url, "new_state=LMS_COMPLETE&response=done", + content_type=self.FORM, + ), + ) + self.retirement.refresh_from_db() + assert self.retirement.current_state.state_name == "RETIRING_LMS" + + def test_a_form_body_is_refused_by_each_batch_disposal(self): + self.login("worker") + for name in ( + "user_v2:retirement_cleanup_list", + "user_v2:retirement_partner_report_cleanup_list", + ): + with self.subTest(address=name): + self.assert_the_body_was_unreadable( + self.client.post(reverse(name), "usernames=subject", content_type=self.FORM), + ) + + def test_taking_partner_reports_reads_no_body_and_is_answered(self): + """It takes what is queued and reads nothing from the request, as the earlier one does.""" + self.login("worker") + taken = self.client.post( + reverse("user_v2:retirement_partner_report_list"), "anything=at-all", + content_type=self.FORM, + ) + earlier = self.client.post(EARLIER_PARTNER_REPORT, "anything=at-all", content_type=self.FORM) + assert (taken.status_code, earlier.status_code) == ( + status.HTTP_200_OK, status.HTTP_200_OK, + ), (taken.content, earlier.content) + + def test_cancelling_is_answered_whatever_media_type_is_sent(self): + """It names the retirement in the address and reads no body, so no parser is reached.""" + self.retirement_in("PENDING", user=self.learner) + self.login("canceller") + response = self.client.delete( + reverse("user_v2:retirement_detail", kwargs={"username": "learner-caller"}), + "nothing=here", + content_type=self.FORM, + ) + assert response.status_code == status.HTTP_204_NO_CONTENT, response.content + + def test_queueing_for_partner_reporting_is_answered_whatever_media_type_is_sent(self): + """The name is in the address, where the earlier version read it from the body.""" + CourseEnrollmentFactory.create(user=self.subject, course_id=COURSE_KEY) + self.login("worker") + response = self.client.put( + reverse("user_v2:retirement_partner_report_detail", kwargs={"username": "subject"}), + "nothing=here", + content_type=self.FORM, + ) + assert response.status_code == status.HTTP_204_NO_CONTENT, response.content + assert UserRetirementPartnerReportingStatus.objects.count() == 1 + + +@skip_unless_lms +class UnservedMethodTests(RetirementApiTestCase): + """ + A method an address does not serve is answered as not served, not as refused. + + Which caller may make a request is decided before the method is + dispatched, and at an address whose capability follows the operation there + is no operation to follow for a method it does not serve. The answer is + then the dispatcher's own: the methods the address does serve, and nothing + about a capability. A caller who is not signed in is still told to + authenticate, because being signed in is asked of every method alike. + """ + + #: A method each retirement address of this version does not serve, and the + #: methods it does. The second group's addresses guard every method with + #: the same classes, so they answer a capability refusal before this one. + PER_OPERATION = ( + ("post", "user_v2:retirement_detail", {"username": "subject"}), + ("put", "user_v2:retirement_detail", {"username": "subject"}), + ("delete", "user_v2:retirement_list", None), + ("patch", "user_v2:retirement_list", None), + ("get", "user_v2:retirement_erasure", {"username": "subject"}), + ("get", "user_v2:retirement_cleanup_list", None), + ) + PER_VIEW = ( + ("get", "user_v2:retirement_partner_report_list", None), + ("get", "user_v2:retirement_partner_report_detail", {"username": "subject"}), + ("get", "user_v2:retirement_partner_report_cleanup_list", None), + ) + + #: What each address whose capability follows the operation serves, as its + #: refusal reports it. + SERVED = { + "user_v2:retirement_list": {"GET", "HEAD", "OPTIONS"}, + "user_v2:retirement_detail": {"GET", "HEAD", "PATCH", "DELETE", "OPTIONS"}, + "user_v2:retirement_erasure": {"POST", "OPTIONS"}, + "user_v2:retirement_cleanup_list": {"POST", "OPTIONS"}, + } + + def setUp(self): + super().setUp() + self.retirement_in("LOCKING_ACCOUNT", user=self.subject) + + def send(self, method, name, kwargs): + """Send a method the named address does not serve.""" + return getattr(self.client, method)(reverse(name, kwargs=kwargs)) + + def test_a_caller_holding_the_capability_is_told_the_method_is_not_served(self): + self.login("worker") + for method, name, kwargs in self.PER_OPERATION + self.PER_VIEW: + with self.subTest(method=method, address=name): + assert_error_envelope( + self.send(method, name, kwargs), + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug="method-not-allowed", + ) + + def test_the_refusal_names_the_methods_the_address_does_serve(self): + """The answer comes from the dispatcher, so it says what is served there.""" + self.login("worker") + for method, name, kwargs in self.PER_OPERATION: + with self.subTest(method=method, address=name): + response = self.send(method, name, kwargs) + assert set(response["Allow"].replace(" ", "").split(",")) == self.SERVED[name] + + def test_a_caller_without_the_capability_is_told_the_same_by_the_collection(self): + """There is no operation at that address for a capability to be asked for.""" + self.login("learner") + for method, name, kwargs in self.PER_OPERATION: + with self.subTest(method=method, address=name): + assert_error_envelope( + self.send(method, name, kwargs), + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug="method-not-allowed", + ) + + def test_a_caller_without_the_capability_is_refused_where_every_method_carries_one(self): + """These addresses ask for the capability whatever the method, so that answer comes first.""" + self.login("learner") + for method, name, kwargs in self.PER_VIEW: + with self.subTest(method=method, address=name): + assert_error_envelope( + self.send(method, name, kwargs), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_an_address_that_serves_one_method_refuses_the_same_caller_instead(self): + """Whoever may call is decided before the method is, so where every method carries the + same capability the caller without it is refused rather than told what is served.""" + self.login("learner") + assert self.client.put( + reverse("accounts_retirement_cleanup"), + ).status_code == status.HTTP_403_FORBIDDEN + assert_error_envelope( + self.send("put", "user_v2:retirement_detail", {"username": "subject"}), + expected_status=status.HTTP_405_METHOD_NOT_ALLOWED, + expected_type_slug="method-not-allowed", + ) + + def test_an_anonymous_caller_is_told_to_authenticate(self): + self.client.logout() + for method, name, kwargs in self.PER_OPERATION + self.PER_VIEW: + with self.subTest(method=method, address=name): + assert_error_envelope( + self.send(method, name, kwargs), + expected_status=status.HTTP_401_UNAUTHORIZED, + expected_type_slug="authn", + ) + + def test_an_action_the_mapping_does_not_name_still_reaches_no_capability(self): + """An action added later is refused; it is an unserved method that is not.""" + viewset = RetirementViewSet() + viewset.action = "an_action_added_later" + assert [ + permission.has_permission(mock.Mock(user=self.superuser), viewset) + for permission in viewset.get_permissions() + ][-1] is False + viewset.action = None + # Read from the view rather than named here: the platform's own JWT + # middleware adds a class to every view it serves, so what the classes + # of this view are is not fixed at import. + assert [type(permission) for permission in viewset.get_permissions()] == list( + RetirementViewSet.permission_classes, + ) diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_schema.py b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py new file mode 100644 index 000000000000..73518608e7a2 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_schema.py @@ -0,0 +1,2914 @@ +"""Tests for what version 2 of the User API publishes in the OpenAPI schema.""" + +import json +from importlib import import_module +from unittest import mock + +import jsonschema +import pytest +from django.conf import settings +from django.urls import reverse +from drf_spectacular.generators import SchemaGenerator +from drf_spectacular.plumbing import get_override +from drf_spectacular.settings import patched_settings +from rest_framework import serializers +from rest_framework.test import APIClient +from waffle.testutils import override_switch + +from common.djangoapps.student.models import PendingNameChange, UserProfile +from common.djangoapps.student.tests.factories import AccountRecoveryFactory, AdminFactory, UserFactory +from lms.djangoapps.verify_student.models import IDVerificationAttempt, VerificationAttempt +from lms.lib.spectacular import lms_mark_legacy_paths_deprecated +from openedx.core.apidocs import get_api_docs_settings +from openedx.core.djangoapps.profile_images.tests.helpers import make_image_file +from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration_context +from openedx.core.djangoapps.user_api.accounts.image_helpers import ( + get_profile_image_names, + get_profile_image_storage, +) +from openedx.core.djangoapps.user_api.accounts.utils import ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH +from openedx.core.djangoapps.user_api.models import UserRetirementPartnerReportingStatus +from openedx.core.djangoapps.user_api.schema import ( + RENAMED_OPERATION_IDS, + SUPERSEDED_USER_PATHS, + mark_superseded_user_paths, +) +from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory +from openedx.core.djangoapps.user_api.v2.serializers import ( + ACCOUNT_DEFAULT_SORT, + ACCOUNT_MINIMAL_FIELDS, + BATCH_OUTCOMES, + ERASURE_SCOPES, + EXPIRATION_DAY_PATTERN, + PREFERENCE_NAME_MAX_LENGTH, + AccountPreferenceChangesField, + AccountPreferenceDocumentField, + AccountQuerySerializer, + RetirementBatchSerializer, + TimeZoneQuerySerializer, + VerificationQuerySerializer, +) +from openedx.core.djangoapps.user_api.v2.view_services import claim_partner_reports +from openedx.core.djangoapps.user_api.v2.views import ( + PREFERENCE_KEY_PATTERN_PARTIAL, + RETIREMENT_SORT_FIELDS, +) +from openedx.core.djangolib.testing.utils import skip_unless_lms + +PASSWORD = "Password1234" + +#: Stands for a property a response does not carry, where that is the assertion. +ABSENT = "absent" + +ACCOUNT_LIST_PATH = "/api/user/v2/accounts/" +ACCOUNT_MEMBER_PATH = "/api/user/v2/accounts/{username}/" +ACCOUNT_DEACTIVATION_PATH = "/api/user/v2/accounts/{username}/deactivation/" +PROFILE_IMAGE_PATH = "/api/user/v2/accounts/{username}/profile_image/" +VERIFICATION_STATUS_PATH = "/api/user/v2/accounts/{username}/verification_status/" +VERIFICATION_LIST_PATH = "/api/user/v2/accounts/{username}/verifications/" +EMAIL_SEARCH_PATH = "/api/user/v2/email_searches/" +PREFERENCE_DOCUMENT_PATH = "/api/user/v2/accounts/{username}/preferences/" +PREFERENCE_NAME_PATH = "/api/user/v2/accounts/{username}/preferences/{key}/" +EMAIL_OPT_IN_PATH = "/api/user/v2/email_opt_ins/" + +#: What both preference addresses say the name in them is read as. +ACCOUNT_PREFERENCES_USERNAME_DESCRIPTION = ( + "Unique name of the user whose preferences are addressed. The name is read as it is stored, " + "so `me` here names an account holding that name rather than the account of the caller " + "making the request." +) +NAME_CHANGE_PATH = "/api/user/v2/name_changes/" +NAME_CHANGE_CONFIRMATION_PATH = "/api/user/v2/name_changes/{username}/confirmations/" + +USER_LIST_PATH = "/api/user/v2/users/" +USER_MEMBER_PATH = "/api/user/v2/users/{username}/" +PREFERENCE_LIST_PATH = "/api/user/v2/user_preferences/" +PREFERENCE_MEMBER_PATH = "/api/user/v2/user_preferences/{username},{key}/" +TIME_ZONE_LIST_PATH = "/api/user/v2/time_zones/" +RETIREMENT_LIST_PATH = "/api/user/v2/retirements/" +RETIREMENT_MEMBER_PATH = "/api/user/v2/retirements/{username}/" +RETIREMENT_ERASURE_PATH = "/api/user/v2/retirements/{username}/erasures/" +RETIREMENT_CLEANUP_PATH = "/api/user/v2/retirement_cleanups/" +PARTNER_REPORT_LIST_PATH = "/api/user/v2/retirement_partner_reports/" +PARTNER_REPORT_MEMBER_PATH = "/api/user/v2/retirement_partner_reports/{username}/" +PARTNER_REPORT_CLEANUP_PATH = "/api/user/v2/retirement_partner_report_cleanups/" + +#: The meaning published for the part of a retirement's address that carries +#: the name the account was registered under. +RETIREMENT_USERNAME_DESCRIPTION = ( + "Name the account being retired was registered under. A retired account is stored under a " + "hashed name, and either name addresses the same retirement when one is read; a write " + "addresses the registered name alone, spelled exactly as it was registered." +) +PARTNER_REPORT_USERNAME_DESCRIPTION = ( + "Name the account was registered under, spelled exactly as it was registered." +) + +#: The addresses that answer with a page of rows, and the ones that answer with +#: a single record. +LIST_PATHS = { + ACCOUNT_LIST_PATH, + VERIFICATION_LIST_PATH, + USER_LIST_PATH, + PREFERENCE_LIST_PATH, + TIME_ZONE_LIST_PATH, + RETIREMENT_LIST_PATH, +} +MEMBER_PATHS = { + ACCOUNT_MEMBER_PATH, + VERIFICATION_STATUS_PATH, + USER_MEMBER_PATH, + PREFERENCE_MEMBER_PATH, + PREFERENCE_DOCUMENT_PATH, + PREFERENCE_NAME_PATH, + RETIREMENT_MEMBER_PATH, + PARTNER_REPORT_MEMBER_PATH, +} + +#: The addresses that act on a resource rather than answer with one. +ACTION_PATHS = { + EMAIL_SEARCH_PATH, + EMAIL_OPT_IN_PATH, + ACCOUNT_DEACTIVATION_PATH, + PROFILE_IMAGE_PATH, + NAME_CHANGE_PATH, + NAME_CHANGE_CONFIRMATION_PATH, + RETIREMENT_ERASURE_PATH, + RETIREMENT_CLEANUP_PATH, + PARTNER_REPORT_LIST_PATH, + PARTNER_REPORT_CLEANUP_PATH, +} + +#: The addresses a generated client library is built from. The retirement +#: pipeline's are deliberately not among them: one service calls them by path +#: literal, and a library for irreversible destruction of personal data invites +#: a caller who has not read what the steps do. +SDK_PATHS = { + ACCOUNT_LIST_PATH, + ACCOUNT_MEMBER_PATH, + VERIFICATION_STATUS_PATH, + VERIFICATION_LIST_PATH, + PREFERENCE_DOCUMENT_PATH, + PREFERENCE_NAME_PATH, + EMAIL_SEARCH_PATH, + EMAIL_OPT_IN_PATH, + ACCOUNT_DEACTIVATION_PATH, + PROFILE_IMAGE_PATH, + NAME_CHANGE_PATH, + NAME_CHANGE_CONFIRMATION_PATH, +} + +VERSION_TWO_PATHS = LIST_PATHS | MEMBER_PATHS | ACTION_PATHS + +#: The addresses this version serves for the authentication app. They answer +#: with the bodies that app has always answered with rather than with the +#: envelope the rest of this version uses, so the checks made over the rest of +#: it leave them out; their own tests are in ``test_authn_addresses``. +AUTHN_PATHS = { + "/api/user/v2/registrations/", + "/api/user/v2/registration_validations/", + "/api/user/v2/login_sessions/", + "/api/user/v2/password_resets/", + "/api/user/v2/password_reset_token_validations/", +} + +#: The addresses the retirement pipeline is served at. +RETIREMENT_PATHS = { + RETIREMENT_LIST_PATH, + RETIREMENT_MEMBER_PATH, + RETIREMENT_ERASURE_PATH, + RETIREMENT_CLEANUP_PATH, + PARTNER_REPORT_LIST_PATH, + PARTNER_REPORT_MEMBER_PATH, + PARTNER_REPORT_CLEANUP_PATH, +} + +PAGINATION_MEMBERS = {"count", "num_pages", "current_page", "start", "next", "previous", "results"} + +#: Where this version is mounted in the service's URL configuration. +VERSION_TWO_MOUNT = "api/user/v2/" + +#: The components this version contributes to the document. The two names +#: ending in ``Enum`` describe sets of choices and are configured in the +#: service's settings rather than derived from the fields carrying them, so +#: that another field of the same name cannot move them. +VERSION_TWO_COMPONENTS = ( + "Account", + "AccountExtendedField", + "AccountLanguage", + "AccountMinimal", + "AccountPreference", + "AccountPreferenceValue", + "AccountProfileImage", + "AccountProjection", + "AccountResponse", + "AccountSocialLink", + "EmailOptIn", + "EmailSearch", + "EmailSearchResult", + "ErrorResponse", + "NameChange", + "PaginatedAccountResponseList", + "PaginatedEmailSearchResultList", + "PaginatedVerificationList", + "PatchedAccountUpdate", + "OutcomeEnum", + "PaginatedRetirementResponseList", + "Retirement", + "RetirementAccount", + "RetirementAccountProfile", + "RetirementBatch", + "RetirementBatchOutcome", + "RetirementBatchResult", + "RetirementErasure", + "RetirementMinimal", + "RetirementPartnerReportRecord", + "RetirementProjection", + "RetirementResponse", + "RetirementUpdate", + "ScopeEnum", + "PaginatedTimeZoneList", + "PaginatedUserDirectoryResponseList", + "PaginatedUserPreferenceRecordResponseList", + "ProfileImageUpload", + "TimeZone", + "UserDirectory", + "UserDirectoryMinimal", + "UserDirectoryProjection", + "UserDirectoryResponse", + "UserPreferenceRecord", + "UserPreferenceRecordMinimal", + "UserPreferenceRecordProjection", + "UserPreferenceRecordResponse", + "Verification", + "VerificationStatus", +) + +#: Every body these endpoints return: the published path it is documented +#: under, the address and parameters that produce it, and the component that +#: describes each record the body carries. +SHAPE_CASES = ( + ("/api/user/v2/users/", "/api/user/v2/users/", {}, "UserDirectory"), + ("/api/user/v2/users/", "/api/user/v2/users/", {"view": "minimal"}, "UserDirectoryMinimal"), + ("/api/user/v2/users/", "/api/user/v2/users/", {"fields": "username,email"}, "UserDirectoryProjection"), + ( + "/api/user/v2/users/", + "/api/user/v2/users/", + {"view": "minimal", "fields": "username"}, + "UserDirectoryProjection", + ), + ("/api/user/v2/users/{username}/", "/api/user/v2/users/shape-admin/", {}, "UserDirectory"), + ( + "/api/user/v2/users/{username}/", + "/api/user/v2/users/shape-admin/", + {"view": "minimal"}, + "UserDirectoryMinimal", + ), + ( + "/api/user/v2/users/{username}/", + "/api/user/v2/users/shape-admin/", + {"fields": "id,url"}, + "UserDirectoryProjection", + ), + ("/api/user/v2/user_preferences/", "/api/user/v2/user_preferences/", {}, "UserPreferenceRecord"), + ("/api/user/v2/user_preferences/", "/api/user/v2/user_preferences/", {"view": "full"}, "UserPreferenceRecord"), + ( + "/api/user/v2/user_preferences/", + "/api/user/v2/user_preferences/", + {"view": "minimal"}, + "UserPreferenceRecordMinimal", + ), + ( + "/api/user/v2/user_preferences/", + "/api/user/v2/user_preferences/", + {"fields": "key,value"}, + "UserPreferenceRecordProjection", + ), + ( + "/api/user/v2/user_preferences/{username},{key}/", + "/api/user/v2/user_preferences/shape-admin,pref-lang/", + {}, + "UserPreferenceRecord", + ), + ( + "/api/user/v2/user_preferences/{username},{key}/", + "/api/user/v2/user_preferences/shape-admin,pref-lang/", + {"view": "full"}, + "UserPreferenceRecord", + ), + ( + "/api/user/v2/user_preferences/{username},{key}/", + "/api/user/v2/user_preferences/shape-admin,pref-lang/", + {"view": "minimal"}, + "UserPreferenceRecordMinimal", + ), + ( + "/api/user/v2/user_preferences/{username},{key}/", + "/api/user/v2/user_preferences/shape-admin,pref-lang/", + {"fields": "value"}, + "UserPreferenceRecordProjection", + ), + ( + PREFERENCE_NAME_PATH, + "/api/user/v2/accounts/shape-admin/preferences/pref-lang/", + {}, + "AccountPreference", + ), + ("/api/user/v2/time_zones/", "/api/user/v2/time_zones/", {"country_code": "FR"}, "TimeZone"), + ("/api/user/v2/accounts/", "/api/user/v2/accounts/", {}, "Account"), + ("/api/user/v2/accounts/", "/api/user/v2/accounts/", {"view": "minimal"}, "AccountMinimal"), + ("/api/user/v2/accounts/", "/api/user/v2/accounts/", {"view": "shared"}, "Account"), + ( + "/api/user/v2/accounts/", + "/api/user/v2/accounts/", + {"fields": "username,email"}, + "AccountProjection", + ), + ("/api/user/v2/accounts/{username}/", "/api/user/v2/accounts/me/", {}, "Account"), + ("/api/user/v2/accounts/{username}/", "/api/user/v2/accounts/shape-admin/", {}, "Account"), + ( + "/api/user/v2/accounts/{username}/", + "/api/user/v2/accounts/shape-admin/", + {"view": "minimal"}, + "AccountMinimal", + ), + ( + "/api/user/v2/accounts/{username}/", + "/api/user/v2/accounts/shape-admin/", + {"fields": "username"}, + "AccountProjection", + ), +) + + +def as_json_schema(node): + """ + Return a published schema node as a JSON Schema document. + + The document describes an absent value with ``nullable``, which JSON + Schema spells as an alternative type, so a validator reading the published + node verbatim would reject a member the API is documented to omit. + """ + if isinstance(node, list): + return [as_json_schema(item) for item in node] + if not isinstance(node, dict): + return node + converted = {key: as_json_schema(value) for key, value in node.items() if key != "nullable"} + if node.get("nullable"): + return {"anyOf": [converted, {"type": "null"}]} + return converted + + +def published_ordering(schema, path): + """Return the sort parameter a list publishes.""" + return next( + published for published in schema["paths"][path]["get"]["parameters"] + if published["name"] == "ordering" + ) + + +def published_body_schema(schema, path): + """Return what a successful body of ``path`` must satisfy, components included.""" + body = schema["paths"][path]["get"]["responses"]["200"]["content"]["application/json"]["schema"] + return as_json_schema({**body, "components": schema["components"]}) + + +def published_request_schema(schema, path, method, media_type): + """Return what a request body of ``path`` must satisfy, components included.""" + body = schema["paths"][path][method]["requestBody"]["content"][media_type]["schema"] + return as_json_schema({**body, "components": schema["components"]}) + + +def published_component_schema(schema, name): + """Return one published component as a document a validator can read on its own.""" + return as_json_schema({ + "$ref": f"#/components/schemas/{name}", + "components": schema["components"], + }) + + +def records_of(body): + """Return the records a body carries, whether it is a page of them or a single one.""" + return body["results"] if "results" in body else [body] + + +def assert_records_match(schema, body, component, case=None): + """ + Assert every record of ``body`` is what the named component says it is. + + A body is checked against the one component that describes it rather than + against the alternatives its address may answer with, because any of those + alternatives accepts a body the others would reject, and a property no + alternative describes is as much a difference as one typed wrongly. + """ + validator = jsonschema.Draft202012Validator(published_component_schema(schema, component)) + published = set(schema["components"]["schemas"][component]["properties"]) + for record in records_of(body): + assert [error.message for error in validator.iter_errors(record)] == [], (case, component, record) + assert set(record) <= published, (case, component, set(record) - published) + +#: The version 1 addresses this change supersedes, written out independently of +#: the constant the hook reads so that changing either one shows up here. +SUPERSEDED_USER_ADDRESSES = { + "/api/user/v1/me": ACCOUNT_MEMBER_PATH, + "/api/user/v1/accounts": ACCOUNT_LIST_PATH, + "/api/user/v1/accounts/search_emails": EMAIL_SEARCH_PATH, + "/api/user/v1/accounts/{username}": ACCOUNT_MEMBER_PATH, + "/api/user/v1/accounts/{username}/deactivate/": ACCOUNT_DEACTIVATION_PATH, + "/api/user/v1/accounts/{username}/image": PROFILE_IMAGE_PATH, + "/api/user/v1/accounts/{username}/verification_status/": VERIFICATION_STATUS_PATH, + "/api/user/v1/accounts/{username}/verifications/": VERIFICATION_LIST_PATH, + "/api/user/v1/accounts/name_change/": NAME_CHANGE_PATH, + "/api/user/v1/accounts/name_change/{username}/confirm/": NAME_CHANGE_CONFIRMATION_PATH, + "/api/user/v1/users/": "/api/user/v2/users/", + "/api/user/v1/users/{id}/": "/api/user/v2/users/{username}/", + "/api/user/v1/user_prefs/": "/api/user/v2/user_preferences/", + "/api/user/v1/user_prefs/{id}/": "/api/user/v2/user_preferences/{username},{key}/", + "/api/user/v1/preferences/{username}": PREFERENCE_DOCUMENT_PATH, + "/api/user/v1/preferences/{username}/{preference_key}": PREFERENCE_NAME_PATH, + "/api/user/v1/preferences/email_opt_in/": EMAIL_OPT_IN_PATH, + "/api/user/v1/preferences/{pref_key}/users/": "/api/user/v2/users/", + "/api/user/v1/forum_roles/{name}/users/": "/api/user/v2/users/", + "/api/user/v1/preferences/time_zones/": "/api/user/v2/time_zones/", + "/api/user/v1/accounts/{username}/retirement_status/": RETIREMENT_MEMBER_PATH, + "/api/user/v1/accounts/retirement_queue/": RETIREMENT_LIST_PATH, + "/api/user/v1/accounts/retirements_by_status_and_date/": RETIREMENT_LIST_PATH, + "/api/user/v1/accounts/update_retirement_status/": RETIREMENT_MEMBER_PATH, + "/api/user/v1/accounts/retirement_cleanup/": RETIREMENT_CLEANUP_PATH, + "/api/user/v1/accounts/retirement_partner_report/": PARTNER_REPORT_LIST_PATH, + "/api/user/v1/accounts/retirement_partner_report_cleanup/": PARTNER_REPORT_CLEANUP_PATH, + "/api/user/v1/accounts/cancel_retirement/": RETIREMENT_MEMBER_PATH, + "/api/user/v1/accounts/retire/": RETIREMENT_ERASURE_PATH, + "/api/user/v1/accounts/retire_misc/": RETIREMENT_ERASURE_PATH, + "/api/user/v1/account/registration/": "/api/user/v2/registrations/", + "/api/user/v2/account/registration/": "/api/user/v2/registrations/", + "/api/user/v1/account/password_reset/": "/api/user/v2/password_resets/", + "/api/user/v1/account/password_reset/token/validate/": "/api/user/v2/password_reset_token_validations/", + "/api/user/v1/validation/registration": "/api/user/v2/registration_validations/", +} + +#: The superseded address the document leaves out, though the hook marks it +#: wherever it is published: it takes its version as a parameter. +SUPERSEDED_USER_ADDRESSES_LEFT_OUT = { + "/api/user/{api_version}/account/login_session/": "/api/user/v2/login_sessions/", +} + +#: Legacy enrollment addresses this schema already marked before the user API joined it. +SUPERSEDED_ENROLLMENT_PATHS = { + "/api/enrollment/v2/course/{course_id}", + "/api/enrollment/v2/enrollment/{course_id}", + "/api/enrollment/v2/enrollment/{username},{course_id}", + "/api/enrollment/v2/enrollments", +} + +#: Enrollment operations whose ids must survive the widened path prefix. +ENROLLMENT_OPERATION_IDS = { + "v1_enrollments_list", + "v1_enrollment_create", + "v2_enrollments_list", + "v2_enrollments_list_legacy", + "v2_enrollment_,_retrieve", + "v2_roles_retrieve", +} + + +@pytest.fixture(name="schema") +@skip_unless_lms +def schema_fixture(db): # pylint: disable=invalid-name, unused-argument + """The schema published under the service's own drf-spectacular settings.""" + return SchemaGenerator().get_schema(request=None, public=True) + + +def service_wide_schema(patterns=None): + """Return the document the service publishes for browsing its whole surface.""" + with patched_settings(get_api_docs_settings()): + return SchemaGenerator(patterns=patterns).get_schema(request=None, public=True) + + +def patterns_without_this_version(): + """Return the service's URL configuration with this version's mount taken out.""" + configured = import_module(settings.ROOT_URLCONF).urlpatterns + remaining = [entry for entry in configured if str(entry.pattern) != VERSION_TWO_MOUNT] + assert len(remaining) == len(configured) - 1, "this version is not mounted where it is looked for" + return remaining + + +def published_names(schema): + """Return every component name and operation id a document carries.""" + return set(schema["components"]["schemas"]) | { + operation["operationId"] for _path, _method, operation in operations(schema) + } + + +def operations(schema): + """Yield every (path, method, operation) the schema documents.""" + for path, path_item in schema["paths"].items(): + for method, operation in path_item.items(): + if isinstance(operation, dict) and method in {"get", "post", "put", "patch", "delete"}: + yield path, method, operation + + +@skip_unless_lms +def test_version_two_addresses_are_published(schema): + assert VERSION_TWO_PATHS <= set(schema["paths"]) + + +@skip_unless_lms +def test_every_operation_of_this_version_is_published_under_one_group(schema): + """ + An operation a client library is generated from carries that mark alone. + + A client takes one class per group, so an operation in two of them is + written into both; every operation the platform generates a library from + carries the one mark, and the rest name the area they belong to. + """ + tags = { + operation["operationId"]: operation.get("tags") + for path, _method, operation in operations(schema) + if path in VERSION_TWO_PATHS + } + assert tags == { + "v2_accounts_list": ["openedx-platform-sdk"], + "v2_accounts_retrieve": ["openedx-platform-sdk"], + "v2_accounts_partial_update": ["openedx-platform-sdk"], + "v2_email_searches_create": ["openedx-platform-sdk"], + "v2_accounts_deactivation_create": ["openedx-platform-sdk"], + "v2_accounts_profile_image_create": ["openedx-platform-sdk"], + "v2_accounts_profile_image_destroy": ["openedx-platform-sdk"], + "v2_accounts_verification_status_retrieve": ["openedx-platform-sdk"], + "v2_accounts_verifications_list": ["openedx-platform-sdk"], + "v2_name_changes_create": ["openedx-platform-sdk"], + "v2_name_changes_confirmations_create": ["openedx-platform-sdk"], + "v2_accounts_preferences_retrieve": ["openedx-platform-sdk"], + "v2_accounts_preferences_partial_update": ["openedx-platform-sdk"], + "v2_accounts_preferences_retrieve_one": ["openedx-platform-sdk"], + "v2_accounts_preferences_update": ["openedx-platform-sdk"], + "v2_accounts_preferences_destroy": ["openedx-platform-sdk"], + "v2_email_opt_ins_create": ["openedx-platform-sdk"], + "v2_users_list": ["user"], + "v2_users_retrieve": ["user"], + "v2_user_preferences_list": ["user"], + "v2_user_preferences_retrieve": ["user"], + "v2_time_zones_list": ["user"], + "v2_retirements_list": ["user"], + "v2_retirements_retrieve": ["user"], + "v2_retirements_partial_update": ["user"], + "v2_retirements_destroy": ["user"], + "v2_retirements_erasures_create": ["user"], + "v2_retirement_cleanups_create": ["user"], + "v2_retirement_partner_reports_create": ["user"], + "v2_retirement_partner_reports_update": ["user"], + "v2_retirement_partner_report_cleanups_create": ["user"], + } + + +@skip_unless_lms +def test_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 | AUTHN_PATHS | set(SUPERSEDED_USER_ADDRESSES) + + +@skip_unless_lms +def test_addresses_owned_by_other_apps_are_not_published(schema): + """Neighbours of the mounts this change adds, including one under its own prefix.""" + for path in ( + "/api/user/v1/accounts/deactivate_logout/", + "/api/user/v1/accounts/replace_usernames/", + "/api/profile_images/v1/{username}/upload", + "/api/profile_images/v1/{username}/remove", + ): + assert path not in schema["paths"], path + + +@skip_unless_lms +def test_pagination_envelope_is_published_in_full(schema): + """Every paged body this version publishes carries the whole envelope, not DRF's four members.""" + paged = [name for name in VERSION_TWO_COMPONENTS if name.startswith("Paginated")] + assert len(paged) == 7 + for component in paged: + published = schema["components"]["schemas"][component] + assert set(published["properties"]) == PAGINATION_MEMBERS, component + + +@pytest.mark.django_db +@skip_unless_lms +def test_published_pagination_envelope_matches_the_response_body(client, schema): + admin = AdminFactory.create(username="schema-admin", password=PASSWORD) + UserPreferenceFactory.create(user=admin, key="pref-lang", value="en") + assert client.login(username=admin.username, password=PASSWORD) + body = client.get(reverse("user_v2:user_preference_list")).json() + published = schema["components"]["schemas"]["PaginatedUserPreferenceRecordResponseList"] + assert set(body) == set(published["properties"]) + + +@skip_unless_lms +def test_error_responses_reference_the_shared_envelope(schema): + """One refusal carries more than the envelope, and it is the only one that may.""" + extends_the_envelope = {(ACCOUNT_LIST_PATH, "get", "404"): "RetiredAccountErrorResponse"} + for path, method, operation in operations(schema): + if path not in VERSION_TWO_PATHS: + continue + for code, response in operation["responses"].items(): + if not code.startswith(("4", "5")): + continue + component = extends_the_envelope.get((path, method, code), "ErrorResponse") + schema_ref = response["content"]["application/json"]["schema"] + assert schema_ref == {"$ref": f"#/components/schemas/{component}"}, (path, method, code) + + +@skip_unless_lms +def test_a_refusal_that_does_not_depend_on_the_resource_reads_the_same_at_every_address(schema): + """Each of these codes is published with one text, whichever resource of this version answers it.""" + published = {"401": set(), "415": set()} + for path, _method, operation in operations(schema): + if path not in VERSION_TWO_PATHS: + continue + for code, response in operation["responses"].items(): + if code in published: + published[code].add(response["description"]) + assert published == { + "401": {"The requester is not authenticated."}, + "415": {"The request body is in a media type this endpoint cannot read."}, + } + + +@skip_unless_lms +def test_an_operation_that_takes_a_response_preset_publishes_the_refusal(schema): + """A preset the operation does not answer to is a 400, so each of them documents one.""" + takes_a_preset = [ + (path, method) + for path, method, operation in operations(schema) + if path in VERSION_TWO_PATHS + and any(parameter["name"] == "view" for parameter in operation.get("parameters", [])) + and "400" in operation["responses"] + ] + assert sorted(takes_a_preset) == [ + (ACCOUNT_LIST_PATH, "get"), + (ACCOUNT_MEMBER_PATH, "get"), + (ACCOUNT_MEMBER_PATH, "patch"), + ("/api/user/v2/retirements/", "get"), + ("/api/user/v2/retirements/{username}/", "get"), + ("/api/user/v2/user_preferences/", "get"), + ("/api/user/v2/user_preferences/{username},{key}/", "get"), + ("/api/user/v2/users/", "get"), + ("/api/user/v2/users/{username}/", "get"), + ] + + +@skip_unless_lms +def test_user_list_publishes_its_filters(schema): + parameters = { + parameter["name"]: parameter + for parameter in schema["paths"]["/api/user/v2/users/"]["get"]["parameters"] + } + assert set(parameters) == { + "course_key", "email", "fields", "forum_role", "is_active", "ordering", + "page", "page_size", "preference_key", "username", "view", + } + # The filter that refuses an unparsable value is still published as a boolean. + assert parameters["is_active"]["schema"] == {"type": "boolean"} + + +@skip_unless_lms +def test_preference_list_publishes_its_filters(schema): + parameters = { + parameter["name"]: parameter + for parameter in schema["paths"]["/api/user/v2/user_preferences/"]["get"]["parameters"] + } + assert set(parameters) == {"fields", "key", "ordering", "page", "page_size", "username", "view"} + # Every published parameter is one the list reads, and one it keeps. + assert [name for name, parameter in parameters.items() if parameter.get("deprecated")] == [] + + +@skip_unless_lms +def test_every_collection_that_selects_accounts_by_name_publishes_the_same_kind_of_value(schema): + """ + A value learned on one of these collections selects the same accounts on the others. + + Each of the three reads a list of names, and a name no account holds is + left out of the page rather than refused, so the published description + opens with the same sentence and the value is published as the text that + carries the list. + """ + published = { + path: next( + parameter for parameter in operation["parameters"] if parameter["name"] == "username" + ) + for path, _method, operation in operations(schema) + if path in {ACCOUNT_LIST_PATH, USER_LIST_PATH, PREFERENCE_LIST_PATH} + } + assert {path: parameter["schema"] for path, parameter in published.items()} == { + ACCOUNT_LIST_PATH: {"type": "string"}, + USER_LIST_PATH: {"type": "string"}, + PREFERENCE_LIST_PATH: {"type": "string"}, + } + assert { + path: parameter["description"].split(".")[0] for path, parameter in published.items() + } == { + ACCOUNT_LIST_PATH: "Comma-separated usernames", + USER_LIST_PATH: "Comma-separated usernames", + PREFERENCE_LIST_PATH: "Comma-separated usernames", + } + + +@skip_unless_lms +def test_enum_post_processing_runs_before_the_deprecation_hook(): + hooks = settings.SPECTACULAR_SETTINGS["POSTPROCESSING_HOOKS"] + assert hooks[0] == "drf_spectacular.hooks.postprocess_schema_enums" + assert "lms.lib.spectacular.lms_mark_legacy_paths_deprecated" in hooks[1:] + assert "openedx.core.djangoapps.user_api.schema.mark_superseded_user_paths" in hooks[1:] + + +@skip_unless_lms +def test_hook_leaves_other_resources_alone(): + """The hook marks only the addresses it lists, even among their neighbours.""" + untouched = [ + "/api/profile_images/v1/{username}/upload", + "/api/profile_images/v1/{username}/remove", + "/api/user/v1/accounts/deactivate_logout/", + "/user_api/v2/account/registration/", + "/api/user/v2/accounts/{username}/preferences/", + "/user_api/v1/users/", + "/user_api/v1/accounts/{username}", + "/user_api/v1/preferences/time_zones/", + "/api/enrollment/v2/enrollments/", + "/api/courses/v1/courses/", + ] + marked = [ + "/api/user/v1/users/", + "/api/user/v1/accounts", + "/api/user/v1/accounts/{username}", + "/api/user/v1/accounts/{username}/image", + "/api/user/v1/accounts/name_change/", + "/api/user/v1/preferences/{username}", + "/api/user/v1/preferences/{username}/{preference_key}", + "/api/user/v1/preferences/email_opt_in/", + "/api/user/v2/account/registration/", + "/api/enrollment/v2/enrollments", + ] + result = {"paths": {path: {"get": {"operationId": path}} for path in untouched + marked}} + + lms_mark_legacy_paths_deprecated(result, generator=None, request=None, public=True) + mark_superseded_user_paths(result, generator=None, request=None, public=True) + + for path in untouched: + assert "deprecated" not in result["paths"][path]["get"], path + for path in marked: + assert result["paths"][path]["get"]["deprecated"] is True, path + + +@skip_unless_lms +def test_every_superseded_address_has_a_published_replacement(schema): + assert set(SUPERSEDED_USER_ADDRESSES) | set(SUPERSEDED_USER_ADDRESSES_LEFT_OUT) == set(SUPERSEDED_USER_PATHS) + for superseded, replacement in SUPERSEDED_USER_ADDRESSES.items(): + assert superseded in schema["paths"] + assert replacement in schema["paths"] + for superseded, replacement in SUPERSEDED_USER_ADDRESSES_LEFT_OUT.items(): + assert superseded not in schema["paths"] + assert replacement in schema["paths"] + + +#: The media types each superseded body is published in, by address and method. +SUPERSEDED_REQUEST_MEDIA_TYPES = { + ("/api/user/v1/accounts/{username}", "patch"): {"application/merge-patch+json"}, + ("/api/user/v1/accounts/search_emails", "post"): { + "application/json", "application/merge-patch+json", + }, + ("/api/user/v1/preferences/{username}", "patch"): {"application/merge-patch+json"}, + ("/api/user/v1/preferences/{username}/{preference_key}", "put"): {"application/json"}, + ("/api/user/v1/preferences/email_opt_in/", "post"): {"application/x-www-form-urlencoded"}, +} + + +@pytest.mark.django_db +@skip_unless_lms +def test_each_superseded_body_is_published_in_the_media_types_that_address_reads(client, schema): + """ + Every media type published for a superseded body is sent to that address here. + + Those bodies are named for addresses whose views name no serializer, and a + shape named on its own is published in every media type the view's parsers + accept, which can be more than the handler reads. A generated client sends + the first type the document offers, so each one is answered by the running + endpoint here, and a type the document leaves out is sent as well. + """ + staff = AdminFactory.create(username="media_type_staff", password=PASSWORD) + assert client.login(username=staff.username, password=PASSWORD) + member = f"/api/user/v1/accounts/{staff.username}" + preferences = f"/api/user/v1/preferences/{staff.username}" + requests = { + ("/api/user/v1/accounts/{username}", "patch"): (member, "{}", 200), + ("/api/user/v1/accounts/search_emails", "post"): ( + "/api/user/v1/accounts/search_emails", json.dumps({"emails": [staff.email]}), 200, + ), + ("/api/user/v1/preferences/{username}", "patch"): ( + preferences, json.dumps({"pref-lang": "en"}), 204, + ), + # The key half of that route carries neither a hyphen nor a dot, so the + # name sent here is one it can address. + ("/api/user/v1/preferences/{username}/{preference_key}", "put"): ( + f"{preferences}/account_privacy", json.dumps("private"), 204, + ), + ("/api/user/v1/preferences/email_opt_in/", "post"): ( + "/api/user/v1/preferences/email_opt_in/", + "course_id=course-v1%3AedX%2BDemoX%2BDemo&email_opt_in=True", + 200, + ), + } + for (path, method), media_types in SUPERSEDED_REQUEST_MEDIA_TYPES.items(): + assert set(schema["paths"][path][method]["requestBody"]["content"]) == media_types, path + address, body, expected_status = requests[(path, method)] + for media_type in media_types: + answer = getattr(client, method)(address, data=body, content_type=media_type) + assert answer.status_code == expected_status, (address, media_type, answer.content) + left_out = client.patch(member, data="{}", content_type="application/json") + assert left_out.status_code == 415 + + +@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 + + +#: The sets of choices this version publishes as a model of their own, with +#: the name each is published under and the values it offers. +PINNED_CHOICE_SETS = { + "ScopeEnum": list(ERASURE_SCOPES), + "OutcomeEnum": list(BATCH_OUTCOMES), +} + +#: A second field of each of those names, with the value it would offer and +#: the name it would be published under: a set of choices sharing a field name +#: with another is named after the body carrying it. +CROWDED_CHOICE_SETS = { + "scope": ("somewhere_else", "RetirementBatchScopeEnum"), + "outcome": ("something_else", "RetirementBatchOutcomeEnum"), +} + + +@pytest.mark.django_db +@skip_unless_lms +def test_each_published_set_of_choices_keeps_its_name_when_another_field_shares_the_name(): + """ + A set of choices is published as a model named for the field that carries it. + + That name holds only while one field of the whole service carries it: a + second field of the same name offering different values renames both + models after the bodies they sit in, so the name a client already + generates a class from disappears. Both documents are therefore read with + such a second field in place, and each set has to still be published + under its own name, with the values it offers. + """ + intruders = { + name: serializers.ChoiceField(choices=[(value, f"Another set of choices, under {name}.")]) + for name, (value, _published_as) in CROWDED_CHOICE_SETS.items() + } + with mock.patch.dict(RetirementBatchSerializer._declared_fields, intruders): # pylint: disable=protected-access + crowded = { + "this version": SchemaGenerator().get_schema(request=None, public=True), + "service-wide": service_wide_schema(), + } + for which, document in crowded.items(): + components = document["components"]["schemas"] + published = { + name: components[name]["enum"] for name in PINNED_CHOICE_SETS if name in components + } + assert published == PINNED_CHOICE_SETS, which + # The second field really was published, so the collision the names + # are held against did happen in this document. + for field, (value, crowded_name) in CROWDED_CHOICE_SETS.items(): + assert components["RetirementBatch"]["properties"][field] == { + "$ref": f"#/components/schemas/{crowded_name}", + }, (which, field) + assert components[crowded_name]["enum"] == [value], (which, field) + + +@skip_unless_lms +def test_every_response_shape_is_published(schema): + """The reduced bodies the presets return are described, not only the full one.""" + components = schema["components"]["schemas"] + assert components["UserDirectoryResponse"]["anyOf"] == [ + {"$ref": "#/components/schemas/UserDirectory"}, + {"$ref": "#/components/schemas/UserDirectoryMinimal"}, + {"$ref": "#/components/schemas/UserDirectoryProjection"}, + ] + assert components["UserPreferenceRecordResponse"]["anyOf"] == [ + {"$ref": "#/components/schemas/UserPreferenceRecord"}, + {"$ref": "#/components/schemas/UserPreferenceRecordMinimal"}, + {"$ref": "#/components/schemas/UserPreferenceRecordProjection"}, + ] + assert sorted(components["UserDirectory"]["required"]) == [ + "email", "id", "name", "preferences", "url", "username", + ] + assert sorted(components["UserDirectoryMinimal"]["required"]) == ["email", "id", "username"] + assert sorted(components["UserPreferenceRecordMinimal"]["required"]) == ["key", "username", "value"] + for component in ("UserDirectoryProjection", "UserPreferenceRecordProjection"): + published = components[component] + assert "required" not in published, component + assert set(published["properties"]) == set( + components[component.replace("Projection", "")]["properties"] + ), component + + +#: A value for every property of a profile an account carries, so that a body +#: checked against its component carries a value the component has to accept +#: rather than a null every type accepts. +FILLED_IN_PROFILE = { + "bio": "Teaches mathematics.", + "city": "Cambridge", + "country": "US", + "gender": "f", + "goals": "Finish the course.", + "level_of_education": "m", + "mailing_address": "12 Park Avenue", + "phone_number": "+18005555555", + "state": "MA", + "year_of_birth": 1985, +} + +#: The properties of an account that only a filled-in one carries: each is held +#: outside the profile, or is left null by a user who never gave it. +FILLED_IN_ONLY_PROPERTIES = ( + "country", + "level_of_education", + "mailing_address", + "pending_name_change", + "phone_number", + "secondary_email", + "state", + "verified_name", +) + + +def fill_in(user): + """Give the user's profile a value for each property it holds, and a name change outstanding.""" + for name, value in FILLED_IN_PROFILE.items(): + setattr(user.profile, name, value) + user.profile.save() + user.profile.language_proficiencies.create(code="fr") + user.profile.social_links.create(platform="twitter", social_link="https://www.twitter.com/example") + PendingNameChange.objects.create( + user=user, new_name="Changed Name", rationale="Asked for in a test", + ) + return user + + +def name_affirmation_answering(verified_name): + """ + Return a patch installing a name affirmation service that holds one accepted name. + + The service is an optional install, and the property it fills is null + wherever it is absent, so the account that carries one is built with a + stand-in rather than left untested on the sites that do not have it. + """ + service = mock.Mock() + service.get_verified_name.return_value = mock.Mock(verified_name=verified_name) + return mock.patch( + "openedx.core.djangoapps.user_api.accounts.serializers.get_name_affirmation_service", + return_value=service, + ) + + +@pytest.mark.django_db +@skip_unless_lms +def test_published_shapes_accept_every_body_the_endpoints_return(client, schema): + """Every preset and selection the parameters offer returns a body the document describes.""" + admin = AdminFactory.create(username="shape-admin", password=PASSWORD) + UserPreferenceFactory.create(user=admin, key="pref-lang", value="en") + fill_in(admin) + assert client.login(username=admin.username, password=PASSWORD) + for published_path, address, params, component in SHAPE_CASES: + case = (address, params) + response = client.get(address, params) + assert response.status_code == 200, case + body = response.json() + if "results" in body: + assert body["count"] == 1, case + validator = jsonschema.Draft202012Validator(published_body_schema(schema, published_path)) + assert [error.message for error in validator.iter_errors(body)] == [], case + assert_records_match(schema, body, component, case) + + +@skip_unless_lms +def test_sort_parameter_publishes_the_fields_it_accepts(schema): + """The sort allow-list and the order used when none is asked for are published.""" + for path, accepted, default in ( + ("/api/user/v2/users/", ["-date_joined", "-username", "date_joined", "username"], ["username"]), + ("/api/user/v2/user_preferences/", ["-key", "-username", "key", "username"], ["username", "key"]), + ): + parameter = next( + published for published in schema["paths"][path]["get"]["parameters"] + if published["name"] == "ordering" + ) + assert parameter["schema"]["items"]["enum"] == accepted, path + assert parameter["schema"]["default"] == default, path + + +@skip_unless_lms +def test_every_list_publishes_the_page_that_does_not_exist(schema): + """Asking for a page past the end of a list is answered 404, so each list documents it.""" + assert LIST_PATHS | MEMBER_PATHS | ACTION_PATHS == set(VERSION_TWO_PATHS) + documented = { + path: sorted(operation["responses"]) + for path, _method, operation in operations(schema) + if path in LIST_PATHS + } + assert documented == { + ACCOUNT_LIST_PATH: ["200", "400", "401", "403", "404", "406"], + VERIFICATION_LIST_PATH: ["200", "400", "401", "403", "404", "406"], + "/api/user/v2/users/": ["200", "400", "401", "403", "404", "406"], + "/api/user/v2/user_preferences/": ["200", "400", "401", "403", "404", "406"], + TIME_ZONE_LIST_PATH: ["200", "400", "404", "406"], + RETIREMENT_LIST_PATH: ["200", "400", "401", "403", "404", "406"], + } + + +@skip_unless_lms +def test_member_addresses_describe_the_parts_they_are_built_from(schema): + """Each part of a member address is published with its meaning and what its route matches.""" + username_schema = {"type": "string", "pattern": rf"^{settings.USERNAME_REGEX_PARTIAL}$"} + published = { + (path, parameter["name"]): parameter + for path, _method, operation in operations(schema) + if path in MEMBER_PATHS + for parameter in operation["parameters"] + if parameter["in"] == "path" + } + assert published == { + (ACCOUNT_MEMBER_PATH, "username"): { + "in": "path", + "name": "username", + "required": True, + "schema": username_schema, + "description": ( + "Unique name of the user whose account is addressed. `me` names the account of " + "the caller, so an account holding that name is read through the collection, " + "with `username=` naming it." + ), + }, + (VERIFICATION_STATUS_PATH, "username"): { + "in": "path", + "name": "username", + "required": True, + "schema": username_schema, + "description": ( + "Unique name of the user whose identity verification is addressed. The name is " + "read as it is stored, so `me` here names an account holding that name rather " + "than the account of the caller making the request." + ), + }, + (USER_MEMBER_PATH, "username"): { + "in": "path", + "name": "username", + "required": True, + "schema": username_schema, + "description": "Unique name of the user whose directory entry is returned.", + }, + (PREFERENCE_MEMBER_PATH, "username"): { + "in": "path", + "name": "username", + "required": True, + "schema": username_schema, + "description": ( + "Username of the user the preference belongs to: the part of the address " + "before the comma." + ), + }, + (PREFERENCE_MEMBER_PATH, "key"): { + "in": "path", + "name": "key", + "required": True, + "schema": {"type": "string", "pattern": rf"^{PREFERENCE_KEY_PATTERN_PARTIAL}$"}, + "description": ( + "Name the preference is stored under: the part of the address after the comma." + ), + }, + (PREFERENCE_DOCUMENT_PATH, "username"): { + "in": "path", + "name": "username", + "required": True, + "schema": username_schema, + "description": ACCOUNT_PREFERENCES_USERNAME_DESCRIPTION, + }, + (PREFERENCE_NAME_PATH, "username"): { + "in": "path", + "name": "username", + "required": True, + "schema": username_schema, + "description": ACCOUNT_PREFERENCES_USERNAME_DESCRIPTION, + }, + (RETIREMENT_MEMBER_PATH, "username"): { + "in": "path", + "name": "username", + "required": True, + "schema": username_schema, + "description": RETIREMENT_USERNAME_DESCRIPTION, + }, + (PARTNER_REPORT_MEMBER_PATH, "username"): { + "in": "path", + "name": "username", + "required": True, + "schema": username_schema, + "description": PARTNER_REPORT_USERNAME_DESCRIPTION, + }, + (PREFERENCE_NAME_PATH, "key"): { + "in": "path", + "name": "key", + "required": True, + "schema": {"type": "string", "pattern": rf"^{PREFERENCE_KEY_PATTERN_PARTIAL}$"}, + "description": ( + "Name the preference is stored under, which is the whole segment, punctuation " + "included. A name carrying a slash has no address of its own, because the " + "segment ends at the slash; such a name is read with the whole document and " + "removed by sending a null for it there. Nothing is stored under a name longer " + f"than {PREFERENCE_NAME_MAX_LENGTH} characters, nor under one carrying no " + "letter, digit, hyphen or underscore." + ), + }, + } + + +@skip_unless_lms +def test_the_addresses_that_work_on_one_account_describe_the_name_they_carry(schema): + """ + An address that works on one account publishes what the name in it is read as. + + Neither of these addresses takes the shorthand the account collection + takes for the caller's own account, so what each of them does with a + request sent under that name is published rather than left for a caller to + find out by sending one. + """ + published = { + (path, method): parameter["description"] + for path, method, operation in operations(schema) + if path in (ACCOUNT_DEACTIVATION_PATH, PROFILE_IMAGE_PATH) + for parameter in operation["parameters"] + if parameter["in"] == "path" and parameter["name"] == "username" + } + image_description = ( + "Unique name of the user whose image is addressed. The name is read as it is stored, so " + "`me` here names an account holding that name rather than the account of the caller " + "making the request." + ) + assert published == { + (ACCOUNT_DEACTIVATION_PATH, "post"): ( + "Unique name of the user whose account is deactivated. The name is read as it is " + "stored, `me` included: this action disables the account the address names, never " + "the account of the caller making the request." + ), + (PROFILE_IMAGE_PATH, "post"): image_description, + (PROFILE_IMAGE_PATH, "delete"): image_description, + } + + +#: Every part of an address of this version that carries a username. A client +#: built from the document holds a value to what the route matches before it +#: sends a request, so an address left off this list is one whose caller learns +#: from a refusal what every sibling address refuses for it. +USERNAME_ADDRESS_PARTS = ( + ACCOUNT_MEMBER_PATH, + ACCOUNT_DEACTIVATION_PATH, + PROFILE_IMAGE_PATH, + VERIFICATION_STATUS_PATH, + VERIFICATION_LIST_PATH, + PREFERENCE_DOCUMENT_PATH, + PREFERENCE_NAME_PATH, + NAME_CHANGE_CONFIRMATION_PATH, + USER_MEMBER_PATH, + PREFERENCE_MEMBER_PATH, + RETIREMENT_MEMBER_PATH, + RETIREMENT_ERASURE_PATH, + PARTNER_REPORT_MEMBER_PATH, +) + + +@skip_unless_lms +def test_every_part_of_an_address_that_carries_a_username_publishes_what_the_route_matches(schema): + """ + One resource does not admit a name at one of its addresses and refuse it at the next. + + The addresses of this version are matched on the same spelling of a + username, whichever app serves them, so each of them is published with + that spelling and a client refuses the same names the routes do. + """ + published = { + (path, parameter["name"]): parameter["schema"] + for path, _method, operation in operations(schema) + if path in VERSION_TWO_PATHS + for parameter in operation.get("parameters", []) + if parameter["in"] == "path" and parameter["name"] == "username" + } + assert published == { + (path, "username"): {"type": "string", "pattern": rf"^{settings.USERNAME_REGEX_PARTIAL}$"} + for path in USERNAME_ADDRESS_PARTS + } + + +@skip_unless_lms +def test_the_country_parameter_publishes_the_constraint_it_is_held_to(schema): + """The parameter comes from the serializer that validates it, so the two cannot disagree.""" + field = TimeZoneQuerySerializer().fields["country_code"] + enforced = next( + validator.regex.pattern for validator in field.validators if hasattr(validator, "regex") + ) + parameter = next( + published for published in schema["paths"][TIME_ZONE_LIST_PATH]["get"]["parameters"] + if published["name"] == "country_code" + ) + assert parameter["description"] == field.help_text + assert parameter["schema"] == { + "oneOf": [{"type": "string", "pattern": enforced}, {"type": "string", "maxLength": 0}], + } + assert "required" not in parameter + + +@skip_unless_lms +def test_published_text_is_written_for_the_clients_that_read_it(schema): + """Descriptions reach every consumer, so they carry no governance references.""" + published = json.dumps({ + "paths": {path: schema["paths"][path] for path in VERSION_TWO_PATHS}, + "components": { + name: schema["components"]["schemas"][name] for name in VERSION_TWO_COMPONENTS + }, + }) + for reference in ("ADR", "OEP-", "drf-spectacular", "extend_schema"): + assert reference not in published, reference + assert schema["components"]["schemas"]["ErrorResponse"]["description"].startswith( + "The body of every unsuccessful response." + ) + + +@skip_unless_lms +def test_the_library_tag_marks_the_addresses_a_client_is_built_from_and_nothing_else(schema): + """The operations a generated client library is built from say so, one by one.""" + tagged = { + path for path, _method, operation in operations(schema) + if path.startswith("/api/user/") and "openedx-platform-sdk" in (operation.get("tags") or []) + } + assert tagged == SDK_PATHS + + +@skip_unless_lms +def test_account_list_publishes_its_selectors(schema): + parameters = { + parameter["name"]: parameter + for parameter in schema["paths"][ACCOUNT_LIST_PATH]["get"]["parameters"] + } + assert set(parameters) == { + "email", "fields", "lms_user_id", "ordering", "page", "page_size", "username", "view", + } + assert parameters["lms_user_id"]["schema"] == {"type": "integer"} + # An empty value narrows nothing, the way an empty value of any other + # parameter does, and is published as one the parameter accepts. + assert parameters["view"]["schema"]["enum"] == ["minimal", "shared", ""] + assert parameters["ordering"]["schema"]["enum"] == ["username", "-username", ""] + assert parameters["ordering"]["schema"]["default"] == ACCOUNT_DEFAULT_SORT == "username" + assert [name for name, parameter in parameters.items() if parameter.get("deprecated")] == [] + + +@skip_unless_lms +def test_the_selectors_are_published_from_the_serializer_that_validates_them(schema): + """The constraint a caller is held to is the one the published parameter carries.""" + declared = AccountQuerySerializer().fields + published = { + parameter["name"]: parameter + for parameter in schema["paths"][ACCOUNT_LIST_PATH]["get"]["parameters"] + } + for name, field in declared.items(): + assert published[name]["description"].startswith(field.help_text), name + assert "required" not in published[name], name + assert published["view"]["description"].endswith( + "* `minimal` - The properties that identify the account's owner\n" + "* `shared` - The properties the owner shares with everybody", + ) + + +@skip_unless_lms +def test_the_education_levels_published_are_the_ones_a_profile_stores(schema): + """Every code the profile offers is named where the property is published, both read and written.""" + offered = [code for code, _label in UserProfile.LEVEL_OF_EDUCATION_CHOICES] + assert offered == ["p", "m", "b", "a", "hs", "jhs", "el", "none", "other"] + for component in ("Account", "PatchedAccountUpdate"): + published = schema["components"]["schemas"][component]["properties"]["level_of_education"] + assert [code for code in offered if f"`{code}`" in published["description"]] == offered, component + + +@skip_unless_lms +def test_a_change_publishes_the_body_it_reads_and_the_media_type_it_reads_it_in(schema): + request = schema["paths"][ACCOUNT_MEMBER_PATH]["patch"]["requestBody"] + assert set(request["content"]) == {"application/merge-patch+json"} + assert request["content"]["application/merge-patch+json"]["schema"] == { + "$ref": "#/components/schemas/PatchedAccountUpdate", + } + changeable = schema["components"]["schemas"]["PatchedAccountUpdate"] + assert "required" not in changeable + assert {"email", "name", "bio", "social_links", "extended_profile"} <= set(changeable["properties"]) + assert "username" not in changeable["properties"] + + +@pytest.mark.django_db +@skip_unless_lms +def test_the_order_the_account_list_publishes_as_its_default_is_the_one_it_answers_with(client, schema): + """ + The default is read off the document and asked of the running list. + + An order stated in the document and an order applied in the handler are + two declarations of one thing, and a client that omits the parameter is + answered by the second while it was built from the first. + """ + parameter = next( + published for published in schema["paths"][ACCOUNT_LIST_PATH]["get"]["parameters"] + if published["name"] == "ordering" + ) + default = parameter["schema"]["default"] + staff = AdminFactory.create(username="sorting_staff", password=PASSWORD) + for name in ("sorted_c", "sorted_a", "sorted_b"): + UserFactory.create(username=name) + assert client.login(username=staff.username, password=PASSWORD) + selection = {"username": "sorted_c,sorted_a,sorted_b"} + + omitted = client.get(ACCOUNT_LIST_PATH, selection) + assert [row["username"] for row in omitted.json()["results"]] == [ + "sorted_a", "sorted_b", "sorted_c", + ] + asked_for = client.get(ACCOUNT_LIST_PATH, {**selection, "ordering": default}) + assert [row["username"] for row in asked_for.json()["results"]] == [ + "sorted_a", "sorted_b", "sorted_c", + ] + the_other_way = client.get(ACCOUNT_LIST_PATH, {**selection, "ordering": f"-{default}"}) + assert [row["username"] for row in the_other_way.json()["results"]] == [ + "sorted_c", "sorted_b", "sorted_a", + ] + + +@pytest.mark.django_db +@skip_unless_lms +def test_every_property_the_change_publishes_a_null_for_is_cleared_by_one(client, schema): + """ + A null is sent for each property published as taking one, and is answered. + + A generated client offers a null exactly where the body publishes one, so + each of those is a change this endpoint has to carry out rather than fail + on, and a property that cannot be cleared this way belongs outside the set. + """ + published = schema["components"]["schemas"]["PatchedAccountUpdate"]["properties"] + takes_a_null = sorted(name for name, shape in published.items() if shape.get("nullable")) + assert takes_a_null == [ + "account_privacy", "city", "country", "gender", "goals", "level_of_education", + "mailing_address", "state", "year_of_birth", + ] + owner = UserFactory.create(username="clearing_owner", password=PASSWORD) + assert client.login(username=owner.username, password=PASSWORD) + address = f"/api/user/v2/accounts/{owner.username}/" + for name in takes_a_null: + answer = client.patch( + address, data=json.dumps({name: None}), content_type="application/merge-patch+json", + ) + assert answer.status_code == 200, (name, answer.content) + # The account the last change answered with holds every one of them cleared, + # bar the city, which a change carries and no response does. + account = answer.json() + assert {name: account.get(name, ABSENT) for name in takes_a_null} == { + # Clearing the visibility choice hands the account back to the visibility + # the site gives an account whose profile does not say how old its owner + # is, the year of birth having been cleared here as well. + "account_privacy": "private", + "city": ABSENT, + "country": None, + "gender": None, + "goals": None, + "level_of_education": None, + "mailing_address": None, + "state": None, + "year_of_birth": None, + } + + +@skip_unless_lms +def test_a_change_publishes_the_refusals_only_it_can_answer_with(schema): + assert sorted(schema["paths"][ACCOUNT_MEMBER_PATH]["patch"]["responses"]) == [ + "200", "400", "401", "403", "404", "406", "415", "429", "500", + ] + assert schema["paths"][ACCOUNT_MEMBER_PATH]["patch"]["responses"]["500"]["description"] == ( + "The change was asked for correctly and could not be carried out. Sending it again " + "with something else in it does not help; sending the same request later may." + ) + assert sorted(schema["paths"][ACCOUNT_MEMBER_PATH]["get"]["responses"]) == [ + "200", "400", "401", "403", "404", "406", + ] + + +@skip_unless_lms +def test_the_operations_that_read_a_body_publish_the_refusal_of_one_they_cannot_read(schema): + """A body the endpoint cannot parse is the caller's fault, and every such address says so.""" + for path, method in ( + (ACCOUNT_MEMBER_PATH, "patch"), + (EMAIL_SEARCH_PATH, "post"), + (NAME_CHANGE_PATH, "post"), + ): + assert schema["paths"][path][method]["responses"]["400"]["description"] == ( + "The request body is not well formed in the media type it was sent in, or carries a " + "value this endpoint will not take. A query parameter this endpoint does not read is " + "refused here too." + ), path + assert schema["paths"][PROFILE_IMAGE_PATH]["post"]["responses"]["400"]["description"] == ( + "The request carries no usable image: none was sent, the body is not well formed in the " + "media type it was sent in, one sent as the whole body was sent without a name or under a " + "name whose extension names another media type, the image is of a type or a size this " + "site does not take, or no picture can be drawn from the file, whose bytes may stop " + "early, carry metadata that does not parse, or declare more pixels than this site draws. " + "A query parameter this endpoint does not read is refused here too." + ) + + +@skip_unless_lms +def test_the_operations_only_an_owner_reaches_publish_the_cause_their_refusal_really_has(schema): + """ + Where only the owner is admitted, nothing is published as missing that is refused instead. + + A name nobody holds is not the caller's own, so these operations refuse it + as another account's rather than report it absent. What is left for the + absent answer is an account whose profile is gone, and that is what the + document describes, so a client reading one of these branches meets it. + """ + assert schema["paths"][ACCOUNT_MEMBER_PATH]["patch"]["responses"]["404"]["description"] == ( + "The account that was addressed is no longer there. Only the caller's own account is " + "changed here, so a name nobody holds is refused as somebody else's account instead of " + "being reported as missing." + ) + for method in ("post", "delete"): + assert schema["paths"][PROFILE_IMAGE_PATH][method]["responses"]["404"]["description"] == ( + "The account at this address carries no profile to record an image on. A name no " + "account holds is refused as somebody else's image instead of being reported here." + ), method + assert schema["paths"][NAME_CHANGE_PATH]["post"]["responses"]["404"]["description"] == ( + "The account of the caller carries no profile to put a name on." + ) + + +@skip_unless_lms +def test_the_search_publishes_the_addresses_it_reads_and_the_page_it_answers_with(schema): + operation = schema["paths"][EMAIL_SEARCH_PATH]["post"] + assert operation["requestBody"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/EmailSearch", + } + assert operation["responses"]["200"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/PaginatedEmailSearchResultList", + } + assert set( + schema["components"]["schemas"]["PaginatedEmailSearchResultList"]["properties"], + ) == PAGINATION_MEMBERS + # The most addresses one search may name is published, so a client knows + # the bound before it sends a body the search will refuse. + addresses = schema["components"]["schemas"]["EmailSearch"]["properties"]["emails"] + assert addresses["type"] == "array" + assert addresses["maxItems"] == 100 + assert sorted(operation["responses"]) == ["200", "400", "401", "403", "404", "406", "415"] + # The page is the only thing it reads from the query string. + assert [parameter["name"] for parameter in operation["parameters"]] == ["page", "page_size"] + + +@skip_unless_lms +def test_a_deactivation_publishes_that_it_answers_with_nothing(schema): + operation = schema["paths"][ACCOUNT_DEACTIVATION_PATH]["post"] + assert "requestBody" not in operation + assert sorted(operation["responses"]) == ["204", "400", "401", "403", "404", "406"] + assert "content" not in operation["responses"]["204"] + assert operation["responses"]["204"]["description"] == "The account was deactivated." + + +@skip_unless_lms +def test_the_name_a_deactivation_disables_is_published_as_a_stored_one(schema): + """``me`` names the caller where an account is read, and a stored account where one is disabled.""" + disabled = { + parameter["name"]: parameter + for parameter in schema["paths"][ACCOUNT_DEACTIVATION_PATH]["post"]["parameters"] + } + assert disabled["username"]["description"] == ( + "Unique name of the user whose account is deactivated. The name is read as it is " + "stored, `me` included: this action disables the account the address names, never the " + "account of the caller making the request." + ) + + +#: The properties of an account that always carry a value when they are sent. +ALWAYS_VALUED_ACCOUNT_PROPERTIES = {"account_privacy", "date_joined", "email", "id", "is_active", "username"} + +#: The properties a profile holds, which an account without one answers with null. +PROFILE_HELD_PROPERTIES = ( + "extended_profile", + "language_proficiencies", + "profile_image", + "requires_parental_consent", + "social_links", +) + + +@skip_unless_lms +def test_an_account_is_published_with_the_properties_visibility_decides(schema): + """Only the properties every caller may see are published as always present.""" + components = schema["components"]["schemas"] + assert components["AccountResponse"]["anyOf"] == [ + {"$ref": "#/components/schemas/Account"}, + {"$ref": "#/components/schemas/AccountMinimal"}, + {"$ref": "#/components/schemas/AccountProjection"}, + ] + assert sorted(components["Account"]["required"]) == [ + "account_privacy", "profile_image", "username", + ] + assert set(components["Account"]["properties"]) == set( + settings.ACCOUNT_VISIBILITY_CONFIGURATION["admin_fields"], + ) + assert sorted(components["AccountMinimal"]["required"]) == ["profile_image", "username"] + assert set(components["AccountMinimal"]["properties"]) == { + "username", "email", "name", "profile_image", + } + assert "required" not in components["AccountProjection"] + + +@skip_unless_lms +def test_an_account_publishes_which_of_its_properties_can_come_back_without_a_value(schema): + """A property that is only sometimes held has to be published as one that can be null.""" + components = schema["components"]["schemas"] + properties = components["Account"]["properties"] + assert { + name for name, published in properties.items() if not published.get("nullable") + } == ALWAYS_VALUED_ACCOUNT_PROPERTIES + extra_field_value = components["AccountExtendedField"]["properties"]["field_value"] + assert "type" not in extra_field_value, "an extra field answers with whatever the site stored in it" + + +def account_owner(client, username, **profile_values): + """Create a user old enough to share a profile, sign them in, and return them.""" + user = UserFactory.create(username=username, password=PASSWORD) + user.profile.year_of_birth = 1985 + for name, value in profile_values.items(): + setattr(user.profile, name, value) + user.profile.save() + assert client.login(username=username, password=PASSWORD) + return user + + +@pytest.mark.django_db +@skip_unless_lms +def test_an_owner_reading_their_account_where_no_recovery_address_is_collected(client, schema): + """The site collects no recovery address, which the account says by carrying none.""" + account_owner(client, "shape-owner") + with override_switch(ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH, active=False): + body = client.get("/api/user/v2/accounts/me/").json() + assert body["secondary_email_enabled"] is None + assert body["secondary_email"] is None + assert_records_match(schema, body, "Account") + + +@pytest.mark.django_db +@skip_unless_lms +def test_an_owner_reading_their_account_where_a_recovery_address_is_collected(client, schema): + """The other side of the same choice: the account says the site collects one.""" + account_owner(client, "shape-owner") + with override_switch(ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH, active=True): + body = client.get("/api/user/v2/accounts/me/").json() + assert body["secondary_email_enabled"] is True + assert body["secondary_email"] is None + assert_records_match(schema, body, "Account") + + +@pytest.mark.django_db +@skip_unless_lms +def test_a_staff_caller_reading_an_account_that_has_no_profile(client, schema): + """Every property a profile holds comes back null for an account that has none.""" + staff = AdminFactory.create(username="shape-staff", password=PASSWORD) + profileless = UserFactory.create(username="shape-profileless", password=PASSWORD) + profileless.profile.delete() + assert client.login(username=staff.username, password=PASSWORD) + body = client.get("/api/user/v2/accounts/shape-profileless/").json() + assert {name: body[name] for name in PROFILE_HELD_PROPERTIES} == dict.fromkeys(PROFILE_HELD_PROPERTIES) + assert_records_match(schema, body, "Account") + + +@pytest.mark.django_db +@skip_unless_lms +def test_a_learner_reading_an_account_whose_owner_shares_it_with_everybody(client, schema): + """A profile shared with all users carries more than the properties everybody may see.""" + shared = UserFactory.create(username="shape-shared", password=PASSWORD) + shared.profile.year_of_birth = 1985 + shared.profile.bio = "Teaches mathematics." + shared.profile.save() + account_owner(client, "shape-reader") + body = client.get("/api/user/v2/accounts/shape-shared/").json() + assert body["account_privacy"] == "all_users" + assert body["bio"] == "Teaches mathematics." + assert body["course_certificates"] is None + assert_records_match(schema, body, "Account") + + +@pytest.mark.django_db +@skip_unless_lms +def test_an_owner_reading_their_account_on_a_site_that_collects_extra_profile_fields(client, schema): + """An extra field answers with the value the site stored, which is not always text.""" + owner = account_owner(client, "shape-owner") + owner.profile.meta = json.dumps({"department": None, "age": 41}) + owner.profile.save() + with with_site_configuration_context(configuration={"extended_profile_fields": ["department", "age"]}): + body = client.get("/api/user/v2/accounts/me/").json() + assert body["extended_profile"] == [ + {"field_name": "department", "field_value": None}, + {"field_name": "age", "field_value": 41}, + ] + assert_records_match(schema, body, "Account") + + +@pytest.mark.django_db +@skip_unless_lms +def test_an_account_holding_a_value_for_every_property_matches_its_component(client, schema): + """ + An account with every property valued is checked against the component. + + A property a body only ever carries as null is one no validator can tell + the published type of, so this account holds what a profile, an + outstanding name change, a recovery address and an accepted name give it. + + This is also the one body the component is held to in both directions. + Elsewhere a record may carry fewer properties than the component + describes, because an owner shares only some of theirs; here the account + is the caller's own and carries every property the answer can hold, so a + property the component promises and this account no longer carries is a + promise to a client that nothing else would catch. + """ + owner = account_owner(client, "shape-filled") + fill_in(owner) + AccountRecoveryFactory.create(user=owner, secondary_email="shape-filled-recovery@example.com") + with override_switch(ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH, active=True): + with name_affirmation_answering("Checked Name"): + body = client.get("/api/user/v2/accounts/me/").json() + assert {name: body[name] for name in FILLED_IN_ONLY_PROPERTIES} == { + "country": "US", + "level_of_education": "m", + "mailing_address": "12 Park Avenue", + "pending_name_change": "Changed Name", + "phone_number": "+18005555555", + "secondary_email": "shape-filled-recovery@example.com", + "state": "MA", + "verified_name": "Checked Name", + } + assert body["bio"] == "Teaches mathematics." + assert body["gender"] == "f" + assert body["goals"] == "Finish the course." + assert body["year_of_birth"] == 1985 + assert body["language_proficiencies"] == [{"code": "fr"}] + assert body["social_links"] == [ + {"platform": "twitter", "social_link": "https://www.twitter.com/example"}, + ] + assert_records_match(schema, body, "Account") + assert set(body) == set(schema["components"]["schemas"]["Account"]["properties"]) + + +@pytest.mark.django_db +@skip_unless_lms +def test_a_change_body_built_from_what_the_document_publishes_is_one_the_endpoint_applies(client, schema): + """ + The published change body is checked by sending one and reading the answer. + + A body a generated client builds comes from the request component alone, + so each property it offers is sent here with a value of the type it + publishes, and the account that comes back has to hold every one of them. + """ + account_owner(client, "shape-changer") + change = { + "bio": "Teaches mathematics.", + "country": "US", + "gender": "f", + "goals": "Finish the course.", + "level_of_education": "m", + "mailing_address": "12 Park Avenue", + "name": "Changed Name", + "phone_number": "+18005555555", + "state": "MA", + "year_of_birth": 1985, + } + validator = jsonschema.Draft202012Validator(published_component_schema(schema, "PatchedAccountUpdate")) + assert [error.message for error in validator.iter_errors(change)] == [] + answer = client.patch( + "/api/user/v2/accounts/me/", + data=json.dumps(change), + content_type="application/merge-patch+json", + ) + assert answer.status_code == 200, answer.content + account = answer.json() + assert {name: account[name] for name in change} == change + assert_records_match(schema, account, "Account") + + +@skip_unless_lms +def test_the_refusal_for_a_retired_email_address_publishes_what_it_adds_to_the_envelope(schema): + """The two members that refusal carries beyond the envelope are typed, not left to be guessed.""" + components = schema["components"]["schemas"] + published = components["RetiredAccountErrorResponse"] + assert set(components["ErrorResponse"]["properties"]) < set(published["properties"]) + assert published["properties"]["can_cancel_retirement"] == { + "type": "boolean", + "description": ( + "Whether the retirement can still be reversed, which it can only while it is inside " + "the cooling-off period." + ), + } + assert published["properties"]["retirement_id"] == { + "type": "integer", + "nullable": True, + "description": ( + "Identifies the retirement to a caller able to reverse it, or null when there is " + "nothing left to reverse." + ), + } + assert published["required"] == components["ErrorResponse"]["required"] + refusal = schema["paths"][ACCOUNT_LIST_PATH]["get"]["responses"]["404"] + assert refusal["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/RetiredAccountErrorResponse", + } + + +@skip_unless_lms +def test_the_version_one_account_addresses_keep_an_operation_id_of_their_own(schema): + """The collection and the member tokenize alike, and are published apart.""" + published = { + (path, method): operation["operationId"] + for path, method, operation in operations(schema) + if path in SUPERSEDED_USER_ADDRESSES + } + assert published[("/api/user/v1/accounts", "get")] == "v1_accounts_list" + assert published[("/api/user/v1/accounts/{username}", "get")] == "v1_accounts_retrieve" + assert published[("/api/user/v1/accounts/{username}", "patch")] == "v1_accounts_partial_update" + assert len(set(published.values())) == len(published) + + +@skip_unless_lms +def test_each_renamed_operation_id_names_an_address_in_the_document_that_derives_it(schema): + """ + 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", "403", "404", "406", "415"] + published = schema["components"]["schemas"]["NameChange"] + assert published["required"] == ["name"] + assert published["properties"]["name"]["maxLength"] == 255 + assert published["properties"]["name"]["description"] == ( + "Full name to put on the profile once the request is confirmed." + ) + + +@skip_unless_lms +def test_a_confirmation_publishes_that_it_reads_nothing_and_answers_with_nothing(schema): + operation = schema["paths"][NAME_CHANGE_CONFIRMATION_PATH]["post"] + assert "requestBody" not in operation + assert operation["responses"]["204"]["description"] == ( + "The profile now carries the name that was asked for." + ) + assert "content" not in operation["responses"]["204"] + assert sorted(operation["responses"]) == ["204", "400", "401", "403", "404", "406"] + + +#: Every form an image may be sent in: the media type, the name a body sent in +#: that type carries, and the extension of the image to send. The extension of +#: the name is held against the media type, and a form part carries its own +#: name, so it needs none. +IMAGE_UPLOAD_FORMS = ( + ("multipart/form-data", None, ".jpeg"), + ("image/gif", "picture.gif", ".gif"), + ("image/jpeg", "picture.jpg", ".jpg"), + ("image/pjpeg", "picture.jpg", ".jpg"), + ("image/png", "picture.png", ".png"), +) + +#: The address the image upload supersedes, which reads the same forms. +SUPERSEDED_IMAGE_PATH = "/api/user/v1/accounts/{username}/image" + + +@skip_unless_lms +def test_the_profile_image_publishes_every_media_type_it_reads_the_image_in(schema): + """The image is read as a part of a form or as the whole body, and both are published.""" + operation = schema["paths"][PROFILE_IMAGE_PATH]["post"] + content = operation["requestBody"]["content"] + assert set(content) == {media_type for media_type, _name, _extension in IMAGE_UPLOAD_FORMS} + assert operation["requestBody"]["required"] is True + assert content["multipart/form-data"]["schema"] == { + "$ref": "#/components/schemas/ProfileImageUpload", + } + for media_type in set(content) - {"multipart/form-data"}: + assert content[media_type]["schema"] == {"type": "string", "format": "binary"}, media_type + header = [ + parameter for parameter in operation["parameters"] if parameter["in"] == "header" + ] + assert [parameter["name"] for parameter in header] == ["Content-Disposition"] + assert header[0].get("required", False) is False + assert 'filename="picture.png"' in header[0]["description"] + published = schema["components"]["schemas"]["ProfileImageUpload"] + assert published["required"] == ["file"] + assert published["properties"]["file"] == { + "type": "string", + "format": "binary", + "description": ( + "Image to show beside the user's name. It is stored in each size the site renders, " + "cropped to a square, and must be a JPEG, PNG or GIF within the size limits this " + "site configures." + ), + } + assert sorted(operation["responses"]) == ["204", "400", "401", "403", "404", "406", "415"] + + +@pytest.mark.django_db +@skip_unless_lms +def test_every_media_type_published_for_an_image_is_one_the_endpoint_stores_it_from(client, schema): + """ + Each form the document offers for an image is sent to the endpoints that serve it. + + A client generated from the document sends one of the published types, so + a type published at either address and refused there would leave such a + client with no way to set an image. Both addresses read the same forms and + publish the same list, which is read from the document here rather than + written out again. + """ + owner = UserFactory.create(username="image_media_types", password=PASSWORD) + assert client.login(username=owner.username, password=PASSWORD) + addresses = { + SUPERSEDED_IMAGE_PATH: f"/api/user/v1/accounts/{owner.username}/image", + PROFILE_IMAGE_PATH: f"/api/user/v2/accounts/{owner.username}/profile_image/", + } + storage = get_profile_image_storage() + try: + for published, address in addresses.items(): + offered = set(schema["paths"][published]["post"]["requestBody"]["content"]) + assert offered == {media_type for media_type, _name, _ext in IMAGE_UPLOAD_FORMS}, published + for media_type, name, extension in IMAGE_UPLOAD_FORMS: + with make_image_file(extension=extension) as image_file: + if name is None: + answer = client.post(address, {"file": image_file}) + else: + answer = client.post( + address, + data=image_file.read(), + content_type=media_type, + HTTP_CONTENT_DISPOSITION=f'attachment; filename="{name}"', + ) + assert answer.status_code == 204, (address, media_type, answer.content) + finally: + for stored in get_profile_image_names(owner.username).values(): + storage.delete(stored) + + +@skip_unless_lms +def test_clearing_the_profile_image_publishes_that_it_reads_nothing(schema): + operation = schema["paths"][PROFILE_IMAGE_PATH]["delete"] + assert "requestBody" not in operation + assert [parameter["name"] for parameter in operation["parameters"]] == ["username"] + assert operation["responses"]["204"]["description"] == "The account carries no image of its own." + assert sorted(operation["responses"]) == ["204", "400", "401", "403", "404", "406"] + assert operation["responses"]["400"]["description"] == ( + "The request carries a query parameter this endpoint does not read." + ) + + +@skip_unless_lms +def test_a_verification_status_publishes_the_properties_it_carries(schema): + body = schema["paths"][VERIFICATION_STATUS_PATH]["get"]["responses"]["200"] + assert body["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/VerificationStatus", + } + published = schema["components"]["schemas"]["VerificationStatus"] + assert set(published["properties"]) == {"is_verified", "status", "expiration_datetime"} + assert sorted(published["required"]) == ["expiration_datetime", "is_verified", "status"] + assert published["properties"]["is_verified"]["type"] == "boolean" + assert all(published["properties"][name]["description"] for name in published["properties"]) + + +@skip_unless_lms +def test_the_day_a_summary_reports_is_published_as_the_day_it_carries(schema): + """ + The summary reports a calendar day and an attempt records a moment, under one name. + + A summary says when an accepted proof runs out as the day alone, so the + property is published with the shape of that day rather than as a + date-time, which is what the same property of an attempt carries. + """ + summary = schema["components"]["schemas"]["VerificationStatus"]["properties"]["expiration_datetime"] + attempt = schema["components"]["schemas"]["Verification"]["properties"]["expiration_datetime"] + assert summary["type"] == "string" + assert summary["pattern"] == EXPIRATION_DAY_PATTERN + assert "format" not in summary + assert "MM/DD/YYYY" in summary["description"] + assert attempt["type"] == "string" + assert attempt["format"] == "date-time" + assert attempt["nullable"] is True + + +@skip_unless_lms +def test_every_status_a_recorded_attempt_can_hold_is_published(schema): + """ + A client switching on these values meets no stored value the text leaves out. + + The attempts come from a table per kind of check, and the statuses those + tables hold are not the same set, so what is published has to name every + one of them. + """ + published = schema["components"]["schemas"]["Verification"]["properties"]["status"]["description"] + stored = { + str(value) + for choices in (IDVerificationAttempt.STATUS, VerificationAttempt.STATUS) + for value, _label in choices + } + assert stored == {"created", "ready", "submitted", "must_retry", "pending", "approved", "denied"} + for value in sorted(stored): + assert f"`{value}`" in published, value + + +@skip_unless_lms +def test_a_verification_attempt_publishes_the_properties_it_carries(schema): + page = schema["components"]["schemas"]["PaginatedVerificationList"] + assert page["properties"]["results"]["items"] == {"$ref": "#/components/schemas/Verification"} + published = schema["components"]["schemas"]["Verification"] + assert set(published["properties"]) == { + "type", "status", "expiration_datetime", "message", "updated_at", "receipt_id", + } + assert published["properties"]["expiration_datetime"]["nullable"] is True + assert published["properties"]["receipt_id"]["nullable"] is True + assert published["properties"]["type"]["readOnly"] is True + assert all(published["properties"][name]["description"] for name in published["properties"]) + + +@skip_unless_lms +def test_an_account_publishes_an_example_of_each_shape_it_answers_with(schema): + """ + Each example a client author reads is a body this endpoint really returns. + + The answer is whichever of three shapes the caller's standing and request + produce, so each is published, and each is held to the component and to + the set of properties that shape carries. + """ + published = schema["paths"][ACCOUNT_MEMBER_PATH]["get"]["responses"]["200"] + examples = published["content"]["application/json"]["examples"] + described_by = { + "YourOwnAccount": "Account", + "AnAccountSomebodyElseOwns": "Account", + "AnAccountReducedToWhatIdentifiesItsOwner": "AccountMinimal", + } + assert list(examples) == list(described_by) + assert [example["description"] for example in examples.values()] == [ + "Everything an account carries, which is what its owner and a staff caller receive.", + "The properties this owner shares with every learner. An owner who shares field by field " + "shares fewer, and one who shares nothing leaves only the three properties every account " + "carries.", + "What `view=minimal` answers with; `fields` selects any other set.", + ] + for name, example in examples.items(): + assert_records_match(schema, example["value"], described_by[name], case=name) + + whole = examples["YourOwnAccount"]["value"] + assert set(whole) == set(schema["components"]["schemas"]["Account"]["properties"]) + shared = examples["AnAccountSomebodyElseOwns"]["value"] + assert set(shared) == set(settings.ACCOUNT_VISIBILITY_CONFIGURATION["bulk_shareable_fields"]) + assert shared["account_privacy"] == "all_users" + reduced = examples["AnAccountReducedToWhatIdentifiesItsOwner"]["value"] + assert set(reduced) == set(ACCOUNT_MINIMAL_FIELDS) + + +@skip_unless_lms +def test_the_verification_list_publishes_the_order_it_can_be_read_in(schema): + """The values published are the values the serializer takes, so the two cannot drift apart.""" + field = VerificationQuerySerializer().fields["ordering"] + parameter = published_ordering(schema, VERIFICATION_LIST_PATH) + assert parameter["schema"]["enum"] == ["updated_at", "-updated_at", ""] + assert parameter["schema"]["enum"] == [*field.choices, ""] + assert parameter["schema"]["default"] == "-updated_at" == field.default + assert parameter["description"].startswith(field.help_text) + assert parameter.get("required", False) is False + + +@skip_unless_lms +def test_every_list_publishes_its_sort_as_the_values_it_takes_and_the_order_it_uses_without_one(schema): + """ + The sort of every list is asked for under one name, and each publishes both + the values it takes and the order a caller that sends none is answered in. + + A list that can be sorted by several properties at once takes them as a + comma-separated array; one with a single sortable property takes that + property on its own, with the empty value asking for the default. + """ + published = {} + for path in ( + "/api/user/v2/users/", + "/api/user/v2/user_preferences/", + ACCOUNT_LIST_PATH, + VERIFICATION_LIST_PATH, + ): + shape = published_ordering(schema, path)["schema"] + published[path] = (shape["type"], sorted(shape.get("items", shape)["enum"]), shape["default"]) + assert published == { + "/api/user/v2/users/": ( + "array", ["-date_joined", "-username", "date_joined", "username"], ["username"], + ), + "/api/user/v2/user_preferences/": ( + "array", ["-key", "-username", "key", "username"], ["username", "key"], + ), + ACCOUNT_LIST_PATH: ("string", ["", "-username", "username"], "username"), + VERIFICATION_LIST_PATH: ("string", ["", "-updated_at", "updated_at"], "-updated_at"), + } + + +@skip_unless_lms +def test_an_account_and_a_row_of_the_account_collection_are_narrowed_by_the_same_presets(schema): + """One account is shaped by the presets that shape a row of the collection, and by the same values.""" + presets = {} + for path, method in ((ACCOUNT_LIST_PATH, "get"), (ACCOUNT_MEMBER_PATH, "get"), (ACCOUNT_MEMBER_PATH, "patch")): + parameter = next( + published for published in schema["paths"][path][method]["parameters"] + if published["name"] == "view" + ) + presets[path, method] = set(parameter["schema"]["enum"]) + assert presets == { + (ACCOUNT_LIST_PATH, "get"): {"minimal", "shared", ""}, + (ACCOUNT_MEMBER_PATH, "get"): {"minimal", "shared", ""}, + (ACCOUNT_MEMBER_PATH, "patch"): {"minimal", "shared", ""}, + } + + +@skip_unless_lms +def test_the_addresses_this_phase_adds_publish_no_other_query_parameters(schema): + """Each of these addresses reads what it names and nothing else.""" + published = { + path: sorted( + parameter["name"] for parameter in operation.get("parameters", []) + if parameter["in"] == "query" + ) + for path, _method, operation in operations(schema) + if path in { + NAME_CHANGE_PATH, + NAME_CHANGE_CONFIRMATION_PATH, + PROFILE_IMAGE_PATH, + VERIFICATION_STATUS_PATH, + VERIFICATION_LIST_PATH, + } + } + assert published == { + NAME_CHANGE_PATH: [], + NAME_CHANGE_CONFIRMATION_PATH: [], + PROFILE_IMAGE_PATH: [], + VERIFICATION_STATUS_PATH: [], + VERIFICATION_LIST_PATH: ["ordering", "page", "page_size"], + } + + +#: The names this resource's operations are published under. The two reads +#: tokenize alike, so the read of one preference is named for what it reads and +#: the read of the whole document keeps the name both addresses derive; left to +#: itself the assembled document tells the two apart with a numeral, which says +#: which address sorts first rather than what a client gets back. +PREFERENCE_OPERATION_IDS = { + (PREFERENCE_DOCUMENT_PATH, "get"): "v2_accounts_preferences_retrieve", + (PREFERENCE_DOCUMENT_PATH, "patch"): "v2_accounts_preferences_partial_update", + (PREFERENCE_NAME_PATH, "get"): "v2_accounts_preferences_retrieve_one", + (PREFERENCE_NAME_PATH, "put"): "v2_accounts_preferences_update", + (PREFERENCE_NAME_PATH, "delete"): "v2_accounts_preferences_destroy", + (EMAIL_OPT_IN_PATH, "post"): "v2_email_opt_ins_create", +} + + +@skip_unless_lms +def test_the_preference_operations_carry_the_names_they_are_published_under(schema): + published = { + (path, method): operation["operationId"] + for path, method, operation in operations(schema) + if path in {PREFERENCE_DOCUMENT_PATH, PREFERENCE_NAME_PATH, EMAIL_OPT_IN_PATH} + } + assert published == PREFERENCE_OPERATION_IDS + + +@skip_unless_lms +def test_the_read_of_one_preference_is_named_in_every_document_that_publishes_it(schema): + """ + Each document names that read as it names its four siblings, and loses no name. + + The two documents derive different names for one operation, because one + trims the area off the front of every path and the other trims nothing, so + the read is named in each of them through the name that document derives. + That leaves the five operations of this resource carrying the same leading + tokens wherever they are published, which is what a client generated from + a document takes its module from. The addresses of version 1 are named the + same way, and there only the document that trims the area off is covered: + the one that trims nothing keeps every name it has published, numeral and + all. + """ + service_wide = service_wide_schema() + assert schema["paths"][PREFERENCE_NAME_PATH]["get"]["operationId"] == ( + "v2_accounts_preferences_retrieve_one" + ) + assert service_wide["paths"][PREFERENCE_NAME_PATH]["get"]["operationId"] == ( + "api_user_v2_accounts_preferences_retrieve_one" + ) + assert schema["paths"][PREFERENCE_DOCUMENT_PATH]["get"]["operationId"] == ( + "v2_accounts_preferences_retrieve" + ) + assert service_wide["paths"][PREFERENCE_DOCUMENT_PATH]["get"]["operationId"] == ( + "api_user_v2_accounts_preferences_retrieve" + ) + assert { + (path, method): operation["operationId"] + for path, method, operation in operations(service_wide) + if path in {PREFERENCE_DOCUMENT_PATH, PREFERENCE_NAME_PATH} + } == { + (PREFERENCE_DOCUMENT_PATH, "get"): "api_user_v2_accounts_preferences_retrieve", + (PREFERENCE_DOCUMENT_PATH, "patch"): "api_user_v2_accounts_preferences_partial_update", + (PREFERENCE_NAME_PATH, "get"): "api_user_v2_accounts_preferences_retrieve_one", + (PREFERENCE_NAME_PATH, "put"): "api_user_v2_accounts_preferences_update", + (PREFERENCE_NAME_PATH, "delete"): "api_user_v2_accounts_preferences_destroy", + } + version_one = { + (path, method): operation["operationId"] + for path, method, operation in operations(service_wide) + if path.startswith("/api/user/v1/preferences/{username}") + } + assert version_one == { + ("/api/user/v1/preferences/{username}", "get"): "api_user_v1_preferences_retrieve", + ("/api/user/v1/preferences/{username}", "patch"): "api_user_v1_preferences_partial_update", + ("/api/user/v1/preferences/{username}/{preference_key}", "get"): ( + "api_user_v1_preferences_retrieve_2" + ), + ("/api/user/v1/preferences/{username}/{preference_key}", "put"): "api_user_v1_preferences_update", + ("/api/user/v1/preferences/{username}/{preference_key}", "delete"): ( + "api_user_v1_preferences_destroy" + ), + } + + +@skip_unless_lms +def test_the_stored_preference_reads_are_named_alike_in_every_document_that_publishes_them(schema): + """ + The collection read and the member read of this resource keep one prefix wherever published. + + A client takes the module it writes an operation into from the leading + tokens of that operation's name, so a document naming the two reads of one + resource differently splits the resource across two modules. The documents + derive different names for the member read, because one trims the area off + the front of every path and the other trims nothing, so it is named in + each of them through the name that document derives. + """ + service_wide = service_wide_schema() + assert { + (path, method): operation["operationId"] + for path, method, operation in operations(schema) + if path in {PREFERENCE_LIST_PATH, PREFERENCE_MEMBER_PATH} + } == { + (PREFERENCE_LIST_PATH, "get"): "v2_user_preferences_list", + (PREFERENCE_MEMBER_PATH, "get"): "v2_user_preferences_retrieve", + } + assert { + (path, method): operation["operationId"] + for path, method, operation in operations(service_wide) + if path in {PREFERENCE_LIST_PATH, PREFERENCE_MEMBER_PATH} + } == { + (PREFERENCE_LIST_PATH, "get"): "api_user_v2_user_preferences_list", + (PREFERENCE_MEMBER_PATH, "get"): "api_user_v2_user_preferences_retrieve", + } + + +@skip_unless_lms +def test_the_preference_document_is_published_as_the_open_ended_object_it_is(schema): + """It has no fixed set of names, so it is described rather than named as a component.""" + members = { + ("get", "application/json"): {"type": "string"}, + # A change may also remove a name, which it does by sending a null. + ("patch", "application/merge-patch+json"): {"type": "string", "nullable": True}, + } + for (method, media_type), member in members.items(): + operation = schema["paths"][PREFERENCE_DOCUMENT_PATH][method] + carried = ( + operation["responses"]["200"] if method == "get" else operation["requestBody"] + )["content"] + assert list(carried) == [media_type], method + body = carried[media_type]["schema"] + assert body["type"] == "object", method + assert body["additionalProperties"] == member, method + assert "$ref" not in body, method + + +@skip_unless_lms +def test_the_preference_document_is_published_as_the_fields_that_carry_it_describe(schema): + """The body is formatted and read by these fields, so each address publishes what its own does.""" + carried = { + ("get", "application/json"): AccountPreferenceDocumentField, + ("patch", "application/merge-patch+json"): AccountPreferenceChangesField, + } + for (method, media_type), field in carried.items(): + operation = schema["paths"][PREFERENCE_DOCUMENT_PATH][method] + published = ( + operation["responses"]["200"] if method == "get" else operation["requestBody"] + )["content"][media_type]["schema"] + assert published == get_override(field, "field"), method + + +@pytest.mark.django_db +@skip_unless_lms +def test_a_merge_patch_the_endpoint_carries_out_is_one_the_request_body_takes(client, schema): + """A null removes a name, so a body generated from the document can express a removal.""" + owner = AdminFactory.create(username="patch-preferences", password=PASSWORD) + UserPreferenceFactory.create(user=owner, key="time_zone", value="Asia/Tokyo") + assert client.login(username=owner.username, password=PASSWORD) + validator = jsonschema.Draft202012Validator(published_request_schema( + schema, PREFERENCE_DOCUMENT_PATH, "patch", "application/merge-patch+json", + )) + assert [error.message for error in validator.iter_errors({"time_zone": None})] == [] + assert [error.message for error in validator.iter_errors({"time_zone": "Europe/Paris"})] == [] + assert [error.message for error in validator.iter_errors({"time_zone": 5})] == [ + "5 is not valid under any of the given schemas", + ] + address = reverse("user_v2:account_preference_list", kwargs={"username": owner.username}) + removal = client.patch( + address, data=json.dumps({"time_zone": None}), content_type="application/merge-patch+json", + ) + assert removal.status_code == 204, removal.content + assert client.get(address).json() == {} + + +@pytest.mark.django_db +@skip_unless_lms +def test_a_change_that_names_no_preference_is_refused_by_the_document_too(client, schema): + """ + A body naming nothing to change is refused, and the document says so as well as the handler. + + A caller that generates a request from the document should learn that the + body has to name something before it is sent, rather than being told so + by a refusal. + """ + owner = AdminFactory.create(username="empty-patch", password=PASSWORD) + UserPreferenceFactory.create(user=owner, key="time_zone", value="Asia/Tokyo") + assert client.login(username=owner.username, password=PASSWORD) + validator = jsonschema.Draft202012Validator(published_request_schema( + schema, PREFERENCE_DOCUMENT_PATH, "patch", "application/merge-patch+json", + )) + assert [error.message for error in validator.iter_errors({})] == [ + "{} should be non-empty", + ] + assert [error.message for error in validator.iter_errors({"time_zone": "Europe/Paris"})] == [] + refusal = client.patch( + reverse("user_v2:account_preference_list", kwargs={"username": owner.username}), + data=json.dumps({}), + content_type="application/merge-patch+json", + ) + assert refusal.status_code == 400, refusal.content + assert refusal.json()["errors"] == { + "non_field_errors": ["Name at least one preference to change."], + } + + +@pytest.mark.django_db +@skip_unless_lms +def test_a_preference_document_the_endpoint_answers_is_what_the_document_describes(client, schema): + owner = AdminFactory.create(username="shape-preferences", password=PASSWORD) + for key, value in (("pref-lang", "en"), ("time_zone", "Asia/Tokyo"), ("visibility.bio", "all_users")): + UserPreferenceFactory.create(user=owner, key=key, value=value) + assert client.login(username=owner.username, password=PASSWORD) + body = client.get( + reverse("user_v2:account_preference_list", kwargs={"username": owner.username}), + ).json() + assert body == {"pref-lang": "en", "time_zone": "Asia/Tokyo", "visibility.bio": "all_users"} + validator = jsonschema.Draft202012Validator( + published_body_schema(schema, PREFERENCE_DOCUMENT_PATH), + ) + assert [error.message for error in validator.iter_errors(body)] == [] + + +@pytest.mark.django_db +@skip_unless_lms +def test_the_email_choice_publishes_the_refusal_a_session_is_answered_with_without_its_token(schema): + """ + The refusal a browser session meets here is published alongside the rest. + + This address is reached from a page, and a session is held to the request + token the site hands out with it, so the refusal of a request that carries + none is the one a browser client meets most. It is driven here and matched + against what the operation publishes, because the choice belongs to the + caller and so nothing else about who they are is refused at this address. + """ + user = UserFactory.create(username="optin_token", password=PASSWORD) + held_to_its_token = APIClient(enforce_csrf_checks=True) + assert held_to_its_token.login(username=user.username, password=PASSWORD) + answer = held_to_its_token.post( + EMAIL_OPT_IN_PATH, + {"course_key": "course-v1:edX+DemoX+Demo", "email_opt_in": True}, + format="json", + ) + assert answer.status_code == 403, answer.content + published = schema["paths"][EMAIL_OPT_IN_PATH]["post"]["responses"] + assert str(answer.status_code) in published + assert published["403"]["description"] == ( + "The request was made with a session cookie and carried none of the request token the " + "site hands out with it, so the site cannot tell that the request came from a page it " + "served. Sending that token with the session, or a signed token in place of the " + "session, is answered." + ) + + +@skip_unless_lms +def test_the_name_a_preference_is_stored_under_is_published_under_one_parameter_name(schema): + """ + Both addresses that carry a preference name carry it under the same name. + + A client generated from the document takes an argument from each path + parameter, so one notion published under two names in one version reaches + a reader as two notions, and a published name cannot be changed afterwards. + """ + carried = { + parameter["name"] + for path, _method, operation in operations(schema) + if path in {PREFERENCE_MEMBER_PATH, PREFERENCE_NAME_PATH} + for parameter in operation["parameters"] + if parameter["in"] == "path" + } + assert carried == {"username", "key"} + + +#: What each preference operation answers, by the address and the method. A +#: change admits only the caller the address names, so the account it names is +#: the caller's own and is there to be changed: no such request is answered +#: with the refusal naming an account that does not exist, and none publishes +#: it. +PREFERENCE_PUBLISHED_ANSWERS = { + (PREFERENCE_DOCUMENT_PATH, "get"): ["200", "400", "401", "403", "404", "406", "500"], + (PREFERENCE_DOCUMENT_PATH, "patch"): ["204", "400", "401", "403", "406", "415", "500"], + (PREFERENCE_NAME_PATH, "get"): ["200", "400", "401", "403", "404", "406", "500"], + (PREFERENCE_NAME_PATH, "put"): ["204", "400", "401", "403", "406", "415", "500"], + (PREFERENCE_NAME_PATH, "delete"): ["204", "400", "401", "403", "404", "406", "500"], +} + + +@skip_unless_lms +def test_each_preference_operation_publishes_the_answers_it_can_give(schema): + published = { + (path, method): sorted(operation["responses"]) + for path, method, operation in operations(schema) + if path in {PREFERENCE_DOCUMENT_PATH, PREFERENCE_NAME_PATH} + } + assert published == PREFERENCE_PUBLISHED_ANSWERS + + +@skip_unless_lms +def test_no_preference_operation_publishes_a_parameter_beyond_the_address_it_is_read_from(schema): + """These addresses read no query parameter: not a page, not a preset, not a selection.""" + published = { + (path, method): [parameter["name"] for parameter in operation.get("parameters", [])] + for path, method, operation in operations(schema) + if path in {PREFERENCE_DOCUMENT_PATH, PREFERENCE_NAME_PATH, EMAIL_OPT_IN_PATH} + } + assert published == { + (PREFERENCE_DOCUMENT_PATH, "get"): ["username"], + (PREFERENCE_DOCUMENT_PATH, "patch"): ["username"], + (PREFERENCE_NAME_PATH, "get"): ["key", "username"], + (PREFERENCE_NAME_PATH, "put"): ["key", "username"], + (PREFERENCE_NAME_PATH, "delete"): ["key", "username"], + (EMAIL_OPT_IN_PATH, "post"): [], + } + + +@skip_unless_lms +def test_the_email_choice_publishes_the_body_it_reads_and_that_it_answers_with_nothing(schema): + operation = schema["paths"][EMAIL_OPT_IN_PATH]["post"] + # The page a learner makes this choice on submits a form, so the media + # types a form submits in are published alongside the JSON body. + assert set(operation["requestBody"]["content"]) == { + "application/json", "application/x-www-form-urlencoded", "multipart/form-data", + } + for media_type, carried in operation["requestBody"]["content"].items(): + assert carried["schema"] == {"$ref": "#/components/schemas/EmailOptIn"}, media_type + assert "content" not in operation["responses"]["204"] + assert sorted(operation["responses"]) == [ + "204", "400", "401", "403", "404", "406", "415", "500", + ] + choice = schema["components"]["schemas"]["EmailOptIn"] + assert sorted(choice["required"]) == ["course_key", "email_opt_in"] + assert choice["properties"]["course_key"]["type"] == "string" + assert choice["properties"]["email_opt_in"]["type"] == "boolean" + # What a boolean is read from here is narrower than the media types allow, + # so the published description says so. + assert choice["properties"]["email_opt_in"]["description"] == ( + "Whether this user wants messages from that organization, sent as true or false. A " + "value that says neither is refused rather than read as one of them." + ) + + +#: What every operation of this version says about a request it can give no +#: answer to. Which representations an answer can be given in is settled as +#: the request enters, before the address it names is read or written, so the +#: refusal reaches an operation that answers with a body and one that does not +#: alike. +UNSATISFIABLE_ACCEPT_DESCRIPTION = ( + "The `Accept` header of the request names no representation this endpoint can answer in." +) + + +@skip_unless_lms +def test_every_operation_publishes_the_refusal_of_an_accept_it_cannot_satisfy(schema): + """ + No operation of this version leaves this refusal unpublished. + + This is asserted over every operation rather than per address, so an + operation added later is held to it without this test being changed. + """ + unpublished = { + f"{method.upper()} {path}" + for path, method, operation in operations(schema) + if path in VERSION_TWO_PATHS and "406" not in operation["responses"] + } + assert unpublished == set() + wrongly_described = { + f"{method.upper()} {path}": operation["responses"]["406"]["description"] + for path, method, operation in operations(schema) + if path in VERSION_TWO_PATHS + and operation["responses"]["406"]["description"] != UNSATISFIABLE_ACCEPT_DESCRIPTION + } + assert wrongly_described == {} + + +@skip_unless_lms +def test_asking_for_a_name_change_publishes_the_refusal_of_a_session_without_its_token(schema): + """ + A write made with a session the site cannot tie to a page it served is refused here too. + + The sibling collection of this version that records a choice for the + caller publishes that refusal; this one answers with it and did not + publish it, so a caller handling failures by the statuses the document + carries fell through to a status it had no model for. + """ + asked = schema["paths"][NAME_CHANGE_PATH]["post"]["responses"] + recorded = schema["paths"][EMAIL_OPT_IN_PATH]["post"]["responses"] + assert "403" in asked + assert asked["403"]["description"] == recorded["403"]["description"] + assert asked["403"]["content"] == recorded["403"]["content"] +def dereferenced(schema, node): + """ + Return a published node with its reference followed, if it carries one. + + A node describing a fixed set of values is published as a component whose + name the generator derives from the field, and that name holds only while + one field of the service claims it, so what the node points at is read + here rather than named. A property carrying a description of its own is + published as the one alternative it may match, which is followed too. + """ + alternatives = node.get("allOf") + if alternatives is not None and len(alternatives) == 1: + node = alternatives[0] + reference = node.get("$ref") + if reference is None: + return node + return schema["components"]["schemas"][reference.rsplit("/", 1)[-1]] + + +def published_property(schema, path, method, name): + """Return the published description of one property of a request body.""" + body = schema["paths"][path][method]["requestBody"]["content"]["application/json"]["schema"] + return dereferenced(schema, dereferenced(schema, body)["properties"][name]) + + +@skip_unless_lms +def test_the_erasure_publishes_the_sets_of_data_it_can_erase(schema): + """A caller picks one of them in the body, so each is named in the document.""" + scope = published_property(schema, RETIREMENT_ERASURE_PATH, "post", "scope") + assert scope["enum"] == list(ERASURE_SCOPES) + body = schema["paths"][RETIREMENT_ERASURE_PATH]["post"]["requestBody"] + assert body["required"] is True + assert dereferenced( + schema, body["content"]["application/json"]["schema"], + )["required"] == ["scope"] + + +@skip_unless_lms +def test_a_batch_publishes_what_it_can_report_about_each_name_it_is_given(schema): + result = schema["components"]["schemas"]["RetirementBatchResult"] + outcome = dereferenced(schema, result["properties"]["outcome"]) + assert outcome["enum"] == list(BATCH_OUTCOMES) + assert sorted(result["properties"]) == ["outcome", "username"] + assert sorted(result["required"]) == ["outcome", "username"] + + +@skip_unless_lms +def test_both_batch_addresses_publish_the_same_exchange(schema): + """They act on different records, and a caller reads and writes the same shapes at both.""" + exchanges = { + path: ( + schema["paths"][path]["post"]["requestBody"]["content"]["application/json"]["schema"], + schema["paths"][path]["post"]["responses"]["200"]["content"]["application/json"]["schema"], + ) + for path in (RETIREMENT_CLEANUP_PATH, PARTNER_REPORT_CLEANUP_PATH) + } + assert exchanges[RETIREMENT_CLEANUP_PATH] == exchanges[PARTNER_REPORT_CLEANUP_PATH] + assert exchanges[RETIREMENT_CLEANUP_PATH] == ( + {"$ref": "#/components/schemas/RetirementBatch"}, + {"$ref": "#/components/schemas/RetirementBatchOutcome"}, + ) + + +@skip_unless_lms +def test_the_queue_publishes_the_filters_it_narrows_by(schema): + parameters = { + parameter["name"]: parameter + for parameter in schema["paths"][RETIREMENT_LIST_PATH]["get"]["parameters"] + } + assert set(parameters) == { + "cool_off_days", "created_after", "created_before", "state", + "fields", "ordering", "page", "page_size", "view", + } + for name in ("cool_off_days", "created_after", "created_before", "state"): + assert parameters[name]["in"] == "query" + assert parameters[name].get("required", False) is False, name + assert parameters[name]["description"], name + assert parameters["cool_off_days"]["schema"]["type"] == "integer" + assert parameters["created_after"]["schema"]["format"] == "date-time" + assert parameters["created_before"]["schema"]["format"] == "date-time" + assert parameters["state"]["schema"]["type"] == "string" + assert parameters["ordering"]["schema"]["items"]["enum"] == sorted( + [*RETIREMENT_SORT_FIELDS, *(f"-{field}" for field in RETIREMENT_SORT_FIELDS)], + ) + assert "default" not in parameters["ordering"]["schema"] + + +@skip_unless_lms +def test_the_claim_publishes_how_many_entries_it_can_be_held_to(schema): + parameters = { + parameter["name"]: parameter + for parameter in schema["paths"][PARTNER_REPORT_LIST_PATH]["post"]["parameters"] + } + assert set(parameters) == {"limit"} + assert parameters["limit"]["schema"]["type"] == "integer" + assert parameters["limit"].get("required", False) is False + assert "requestBody" not in schema["paths"][PARTNER_REPORT_LIST_PATH]["post"] + + +@skip_unless_lms +def test_the_claim_answers_with_an_array_rather_than_a_page(schema): + """The entries answered are the entries taken, so the answer is not cut into pages.""" + body = schema["paths"][PARTNER_REPORT_LIST_PATH]["post"]["responses"]["200"] + assert body["content"]["application/json"]["schema"] == { + "type": "array", + "items": {"$ref": "#/components/schemas/RetirementPartnerReportRecord"}, + } + + +@skip_unless_lms +def test_selecting_properties_publishes_how_far_a_name_reaches(schema): + """A caller is told that a dotted name selects nothing and is refused.""" + for path, method in ( + (RETIREMENT_MEMBER_PATH, "get"), + (RETIREMENT_LIST_PATH, "get"), + ): + described = { + parameter["name"]: parameter["description"] + for parameter in schema["paths"][path][method]["parameters"] + }["fields"] + assert "`user.email`" in described, (path, method) + assert "names no field a response carries and is refused" in described, (path, method) + assert "the whole of that property is returned" not in described, (path, method) + + +@skip_unless_lms +def test_the_claim_publishes_exactly_the_properties_an_entry_carries(schema): + """An entry is published with the properties it is answered with, and with no others.""" + user = UserFactory.create() + UserRetirementPartnerReportingStatus.objects.create( + user=user, + original_username=user.username, + original_email=user.email, + original_name=user.profile.name, + ) + answered = set(claim_partner_reports(UserRetirementPartnerReportingStatus.objects.all())[0]) + record = schema["components"]["schemas"]["RetirementPartnerReportRecord"] + assert set(record["properties"]) == answered + assert set(record["required"]) == answered + + +@skip_unless_lms +def test_the_queue_answers_with_a_page(schema): + body = schema["paths"][RETIREMENT_LIST_PATH]["get"]["responses"]["200"] + assert body["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/PaginatedRetirementResponseList", + } + page = schema["components"]["schemas"]["PaginatedRetirementResponseList"] + assert set(page["properties"]) == PAGINATION_MEMBERS + assert page["properties"]["results"] == { + "type": "array", + "items": {"$ref": "#/components/schemas/RetirementResponse"}, + } + + +@skip_unless_lms +def test_a_retirement_record_publishes_the_step_as_a_name(schema): + record = schema["components"]["schemas"]["Retirement"] + for name in ("current_state", "last_state"): + assert record["properties"][name]["type"] == "string", name + assert set(record["properties"]) == { + "id", "user", "original_username", "original_email", "original_name", + "retired_username", "retired_email", "current_state", "last_state", "created", "modified", + } + assert "responses" not in record["properties"] + + +#: What each operation of the retirement pipeline answers, by the address and +#: the method. The erasure is the one operation of this area that lets a +#: failure of the work it drives reach the caller, because a step that fails +#: partway through has already erased part of what it names and the caller +#: decides what to do about that, so it is the one that publishes a 500. +RETIREMENT_PUBLISHED_ANSWERS = { + (RETIREMENT_LIST_PATH, "get"): ["200", "400", "401", "403", "404", "406"], + (RETIREMENT_MEMBER_PATH, "get"): ["200", "400", "401", "403", "404", "406"], + (RETIREMENT_MEMBER_PATH, "patch"): ["204", "400", "401", "403", "404", "406", "409", "415"], + (RETIREMENT_MEMBER_PATH, "delete"): ["204", "400", "401", "403", "404", "406", "409"], + (RETIREMENT_ERASURE_PATH, "post"): [ + "204", "400", "401", "403", "404", "406", "409", "415", "500", + ], + (RETIREMENT_CLEANUP_PATH, "post"): ["200", "400", "401", "403", "406", "409", "415"], + (PARTNER_REPORT_LIST_PATH, "post"): ["200", "400", "401", "403", "406"], + (PARTNER_REPORT_MEMBER_PATH, "put"): ["204", "400", "401", "403", "404", "406", "409"], + (PARTNER_REPORT_CLEANUP_PATH, "post"): ["200", "400", "401", "403", "406", "409", "415"], +} + + +@skip_unless_lms +def test_each_retirement_operation_publishes_the_answers_it_can_give(schema): + published = { + (path, method): sorted(operation["responses"]) + for path, method, operation in operations(schema) + if path in RETIREMENT_PATHS + } + assert published == RETIREMENT_PUBLISHED_ANSWERS + + +@skip_unless_lms +def test_the_move_of_a_retirement_publishes_the_whole_body_it_reads(schema): + """ + The move is an instruction read whole, so what it requires is published as required. + + A body carrying changes to a stored record leaves every field optional and + is published under a name of its own. This one is refused when either the + step to move to or what happened at the step being left is missing, so a + caller generating a request from the document is told both are needed, and + the account update beside it -- which really does carry changes -- keeps + the optional form. + """ + body = schema["paths"][RETIREMENT_MEMBER_PATH]["patch"]["requestBody"] + assert body["required"] is True + assert body["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/RetirementUpdate", + } + component = schema["components"]["schemas"]["RetirementUpdate"] + assert component["required"] == ["new_state", "response"] + assert sorted(component["properties"]) == ["force", "new_state", "response"] + assert "PatchedRetirementUpdate" not in schema["components"]["schemas"] + + changes = schema["paths"][ACCOUNT_MEMBER_PATH]["patch"]["requestBody"] + assert changes.get("required", False) is False + assert "required" not in schema["components"]["schemas"]["PatchedAccountUpdate"] + + +@skip_unless_lms +def test_a_retirement_publishes_the_profile_it_answers_as_null_where_there_is_none(schema): + """An account being retired need not hold a profile, and the property is answered either way.""" + account = schema["components"]["schemas"]["RetirementAccount"] + assert account["properties"]["profile"] == { + "allOf": [{"$ref": "#/components/schemas/RetirementAccountProfile"}], + "description": "The profile of the account, or null for an account that has none.", + "readOnly": True, + "nullable": True, + } + assert "profile" in account["required"] + # The profile itself carries no property that may be absent, so nothing + # below it is published as nullable. + profile = schema["components"]["schemas"]["RetirementAccountProfile"] + assert [name for name, published in profile["properties"].items() if published.get("nullable")] == [] + + +#: What the body of a superseded operation requires, for the operations whose +#: body is written out as a shape rather than named by a serializer. The +#: entries are addresses of several resources, because the rule is read from +#: the shape and not from the address it is published at. +SUPERSEDED_NEEDED_BODIES = { + ("/api/user/v1/accounts/retirement_cleanup/", "post"): ["usernames"], + ("/api/user/v1/accounts/retirement_partner_report/", "put"): ["username"], + ("/api/user/v1/accounts/retirement_partner_report_cleanup/", "post"): ["original_username"], + ("/api/user/v1/accounts/retire/", "post"): ["username"], + ("/api/user/v1/accounts/retire_misc/", "post"): ["username"], + ("/api/user/v1/accounts/update_retirement_status/", "patch"): ["username", "new_state", "response"], +} + + +def members_needed(shape): + """Return the members a body shape cannot be sent without, through a list to its members.""" + if "items" in shape: + return members_needed(shape["items"]) + return shape["required"] + + +@skip_unless_lms +def test_a_superseded_operation_publishes_a_body_it_cannot_be_called_without(schema): + """ + A body written out as a shape says what it needs, and the operation needs it. + + An operation whose body is named by a serializer is marked as needing one + by the fields that serializer requires. A body written out as a shape is + published as it stands, so nothing has judged whether the operation can be + called with no body at all: it is read here from what the shape itself + says it cannot be sent without. Both documents are checked, because the + one the service serves for browsing its whole surface has published these + operations for years and a client generated from it stops sending a body + the moment the document stops asking for one. + """ + for document in (schema, service_wide_schema()): + for (path, method), needed in SUPERSEDED_NEEDED_BODIES.items(): + body = document["paths"][path][method]["requestBody"] + assert body["required"] is True, (path, method) + assert members_needed( + body["content"]["application/json"]["schema"], + ) == needed, (path, method) + + +@skip_unless_lms +def test_the_claim_of_the_partner_report_queue_publishes_no_body_on_either_version(schema): + """Neither reads one: the queue it drains is named by nothing the caller sends.""" + for document in (schema, service_wide_schema()): + for path in ("/api/user/v1/accounts/retirement_partner_report/", PARTNER_REPORT_LIST_PATH): + assert "requestBody" not in document["paths"][path]["post"], path + + +@skip_unless_lms +def test_a_body_whose_shape_requires_nothing_is_not_published_as_one_that_is_needed(schema): + """ + The value of one stored preference is a bare string, so the body names nothing it needs. + + The write of one preference takes a request carrying no body at all and + answers that the preference now stands as the request asked, so the shape + it publishes names nothing it cannot be sent without and the operation is + not published as needing a body. The change of a whole preference + document is the other way round: that shape names at least one member, and + the operation is published as needing one. + """ + document = service_wide_schema() + body = document["paths"]["/api/user/v1/preferences/{username}/{preference_key}"]["put"]["requestBody"] + assert body.get("required", False) is False + assert "required" not in body["content"]["application/json"]["schema"] + assert body["content"]["application/json"]["schema"]["type"] == "string" + + changes = document["paths"]["/api/user/v1/preferences/{username}"]["patch"] + assert changes["requestBody"]["required"] is True + assert changes["requestBody"]["content"]["application/merge-patch+json"]["schema"][ + "minProperties" + ] == 1 + + +#: The retirement-status model as it was published while the corrected request +#: body was its only reference. It is written out here rather than read from +#: the configuration, so that a pin which no longer describes what a client +#: already generates from fails this. +PATCHED_RETIREMENT_STATUS_COMPONENT = { + "type": "object", + "description": "Perform serialization for the RetirementStatus model", + "properties": { + "id": {"type": "integer", "readOnly": True}, + "user": {"allOf": [{"$ref": "#/components/schemas/RetirementUser"}], "readOnly": True}, + "current_state": {"allOf": [{"$ref": "#/components/schemas/RetirementState"}], "readOnly": True}, + "last_state": {"allOf": [{"$ref": "#/components/schemas/RetirementState"}], "readOnly": True}, + "created": {"type": "string", "format": "date-time", "readOnly": True}, + "modified": {"type": "string", "format": "date-time", "readOnly": True}, + "original_username": {"type": "string", "maxLength": 150}, + "original_email": {"type": "string", "format": "email", "maxLength": 254}, + "original_name": {"type": "string", "maxLength": 255}, + "retired_username": {"type": "string", "maxLength": 150}, + "retired_email": {"type": "string", "format": "email", "maxLength": 254}, + }, +} + + +@skip_unless_lms +def test_the_component_the_corrected_request_body_leaves_behind_is_carried_by_both_documents(schema): + """ + The partial update of a version 1 retirement was the component's only reference. + + A client generated from a document names a class after each component in + it, so a component that loses its last reference is named in the settings + both documents are built from, rather than disappearing with the body that + was wrong. The document the service serves for browsing its whole surface + overlays its own keys on those settings and keeps this one, and the + narrower document a client library is generated from carries it too. + """ + for document in (schema, service_wide_schema()): + components = document["components"]["schemas"] + assert components["PatchedUserRetirementStatus"] == PATCHED_RETIREMENT_STATUS_COMPONENT + # Nothing the kept component refers to is missing from the document + # that carries it, so no reference in it dangles. + assert "RetirementUser" in components + assert "RetirementState" in components + + +#: The two addresses whose published name the body correction would otherwise +#: move, with the name the service-wide document publishes for each. +CORRECTED_QUEUE_READS = { + "/api/user/v1/accounts/retirement_queue/": "api_user_v1_accounts_retirement_queue_retrieve", + "/api/user/v1/accounts/retirements_by_status_and_date/": ( + "api_user_v1_accounts_retirements_by_status_and_date_retrieve" + ), +} + + +@skip_unless_lms +def test_the_corrected_queue_reads_keep_the_names_the_service_wide_document_publishes(schema): + """ + Correcting a body to the array it answers with would otherwise move its name. + + The generator names an operation for what it answers with, so an operation + whose body is corrected from one record to the array of them is named for a + list where it was named for a single record. Both of these have been + published under the single-record name for years and a client imports a + module by that name, so each is pinned; the document that has published + neither keeps the name it derives. + """ + derived = service_wide_schema() + published = { + path: derived["paths"][path]["get"]["operationId"] for path in CORRECTED_QUEUE_READS + } + assert published == CORRECTED_QUEUE_READS + + with mock.patch.dict("openedx.core.djangoapps.user_api.schema.RENAMED_OPERATION_IDS", {}, clear=True): + unpinned = service_wide_schema() + assert { + path: unpinned["paths"][path]["get"]["operationId"] for path in CORRECTED_QUEUE_READS + } == { + "/api/user/v1/accounts/retirement_queue/": "api_user_v1_accounts_retirement_queue_list", + "/api/user/v1/accounts/retirements_by_status_and_date/": ( + "api_user_v1_accounts_retirements_by_status_and_date_list" + ), + } + # The document this area's version is published in has carried neither + # address before, so each keeps the name derived there. + assert { + path: schema["paths"][path]["get"]["operationId"] for path in CORRECTED_QUEUE_READS + } == { + "/api/user/v1/accounts/retirement_queue/": "v1_accounts_retirement_queue_list", + "/api/user/v1/accounts/retirements_by_status_and_date/": ( + "v1_accounts_retirements_by_status_and_date_list" + ), + } diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_selectors.py b/openedx/core/djangoapps/user_api/v2/tests/test_selectors.py new file mode 100644 index 000000000000..c64f945d4f66 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_selectors.py @@ -0,0 +1,304 @@ +"""Tests for the selections version 2 of the User API answers from.""" + +import datetime + +import pytest +from django.conf import settings +from django.test import TestCase + +from common.djangoapps.student.models import get_retired_username_by_username +from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory +from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import ( # pylint: disable=unused-import + create_retirement_status, + setup_retirement_states, # noqa: F401 +) +from openedx.core.djangoapps.user_api.models import ( + RetirementState, + UserRetirementPartnerReportingStatus, + UserRetirementStatus, +) +from openedx.core.djangoapps.user_api.v2.selectors import ( + accounts_holding_emails, + cancellable_retirement_for_email, + partner_reports, + partner_reports_requested_as, + retirement_of_the_account_named, + retirements, + retirements_requested_as, + unclaimed_partner_reports, + username_holding_id, + usernames_among, + usernames_holding_email, +) +from openedx.core.djangolib.testing.utils import skip_unless_lms + + +@skip_unless_lms +class AccountSelectionTests(TestCase): + """What the account selections answer, asked for outside a request.""" + + def setUp(self): + super().setUp() + self.first = UserFactory.create(username="first-holder", email="first@example.com") + self.second = UserFactory.create(username="second-holder", email="second@example.com") + + def test_the_accounts_holding_a_set_of_addresses_come_back_by_name(self): + found = accounts_holding_emails(["second@example.com", "first@example.com"]) + assert [user.username for user in found] == ["first-holder", "second-holder"] + + def test_an_address_no_account_holds_is_left_out(self): + found = accounts_holding_emails(["first@example.com", "nobody@example.com"]) + assert [user.username for user in found] == ["first-holder"] + + def test_no_addresses_find_no_accounts(self): + assert not list(accounts_holding_emails([])) + + def test_the_accounts_named_come_back_in_the_order_asked_for(self): + assert list(usernames_among(["second-holder", "first-holder"], "username")) == [ + "first-holder", "second-holder", + ] + assert list(usernames_among(["first-holder", "second-holder"], "-username")) == [ + "second-holder", "first-holder", + ] + + def test_a_name_no_account_holds_is_left_out_of_the_selection(self): + assert list(usernames_among(["first-holder", "nobody-at-all"], "username")) == ["first-holder"] + + def test_no_names_select_no_accounts(self): + assert not list(usernames_among([], "username")) + + def test_the_account_holding_one_address_is_named(self): + assert usernames_holding_email("second@example.com") == ["second-holder"] + + def test_no_account_holds_an_unregistered_address(self): + assert usernames_holding_email("nobody@example.com") == [] + + def test_every_account_holding_one_address_is_named(self): + """Nothing keeps one address to one account, so an address can be held by several.""" + UserFactory.create(username="also-second", email="second@example.com") + assert usernames_holding_email("second@example.com") == ["also-second", "second-holder"] + + def test_the_account_holding_one_identifier_is_named(self): + assert username_holding_id(self.second.id) == "second-holder" + + def test_no_account_holds_an_unused_identifier(self): + assert username_holding_id(self.second.id + 1000) is None + + +@skip_unless_lms +class RetirementSelectionTests(TestCase): + """Which retirement of a retired address can still be cancelled.""" + + def setUp(self): + super().setUp() + RetirementState.objects.create(state_name="PENDING", state_execution_order=1) + self.user = UserFactory.create(username="leaving", email="leaving@example.com") + + def age(self, retirement, days): + """Record the given retirement as having been asked for that many days ago.""" + asked = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=days) + UserRetirementStatus.objects.filter(pk=retirement.pk).update(created=asked) + + def test_a_retirement_inside_the_cooling_off_period_is_the_one_reported(self): + retirement = UserRetirementStatus.create_retirement(self.user) + found = cancellable_retirement_for_email("leaving@example.com") + assert found is not None + assert found.pk == retirement.pk + assert found.original_email == "leaving@example.com" + + def test_a_retirement_older_than_the_cooling_off_period_is_not_reported(self): + retirement = UserRetirementStatus.create_retirement(self.user) + self.age(retirement, settings.COOL_OFF_DAYS + 1) + assert cancellable_retirement_for_email("leaving@example.com") is None + + def test_an_address_with_no_retirement_behind_it_has_none(self): + UserRetirementStatus.create_retirement(self.user) + assert cancellable_retirement_for_email("staying@example.com") is None + + def test_the_most_recently_asked_for_retirement_of_an_address_is_the_one_reported(self): + """One address can have been held and left more than once; the last one is cancellable.""" + earlier = UserRetirementStatus.create_retirement(self.user) + self.age(earlier, 2) + again = UserFactory.create(username="leaving-again", email="leaving-again@example.com") + later = UserRetirementStatus.create_retirement(again) + UserRetirementStatus.objects.filter(pk=later.pk).update(original_email="leaving@example.com") + found = cancellable_retirement_for_email("leaving@example.com") + assert found.pk == later.pk + assert found.user_id == again.id + + +@skip_unless_lms +@pytest.mark.usefixtures("setup_retirement_states") +class RetirementPipelineSelectionTests(TestCase): + """What the retirement selections answer, asked for outside a request.""" + + def setUp(self): + super().setUp() + self.subject = UserFactory.create(username="pipeline-subject", email="subject@example.com") + self.retirement = create_retirement_status(self.subject) + + def test_the_queue_carries_the_account_and_both_steps_without_asking_again(self): + selected = retirements().get(id=self.retirement.id) + with self.assertNumQueries(0): + assert selected.user.username == "pipeline-subject" + assert selected.user.profile.name == self.subject.profile.name + assert selected.current_state.state_name == "PENDING" + assert selected.last_state.state_name == "PENDING" + + def test_an_account_is_addressed_by_the_name_it_was_registered_under(self): + assert retirement_of_the_account_named(retirements(), "pipeline-subject").id == ( + self.retirement.id + ) + + def test_an_account_is_addressed_by_the_hashed_name_it_was_moved_to(self): + hashed = get_retired_username_by_username("pipeline-subject") + self.subject.username = hashed + self.subject.save() + assert retirement_of_the_account_named(retirements(), hashed).id == self.retirement.id + assert retirement_of_the_account_named(retirements(), "pipeline-subject").id == ( + self.retirement.id + ) + + def test_a_name_no_account_holds_addresses_no_retirement(self): + assert retirement_of_the_account_named(retirements(), "nobody") is None + + def test_an_account_with_no_retirement_behind_it_addresses_none(self): + UserFactory.create(username="not-retiring") + assert retirement_of_the_account_named(retirements(), "not-retiring") is None + + def test_a_record_the_selection_given_leaves_out_addresses_no_retirement(self): + """Each selection is asked of the records the caller may act on, not of every record held.""" + assert retirement_of_the_account_named( + retirements().exclude(id=self.retirement.id), "pipeline-subject", + ) is None + + def test_a_retirement_is_found_by_the_name_it_was_requested_under(self): + found = retirements_requested_as(retirements(), ["pipeline-subject", "nobody"]) + assert set(found) == {"pipeline-subject"} + assert [row.id for row in found["pipeline-subject"]] == [self.retirement.id] + + def test_a_name_spelled_differently_finds_no_retirement(self): + """Stored names are compared without regard to case in some deployments, so this is exact.""" + assert set(retirements_requested_as(retirements(), ["Pipeline-Subject"])) == set() + + def test_both_retirements_of_a_pair_differing_only_in_case_are_found_separately(self): + other = UserFactory.create(username="PIPELINE-SUBJECT", email="other@example.com") + other_retirement = create_retirement_status(other) + found = retirements_requested_as(retirements(), ["pipeline-subject", "PIPELINE-SUBJECT"]) + assert [row.id for row in found["pipeline-subject"]] == [self.retirement.id] + assert [row.id for row in found["PIPELINE-SUBJECT"]] == [other_retirement.id] + + def test_every_retirement_requested_under_one_name_is_found_under_it(self): + """The stored name is indexed but not unique, so a name may carry several records.""" + twin = create_retirement_status(UserFactory.create(username="pipeline-twin")) + UserRetirementStatus.objects.filter(id=twin.id).update(original_username="pipeline-subject") + found = retirements_requested_as(retirements(), ["pipeline-subject"]) + assert sorted(row.id for row in found["pipeline-subject"]) == sorted( + [self.retirement.id, twin.id], + ) + + def test_the_partner_report_queue_leaves_out_what_has_been_taken(self): + waiting = self.queued(self.subject) + taken_user = UserFactory.create(username="already-taken") + self.queued(taken_user, taken=True) + assert [report.id for report in unclaimed_partner_reports()] == [waiting.id] + + def test_every_entry_is_offered_for_disposal_whether_it_has_been_taken_or_not(self): + """An entry is disposed of once it has been taken, which is when the queue stops offering it.""" + waiting = self.queued(self.subject) + taken = self.queued(UserFactory.create(username="already-taken"), taken=True) + assert sorted(report.id for report in partner_reports()) == sorted([waiting.id, taken.id]) + + def test_the_partner_report_queue_carries_the_courses_each_account_studied(self): + self.queued(self.subject) + CourseEnrollmentFactory.create(user=self.subject, course_id="course-v1:edX+DemoX+Demo") + report = list(unclaimed_partner_reports())[0] + with self.assertNumQueries(0): + assert [ + enrolment.course_id.org for enrolment in report.user.courseenrollment_set.all() + ] == ["edX"] + + def test_a_partner_report_is_found_by_the_name_it_was_registered_under(self): + report = self.queued(self.subject) + found = partner_reports_requested_as(partner_reports(), ["pipeline-subject", "nobody"]) + assert set(found) == {"pipeline-subject"} + assert [row.id for row in found["pipeline-subject"]] == [report.id] + + def test_every_partner_report_registered_under_one_name_is_found_under_it(self): + report = self.queued(self.subject) + twin = self.queued(UserFactory.create(username="pipeline-twin")) + UserRetirementPartnerReportingStatus.objects.filter(id=twin.id).update( + original_username="pipeline-subject", + ) + found = partner_reports_requested_as(partner_reports(), ["pipeline-subject"]) + assert sorted(row.id for row in found["pipeline-subject"]) == sorted([report.id, twin.id]) + + def test_a_partner_report_is_not_found_by_a_name_spelled_differently(self): + self.queued(self.subject) + assert set(partner_reports_requested_as(partner_reports(), ["Pipeline-Subject"])) == set() + + def queued(self, user, taken=False): + """Return a partner report entry for this account, taken for processing or not.""" + return UserRetirementPartnerReportingStatus.objects.create( + user=user, + original_username=user.username, + original_email=user.email, + original_name=user.profile.name, + is_being_processed=taken, + ) + + +class _CollationIgnoringCase: + """ + Stands in for a database that ignores case when it compares stored names. + + The deployments this runs on do exactly that, which once let a learner + register a retired name again by changing its capitalisation; the database + the tests run on compares names character for character, so what the + selections do with a second row that differs only in case is settled here + rather than left to the database to decide. + """ + + def __init__(self, rows): + self.rows = rows + + def filter(self, **kwargs): # pylint: disable=unused-argument + """Return every row, whichever names were asked for.""" + return self.rows + + +@skip_unless_lms +@pytest.mark.usefixtures("setup_retirement_states") +class CaseExactSelectionTests(TestCase): + """A name is compared character for character, whatever the database would compare.""" + + def setUp(self): + super().setUp() + self.lower = UserFactory.create(username="exact-subject", email="lower@example.com") + self.upper = UserFactory.create(username="EXACT-SUBJECT", email="upper@example.com") + + def test_a_retirement_is_selected_by_the_spelling_asked_for(self): + wanted = create_retirement_status(self.lower) + create_retirement_status(self.upper) + offered = _CollationIgnoringCase(list(UserRetirementStatus.objects.all())) + found = retirements_requested_as(offered, ["exact-subject"]) + assert set(found) == {"exact-subject"} + assert [row.id for row in found["exact-subject"]] == [wanted.id] + + def test_a_partner_report_is_selected_by_the_spelling_asked_for(self): + wanted = UserRetirementPartnerReportingStatus.objects.create( + user=self.lower, + original_username=self.lower.username, + original_email=self.lower.email, + original_name=self.lower.profile.name, + ) + UserRetirementPartnerReportingStatus.objects.create( + user=self.upper, + original_username=self.upper.username, + original_email=self.upper.email, + original_name=self.upper.profile.name, + ) + offered = _CollationIgnoringCase(list(UserRetirementPartnerReportingStatus.objects.all())) + found = partner_reports_requested_as(offered, ["exact-subject"]) + assert set(found) == {"exact-subject"} + assert [row.id for row in found["exact-subject"]] == [wanted.id] diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_url_mount.py b/openedx/core/djangoapps/user_api/v2/tests/test_url_mount.py new file mode 100644 index 000000000000..1c2c1d7dfafe --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_url_mount.py @@ -0,0 +1,137 @@ +"""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 Resolver404, get_resolver, resolve, reverse + +from openedx.core.djangolib.testing.utils import skip_unless_lms + +AUTHN_LOGIN_VIEWS = "openedx.core.djangoapps.user_authn.views.login" +AUTHN_REGISTER_VIEWS = "openedx.core.djangoapps.user_authn.views.register" +V2_VIEWS = "openedx.core.djangoapps.user_api.v2.views" +AUTHN_URLS = "openedx.core.djangoapps.user_authn.urls" +V2_URLS = "openedx.core.djangoapps.user_api.v2.urls" +USER_API_URLS = "openedx.core.djangoapps.user_api.urls" + + +@skip_unless_lms +class SharedPrefixTests(SimpleTestCase): + """ + The authentication app keeps the two addresses it serves under this prefix. + + The service matches the include that carries the rest of the user surface + and the patterns installed applications contribute, the authentication + app's among them, before the mount for this version, so those two + addresses reach that app before the mount is tried. The mount's fallback + leaves them alone as well, so that each sent without its closing slash is + redirected to the slashed form the app serves rather than refused here. + """ + + def test_the_authentication_app_and_the_rest_of_the_user_surface_are_matched_before_this_mount(self): + patterns = get_resolver().url_patterns + + def position(urlconf): + return next( + index for index, pattern in enumerate(patterns) + if getattr(getattr(pattern, "urlconf_name", None), "__name__", None) == urlconf + ) + + def first_match(address): + for index, pattern in enumerate(patterns): + try: + if pattern.resolve(address.removeprefix("/")): + return index + except Resolver404: + continue + return None + + mount = position(V2_URLS) + assert position(USER_API_URLS) < mount + for address in ("/api/user/v2/account/registration/", "/api/user/v2/account/login_session/"): + assert first_match(address) == position(AUTHN_URLS) < mount, address + + def assert_resolves_to(self, address, module, class_name, url_name, kwargs=None): + """Assert an address reaches the named view through the named route.""" + match = resolve(address) + view_class = match.func.view_class + assert (view_class.__module__, view_class.__name__) == (module, class_name), address + assert match.url_name == url_name, address + assert match.kwargs == (kwargs or {}), address + + def test_registration_still_reaches_the_authentication_app(self): + self.assert_resolves_to( + "/api/user/v2/account/registration/", + AUTHN_REGISTER_VIEWS, + "RegistrationView", + "user_api_registration_v2", + ) + + def test_login_session_still_reaches_the_authentication_app(self): + self.assert_resolves_to( + "/api/user/v2/account/login_session/", + AUTHN_LOGIN_VIEWS, + "LoginSessionView", + "user_api_login_session", + {"api_version": "v2"}, + ) + + def test_the_version_one_addresses_beside_them_are_unaffected(self): + self.assert_resolves_to( + "/api/user/v1/account/registration/", + AUTHN_REGISTER_VIEWS, + "RegistrationView", + "user_api_registration", + ) + self.assert_resolves_to( + "/api/user/v1/account/login_session/", + AUTHN_LOGIN_VIEWS, + "LoginSessionView", + "user_api_login_session", + {"api_version": "v1"}, + ) + + def test_both_shared_prefix_addresses_still_reverse_to_themselves(self): + assert reverse("user_api_registration_v2") == "/api/user/v2/account/registration/" + assert reverse( + "user_api_login_session", kwargs={"api_version": "v2"}, + ) == "/api/user/v2/account/login_session/" + + +@skip_unless_lms +class ClaimedPrefixTests(SimpleTestCase): + """ + Every address under this prefix that no route serves is answered by this version. + + The mount matches the whole prefix, so an address under it that none of + the routes and neither of the two addresses the authentication app keeps + matches is answered here rather than left to anything mounted later. That + is what makes a mistyped address come back as JSON, and it is also what + keeps an address registered under this prefix by anything mounted after + this version from being reached at all. + """ + + def assert_refused_here(self, address): + """Assert an address under the prefix is answered by this version's refusal.""" + match = resolve(address) + view_class = match.func.view_class + assert (view_class.__module__, view_class.__name__) == (V2_VIEWS, "UnknownAddressView"), address + + def test_a_mistyped_address_is_answered_by_this_version(self): + self.assert_refused_here("/api/user/v2/acounts/") + + def test_an_address_below_a_route_of_this_version_is_answered_by_this_version(self): + self.assert_refused_here("/api/user/v2/accounts/alice/not-a-property/") + + def test_a_third_address_of_the_other_apps_shape_is_answered_here(self): + """Only the two addresses spelled out for that app are left to it; a third is not.""" + self.assert_refused_here("/api/user/v2/account/password_reset/") + + def test_an_address_a_later_mount_would_serve_is_answered_here_instead(self): + """ + The prefix is claimed whole, which is the constraint an operator needs. + + Anything mounted after this version that registers an address under + the prefix is unreachable, so the shape of such an address is pinned + here rather than left to be discovered from a 404 in a deployment. + """ + self.assert_refused_here("/api/user/v2/some_plugin/thing/") + self.assert_refused_here("/api/user/v2/badges/") diff --git a/openedx/core/djangoapps/user_api/v2/tests/test_urls.py b/openedx/core/djangoapps/user_api/v2/tests/test_urls.py new file mode 100644 index 000000000000..a37795560f41 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/tests/test_urls.py @@ -0,0 +1,983 @@ +"""Tests for the version 2 User API URL surface.""" + +import re + +import pytest +from django.core.exceptions import ImproperlyConfigured +from django.urls import NoReverseMatch, get_resolver, resolve, reverse +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser +from edx_rest_framework_extensions.testing import assert_error_envelope +from rest_framework import status +from rest_framework.request import Request +from rest_framework.settings import api_settings +from rest_framework.test import APIRequestFactory, APITestCase +from rest_framework.views import APIView + +from common.djangoapps.student.tests.factories import AdminFactory +from openedx.core.djangoapps.profile_images.v2.views import ProfileImageView +from openedx.core.djangoapps.user_api.v2 import urls as v2_urls +from openedx.core.djangoapps.user_api.v2.views import ( + AccountDeactivationView, + AccountPreferenceViewSet, + AccountViewSet, + EmailOptInViewSet, + IDVerificationStatusView, + IDVerificationViewSet, + NameChangeViewSet, + RetirementCleanupViewSet, + RetirementPartnerReportCleanupViewSet, + RetirementPartnerReportViewSet, + RetirementViewSet, + 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" +V1_PREFERENCE_VIEW_MODULE = "openedx.core.djangoapps.user_api.preferences.views" +V2_NAMESPACE = "user_v2" + +#: A route name is lower case, starts with a letter and separates words with +#: underscores; nothing else is accepted. +SNAKE_CASE_NAME = re.compile(r"[a-z][a-z0-9_]*\Z") +VERSION_IN_NAME = re.compile(r"v\d") + +EXPECTED_ADDRESSES = { + "user_v2:account_list": ({}, "/api/user/v2/accounts/"), + "user_v2:account_detail": ({"username": "alice"}, "/api/user/v2/accounts/alice/"), + "user_v2:email_search_list": ({}, "/api/user/v2/email_searches/"), + "user_v2:account_deactivation": ( + {"username": "alice"}, + "/api/user/v2/accounts/alice/deactivation/", + ), + "user_v2:account_preference_list": ( + {"username": "alice"}, + "/api/user/v2/accounts/alice/preferences/", + ), + "user_v2:account_preference_detail": ( + {"username": "alice", "key": "time_zone"}, + "/api/user/v2/accounts/alice/preferences/time_zone/", + ), + "user_v2:email_opt_in_list": ({}, "/api/user/v2/email_opt_ins/"), + "user_v2:account_profile_image": ( + {"username": "alice"}, + "/api/user/v2/accounts/alice/profile_image/", + ), + "user_v2:account_verification_status": ( + {"username": "alice"}, + "/api/user/v2/accounts/alice/verification_status/", + ), + "user_v2:account_verification_list": ( + {"username": "alice"}, + "/api/user/v2/accounts/alice/verifications/", + ), + "user_v2:name_change_list": ({}, "/api/user/v2/name_changes/"), + "user_v2:name_change_confirmation": ( + {"username": "alice"}, + "/api/user/v2/name_changes/alice/confirmations/", + ), + "user_v2:user_list": ({}, "/api/user/v2/users/"), + "user_v2:user_detail": ({"username": "alice"}, "/api/user/v2/users/alice/"), + "user_v2:user_preference_list": ({}, "/api/user/v2/user_preferences/"), + "user_v2:user_preference_detail": ( + {"username": "alice", "key": "time_zone"}, + "/api/user/v2/user_preferences/alice,time_zone/", + ), + "user_v2:time_zone_list": ({}, "/api/user/v2/time_zones/"), + "user_v2:retirement_list": ({}, "/api/user/v2/retirements/"), + "user_v2:retirement_detail": ({"username": "alice"}, "/api/user/v2/retirements/alice/"), + "user_v2:retirement_erasure": ( + {"username": "alice"}, + "/api/user/v2/retirements/alice/erasures/", + ), + "user_v2:retirement_cleanup_list": ({}, "/api/user/v2/retirement_cleanups/"), + "user_v2:retirement_partner_report_list": ({}, "/api/user/v2/retirement_partner_reports/"), + "user_v2:retirement_partner_report_detail": ( + {"username": "alice"}, + "/api/user/v2/retirement_partner_reports/alice/", + ), + "user_v2:retirement_partner_report_cleanup_list": ( + {}, + "/api/user/v2/retirement_partner_report_cleanups/", + ), + "user_v2:registration_list": ({}, "/api/user/v2/registrations/"), + "user_v2:registration_validation_list": ({}, "/api/user/v2/registration_validations/"), + "user_v2:login_session_list": ({}, "/api/user/v2/login_sessions/"), + "user_v2:password_reset_list": ({}, "/api/user/v2/password_resets/"), + "user_v2:password_reset_token_validation_list": ({}, "/api/user/v2/password_reset_token_validations/"), +} + + +def registered_route_names(): + """ + Return the route names the mounted URLconf registers under this namespace. + + These are the names ``reverse()`` accepts; they are read from the resolver + rather than from this module so that a route added or renamed in the + URLconf is seen here. + """ + _prefix, namespace_resolver = get_resolver().namespace_dict[V2_NAMESPACE] + return {key for key in namespace_resolver.reverse_dict if isinstance(key, str)} + + +def resolved_route_names(): + """Return the route names ``resolve()`` reports for the published addresses.""" + return {resolve(address).url_name for _kwargs, address in EXPECTED_ADDRESSES.values()} + + +def routed_actions(view_func): + """ + Return the method-to-action map a viewset mount was registered with. + + DRF adds a ``head`` entry to a mount's ``actions`` the first time it serves + a GET, so the raw attribute depends on what the test suite requested first. + """ + return {method: action for method, action in view_func.actions.items() if method != "head"} + + +def served_methods(view_func): + """ + Return the request methods a route mounted on a plain view serves. + + A viewset mount carries its method-to-action map; a plain view has one + handler per method instead, and the set of those is what the route serves. + """ + return set(view_func.view_class().allowed_methods) + + +def view_identity(view_func): + """ + Return the module and class name of the view a route is mounted on. + + A viewset mount carries the class name on the wrapper itself; Django's + plain class-based views record the class separately instead. + """ + view_class = getattr(view_func, "view_class", None) + name = view_class.__name__ if view_class is not None else view_func.__name__ + return (view_func.__module__, name) + + +@skip_unless_lms +class UrlLiteralTests(APITestCase): + """Every name reverses to the address the API publishes.""" + + def test_reverse_literals(self): + for name, (kwargs, expected) in EXPECTED_ADDRESSES.items(): + assert reverse(name, kwargs=kwargs) == expected, name + + def test_names_are_snake_case_and_carry_no_version(self): + registered = registered_route_names() + assert registered == {name.split(":")[1] for name in EXPECTED_ADDRESSES} + for name in registered: + assert SNAKE_CASE_NAME.match(name), name + assert not VERSION_IN_NAME.search(name), name + + def test_resolved_names_are_the_names_that_reverse(self): + resolved = resolved_route_names() + assert resolved == registered_route_names() + for address, name in ( + ("/api/user/v2/accounts/", "account_list"), + ("/api/user/v2/accounts/alice/", "account_detail"), + ("/api/user/v2/email_searches/", "email_search_list"), + ("/api/user/v2/accounts/alice/deactivation/", "account_deactivation"), + ("/api/user/v2/accounts/alice/preferences/", "account_preference_list"), + ("/api/user/v2/accounts/alice/preferences/time_zone/", "account_preference_detail"), + ("/api/user/v2/email_opt_ins/", "email_opt_in_list"), + ("/api/user/v2/accounts/alice/profile_image/", "account_profile_image"), + ("/api/user/v2/accounts/alice/verification_status/", "account_verification_status"), + ("/api/user/v2/accounts/alice/verifications/", "account_verification_list"), + ("/api/user/v2/name_changes/", "name_change_list"), + ("/api/user/v2/name_changes/alice/confirmations/", "name_change_confirmation"), + ("/api/user/v2/users/", "user_list"), + ("/api/user/v2/users/alice/", "user_detail"), + ("/api/user/v2/user_preferences/", "user_preference_list"), + ("/api/user/v2/user_preferences/alice,time_zone/", "user_preference_detail"), + ("/api/user/v2/time_zones/", "time_zone_list"), + ("/api/user/v2/retirements/", "retirement_list"), + ("/api/user/v2/retirements/alice/", "retirement_detail"), + ("/api/user/v2/retirements/alice/erasures/", "retirement_erasure"), + ("/api/user/v2/retirement_cleanups/", "retirement_cleanup_list"), + ("/api/user/v2/retirement_partner_reports/", "retirement_partner_report_list"), + ( + "/api/user/v2/retirement_partner_reports/alice/", + "retirement_partner_report_detail", + ), + ( + "/api/user/v2/retirement_partner_report_cleanups/", + "retirement_partner_report_cleanup_list", + ), + ): + match = resolve(address) + assert match.url_name == name, address + assert match.view_name == f"{V2_NAMESPACE}:{name}", address + + def test_viewsets_build_action_addresses_under_this_mount(self): + accounts = AccountViewSet(basename="account") + assert accounts.reverse_action("list") == "/api/user/v2/accounts/" + assert accounts.reverse_action( + "detail", kwargs={"username": "alice"}, + ) == "/api/user/v2/accounts/alice/" + name_changes = NameChangeViewSet(basename="name_change") + assert name_changes.reverse_action("list") == "/api/user/v2/name_changes/" + assert name_changes.reverse_action( + "confirmation", kwargs={"username": "alice"}, + ) == "/api/user/v2/name_changes/alice/confirmations/" + assert UserViewSet(basename="user").reverse_action("list") == "/api/user/v2/users/" + assert UserViewSet(basename="user").reverse_action( + "detail", kwargs={"username": "alice"}, + ) == "/api/user/v2/users/alice/" + preferences = UserPreferenceViewSet(basename="user_preference") + assert preferences.reverse_action("list") == "/api/user/v2/user_preferences/" + assert preferences.reverse_action( + "detail", kwargs={"username": "alice", "key": "time_zone"}, + ) == "/api/user/v2/user_preferences/alice,time_zone/" + + def test_action_addresses_follow_the_namespace_the_request_arrived_on(self): + request = Request(APIRequestFactory().get("/api/user/v2/users/")) + request.resolver_match = resolve("/api/user/v2/users/") + view = UserViewSet(basename="user", request=request) + assert view.reverse_action("list") == "http://testserver/api/user/v2/users/" + + def test_addresses_resolve_to_the_version_two_viewsets(self): + expected = { + "/api/user/v2/accounts/": ("AccountViewSet", {"get": "list"}), + "/api/user/v2/accounts/alice/": ( + "AccountViewSet", {"get": "retrieve", "patch": "partial_update"}, + ), + "/api/user/v2/accounts/alice/verifications/": ( + "IDVerificationViewSet", {"get": "list"}, + ), + "/api/user/v2/accounts/alice/preferences/": ( + "AccountPreferenceViewSet", + {"get": "retrieve_all", "patch": "partial_update_all"}, + ), + "/api/user/v2/accounts/alice/preferences/time_zone/": ( + "AccountPreferenceViewSet", + {"get": "retrieve", "put": "update", "delete": "destroy"}, + ), + "/api/user/v2/email_searches/": ("EmailSearchViewSet", {"post": "create"}), + "/api/user/v2/email_opt_ins/": ("EmailOptInViewSet", {"post": "create"}), + "/api/user/v2/name_changes/": ("NameChangeViewSet", {"post": "create"}), + "/api/user/v2/name_changes/alice/confirmations/": ( + "NameChangeViewSet", {"post": "confirmations"}, + ), + "/api/user/v2/users/": ("UserViewSet", {"get": "list"}), + "/api/user/v2/users/alice/": ("UserViewSet", {"get": "retrieve"}), + "/api/user/v2/user_preferences/": ("UserPreferenceViewSet", {"get": "list"}), + "/api/user/v2/user_preferences/alice,time_zone/": ( + "UserPreferenceViewSet", {"get": "retrieve"}, + ), + "/api/user/v2/time_zones/": ("CountryTimeZoneViewSet", {"get": "list"}), + } + for address, (class_name, actions) in expected.items(): + match = resolve(address) + assert view_identity(match.func) == (V2_VIEW_MODULE, class_name), address + assert routed_actions(match.func) == actions, address + + def test_a_deactivation_authenticates_the_way_the_platform_does(self): + """ + The view serving a deactivation declares no authentication of its own. + + Disabling an account is reached with whatever credentials the platform + accepts everywhere else, so the view carries no list of its own that a + deployment changing that setting would have to be changed alongside. + """ + view_class = resolve("/api/user/v2/accounts/alice/deactivation/").func.view_class + assert view_class is AccountDeactivationView + assert "authentication_classes" not in vars(AccountDeactivationView) + assert AccountDeactivationView.authentication_classes is APIView.authentication_classes + + def test_the_views_reached_before_an_address_is_confirmed_replace_one_class(self): + """ + These four are reached with the library's token class and a wider session one. + + The site's own settings pages are reached by a user who has not + confirmed their email address, whom the session class the platform + configures refuses, so that one class is replaced. The class reading a + token is the one the platform's own pair is built on, named here rather + than inherited, because a view that declares its own classes is not one + that pair serves and is not recorded as one. + """ + platform_token = api_settings.DEFAULT_AUTHENTICATION_CLASSES[0] + assert issubclass(platform_token, JwtAuthentication) + for view_class in ( + AccountViewSet, ProfileImageView, AccountPreferenceViewSet, EmailOptInViewSet, + ): + assert view_class.authentication_classes == ( + JwtAuthentication, SessionAuthenticationAllowInactiveUser, + ), view_class.__name__ + + def test_what_is_recorded_about_an_account_authenticates_the_way_the_platform_does(self): + """ + The views serving what has been recorded about an account declare no authentication. + + The account itself is reached with the session class that admits a + user who has not confirmed their address, because the site's own + settings pages are reached that way; what has been recorded about that + account is reached with whatever credentials the platform accepts + everywhere else, so these views carry no list of their own that a + deployment changing that setting would have to be changed alongside. + """ + assert resolve( + "/api/user/v2/accounts/alice/verifications/", + ).func.cls is IDVerificationViewSet + for view_class in (IDVerificationStatusView, IDVerificationViewSet): + assert "authentication_classes" not in vars(view_class), view_class.__name__ + assert view_class.authentication_classes is APIView.authentication_classes + + def test_the_hand_routed_addresses_resolve_to_the_views_that_serve_them(self): + """The addresses that are not router-generated carry a view and a method set too.""" + expected = { + "/api/user/v2/accounts/alice/deactivation/": ( + V2_VIEW_MODULE, "AccountDeactivationView", {"POST", "OPTIONS"}, + ), + "/api/user/v2/accounts/alice/profile_image/": ( + PROFILE_IMAGE_VIEW_MODULE, "ProfileImageView", {"POST", "DELETE", "OPTIONS"}, + ), + "/api/user/v2/accounts/alice/verification_status/": ( + V2_VIEW_MODULE, "IDVerificationStatusView", {"GET", "OPTIONS"}, + ), + } + for address, (module, class_name, methods) in expected.items(): + match = resolve(address) + assert view_identity(match.func) == (module, class_name), address + assert served_methods(match.func) == methods, address + assert match.kwargs == {"username": "alice"}, address + + def test_username_with_a_dot_resolves_to_the_member_route(self): + match = resolve("/api/user/v2/users/ada.lovelace/") + assert view_identity(match.func) == (V2_VIEW_MODULE, "UserViewSet") + assert match.kwargs == {"username": "ada.lovelace"} + + def test_preference_member_address_splits_into_username_and_key(self): + match = resolve("/api/user/v2/user_preferences/ada.lovelace,pref-lang/") + assert match.kwargs == {"username": "ada.lovelace", "key": "pref-lang"} + + def test_a_key_containing_a_comma_belongs_to_the_key(self): + """A preference name is free-form text, and the username half cannot contain a comma.""" + match = resolve("/api/user/v2/user_preferences/ada.lovelace,a,b/") + assert match.kwargs == {"username": "ada.lovelace", "key": "a,b"} + assert reverse( + "user_v2:user_preference_detail", kwargs={"username": "ada.lovelace", "key": "a,b"}, + ) == "/api/user/v2/user_preferences/ada.lovelace,a,b/" + + def test_a_username_outside_the_allowed_pattern_does_not_reach_the_viewset(self): + 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") + + def test_a_name_carrying_a_slash_is_not_addressed_as_a_preference(self): + """A name is read from one segment, which the slash ends, so no member route serves it.""" + for key in ("a/b", "x/", "/x", "a//b"): + match = resolve(f"/api/user/v2/accounts/ada.lovelace/preferences/{key}/") + assert view_identity(match.func) == (V2_VIEW_MODULE, "UnknownAddressView"), key + with pytest.raises(NoReverseMatch): + reverse( + "user_v2:account_preference_detail", + kwargs={"username": "ada.lovelace", "key": key}, + ) + + def test_every_name_the_segment_can_carry_can_be_addressed(self): + """A preference name is free-form text, so the key half takes the whole segment.""" + for key in ("pref-lang", "visibility.bio", "visibility.", "account_privacy", "a,b"): + address = f"/api/user/v2/accounts/ada.lovelace/preferences/{key}/" + match = resolve(address) + assert view_identity(match.func) == (V2_VIEW_MODULE, "AccountPreferenceViewSet"), key + assert match.kwargs == {"username": "ada.lovelace", "key": key}, key + assert reverse( + "user_v2:account_preference_detail", + kwargs={"username": "ada.lovelace", "key": key}, + ) == address, key + + def test_a_name_that_is_a_collection_of_this_version_is_read_as_a_preference_name(self): + """The collections stand at the top of this version, so no preference name shadows one.""" + for key in ("email_opt_in", "email_opt_ins", "time_zones"): + match = resolve(f"/api/user/v2/accounts/alice/preferences/{key}/") + assert view_identity(match.func) == (V2_VIEW_MODULE, "AccountPreferenceViewSet"), key + assert match.kwargs == {"username": "alice", "key": key}, key + for address, name in ( + ("/api/user/v2/email_opt_ins/", "email_opt_in_list"), + ("/api/user/v2/time_zones/", "time_zone_list"), + ): + assert resolve(address).url_name == name, address + + def test_the_preference_addresses_are_served_only_with_their_slash(self): + """Without it they are matched by the fallback, which answers rather than redirects.""" + for address in ( + "/api/user/v2/accounts/alice/preferences", + "/api/user/v2/accounts/alice/preferences/time_zone", + ): + assert view_identity(resolve(address).func) == ( + V2_VIEW_MODULE, "UnknownAddressView", + ), address + + def test_the_name_in_a_preference_address_is_read_as_a_stored_one(self): + """``me`` is a username here, and is routed as one rather than resolved to the caller.""" + for address, kwargs in ( + ("/api/user/v2/accounts/me/preferences/", {"username": "me"}), + ( + "/api/user/v2/accounts/me/preferences/time_zone/", + {"username": "me", "key": "time_zone"}, + ), + ): + match = resolve(address) + assert view_identity(match.func) == (V2_VIEW_MODULE, "AccountPreferenceViewSet"), address + assert match.kwargs == kwargs, address + + def test_the_preference_viewset_serves_both_of_its_addresses(self): + """One class holds every action of this resource, over the two addresses it has.""" + collection = resolve("/api/user/v2/accounts/alice/preferences/") + member = resolve("/api/user/v2/accounts/alice/preferences/time_zone/") + assert collection.func.cls is AccountPreferenceViewSet + assert member.func.cls is AccountPreferenceViewSet + assert set(routed_actions(collection.func)) | set(routed_actions(member.func)) == { + "get", "patch", "put", "delete", + } + + +@skip_unless_lms +class RouteShapeTests(APITestCase): + """ + Every address this version serves is named and ends in a slash. + + The mount also carries one pattern that is neither: the fallback matched + after all of them, which answers an address no route serves. It addresses + no resource and reverses to nothing, which is why it has no name, and + pinning it here keeps a second wildcard from being added quietly beside + it. + """ + + def test_every_address_is_named_and_ends_in_a_slash(self): + addresses = [pattern for pattern in v2_urls.urlpatterns if pattern.name] + assert sorted(pattern.name for pattern in addresses) == [ + "account_deactivation", + "account_detail", + "account_list", + "account_preference_detail", + "account_preference_list", + "account_profile_image", + "account_verification_list", + "account_verification_status", + "email_opt_in_list", + "email_search_list", + "login_session_list", + "name_change_confirmation", + "name_change_list", + "password_reset_list", + "password_reset_token_validation_list", + "registration_list", + "registration_validation_list", + "retirement_cleanup_list", + "retirement_detail", + "retirement_erasure", + "retirement_list", + "retirement_partner_report_cleanup_list", + "retirement_partner_report_detail", + "retirement_partner_report_list", + "time_zone_list", + "user_detail", + "user_list", + "user_preference_detail", + "user_preference_list", + ] + for pattern in addresses: + assert SNAKE_CASE_NAME.match(pattern.name), pattern.name + assert str(pattern.pattern).endswith("/$"), pattern.name + + def test_every_pattern_is_anchored_where_the_mount_leaves_off(self): + """ + An address is matched against what the mount leaves of the path, by searching it. + + A pattern that does not say where it starts therefore answers any + address that merely ends in it, so every one of them says so, + including the fallback. + """ + for pattern in v2_urls.urlpatterns: + assert str(pattern.pattern).startswith("^"), (pattern.name, str(pattern.pattern)) + + def test_the_router_route_that_is_replaced_has_to_exist(self): + """Dropping a route the router does not generate is refused instead of passing silently.""" + routes = v2_urls.router.urls + assert v2_urls.REPLACED_ROUTER_ROUTE in {route.name for route in routes} + kept = v2_urls.without_route(routes, v2_urls.REPLACED_ROUTER_ROUTE) + assert [route.name for route in kept] == [ + "account_list", + "account_detail", + "account_verification_list", + "email_search_list", + "email_opt_in_list", + "name_change_list", + "name_change_confirmation", + "user_list", + "user_detail", + "user_preference_list", + "time_zone_list", + "retirement_list", + "retirement_detail", + "retirement_erasure", + "retirement_cleanup_list", + "retirement_partner_report_list", + "retirement_partner_report_detail", + "retirement_partner_report_cleanup_list", + ] + with pytest.raises(ImproperlyConfigured): + v2_urls.without_route(routes, "user_preference_detail_renamed") + + def test_the_one_unnamed_pattern_is_the_fallback_and_it_is_matched_last(self): + unnamed = [pattern for pattern in v2_urls.urlpatterns if not pattern.name] + assert len(unnamed) == 1 + assert unnamed[0] is v2_urls.urlpatterns[-1] + assert view_identity(unnamed[0].callback) == (V2_VIEW_MODULE, "UnknownAddressView") + + +@skip_unless_lms +class LegacyAddressTests(APITestCase): + """Mounting version 2 leaves the version 1 surface exactly where it was.""" + + def test_version_one_account_addresses_still_reach_the_version_one_views(self): + expected = { + "/api/user/v1/me": ("AccountViewSet", {"get": "get"}), + "/api/user/v1/accounts": ("AccountViewSet", {"get": "list"}), + "/api/user/v1/accounts/search_emails": ("AccountViewSet", {"post": "search_emails"}), + "/api/user/v1/accounts/alice": ( + "AccountViewSet", {"get": "retrieve", "patch": "partial_update"}, + ), + } + for address, (class_name, actions) in expected.items(): + match = resolve(address) + assert view_identity(match.func) == (V1_ACCOUNT_VIEW_MODULE, class_name), address + assert routed_actions(match.func) == actions, address + + deactivation = resolve("/api/user/v1/accounts/alice/deactivate/") + assert view_identity(deactivation.func) == ( + V1_ACCOUNT_VIEW_MODULE, "AccountDeactivationView", + ) + + def test_version_one_addresses_of_the_resources_this_version_adds_are_unchanged(self): + name_changes = { + "/api/user/v1/accounts/name_change/": {"post": "create"}, + "/api/user/v1/accounts/name_change/alice/confirm/": {"post": "confirm"}, + } + for address, actions in name_changes.items(): + match = resolve(address) + assert view_identity(match.func) == (V1_ACCOUNT_VIEW_MODULE, "NameChangeView"), address + assert routed_actions(match.func) == actions, address + + image = resolve("/api/user/v1/accounts/alice/image") + assert view_identity(image.func) == ( + "openedx.core.djangoapps.profile_images.views", "ProfileImageView", + ) + for address, class_name in ( + ("/api/user/v1/accounts/alice/verification_status/", "IDVerificationStatusView"), + ("/api/user/v1/accounts/alice/verifications/", "IDVerificationStatusDetailsView"), + ): + match = resolve(address) + assert view_identity(match.func) == ( + "openedx.core.djangoapps.user_api.verification_api.views", class_name, + ), address + + def test_the_profile_image_twins_in_their_own_app_are_left_where_they_are(self): + """This version adds no second address for them, and theirs keep their names.""" + assert reverse("profile_image_upload", kwargs={"username": "alice"}) == ( + "/api/profile_images/v1/alice/upload" + ) + assert reverse("profile_image_remove", kwargs={"username": "alice"}) == ( + "/api/profile_images/v1/alice/remove" + ) + for name in ("profile_image_upload", "profile_image_remove"): + match = resolve(reverse(name, kwargs={"username": "alice"})) + assert match.func.view_class.__module__ == "openedx.core.djangoapps.profile_images.views" + + def test_version_one_addresses_still_reach_the_version_one_views(self): + expected = { + "/api/user/v1/users/": "UserViewSet", + "/api/user/v1/users/42/": "UserViewSet", + "/api/user/v1/user_prefs/": "UserPreferenceViewSet", + "/api/user/v1/user_prefs/42/": "UserPreferenceViewSet", + "/api/user/v1/preferences/pref-lang/users/": "PreferenceUsersListView", + "/api/user/v1/forum_roles/Moderator/users/": "ForumRoleUsersListView", + "/api/user/v1/preferences/time_zones/": "CountryTimeZoneListView", + } + for address, class_name in expected.items(): + match = resolve(address) + assert view_identity(match.func) == (V1_VIEW_MODULE, class_name), address + + def test_version_one_url_names_still_reverse(self): + assert reverse("accounts_api", kwargs={"username": "alice"}) == "/api/user/v1/accounts/alice" + assert reverse("own_username_api") == "/api/user/v1/me" + assert reverse("accounts_detail_api") == "/api/user/v1/accounts" + assert reverse("accounts_search_emails_api") == "/api/user/v1/accounts/search_emails" + assert reverse("accounts_deactivation", kwargs={"username": "alice"}) == ( + "/api/user/v1/accounts/alice/deactivate/" + ) + assert reverse("accounts_profile_image_api", kwargs={"username": "alice"}) == ( + "/api/user/v1/accounts/alice/image" + ) + assert reverse("request_name_change") == "/api/user/v1/accounts/name_change/" + assert reverse("confirm_name_change", kwargs={"username": "alice"}) == ( + "/api/user/v1/accounts/name_change/alice/confirm/" + ) + assert reverse("verification_status", kwargs={"username": "alice"}) == ( + "/api/user/v1/accounts/alice/verification_status/" + ) + assert reverse("verification_details", kwargs={"username": "alice"}) == ( + "/api/user/v1/accounts/alice/verifications/" + ) + assert reverse("preferences_api", kwargs={"username": "alice"}) == "/api/user/v1/preferences/alice" + + def test_version_one_preference_addresses_still_reach_the_version_one_views(self): + expected = { + "/api/user/v1/preferences/alice": (V1_PREFERENCE_VIEW_MODULE, "PreferencesView"), + "/api/user/v1/preferences/alice/time_zone": ( + V1_PREFERENCE_VIEW_MODULE, "PreferencesDetailView", + ), + "/api/user/v1/preferences/email_opt_in/": ( + V1_VIEW_MODULE, "UpdateEmailOptInPreference", + ), + } + for address, identity in expected.items(): + assert view_identity(resolve(address).func) == identity, address + + def test_version_one_preference_url_names_still_reverse(self): + assert reverse( + "preferences_detail_api", kwargs={"username": "alice", "preference_key": "time_zone"}, + ) == "/api/user/v1/preferences/alice/time_zone" + assert reverse("preferences_email_opt_in") == "/api/user/v1/preferences/email_opt_in/" + assert reverse("preferences_email_opt_in_legacy") == ( + "/user_api/v1/preferences/email_opt_in/" + ) + + def test_the_unversioned_opt_in_address_reaches_the_version_one_view_as_before(self): + """Both of its addresses are served by one view, and this version adds no third.""" + versioned = resolve("/api/user/v1/preferences/email_opt_in/") + unversioned = resolve("/user_api/v1/preferences/email_opt_in/") + assert view_identity(unversioned.func) == view_identity(versioned.func) + assert view_identity(unversioned.func) == (V1_VIEW_MODULE, "UpdateEmailOptInPreference") + assert served_methods(unversioned.func) == served_methods(versioned.func) == { + "POST", "OPTIONS", + } + + def test_unversioned_twins_still_resolve(self): + match = resolve("/user_api/v1/users/") + assert view_identity(match.func) == (V1_VIEW_MODULE, "UserViewSet") + + +@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_username(self): + self.login(self.admin) + response = self.client.get(self.url, {"username": "bob", "page_size": 100}) + assert self.usernames_in(response) == ["bob"] + + def test_several_names_select_the_entries_of_each(self): + """ + The value is a list of names, as it is on the account collection. + + A caller that learned the list on one collection of this version and + sent it to another was answered with an empty page, which reads as a + page of the accounts asked for rather than as a value this collection + does not take. + """ + self.login(self.admin) + response = self.client.get(self.url, {"username": "alice,bob", "page_size": 100}) + assert response.status_code == status.HTTP_200_OK + assert self.usernames_in(response) == ["alice", "bob"] + assert response.data["count"] == 2 + + def test_a_name_in_the_list_that_no_account_holds_is_left_out_of_the_page(self): + self.login(self.admin) + response = self.client.get(self.url, {"username": "alice,nobody", "page_size": 100}) + assert self.usernames_in(response) == ["alice"] + assert response.data["count"] == 1 + + def test_a_position_in_the_list_that_carries_nothing_names_nothing(self): + self.login(self.admin) + response = self.client.get(self.url, {"username": ",alice,,bob,", "page_size": 100}) + assert self.usernames_in(response) == ["alice", "bob"] + + def test_an_empty_name_narrows_nothing(self): + self.login(self.admin) + response = self.client.get(self.url, {"username": "", "page_size": 100}) + assert response.status_code == status.HTTP_200_OK + assert self.usernames_in(response) == ["admin-caller", "alice", "bob", "learner-caller"] + + def test_filter_by_email(self): + self.login(self.admin) + response = self.client.get(self.url, {"email": "bob@example.com"}) + 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_several_names_select_the_preferences_of_each(self): + """The value is a list of names, as it is on the sibling collections of this version.""" + self.login(self.admin) + response = self.client.get(self.url, {"username": "alice,bob", "page_size": 100}) + assert response.status_code == status.HTTP_200_OK + assert self.keys_in(response) == [("alice", "pref-lang"), ("bob", "time_zone")] + assert response.data["count"] == 2 + + def test_a_name_in_the_list_that_no_account_holds_is_left_out_of_the_page(self): + self.login(self.admin) + response = self.client.get(self.url, {"username": "alice,nobody", "page_size": 100}) + assert self.keys_in(response) == [("alice", "pref-lang")] + + def test_an_empty_name_narrows_nothing(self): + self.login(self.admin) + response = self.client.get(self.url, {"username": "", "page_size": 100}) + assert response.status_code == status.HTTP_200_OK + assert self.keys_in(response) == [("alice", "pref-lang"), ("bob", "time_zone")] + + def test_the_owner_id_the_earlier_list_took_is_refused_rather_than_ignored(self): + """A caller that ported by changing the version segment is told, not quietly unfiltered.""" + self.login(self.admin) + 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 NameSelectorTests(UserApiV2TestCase): + """``?username=`` on the collections of this version that select accounts by name.""" + + def test_every_collection_that_selects_accounts_by_name_reads_one_list(self): + """ + One value names the same accounts wherever it is sent. + + The member address of the stored preferences already reads a comma as + the separator between the two names that identify a preference, so a + collection of this version that read the comma as part of a username + would be answering a value its own addresses spell differently. + """ + self.login(self.admin) + selection = {"username": "alice,bob", "page_size": 100} + pages = { + name: self.client.get(reverse(f"user_v2:{name}"), selection) + for name in ("user_list", "user_preference_list", "account_list") + } + assert {name: page.status_code for name, page in pages.items()} == { + "user_list": status.HTTP_200_OK, + "user_preference_list": status.HTTP_200_OK, + "account_list": status.HTTP_200_OK, + } + assert { + name: sorted({row["username"] for row in page.data["results"]}) + for name, page in pages.items() + } == { + "user_list": ["alice", "bob"], + "user_preference_list": ["alice", "bob"], + "account_list": ["alice", "bob"], + } + + +@skip_unless_lms +class TimeZoneListViewTests(UserApiV2TestCase): + """GET /api/user/v2/time_zones/""" + + 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 + + +class RecordingScopePolicy: + """A record-visibility policy that drops one owner's rows and records each call.""" + + def __init__(self, hidden_username): + self.hidden_username = hidden_username + self.calls = [] + + def scope(self, queryset, subject): + """Return the rows of ``queryset`` that do not belong to the hidden owner.""" + self.calls.append((queryset.model, subject)) + owner_field = "username" if queryset.model is User else "user__username" + return queryset.exclude(**{owner_field: self.hidden_username}) + + +@skip_unless_lms +class QuerysetScopingSeamTests(UserApiV2TestCase): + """ + The record-visibility layer of the two collections backed by a queryset. + + Each of these collections declares a policy that decides which rows a + caller may see, and the rows it leaves out are absent from the answer + rather than refused. The policy that ships keeps every row, because only + platform administrators reach these collections at all, so these tests + put a narrowing policy in its place and read the answer: a collection + that stopped consulting its policy would hand back the rows the policy + left out, and a collection that no longer declared one would narrow + nothing whatever policy it was given. + """ + + def scoped(self, view_class, hidden_username): + """Run the view class with a policy that hides one owner's rows.""" + policy = RecordingScopePolicy(hidden_username) + return patch.object(view_class, "scoping_policy", policy, create=True), policy + + def test_user_list_leaves_out_the_rows_its_policy_excludes(self): + self.login(self.admin) + patched, policy = self.scoped(UserViewSet, "alice") + with patched: + response = self.client.get(reverse("user_v2:user_list")) + assert response.status_code == status.HTTP_200_OK + assert self.usernames_in(response) == ["admin-caller", "bob", "learner-caller"] + assert policy.calls == [(User, self.admin)] + + def test_user_detail_of_an_excluded_row_is_not_found(self): + self.login(self.admin) + patched, _ = self.scoped(UserViewSet, "alice") + with patched: + response = self.client.get(reverse("user_v2:user_detail", kwargs={"username": "alice"})) + assert_error_envelope( + response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + + def test_user_detail_of_an_included_row_is_returned(self): + self.login(self.admin) + patched, _ = self.scoped(UserViewSet, "alice") + with patched: + response = self.client.get(reverse("user_v2:user_detail", kwargs={"username": "bob"})) + assert response.status_code == status.HTTP_200_OK + assert response.data["username"] == "bob" + + def test_preference_list_leaves_out_the_rows_its_policy_excludes(self): + self.login(self.admin) + patched, policy = self.scoped(UserPreferenceViewSet, "alice") + with patched: + response = self.client.get(reverse("user_v2:user_preference_list")) + assert response.status_code == status.HTTP_200_OK + assert [(row["username"], row["key"]) for row in response.data["results"]] == [("bob", "time_zone")] + assert policy.calls == [(UserPreference, self.admin)] + + def test_preference_detail_of_an_excluded_row_is_not_found(self): + self.login(self.admin) + patched, _ = self.scoped(UserPreferenceViewSet, "alice") + with patched: + response = self.client.get( + reverse("user_v2:user_preference_detail", kwargs={"username": "alice", "key": "pref-lang"}), + ) + assert_error_envelope( + response, expected_status=status.HTTP_404_NOT_FOUND, expected_type_slug="not-found", + ) + + def test_preference_detail_of_an_included_row_is_returned(self): + self.login(self.admin) + patched, _ = self.scoped(UserPreferenceViewSet, "alice") + with patched: + response = self.client.get( + reverse("user_v2:user_preference_detail", kwargs={"username": "bob", "key": "time_zone"}), + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["value"] == "UTC" + + def test_each_scoped_collection_declares_a_policy_of_its_own(self): + """ + Each of these two views names the policy that governs its rows. + + A view that inherited the default instead of naming one would be left + with no policy at all, so the declaration is read from the view's own + attributes rather than from anything it inherits. + """ + for view_class in (UserViewSet, UserPreferenceViewSet): + declared = vars(view_class).get("scoping_policy") + assert declared is not None, f"{view_class.__name__} declares no record-visibility policy" + assert callable(getattr(declared, "scope", None)), ( + f"{view_class.__name__} declares a record-visibility policy that cannot narrow rows" + ) + + def test_the_rows_are_fetched_through_the_declared_policy(self): + """ + The policy is what the rows are fetched through, not a step that can be skipped. + + A view left without a usable policy hands back no rows at all rather + than every row, which is how a collection that stopped consulting its + policy is told apart from one whose policy happens to keep every row. + """ + for view_class in (UserViewSet, UserPreferenceViewSet): + with patch.object(view_class, "scoping_policy", None, create=True): + with pytest.raises(ImproperlyConfigured): + view_class().get_queryset() + + +def routed_view_classes(): + """Return every view class this version mounts, by the name it is routed under.""" + classes = {} + for entry in v2_urls.urlpatterns: + view_class = getattr(entry.callback, "cls", None) + if view_class is not None: + classes[view_class.__name__] = view_class + return classes + + +#: Words that name how an address is built rather than what it answers. A +#: description carrying one of these was written for somebody reading this +#: module, and the body of an OPTIONS request is not where they will read it. +IMPLEMENTATION_TERMS = ( + "ViewSet", + "queryset", + "permission_classes", + "serializer", + "filter set", + "filter backend", + "mixin", + "scoping policy", + "router", + "selectors", + "class attribute", +) + +#: The longest description an address gives of itself. The client-facing text +#: is a short paragraph; the maintainer-facing text these replaced ran to four. +DESCRIPTION_LIMIT = 700 + + +@skip_unless_lms +class AddressSelfDescriptionTests(UserApiV2TestCase): + """ + What each address of this version says about itself to a caller that asks. + + An OPTIONS request is answered with the description the view class + carries, so that description is published text and is read by clients and + by documentation browsers alongside the description of the same address in + the schema. These tests hold it to being written for the caller: no markup + that renders as itself in a JSON body, no vocabulary from the + implementation, and no essay. + """ + + def test_every_mounted_address_is_described(self): + """Each view this version mounts says something about itself.""" + described = { + name: view_class().get_view_description() + for name, view_class in routed_view_classes().items() + } + assert len(described) == 22, sorted(described) + assert [name for name, text in described.items() if not text.strip()] == [] + + def test_no_address_describes_itself_in_implementation_terms(self): + offenders = { + name: [term for term in IMPLEMENTATION_TERMS if term in view_class().get_view_description()] + for name, view_class in routed_view_classes().items() + } + assert {name: terms for name, terms in offenders.items() if terms} == {} + + def test_no_address_describes_itself_with_markup_a_caller_sees_as_itself(self): + """Double backticks are markup in this module's own documentation and text in a JSON body.""" + carrying_markup = [ + name for name, view_class in routed_view_classes().items() + if "``" in view_class().get_view_description() + ] + assert carrying_markup == [] + + def test_no_address_describes_itself_at_length(self): + too_long = { + name: len(view_class().get_view_description()) + for name, view_class in routed_view_classes().items() + if len(view_class().get_view_description()) > DESCRIPTION_LIMIT + } + assert too_long == {} + + def test_options_answers_with_the_description_the_view_carries(self): + """ + The description a view carries is the one a caller is handed. + + This is what makes the text above published rather than internal: the + framework answers an OPTIONS request with it verbatim. + """ + self.login(self.admin) + answer = self.client.options(reverse("user_v2:user_list")) + assert answer.status_code == status.HTTP_200_OK + assert answer.json()["description"] == UserViewSet().get_view_description() + assert answer.json()["description"].startswith( + "Directory entries of the platform's users, each addressed by username.", + ) + + +@skip_unless_lms +class SelectedPropertyNameTests(UserApiV2TestCase): + """ + What a selected property name may be, at the collections that take one. + + The parameter takes the names of properties the answer carries. A name + written with a dot in it names no such property: the answer carries the + property the name starts with, whole, and the rest of the name selects + nothing. Reading such a name as its first part would hand a caller the + whole of a property it asked one piece of, and would accept a second part + that names nothing at all. + """ + + def setUp(self): + super().setUp() + self.list_url = reverse("user_v2:user_list") + self.member_url = reverse("user_v2:user_detail", kwargs={"username": "alice"}) + self.preference_list_url = reverse("user_v2:user_preference_list") + self.login(self.admin) + + def test_a_name_written_with_a_dot_is_refused_on_the_directory_list(self): + response = self.client.get(f"{self.list_url}?fields=username.first") + assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert response.json()["errors"]["fields"] == [ + "No such field: username.first. Select any of: email, id, name, preferences, url, " + "username.", + ] + + def test_a_name_written_with_a_dot_is_refused_on_a_directory_entry(self): + response = self.client.get(f"{self.member_url}?fields=preferences.pref-lang") + assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert "No such field: preferences.pref-lang." in response.json()["errors"]["fields"][0] + + def test_a_name_written_with_a_dot_is_refused_on_the_preference_list(self): + response = self.client.get(f"{self.preference_list_url}?fields=user.email") + assert_error_envelope( + response, expected_status=status.HTTP_400_BAD_REQUEST, expected_type_slug="validation", + ) + assert "No such field: user.email." in response.json()["errors"]["fields"][0] + + def test_a_plain_name_still_selects_that_property_alone(self): + response = self.client.get(f"{self.member_url}?fields=username") + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"username": "alice"} + + def test_a_plain_name_still_selects_that_property_alone_on_a_list(self): + response = self.client.get(f"{self.list_url}?fields=username,email&ordering=username") + assert response.status_code == status.HTTP_200_OK + assert response.json()["results"] == [ + {"username": "admin-caller", "email": self.admin.email}, + {"username": "alice", "email": "alice@example.com"}, + {"username": "bob", "email": "bob@example.com"}, + {"username": "learner-caller", "email": self.learner.email}, + ] + + +@skip_unless_lms +class UnsatisfiableAcceptTests(UserApiV2TestCase): + """ + What an address answers a request it can give no acceptable answer to. + + Which representations an answer may be given in is settled as the request + enters, so the refusal reaches an address that answers with a body and one + that answers with none alike. It is published by every operation of this + version, and these are the measurements that publication rests on. + """ + + def addresses(self): + """Return a representative request to each kind of address of this version, by method.""" + return [ + ("get", reverse("user_v2:user_list"), self.admin), + ("get", reverse("user_v2:user_detail", kwargs={"username": "alice"}), self.admin), + ("get", reverse("user_v2:user_preference_list"), self.admin), + ("get", reverse("user_v2:time_zone_list"), self.learner), + ("get", reverse("user_v2:account_list"), self.learner), + ("get", reverse("user_v2:account_detail", kwargs={"username": "me"}), self.learner), + ("get", reverse("user_v2:account_preference_list", kwargs={"username": "alice"}), self.alice), + ( + "get", + reverse("user_v2:account_preference_detail", kwargs={"username": "alice", "key": "pref-lang"}), + self.alice, + ), + ("delete", reverse("user_v2:account_profile_image", kwargs={"username": "alice"}), self.alice), + ] + + def test_an_accept_header_naming_nothing_the_address_answers_in_is_refused(self): + answered = {} + for method, url, caller in self.addresses(): + self.client.logout() + self.login(caller) + response = getattr(self.client, method)(url, HTTP_ACCEPT="text/csv") + answered[f"{method.upper()} {url}"] = response.status_code + assert set(answered.values()) == {status.HTTP_406_NOT_ACCEPTABLE}, answered + + def test_the_refusal_comes_back_in_the_body_this_version_answers_with(self): + """The refusal is readable even though the caller asked for a representation it is not in.""" + self.login(self.admin) + response = self.client.get(reverse("user_v2:user_list"), HTTP_ACCEPT="text/csv") + assert_error_envelope( + response, expected_status=status.HTTP_406_NOT_ACCEPTABLE, expected_type_slug="not-acceptable", + ) + + def test_an_address_that_answers_with_no_body_refuses_it_too(self): + """ + Nothing is read or changed on the way to the refusal. + + Clearing an image answers with no body at all, so the representation + asked for could make no difference to what comes back; the refusal is + settled before the handler runs, and the stored image is left alone. + """ + UserProfile.objects.filter(user=self.alice).update(profile_image_uploaded_at=datetime.now(UTC)) + self.login(self.alice) + response = self.client.delete( + reverse("user_v2:account_profile_image", kwargs={"username": "alice"}), + HTTP_ACCEPT="text/csv", + ) + assert response.status_code == status.HTTP_406_NOT_ACCEPTABLE + self.alice.profile.refresh_from_db() + assert self.alice.profile.has_profile_image is True + + def test_an_accept_header_the_address_can_satisfy_is_answered(self): + """The refusal is about what was asked for, not about asking at all.""" + self.login(self.admin) + response = self.client.get(reverse("user_v2:user_list"), HTTP_ACCEPT="application/json") + assert response.status_code == status.HTTP_200_OK + assert self.usernames_in(response) == ["admin-caller", "alice", "bob", "learner-caller"] + + +@skip_unless_lms +class SessionWithoutItsTokenTests(UserApiV2TestCase): + """ + What a write answers a session the site cannot tie to a page it served. + + Both of these collections record something for the caller and are reached + from the site's own pages, and both refuse such a session. Only one of + them published that refusal, so they are measured together here. + """ + + def test_asking_for_a_name_change_with_a_session_without_its_token_is_refused(self): + client = APIClient(enforce_csrf_checks=True) + assert client.login(username=self.alice.username, password=PASSWORD) + assert_error_envelope( + client.post(reverse("user_v2:name_change_list"), {"name": "Alice Example"}, format="json"), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + assert PendingNameChange.objects.filter(user=self.alice).count() == 0 + + def test_recording_an_organizations_messages_with_the_same_session_is_refused_the_same_way(self): + """The refusal the sibling collection publishes is the one this one answers with.""" + client = APIClient(enforce_csrf_checks=True) + assert client.login(username=self.alice.username, password=PASSWORD) + assert_error_envelope( + client.post( + reverse("user_v2:email_opt_in_list"), + {"course_key": str(COURSE_KEY), "email_opt_in": True}, + format="json", + ), + expected_status=status.HTTP_403_FORBIDDEN, + expected_type_slug="authz", + ) + + def test_a_name_change_asked_for_with_the_token_the_session_carries_is_recorded(self): + """The refusal is about the missing token, not about the address.""" + self.login(self.alice) + response = self.client.post( + reverse("user_v2:name_change_list"), {"name": "Alice Example"}, format="json", + ) + assert response.status_code == status.HTTP_201_CREATED + assert PendingNameChange.objects.get(user=self.alice).new_name == "Alice Example" + + +class HiddenKeyPolicy: + """A record-visibility policy that drops the preferences stored under one name.""" + + def __init__(self, hidden_key): + self.hidden_key = hidden_key + self.calls = 0 + + def scope(self, queryset, subject): # pylint: disable=unused-argument + """Return the rows of ``queryset`` stored under any other name.""" + self.calls += 1 + return queryset.exclude(key=self.hidden_key) + + +@skip_unless_lms +class EmbeddedRowScopingTests(UserApiV2TestCase): + """ + Rows of one resource carried inside another come through the policy that governs them. + + A directory entry carries the preferences stored against its account, and + a stored preference asked for whole carries its owner's entry, which + carries those preferences again. Those rows belong to the other + collection, so the policy that decides which of them a caller may see is + that collection's and not the one the entry came from. Applying it is + arranged where the page is fetched, so it costs no query per row. + """ + + def directory_of(self, rows): + """Create ``rows`` users, each with two stored preferences.""" + made = [] + for index in range(rows): + user = UserFactory.create(username=f"row-{index:03d}", password=PASSWORD) + UserPreferenceFactory.create(user=user, key="pref-lang", value="en") + UserPreferenceFactory.create(user=user, key="time_zone", value="UTC") + made.append(user) + return made + + def test_the_preferences_inside_a_directory_entry_come_through_their_own_policy(self): + policy = HiddenKeyPolicy("pref-lang") + self.login(self.admin) + with patch.object(UserViewSet, "embedded_preference_policy", policy, create=True): + response = self.client.get(reverse("user_v2:user_detail", kwargs={"username": "alice"})) + assert response.status_code == status.HTTP_200_OK + assert response.data["preferences"] == {} + assert policy.calls == 1 + + def test_a_preference_the_embedded_policy_keeps_is_still_carried(self): + policy = HiddenKeyPolicy("pref-lang") + self.login(self.admin) + with patch.object(UserViewSet, "embedded_preference_policy", policy, create=True): + response = self.client.get(reverse("user_v2:user_detail", kwargs={"username": "bob"})) + assert response.status_code == status.HTTP_200_OK + assert response.data["preferences"] == {"time_zone": "UTC"} + + def test_the_preferences_inside_a_page_of_entries_come_through_their_own_policy(self): + self.directory_of(5) + policy = HiddenKeyPolicy("pref-lang") + self.login(self.admin) + with patch.object(UserViewSet, "embedded_preference_policy", policy, create=True): + response = self.client.get(reverse("user_v2:user_list"), {"page_size": 100}) + assert response.status_code == status.HTTP_200_OK + carried = {row["username"]: row["preferences"] for row in response.data["results"]} + assert [name for name, preferences in carried.items() if "pref-lang" in preferences] == [] + assert carried["row-000"] == {"time_zone": "UTC"} + + def test_narrowing_the_embedded_preferences_costs_no_query_per_row(self): + """ + The policy is applied where the page is fetched, so it is run once however many rows there are. + + One row would hide a per-row query that cancels a prefetch, so this is + measured at five rows and again at twenty-five. + """ + self.login(self.admin) + measured = {} + for rows in (5, 25): + User.objects.filter(username__startswith="row-").delete() + self.directory_of(rows) + policy = HiddenKeyPolicy("pref-lang") + with patch.object(UserViewSet, "embedded_preference_policy", policy, create=True): + # The first request of a signed-in client pays for its session, + # which is not part of what is being measured here. + self.client.get(reverse("user_v2:user_list"), {"page_size": 100}) + with CaptureQueriesContext(connection) as captured: + response = self.client.get(reverse("user_v2:user_list"), {"page_size": 100}) + assert response.status_code == status.HTTP_200_OK + assert len(response.data["results"]) == rows + 4 + measured[rows] = len(captured) + assert measured[5] == measured[25], measured + + def test_the_preferences_inside_an_embedded_owner_come_through_their_own_policy(self): + """A preference asked for whole carries its owner, whose own preferences are narrowed too.""" + policy = HiddenKeyPolicy("pref-lang") + self.login(self.admin) + with patch.object(UserPreferenceViewSet, "embedded_preference_policy", policy, create=True): + response = self.client.get( + reverse("user_v2:user_preference_detail", kwargs={"username": "alice", "key": "pref-lang"}), + {"view": "full"}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["user"]["preferences"] == {} + + def test_narrowing_an_embedded_owners_preferences_costs_no_query_per_row(self): + self.login(self.admin) + measured = {} + for rows in (5, 25): + User.objects.filter(username__startswith="row-").delete() + self.directory_of(rows) + policy = HiddenKeyPolicy("pref-lang") + address = reverse("user_v2:user_preference_list") + with patch.object(UserPreferenceViewSet, "embedded_preference_policy", policy, create=True): + self.client.get(address, {"view": "full", "page_size": 100}) + with CaptureQueriesContext(connection) as captured: + response = self.client.get(address, {"view": "full", "page_size": 100}) + assert response.status_code == status.HTTP_200_OK + assert len(response.data["results"]) == rows * 2 + 2 + measured[rows] = len(captured) + assert measured[5] == measured[25], measured + + +class HiddenOwnerPolicy: + """A record-visibility policy over owners that hides one of them and counts its calls.""" + + def __init__(self, hidden_username): + self.hidden_username = hidden_username + self.calls = 0 + + def scope(self, queryset, subject): # pylint: disable=unused-argument + """Return the rows of ``queryset`` that are not the hidden owner.""" + self.calls += 1 + return queryset.exclude(username=self.hidden_username) + + +@skip_unless_lms +class EmbeddedOwnerScopingTests(UserApiV2TestCase): + """ + The owner carried inside a stored preference comes through the policy that governs owners. + + A preference asked for whole carries its owner's directory entry, which is + a row of the directory and not of the preference collection, so which + owners a caller may see is the directory's decision. An entry the caller + may not see is left out of the answer; the preference itself is still + returned, named by the owner it is stored against, because which + preferences come back is the preference collection's own decision. + """ + + def preferences_of(self, rows): + """Create ``rows`` users, each with two stored preferences.""" + for index in range(rows): + user = UserFactory.create(username=f"row-{index:03d}", password=PASSWORD) + UserPreferenceFactory.create(user=user, key="pref-lang", value="en") + UserPreferenceFactory.create(user=user, key="time_zone", value="UTC") + + def test_an_owner_the_embedded_policy_leaves_out_is_not_carried_inside_a_page(self): + policy = HiddenOwnerPolicy("alice") + self.login(self.admin) + with patch.object(UserPreferenceViewSet, "embedded_user_policy", policy, create=True): + response = self.client.get( + reverse("user_v2:user_preference_list"), {"view": "full", "page_size": 100}, + ) + assert response.status_code == status.HTTP_200_OK + carried = {row["username"]: row["user"] for row in response.data["results"]} + assert set(carried) == {"alice", "bob"} + assert carried["alice"] is None + assert carried["bob"]["username"] == "bob" + assert carried["bob"]["email"] == "bob@example.com" + + def test_an_owner_the_embedded_policy_leaves_out_is_not_carried_inside_one_preference(self): + policy = HiddenOwnerPolicy("alice") + self.login(self.admin) + with patch.object(UserPreferenceViewSet, "embedded_user_policy", policy, create=True): + response = self.client.get( + reverse("user_v2:user_preference_detail", kwargs={"username": "alice", "key": "pref-lang"}), + {"view": "full"}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["user"] is None + assert response.data["username"] == "alice" + assert response.data["key"] == "pref-lang" + assert response.data["value"] == "en" + + def test_an_owner_the_embedded_policy_keeps_is_still_carried(self): + """ + An owner the policy keeps comes back whole. + + This is a control rather than a guard: it reads a preference whose + owner the policy keeps, so it is green whether or not the policy is + consulted. It is here to show the answer is not narrowed too far. + """ + policy = HiddenOwnerPolicy("alice") + self.login(self.admin) + with patch.object(UserPreferenceViewSet, "embedded_user_policy", policy, create=True): + response = self.client.get( + reverse("user_v2:user_preference_detail", kwargs={"username": "bob", "key": "time_zone"}), + {"view": "full"}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["user"]["username"] == "bob" + assert response.data["user"]["preferences"] == {"time_zone": "UTC"} + + def test_the_owners_of_a_page_are_resolved_once_however_many_rows_it_carries(self): + """ + The owners a page may carry are settled for the whole page, so the cost does not grow with rows. + + One row would hide a per-row lookup, so this is measured at five rows + and again at twenty-five, and the policy is asked once either way. + """ + self.login(self.admin) + measured = {} + address = reverse("user_v2:user_preference_list") + for rows in (5, 25): + User.objects.filter(username__startswith="row-").delete() + self.preferences_of(rows) + policy = HiddenOwnerPolicy("alice") + with patch.object(UserPreferenceViewSet, "embedded_user_policy", policy, create=True): + # The first request of a signed-in client pays for its session, + # which is not part of what is being measured here. + self.client.get(address, {"view": "full", "page_size": 100}) + with CaptureQueriesContext(connection) as captured: + response = self.client.get(address, {"view": "full", "page_size": 100}) + assert response.status_code == status.HTTP_200_OK + assert len(response.data["results"]) == rows * 2 + 2 + assert policy.calls == 2, "the owners of a page are resolved once per request" + measured[rows] = len(captured) + assert measured[5] == measured[25], measured + + def test_no_owner_is_resolved_for_a_page_that_carries_none(self): + """A page that does not carry its owners does not ask which owners it could carry.""" + self.login(self.admin) + policy = HiddenOwnerPolicy("alice") + with patch.object(UserPreferenceViewSet, "embedded_user_policy", policy, create=True): + response = self.client.get(reverse("user_v2:user_preference_list"), {"page_size": 100}) + assert response.status_code == status.HTTP_200_OK + assert [row["username"] for row in response.data["results"]] == ["alice", "bob"] + assert policy.calls == 0 + + def test_a_policy_answering_with_a_collection_of_its_own_still_narrows_what_is_carried(self): + """ + A policy need not narrow the collection it is handed for its answer to hold. + + The owners of the page are taken out of what the policy answers with, + so a policy that builds its own collection instead of narrowing the + one it is given narrows the page just the same. + """ + class OwnCollectionPolicy: + """A policy that answers with a collection of its own.""" + + def scope(self, queryset, subject): # pylint: disable=unused-argument + """Return the one owner this policy keeps, built without the given collection.""" + return User.objects.filter(username="bob") + + self.login(self.admin) + with patch.object(UserPreferenceViewSet, "embedded_user_policy", OwnCollectionPolicy(), create=True): + response = self.client.get( + reverse("user_v2:user_preference_list"), {"view": "full", "page_size": 100}, + ) + assert response.status_code == status.HTTP_200_OK + carried = {row["username"]: row["user"] for row in response.data["results"]} + assert carried["alice"] is None + assert carried["bob"]["username"] == "bob" + + def test_the_embedded_owner_policy_is_declared_by_the_view_that_embeds(self): + """ + The view names the policy that governs the owners it carries, rather than inheriting one. + + A view that inherited the default would be left with no policy over + the entries it embeds, which is how a view that stopped declaring one + is told apart from a view whose policy happens to keep every owner. + """ + declared = vars(UserPreferenceViewSet).get("embedded_user_policy") + assert declared is not None, "UserPreferenceViewSet declares no policy over the owners it carries" + assert callable(getattr(declared, "scope", None)), ( + "UserPreferenceViewSet declares a policy over the owners it carries that cannot narrow rows" + ) diff --git a/openedx/core/djangoapps/user_api/v2/urls.py b/openedx/core/djangoapps/user_api/v2/urls.py new file mode 100644 index 000000000000..7463c74145d6 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/urls.py @@ -0,0 +1,222 @@ +"""URLs for version 2 of the User API.""" + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.urls import re_path +from rest_framework.routers import SimpleRouter + +from openedx.core.djangoapps.profile_images.v2.views import ProfileImageView + +from . import authn_views +from .views import ( + PREFERENCE_KEY_PATTERN_PARTIAL, + AccountDeactivationView, + AccountPreferenceViewSet, + AccountViewSet, + CountryTimeZoneViewSet, + EmailOptInViewSet, + EmailSearchViewSet, + IDVerificationStatusView, + IDVerificationViewSet, + NameChangeViewSet, + RetirementCleanupViewSet, + RetirementPartnerReportCleanupViewSet, + RetirementPartnerReportViewSet, + RetirementViewSet, + UnknownAddressView, + UserPreferenceViewSet, + UserViewSet, +) + +app_name = "user_v2" + +#: A preference is identified by its owner and its key, so the router's +#: database-id member route is dropped and replaced by the address declared +#: below under the same name. +REPLACED_ROUTER_ROUTE = "user_preference_detail" + +#: The addresses under this prefix that belong to the authentication app. That +#: app's routes are matched before this mount, so it answers these addresses +#: itself; naming them here keeps the fallback below from matching them too. +#: They are spelled out one by one rather than by the segment they share, so +#: that only what that app serves is left to it and every other address under +#: this prefix is answered here. The trailing slash is optional, which leaves an +#: address sent without one to be redirected to the slashed form the app serves +#: rather than answered as unknown. +OTHER_APP_ADDRESSES = r"account/(registration|login_session)/?" + + +def without_route(routes, name): + """ + Return ``routes`` without the one registered under ``name``. + + A name that matches nothing means the router stopped generating that route + under that name: the address it serves would then be published alongside + the one declared here instead of being replaced by it, so the mismatch is + refused here rather than discovered at the address. + """ + kept = [route for route in routes if route.name != name] + if len(kept) == len(routes): + raise ImproperlyConfigured(f"The router registers no route named {name!r} to replace.") + return kept + + +class SnakeCaseRouter(SimpleRouter): + """ + A router that names its routes ``_``. + + The names a route is registered under are the ones ``resolve()`` reports + and the ones ``reverse()`` accepts, so they are generated in the published + form rather than rewritten afterwards. + """ + + routes = [route._replace(name=route.name.replace("-", "_")) for route in SimpleRouter.routes] + + +# An address beneath an account is plural where it names rows that accumulate +# behind it, and singular where it names the one thing every account has. +router = SnakeCaseRouter() +router.register(r"accounts", AccountViewSet, basename="account") +# An identity check belongs to the user who went through it and is reached by +# no other route, so the collection is registered beneath the account it +# belongs to. +router.register( + rf"accounts/(?P{settings.USERNAME_REGEX_PARTIAL})/verifications", + IDVerificationViewSet, + basename="account_verification", +) +# What follows the account collection in an address is the name of an account, +# and a user may register any name, so the search over email addresses stands +# beside the accounts rather than under them. +router.register(r"email_searches", EmailSearchViewSet, basename="email_search") +# What a user wants to be sent is recorded against that user and against the +# organization running a course they named, so the choice is reached without +# naming either in the address and stands beside the accounts rather than +# under them. +router.register(r"email_opt_ins", EmailOptInViewSet, basename="email_opt_in") +router.register(r"name_changes", NameChangeViewSet, basename="name_change") +router.register(r"users", UserViewSet, basename="user") +router.register(r"user_preferences", UserPreferenceViewSet, basename="user_preference") +router.register(r"time_zones", CountryTimeZoneViewSet, basename="time_zone") +# A retirement belongs to one account but is listed, worked and disposed of +# across accounts, so it stands beside them rather than under one of them; the +# name the account was registered under identifies it. +router.register(r"retirements", RetirementViewSet, basename="retirement") +# Disposing of the records a finished retirement leaves behind is one +# operation over a set of them, with no member address of its own, so it +# stands beside the collections rather than under one: a fixed word directly +# after a collection would occupy the place a member's name goes, and a user +# may register any name. +router.register(r"retirement_cleanups", RetirementCleanupViewSet, basename="retirement_cleanup") +# What is owed to the organizations an account studied with outlives the +# account, so it is recorded on its own and reached without naming one. +router.register( + r"retirement_partner_reports", + RetirementPartnerReportViewSet, + basename="retirement_partner_report", +) +# The entries that have been reported on are disposed of in the same way, and +# at a collection of their own for the same reason. +router.register( + r"retirement_partner_report_cleanups", + RetirementPartnerReportCleanupViewSet, + basename="retirement_partner_report_cleanup", +) + + +urlpatterns = [ + *without_route(router.urls, REPLACED_ROUTER_ROUTE), + # 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", + ), + # One account's preferences are one document, read and changed whole, and + # each of them is also reached on its own; a preference is stored under its + # owner and its name together, so neither address can be built without the + # account. The two are declared rather than generated because the document + # is changed with a method the generated collection route does not carry. + re_path( + rf"^accounts/(?P{settings.USERNAME_REGEX_PARTIAL})/preferences/$", + AccountPreferenceViewSet.as_view({"get": "retrieve_all", "patch": "partial_update_all"}), + name="account_preference_list", + ), + # The name a preference is stored under takes the rest of the segment, so + # every name that carries no separator of its own can be addressed. + re_path( + rf"^accounts/(?P{settings.USERNAME_REGEX_PARTIAL})/preferences/" + rf"(?P{PREFERENCE_KEY_PATTERN_PARTIAL})/$", + AccountPreferenceViewSet.as_view( + {"get": "retrieve", "put": "update", "delete": "destroy"}, + ), + name="account_preference_detail", + ), + # The username half carries the pattern a username is allowed to match and + # the key half takes the rest of the segment. + re_path( + rf"^user_preferences/(?P{settings.USERNAME_REGEX_PARTIAL})" + rf",(?P{PREFERENCE_KEY_PATTERN_PARTIAL})/$", + UserPreferenceViewSet.as_view({"get": "retrieve"}), + name="user_preference_detail", + ), + # Registering an account, checking what it would be registered with, + # signing in and asking for a password reset are each answered by a + # subclass of the authentication app's own view that adds only its + # description, so a request runs that view's one code path, with its + # decorators, its CSRF handling and any limit it counts together with the + # app's older addresses. Each is a collection that is only ever posted to + # or described: registering and signing in answer with a session and where + # to go next rather than with the account, which is read at + # accounts/{username}/ like any other. Signing in answers as the version + # this API is at, which the view reads from its route. + re_path(r"^registrations/$", authn_views.RegistrationView.as_view(), name="registration_list"), + re_path( + r"^registration_validations/$", + authn_views.RegistrationValidationView.as_view(), + name="registration_validation_list", + ), + re_path( + r"^login_sessions/$", + authn_views.LoginSessionView.as_view(), + {"api_version": "v2"}, + name="login_session_list", + ), + re_path(r"^password_resets/$", authn_views.PasswordResetView.as_view(), name="password_reset_list"), + # The token is a secret, so it is sent in the body rather than in the + # address, where it would be written to every log the request passes. + re_path( + r"^password_reset_token_validations/$", + authn_views.PasswordResetTokenValidation.as_view(), + name="password_reset_token_validation_list", + ), + # Everything under this prefix that the authentication app does not serve + # belongs to this version, including an address no route of it answers, + # which is refused with the JSON error body most views of this version + # answer with. The route answers rather than reverses, so it carries no + # name. + # + # This claims the whole prefix: it matches every address under it that the + # routes above and the addresses named in OTHER_APP_ADDRESSES do not. The + # service mounts it after the patterns contributed by installed plugins, + # so an address a plugin registers under this prefix is answered by that + # plugin, and only an address nothing serves reaches this fallback. + re_path(rf"^(?!{OTHER_APP_ADDRESSES}$).*$", UnknownAddressView.as_view()), +] diff --git a/openedx/core/djangoapps/user_api/v2/view_services.py b/openedx/core/djangoapps/user_api/v2/view_services.py new file mode 100644 index 000000000000..f789e4b68f52 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/view_services.py @@ -0,0 +1,406 @@ +"""Operations version 2 of the User API performs on the account retirement pipeline.""" + +import logging + +from rest_framework.exceptions import NotFound +from wiki.models import ArticleRevision +from wiki.models.pluginbase import RevisionPluginRevision + +from common.djangoapps.student.models import ( + AccountRecovery, + CourseEnrollmentAllowed, + ManualEnrollmentAudit, + PendingEmailChange, + PendingNameChange, + PendingSecondaryEmailChange, + get_retired_email_by_email, + get_retired_username_by_username, +) +from openedx.core.djangoapps.api_admin.models import ApiAccessRequest +from openedx.core.djangoapps.course_groups.models import UnregisteredLearnerCohortAssignments +from openedx.core.djangoapps.credit.models import CreditRequest, CreditRequirementStatus +from openedx.core.djangoapps.user_api.accounts.signals import ( + USER_RETIRE_LMS_CRITICAL, + USER_RETIRE_LMS_MISC, + USER_RETIRE_MAILINGS, +) +from openedx.core.djangoapps.user_api.accounts.utils import ( + handle_retirement_cancellation, + redact_and_delete_historical_social_auth, +) +from openedx.core.djangoapps.user_api.accounts.views import ( + AccountRetirementPartnerReportView, + AccountRetirementView, + LMSAccountRetirementView, +) +from openedx.core.djangoapps.user_api.models import ( + RetirementStateError, + UserOrgTag, + UserRetirementPartnerReportingStatus, + UserRetirementStatus, +) + +from .errors import AmbiguousAccountName, RetirementStateConflict +from .selectors import partner_reports_requested_as, retirements_requested_as +from .serializers import ( + ERASURE_CORE_SCOPE, + ERASURE_MISC_SCOPE, + OUTCOME_ALREADY_ABSENT, + OUTCOME_DELETED, +) + +log = logging.getLogger(__name__) + +#: The step a retirement stands at for as long as the learner may still change +#: their mind, which is the only step a retirement can be cancelled from. +PENDING_STATE = "PENDING" + +#: The step a retirement stands at once every other step has run, which is the +#: only step its record is deleted from. +COMPLETE_STATE = "COMPLETE" + +#: What is written over a retirement record's copies of the account's name and +#: address before the record is deleted, so that a downstream system treating +#: the delete as a soft one holds the replacement rather than the data. +REDACTED_VALUE = "redacted-before-delete" +REDACTED_EMAIL = "redacted-before-delete@safe.com" + +#: What a caller is told about a failure. The specific reason is logged with +#: the records it names rather than returned, because it is written for whoever +#: operates the pipeline and not for whoever calls it. +NO_SUCH_RETIREMENT = "No retirement was requested under that name." +NOT_OPEN_TO_ACTION = "This retirement does not stand at a step of the pipeline that admits this operation." +CANNOT_MOVE_TO_STEP = "This retirement cannot be moved to that step from the step it stands at." +ONLY_PENDING_IS_CANCELLED = ( + f"Only a retirement still at {PENDING_STATE} can be cancelled, and this one has moved past it." +) +BATCH_LEFT_ALONE = "Some of the records named are not in a state this operation acts on, so none of them was changed." +NAME_IS_AMBIGUOUS = "More than one record was requested under that name, so nothing was done." +SEVERAL_RECORDS_HOLD_THIS_NAME = ( + "More than one record was requested under this name, and acting on one of them would act on an " + "account this request did not name." +) + + +def _only_record_named(username, records): + """ + Return the one record requested under this name, or refuse the name. + + Every operation of this pipeline that names one account resolves it here, + so one name addresses the same record whichever operation is asked for. + The name a record keeps is not unique, so a name several records were + requested under is refused: one of them belongs to an account the caller + did not name, and nothing says which. + """ + if not records: + log.info("No record is requested under the name %r.", username) + raise NotFound(NO_SUCH_RETIREMENT) + if len(records) > 1: + log.warning( + "The name %r was refused: %s records are requested under it, %r.", + username, len(records), sorted(record.id for record in records), + ) + raise AmbiguousAccountName(NAME_IS_AMBIGUOUS) + return records[0] + + +def retirement_requested_as(records, username): + """Return the one record of this selection requested under exactly this name.""" + return _only_record_named( + username, retirements_requested_as(records, [username]).get(username, []), + ) + + +def retirement_open_to_action(records, username): + """ + Return the retirement requested under this name while it admits pipeline work. + + A retirement admits work only between the step that waits for the learner + and the step that records a part of the pipeline as finished, which is what + keeps a step from being run twice over the same account. + """ + retirement = retirement_requested_as(records, username) + try: + # Which steps admit work is decided by the pipeline's own model layer, + # where the order of the steps is kept, rather than being written out a + # second time here. It is asked about the record resolved above rather + # than about the name it was resolved under, so the record this answers + # for is the record the caller is handed to work on. + retirement.check_state_allows_retirement_action() + except RetirementStateError as exc: + log.info("The retirement requested as %r admits no pipeline work: %s", username, exc) + raise RetirementStateConflict(NOT_OPEN_TO_ACTION) from exc + return retirement + + +def move_retirement(retirement, new_state, response, force): + """Record that this retirement now stands at another step of the pipeline.""" + try: + retirement.update_state({ + "username": retirement.original_username, + "new_state": new_state, + "response": response, + "force": force, + }) + except RetirementStateError as exc: + log.info("Retirement %s was not moved to %r: %s", retirement.id, new_state, exc) + raise RetirementStateConflict(CANNOT_MOVE_TO_STEP) from exc + + +def cancel_retirement(retirement): + """ + Undo this retirement request, restoring the account it was made for. + + The record is deleted, which is what also removes the permanent record of + the request, and the account is returned to the address it was registered + under with a password nobody holds, so its owner signs in again by asking + for a new one. + """ + standing_at = retirement.current_state.state_name + if standing_at != PENDING_STATE: + log.info("Retirement %s stands at %s, so it was not cancelled.", retirement.id, standing_at) + raise RetirementStateConflict(ONLY_PENDING_IS_CANCELLED) + handle_retirement_cancellation(retirement) + + +def _erase_core_data(retirement): + """Erase what the platform itself holds about the account being retired.""" + user = retirement.user + # The name the account was registered under, rather than the name it holds + # now: this step replaces the one it holds, so a run of it that follows one + # that got that far would otherwise hash a hash. + retired_username = retirement.retired_username or get_retired_username_by_username( + retirement.original_username, + ) + retired_email = retirement.retired_email or get_retired_email_by_email(user.email) + original_email = retirement.original_email + + AccountRetirementView.clear_pii_from_userprofile(user) + AccountRetirementView.delete_users_profile_images(user) + AccountRetirementView.delete_users_country_cache(user) + AccountRetirementView.clear_pii_from_certificate_records(user) + AccountRetirementView.retire_entitlement_support_detail(user) + + PendingEmailChange.delete_by_user_value(user, field="user") + UserOrgTag.delete_by_user_value(user, field="user") + PendingSecondaryEmailChange.redact_and_delete_pending_secondary_email(user.id) + AccountRecovery.retire_recovery_email(user.id) + + CourseEnrollmentAllowed.delete_by_user_value(original_email, field="email") + UnregisteredLearnerCohortAssignments.delete_by_user_value(original_email, field="email") + + USER_RETIRE_LMS_CRITICAL.send( + sender=AccountRetirementView, + user=user, + retired_username=retired_username, + retired_email=retired_email, + ) + + user.first_name = "" + user.last_name = "" + user.is_active = False + user.username = retired_username + user.email = retired_email + user.save() + + +def _erase_misc_data(retirement): + """Erase what the features built on top of the platform hold about the account being retired.""" + user = retirement.user + RevisionPluginRevision.retire_user(user) + ArticleRevision.retire_user(user) + PendingNameChange.delete_by_user_value(user, field="user") + ManualEnrollmentAudit.retire_manual_enrollments(user, retirement.retired_email) + + CreditRequest.retire_user(retirement) + ApiAccessRequest.retire_user(user) + CreditRequirementStatus.retire_user(retirement) + redact_and_delete_historical_social_auth(user.id) + + USER_RETIRE_LMS_MISC.send(sender=LMSAccountRetirementView, user=user) + USER_RETIRE_MAILINGS.send( + sender=LMSAccountRetirementView, + email=retirement.original_email, + new_email=retirement.retired_email, + user=user, + ) + + +#: What each scope erases. The steps of a scope run in the order they are +#: written, and each scope announces what it has done to whatever else holds +#: data about the account. The sender named is the one the pipeline has always +#: announced these steps under, because a listener may select on it. +_ERASURES = { + ERASURE_CORE_SCOPE: _erase_core_data, + ERASURE_MISC_SCOPE: _erase_misc_data, +} + + +def erase_account_data(retirement, scope): + """ + Erase one set of the retiring account's personal data. + + The steps are not wrapped in a transaction: a part of the pipeline that + fails halfway is recovered by the driver recording the retirement as + errored and running the step again, and a rollback would undo erasures + that had already succeeded. + """ + try: + _ERASURES[scope](retirement) + except Exception: + log.exception("Erasing the %s data of retirement %s failed partway through.", scope, retirement.id) + raise + + +def record_partner_report(retirement): + """Queue this retirement for reporting to the organizations the account studied with.""" + # pylint: disable=protected-access + organizations = AccountRetirementPartnerReportView._get_orgs_for_user(retirement.user) + if not organizations: + return + UserRetirementPartnerReportingStatus.objects.get_or_create( + user=retirement.user, + defaults={ + "original_username": retirement.original_username, + "original_email": retirement.original_email, + "original_name": retirement.original_name, + }, + ) + + +def _partner_report_of(report): + """Return what a partner needs in order to forget the account this queue entry names.""" + # pylint: disable=protected-access + organizations = AccountRetirementPartnerReportView._get_orgs_for_user(report.user) + return { + "user_id": report.user.pk, + "original_username": report.original_username, + AccountRetirementPartnerReportView.ORIGINAL_EMAIL_KEY: report.original_email, + AccountRetirementPartnerReportView.ORIGINAL_NAME_KEY: report.original_name, + "orgs": sorted(organizations), + "created": report.created, + } + + +def claim_partner_reports(waiting, limit=None): + """ + Take the partner reports waiting to be processed, and return them. + + The entries returned are exactly the entries taken: they are read first + and then marked by the identifiers that were read, so an entry added while + the answer is being built is left for the next caller rather than marked as + taken by one that never saw it. Nothing releases an entry once it is + taken. + """ + if limit is not None: + waiting = waiting[:limit] + taken = list(waiting) + reports = [_partner_report_of(report) for report in taken] + UserRetirementPartnerReportingStatus.objects.filter( + id__in=[report.id for report in taken], + ).update(is_being_processed=True) + return reports + + +def _batch_outcomes(usernames, deleted): + """Return what was done about each name given, in the order the names were given.""" + return [ + { + "username": username, + "outcome": OUTCOME_DELETED if username in deleted else OUTCOME_ALREADY_ABSENT, + } + for username in usernames + ] + + +def _refuse_batch(refused): + """Refuse a whole batch, naming the records at fault and why.""" + log.info("A batch was left alone because of %r.", sorted(refused)) + raise RetirementStateConflict(BATCH_LEFT_ALONE, errors=refused) + + +def _one_record_for_each_name(found): + """ + Return the one record each name carries, refusing the batch over any name carrying several. + + A batch is acted on whole or not at all, and a name several records were + requested under names an account the caller did not, so the batch is + refused and nothing is deleted. + """ + ambiguous = { + username: [SEVERAL_RECORDS_HOLD_THIS_NAME] + for username, records in found.items() + if len(records) > 1 + } + if ambiguous: + log.warning("A batch was left alone because these names are ambiguous: %r.", sorted(ambiguous)) + raise AmbiguousAccountName(NAME_IS_AMBIGUOUS, errors=ambiguous) + return {username: records[0] for username, records in found.items()} + + +def delete_retirements(records, usernames): + """ + Delete the retirement records of the accounts named, once the pipeline has finished with them. + + The records are resolved from the selection given, so a record outside it + is reported as absent and left alone. A name no record was requested under + is reported as such rather than refused, so a caller repeating a request + that has already been carried out is answered rather than left to tell + "already done" from "never existed". + A record the pipeline has not finished with refuses the whole batch and + nothing is deleted, because the caller asked for a set of records and a + half-applied delete cannot be undone. + """ + found = _one_record_for_each_name(retirements_requested_as(records, usernames)) + refused = { + username: [ + f"This retirement stands at {record.current_state.state_name}; only a retirement at " + f"{COMPLETE_STATE} is deleted here.", + ] + for username, record in found.items() + if record.current_state.state_name != COMPLETE_STATE + } + if refused: + _refuse_batch(refused) + doomed = [record.id for record in found.values()] + if doomed: + # The record's own copies of the name, the address and the full name are + # written over before it goes, so that a system syncing these rows and + # treating a delete as a soft one is left holding the replacement. + UserRetirementStatus.objects.filter(id__in=doomed).update( + original_username=REDACTED_VALUE, + original_email=REDACTED_EMAIL, + original_name=REDACTED_VALUE, + ) + # The step is stated again on the delete, because the write above has + # left the records with nothing of the account on them and a record + # moved off that step between the two statements is one the caller was + # never answered about. + UserRetirementStatus.objects.filter( + id__in=doomed, current_state__state_name=COMPLETE_STATE, + ).delete() + return _batch_outcomes(usernames, found) + + +def delete_partner_reports(entries, usernames): + """ + Delete the partner report entries of the accounts named, once they have been reported on. + + The entries are resolved from the selection given, so an entry outside it + is reported as absent and left alone. An entry nobody has taken for + processing has not been reported on yet, so it refuses the whole batch and + nothing is deleted; a name with no entry at all is reported as such, which + is what a repeat of a request already carried out reports for every name. + """ + found = _one_record_for_each_name(partner_reports_requested_as(entries, usernames)) + refused = { + username: ["This partner report has not been taken for processing, so it has not been reported on yet."] + for username, report in found.items() + if not report.is_being_processed + } + if refused: + _refuse_batch(refused) + doomed = [report.id for report in found.values()] + if doomed: + UserRetirementPartnerReportingStatus.objects.filter(id__in=doomed).delete() + return _batch_outcomes(usernames, found) diff --git a/openedx/core/djangoapps/user_api/v2/view_utils.py b/openedx/core/djangoapps/user_api/v2/view_utils.py new file mode 100644 index 000000000000..30bb6c833cf6 --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/view_utils.py @@ -0,0 +1,116 @@ +"""View behaviour shared by the endpoints of version 2 of the User API.""" + +import logging + +from django.conf import settings +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse +from rest_framework.exceptions import ValidationError +from rest_framework.settings import api_settings + +from .serializers import ErrorResponse + +log = logging.getLogger(__name__) + +#: Groups the user addresses of every version under one heading in the schema. +USER_TAG = "user" + +#: Marks the operations a generated client library is built from. +SDK_TAG = "openedx-platform-sdk" + +#: The framework's own representation selector. This deployment reads nothing +#: from it, since ``URL_FORMAT_OVERRIDE`` is unset and JSON is the only +#: renderer, and a caller that sends it anyway is answered rather than refused. +FORMAT_PARAM = "format" + +#: The whole of a username, as every route of this version matches one. A +#: client built from the published document holds a name to this shape before +#: it sends a request, so each address that carries a name is described with +#: it and no address of one resource admits a name another refuses. +USERNAME_PATTERN = rf"^{settings.USERNAME_REGEX_PARTIAL}$" + + +def query_param(name, description, **kwargs): + """Build an optional query parameter for the published schema.""" + return OpenApiParameter( + name=name, + description=description, + required=False, + location=OpenApiParameter.QUERY, + **kwargs, + ) + + +def path_param(name, description, pattern): + """Build a path parameter for the published schema, carrying what its route matches.""" + return OpenApiParameter( + name=name, + description=description, + required=True, + location=OpenApiParameter.PATH, + type=str, + pattern=pattern, + ) + + +def names_in(value): + """ + Return the names a comma-separated value carries, in the order they were sent. + + A position carrying nothing names nothing, so an empty one is left out + rather than read as a name no account holds. Every collection of this + version that selects rows by name reads its value through here, so the + same value selects the same rows wherever it is sent. + """ + return [name for name in value.strip(",").split(",") if name] + + +RESP_UNAUTHENTICATED = OpenApiResponse(ErrorResponse, description="The requester is not authenticated.") +RESP_UNREADABLE_BODY = OpenApiResponse( + ErrorResponse, description="The request body is in a media type this endpoint cannot read.", +) + + +class StrictQueryParametersMixin: + """ + Refuses a query parameter the view reads nothing from. + + An unknown parameter is otherwise dropped in silence: a caller that + mistyped a filter, or kept one an earlier version of the endpoint + answered, receives the whole collection and reads it as the narrowed page + it asked for. The names a view answers to are assembled from what it + declares (its paginator, its sort backend, its filter set, and the + serializer that validates its query string), so a parameter that is added + or renamed cannot fall out of step with this check. + """ + + #: Names the view reads that none of the declarations below account for. + extra_query_params = () + + def known_query_params(self): + """Return the names of the query parameters this view reads.""" + names = {FORMAT_PARAM, api_settings.URL_FORMAT_OVERRIDE, *self.extra_query_params} + pagination_class = getattr(self, "pagination_class", None) + if pagination_class is not None: + names.update({pagination_class.page_query_param, pagination_class.page_size_query_param}) + for backend in getattr(self, "filter_backends", ()): + names.add(getattr(backend, "ordering_param", None)) + filterset_class = getattr(self, "filterset_class", None) + if filterset_class is not None: + names.update(filterset_class.base_filters) + query_serializer_class = getattr(self, "query_serializer_class", None) + if query_serializer_class is not None: + names.update(query_serializer_class().fields) + return names - {None} + + def initial(self, request, *args, **kwargs): + """Check the query string once the caller has been admitted.""" + super().initial(request, *args, **kwargs) + known = self.known_query_params() + unknown = sorted(set(request.query_params) - known) + if unknown: + log.info("Query refused on %s: %r", type(self).__name__, unknown) + offered = ", ".join(sorted(known)) + raise ValidationError({ + name: [f"No such query parameter. Send any of: {offered}."] + for name in unknown + }) diff --git a/openedx/core/djangoapps/user_api/v2/views.py b/openedx/core/djangoapps/user_api/v2/views.py new file mode 100644 index 000000000000..cc5f0c6c286f --- /dev/null +++ b/openedx/core/djangoapps/user_api/v2/views.py @@ -0,0 +1,3085 @@ +"""Views for version 2 of the User API.""" + +import contextlib +import logging + +from django.conf import settings +from django.contrib.auth.models import User # pylint: disable=imported-auth-user +from django.db import transaction +from django.db.models import F, Prefetch +from django.http import Http404 +from django_filters.rest_framework import DjangoFilterBackend +from django_ratelimit.core import is_ratelimited +from drf_spectacular.utils import OpenApiExample, OpenApiResponse, extend_schema, extend_schema_view +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser +from edx_rest_framework_extensions.mixins import StandardizedErrorMixin +from edx_rest_framework_extensions.paginators import IterablePaginationMixin +from edx_rest_framework_extensions.permissions import IsStaff +from edx_rest_framework_extensions.scoping import FullScopePolicy, ScopedQuerysetMixin +from edx_rest_framework_extensions.shaping import MinimalViewMixin, project +from rest_framework import permissions, status, viewsets +from rest_framework.decorators import action +from rest_framework.exceptions import APIException, NotFound, PermissionDenied, Throttled, ValidationError +from rest_framework.metadata import SimpleMetadata +from rest_framework.parsers import JSONParser +from rest_framework.response import Response +from rest_framework.reverse import reverse +from rest_framework.settings import api_settings +from rest_framework.views import APIView + +from common.djangoapps.student.models import UserProfile, is_email_retired +from common.djangoapps.student.models_api import confirm_name_change, do_name_change_request, get_pending_name_change +from common.djangoapps.track import segment +from lms.djangoapps.verify_student.services import IDVerificationService +from openedx.core.djangoapps.user_api import accounts +from openedx.core.djangoapps.user_api.accounts.api import get_account_settings, update_account_settings +from openedx.core.djangoapps.user_api.accounts.permissions import ( + CanCancelUserRetirement, + CanDeactivateUser, + CanGetAccountInfo, + CanRetireUser, +) +from openedx.core.djangoapps.user_api.errors import ( + AccountUpdateError, + AccountValidationError, + PreferenceUpdateError, + PreferenceValidationError, + UserAPIInternalError, + UserNotAuthorized, + UserNotFound, +) +from openedx.core.djangoapps.user_api.models import UserPreference +from openedx.core.djangoapps.user_api.preferences.api import ( + delete_user_preference, + get_country_time_zones, + get_user_preference, + get_user_preferences, + set_user_preference, + update_email_opt_in, + update_user_preferences, +) +from openedx.core.lib.api.parsers import MergePatchParser + +from .errors import AmbiguousAccountName, CatalogedRequestErrorsMixin, ErrorExtensionsMixin, RetiredAccount +from .filters import RetirementFilterSet, StrictOrderingFilter, UserFilterSet, UserPreferenceFilterSet +from .paginators import UserApiPagination +from .permissions import CanSelectAccountsByContact, IsActiveUser, IsAddressedUser, IsStaffOrAbsent +from .schema import ResponseShapes +from .selectors import ( + accounts_holding_emails, + accounts_named, + cancellable_retirement_for_email, + partner_reports, + retirement_of_the_account_named, + retirements, + unclaimed_partner_reports, + username_holding_id, + usernames_among, + usernames_holding_email, +) +from .serializers import ( + ACCOUNT_DEFAULT_SORT, + ACCOUNT_MINIMAL_FIELDS, + ACCOUNT_PREFERENCE_CHANGES_SCHEMA, + ACCOUNT_PREFERENCE_DOCUMENT, + ACCOUNT_PREFERENCES_SCHEMA, + EMAIL_SEARCH_MAX_ADDRESSES, + EMBEDDABLE_OWNER_IDS, + FULL_VIEW, + MINIMAL_VIEW, + PREFERENCE_MINIMAL_FIELDS, + PREFERENCE_NAME_MAX_LENGTH, + RETIREMENT_MINIMAL_FIELDS, + SHARED_VIEW, + USER_MINIMAL_FIELDS, + VERIFICATION_DEFAULT_SORT, + AccountMinimalSerializer, + AccountPreferenceSerializer, + AccountPreferencesUpdateSerializer, + AccountPreferenceValueSerializer, + AccountProjectionSerializer, + AccountQuerySerializer, + AccountSerializer, + AccountUpdateSerializer, + EmailOptInSerializer, + EmailSearchResultSerializer, + EmailSearchSerializer, + ErrorResponse, + NameChangeSerializer, + RetiredAccountErrorResponse, + RetirementBatchOutcomeSerializer, + RetirementBatchSerializer, + RetirementErasureSerializer, + RetirementMinimalSerializer, + RetirementPartnerReportQuerySerializer, + RetirementPartnerReportRecordSerializer, + RetirementProjectionSerializer, + RetirementSerializer, + RetirementUpdateSerializer, + TimeZoneQuerySerializer, + TimeZoneSerializer, + UserDirectoryMinimalSerializer, + UserDirectoryProjectionSerializer, + UserDirectorySerializer, + UserPreferenceRecordMinimalSerializer, + UserPreferenceRecordProjectionSerializer, + UserPreferenceRecordSerializer, + VerificationQuerySerializer, + VerificationSerializer, + VerificationStatusSerializer, + full_view_requested, + why_a_name_cannot_be_stored, +) +from .view_services import ( + NO_SUCH_RETIREMENT, + cancel_retirement, + claim_partner_reports, + delete_partner_reports, + delete_retirements, + erase_account_data, + move_retirement, + record_partner_report, + retirement_open_to_action, + retirement_requested_as, +) +from .view_utils import ( + RESP_UNAUTHENTICATED, + RESP_UNREADABLE_BODY, + SDK_TAG, + USER_TAG, + USERNAME_PATTERN, + StrictQueryParametersMixin, + names_in, + path_param, + query_param, +) + +log = logging.getLogger(__name__) + +#: Fields the user directory sorts on, and the order it uses when asked for none. +USER_SORT_FIELDS = ("username", "date_joined") +USER_DEFAULT_SORT = ("username",) + +#: Fields the stored-preference list sorts on, and the order it uses when asked for none. +PREFERENCE_SORT_FIELDS = ("key", "username") +PREFERENCE_DEFAULT_SORT = ("username", "key") + +#: The part of a preference's address that carries its key. The key is +#: free-form text that may itself contain a comma, so it takes the rest of the +#: segment; the route and the published address are built from this one pattern. +PREFERENCE_KEY_PATTERN_PARTIAL = "[^/]+" + +#: What the identity verification service reports for a user whose proof of +#: identity has been accepted. +APPROVED_STATUS = "approved" + +_USER_PATH_PARAM = path_param( + "username", + "Unique name of the user whose directory entry is returned.", + USERNAME_PATTERN, +) +_PREFERENCE_USERNAME_PATH_PARAM = path_param( + "username", + "Username of the user the preference belongs to: the part of the address before the comma.", + USERNAME_PATTERN, +) +_PREFERENCE_KEY_PATH_PARAM = path_param( + "key", + "Name the preference is stored under: the part of the address after the comma.", + rf"^{PREFERENCE_KEY_PATTERN_PARTIAL}$", +) + +#: The presets an account response answers to. +ACCOUNT_PRESETS = (MINIMAL_VIEW, SHARED_VIEW) + +#: The member of the account collection that addresses the caller's own account. +#: It belongs to that collection, and the addresses beneath one account do not +#: translate it: each of those reads the name it is given as the name of a +#: stored account, so a request sent to one of them under this name works on +#: the account registered under it, whichever account that is. The preference +#: document of an account and the deactivation of one both publish that in the +#: description of the name they carry; it is written here as well because an +#: address added beside them inherits the rule and not the sentence. +OWN_ACCOUNT_IDENTIFIER = "me" + +#: What a caller is told about a member address that more than one account +#: answers to. One account is addressed there, so an address several of them +#: answer to names none of them in particular. +AMBIGUOUS_ACCOUNT_ADDRESS = "More than one account answers to this address. Name one of them as it is stored." + +#: The account actions that answer with an account, and so read the parameters +#: that shape one. +SHAPED_ACCOUNT_ACTIONS = ("list", "retrieve", "partial_update") + +#: The properties whose change is limited to a rate, and the setting carrying +#: the limit of each. Both settings are empty until a deployment gives one of +#: them a value, and an empty limit limits nothing, so a site that has +#: configured neither takes every change to either property. +RATE_LIMITED_ACCOUNT_FIELDS = ( + ("email", "EMAIL_CHANGE_RATE_LIMIT"), + ("secondary_email", "SECONDARY_EMAIL_RATE_LIMIT"), +) + +#: Parameters that shape a response rather than select the rows in it. +VIEW_PARAM = "view" +FIELDS_PARAM = "fields" +RESPONSE_SHAPING_PARAMS = (VIEW_PARAM, FIELDS_PARAM) + +_FIELDS_PARAM = query_param( + FIELDS_PARAM, + "Comma-separated list of top-level field names to keep in each returned object. " + "A name the response cannot carry is refused. Selection reaches the top level only: " + "a name written as a path, such as `user.email`, names no field a response carries " + "and is refused with the names that can be selected.", +) +#: The presets each list answers to, published as the parameter's enum and +#: accepted by the view from the same declaration. +USER_PRESETS = (MINIMAL_VIEW,) +PREFERENCE_PRESETS = (FULL_VIEW, MINIMAL_VIEW) + +_MINIMAL_VIEW_PARAM = query_param( + VIEW_PARAM, + "Response preset. `minimal` reduces each user to its identifying fields " + "(`id`, `username`, `email`). Omit it to receive the full entry.", + enum=list(USER_PRESETS), +) +_PREFERENCE_VIEW_PARAM = query_param( + VIEW_PARAM, + "Response preset. `full` embeds the owning user's whole directory entry in each " + "preference; `minimal` returns only `username`, `key` and `value`. Omit it to " + "receive the owner's username alongside the preference.", + enum=list(PREFERENCE_PRESETS), +) + + +class AddressMetadata(SimpleMetadata): + """ + Describes an address by what it reads, rather than by what its view declares. + + The standard description is built from the parsers and the serializer + declared on the view, which belong to the view as a whole rather than to + the address being asked about: an address that reads one media type is + otherwise described as reading every media type the platform reads + anywhere, a method whose handler reads no body is described as taking the + one the view's serializer carries, and a method whose body differs from + what the view answers with is described as taking the answer. A caller + that negotiates from this description sends what it was told to send, so + each part of it is answered from the address and the method being + described. + """ + + def determine_metadata(self, request, view): + """Describe the address, naming the media types the bodies it takes are read in.""" + description = super().determine_metadata(request, view) + description["parses"] = [parser.media_type for parser in view.get_parsers()] + return description + + def determine_actions(self, request, view): + """Describe the body of each method that takes one, and leave out the methods that take none.""" + described = super().determine_actions(request, view) + served = getattr(view, "action_map", None) or {} + bodiless = set(getattr(view, "bodiless_actions", ())) + taken = getattr(view, "request_serializer_class", None) + if taken is not None: + described = {method: self.get_serializer_info(taken()) for method in described} + return { + method: fields for method, fields in described.items() + if served.get(method.lower()) not in bodiless + } + + +def _ordering_param(sort_fields, default): + """Build the sort parameter of a list, naming the fields it accepts.""" + return query_param( + "ordering", + "Fields to sort by, in order of precedence, each optionally prefixed with `-` to sort " + "descending. A field that is not listed here is refused.", + many=True, + enum=[*sort_fields, *(f"-{field}" for field in sort_fields)], + default=list(default), + style="form", + explode=False, + ) + + +_USER_ORDERING_PARAM = _ordering_param(USER_SORT_FIELDS, USER_DEFAULT_SORT) +_PREFERENCE_ORDERING_PARAM = _ordering_param(PREFERENCE_SORT_FIELDS, PREFERENCE_DEFAULT_SORT) + +_RESP_FORBIDDEN = OpenApiResponse(ErrorResponse, description="The requester may not use this endpoint.") +#: Published by every operation of this version. Which representations an +#: answer can be given in is settled as the request enters, before the address +#: it names is read or written, so an ``Accept`` header naming none of them is +#: refused at every address whether or not that address answers with a body. +_RESP_SESSION_WITHOUT_ITS_TOKEN = OpenApiResponse( + ErrorResponse, + description=( + "The request was made with a session cookie and carried none of the request token the " + "site hands out with it, so the site cannot tell that the request came from a page it " + "served. Sending that token with the session, or a signed token in place of the " + "session, is answered." + ), +) +RESP_NOT_ACCEPTABLE = OpenApiResponse( + ErrorResponse, + description=( + "The `Accept` header of the request names no representation this endpoint can answer in." + ), +) +_RESP_NOT_FOUND = OpenApiResponse(ErrorResponse, description="No such resource.") +_RESP_NO_SUCH_PAGE = OpenApiResponse(ErrorResponse, description="There is no such page of results.") +_RESP_BAD_REQUEST = OpenApiResponse( + ErrorResponse, description="A query parameter is missing, invalid, or not one this endpoint reads.", +) +_RESP_UNUSABLE_BODY = OpenApiResponse( + ErrorResponse, + description=( + "The request body is not well formed in the media type it was sent in, or carries a " + "value this endpoint will not take. A query parameter this endpoint does not read is " + "refused here too." + ), +) +_RESP_RATE_LIMITED = OpenApiResponse( + ErrorResponse, + description=( + "The caller has changed this property too often lately and has to wait. Only a site that " + "has given a limit for the property answers this; a site that has given none, which is " + "how the platform ships, takes every change." + ), +) +_RESP_CHANGE_FAILED = OpenApiResponse( + ErrorResponse, + description=( + "The change was asked for correctly and could not be carried out. Sending it again " + "with something else in it does not help; sending the same request later may." + ), +) +_RESP_NO_SUCH_ACCOUNT = OpenApiResponse( + ErrorResponse, description="No account is held at the address given.", +) +_RESP_CHANGED_ACCOUNT_GONE = OpenApiResponse( + ErrorResponse, + description=( + "The account that was addressed is no longer there. Only the caller's own account is " + "changed here, so a name nobody holds is refused as somebody else's account instead of " + "being reported as missing." + ), +) +_RESP_NO_SUCH_PAGE_OR_RETIRED = OpenApiResponse( + RetiredAccountErrorResponse, + description=( + "There is no such page of results, or the `email` address given is one an account held " + "before it was retired. In that second case the body also carries `can_cancel_retirement` " + "and `retirement_id`, which name what is left of that account to a caller able to reverse " + "the retirement." + ), +) + +#: A directory entry comes back whole, as the identifying fields the ``minimal`` +#: preset keeps, or as the properties ``fields`` selected. +_USER_SHAPES = ResponseShapes( + component_name="UserDirectoryResponse", + serializers=[ + UserDirectorySerializer, + UserDirectoryMinimalSerializer, + UserDirectoryProjectionSerializer, + ], +) + +_ACCOUNT_PATH_PARAM = path_param( + "username", + "Unique name of the user whose account is addressed. " + f"`{OWN_ACCOUNT_IDENTIFIER}` names the account of the caller, so an account holding that " + "name is read through the collection, with `username=` naming it.", + USERNAME_PATTERN, +) +_DEACTIVATION_PATH_PARAM = path_param( + "username", + "Unique name of the user whose account is deactivated. The name is read as it is stored, " + f"`{OWN_ACCOUNT_IDENTIFIER}` included: this action disables the account the address names, " + "never the account of the caller making the request.", + USERNAME_PATTERN, +) +_ACCOUNT_VIEW_PARAM = query_param( + VIEW_PARAM, + "Response preset. `minimal` reduces the account to the properties that identify its owner; " + "`shared` returns only the properties the owner shares with everybody, even to a caller who " + "may see more. Omit it, or send it empty, to receive everything the caller may see.", + enum=[*ACCOUNT_PRESETS, ""], +) + +#: An account comes back with every property the caller may see, as the +#: identifying properties the ``minimal`` preset keeps, or as the properties +#: ``fields`` selected. +_ACCOUNT_SHAPES = ResponseShapes( + component_name="AccountResponse", + serializers=[ + AccountSerializer, + AccountMinimalSerializer, + AccountProjectionSerializer, + ], +) + +_ACCOUNT_IMAGE_EXAMPLE = { + "has_image": True, + "image_url_full": "https://example.com/media/profile_images/jane_doe_500.jpg", + "image_url_large": "https://example.com/media/profile_images/jane_doe_120.jpg", + "image_url_medium": "https://example.com/media/profile_images/jane_doe_50.jpg", + "image_url_small": "https://example.com/media/profile_images/jane_doe_30.jpg", +} + +_ACCOUNT_EXAMPLES = [ + OpenApiExample( + "Your own account", + description=( + "Everything an account carries, which is what its owner and a staff caller receive." + ), + value={ + "username": "jane_doe", + "profile_image": _ACCOUNT_IMAGE_EXAMPLE, + "account_privacy": "custom", + "email": "jane.doe@example.com", + "id": 42, + "name": "Jane Doe", + "bio": "Teaches mathematics.", + "country": "US", + "state": "MA", + "date_joined": "2024-03-01T09:30:00Z", + "last_login": "2026-02-11T18:02:41Z", + "is_active": True, + "gender": "f", + "year_of_birth": 1985, + "level_of_education": "m", + "goals": "Finish the course.", + "mailing_address": "12 Park Avenue", + "phone_number": "+18005555555", + "language_proficiencies": [{"code": "fr"}], + "social_links": [ + {"platform": "twitter", "social_link": "https://www.twitter.com/example"}, + ], + "extended_profile": [{"field_name": "occupation", "field_value": "Teacher"}], + "requires_parental_consent": False, + "pending_name_change": None, + "verified_name": "Jane Doe", + "secondary_email": None, + "secondary_email_enabled": None, + "course_certificates": None, + "time_zone": None, + }, + response_only=True, + status_codes=["200"], + ), + OpenApiExample( + "An account somebody else owns", + description=( + "The properties this owner shares with every learner. An owner who shares field by " + "field shares fewer, and one who shares nothing leaves only the three properties " + "every account carries." + ), + value={ + "username": "jane_doe", + "profile_image": _ACCOUNT_IMAGE_EXAMPLE, + "account_privacy": "all_users", + "bio": "Teaches mathematics.", + "country": "US", + "course_certificates": None, + "date_joined": "2024-03-01T09:30:00Z", + "language_proficiencies": [{"code": "fr"}], + "level_of_education": "m", + "social_links": [ + {"platform": "twitter", "social_link": "https://www.twitter.com/example"}, + ], + "time_zone": None, + }, + response_only=True, + status_codes=["200"], + ), + OpenApiExample( + "An account reduced to what identifies its owner", + description="What `view=minimal` answers with; `fields` selects any other set.", + value={ + "username": "jane_doe", + "email": "jane.doe@example.com", + "name": "Jane Doe", + "profile_image": _ACCOUNT_IMAGE_EXAMPLE, + }, + response_only=True, + status_codes=["200"], + ), +] + +#: A stored preference comes back whole, as the three fields the ``minimal`` +#: preset keeps, or as the properties ``fields`` selected. +_PREFERENCE_SHAPES = ResponseShapes( + component_name="UserPreferenceRecordResponse", + serializers=[ + UserPreferenceRecordSerializer, + UserPreferenceRecordMinimalSerializer, + UserPreferenceRecordProjectionSerializer, + ], +) + + +def _the_one_addressed(username, records, name_of): + """ + Return the one of these records the address names, or refuse the address. + + A name can be answered with more than one record, in which case the record + stored under exactly the name given is the one addressed. Where none of + them is stored under it the address names no one of them, so it is refused + rather than worked on: which record came back first is no basis for + reading or changing an account. + """ + stored_under_it = [record for record in records if name_of(record) == username] + if stored_under_it: + return stored_under_it[0] + if len(records) > 1: + raise AmbiguousAccountName({api_settings.NON_FIELD_ERRORS_KEY: [AMBIGUOUS_ACCOUNT_ADDRESS]}) + if not records: + raise NotFound + return records[0] + + +def _user_named(username): + """Return the user an address names, or refuse the address.""" + return _the_one_addressed(username, accounts_named(username), lambda user: user.username) + + +class _MemberLookupMixin: + """Report a missing member through the standard error envelope.""" + + def get_object(self): + try: + return super().get_object() + except Http404 as exc: + raise NotFound from exc + + +class _ActionAddressMixin: + """ + Builds the address of an action from the snake_case names these routes carry. + + A router names an action ``-`` by default, and that name + belongs to an earlier mount of the same resource, so the default lookup + would hand out the older address instead of this one. + """ + + #: The namespace these routes are mounted under, read when an address is + #: asked for outside a request and there is no resolver match to take it from. + url_namespace = "user_v2" + + def reverse_action(self, url_name, *args, **kwargs): + """Return the address of one of this viewset's actions.""" + request = getattr(self, "request", None) + namespace = self.url_namespace + if request is not None and request.resolver_match is not None: + namespace = request.resolver_match.namespace or namespace + kwargs.setdefault("request", request) + return reverse(f"{namespace}:{self.basename}_{url_name}", *args, **kwargs) + + +def _requested_field_names(raw): + """ + Return the property names a ``fields`` value asks for, each as it was written. + + A name is held against the properties the answer carries whole, so a name + written with a dot in it names no such property and is refused. Reading + only the part before the dot would hand back the whole of a property the + caller asked one piece of, and would take a part after it that names + nothing at all without saying so. + """ + if not raw: + return [] + return [name.strip() for name in raw.split(",") if name.strip()] + + +class _ResponsePresetMixin(MinimalViewMixin): + """ + Applies the ``view`` and ``fields`` presets to an already serialized payload. + + A view lists the presets it answers to, and a ``view`` value outside that + list is refused: a caller that mistypes a preset asked for a smaller + payload and would otherwise be handed the full one without being told. + The same holds for a field name the response cannot carry, which would + otherwise select nothing and leave the caller with empty objects. + + Both are settled as the request enters, before the address it names is + read or written. Which properties an answer can carry depends on the + caller and on the address, so the refusal comes back the same at a name + nobody holds as at one somebody does, and nothing is read or changed on + the way to it. + """ + + #: Presets this view answers to. An empty ``view`` narrows nothing, the way + #: an empty value of any other parameter does. + response_presets = () + + def initial(self, request, *args, **kwargs): + """Check the requested preset and fields once the caller has been admitted, before anything else.""" + super().initial(request, *args, **kwargs) + preset = request.query_params.get(VIEW_PARAM) + if preset and preset not in self.response_presets: + raise ValidationError({ + VIEW_PARAM: [ + f"Ask for any of: {', '.join(self.response_presets)}, " + "or omit this parameter for the default response.", + ], + }) + self.check_requested_fields(request) + + def shapeable_fields(self): + """Return the names of the properties a response of this view can carry.""" + if self.is_minimal_view_requested(): + return set(self.minimal_fields) + return set(self.get_serializer_class()().fields) + + #: What a caller is told in place of the names, at a view that may withhold + #: them. A view that narrows what a response carries by who is asking says + #: here what it tells a caller it has narrowed the answer for. + withheld_fields_message = "" + + def shapeable_fields_may_be_named(self): + """Whether a refusal may list the properties a response of this view can carry.""" + return True + + def check_requested_fields(self, request): + """Refuse a selected field the response cannot carry, naming the ones it can.""" + available = self.shapeable_fields() + unknown = sorted(set(_requested_field_names(request.query_params.get(FIELDS_PARAM))) - available) + if unknown: + if self.shapeable_fields_may_be_named(): + rest = f"Select any of: {', '.join(sorted(available))}." + else: + rest = self.withheld_fields_message + raise ValidationError({ + FIELDS_PARAM: [f"No such field: {', '.join(unknown)}. {rest}"], + }) + + def shape(self, data, request): + """Return ``data`` reduced to the preset and the fields the caller asked for.""" + data = self.shape_minimal(data, request) + requested_fields = _requested_field_names(request.query_params.get(FIELDS_PARAM)) + if not requested_fields: + return data + if isinstance(data, list): + return [project(item, requested_fields) for item in data] + return project(data, requested_fields) + + def shape_page(self, response, request): + """Shape the items of a paginated response in place.""" + response.data["results"] = self.shape(response.data["results"], request) + return response + + +@extend_schema(tags=[USER_TAG]) +@extend_schema_view( + list=extend_schema( + summary="List users", + description=( + "Returns a page of the user directory. Supply `preference_key` to keep only the " + "users who have stored a preference under that key, or `forum_role` together with " + "`course_key` to keep only the holders of a course forum role." + ), + parameters=[_MINIMAL_VIEW_PARAM, _FIELDS_PARAM, _USER_ORDERING_PARAM], + responses={ + 200: _USER_SHAPES, + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NO_SUCH_PAGE, + 406: RESP_NOT_ACCEPTABLE, + }, + ), + retrieve=extend_schema( + summary="Get one user", + description="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, + 406: RESP_NOT_ACCEPTABLE, + }, + ), +) +class UserViewSet( + StandardizedErrorMixin, + CatalogedRequestErrorsMixin, + _ActionAddressMixin, + _MemberLookupMixin, + _ResponsePresetMixin, + StrictQueryParametersMixin, + ScopedQuerysetMixin, + viewsets.ReadOnlyModelViewSet, +): + """ + Directory entries of the platform's users, each addressed by username. + + An entry carries the identifying properties of one account together with + the preferences stored against it. Only a platform administrator whose own + account is still active may read this directory. + """ + + # Who may read the directory is settled in three layers, each of which can + # be replaced on its own: the permission classes below settle who may call + # the collection at all, the record-visibility policy settles which rows + # such a caller may see, and the filter set narrows those rows by the + # parameters the request carries. + + permission_classes = (permissions.IsAuthenticated, IsActiveUser, permissions.IsAdminUser) + serializer_class = UserDirectorySerializer + pagination_class = UserApiPagination + queryset = User.objects.all().select_related("profile") + # Platform administrators may read every entry, so the policy keeps the + # queryset whole; a narrower policy can replace it without touching the + # filtering below. + scoping_policy = FullScopePolicy() + #: The preferences carried inside an entry are rows of the stored + #: preference collection, so which of them a caller may see is that + #: collection's to decide and not this one's. It is declared separately for + #: that reason, and applied where the page is fetched rather than once per + #: entry, which would cost a query per row. + embedded_preference_policy = FullScopePolicy() + lookup_field = "username" + # The router's default lookup pattern excludes dots, which usernames allow. + lookup_value_regex = settings.USERNAME_REGEX_PARTIAL + filter_backends = (DjangoFilterBackend, StrictOrderingFilter) + filterset_class = UserFilterSet + ordering_fields = USER_SORT_FIELDS + ordering = USER_DEFAULT_SORT + # A sort the caller asks for is completed with these so that it is total: + # rows that compare equal would otherwise be ordered differently from one + # page to the next, and a client reading the pages in turn would see some + # of them twice and miss others. + ordering_tiebreak = ("username",) + minimal_fields = USER_MINIMAL_FIELDS + response_presets = USER_PRESETS + extra_query_params = RESPONSE_SHAPING_PARAMS + + def get_queryset(self): + """Fetch the preferences carried inside each entry through the policy that governs them.""" + return super().get_queryset().prefetch_related( + Prefetch( + "preferences", + queryset=self.embedded_preference_policy.scope( + UserPreference.objects.all(), self.request.user, + ), + ), + ) + + def list(self, request, *args, **kwargs): + """Return a page of directory entries, shaped as the caller asked.""" + return self.shape_page(super().list(request, *args, **kwargs), request) + + def retrieve(self, request, *args, **kwargs): + """Return one directory entry, shaped as the caller asked.""" + response = super().retrieve(request, *args, **kwargs) + response.data = self.shape(response.data, request) + return response + + +@extend_schema(tags=[USER_TAG]) +@extend_schema_view( + list=extend_schema( + summary="List stored preferences", + description=( + "Returns a page of stored user preferences. Supply `key` to keep only the " + "preferences stored under that name, or `username` to keep only one user's." + ), + parameters=[_PREFERENCE_VIEW_PARAM, _FIELDS_PARAM, _PREFERENCE_ORDERING_PARAM], + responses={ + 200: _PREFERENCE_SHAPES, + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NO_SUCH_PAGE, + 406: RESP_NOT_ACCEPTABLE, + }, + ), + retrieve=extend_schema( + summary="Get one stored preference", + description=( + "Returns the preference stored under this key for this user. A preference is " + "identified by the pair, which the address carries as the username, a comma and " + "the key, for example `alice,time_zone`." + ), + parameters=[ + _PREFERENCE_USERNAME_PATH_PARAM, + _PREFERENCE_KEY_PATH_PARAM, + _PREFERENCE_VIEW_PARAM, + _FIELDS_PARAM, + ], + responses={ + 200: _PREFERENCE_SHAPES, + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NOT_FOUND, + 406: RESP_NOT_ACCEPTABLE, + }, + ), +) +class UserPreferenceViewSet( + StandardizedErrorMixin, + CatalogedRequestErrorsMixin, + _ActionAddressMixin, + _ResponsePresetMixin, + StrictQueryParametersMixin, + ScopedQuerysetMixin, + viewsets.ReadOnlyModelViewSet, +): + """ + Preferences stored for the platform's users, each addressed by its owner and its name. + + A preference is identified by the two together, so a member address + carries the owner's username, a comma and the name the preference is + stored under, as in alice,time_zone. Only a platform administrator whose + own account is still active may read these. + """ + + # The pair identifies a row, so the member address is built from it rather + # than from a database id, and the route that serves it is declared with + # the collection rather than generated. Who may read these rows is settled + # in the same three layers as the user directory. + + permission_classes = (permissions.IsAuthenticated, IsActiveUser, permissions.IsAdminUser) + serializer_class = UserPreferenceRecordSerializer + pagination_class = UserApiPagination + queryset = ( + UserPreference.objects.all() + .select_related("user", "user__profile") + .annotate(username=F("user__username")) + ) + scoping_policy = FullScopePolicy() + #: The entry of a preference's owner carries that owner's own preferences, + #: which are rows of this same collection but are reached through the + #: entry rather than as rows of the page, so the policy that governs them + #: is applied where they are fetched. + embedded_preference_policy = FullScopePolicy() + #: The owner's entry is itself a row of the user collection rather than of + #: this one, so which owners a caller may be given the entry of is that + #: collection's decision and is declared separately for that reason. It is + #: settled for a whole page in one query, because asking it of each row in + #: turn would cost a query per row. An owner the policy leaves out is left + #: out of the row that carries it; the row itself stays, named by the owner + #: it is stored against, because which rows come back is this collection's + #: own decision and is made by the policy above it. + embedded_user_policy = FullScopePolicy() + filter_backends = (DjangoFilterBackend, StrictOrderingFilter) + filterset_class = UserPreferenceFilterSet + ordering_fields = PREFERENCE_SORT_FIELDS + ordering = PREFERENCE_DEFAULT_SORT + # The owner and the key identify a row, so they complete any sort the + # caller asks for and keep the order of a page stable. + ordering_tiebreak = ("username", "key") + minimal_fields = PREFERENCE_MINIMAL_FIELDS + response_presets = PREFERENCE_PRESETS + extra_query_params = RESPONSE_SHAPING_PARAMS + + def get_queryset(self): + """Fetch each owner's own preferences through the policy that governs them, when embedding.""" + queryset = super().get_queryset() + if full_view_requested(getattr(self, "request", None)): + queryset = queryset.prefetch_related( + Prefetch( + "user__preferences", + queryset=self.embedded_preference_policy.scope( + UserPreference.objects.all(), self.request.user, + ), + ), + ) + return queryset + + def get_serializer(self, *args, **kwargs): + """Build the serializer, having settled which owners the rows it is given may carry.""" + serializer = super().get_serializer(*args, **kwargs) + if args and full_view_requested(getattr(self, "request", None)): + serializer.context[EMBEDDABLE_OWNER_IDS] = self._embeddable_owner_ids(args[0]) + return serializer + + def _embeddable_owner_ids(self, rows): + """Return the ids of the owners of ``rows`` whose entry the caller may be given, in one query.""" + if isinstance(rows, UserPreference): + rows = [rows] + owner_ids = {row.user_id for row in rows} + if not owner_ids: + return frozenset() + # The owners of the page are taken out of the policy's answer rather + # than handed to it: a policy free to answer with a collection of its + # own is then still narrowed to this page, and what comes back is the + # size of the page rather than the size of the collection. + owners = self.embedded_user_policy.scope(User.objects.all(), self.request.user) + return frozenset(owners.filter(pk__in=owner_ids).values_list("pk", flat=True)) + + def get_object(self): + """Look the preference up by the username and key that identify it.""" + try: + preference = self.get_queryset().get( + user__username=self.kwargs["username"], key=self.kwargs["key"], + ) + except UserPreference.DoesNotExist as exc: + raise NotFound from exc + self.check_object_permissions(self.request, preference) + return preference + + def list(self, request, *args, **kwargs): + """Return a page of stored preferences, shaped as the caller asked.""" + return self.shape_page(super().list(request, *args, **kwargs), request) + + def retrieve(self, request, *args, **kwargs): + """Return one stored preference, shaped as the caller asked.""" + response = super().retrieve(request, *args, **kwargs) + response.data = self.shape(response.data, request) + return response + + +@extend_schema(tags=[USER_TAG]) +class CountryTimeZoneViewSet( + StandardizedErrorMixin, + CatalogedRequestErrorsMixin, + _ActionAddressMixin, + StrictQueryParametersMixin, + IterablePaginationMixin, + viewsets.ViewSet, +): + """ + Time zones commonly used in a country, or every time zone when no country is named. + + The list is the same for every caller and is open to anyone. + """ + + # The rows come from the time-zone database rather than from a table of + # this app's, so there is no queryset to narrow and the one parameter the + # view reads is validated by a serializer instead of a filter set. The + # data is reference data, which is why no caller is turned away. + + permission_classes = (permissions.AllowAny,) + serializer_class = TimeZoneSerializer + #: Validates the query string, and names the parameters this view reads. + query_serializer_class = TimeZoneQuerySerializer + pagination_class = UserApiPagination + + def get_serializer(self, *args, **kwargs): + """Build the output serializer; a plain ViewSet has no generic machinery to do it.""" + return self.serializer_class(*args, **kwargs) + + @extend_schema( + summary="List time zones", + description=( + "Returns a page of time zones, ordered by their display form. Supply " + "`country_code` to receive the time zones commonly used in one country." + ), + # The parameters are published from the serializer that validates them, + # so the constraint a caller is held to is the one it reads. + parameters=[TimeZoneQuerySerializer], + responses={ + 200: TimeZoneSerializer, + 400: _RESP_BAD_REQUEST, + 404: _RESP_NO_SUCH_PAGE, + 406: RESP_NOT_ACCEPTABLE, + }, + ) + def list(self, request): + """Return a page of time zones, for one country or for the world.""" + query = self.query_serializer_class(data=request.query_params) + query.is_valid(raise_exception=True) + time_zones = get_country_time_zones(query.validated_data.get("country_code")) + return self.paginate_iterable(request, time_zones) + + +def _shareable_account_fields(): + """Return every property an account's owner is able to share with another user.""" + return set(settings.ACCOUNT_VISIBILITY_CONFIGURATION["custom_shareable_fields"]) + + +#: The classes every operation on an account admits a caller by, wherever it is +#: served from. The account collection declares this tuple and every address +#: that works on one of its accounts carries it, so the rules of the resource +#: are declared once rather than restated by each address that reaches into it. +#: It is read from here rather than from the collection's own attribute because +#: that attribute is added to in place, by the first request to reach a view +#: that reads a token, and admission must not depend on which addresses a +#: process has served already. +ACCOUNT_RESOURCE_PERMISSIONS = ( + permissions.IsAuthenticated, + CanGetAccountInfo, + CanSelectAccountsByContact, +) + + +class AccountResourceMixin: + """ + Carries the rules of the account resource into the operations on one account. + + An address beneath an account works on a row of the collection the account + resource answers from, so a caller that resource refuses is a caller the + operation refuses too: the classes the resource declares are applied first + and the ones this address declares after, which keeps an address reached + this way from standing open to a caller the resource is closed to. The + account the address names is resolved here as well, so the record a + permission settled the request on is the record the work lands on. + """ + + def account_resource_permissions(self): + """Return the classes the account resource admits a caller by.""" + return ACCOUNT_RESOURCE_PERMISSIONS + + def declared_permissions(self): + """Return the classes the address being served declares of its own.""" + return super().get_permissions() + + def get_permissions(self): + """Return the collection's classes, then the ones this address declares.""" + declared = self.declared_permissions() + already_carried = {type(permission) for permission in declared} + carried = [ + permission() for permission in self.account_resource_permissions() + if permission not in already_carried + ] + return carried + declared + + def addressed_account(self): + """Return the account this address names, refusing an address that names no one account.""" + return _user_named(self.kwargs["username"]) + + +@extend_schema(tags=[SDK_TAG]) +@extend_schema_view( + list=extend_schema( + summary="List accounts", + description=( + "Returns a page of accounts. Name them with `username`, or select the accounts " + "holding an `email` address or the one holding an `lms_user_id`; omit all three to " + "receive your own account. A name, an address or an identifier no account holds is " + "left out of the page, which is empty when nothing given is held. An address held by " + "more than one account brings back each of them. " + "Each account carries the properties the caller may " + "see: everything for your own account and for a staff caller, and otherwise the " + "properties its owner shares. " + "A property named in `fields` that an owner does not share is left out of their " + "account rather than refused, so a row of the page can come back empty." + ), + parameters=[AccountQuerySerializer, _FIELDS_PARAM], + responses={ + 200: _ACCOUNT_SHAPES, + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NO_SUCH_PAGE_OR_RETIRED, + 406: RESP_NOT_ACCEPTABLE, + }, + ), + retrieve=extend_schema( + summary="Get one account", + description=( + "Returns the account of this user, carrying the properties the caller may see. " + "Address `me` for the account of the caller. A property named in `fields` that " + "this owner does not share is left out rather than refused, so the answer can be " + "an empty object." + ), + parameters=[_ACCOUNT_PATH_PARAM, _ACCOUNT_VIEW_PARAM, _FIELDS_PARAM], + responses={ + 200: _ACCOUNT_SHAPES, + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NO_SUCH_ACCOUNT, + 406: RESP_NOT_ACCEPTABLE, + }, + examples=_ACCOUNT_EXAMPLES, + ), + partial_update=extend_schema( + summary="Change one account", + description=( + "Changes the properties named in the body and returns the account as it now stands. " + "A user may change only their own account, which `me` also addresses. The body is a " + "merge patch: only the properties it carries are changed, and `social_links` are " + "merged into the stored list by platform. A new email address takes effect only once " + "the user confirms it from the message sent there." + ), + parameters=[_ACCOUNT_PATH_PARAM, _ACCOUNT_VIEW_PARAM, _FIELDS_PARAM], + request=AccountUpdateSerializer, + responses={ + 200: _ACCOUNT_SHAPES, + 400: _RESP_UNUSABLE_BODY, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_CHANGED_ACCOUNT_GONE, + 406: RESP_NOT_ACCEPTABLE, + 415: RESP_UNREADABLE_BODY, + 429: _RESP_RATE_LIMITED, + 500: _RESP_CHANGE_FAILED, + }, + ), +) +class AccountViewSet( + StandardizedErrorMixin, + CatalogedRequestErrorsMixin, + ErrorExtensionsMixin, + AccountResourceMixin, + _ActionAddressMixin, + _ResponsePresetMixin, + StrictQueryParametersMixin, + IterablePaginationMixin, + viewsets.ViewSet, +): + """ + Accounts of the platform's users. Address one by username, or me for your own. + + An account carries the properties the caller may see: all of them for your + own account and for a staff caller, and otherwise the ones its owner + shares. A property an owner does not share is left out of their account + rather than refused, so an account can come back as an empty object. + + Selecting accounts that nothing holds gives an empty page rather than a + refusal. An address an account held before it was retired is refused + instead, and that refusal says whether the retirement can still be + cancelled. + + A property no account of this kind could show this caller is refused + rather than answered as an empty object. + """ + + # The properties of an account are read from the account service rather + # than from a table of this app's, so there is no queryset to narrow and + # the page is cut from the names the selection resolved to. The service is + # what settles which properties of each account the caller may see; the + # selection settles which accounts the page carries, and is applied in the + # handler rather than declared as a filter set because a filter set has no + # room for the retired-address refusal, which reports rows it cannot carry + # instead of leaving them out. Which properties the answer can carry is + # settled before the address is read, so a request for a property this + # kind of answer never carries is refused without reading or writing + # anything on the way to it. + + # Account settings are reached from the site's own pages by users who have + # not confirmed their email address yet, and the platform default refuses + # a session belonging to such an account. Only the session class differs in + # what it admits: a token is read by the library class the platform's own + # pair is built on, named here because a view declaring its own classes is + # not one that pair serves. + authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser) + permission_classes = ACCOUNT_RESOURCE_PERMISSIONS + #: A change is a merge patch: a body in any other media type is refused. + parser_classes = (MergePatchParser,) + serializer_class = AccountSerializer + #: Validates the query string of the list, and names the parameters it reads. + query_serializer_class = AccountQuerySerializer + pagination_class = UserApiPagination + lookup_field = "username" + # The router's default lookup pattern excludes dots, which usernames allow. + lookup_value_regex = settings.USERNAME_REGEX_PARTIAL + minimal_fields = ACCOUNT_MINIMAL_FIELDS + response_presets = ACCOUNT_PRESETS + extra_query_params = RESPONSE_SHAPING_PARAMS + withheld_fields_message = "An account another user holds carries only the properties its owner shares." + #: The validated query string of the request being served, read once. + _validated_selection = None + + def get_serializer(self, *args, **kwargs): + """Build the output serializer; a plain ViewSet has no generic machinery to do it.""" + return self.serializer_class(*args, **kwargs) + + def known_query_params(self): + """Return the query parameters the action being served reads.""" + names = super().known_query_params() + if self.action != "list": + # One account is answered whole, so neither the selectors that + # choose which accounts a page carries nor the parameters that cut + # it into pages are read at a member address. + names -= set(self.query_serializer_class().fields) - {VIEW_PARAM} + names -= { + self.pagination_class.page_query_param, + self.pagination_class.page_size_query_param, + } + if self.action not in SHAPED_ACCOUNT_ACTIONS: + names -= set(RESPONSE_SHAPING_PARAMS) + return names + + def shapeable_fields(self): + """Return the properties an account response can carry for the caller making it.""" + describable = set(AccountSerializer().fields) + if self.is_minimal_view_requested(): + describable &= set(self.minimal_fields) + if self._whole_accounts_are_readable(self.request): + return describable + return describable & _shareable_account_fields() + + def shapeable_fields_may_be_named(self): + """ + Whether a refusal may list the properties an answer of this kind can carry. + + Which properties an owner shares is that owner's to decide, and the + set the site offers them to choose from is the site's own + configuration. A caller addressing an account it does not hold is + therefore told that it may not select what it selected, and not what + the site holds for other people: the list would otherwise name every + property the site is configured to let an owner share, including ones + nobody on the site shares. A caller that narrowed the answer itself, + by asking for a preset, is still told what that preset carries. + """ + return not self._only_shared_properties_are_readable(self.request) + + def _whole_accounts_are_readable(self, request): + """Whether every account this request addresses comes back whole to this caller.""" + if request.query_params.get(VIEW_PARAM) == SHARED_VIEW: + return False + return not self._only_shared_properties_are_readable(request) + + def _only_shared_properties_are_readable(self, request): + """Whether who the caller is keeps them from every property of the accounts addressed.""" + if request.user.is_staff: + return False + return self._addressed_names(request) != [request.user.username] + + def _selection(self, request): + """ + Return the query string as the serializer that validates it reads it. + + Which accounts a request addresses is read both before the handler + runs, to settle what the answer can carry, and inside it, to cut the + page. Both read the values the serializer validated, so 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._account_addressed( + request, + self._addressed_username(request, username), + request.query_params.get(VIEW_PARAM), + ) + return Response(self.shape(account, request)) + + def partial_update(self, request, username): + """Change the properties the body carries and return the account as it now stands.""" + username = self._addressed_username(request, username) + changes = request.data + if not isinstance(changes, dict): + raise ValidationError({ + api_settings.NON_FIELD_ERRORS_KEY: ["Send the properties to change as an object."], + }) + # 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._account_addressed( + request, username, request.query_params.get(VIEW_PARAM), + ) + 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 [] + found = self._accounts_found(request, names, preset) + # The accounts come back in the order the database returned them, which + # is not the order of the page they were selected for, so each is put + # back under the name it is stored as. Which stored names a selection + # resolves to is the database's decision and need not be the names it + # was given, so an account none of those names is written as is carried + # at the end of the page rather than left out of it. + by_name = {account["username"]: account for account in found} + claimed = {name for name in names if name in by_name} + ordered = [by_name[name] for name in names if name in by_name] + ordered.extend(account for account in found if account["username"] not in claimed) + return ordered + + def _account_addressed(self, request, username, preset=None): + """ + Return the one account a member address names, as this caller may see it. + + The lookup settles which stored account an address names, so the + account it comes back with is the answer here whatever name that + account is stored under. An address the lookup comes back from with + several accounts and none of them stored under exactly the name given + names no one of them, so it is refused rather than answered with + whichever of them came back first. + """ + found = self._accounts_found(request, [username], preset) + return _the_one_addressed(username, found, lambda account: account["username"]) + + def _accounts_found(self, request, names, preset): + """Return the accounts the lookup comes back with for these names.""" + try: + return get_account_settings(request, names, view=preset if preset == SHARED_VIEW else None) + except UserNotFound as exc: + raise NotFound from exc + + def _selected_usernames(self, request, selection): + """Return the usernames the selectors name, in the order the page carries them.""" + return usernames_among( + self._selected_names(request, selection), + selection.get("ordering") or ACCOUNT_DEFAULT_SORT, + ) + + def _selected_names(self, request, selection): + """ + Return the usernames named by whichever selector the caller used. + + Every selector narrows the same collection, so each one that finds + nothing narrows it to nothing and leaves the page empty, whether it + was given a name, an address or an identifier. An address an account + held before it was retired is the exception: the account was there and + is not a row this page can carry, which is reported rather than left + out in silence. + """ + usernames = selection.get("username") + if usernames: + return names_in(usernames) + email = selection.get("email") + if email: + return self._usernames_holding_email(email) + identifier = selection.get("lms_user_id") + if identifier is not None: + return self._usernames_holding_identifier(identifier) + return [request.user.username] + + def _usernames_holding_email(self, email): + """Return the accounts holding this address, by name, and nothing when none does.""" + if is_email_retired(email): + raise self._retired_account(email) + return usernames_holding_email(email) + + def _usernames_holding_identifier(self, identifier): + """Return the account holding this numeric identifier, and nothing when none does.""" + username = username_holding_id(identifier) + return [username] if username is not None else [] + + def _retired_account(self, email): + """Build the refusal for an address an account held before it was retired.""" + retirement = cancellable_retirement_for_email(email) + return RetiredAccount( + accounts.RETIRED_EMAIL_MSG, + can_cancel_retirement=retirement is not None, + retirement_id=retirement.id if retirement is not None else None, + ) + + def _check_change_rate(self, request, changes): + """Refuse a property the caller has changed too often lately.""" + for field, limit_setting in RATE_LIMITED_ACCOUNT_FIELDS: + limit = getattr(settings, limit_setting, None) + if not (changes.get(field) and limit): + continue + if is_ratelimited( + request=request, + group=f"{field}_change_rate_limit", + key="user", + rate=limit, + increment=True, + ): + log.info("Change of %r refused for %r: over the rate limit", field, request.user.username) + raise Throttled(detail=f"Wait before changing {field} again.") + + def _refused_changes(self, username, error): + """Return the refusal naming the property that each failed validation.""" + log.info("Account %r not changed: %r", username, error.field_errors) + refusals = {} + for field, reported in error.field_errors.items(): + message = reported.get("user_message") if isinstance(reported, dict) else None + refusals[field] = [message or "This value cannot be used here."] + return ValidationError(refusals) + + def _failed_change(self, username, error): + """ + Return the failure for a change that was asked for correctly and could not be carried out. + + The body has already been measured against what this endpoint takes, + and a value the account refuses is reported property by property, so + what is left here is the work itself failing: a row that would not + save, a message that would not send. Neither is answered by sending + the request again with something else in it, so neither is the + caller's fault. + """ + log.error("Account %r not changed: %s", username, error.developer_message) + return APIException("The change could not be carried out.") + + +@extend_schema(tags=[SDK_TAG]) +@extend_schema_view( + post=extend_schema( + summary="Deactivate an account", + description=( + "Makes the account's password unusable, so its owner can no longer sign in with it. " + "The account and the data belonging to it are kept, and none of it is removed. Asking " + "for a password reset does not undo this: the reset flow refuses an account whose " + "password is unusable, so signing in again takes a new password set on the account by " + "other means." + ), + parameters=[_DEACTIVATION_PATH_PARAM], + request=None, + responses={ + 204: OpenApiResponse(description="The account was deactivated."), + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NOT_FOUND, + 406: RESP_NOT_ACCEPTABLE, + }, + ), +) +class AccountDeactivationView( + StandardizedErrorMixin, + CatalogedRequestErrorsMixin, + AccountResourceMixin, + StrictQueryParametersMixin, + APIView, +): + """ + Disables the account the address names, so its owner can no longer sign in with it. + + The account and everything belonging to it are kept, and none of it is + removed. Asking for a password reset does not undo this. + """ + + # Disabling an account is something a privileged caller does to somebody + # else's account rather than a property of that account, and it shares + # neither its callers nor its body with the way an account is read or + # changed: the caller holds the privilege to disable accounts and has + # confirmed their own address, and the body is read as JSON rather than as + # a merge patch. It is therefore a view of its own rather than an action + # of the account collection. + + permission_classes = (permissions.IsAuthenticated, IsActiveUser, CanDeactivateUser) + + def post(self, request, username): + """Make the password of the named account unusable and record it as disabled.""" + user = self.addressed_account() + segment.identify(user.id, {"is_disabled": "true"}) + user.set_unusable_password() + user.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class _SearchedAccountsMixin: + """ + The accounts a search names, before it is narrowed to the ones a caller may see. + + A view's scoping policy is applied on top of the selection the classes + resolved after it answer with, so the unnarrowed selection is declared + here rather than on the view, where it would stand in front of the layer + that narrows it and leave the policy with nothing to do. + """ + + #: The addresses asked about, held for as long as one request is served. + searched_emails = () + + def get_queryset(self): + """Return the accounts holding the addresses this request asked about.""" + return accounts_holding_emails(self.searched_emails) + + +@extend_schema(tags=[SDK_TAG]) +@extend_schema_view( + create=extend_schema( + summary="Search accounts by email address", + description=( + "Returns a page of the accounts holding the email addresses in the body. An address " + "no account holds is left out of the answer, and a search names at most " + f"{EMAIL_SEARCH_MAX_ADDRESSES} addresses. The addresses travel in the body so that " + "they are not written to a server log or a browser history, which is why this reads " + "with a POST. For the same reason the `next` and `previous` links of the page are " + "reached by sending the same body to them, the way this request was sent, rather " + "than by fetching them." + ), + request=EmailSearchSerializer, + responses={ + 200: EmailSearchResultSerializer(many=True), + 400: _RESP_UNUSABLE_BODY, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NOT_FOUND, + 406: RESP_NOT_ACCEPTABLE, + 415: RESP_UNREADABLE_BODY, + }, + ), +) +class EmailSearchViewSet( + StandardizedErrorMixin, + CatalogedRequestErrorsMixin, + StrictQueryParametersMixin, + IterablePaginationMixin, + ScopedQuerysetMixin, + _SearchedAccountsMixin, + viewsets.ViewSet, +): + """ + The accounts holding a given set of email addresses. + + The addresses to look for are sent in the request body, so a search is + made with POST, and the links on a page of results are followed by sending + the body to them again rather than by fetching them. Only a platform + administrator may search here; every other caller is told there is nothing + at this address. + """ + + # The addresses are sent in a body so that they stay out of server logs + # and browser histories, which is what makes the search a POST and what + # makes its pages be followed with a body. The search stands beside the + # accounts rather than under them because what follows the account + # collection in an address is the name of an account, and a user may + # register any name. + + permission_classes = ( + permissions.IsAuthenticated, + IsActiveUser, + CanGetAccountInfo, + IsStaffOrAbsent, + ) + # A platform administrator may read every account the addresses match, so + # the policy keeps the selection whole; a narrower policy can replace it + # without touching the search below, and the accounts come from the same + # table the other collections of this version narrow. + scoping_policy = FullScopePolicy() + parser_classes = (JSONParser,) + metadata_class = AddressMetadata + serializer_class = EmailSearchResultSerializer + #: A search is asked for by sending the addresses to look for and is + #: answered with an account per address found, so the body it takes is not + #: the shape it answers with. + request_serializer_class = EmailSearchSerializer + pagination_class = UserApiPagination + + def get_serializer(self, *args, **kwargs): + """Build the output serializer; a plain ViewSet has no generic machinery to do it.""" + return self.serializer_class(*args, **kwargs) + + def create(self, request): + """Return a page of the accounts holding the addresses the body carries.""" + search = EmailSearchSerializer(data=request.data) + search.is_valid(raise_exception=True) + self.searched_emails = search.validated_data["emails"] + return self.paginate_iterable(request, self.get_queryset()) + + +_NAME_CHANGE_PATH_PARAM = path_param( + "username", + "Unique name of the user whose requested change is confirmed. The name is read as it is " + f"stored, so `{OWN_ACCOUNT_IDENTIFIER}` here names an account holding that name rather than " + "the account of the caller making the request.", + USERNAME_PATTERN, +) + + +@extend_schema(tags=[SDK_TAG]) +@extend_schema_view( + create=extend_schema( + summary="Request a profile name change", + description=( + "Records the full name the caller wants on their profile. The profile keeps its " + "current name until the request is confirmed, and a caller who already has a request " + "outstanding replaces it with this one." + ), + request=NameChangeSerializer, + responses={ + 201: NameChangeSerializer, + 400: _RESP_UNUSABLE_BODY, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_SESSION_WITHOUT_ITS_TOKEN, + 404: OpenApiResponse( + ErrorResponse, + description="The account of the caller carries no profile to put a name on.", + ), + 406: RESP_NOT_ACCEPTABLE, + 415: RESP_UNREADABLE_BODY, + }, + ), +) +class NameChangeViewSet( + StandardizedErrorMixin, + 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 + #: Describes each address by the body and the media types it reads. + metadata_class = AddressMetadata + #: The actions whose handler reads no body, which their address describes + #: none for. Confirming a change carries out the one already asked for, so + #: there is nothing for a caller to send. + bodiless_actions = ("confirmations",) + lookup_field = "username" + # The router's default lookup pattern excludes dots, which usernames allow. + lookup_value_regex = settings.USERNAME_REGEX_PARTIAL + + def get_serializer(self, *args, **kwargs): + """Build the serializer; a plain ViewSet has no generic machinery to do it.""" + return self.serializer_class(*args, **kwargs) + + def create(self, request): + """Record the name the caller wants on their profile.""" + requested = self.get_serializer(data=request.data) + requested.is_valid(raise_exception=True) + name = requested.validated_data["name"] + rationale = f"Name change requested through account API by {request.user.username}" + pending = self._requested_change(request.user, name, rationale) + if pending is None: + raise ValidationError({"name": ["The profile already carries this name."]}) + return Response( + self.get_serializer({"name": pending.new_name}).data, + status=status.HTTP_201_CREATED, + ) + + @extend_schema( + summary="Confirm a requested profile name change", + description=( + "Puts the name the user asked for on their profile and keeps the name it carried " + "until now in the profile's history. Only a member of staff may confirm a request." + ), + parameters=[_NAME_CHANGE_PATH_PARAM], + request=None, + responses={ + 204: OpenApiResponse(description="The profile now carries the name that was asked for."), + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: OpenApiResponse( + ErrorResponse, + description="No such user, or the user has asked for no name change.", + ), + 406: RESP_NOT_ACCEPTABLE, + }, + ) + @action( + detail=True, + methods=["post"], + url_path="confirmations", + url_name="confirmation", + # Confirming somebody else's requested name is a privilege, so this + # action also refuses a caller whose own account is no longer active. + permission_classes=[permissions.IsAuthenticated, IsActiveUser, IsStaff], + ) + def confirmations(self, request, username): + """Put the name the named user asked for on their profile.""" + user = _user_named(username) + pending = get_pending_name_change(user) + if pending is None: + raise NotFound + self._confirm_change(user, pending) + return Response(status=status.HTTP_204_NO_CONTENT) + + def _requested_change(self, user, name, rationale): + """Store the name this user asks for, or return nothing when it is the name they have.""" + try: + return do_name_change_request(user, name, rationale)[0] + except UserProfile.DoesNotExist as exc: + log.warning("Name change refused for %r: the account carries no profile", user.username) + raise NotFound from exc + + def _confirm_change(self, user, pending): + """Put the requested name on the profile.""" + try: + confirm_name_change(user, pending) + except UserProfile.DoesNotExist as exc: + log.warning("Name change refused for %r: the account carries no profile", user.username) + raise NotFound from exc + + +_VERIFICATION_PATH_PARAM = path_param( + "username", + "Unique name of the user whose identity verification is addressed. The name is read as it is " + f"stored, so `{OWN_ACCOUNT_IDENTIFIER}` here names an account holding that name rather than " + "the account of the caller making the request.", + USERNAME_PATTERN, +) + + +def _verification_sort_key(attempt): + """Return what an attempt is ordered by, with a tie-break that is always comparable.""" + return (attempt.updated_at, str(getattr(attempt, "receipt_id", "") or "")) + + +@extend_schema(tags=[SDK_TAG]) +class IDVerificationStatusView( + StandardizedErrorMixin, + CatalogedRequestErrorsMixin, + AccountResourceMixin, + StrictQueryParametersMixin, + APIView, +): + """ + Whether a user has proved their identity, as one summary. + + Every account has a summary, and one belonging to a user who has been + through no check says so; the address is absent only when the user it + names is. A user may read their own summary, and a staff caller whose own + account is still active may read anyone's. + """ + + # The summary is computed rather than stored and is never listed, so it is + # served from this one address rather than routed as a collection, and it + # is read from the identity verification service rather than from a table + # of this app's. It admits a different set of callers from the account + # collection, which is why it is a view of its own rather than an action + # of that collection. + + permission_classes = ( + permissions.IsAuthenticated, + # Reading somebody else's summary is a privilege, so the staff half + # also refuses a caller whose own account is no longer active. + (IsStaff & IsActiveUser) | IsAddressedUser, + ) + serializer_class = VerificationStatusSerializer + + def get_serializer(self, *args, **kwargs): + """Build the serializer; a plain APIView has no generic machinery to do it.""" + return self.serializer_class(*args, **kwargs) + + @extend_schema( + summary="Get the identity verification status", + description=( + "Returns where this user stands on having proved their identity. A user may read " + "their own status, and a staff caller may read anyone's. A user who has been through " + "no check stands at `none`." + ), + parameters=[_VERIFICATION_PATH_PARAM], + responses={ + 200: VerificationStatusSerializer, + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: OpenApiResponse(ErrorResponse, description="No such user."), + 406: RESP_NOT_ACCEPTABLE, + }, + ) + def get(self, request, username): + """Return where this user stands on having proved their identity.""" + standing = IDVerificationService.user_status(_user_named(username)) + return Response(self.get_serializer({ + "is_verified": standing.get("status") == APPROVED_STATUS, + "status": standing.get("status"), + "expiration_datetime": standing.get("verification_expiry", ""), + }).data) + + +@extend_schema(tags=[SDK_TAG]) +@extend_schema_view( + list=extend_schema( + summary="List identity verification attempts", + description=( + "Returns a page of the attempts this user has made at proving their identity, the " + "most recently changed one first. A user who has been through no checks has an " + "empty page. A user may read their own attempts, and a staff caller may read " + "anyone's." + ), + # The sort is published from the serializer that validates it, so the + # values a caller is held to are the values it reads. + parameters=[_VERIFICATION_PATH_PARAM, VerificationQuerySerializer], + responses={ + 200: VerificationSerializer(many=True), + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: OpenApiResponse( + ErrorResponse, description="No such user, or no such page of attempts.", + ), + 406: RESP_NOT_ACCEPTABLE, + }, + ), +) +class IDVerificationViewSet( + StandardizedErrorMixin, + CatalogedRequestErrorsMixin, + AccountResourceMixin, + StrictQueryParametersMixin, + IterablePaginationMixin, + viewsets.ViewSet, +): + """ + The attempts one user has made at proving their identity. + + A user may read their own attempts, and a staff caller may read anyone's. + A user who has been through no checks has an empty page; the address is + absent only when the user it names is. + """ + + # The attempts are collected from the identity verification service, which + # holds a table per kind of check, so there is no queryset of this app's to + # narrow: the page is cut from what those tables return and is sorted in + # the view rather than by a filter backend. An attempt is reached by no + # route other than this one, which is why the collection is addressed + # beneath the account it belongs to. + + permission_classes = ( + permissions.IsAuthenticated, + # Reading somebody else's attempts is a privilege, so the staff half + # also refuses a caller whose own account is no longer active. + (IsStaff & IsActiveUser) | IsAddressedUser, + ) + serializer_class = VerificationSerializer + pagination_class = UserApiPagination + #: Validates the query string, and names the parameters this view reads. + query_serializer_class = VerificationQuerySerializer + + def get_serializer(self, *args, **kwargs): + """Build the serializer; a plain ViewSet has no generic machinery to do it.""" + return self.serializer_class(*args, **kwargs) + + def list(self, request, username): + """Return a page of this user's attempts, in the order the caller asked for.""" + query = self.query_serializer_class(data=request.query_params) + query.is_valid(raise_exception=True) + attempts = IDVerificationService.verifications_for_user(_user_named(username)) + ordering = query.validated_data.get("ordering") or VERIFICATION_DEFAULT_SORT + return self.paginate_iterable( + request, + sorted(attempts, key=_verification_sort_key, reverse=ordering.startswith("-")), + ) + + +_ACCOUNT_PREFERENCES_PATH_PARAM = path_param( + "username", + "Unique name of the user whose preferences are addressed. The name is read as it is stored, " + f"so `{OWN_ACCOUNT_IDENTIFIER}` here names an account holding that name rather than the " + "account of the caller making the request.", + USERNAME_PATTERN, +) +_ACCOUNT_PREFERENCE_KEY_PATH_PARAM = path_param( + "key", + "Name the preference is stored under, which is the whole segment, punctuation included. A " + "name carrying a slash has no address of its own, because the segment ends at the slash; " + "such a name is read with the whole document and removed by sending a null for it there. " + f"Nothing is stored under a name longer than {PREFERENCE_NAME_MAX_LENGTH} characters, nor " + "under one carrying no letter, digit, hyphen or underscore.", + rf"^{PREFERENCE_KEY_PATTERN_PARTIAL}$", +) + +#: The actions of the preference resource an active administrator may call +#: against an account other than their own. +STAFF_READABLE_PREFERENCE_ACTIONS = ("retrieve_all", "retrieve") + +#: The action that changes the whole preference document of one account. +DOCUMENT_CHANGE_ACTION = "partial_update_all" + +#: The action recorded for a request that asks what an address answers rather +#: than for what it holds. Such a request is answered to whoever may read the +#: address; the description of a write it carries is built behind a second +#: check, which carries the method of that write. +DESCRIBE_ADDRESS_ACTION = "metadata" + +#: Who may read the preferences of the account an address names, and who may +#: change them. Reading somebody else's is a privilege, so the administrator +#: half also refuses a caller whose own account is no longer active; changing +#: them belongs to their owner alone. +_PREFERENCE_READ_PERMISSIONS = ( + permissions.IsAuthenticated, + (IsStaff & IsActiveUser) | IsAddressedUser, +) +_PREFERENCE_WRITE_PERMISSIONS = (permissions.IsAuthenticated, IsAddressedUser) + +#: The media type a change to the whole document is sent in: a merge patch, in +#: which a null removes a name. Every other body of this resource is read in +#: the media types the platform reads everywhere. +_DOCUMENT_PARSERS = (MergePatchParser,) + +_RESP_PREFERENCE_DOCUMENT = OpenApiResponse( + ACCOUNT_PREFERENCES_SCHEMA, description="The preferences this account stores.", +) +_RESP_NO_SUCH_PREFERENCE = OpenApiResponse( + ErrorResponse, description="No account is held at the address given, or it stores no such preference.", +) +_RESP_PREFERENCE_NOT_STORED = OpenApiResponse( + ErrorResponse, + description=( + "The request body is not well formed in the media type it was sent in, or a preference " + "name is longer than one can be stored under, or a value is one a preference cannot " + "hold. A query parameter this endpoint does not read is refused here too." + ), +) +_RESP_PREFERENCE_SERVICE_FAILED = OpenApiResponse( + ErrorResponse, + description=( + "The request was correct and could not be carried out. Sending it again with something " + "else in it does not help; sending the same request later may." + ), +) + + +@extend_schema(tags=[SDK_TAG]) +class AccountPreferenceViewSet( + StandardizedErrorMixin, + CatalogedRequestErrorsMixin, + AccountResourceMixin, + StrictQueryParametersMixin, + viewsets.ViewSet, +): + """ + The preferences of the one account an address names. + + They are one document: an object of preference name to stored value, read + whole and changed with a merge patch. Each preference is also reached on + its own. A user reads and changes their own preferences, and a staff + caller whose own account is still active reads anyone's but changes + nobody else's. + + A preference name takes the whole of its segment of the address, so the + names carrying punctuation that the platform's own features store under + can be addressed. A name carrying a slash cannot, because the segment ends + there; such a name is read and removed through the whole document, which + is also the only way one arrives. + """ + + # The preferences are read and written through the preference service + # rather than from a table of this app's, so there is no queryset here. + # Treating them as one document is what the pages that keep a user's + # settings read and save; this collection carries no pages of its own, and + # the record-shaped view of the same data across accounts is the stored + # preference collection of this version. + + # The language a user picked is written from every page of the site, and + # the settings page these preferences are kept on is reached by users who + # have not confirmed their email address yet, whom the platform default + # refuses. Only the session class differs in what it admits: a token is + # read by the library class the platform's own pair is built on, named here + # because a view declaring its own classes is not one that pair serves. + authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser) + permission_classes = _PREFERENCE_WRITE_PERMISSIONS + serializer_class = AccountPreferenceSerializer + #: Describes each address by the body and the media types it reads. + metadata_class = AddressMetadata + + #: Action being served, which the parsers follow. It is declared here + #: because it is recorded while the request is being built, which is + #: before the view machinery would otherwise set it. + action = None + + def get_serializer(self, *args, **kwargs): + """Build the output serializer; a plain ViewSet has no generic machinery to do it.""" + return self.serializer_class(*args, **kwargs) + + def declared_permissions(self): + """Return the classes this address declares for the request being served.""" + reads = self.action in STAFF_READABLE_PREFERENCE_ACTIONS or self._describes_the_address() + permission_classes = _PREFERENCE_READ_PERMISSIONS if reads else self.permission_classes + return [permission() for permission in permission_classes] + + def _describes_the_address(self): + """ + Return whether the request asks what the address answers rather than for what it holds. + + Such a request is guarded as a read of the address, so a caller who may + read somebody else's preferences may also ask what the address does + with them. The description of a write that the answer carries is built + behind a second check made with the method of that write, which is + guarded as that write and so is left out for a caller who may not make + it. + """ + return self.action == DESCRIBE_ADDRESS_ACTION and self.request.method == "OPTIONS" + + def initialize_request(self, request, *args, **kwargs): + """ + Record which action is being served before anything reads the request. + + The media type a body is read in follows the action here, and the + parsers are chosen while the request is being built, which is before + the action would otherwise be recorded. + """ + self.action = self.action_map.get(request.method.lower()) + return super().initialize_request(request, *args, **kwargs) + + def get_parsers(self): + """Return the parsers that read the bodies the address being served takes.""" + if self.action == DOCUMENT_CHANGE_ACTION or self._describes_an_address_the_document_is_changed_at(): + return [parser() for parser in _DOCUMENT_PARSERS] + return super().get_parsers() + + def _describes_an_address_the_document_is_changed_at(self): + """ + Return whether the request asks what an address that takes the whole document answers. + + Such a request is answered with the media types that address reads, + which are the ones of the change it serves rather than the ones read + everywhere else; the action recorded for it is not the change, so the + methods the address serves are what decide. + """ + return ( + self.action == DESCRIBE_ADDRESS_ACTION + and DOCUMENT_CHANGE_ACTION in (self.action_map or {}).values() + ) + + @extend_schema( + summary="Get the preferences of one account", + description=( + "Returns every preference stored for this user, as an object of preference name to " + "stored value. A user may read their own preferences, and a staff caller may read " + "anyone's. An account that stores none is answered with an empty object. The names " + "come back sorted by name, character by character and by the code of each character, " + "so a name beginning with a digit or a capital letter comes before one beginning in " + "lower case." + ), + parameters=[_ACCOUNT_PREFERENCES_PATH_PARAM], + responses={ + 200: _RESP_PREFERENCE_DOCUMENT, + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NO_SUCH_ACCOUNT, + 406: RESP_NOT_ACCEPTABLE, + 500: _RESP_PREFERENCE_SERVICE_FAILED, + }, + ) + def retrieve_all(self, request, username): + """Return every preference stored for the account the address names.""" + with self._service_failures_named(username): + stored = get_user_preferences(request.user, username=username) + return Response(ACCOUNT_PREFERENCE_DOCUMENT.to_representation(stored)) + + @extend_schema( + summary="Change the preferences of one account", + description=( + "Changes the preferences the body names and leaves the rest as they are. The body is " + "a merge patch: a value is the text to store under that name, and a null removes the " + "name. A user may change only their own preferences. Nothing is stored unless every " + "name in the body can be, and a value that cannot be stored is reported under the " + "name that carried it." + ), + parameters=[_ACCOUNT_PREFERENCES_PATH_PARAM], + request={MergePatchParser.media_type: ACCOUNT_PREFERENCE_CHANGES_SCHEMA}, + responses={ + 204: OpenApiResponse(description="The preferences now stand as the body asked."), + 400: _RESP_PREFERENCE_NOT_STORED, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 406: RESP_NOT_ACCEPTABLE, + 415: RESP_UNREADABLE_BODY, + 500: _RESP_PREFERENCE_SERVICE_FAILED, + }, + ) + def partial_update_all(self, request, username): + """Store the preferences the body names and remove the ones it sends a null for.""" + changes = AccountPreferencesUpdateSerializer(data=request.data) + changes.is_valid(raise_exception=True) + with self._service_failures_named(username): + with transaction.atomic(): + update_user_preferences(request.user, changes.validated_data, user=username) + return Response(status=status.HTTP_204_NO_CONTENT) + + @extend_schema( + summary="Get one preference of one account", + description=( + "Returns one preference of this user, as the name it is stored under and the value " + "stored there. A user may read their own preferences, and a staff caller may read " + "anyone's. A name the account stores no value under is absent." + ), + parameters=[_ACCOUNT_PREFERENCES_PATH_PARAM, _ACCOUNT_PREFERENCE_KEY_PATH_PARAM], + responses={ + 200: AccountPreferenceSerializer, + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NO_SUCH_PREFERENCE, + 406: RESP_NOT_ACCEPTABLE, + 500: _RESP_PREFERENCE_SERVICE_FAILED, + }, + ) + def retrieve(self, request, username, key): + """Return one preference of the account the address names.""" + with self._service_failures_named(username): + value = get_user_preference(request.user, key, username=username) + if value is None: + raise NotFound + return Response(self.get_serializer({"key": key, "value": value}).data) + + @extend_schema( + summary="Store one preference of one account", + description=( + "Stores a value under one preference of this user, whether or not the name already " + "holds one. A user may change only their own preferences. A value a preference " + "cannot hold is refused under `value`, and a preference cannot hold an empty value. " + "A name nothing can be stored under, whether because it is longer than the store " + "holds or because it carries none of the characters a name is made of, is refused " + "under `key`, which is the part of the address that carried it." + ), + parameters=[_ACCOUNT_PREFERENCES_PATH_PARAM, _ACCOUNT_PREFERENCE_KEY_PATH_PARAM], + request=AccountPreferenceValueSerializer, + responses={ + 204: OpenApiResponse(description="The preference now holds this value."), + 400: _RESP_PREFERENCE_NOT_STORED, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 406: RESP_NOT_ACCEPTABLE, + 415: RESP_UNREADABLE_BODY, + 500: _RESP_PREFERENCE_SERVICE_FAILED, + }, + ) + def update(self, request, username, key): + """Store a value under one preference of the account the address names.""" + self._refuse_a_name_nothing_is_stored_under(key) + sent = AccountPreferenceValueSerializer(data=request.data) + sent.is_valid(raise_exception=True) + with self._service_failures_named(username, carried_by="value"): + set_user_preference( + request.user, key, sent.validated_data["value"], username=username, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + @extend_schema( + summary="Remove one preference of one account", + description=( + "Removes one preference of this user, so the name is no longer stored. A user may " + "remove only their own preferences. A name the account stores no value under is " + "absent." + ), + parameters=[_ACCOUNT_PREFERENCES_PATH_PARAM, _ACCOUNT_PREFERENCE_KEY_PATH_PARAM], + responses={ + 204: OpenApiResponse(description="The account stores no value under that name."), + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NO_SUCH_PREFERENCE, + 406: RESP_NOT_ACCEPTABLE, + 500: _RESP_PREFERENCE_SERVICE_FAILED, + }, + ) + def destroy(self, request, username, key): + """Remove one preference of the account the address names.""" + with self._service_failures_named(username): + removed = delete_user_preference(request.user, key, username=username) + if not removed: + raise NotFound + return Response(status=status.HTTP_204_NO_CONTENT) + + @staticmethod + def _refuse_a_name_nothing_is_stored_under(key): + """ + Refuse a name no value can be stored under, as the part of the address that carried it. + + The name arrives in the address rather than in the body, so a name the + store refuses is reported under the address member that carried it: a + caller cannot mend it by changing the value they sent. Which names are + held is the store's own to decide, so what it refuses is read from the + column that keeps them rather than from a list kept here. + """ + refused = why_a_name_cannot_be_stored(key) + if refused: + raise ValidationError({"key": refused}) + + @contextlib.contextmanager + def _service_failures_named(self, username, carried_by=None): + """ + Answer a failure of the preference service in the body this API answers with. + + A value the preference refuses is reported under the member of the + body that carried it, which is the preference name in a document and + the one property of a single change. Work that was asked for correctly + and failed anyway is not the caller's to correct, so what it says about + itself is logged rather than published. + """ + try: + yield + except UserNotAuthorized as exc: + raise PermissionDenied from exc + except UserNotFound as exc: + raise NotFound from exc + except PreferenceValidationError as exc: + raise self._refused_preferences(username, exc, carried_by) from exc + except (PreferenceUpdateError, UserAPIInternalError) as exc: + log.error("Preferences of %r could not be served: %s", username, exc) + raise APIException("The request could not be carried out.") from exc + + def _refused_preferences(self, username, error, carried_by=None): + """Return the refusal naming the member of the body that carried each value refused.""" + log.info("Preferences of %r not changed: %r", username, error.preference_errors) + refusals = {} + for key, reported in error.preference_errors.items(): + message = reported.get("user_message") if isinstance(reported, dict) else None + refusals[carried_by or key] = [message or "This value cannot be stored here."] + return ValidationError(refusals) + + +@extend_schema(tags=[SDK_TAG]) +@extend_schema_view( + create=extend_schema( + summary="Record whether to send a user an organization's messages", + description=( + "Records whether the caller wants messages from the organization running the course " + "named in the body. The choice is kept per organization, so it covers every course " + "that organization runs, and it is always the caller's own. A user whose account " + "needs a guardian's consent is recorded as not wanting them, whatever the body says." + ), + request=EmailOptInSerializer, + responses={ + 204: OpenApiResponse(description="The choice was recorded."), + 400: _RESP_UNUSABLE_BODY, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_SESSION_WITHOUT_ITS_TOKEN, + 404: OpenApiResponse( + ErrorResponse, + description="The account of the caller carries no profile to read a consent rule from.", + ), + 406: RESP_NOT_ACCEPTABLE, + 415: RESP_UNREADABLE_BODY, + 500: _RESP_PREFERENCE_SERVICE_FAILED, + }, + ), +) +class EmailOptInViewSet( + StandardizedErrorMixin, + CatalogedRequestErrorsMixin, + StrictQueryParametersMixin, + viewsets.ViewSet, +): + """ + The choices users have made about the messages organizations send them. + + A choice belongs to the user who made it, so it is recorded against the + caller and no username is named anywhere in the request. The organization + it applies to is the one running the course the body names. The choice is + held per organization rather than per course, so naming two courses of one + organization records one choice. + """ + + # Naming the course rather than the organization is how the pages a user + # makes this choice on identify the organization they are making it about. + + # The choice is made while enrolling, which a user reaches before they have + # confirmed their email address, and the platform default refuses a session + # belonging to such an account. Only the session class differs in what it + # admits: a token is read by the library class the platform's own pair is + # built on, named here because a view declaring its own classes is not one + # that pair serves. + authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser) + permission_classes = (permissions.IsAuthenticated,) + serializer_class = EmailOptInSerializer + + def get_serializer(self, *args, **kwargs): + """Build the serializer; a plain ViewSet has no generic machinery to do it.""" + return self.serializer_class(*args, **kwargs) + + def create(self, request): + """Record whether the caller wants messages from the organization running this course.""" + choice = self.get_serializer(data=request.data) + choice.is_valid(raise_exception=True) + try: + update_email_opt_in( + request.user, + choice.validated_data["course_key"].org, + choice.validated_data["email_opt_in"], + ) + except UserNotFound as exc: + raise NotFound from exc + except UserAPIInternalError as exc: + log.error("Email choice of %r not recorded: %s", request.user.username, exc) + raise APIException("The choice could not be recorded.") from exc + return Response(status=status.HTTP_204_NO_CONTENT) + + +@extend_schema(exclude=True) +class UnknownAddressView(StandardizedErrorMixin, APIView): + """ + Refuses an address under this version of the API that no route serves. + + The refusal carries the JSON error body most views of this version answer + with, rather than the service's HTML error page, which a client that asked + for JSON cannot read. The registration, sign-in and password reset + addresses answer with the bodies of the authentication app's views they + extend instead. + """ + + permission_classes = (permissions.AllowAny,) + + def initial(self, request, *args, **kwargs): + """Refuse the address before a handler is chosen, whatever the method.""" + raise NotFound + + +#: Fields the retirement queue sorts on. A retirement's own identifier is not +#: among them: it is the order the queue is worked in and the order a page +#: falls back to, but it is a database key rather than something a caller +#: chooses a working order by. +RETIREMENT_SORT_FIELDS = ("created", "modified", "original_username", "current_state") + +#: The order the queue is worked in, which is the order retirements were +#: requested in. +RETIREMENT_DEFAULT_SORT = ("id",) + +#: The presets a retirement response answers to. +RETIREMENT_PRESETS = (MINIMAL_VIEW,) + +#: The retirement actions that answer with a retirement, and so read the +#: parameters that shape one. +SHAPED_RETIREMENT_ACTIONS = ("list", "retrieve") + +_RETIREMENT_PATH_PARAM = path_param( + "username", + "Name the account being retired was registered under. A retired account is stored under a " + "hashed name, and either name addresses the same retirement when one is read; a write " + "addresses the registered name alone, spelled exactly as it was registered.", + USERNAME_PATTERN, +) +_RETIREMENT_VIEW_PARAM = query_param( + VIEW_PARAM, + "Response preset. `minimal` reduces each retirement to which account it is for and where it " + "stands, carrying none of the account owner's own data. Omit it to receive the whole record.", + enum=list(RETIREMENT_PRESETS), +) +#: The number of days is published from the filter that reads it, so the two +#: cannot disagree. It is declared here because the kind of value a filter +#: taking a method of its own reads cannot be worked out from the records. +_RETIREMENT_COOL_OFF_PARAM = query_param( + "cool_off_days", + RetirementFilterSet.base_filters["cool_off_days"].extra["help_text"], + type=int, +) +_RETIREMENT_ORDERING_PARAM = query_param( + "ordering", + "Fields to sort by, in order of precedence, each optionally prefixed with `-` to sort " + "descending. A field that is not listed here is refused. Omit it to receive the retirements " + "in the order they were requested, which is the order the pipeline works them in.", + many=True, + enum=[*RETIREMENT_SORT_FIELDS, *(f"-{field}" for field in RETIREMENT_SORT_FIELDS)], + style="form", + explode=False, +) +_PARTNER_REPORT_PATH_PARAM = path_param( + "username", + "Name the account was registered under, spelled exactly as it was registered.", + USERNAME_PATTERN, +) + +#: A retirement comes back whole, as the fields the ``minimal`` preset keeps, +#: or as the properties ``fields`` selected. +_RETIREMENT_SHAPES = ResponseShapes( + component_name="RetirementResponse", + serializers=[ + RetirementSerializer, + RetirementMinimalSerializer, + RetirementProjectionSerializer, + ], +) + +_RESP_NO_SUCH_RETIREMENT = OpenApiResponse( + ErrorResponse, description="No retirement was requested under that name.", +) +#: What a refusal says at an address that finds the record it acts on by the +#: name the record was requested under. That name is not unique, so more than +#: one record may answer to it, and the request is refused rather than one of +#: them picked. +_RESP_AMBIGUOUS_OR_BAD_REQUEST = OpenApiResponse( + ErrorResponse, + description=( + "A query parameter is missing, invalid, or not one this endpoint reads, or more than one " + "record was requested under the name given. Nothing was changed." + ), +) +_RESP_AMBIGUOUS_OR_UNUSABLE_BODY = OpenApiResponse( + ErrorResponse, + description=( + "The request body is not well formed in the media type it was sent in, or carries a value " + "this endpoint will not take, or more than one record was requested under a name the " + "request gives. A query parameter this endpoint does not read is refused here too. " + "Nothing was changed." + ), +) +_RESP_RETIREMENT_STATE = OpenApiResponse( + ErrorResponse, + description=( + "The retirement does not stand at a step of the pipeline that admits this operation. " + "Nothing was changed." + ), +) +_RESP_BATCH_STATE = OpenApiResponse( + ErrorResponse, + description=( + "One or more of the records named is not in a state this operation acts on. Nothing was " + "deleted, and `errors` names each record at fault and why." + ), +) +_RESP_RETIREMENT_MOVED = OpenApiResponse(description="The retirement now stands at the step asked for.") +_RESP_RETIREMENT_CANCELLED = OpenApiResponse( + description="The retirement request is gone and the account it was made for is usable again.", +) +_RESP_DATA_ERASED = OpenApiResponse(description="The data named by the scope has been erased.") +_RESP_ERASURE_FAILED = OpenApiResponse( + ErrorResponse, + description=( + "A step of the erasure did not finish. Part of the data named by the scope may already " + "be gone, and the request may be sent again: each step clears what it clears whether or " + "not it has run before." + ), +) +_RESP_PARTNER_REPORT_QUEUED = OpenApiResponse( + description=( + "The account is queued for reporting to the organizations it studied with, or was not " + "enrolled with any and so needs no report." + ), +) + +class _RetirementRecordAccessMixin: + """ + Who may act on the retirement records, and which of those records a caller acts on. + + The records this version lists, moves and cancels are the records its + batch disposal deletes, so the capability each operation asks for and the + policy deciding which records a caller acts on are declared here, where + all of them resolve them, rather than once per view. A rule added here is + asked of the disposal as well as of the operations on one record. + + These are declared in a class resolved before the library mixin that + reads the policy, which declares a policy of its own for a view that has + none; the selection the policy narrows is declared in a class resolved + after it instead. + """ + + permission_classes = (permissions.IsAuthenticated,) + # The operations these addresses replace read a body as JSON alone, and + # one of them erases personal data for good, so the same narrowing is kept + # rather than widened to every media type the platform can parse. + parser_classes = (JSONParser,) + # Any caller holding the pipeline's capability may act on every + # retirement, so the policy keeps the records whole; a narrower policy can + # replace it here and is applied by every operation below. + scoping_policy = FullScopePolicy() + #: The capability each action asks for beyond the classes above. + action_permissions = {} + + def get_permissions(self): + """Return the classes deciding who may make the request being served.""" + if self.action is None: + # No action serves this method at this address, so there is no + # capability to ask for: the request is admitted by the classes + # every action shares and answered as a method this address does + # not serve. Each address this replaces serves one method and asks + # its capability of every caller it is given, so a caller without + # that capability is refused there before the method is weighed. + return [permission() for permission in self.permission_classes] + # An action this mapping does not name reaches no capability of this + # resource, rather than inheriting the one every other action shares. + specific = self.action_permissions.get(self.action, (~permissions.AllowAny,)) + return [permission() for permission in (*self.permission_classes, *specific)] + + +class _RetirementRecordsMixin: + """ + The retirement records, before they are narrowed to the ones a caller may act on. + + A view's policy is applied on top of the selection the classes resolved + after it answer with, so the unnarrowed selection is declared here rather + than on the view, where it would stand in front of the layer that narrows + it and leave the policy with nothing to do. + """ + + def get_queryset(self): + """Return the retirement records, before the policy narrows them to the caller's.""" + return retirements() + + +#: Who may make each request the retirement collection serves. Cancelling a +#: retirement is the one operation of this collection that is not pipeline +#: work: it undoes a request rather than carrying it out, so it is open to +#: whoever handles that for a learner and closed to the pipeline itself. An +#: action this mapping does not name is refused, so one added later cannot +#: reach the pipeline's capability by inheriting it. +RETIREMENT_ACTION_PERMISSIONS = { + "list": (CanRetireUser,), + "retrieve": (CanRetireUser,), + "partial_update": (CanRetireUser,), + "erasures": (CanRetireUser,), + "destroy": (CanCancelUserRetirement,), + "metadata": (CanRetireUser,), +} + + +# How this collection is built, which is written here rather than in the +# description the class carries: that description is what an OPTIONS request +# is answered with, so it is published text and belongs to the caller. +# +# A retirement is not written through generic machinery -- every change goes +# through the pipeline's own model layer, which is what decides whether a step +# may run. Access is layered: the permission classes decide who may call an +# action at all, the visibility policy decides which retirements such a caller +# may act on, and the filters narrow the page a list carries further. Every +# action resolves the records it reads or changes from the selection the policy +# left, a member address included, so a retirement the policy leaves out is +# reported as absent rather than refused. +@extend_schema(tags=[USER_TAG]) +@extend_schema_view( + list=extend_schema( + summary="List retirements", + description=( + "Returns a page of the retirement queue. Supply `state` to keep only the retirements " + "standing at one of the pipeline steps it names, `cool_off_days` to keep only those " + "requested at least that many days ago, and `created_after` or `created_before` to " + "bound when they were requested." + ), + parameters=[ + _RETIREMENT_VIEW_PARAM, + _FIELDS_PARAM, + _RETIREMENT_COOL_OFF_PARAM, + _RETIREMENT_ORDERING_PARAM, + ], + responses={ + 200: _RETIREMENT_SHAPES, + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NO_SUCH_PAGE, + 406: RESP_NOT_ACCEPTABLE, + }, + ), + retrieve=extend_schema( + summary="Get one retirement", + description=( + "Returns the retirement of the account this name addresses. A retired account is " + "stored under a hashed name, and both that name and the one the account was " + "registered under address the same retirement." + ), + parameters=[_RETIREMENT_PATH_PARAM, _RETIREMENT_VIEW_PARAM, _FIELDS_PARAM], + responses={ + 200: _RETIREMENT_SHAPES, + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NO_SUCH_RETIREMENT, + 406: RESP_NOT_ACCEPTABLE, + }, + ), + partial_update=extend_schema( + summary="Move a retirement to another pipeline step", + description=( + "Records that this retirement now stands at another step of the pipeline, and " + "appends what happened at the step it is leaving to its log. The steps and their " + "order are configured by the operator, and a step that is not a later one than the " + "current step is refused unless `force` is sent." + ), + parameters=[_RETIREMENT_PATH_PARAM], + request=RetirementUpdateSerializer, + responses={ + 204: _RESP_RETIREMENT_MOVED, + 400: _RESP_AMBIGUOUS_OR_UNUSABLE_BODY, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NO_SUCH_RETIREMENT, + 406: RESP_NOT_ACCEPTABLE, + 409: _RESP_RETIREMENT_STATE, + 415: RESP_UNREADABLE_BODY, + }, + ), + destroy=extend_schema( + summary="Cancel a retirement", + description=( + "Deletes this retirement request and returns the account it was made for to the " + "address it was registered under, with a password nobody holds, so its owner signs " + "in again by asking for a new one. Only a retirement the pipeline has not started " + "work on can be cancelled." + ), + parameters=[_RETIREMENT_PATH_PARAM], + responses={ + 204: _RESP_RETIREMENT_CANCELLED, + 400: _RESP_AMBIGUOUS_OR_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NO_SUCH_RETIREMENT, + 406: RESP_NOT_ACCEPTABLE, + 409: _RESP_RETIREMENT_STATE, + }, + ), + erasures=extend_schema( + summary="Erase a retiring account's data", + description=( + "Erases one set of the retiring account's personal data. This cannot be undone, and " + "which set is erased is named by `scope` in the body. The retirement is not moved by " + "this request: the caller records where it stands." + ), + parameters=[_RETIREMENT_PATH_PARAM], + request=RetirementErasureSerializer, + responses={ + 204: _RESP_DATA_ERASED, + 400: _RESP_AMBIGUOUS_OR_UNUSABLE_BODY, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NO_SUCH_RETIREMENT, + 406: RESP_NOT_ACCEPTABLE, + 409: _RESP_RETIREMENT_STATE, + 415: RESP_UNREADABLE_BODY, + 500: _RESP_ERASURE_FAILED, + }, + ), +) +class RetirementViewSet( + StandardizedErrorMixin, + CatalogedRequestErrorsMixin, + ErrorExtensionsMixin, + _ActionAddressMixin, + _ResponsePresetMixin, + StrictQueryParametersMixin, + _RetirementRecordAccessMixin, + ScopedQuerysetMixin, + _RetirementRecordsMixin, + viewsets.GenericViewSet, +): + """ + The accounts being retired, addressed by the name each was registered under. + + A retirement records one account's progress through the steps the operator + has configured. The requests here carry that work out; where an account + stands is recorded separately, by the caller driving them. + + A read answers for either the name the account was registered under or the + hashed name it was given when retired: both address the same account. A + write takes the registered name alone, spelled exactly as it was + registered, so it reaches one account and no other. + + Cancelling a retirement is open to a different caller from the requests + that carry one out. + """ + + serializer_class = RetirementSerializer + pagination_class = UserApiPagination + action_permissions = RETIREMENT_ACTION_PERMISSIONS + lookup_field = "username" + # The router's default lookup pattern excludes dots, which usernames allow. + lookup_value_regex = settings.USERNAME_REGEX_PARTIAL + filter_backends = (DjangoFilterBackend, StrictOrderingFilter) + filterset_class = RetirementFilterSet + ordering_fields = RETIREMENT_SORT_FIELDS + ordering = RETIREMENT_DEFAULT_SORT + # A sort the caller asks for is completed with the order the queue is + # worked in, so that retirements comparing equal keep one relative order + # from page to page and none is seen twice or missed. + ordering_tiebreak = ("id",) + minimal_fields = RETIREMENT_MINIMAL_FIELDS + response_presets = RETIREMENT_PRESETS + extra_query_params = RESPONSE_SHAPING_PARAMS + + def known_query_params(self): + """Return the query parameters the action being served reads.""" + names = super().known_query_params() + if self.action != "list": + # One retirement is answered whole, so neither the filters that + # choose which retirements a page carries nor the parameters that + # cut it into pages are read at a member address. + names -= set(self.filterset_class.base_filters) + names -= { + StrictOrderingFilter.ordering_param, + self.pagination_class.page_query_param, + self.pagination_class.page_size_query_param, + } + if self.action not in SHAPED_RETIREMENT_ACTIONS: + names -= set(RESPONSE_SHAPING_PARAMS) + return names + + def list(self, request, *args, **kwargs): + """Return a page of the retirement queue, shaped as the caller asked.""" + page = self.paginate_queryset(self.filter_queryset(self.get_queryset())) + serializer = self.get_serializer(page, many=True) + return self.shape_page(self.get_paginated_response(serializer.data), request) + + def retrieve(self, request, username): + """Return the retirement of the account this name addresses.""" + retirement = retirement_of_the_account_named(self.get_queryset(), username) + if retirement is None: + log.info("No retirement is held for an account addressed as %r.", username) + raise NotFound(NO_SUCH_RETIREMENT) + return Response(self.shape(self.get_serializer(retirement).data, request)) + + def partial_update(self, request, username): + """Record that this retirement now stands at another step of the pipeline.""" + changes = RetirementUpdateSerializer(data=request.data) + changes.is_valid(raise_exception=True) + move_retirement( + retirement_requested_as(self.get_queryset(), username), + changes.validated_data["new_state"], + changes.validated_data["response"], + changes.validated_data["force"], + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + def destroy(self, request, username): + """Undo this retirement request and return the account to its owner.""" + cancel_retirement(retirement_requested_as(self.get_queryset(), username)) + return Response(status=status.HTTP_204_NO_CONTENT) + + @action(detail=True, methods=["post"], url_path="erasures", url_name="erasure") + def erasures(self, request, username): + """Erase one set of the retiring account's personal data.""" + erasure = RetirementErasureSerializer(data=request.data) + erasure.is_valid(raise_exception=True) + erase_account_data( + retirement_open_to_action(self.get_queryset(), username), + erasure.validated_data["scope"], + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + + +class _PartnerReportAccessMixin: + """ + Who may act on the partner report entries, and which of those entries a caller acts on. + + The entries this version queues and takes for processing are the entries + its batch disposal deletes, so the capability every operation of them asks + for and the policy deciding which of them a caller acts on are declared + here, where all of them resolve them. A rule added here is asked of the + disposal as well as of the queue. Which entries each operation starts from + differs -- one takes those nobody has taken, the other disposes of those + that have been -- so that selection is declared by each of them, in a + class resolved after the library mixin that reads the policy. + """ + + permission_classes = (permissions.IsAuthenticated, CanRetireUser) + # The operations these addresses replace read a body as JSON alone, and + # that narrowing is kept rather than widened to every media type the + # platform can parse. + parser_classes = (JSONParser,) + scoping_policy = FullScopePolicy() + + +class _UnclaimedPartnerReportsMixin: + """ + The partner reports waiting to be processed, before they are narrowed to the ones a caller may see. + + A view's scoping policy is applied on top of the selection the classes + resolved after it answer with, so the unnarrowed selection is declared + here rather than on the view, where it would stand in front of the layer + that narrows it and leave the policy with nothing to do. + """ + + def get_queryset(self): + """Return the partner reports nobody has taken for processing.""" + return unclaimed_partner_reports() + + +class _EveryPartnerReportMixin: + """ + Every partner report entry, before they are narrowed to the ones a caller may act on. + + Declared in a class of its own for the same reason as the queue's own + selection, and separately from it because an entry is disposed of once it + has been taken for processing, which is what the queue leaves out. + """ + + def get_queryset(self): + """Return every partner report entry, taken for processing or not.""" + return partner_reports() + + +_RESP_PARTNER_REPORTS_TAKEN = OpenApiResponse( + RetirementPartnerReportRecordSerializer(many=True), + description=( + "The entries taken by this request, which are exactly the entries it marked as being " + "processed. A second request returns the entries added since." + ), +) + + +@extend_schema(tags=[USER_TAG]) +@extend_schema_view( + create=extend_schema( + summary="Take partner reports for processing", + description=( + "Returns the queued partner reports nobody has taken yet and marks the ones it " + "returns as being processed, so that two callers do not report the same account. " + "Nothing releases an entry once it has been taken, which is why this is a write " + "rather than a read and why the answer is not cut into pages: a page would hand the " + "caller the first entries while taking all of them." + ), + parameters=[ + query_param( + "limit", + "Take at most this many entries. Omit it to take every entry waiting.", + type=int, + ), + ], + request=None, + responses={ + 200: _RESP_PARTNER_REPORTS_TAKEN, + 400: _RESP_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 406: RESP_NOT_ACCEPTABLE, + }, + ), + update=extend_schema( + summary="Queue an account for partner reporting", + description=( + "Queues the retiring account this name addresses for reporting to the organizations " + "running the courses it was enrolled in. An account enrolled with no organization " + "needs no report and is not queued. Sending this twice for the same account leaves " + "one entry." + ), + parameters=[_PARTNER_REPORT_PATH_PARAM], + request=None, + responses={ + 204: _RESP_PARTNER_REPORT_QUEUED, + 400: _RESP_AMBIGUOUS_OR_BAD_REQUEST, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 404: _RESP_NO_SUCH_RETIREMENT, + 406: RESP_NOT_ACCEPTABLE, + 409: _RESP_RETIREMENT_STATE, + }, + ), +) +class RetirementPartnerReportViewSet( + StandardizedErrorMixin, + CatalogedRequestErrorsMixin, + ErrorExtensionsMixin, + _ActionAddressMixin, + StrictQueryParametersMixin, + _PartnerReportAccessMixin, + ScopedQuerysetMixin, + _UnclaimedPartnerReportsMixin, + viewsets.ViewSet, +): + """ + The accounts whose retirement still has to be reported to the organizations they studied with. + + An entry is added as the pipeline finishes an account, and carries the name + and address that account held before it was retired together with the short + names of the organizations running the courses it was enrolled in. + + Taking entries is a write rather than a read: a request is answered with + exactly the entries it has marked as taken, so an entry added while that + answer is being built is left for the next caller rather than reported to + this one. Nothing releases an entry once it has been taken. + """ + + serializer_class = RetirementPartnerReportRecordSerializer + #: Validates the query string, and names the parameters this view reads. + query_serializer_class = RetirementPartnerReportQuerySerializer + lookup_field = "username" + # The router's default lookup pattern excludes dots, which usernames allow. + lookup_value_regex = settings.USERNAME_REGEX_PARTIAL + + def get_serializer(self, *args, **kwargs): + """Build the output serializer; a plain ViewSet has no generic machinery to do it.""" + return self.serializer_class(*args, **kwargs) + + def known_query_params(self): + """Return the query parameters the action being served reads.""" + names = super().known_query_params() + if self.action != "create": + # How many entries to take is only meaningful where entries are taken. + names -= set(self.query_serializer_class().fields) + return names + + def create(self, request): + """Take the queued partner reports and return them.""" + query = self.query_serializer_class(data=request.query_params) + query.is_valid(raise_exception=True) + reports = claim_partner_reports(self.get_queryset(), query.validated_data.get("limit")) + return Response(self.get_serializer(reports, many=True).data) + + def update(self, request, username): + """Queue the retiring account this name addresses for partner reporting.""" + # The record this queues belongs to the retirement collection rather + # than to this one, whose own selection is of queue entries, so the + # records it is resolved from are named here and the policy this view + # declares is applied to them, as it is to everything else it acts on. + records = self.scoping_policy.scope(retirements(), request.user) + record_partner_report(retirement_open_to_action(records, username)) + return Response(status=status.HTTP_204_NO_CONTENT) + + +# The entries a request answers with are read first and marked by the +# identifiers that were read, which is what keeps an entry added during the +# read from being marked by a caller that never saw it; the enrolments behind +# the organization names are fetched with the entries so that the cost of the +# answer does not grow with the number of entries. Written here rather than in +# the description the class carries, which is published to every caller that +# asks an address what it does. +_RESP_BATCH_OUTCOME = OpenApiResponse( + RetirementBatchOutcomeSerializer, + description=( + "What was done about each name given. A name with no record left is reported as such " + "rather than refused, so a repeat of a request already carried out is answered." + ), +) + + +# One operation over a set of records named in its body, with a condition that +# covers the whole set and no member address of its own, so it stands at a +# collection of its own rather than among the operations on one record. Who may +# call it and which records it deletes are resolved through the layers those +# operations resolve them through, so a rule added to the records is asked of +# their disposal too. Written here rather than in the description the class +# carries, which is published to every caller that asks what this address does. +@extend_schema(tags=[USER_TAG]) +@extend_schema_view( + create=extend_schema( + summary="Delete finished retirement records", + description=( + "Deletes the retirement records of the accounts named, once the pipeline has " + "finished with them. The record's own copies of the account's name, address and " + "full name are written over before it is deleted, so that a system syncing these " + "rows and treating a delete as a soft one is left holding the replacement. A record " + "the pipeline has not finished with refuses the whole batch, and nothing is deleted." + ), + request=RetirementBatchSerializer, + responses={ + 200: _RESP_BATCH_OUTCOME, + 400: _RESP_AMBIGUOUS_OR_UNUSABLE_BODY, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 406: RESP_NOT_ACCEPTABLE, + 409: _RESP_BATCH_STATE, + 415: RESP_UNREADABLE_BODY, + }, + ), +) +class RetirementCleanupViewSet( + StandardizedErrorMixin, + CatalogedRequestErrorsMixin, + ErrorExtensionsMixin, + StrictQueryParametersMixin, + _RetirementRecordAccessMixin, + ScopedQuerysetMixin, + _RetirementRecordsMixin, + viewsets.ViewSet, +): + """ + Disposes of a set of finished retirement records. + + The accounts to dispose of are named in the body, and every one of them has + to name a retirement the pipeline has finished with: if any does not, the + whole batch is refused and nothing is deleted. + + This is not how a retirement request is called off. That works on a request + the pipeline has not started on, is asked for one account at a time, and is + open to a different caller. + """ + + serializer_class = RetirementBatchOutcomeSerializer + #: Disposing of finished records is pipeline work, so it asks for the + #: capability the rest of that work asks for. + action_permissions = {"create": (CanRetireUser,), "metadata": (CanRetireUser,)} + + def get_serializer(self, *args, **kwargs): + """Build the output serializer; a plain ViewSet has no generic machinery to do it.""" + return self.serializer_class(*args, **kwargs) + + def create(self, request): + """Delete the retirement records of the accounts the body names.""" + batch = RetirementBatchSerializer(data=request.data) + batch.is_valid(raise_exception=True) + results = delete_retirements(self.get_queryset(), batch.validated_data["usernames"]) + return Response(self.get_serializer({"results": results}).data) + + +# This disposal stands at a collection of its own for the same reason the +# retirement records' own disposal does, and the two are kept apart because +# they act on different records in different tables under different conditions. +# Who may call this one and which entries it deletes are resolved through the +# layers the operations on those entries resolve them through. Written here +# rather than in the description the class carries, which is published to every +# caller that asks what this address does. +@extend_schema(tags=[USER_TAG]) +@extend_schema_view( + create=extend_schema( + summary="Delete reported partner report entries", + description=( + "Deletes the partner report entries of the accounts named, once they have been " + "reported on. An entry nobody has taken for processing has not been reported on " + "yet, so it refuses the whole batch and nothing is deleted." + ), + request=RetirementBatchSerializer, + responses={ + 200: _RESP_BATCH_OUTCOME, + 400: _RESP_AMBIGUOUS_OR_UNUSABLE_BODY, + 401: RESP_UNAUTHENTICATED, + 403: _RESP_FORBIDDEN, + 406: RESP_NOT_ACCEPTABLE, + 409: _RESP_BATCH_STATE, + 415: RESP_UNREADABLE_BODY, + }, + ), +) +class RetirementPartnerReportCleanupViewSet( + StandardizedErrorMixin, + CatalogedRequestErrorsMixin, + ErrorExtensionsMixin, + StrictQueryParametersMixin, + _PartnerReportAccessMixin, + ScopedQuerysetMixin, + _EveryPartnerReportMixin, + viewsets.ViewSet, +): + """ + Disposes of a set of partner report entries that have been reported on. + + The accounts whose entries are to be disposed of are named in the body, and + every one of them has to name an entry that was taken for reporting: an + entry nobody has taken has not been reported on yet, so it refuses the + whole batch and nothing is deleted. + """ + + serializer_class = RetirementBatchOutcomeSerializer + + def get_serializer(self, *args, **kwargs): + """Build the output serializer; a plain ViewSet has no generic machinery to do it.""" + return self.serializer_class(*args, **kwargs) + + def create(self, request): + """Delete the partner report entries of the accounts the body names.""" + batch = RetirementBatchSerializer(data=request.data) + batch.is_valid(raise_exception=True) + results = delete_partner_reports(self.get_queryset(), batch.validated_data["usernames"]) + return Response(self.get_serializer({"results": results}).data) diff --git a/openedx/core/tests/test_apidocs.py b/openedx/core/tests/test_apidocs.py index 4d7d79a49eb2..308c9685cf66 100644 --- a/openedx/core/tests/test_apidocs.py +++ b/openedx/core/tests/test_apidocs.py @@ -44,13 +44,21 @@ class UntagDeprecatedOperationsRegistrationTest(SimpleTestCase): """Every schema configuration runs the hook after the hooks that mark operations deprecated.""" # The other environments inherit the hooks from these two modules. + SHARED_HOOK = "'openedx.core.djangoapps.user_api.schema.mark_superseded_user_paths'," MARKING_HOOKS = { - "cms/envs/common.py": "'cms.lib.spectacular.cms_mark_migrated_paths',", - "lms/envs/common.py": "'lms.lib.spectacular.lms_mark_legacy_paths_deprecated',", + "cms/envs/common.py": ( + "'cms.lib.spectacular.cms_mark_migrated_paths',", + SHARED_HOOK, + ), + "lms/envs/common.py": ( + "'lms.lib.spectacular.lms_mark_legacy_paths_deprecated',", + SHARED_HOOK, + ), } def test_the_hook_runs_last(self): - for module, marking_hook in self.MARKING_HOOKS.items(): + for module, marking_hooks in self.MARKING_HOOKS.items(): source = (REPO_ROOT / module).read_text() assert HOOK in source, module - assert source.index(marking_hook) < source.index(HOOK), module + for marking_hook in marking_hooks: + assert source.index(marking_hook) < source.index(HOOK), (module, marking_hook)