Files
RenewCMS/test/roles_permissions_foundation_test.go
T

434 lines
15 KiB
Go

package test
import (
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/persistence/database"
"RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"slices"
"testing"
"gorm.io/gorm"
)
func roleWithGrant(key string, scope role.Scope) role.Role {
return role.Role{Name: "Probe", Grants: []role.Grant{{Key: key, Scope: scope}}}
}
func TestRoleGrantSetDecisions(t *testing.T) {
cases := []struct {
key string
scope role.Scope
owned bool
allowed bool
}{
{"content.update", role.ScopeAll, false, true},
{"content.update", role.ScopeAll, true, true},
{"content.update", role.ScopeOwn, true, true},
{"content.update", role.ScopeOwn, false, false},
{"content.update", role.ScopeNone, true, false},
{"content.create", role.ScopeAll, false, true},
{"schema.view", role.ScopeNone, false, false},
}
for _, c := range cases {
t.Run(string(c.scope)+" "+c.key, func(t *testing.T) {
createdBy := uint32(2)
if c.owned {
createdBy = 1
}
if got := roleWithGrant(c.key, c.scope).GrantSet().Permits(c.key, "", 1, createdBy); got != c.allowed {
t.Errorf("owned=%v: expected %v, got %v", c.owned, c.allowed, got)
}
})
}
t.Run("A key absent from the role is not allowed", func(t *testing.T) {
if (role.Role{}).GrantSet().Permits("content.read", "", 1, 1) {
t.Errorf("expected false")
}
})
t.Run("An unknown key is rejected, never silently allowed", func(t *testing.T) {
everything := role.BuiltInRoles()[0]
everything.Grants = append(everything.Grants, role.Grant{Key: "content.explode", Scope: role.ScopeAll})
if everything.GrantSet().Permits("content.explode", "", 1, 1) {
t.Errorf("expected false")
}
if _, err := role.Lookup("content.explode"); !errors.Is(err, role.ErrUnknownPermission) {
t.Errorf("expected ErrUnknownPermission, got %v", err)
}
})
}
func TestRoleCatalogue(t *testing.T) {
keys := []string{
"content.read", "content.create", "content.update", "content.delete", "content.publish",
"schema.view", "schema.edit", "schema.delete",
"media.view", "media.upload", "media.delete",
"authors.view", "authors.create", "authors.edit", "authors.delete",
"activity.view", "activity.export",
"users.view", "users.invite", "users.change_role", "users.remove",
"settings.view", "settings.edit_general", "settings.manage_sites", "settings.delete_site",
"apikeys.view", "apikeys.create", "apikeys.revoke",
"roles.view", "roles.manage", "roles.edit_owner_only",
"system.apply_updates", "system.edit_retention", "system.transfer_ownership",
}
t.Run("The catalogue is stable and complete", func(t *testing.T) {
catalogue := role.Catalogue()
got := []string{}
ownerOnly := []string{}
scoped := []string{}
for _, permission := range catalogue {
got = append(got, permission.Key)
if permission.OwnerOnly {
ownerOnly = append(ownerOnly, permission.Key)
}
if permission.Kind == role.KindScoped {
scoped = append(scoped, permission.Key)
}
if permission.Group == "content" && permission.Kind == role.KindToggle && permission.Key != "content.create" {
t.Errorf("unexpected toggle %s", permission.Key)
}
}
if !slices.Equal(got, keys) {
t.Errorf("catalogue order differs: %v", got)
}
wantOwnerOnly := []string{"settings.delete_site", "roles.edit_owner_only", "system.apply_updates", "system.edit_retention", "system.transfer_ownership"}
if !slices.Equal(ownerOnly, wantOwnerOnly) {
t.Errorf("owner-only keys: %v", ownerOnly)
}
wantScoped := []string{"content.read", "content.update", "content.delete", "content.publish", "media.delete", "authors.edit", "activity.view", "apikeys.view", "apikeys.revoke"}
if !slices.Equal(scoped, wantScoped) {
t.Errorf("scoped keys: %v", scoped)
}
wantGroups := []string{"content", "schema", "media", "authors", "activity", "users", "settings", "roles", "system"}
if !slices.Equal(role.Groups(), wantGroups) {
t.Errorf("groups: %v", role.Groups())
}
})
}
func grantMap(grants []role.Grant) map[string]role.Scope {
scopes := map[string]role.Scope{}
for _, grant := range grants {
scopes[grant.Key] = grant.Scope
}
return scopes
}
func openMigratedRolesDB(t *testing.T) *gorm.DB {
t.Helper()
db := openUnmigratedSQLite(t)
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatalf("migrate: %v", err)
}
return db
}
func storedRole(t *testing.T, db *gorm.DB, name string) role.Role {
t.Helper()
found, err := persistence.NewRoleRepository(db).GetByName(name)
if err != nil {
t.Fatalf("role %s: %v", name, err)
}
return found
}
func countRows(t *testing.T, db *gorm.DB, model any) int64 {
t.Helper()
var total int64
if err := db.Model(model).Count(&total).Error; err != nil {
t.Fatalf("count: %v", err)
}
return total
}
func TestSeedBuiltInRoles(t *testing.T) {
t.Run("Seeding an empty database creates the three built-in roles", func(t *testing.T) {
db := openMigratedRolesDB(t)
owner, admin, writer := storedRole(t, db, "Owner"), storedRole(t, db, "Admin"), storedRole(t, db, "Writer")
for _, found := range []role.Role{owner, admin, writer} {
if !found.BuiltIn || found.Version != 1 {
t.Errorf("%s: built_in %v version %d", found.Name, found.BuiltIn, found.Version)
}
}
if !owner.Locked || admin.Locked || writer.Locked {
t.Errorf("only Owner is locked")
}
if len(owner.Grants) != 34 || len(admin.Grants) != 29 || len(writer.Grants) != 9 {
t.Fatalf("grant counts %d %d %d", len(owner.Grants), len(admin.Grants), len(writer.Grants))
}
for _, grant := range append(slices.Clone(owner.Grants), admin.Grants...) {
if grant.Scope != role.ScopeAll {
t.Errorf("%s not at all", grant.Key)
}
}
for _, grant := range admin.Grants {
if permission, _ := role.Lookup(grant.Key); permission.OwnerOnly {
t.Errorf("admin holds owner-only %s", grant.Key)
}
}
scopes := grantMap(writer.Grants)
if scopes["authors.edit"] != role.ScopeOwn || scopes["activity.view"] != role.ScopeOwn || scopes["content.create"] != role.ScopeAll {
t.Errorf("writer grants %v", scopes)
}
})
t.Run("Seeding twice changes nothing", func(t *testing.T) {
db := openMigratedRolesDB(t)
ownerID := storedRole(t, db, "Owner").ID
if err := database.SeedBuiltInRoles(db); err != nil {
t.Fatal(err)
}
if countRows(t, db, &models.Role{}) != 3 || countRows(t, db, &models.RolePermission{}) != 72 || storedRole(t, db, "Owner").ID != ownerID {
t.Errorf("seeding was not idempotent")
}
})
t.Run("Seeding never overwrites an edited built-in role", func(t *testing.T) {
db := openMigratedRolesDB(t)
writer := storedRole(t, db, "Writer")
if err := db.Model(&models.Role{}).Where("id = ?", writer.ID).Update("version", 4).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&models.RolePermission{RoleID: writer.ID, Key: "media.delete", Scope: "own"}).Error; err != nil {
t.Fatal(err)
}
if err := database.SeedBuiltInRoles(db); err != nil {
t.Fatal(err)
}
after := storedRole(t, db, "Writer")
if after.Version != 4 || grantMap(after.Grants)["media.delete"] != role.ScopeOwn {
t.Errorf("edited writer overwritten: %+v", after)
}
})
t.Run("Seeding never touches a custom role that reuses a built-in name", func(t *testing.T) {
db := openUnmigratedSQLite(t)
if err := db.AutoMigrate(&models.Role{}, &models.RolePermission{}); err != nil {
t.Fatal(err)
}
custom := models.Role{Name: "Writer", Version: 7}
if err := db.Create(&custom).Error; err != nil {
t.Fatal(err)
}
if err := database.SeedBuiltInRoles(db); err != nil {
t.Fatalf("startup aborted: %v", err)
}
var kept models.Role
if err := db.First(&kept, custom.ID).Error; err != nil || kept.BuiltIn || kept.Version != 7 {
t.Errorf("custom row changed: %+v %v", kept, err)
}
if found := storedRole(t, db, "Writer"); !found.BuiltIn || found.ID == custom.ID {
t.Errorf("built-in writer missing: %+v", found)
}
})
}
func createUserRow(t *testing.T, db *gorm.DB, id uint32, legacy string, roleID *uint32) {
t.Helper()
row := models.User{ID: id, Username: "u" + legacy + string(rune('a'+id)), Email: legacy + string(rune('a'+id)) + "@x.example", Password: "x", Role: legacy, RoleID: roleID}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("user %d: %v", id, err)
}
}
func userRow(t *testing.T, db *gorm.DB, id uint32) models.User {
t.Helper()
var row models.User
if err := db.First(&row, id).Error; err != nil {
t.Fatal(err)
}
return row
}
func roleIDOf(row models.User) uint32 {
if row.RoleID == nil {
return 0
}
return *row.RoleID
}
func TestBackfillUserRoleIDs(t *testing.T) {
t.Run("Existing owner and admin users get the matching built-in role", func(t *testing.T) {
db := openMigratedRolesDB(t)
createUserRow(t, db, 3, "owner", nil)
createUserRow(t, db, 5, "admin", nil)
createUserRow(t, db, 9, "admin", nil)
if err := database.BackfillUserRoleIDs(db); err != nil {
t.Fatal(err)
}
ownerID, adminID := storedRole(t, db, "Owner").ID, storedRole(t, db, "Admin").ID
if roleIDOf(userRow(t, db, 3)) != ownerID || roleIDOf(userRow(t, db, 5)) != adminID || roleIDOf(userRow(t, db, 9)) != adminID {
t.Errorf("role ids not backfilled")
}
if userRow(t, db, 3).Role != "owner" || userRow(t, db, 5).Role != "admin" {
t.Errorf("legacy role changed")
}
})
t.Run("The backfill runs after BackfillUserRoles on a database that predates both", func(t *testing.T) {
db := openMigratedRolesDB(t)
createUserRow(t, db, 2, "", nil)
createUserRow(t, db, 4, "", nil)
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatal(err)
}
two, four := userRow(t, db, 2), userRow(t, db, 4)
if two.Role != "owner" || roleIDOf(two) != storedRole(t, db, "Owner").ID {
t.Errorf("user 2: %+v", two)
}
if four.Role != "admin" || roleIDOf(four) != storedRole(t, db, "Admin").ID {
t.Errorf("user 4: %+v", four)
}
})
t.Run("The role_id backfill is idempotent and respects an existing role_id", func(t *testing.T) {
db := openMigratedRolesDB(t)
custom, err := persistence.NewRoleRepository(db).Create(role.Role{Name: "Contributor", Version: 1})
if err != nil {
t.Fatal(err)
}
createUserRow(t, db, 5, "admin", &custom.ID)
for range 2 {
if err := database.BackfillUserRoleIDs(db); err != nil {
t.Fatal(err)
}
}
if roleIDOf(userRow(t, db, 5)) != custom.ID {
t.Errorf("custom role replaced")
}
})
t.Run("A database with no users is left alone", func(t *testing.T) {
if err := database.BackfillUserRoleIDs(openMigratedRolesDB(t)); err != nil {
t.Errorf("unexpected error %v", err)
}
})
}
func createdByOf(t *testing.T, db *gorm.DB, table string, id uint32) uint32 {
t.Helper()
var value uint32
if err := db.Table(table).Unscoped().Select("created_by").Where("id = ?", id).Scan(&value).Error; err != nil {
t.Fatal(err)
}
return value
}
func createArticleRow(t *testing.T, db *gorm.DB, id uint32, createdBy uint32) {
t.Helper()
if err := db.Create(&models.Article{ID: id, Title: "Article", Slug: "a-" + string(rune('a'+id)), CreatedBy: createdBy, SiteID: 1}).Error; err != nil {
t.Fatal(err)
}
}
func createEntryRow(t *testing.T, db *gorm.DB, id uint32, createdBy uint32) {
t.Helper()
if err := db.Create(&models.Entry{ID: id, ContentTypeID: 1, Data: "{}", CreatedBy: createdBy}).Error; err != nil {
t.Fatal(err)
}
}
func TestBackfillCreatedBy(t *testing.T) {
t.Run("Existing articles are attributed to the first owner", func(t *testing.T) {
db := openMigratedRolesDB(t)
createUserRow(t, db, 3, "owner", nil)
createUserRow(t, db, 4, "owner", nil)
createArticleRow(t, db, 10, 0)
createArticleRow(t, db, 11, 0)
if err := database.BackfillCreatedBy(db); err != nil {
t.Fatal(err)
}
if createdByOf(t, db, "articles", 10) != 3 || createdByOf(t, db, "articles", 11) != 3 {
t.Errorf("articles not attributed")
}
})
t.Run("Entries without an author are attributed to the first owner", func(t *testing.T) {
db := openMigratedRolesDB(t)
createUserRow(t, db, 3, "owner", nil)
createEntryRow(t, db, 20, 0)
createEntryRow(t, db, 21, 7)
if err := database.BackfillCreatedBy(db); err != nil {
t.Fatal(err)
}
if createdByOf(t, db, "entries", 20) != 3 || createdByOf(t, db, "entries", 21) != 7 {
t.Errorf("entries wrong")
}
})
t.Run("The created_by backfill never rewrites an existing value, and is idempotent", func(t *testing.T) {
db := openMigratedRolesDB(t)
createUserRow(t, db, 3, "owner", nil)
createArticleRow(t, db, 10, 7)
for range 2 {
if err := database.BackfillCreatedBy(db); err != nil {
t.Fatal(err)
}
}
if createdByOf(t, db, "articles", 10) != 7 {
t.Errorf("existing value rewritten")
}
})
t.Run("With no owner yet the backfill does nothing", func(t *testing.T) {
db := openMigratedRolesDB(t)
createArticleRow(t, db, 10, 0)
if err := database.BackfillCreatedBy(db); err != nil {
t.Fatalf("unexpected error %v", err)
}
if createdByOf(t, db, "articles", 10) != 0 {
t.Errorf("created_by changed")
}
})
}
func TestNewArticlesRecordTheirCreator(t *testing.T) {
t.Run("New articles record their creator", func(t *testing.T) {
repo := newMockArticleRepo()
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
created, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "Valid Title", Body: "x", Caller: ownerCaller(7)})
if err != nil {
t.Fatal(err)
}
if repo.articles[created.ID].CreatedBy != 7 {
t.Errorf("stored created_by %d", repo.articles[created.ID].CreatedBy)
}
})
}
func TestGetMyAccessUseCase(t *testing.T) {
t.Run("A user with no role_id falls back to the built-in role named by the legacy role", func(t *testing.T) {
uc := useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo())
for legacy, name := range map[user.Role]string{user.RoleOwner: "Owner", user.RoleAdmin: "Admin"} {
access, err := uc.Get(user.User{ID: 11, Role: legacy})
if err != nil || access.Role.Name != name {
t.Errorf("%s: %v %v", legacy, access.Role.Name, err)
}
}
})
t.Run("A user whose role cannot be resolved gets an error", func(t *testing.T) {
uc := useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo())
if _, err := uc.Get(user.User{ID: 13, RoleID: 99}); !errors.Is(err, role.ErrRoleNotFound) {
t.Errorf("expected ErrRoleNotFound, got %v", err)
}
if _, err := uc.Get(user.User{ID: 13}); !errors.Is(err, role.ErrRoleNotFound) {
t.Errorf("expected ErrRoleNotFound, got %v", err)
}
})
t.Run("The fallback never picks a custom role that reuses a built-in name", func(t *testing.T) {
repo := newMockRoleRepo()
_, _ = repo.Create(role.Role{Name: "Owner"})
if _, err := useCases.NewGetMyAccessUseCase(repo).Get(user.User{Role: user.RoleOwner}); !errors.Is(err, role.ErrRoleNotFound) {
t.Errorf("expected ErrRoleNotFound, got %v", err)
}
})
}