feat: add Role (owner|admin) to the user domain, persistence model and mapper
This commit is contained in:
1 parent
162fea4afb
commit
461e93e3b5
30 files changed
+791
-48
No files matched your search
@@ -26,7 +26,8 @@ Feature: An invited person accepts their invitation and gets a verified account
|
||||
And error responses are plain text from http.Error, success responses are JSON
|
||||
And accepting creates a user with the invite's email (already lower case), the chosen username,
|
||||
the hashed password, is_verified true, an empty verification code, and an empty display name
|
||||
And the created user has the same access as any other verified user: no roles, no ownership
|
||||
And the created user is an admin: same access to articles, the users list and Settings, but
|
||||
cannot invite
|
||||
And the jwt cookie is set exactly as registration sets it: HttpOnly, path "/", 24 hours, with
|
||||
claims user_id and token_version, and Secure in production
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ Feature: A logged-in user fetches their own public profile without triggering th
|
||||
Background:
|
||||
Given the public user struct and its converter, shared by the auth and user handlers from a
|
||||
package under api/pkg (features/user_display_name.feature), exactly the JSON shape
|
||||
POST /v1/auth/verify returns: id, username, email, is_verified, created_at, updated_at and
|
||||
display_name
|
||||
POST /v1/auth/verify returns: id, username, email, is_verified, created_at, updated_at,
|
||||
display_name and role
|
||||
And this slice adds GET /v1/auth/me, handled by a new MeHandler (api/handlers/auth/meHandler.go)
|
||||
built with NewMeHandler(container.GetUserUseCase), the same GetUserUseCase every other
|
||||
protected handler already gets from api/dependecyInjection.go; no new use case or domain
|
||||
@@ -43,23 +43,23 @@ Feature: A logged-in user fetches their own public profile without triggering th
|
||||
|
||||
Scenario: A verified user fetches their own profile
|
||||
Given a user repository containing user id 7, username "alice", email "alice@example.com",
|
||||
is_verified true, TokenVersion 0
|
||||
is_verified true, role "owner", TokenVersion 0
|
||||
And a valid jwt cookie for user id 7 encoding token_version 0
|
||||
When the client sends GET /v1/auth/me with that cookie
|
||||
Then the response status is 200
|
||||
And the response header Cache-Control is "no-store"
|
||||
And the response body is the user as JSON with id 7, username "alice", email
|
||||
"alice@example.com", is_verified true
|
||||
"alice@example.com", is_verified true, role "owner"
|
||||
|
||||
Scenario: An unverified user still fetches their own profile, with is_verified false, not a 403
|
||||
Given a user repository containing user id 8, username "bob", email "bob@example.com",
|
||||
is_verified false, TokenVersion 0
|
||||
is_verified false, role "owner", TokenVersion 0
|
||||
And a valid jwt cookie for user id 8 encoding token_version 0
|
||||
When the client sends GET /v1/auth/me with that cookie
|
||||
Then the response status is 200
|
||||
And the response header Cache-Control is "no-store"
|
||||
And the response body is the user as JSON with id 8, username "bob", email "bob@example.com",
|
||||
is_verified false
|
||||
is_verified false, role "owner"
|
||||
|
||||
Scenario: No jwt cookie at all is refused
|
||||
Given a user repository containing user id 7, username "alice", is_verified true, TokenVersion 0
|
||||
|
||||
@@ -37,13 +37,13 @@ wiring pattern in `test/stale_token_invalidation_http_test.go`.
|
||||
## HTTP-level checks
|
||||
|
||||
8. Seed `mockUserRepo` with user id 7, username "alice", email "alice@example.com",
|
||||
`IsVerified: true`, `TokenVersion: 0`. Mint a cookie for user 7 with `token_version: 0`.
|
||||
`GET /v1/auth/me` → assert status `200`, `Cache-Control` header equals `"no-store"`, and the
|
||||
decoded JSON body has `id: 7`, `username: "alice"`, `email: "alice@example.com"`,
|
||||
`is_verified: true`.
|
||||
9. Seed user id 8, username "bob", email "bob@example.com", `IsVerified: false`,
|
||||
`IsVerified: true`, `Role: owner`, `TokenVersion: 0`. Mint a cookie for user 7 with
|
||||
`token_version: 0`. `GET /v1/auth/me` → assert status `200`, `Cache-Control` header equals
|
||||
`"no-store"`, and the decoded JSON body has `id: 7`, `username: "alice"`,
|
||||
`email: "alice@example.com"`, `is_verified: true`, `role: "owner"`.
|
||||
9. Seed user id 8, username "bob", email "bob@example.com", `IsVerified: false`, `Role: owner`,
|
||||
`TokenVersion: 0`. Mint a matching cookie. `GET /v1/auth/me` → assert status `200` (not 403),
|
||||
`Cache-Control: no-store`, and `is_verified: false` in the body.
|
||||
`Cache-Control: no-store`, `is_verified: false` and `role: "owner"` in the body.
|
||||
10. No `jwt` cookie at all against the router from step 6 → assert status `401` (produced by
|
||||
`jwtauth.Authenticator` itself; don't assert an exact body for this case, same rationale as
|
||||
`features/account_verification.qa.md` step 15).
|
||||
|
||||
@@ -6,9 +6,13 @@ Feature: A verified user can invite another person by email, and the users list
|
||||
registration stays closed to everyone who was not invited
|
||||
|
||||
Background:
|
||||
Given the workspace is shared: there are no roles, no ownership and no way to remove a user
|
||||
Given the workspace is shared: every verified user sees the same articles and the same list,
|
||||
roles are owner and admin (features/user_roles.feature), and there is no way to remove a user
|
||||
And POST /v1/user/invite and GET /v1/user/ are mounted in NewUserRouter, inside the group that
|
||||
requires a valid jwt cookie, a current token_version and a verified user
|
||||
And POST /v1/user/invite additionally requires the owner role (403
|
||||
apperr.OwnerRequiredErrorMessage for an admin); GET /v1/user/ is open to every verified user
|
||||
And wherever a scenario has verified user id 7 post an invite, user 7 is the owner
|
||||
And an invite has an id, an email stored in lower case, a hashed code, an expires_at equal to
|
||||
its creation time plus 3 days, an invited_by user id, a nullable accepted_at and a created_at
|
||||
And the raw invite code is a uuid, hashed with the same hash.Hasher used for verification codes,
|
||||
@@ -17,8 +21,8 @@ Feature: A verified user can invite another person by email, and the users list
|
||||
expires_at is in the future, expired when accepted_at is empty and expires_at is in the past,
|
||||
accepted when accepted_at is set
|
||||
And the users list item shape is: id, kind ("user" or "invite"), status, created_at, plus
|
||||
username and display_name for a user, plus email for an invite; a user is never listed with
|
||||
an email, and neither the code, its hash, invited_by nor password fields ever appear
|
||||
username, display_name, email and role for a user, plus email for an invite; neither the
|
||||
code, its hash, invited_by nor password fields ever appear
|
||||
And error responses are plain text from http.Error, success responses are JSON
|
||||
And the invite email uses the template "invitation" with data Host (the configured PublicURL),
|
||||
VerificationCode (the raw code), Email (the invited address) and InvitedBy (the inviter's
|
||||
@@ -30,7 +34,7 @@ Feature: A verified user can invite another person by email, and the users list
|
||||
/login and /password-reset
|
||||
|
||||
Scenario: A verified user invites a new address
|
||||
Given a user repository containing verified user id 7, username "marta", display_name ""
|
||||
Given a user repository containing verified user id 7, who is the owner, username "marta", display_name ""
|
||||
And an empty invite repository
|
||||
And a valid jwt cookie for user id 7
|
||||
When the client sends POST /v1/user/invite with body {"email": "Sam.Oliveira@Renew.example"}
|
||||
@@ -116,6 +120,7 @@ Feature: A verified user can invite another person by email, and the users list
|
||||
| with no jwt cookie | POST | /v1/user/invite | 401 |
|
||||
| with a jwt cookie of an unverified user | POST | /v1/user/invite | 403 |
|
||||
| with a jwt cookie whose token_version is stale | POST | /v1/user/invite | 401 |
|
||||
| with a jwt cookie of a verified admin | POST | /v1/user/invite | 403 |
|
||||
| with no jwt cookie | GET | /v1/user/ | 401 |
|
||||
| with a jwt cookie of an unverified user | GET | /v1/user/ | 403 |
|
||||
|
||||
@@ -142,9 +147,10 @@ Feature: A verified user can invite another person by email, and the users list
|
||||
And the accepted invite is not listed
|
||||
And the invite items have exactly the keys id, kind, email, status, created_at, with kind
|
||||
"invite" and status "pending" or "expired"
|
||||
And the user items have exactly the keys id, kind, username, display_name, status, created_at,
|
||||
with kind "user" and status "accepted"
|
||||
And no user item has an email key, and no item has a key named password, password_reset_code,
|
||||
And the user items have exactly the keys id, kind, username, display_name, email, role, status,
|
||||
created_at, with kind "user" and status "accepted"
|
||||
And each user item has its own email and a role
|
||||
And no item has a key named password, password_reset_code,
|
||||
verification_code, verification_expiration, password_reset_expiration, token_version,
|
||||
code, hashed_code, invited_by or accepted_at
|
||||
|
||||
|
||||
@@ -14,11 +14,13 @@ plus a `fixedClock` only if the use case takes one. Reuse `mockUserRepo` and `st
|
||||
3. Handler: `user.NewHandler(updateProfile, createInvite, listUsersAndInvites)` mounted on a fresh
|
||||
`chi.NewRouter()` mirroring `NewUserRouter`: `jwtauth.Verifier` + `jwtauth.Authenticator` with
|
||||
`jwtauth.New("HS256", []byte("test-secret"), nil)`, `RequireCurrentToken(getUser)`,
|
||||
`RequireVerifiedUser(getUser)`, then `POST /invite` and `GET /`. For the rate-limit case
|
||||
wrap only `POST /invite` in `TrustedProxyRealIP(nil)` + `NewRateLimiter(2, time.Minute)`.
|
||||
`RequireVerifiedUser(getUser)`, then `POST /invite` and `GET /`. `POST /invite` is also wrapped
|
||||
in `RequireOwner(getUser)`, placed before the limiter. For the rate-limit case
|
||||
wrap only `POST /invite` in `RequireOwner(getUser)` + `TrustedProxyRealIP(nil)` +
|
||||
`NewRateLimiter(2, time.Minute)`.
|
||||
Requests via `httptest`; jwt cookie built with the `user_id` and `token_version` claims as in
|
||||
`test/verified_account_required_http_test.go`. No live server, no real SQLite, no real SMTP.
|
||||
4. Seed users: verified id 7 "marta"; unverified id 8 for the 403 cases. Time is real: build
|
||||
4. Seed users: verified id 7 "marta" with `Role: owner`; unverified id 8 for the 403 cases. Time is real: build
|
||||
invites with `ExpiresAt`/`CreatedAt` relative to `time.Now()` and compare with a 5 s tolerance.
|
||||
|
||||
## POST /v1/user/invite
|
||||
@@ -47,7 +49,8 @@ plus a `fixedClock` only if the use case takes one. Reuse `mockUserRepo` and `st
|
||||
`inviteRepo` empty. Repeat the same request without re-arming -> `201`. With a seeded expired invite and a
|
||||
failing send -> `500` and no invite for that address remains.
|
||||
13. Auth table (`POST`): no cookie -> `401`; cookie of unverified user 8 -> `403`; cookie with stale
|
||||
`token_version` -> `401`. Each asserts no invite and no mail.
|
||||
`token_version` -> `401`; cookie of a verified admin (seeded with `Role: admin`) -> `403`, plain
|
||||
text body exactly `apperr.OwnerRequiredErrorMessage`. Each asserts no invite and no mail.
|
||||
14. Rate limit: limiter of 2 per minute, `RemoteAddr` `203.0.113.20:1234`, three valid POSTs -> `201`,
|
||||
`201`, `429`; third body is exactly `apperr.TooManyRequestsErrorMessage`, has `Retry-After`, and adds no
|
||||
invite or mail. `GET /` from the same address afterwards is not `429`.
|
||||
@@ -55,9 +58,10 @@ plus a `fixedClock` only if the use case takes one. Reuse `mockUserRepo` and `st
|
||||
## GET /v1/user/
|
||||
15. Seed per the feature scenario (three users, an expired, a pending and an accepted invite, with the
|
||||
stated `CreatedAt` values). `200`; decode as `[]map[string]json.RawMessage`. Assert length 5, order
|
||||
(pending sam, expired p.huber, ines, jonas, marta), key sets exactly as specified for user and invite
|
||||
items, accepted invite absent, and none of the forbidden keys anywhere. Also assert the raw body string
|
||||
contains no user email address.
|
||||
(pending sam, expired p.huber, ines, jonas, marta), key sets exactly as specified: `id`, `kind`,
|
||||
`username`, `display_name`, `email`, `role`, `status`, `created_at` for user items and `id`, `kind`,
|
||||
`email`, `status`, `created_at` for invite items, accepted invite absent, and none of the forbidden
|
||||
keys anywhere. Also assert the raw body string contains each accepted user's own email address.
|
||||
16. Only user 7 -> array with exactly one `kind:"user"` item, status `accepted`.
|
||||
17. Invite created 3 days + 1 minute ago -> `status:"expired"`; created 2 days ago -> `pending`;
|
||||
with `AcceptedAt` set -> not listed.
|
||||
|
||||
@@ -7,8 +7,8 @@ Feature: API responses never expose a user's secret fields
|
||||
credential or a still-valid reset/verification secret
|
||||
|
||||
Background:
|
||||
Given the public shape of a user in any API response is exactly seven fields: id, username,
|
||||
email, is_verified, created_at, updated_at and display_name
|
||||
Given the public shape of a user in any API response is exactly eight fields: id, username,
|
||||
email, is_verified, created_at, updated_at, display_name and role
|
||||
And password, password_reset_code, verification_code, verification_expiration,
|
||||
password_reset_expiration and token_version never appear in any response body, under any key
|
||||
name, regardless of their value or whether they are set
|
||||
@@ -21,7 +21,7 @@ Feature: API responses never expose a user's secret fields
|
||||
When the client sends POST /v1/auth/verify with body {"code": "abc123"}
|
||||
Then the response status is 200
|
||||
And the response body is a JSON object with exactly the keys id, username, email,
|
||||
is_verified, created_at, updated_at and display_name
|
||||
is_verified, created_at, updated_at, display_name and role
|
||||
And the response body does not contain the keys password, password_reset_code,
|
||||
verification_code, verification_expiration, password_reset_expiration or token_version
|
||||
And the response body's is_verified is true
|
||||
|
||||
@@ -21,11 +21,12 @@ Package: `test` (black-box), extending `test/account_verification_http_test.go`'
|
||||
user id 7. Decode the response body into `map[string]json.RawMessage` (not into `user.User`)
|
||||
and assert:
|
||||
- the key set is exactly `{"id", "username", "email", "is_verified", "created_at",
|
||||
"updated_at", "display_name"}`;
|
||||
"updated_at", "display_name", "role"}`;
|
||||
- none of `password`, `password_reset_code`, `verification_code`,
|
||||
`verification_expiration`, `password_reset_expiration`, `token_version` is present;
|
||||
- `is_verified` unmarshals to `true`;
|
||||
- `display_name` unmarshals to `""`.
|
||||
- `display_name` unmarshals to `""`;
|
||||
- `role` unmarshals to `"owner"` (seeded user 7 has `Role: owner`).
|
||||
5. Rejected verify: same seed, `{"code":"wrong-code"}` → assert status `403` and that the raw
|
||||
response body bytes, treated as a string, contain none of the substrings `password`,
|
||||
`verification_code`, `password_reset_code`, `verification_expiration`,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
Feature: Users have a role, owner or admin, and only the owner can invite
|
||||
|
||||
As the person who registered the workspace
|
||||
I want my account to be the owner and everyone I invite to be an admin
|
||||
So that only I can invite people, while every admin keeps the same access to articles,
|
||||
the users list, their own profile and Settings
|
||||
|
||||
Background:
|
||||
Given a user has a role that is exactly "owner" or "admin", stored on the user record and read
|
||||
from the user repository on every request, never from the jwt claims
|
||||
And the first registered user is the owner: CreateUser creates the user with role "owner",
|
||||
and registration is only possible while no verified user exists
|
||||
And every user created by accepting an invite is an admin: AcceptInvite creates the user with
|
||||
role "admin", whatever the inviter's role
|
||||
And there is exactly one owner and no endpoint changes, transfers or removes a role
|
||||
And AutoMigrate adds the role column to existing databases and backfills it: the user with the
|
||||
lowest id becomes "owner" and every other user "admin"; a database that already has roles
|
||||
is left unchanged by a later start
|
||||
And the public user shape (api/pkg/publicuser) is id, username, email, is_verified, created_at,
|
||||
updated_at, display_name and role
|
||||
And the users list user items are id, kind, username, display_name, email, role, status,
|
||||
created_at; invite items are unchanged: id, kind, email, status, created_at
|
||||
And POST /v1/user/invite is owner-only: after the jwt, current-token and verified checks, a
|
||||
RequireOwner check loads the user with GetUserUseCase and refuses any role other than
|
||||
"owner" with 403 and the plain-text body apperr.OwnerRequiredErrorMessage
|
||||
"Only the owner can do this.", before the rate limiter, the body decoding and the validation
|
||||
And GET /v1/user/ stays open to every verified user, owner and admin alike
|
||||
And error responses are plain text from http.Error, success responses are JSON
|
||||
|
||||
Scenario: The first registered user is the owner
|
||||
Given the user repository is empty
|
||||
When the client sends POST /v1/auth/register with username "marta", password "hunter2example12",
|
||||
email "marta@renew.example"
|
||||
Then the response status is 200
|
||||
And the user repository holds one user with username "marta" and role "owner"
|
||||
|
||||
Scenario: A user created by accepting an invite is an admin
|
||||
Given a verified owner "marta" and a pending invite for "lea.martin@renew.example" with a known raw code
|
||||
When the client sends POST /v1/auth/invite/accept with that code, username "lea" and password "limewash2026"
|
||||
Then the response status is 200
|
||||
And the user repository holds user "lea" with role "admin"
|
||||
And user "marta" still has role "owner"
|
||||
|
||||
Scenario Outline: GET /v1/auth/me returns the role of the session user
|
||||
Given a verified user "<username>" with role "<role>" and a valid jwt cookie for that user
|
||||
When the client sends GET /v1/auth/me
|
||||
Then the response status is 200
|
||||
And the response body has the key role with value "<role>"
|
||||
|
||||
Examples:
|
||||
| username | role |
|
||||
| marta | owner |
|
||||
| lea | admin |
|
||||
|
||||
Scenario: An unverified owner sees their role on /auth/me
|
||||
Given an unverified user "bob" with role "owner" and a valid jwt cookie
|
||||
When the client sends GET /v1/auth/me
|
||||
Then the response status is 200
|
||||
And the response body's role is "owner" and is_verified is false
|
||||
|
||||
Scenario: The users list carries email and role on accepted users
|
||||
Given verified owner id 1 "marta" with email "marta@renew.example"
|
||||
And admin id 2 "jonas" with email "jonas@renew.example"
|
||||
And admin id 3 "ines" with display_name "Inès Duarte" and email "ines.duarte@renew.example"
|
||||
And a pending invite for "sam.oliveira@renew.example"
|
||||
When the client sends GET /v1/user/ with a valid jwt cookie for user id 2
|
||||
Then the response status is 200
|
||||
And the user items have exactly the keys id, kind, username, display_name, email, role, status, created_at
|
||||
And user 1 has email "marta@renew.example" and role "owner"; users 2 and 3 have role "admin" and their own email
|
||||
And the invite item has exactly the keys id, kind, email, status, created_at
|
||||
And no item has a key named password, password_reset_code, verification_code,
|
||||
verification_expiration, password_reset_expiration, token_version, code, hashed_code,
|
||||
invited_by or accepted_at
|
||||
|
||||
Scenario: The owner can invite
|
||||
Given a verified owner id 1 "marta" and a valid jwt cookie for user id 1
|
||||
When the client sends POST /v1/user/invite with body {"email": "sam.oliveira@renew.example"}
|
||||
Then the response status is 201
|
||||
And one invite exists and one "invitation" email was sent
|
||||
|
||||
Scenario: An admin cannot invite
|
||||
Given a verified admin id 2 "jonas" and a valid jwt cookie for user id 2
|
||||
When the client sends POST /v1/user/invite with body {"email": "sam.oliveira@renew.example"}
|
||||
Then the response status is 403
|
||||
And the response body is exactly apperr.OwnerRequiredErrorMessage, as plain text
|
||||
And the invite repository is still empty
|
||||
And no email was sent
|
||||
|
||||
Scenario Outline: The owner check comes before validation and the rate limiter
|
||||
Given a verified admin id 2 and a valid jwt cookie for user id 2
|
||||
When the client sends POST /v1/user/invite with body <body>
|
||||
Then the response status is 403
|
||||
And no invite was created and no email was sent
|
||||
|
||||
Examples:
|
||||
| body |
|
||||
| {"email": "not-an-email"} |
|
||||
| not json |
|
||||
|
||||
Scenario: An admin's refused invites do not use up the rate limit
|
||||
Given AUTH_RATE_LIMIT_MAX_ATTEMPTS is 2 for a window covering the whole test
|
||||
When an admin at IP "203.0.113.20" sends 3 POST /v1/user/invite requests
|
||||
Then all 3 responses are 403 and none is 429
|
||||
|
||||
Scenario: The role is read on every request, not from the token
|
||||
Given a verified user id 2 whose stored role is "owner", with a valid jwt cookie
|
||||
And the stored role changes to "admin" without the token changing
|
||||
When the client sends POST /v1/user/invite with a valid body
|
||||
Then the response status is 403
|
||||
|
||||
Scenario: An admin can still list users
|
||||
Given a verified admin id 2 and a valid jwt cookie for user id 2
|
||||
When the client sends GET /v1/user/
|
||||
Then the response status is 200
|
||||
@@ -0,0 +1,55 @@
|
||||
# QA procedure: user_roles
|
||||
|
||||
Package: `test` (black-box). Reuse `mockUserRepo`, `stubHasher`, `mockMailRepo`, `mockInviteRepo`.
|
||||
No live server, no real SQLite file, no real SMTP.
|
||||
|
||||
## Setup shared by every case
|
||||
1. Call `validator.InitValidator()` once.
|
||||
2. Use cases: `NewGetUserUseCase`, `NewCreateInviteUseCase`, `NewListUsersAndInvitesUseCase`,
|
||||
`NewCreateUserUseCase`, `NewAcceptInviteUseCase` over the fakes.
|
||||
3. Router mirrors `NewUserRouter`: `jwtauth.Verifier` + `Authenticator` with
|
||||
`jwtauth.New("HS256", []byte("test-secret"), nil)`, `RequireCurrentToken`, `RequireVerifiedUser`,
|
||||
`GET /`, and a group with `RequireOwner(getUser)`, `TrustedProxyRealIP(nil)`,
|
||||
`NewRateLimiter(2, time.Minute)`, `POST /invite`. A second router mirrors `NewAuthRouter` for
|
||||
`/register`, `/invite/accept` and `/me`.
|
||||
4. Seed: owner id 1 "marta" (role owner), admin id 2 "jonas" (role admin), both verified.
|
||||
Cookies carry `user_id` and `token_version`, and never a role.
|
||||
|
||||
## Roles at creation
|
||||
5. Empty repo, `POST /v1/auth/register` -> 200, the stored user has `Role == user.RoleOwner`.
|
||||
6. Pending invite with a known code (stubHasher), `POST /v1/auth/invite/accept` -> 200, the stored
|
||||
user has `Role == user.RoleAdmin`, and user 1 is still owner.
|
||||
7. Use-case level: `CreateUser` returns a user with `RoleOwner`; `AcceptInvite` returns `RoleAdmin`.
|
||||
|
||||
## GET /v1/auth/me
|
||||
8. Table over owner and admin: 200, decode into `map[string]json.RawMessage`, key `role` equals the stored role.
|
||||
9. Unverified owner -> 200, `role` "owner", `is_verified` false.
|
||||
10. The key set of the public user is exactly the eight fields, and `POST /v1/auth/verify` returns the same eight.
|
||||
|
||||
## GET /v1/user/
|
||||
11. Seed three users plus a pending invite. 200; user items have exactly the keys id, kind, username,
|
||||
display_name, email, role, status, created_at; email and role match the seed. The invite item has
|
||||
exactly id, kind, email, status, created_at. No forbidden key appears anywhere.
|
||||
12. Cookie of admin 2 -> 200.
|
||||
|
||||
## POST /v1/user/invite
|
||||
13. Owner cookie, valid body -> 201, one invite, one "invitation" mail.
|
||||
14. Admin cookie, valid body -> 403, body exactly `apperr.OwnerRequiredErrorMessage`, `Content-Type`
|
||||
is not JSON-decoded (plain text), no invite, no mail.
|
||||
15. Admin cookie with `{"email":"not-an-email"}` and with `not json` -> 403 both.
|
||||
16. Rate limit 2: admin sends 3 requests -> 403, 403, 403.
|
||||
17. Flip user 2's stored role from owner to admin between two requests with the same cookie ->
|
||||
second request 403.
|
||||
18. Existing auth table from invitations.qa.md (no cookie 401, unverified 403, stale token 401) is
|
||||
unchanged; the unverified 403 body remains `apperr.VerificationRequiredErrorMessage`.
|
||||
|
||||
## Use case level
|
||||
19. `RequireOwner` with a user id that no longer exists -> 401 `apperr.UnauthorizedErrorMessage`;
|
||||
with an unexpected repository error -> 500.
|
||||
|
||||
## Manual verification (not exercised by the Go tests)
|
||||
- Persistence backfill: copy a SQLite file from before this change with 3 users and no role column.
|
||||
Start the app: the column exists, the lowest id is `owner`, the others `admin`. Restart: nothing
|
||||
changes. Start against a fresh file: registering creates an owner.
|
||||
- Layering check by review: `Role` and the sentinel/constants live in `internal/domain/user`; GORM
|
||||
tags only in `persistence/models`, converted by the mapper.
|
||||
@@ -12,9 +12,10 @@ Feature: Users screen and InviteUserModal
|
||||
And UsersPage calls GET /v1/user/ once per navigation, and InviteUserModal sends POST
|
||||
/v1/user/invite; both endpoints already exist (features/invitations.feature) and this slice
|
||||
wires the SPA to them unchanged, with no Go change
|
||||
And the list items keep the API shape: kind "user" (id, username, display_name, status
|
||||
"accepted", created_at) or kind "invite" (id, email, status "pending" or "expired",
|
||||
created_at); the list is used in the order the API returns it (created date, newest first),
|
||||
And the list items keep the API shape: user items are kind "user" (id, username, display_name,
|
||||
email, role, status "accepted", created_at) or kind "invite" (id, email, status "pending" or
|
||||
"expired", created_at); SPA display of role, owner-only actions and the redesigned columns is
|
||||
a later SPA slice; this amendment covers the API shape only; the list is used in the order the API returns it (created date, newest first),
|
||||
and rows are keyed by kind and id, because a user and an invite can share a numeric id
|
||||
And DashboardShell's navigation gains "Users" (icon users, linking to /users) between
|
||||
"Articles" and "Integration", in the sidebar and in the mobile drawer, so it lists Home,
|
||||
@@ -36,7 +37,7 @@ Feature: Users screen and InviteUserModal
|
||||
And a user row's name cell shows the display name first (medium weight, highlighted) with the
|
||||
username beside it in muted text when display_name is not empty, otherwise the username
|
||||
alone (medium weight, highlighted); an invite row shows the email address in regular weight,
|
||||
toned; an accepted user is never shown by email
|
||||
toned
|
||||
And the row whose kind is "user" and whose id equals the session user's id (from GET
|
||||
/v1/auth/me) carries a muted mono "you" tag after the name
|
||||
And InviteStatusBadge shows icon and word, never color alone: Accepted (circle-check, success
|
||||
@@ -101,7 +102,6 @@ Feature: Users screen and InviteUserModal
|
||||
And the third row shows "Inès Duarte" first with "ines" beside it in muted text, an Accepted
|
||||
badge and "18 Jun 2026"
|
||||
And the fifth row shows "marta" with the "you" tag, an Accepted badge and "12 Mar 2026"
|
||||
And no row shows an email address for an accepted user
|
||||
And below md the same rows render as cards, each with the name line, then the badge and
|
||||
"Created {date}"
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Implementation note for coder (informational, not a QA assertion)
|
||||
|
||||
The invitations API is already built and covered by features/invitations.qa.md: this slice has no
|
||||
Go change, so it has no HTTP-level Go section.
|
||||
Go change in this slice; the API shape was extended by user_roles.feature, so it has no HTTP-level Go section.
|
||||
|
||||
The shell specs were amended to five destinations (Home, Articles, Users, Integration, Settings):
|
||||
nuxt_ui_shell_foundation.feature / .qa.md (case 14) and dashboard_shell_branding.feature. Their
|
||||
@@ -24,7 +24,7 @@ part). Run each layout case at a desktop and a below-md width (mock `matchMedia`
|
||||
|
||||
1. **Default** — assert breadcrumb "Users", title, subtitle, "Invite user", rows in fixture order,
|
||||
"Inès Duarte" with "ines" beside it, the "you" tag only on the "marta" row, badge words and
|
||||
the fixture's absolute dates; assert no accepted row shows an email.
|
||||
the fixture's absolute dates.
|
||||
2. **Navigation** — assert the sidebar and the drawer list Home, Articles, Users, Integration,
|
||||
Settings in that order, and "Users" carries aria-current="page" on /users.
|
||||
3. **Loading** — unresolved `GET`; with fake timers assert nothing is drawn at 149 ms; at 150 ms
|
||||
|
||||
Reference in new issue
Block a user