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
@@ -74,6 +74,10 @@ func getDb(dbName string) *gorm.DB {
|
||||
panic("Failed to migrate database: " + err.Error())
|
||||
}
|
||||
|
||||
if err := persistence.BackfillUserRoles(db); err != nil {
|
||||
panic("Failed to backfill user roles: " + err.Error())
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ type userListItem struct {
|
||||
Kind string `json:"kind"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -123,7 +125,7 @@ func (h *InviteHandler) ListUsers(w http.ResponseWriter, _ *http.Request) {
|
||||
items = append(items, inviteListItem{entry.ID, entry.Kind, entry.Email, entry.Status, entry.CreatedAt})
|
||||
continue
|
||||
}
|
||||
items = append(items, userListItem{entry.ID, entry.Kind, entry.Username, entry.DisplayName, entry.Status, entry.CreatedAt})
|
||||
items = append(items, userListItem{entry.ID, entry.Kind, entry.Username, entry.DisplayName, entry.Email, entry.Role, entry.Status, entry.CreatedAt})
|
||||
}
|
||||
|
||||
body, err := json.Marshal(items)
|
||||
|
||||
@@ -77,3 +77,21 @@ func RequireVerifiedUser(getUser *useCases.GetUserUseCase) func(http.Handler) ht
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func RequireOwner(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
currentUser, ok := resolveCurrentUser(getUser, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if currentUser.Role != user.RoleOwner {
|
||||
http.Error(w, apperr.OwnerRequiredErrorMessage, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -21,4 +21,5 @@ const PendingInviteErrorMessage = "There's already a pending invite for this add
|
||||
const InviteNotSentErrorMessage = "Couldn't send the invite. Try again in a moment."
|
||||
const InviteInvalidErrorMessage = "This invite link doesn't work."
|
||||
const InviteExpiredErrorMessage = "This invite has expired."
|
||||
const OwnerRequiredErrorMessage = "Only the owner can do this."
|
||||
const UsernameTakenErrorMessage = "That username is taken. Try another."
|
||||
@@ -13,6 +13,7 @@ type PublicUser struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Role user.Role `json:"role"`
|
||||
}
|
||||
|
||||
func New(u user.User) PublicUser {
|
||||
@@ -24,5 +25,6 @@ func New(u user.User) PublicUser {
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
DisplayName: u.DisplayName,
|
||||
Role: u.Role,
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,7 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
|
||||
r.Patch("/me", userHandler.UpdateMe)
|
||||
r.Get("/", inviteHandler.ListUsers)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireOwner(container.GetUserUseCase))
|
||||
r.Use(middleware.TrustedProxyRealIP(trustedProxies))
|
||||
r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window))
|
||||
r.Post("/invite", inviteHandler.CreateInvite)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,6 +16,13 @@ var ErrPasswordResetCodeExpired = errors.New("password reset code expired")
|
||||
var ErrInvalidDisplayName = errors.New("invalid display name")
|
||||
var ErrUsernameTaken = errors.New("username taken")
|
||||
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleOwner Role = "owner"
|
||||
RoleAdmin Role = "admin"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uint32 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
@@ -24,6 +31,7 @@ type User struct {
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
IsVerified bool `json:"is_verified"`
|
||||
Role Role `json:"role"`
|
||||
VerificationCode string `json:"verification_code"`
|
||||
VerificationExpiration time.Time `json:"verification_expiration"`
|
||||
PasswordResetExpiration time.Time `json:"password_reset_expiration"`
|
||||
|
||||
@@ -28,6 +28,7 @@ func UserToDomain(m models.User) user.User {
|
||||
Email: m.Email,
|
||||
DisplayName: m.DisplayName,
|
||||
IsVerified: m.IsVerified,
|
||||
Role: user.Role(m.Role),
|
||||
VerificationCode: nullableStringToDomain(m.VerificationCode),
|
||||
VerificationExpiration: m.VerificationExpiration,
|
||||
PasswordResetExpiration: m.PasswordResetExpiration,
|
||||
@@ -45,6 +46,7 @@ func UserToModel(a user.User) models.User {
|
||||
Email: a.Email,
|
||||
DisplayName: a.DisplayName,
|
||||
IsVerified: a.IsVerified,
|
||||
Role: string(a.Role),
|
||||
VerificationCode: DomainToNullableString(a.VerificationCode),
|
||||
VerificationExpiration: a.VerificationExpiration,
|
||||
PasswordResetExpiration: a.PasswordResetExpiration,
|
||||
|
||||
@@ -12,6 +12,7 @@ type User struct {
|
||||
Email string `gorm:"unique;not null"`
|
||||
DisplayName string `gorm:"not null;default:''"`
|
||||
IsVerified bool `gorm:"default=false;not null"`
|
||||
Role string `gorm:"not null;default:''"`
|
||||
VerificationCode *string `gorm:"unique"`
|
||||
VerificationExpiration time.Time
|
||||
PasswordResetExpiration time.Time
|
||||
|
||||
@@ -34,6 +34,7 @@ func (u *UserRepository) Create(user domainUser.User) (domainUser.User, error) {
|
||||
PasswordResetCode: mappers.DomainToNullableString(user.PasswordResetCode),
|
||||
Email: user.Email,
|
||||
IsVerified: user.IsVerified,
|
||||
Role: string(user.Role),
|
||||
VerificationCode: mappers.DomainToNullableString(user.VerificationCode),
|
||||
VerificationExpiration: user.VerificationExpiration,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
domainUser "RenewCMS/internal/domain/user"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func BackfillUserRoles(db *gorm.DB) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
var firstUser entity.User
|
||||
result := tx.Where("role = ''").Order("id asc").Limit(1).Find(&firstUser)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var lowest entity.User
|
||||
if err := tx.Order("id asc").First(&lowest).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if lowest.Role == "" {
|
||||
if err := tx.Model(&entity.User{}).Where("id = ?", lowest.ID).Update("role", string(domainUser.RoleOwner)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Model(&entity.User{}).Where("role = ''").Update("role", string(domainUser.RoleAdmin)).Error
|
||||
})
|
||||
}
|
||||
@@ -54,6 +54,7 @@ func (a *AcceptInviteUseCase) AcceptInvite(command AcceptInviteCommand) (user.Us
|
||||
Password: hashedPassword,
|
||||
Email: pending.Email,
|
||||
IsVerified: true,
|
||||
Role: user.RoleAdmin,
|
||||
})
|
||||
if errors.Is(err, invite.ErrInviteNotFound) {
|
||||
return user.User{}, invite.ErrInviteInvalid
|
||||
|
||||
@@ -56,6 +56,7 @@ func (g *CreateUserUseCase) CreateUser(createUser CreateUserCommand) (user.User,
|
||||
PasswordResetCode: "",
|
||||
Email: createUser.Email,
|
||||
IsVerified: false,
|
||||
Role: user.RoleOwner,
|
||||
VerificationCode: hashedVerificationCode,
|
||||
VerificationExpiration: time.Now().Add(2 * time.Hour),
|
||||
})
|
||||
|
||||
@@ -23,6 +23,7 @@ type DirectoryEntry struct {
|
||||
Username string
|
||||
DisplayName string
|
||||
Email string
|
||||
Role string
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -41,6 +42,8 @@ func (g *ListUsersAndInvitesUseCase) ListUsersAndInvites() []DirectoryEntry {
|
||||
Kind: EntryKindUser,
|
||||
Username: existingUser.Username,
|
||||
DisplayName: existingUser.DisplayName,
|
||||
Email: existingUser.Email,
|
||||
Role: string(existingUser.Role),
|
||||
Status: string(invite.StatusAccepted),
|
||||
CreatedAt: existingUser.CreatedAt,
|
||||
})
|
||||
|
||||
@@ -99,13 +99,13 @@ func TestInvitationsQaList(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The list never carries secrets or user emails", func(t *testing.T) {
|
||||
t.Run("The list never carries secrets", func(t *testing.T) {
|
||||
f := newInviteFixture(t, 100)
|
||||
f.invites.seed(pendingInvite("sam@renew.example"))
|
||||
|
||||
body := f.list(t).Body.String()
|
||||
|
||||
for _, forbidden := range []string{"\"code\"", "hashed_code", "verification_code", "password", "marta@renew.example"} {
|
||||
for _, forbidden := range []string{"\"code\"", "hashed_code", "verification_code", "password"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Errorf("list leaks %q", forbidden)
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ func expiredInvite(email string) invite.Invite {
|
||||
|
||||
func inviteInviterRepo(displayName string) *mockUserRepo {
|
||||
repo := newMockUserRepo()
|
||||
repo.users[7] = user.User{ID: 7, Username: "marta", DisplayName: displayName, Email: "marta@renew.example", IsVerified: true}
|
||||
repo.users[7] = user.User{ID: 7, Username: "marta", DisplayName: displayName, Email: "marta@renew.example", IsVerified: true, Role: user.RoleOwner}
|
||||
repo.nextID = 7
|
||||
return repo
|
||||
}
|
||||
@@ -478,14 +478,14 @@ func TestInvitationsHttp(t *testing.T) {
|
||||
if want.kind == "invite" {
|
||||
assertExactKeys(t, item, "id", "kind", "email", "status", "created_at")
|
||||
} else {
|
||||
assertExactKeys(t, item, "id", "kind", "username", "display_name", "status", "created_at")
|
||||
assertExactKeys(t, item, "id", "kind", "username", "display_name", "email", "role", "status", "created_at")
|
||||
}
|
||||
}
|
||||
if decodedString(t, items[2], "display_name") != "Ines Duarte" {
|
||||
t.Errorf("expected display name for ines")
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "jonas@renew.example") || strings.Contains(rec.Body.String(), "marta@renew.example") {
|
||||
t.Errorf("user emails and accepted invites must not be listed")
|
||||
if decodedString(t, items[3], "email") != "jonas@renew.example" || decodedString(t, items[4], "email") != "marta@renew.example" {
|
||||
t.Errorf("expected user emails in the list")
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
var displayNameResponseKeys = []string{"id", "username", "email", "is_verified", "created_at", "updated_at", "display_name"}
|
||||
var displayNameResponseKeys = []string{"id", "username", "email", "is_verified", "created_at", "updated_at", "display_name", "role"}
|
||||
|
||||
func newDisplayNameRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
|
||||
hasher := &stubHasher{}
|
||||
|
||||
@@ -125,7 +125,7 @@ func TestUserResponseFieldHygiene(t *testing.T) {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
expectedKeys := []string{"id", "username", "email", "is_verified", "created_at", "updated_at", "display_name"}
|
||||
expectedKeys := []string{"id", "username", "email", "is_verified", "created_at", "updated_at", "display_name", "role"}
|
||||
if len(decoded) != len(expectedKeys) {
|
||||
t.Errorf("expected exactly %d keys, got %d: %v", len(expectedKeys), len(decoded), decoded)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/handlers/auth"
|
||||
userhandler "RenewCMS/api/handlers/user"
|
||||
"RenewCMS/api/middleware"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/user"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
rolesInviteBody = `{"email": "sam.oliveira@renew.example"}`
|
||||
rolesClientIP = "203.0.113.20"
|
||||
)
|
||||
|
||||
type userRolesFixture struct {
|
||||
users *mockUserRepo
|
||||
invites *inviteFakeRepo
|
||||
mail *mockMailRepo
|
||||
router *chi.Mux
|
||||
token *jwtauth.JWTAuth
|
||||
}
|
||||
|
||||
func newUserRolesFixture(t *testing.T, maxAttempts int) *userRolesFixture {
|
||||
t.Helper()
|
||||
validator.InitValidator()
|
||||
f := &userRolesFixture{
|
||||
users: newMockUserRepo(),
|
||||
invites: newInviteFakeRepo(),
|
||||
mail: newMockMailRepo(),
|
||||
token: jwtauth.New("HS256", []byte("test-secret"), nil),
|
||||
}
|
||||
f.invites.users = f.users
|
||||
hasher := &stubHasher{}
|
||||
getUser := useCases.NewGetUserUseCase(f.users)
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
f.token,
|
||||
useCases.NewCreateUserUseCase(f.users, hasher, f.mail, invitePublicURL),
|
||||
useCases.NewAuthenticateUserUseCase(f.users, hasher),
|
||||
true,
|
||||
)
|
||||
acceptHandler := auth.NewAcceptInviteHandler(
|
||||
useCases.NewGetInviteUseCase(f.invites, hasher),
|
||||
useCases.NewAcceptInviteUseCase(f.users, f.invites, hasher),
|
||||
f.token,
|
||||
true,
|
||||
)
|
||||
meHandler := auth.NewMeHandler(getUser)
|
||||
inviteHandler := userhandler.NewInviteHandler(
|
||||
useCases.NewCreateInviteUseCase(f.users, f.invites, hasher, f.mail, invitePublicURL),
|
||||
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites),
|
||||
)
|
||||
|
||||
f.router = chi.NewRouter()
|
||||
f.router.Route("/v1/auth", func(r chi.Router) {
|
||||
r.Post("/register", loginHandler.Register)
|
||||
r.Post("/invite/accept", acceptHandler.AcceptInvite)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(f.token))
|
||||
r.Use(jwtauth.Authenticator(f.token))
|
||||
r.Use(middleware.RequireCurrentToken(getUser))
|
||||
r.Get("/me", meHandler.Me)
|
||||
})
|
||||
})
|
||||
f.router.Route("/v1/user", func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(f.token))
|
||||
r.Use(jwtauth.Authenticator(f.token))
|
||||
r.Use(middleware.RequireCurrentToken(getUser))
|
||||
r.Use(middleware.RequireVerifiedUser(getUser))
|
||||
r.Get("/", inviteHandler.ListUsers)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireOwner(getUser))
|
||||
r.Use(middleware.TrustedProxyRealIP(nil))
|
||||
r.Use(middleware.NewRateLimiter(maxAttempts, time.Hour))
|
||||
r.Post("/invite", inviteHandler.CreateInvite)
|
||||
})
|
||||
})
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *userRolesFixture) seedUser(id uint32, username string, role user.Role, verified bool) {
|
||||
f.users.users[id] = user.User{
|
||||
ID: id,
|
||||
Username: username,
|
||||
Email: username + "@renew.example",
|
||||
IsVerified: verified,
|
||||
Role: role,
|
||||
}
|
||||
if id > f.users.nextID {
|
||||
f.users.nextID = id
|
||||
}
|
||||
}
|
||||
|
||||
func (f *userRolesFixture) do(t *testing.T, method, target, body string, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, target, bytes.NewBufferString(body))
|
||||
req.RemoteAddr = rolesClientIP + ":4321"
|
||||
if cookie != nil {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
f.router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func (f *userRolesFixture) as(t *testing.T, userID uint32) *http.Cookie {
|
||||
t.Helper()
|
||||
return displayNameCookie(t, f.token, userID, 0)
|
||||
}
|
||||
|
||||
func assertNothingInvited(t *testing.T, f *userRolesFixture) {
|
||||
t.Helper()
|
||||
if len(f.invites.invites) != 0 {
|
||||
t.Errorf("expected no invite, got %d", len(f.invites.invites))
|
||||
}
|
||||
if len(f.mail.SentEmails) != 0 {
|
||||
t.Errorf("expected no email, got %d", len(f.mail.SentEmails))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRolesHttp(t *testing.T) {
|
||||
t.Run("The first registered user is the owner", func(t *testing.T) {
|
||||
f := newUserRolesFixture(t, 100)
|
||||
|
||||
rec := f.do(t, http.MethodPost, "/v1/auth/register", `{"username": "marta", "password": "hunter2example12", "email": "marta@renew.example"}`, nil)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(f.users.users) != 1 {
|
||||
t.Fatalf("expected one user, got %d", len(f.users.users))
|
||||
}
|
||||
for _, stored := range f.users.users {
|
||||
if stored.Username != "marta" || stored.Role != user.RoleOwner {
|
||||
t.Errorf("unexpected user %+v", stored)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A user created by accepting an invite is an admin", func(t *testing.T) {
|
||||
f := newUserRolesFixture(t, 100)
|
||||
f.seedUser(1, "marta", user.RoleOwner, true)
|
||||
pending := pendingInvite(acceptEmail)
|
||||
pending.HashedCode = "hashed:" + acceptCode
|
||||
f.invites.seed(pending)
|
||||
|
||||
rec := f.do(t, http.MethodPost, "/v1/auth/invite/accept", acceptBody(acceptCode, "lea", acceptPassword), nil)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
roles := map[string]user.Role{}
|
||||
for _, stored := range f.users.users {
|
||||
roles[stored.Username] = stored.Role
|
||||
}
|
||||
if roles["lea"] != user.RoleAdmin {
|
||||
t.Errorf("expected lea admin, got %q", roles["lea"])
|
||||
}
|
||||
if roles["marta"] != user.RoleOwner {
|
||||
t.Errorf("expected marta still owner, got %q", roles["marta"])
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
username string
|
||||
role user.Role
|
||||
}{{"marta", user.RoleOwner}, {"lea", user.RoleAdmin}} {
|
||||
t.Run("GET /v1/auth/me returns the role of the session user: "+tc.username, func(t *testing.T) {
|
||||
f := newUserRolesFixture(t, 100)
|
||||
f.seedUser(4, tc.username, tc.role, true)
|
||||
|
||||
rec := f.do(t, http.MethodGet, "/v1/auth/me", "", f.as(t, 4))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
if got := decodedString(t, decodeKeys(t, rec.Body.Bytes()), "role"); got != string(tc.role) {
|
||||
t.Errorf("expected role %q, got %q", tc.role, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("An unverified owner sees their role on /auth/me", func(t *testing.T) {
|
||||
f := newUserRolesFixture(t, 100)
|
||||
f.seedUser(5, "bob", user.RoleOwner, false)
|
||||
|
||||
rec := f.do(t, http.MethodGet, "/v1/auth/me", "", f.as(t, 5))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
decoded := decodeKeys(t, rec.Body.Bytes())
|
||||
if decodedString(t, decoded, "role") != "owner" || string(decoded["is_verified"]) != "false" {
|
||||
t.Errorf("unexpected body %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The users list carries email and role on accepted users", func(t *testing.T) {
|
||||
f := newUserRolesFixture(t, 100)
|
||||
day := func(d int) time.Time { return time.Date(2026, 3, d, 9, 0, 0, 0, time.UTC) }
|
||||
f.users.users[1] = user.User{ID: 1, Username: "marta", Email: "marta@renew.example", IsVerified: true, Role: user.RoleOwner, CreatedAt: day(1)}
|
||||
f.users.users[2] = user.User{ID: 2, Username: "jonas", Email: "jonas@renew.example", IsVerified: true, Role: user.RoleAdmin, CreatedAt: day(2)}
|
||||
f.users.users[3] = user.User{ID: 3, Username: "ines", DisplayName: "Inès Duarte", Email: "ines.duarte@renew.example", IsVerified: true, Role: user.RoleAdmin, CreatedAt: day(3)}
|
||||
f.invites.seed(pendingInvite("sam.oliveira@renew.example"))
|
||||
|
||||
rec := f.do(t, http.MethodGet, "/v1/user/", "", f.as(t, 2))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
items := decodeList(t, rec.Body.Bytes())
|
||||
if len(items) != 4 {
|
||||
t.Fatalf("expected 4 items, got %d", len(items))
|
||||
}
|
||||
wantRoles := map[string][2]string{
|
||||
"marta": {"marta@renew.example", "owner"},
|
||||
"jonas": {"jonas@renew.example", "admin"},
|
||||
"ines": {"ines.duarte@renew.example", "admin"},
|
||||
}
|
||||
for _, item := range items {
|
||||
if decodedString(t, item, "kind") == "invite" {
|
||||
assertExactKeys(t, item, "id", "kind", "email", "status", "created_at")
|
||||
continue
|
||||
}
|
||||
assertExactKeys(t, item, "id", "kind", "username", "display_name", "email", "role", "status", "created_at")
|
||||
want := wantRoles[decodedString(t, item, "username")]
|
||||
if decodedString(t, item, "email") != want[0] || decodedString(t, item, "role") != want[1] {
|
||||
t.Errorf("unexpected user item %v", item)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"password", "password_reset_code", "verification_code", "verification_expiration", "password_reset_expiration", "token_version", "code", "hashed_code", "invited_by", "accepted_at"} {
|
||||
for _, item := range items {
|
||||
if _, present := item[forbidden]; present {
|
||||
t.Errorf("item carries forbidden key %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The owner can invite", func(t *testing.T) {
|
||||
f := newUserRolesFixture(t, 100)
|
||||
f.seedUser(1, "marta", user.RoleOwner, true)
|
||||
|
||||
rec := f.do(t, http.MethodPost, "/v1/user/invite", rolesInviteBody, f.as(t, 1))
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(f.invites.invites) != 1 || len(f.mail.SentEmails) != 1 || f.mail.SentEmails[0].Template != "invitation" {
|
||||
t.Errorf("expected one invite and one invitation email, got %d and %+v", len(f.invites.invites), f.mail.SentEmails)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An admin cannot invite", func(t *testing.T) {
|
||||
f := newUserRolesFixture(t, 100)
|
||||
f.seedUser(2, "jonas", user.RoleAdmin, true)
|
||||
|
||||
rec := f.do(t, http.MethodPost, "/v1/user/invite", rolesInviteBody, f.as(t, 2))
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", rec.Code)
|
||||
}
|
||||
if rec.Body.String() != apperr.OwnerRequiredErrorMessage+"\n" {
|
||||
t.Errorf("unexpected body %q", rec.Body.String())
|
||||
}
|
||||
assertNothingInvited(t, f)
|
||||
})
|
||||
|
||||
for _, body := range []string{`{"email": "not-an-email"}`, `not json`} {
|
||||
t.Run("The owner check comes before validation and the rate limiter: "+body, func(t *testing.T) {
|
||||
f := newUserRolesFixture(t, 100)
|
||||
f.seedUser(2, "jonas", user.RoleAdmin, true)
|
||||
|
||||
rec := f.do(t, http.MethodPost, "/v1/user/invite", body, f.as(t, 2))
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", rec.Code)
|
||||
}
|
||||
assertNothingInvited(t, f)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("An admin's refused invites do not use up the rate limit", func(t *testing.T) {
|
||||
f := newUserRolesFixture(t, 2)
|
||||
f.seedUser(2, "jonas", user.RoleAdmin, true)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
rec := f.do(t, http.MethodPost, "/v1/user/invite", rolesInviteBody, f.as(t, 2))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("request %d: expected 403, got %d", i, rec.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The role is read on every request, not from the token", func(t *testing.T) {
|
||||
f := newUserRolesFixture(t, 100)
|
||||
f.seedUser(2, "jonas", user.RoleOwner, true)
|
||||
cookie := f.as(t, 2)
|
||||
stored := f.users.users[2]
|
||||
stored.Role = user.RoleAdmin
|
||||
f.users.users[2] = stored
|
||||
|
||||
rec := f.do(t, http.MethodPost, "/v1/user/invite", rolesInviteBody, cookie)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An admin can still list users", func(t *testing.T) {
|
||||
f := newUserRolesFixture(t, 100)
|
||||
f.seedUser(2, "jonas", user.RoleAdmin, true)
|
||||
|
||||
rec := f.do(t, http.MethodGet, "/v1/user/", "", f.as(t, 2))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A jwt for a since-deleted account is refused with 401 on invite", func(t *testing.T) {
|
||||
f := newUserRolesFixture(t, 100)
|
||||
f.seedUser(1, "marta", user.RoleOwner, true)
|
||||
cookie := f.as(t, 1)
|
||||
delete(f.users.users, 1)
|
||||
|
||||
rec := f.do(t, http.MethodPost, "/v1/user/invite", rolesInviteBody, cookie)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserRolesUseCases(t *testing.T) {
|
||||
t.Run("CreateUser returns an owner", func(t *testing.T) {
|
||||
users := newMockUserRepo()
|
||||
uc := useCases.NewCreateUserUseCase(users, &stubHasher{}, newMockMailRepo(), invitePublicURL)
|
||||
|
||||
created, err := uc.CreateUser(useCases.CreateUserCommand{Username: "marta", Password: "hunter2example12", Email: "marta@renew.example"})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if created.Role != user.RoleOwner {
|
||||
t.Errorf("expected owner, got %q", created.Role)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("AcceptInvite returns an admin", func(t *testing.T) {
|
||||
users := newMockUserRepo()
|
||||
invites := newInviteFakeRepo()
|
||||
invites.users = users
|
||||
pending := pendingInvite(acceptEmail)
|
||||
pending.HashedCode = "hashed:" + acceptCode
|
||||
invites.seed(pending)
|
||||
uc := useCases.NewAcceptInviteUseCase(users, invites, &stubHasher{})
|
||||
|
||||
created, err := uc.AcceptInvite(useCases.AcceptInviteCommand{Code: acceptCode, Username: "lea", Password: acceptPassword})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if created.Role != user.RoleAdmin {
|
||||
t.Errorf("expected admin, got %q", created.Role)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRequireOwnerRepositoryFailure(t *testing.T) {
|
||||
t.Run("An unexpected repository error is a 500, not a 403", func(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
repo := newMockUserRepo()
|
||||
repo.users[1] = user.User{ID: 1, Username: "marta", IsVerified: true, Role: user.RoleOwner}
|
||||
r := chi.NewRouter()
|
||||
r.Use(jwtauth.Verifier(token))
|
||||
r.Use(jwtauth.Authenticator(token))
|
||||
r.Use(middleware.RequireOwner(useCases.NewGetUserUseCase(repo)))
|
||||
r.Post("/", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
||||
repo.fail.failNext("Get")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
req.AddCookie(displayNameCookie(t, token, 1, 0))
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBackfillUserRoles(t *testing.T) {
|
||||
openDB := func(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:roles?mode=memory&cache=private"), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&entity.User{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
return db
|
||||
}
|
||||
rolesByID := func(t *testing.T, db *gorm.DB) map[uint32]string {
|
||||
t.Helper()
|
||||
var rows []entity.User
|
||||
if err := db.Find(&rows).Error; err != nil {
|
||||
t.Fatalf("find: %v", err)
|
||||
}
|
||||
roles := map[uint32]string{}
|
||||
for _, row := range rows {
|
||||
roles[row.ID] = row.Role
|
||||
}
|
||||
return roles
|
||||
}
|
||||
seed := func(t *testing.T, db *gorm.DB, id uint32, role string) {
|
||||
t.Helper()
|
||||
suffix := string(rune('a' + id))
|
||||
row := entity.User{ID: id, Username: "u" + suffix, Password: "x", Email: "e" + suffix, Role: role}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("The lowest id becomes owner and every other user admin", func(t *testing.T) {
|
||||
db := openDB(t)
|
||||
seed(t, db, 3, "")
|
||||
seed(t, db, 5, "")
|
||||
seed(t, db, 9, "")
|
||||
|
||||
if err := persistence.BackfillUserRoles(db); err != nil {
|
||||
t.Fatalf("backfill: %v", err)
|
||||
}
|
||||
|
||||
roles := rolesByID(t, db)
|
||||
if roles[3] != "owner" || roles[5] != "admin" || roles[9] != "admin" {
|
||||
t.Errorf("unexpected roles %v", roles)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A later start changes nothing", func(t *testing.T) {
|
||||
db := openDB(t)
|
||||
seed(t, db, 2, "admin")
|
||||
seed(t, db, 4, "owner")
|
||||
|
||||
if err := persistence.BackfillUserRoles(db); err != nil {
|
||||
t.Fatalf("backfill: %v", err)
|
||||
}
|
||||
|
||||
roles := rolesByID(t, db)
|
||||
if roles[2] != "admin" || roles[4] != "owner" {
|
||||
t.Errorf("unexpected roles %v", roles)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An empty database is left alone", func(t *testing.T) {
|
||||
db := openDB(t)
|
||||
|
||||
if err := persistence.BackfillUserRoles(db); err != nil {
|
||||
t.Fatalf("backfill: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user