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" "RenewCMS/internal/infrastructure/persistence/database" entity "RenewCMS/internal/infrastructure/persistence/models" "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, discardRecorder), 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), nil, nil, 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 := database.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 := database.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 := database.BackfillUserRoles(db); err != nil { t.Fatalf("backfill: %v", err) } }) }