feat: /v1/role list, get, create, update and delete. Reassign members on delete. Write-time validation (ValidateGrants, duplicates, typed keys against real content types). Name uniqueness (role_name_taken), role_locked, role_has_members, role_conflict on version mismatch. No-escalation: nobody grants a permission they don't hold, and roles.edit_owner_only guards the Owner role. Activity events for role changes. Typed-key exposure in /auth/me. Cleanup of typed rows when a content type is deleted. | Backend only. Will carry the deferred ListGrants decision.
This commit is contained in:
1 parent
a043f7f41a
commit
094df63a7f
51 files changed
+4822
-115
No files matched your search
@@ -0,0 +1,252 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/infrastructure/persistence/models"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"errors"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func rolesAPIGrantKeys(grants []role.Grant) []string {
|
||||
keys := []string{}
|
||||
for _, grant := range grants {
|
||||
keys = append(keys, grant.Key+"="+string(grant.Scope))
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func rolesAPICreateRole(t *testing.T, repo *persistence.RoleRepository, name string, rows ...string) role.Role {
|
||||
t.Helper()
|
||||
created, err := repo.Create(role.Role{Name: name, Version: 1, Grants: grantRows(rows...)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
func TestRoleRepositoryRolesAPI(t *testing.T) {
|
||||
t.Run("List returns the grants of every role in one pass", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
repo := persistence.NewRoleRepository(db)
|
||||
rolesAPICreateRole(t, repo, "Contributor", "content.read=all", "content.update:events=own")
|
||||
listed, err := repo.List()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(listed) != 4 {
|
||||
t.Fatalf("expected 4 roles, got %d", len(listed))
|
||||
}
|
||||
for index, expected := range []int{34, 29, 9, 2} {
|
||||
if len(listed[index].Grants) != expected {
|
||||
t.Errorf("role %s: expected %d grants, got %d", listed[index].Name, expected, len(listed[index].Grants))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("seeded roles have no editor and no edit time", func(t *testing.T) {
|
||||
repo := persistence.NewRoleRepository(openMigratedRolesDB(t))
|
||||
listed, _ := repo.List()
|
||||
for _, seeded := range listed {
|
||||
if seeded.UpdatedBy != nil || !seeded.UpdatedAt.IsZero() {
|
||||
t.Errorf("role %s: unexpected editor %v at %v", seeded.Name, seeded.UpdatedBy, seeded.UpdatedAt)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Save writes the editor and the time and bumps the version", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
repo := persistence.NewRoleRepository(db)
|
||||
created := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
|
||||
editor := uint32(5)
|
||||
edited := created
|
||||
edited.Description = "changed"
|
||||
edited.UpdatedAt = time.Date(2026, time.October, 5, 11, 2, 0, 0, time.UTC)
|
||||
edited.UpdatedBy = &editor
|
||||
saved, err := repo.Save(edited)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.Version != 2 || saved.UpdatedBy == nil || *saved.UpdatedBy != 5 || !saved.UpdatedAt.Equal(edited.UpdatedAt) {
|
||||
t.Fatalf("unexpected saved role %+v", saved)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Save is a compare-and-swap on the version", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
repo := persistence.NewRoleRepository(db)
|
||||
created := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
|
||||
first := created
|
||||
first.Description = "first"
|
||||
second := created
|
||||
second.Description = "second"
|
||||
if _, err := repo.Save(first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := repo.Save(second); !errors.Is(err, role.ErrRoleConflict) {
|
||||
t.Fatalf("expected ErrRoleConflict, got %v", err)
|
||||
}
|
||||
stored, _ := repo.Get(created.ID)
|
||||
if stored.Description != "first" || stored.Version != 2 {
|
||||
t.Fatalf("unexpected stored role %+v", stored)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("two concurrent saves of version 1 give exactly one winner", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
repo := persistence.NewRoleRepository(db)
|
||||
created := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
|
||||
results := make([]error, 2)
|
||||
var group sync.WaitGroup
|
||||
for index := range results {
|
||||
group.Add(1)
|
||||
go func() {
|
||||
defer group.Done()
|
||||
attempt := created
|
||||
attempt.Description = []string{"a", "b"}[index]
|
||||
_, results[index] = repo.Save(attempt)
|
||||
}()
|
||||
}
|
||||
group.Wait()
|
||||
successes := 0
|
||||
for _, err := range results {
|
||||
if err == nil {
|
||||
successes++
|
||||
} else if !errors.Is(err, role.ErrRoleConflict) {
|
||||
t.Fatalf("unexpected error %v", err)
|
||||
}
|
||||
}
|
||||
if successes != 1 {
|
||||
t.Fatalf("expected exactly one winner, got %d", successes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Delete removes the role and its grants", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
repo := persistence.NewRoleRepository(db)
|
||||
created := rolesAPICreateRole(t, repo, "Intern", "content.read=own")
|
||||
rowsBefore := countRows(t, db, &models.RolePermission{})
|
||||
if err := repo.Delete(created.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := repo.Get(created.ID); !errors.Is(err, role.ErrRoleNotFound) {
|
||||
t.Fatalf("expected the role to be gone, got %v", err)
|
||||
}
|
||||
if countRows(t, db, &models.RolePermission{}) != rowsBefore-1 {
|
||||
t.Error("the grant rows were not removed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Delete refuses a role that still has members", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
repo := persistence.NewRoleRepository(db)
|
||||
created := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
|
||||
createUserRow(t, db, 1, "", &created.ID)
|
||||
if err := repo.Delete(created.ID); !errors.Is(err, role.ErrRoleHasMembers) {
|
||||
t.Fatalf("expected ErrRoleHasMembers, got %v", err)
|
||||
}
|
||||
if _, err := repo.Get(created.ID); err != nil {
|
||||
t.Fatalf("the role must still exist: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Delete reports a missing role", func(t *testing.T) {
|
||||
repo := persistence.NewRoleRepository(openMigratedRolesDB(t))
|
||||
if err := repo.Delete(9999); !errors.Is(err, role.ErrRoleNotFound) {
|
||||
t.Fatalf("expected ErrRoleNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ReassignMembers counts the moved users and touches only role_id", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
repo := persistence.NewRoleRepository(db)
|
||||
source := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
|
||||
writer := storedRole(t, db, "Writer")
|
||||
createUserRow(t, db, 1, "", &source.ID)
|
||||
createUserRow(t, db, 2, "", &source.ID)
|
||||
createUserRow(t, db, 3, "", &writer.ID)
|
||||
before := userRow(t, db, 1)
|
||||
moved, err := repo.ReassignMembers(source.ID, writer.ID)
|
||||
if err != nil || moved != 2 {
|
||||
t.Fatalf("expected 2 moved, got %d %v", moved, err)
|
||||
}
|
||||
after := userRow(t, db, 1)
|
||||
if roleIDOf(after) != writer.ID {
|
||||
t.Errorf("user 1 was not moved")
|
||||
}
|
||||
after.RoleID, before.RoleID = nil, nil
|
||||
if !reflect.DeepEqual(before, after) {
|
||||
t.Errorf("only role_id may change: %+v vs %+v", before, after)
|
||||
}
|
||||
if moved, _ := repo.ReassignMembers(source.ID, writer.ID); moved != 0 {
|
||||
t.Errorf("a second move must move nobody, got %d", moved)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RemoveTypedGrants removes the five typed forms of one type only and bumps affected roles once", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
repo := persistence.NewRoleRepository(db)
|
||||
affected := rolesAPICreateRole(t, repo, "Events editor", "content.read=all", "content.update:events=own", "content.delete:events=none", "content.read:events=all", "content.create:events=all", "content.publish:events=own", "content.update:venues=all")
|
||||
untouched := rolesAPICreateRole(t, repo, "Venues only", "content.update:venues=all", "content.read=all")
|
||||
removed, err := repo.RemoveTypedGrants("events")
|
||||
if err != nil || removed != 1 {
|
||||
t.Fatalf("expected 1 affected role, got %d %v", removed, err)
|
||||
}
|
||||
stored, _ := repo.Get(affected.ID)
|
||||
if !reflect.DeepEqual(rolesAPIGrantKeys(stored.Grants), []string{"content.read=all", "content.update:venues=all"}) || stored.Version != 2 {
|
||||
t.Fatalf("unexpected affected role %v version %d", rolesAPIGrantKeys(stored.Grants), stored.Version)
|
||||
}
|
||||
other, _ := repo.Get(untouched.ID)
|
||||
if other.Version != 1 || len(other.Grants) != 2 {
|
||||
t.Fatalf("the other role must be untouched: %+v", other)
|
||||
}
|
||||
if again, _ := repo.RemoveTypedGrants("events"); again != 0 {
|
||||
t.Errorf("a second removal must affect nothing, got %d", again)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a content type key that prefixes another is not matched", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
repo := persistence.NewRoleRepository(db)
|
||||
kept := rolesAPICreateRole(t, repo, "Long key", "content.update:events_extra=all")
|
||||
if removed, _ := repo.RemoveTypedGrants("events"); removed != 0 {
|
||||
t.Fatalf("expected nothing removed, got %d", removed)
|
||||
}
|
||||
stored, _ := repo.Get(kept.ID)
|
||||
if len(stored.Grants) != 1 {
|
||||
t.Fatal("the grant of events_extra must be kept")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the new methods fail on a broken database", func(t *testing.T) {
|
||||
for name, call := range map[string]func(*persistence.RoleRepository) error{
|
||||
"Delete": func(r *persistence.RoleRepository) error { return r.Delete(4) },
|
||||
"ReassignMembers": func(r *persistence.RoleRepository) error { _, err := r.ReassignMembers(4, 3); return err },
|
||||
"RemoveTypedGrants": func(r *persistence.RoleRepository) error { _, err := r.RemoveTypedGrants("events"); return err },
|
||||
} {
|
||||
db := openMigratedRolesDB(t)
|
||||
for _, table := range []string{"role_permissions", "users"} {
|
||||
if err := db.Migrator().DropTable(table); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
if err := call(persistence.NewRoleRepository(db)); err == nil || errors.Is(err, role.ErrRoleNotFound) {
|
||||
t.Errorf("%s: expected a storage failure, got %v", name, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the role columns exist after Migrate", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
for _, column := range []string{"updated_at", "updated_by"} {
|
||||
if !db.Migrator().HasColumn(&models.Role{}, column) {
|
||||
t.Errorf("roles.%s is missing", column)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user