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) } } // 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") } }