17 KiB
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
- Role fake (extends
mockRoleRepo): addsMemberCounts(reads the shared user and invite fakes),DeleteandReassignMembersthat count open invites, call counters forList,Get,GetByName,MemberCounts. Remove nothing else. - User fake:
GetAll,Create(stores role_id),UpdateRole(id, from, to)compare-and-swap (user.ErrRoleChanged,user.ErrUserNotFound,role.ErrRoleNotFoundwhen 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. - Invite fake: carries
RoleID,GetAll,Create,Renew,AcceptWithUser(creates the user with the invite's role_id), invites 1-4 of the Background. - Content type fake with "events" and "venues", a recording
audit.Recorder, a mail fake that records template, recipient and data. - HTTP harness:
validator.InitValidator(), a freshchi.NewRouter()mirroringNewUserRouter, the transfer route ofNewSiteRouterandNewAuthRouterin 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. - Helper
userItem(t, rec)androleRefOf(t, body)to assert shapes, andseededUser(id, builtInName)so fixtures setRoleIDinstead of a string.
A. Domain (no fakes)
user.Userhas noRolefield anduser.Role,RoleOwner,RoleAdmindo not exist (hygiene test over the source tree).invite.InvitecarriesRoleID.- New sentinels exist and map with
errors.Is:user.ErrRoleChanged,user.ErrInvalidTransfer,invite.ErrInvalidRole(orrole.ErrUnknownRole),user.ErrConfirmationMismatch. Escalation is read witherrors.Asonrole.EscalationError. internal/domain/userandinternal/domain/inviteimport only the stdlib and domain packages.auditconstantsActionUserChangeRole,ActionUserTransferOwnership,FieldRole,ParamRole,ParamRoleIDexist.GrantSet.Coversrows used by this slice (People manager against Writer, Content lead, Admin, Intern, Contributor): first violation keycontent.updatefor the first three, none for Intern and Contributor.
B. Use cases (fakes only)
ChangeUserRole: every row of the "Changing a user's role" Outline witherrors.Is/errors.As. Assert the order with counters: for a refusal before the write,UpdateRoleis never called. The no-op row: no write, no event. Race: the hook changes the target's role between read and write, expectuser.ErrRoleChanged. Role deleted between lookup and write:role.ErrRoleNotFoundmaps 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.TransferOwnership: happy path (former owner gets the Admin role found bybuiltInRole, 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.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 bybuiltInRole. The stored invite hasRoleID. invite.create params role_id and role. Existing failure paths (email has account, pending, mail failure rolls back) unchanged.RenewInvite: keeps the role, covers check after 404 and 409, no mail on a refusal.CancelInvite: no coverage check.AcceptInvite: creates the user with the invite'sRoleIDand no legacy string.RoleID0 or unknown role givesinvite.ErrInviteInvalid, no user, failed event. Grants edited before the accept are what the user gets.CreateUser: registration stores the OwnerRoleID. Unseeded Owner role: error and no user.ListUsersAndInvites: role per row from oneListcall (counter),nullfor an unresolvable user, invite rows carry their role.GetMyAccess,CheckPermission,resolveRole:RoleID0 givesErrRoleNotFound, empty GrantSet inCheckPermission, an error inGetMyAccess. No legacy fallback rows remain.ListRoles/GetRole/DeleteRole/UpdateRole: members come fromMemberCounts(counter 1,GetAllcounter 0), include open invites, exclude accepted ones.DeleteRoleis blocked by a pending or an expired invite.ReassignRoleMembersreturns users plus invites and recordsmovedwith the total.- Activity: the new actions and params validate through
audit.Event.Validate().
C. HTTP
- Gate matrix: every row of the first Outline, one subtest per row, stores reset per row. API key on session-only routes gets
apperr.SessionRequiredErrorMessagebefore the gate. Live-role scenario with the same cookie. - 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. - Order: for a Writer,
PUT /v1/user/abc/roleand the transfer with no body are 403 before 400. For user 31 an unknownrole_idis 400 before an escalation 403 on a higher role, and a Locked current role is 403 before an unknownrole_id. For the invite, a refused role leaves expired invite 2 in place. - Shapes: PUT returns the user item (RoleRef), invite create returns role, transfer returns owner and former_owner,
GET /v1/userkeys exactly as in the feature,PublicUserhas 7 keys in login, verify, register, accept and/auth/me, withaccess.roleintact. - 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_ownershipand holds the Admin grants; user 5's key holds the Owner grants. - Concurrency: the race scenarios with a hook (one 200, one 409 user_role_changed).
- Loads per request: a gated request loads the user at most once and the role once,
MemberCounts0 times on the permission path. - Regression: the foundation, enforcement, content-scope and roles-api suites stay green after fixtures move to
RoleID.
D. Persistence and migration (openMigratedRolesDB)
UserRepository:Createwritesrole_id(NULL for 0), the mapper has no role string;UpdateRoleis a compare-and-swap (two calls with the samefrom: one wins) and returnsErrRoleChanged/ErrUserNotFound/role.ErrRoleNotFound;TransferOwnershipupdates both rows in one transaction, rolls both back whenfromis no longer the owner ortois unverified or missing, and two concurrent transfers give one winner; failure paths (DB closed).InviteRepository:role_idcolumn oninvitesafterMigrate,Create/Renewkeep it,AcceptWithUserstores the user'srole_idand marks the invite accepted in one transaction.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).Deleterefuses withErrRoleHasMembersfor a user or an open invite and succeeds for an accepted one.ReassignMembersmoves users and open invites and returns the sum.- Migration: build a legacy database with
ALTER TABLE users ADD COLUMN role VARCHAR(32) NOT NULL DEFAULT ''plus rows for each Outline row, runMigrate: role_ids as in the feature, invites get Admin,BackfillCreatedByfinds the owner by role_id, a secondMigrateis a no-op, the column is still there. A fresh database has nousers.rolecolumn andMigratesucceeds.Migratewith no users succeeds. sqlite_schema_unchanged_test.go: the expected schema gainsinvites.role_idand losesusers.roleon 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
rolekey; 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
rolestring 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/meand 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:apion the copy: every user has a role_id, every invite an Admin role_id, theusers.rolecolumn 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.roleempty). - No secret appears in any scenario. Env vars are referenced by name only (JWT_SECRET, SMTP_HOST).