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:
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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user