Files
RenewCMS/features/roles_spa_users.qa.md
T

80 lines
12 KiB
Markdown

# QA procedure: SPA Users, Profile, API keys and Activity for roles (slice 7)
Spec: features/roles_spa_users.feature. Vitest + Vue Test Utils under `task test:web`, /v1 mocked through the existing kits (the useApi mock used in views/__tests__), access bodies built with web/src/test/access.ts (ownerAccess, adminAccess, writerAccess, customAccess, meBody). No live server, no real SMTP. Visual checks are out of this slice by user decision.
This slice now touches Go as well as the SPA (backend rule "Who assigned a role"). `task test` (go test -tags nofrontend ./test/... -count=1), `task lint:go` / `gofmt -l` and `go vet -tags nofrontend` apply, as does the mutation scope of `task mutate` for the touched use cases (ChangeUserRole, TransferOwnership, AcceptInvite, ListUsersAndInvites, GetMyAccess), scoped through MUTATE_FILES and MUTATE_RUN. Baseline: run both `task test` and `task test:web` on the unmodified tree first (known Go failure: TestRouterRoutesAreInTheCatalogue).
## Honesty rules
0. First run the baselines above and record exit codes and failing specs.
1. If the runner cannot execute, every SPA item is NOT RUN, never passing.
2. Report each new or edited spec and which already failed in the baseline.
3. Spec only the copy the design gives. Items marked "copy TBD" in the feature get behaviour specs (the toast or inline error exists, the dialog closes or stays open) that do not assert text, and are listed as design gaps.
4. Date and clock: fixed `created_at` / `updated_at` strings; no spec freezes the system date; no scenario asserts a relative date.
5. "Remove user" and "Send password reset" for another user are not built. A spec asserts their absence only where listed below.
## Automated (Vitest)
| Spec | Cases |
|---|---|
| composables/__tests__/useUsers.spec.ts (extend) | inviteUser sends role_id when given and {email} only when not; changeUserRole PUT /v1/user/{id}/role and its answer; transferOwnership POST body and answer; refusal mapping of escalation, role_locked, own_role, missing_permission, user_role_changed, invalid_role, invalid_transfer, confirmation_mismatch, user_not_found, session_required |
| views/__tests__/UsersView.spec.ts (extend) | gating table (users.view, invite, change_role, Owner), 403 page without users.view, invite row with role badge, ownership refresh of the list, a user item carrying role_assigned_by renders no "Assigned by" text in the table or the cards |
| views/__tests__/UsersRowActions.spec.ts (extend) | row menu per grant, own row, owner row for an Admin and for the Owner, invite rows unchanged, Transfer ownership absent without system.transfer_ownership |
| components/__tests__/InviteUserModal.spec.ts (extend, or create if the modal has no spec) | role picker default Writer and its hint, Owner not offered, escalation disabling through grantLocks, Writer above the caller then no default and Send disabled, no roles.view then no picker and {email} only, answers 403 escalation / 403 role_locked / 400 invalid_role keep the dialog and the address |
| components/__tests__/ChangeRoleDialog.spec.ts (new) | title and body, options with summaries, Current mark, button off until another option, Owner option disabled with its reason, escalation reason, in-flight lock, each answer of the table, roles.view absent |
| components/__tests__/TransferOwnershipDialog.spec.ts (new) | pick step (accepted users only, no self, no invites), Continue gate, confirm copy with the member name and email, exact-match gate (case and whitespace), Back keeps the choice, in-flight lock, success refreshes /auth/me then the list, each refusal |
| components/__tests__/RolePicker.spec.ts (new or via the dialogs) | the extension that lists a locked role as disabled with a reason; slice 6 behaviour unchanged (locked and source still hidden in DeleteRoleDialog) |
| views/__tests__/ProfileView.spec.ts (extend) | Your role card with RoleBadge and the keys sentence; with {id: 3, name: "Marta Alves"} the card reads "Assigned by Marta Alves"; with null or an absent key no "Assigned by" text exists; the line follows a refreshed session; live change of access.role; skeleton; See what I can do dialog on desktop and sheet at mobile with Close and focus return; no GET /v1/role call |
| lib/__tests__/permissions.spec.ts (extend) | parsing access.role_assigned_by: object, null, absent |
| views/__tests__/ApiKeysView.spec.ts (extend) | info alert copy, Follows role column for own keys, "—" for other creators, mobile card, badge follows a refreshed role |
| lib/__tests__/activitySentence.spec.ts (extend, new if absent) | the five design sentences, role.reassign (no text assertion), failed templates (no text assertion), unknown action |
| views/__tests__/ActivityDetails.spec.ts (extend) | role.edit Added/Removed with human names and "(Own)", name/description diff, role.delete members line, user.change_role Role diff, user.transfer_ownership, empty diff |
| views/__tests__/ActivityFilters.spec.ts (extend) | users and roles categories present, security chip on transfer |
| i18n/roleMessages.spec.ts (extend) | new keys exist in EN and FR, placeholders match, "Assigned by {0}" |
Mocks to reuse: GET /v1/role as the world of features/roles_permissions_users_api.feature (Owner locked, Admin, Writer, Contributor, Intern, Content lead, People manager, Viewer, Inviter); GET /v1/user with Marta Alves (Owner), Jonas Weber (jonas@renewbuild.example, Admin), Ines Duarte (Writer), Lea Martin, an expired invite and a pending invite; access built with ownerAccess() / adminAccess() / customAccess().
## Automated (Go, httptest, in-memory fakes) in test/
Build each handler with `NewHandler(...)` over the use cases and in-memory fakes of user.Repository, invite.Repository and role.Repository. The existing users-API fakes are extended with the assigner field. Mount them on a fresh `chi.NewRouter()` that mirrors api/router.go, with `jwtauth.Verifier` + `Authenticator` and a test `jwtauth.New("HS256", …)`. Call `validator.InitValidator()`. No SQLite file, no SMTP, no live server. Name the new fakes uniquely across the package.
| Test | Steps and assertions |
|---|---|
| TestRoleAssignedByIsSetOnChangeRole | user 5 PUTs a role for 41; the fake stores 5; GET /v1/auth/me as 41 shows access.role_assigned_by {id 5, name}; a second PUT by user 3 stores 3 |
| TestRoleAssignedByUnchangedOnNoOp | PUT with the current role: 200, fake UpdateRole not called, stored value unchanged |
| TestRoleAssignedByUnchangedOnRefusal | table of 403 role_locked, 403 own_role, 403 escalation, 404, 400 invalid_role, 409 user_role_changed: stored value unchanged |
| TestRoleAssignedByAtAcceptInvite | accept an invite created by user 5: the new user's assigner is 5; an invite whose inviter is missing gives null and still succeeds |
| TestRoleAssignedByAtTransfer | transfer 3 to 5: user 5's assigner is 3, user 3's is null, both in the one fake transaction; a failed transaction (fake error, stale from) changes neither |
| TestRoleAssignedByAtRegister | the Owner registered first has null |
| TestRoleAssignedByUnchangedOnReassign | POST /v1/role/{id}/reassign leaves the assigner of the moved users unchanged |
| TestUserListCarriesRoleAssignedBy | GET /v1/user as an Admin: exact key set of user items and invite items (no key on invites), object and null cases, assigner resolved from the listed users with no per-row repository call (the fake counts GetByID calls) |
| TestRoleAssignedByFollowsRename | rename user 5 in the fake, then GET /v1/auth/me for 41 shows the new name; delete user 5 from the fake, the field is null, never a nameless object |
| TestAuthMeKeepsOtherShapes | login, verify, register and invite/accept bodies carry no role_assigned_by; PublicUser keys unchanged |
| TestRoleAssignedByNoNewGate | a user with only users.view reads it on GET /v1/user; any signed-in user reads their own on /auth/me; an unauthenticated caller gets 401 as before |
| TestAutoMigrateAddsColumn (persistence level, only if the suite already has an in-memory persistence test; otherwise MANUAL) | the model has a nullable role_assigned_by_id column, existing rows read NULL. Real SQLite file and an upgrade of an existing database are MANUAL item 9 |
Layering checks go in the existing layering test or an added one: internal/domain/user imports no gorm, chi or infrastructure; the handlers import no persistence.
Sub-decisions applied: S1 transfer sets the new owner's assigner to the former owner and the former owner's to null; S2 bulk reassign leaves the assigner unchanged; S3 the name is display_name when set, else username; S4 a PUT with the same role changes nothing.
## Manual checks
1. Keyboard: tab and focus order in Change role (radio group, Current mark announced), Transfer ownership (pick, confirm field) and the invite dialog; focus returns to the row menu button after close; Escape is blocked while sending.
2. Screen reader: disabled options announce their reason; the Current option is announced; the toast on role change is announced politely.
3. Two browsers: an owner changes a user's role while an admin has the Change role dialog open; the admin's save gets the user_role_changed path. Same for two simultaneous transfers.
4. Real flow in a real browser: create Contributor in Roles, invite someone with it, accept the invite from a real SMTP mailbox (real delivery is manual), change the role, check the new user's sidebar follows it with no re-login.
5. Real transfer: the Owner transfers to an Admin, the former owner's sidebar loses owner-only entries without reload, an API key created by the former owner now acts as Admin.
6. Mobile 390 px: card list menus, bottom sheet for See what I can do, 44 px targets, French copy, dark mode.
7. Activity: perform each of the six events in the real app and read them in /activity.
8. `task lint:web` (eslint and vue-tsc) green; `task test:web` exit 0 on the final tree.
9. Upgrade a real database from the previous release with `task run`: the column appears, old users show no "Assigned by" line, then change a role and see it on that user's Profile with the actor's name.
10. Rename the assigner and reload the assigned user's Profile; confirm the new name.
## Design gaps and backend notes
- G1 "Assigned by" is now stored and exposed. The design shows it only on the Profile role card. The Users list has no such column and does not show it.
- G2 "Remove user" and "Send password reset" in the user row menu have no route (open points 2 and 3). Omitted.
- G3 copy TBD in the feature: role changed toast, user_role_changed, invalid_role, user_not_found, invalid_transfer, confirmation_mismatch, 500 inline errors of the two dialogs, no-roles.view tooltip, role.reassign sentence, failed templates, category names, Changed diff line.
- G4 the design's blocked-option reason per role ("It can publish. Your role can't grant that.") is replaced by the generic sentence already in RolePicker.
- G5 the design says "Her API keys" and "unless he transfers it": gendered; neutral copy is used ("Their API keys follow the new role.", "unless they transfer it").
- G6 design lists POST /v1/invite and role.created / role.updated / role.deleted; the backend uses POST /v1/user/invite and role.create / role.edit / role.delete / role.reassign. Backend wins.
- G7 API keys list has no creator role; "Follows role" shows the caller's badge on the caller's keys and "—" on other creators' keys.
- G8 the Profile role card sits after the Account card; the coder confirms against the AppFrame board.
- G9 RolePicker gains an opt-in prop (Owner shown disabled with a reason, "Current" mark); DeleteRoleDialog is unchanged.
- Backend change: role_assigned_by only (sub-decisions S1 to S4). docs/api*.md document `access.role_assigned_by` on GET /v1/auth/me and `role_assigned_by` on the GET /v1/user accepted-user item. features/roles_permissions_users_api.feature is not edited; this feature carries the delta.