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 }