Files
summercms/modules/cabana/permissions_test.go
Jakub Zych 06e2e60ce1 feat(quick-261016-s8x): pact.NavigationItem.Visible hides navigation entries at request time
- pact.NavigationItem gains Visible func(ctx) bool (json:"-"); nil keeps the
  entry always listed
- cabana.Registry.Metadata runs it after the permission check, drops a main
  item (with its side menu) or a side item that answers false, and relinks a
  parent whose controller is a hidden child's to its first visible openable
  child
- TestNavigationHidesInvisibleItemsAndRepointsTarget covers the rules
- pact and cabana READMEs, users-and-permissions and admin-controllers docs
  describe the hook
2026-10-16 20:34:26 +02:00

212 lines
9.4 KiB
Go

package cabana
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
)
// TestAllowsFollowsWinterHasAnyAccess pins the permission check to Winter's
// User::hasAnyAccess: wildcards match on both sides and several required codes
// are an OR.
func TestAllowsFollowsWinterHasAnyAccess(t *testing.T) {
grant := func(codes ...string) *bouncer.Principal {
grants := map[string]bool{}
for _, code := range codes {
grants[code] = true
}
return &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: grants}
}
tests := []struct {
name string
principal *bouncer.Principal
required []string
want bool
}{
{"nil principal", nil, []string{"a.b.c"}, false},
{"superuser", &bouncer.Principal{Backend: true, IsSuperuser: true}, []string{"a.b.c"}, true},
{"empty requirement is open", grant(), nil, true},
{"exact grant", grant("a.b.c"), []string{"a.b.c"}, true},
{"missing grant", grant("a.b.d"), []string{"a.b.c"}, false},
{"grant wildcard covers code", grant("a.b.*"), []string{"a.b.c"}, true},
{"grant wildcard other prefix", grant("a.x.*"), []string{"a.b.c"}, false},
{"required wildcard met by any grant under the prefix", grant("a.b.access_genres"), []string{"a.b.*"}, true},
{"required wildcard not met by a sibling plugin", grant("a.x.access_genres"), []string{"a.b.*"}, false},
{"required wildcard with no grants", grant(), []string{"a.b.*"}, false},
{"required wildcard met by a grant wildcard", grant("a.b.*"), []string{"a.b.*"}, true},
{"required leading wildcard", grant("a.b.access_genres"), []string{"*.access_genres"}, true},
{"required leading wildcard miss", grant("a.b.access_styles"), []string{"*.access_genres"}, false},
{"several codes are any, first grants", grant("a.b.one"), []string{"a.b.one", "a.b.two"}, true},
{"several codes are any, last grants", grant("a.b.two"), []string{"a.b.one", "a.b.two"}, true},
{"several codes are any, none grants", grant("a.b.three"), []string{"a.b.one", "a.b.two"}, false},
{"disabled grant is not a grant", &bouncer.Principal{PermissionGrants: map[string]bool{"a.b.c": false}}, []string{"a.b.c"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Allows(tt.principal, tt.required); got != tt.want {
t.Fatalf("Allows(%v) = %v, want %v", tt.required, got, tt.want)
}
})
}
}
type navController struct {
id string
required []string
}
func (c navController) ID() string { return c.id }
func (navController) ModelName() string { return "Metadata" }
func (navController) ConfigDir() string { return "controllers/metadata" }
func (c navController) RequiredPermissions() []string { return c.required }
// TestNavigationDropsDeniedParentAndRepointsTarget covers the WR-02 rules: a
// main item the principal may not open is dropped whatever its children allow,
// and an allowed parent never links to a controller the principal cannot open.
func TestNavigationDropsDeniedParentAndRepointsTarget(t *testing.T) {
reg := &Registry{
byID: map[string]*CompiledController{
"acme.shop.albums": {Controller: navController{"acme.shop.albums", []string{"acme.shop.access_albums"}}},
"acme.shop.genres": {Controller: navController{"acme.shop.genres", []string{"acme.shop.access_genres"}}},
},
navigation: []pact.NavigationItem{
{
Code: "shop", Label: "Shop", Controller: "acme.shop.albums", Permissions: []string{"acme.shop.*"},
SideMenu: []pact.NavigationItem{
{Code: "albums", Label: "Albums", Controller: "acme.shop.albums", Permissions: []string{"acme.shop.access_albums"}},
{Code: "genres", Label: "Genres", Controller: "acme.shop.genres", Permissions: []string{"acme.shop.access_genres"}},
},
},
{
Code: "locked", Label: "Locked", Controller: "acme.shop.albums", Permissions: []string{"acme.locked.access"},
SideMenu: []pact.NavigationItem{
{Code: "genres", Label: "Genres", Controller: "acme.shop.genres", Permissions: []string{"acme.shop.access_genres"}},
},
},
},
}
genresOnly := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: map[string]bool{"acme.shop.access_genres": true}}
nav, _ := reg.Metadata(context.Background(), genresOnly, nil)
if len(nav) != 1 || nav[0].Code != "shop" {
t.Fatalf("navigation = %#v, want only the shop item (the locked parent must be dropped)", nav)
}
if nav[0].Controller != "acme.shop.genres" {
t.Fatalf("parent controller = %q, want the first openable child", nav[0].Controller)
}
if len(nav[0].SideMenu) != 1 || nav[0].SideMenu[0].Code != "genres" {
t.Fatalf("side menu = %#v", nav[0].SideMenu)
}
both := &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{"acme.shop.access_albums": true}}
nav, _ = reg.Metadata(context.Background(), both, nil)
if len(nav) != 1 || nav[0].Controller != "acme.shop.albums" {
t.Fatalf("navigation = %#v, want the parent to keep its own controller", nav)
}
}
type navVisibleKey struct{}
// TestNavigationHidesInvisibleItemsAndRepointsTarget covers the
// pact.NavigationItem.Visible rules: a side item whose Visible answers false
// is left out while a sibling with no Visible stays, a parent whose own
// controller is that hidden child's links to the first visible openable child
// (or to nothing when none is), a parent whose Visible answers false is
// dropped with its whole side menu, and Visible receives the context passed
// to Metadata. The principal is a superuser so permissions are not a factor.
func TestNavigationHidesInvisibleItemsAndRepointsTarget(t *testing.T) {
hidden := func(context.Context) bool { return false }
shown := func(context.Context) bool { return true }
var seen []any
fromCtx := func(ctx context.Context) bool {
seen = append(seen, ctx.Value(navVisibleKey{}))
return true
}
reg := &Registry{
byID: map[string]*CompiledController{
"acme.shop.orders": {Controller: navController{"acme.shop.orders", nil}},
"acme.shop.payments": {Controller: navController{"acme.shop.payments", nil}},
"acme.shop.quotes": {Controller: navController{"acme.shop.quotes", nil}},
},
navigation: []pact.NavigationItem{
{
Code: "shop", Label: "Shop", Controller: "acme.shop.orders", Order: 1,
SideMenu: []pact.NavigationItem{
{Code: "orders", Label: "Orders", Controller: "acme.shop.orders", Visible: hidden},
{Code: "payments", Label: "Payments", Controller: "acme.shop.payments"},
{Code: "quotes", Label: "Quotes", Controller: "acme.shop.quotes", Visible: fromCtx},
},
},
{
Code: "off", Label: "Off", Controller: "acme.shop.payments", Order: 2, Visible: hidden,
SideMenu: []pact.NavigationItem{
{Code: "payments", Label: "Payments", Controller: "acme.shop.payments", Visible: shown},
},
},
{
Code: "empty", Label: "Empty", Controller: "acme.shop.quotes", Order: 3,
SideMenu: []pact.NavigationItem{
{Code: "quotes", Label: "Quotes", Controller: "acme.shop.quotes", Visible: hidden},
},
},
},
}
super := &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}
ctx := context.WithValue(context.Background(), navVisibleKey{}, "request")
nav, _ := reg.Metadata(ctx, super, nil)
if len(nav) != 2 || nav[0].Code != "shop" || nav[1].Code != "empty" {
t.Fatalf("navigation = %#v, want shop and empty (the off parent must be dropped although its child is visible)", nav)
}
shop := nav[0]
if len(shop.SideMenu) != 2 || shop.SideMenu[0].Code != "payments" || shop.SideMenu[1].Code != "quotes" {
t.Fatalf("shop side menu = %#v, want payments and quotes (orders hidden)", shop.SideMenu)
}
if shop.Controller != "acme.shop.payments" {
t.Fatalf("shop controller = %q, want the first visible openable child", shop.Controller)
}
empty := nav[1]
if len(empty.SideMenu) != 0 || empty.Controller != "" {
t.Fatalf("empty = %#v, want no side menu and no controller", empty)
}
if len(seen) != 1 || seen[0] != "request" {
t.Fatalf("Visible saw context values %v, want the one passed to Metadata", seen)
}
}
// TestRelationMutationsFollowToolbarButtons pins WR-05: link and unlink are
// refused unless the relation's view panel declares them.
func TestRelationMutationsFollowToolbarButtons(t *testing.T) {
svc := phase09DeniedService()
cc := svc.reg.byID["acme.demo.widgets"]
super := &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}
relation := func(buttons ...string) {
cc.Relations = map[string]*CompiledRelation{"editors": {Schema: &RelationSchema{Name: "editors", View: RelationPanel{ToolbarButtons: buttons}}}}
}
call := func(handler func(*service, http.ResponseWriter, *http.Request)) int {
rec := httptest.NewRecorder()
handler(svc, rec, phase09Request(super))
return rec.Code
}
relation("link")
if code := call((*service).relationUnlink); code != http.StatusForbidden {
t.Fatalf("unlink on a link-only relation = %d, want 403", code)
}
if code := call((*service).relationLink); code == http.StatusForbidden {
t.Fatal("link on a link-only relation was refused")
}
relation()
for name, handler := range map[string]func(*service, http.ResponseWriter, *http.Request){"link": (*service).relationLink, "unlink": (*service).relationUnlink} {
if code := call(handler); code != http.StatusForbidden {
t.Fatalf("%s on a relation with no buttons = %d, want 403", name, code)
}
}
relation("link", "unlink")
if code := call((*service).relationUnlink); code == http.StatusForbidden {
t.Fatal("unlink on a link|unlink relation was refused")
}
}