feat: add Role (owner|admin) to the user domain, persistence model and mapper

This commit is contained in:
fsylvain committed 2026-09-30 00:58:58 +02:00
1 parent 162fea4afb
commit 461e93e3b5
30 files changed
+791 -48

No files matched your search

+4
View File
@@ -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
}
+3 -1
View File
@@ -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)
+18
View File
@@ -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)
})
}
}
+1
View File
@@ -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."
+2
View File
@@ -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,
}
}
+1
View File
@@ -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)
+2 -1
View File
@@ -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
+6 -6
View File
@@ -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
+6 -6
View File
@@ -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).
+13 -7
View File
@@ -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
+11 -7
View File
@@ -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.
+3 -3
View File
@@ -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
+3 -2
View File
@@ -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`,
+114
View File
@@ -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
+55
View File
@@ -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.
+5 -5
View File
@@ -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}"
+2 -2
View File
@@ -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
+8
View File
@@ -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,
})
+2 -2
View File
@@ -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)
}
+4 -4
View File
@@ -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")
}
})
+1 -1
View File
@@ -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{}
+1 -1
View File
@@ -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)
}
+484
View File
@@ -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)
}
})
}