feat: Users API, ownership transfer and removal of the legacy role string
This commit is contained in:
1 parent
094df63a7f
commit
80f7037d3a
124 files changed
+4644
-824
No files matched your search
+61
-131
@@ -7,20 +7,16 @@ import (
|
||||
"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"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -50,22 +46,22 @@ func newUserRolesFixture(t *testing.T, maxAttempts int) *userRolesFixture {
|
||||
getUser := useCases.NewGetUserUseCase(f.users)
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
f.token,
|
||||
useCases.NewCreateUserUseCase(f.users, hasher, f.mail, invitePublicURL, discardRecorder),
|
||||
useCases.NewCreateUserUseCase(f.users, newSeededMockRoleRepo(), hasher, f.mail, invitePublicURL, discardRecorder),
|
||||
useCases.NewAuthenticateUserUseCase(f.users, hasher, discardRecorder),
|
||||
true,
|
||||
)
|
||||
acceptHandler := auth.NewAcceptInviteHandler(
|
||||
useCases.NewGetInviteUseCase(f.invites, hasher),
|
||||
useCases.NewAcceptInviteUseCase(f.users, f.invites, hasher, discardRecorder),
|
||||
useCases.NewAcceptInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), hasher, discardRecorder),
|
||||
f.token,
|
||||
true,
|
||||
)
|
||||
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()))
|
||||
inviteHandler := userhandler.NewInviteHandler(
|
||||
useCases.NewCreateInviteUseCase(f.users, f.invites, hasher, f.mail, invitePublicURL, discardRecorder),
|
||||
useCases.NewCreateInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), hasher, f.mail, invitePublicURL, discardRecorder),
|
||||
nil,
|
||||
nil,
|
||||
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites),
|
||||
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites, newSeededMockRoleRepo()),
|
||||
)
|
||||
|
||||
f.router = chi.NewRouter()
|
||||
@@ -95,13 +91,13 @@ func newUserRolesFixture(t *testing.T, maxAttempts int) *userRolesFixture {
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *userRolesFixture) seedUser(id uint32, username string, role user.Role, verified bool) {
|
||||
func (f *userRolesFixture) seedUser(id uint32, username string, roleID uint32, verified bool) {
|
||||
f.users.users[id] = user.User{
|
||||
ID: id,
|
||||
Username: username,
|
||||
Email: username + "@renew.example",
|
||||
IsVerified: verified,
|
||||
Role: role,
|
||||
RoleID: roleID,
|
||||
}
|
||||
if id > f.users.nextID {
|
||||
f.users.nextID = id
|
||||
@@ -109,10 +105,7 @@ func (f *userRolesFixture) seedUser(id uint32, username string, role user.Role,
|
||||
}
|
||||
|
||||
func (f *userRolesFixture) seedWriter(id uint32, username string) {
|
||||
f.seedUser(id, username, "", true)
|
||||
stored := f.users.users[id]
|
||||
stored.RoleID = writerRoleID
|
||||
f.users.users[id] = stored
|
||||
f.seedUser(id, username, writerRoleID, true)
|
||||
}
|
||||
|
||||
func (f *userRolesFixture) do(t *testing.T, method, target, body string, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||
@@ -155,17 +148,18 @@ func TestUserRolesHttp(t *testing.T) {
|
||||
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 {
|
||||
if stored.Username != "marta" || stored.RoleID != ownerRoleID {
|
||||
t.Errorf("unexpected user %+v", stored)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A user created by accepting an invite is an admin", func(t *testing.T) {
|
||||
t.Run("A user created by accepting an invite takes the role of the invite", func(t *testing.T) {
|
||||
f := newUserRolesFixture(t, 100)
|
||||
f.seedUser(1, "marta", user.RoleOwner, true)
|
||||
f.seedUser(1, "marta", ownerRoleID, true)
|
||||
pending := pendingInvite(acceptEmail)
|
||||
pending.HashedCode = "hashed:" + acceptCode
|
||||
pending.RoleID = adminRoleID
|
||||
f.invites.seed(pending)
|
||||
|
||||
rec := f.do(t, http.MethodPost, "/v1/auth/invite/accept", acceptBody(acceptCode, "lea", acceptPassword), nil)
|
||||
@@ -173,23 +167,24 @@ func TestUserRolesHttp(t *testing.T) {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
roles := map[string]user.Role{}
|
||||
roles := map[string]uint32{}
|
||||
for _, stored := range f.users.users {
|
||||
roles[stored.Username] = stored.Role
|
||||
roles[stored.Username] = stored.RoleID
|
||||
}
|
||||
if roles["lea"] != user.RoleAdmin {
|
||||
t.Errorf("expected lea admin, got %q", roles["lea"])
|
||||
if roles["lea"] != adminRoleID {
|
||||
t.Errorf("expected lea admin, got %d", roles["lea"])
|
||||
}
|
||||
if roles["marta"] != user.RoleOwner {
|
||||
t.Errorf("expected marta still owner, got %q", roles["marta"])
|
||||
if roles["marta"] != ownerRoleID {
|
||||
t.Errorf("expected marta still owner, got %d", 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) {
|
||||
role uint32
|
||||
name string
|
||||
}{{"marta", ownerRoleID, "Owner"}, {"lea", adminRoleID, "Admin"}} {
|
||||
t.Run("GET /v1/auth/me returns the role of the session user only in access.role: "+tc.username, func(t *testing.T) {
|
||||
f := newUserRolesFixture(t, 100)
|
||||
f.seedUser(4, tc.username, tc.role, true)
|
||||
|
||||
@@ -198,15 +193,24 @@ func TestUserRolesHttp(t *testing.T) {
|
||||
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)
|
||||
decoded := decodeKeys(t, rec.Body.Bytes())
|
||||
if _, present := decoded["role"]; present {
|
||||
t.Errorf("no top-level role expected: %s", rec.Body.String())
|
||||
}
|
||||
var access struct {
|
||||
Role struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"role"`
|
||||
}
|
||||
if err := json.Unmarshal(decoded["access"], &access); err != nil || access.Role.Name != tc.name {
|
||||
t.Errorf("expected access.role %q, got %q (%v)", tc.name, access.Role.Name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("An unverified owner sees their role on /auth/me", func(t *testing.T) {
|
||||
t.Run("An unverified owner can read /auth/me", func(t *testing.T) {
|
||||
f := newUserRolesFixture(t, 100)
|
||||
f.seedUser(5, "bob", user.RoleOwner, false)
|
||||
f.seedUser(5, "bob", ownerRoleID, false)
|
||||
|
||||
rec := f.do(t, http.MethodGet, "/v1/auth/me", "", f.as(t, 5))
|
||||
|
||||
@@ -214,7 +218,7 @@ func TestUserRolesHttp(t *testing.T) {
|
||||
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" {
|
||||
if string(decoded["is_verified"]) != "false" {
|
||||
t.Errorf("unexpected body %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
@@ -222,9 +226,9 @@ func TestUserRolesHttp(t *testing.T) {
|
||||
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.users.users[1] = user.User{ID: 1, Username: "marta", Email: "marta@renew.example", IsVerified: true, RoleID: ownerRoleID, CreatedAt: day(1)}
|
||||
f.users.users[2] = user.User{ID: 2, Username: "jonas", Email: "jonas@renew.example", IsVerified: true, RoleID: adminRoleID, CreatedAt: day(2)}
|
||||
f.users.users[3] = user.User{ID: 3, Username: "ines", DisplayName: "Inès Duarte", Email: "ines.duarte@renew.example", IsVerified: true, RoleID: adminRoleID, CreatedAt: day(3)}
|
||||
f.invites.seed(pendingInvite("sam.oliveira@renew.example"))
|
||||
|
||||
rec := f.do(t, http.MethodGet, "/v1/user/", "", f.as(t, 2))
|
||||
@@ -237,18 +241,21 @@ func TestUserRolesHttp(t *testing.T) {
|
||||
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"},
|
||||
"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")
|
||||
assertExactKeys(t, item, "id", "kind", "email", "role", "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] {
|
||||
var ref struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.Unmarshal(item["role"], &ref); err != nil || decodedString(t, item, "email") != want[0] || ref.Name != want[1] {
|
||||
t.Errorf("unexpected user item %v", item)
|
||||
}
|
||||
}
|
||||
@@ -263,7 +270,7 @@ func TestUserRolesHttp(t *testing.T) {
|
||||
|
||||
t.Run("The owner can invite", func(t *testing.T) {
|
||||
f := newUserRolesFixture(t, 100)
|
||||
f.seedUser(1, "marta", user.RoleOwner, true)
|
||||
f.seedUser(1, "marta", ownerRoleID, true)
|
||||
|
||||
rec := f.do(t, http.MethodPost, "/v1/user/invite", rolesInviteBody, f.as(t, 1))
|
||||
|
||||
@@ -277,7 +284,7 @@ func TestUserRolesHttp(t *testing.T) {
|
||||
|
||||
t.Run("An admin can invite", func(t *testing.T) {
|
||||
f := newUserRolesFixture(t, 100)
|
||||
f.seedUser(2, "jonas", user.RoleAdmin, true)
|
||||
f.seedUser(2, "jonas", adminRoleID, true)
|
||||
|
||||
rec := f.do(t, http.MethodPost, "/v1/user/invite", rolesInviteBody, f.as(t, 2))
|
||||
|
||||
@@ -329,7 +336,7 @@ func TestUserRolesHttp(t *testing.T) {
|
||||
|
||||
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)
|
||||
f.seedUser(2, "jonas", ownerRoleID, true)
|
||||
cookie := f.as(t, 2)
|
||||
stored := f.users.users[2]
|
||||
stored.RoleID = writerRoleID
|
||||
@@ -344,7 +351,7 @@ func TestUserRolesHttp(t *testing.T) {
|
||||
|
||||
t.Run("An admin can still list users", func(t *testing.T) {
|
||||
f := newUserRolesFixture(t, 100)
|
||||
f.seedUser(2, "jonas", user.RoleAdmin, true)
|
||||
f.seedUser(2, "jonas", adminRoleID, true)
|
||||
|
||||
rec := f.do(t, http.MethodGet, "/v1/user/", "", f.as(t, 2))
|
||||
|
||||
@@ -355,7 +362,7 @@ func TestUserRolesHttp(t *testing.T) {
|
||||
|
||||
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)
|
||||
f.seedUser(1, "marta", ownerRoleID, true)
|
||||
cookie := f.as(t, 1)
|
||||
delete(f.users.users, 1)
|
||||
|
||||
@@ -370,34 +377,35 @@ func TestUserRolesHttp(t *testing.T) {
|
||||
func TestUserRolesUseCases(t *testing.T) {
|
||||
t.Run("CreateUser returns an owner", func(t *testing.T) {
|
||||
users := newMockUserRepo()
|
||||
uc := useCases.NewCreateUserUseCase(users, &stubHasher{}, newMockMailRepo(), invitePublicURL, discardRecorder)
|
||||
uc := useCases.NewCreateUserUseCase(users, newSeededMockRoleRepo(), &stubHasher{}, newMockMailRepo(), invitePublicURL, discardRecorder)
|
||||
|
||||
created, err := uc.CreateUser(testCtx, 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)
|
||||
if created.RoleID != ownerRoleID {
|
||||
t.Errorf("expected owner, got %d", created.RoleID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("AcceptInvite returns an admin", func(t *testing.T) {
|
||||
t.Run("AcceptInvite returns a user with the role of the invite", func(t *testing.T) {
|
||||
users := newMockUserRepo()
|
||||
invites := newInviteFakeRepo()
|
||||
invites.users = users
|
||||
pending := pendingInvite(acceptEmail)
|
||||
pending.HashedCode = "hashed:" + acceptCode
|
||||
pending.RoleID = adminRoleID
|
||||
invites.seed(pending)
|
||||
uc := useCases.NewAcceptInviteUseCase(users, invites, &stubHasher{}, discardRecorder)
|
||||
uc := useCases.NewAcceptInviteUseCase(users, invites, newSeededMockRoleRepo(), &stubHasher{}, discardRecorder)
|
||||
|
||||
created, err := uc.AcceptInvite(testCtx, 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)
|
||||
if created.RoleID != adminRoleID {
|
||||
t.Errorf("expected admin, got %d", created.RoleID)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -406,7 +414,7 @@ func TestRequirePermissionRepositoryFailure(t *testing.T) {
|
||||
t.Run("An unexpected repository error is a 500, not a 403", func(t *testing.T) {
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
repo := newMockUserRepo()
|
||||
repo.users[1] = user.User{ID: 1, Username: "marta", IsVerified: true, Role: user.RoleOwner}
|
||||
repo.users[1] = user.User{ID: 1, Username: "marta", IsVerified: true, RoleID: ownerRoleID}
|
||||
r := chi.NewRouter()
|
||||
r.Use(authtoken.Verifier(token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
@@ -424,81 +432,3 @@ func TestRequirePermissionRepositoryFailure(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user