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,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
|
||||
Reference in new issue
Block a user