Files
RenewCMS/features/roles_permissions_users_api.qa.md
T

17 KiB

QA procedure: roles_permissions_users_api

Package: test (black-box). New fakes need unique names, for example usersAPIRoleRepo, usersAPIUserRepo, usersAPIInviteRepo, usersAPIRecorder. Reuse mintCookie, assertGate, assertRefusal, assertEscalation, the API key minting helper, grantsOf, openMigratedRolesDB and the fakes of test/role_support_test.go, test/roles_api_world_test.go and test/roles_content_scope_world_test.go. Do not copy any scope-rank comparison into tests: call GrantSet.Covers and the exported helpers. No live server, no real SQLite file, no real SMTP.

Deferred to later slices

  • 5-7: SPA (Users: role badges, Change role dialog, role picker in the invite dialog, Transfer ownership dialogs, profile role card, role-aware shell). The SPA reacts to the status, the body code and the X-Missing-Permission / X-Escalation-Key headers.
  • A user-removal route, which must refuse the Owner.
  • Dropping the physical users.role column (a later release).

Design notes for the user

  • docs/design names POST /v1/invite and GET /v1/me. The code routes are POST /v1/user/invite and GET /v1/auth/me and stay. POST /v1/site/transfer-ownership is the design's route.
  • docs/design names the events user.role_changed and site.ownership_transferred. The code convention is user.change_role and user.transfer_ownership (category users).
  • The design copy for "Owner can't be changed or removed" and "There is one owner" maps onto the existing 403 role_locked. The copy of the change-role dialog should key on that code.
  • New codes with no design copy: invalid_role, user_role_changed, invalid_transfer, confirmation_mismatch.
  • The design shows "Assigned by Marta Alves" on the profile role card. This slice stores no assigner. It is a design question for slice 7.
  • Agents never edit docs/design: these items are for you to relay to Claude Design.

Inventory (route, key, session only, checks after the gate, Owner / Admin / Writer)

Route Key Session only After the gate Owner / Admin / Writer
PUT /v1/user/{id}/role users.change_role yes id, body, user 404, Locked current, role 400, Locked new, own account, covers current, covers new, no-op, CAS write ok / ok / 403
POST /v1/user/invite users.invite no body, role (default Writer) 400, Locked, covers, email has account, pending, create, mail ok / ok / 403
POST /v1/user/invite/{id}/renew users.invite no id, 404, 409, covers the invite's role, renew, mail ok / ok / 403
DELETE /v1/user/invite/{id} users.invite no id, 404, delete ok / ok / 403
GET /v1/user users.view no none, roles read once ok / ok / 403
POST /v1/site/transfer-ownership system.transfer_ownership yes body, target 400, confirm_email 400, CAS transaction ok / 403 / 403
POST /v1/auth/invite/accept public no invite's role read at accept as before
POST /v1/auth/register public no creates the Owner (role_id) as before
GET /v1/auth/me none as before no top-level role; access.role only as before
GET /v1/role, DELETE /v1/role/{id}, POST /v1/role/{id}/reassign as before as before members include open invites; reassign moves invites as before

Test infrastructure first

  1. Role fake (extends mockRoleRepo): adds MemberCounts (reads the shared user and invite fakes), Delete and ReassignMembers that count open invites, call counters for List, Get, GetByName, MemberCounts. Remove nothing else.
  2. User fake: GetAll, Create (stores role_id), UpdateRole(id, from, to) compare-and-swap (user.ErrRoleChanged, user.ErrUserNotFound, role.ErrRoleNotFound when the new role is gone), TransferOwnership(from, to, ownerRoleID, formerRoleID) in one step, a hook between read and write for race tests, a switchable failure per method. Users 3, 5, 9, 10, 12 (unverified), 13 (role_id 0), 14 (role_id 99), 31, 32, 33, 41-46.
  3. Invite fake: carries RoleID, GetAll, Create, Renew, AcceptWithUser (creates the user with the invite's role_id), invites 1-4 of the Background.
  4. Content type fake with "events" and "venues", a recording audit.Recorder, a mail fake that records template, recipient and data.
  5. HTTP harness: validator.InitValidator(), a fresh chi.NewRouter() mirroring NewUserRouter, the transfer route of NewSiteRouter and NewAuthRouter in api/router.go (Verifier, apiKeyAuth equivalent, AuditContext, Authenticator, RequireCurrentToken, RequireVerifiedUser, RequireSession on session-only routes, RequirePermission(...) with the key constants), over real use cases and the fakes.
  6. Helper userItem(t, rec) and roleRefOf(t, body) to assert shapes, and seededUser(id, builtInName) so fixtures set RoleID instead of a string.

A. Domain (no fakes)

  1. user.User has no Role field and user.Role, RoleOwner, RoleAdmin do not exist (hygiene test over the source tree). invite.Invite carries RoleID.
  2. New sentinels exist and map with errors.Is: user.ErrRoleChanged, user.ErrInvalidTransfer, invite.ErrInvalidRole (or role.ErrUnknownRole), user.ErrConfirmationMismatch. Escalation is read with errors.As on role.EscalationError.
  3. internal/domain/user and internal/domain/invite import only the stdlib and domain packages. audit constants ActionUserChangeRole, ActionUserTransferOwnership, FieldRole, ParamRole, ParamRoleID exist.
  4. GrantSet.Covers rows used by this slice (People manager against Writer, Content lead, Admin, Intern, Contributor): first violation key content.update for the first three, none for Intern and Contributor.

B. Use cases (fakes only)

  1. ChangeUserRole: every row of the "Changing a user's role" Outline with errors.Is / errors.As. Assert the order with counters: for a refusal before the write, UpdateRole is never called. The no-op row: no write, no event. Race: the hook changes the target's role between read and write, expect user.ErrRoleChanged. Role deleted between lookup and write: role.ErrRoleNotFound maps to invalid_role. Events: change_role has changes role before/after names and params from/to; refusals and no-ops record nothing; a repository failure records a failed event.
  2. TransferOwnership: happy path (former owner gets the Admin role found by builtInRole, new owner gets the Owner role id, event with security true), all refusals of the Outline, a CAS failure, a repository failure (failed event, nobody changed), exactly one Owner afterwards.
  3. CreateInvite: every row of the "Inviting with a role" Outline. A refused call does not delete an expired invite and does not send a mail. The default role is the built-in Writer found by builtInRole. The stored invite has RoleID. invite.create params role_id and role. Existing failure paths (email has account, pending, mail failure rolls back) unchanged.
  4. RenewInvite: keeps the role, covers check after 404 and 409, no mail on a refusal. CancelInvite: no coverage check.
  5. AcceptInvite: creates the user with the invite's RoleID and no legacy string. RoleID 0 or unknown role gives invite.ErrInviteInvalid, no user, failed event. Grants edited before the accept are what the user gets.
  6. CreateUser: registration stores the Owner RoleID. Unseeded Owner role: error and no user.
  7. ListUsersAndInvites: role per row from one List call (counter), null for an unresolvable user, invite rows carry their role.
  8. GetMyAccess, CheckPermission, resolveRole: RoleID 0 gives ErrRoleNotFound, empty GrantSet in CheckPermission, an error in GetMyAccess. No legacy fallback rows remain.
  9. ListRoles / GetRole / DeleteRole / UpdateRole: members come from MemberCounts (counter 1, GetAll counter 0), include open invites, exclude accepted ones. DeleteRole is blocked by a pending or an expired invite. ReassignRoleMembers returns users plus invites and records moved with the total.
  10. Activity: the new actions and params validate through audit.Event.Validate().

C. HTTP

  1. Gate matrix: every row of the first Outline, one subtest per row, stores reset per row. API key on session-only routes gets apperr.SessionRequiredErrorMessage before the gate. Live-role scenario with the same cookie.
  2. Each Outline of the feature: change role, invite with a role, renew and cancel, accept, transfer refusals. Assert status, exact body text, headers (X-Missing-Permission, X-Escalation-Key), the stores after the call and the recorder.
  3. Order: for a Writer, PUT /v1/user/abc/role and the transfer with no body are 403 before 400. For user 31 an unknown role_id is 400 before an escalation 403 on a higher role, and a Locked current role is 403 before an unknown role_id. For the invite, a refused role leaves expired invite 2 in place.
  4. Shapes: PUT returns the user item (RoleRef), invite create returns role, transfer returns owner and former_owner, GET /v1/user keys exactly as in the feature, PublicUser has 7 keys in login, verify, register, accept and /auth/me, with access.role intact.
  5. Live effect: after PUT, the target's next request on a gated route, and its API key, see the new grants with the same cookie. After the transfer, user 3 is refused system.transfer_ownership and holds the Admin grants; user 5's key holds the Owner grants.
  6. Concurrency: the race scenarios with a hook (one 200, one 409 user_role_changed).
  7. Loads per request: a gated request loads the user at most once and the role once, MemberCounts 0 times on the permission path.
  8. Regression: the foundation, enforcement, content-scope and roles-api suites stay green after fixtures move to RoleID.

D. Persistence and migration (openMigratedRolesDB)

  1. UserRepository: Create writes role_id (NULL for 0), the mapper has no role string; UpdateRole is a compare-and-swap (two calls with the same from: one wins) and returns ErrRoleChanged / ErrUserNotFound / role.ErrRoleNotFound; TransferOwnership updates both rows in one transaction, rolls both back when from is no longer the owner or to is unverified or missing, and two concurrent transfers give one winner; failure paths (DB closed).
  2. InviteRepository: role_id column on invites after Migrate, Create / Renew keep it, AcceptWithUser stores the user's role_id and marks the invite accepted in one transaction.
  3. RoleRepository.MemberCounts: users (verified or not), pending and expired invites count, accepted invites do not, NULL role_id users do not; a role with none is absent or 0; at most 2 SQL statements for any number of roles (count with a GORM callback). Delete refuses with ErrRoleHasMembers for a user or an open invite and succeeds for an accepted one. ReassignMembers moves users and open invites and returns the sum.
  4. Migration: build a legacy database with ALTER TABLE users ADD COLUMN role VARCHAR(32) NOT NULL DEFAULT '' plus rows for each Outline row, run Migrate: role_ids as in the feature, invites get Admin, BackfillCreatedBy finds the owner by role_id, a second Migrate is a no-op, the column is still there. A fresh database has no users.role column and Migrate succeeds. Migrate with no users succeeds.
  5. sqlite_schema_unchanged_test.go: the expected schema gains invites.role_id and loses users.role on a fresh database.

Existing tests that must change (recheck with go vet -tags nofrontend ./test/...)

File (cluster) Change
Every fixture that builds `user.User{Role: user.RoleOwner RoleAdmin}or"owner"/"admin": 44 files, about 217 occurrences, for example user_roles_test.go, invitations_test.go, invite_management_test.go, get_current_user_http_test.go, api_keys__test.go, media_purge_http_test.go, update_http_test.go, activity_log_backend_http_test.go, session_revocation_http_test.go, custom_content_types_support_test.go, image_variants_env_test.go, author_support_test.go, seo_data_support_test.go, mysql_server_test.go, postgres_server_test.go`
roles_permissions_foundation_test.go, roles_permissions_foundation_http_test.go Delete the "falls back to the built-in role named by the legacy role" rows and BackfillUserRoleIDs scenarios. Replace with the role_id 0 and role_id 99 denial rows and the new migration tests
roles_permissions_enforcement*_test.go (legacy rows, "no role_id and a legacy string other than owner or admin") Replace with the role_id 0 rows
roles_api_world_test.go, roles_api_usecases_test.go, roles_api_http_test.go, roles_api_http_write_test.go, roles_api_persistence_test.go, roles_api_hardening_test.go, roles_api_domain_test.go User 10 (role_id 0, legacy admin) gets RoleID Admin; Admin members stay correct; add MemberCounts, invites in members, and GetAll counter 0
role_support_test.go, roles_content_scope_world_test.go, roles_content_scope_support_test.go, role_repository_gaps_test.go, role_hardening_test.go mockRoleRepo gains MemberCounts; Delete and ReassignMembers include open invites; the user fake gains UpdateRole and TransferOwnership; the invite fake gains RoleID
user_response_field_hygiene tests (and any publicuser shape test) Eight keys become seven (no role)
user_display_name_test.go, get_current_user_http_test.go /auth/me top-level role assertions removed; access.role kept
activity_usecases_test.go, activity_domain_test.go New actions, params and field
tests of the builtin user type (schema_builtin_view tests, builtin_type tests) The role field is text, not required, no options
sqlite_schema_unchanged_test.go See item 33
api_catalogue_drift_test.go:52 Add PUT /v1/user/{id}/role and POST /v1/site/transfer-ownership to routesNotYetInTheCatalogue. The pre-existing failure of TestRouterRoutesAreInTheCatalogue is not this slice's: report, do not fix
web/src Vitest specs and kits that mock role: 'owner' Untouched here (the SPA is slices 5-7). They stay green because /v1 is mocked. Report them as "not run against the new API"

Existing features and docs to amend

  • features/user_roles.feature: "role is exactly owner or admin", "AcceptInvite creates role admin", "/auth/me returns role", "users list role", "no endpoint transfers a role": add "superseded by roles_permissions_users_api" notes in place.
  • features/invitations.feature and features/invite_management.feature: invite body, item and response keys gain role; the item key lists.
  • features/get_current_user.feature and features/user_response_field_hygiene.feature: no role key; seven public fields.
  • features/roles_permissions_foundation.feature: the legacy fallback scenarios, "users gain a nullable role_id; the legacy string is unchanged", the backfill scenarios.
  • features/roles_permissions_enforcement.feature: lines on the legacy resolution and the unresolvable role.
  • features/roles_permissions_roles_api.feature: the Background line "else the built-in named by the legacy owner/admin string", user 10 as legacy admin, and "Out of scope here" (pending-invite counting and role_has_members for invites: now done).
  • features/schema_builtin_view.feature: the builtin user type's role field.
  • features/activity-log-producers.feature and .qa.md: the two actions and the invite params.
  • features/users_screen.feature and features/users_row_actions.feature (SPA): note that the role string they read becomes a RoleRef, to be handled in slices 5-7.
  • docs/api.md: Users and invites table, new routes, shapes, codes, headers, activity, members of a role, /auth/me and the seven public fields. docs/architecture.md: repository methods. web/src/data/apiCatalogue.ts and the generated llms docs (task build:llms): the two new routes and the changed shapes.
  • docs/configuration.md: no new env var.
  • docs/design: for you to relay, not edited by agents (see Design notes).

Manual verification

  • Copy a real database from a pre-slice-4 build, run task dev:api on the copy: every user has a role_id, every invite an Admin role_id, the users.role column is still there, the activity log and created_by are intact.
  • As the Owner: invite with the default (Writer) and with Contributor, accept in a real browser from the mailed link, check the new user's role and /auth/me. Real SMTP delivery is a manual item.
  • As an Admin: change a Contributor to Writer and then to Admin, try Owner (role_locked) and your own role (own_role). As a People manager: try a Writer (escalation with the key in X-Escalation-Key).
  • Transfer ownership between two real sessions: the old owner's tab keeps working as Admin without re-login, the new owner's tab gains Owner permissions, the old owner's API key is an Admin key.
  • Delete a role that has only an expired invite (409), move members, delete (204).
  • Run the migration on MySQL/MariaDB and PostgreSQL with Docker (the Docker QA steps run for real here): the legacy backfill, the invite role_id, the CAS update and the transfer transaction behave like SQLite.
  • Known risk to check by hand: starting the previous binary on an upgraded database is not supported (post-upgrade users have users.role empty).
  • No secret appears in any scenario. Env vars are referenced by name only (JWT_SECRET, SMTP_HOST).