Files
summercms/modules/cabana/token_scopes_test.go
Jakub Zych 78124ab86f test(16.3-04): full Go coverage for tokens, guard, account API and CLI
Pin bouncer Scope serialization, party Activated order, lagoon migration,
compile/overlap/mint/expiry/validation, guard/debounce/resolver,
interactive-only, account token API and CLI on Postgres.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 13:32:11 +02:00

355 lines
14 KiB
Go

package cabana
import (
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/phrasebook"
)
func TestScopeMatch(t *testing.T) {
cases := []struct {
name string
pattern string
required string
want int
}{
{"exact full same", "a.b.c", "a.b.c", matchFull},
{"exact full via a.b.*", "a.b.*", "a.b.c", matchFull},
{"exact full via a.*", "a.*", "a.b.c", matchFull},
{"exact none a.x.*", "a.x.*", "a.b.c", matchNone},
{"exact none a.b.d", "a.b.d", "a.b.c", matchNone},
{"wild full same", "a.b.*", "a.b.*", matchFull},
{"wild full broader a.*", "a.*", "a.b.*", matchFull},
{"wild partial narrower pattern", "a.b.*", "a.*", matchPartial},
{"wild partial exact narrower", "a.b.c", "a.*", matchPartial},
{"wild none a.x.*", "a.x.*", "a.b.*", matchNone},
{"wild none x.*", "x.*", "a.b.*", matchNone},
{"leading partial exact", "*.manage", "a.manage", matchPartial},
{"leading none wild required", "*.manage", "a.manage.*", matchNone},
{"leading none wild pattern required", "*.manage", "*.manage", matchNone},
{"empty pattern", "", "a.b.c", matchNone},
{"empty required", "a.b.c", "", matchNone},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := scopeMatch(tc.pattern, tc.required); got != tc.want {
t.Fatalf("scopeMatch(%q, %q) = %d, want %d", tc.pattern, tc.required, got, tc.want)
}
})
}
}
func TestAllowsScope(t *testing.T) {
dev := &bouncer.Principal{
ID: 1,
Backend: true,
PermissionGrants: map[string]bool{
"acme.demo.access": true,
"acme.demo.reports": true,
},
}
super := &bouncer.Principal{ID: 2, Backend: true, IsSuperuser: true}
cases := []struct {
name string
p *bouncer.Principal
scope *bouncer.ScopeLimit
required []string
want bool
}{
{
name: "JWT nil scope keeps empty-list shortcut",
p: dev, scope: nil, required: nil, want: true,
},
{
name: "JWT nil scope permission grant",
p: dev, scope: nil, required: []string{"acme.demo.access"}, want: true,
},
{
name: "JWT nil scope missing grant",
p: dev, scope: nil, required: []string{"acme.other.x"}, want: false,
},
{
name: "JWT superuser empty list",
p: super, scope: nil, required: nil, want: true,
},
{
name: "full * every method empty list",
p: dev, scope: &bouncer.ScopeLimit{Full: true}, required: nil, want: true,
},
{
name: "full * permission as owner",
p: dev, scope: &bouncer.ScopeLimit{Full: true}, required: []string{"acme.demo.access"}, want: true,
},
{
name: "full admin:read empty list",
p: dev, scope: &bouncer.ScopeLimit{Full: true}, required: nil, want: true,
},
{
name: "plugin scope with owner grant",
p: dev,
scope: &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.*"}}},
required: []string{"acme.demo.access"}, want: true,
},
{
name: "plugin scope without owner grant",
p: &bouncer.Principal{
ID: 3, Backend: true,
PermissionGrants: map[string]bool{"acme.other.x": true},
},
scope: &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.*"}}},
required: []string{"acme.demo.access"}, want: false,
},
{
name: "superuser with scoped token limited",
p: super,
scope: &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.reports"}}},
required: []string{"acme.demo.access"}, want: false,
},
{
name: "superuser scoped token matching scope code",
p: super,
scope: &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.reports"}}},
required: []string{"acme.demo.reports"}, want: true,
},
{
name: "empty requirement list refused unless full",
p: dev,
scope: &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.*"}}},
required: nil, want: false,
},
{
name: "multi-code literal: one scope must match every code",
p: dev,
scope: &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.*"}}},
required: []string{"acme.demo.access", "acme.demo.reports"}, want: true,
},
{
name: "multi-code literal: two scopes each matching part fails",
p: dev,
scope: &bouncer.ScopeLimit{Scopes: [][]string{
{"acme.demo.access"},
{"acme.demo.reports"},
}},
required: []string{"acme.demo.access", "acme.demo.reports"}, want: false,
},
{
name: "wildcard requirement broader pattern partial via ownerHas(pattern)",
p: &bouncer.Principal{
ID: 4, Backend: true,
PermissionGrants: map[string]bool{"acme.demo.*": true},
},
scope: &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.access"}}},
required: []string{"acme.demo.*"}, want: true,
},
{
name: "wildcard requirement equal pattern full",
p: dev,
scope: &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.*"}}},
required: []string{"acme.demo.*"}, want: true,
},
{
name: "wildcard requirement narrower pattern none for Allows owner side",
p: &bouncer.Principal{
ID: 5, Backend: true,
PermissionGrants: map[string]bool{"acme.demo.access": true},
},
scope: &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.access.extra"}}},
required: []string{"acme.demo.*"}, want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p := *tc.p
p.Scope = tc.scope
if got := Allows(&p, tc.required); got != tc.want {
t.Fatalf("Allows = %v, want %v", got, tc.want)
}
})
}
}
type scopePlugin struct {
id string
perms []pact.Permission
scopes []pact.TokenScope
}
func (p scopePlugin) ID() string { return p.id }
func (p scopePlugin) Requires() []string { return nil }
func (p scopePlugin) Register(*backpack.App) error { return nil }
func (p scopePlugin) Boot(*backpack.App) error { return nil }
func (p scopePlugin) Permissions() []pact.Permission {
if p.perms != nil {
return p.perms
}
return []pact.Permission{
{Code: "acme.demo.access", Roles: []string{"developer"}},
{Code: "acme.demo.reports", Roles: []string{"developer"}},
}
}
func (p scopePlugin) TokenScopes() []pact.TokenScope { return p.scopes }
func TestTokenScopeCompile(t *testing.T) {
basePerms := []pact.Permission{
{Code: "acme.demo.access", Roles: []string{"developer"}},
{Code: "acme.demo.reports", Roles: []string{"developer"}},
}
all := []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"}
cases := []struct {
name string
scopes []pact.TokenScope
plugins []party.Plugin
want string
}{
{"bad name", []pact.TokenScope{{Name: "Bad", Label: "L", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: []string{"GET"}}}, nil, "invalid token scope name"},
// "*" fails the name pattern before the reserved check.
{"reserved *", []pact.TokenScope{{Name: "*", Label: "L", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: []string{"GET"}}}, nil, "invalid token scope name"},
{"reserved admin", []pact.TokenScope{{Name: "admin", Label: "L", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: []string{"GET"}}}, nil, "reserved"},
{"reserved admin:x", []pact.TokenScope{{Name: "admin:x", Label: "L", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: []string{"GET"}}}, nil, "reserved"},
{"empty permissions", []pact.TokenScope{{Name: "demo", Label: "L", Description: "D", Permissions: nil, Methods: []string{"GET"}}}, nil, "empty permissions"},
{"empty methods", []pact.TokenScope{{Name: "demo", Label: "L", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: nil}}, nil, "empty methods"},
{"lower-case method", []pact.TokenScope{{Name: "demo", Label: "L", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: []string{"get"}}}, nil, "invalid method"},
{"unknown method", []pact.TokenScope{{Name: "demo", Label: "L", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: []string{"GET", "TRACE"}}}, nil, "invalid method"},
{"missing GET", []pact.TokenScope{{Name: "demo", Label: "L", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: []string{"POST"}}}, nil, "must include GET"},
{"unknown permission", []pact.TokenScope{{Name: "demo", Label: "L", Description: "D", Permissions: []string{"acme.missing.x"}, Methods: []string{"GET"}}}, nil, "unknown permission"},
{"empty label", []pact.TokenScope{{Name: "demo", Label: "", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: []string{"GET"}}}, nil, "empty label"},
{"empty description", []pact.TokenScope{{Name: "demo", Label: "L", Description: " ", Permissions: []string{"acme.demo.access"}, Methods: []string{"GET"}}}, nil, "empty description"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
plugins := tc.plugins
if plugins == nil {
plugins = []party.Plugin{scopePlugin{id: "acme.demo", perms: basePerms, scopes: tc.scopes}}
}
_, err := compileTokenCatalog(plugins)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err = %v, want substring %q", err, tc.want)
}
})
}
t.Run("duplicate naming both plugins", func(t *testing.T) {
a := scopePlugin{id: "acme.a", perms: []pact.Permission{{Code: "acme.a.access", Roles: []string{"developer"}}}, scopes: []pact.TokenScope{{
Name: "demo", Label: "Demo", Description: "D",
Permissions: []string{"acme.a.access"}, Methods: all,
}}}
b := scopePlugin{id: "acme.b", perms: []pact.Permission{{Code: "acme.b.access", Roles: []string{"developer"}}}, scopes: []pact.TokenScope{{
Name: "demo", Label: "Demo", Description: "D",
Permissions: []string{"acme.b.access"}, Methods: all,
}}}
_, err := compileTokenCatalog([]party.Plugin{a, b})
if err == nil || !strings.Contains(err.Error(), "duplicate") {
t.Fatalf("err = %v", err)
}
if !strings.Contains(err.Error(), "acme.a") || !strings.Contains(err.Error(), "acme.b") {
t.Fatalf("duplicate must name both plugins: %v", err)
}
})
t.Run("valid order framework then plugins", func(t *testing.T) {
first := scopePlugin{id: "acme.first", perms: basePerms, scopes: []pact.TokenScope{{
Name: "first", Label: "First", Description: "D",
Permissions: []string{"acme.demo.access"}, Methods: all,
}}}
second := scopePlugin{id: "acme.second", perms: []pact.Permission{
{Code: "acme.other.x", Roles: []string{"developer"}},
}, scopes: []pact.TokenScope{{
Name: "second", Label: "Second", Description: "D",
Permissions: []string{"acme.other.x"}, Methods: []string{"GET", "HEAD"},
}}}
reg, err := compileTokenCatalog([]party.Plugin{first, second})
if err != nil {
t.Fatal(err)
}
names := make([]string, 0, len(reg.tokenScopes.ordered))
for _, s := range reg.tokenScopes.ordered {
names = append(names, s.Name)
}
want := []string{"*", "admin:read", "first", "second"}
if !stringSlicesEqual(names, want) {
t.Fatalf("order = %v, want %v", names, want)
}
})
t.Run("unresolved phrase key", func(t *testing.T) {
reg, err := compileTokenCatalog([]party.Plugin{scopePlugin{id: "acme.demo", perms: basePerms, scopes: []pact.TokenScope{{
Name: "demo", Label: "acme.demo::lang.missing", Description: "plain",
Permissions: []string{"acme.demo.access"}, Methods: all,
}}}})
if err != nil {
t.Fatal(err)
}
tr := phrasebook.NewTranslator(phrasebook.NewCatalog(), phrasebook.Options{Locale: "en", Fallback: "en"})
if err := validateTokenScopeMessageKeys(reg, tr); err == nil || !strings.Contains(err.Error(), "missing phrase key") {
t.Fatalf("err = %v", err)
}
})
}
func TestScopeOverlap(t *testing.T) {
reg, err := compileTokenCatalog([]party.Plugin{scopePlugin{id: "acme.demo", scopes: []pact.TokenScope{{
Name: "demo", Label: "Demo", Description: "D",
Permissions: []string{"acme.demo.*"}, Methods: []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"},
}}}})
if err != nil {
t.Fatal(err)
}
dev := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: map[string]bool{"acme.demo.access": true}}
none := &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{"acme.other.x": true}}
super := &bouncer.Principal{ID: 3, Backend: true, IsSuperuser: true}
if !scopeOverlaps(reg, dev, "*") {
t.Fatal("* always overlaps")
}
if !scopeOverlaps(reg, none, "*") {
t.Fatal("* always overlaps even without grants")
}
if !scopeOverlaps(reg, super, "admin:read") || !scopeOverlaps(reg, dev, "admin:read") {
t.Fatal("admin:read for superuser or any catalog grant")
}
if scopeOverlaps(reg, &bouncer.Principal{ID: 4, Backend: true}, "admin:read") {
t.Fatal("admin:read without grants or superuser")
}
if !scopeOverlaps(reg, dev, "demo") {
t.Fatal("demo should overlap for developer")
}
if scopeOverlaps(reg, none, "demo") {
t.Fatal("demo must not overlap without matching grant")
}
}
func TestScopeCoversAll(t *testing.T) {
full := &bouncer.ScopeLimit{Full: true}
if !scopeCoversAll(full, []string{"a.b"}) || !scopeCoversAll(nil, []string{"a.b"}) {
t.Fatal("full/nil cover everything")
}
limit := &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.*"}}}
if !scopeCoversAll(limit, []string{"acme.demo.access", "acme.demo.reports"}) {
t.Fatal("full match cover")
}
if scopeCoversAll(limit, []string{"acme.other.x"}) {
t.Fatal("none")
}
partial := &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.access"}}}
if !scopeCoversAll(partial, []string{"acme.demo.*"}) {
t.Fatal("partial cover of broader required")
}
if scopeCoversAll(limit, nil) {
t.Fatal("empty cover refused for non-full")
}
}
func stringSlicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}