296 lines
11 KiB
Go
296 lines
11 KiB
Go
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
|
|
}
|