Files
RenewCMS/test/roles_api_persistence_test.go
T

253 lines
9.0 KiB
Go

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