feat: Users API, ownership transfer and removal of the legacy role string
This commit is contained in:
1 parent
094df63a7f
commit
80f7037d3a
124 files changed
+4644
-824
No files matched your search
@@ -0,0 +1,359 @@
|
||||
Feature: Roles and permissions users API: assign a role to a user, invite with a role, transfer ownership, and retire the legacy role string
|
||||
|
||||
As the owner of a RenewCMS site
|
||||
I want to give every user and every invite exactly one stored role, change it under the no-escalation rule,
|
||||
and hand the Owner role to another member
|
||||
So that Users shows real roles, nobody grants a role they do not cover, and the old owner/admin string is gone
|
||||
|
||||
Background:
|
||||
Given the spec of record is docs/design/screens/roles.md (sections 5, 7, 8, 9 and the tables "No-escalation feedback" and "HTTP status to copy")
|
||||
and docs/design/routes.md "Roles and permissions"
|
||||
And features/roles_permissions_foundation.feature provides the catalogue and the built-in roles,
|
||||
features/roles_permissions_enforcement.feature the 403 contract (body "missing_permission\n", header X-Missing-Permission),
|
||||
features/roles_permissions_content_scope.feature the GrantSet, and features/roles_permissions_roles_api.feature
|
||||
the role routes, GrantSet.Covers, the escalation contract (body "escalation", header X-Escalation-Key) and the activity conventions
|
||||
And "403" means status 403, body "missing_permission\n" and the header X-Missing-Permission, nothing stored, no audit event
|
||||
And every other refusal is plain text from http.Error, one snake_case code, and writes nothing, not even the deletion of an expired invite
|
||||
And success bodies are JSON with snake_case fields and Cache-Control "no-store" on the new routes
|
||||
And the new refusals are:
|
||||
| situation | status | body | extra header |
|
||||
| user id unknown | 404 | user_not_found (existing apperr) | |
|
||||
| role_id unknown (PUT role, invite) | 400 | invalid_role | |
|
||||
| the target user holds the Owner role, or the role to give is the Owner role | 403 | role_locked (existing) | |
|
||||
| change the role of your own account | 403 | own_role (existing) | |
|
||||
| the current or the new role is above the caller's grants | 403 | escalation (existing) | X-Escalation-Key: first offending key |
|
||||
| the target's role changed between read and write | 409 | user_role_changed | |
|
||||
| transfer target unknown, self, unverified, or not a user | 400 | invalid_transfer | |
|
||||
| confirm_email differs from the target's email | 400 | confirmation_mismatch | |
|
||||
| delete a role that still has members, users or open invites | 409 | role_has_members (existing) | |
|
||||
| an API key on a session-only route | 403 | apperr.SessionRequiredErrorMessage | |
|
||||
And an undecodable body is 400 apperr.BodyErrorMessage, a failed validate tag is 400 with the validator text, a non-numeric id is 400 apperr.IdUint32ErrorMessage
|
||||
And the request structs carry validate tags: PUT role "role_id" required, transfer "user_id" required and "confirm_email" required.
|
||||
The invite body keeps "email" required,inviteemail and gains the optional "role_id" (absent or 0 means the Writer role)
|
||||
And the routes are:
|
||||
| route | key (role.Key constant) | session only | mounted in |
|
||||
| PUT /v1/user/{id}/role | users.change_role | yes | NewUserRouter |
|
||||
| POST /v1/user/invite | users.invite (unchanged) | no | NewUserRouter, with the rate limiter |
|
||||
| POST /v1/user/invite/{id}/renew | users.invite (unchanged) | no | NewUserRouter, with the rate limiter |
|
||||
| DELETE /v1/user/invite/{id} | users.invite (unchanged) | no | NewUserRouter, with the rate limiter |
|
||||
| GET /v1/user | users.view (unchanged) | no | NewUserRouter |
|
||||
| POST /v1/site/transfer-ownership | system.transfer_ownership | yes | NewSiteRouter, session group |
|
||||
And the permission gate runs before the id, the body and any lookup. An API key on a session-only route gets 403 session_required before the gate
|
||||
And handlers receive use cases only: ChangeUserRoleUseCase, TransferOwnershipUseCase, and the amended CreateInviteUseCase, RenewInviteUseCase,
|
||||
ListUsersAndInvitesUseCase. They depend on domain interfaces only (user.Repository, invite.Repository, role.Repository, contenttype.Repository, audit.Recorder)
|
||||
And the transfer handler lives in its own package (api/handlers/ownership), built by NewHandler(TransferOwnershipUseCase) and mounted by NewSiteRouter
|
||||
And RoleRef is the one role summary shape {id, name, built_in, locked}. It is used by /auth/me access.role, by user items, by invite items and by the responses below
|
||||
And a user's role is role_id, and nothing else. There is no fallback on a string
|
||||
And the built-in roles are found by a single helper builtInRole(roles, name): GetByName, then built_in must be true, else role.ErrRoleNotFound
|
||||
And the order of checks on PUT /v1/user/{id}/role is: session, gate users.change_role, id (400), body (400), load the target user (404),
|
||||
the target holds a Locked role (403 role_locked), load role_id (400 invalid_role), the new role is Locked (403 role_locked),
|
||||
the target is the caller (403 own_role), the target's current role covered by the caller (403 escalation),
|
||||
the new role covered by the caller (403 escalation), same role (200, no write), write (409 user_role_changed on a stale read)
|
||||
And the order of checks on POST /v1/user/invite is: gate users.invite, rate limiter, body (400), load the inviter, load the role (default Writer, 400 invalid_role),
|
||||
the role is Locked (403 role_locked), the role covered by the caller (403 escalation), email already has an account (409), pending invite (409),
|
||||
create, send the mail (the existing rollback on a send failure is unchanged)
|
||||
And the order of checks on POST /v1/user/invite/{id}/renew is: the existing steps up to 404 and 409 (already accepted),
|
||||
then the invite's role covered by the caller (403 escalation), then renew. DELETE /v1/user/invite/{id} needs no coverage
|
||||
And the order of checks on POST /v1/site/transfer-ownership is: session, gate system.transfer_ownership, body (400), the target (400 invalid_transfer),
|
||||
confirm_email (400 confirmation_mismatch), write (409 user_role_changed when the caller no longer holds the Owner role)
|
||||
And the roles and people of the world (all verified, valid jwt cookie, token_version 0, role_id set, unless noted):
|
||||
| role | grants | users | open invites |
|
||||
| Owner | built-in, Locked, 34 keys | user 3 marta | none |
|
||||
| Admin | built-in, 29 keys | 5 jonas, 10 lea, 12 sam (unverified) | none |
|
||||
| Writer | built-in, 9 keys | 9 ines | invite 1 |
|
||||
| Contributor | content.read=all, content.create, content.update=own | 41, 42, 43, 44 | invite 2 (expired), invite 3 (pending) |
|
||||
| Intern | content.read=own | 46 | none |
|
||||
| Content lead | content.read=all, content.update=all, content.publish=all, content.delete=all | 45 | none |
|
||||
| People manager | users.view, users.invite, users.change_role, content.read=all, content.create, content.update=own, content.publish=own, schema.view | 31 | none |
|
||||
| Viewer | users.view, roles.view | 32 | none |
|
||||
| Inviter | users.invite | 33 | none |
|
||||
And invite 1 is "new1@renew.example" for Writer, invite 2 is "old2@renew.example" for Contributor and expired, invite 3 is "new3@renew.example" for Contributor and pending,
|
||||
invite 4 is accepted (user 9). Accepted invites are history and never count as members
|
||||
And members, as counted for GET /v1/role, are: Owner 1, Admin 3, Writer 2, Contributor 6, Intern 1, Content lead 1, People manager 1, Viewer 1, Inviter 1
|
||||
|
||||
Scenario Outline: The gate decides who changes roles, invites and transfers
|
||||
When <caller> sends <call>
|
||||
Then the result is <result>
|
||||
|
||||
Examples:
|
||||
| caller | call | result |
|
||||
| user 3 Owner | PUT /v1/user/41/role {"role_id": <Intern id>} | 200 |
|
||||
| user 5 Admin | PUT /v1/user/41/role {"role_id": <Intern id>} | 200 |
|
||||
| user 31 People manager | PUT /v1/user/41/role {"role_id": <Intern id>} | 200 |
|
||||
| user 9 Writer | PUT /v1/user/41/role {"role_id": <Intern id>} | 403 users.change_role |
|
||||
| user 9 Writer | PUT /v1/user/abc/role | 403 users.change_role (before the id is read) |
|
||||
| user 32 Viewer | PUT /v1/user/999/role | 403 users.change_role (before any lookup) |
|
||||
| user 5 Admin | PUT /v1/user/abc/role | 400 apperr.IdUint32ErrorMessage |
|
||||
| API key created by user 5 | PUT /v1/user/41/role {"role_id": <Intern id>} | 403 apperr.SessionRequiredErrorMessage, nothing stored |
|
||||
| no cookie | PUT /v1/user/41/role | 401 apperr.UnauthorizedErrorMessage |
|
||||
| user 12 unverified Admin | PUT /v1/user/41/role | 403 apperr.VerificationRequiredErrorMessage |
|
||||
| user 3 Owner | POST /v1/site/transfer-ownership {"user_id": 5, "confirm_email": <email of 5>} | 200 |
|
||||
| user 5 Admin | POST /v1/site/transfer-ownership {"user_id": 10, "confirm_email": <email of 10>} | 403 system.transfer_ownership |
|
||||
| user 9 Writer | POST /v1/site/transfer-ownership | 403 system.transfer_ownership (before the body) |
|
||||
| API key created by user 3 | POST /v1/site/transfer-ownership {"user_id": 5, "confirm_email": <email of 5>} | 403 apperr.SessionRequiredErrorMessage, nothing stored |
|
||||
| user 32 Viewer | POST /v1/user/invite {"email": "x@renew.example"} | 403 users.invite |
|
||||
| user 33 Inviter | GET /v1/user | 403 users.view |
|
||||
And the gate follows the role live: after Viewer gains users.change_role in the repository, the same cookie's next PUT passes the gate, with no re-login
|
||||
|
||||
Scenario Outline: Changing a user's role
|
||||
When <caller> sends PUT /v1/user/<id>/role with {"role_id": <role>}
|
||||
Then the result is <result>
|
||||
|
||||
Examples:
|
||||
| caller | id | role | result |
|
||||
| user 5 Admin | 41 | Writer | 200, the user item of user 41 with role Writer |
|
||||
| user 5 Admin | 41 | Admin | 200 (an Admin covers Admin) |
|
||||
| user 5 Admin | 10 | Contributor | 200 (demoting another Admin is allowed, Admin covers Admin) |
|
||||
| user 3 Owner | 5 | Content lead | 200 |
|
||||
| user 31 People manager | 41 | Intern | 200 (Contributor and Intern are both covered) |
|
||||
| user 31 People manager | 41 | Writer | 403 escalation, X-Escalation-Key content.update (Writer updates at all, the caller at own) |
|
||||
| user 31 People manager | 41 | Content lead | 403 escalation, X-Escalation-Key content.update |
|
||||
| user 31 People manager | 45 | Intern | 403 escalation, X-Escalation-Key content.update (the current role Content lead is not covered) |
|
||||
| user 31 People manager | 5 | Intern | 403 escalation, X-Escalation-Key content.update (the current role Admin is not covered) |
|
||||
| user 5 Admin | 41 | Owner | 403 role_locked |
|
||||
| user 3 Owner | 41 | Owner | 403 role_locked (one owner, transfer instead) |
|
||||
| user 5 Admin | 3 | Admin | 403 role_locked (the owner cannot be changed) |
|
||||
| user 3 Owner | 3 | Admin | 403 role_locked (the Owner role is checked before the own account) |
|
||||
| user 5 Admin | 5 | Writer | 403 own_role |
|
||||
| user 31 People manager | 31 | Intern | 403 own_role |
|
||||
| user 5 Admin | 999| Writer | 404 user_not_found |
|
||||
| user 5 Admin | 41 | 999 | 400 invalid_role |
|
||||
| user 5 Admin | 41 | (no role_id) | 400 (validate tag required) |
|
||||
| user 5 Admin | 41 | 0 | 400 (validate tag required) |
|
||||
| user 5 Admin | 12 | Writer | 200 (an unverified user may be given a role) |
|
||||
And a refused call changes no role_id, bumps no token_version and records no event
|
||||
And the success body is the user item of GET /v1/user: id, kind "user", username, display_name, email, role (RoleRef), status "accepted", created_at
|
||||
|
||||
Scenario: Giving the same role again is a no-op
|
||||
When user 5 sends PUT /v1/user/41/role with the Contributor id
|
||||
Then the status is 200 and the body is the unchanged user item
|
||||
And no write happens and no event is recorded
|
||||
And a caller who does not cover the Contributor role (a Viewer-like role with users.change_role only) still gets 403 escalation, because the current role is checked first
|
||||
|
||||
Scenario: A role change takes effect at once and keeps sessions and keys
|
||||
Given user 41 has a valid cookie and an API key, and holds the Contributor role
|
||||
When user 5 moves user 41 to the Writer role
|
||||
Then user 41's token_version is unchanged and the same cookie and the same API key still authenticate
|
||||
And user 41's next request is decided by the Writer grants, with no re-login
|
||||
And GET /v1/auth/me for user 41 shows access.role Writer
|
||||
|
||||
Scenario: Two concurrent changes on the same user give one winner
|
||||
Given user 5 and user 3 both read user 41 on the Contributor role
|
||||
When both send a PUT with a different new role at the same time
|
||||
Then exactly one answers 200 and the other 409 user_role_changed
|
||||
And the stored role is the winner's
|
||||
And the repository UpdateRole(id, from, to) is a compare-and-swap on the role the use case read
|
||||
|
||||
Scenario: A role deleted meanwhile is an invalid role, not a dangling reference
|
||||
Given the custom role "Temp" has no member and is deleted between the lookup and the write of a PUT
|
||||
Then the answer is 400 invalid_role and user 41 keeps the Contributor role
|
||||
|
||||
Scenario: The Owner role always has exactly one verified holder
|
||||
Given two registrants hold the Owner role and neither is verified
|
||||
When the first one verifies
|
||||
Then the second one cannot be verified while a verified Owner exists, and the answer is the existing verification refusal
|
||||
And the repository performs the check and the verification in one transaction, so two concurrent verifications leave one winner
|
||||
Then no call of this feature leaves zero or two verified holders of the Owner role
|
||||
And PUT role to or from the Owner is 403 role_locked, DELETE and edit of the Owner role stay role_locked, reassign from or to the Owner role stays refused
|
||||
And only POST /v1/site/transfer-ownership moves the Owner role, in one transaction
|
||||
|
||||
Scenario Outline: Inviting with a role
|
||||
When <caller> sends POST /v1/user/invite with <body>
|
||||
Then the result is <result>
|
||||
|
||||
Examples:
|
||||
| caller | body | result |
|
||||
| user 5 Admin | {"email": "a@renew.example"} | 201, role is the Writer RoleRef (default), one "invitation" mail sent |
|
||||
| user 5 Admin | {"email": "a@renew.example", "role_id": 0} | 201, role Writer |
|
||||
| user 5 Admin | {"email": "a@renew.example", "role_id": <Contributor id>} | 201, role Contributor |
|
||||
| user 5 Admin | {"email": "a@renew.example", "role_id": <Admin id>} | 201, role Admin |
|
||||
| user 3 Owner | {"email": "a@renew.example", "role_id": <Content lead id>} | 201 |
|
||||
| user 5 Admin | {"email": "a@renew.example", "role_id": <Owner id>} | 403 role_locked, no invite, no mail |
|
||||
| user 5 Admin | {"email": "a@renew.example", "role_id": 999} | 400 invalid_role, no invite, no mail |
|
||||
| user 31 People manager | {"email": "a@renew.example", "role_id": <Intern id>} | 201 |
|
||||
| user 31 People manager | {"email": "a@renew.example"} | 403 escalation, X-Escalation-Key content.update (the default Writer is above the caller) |
|
||||
| user 31 People manager | {"email": "a@renew.example", "role_id": <Content lead id>} | 403 escalation, X-Escalation-Key content.update |
|
||||
| user 33 Inviter | {"email": "a@renew.example"} | 403 escalation, X-Escalation-Key content.read |
|
||||
| user 5 Admin | {"email": "old2@renew.example", "role_id": 999} | 400 invalid_role, and expired invite 2 is not deleted |
|
||||
| user 5 Admin | {"email": "old2@renew.example", "role_id": <Writer id>} | 201, expired invite 2 is replaced by a new invite for Writer |
|
||||
| user 5 Admin | {"email": "new3@renew.example"} | 409 apperr.PendingInviteErrorMessage (existing) |
|
||||
| user 5 Admin | {"email": "ines@renew.example"} (user 9's address) | 409 apperr.EmailHasAccountErrorMessage (existing) |
|
||||
| user 5 Admin | {"email": "not-an-email"} | 400 (validate tag inviteemail) |
|
||||
And the invite response is {id, kind "invite", email, status, role (RoleRef), created_at, expires_at}
|
||||
And an API key may still create invites, and the no-escalation check then uses the live role of the key's creator
|
||||
|
||||
Scenario: Renewing keeps the invite's role
|
||||
Given invite 2 is expired and for Contributor
|
||||
When user 5 sends POST /v1/user/invite/2/renew
|
||||
Then the status is 200, the invite is pending again, its role is still Contributor and the response carries the role
|
||||
And a new code is mailed as before and the old code no longer works
|
||||
|
||||
Scenario Outline: Renewing and cancelling against the no-escalation rule
|
||||
When <caller> sends <call>
|
||||
Then the result is <result>
|
||||
|
||||
Examples:
|
||||
| caller | call | result |
|
||||
| user 31 People manager | POST /v1/user/invite/3/renew (Contributor) | 200 |
|
||||
| user 31 People manager | POST /v1/user/invite/1/renew (Writer) | 403 escalation, X-Escalation-Key content.update, nothing renewed, no mail |
|
||||
| user 31 People manager | DELETE /v1/user/invite/1 (Writer) | 204 (cancelling removes power, no coverage needed) |
|
||||
| user 5 Admin | POST /v1/user/invite/999/renew | 404 apperr.InviteNotFoundErrorMessage (before any coverage check) |
|
||||
| user 5 Admin | POST /v1/user/invite/4/renew (accepted) | 409 apperr.InviteAlreadyAcceptedErrorMessage |
|
||||
|
||||
Scenario Outline: Accepting an invite creates a user with the invite's role
|
||||
Given a pending invite for "new3@renew.example" for <role>
|
||||
When the client sends POST /v1/auth/invite/accept with its code, username "kim" and a valid password
|
||||
Then the status is 200 and the stored user "kim" has the role_id of <role>
|
||||
And the user is verified and has no legacy role string
|
||||
And GET /v1/auth/me for "kim" shows access.role <role> with that role's grants
|
||||
|
||||
Examples:
|
||||
| role |
|
||||
| Writer |
|
||||
| Contributor |
|
||||
| Admin |
|
||||
And an invite whose role_id is 0 or names a role that no longer exists is refused as apperr.InviteInvalidErrorMessage, no user is created, and a failed invite.accept event is recorded
|
||||
And the invite's role is read at accept time, so editing the role's grants before the accept changes what the new user gets
|
||||
|
||||
Scenario: Open invites count as role members
|
||||
When user 5 sends GET /v1/role
|
||||
Then Writer has members 2 (user 9 and invite 1) and Contributor has members 6 (users 41-44, expired invite 2, pending invite 3)
|
||||
And accepted invite 4 counts for nobody
|
||||
And the members of a role are counted by role_id on users plus role_id on invites with accepted_at NULL, with no GetAll scan
|
||||
|
||||
Scenario Outline: Deleting and moving roles that hold invites
|
||||
Given the custom role "Temp" has no user and one open invite (<state>)
|
||||
When user 5 sends DELETE /v1/role/<Temp id>
|
||||
Then the answer is 409 role_has_members and nothing is deleted
|
||||
|
||||
Examples:
|
||||
| state |
|
||||
| pending |
|
||||
| expired |
|
||||
And after POST /v1/role/<Temp id>/reassign {"target_role_id": <Intern id>} the answer is {"moved": 1}, the invite's role is Intern and DELETE answers 204
|
||||
And moved counts users and invites together, so Contributor reassigned to Intern answers {"moved": 6}
|
||||
And the reassign event params moved is that total
|
||||
And an accepted invite that still names the role does not block the delete
|
||||
|
||||
Scenario: Transferring ownership
|
||||
Given user 3 holds the Owner role and user 5 is a verified Admin with email "jonas@renew.example"
|
||||
When user 3 sends POST /v1/site/transfer-ownership with {"user_id": 5, "confirm_email": "jonas@renew.example"}
|
||||
Then the status is 200 and the body is {"owner": {"id": 5, "name": ...}, "former_owner": {"id": 3, "name": ..., "role": the Admin RoleRef}}
|
||||
And user 5 holds the Owner role and user 3 holds the Admin role, in one transaction
|
||||
And both users keep their token_version, cookies and API keys
|
||||
And user 3's next request is decided by the Admin grants and user 5's by the Owner grants
|
||||
And GET /v1/role shows Owner members 1 and Admin members 3 (jonas left Admin, marta joined it)
|
||||
And a second POST by user 3 answers 403 missing_permission with X-Missing-Permission system.transfer_ownership
|
||||
|
||||
Scenario Outline: Transfer refusals
|
||||
When user 3 sends POST /v1/site/transfer-ownership with <body>
|
||||
Then the result is <result>
|
||||
|
||||
Examples:
|
||||
| body | result |
|
||||
| {} | 400 (validate tag required) |
|
||||
| {"user_id": 5} | 400 (validate tag required on confirm_email) |
|
||||
| {"user_id": 999, "confirm_email": "x@renew.example"} | 400 invalid_transfer |
|
||||
| {"user_id": 3, "confirm_email": <email of 3>} | 400 invalid_transfer (yourself) |
|
||||
| {"user_id": 12, "confirm_email": <email of 12>} | 400 invalid_transfer (unverified) |
|
||||
| {"user_id": 5, "confirm_email": "JONAS@renew.example"} | 400 confirmation_mismatch (exact match) |
|
||||
| {"user_id": 5, "confirm_email": ""} | 400 (validate tag required) |
|
||||
And a refused call changes no role_id and records no event
|
||||
And an invite id is never a valid target, only members can be chosen
|
||||
|
||||
Scenario: Two simultaneous transfers give one new owner
|
||||
Given user 3 sends a transfer to user 5 and a transfer to user 10 at the same time
|
||||
Then exactly one answers 200 and the other 409 user_role_changed
|
||||
And exactly one verified user holds the Owner role afterwards
|
||||
And the repository TransferOwnership(from, to, ownerRoleID, formerRoleID) updates both rows in one transaction and fails the whole transaction when from no longer holds the Owner role
|
||||
|
||||
Scenario: The list of users and invites shows roles
|
||||
When user 5 sends GET /v1/user
|
||||
Then user items have exactly the keys id, kind, username, display_name, email, role, status, created_at, and role is a RoleRef
|
||||
And invite items have exactly the keys id, kind, email, role, status, created_at, and role is the invite's RoleRef
|
||||
And user 3's role is {"id": <Owner id>, "name": "Owner", "built_in": true, "locked": true}
|
||||
And the roles are read with one role List call, not one per row
|
||||
And a user whose role cannot be resolved shows role null
|
||||
And no item carries password, password_reset_code, verification_code, verification_expiration, password_reset_expiration, token_version, code, hashed_code, invited_by or accepted_at
|
||||
|
||||
Scenario: The public user shape has no role string
|
||||
Then PublicUser is exactly id, username, email, is_verified, created_at, updated_at and display_name, in every response that includes a user
|
||||
And GET /v1/auth/me keeps access.role as its only role field, and holds the keys id, username, email, is_verified, created_at, updated_at, display_name, author and access
|
||||
And POST /v1/auth/login, /verify, /register and /invite/accept carry no role key
|
||||
|
||||
Scenario: The legacy role string is gone from the code
|
||||
Then the type user.Role, the constants user.RoleOwner and user.RoleAdmin and the field user.User.Role do not exist
|
||||
And resolveRole(role_id) is the only resolution: role_id 0 gives role.ErrRoleNotFound, and legacyBuiltInNames does not exist
|
||||
And CheckPermission gives an empty GrantSet for role.ErrRoleNotFound, as before, and GetMyAccess returns the error
|
||||
And ListUsersAndInvites, memberCounts, CreateUser, AcceptInvite, the GORM User model, the user mapper and the user repository read and write no role string
|
||||
And userRepository.Create writes role_id, with NULL when the domain role_id is 0
|
||||
And the builtin "user" content type keeps a field "role", now a text field that is not required and has no options
|
||||
|
||||
Scenario: Registration creates the Owner
|
||||
Given the user repository is empty
|
||||
When the client sends POST /v1/auth/register with a valid body
|
||||
Then the stored user has the role_id of the built-in Owner role and is unverified, as before
|
||||
And when the Owner role is not seeded the answer is 500 "Internal server error" and no user is created
|
||||
|
||||
Scenario: A user without a usable role is denied, never promoted
|
||||
Given user 13 has role_id 0 and user 14 has role_id 99 (no such role)
|
||||
Then a gated route answers 403 missing_permission for both
|
||||
And GET /v1/auth/me answers 500 "Internal server error" for both
|
||||
And neither is counted in any role's members and both show role null in GET /v1/user
|
||||
|
||||
Scenario Outline: The migration gives every user and every invite a role_id
|
||||
Given a database that predates this slice, with a users.role column holding <legacy> and no role_id
|
||||
When Migrate runs
|
||||
Then the user has the role_id of <role>
|
||||
And running Migrate again changes nothing
|
||||
And a user created after the upgrade with no role_id is never promoted by a later Migrate, and neither is an invite without a role_id
|
||||
And the legacy steps only touch rows whose legacy users.role value still decides them, and are skipped when the column is absent
|
||||
|
||||
Examples:
|
||||
| legacy | role |
|
||||
| "owner" (lowest id of the owners) | Owner |
|
||||
| "owner" (any other id) | Admin |
|
||||
| "admin" | Admin |
|
||||
| "" (lowest id) | Owner |
|
||||
| "" (any other id) | Admin |
|
||||
| "something-else" | Writer |
|
||||
And a user that already has a role_id keeps it, even when the legacy string disagrees
|
||||
And every existing invite without a role_id gets the Admin role, accepted ones included
|
||||
And the backfill of created_by attributes orphans to the lowest user id holding the Owner role (no longer "role = 'owner'")
|
||||
And a fresh database never has a users.role column, and the legacy steps are skipped when the column is absent
|
||||
And a database that still has the column keeps it, untouched and unused (never dropped by this slice)
|
||||
And the steps run in this order: AutoMigrate, seed built-in roles, legacy role backfill, invite role backfill, created_by backfill, the rest
|
||||
|
||||
Scenario: The member counts are one bulk query
|
||||
Then role.Repository gains MemberCounts() (map[uint32]int, error): users grouped by role_id plus invites with accepted_at NULL grouped by role_id, two statements for any number of roles
|
||||
And roleViewer.views and DeleteRole use it, and no role use case calls user.Repository.GetAll
|
||||
And Delete re-counts users and open invites inside its transaction and returns role.ErrRoleHasMembers
|
||||
And ReassignMembers(from, to) moves users and open invites in one transaction and returns their sum
|
||||
And the persistence repository keeps the SQL portable across SQLite, MySQL/MariaDB and PostgreSQL
|
||||
|
||||
Scenario: Activity events for role assignment and ownership
|
||||
Given the audit recorder is enabled
|
||||
When user 5 moves user 41 from Contributor to Writer
|
||||
Then one user.change_role event is recorded in category users, outcome success, actor user 5, target {"type": "user", "id": "41", "name": the username}
|
||||
And its changes hold {"field": "role", "kind": "text", "before": "Contributor", "after": "Writer"} and its params from and to hold the two role ids
|
||||
When user 3 transfers the Owner role to user 5
|
||||
Then one user.transfer_ownership event is recorded in category users, security true, actor user 3, target user 5, params from 3 and to 5
|
||||
When user 5 creates an invite for Contributor and the invitee accepts it
|
||||
Then invite.create and invite.accept carry the params role_id and role (the role name), and invite.renew is unchanged
|
||||
And a refusal (403, 404, 400, 409) records nothing, a no-op records nothing
|
||||
And a storage failure at the write records the same event with outcome failed and a failure reason, through recordFailure
|
||||
And audit gains ActionUserChangeRole "user.change_role", ActionUserTransferOwnership "user.transfer_ownership", FieldRole "role", ParamRole "role" and ParamRoleID "role_id"
|
||||
And GET /v1/activity?category=users returns the new events to a caller whose activity.view is all
|
||||
|
||||
Scenario: Layering holds
|
||||
Then internal/domain/user and internal/domain/invite import the stdlib and other domain packages only
|
||||
And the use cases import no persistence, mailer, models, gorm, net/http or api package
|
||||
And api/router.go uses role.KeyUsersChangeRole and role.KeySystemTransferOwnership, and no string equal to a catalogue key
|
||||
And no handler imports persistence, mailer, models or gorm
|
||||
And the handlers validate with validate tags and validator.Validate.Struct, never by hand
|
||||
|
||||
Scenario: Out of scope here
|
||||
Then there is no SPA change (slices 5-7), and the SPA that reads a role string is adapted there
|
||||
And there is no DELETE /v1/user/{id}, "Remove user" has no route yet
|
||||
And the physical users.role column is not dropped
|
||||
And TestRouterRoutesAreInTheCatalogue was already failing and is reported, not fixed (the two new routes are only added to its exemption list)
|
||||
Reference in new issue
Block a user