From 461e93e3b5a2a5ae0e8e1f53d963158a3d862e22 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Wed, 30 Sep 2026 00:58:58 +0200 Subject: [PATCH] feat: add Role (owner|admin) to the user domain, persistence model and mapper --- api/dependecyInjection.go | 4 + api/handlers/user/inviteHandler.go | 4 +- api/middleware/auth.go | 18 + api/pkg/apperr/messages.go | 1 + api/pkg/publicuser/publicUser.go | 2 + api/router.go | 1 + features/accept_invitation.feature | 3 +- features/get_current_user.feature | 12 +- features/get_current_user.qa.md | 12 +- features/invitations.feature | 20 +- features/invitations.qa.md | 18 +- features/user_response_field_hygiene.feature | 6 +- features/user_response_field_hygiene.qa.md | 5 +- features/user_roles.feature | 114 +++++ features/user_roles.qa.md | 55 ++ features/users_screen.feature | 10 +- features/users_screen.qa.md | 4 +- internal/domain/user/userDomain.go | 8 + .../persistence/mappers/userMapper.go | 2 + .../persistence/models/userModel.go | 1 + .../repositories/userRepository.go | 1 + .../repositories/userRoleBackfill.go | 33 ++ .../infrastructure/useCases/AcceptInvite.go | 1 + .../infrastructure/useCases/CreateUser.go | 1 + .../useCases/ListUsersAndInvites.go | 3 + test/invitations_qa_test.go | 4 +- test/invitations_test.go | 8 +- test/user_display_name_test.go | 2 +- test/user_response_field_hygiene_test.go | 2 +- test/user_roles_test.go | 484 ++++++++++++++++++ 30 files changed, 791 insertions(+), 48 deletions(-) create mode 100644 features/user_roles.feature create mode 100644 features/user_roles.qa.md create mode 100644 internal/infrastructure/persistence/repositories/userRoleBackfill.go create mode 100644 test/user_roles_test.go diff --git a/api/dependecyInjection.go b/api/dependecyInjection.go index d265665..e5fd36a 100644 --- a/api/dependecyInjection.go +++ b/api/dependecyInjection.go @@ -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 } diff --git a/api/handlers/user/inviteHandler.go b/api/handlers/user/inviteHandler.go index f18be9b..2eb81b5 100644 --- a/api/handlers/user/inviteHandler.go +++ b/api/handlers/user/inviteHandler.go @@ -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) diff --git a/api/middleware/auth.go b/api/middleware/auth.go index 29d52b2..7bba14a 100644 --- a/api/middleware/auth.go +++ b/api/middleware/auth.go @@ -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) + }) + } +} diff --git a/api/pkg/apperr/messages.go b/api/pkg/apperr/messages.go index 72e3cbe..d503de0 100644 --- a/api/pkg/apperr/messages.go +++ b/api/pkg/apperr/messages.go @@ -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." diff --git a/api/pkg/publicuser/publicUser.go b/api/pkg/publicuser/publicUser.go index e7f4617..daa311e 100644 --- a/api/pkg/publicuser/publicUser.go +++ b/api/pkg/publicuser/publicUser.go @@ -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, } } diff --git a/api/router.go b/api/router.go index bfbf103..e4440e9 100644 --- a/api/router.go +++ b/api/router.go @@ -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) diff --git a/features/accept_invitation.feature b/features/accept_invitation.feature index 6632343..cc1d0b9 100644 --- a/features/accept_invitation.feature +++ b/features/accept_invitation.feature @@ -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 diff --git a/features/get_current_user.feature b/features/get_current_user.feature index 8b956a3..ed4ca92 100644 --- a/features/get_current_user.feature +++ b/features/get_current_user.feature @@ -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 diff --git a/features/get_current_user.qa.md b/features/get_current_user.qa.md index 6f8b5a3..da1d6d8 100644 --- a/features/get_current_user.qa.md +++ b/features/get_current_user.qa.md @@ -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). diff --git a/features/invitations.feature b/features/invitations.feature index a104b39..400d0e5 100644 --- a/features/invitations.feature +++ b/features/invitations.feature @@ -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 diff --git a/features/invitations.qa.md b/features/invitations.qa.md index 7542fea..04f6d0c 100644 --- a/features/invitations.qa.md +++ b/features/invitations.qa.md @@ -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. diff --git a/features/user_response_field_hygiene.feature b/features/user_response_field_hygiene.feature index 91fa5f8..aac5ab3 100644 --- a/features/user_response_field_hygiene.feature +++ b/features/user_response_field_hygiene.feature @@ -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 diff --git a/features/user_response_field_hygiene.qa.md b/features/user_response_field_hygiene.qa.md index 968e491..f6e1c81 100644 --- a/features/user_response_field_hygiene.qa.md +++ b/features/user_response_field_hygiene.qa.md @@ -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`, diff --git a/features/user_roles.feature b/features/user_roles.feature new file mode 100644 index 0000000..ecab29d --- /dev/null +++ b/features/user_roles.feature @@ -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 "" with 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 "" + + 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 + 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 diff --git a/features/user_roles.qa.md b/features/user_roles.qa.md new file mode 100644 index 0000000..e8c04a6 --- /dev/null +++ b/features/user_roles.qa.md @@ -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. diff --git a/features/users_screen.feature b/features/users_screen.feature index 05dc91e..dea9b98 100644 --- a/features/users_screen.feature +++ b/features/users_screen.feature @@ -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}" diff --git a/features/users_screen.qa.md b/features/users_screen.qa.md index 495ec42..7807f9d 100644 --- a/features/users_screen.qa.md +++ b/features/users_screen.qa.md @@ -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 diff --git a/internal/domain/user/userDomain.go b/internal/domain/user/userDomain.go index 98b525e..54730c5 100644 --- a/internal/domain/user/userDomain.go +++ b/internal/domain/user/userDomain.go @@ -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"` diff --git a/internal/infrastructure/persistence/mappers/userMapper.go b/internal/infrastructure/persistence/mappers/userMapper.go index 7723138..d27e288 100644 --- a/internal/infrastructure/persistence/mappers/userMapper.go +++ b/internal/infrastructure/persistence/mappers/userMapper.go @@ -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, diff --git a/internal/infrastructure/persistence/models/userModel.go b/internal/infrastructure/persistence/models/userModel.go index c411daf..03d133e 100644 --- a/internal/infrastructure/persistence/models/userModel.go +++ b/internal/infrastructure/persistence/models/userModel.go @@ -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 diff --git a/internal/infrastructure/persistence/repositories/userRepository.go b/internal/infrastructure/persistence/repositories/userRepository.go index 12024cd..63d7ed6 100644 --- a/internal/infrastructure/persistence/repositories/userRepository.go +++ b/internal/infrastructure/persistence/repositories/userRepository.go @@ -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, }) diff --git a/internal/infrastructure/persistence/repositories/userRoleBackfill.go b/internal/infrastructure/persistence/repositories/userRoleBackfill.go new file mode 100644 index 0000000..4e3c9e1 --- /dev/null +++ b/internal/infrastructure/persistence/repositories/userRoleBackfill.go @@ -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 + }) +} diff --git a/internal/infrastructure/useCases/AcceptInvite.go b/internal/infrastructure/useCases/AcceptInvite.go index 711ddbd..95ba8d4 100644 --- a/internal/infrastructure/useCases/AcceptInvite.go +++ b/internal/infrastructure/useCases/AcceptInvite.go @@ -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 diff --git a/internal/infrastructure/useCases/CreateUser.go b/internal/infrastructure/useCases/CreateUser.go index 3596723..28bb103 100644 --- a/internal/infrastructure/useCases/CreateUser.go +++ b/internal/infrastructure/useCases/CreateUser.go @@ -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), }) diff --git a/internal/infrastructure/useCases/ListUsersAndInvites.go b/internal/infrastructure/useCases/ListUsersAndInvites.go index 20c9816..6c606a0 100644 --- a/internal/infrastructure/useCases/ListUsersAndInvites.go +++ b/internal/infrastructure/useCases/ListUsersAndInvites.go @@ -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, }) diff --git a/test/invitations_qa_test.go b/test/invitations_qa_test.go index 31dcaf6..5968650 100644 --- a/test/invitations_qa_test.go +++ b/test/invitations_qa_test.go @@ -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) } diff --git a/test/invitations_test.go b/test/invitations_test.go index 99d4da5..8cf9a8b 100644 --- a/test/invitations_test.go +++ b/test/invitations_test.go @@ -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") } }) diff --git a/test/user_display_name_test.go b/test/user_display_name_test.go index 35f52b0..8829e9b 100644 --- a/test/user_display_name_test.go +++ b/test/user_display_name_test.go @@ -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{} diff --git a/test/user_response_field_hygiene_test.go b/test/user_response_field_hygiene_test.go index 7bb2c33..9ebf242 100644 --- a/test/user_response_field_hygiene_test.go +++ b/test/user_response_field_hygiene_test.go @@ -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) } diff --git a/test/user_roles_test.go b/test/user_roles_test.go new file mode 100644 index 0000000..7587d42 --- /dev/null +++ b/test/user_roles_test.go @@ -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) + } + }) +}