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

+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)
}
})
}