refac: DELETE /v1/user/{id}?expected_role_id=N, user.remove audit event, 409 target_role_not_found, GetAll returns its error, Own-scope visibility filter moved into repos, JSON tags removed from use-case types

This commit is contained in:
Florian Sylvain committed 2026-10-29 16:28:25 +01:00
1 parent 5d6f378fce
commit b39a41be88
124 files changed
+8816 -523

No files matched your search

@@ -0,0 +1,57 @@
Feature: Relation picker "loading more" state
As an editor filling a relation field
I want the picker to show that the next page is loading and to keep my place
So that pressing "Load more" never loses focus or shows a blank list
Background:
Given the spec of record is docs/design/screens/schema-later.md group 7 states 2 and 3, and docs/design/change-log-roles-sync.md "Relation picker"
And the picker is RelationField.vue over useRelationSearch.ts, which already requests GET /v1/content-type/{key}/entry?page=N&size=25 and has loading, loadingMore, hasMore and total
And the sample is 25 venues loaded of 62 for "rue", footer "Showing 25 of 62" with "Load more"
And the design marks the group "Later" but the picker exists in the app; this file specifies the missing state only
And the number of skeleton rows is read from the board frame SchemaFrame.dc.html, not fixed here
And the SPA lives in web/src, Nuxt UI only, design tokens only, EN and FR in i18n/messages.ts, no comments in code
Rule: Loading more
Scenario: Pressing Load more
Given 25 of 62 results are listed for "rue"
When "Load more" is pressed
Then GET /v1/content-type/venues/entry?page=2&size=25 is sent
And skeleton rows follow the last loaded result
And the button reads "Loading…" with a loader and is disabled
And the footer still reads "Showing 25 of 62" and the loaded results stay in place
And a second press while loading sends no request
Scenario: The page arrives
When the page answers 25 more items
Then the skeleton rows are replaced by the new results, the footer reads "Showing 50 of 62", the button returns to "Load more" and is enabled
And focus moves to the first new row
Scenario: The last page
Given 12 items remain
When the page arrives
Then the footer reads "Showing 62 of 62" and the button is gone
Scenario: The next page fails
When the request answers 500 or the network fails
Then the skeleton rows disappear, the loaded results stay, the button is enabled again with its label "Load more"
And focus returns to the button
And the existing error treatment of the picker is unchanged (design gap G2)
Scenario: A new search while loading more
When the person types a new term
Then the pending page is ignored, the skeleton of the first page shows as for any search, and the footer follows the new total
Scenario: Focus handling
Given the keyboard user pressed Load more with Enter
Then focus stays on the button while loading and moves to the first new row when the page arrives
And a screen reader hears "Loading…" and then the new count, announced politely
Scenario: Reduced motion
Given prefers-reduced-motion is set
Then the skeleton rows do not animate
Scenario: Mobile
Given a 390 px viewport
Then the skeleton rows and the button are full width with 44 px targets
@@ -0,0 +1,30 @@
# QA procedure: Relation picker loading more
Spec: features/relation_picker_loading_more.feature. Vitest + Vue Test Utils under `task test:web`, /v1 mocked in the style of views/__tests__/EntryEditorRelationStates.spec.ts (a `search` callback per page).
## Honesty rules
0. Baseline `task test:web` first. 1. Runner cannot execute: NOT RUN. 2. Report new and edited specs and baseline failures.
3. The skeleton row count is read from the board frame; assert it only if the frame gives it.
## Automated (Vitest)
| Spec | Cases |
|---|---|
| views/__tests__/EntryEditorRelationStates.spec.ts (extend "Load more") | request URL `page=2&size=25`; during the pending promise skeleton rows exist after the last option, the button is disabled with text "Loading…", the footer text unchanged, a second click sends nothing; after resolve rows replaced, footer "Showing 50 of 62", button enabled; last page footer and no button; failure restores the button and keeps the results; focus on the first new option; focus back on the button after a failure |
| composables/__tests__/useRelationSearch.spec.ts (extend) | loadingMore true only for page above 1, a stale page is ignored after a new search, hasMore and total |
| i18n/roleMessages.spec.ts or the messages spec (extend) | "Loading…" EN and FR |
Mocks to reuse: 62 venues for "rue", 25 per page, the board's venue names from the frame script.
## Visual (task visual:check)
Run `task visual:check -- schema-later` (screen name as the script lists it), states "Relation picker: loading more" and "Relation in the entry form". Sample data: the board's 25 venues for "rue", "Showing 25 of 62". The pending state needs a never-resolving mock for the second page. Light, dark, desktop, mobile within 10 %.
## Manual checks
1. A disabled button loses keyboard focus in some browsers: verify in Chrome, Firefox and Safari that focus stays on the button (or moves as designed) and a screen reader announces the change; if not, use aria-disabled and document it.
2. Throttle the network to see the skeleton; reduced motion on.
3. Mobile 390 px, dark, FR.
4. `task lint:web` green; `task test:web` exit 0.
## Design gaps and notes
- G1 The design says "Focus stays on the button, then moves to the first new row"; disabled-button focus is browser-dependent (manual item 1).
- G2 The failure of the next page is not drawn.
- G3 The group is "Later" in the design (API "not built") but the app has the picker; the coder reuses it.
+157
View File
@@ -0,0 +1,157 @@
Feature: Role 409 conflicts: stale save, change role, transfer ownership and a missing reassign target (group 15)
As a person editing a role, changing a member's role or handing over ownership
I want a stale request to say who or what changed, to keep what I entered and to offer the next step
So that nothing is overwritten and I never lose my edits
Background:
Given the spec of record is docs/design/screens/roles.md groups 12, 13 and 15, the table "HTTP status to copy" rows role_conflict, and docs/design/states.md "Stale state"
And the backend contract is, after this slice:
| call | status | body |
| PUT /v1/role/{id} with a stale version | 409 | role_conflict |
| PUT /v1/user/{id}/role | 409 | user_role_changed |
| POST /v1/site/transfer-ownership | 409 | user_role_changed |
| DELETE /v1/user/{id} | 409 | user_role_changed |
| DELETE /v1/role/{id} while it has members | 409 | role_has_members |
| POST /v1/role/{id}/reassign, target missing | 409 | target_role_not_found |
| POST /v1/role/{id}/reassign, same role, locked or uncovered target | 400 | invalid_reassign |
| any action on a deleted role (the path role) | 404 | role_not_found |
And a 409 body is plain text and names nobody: the SPA re-reads GET /v1/role/{id} (updated_by, updated_at) for a stale role save, and GET /v1/user for the member's current role
And the removal route and its 409 are specified in features/roles_sync_remove_user.feature
And the message appears where the person is working (banner in the editor, alert in a dialog) and never as a toast while that surface is open; a toast shows only when the surface is gone
And a warning toast stays until dismissed or Reload is pressed; an info toast closes after 6 s
And the SPA lives in web/src, Nuxt UI only, design tokens only, EN and FR in i18n/messages.ts, no comments in code
Rule: Backend: a missing reassign target is a distinct 409
Background for this Rule (house style of roles_permissions_roles_api.feature):
Given the new domain sentinel role.ErrReassignTargetNotFound and the new apperr string ReassignTargetNotFoundErrorMessage = "target_role_not_found"
And ReassignRoleMembersUseCase.checkTarget returns it when roles.Get(target) answers role.ErrRoleNotFound, instead of role.ErrInvalidReassign
And api/handlers/role maps it to 409 with the plain-text body, in the existing refusals table
And target 0, target equal to the source, a locked target and a target the caller cannot cover still answer 400 invalid_reassign
And a missing source role still answers 404 role_not_found
And nothing is moved and no event is recorded on this refusal (a refusal records nothing)
And docs/api.md documents the 409 on POST /v1/role/{id}/reassign
Scenario Outline: Reassign target answers
When the Owner sends POST /v1/role/<source>/reassign {"target_role_id": <target>}
Then the answer is <status> <body>
And no member moved
Examples:
| source | target | status | body |
| Contributor | an id that no longer exists | 409 | target_role_not_found |
| Contributor | Contributor (itself) | 400 | invalid_reassign |
| Contributor | 0 | 400 | invalid_reassign |
| Contributor | Owner (locked) | 400 | invalid_reassign |
| Contributor | Admin, caller is a People manager | 400 | invalid_reassign |
| 9999 | Writer | 404 | role_not_found |
Rule: Stale role save (editor)
Scenario: The banner
Given Contributor was loaded at version 2 and the person made 2 edits
When PUT /v1/role/4 answers 409 role_conflict and GET /v1/role/4 answers updated_by "Jonas Weber", updated_at 11:02
Then a banner above the form reads "This role changed while you were editing · Jonas Weber saved changes to Contributor at 11:02. Reload to see them, or review how they differ from your edits." with "Reload role" and "Review differences"
And the edits are kept, Save is off, and the bar reads "2 unsaved changes. Save is off until you reload"
Scenario: Review differences
Given the banner is shown with edits Events Update Own to All and Events Delete None to Own against the saved role
When "Review differences" is pressed
Then a dialog "How the saved role differs from your edits · Jonas Weber saved changes to Contributor at 11:02." lists only the settings where the two disagree, as a table Setting, Saved, Yours: Events Update Own All; Events Delete None Own
And it offers "Use the saved role" and "Keep my edits"
And in FR the title is "Comment le rôle enregistré diffère de vos modifications" with "Réglage", "Enregistré", "Vous", "Utiliser le rôle enregistré", "Garder mes modifications"
Scenario Outline: Resolving
When the person presses <action>
Then <effect>
Examples:
| action | effect |
| Keep my edits | GET /v1/role/4 replaces the saved role and its version, the edits are re-applied on top, changed cells carry the square marker, Save is on, the dialog and the banner close, an info toast reads "Role reloaded · Your 2 changes are still here. Review them, then save." and the bar reads "2 unsaved changes" |
| Use the saved role | the edits are dropped, the form shows the saved role, Save is off, no toast about edits |
| Reload role (banner) | the same as "Use the saved role" |
| Escape or close | the dialog closes and the banner and kept edits stay |
And "Keep my edits" followed by Save sends PUT /v1/role/4 with the reloaded version
And in FR the toast reads "Rôle rechargé · Vos 2 modifications sont toujours là. Relisez-les, puis enregistrez."
Scenario: A second conflict
Given edits were kept on top of version 3
When the save answers 409 role_conflict again
Then the banner returns with the newest updated_by and time, and the edits are still kept
Scenario: The name taken is not a conflict
When the save answers 409 role_name_taken
Then the existing field error shows and no banner
Scenario: The time is not clock-sensitive
Given updated_at is a fixed string and the time zone is pinned in the test and in the visual setup
Then "11:02" is the formatted time of that string, not the current time
Rule: Change role when the member's role changed first
Scenario: The alert in the dialog
Given the Change role dialog for Ines Duarte (Writer) is open with Contributor chosen
When PUT /v1/user/{id}/role answers 409 user_role_changed and GET /v1/user answers Ines Duarte with role Admin
Then the dialog stays open with an alert "Role changed in the meantime · Ines Duarte is now Admin. Your choice is kept. Check it and try again."
And Contributor stays chosen, the "Current" mark moves from Writer to Admin, and "Change role" stays enabled
And pressing "Change role" again sends PUT /v1/user/{id}/role {"role_id": <Contributor id>}
And in FR "Rôle modifié entre-temps · Ines Duarte a maintenant le rôle Admin. Votre choix est conservé. Vérifiez-le et réessayez."
And no toast shows while the dialog is open
Scenario: The chosen role became the current one
Given the re-read shows Ines Duarte now holds Contributor, the role the person chose
Then the alert still shows and "Change role" is disabled because the chosen role is now the current one
Scenario: The member disappeared
When GET /v1/user no longer lists Ines Duarte, or PUT answers 404 user_not_found
Then the dialog closes, the list is refreshed and the existing "no longer exists" toast of slice 7 shows
Scenario: The dialog is gone when the answer arrives (component level)
Given the dialog component was unmounted or its open state set to false before the 409 arrived
Then a warning toast reads "Role not changed · Ines Duarte's role changed in the meantime, so nothing was saved. Reload and try again. · Reload"
And "Reload" refetches GET /v1/user and dismisses the toast
And in FR "Rôle non modifié · Le rôle d'Ines Duarte a changé entre-temps, donc rien n'a été enregistré. Rechargez et réessayez. · Recharger"
And this is specified at component level only, no page-level trigger exists because the dialog cannot be dismissed while sending
Rule: Transfer ownership when the target's role changed first
Scenario: The alert in the dialog
Given the Owner confirmed Jonas Weber and typed "jonas@renewbuild.example"
When POST /v1/site/transfer-ownership answers 409 user_role_changed and GET /v1/user answers Jonas Weber with role Writer
Then an alert in the dialog reads "Role changed in the meantime · Jonas Weber is now Writer. What you typed is kept. Check the change, then try again." with "Reload member"
And the typed email is kept and "Transfer ownership" is disabled until "Reload member" refreshes the target
And "Reload member" re-reads GET /v1/user, updates the shown role of the target and re-enables "Transfer ownership" with the typed email still valid
And in FR "Rôle modifié entre-temps · Jonas Weber a maintenant le rôle Writer. Ce que vous avez saisi est conservé. Vérifiez le changement, puis réessayez. · Recharger le membre"
Scenario: The dialog is gone when the answer arrives (component level)
Then a warning toast reads "Ownership not transferred · Jonas Weber's role changed in the meantime, so nothing was transferred. Check it and try again. · Reload"
And in FR "Propriété non transférée · Le rôle de Jonas Weber a changé entre-temps, donc rien n'a été transféré. Vérifiez-le et réessayez. · Recharger"
Scenario Outline: Other transfer answers keep the slice 7 behaviour
When the server answers <status> <code>
Then the behaviour of features/roles_spa_users.feature "Transfer answers" holds
Examples:
| status | code |
| 400 | invalid_transfer |
| 400 | confirmation_mismatch |
| 403 | missing_permission |
| 500 | (network) |
Rule: Role list actions
Scenario: Role edited elsewhere (409 on a list action)
Given the roles list was loaded and Contributor was edited by Jonas Weber afterwards
When Duplicate, Delete or Move members answers 409 role_conflict
Then a warning toast reads "Role changed elsewhere · Jonas Weber edited Contributor, so nothing was saved. Reload and try again. · Reload"
And "Reload" refetches GET /v1/role
And in FR "Rôle modifié ailleurs · Jonas Weber a modifié Contributor, donc rien n'a été enregistré. Rechargez et réessayez. · Recharger"
Scenario: Role deleted elsewhere (404)
When an action answers 404 role_not_found
Then the row is removed from the list and an info toast reads "That role no longer exists · It was deleted somewhere else, so we removed it from the list." and closes after 6 s
And in FR "Ce rôle n'existe plus · Il a été supprimé ailleurs, nous l'avons donc retiré de la liste."
Scenario: Reassign target deleted (409 target_role_not_found)
Given the Delete role dialog of Intern is open with Writer chosen
When POST /v1/role/{id}/reassign answers 409 target_role_not_found
Then the inline error "Writer no longer exists · It was deleted somewhere else. Choose another role to move the members to." of features/roles_sync_copy_activity.feature shows in the dialog, never a toast
And GET /v1/role is re-read so the picker no longer offers Writer
+55
View File
@@ -0,0 +1,55 @@
# QA procedure: Role 409 conflicts (roles sync pass 6)
Spec: features/roles_sync_conflicts.feature. Vitest + Vue Test Utils under `task test:web`, /v1 mocked through the existing kits (the useApi mock used in views/__tests__), access built with web/src/test/access.ts. The backend Rule adds Go tests. No live server, no real SMTP.
## Honesty rules
0. Baseline `task test:web` and `task test` first (known Go failure from slice 7: TestRouterRoutesAreInTheCatalogue).
1. A runner that cannot execute: its items are NOT RUN. 2. Report new and edited specs and baseline failures.
3. Assert only the copy the design gives. "at 11:02" is rendered from a fixed `updated_at` through a pinned time zone; no spec freezes the system date.
4. The closed-surface toasts are component-level specs (user decision), with no page-level trigger.
## Automated (Vitest)
| Spec | Cases |
|---|---|
| views/__tests__/RoleEditorView.spec.ts (extend) | banner copy with updated_by and time, Save off, Reload role == Use the saved role, Review differences dialog rows (only disagreeing settings), Keep my edits (reload, edits on top, markers, Save on, info toast "Role reloaded · Your 2 changes..." and counter), Use the saved role, Escape keeps banner, a second 409, name taken is not a conflict |
| components/__tests__/RoleDifferencesDialog.spec.ts (new) | table Setting/Saved/Yours, EN/FR, the two actions, focus on open and return on close |
| lib/__tests__/roleDiff.spec.ts (new) | settings that disagree, tri-state and Create switch, custom type rows, grid order |
| components/__tests__/ChangeRoleDialog.spec.ts (extend) | 409 keeps the dialog open, alert copy with the re-read role, choice kept, Current mark moves, button enabled / disabled when the chosen role became current, second try sends the same role_id, no toast while open, member gone closes and refreshes, unmounted-dialog toast with Reload (component level) |
| components/__tests__/TransferOwnershipDialog.spec.ts (extend) | 409 alert copy with Reload member, typed email kept, primary off until reload, reload re-enables, unmounted-dialog toast, other answers unchanged |
| views/__tests__/RolesListView.spec.ts (extend) | 409 role_conflict on Duplicate, Delete, Move members: warning toast with Reload that refetches and dismisses; 404 removes the row and shows the info toast closing at 6 s (fake timers) |
| components/__tests__/DeleteRoleDialog.spec.ts (extend) | 409 target_role_not_found: inline error, picker cleared and re-read, no toast; 400 invalid_reassign unchanged |
| composables/__tests__/useRoles.spec.ts (extend) | refusal mapping adds target_role_not_found |
| composables/__tests__/useUsers.spec.ts (extend) | user_role_changed leaves the re-read to the caller |
| i18n/roleMessages.spec.ts (extend) | every group 12, 13 and 15 key exists in EN and FR, placeholders match |
Mocks to reuse: Contributor (version 2, 3), Jonas Weber (updated_by, 11:02), Ines Duarte Writer then Admin, Jonas Weber Writer, the board's difference rows (Events Update Own/All, Events Delete None/Own).
## Automated (Go, httptest, in-memory fakes) in test/
Build the role handler with `NewHandler(...)` over the use cases and the in-memory fakes of role.Repository, user.Repository and the audit recorder of the existing roles API tests. Mount on a fresh `chi.NewRouter()` mirroring api/router.go with `jwtauth.Verifier` + `Authenticator` and a test `jwtauth.New("HS256", …)`. Call `validator.InitValidator()`. No SQLite file, no SMTP. Unique fake names.
| Test | Steps and assertions |
|---|---|
| TestReassignMissingTargetIsConflict | the Owner reassigns Contributor to an id that does not exist: 409, body `target_role_not_found`, no member moved, no event recorded |
| TestReassignInvalidTargetsStay400 | table: itself, 0, Owner (locked), an uncovered role for a People manager: 400 `invalid_reassign`, nothing moved |
| TestReassignMissingSourceIs404 | unknown source: 404 `role_not_found` |
| TestReassignTargetSentinelLayering | the use case returns role.ErrReassignTargetNotFound (errors.Is); internal/domain/role imports no gorm, chi or infrastructure |
Update the existing tests of test/roles_api_* that expected 400 for a missing target. Mutation scope: `MUTATE_FILES=ReassignRoleMembers.go MUTATE_RUN='TestReassign' task mutate -- ./internal/infrastructure/useCases`. The existing 409 tests of ChangeUserRole and TransferOwnership (test/users_api_*) are the regression for `user_role_changed`.
## Visual (task visual:check)
`task visual:check -- roles` for the group 15 states: Stale save banner, Stale save review differences, Stale save edits kept after reload, Change role member's role changed, Transfer ownership target's role changed; and the group 12 toasts. Board sample data as above. Light, dark, desktop, mobile within 10 %. The banner's "11:02" is a fixed frame string and the setup pins the time zone, so it is not clock-sensitive.
## Manual checks
1. Two browsers: an admin edits Contributor, the owner saves first, the admin's save shows the banner with the real name and time; Keep my edits, then Save succeeds.
2. Two browsers: an owner changes a member's role while an admin has Change role open, then confirms; the alert shows the member's real new role.
3. Same for two simultaneous ownership transfers.
4. Two browsers: delete Writer in one, in the other confirm "Move members" to Writer; the inline error shows.
5. Focus: the alert is announced politely and focus returns to the dialog's primary or "Reload member"; Escape blocked while sending.
6. Mobile 390 px: the difference table scrolls or stacks; 44 px targets; FR; dark.
7. `task lint:web` green; `task test:web` and `task test` exit 0.
## Design gaps and notes
- G1 The design draws a 409 for a deleted reassign target; the backend now matches through `target_role_not_found` (the body name is a proposal, not in the design).
- G2 "Review differences" lists Setting / Saved / Yours rows; how a name or description difference is shown is not drawn.
- G3 When the chosen role became current after the re-read, the design does not say what the button does; the draft disables it.
- G4 Backend change here: one sentinel, one apperr string, one refusals-table row, docs/api.md.
+169
View File
@@ -0,0 +1,169 @@
Feature: Roles sync pass 6: final copy, neutral wording and role activity events
As a person using roles, users and activity in EN or FR
I want the final toast, error and activity wording of the roles-sync design
So that every message names what happened, says what was kept and says what to do next
Background:
Given the spec of record is docs/design/screens/roles.md groups 8, 12 and 13, the tables "Activity events: sentences", "Activity events: failed sentences", "Activity filter: category names" and "Final copy: toasts and errors, EN and FR", docs/design/states.md "Roles sync (pass 6)", docs/design/screens/activity.md "Roles sync (pass 6)" and docs/design/change-log-roles-sync.md section 1
And the stale-state toasts, banners and dialog alerts of group 15 are in features/roles_sync_conflicts.feature, not here
And the removal of a user (route, dialog, toast, user.remove event) is in features/roles_sync_remove_user.feature
And the audit actions are role.create, role.edit, role.delete, role.reassign, user.change_role and user.transfer_ownership (internal/domain/audit/auditDomain.go), the outcome is a field and a failed event keeps its name
And the SPA lives in web/src, Nuxt UI only, design tokens only, EN and FR in i18n/messages.ts, no comments in code
And a string is written "Title · Body": the first part is the toast or alert title, the second its description
And French is asserted only for the strings of the "Final copy" table and the activity tables; strings the design gives in EN only are listed in the qa as design gaps
And the "Why it failed" reasons of the design (role_conflict, role_locked, missing_permission) are out of this slice
Rule: Neutral wording
Scenario: No gendered pronoun in the role copy
Then the Change role dialog reads "Their API keys follow the new role."
And the Transfer ownership confirmation reads "You become an Admin. You can't take ownership back unless they transfer it."
And no EN string of the roles, users, profile, API keys and activity screens contains he, she, his, her or him as a word
Rule: Role changed (group 12)
Scenario: The success toast
Given Ines Duarte holds Writer and the Change role dialog is open
When the person picks Content lead and PUT /v1/user/{id}/role answers 200
Then a success toast reads "Role changed · Ines Duarte is now Content lead, and their API keys follow the new role."
And it closes after 6 s
And in FR it reads "Rôle modifié · Ines Duarte a maintenant le rôle Content lead, et ses clés API suivent le nouveau rôle."
Rule: Role editor inline errors (group 13)
Scenario Outline: Save failed
Given the person edited Contributor and presses Save changes
When the server answers <failure>
Then the editor shows "Couldn't save the role · Your changes are still here. Try again in a moment." with the action "Try again"
And every edit is kept, Save changes stays enabled and "Try again" sends the same save again
And in FR it reads "Impossible d'enregistrer le rôle · Vos modifications sont toujours là. Réessayez dans un instant." with "Réessayer"
And it applies to POST /v1/role (new role) and PUT /v1/role/{id}
Examples:
| failure |
| 500 |
| a network failure |
Scenario: Replaces the generic error
Then the placeholder wording of the previous slice for a failed save is gone from EN and FR
Rule: Change role inline error (group 13)
Scenario: Change role failed
Given the Change role dialog is open with Contributor chosen
When PUT /v1/user/{id}/role answers 500 or the network fails
Then an alert in the dialog reads "Couldn't change the role · Nothing was changed. Try again in a moment."
And the choice is kept and "Change role" stays enabled
And in FR it reads "Impossible de modifier le rôle · Rien n'a été modifié. Réessayez dans un instant."
Scenario: Role above the caller
Then a role above the caller's reads "Your role can't assign a role with more power than yours." in the picker and in the dialog
And in FR "Votre rôle ne peut pas attribuer un rôle plus puissant que le vôtre."
Rule: Delete role inline errors (group 13)
Scenario Outline: Delete answers inside the dialog
Given the Delete role dialog of <role> is open
When <trigger>
Then <effect>
Examples:
| role | trigger | effect |
| Intern | POST /v1/role/{id}/reassign answers 409 target_role_not_found for the chosen target Writer | alert "Writer no longer exists · It was deleted somewhere else. Choose another role to move the members to.", the picker is cleared and "Move 1 member and delete role" is disabled until a role is chosen |
| Intern | DELETE answers 409 role_has_members and the re-read of GET /v1/role shows 1 member | the dialog turns into the blocked state with "Role still has members · Intern now has 1 member. Move them to another role first." and "Move 1 member and delete role" disabled until a role is chosen |
| Intern | POST /v1/role/{id}/reassign answers 500 or the network fails with Writer chosen | alert "Couldn't move the members · Nothing was changed. Try again in a moment.", Writer stays chosen, the action stays enabled, DELETE is not sent |
| Intern | POST /v1/role/{id}/reassign answers 400 invalid_reassign (same role, locked or uncovered target) | the existing refusal copy of features/roles_spa_screens.feature, the picker is cleared |
And in FR the first three read "Writer n'existe plus · Il a été supprimé ailleurs. Choisissez un autre rôle pour y déplacer les membres.", "Le rôle a encore des membres · Intern a maintenant 1 membre. Déplacez-le d'abord vers un autre rôle." and "Impossible de déplacer les membres · Rien n'a été modifié. Réessayez dans un instant."
Rule: Invite inline errors (group 13)
Scenario Outline: Invite answers
Given the Invite user dialog holds "lea.martin@renew.example" and Writer
When <trigger>
Then <effect>
And the dialog stays open and keeps the email and the role
And in FR it reads <french>
Examples:
| trigger | effect | french |
| the client check or a 400 rejects the address | under Email: "Enter a valid email address." | "Saisissez une adresse e-mail valide." |
| POST /v1/user/invite answers 409 (existing account) | under Email: "This address already has an account." | "Cette adresse a déjà un compte." |
| POST /v1/user/invite answers 403 escalation | under Role: "Your role can't assign a role with more power than yours." | "Votre rôle ne peut pas attribuer un rôle plus puissant que le vôtre." |
| POST /v1/user/invite answers 500 or the network fails | alert "Couldn't send the invite · Try again in a moment." | "Impossible d'envoyer l'invitation · Réessayez dans un instant." |
And the existing 409 "pending invite" state of users.md is unchanged
Rule: Final copy parity
Scenario: Every string of the "Final copy" table exists in EN and FR
Then each key of the table exists in i18n/messages.ts with matching placeholders
And roleMessages.spec.ts lists them
Rule: Backend: the destination name of a reassignment
Background for this Rule (house style of roles_permissions_roles_api.feature):
Given the audit event role.reassign carries the params from (source role id), to (destination role id) and moved
And this slice adds one additive param, to_name: the destination role's name at move time, a string
And the event target keeps naming the source role, so the source name stays available after the source is deleted
And no new route, no new permission and no schema change: params are stored as the existing JSON map
And the layer touched is internal/infrastructure/useCases/ReassignRoleMembers.go and its audit param constant in internal/domain/audit; docs/api.md gains one line
Scenario: The param is recorded
When POST /v1/role/{id}/reassign moves 4 members from Contributor to Writer
Then the recorded role.reassign event has params from, to, moved 4 and to_name "Writer"
And a reassignment that moves 0 members still records nothing
And a refusal still records nothing
Scenario: Old events
Given a stored role.reassign event without to_name
Then GET /v1/activity returns it unchanged and the SPA uses the fallback below
Rule: Activity sentences for the six events
Scenario Outline: Success sentences
Given GET /v1/activity returns an event <action> with outcome success
Then the sentence reads <sentence> and in FR <french>
Examples:
| action | data | sentence | french |
| role.create | target role Contributor | Role Contributor created | Rôle Contributor créé |
| role.edit | target role Contributor | Role Contributor edited | Rôle Contributor modifié |
| role.delete | target role Intern | Role Intern deleted | Rôle Intern supprimé |
| role.reassign | target role Intern, params to_name Writer, moved 3 | Moved 3 members from Intern to Writer | 3 membres déplacés de Intern vers Writer |
| role.reassign | target role Intern, params to_name Writer, moved 1 | Moved 1 member from Intern to Writer | 1 membre déplacé de Intern vers Writer |
| user.change_role | target Ines Duarte, changes role before Writer after Content lead | Ines Duarte's role changed from Writer to Content lead | Le rôle d'Ines Duarte est passé de Writer à Content lead |
| user.transfer_ownership | actor Marta Alves, target Jonas Weber | Ownership transferred from Marta Alves to Jonas Weber | Propriété transférée de Marta Alves à Jonas Weber |
And the sentences "Moved the members of the role {0}" and "Changed the role of {0}" are no longer the output for these events
Scenario Outline: Failed sentences
Given the event <action> has the outcome failed
Then the sentence reads <sentence> followed by the Failed badge, and in FR <french>
Examples:
| action | sentence | french |
| role.create | Failed to create the role Contributor | Échec de la création du rôle Contributor |
| role.edit | Failed to edit the role Contributor | Échec de la modification du rôle Contributor |
| role.delete | Failed to delete the role Intern | Échec de la suppression du rôle Intern |
| role.reassign | Failed to move 3 members from Intern to Writer | Échec du déplacement de 3 membres de Intern vers Writer |
| user.change_role | Failed to change the role of Ines Duarte | Échec du changement de rôle d'Ines Duarte |
| user.transfer_ownership | Failed to transfer ownership to Jonas Weber | Échec du transfert de la propriété à Jonas Weber |
Scenario Outline: role.reassign without the destination name or the count (old events, failed events)
Given a role.reassign event with outcome <outcome> whose params lack <missing>
Then the sentence reads <sentence>
And it never contains "undefined" or an empty name
Examples:
| outcome | missing | sentence |
| success | to_name | Moved 3 members from Intern to another role |
| success | moved | Moved the members of the role Intern |
| failed | to_name, moved | Failed to move the members of the role Intern |
And these fallback wordings are a design gap (the design draws only the full sentences); the coder uses them and the qa lists them
Scenario: Unknown action
Given an event whose action the catalogue does not know
Then it renders as before and does not crash
Rule: Activity category names
Scenario: The category select
When the Category select is open
Then the options read All categories, Articles, Media, Users and invites, Roles, API keys, Authentication, System, in that order
And in FR Toutes les catégories, Articles, Médias, Utilisateurs et invitations, Rôles, Clés API, Authentification, Système
And "Users & invites" no longer appears in the Activity filter, the permission groups or the API catalogue
And choosing Roles requests category roles and shows the role events
And choosing Users and invites keeps requesting category users
+56
View File
@@ -0,0 +1,56 @@
# QA procedure: Roles sync pass 6, final copy and role activity events
Spec: features/roles_sync_copy_activity.feature. Vitest + Vue Test Utils under `task test:web`, /v1 mocked with the existing kits (useApi mock of views/__tests__, access bodies from web/src/test/access.ts, activityFixtures.ts). The backend Rule adds a Go test. No live server, no real SMTP.
## Honesty rules
0. First run `task test:web` and `task test` on the unmodified tree and record exit codes and failing specs (known Go failure from slice 7: TestRouterRoutesAreInTheCatalogue).
1. If a runner cannot execute, every item of that side is NOT RUN, never passing.
2. Report each new or edited spec and which already failed in the baseline.
3. Assert only copy the design gives. FR is asserted only where the design table gives it. The fallback sentences of the feature are a design gap and are asserted as the coder writes them, flagged in the report.
4. No spec freezes the system date; fixed `created_at` strings; no relative-date assertion.
## Automated (Vitest)
| Spec | Cases |
|---|---|
| lib/__tests__/activitySentence.spec.ts (extend) | the six success sentences EN/FR, role.reassign with moved 3 and 1 and to_name, the six failed sentences EN/FR, the three fallback rows (no "undefined"), unknown action |
| lib/__tests__/activityCategories.spec.ts (extend) | category list and order, "Users and invites", "Roles" requests `category=roles`, "Users & invites" absent |
| views/__tests__/ActivityRoles.spec.ts (extend) | list with the six events, the six failed rows with the Failed badge |
| views/__tests__/ActivityFilters.spec.ts (extend) | the select options EN/FR |
| components/__tests__/ChangeRoleDialog.spec.ts (extend) | success toast EN/FR with member and role names, "Couldn't change the role" alert for 500 and network, choice kept, button enabled |
| views/__tests__/RoleEditorView.spec.ts (extend) | save failed (500, network) for POST and PUT: alert, "Try again", edits kept, Save enabled |
| components/__tests__/DeleteRoleDialog.spec.ts (extend) | 409 target_role_not_found: alert, picker cleared, action off; 400 invalid_reassign keeps the existing refusal; role_has_members with the live count; 500 on reassign keeps the choice and sends no DELETE |
| components/__tests__/InviteUserModal.spec.ts (extend) | the four invite errors placement (under Email, under Role, alert), email and role kept |
| i18n/roleMessages.spec.ts (extend) | every key of the "Final copy" table exists in EN and FR, placeholders match |
| i18n/neutralWording.spec.ts (new) | no EN value in the roles, users, profile, api-key and activity keys matches \b(he\|she\|his\|her\|him)\b |
Mocks to reuse: Ines Duarte, Jonas Weber, Marta Alves; roles Owner, Admin, Writer, Contributor, Content lead, Intern (1 member).
## Automated (Go, httptest, in-memory fakes) in test/
Build the role handler with `NewHandler(...)` over the use cases and the in-memory fakes of role.Repository, user.Repository and the audit recorder of the existing roles API tests. Mount on a fresh `chi.NewRouter()` mirroring api/router.go with `jwtauth.Verifier` + `Authenticator` and a test `jwtauth.New("HS256", …)`. Call `validator.InitValidator()`. No SQLite file, no SMTP. New fake names must be unique across the package.
| Test | Steps and assertions |
|---|---|
| TestReassignRecordsDestinationName | POST /v1/role/{Contributor}/reassign {target_role_id: Writer} with 4 members: the recorded event params are from, to, moved 4 and to_name "Writer" |
| TestReassignZeroMovedRecordsNothing | 0 members: 200 `{"moved":0}`, no event (unchanged) |
| TestReassignRefusalRecordsNothing | a refusal (400, 403, 404, 409) records no event |
Extend, do not duplicate, test/roles_api_usecases_test.go line ~735 (the existing params assertion). Mutation scope for the touched use case: `MUTATE_FILES=ReassignRoleMembers.go MUTATE_RUN='TestReassign' task mutate -- ./internal/infrastructure/useCases`. docs/api.md gains one line for `to_name`. `task lint:go` and `gofmt -l` apply.
## Visual (task visual:check)
Screens `roles` and `activity`; states from docs/design/screens/roles.md groups 8, 12 and 13. Sample data is the board's: Ines Duarte, Jonas Weber, Marta Alves, Contributor, Intern, Writer, Content lead, 3 members moved. Gate: every capture within 10 % in light and dark, desktop and mobile. `skip: clock-sensitive` for any activity row whose rendering depends on relative time; the role rows use fixed timestamps from the frame and are checked.
States: Role changed toast, Role editor save failed, Change role failed, Delete role target deleted / role gained members / move failed, Invite invalid email / account exists / role above yours / send failed, Activity role events list, failed role events, category filter open.
## Manual checks
1. Toast durations (6 s; warnings stay until dismissed or Reload), polite announcement.
2. Keyboard: focus lands on "Try again", and on the field with the error in the invite dialog.
3. Real browser, FR: read every message of the table once.
4. Real flow: move members of a role to another and read the event in /activity with the destination name.
5. `task lint:web` (eslint and vue-tsc) green; `task test:web` and `task test` exit 0.
## Design gaps and notes
- G1 FR for strings the design gives in EN only (Read = Own banners and empty states, Profile card tags, Integration copy) is proposed by the coder and not asserted.
- G2 The activity reasons ("Why it failed") are out of this slice by decision.
- G3 Fallback wordings for role.reassign events without to_name or moved ("Moved 3 members from Intern to another role", "Moved the members of the role Intern", "Failed to move the members of the role Intern") are not in the design.
- G4 Singular "Failed to move 1 member" is derived from the success sentence; the design gives the failed sentence for 3 only. A failed reassign does not record `moved`, so the failed full sentence appears only if the event carries it.
- G5 The user.remove sentences are in features/roles_sync_remove_user.feature.
- Backend change: the additive `to_name` param only.
@@ -0,0 +1,87 @@
Feature: Integration page: permission-gated routes and role-aware copy
As a developer reading the API reference
I want routes that need a permission to say so, and the authentication copy to describe roles as they are now
So that a key's owner knows which permission a route needs
Background:
Given the spec of record is docs/design/screens/integration.md (Page copy, EndpointCard states, Error messages) and docs/design/change-log-roles-sync.md "Integration page"
And the catalogue is web/src/data/apiCatalogue.ts and its search index; routes are gated in api/router.go by container.require(...) with the permission keys of internal/domain/role
And the real gating is:
| route | permission label (lib/permissionNames.ts) |
| POST /v1/content-type/ | Create and edit content types |
| PUT /v1/content-type/{key} | Create and edit content types |
| DELETE /v1/content-type/{key} | Delete content types |
| GET /v1/builtin-type/ | View content types |
| GET /v1/builtin-type/{key} | View content types |
| GET /v1/media/purge-settings | View settings |
| PATCH /v1/media/purge-settings | Edit general settings |
| GET /v1/media/purge-preview | Delete files |
| POST /v1/media/purge | Delete files |
And DELETE /v1/user/{id} ("Remove users") is added by features/roles_sync_remove_user.feature with the same chip
And a missing permission answers 403 with the literal plain-text body `missing_permission`
And the SPA lives in web/src, Nuxt UI only, design tokens only, EN and FR in i18n/messages.ts, no comments in code
Rule: Access chip
Scenario Outline: The nine routes
Given the endpoint card <route>
Then its access chip reads "Needs permission"
And its description ends with the sentence Needs the permission "<permission>".
And the chip is not "Owner or admin"
Examples:
| route | permission |
| POST /v1/content-type/ | Create and edit content types |
| PUT /v1/content-type/{key} | Create and edit content types |
| DELETE /v1/content-type/{key} | Delete content types |
| GET /v1/builtin-type/ | View content types |
| GET /v1/builtin-type/{key} | View content types |
| GET /v1/media/purge-settings | View settings |
| PATCH /v1/media/purge-settings | Edit general settings |
| GET /v1/media/purge-preview | Delete files |
| POST /v1/media/purge | Delete files |
Scenario: The access level type
Then the access levels are Public, Verified user, Needs permission, Owner only
And "Owner or admin" is no longer a chip anywhere in the catalogue
And "Owner only" stays on the routes that are owner-only
Scenario: The permission names stay in step with the product
Then each permission named in a description equals the label returned by lib/permissionNames.ts for the key the router requires
Rule: Per-route 403 line
Scenario: missing_permission
Given any of the nine expanded cards
Then the Errors list shows a 403 chip with the caption `missing_permission` in code style
And "Only owners and admins can do this." no longer appears
Scenario: The Errors table
Then the 403 row lists "This action needs a real session.", `missing_permission` (code style, literal body) and "You must verify your account to access this resource."
And the note under the row reads "`missing_permission` is the literal body, not a sentence. Site SEO defaults need the permission to manage sites. Article SEO fields need only the right to edit the article."
And "Only the owner can do this." and "Only owners and admins can do this." no longer appear
Rule: Section copy
Scenario: Authentication, API key card
Then the card reads "Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user's role as it is now. A key never has more rights than its creator. A route that needs a permission answers 403 "missing_permission" to a key whose creator's role lacks it."
And "(owner or admin)" no longer appears
Scenario: Content types section intro
Then it reads "Define your own kind of content, like events or recipes. A user whose role can create and edit content types defines a type made of 1 to 50 ordered fields; the type then has its own entry routes."
And it no longer says "An owner or admin creates a type"
Scenario: Entries section intro
Then it reads "Read and write the content of a custom type. Each route needs the matching content permission of the key creator's role."
And it no longer says "Any verified user can write entries"
Scenario: Sentences that still mention owner or admin
Then no sentence of the Authentication section or the content-type and built-in groups says that owner or admin routes answer 403 to a key whose creator is neither
Rule: Search
Scenario: The search index follows the catalogue
When the person types "permission" in the palette
Then the permission-gated routes match through the access chip and the description, and the live region announces the count
And typing "missing_permission" matches the error code of those routes
And "owner or admin" no longer matches the nine routes
@@ -0,0 +1,29 @@
# QA procedure: Integration catalogue and role-aware copy (roles sync pass 6)
Spec: features/roles_sync_integration_catalogue.feature. Vitest + Vue Test Utils under `task test:web`. Static data, no /v1 call, no live server.
## Honesty rules
0. Baseline `task test:web` first. 1. Runner cannot execute: NOT RUN. 2. Report new and edited specs and baseline failures.
3. The two section intros are the user-approved proposals, not design copy; mark them as such in the report.
## Automated (Vitest)
| Spec | Cases |
|---|---|
| data/__tests__/apiCatalogue.spec.ts (extend) | the nine routes have chip "Needs permission" and a description ending `Needs the permission "<label>"`; no route keeps "Owner or admin"; Owner only routes keep their chip; the 403 caption is `missing_permission` for the nine; the Errors table 403 row and its note; the Authentication card sentence; the two intros; the permission label equals lib/permissionNames.ts for the key the router requires |
| views/__tests__/IntegrationView.spec.ts or the existing integration spec (extend) | the chip renders, mobile shows it in the chips line, search "permission" and "missing_permission" returns the routes, "owner or admin" does not |
| data/__tests__/apiGating.spec.ts (new) | a fixed table of the nine routes (plus DELETE /v1/user/{id}) and the keys of api/router.go; fails if a label changes |
## Visual (task visual:check)
Run `task visual:check -- integration`. States of the "Integration" group: 2 (Media group, POST /v1/media/), 4 Authentication, 6 Errors, 9a to 9j Custom content types, and the search states 8c-8e. Board sample data: the origin `https://cms.renew.example`, the board's media list and purge samples. Light, dark, desktop, mobile within 10 %. Not clock-sensitive. The board is from before the new DELETE /v1/user/{id} card and the Users group count of 7, which may exceed 10 % on that group (see G5).
## Manual checks
1. Real server: call each gated route with a key whose creator's role lacks the permission and read the 403 body `missing_permission`.
2. Search palette keyboard flow with the new chip.
3. `task lint:web` green; `task test:web` exit 0.
## Design gaps and notes
- G1 screens/integration.md "Custom content types round" still says "Owner or admin" and "Only owners and admins can do this."; pass 6 supersedes it. The user should refresh that file.
- G2 Open item 5 of the change log (do owner-only routes answer `missing_permission`?) is not verified here; "Owner only" chips are unchanged.
- G3 Both intros are proposals approved by the user, not from the design.
- G4 Left alone, out of scope: screens/schema.md and the Schema frames ("Owner or admin" for Schema), states.md "Sites and authors: roles" and the 403 owner_only rows, screens/profile.md Display name copy.
- G5 The DELETE /v1/user/{id} card is not on the Integration board.
@@ -0,0 +1,55 @@
Feature: Profile role card of the roles-sync design (group 9)
As a signed-in person
I want my role card to show my role, whether it is locked, built-in or custom, and what I can do
So that I can understand my access without reading who assigned it
Background:
Given the spec of record is docs/design/screens/roles.md group 9 and docs/design/change-log-roles-sync.md section 2
And features/roles_spa_users.feature "Profile, your role" is amended by this file: the summary line and "Assigned by" are removed
And GET /v1/auth/me answers access.role {id, name, built_in, locked} and access.permissions; the design says GET /v1/me, the real route is /v1/auth/me
And the card layout, top to bottom, inside the existing card with a gap of 12: the title, one row of badges, the link row with the list-checks icon, the muted key line
And the backend keeps answering role_assigned_by (slice 7); the SPA ignores it
And the SPA lives in web/src, Nuxt UI only, design tokens only, EN and FR in i18n/messages.ts, no comments in code
Rule: The card
Scenario Outline: Role card states
Given GET /v1/auth/me answers access.role <role>
When the person opens /profile
Then the card "Your role" shows the RoleBadge <badge>, then <tags>, in one row
And the link row shows "See what I can do" with the list-checks icon
And the muted line reads "Your API keys follow this role. If your role changes, they change too."
And "Locked" is absent when locked is false and "Custom" is absent when built_in is true
Examples:
| role | badge | tags |
| {"name": "Contributor", "built_in": false, "locked": false} | Contributor | Custom |
| {"name": "Owner", "built_in": true, "locked": true} | Owner | Locked, then Built-in |
| {"name": "Writer", "built_in": true, "locked": false} | Writer | Built-in |
Scenario: Removed content
Given access.role_assigned_by is {"id": 3, "name": "Marta Alves"}
Then the card shows no summary line such as "Creates content and edits only their own."
And the card shows no "Assigned by" text and no empty placeholder
And the Users table and cards still show no role_assigned_by
Scenario: See what I can do
When "See what I can do" is pressed
Then a dialog on desktop and a bottom sheet on mobile opens: "What you can do" / "A summary of your role, Contributor. Read-only." with "Close"
And its body is built from the permission list of the session, with no GET /v1/role call
And Close returns focus to the link
Scenario: The card follows the session
Given the Profile is open
When the session is refreshed and access.role becomes Writer, built-in
Then the badge reads "Writer", the tags become Built-in and the summary dialog follows
Scenario: While the session loads
Given the session has not loaded
Then the existing profile skeleton shows with the role card inside it and no role text
Rule: Backend
Scenario: No change
Then GET /v1/auth/me and GET /v1/user keep answering role_assigned_by
And access.role already carries built_in and locked (lib/permissions.ts), so no endpoint changes
@@ -0,0 +1,28 @@
# QA procedure: Profile role card (roles sync pass 6)
Spec: features/roles_sync_profile_role_card.feature. Vitest + Vue Test Utils under `task test:web`, access from web/src/test/access.ts (ownerAccess, customAccess, writerAccess).
## Honesty rules
0. Baseline `task test:web` first. 1. Runner cannot execute: NOT RUN. 2. Report new and edited specs and baseline failures. 3. EN strings are asserted; FR for "Custom", "Locked", "Built-in" is proposed by the coder (gap G1).
## Automated (Vitest)
| Spec | Cases |
|---|---|
| views/__tests__/ProfileView.spec.ts (edit) | the three card states and their tag rows and order, no summary line, no "Assigned by" with an object, null and an absent key (replaces the slice 7 case that asserted "Assigned by Marta Alves"), See what I can do desktop dialog and mobile sheet, Close and focus return, live role change, skeleton, no GET /v1/role call |
| lib/__tests__/permissions.spec.ts (edit) | role_assigned_by is still parsed but not used by Profile |
| i18n/roleMessages.spec.ts (extend) | keys for Locked, Built-in, Custom in EN and FR |
| components/__tests__/RoleBadge.spec.ts (extend if it exists) | the Owner crown icon and the locked variant |
## Visual (task visual:check)
Run `task visual:check -- profile`. States: Role card custom role, Role card Owner. Board sample data: Contributor (Custom) and Owner (Locked, Built-in), key line as in the design. Light, dark, desktop, mobile within 10 %. The card sits after the Account card (G8 of slice 7; the coder confirms against the board).
## Manual checks
1. Tab order: the link is reachable after the badges; focus returns after Close.
2. Mobile 390 px: the badge row wraps without overflow, 44 px target for the link.
3. `task lint:web` green; `task test:web` exit 0.
## Design gaps and notes
- G1 FR copy for the tags is not in the design.
- G2 Backend `role_assigned_by` stays (user decision); the SPA ignores it.
- G3 The design routes cite GET /v1/me; the real one is /v1/auth/me.
- Leftover, out of scope: screens/profile.md Display name copy is stale.
+101
View File
@@ -0,0 +1,101 @@
Feature: Read = Own surfaces of the roles-sync design (group 14)
As a person whose role reads only their own content
I want the editor, the Articles list and the entry list to say what I can see
So that hidden items are not hinted at and the counts match what I can open
Background:
Given the backend contract of features/roles_permissions_content_scope.feature: a caller with content.read=own gets online articles plus their own drafts, and only the entries they created, with counts, total, page and size computed over the visible set
And the spec of record is docs/design/screens/roles.md group 14, its table "Read = Own: what each surface returns", and docs/design/change-log-roles-sync.md section 4
And the SPA never computes visibility: every count, total and pager value shown comes from the API (GET /v1/article counts and total, GET /v1/content-type/{key}/entry total)
And the SPA knows a person reads Own through scopeOf("content.read", type) of stores/permissions.ts
And Update or Delete set to Own keep the "View only" rows of slice 5; only Read = Own hides rows
And the SPA lives in web/src, Nuxt UI only, design tokens only, EN and FR in i18n/messages.ts, no comments in code
Rule: Role editor
Scenario: Info alert when Read is Own
Given the editor shows a role where Read is Own on at least one type row
Then an info alert under the grid reads "Read: Own · Articles show online articles plus the person's own drafts. Custom content types show only entries the person created. Status counts follow the same rule."
And in FR "Lecture : Les siennes · Les articles affichent les articles en ligne et les brouillons de la personne. Les types de contenu personnalisés n'affichent que les entrées créées par la personne. Les compteurs de statut suivent la même règle."
And it is absent when no Read cell is Own, and it appears and disappears live as the cell changes
Scenario Outline: Type-specific preview sentences
Given the Read cell of <row> is Own
Then the preview line for <row> reads <sentence>
Examples:
| row | sentence |
| Articles | Can read online articles and their own drafts, create entries and edit only their own. |
| a custom type (Events) | Can read only entries they created, create entries and edit only their own. |
| All content types | one line per kind of type: the Articles sentence and the custom-type sentence |
And "read only their own" is gone from the preview
And a row with Read All still reads "Can read any entry, ..." as before
Scenario: Read-only editor
Given the person holds roles.view only and opens a role whose Read is Own
Then the alert and the sentences render the same way, with no Save bar
Rule: Articles list
Scenario: Read = Own with drafts of their own
Given the person is Pedro Lima, Junior writer, with content.read=own
And GET /v1/article answers 3 online articles by others and 2 own drafts, counts all 5, online 3, draft 2
When the person opens /articles
Then the header reads "Articles" with "Online articles and the drafts you created."
And a banner reads "Drafts by others are hidden · Your role reads online articles and the drafts you created. The counts follow the same rule."
And the tabs read All 5, Online 3, Draft 2, taken from the counts of the answer
And "New article" is enabled when content.create allows it
Scenario: No drafts of their own
Given GET /v1/article?status=draft answers no items and counts all 3, online 3, draft 0
When the person opens the Draft tab
Then the empty state reads "You have no drafts · Drafts you create appear here. Your role can't see drafts by other people." with "New article"
And the tabs read All 3, Online 3, Draft 0
Scenario Outline: The banner and the empty state depend on Read being Own
Given the person has content.read=<scope>
Then the banner "Drafts by others are hidden" is <banner>
And an empty Draft tab shows <empty>
Examples:
| scope | banner | empty |
| own | shown | "You have no drafts" |
| all | absent | the existing empty state of the articles list |
Rule: Entry list of a custom type
Scenario: Read = Own with entries of their own
Given content.read=own on Events and GET /v1/content-type/events/entry answers 3 items, total 3
When the person opens /content/events
Then the header reads "Events" with "Your role shows only entries you created."
And a banner reads "Entries by others aren't shown · Your role reads only entries you created. Ask an owner or admin if you need to see more."
And the count line reads "3 entries" from total, and no pager shows when total fits one page
And "New entry" is enabled when content.create allows it
Scenario: No entries of their own
Given the answer has no items and total 0
Then the empty state reads "You haven't created an event yet · Your role shows only entries you created, so other people's events aren't listed here. Use New entry to add one." with "New entry"
And nothing hints that other people's entries exist
Scenario Outline: The singular noun is derived from the type name
Given the content type is named <name>
Then the empty state title reads <title>
And the body reads "Your role shows only entries you created, so other people's <plural> aren't listed here. Use New entry to add one."
Examples:
| name | title | plural |
| Events | You haven't created an event yet | events |
| Recipes | You haven't created a recipe yet | recipes |
| Press mentions | You haven't created a press mention yet | press mentions |
| Categories | You haven't created a category yet | categories |
And the derivation reuses the helper that already singularises type names for the relation sentences (lib/relationSentences.ts), lowercased
And irregular names (for example "People", "Series") are a design gap of the qa
Scenario: Direct link to a hidden item
Given GET /v1/content-type/events/entry/1 answers 404 entry_not_found
Then the existing "Entry not found" page shows, with no hint that the entry exists
And GET /v1/article/{id} of someone else's draft answers 404 and shows the existing article-not-found page
Rule: Scope of the change
Scenario: The SPA does not recompute
Given the API answers counts all 2, online 2, draft 0 for a reader with Read = Own
Then the tabs read exactly those numbers whatever the person's scope
+34
View File
@@ -0,0 +1,34 @@
# QA procedure: Read = Own surfaces (roles sync pass 6)
Spec: features/roles_sync_read_own.feature. Vitest + Vue Test Utils under `task test:web`. /v1 mocked through the existing kits; sessions from web/src/test/access.ts (customAccess with content.read=own). No live server.
## Honesty rules
0. Baseline `task test:web` first. 1. Runner cannot execute: NOT RUN. 2. Report new and edited specs and baseline failures. 3. EN is asserted; FR only for the editor note (the design gives it); FR for the banners and empty states is proposed (gap G1). 4. No spec freezes the date.
## Automated (Vitest)
| Spec | Cases |
|---|---|
| views/__tests__/RoleEditorView.spec.ts (extend) | info alert present/absent with Read own/all, live toggle, read-only editor, EN/FR text |
| lib/__tests__/rolePreview.spec.ts (extend) | Articles and custom-type sentences for Read own, All-types grouping, "read only their own" gone, Read all unchanged |
| views/__tests__/ArticlesReadOwn.spec.ts (new; the articles list specs today are ArticlesPagination, ArticlesSitesAuthors, SeoArticles) | banner and header for Read own, tabs from counts (5/3/2), Draft tab empty state with counts 3/3/0, banner absent and existing empty state for Read all, counts taken as returned |
| views/__tests__/ContentList.spec.ts (extend) | Read own header, banner, "3 entries", no pager; empty state with the singular noun (Events, Recipes, Press mentions, Categories); Read all unchanged |
| views/__tests__/ContentListNavigation.spec.ts or an entry-not-found spec (extend) | direct link 404 shows the existing page and no hint |
| lib/__tests__/relationSentences.spec.ts (extend) | the singular helper on the four names of the feature |
| i18n/roleMessages.spec.ts (extend) | the new keys exist in EN and FR, placeholders match |
Mocks to reuse: Pedro Lima (Junior writer), the board's 3 online articles and 2 drafts; Events with 3 entries and with none.
## Visual (task visual:check)
All under the `roles` screen (user decision): "Role editor: Read set to Own", "Articles list: Read = Own", "Articles list: no drafts of their own", "Entry list: Read = Own", "Entry list: no entries of their own". web/visual/states.ts gets the setups. Board data: Pedro Lima, All 5 / Online 3 / Draft 2, All 3 / Online 3 / Draft 0, Events "3 entries". Light, dark, desktop, mobile within 10 %.
## Manual checks
1. Real flow: create "Junior writer" with Read Own, assign it to a user, open /articles and /content/events as that user, compare tabs with the database.
2. Two browsers: the owner publishes a draft of someone else while the Junior writer has the list open; reload shows the new online count.
3. FR read-through.
4. `task lint:web` green; `task test:web` exit 0.
## Design gaps and notes
- G1 FR for the banners and empty states is not in the design.
- G2 The singular noun is derived from the type name; irregular names ("People", "Series") produce a wrong noun. A per-type singular name is a possible later field.
- G3 The mobile layout of the banner is read from the board frame.
- Backend: no change; counts and totals already come from the API (CountVisibleTo).
+147
View File
@@ -0,0 +1,147 @@
Feature: Remove a user, guarded by the member's role (backend route and SPA action)
As an owner, or a person whose role holds "Remove users"
I want to take someone's access away
So that a removal never hits a member whose role changed since I looked, and never exceeds my own power
Background:
Given the spec of record is docs/design/screens/roles.md group 5 (row menu "Remove user", owner row "Remove user (disabled)"), group 12 (toast "User not removed") and the tables "HTTP status to copy" and "No-escalation feedback"
And the permission is users.remove ("Remove users", an on/off toggle of the catalogue, internal/domain/role KeyUsersRemove), already known to the catalogue and to the role editor
And the guards mirror ChangeUserRole and TransferOwnership (internal/infrastructure/useCases): ensureCoveredBy for the caller's power, role.ErrOwnRole, role.ErrRoleLocked, user.ErrRoleChanged, user.ErrUserNotFound
And a refusal is plain text: user_role_changed, own_role, role_locked, escalation (X-Escalation-Key), missing_permission (X-Missing-Permission), user_not_found, invalid_role
And the dialog copy and the success toast copy of a removal are not in the design (design gap G1, open question Q-RM1): they are specced as behaviour, never asserted as text
And the SPA lives in web/src, Nuxt UI only, design tokens only, EN and FR in i18n/messages.ts, no comments in code
Rule: Backend: DELETE /v1/user/{id}
Background for this Rule (house style of roles_permissions_users_api.feature):
Given the route is DELETE /v1/user/{id}?expected_role_id=<N>, mounted under /v1/user inside the JWT group of api/router.go, gated by container.require(role.KeyUsersRemove)
And expected_role_id is the id of the role the client believes the member holds (the id of the role of the list item the person saw); it is required, a uint32 greater than 0
And a query parameter is used because a DELETE body is dropped by some proxies; no request body is read
And the layers touched are: internal/domain/user (Repository gains DeleteIfRole(id, roleID uint32) error, a compare-and-swap that deletes only if the stored role_id still equals roleID and answers ErrRoleChanged otherwise, ErrUserNotFound when the row is gone), internal/domain/audit (ActionUserRemove = "user.remove"), the new use case internal/infrastructure/useCases/RemoveUser.go (RemoveUserUseCase, RemoveUserCommand{Caller, TargetID, ExpectedRoleID}), persistence/repositories userRepository.go, a new handler method on api/handlers/user, the route in api/router.go, api/dependecyInjection.go, the integration catalogue and docs/api*.md
And no handler reaches a repository directly, and no domain package imports gorm
And the order of checks is: gate users.remove (403 missing_permission), id format (400 with the existing uint32 message), expected_role_id (400 invalid_role), target lookup (404 user_not_found), target is the caller (403 own_role), target role differs from expected_role_id (409 user_role_changed), target role locked (403 role_locked), target role not covered by the caller (403 escalation), then DeleteIfRole
And only accepted users are removable; invites are cancelled through DELETE /v1/user/invite/{id}, unchanged
Scenario Outline: Answers
Given the world of features/roles_permissions_users_api.feature (Owner Marta Alves, Admin Jonas Weber, Ines Duarte Writer, Lea Martin Content lead, a People manager with users.view and users.remove)
When <caller> sends DELETE /v1/user/<target>?expected_role_id=<expected>
Then the answer is <status> <body>
And <effect>
Examples:
| caller | target | expected | status | body | effect |
| the Owner | Ines Duarte | Writer id | 204 | (empty) | the user is gone from GET /v1/user and one user.remove event is recorded |
| an Admin | Ines Duarte | Writer id | 204 | (empty) | the same |
| a People manager | Ines Duarte | Writer id | 204 | (empty) | the same when the manager's role covers Writer |
| the Owner | Ines Duarte | Admin id | 409 | user_role_changed | nothing removed, no event |
| the Owner | Ines Duarte | (absent) | 400 | invalid_role | nothing removed |
| the Owner | Ines Duarte | 0 | 400 | invalid_role | nothing removed |
| the Owner | abc | Writer id | 400 | the uint32 id message | nothing removed |
| the Owner | 9999 | Writer id | 404 | user_not_found | nothing removed |
| the Owner | the Owner (self) | Owner id | 403 | own_role | nothing removed |
| an Admin | Jonas Weber (self) | Admin id | 403 | own_role | nothing removed |
| an Admin | Marta Alves | Owner id | 403 | role_locked | the owner is never removable; nothing removed |
| a People manager | Lea Martin | Content lead id | 403 | escalation | X-Escalation-Key names the first key the manager lacks; nothing removed |
| a People manager | Jonas Weber | Admin id | 403 | escalation | a role above the caller's power cannot be removed |
| a user without users.remove | Ines Duarte | Writer id | 403 | missing_permission | X-Missing-Permission is users.remove; the target is not even looked up |
| no cookie | Ines Duarte | Writer id | 401 | the existing 401 text | nothing removed |
And a stale role beats the coverage check: a target whose role changed answers 409 before 403 escalation or role_locked
And a refusal (400, 403, 404, 409) records no audit event and changes nothing
Scenario: A race between the check and the delete
Given the target's role changes after the use case read it and before DeleteIfRole runs
Then DeleteIfRole answers ErrRoleChanged, the route answers 409 user_role_changed and the user remains
Scenario: A failed write
Given the repository fails with an error that is not a refusal
Then the route answers 500 "Internal server error", the user remains and one user.remove event with outcome failed is recorded
Scenario: What a removal revokes
Given user 41 has a session cookie and an API key rcms_… that user 41 created
When user 41 is removed
Then the next request with user 41's cookie answers 401 "You must be logged in to access this resource."
And the next request with user 41's key answers 401 (a key whose user was deleted)
And no other user's session or key changes
Scenario: What a removal leaves
Then articles, entries and media created by user 41 stay, with their created_by id unchanged
And invites sent by user 41 stay
And the audit events whose actor is user 41 stay and keep naming them
Scenario: The audit event
When user 41 "ines" is removed by the Owner
Then one event is recorded with action user.remove, category users, outcome success, actor the caller, target {type "user", id 41, name "ines"}
And the event is shown in /activity with the category "Users and invites"
Scenario: Layering
Then internal/domain/user imports no gorm, chi or infrastructure, the handler imports no persistence, and RemoveUserUseCase depends only on domain interfaces
Rule: Activity sentences for user.remove
Scenario Outline: Sentences
Given GET /v1/activity returns an event user.remove, target Ines Duarte, with outcome <outcome>
Then the sentence reads <sentence> and in FR <french>
Examples:
| outcome | sentence | french |
| failed | Failed to remove Ines Duarte | Échec du retrait d'Ines Duarte |
| success | Removed Ines Duarte | Ines Duarte retiré |
And the success sentence and its FR are not in the design (design gap G2); the failed pair is the design's
And the security chip does not show on user.remove
Rule: Row menu and gating
Scenario Outline: What the row menu offers
Given the session grants <grants>
When the person opens the menu on <row>
Then "Remove user" is <state>
Examples:
| grants | row | state |
| users.view, users.remove | Ines Duarte (accepted) | enabled, below "Change role" when that is offered |
| users.view, users.change_role | Ines Duarte (accepted) | absent |
| users.view, users.remove | the owner row | disabled with "The owner can't be changed or removed. Transfer ownership first." |
| users.view, users.remove | the row marked "you" | disabled with a reason (copy TBD, gap G1) |
| users.view, users.remove | a pending or expired invite | absent (invites keep "Cancel invite" and "Renew invite") |
| the Owner | Ines Duarte (accepted) | enabled |
And a person with users.view and users.remove only sees a menu with "Remove user" and nothing else
And "Send password reset" stays out of this slice
Scenario: Roles that cannot be covered
Given the session cannot cover Content lead
Then "Remove user" is disabled on a Content lead row with the reason "Your role can't assign a role with more power than yours." (reuse of the no-escalation sentence, to confirm in Q-RM1)
Rule: Removing
Scenario: The confirmation dialog (behaviour only)
When "Remove user" is chosen on Ines Duarte (Writer)
Then a confirmation dialog opens with Cancel and a destructive confirm action
And it names Ines Duarte (title and body copy TBD, gap G1)
Scenario: Confirming
When the person confirms
Then DELETE /v1/user/<Ines id>?expected_role_id=<id of the role of the member's list item> is sent
And while in flight the dialog cannot be dismissed and both buttons are disabled
And on 204 the dialog closes, the row disappears without reloading the whole list, and a success toast shows (copy TBD, gap G1)
And the count line of the Users page follows
Scenario Outline: Remove answers
When the server answers <status> <code>
Then <effect>
Examples:
| status | code | effect |
| 409 | user_role_changed | the dialog closes, GET /v1/user is re-read and a warning toast reads "User not removed · Ines Duarte's role changed in the meantime, so nothing was removed. Check it and try again. · Reload" |
| 404 | user_not_found | the list is re-read, the row is gone, the dialog closes and the existing toast "That user no longer exists" / "The list was refreshed." shows |
| 403 | escalation | "You can't grant that" / "You can only grant what your role has." (shared refusal copy) and the dialog closes |
| 403 | role_locked | "This role is locked" / "The Owner role can't be changed or deleted. Transfer ownership from Users instead." |
| 403 | own_role | "You can't change your own role" / "Ask another owner or admin." (shared refusal copy, wording gap G3) |
| 403 | missing_permission | the shared refusal toast "You don't have permission to do that" / "Your role doesn't include Remove users. Ask an owner or admin." and the dialog closes |
| 500 | (network) | the dialog stays open with an inline error (copy TBD, gap G1) and the confirm action stays enabled |
And in FR the warning toast reads "Utilisateur non retiré · Le rôle d'Ines Duarte a changé entre-temps, donc rien n'a été retiré. Vérifiez-le et réessayez. · Recharger"
And "Reload" refetches GET /v1/user and dismisses the toast
And the warning toast stays until dismissed or Reload is pressed
Rule: The API reference
Scenario: The route stays out of the API reference until the design draws it
Then DELETE /v1/user/{id} is not added to web/src/data/apiCatalogue.ts, because the Integration board has no card for it
And "DELETE /v1/user/{id}" is added to routesNotYetInTheCatalogue in test/api_catalogue_drift_test.go so TestRouterRoutesAreInTheCatalogue stays satisfied
And the "Users and invites" group still has 6 routes
+62
View File
@@ -0,0 +1,62 @@
# QA procedure: Remove a user (roles sync pass 6)
Spec: features/roles_sync_remove_user.feature. This slice touches Go and the SPA. Go: `task test` (go test -tags nofrontend ./test/... -count=1), `task lint:go`, `gofmt -l`, `go vet -tags nofrontend`, and the mutation scope of `task mutate` for RemoveUser.go. SPA: Vitest + Vue Test Utils under `task test:web`. No live server, no real SQLite file in the Go tests, no real SMTP.
## Honesty rules
0. First run `task test` and `task test:web` on the unmodified tree and record exit codes and failing specs (known Go failure: TestRouterRoutesAreInTheCatalogue; it must pass at the end because the route is added to the catalogue).
1. A runner that cannot execute: its items are NOT RUN, never passing.
2. Report each new or edited test and which already failed in the baseline.
3. The removal dialog, success toast, inline 500 error, own-row reason and the user.remove success sentence are not in the design: tests check behaviour (the element exists, opens, closes, is disabled) and never assert their text. They are listed as design gaps.
4. No spec freezes the date; fixed `created_at` strings.
## Automated (Go, httptest, in-memory fakes) in test/
Build the handler with `NewHandler(...)` (or the named constructor of the user handler) over RemoveUserUseCase with in-memory fakes of user.Repository (extended with DeleteIfRole, which can be told to answer ErrRoleChanged or an error), role.Repository, contenttype.Repository and an audit recorder. Mount on a fresh `chi.NewRouter()` mirroring api/router.go: the route inside `jwtauth.Verifier` + `Authenticator` with a test `jwtauth.New("HS256", …)` and the require(users.remove) middleware built as in api/router.go. Call `validator.InitValidator()`. Unique fake names in package test.
| Test | Steps and assertions |
|---|---|
| TestRemoveUserAnswers | the table of the feature (15 rows): status, body, user still listed or gone, X-Escalation-Key and X-Missing-Permission headers, 401 without cookie |
| TestRemoveUserCheckOrder | stale role beats role_locked and escalation; own_role beats stale; missing_permission is answered before the target lookup (the fake counts Get calls) |
| TestRemoveUserExpectedRoleIDValidation | absent, 0, non-numeric, negative: 400 invalid_role; bad id: the uint32 message |
| TestRemoveUserRaceAnswers409 | the fake changes the stored role between Get and DeleteIfRole: 409, user present, no event |
| TestRemoveUserWriteFailure | non-refusal error: 500, user present, one failed user.remove event |
| TestRemoveUserAudit | success event: action user.remove, category users, target {user, id, username}, actor the caller; refusals record nothing |
| TestRemoveUserRevokesAccess | after removal, a request with that user's cookie answers 401, and an API key of that user answers 401; another user's cookie and key still work |
| TestRemoveUserLeavesContent | the removed user's articles, entries and invites remain with created_by unchanged |
| TestRemoveUserOnlyAcceptedUsers | an invite id is not a user: 404 user_not_found; invites stay cancellable through DELETE /v1/user/invite/{id} |
| TestRemoveUserLayering | internal/domain/user imports no gorm, chi or infrastructure; the handler imports no persistence; the use case depends on domain interfaces only (extend the existing layering test) |
| TestRouterRoutesAreInTheCatalogue (existing) | passes: DELETE /v1/user/{id} is listed in routesNotYetInTheCatalogue (the Integration board has no card for it yet) |
| persistence level (only if the suite has an in-memory persistence test; otherwise MANUAL item 5) | DeleteIfRole deletes when the role matches, answers ErrRoleChanged when it differs and ErrUserNotFound when absent, on SQLite and, with `task test:mysql` / `task test:postgres`, on the other drivers (SQL must stay portable) |
Mutation scope: `MUTATE_FILES=RemoveUser.go MUTATE_RUN='TestRemoveUser' MUTATE_WORKERS=2 task mutate -- ./internal/infrastructure/useCases`. docs/api.md and docs/api*.md document the route, its query parameter, the order of checks, the 409 and what a removal revokes and leaves.
## Automated (Vitest)
| Spec | Cases |
|---|---|
| composables/__tests__/useUsers.spec.ts (extend) | removeUser sends DELETE /v1/user/{id}?expected_role_id=N, resolves on 204; refusal mapping of user_role_changed, user_not_found, escalation, role_locked, own_role, missing_permission, invalid_role |
| views/__tests__/UsersRowActions.spec.ts (extend) | "Remove user" per grant (the table of the feature), owner row disabled with the design reason, own row disabled, absent for invites, a menu with only "Remove user", "Send password reset" still absent, disabled on a role the caller cannot cover |
| components/__tests__/RemoveUserDialog.spec.ts (new) | opens on the action, names the member, Cancel closes, confirm sends the request with the role id of the list item, in-flight lock, 204 closes and removes the row, each answer of the table (409 warning toast with Reload that refetches and dismisses, 404 refresh and existing toast, 403 shared refusals, 500 inline error keeps the dialog); texts of the dialog and success toast are not asserted |
| views/__tests__/UsersView.spec.ts (extend) | the row disappears after removal without a full reload, the count line follows |
| lib/__tests__/activitySentence.spec.ts (extend) | user.remove failed "Failed to remove Ines Duarte" EN and FR; success is covered with the coder's wording, flagged as a gap |
| data/__tests__/apiCatalogue.spec.ts (extend) | the catalogue does not contain DELETE /v1/user/{id}; "Users and invites" still has 6 routes |
| i18n/roleMessages.spec.ts (extend) | the "User not removed" keys exist in EN and FR with matching placeholders |
Mocks to reuse: Marta Alves (Owner), Jonas Weber (Admin, jonas@renewbuild.example), Ines Duarte (Writer), Lea Martin (Content lead), a pending and an expired invite; access from ownerAccess() / adminAccess() / customAccess().
## Visual (task visual:check)
`task visual:check -- roles` for: "Users: Row menu" (menu with Remove user), "Users: Owner row menu" (Remove user disabled), and the group 12 toast "User not removed: member's role changed first (409)". Board sample data: Ines Duarte, the Owner row, the toast copy of the frame. The removal dialog has no board frame, so no visual state exists for it and none is invented. Light, dark, desktop, mobile within 10 %.
## Manual checks
1. Two browsers: an owner changes Ines Duarte's role while an admin has the removal confirmation open, then the admin confirms: the toast "User not removed" shows and the list reloads with the new role.
2. A removed user's open browser tab: the next action answers 401 and returns to the login; their API key answers 401.
3. Remove a user who owns articles; the articles still show their author correctly (see Q-RM2).
4. Keyboard: menu item reachable, dialog focus order, Escape blocked while sending, focus returns to the row menu.
5. Upgrade check on a real SQLite file and, with `task test:mysql` / `task test:postgres`, the other drivers: DeleteIfRole compare-and-swap.
6. Mobile 390 px: card list menu, 44 px targets; FR; dark.
7. `task lint:go`, `gofmt -l`, `go vet -tags nofrontend`, `task lint:web`, `task test`, `task test:web` all green.
## Design gaps and backend notes
- G1 The design has the menu item "Remove user" and the 409 toast, but no confirmation dialog, no success toast, no 500 copy, no reason for the own row. They are open question Q-RM1.
- G2 The design has no success sentence for user.remove; "Removed {0}" / "{0} retiré" is the coder's proposal.
- G3 The own_role refusal copy "You can't change your own role" is reused for a removal; a removal-specific sentence is not in the design.
- G4 The design says the users group has 6 routes; with this route it has 7.
- Backend change (new): DELETE /v1/user/{id}?expected_role_id=N, the DeleteIfRole repository method, ActionUserRemove, RemoveUserUseCase, handler method, route, DI wiring, docs.