297 lines
11 KiB
Go
297 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 TestRoleAllowsKindAndOwnerOnly(t *testing.T) {
|
|
t.Run("A toggle grant at scope own does not count", func(t *testing.T) {
|
|
if roleWithGrant("schema.view", role.ScopeOwn).Allows("schema.view", true) {
|
|
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).Allows("system.apply_updates", true) {
|
|
t.Errorf("expected false")
|
|
}
|
|
})
|
|
t.Run("Owner-only grants count on the locked Owner", func(t *testing.T) {
|
|
if !role.BuiltInRoles()[0].Allows("system.apply_updates", false) {
|
|
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 TestRoleAllowsFor(t *testing.T) {
|
|
t.Run("A per-type override denies despite the wildcard", func(t *testing.T) {
|
|
r := role.Role{Grants: []role.Grant{{Key: "content.update", Scope: role.ScopeAll}, {Key: "content.update:blog", Scope: role.ScopeOwn}}}
|
|
if r.AllowsFor("content.update", "blog", false) {
|
|
t.Errorf("override should deny a non-owned entry")
|
|
}
|
|
if !r.AllowsFor("content.update", "blog", true) {
|
|
t.Errorf("override should allow an owned entry")
|
|
}
|
|
if !r.AllowsFor("content.update", "news", false) {
|
|
t.Errorf("other types fall back to the wildcard")
|
|
}
|
|
})
|
|
t.Run("A per-type override grants without a wildcard", func(t *testing.T) {
|
|
r := role.Role{Grants: []role.Grant{{Key: "content.read:blog", Scope: role.ScopeAll}}}
|
|
if !r.AllowsFor("content.read", "blog", false) || r.AllowsFor("content.read", "news", false) {
|
|
t.Errorf("override should apply to blog only")
|
|
}
|
|
})
|
|
t.Run("An empty type key behaves like Allows", func(t *testing.T) {
|
|
r := roleWithGrant("content.read", role.ScopeAll)
|
|
if !r.AllowsFor("content.read", "", false) {
|
|
t.Errorf("expected true")
|
|
}
|
|
})
|
|
t.Run("A malformed type key or non per-type key is denied", func(t *testing.T) {
|
|
r := role.BuiltInRoles()[0]
|
|
if r.AllowsFor("content.read", "Bad Key", true) || r.AllowsFor("schema.view", "blog", true) {
|
|
t.Errorf("expected false")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestBackfillCreatedByWithSoftDeletedArticle(t *testing.T) {
|
|
db := openMigratedRolesDB(t)
|
|
createUserRow(t, db, 3, "owner", nil)
|
|
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")
|
|
}
|
|
}
|