Files
RenewCMS/test/role_hardening_test.go
T

296 lines
11 KiB
Go

package test
import (
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/persistence/database"
"RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"errors"
"strings"
"testing"
"gorm.io/gorm"
)
func deleteGrantRow(t *testing.T, db *gorm.DB, roleID uint32, key string) {
t.Helper()
if err := db.Where("role_id = ? AND permission_key = ?", roleID, key).Delete(&models.RolePermission{}).Error; err != nil {
t.Fatal(err)
}
}
func TestBuiltInReconciliation(t *testing.T) {
reseed := func(t *testing.T, db *gorm.DB) {
t.Helper()
if err := database.SeedBuiltInRoles(db); err != nil {
t.Fatal(err)
}
}
t.Run("Owner regains a missing key at scope all without a version bump", func(t *testing.T) {
db := openMigratedRolesDB(t)
owner := storedRole(t, db, "Owner")
deleteGrantRow(t, db, owner.ID, "system.apply_updates")
deleteGrantRow(t, db, owner.ID, "content.read")
reseed(t, db)
after := storedRole(t, db, "Owner")
scopes := grantMap(after.Grants)
if scopes["system.apply_updates"] != role.ScopeAll || scopes["content.read"] != role.ScopeAll {
t.Errorf("owner did not regain keys")
}
if after.Version != owner.Version || len(after.Grants) != len(owner.Grants) {
t.Errorf("version or grant count changed: %+v", after.Version)
}
})
t.Run("Owner keeps an existing grant untouched", func(t *testing.T) {
db := openMigratedRolesDB(t)
owner := storedRole(t, db, "Owner")
if err := db.Model(&models.RolePermission{}).Where("role_id = ? AND permission_key = ?", owner.ID, "content.read").Update("scope", "own").Error; err != nil {
t.Fatal(err)
}
reseed(t, db)
if grantMap(storedRole(t, db, "Owner").Grants)["content.read"] != role.ScopeOwn {
t.Errorf("existing grant downgraded or overwritten")
}
})
t.Run("An unedited Admin regains a deleted grant but never an owner-only one", func(t *testing.T) {
db := openMigratedRolesDB(t)
admin := storedRole(t, db, "Admin")
deleteGrantRow(t, db, admin.ID, "users.invite")
reseed(t, db)
scopes := grantMap(storedRole(t, db, "Admin").Grants)
if scopes["users.invite"] != role.ScopeAll {
t.Errorf("admin did not regain users.invite")
}
if _, owned := scopes["system.apply_updates"]; owned {
t.Errorf("admin gained an owner-only key")
}
})
t.Run("An edited Writer does not regain a deleted grant", func(t *testing.T) {
db := openMigratedRolesDB(t)
writer := storedRole(t, db, "Writer")
if err := db.Model(&models.Role{}).Where("id = ?", writer.ID).Update("version", 2).Error; err != nil {
t.Fatal(err)
}
deleteGrantRow(t, db, writer.ID, "media.upload")
reseed(t, db)
after := storedRole(t, db, "Writer")
if _, back := grantMap(after.Grants)["media.upload"]; back || after.Version != 2 {
t.Errorf("edited writer reconciled: %+v", after)
}
})
t.Run("An unedited Writer regains a deleted grant", func(t *testing.T) {
db := openMigratedRolesDB(t)
writer := storedRole(t, db, "Writer")
deleteGrantRow(t, db, writer.ID, "media.upload")
reseed(t, db)
if grantMap(storedRole(t, db, "Writer").Grants)["media.upload"] != role.ScopeAll {
t.Errorf("writer did not regain media.upload")
}
})
}
func TestRoleRepositorySaveVersionCheck(t *testing.T) {
t.Run("A stale version is a conflict and leaves grants untouched", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
writer := storedRole(t, db, "Writer")
before := len(writer.Grants)
writer.Version = 7
writer.Name = "Renamed"
writer.Grants = []role.Grant{{Key: "media.delete", Scope: role.ScopeOwn}}
if _, err := repo.Save(writer); !errors.Is(err, role.ErrRoleConflict) {
t.Fatalf("want ErrRoleConflict, got %v", err)
}
after := storedRole(t, db, "Writer")
if len(after.Grants) != before || after.Version != 1 {
t.Errorf("stale save changed the role: %+v", after)
}
})
t.Run("A successful save bumps the version and replaces grants", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
writer := storedRole(t, db, "Writer")
writer.Description = "edited"
writer.Grants = []role.Grant{{Key: "media.delete", Scope: role.ScopeOwn}}
saved, err := repo.Save(writer)
if err != nil {
t.Fatal(err)
}
if saved.Version != writer.Version+1 || saved.Description != "edited" || len(saved.Grants) != 1 {
t.Errorf("saved: %+v", saved)
}
again := writer
if _, err := repo.Save(again); !errors.Is(err, role.ErrRoleConflict) {
t.Errorf("reusing the old version must conflict, got %v", err)
}
})
t.Run("Save cannot flip BuiltIn or Locked", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
writer := storedRole(t, db, "Writer")
writer.BuiltIn = false
writer.Locked = true
saved, err := repo.Save(writer)
if err != nil {
t.Fatal(err)
}
if !saved.BuiltIn || saved.Locked {
t.Errorf("flags changed: %+v", saved)
}
})
}
func TestMockRoleRepoSaveMirrorsVersionCheck(t *testing.T) {
repo := newSeededMockRoleRepo()
writer := repo.byName("Writer")
stale := writer
stale.Version = 9
if _, err := repo.Save(stale); !errors.Is(err, role.ErrRoleConflict) {
t.Errorf("want conflict, got %v", err)
}
writer.BuiltIn = false
saved, err := repo.Save(writer)
if err != nil || saved.Version != 2 || !saved.BuiltIn {
t.Errorf("saved %+v err %v", saved, err)
}
}
func TestValidateGrants(t *testing.T) {
cases := []struct {
name string
locked bool
grants []role.Grant
want error
}{
{"valid scoped and toggle", false, []role.Grant{{Key: "content.read", Scope: role.ScopeOwn}, {Key: "schema.view", Scope: role.ScopeAll}}, nil},
{"unknown key", false, []role.Grant{{Key: "content.explode", Scope: role.ScopeAll}}, role.ErrUnknownPermission},
{"scope none stored", false, []role.Grant{{Key: "content.read", Scope: role.ScopeNone}}, role.ErrInvalidScope},
{"unknown scope", false, []role.Grant{{Key: "content.read", Scope: "some"}}, role.ErrInvalidScope},
{"toggle with own", false, []role.Grant{{Key: "schema.view", Scope: role.ScopeOwn}}, role.ErrInvalidScope},
{"owner-only on regular role", false, []role.Grant{{Key: "system.apply_updates", Scope: role.ScopeAll}}, role.ErrOwnerOnlyGrant},
{"owner-only on locked owner", true, []role.Grant{{Key: "system.apply_updates", Scope: role.ScopeAll}}, nil},
{"per-type key", false, []role.Grant{{Key: "content.read:blog", Scope: role.ScopeOwn}}, nil},
{"malformed per-type key", false, []role.Grant{{Key: "content.read:Bad Type", Scope: role.ScopeAll}}, role.ErrUnknownPermission},
{"per-type toggle with own", false, []role.Grant{{Key: "content.create:blog", Scope: role.ScopeOwn}}, role.ErrInvalidScope},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if err := role.ValidateGrants(c.locked, c.grants); !errors.Is(err, c.want) {
t.Errorf("want %v, got %v", c.want, err)
}
})
}
t.Run("Built-in definitions are valid", func(t *testing.T) {
for _, builtIn := range role.BuiltInRoles() {
if err := role.ValidateGrants(builtIn.Locked, builtIn.Grants); err != nil {
t.Errorf("%s: %v", builtIn.Name, err)
}
}
})
}
func TestRoleGrantSetKindAndOwnerOnly(t *testing.T) {
t.Run("A toggle grant at scope own does not count", func(t *testing.T) {
if roleWithGrant("schema.view", role.ScopeOwn).GrantSet().Holds("schema.view") {
t.Errorf("expected false")
}
})
t.Run("Owner-only grants are ignored on a role that is not locked", func(t *testing.T) {
if roleWithGrant("system.apply_updates", role.ScopeAll).GrantSet().Holds("system.apply_updates") {
t.Errorf("expected false")
}
})
t.Run("Owner-only grants count on the locked Owner", func(t *testing.T) {
if !role.BuiltInRoles()[0].GrantSet().Holds("system.apply_updates") {
t.Errorf("expected true")
}
})
}
func TestParseTypedKey(t *testing.T) {
t.Run("A valid composite key is split", func(t *testing.T) {
base, typeKey, err := role.ParseTypedKey("content.update:blog_post-2")
if err != nil || base != "content.update" || typeKey != "blog_post-2" {
t.Errorf("got %q %q %v", base, typeKey, err)
}
})
rejected := map[string]string{
"no separator": "content.read",
"empty type key": "content.read:",
"non per-type key": "schema.view:blog",
"unknown base": "content.explode:blog",
"uppercase": "content.read:Blog",
"space": "content.read:a b",
"second colon": "content.read:a:b",
"too long": "content.read:" + strings.Repeat("a", 100),
}
for name, composite := range rejected {
t.Run("Rejects "+name, func(t *testing.T) {
if _, _, err := role.ParseTypedKey(composite); !errors.Is(err, role.ErrUnknownPermission) {
t.Errorf("want ErrUnknownPermission, got %v", err)
}
})
}
t.Run("Accepts a composite key of exactly 100 characters", func(t *testing.T) {
composite := "content.read:" + strings.Repeat("a", 87)
if _, _, err := role.ParseTypedKey(composite); err != nil {
t.Errorf("unexpected %v", err)
}
})
}
func TestGrantSetTypedDecisions(t *testing.T) {
t.Run("A per-type override denies despite the wildcard", func(t *testing.T) {
set := unlockedGrantSet("content.update=all", "content.update:blog=own")
if set.Permits("content.update", "blog", 1, 2) {
t.Errorf("override should deny a non-owned entry")
}
if !set.Permits("content.update", "blog", 1, 1) {
t.Errorf("override should allow an owned entry")
}
if !set.Permits("content.update", "news", 1, 2) {
t.Errorf("other types fall back to the wildcard")
}
})
t.Run("A per-type override grants without a wildcard", func(t *testing.T) {
set := unlockedGrantSet("content.read:blog=all")
if !set.Permits("content.read", "blog", 1, 2) || set.Permits("content.read", "news", 1, 2) {
t.Errorf("override should apply to blog only")
}
})
t.Run("An empty type key reads the wildcard", func(t *testing.T) {
if !roleWithGrant("content.read", role.ScopeAll).GrantSet().Permits("content.read", "", 1, 2) {
t.Errorf("expected true")
}
})
t.Run("A malformed type key or non per-type key is denied", func(t *testing.T) {
set := role.BuiltInRoles()[0].GrantSet()
if set.Permits("content.read", "Bad Key", 1, 1) || set.Permits("schema.view", "blog", 1, 1) {
t.Errorf("expected false")
}
})
}
func TestBackfillCreatedByWithSoftDeletedArticle(t *testing.T) {
db := openMigratedRolesDB(t)
createUserRow(t, db, 3, ownerIDPtr(t, db))
createArticleRow(t, db, 10, 0)
if err := db.Delete(&models.Article{}, 10).Error; err != nil {
t.Fatal(err)
}
if err := database.BackfillCreatedBy(db); err != nil {
t.Fatal(err)
}
if createdByOf(t, db, "articles", 10) != 3 {
t.Errorf("soft-deleted article not attributed")
}
}