package test import ( "RenewCMS/internal/domain/role" "errors" "go/ast" "go/parser" "go/token" "path/filepath" "reflect" "slices" "strconv" "strings" "testing" ) func TestGrantSetScopeFor(t *testing.T) { longKey := strings.Repeat("a", 101) cases := []struct { grants []string key string typeKey string want role.Scope }{ {[]string{"content.update=all"}, "content.update", "", role.ScopeAll}, {[]string{"content.update=all"}, "content.update", "blog", role.ScopeAll}, {[]string{"content.update=all", "content.update:blog=own"}, "content.update", "blog", role.ScopeOwn}, {[]string{"content.update=all", "content.update:blog=own"}, "content.update", "news", role.ScopeAll}, {[]string{"content.update=all", "content.update:blog=none"}, "content.update", "blog", role.ScopeNone}, {[]string{"content.update=own", "content.update:blog=all"}, "content.update", "blog", role.ScopeAll}, {[]string{"content.update:blog=all"}, "content.update", "blog", role.ScopeAll}, {[]string{"content.update:blog=all"}, "content.update", "news", role.ScopeNone}, {[]string{"content.update:blog=all"}, "content.update", "", role.ScopeNone}, {[]string{"content.create=all", "content.create:blog=none"}, "content.create", "blog", role.ScopeNone}, {[]string{"content.create=all", "content.create:blog=all"}, "content.create", "blog", role.ScopeAll}, {[]string{"content.read=all"}, "content.read", "Bad Key", role.ScopeNone}, {[]string{"content.read=all"}, "content.read", longKey, role.ScopeNone}, {[]string{"schema.view=all"}, "schema.view", "blog", role.ScopeNone}, {[]string{"schema.view=all"}, "schema.view", "", role.ScopeAll}, {[]string{"content.explode=all"}, "content.explode", "", role.ScopeNone}, {nil, "content.read", "", role.ScopeNone}, } for _, c := range cases { name := strings.Join(c.grants, ", ") + " | " + c.key + " | " + c.typeKey if len(name) > 90 { name = name[:90] } t.Run(name, func(t *testing.T) { if got := unlockedGrantSet(c.grants...).ScopeFor(c.key, c.typeKey); got != c.want { t.Errorf("expected %s, got %s", c.want, got) } }) } t.Run("a zero GrantSet resolves everything to none", func(t *testing.T) { var empty role.GrantSet if empty.ScopeFor("content.read", "") != role.ScopeNone || empty.ScopeFor("content.read", "blog") != role.ScopeNone || empty.Holds("content.read") || empty.HoldsAll("content.read") || empty.HoldsFor("content.read", "blog") { t.Error("a zero set must hold nothing") } }) t.Run("HoldsFor is true for own and all of the typed scope only", func(t *testing.T) { set := unlockedGrantSet("content.update=all", "content.update:blog=own", "content.update:news=none") if !set.HoldsFor("content.update", "blog") || !set.HoldsFor("content.update", "other") || set.HoldsFor("content.update", "news") { t.Errorf("unexpected HoldsFor results") } }) } func TestGrantSetPermits(t *testing.T) { cases := []struct { scope role.Scope caller uint32 createdBy uint32 want bool }{ {role.ScopeAll, 9, 21, true}, {role.ScopeAll, 9, 0, true}, {role.ScopeOwn, 21, 21, true}, {role.ScopeOwn, 9, 21, false}, {role.ScopeOwn, 21, 0, false}, {role.ScopeOwn, 0, 0, false}, {role.ScopeNone, 21, 21, false}, } for _, c := range cases { name := string(c.scope) + " caller " + strconv.Itoa(int(c.caller)) + " created_by " + strconv.Itoa(int(c.createdBy)) t.Run(name, func(t *testing.T) { set := unlockedGrantSet("content.update:blog=" + string(c.scope)) if got := set.Permits("content.update", "blog", c.caller, c.createdBy); got != c.want { t.Errorf("expected %v, got %v", c.want, got) } }) } t.Run("a typed scope replaces the wildcard for Permits", func(t *testing.T) { set := unlockedGrantSet("content.update=all", "content.update:blog=own") if set.Permits("content.update", "blog", 9, 21) || !set.Permits("content.update", "news", 9, 21) { t.Errorf("unexpected Permits results") } }) } func TestRoleGrantSetNormalisation(t *testing.T) { duplicates := []struct { rows []string key string typeKey string want role.Scope }{ {[]string{"content.update=own", "content.update=all"}, "content.update", "", role.ScopeOwn}, {[]string{"content.update=all", "content.update=own"}, "content.update", "", role.ScopeOwn}, {[]string{"content.update:blog=all", "content.update:blog=own"}, "content.update", "blog", role.ScopeOwn}, {[]string{"content.update=all", "content.update:blog=own"}, "content.update", "blog", role.ScopeOwn}, {[]string{"content.read=all", "content.read=none"}, "content.read", "", role.ScopeNone}, } for _, c := range duplicates { t.Run("duplicates resolve to the most restrictive: "+strings.Join(c.rows, ", "), func(t *testing.T) { forward := unlockedGrantSet(c.rows...) reversed := slices.Clone(c.rows) slices.Reverse(reversed) backward := unlockedGrantSet(reversed...) if forward.ScopeFor(c.key, c.typeKey) != c.want || backward.ScopeFor(c.key, c.typeKey) != c.want { t.Errorf("expected %s both ways, got %s and %s", c.want, forward.ScopeFor(c.key, c.typeKey), backward.ScopeFor(c.key, c.typeKey)) } }) } t.Run("a toggle key stored at own is none", func(t *testing.T) { if got := unlockedGrantSet("schema.view=own").ScopeFor("schema.view", ""); got != role.ScopeNone { t.Errorf("expected none, got %s", got) } }) t.Run("an unknown scope string is none", func(t *testing.T) { if got := unlockedGrantSet("content.read=everything").ScopeFor("content.read", ""); got != role.ScopeNone { t.Errorf("expected none, got %s", got) } }) t.Run("a typed key on a non-content key or with an invalid type key is dropped", func(t *testing.T) { set := unlockedGrantSet("schema.view:blog=all", "content.read:Bad Key=all", "content.read=all") if set.ScopeFor("schema.view", "blog") != role.ScopeNone || set.ScopeFor("content.read", "Bad Key") != role.ScopeNone || set.ScopeFor("content.read", "x") != role.ScopeAll { t.Errorf("unexpected scopes") } }) } func TestRoleGrantSetOwnerOnlyKeys(t *testing.T) { ownerOnly := []string{"settings.delete_site", "roles.edit_owner_only", "system.apply_updates", "system.edit_retention", "system.transfer_ownership"} t.Run("owner-only keys are dropped on a role that is not locked", func(t *testing.T) { for _, key := range ownerOnly { set := role.Role{Locked: false, Grants: grantRows(key + "=all")}.GrantSet() if set.ScopeFor(key, "") != role.ScopeNone || set.Holds(key) || set.HoldsAll(key) { t.Errorf("%s must not be effective on an unlocked role", key) } } }) t.Run("owner-only keys are kept on a locked role", func(t *testing.T) { for _, key := range ownerOnly { set := role.Role{Locked: true, Grants: grantRows(key + "=all")}.GrantSet() if set.ScopeFor(key, "") != role.ScopeAll { t.Errorf("%s must be effective on a locked role", key) } } }) t.Run("the built-in roles keep their catalogue", func(t *testing.T) { builtIns := role.BuiltInRoles() owner, admin := builtIns[0].GrantSet(), builtIns[1].GrantSet() for _, permission := range role.Catalogue() { if owner.ScopeFor(permission.Key, "") != role.ScopeAll { t.Errorf("Owner lacks %s", permission.Key) } if permission.OwnerOnly && admin.ScopeFor(permission.Key, "") != role.ScopeNone { t.Errorf("Admin holds %s", permission.Key) } } }) } func TestValidateGrantsRows(t *testing.T) { cases := []struct { rows []string want error }{ {[]string{"content.update=all", "content.update=own"}, role.ErrDuplicateGrant}, {[]string{"content.update:blog=all", "content.update:blog=own"}, role.ErrDuplicateGrant}, {[]string{"content.update=all", "content.update:blog=own"}, nil}, {[]string{"content.update:blog=none"}, nil}, {[]string{"content.update=none"}, role.ErrInvalidScope}, {[]string{"content.create:blog=none"}, nil}, {[]string{"content.create:blog=own"}, role.ErrInvalidScope}, {[]string{"content.update:Bad Key=all"}, role.ErrUnknownPermission}, {[]string{"schema.view:blog=all"}, role.ErrUnknownPermission}, {[]string{"settings.delete_site=all"}, role.ErrOwnerOnlyGrant}, } for _, c := range cases { t.Run(strings.Join(c.rows, ", "), func(t *testing.T) { err := role.ValidateGrants(false, grantRows(c.rows...)) if c.want == nil && err != nil || c.want != nil && !errors.Is(err, c.want) { t.Errorf("expected %v, got %v", c.want, err) } }) } } func TestRoleHasNoParallelDecisionHelpers(t *testing.T) { roleType := reflect.TypeOf(role.Role{}) for _, name := range []string{"Allows", "AllowsFor", "scopeOf"} { if _, present := roleType.MethodByName(name); present { t.Errorf("Role must not have %s", name) } } for _, source := range roleDomainSources(t) { if strings.Contains(source.text, "func NewGrantSet") || strings.Contains(source.text, "scopeOf(") { t.Errorf("%s still holds a parallel implementation", source.path) } } } type goSource struct { path string text string } func roleDomainSources(t *testing.T) []goSource { t.Helper() files, err := filepath.Glob("../internal/domain/role/*.go") if err != nil || len(files) == 0 { t.Fatalf("no role domain files: %v", err) } sources := []goSource{} for _, file := range files { sources = append(sources, goSource{path: file, text: readTestFile(t, file)}) } return sources } func TestRoleKeyConstantsMatchTheCatalogue(t *testing.T) { file, err := parser.ParseFile(token.NewFileSet(), "../internal/domain/role/roleKeys.go", nil, 0) if err != nil { t.Fatal(err) } constants := map[string]string{} ast.Inspect(file, func(node ast.Node) bool { spec, ok := node.(*ast.ValueSpec) if !ok { return true } for index, name := range spec.Names { literal, isLiteral := spec.Values[index].(*ast.BasicLit) if !isLiteral || !name.IsExported() { continue } value, _ := strconv.Unquote(literal.Value) constants[name.Name] = value } return true }) catalogueKeys := map[string]bool{} for _, permission := range role.Catalogue() { catalogueKeys[permission.Key] = true } constantValues := map[string]bool{} for name, value := range constants { constantValues[value] = true if !catalogueKeys[value] { t.Errorf("constant %s = %q is not a catalogue key", name, value) } if want := constantNameOf(value); name != want { t.Errorf("constant for %q is named %s, want %s", value, name, want) } } for key := range catalogueKeys { if !constantValues[key] { t.Errorf("catalogue key %q has no constant", key) } } if len(constants) != len(catalogueKeys) { t.Errorf("expected %d constants, got %d", len(catalogueKeys), len(constants)) } } func constantNameOf(key string) string { group, action, _ := strings.Cut(key, ".") words := append([]string{group}, strings.Split(action, "_")...) name := "Key" for _, word := range words { if word == "apikeys" { name += "APIKeys" continue } name += strings.ToUpper(word[:1]) + word[1:] } return name }