# 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) 7. `user.User` has no `Role` field and `user.Role`, `RoleOwner`, `RoleAdmin` do not exist (hygiene test over the source tree). `invite.Invite` carries `RoleID`. 8. 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`. 9. `internal/domain/user` and `internal/domain/invite` import only the stdlib and domain packages. `audit` constants `ActionUserChangeRole`, `ActionUserTransferOwnership`, `FieldRole`, `ParamRole`, `ParamRoleID` exist. 10. `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) 11. `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. 12. `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. 13. `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. 14. `RenewInvite`: keeps the role, covers check after 404 and 409, no mail on a refusal. `CancelInvite`: no coverage check. 15. `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. 16. `CreateUser`: registration stores the Owner `RoleID`. Unseeded Owner role: error and no user. 17. `ListUsersAndInvites`: role per row from one `List` call (counter), `null` for an unresolvable user, invite rows carry their role. 18. `GetMyAccess`, `CheckPermission`, `resolveRole`: `RoleID` 0 gives `ErrRoleNotFound`, empty GrantSet in `CheckPermission`, an error in `GetMyAccess`. No legacy fallback rows remain. 19. `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. 20. Activity: the new actions and params validate through `audit.Event.Validate()`. ## C. HTTP 21. 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. 22. 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. 23. 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. 24. 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. 25. 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. 26. Concurrency: the race scenarios with a hook (one 200, one 409 user_role_changed). 27. Loads per request: a gated request loads the user at most once and the role once, `MemberCounts` 0 times on the permission path. 28. Regression: the foundation, enforcement, content-scope and roles-api suites stay green after fixtures move to `RoleID`. ## D. Persistence and migration (`openMigratedRolesDB`) 29. `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). 30. `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. 31. `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. 32. 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. 33. `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` | Set `RoleID` to the seeded id through `seededUser`. Delete the `Role` field assignments. Assertions on a `role` string become assertions on `RoleRef` or are removed | | `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).