feat: /v1/role list, get, create, update and delete. Reassign members on delete. Write-time validation (ValidateGrants, duplicates, typed keys against real content types). Name uniqueness (role_name_taken), role_locked, role_has_members, role_conflict on version mismatch. No-escalation: nobody grants a permission they don't hold, and roles.edit_owner_only guards the Owner role. Activity events for role changes. Typed-key exposure in /auth/me. Cleanup of typed rows when a content type is deleted. | Backend only. Will carry the deferred ListGrants decision.

This commit is contained in:
Florian Sylvain committed 2026-10-27 00:45:24 +01:00
1 parent a043f7f41a
commit 094df63a7f
51 files changed
+4822 -115

No files matched your search

+233
View File
@@ -0,0 +1,233 @@
package test
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/role"
"errors"
"reflect"
"testing"
)
func escalationKeyOf(t *testing.T, err error) string {
t.Helper()
var escalation role.EscalationError
if !errors.As(err, &escalation) || !errors.Is(err, role.ErrEscalation) {
t.Fatalf("expected an EscalationError, got %v", err)
}
return escalation.Key
}
func TestGrantSetCovers(t *testing.T) {
typeKeys := []string{"article", "events", "venues"}
builtIns := role.BuiltInRoles()
ownerSet, adminSet, writerSet := builtIns[0].GrantSet(), builtIns[1].GrantSet(), builtIns[2].GrantSet()
cases := []struct {
name string
caller role.GrantSet
candidate role.GrantSet
wantKey string
}{
{"owner covers admin", ownerSet, adminSet, ""},
{"owner covers writer", ownerSet, writerSet, ""},
{"admin does not cover owner and names the first owner-only key", adminSet, ownerSet, role.KeySettingsDeleteSite},
{"admin covers writer", adminSet, writerSet, ""},
{"writer does not cover admin", writerSet, adminSet, role.KeyContentDelete},
{"own covers own", unlockedGrantSet("content.publish=own"), unlockedGrantSet("content.publish=own"), ""},
{"own does not cover all", unlockedGrantSet("content.publish=own"), unlockedGrantSet("content.publish=all"), role.KeyContentPublish},
{"none does not cover own", unlockedGrantSet("content.read=all"), unlockedGrantSet("content.delete=own"), role.KeyContentDelete},
{"denying never escalates", unlockedGrantSet("content.publish=own"), unlockedGrantSet("content.publish:events=none"), ""},
{"typed all above wildcard own", unlockedGrantSet("content.publish=own"), unlockedGrantSet("content.publish:events=all"), "content.publish:events"},
{"typed own under wildcard own", unlockedGrantSet("content.update=own"), unlockedGrantSet("content.update:events=own"), ""},
{"wildcard all would raise article", unlockedGrantSet("content.update=all", "content.update:article=none"), unlockedGrantSet("content.update=all"), "content.update:article"},
{"typed-only reader does not cover wildcard read", unlockedGrantSet("content.read:events=all"), unlockedGrantSet("content.read=all"), role.KeyContentRead},
{"toggle missing", unlockedGrantSet("content.read=all"), unlockedGrantSet("schema.edit=all"), role.KeySchemaEdit},
{"first violation follows the catalogue order", unlockedGrantSet(), unlockedGrantSet("content.delete=all", "content.read=all"), role.KeyContentRead},
{"wildcard is checked before its typed keys", unlockedGrantSet(), unlockedGrantSet("content.read:events=all", "content.read=all"), role.KeyContentRead},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := c.caller.Covers(c.candidate, typeKeys)
if c.wantKey == "" {
if err != nil {
t.Fatalf("expected the grants to be covered, got %v", err)
}
return
}
if got := escalationKeyOf(t, err); got != c.wantKey {
t.Fatalf("expected the key %q, got %q", c.wantKey, got)
}
})
}
t.Run("a set always covers itself", func(t *testing.T) {
for _, set := range []role.GrantSet{ownerSet, adminSet, writerSet, unlockedGrantSet(), unlockedGrantSet("content.update=all", "content.update:article=none", "content.delete:events=none")} {
if err := set.Covers(set, typeKeys); err != nil {
t.Fatalf("expected a set to cover itself, got %v", err)
}
}
})
}
func TestGrantSetEffectiveGrants(t *testing.T) {
t.Run("the events editor lists typed entries after the wildcard in catalogue then type order", func(t *testing.T) {
got := role.Role{Grants: grantRows("content.delete:events=none", "content.update:venues=all", "content.read=all", "content.update:events=own")}.EffectiveGrants()
want := grantRows("content.read=all", "content.update:events=own", "content.update:venues=all", "content.delete:events=none")
if !reflect.DeepEqual(got, want) {
t.Fatalf("expected %v, got %v", want, got)
}
})
t.Run("duplicates resolve to the most restrictive scope and unknown keys are dropped", func(t *testing.T) {
got := role.Role{Grants: grantRows("content.update=all", "content.update=own", "content.explode=all", "content.update:Bad Key=all")}.EffectiveGrants()
want := grantRows("content.update=own")
if !reflect.DeepEqual(got, want) {
t.Fatalf("expected %v, got %v", want, got)
}
})
t.Run("owner-only keys are dropped on an unlocked role and kept on a locked one", func(t *testing.T) {
grants := grantRows("system.apply_updates=all", "content.read=all")
unlocked := role.Role{Grants: grants}.EffectiveGrants()
if !reflect.DeepEqual(unlocked, grantRows("content.read=all")) {
t.Fatalf("unexpected unlocked grants %v", unlocked)
}
locked := role.Role{Locked: true, Grants: grants}.EffectiveGrants()
if !reflect.DeepEqual(locked, grantRows("content.read=all", "system.apply_updates=all")) {
t.Fatalf("unexpected locked grants %v", locked)
}
})
t.Run("a role with nothing granted lists an empty slice, never nil", func(t *testing.T) {
if got := (role.Role{}).EffectiveGrants(); got == nil || len(got) != 0 {
t.Fatalf("expected an empty non-nil slice, got %#v", got)
}
})
t.Run("wildcard entries without effect are not listed", func(t *testing.T) {
if got := (role.Role{Grants: grantRows("content.create=own", "content.read=none")}).EffectiveGrants(); len(got) != 0 {
t.Fatalf("expected nothing, got %v", got)
}
})
t.Run("the effective list is valid as the grants of a write", func(t *testing.T) {
listed := role.Role{Grants: grantRows("content.update=own", "content.update=all", "content.delete:events=none", "media.view=all")}.EffectiveGrants()
if err := role.ValidateGrants(false, listed); err != nil {
t.Fatalf("expected the list to validate, got %v", err)
}
})
}
func TestDiffGrants(t *testing.T) {
t.Run("the example edit gives added, removed and changed", func(t *testing.T) {
before := grantRows("content.read=all", "content.update=own", "media.view=all")
after := grantRows("content.read=all", "content.update=all", "content.delete=own")
diff := role.DiffGrants(before, after)
if !reflect.DeepEqual(diff.Added, grantRows("content.delete=own")) {
t.Errorf("unexpected added %v", diff.Added)
}
if !reflect.DeepEqual(diff.Removed, grantRows("media.view=all")) {
t.Errorf("unexpected removed %v", diff.Removed)
}
if !reflect.DeepEqual(diff.Changed, []role.GrantChange{{Key: "content.update", From: role.ScopeOwn, To: role.ScopeAll}}) {
t.Errorf("unexpected changed %v", diff.Changed)
}
})
t.Run("an absent typed entry against a typed none is an addition", func(t *testing.T) {
diff := role.DiffGrants(nil, grantRows("content.delete:events=none"))
if len(diff.Added) != 1 || len(diff.Removed) != 0 || len(diff.Changed) != 0 {
t.Fatalf("unexpected diff %+v", diff)
}
})
t.Run("an unchanged list gives empty non-nil slices", func(t *testing.T) {
diff := role.DiffGrants(grantRows("content.read=all"), grantRows("content.read=all"))
if diff.Added == nil || diff.Removed == nil || diff.Changed == nil || !diff.Empty() {
t.Fatalf("expected empty non-nil slices, got %+v", diff)
}
})
}
func TestRoleWriteRules(t *testing.T) {
t.Run("the new sentinels are distinct and match with errors.Is", func(t *testing.T) {
sentinels := []error{role.ErrRoleHasMembers, role.ErrRoleNameTaken, role.ErrRoleLocked, role.ErrBuiltInRole, role.ErrBuiltInName, role.ErrOwnRole, role.ErrInvalidRoleName, role.ErrUnknownContentType, role.ErrNotBuiltIn, role.ErrInvalidReassign, role.ErrEscalation}
for index, sentinel := range sentinels {
for other, candidate := range sentinels {
if (index == other) != errors.Is(sentinel, candidate) {
t.Fatalf("sentinels %d and %d are not distinct", index, other)
}
}
}
})
t.Run("a name is trimmed and a blank one is refused", func(t *testing.T) {
name, err := role.NormalizeName(" Reviewer ")
if err != nil || name != "Reviewer" {
t.Fatalf("expected Reviewer, got %q %v", name, err)
}
if _, err := role.NormalizeName(" "); !errors.Is(err, role.ErrInvalidRoleName) {
t.Fatalf("expected ErrInvalidRoleName, got %v", err)
}
})
t.Run("names collide case-insensitively", func(t *testing.T) {
if !role.SameName(" contributor ", "Contributor") || role.SameName("Intern", "Contributor") {
t.Fatal("unexpected name comparison")
}
})
t.Run("typed keys must belong to an allowed type", func(t *testing.T) {
allowed := []string{"article", "events"}
if err := role.ValidateGrantTypes(grantRows("content.update:events=all", "content.read=all"), allowed); err != nil {
t.Fatalf("expected valid types, got %v", err)
}
for _, key := range []string{"content.update:nope=all", "content.update:author=all"} {
if err := role.ValidateGrantTypes(grantRows(key), allowed); !errors.Is(err, role.ErrUnknownContentType) {
t.Fatalf("%s: expected ErrUnknownContentType, got %v", key, err)
}
}
})
t.Run("the typed keys of a content type are the five per-type permissions", func(t *testing.T) {
want := []string{"content.read:events", "content.create:events", "content.update:events", "content.delete:events", "content.publish:events"}
if got := role.TypedKeys("events"); !reflect.DeepEqual(got, want) {
t.Fatalf("expected %v, got %v", want, got)
}
})
t.Run("the repository no longer lists grants on their own", func(t *testing.T) {
if _, present := reflect.TypeOf((*role.Repository)(nil)).Elem().MethodByName("ListGrants"); present {
t.Fatal("role.Repository must not have ListGrants")
}
for _, method := range []string{"List", "Get", "GetByName", "Create", "Save", "Delete", "ReassignMembers", "RemoveTypedGrants"} {
if _, present := reflect.TypeOf((*role.Repository)(nil)).Elem().MethodByName(method); !present {
t.Fatalf("role.Repository lacks %s", method)
}
}
})
}
func TestRoleActivityVocabulary(t *testing.T) {
t.Run("the roles category, target and actions exist", func(t *testing.T) {
if audit.CategoryRoles != "roles" || audit.TargetRole != "role" {
t.Fatalf("unexpected category %q or target %q", audit.CategoryRoles, audit.TargetRole)
}
actions := []string{audit.ActionRoleCreate, audit.ActionRoleEdit, audit.ActionRoleDelete, audit.ActionRoleReassign}
if !reflect.DeepEqual(actions, []string{"role.create", "role.edit", "role.delete", "role.reassign"}) {
t.Fatalf("unexpected actions %v", actions)
}
})
t.Run("an event in the roles category is valid and the filter accepts it", func(t *testing.T) {
event := audit.NewEvent(audit.ActionRoleCreate, audit.CategoryRoles)
event.Source = audit.SourceWeb
event.Actor = audit.AnonymousActor("x")
if err := event.Validate(); err != nil {
t.Fatalf("expected a valid event, got %v", err)
}
if category, err := audit.ParseCategory("roles"); err != nil || category != audit.CategoryRoles {
t.Fatalf("expected the roles filter, got %q %v", category, err)
}
})
}