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