Repository navigation
feat: standardize the registration, sign-in and password reset APIs - #39331
Abdul-Muqadim-Arbisoft wants to merge 130 commits into
Conversation
The user directory, the stored-preference listings and the time-zone
reference list were the least conforming endpoints of the User API: the
only gate was the deprecated X-Edx-Api-Key header, which ignores
request.user entirely, so any holder of the key could read every user's
email address and full preference mapping. The listings were addressed by
database id, two of them put the parent key in the path instead of the
query string, the time-zone list returned an unbounded array, the stored
preference row embedded its owner's entire record (one extra query per
row), and the forum-role listing created the role it was asked about on
every GET.
Each of those is a contract change, so they land on a new version and
/api/user/v1/ is untouched and keeps serving them.
What callers get instead:
GET /api/user/v2/users/ (also ?preference_key=,
?forum_role=&course_key=)
GET /api/user/v2/users/{username}/
GET /api/user/v2/user_preferences/
GET /api/user/v2/user_preferences/{username},{key}/
GET /api/user/v2/time_zones/
Authenticated staff callers rather than an API key, the standard error
envelope, the standard pagination envelope on all three lists, filter
sets and a deterministic ordering, and the ?view= and ?fields= response
presets. The forum-role filter no longer creates a role, and an
unparsable course key is a validation error rather than a 500.
Being refused is part of the contract, so the refusals are explicit. A
deactivated staff account is turned away on both paths rather than only
one: the session authenticator already rejected it, but IsAdminUser reads
only is_staff and the JWT authenticator does not read is_active, so such
an account could read the whole directory until its token expired. These
addresses require an active user.
An unrecognised query parameter is a 400 rather than silence. A client
porting from a superseded address by changing the version segment alone
would otherwise pass a filter this version does not define, have it
ignored, and receive every row believing the page was filtered. The
allowlist is derived from the paginator, the ordering backend, the filter
set and the query serializer, so it cannot drift away from the filters it
describes.
Shared building blocks are used rather than rewritten:
StandardizedErrorMixin, DefaultPagination, IterablePaginationMixin,
ScopedQuerysetMixin with FullScopePolicy, MinimalViewMixin and project.
The two local classes each supply one predicate the library does not,
and each docstring says which.
The tests pin behaviour rather than structure: every allowed and every
refused caller with the error type asserted, value-level parity with each
superseded address, query counts that stay constant across 1, 5 and 25
rows where the superseded listing pays per row, SQL captures proving no
GET writes, and the URL literals each name reverses to.
The LMS drf-spectacular pre-processing hook is an allowlist and admitted only /api/enrollment/vN/, so everything under /api/user/ generated, routed and tested while being invisible in the published document. The new v2 addresses would have shipped undocumented and the schema gate would have reported the prefix missing with no clue as to why. The filter now admits exactly what this change owns: the v2 addresses, matched by their view package rather than by path prefix, plus the seven legacy paths they supersede, so each superseded address appears beside its replacement. It is deliberately not a /api/user/vN/ prefix. Admitting the whole prefix pulled thirty-eight further v1 operations into the document -- undocumented request bodies and internal docstrings published as client-facing text -- none of which this change owns or documents. SCHEMA_PATH_PREFIX becomes a two-branch pattern, stripped only to derive tags and operationIds. Every existing enrollment operationId was compared before and after and is unchanged, and so is every operationId, path and component of the service-wide /api-docs document: a generated client derives a module per operationId and a model per component, so a rename there deletes a module as surely as a removal does. The title follows the contents, and the three comments that named the old title follow it. Deprecation is marked where it belongs, outside the frozen modules: the existing post-processing hook gains a tuple of the seven exact legacy paths this change supersedes. Exact paths, not a prefix, so an address whose replacement nobody has written yet can never be marked -- the hook test includes neighbouring user addresses, the unversioned twins and another area's paths and asserts they are left alone. POSTPROCESSING_HOOKS is unchanged: setting it replaces drf-spectacular's defaults, so the enum hook stays restated and stays first.
Evidence for this PR: documents, URL names, test ids, mutations, query counts, gates, lintTrees: base = This PR now has 23 commits, head The 22 commits (
|
| document | paths | operationIds | components.schemas |
operations changed in place | verdict |
|---|---|---|---|---|---|
LMS /lms-api/schema/ |
73 → 83 (0 removed; +5 new, +5 superseded keys) | 92 → 106 (0 removed, +14) | 72 → 72 | 0 | RESULT: PASS (0 BREAKING, 0 WARN, 15 INFO) |
LMS /api-docs/ |
519 → 524 (0 removed, +5) | 689 → 696 (0 removed, +7) | 249 → 249 | 9 | RESULT: FAIL (2 BREAKING, 0 WARN, 17 INFO), the two declared lines |
Studio /api-docs/ |
248 → 248 | 348 → 348 | 210 → 210 | 9 | RESULT: FAIL (2 BREAKING, 0 WARN, 9 INFO), the same two lines |
Studio /authoring-api/schema/ |
69 → 69 | 93 → 93 | 94 → 94 | 0; byte-identical (sorted-JSON sha256 4c19412df22d… at base, 7325fef195 and head) |
RESULT: PASS (0 BREAKING, 0 WARN, 0 INFO) |
docs.docs_settings |
508 → 513 (0 removed, +5) | 679 → 686 (0 removed, +7) | 243 → 243 | 9 | RESULT: FAIL (2 BREAKING, 0 WARN, 17 INFO), the same two lines |
The nine operations changed in place are the same in both service-wide documents and in docs.docs_settings.
Each gains deprecated: true. The six that read a body or answer a form also gain requestBody (the four POSTs
below) and responses:
GET /api/user/v1/account/registration/ deprecated, responses
POST /api/user/v1/account/registration/ deprecated, requestBody, responses
GET /api/user/v2/account/registration/ deprecated, responses
POST /api/user/v2/account/registration/ deprecated, requestBody, responses
GET /api/user/v1/account/password_reset/ deprecated, responses
POST /api/user/v1/account/password_reset/token/validate/ deprecated, requestBody, responses
POST /api/user/v1/validation/registration deprecated, requestBody, responses
GET /api/user/{api_version}/account/login_session/ deprecated, responses
POST /api/user/{api_version}/account/login_session/ deprecated
Published shapes of the superseded registration POSTs (moved from the PR body). In both service-wide documents
and in /lms-api/schema/ they are the replacement's. password is one of three anyOf alternatives rather than a
top-level required member, and the 400 is oneOf the field-keyed body and {detail}. Neither is a removal:
at base these operations published no request body and no typed 400.
In /lms-api/schema/ the five superseded keys enter the document already marked, and the templated sign-in key
is left out (PR body, 0027 gap 2). The new operations are published as v2_registrations_retrieve/_create,
v2_registration_validations_create, v2_login_sessions_retrieve/_create, v2_password_resets_retrieve and
v2_password_reset_token_validations_create in /lms-api/schema/, and with api_user_ in front in the
service-wide documents.
The same documents, 7325fef195 → head
Generated at both trees by the same probe. One leaf differs, the same one in the three LMS documents. Studio's two
documents are byte-identical.
| document | bytes | sorted-JSON sha256 | leaves differing | paths / operationIds / components |
|---|---|---|---|---|
LMS /lms-api/schema/ |
361966 → 362009 | fc5ede9974ca → 68435e73af0a |
1 | 83 / 106 / 72, none removed or added |
LMS /api-docs/ |
1122424 → 1122467 | 3a75957a8e4d → c7730d9f3e51 |
1 | 524 / 696 / 249, none removed or added |
Studio /api-docs/ |
685059 → 685059 | 7762f5d7371d, byte-identical |
0 | 248 / 348 / 210 |
Studio /authoring-api/schema/ |
279389 → 279389 | 4c19412df22d, byte-identical |
0 | 69 / 93 / 94 |
docs.docs_settings |
1699428 → 1699531 | 708b15952362 → 2e72fa9521f6 |
1 | 513 / 686 / 243, none removed or added |
paths./api/user/v2/login_sessions/.post.responses.403.content.application/json.schema.required
["success"] -> ["success", "value", "error_code"]
schema_diff.py compare reads all five pairs as RESULT: PASS (0 BREAKING, 0 WARN, 0 INFO). It does not compare
a response's required list, so this leaf diff is the evidence. The change adds a promise about a response; no
request shape, code, path or name changed.
The same documents, master → head (measured at 7325fef195)
| document | paths | operationIds | components.schemas |
verdict master → head | verdict master → base |
|---|---|---|---|---|---|
LMS /lms-api/schema/ |
19 → 83 (0 removed) | 25 → 106 (0 removed) | 8 → 72 (0 removed) | RESULT: PASS (0 BREAKING, 7 WARN, 83 INFO) |
RESULT: PASS (0 BREAKING, 7 WARN, 69 INFO) |
LMS /api-docs/ |
495 → 524 (0 removed) | 658 → 696 (0 removed) | 198 → 249 (0 removed) | RESULT: FAIL (30 BREAKING, 37 WARN, 85 INFO) |
RESULT: FAIL (28 BREAKING, 37 WARN, 69 INFO) |
Studio /api-docs/ |
248 → 248 | 348 → 348 | 198 → 210 (0 removed) | RESULT: FAIL (30 BREAKING, 33 WARN, 46 INFO) |
RESULT: FAIL (28 BREAKING, 33 WARN, 37 INFO) |
Studio /authoring-api/schema/ |
69 → 69 | 93 → 93 | 93 → 94 (0 removed) | RESULT: PASS (0 BREAKING, 0 WARN, 1 INFO) |
RESULT: PASS (0 BREAKING, 0 WARN, 1 INFO) |
docs.docs_settings |
484 → 513 (0 removed) | 648 → 686 (0 removed) | 192 → 243 (0 removed) | RESULT: FAIL (30 BREAKING, 37 WARN, 85 INFO) |
RESULT: FAIL (28 BREAKING, 37 WARN, 69 INFO) |
The BREAKING and WARN lines of master → head differ from those of master → base by exactly the two declared
request body newly required lines in each service-wide document, and by nothing else. Every other line is the
stack's, already present at base. Nothing is removed against master in any document, at base or at head. The one
INFO line of Studio's /authoring-api/schema/ is the component PatchedUserRetirementStatus, which #39330 adds to
every document Studio builds; no operation refers to it, and it is the same at base.
URL names (the URL gate's walker, run in each tree; head here is 7325fef195, and the last two commits touch no route)
| service | base → head | master → head |
|---|---|---|
| LMS | 3059 → 3064 routes; 2537 → 2542 names; 0 removed; +5: user_v2:registration_list, registration_validation_list, login_session_list, password_reset_list, password_reset_token_validation_list |
3034 → 3064 routes; 2513 → 2542 names; 0 removed |
| Studio | 2193 routes, 1790 names, unchanged | unchanged |
url_snapshot.py check, base → head: LMS RESULT: PASS (0 FAIL, 1 WARN) (R10's verb heuristic on
login_sessions) and Studio RESULT: PASS (0 FAIL, 0 WARN). Master → head: LMS RESULT: FAIL (2 FAIL, 3 WARN).
The two FAILs (R6, R8 on api/user/v2/{_}., the stack's fallback route) are already in master → base
(RESULT: FAIL (2 FAIL, 2 WARN)). This PR adds only the R10 WARN.
Test ids (--collect-only, LC_ALL=C sort -u, comm)
| set | base | 7325fef195 |
head | removed | added, base → head |
|---|---|---|---|---|---|
LMS: user_api/, profile_images/, lms/lib/tests/, lms/tests.py, openedx/core/tests/test_apidocs.py |
2125 | 2265 | 2266 | 0 | 141: 138 in test_authn_addresses.py, 2 in lms/lib/tests/test_spectacular.py, 1 in test_url_mount.py |
user_authn/ |
556 | 556 | 556 | 0 | 0 |
Studio cms/lib/tests/ |
35 | 40 | 40 | 0 | 5 in cms/lib/tests/test_spectacular.py |
Both steps were measured; neither removes an id. Run at 7325fef195: the LMS set 2265 passed, user_authn/ 555
passed and 1 skipped (a skip the test settings cause in test_register.py, the same at base), Studio 40 passed. Run at head: v2/tests + lms/lib/tests 1530 passed, Studio 40 passed, and
test_authn_addresses.py 138 passed under each of pytest-randomly seeds 1 to 5.
Changes between this PR's own commits (for commit-by-commit review; none is visible against base):
- Renamed:
ConformingDocumentTests::test_signing_in_publishes_the_security_every_other_address_here_publishes
became::test_signing_in_publishes_the_security_the_older_sign_in_publishesinfix: publish the new sign-in with the CSRF requirement the older sign-in publishes. Its old assertion (csrfabsent from the new sign-in's
security) is now the opposite. - Same id, wider body:
LoginLimitTests::test_failures_here_count_at_every_older_addressgains the direction
from the new address to/api/user/v2/account/login_session/(test: show both sign-in limits shared in every direction). - Expectation changed: Studio
SupersededUserOperationTest::test_a_registration_names_the_fields_the_stock_form_requires
(added infix: publish the refusals of an unusable Accept header and an unreadable body) and
RefusedRepresentationTests::test_the_fields_published_as_required_are_the_ones_the_stock_form_requires. In
fix: publish the registrations that need no password,requiredbecamename, username, email, honor_code
plus theanyOfof the three ways of registering. - Moved: in
fix: keep the sign-in address that takes its version as a parameter out of the standardized document, the templated sign-in key moves toSUPERSEDED_USER_ADDRESSES_LEFT_OUTintest_schema.pyand
becomes a neighbour the LMS filter does not admit. The"account"tag group thatfeat: mark …had added is
removed again, so the tag-group test is unchanged against base. - Same ids, wider bodies: in
test: hold the members each response promises to the bodies really sent, every
real-response check ofPublishedShapeTests,RefusedRepresentationTests,ExtensionFormShapeTestsand
SignInWithAnExposedPasswordTestsalso holds each body to the members its operation publishes as always
present, and requests were added that leave a conditional member out. One test is new:
test_the_members_published_as_always_present_are_held_to_the_ones_each_body_carries.
Existing assertions this PR changes (against base; moved from the PR body)
test_schema.py::test_only_this_version_and_the_addresses_it_supersedes_are_published: the expected set gains
the five new addresses and the five superseded keys this document admits.test_schema.py::test_addresses_owned_by_other_apps_are_not_published:/api/user/v1/account/registration/and
/api/user/v2/account/registration/are removed from its list, because both are now published as
superseded. Nothing replaces them, so the test now checks four addresses instead of six. Their presence is
asserted by the test above and bytest_every_superseded_address_has_a_published_replacement.test_schema.py::test_hook_leaves_other_resources_alone:/api/user/v2/account/registration/moves from
untouchedtomarked, and its unmarked twin/user_api/v2/account/registration/takes its place.test_schema.py::test_every_superseded_address_has_a_published_replacement: the tuple must equal the published
superseded addresses plus the one left out (the templated sign-in key). That key must be absent from the
document, and its replacement must be present.lms/lib/tests/test_spectacular.py::LmsApiFilterTest::test_keeps_enrollment_this_version_and_the_addresses_it_supersedes:
/api/user/v2/account/registration/moves from the inputs left out to the expected result, and
/user_api/v2/account/registration/takes its place among the inputs left out.lms/lib/tests/test_spectacular.py::LmsApiFilterTest::test_under_the_version_prefix_the_view_package_decides:
the input route changes from/api/user/v2/account/registration/to/api/user/v2/login_sessions/, because
the old input is now admitted as superseded and can no longer show the view package deciding. The assertion
itself is unchanged: authentication-app views are not admitted under the prefix, and version 2's are.test_views.py::AddressSelfDescriptionTests::test_every_mounted_address_is_described: the routed view
classes go from 17 to 22, adding the five here.- Tables extended, not changed:
test_schema.pySUPERSEDED_USER_ADDRESSES(+5),test_urls.py
EXPECTED_ADDRESSESand theRouteShapeTestsname list (+5 each), and Studio'sSUPERSEDED_USER_OPERATION_IDS
(+9) andSUPERSEDED_USER_EXCHANGES(+8). The existing tests that read them now cover the new entries too. - Docstrings only:
test_url_mount.py::SharedPrefixTestsand
test_urls.py::MalformedAddressTests::test_every_address_left_to_the_other_app_is_one_that_app_servesnow
describe the real match order. One test pins that order:
SharedPrefixTests::test_the_authentication_app_and_the_rest_of_the_user_surface_are_matched_before_this_mount.
The 23rd commit changes no existing assertion; it adds ServedCredentialSchemesTest to both
lib/tests/test_spectacular.py.
Mutations: each behaviour reverted inside a container copy, and the suite going red
At head 47925143f3, each on a copy of head's tree; test_authn_addresses.py has 138 tests:
| mutation | red |
|---|---|
M1: user_id dropped from the registration's authenticated_user.required |
PublishedShapeTests::test_a_registration_and_its_refusals (1 failed, 137 passed). v2/tests + lms/lib/tests: 1 failed, 1529 passed |
M2: is_valid dropped from the token check's 200 required |
PublishedShapeTests::test_a_token_check_and_its_refusals. v2/tests + lms/lib/tests: 1 failed, 1529 passed |
M3: validation_decisions dropped from the validation's 200 required |
PublishedShapeTests::test_a_validation_and_its_refusals |
M4: success dropped from the sign-in's 200 required |
PublishedShapeTests::test_a_sign_in_and_its_refusals |
M5 to M7: a member carried in some cases only made always present (the validation's username_suggestions; every form field's options; one member of the sign-in refusals) |
M5 ::test_a_validation_and_its_refusals; M6 ::test_the_forms and ExtensionFormShapeTests::test_a_field_the_site_adds_is_described_as_published; M7 ::test_a_sign_in_and_its_refusals and both SignInWithAnExposedPasswordTests |
| M8: the new sign-in's 403 given the 400 refusals' shape again | PublishedShapeTests::test_a_sign_in_and_its_refusals |
M9: user_id dropped from required and listed as conditional, to hide the loosening |
PublishedShapeTests::test_a_registration_and_its_refusals, at the guard that each listed member is seen both carried and left out |
| M10: the one request whose 409 leaves out a listed member removed | the same test, at the same guard |
M11: detail dropped from the DRF refusal's required |
8 tests across PublishedShapeTests, RefusedRepresentationTests and SignInWithAnExposedPasswordTests |
| M12: the check told to ignore the list of conditional members | 8 tests, the unit test among them |
At 7325fef195, before those pins existed, the M1 and M2 loosenings left v2/tests + lms/lib/tests (1529
passed) and Studio (40 passed) green, which is why they were added. A type change at that tree (the token check's
is_valid published as text) was already red through real-response validation
(PublishedShapeTests::test_a_token_check_and_its_refusals, 1 failed, 268 passed), while Studio's exchange table
stayed green: it reads the same constants the document is built from.
At 7e6a731143, the same 20 commits as 7325fef195 on #39330's previous push f01a160802. The two files these
mutate, openedx/core/djangoapps/user_api/schema.py and lms/lib/spectacular.py, are identical at head:
| mutation | red |
|---|---|
the templated sign-in key dropped from SUPERSEDED_USER_PATHS |
Studio 3 failed, 36 passed, including SupersededUserOperationTest::test_every_operation_of_those_addresses_is_marked_superseded (reads Studio's own document); LMS 4 failed, including SupersededDocumentTests::test_each_operation_is_marked_superseded_in_both_documents and lms/lib/tests/test_spectacular.py::MarkSupersededUserPathsTest::test_the_authentication_addresses_are_deprecated_and_their_neighbours_are_not |
the version 1 registration read's entry removed from superseded_bodies() |
Studio 2 failed (the exchange test and the media-type refusal test); LMS 3 failed, including SupersededDocumentTests::test_each_operation_publishes_what_the_one_replacing_it_publishes |
| the LMS filter admitting the templated key | LMS 6 failed, including SupersededDocumentTests::test_the_narrow_document_leaves_out_the_address_that_takes_its_version_as_a_parameter; Studio 39 passed, as it should, since Studio has no such filter |
During the build, at commit 99dda75f28 (tree c67341fca4, on an earlier tip of the stack, d0f1d502bc).
The runtime code these exercise has not changed since:
| mutation | red |
|---|---|
drop api_version="v2" from the sign-in route |
16 tests (every sign-in parity test) |
registration given a post of its own under a fresh rate limit |
both shared-counter tests of RegistrationLimitTests |
| rename the registration or the validation class | both shared-counter tests of each |
validation post under a fresh rate limit |
RegistrationValidationLimitTests::* (2) |
| token check without its throttle | the two shared-counter tests of TokenValidationLimitTests |
| token check without the platform authentication | CSRF 403, malformed-JWT 401, JWT and own-token tests (7) |
| registration declaring the default authentication pair | RegistrationParityTests::test_credentials_in_a_header_are_not_read |
sign-in post calling the sign-in function directly (no csrf_protect) |
LoginParityTests::test_a_sign_in_without_a_csrf_token_is_refused |
each form get without ensure_csrf_cookie (registration, reset, sign-in) |
the byte and cookie parity test of each; test_the_token_the_form_sets_lets_the_caller_sign_in |
| registration at the new address answered as version 1 | both confirm_email version tests |
| registration routed after the fallback | 31 tests |
| a slash made optional on a new route | the slashless-refusal and reverse tests |
| a new address added to the superseded tuple | ConformingDocumentTests::test_no_operation_is_marked_superseded and the filter tests |
| a superseded body entry removed | the same-as-replacement test (LMS) and the exchange test (Studio) |
| a table entry added for the posted sign-in | survives, as designed: the handler's own description is published in place of any table entry, which is why it has none; its published exchange is pinned |
During review, at the intermediate commits of this PR where each test was added (on the earlier base
24b8a7bde5); not re-run at head:
| mutation | red |
|---|---|
| a JWT from a restricted application let through (middleware adds nothing; or the new token check composes the permission so the middleware skips it) | TokenValidationParityTests::test_a_caller_presenting_a_token_issued_to_a_restricted_application_is_refused |
| the session authentication accepting an inactive account | TokenValidationParityTests::test_a_session_whose_account_was_made_inactive_is_answered_as_no_session |
| the sign-in limit per account kept per address; or one counter for every account | both per-account LoginLimitTests |
| the sign-in or registration limit per client kept per address; or only the version 1 address keeping its own | the direction tests of LoginLimitTests / RegistrationLimitTests |
an openedx-event, a Signal.send, a send_robust, or a post_save for another model sent from a form GET; the event sent while events are turned off |
test_a_read_sends_no_signal_or_event_at_either_address in both capture classes |
406 dropped from a table and from the pinned list; 415 the same; country added to required |
RefusedRepresentationTests (LMS) and the Studio refusal and required-list tests |
| the registration shape requiring a password again; the password dropped with nothing in its place; the token way removed | RegistrationRequestShapeTests (and Studio's required-list test) |
the registration or sign-in 400 without {detail}; detail published as a list |
test_each_operation_reading_a_form_publishes_the_refusal_of_one_it_cannot_parse |
| the password-change answer after a correct password turned off, or reached without signing in | SignInWithAnExposedPasswordTests::* |
| this version's mount matched before the installed apps' routes | SharedPrefixTests::test_the_authentication_app_and_the_rest_of_the_user_surface_are_matched_before_this_mount |
| a declaration added to the older token check, or a doubled one on the new | ViewShapeTests::test_the_extended_views_are_left_as_they_were, ::test_each_view_declares_only_who_may_call_it (in the order where the JWT middleware has already run) |
Query counts (equal on both mounts; test settings), measured at 0156789248 on the earlier base 24b8a7bde5
| operation | queries, older address = new address | writes |
|---|---|---|
| registration form GET (third-party sign-in on / off) | 12 / 7 | the session row only, with third-party sign-in on |
| password reset form GET | 9 | none |
| sign-in form GET (on / off) | 14 / 9 | the session row only, with third-party sign-in on |
| successful registration | 62 | identical lists |
| sign-in: success / failure | 27 / 18 | identical lists |
| token check | 10 | none |
| registration validation | 15 | none |
HEAD equals GET on all three forms. The suite pins the equality (test_*_takes_as_many_queries_at_either_address),
and those tests pass at head. The absolute numbers have not been measured again on ed83beab3d.
A replacement of the older view's handler at runtime (moved from the PR body)
Each version 2 method is drf-spectacular's per-view copy of the extended view's method. It calls the function object
the older class held when this version's URLconf first loaded. A replacement on the older class made after that
reaches only the older addresses. This was measured during review, on the earlier base 24b8a7bde5, with
mock.patch.object(register.RegistrationView, "post", …) after a first resolve():
/api/user/v2/account/registration/ answered 418 while /api/user/v2/registrations/ answered 400, and the sign-in
behaved the same way. A replacement made before the URLconf loads reaches both. A method written on the subclass would
avoid this but start the rate limits counting afresh, so the classes define none.
Gate runs (run_gates.sh, MongoDB attached, --allow on the 13 files of the diff)
LMS against base ed83beab3d LMS against master d178206575 Studio against base ed83beab3d
at head 47925143f3 at head 47925143f3 at head 47925143f3
versions PASS versions PASS versions PASS
hygiene PASS (0 FAIL, 0 WARN) hygiene FAIL (1 FAIL, 5 WARN) hygiene PASS (0 FAIL, 0 WARN)
schema PASS (0 BREAKING, schema PASS (0 BREAKING, schema FAIL: check-present,
12 WARN, 15 INFO) 88 WARN, 83 INFO) no /api/user/v2/ path
urls PASS (0 FAIL, 1 WARN) urls FAIL (2 FAIL, 3 WARN) urls PASS (0 FAIL, 0 WARN)
old-tests 820 passed, 4 skipped old-tests 820 passed, 4 skipped old-tests 177 passed, 384 skipped
tests 1528 passed tests 1528 passed tests 40 passed
- All three runs were made at head. Each gives the same RESULT, WARN, BREAKING and FAIL lines as at
7325fef195; only
the LMStestscount moves, 1527 → 1528, which is the new unit test. - The two FAILs against master are both outside this PR's diff: hygiene
H3atuser_api/v2/tests/test_views.py:1500,
and urlsR6/R8on the version's/api/user/v2/fallback route. Both come from the earlier phases of the stack.
Studio'scheck-presentfails because Studio serves no/api/user/v2/path, by design. - Versions gate against base: 3 files added inside
user_api/v2/(authn_views.py,authn_schemas.py,
tests/test_authn_addresses.py). 6 are allowed:urls.py,views.py, andtests/test_{schema,urls,url_mount,views}.py
(the last four flaggedOLD TEST CHANGED, each change listed above under "Existing assertions this PR changes"). 4 other files sit outside any
version directory:openedx/core/djangoapps/user_api/schema.py,lms/lib/spectacular.pyand the two
lib/tests/test_spectacular.py. No frozen surface is touched. - Hygiene against master:
FAIL H3 openedx/core/djangoapps/user_api/v2/tests/test_views.py:1500(class RecordingScopePolicy), plus WARNs onv2/paginators.py:11and feat: standardize the account retirement pipeline as /api/user/v2/ #39330's four retirement viewsets. None is a line
this PR adds; its only line intest_views.pyis the 17 → 22 count at:1670. - Studio schema: the check that
/api/user/v2/paths exist fails because Studio serves none, by design (PR
body, Deprecations). Its document comparison is in the tables above. - Of the schema gate's 12 WARNs against base, 4 are the generator not resolving the platform's default authenticators
for the two token-check operations, both now in this document. The other 8 are the same warning for feat: standardize the account retirement pipeline as /api/user/v2/ #39330's four
retirement viewsets, reported as new only because a docstring edit inv2/views.pymoved their line numbers by two.
(Moved from the PR body.)
Repository CI lint (each step on a snapshot of one commit)
| step | base ed83beab3d |
7325fef195 |
head 47925143f3 |
|---|---|---|---|
ruff check . |
EXIT=0 | EXIT=0 | EXIT=0 |
| lint-imports | EXIT=0, 4 contracts kept, 0 broken | the same | the same |
| pylint lms-2 / openedx-2 / cms | 0 / 0 / 0 messages | 0 / 0 / 0 | 0 / 0 / 0 |
| mypy, xsslint, pii_check, check_keywords (lms, cms), verify-dunder-init | EXIT=0 | EXIT=0 | EXIT=0 |
| commitlint | none | 20 commits, EXIT=0 | 22 commits, EXIT=0 |
No new finding at head, and base and head pass every step.
The 23rd commit: be57050bad fix: describe the CSRF token in every document whose sign-in names it
Trees: everything in this section was run at 8eb071be02 except the last paragraph. Head be57050bad is the
same commit with one statement changed: Studio's append in cms/envs/common.py, from dict(CREDENTIAL_SCHEMES) to
{'csrf': CREDENTIAL_SCHEMES['csrf']}, with the comment above it extended and one sentence added to the commit
message. Both build the same mapping today: CREDENTIAL_SCHEMES holds only csrf. At head the commit is 5 files,
+178/−4 (schema_components.py, both envs/common.py, both lib/tests/test_spectacular.py); the diff from base is
16 files, +3521/−42.
Which operations name csrf. Only POSTs to a sign-in, in every document at every tree; no GET names it.
| document | master d178206575 and base ed83beab3d |
47925143f3 |
8eb071be02 |
|---|---|---|---|
LMS /lms-api/schema/ |
none | the new sign-in; not declared | the new sign-in; declared |
LMS /api-docs/ |
the older sign-in (/api/user/… and /user_api/… keys); not declared |
those and the new sign-in; not declared | all three; declared |
Studio /api-docs/ |
the older sign-in (two keys); not declared | the same | the same; declared |
Studio /authoring-api/schema/ |
none | none | none; declares csrf, as every document built from Studio's settings does |
docs.docs_settings |
the older sign-in; declared by its own settings | the older and the new sign-in; declared | the same, unchanged |
Documents, 47925143f3 → 8eb071be02 (served by the test client, plus docs.docs_settings, as above):
| document | bytes | paths / operationIds / components.schemas |
securitySchemes |
leaves differing |
|---|---|---|---|---|
LMS /lms-api/schema/ |
362009 → 362254 | 83 / 106 / 72, none removed or added | 1 → 2, +csrf |
4 |
LMS /api-docs/ |
1122467 → 1122712 | 524 / 696 / 249, none removed or added | 1 → 2, +csrf |
4 |
Studio /api-docs/ |
685059 → 685304 | 248 / 348 / 210, none removed or added | 1 → 2, +csrf |
4 |
Studio /authoring-api/schema/ |
279389 → 279661 | 69 / 93 / 94, none removed or added | 0 → 1, +csrf |
4 |
docs.docs_settings |
1699531, byte-identical | 513 / 686 / 243 | 4 → 4 | 0 |
The four leaves, the same in each served document:
components.securitySchemes.csrf.type absent -> "apiKey"
components.securitySchemes.csrf.name absent -> "X-CSRFToken"
components.securitySchemes.csrf.in absent -> "header"
components.securitySchemes.csrf.description absent -> "The value of the `csrftoken` cookie the sign-in, registration and password reset forms set, sent in this header along with that cookie."
schema_diff.py compare reads all five pairs as RESULT: PASS (0 BREAKING, 0 WARN, 0 INFO): it does not look at
securitySchemes, so the leaf diff is the evidence. Every schema_diff.py report against base and against master at
8eb071be02 is line-for-line the one at 47925143f3, and neither removes a path, operationId or component name in
any of the five documents. docs/docs_settings.py sets securitySchemes itself, replacing what the service appends,
so its document keeps its own csrf entry (same type, header and location, other wording).
Tests (ServedCredentialSchemesTest; LMS 2 in lms/lib/tests/test_spectacular.py, Studio 3 in
cms/lib/tests/test_spectacular.py). Each requests its service's two served documents through their real views and
settings, and holds every credential an operation names to a scheme the same document declares. One test per service
shows a sign-in naming csrf, so that check is not vacuous, and compares the scheme with a literal written out in the
test. Studio's third holds every csrf scheme either document declares to that literal. Before the fix: LMS 2
failed; Studio 2 failed and 1 passed, vacuously, since neither document declared the scheme. After: LMS module 33
passed, Studio module 43 passed.
Mutations (each on a copy of 8eb071be02, both modules run):
| mutation | LMS module (33) | Studio module (43) |
|---|---|---|
| M1: the LMS append dropped | 2 failed | 43 passed (not its settings) |
| M2: the Studio append dropped | 33 passed | 2 failed |
M3: the header named X-CSRF-Token |
1 failed | 2 failed |
M4: the description naming a csrf_token cookie |
1 failed | 2 failed |
M5: in set to query |
1 failed | 2 failed |
M6: the scheme appended as csrfToken, a name no operation uses |
2 failed | 2 failed |
Gates at 8eb071be02 (--allow on the 16 files; each tree clean, none SKIPPED):
LMS against base ed83beab3d LMS against master d178206575 Studio against base ed83beab3d
versions PASS versions PASS versions PASS
hygiene PASS (0 FAIL, 0 WARN) hygiene FAIL (1 FAIL, 5 WARN) hygiene PASS (0 FAIL, 0 WARN)
schema PASS (0 BREAKING, schema PASS (0 BREAKING, schema FAIL: check-present,
12 WARN, 15 INFO) 88 WARN, 83 INFO) no /api/user/v2/ path
urls PASS (0 FAIL, 1 WARN) urls FAIL (2 FAIL, 3 WARN) urls PASS (0 FAIL, 0 WARN)
old-tests 820 passed, 4 skipped old-tests 820 passed, 4 skipped old-tests 177 passed, 384 skipped
tests 1530 passed tests 1530 passed tests 43 passed
Every RESULT, WARN, BREAKING and FAIL line is the one at 47925143f3; only the tests counts move (1528 → 1530,
1528 → 1530, 40 → 43), by the five new tests. Versions gate: 3 files added inside user_api/v2/; 8 allowed, the six
listed above plus lms/envs/common.py and cms/envs/common.py; 5 other files, the four listed above plus
user_api/schema_components.py. Hygiene against base scans 16 files.
Wide runs and test ids at 8eb071be02. The LMS set of the test-id table: 2268 passed; user_authn/: 555
passed, 1 skipped; Studio cms/lib/tests/ and cms/tests.py: 47 passed. Ids against 47925143f3: LMS set 2266 →
2268, user_authn/ 556 → 556, Studio cms/lib/tests/ 40 → 43, none removed; the five added are the new tests.
test_authn_addresses.py: 138 passed under each of pytest-randomly seeds 1 to 5.
At head be57050bad: cms/lib/tests/test_spectacular.py 43 passed; ruff check . clean; repository CI lint,
every step EXIT=0, with output equal to 8eb071be02's apart from timing lines and line order (pylint lms-2 /
openedx-2 / cms 0 messages; lint-imports 4 contracts kept, 0 broken); commitlint 23 commits, EXIT=0.
|
One commit added: What it fixes. The sign-in operations publish a Evidence. Measured at Merging with Phase D2 (a sibling on #39330 that also adds |
…image APIs as /api/user/v2/
The account surface was the largest non-conforming part of the User API and the
one most callers depend on: frontend-platform's account fetch, the account and
profile MFEs, and both mobile apps all read it. Ten of its operations answered
in shapes nothing documented, refused malformed input with server faults, and
addressed one account's records through a permission that read a username from
somewhere other than the address.
Every change here is a contract change, so they land on a new version:
/api/user/v1/ is untouched and keeps serving every one of its callers.
What this version answers:
GET /api/user/v2/accounts/ (?username=, ?email=,
?lms_user_id=, ?view=,
?fields=, ?ordering=)
GET /api/user/v2/accounts/me/
GET /api/user/v2/accounts/{username}/
PATCH /api/user/v2/accounts/{username}/
POST /api/user/v2/accounts/{username}/deactivation/
POST /api/user/v2/email_searches/
POST /api/user/v2/name_changes/
POST /api/user/v2/name_changes/{username}/confirmations/
GET /api/user/v2/accounts/{username}/verification_status/
GET /api/user/v2/accounts/{username}/verifications/
POST /api/user/v2/accounts/{username}/profile_image/
DELETE /api/user/v2/accounts/{username}/profile_image/
A refusal is part of the contract, so the refusals are explicit. The request
body is measured against a serializer before the account service reads it, so a
wrongly typed property is a validation envelope rather than a server fault; a
failure inside the service is a server fault rather than a validation envelope.
A permission reads the username from the address and from nowhere else, and
compares it exactly, because the handler behind it selects the record by that
same value. An account that is no longer active is refused on both halves of the
default authentication pair rather than only one.
Lists answer in the standard envelope, bounded and ordered by username; a filter
that matches nothing answers an empty page rather than reporting the address as
missing, and a status that has never been established answers "none" rather than
reporting the account as missing. An email search is bounded at a hundred
addresses, which bounds its answer.
The schema describes what the code does: the superseded addresses publish the
media types they actually read, every operation publishes the codes it really
answers, and the response components carry the properties the bodies really
hold, with presets modelled rather than implied.
The tests pin behaviour rather than structure. Every allowed and every refused
caller is named with the error type asserted; value-level parity is checked
against each superseded address; query counts are measured on the earlier
endpoint first and asserted not to grow with the collection; SQL captures prove
no read writes, including writes deferred to commit; and each guard was checked
by breaking what it covers and watching the right tests fail.
The profile-image endpoints are addressed under the User API but served by a
view that belongs to the profile_images app, so the conforming version lives in
that app's own new version package and is mounted into the user version two
URLconf exactly as the earlier view is imported today. The app's existing
modules are untouched, and the image validation they own is called rather than
copied.
An upload is read before anything is stored, so a truncated file, metadata that
cannot be parsed, or a header declaring more pixels than the site will ever draw
are answered as the caller's error rather than as a server fault, while a
failure to store a well-formed image stays a server fault. The media types the
address reads are published, and the body is published as required.
A caller may set and clear the image on the account the address names and on no
other, staff included, since clearing an image is a change to an account rather
than a moderation tool. The permission reads the username from the address and
compares it exactly, because that is the value the stored names are built from,
so the account a caller is admitted for is always the account written to.
The tests drive real uploads: each refusal with its error type, parity against
the earlier address for the bodies both answer, and a check that a caller
holding another spelling of the owner's name is refused and stores nothing.
This package is shared with the CMS, and the CI shard that runs it under cms.envs.test could not import this module at all: it read settings.ACCOUNT_VISIBILITY_CONFIGURATION, an LMS setting, in the module body. Collection failed, which failed the shard, which cancelled the rest of the matrix. Every test here that reads these sets is already @skip_unless_lms, but a module body runs before any skip can apply, so the setting is now read through the same guard user_api/accounts/tests/test_views.py uses for it, and the field sets fall back to empty when it is absent. Under cms.envs.test this module now collects, and the failures left in these two packages are the same 44 as on master, in the same tests -- an artefact of running two of the shard's sixty-nine paths rather than anything in this branch. Under lms.envs.test nothing changes.
…uth class Four views declare their own authentication_classes so that a caller who has not yet confirmed their email address can still reach their own account, its picture, its preferences and its email choice: the session class the platform configures refuses such a caller, so that half is replaced. For the token half they named DefaultJwtAuthentication. That class exists only to be named in DEFAULT_AUTHENTICATION_CLASSES -- its own module says it is only meant for that -- and the using_default_auth_classes attribute it records exists so an operator can find the endpoints that are on the defaults. A view that declares its own classes is not one of those, so recording the attribute filed a false signal and polluted the query the attribute serves. Nothing on master declares the class: two files name it, both definitional, against the library class used throughout the rest of the platform. The four views now name JwtAuthentication, the class the platform's own pair is built on. Two of these addresses, the account and its picture, were added earlier on this branch and are corrected in place here. The guards assert the attribute is absent, each alongside a control request to a sibling address that declares no classes and must still record it, so a pin cannot pass merely because a patch target was mistyped.
An audit across the three phases on this branch found four defects, each introduced by an earlier phase and each visible to a caller or to a generated client. They are corrected together here, as the JWT class was, rather than left to differ between sibling addresses of one version. The profile image published a username with no pattern, the only one of the eleven username path parameters in this version without it, so a generated client validated the name before the request on nine addresses of the resource and not on the tenth. The parameter helpers and the username pattern move from views.py into view_utils.py, which that module already imports from, and the parameter is built with the helper. The helper being private to one module was the cause, and a further module would have hit it again. The stored-preference resource pinned one operation id inline, which applies to every document, so the service-wide document published that operation beside a collection read carrying a different prefix, and a client generated from it derived the two from different modules. The pin moves into the per-document table beside the entries that already do this, so the resource's operations share their leading tokens in each document. One id changes, in the service-wide document only, to a name that document has never published. The username filter accepted a single exact name on two collections and a comma-separated list on a third, so a caller who learned the list form on the account collection was answered a 200 with an empty page rather than told the filter does not take a list. All three now accept the list, through one shared selector, matching the member address of that resource, which already reads a comma as a separator. The email search was the one collection in this version reading accounts directly without declaring a scoping seam, while the two collections over the same table declare one. It now declares the same policy through a queryset the shared mixin can wrap. No caller gains or loses access and no row changes: the policy is a pass-through today, and the seam is what was missing.
…er them Both documents already published these four addresses with a 200 and no body before their bodies were described. No handler answers that: each answers 204. The corrected code is now pinned in both documents and each handler is driven on its success path in the same test, so the published code and the answered one can only move together. Replaces the pin that covered two of the four in one document.
…here too Studio mounts the user API at the same addresses the LMS does and publishes its own unauthenticated document at /api-docs/, built from its own settings, so none of the marking applied in the LMS reached it: all twenty-five of those operations were published there as current while the LMS published them as superseded. A client generated from Studio's document therefore offered a caller an address the LMS document steers them off. The list of superseded addresses and the hook that reads it are shared rather than restated, so the two services cannot drift apart. Measured on the two generated documents: Studio marks 25 of 25, as the LMS does, and nothing else in Studio's document changes -- no path, operation, operationId or component is added or lost. The narrow Authoring API document Studio also serves carries none of these addresses, so the hook cannot reach it.
…ettings The documentation settings replaced the appended components wholesale rather than adding the security schemes to them, so any component name the service settings append was dropped from the settings the published reference is generated from. Nothing showed it: the name simply stops being in the reference, and in every client built from it, while every count stays where it was. They now add to what they inherit. The test loads those settings in a process of its own -- loading them here would turn every feature flag on and add Studio's applications to the settings this process already runs under -- and checks that a component the service settings append arrives alongside the security schemes.
Asking for a page of nothing, of fewer than nothing, or of something that is not a whole number of rows was answered 200 with the default page of ten, on every collection of this version that pages its rows. The framework drops a size it cannot honour and falls back without telling the caller, so the page reads as the one that was asked for; the links on it carry the value back, so a client that follows them keeps sending a number nothing reads. The shared paginator now reads the value itself and refuses one no page can have, the way this version already refuses a query parameter it does not read, a sort field it does not offer and a boolean naming neither side. A number above what a page carries is still answered with a full page, which is what it has always meant, and a value carrying nothing still leaves the size alone.
The standard error body carries one line for a person to read beside the list of what each parameter or property was refused for. Where the refusal named parameters, that line was produced by printing the list itself, so a caller read back the brackets, the quoting and the class names that write a data structure out instead of a sentence. Everything that puts the line in front of a person showed it: the message of the exception a generated client raises, a line in an integration log, the notification a page puts up. The line is now written from the same values as a sentence, naming each parameter and what it was refused for; a refusal that names nothing in particular is spelled on its own. The list itself is untouched, and a refusal that never had a list -- an address that matches nothing, a caller that may not read, a method the address does not serve -- keeps the line it had.
…ved it to A member address of the account collection was answered by matching the name it carried against the name each record that came back is stored under, by a reduced form of that name failing that, and anything matching neither was dropped. The database settles which stored records a name resolves to, and the name it resolves to need not be written the way the address wrote it: where it resolved to a record neither comparison matched, the list came back empty, the member handler indexed the first element of it, and an authenticated caller was answered 500. The superseded address answers 200 with that record, so the replacement was worse than the address it stands in for, and a client or MFE that pads or transliterates a name turned a working read into a server fault. A member address is now answered from the records the lookup resolved it to: the one stored under exactly the name given when there is one, and otherwise the single record that came back. An address the lookup resolves to several records, none of them stored under the name given, names no one of them, so it is refused with 400 rather than answered with whichever came back first. A lookup that comes back with nothing is reported as an absent address rather than as a fault. The page of a list keeps the order its names were asked in, which is what the matching is for; a record it resolved to under a name none of them is written as is now carried at the end of the page rather than left out of it in silence. Tests: a member address the lookup resolves to a record stored differently, one it resolves to nothing, one it resolves to several records with and without an exact match among them, and a page carrying a record resolved under another name. Each fails against the previous code, the first three with the 500 this corrects.
The account collection selects by the email address of an owner with a lookup that takes one record and refuses to answer at all where there is more than one. Nothing in the user model keeps one address to one account, so a staff caller looking up an address two accounts hold was answered 500, with nothing in the answer to say why. The superseded address fails the same way, so this was carried forward rather than introduced. The selector now names every account holding the address, and the page carries them all. A collection is what this address answers, and a page of the accounts holding an address is already what the search over addresses beside it answers with, so the page is the answer here whether the address is held once or several times. An address no account holds still leaves the page empty. Tests: the selector asked for an address two accounts hold, and the collection read by a staff caller for the same address, asserting both accounts in the page. Both fail against the previous code, the second with the 500 this corrects.
…source The accounts are one collection however many addresses reach into them, and six operations that read or change a row of it were served by views standing apart from it: the identity verification summary and attempts, the preference document and one preference of it, and setting and clearing the image, each declaring a flat list of classes of its own, none of them reading what the account resource admits a caller by. A rule declared for that resource therefore did not reach them. Measured with a rule admitting nobody added to the resource: the account read answered 403 while the summary, the attempts and the preferences answered 200, one preference and an upload reached their handlers, and clearing the image answered 204 and cleared the stored image -- a change to an account carried out while every read of the same account was refused. Deactivating an account read nothing of the resource either. The classes the resource admits a caller by are now declared once and carried by every address that works on one of its accounts, which applies them first and the classes of its own address after. The same measurement now refuses all eight, and the stored image is left alone. The declaration is read from the shared tuple rather than from the collection's own attribute because that attribute is added to in place by the first request to reach a view that reads a token, which would otherwise make admission depend on which addresses a process had served already. The account an address names is resolved through the resource as well, by one rule: the record stored under exactly the name given, or the single record the name came back with, and otherwise a refusal. Three operations resolved the record they write to with a lookup that takes one record and fails outright on more, each catching only the not-found case, while the class in front of them had settled the question on the name as text. Making a password unusable, confirming a profile name and recording an image can therefore no longer land on a record nobody addressed: each is refused with 400 and writes nothing. Recording an image is looked up again by the writing helper, which this version does not own, so the refusal is raised where that lookup comes back with more than one record. The preference addresses keep their own two-tier rule -- a read of somebody else's is a privilege, a change of it belongs to the owner -- and declare it through the hook the shared gate calls, so both tiers still apply and the resource's rules apply before them. One test outside this version's own modules moves with it: the preference guard harness handed a bare request to classes that are given the wrapped one by the view, and the classes carried in from the resource read the request through it, so the harness now authenticates the request the way the test client does. Tests: every read and every change of one account measured twice, once as they stand and once with a rule admitting nobody added to the resource, with the stored image asserted untouched after the refusal; a deactivation refused with the privilege for it held, so that the refusal measured is the resource's; and each of the three write targets, refused when the address names no one account and landing on the account stored under the name when it does. Removing the shared gate from the four views, or emptying the shared declaration, turns the refusal tests red.
The answer to an OPTIONS request is built from the parsers and the serializer declared on the view, which belong to the view rather than to the address being asked about, and two addresses of this version were described wrongly because of it. The preference document of one account is read and changed as a merge patch and a body in any other media type is refused, which is what the published document says of it. The description of that address named the three media types the platform reads bodies in everywhere else and left out the one it takes, because the parsers follow the action being served and the action recorded for this question is not the change. A caller negotiating from the description -- the one mechanism offered for it -- therefore sent a body in a media type the address refuses and was answered 415, with the two published descriptions of one address contradicting each other. The parsers now follow the methods the address serves, and the description reports the ones it would read a body with. Confirming a requested profile name carries out the change already asked for and reads no body, which is what the published operation says. The description of that address offered a body with a required name in it, built from the view's serializer, so a client or a form driven by it asked the operator for a name before confirming the change and sent a body the address ignores. An action that reads no body is now declared as such and no body is described for it. The address that asks for a name still describes the one it takes. Tests: the media types named by each preference address, a change sent in every media type the document address names being carried out, no body described for a confirmation, and the body still described for asking. Each fails against the previous code, the negotiated change with the 415 this corrects, and each half of the description turns its own tests red when taken back off.
…ently An address can have been held and left more than once, so more than one retirement of it can sit inside the cooling-off period. This version reports the most recent, being the only one a caller could still undo; the superseded version reads the same window with a lookup that takes a single record and so fails outright on the second. The window boundaries are the same in both. The selection is on a path declared to answer as the superseded one answers, and this is the one input they answer differently for, so the selector now says so where the next reader of it will be.
…ot to a maintainer
…PI's version 2
Registering, signing in and resetting a password are reached today at
addresses the rest of the user API no longer uses: a singular `account`
segment with a second collection nested under it and no identifier between
them, a verb (`token/validate`), a route without a trailing slash, and one
route answering two versions through a `{api_version}` parameter. This adds
the five addresses version 2 names them by:
/api/user/v2/registrations/ GET, POST
/api/user/v2/registration_validations/ POST
/api/user/v2/login_sessions/ GET, POST
/api/user/v2/password_resets/ GET
/api/user/v2/password_reset_token_validations/ POST
Nothing changes for any caller of the existing addresses: every route, URL
name and view of the authentication app stays exactly where it was, and no
file of that app is edited.
Each new address is served by a subclass of the view already serving the
older one. The subclass adds `permission_classes = (AllowAny,)`, which is
what those views already allow, and the description it is published with,
and nothing else. Every request is answered by the inherited, decorated
method, so authentication, CSRF handling, cookies, bodies, status codes and
the third-party sign-in flow are the older address's. That includes the
limits on how often a request may be sent: those counters are keyed on the
decorated method and on the view class's name, so the subclasses keep their
parents' names and the counters stay shared between old and new addresses
(spend the limit at one, be refused at the other). Sign-in is bound to
version 2 by its route, and registration answers as the version 2
registration address does, `confirm_email` included.
The bodies are published as shapes written in place, since none of these
views uses a serializer: the form descriptions, the registration and
sign-in success and refusal bodies, the token and validation results, and
DRF's `{detail}` refusals, each only for the codes the handler is seen to
produce. Sign-in publishes the version 2 fields it really reads and the
same security block as the other addresses here.
The new tests send every request to the older address and to the new one
and compare status, media type, cookies and body; cover the callers each
view refuses (CSRF, malformed credentials, rate limits with a real cache,
the switches that close registration or require another provider, a
deployment's own filter steps); pin query counts and that reading a form
writes nothing but the session; and hold every captured body against the
shape the service-wide document publishes for it.
…es superseded
Each of the nine operations the new version 2 addresses replace is now
published with `deprecated: true`, in the service's own document and in the
service-wide documents of the LMS and of Studio, which both serve them:
/api/user/v1/account/registration/ GET, POST
/api/user/v2/account/registration/ GET, POST
/api/user/v1/account/password_reset/ GET
/api/user/v1/account/password_reset/token/validate/ POST
/api/user/v1/validation/registration POST
/api/user/{api_version}/account/login_session/ GET, POST
The six published keys are added to the existing superseded-address tuple,
spelled exactly as the generator publishes them, which both admits them to
the service's own document and lets the existing hook mark them. No route,
URL name, operationId or component changes, and nothing is written on the
views themselves. The unversioned `/user_api/...` twins and `/create_account`
are left unmarked, as the twins of every other superseded user address are.
Eight of the nine operations published no body at all, or only a 200 with
none. They now publish the same bodies and codes as the address replacing
each, from the same shapes, so the two cannot drift: the form descriptions,
the registration request and its 400/403/409 refusals, the token and
validation checks with their refusals. The schema gate therefore flags two
lines of each service-wide document as a request body newly required: the two
registration POSTs. Neither published any request body before; both now
publish the registration body as required, because their handler refuses an
empty body with a 400. The
posted sign-in keeps the description its handler has always carried, since
one key serves two versions with different request fields, and a
description on the handler is published in place of any table entry; its
two components stay referenced.
Tests pin the marks in every document, the published names and groups, that
each superseded operation publishes what its replacement does, and that the
hook and the filter leave every neighbour alone: the twins, `/create_account`,
`/login_ajax`, unpublished spellings of the keys, the new addresses and the
version's other addresses.
…fore any request runs The token check is answered by views that read signed tokens, and the platform's middleware adds a permission to such a view's class, in place, the first time one is requested. Two checks read the classes after the fact, so they failed whenever a token check had run earlier in the same process: under a random test order, or with the token test named first. They now read the classes as the test module is imported, which pytest does for every module before the first test runs. What they guard is unchanged: the version 2 views declare only who may call them, and the views they extend declare no permission of their own. Both still fail when either declaration changes.
…ly null A site can add fields to the registration form through a form it names in its settings. Each such field is described with that form field's label and starting value, and a Django form field has neither unless it is given them, so the form description then carries null for both. The published input shape said both were always text, so on such a site the registration form at version 2 and at the addresses it replaces failed the shape every document publishes for it, and a client generated from the document would reject the whole form. Both members are now published as text or null, and the starting value's description says it is null in that case. The new test names an extension form, reads the form at every address that serves it, and holds each body against the shape both documents of the service publish.
…ble body
Every registration, sign-in and password reset operation, at version 2 and
at the addresses it replaces, refuses a request whose Accept header names
nothing it can answer in (406), and every one of them that reads a body
refuses one in a media type it cannot read (415). Both are settled before
the handler runs and answered as {"detail": ...}. None of them published
either code, although every other operation of version 2 publishes 406 and
every other one reading a body publishes 415, so a client generated from
any document met a status it was never told about.
Each response table now carries both codes, in the body these views really
answer with and with the descriptions the rest of the version uses. The
tables are shared with the descriptions of the older addresses, so Studio's
document gains the same codes for them.
The new test sends each refusal to every address and holds the answer
against what each document publishes for that code, so a code the handler
produces that is missing from a table fails it even where the pinned list
of codes was changed to match. A second test reads which fields the stock
registration form requires and requires the published registration body to
name exactly those, and Studio's test pins the same list and codes.
…ut of the standardized document
Listing /api/user/{api_version}/account/login_session/ among the superseded
addresses both marks it deprecated wherever it is published and admits it
into the LMS document of the standardized APIs, where it had never been.
Signing in by posting there is published with the description its handler
carries: a request naming `email`, which version 2 does not read, and a
success body requiring members the sign-in never answers with. Nothing in
the schema module can replace a description a handler carries, so
admitting the address put a description known to be wrong into that
document.
The address is now left out of that document, as it was before, while both
service-wide documents still mark both of its operations deprecated and
still carry the two components its description names. The address
replacing it is published in all three as before.
The tests pin that the address and its two components stay out of the
standardized document, and that each superseded operation is marked in the
documents that publish it.
…-in publishes The sign-in at /api/user/v2/login_sessions/ needs the CSRF token exactly as the older address does, and the older one publishes that need as a `csrf` security requirement. The new one published no requirement at all, behind a comment saying no published document declares that scheme. The reference generated from docs.docs_settings, which docs/lms-openapi.yaml is built from, does declare it (the X-CSRFToken header), so a client generated from it would send the token to the older sign-in and not to the new one, and its migrated call would be refused. The new sign-in now keeps the requirement of the view it extends, so every document publishes the same security for both sign-in addresses. The documents the services serve still do not declare the scheme, as before for the older address. The test requires the new sign-in's security to be the older sign-in's in both of the service's documents, and every other address here to need nothing.
The check that reading a form writes nothing recognised only statements starting INSERT INTO, UPDATE or DELETE FROM. A bulk create that ignores conflicts sends INSERT OR IGNORE INTO on SQLite and INSERT IGNORE INTO on MySQL, so a form read doing one was never counted and passed. A statement now counts as a write when it starts with INSERT, UPDATE, DELETE or REPLACE, whatever follows, and a write whose table cannot be read is still listed.
Reading a form was guarded only by a capture of the statements it sends to the database. A Django signal or an Open edX event sent from a form read, whose receivers do their work elsewhere, sent no statement and so passed. Each read of the registration, sign-in and password reset forms, GET and HEAD, at the older address and at this one, now records every signal sent and every event sent, turned on or not, and expects none beyond what every request sends and the saving of the caller's session.
…r way Every capture of what reading a form writes or sends used a new caller with no sign-in through another provider under way, so the part of the registration and sign-in form builders that runs only partway through such a sign-in never ran under it. A write added there passed. Three more cases now read each form with a sign-in under way: through a provider that fills the profile in, through one that does not, and through one this site does not know. Each asserts the same writes and signals at both addresses, and that the registration form was really built for that sign-in.
…h 403 The token check is identified by the platform's JWT authentication, so the platform's middleware adds the permission that refuses a JWT issued to a restricted application, at the older address and at this one alike. The published 403 named only the missing CSRF token, and no test sent such a JWT. The 403 now names both causes, and two tests run at both addresses: a JWT issued to a restricted application is refused with the same body, and a session whose account was since made inactive is answered as a caller who is not signed in.
The sign-in limit tests spent failures from one client, so only the limit per client was ever exceeded; nothing showed that the limit per account is counted together at the new sign-in address and the older one. Failures naming one account, each from a client of its own, are now spent at one address and refused at the other, both ways, while another account is still let in. The directions not yet sent are added too: failures at the version one sign-in and at the older sign-in function count at the new address, failures there count at the older version two address, and registrations sent to the version one address or to the older registration function count at the new one.
A registration that completes a sign-in through another provider carries no password: the server generates one. The authentication front end leaves the password out while such a sign-in is under way in the session, and a client that signed the person in with the provider itself sends the provider and its access token instead. The published request required a password of every registration, so a validator or a strict client built from either document refused both. The shape now requires the fields every registration needs and, beside them, one of three things: a password, the provider of the sign-in under way, or a provider with its access token. An ordinary registration is still published as needing a password, and the tests send each body to every registration address after holding it against both documents.
Registration and signing in read a submitted form, which is parsed when
the handler first reads it. A body that cannot be parsed in the media type
it is sent as, such as a multipart body with no boundary, is refused with
400 and a body holding `detail` alone, before the handler can answer with
its own. The 400 of both was published with the handler's body only, so a
client typed from either document could not read that answer: the
registration body needs each member to be a list and the sign-in body
needs `success`.
Each 400 now offers the handler's body or the `{detail}` body, and says
when the second is sent. The older registration addresses share the
description. The test helper that looks for undeclared members now holds a
body to the one shape it satisfies, so the existing checks of these 400s
keep their strength.
…imit The registration limit counts the requests to register sent from an address, not the accounts they created: a request refused for a missing field counts as much as one that registers an account. The published 403 and the operation description said the refusal came after too many accounts were registered, so an integrator budgeting on accounts created would be refused sooner than the document said. The existing limit tests already reach the 403 after refused registrations alone.
Where a site checks passwords against lists of exposed passwords, signing in with a correct password can still be answered with 400. A password found often must be changed first and the caller is not signed in. One found less often is answered with `nudge-password-change` after the caller has been signed in: the session cookie is set, but not the cookies a successful sign-in adds. The published 400 said only that signing in had failed, so a client following it would treat a signed-in caller as signed out. The 400 now names both cases and what each leaves behind, and `redirect_url` says what it holds in the second. The new tests reach each side of the two thresholds and hold the answer against both documents.
…ollections
The comment above the routes said registering an account creates
something no address here reads back. The account a registration creates
is read at accounts/{username}/ like any other. The real reasons are that
each route is answered by a subclass of the authentication app's own view,
so a request runs that view's one code path with its decorators, its CSRF
handling and the limits it shares with the older addresses, and that
registering and signing in answer with a session and where to go next
rather than with the account.
The comment now says that, and a test registers an account and reads it
back at its own address with the session the registration set.
…rries The version 2 sign-in answers 403 with a JSON body in one case only: the caller signed in through another provider with an account linked to none here. That body always carries a message (`value`) and a name (`error_code`, which is `third-party-auth-with-no-linked-account`), but the documents published it with the shape of the 400 refusals, which marks only `success` as always present. A client generated from them would treat both members as optional. The 403 now marks `success`, `value` and `error_code` as always present. Nothing changes in what the endpoint answers. The 400 refusals keep their shape, because each of those members is left out of one of them in some case.
Checking a real response against the document catches a wrong type or a missing member, but not a member the document stops promising, because the body still satisfies a schema that requires less. Dropping `user_id` from a registration's 200 body, or `is_valid` from the token check's 200 body, left every test green. Every real body the suite receives from these addresses is now also held to the members its operation and code publish as always present. Those must be exactly the members the body carries, less the ones a table lists as carried in some cases only, each with its case named. Each listed member is shown carried by one real body and left out of another, so the table cannot hide a member that is always there. Requests are added for the cases that leave a member out: a step the site adds that refuses without naming the refusal or without a message, a username held back for retired accounts, and a password found on lists of exposed passwords.
The sign-in operations publish a security requirement named `csrf`, but no document either service serves declared a scheme by that name; only the settings the published reference is generated from did. A document naming a credential it does not describe is invalid, and a client generated from it has no way to send that credential. The older sign-in has named the token this way in both services' service-wide documents since before this version, and the new sign-in repeats it in both LMS documents. The scheme is now defined once in the user API, beside the components both services already append, and both services append it: the value of the `csrftoken` cookie the forms set, sent in the `X-CSRFToken` header. Studio's document of its own API carries the scheme as well, because every document built from those settings carries what they append. Studio appends that one scheme rather than the whole set, since no operation it serves names any other credential. The tests request each document at the address it is served from, hold every credential its operations name to a scheme it declares, and pin how the token is described with the text written out.
…ount The LMS schema tests generate the user documents from the user mounts only. The registration, sign-in and password reset addresses this version supersedes are served from the authentication app's own mount, so neither document those tests generate carried them. The checks made over every operation of the published document (each name unique, no name told apart by a numeral, one area per tag group) ran without any of those operations in it, and stayed green whatever the documents said about them. The documents are now generated with that mount as well, matched by its module as the user API's own schema tests already match it. A new test pins, in each document, the operations of those addresses it carries and their superseded mark: seven in both, and the two of the sign-in that takes its version as a parameter in the document covering the whole service only. It fails if the mount drops out of the generation again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ubject it can see Several tests of what the documents say about the registration, sign-in and password reset addresses check what a document leaves out, or run over what a filter leaves of it, and passed just as well with nothing to check: - Studio's check that every credential an operation names is described ran over its authoring document as well, whose operations name none. - That check, in both services, passed if no operation named any credential at all. - Studio's check that every document describing the CSRF token describes it alike passed if no document described it. - Studio's check that no replacing address is published passed if the generation reached no address under the version two prefix. - The checks that the narrower LMS document leaves out the sign-in that takes its version as a parameter, and its models, passed if that address and those models were generated nowhere. Each now asserts its subject first: the credentials the sign-in and the other operations name, the document whose older sign-in names the token, the superseded registration Studio serves under the version two prefix, and the sign-in with its models in the document covering the whole service, which is generated from the same mounts. The user API's schema tests gain one test for that sign-in, next to the one listing each superseded address's replacement: it is in the document covering the whole service and not in the narrower one. Removing the subject from the document turns each of these tests red; before, it left them green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… user mounts The tests of what the two LMS documents publish for the registration, sign-in and password reset addresses generated the whole service's document in each of seven test classes, once or twice each: thirteen generations of every endpoint the service serves, to read the fourteen operations of the narrower document and the sixteen of the one covering the whole service. Both documents are now generated once, from the mounts the user API's schema tests generate theirs from, which include the authentication app's mount, and under the settings each is published with. The whole document's path prefix is pinned to the root it derives from every address the service serves, as those tests pin it. Each class reads a copy, so no test can change what another one reads. The operations and the models they refer to come out identical to the ones in the whole documents, with third-party sign-in turned on and off. Every check reads only these addresses' operations and fails on a missing one, as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
be57050 to
ae4332c
Compare
|
Rebased onto the rewritten #39330, plus this round's corrections. It is still 23 commits: three that only described the removed catch-all route were dropped, and this round's fixes were added. This branch was rewritten and force-pushed. The description is replaced and an evidence comment follows. What changed in the history. Each correction now sits in the PR whose code it changes. Before, some corrections to #39297's code (for example, the JWT class on the account and image views) existed only in #39314. Merging #39297 alone would have left them out. Now each PR is complete once the PRs before it merge (order: #39297 → #39314 → #39330 → #39334 / #39331). The corrections from the review round of 2026-11-04 are added as commits at the end of each PR. Commits that changed a lower PR's code moved down, with their authors and messages kept. Some fixes were folded into earlier commits of the same PR so that every commit passes the suites. How to review the rewrite. The new commits of the 2026-11-04 round are listed in the description's commit table. Each one names its subject, and each behaviour change comes with a test that fails without it. |
Evidence for #39331Each test is named as Declared differences → tests
Gate report
Repo CI lint: ruff, xsslint, pii_check, check_keywords, lint-imports ("lms and cms are independent" KEPT), mypy, verify-dunder-init and pylint add 0 findings against #39330. commitlint passes on every commit. Test runs at this head (
The one LMS failure is master's own Changed by the corrections (tests only)
|
Summary
Part of
[API] Userunder the umbrella #38137, followingdocs/decisions/and OEP-69.This PR serves registration, sign-in, password reset and registration validation at five conforming addresses under
/api/user/v2/. It is a change to addresses and documentation only. Each new address is served by a subclass of the view that serves the older address. The subclass addspermission_classes = (AllowAny,), the restricted-application guard every view of this version carries, and its published description. Bodies, status codes, authentication, CSRF, cookies and rate limits are inherited unchanged, and no file underuser_authn/is edited.Why these addresses, and why in version 2. The older addresses use a singular collection (
account), nest one collection under another with no identifier between them, take the version as a path parameter ({api_version}), lack a trailing slash, or carry a verb. Registration and sign-in already have a published version 2 contract, and the new addresses keep exactly that contract. Password reset and registration validation join version 2 with their contract unchanged.Stack and merge order: #39297 → #39314 → #39330 → #39331 (this PR, on #39330). #39334 is a sibling, also on #39330. See "Merging with #39334".
23 commits, 15 files, +3462 / −13 (this PR's own range):
Operations
GET,POST /api/user/v1/account/registration/and/api/user/v2/account/registration/GET,POST /api/user/v2/registrations/POST /api/user/v1/validation/registrationPOST /api/user/v2/registration_validations/GET,POST /api/user/{api_version}/account/login_session/(v1 and v2)GET,POST /api/user/v2/login_sessions/GET /api/user/v1/account/password_reset/GET /api/user/v2/password_resets/POST /api/user/v1/account/password_reset/token/validate/POST /api/user/v2/password_reset_token_validations/(the token stays in the body)Declared differences a caller can see
email_or_usernameand refusesemailalone. Registration offers and requires a configuredconfirm_email. A version 1 client therefore changes these fields as well as the address.OPTIONSdescriptions differ, because each class describes itself. Everything else is compared on both mounts: status,Content-Type, the cookies set, and the body.301), as every address of the service is./api/user/v2/answers in two error shapes. The views of feat: standardize the user accounts, profile image, verification and name-change API #39297–feat: standardize the account retirement pipeline as /api/user/v2/ #39330 answer with the RFC 9457 envelope. These five answer with the authentication app's own bodies (error_code,context.failure_count, field-keyeduser_message), and with DRF's{detail}for the refusals DRF produces itself (405, 406, 415, an unparsable body).403at the token check, as the older token check refuses it. The other four views read no JWT, so for them the guard has no effect.Published documents
deprecated: truein/lms-api/schema/and in both services'/api-docs/. Studio serves them too, but it mounts none of the replacements.POST /api/user/v1/account/registration/andPOST /api/user/v2/account/registration/published no request body until now. They now publish the registration body, marked required, because the handler refuses an empty request. This is the same handler, and it is not changed./lms-api/schema/. Its frozen handler describes the version 1 request. It is still marked in both service-wide documents.csrfsecurity scheme that its sign-in operations name. Both services append it from one definition. Studio's Authoring API document gains the scheme without using it.ADRs applied
The evidence for each box is in the compliance matrix in the evidence comment.
permission_classes = (AllowAny,)on all five views; every v1 check is inherited unchanged; restricted-application guard on every viewAPIViewsubclasses: these collections are only posted to or describedauthentication_classes = [], and the token check keeps the platform defaultv2; the superseded views are untouched<basename>_listnamesIn-place edits
No
user_authnview or URLconf is in the diff. Outside a version directory:cms/envs/common.pyAPPEND_COMPONENTS = {'securitySchemes': {'csrf': …}}, the one credential Studio's sign-in names. Noschemasentry is addedcms/lib/spectacular.pySUPERSEDED_PATHSlms/envs/common.pyAPPEND_COMPONENTSgainssecuritySchemes: dict(CREDENTIAL_SCHEMES); the import names itlms/lib/spectacular.py/lms-api/schema/cms/lib/tests/test_spectacular.py,lms/lib/tests/test_spectacular.pyuser_api/schema.py,user_api/schema_components.py(from earlier in the stack)CREDENTIAL_SCHEMES['csrf']Error-format decision
Unchanged here; it moves to the package's next version,
/api/user/v3/. The authentication frontend and the legacy sign-in page parse the legacy members, so the new addresses answer with exactly those bodies and document them. The following are recorded for that version, unchanged here:403for a missing CSRF token.403s behind two non-default switches.200carrying an error body, when a deployment's own registration filter step refuses a registration without giving a status.Deprecations
The nine operations above are marked
deprecated: true, with no header and no note in code. They are tracked on #39128 (entry 6565029569). The unversioned/user_api/…twins and/create_accountare not marked.Before removal:
submit_urlnames it. That description has to namePOST /api/user/v2/login_sessions/andemail_or_username.submit_urlnames/api/user/v2/account/registration/. It has to name/api/user/v2/registrations/.Authorization
No authorization changes. Every legacy check is inherited and tested on both mounts. That covers the rate limits (shared), CSRF, the third-party sign-in switches, the token's owner check and the deployment's filter steps. None of these moves into a permission class, because each answers with a status or a body that clients read.
Operational note
Traffic at the new addresses is reported under
openedx.core.djangoapps.user_api.v2.authn_views.*transaction names. Dashboards that key on theuser_authn.views.*names will see only the older addresses' share.Merging with #39334
Both PRs are built on #39330, so whichever one merges second meets this. The recipe describes the
resulting content of each file, not a choice of "ours" or "theirs", so it holds in either direction.
It was derived from a real local merge,
git merge --no-ffof #39334's head into this PR's head (trees identical to the heads first pushedhere; #39334's later commit that lifts its limit on mappings per request changes no conflicted hunk:
git merge-treereports the same seven files with the same conflict content, and the collected-id counts are unchanged), followed byorigin/masterd2dbc6a593. Every check below was run on that tree.1. Which files conflict
git merge-tree --write-tree --name-only <this head> <#39334 head>reports seven files:cms/lib/spectacular.pycms/lib/tests/test_spectacular.pyopenedx/core/djangoapps/user_api/schema.pyopenedx/core/djangoapps/user_api/schema_components.pyopenedx/core/djangoapps/user_api/v2/tests/test_schema.pyopenedx/core/djangoapps/user_api/v2/tests/test_urls.pyopenedx/core/djangoapps/user_api/v2/tests/test_views.pyThree more files change on both sides and auto-merge:
lms/envs/common.py,lms/lib/tests/test_spectacular.pyand
openedx/core/djangoapps/user_api/v2/urls.py. Keep them as merged. Two of #39334's tests still need a one-lineedit after the merge (step 3).
2. How to resolve each conflict
cms/lib/spectacular.py,SUPERSEDED_PATHS. Keep both sides' entries:/api/user/v1/accounts/deactivate_logout/and/api/user/v1/accounts/replace_usernames/;Studio serves all eight and serves no replacement for any of them.
openedx/core/djangoapps/user_api/schema.py,SUPERSEDED_USER_PATHS. Keep both sides' entries: #39334's twoand this PR's six. The tuple then holds 38 entries, with no duplicate.
openedx/core/djangoapps/user_api/schema_components.py. Both sides addCREDENTIAL_SCHEMES. Write a singlemapping holding
jwtandbearerfrom #39334 andcsrffrom this PR, each entry exactly as its side wrote it. Replaceboth comments with one comment that does not count the entries:
Leave the settings alone:
lms/envs/common.pyauto-merges to a single'securitySchemes': dict(CREDENTIAL_SCHEMES),.cms/envs/common.pyis changed by this PR only. Studio keeps{'csrf': CREDENTIAL_SCHEMES['csrf']}, the onecredential its own operations name.
cms/lib/tests/test_spectacular.pyhas two hunks.SUPERSEDED_USER_OPERATION_IDS. Keep both sides' entries: feat: standardize the two account operations the retirement pipeline does not drive #39334's two and this PR's eleven.SupersededUserOperationTest.test_signing_in_by_posting_keeps_what_it_has_always_publishedandtest_no_address_replacing_a_superseded_one_is_published_here.test_no_operation_here_names_a_credential_only_the_other_service_describes, and tighten it, donot exempt it. After the merge, Studio's sign-in names
csrf, which is now inCREDENTIAL_SCHEMES, so feat: standardize the two account operations the retirement pipeline does not drive #39334'slast assertion no longer holds. Make three changes:
assert named - described <= {"csrf"}, ...becomesassert named - described == set(), sorted(named - described).assert named & set(CREDENTIAL_SCHEMES) == set()becomesassert described & set(CREDENTIAL_SCHEMES) <= named, sorted((described & set(CREDENTIAL_SCHEMES)) - named).Studio may describe an appended scheme only if one of its operations names it.
CREDENTIAL_SCHEMESis still used, so its import stays and ruff is clean.openedx/core/djangoapps/user_api/v2/tests/test_urls.py,EXPECTED_ADDRESSES. Keep both sides' entries:#39334's
user_v2:username_replacement_listand this PR's five.openedx/core/djangoapps/user_api/v2/tests/test_views.py,AddressSelfDescriptionTests.test_every_mounted_address_is_described. This is a count, not a table. Writeassert len(described) == 22, sorted(described): the base mounts 16 views, #39334 adds 1 and this PR adds 5.openedx/core/djangoapps/user_api/v2/tests/test_schema.pyhas five hunks.Superseded-address tables.
SUPERSEDED_USER_ADDRESSES: keep both sides' entries, feat: standardize the two account operations the retirement pipeline does not drive #39334's two and this PR's five.SUPERSEDED_USER_ADDRESSES_LEFT_OUT(this PR): define it once, holding only the sign-in address.SUPERSEDED_USER_METHODS(feat: standardize the two account operations the retirement pipeline does not drive #39334): add this PR's six paths:/api/user/v1/account/registration/:"post"/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"/api/user/{api_version}/account/login_session/:"post"The
untouchedlist oftest_hook_leaves_other_resources_alone. Both sides removed entries here, soapply both removals:
/api/user/v2/account/registration/with/user_api/v2/account/registration/./api/user/v1/accounts/deactivate_logout/.From this hunk, only
/user_api/v2/account/registration/remains. Keeping both sides here fails the test.The
markedlist of the same test. Keep both sides' entries: this PR's/api/user/v2/account/registration/andfeat: standardize the two account operations the retirement pipeline does not drive #39334's
deactivate_logoutandreplace_usernames.The head of
test_every_superseded_address_has_a_published_replacement. Keep feat: standardize the two account operations the retirement pipeline does not drive #39334's docstring and bothassertions:
The body of the same test.
assert SUPERSEDED_USER_METHODS[superseded] in schema["paths"][replacement], supersededin thefirst loop.
SUPERSEDED_USER_ADDRESSES_LEFT_OUT, and add the same method assertion to it.test_the_superseded_address_left_out_is_generated_from_the_same_mounts, unchanged.The merge also applies one deletion without a conflict. #39334 deletes
test_addresses_owned_by_other_apps_are_not_published,which this PR left as #39330 wrote it. Keep it deleted. After #39334, both addresses it asserts are absent
(
deactivate_logout,replace_usernames) are published as superseded.3. Edits the merge needs where nothing conflicts
This PR's sign-in names
csrfin the LMS documents. Two of #39334's exact-set assertions do not conflict textually,but they fail after the merge until
"csrf"is added:v2/tests/test_schema.py::test_every_credential_an_operation_publishes_is_one_the_document_describes:assert named == {"cookieAuth", "jwt", "bearer", "csrf"}.lms/lib/tests/test_spectacular.py::PublishedDocumentTest::test_every_credential_an_operation_names_is_described_by_the_document:the same change.
One exemption has nothing left to exempt:
lms/lib/tests/test_spectacular.py::ServiceWideDocumentSettingsTest::test_the_credentials_this_service_describes_reach_the_document_it_publishes.Its first assertion now requires
csrfto be described.assert named - described <= {"csrf"}, ...toassert named - described == set(), sorted(named - described).4. Checks to run before trusting the result
No conflict markers:
git grep -nE '^(<<<<<<<|=======|>>>>>>>|\|\|\|\|\|\|\|)( |$)' -- '*.py'prints nothing.Structure of every conflicted or auto-merged module. Parse each of the ten files and compare it with both
parents and the base. Expected = (feat: standardize the two account operations the retirement pipeline does not drive #39334 ∪ this PR) minus what either side deleted. Check:
matches expected;
The last check is not academic. One of the two merges made for this recipe replayed the same hunk resolutions
and still produced a second, identical
SUPERSEDED_USER_ADDRESSES_LEFT_OUT = {...}block afterSUPERSEDED_USER_METHODS, because the hunk boundaries differed. ruff and pylint both pass with the duplicate in place, and the twovalues are identical, so no test notices it either.
ruff check .reports "All checks passed!", with no F401 (unused import).Tests. Run the LMS targets under
lms.envs.testand the Studio targets undercms.envs.test:openedx/core/djangoapps/user_api/ openedx/core/djangoapps/profile_images/ openedx/core/djangoapps/user_authn/ openedx/core/djangoapps/oauth_dispatch/ common/djangoapps/student/tests/ lms/lib/tests/ lms/tests.py openedx/core/tests/test_apidocs.py openedx/core/djangolib/testing/tests/cms/lib/tests/ cms/tests.py openedx/core/djangoapps/user_api/ openedx/core/djangoapps/user_authn/Collected test ids. Compare as sets under
LC_ALL=C, not as counts. The merged ids must equal ids(feat: standardize the two account operations the retirement pipeline does not drive #39334) ∪ids(this PR) minus exactly one id:
v2/tests/test_schema.py::test_addresses_owned_by_other_apps_are_not_published.Served documents (LMS
/lms-api/schema/and/api-docs/schema/, Studio/authoring-api/schema/and/api-docs/schema/, and the documentation settings). Check:$ref;bearer,cookieAuth,csrfandjwt;/api-docs/addscsrfand nothing else.5. Expected numbers (merged tree plus
origin/masterd2dbc6a593)Tests (collected / result):
Collected ids. The merged LMS ids equal feat: standardize the two account operations the retirement pipeline does not drive #39334 ∪ this PR (3855 ids) minus the one deleted test. The Studio ids equal
feat: standardize the two account operations the retirement pipeline does not drive #39334 ∪ this PR (2951 ids) minus the same test. Every master id is still collected: 1975 under LMS and 1165 under
Studio, for these targets.
Per conflicted test module (feat: standardize the two account operations the retirement pipeline does not drive #39334 / this PR / merged):
cms/lib/tests/test_spectacular.py(Studio)lms/lib/tests/test_spectacular.pyv2/tests/test_schema.pyv2/tests/test_urls.pyv2/tests/test_views.pyServed documents (paths / operations; 0 dangling
$refin every one):/lms-api/schema/bearer,cookieAuth,csrf,jwt/api-docs/schema/bearer,cookieAuth,csrf,jwt/authoring-api/schema/csrf/api-docs/schema/cookieAuth,csrfNo path, operation, operationId or component of master, #39334 or this PR is lost. Every credential an operation names is
described in the same document. drf-spectacular reports 0 errors that master does not already report. The only
new warnings are of one kind, "could not resolve authenticator", which master already emits on hundreds of views.
ruff: "All checks passed!", also with
--select F401,F811,F841over the touched packages.Deliberate deviations
login_session_list. Its verb heuristic matches "login". The address names the session that a POST creates.masterthe gate passes.Known pre-existing behaviour, left as it is
200-with-error-body case and the HTML403s listed under "Error-format decision"./api-docs/schema/caching, as described in feat: standardize the user accounts, profile image, verification and name-change API #39297.Evidence
Full tables are in the evidence comment.
Gates (PR base #39330):
masterae4332cd28), MongoDB attached: Studio (cms/lib/tests/,cms/tests.py,user_api/) 216 passed, 2003 skipped, 0 failed. LMS, serial (user_api/,profile_images/,user_authn/,lms/lib/tests/,lms/tests.py,openedx/core/tests/test_apidocs.py): 2922 passed, 1 skipped, 1 failed. The failure is master's ownprofile_images/tests/test_views.py::ProfileImageViewPostTestCase::test_upload_staff, which found an image already stored under its account's name in the upload directory. That checkout's upload directory had been shared with an earlier, interrupted run (the shared-location issue noted in feat: standardize the user accounts, profile image, verification and name-change API #39297). On a fresh checkout of this head,profile_images/passes serially: 130 passed.user_api/v2/: 1597 passed, also under a random order.origin/masterd2dbc6a593(the merge described above; this PR's side is tree-identical to this head): LMS 3846 passed, 8 skipped, 0 failed; Studio 391 passed, 2559 skipped, 0 failed. This PR merged with master alone was collected (3672 LMS, 2775 Studio ids, 0 master ids lost) but not run at this head.Follow-ups
/api/user/v3/): serializers and the standardized error envelope, JSON in place of Django's HTML CSRF page, and a sign-in form description that names the version 2 fields.