Files
RenewCMS/features/roles_permissions_users_api.qa.md
T

130 lines
17 KiB
Markdown

# 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).