- 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
212 lines
9.4 KiB
Go
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")
|
|
}
|
|
}
|