package folder import ( "bytes" "context" "encoding/json" "fmt" "net/http" "strings" "testing" "github.com/stretchr/testify/require" "github.com/xlab/treeprint" apierrors "k8s.io/apimachinery/pkg/api/errors" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic" "k8s.io/client-go/rest" dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" folderV1 "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" "github.com/grafana/grafana/pkg/apimachinery/utils" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/search/model" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tests/apis" "github.com/grafana/grafana/pkg/tests/testinfra" "github.com/grafana/grafana/pkg/util/testutil" ) func TestIntegrationFolderTree(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) if !db.IsTestDbSQLite() { t.Skip("test only on sqlite for now") } modes := []grafanarest.DualWriterMode{ grafanarest.Mode0, // legacy only grafanarest.Mode2, // write both, read legacy grafanarest.Mode3, // write both, read unified grafanarest.Mode4, grafanarest.Mode5, } for _, mode := range modes { t.Run(fmt.Sprintf("mode %d", mode), func(t *testing.T) { flags := []string{} if mode >= grafanarest.Mode3 { // make sure modes 0-3 work without it flags = append(flags, featuremgmt.FlagUnifiedStorageSearch) } helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ AppModeProduction: true, DisableAnonymous: true, APIServerStorageType: "unified", EnableFeatureToggles: flags, UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ "dashboards.dashboard.grafana.app": { DualWriterMode: mode, }, "folders.folder.grafana.app": { DualWriterMode: mode, }, }, }) defer helper.Shutdown() tests := []struct { Name string Definition FolderDefinition Expected []ExpectedTree }{ { Name: "admin-only-tree", Definition: FolderDefinition{ Children: []FolderDefinition{ {Name: "top", Creator: helper.Org1.Admin, Children: []FolderDefinition{ {Name: "middle", Creator: helper.Org1.Admin, Children: []FolderDefinition{ {Name: "child", Creator: helper.Org1.Admin, Permissions: []FolderPermission{{ Permission: "View", User: helper.Org1.None, }}, }, }, }, }, }, }, }, Expected: []ExpectedTree{ {User: helper.Org1.Admin, Listing: ` └── top (admin,edit,save,delete) ....└── middle (admin,edit,save,delete) ........└── child (admin,edit,save,delete)`}, {User: helper.Org1.Viewer, Listing: ` └── top (view) ....└── middle (view) ........└── child (view)`}, {User: helper.Org1.None, Listing: ` └── sharedwithme (???) ....└── child (view)`, E403: []string{"top", "middle"}, }, }, }, } var statusCode int for _, tt := range tests { t.Run(tt.Name, func(t *testing.T) { tt.Definition.RequireUniqueName(t, make(map[string]bool)) tt.Definition.CreateWithLegacyAPI(t, helper, "") // CreateWithLegacyAPI for _, expect := range tt.Expected { unstructured, client := getFolderClients(t, expect.User) t.Run(fmt.Sprintf("query as %s", expect.User.Identity.GetLogin()), func(t *testing.T) { legacy := getFoldersFromLegacyAPISearch(t, client) legacy.requireEqual(t, expect.Listing, "legacy") listed := getFoldersFromAPIServerList(t, unstructured) listed.requireEqual(t, expect.Listing, "listed") search := getFoldersFromDashboardV0Search(t, client, expect.User.Identity.GetNamespace()) search.requireEqual(t, expect.Listing, "search") // ensure sure GET also works on each folder we can list listed.forEach(func(fv *FolderView) { if fv.Name == folder.SharedWithMeFolderUID { return // skip it } found, err := unstructured.Get(context.Background(), fv.Name, v1.GetOptions{}) require.NoErrorf(t, err, "getting folder: %s", fv.Name) require.Equal(t, found.GetName(), fv.Name) }) // Forbidden things should really be hidden for _, name := range expect.E403 { _, err := unstructured.Get(context.Background(), name, v1.GetOptions{}) require.Error(t, err) require.Truef(t, apierrors.IsForbidden(err), "error: %w", err) // 404 vs 403 ???? result := client.Get().AbsPath("api", "folders", name). Do(context.Background()). StatusCode(&statusCode) require.Equal(t, int(http.StatusForbidden), statusCode) require.Error(t, result.Error()) // Verify sub-resources are hidden for _, sub := range []string{"access", "parents", "children", "counts"} { _, err := unstructured.Get(context.Background(), name, v1.GetOptions{}, sub) require.Error(t, err, "expect error for subresource", sub) require.Truef(t, apierrors.IsForbidden(err), "error: %w", err) // 404 vs 403 ???? } // Verify legacy API access is also hidden for _, sub := range []string{"permissions", "counts"} { result := client.Get().AbsPath("api", "folders", name, sub). Do(context.Background()). StatusCode(&statusCode) require.Equalf(t, int(http.StatusForbidden), statusCode, "legacy access to: %s", sub) require.Error(t, result.Error()) } } }) } }) } }) } } type ExpectedTree struct { User apis.User Listing string E403 []string } type FolderDefinition struct { Name string Creator apis.User // The user who will create the folder Permissions []FolderPermission Children []FolderDefinition } type FolderPermission struct { Permission string User apis.User // Team team.Team // Role identity.RoleType } func (f *FolderDefinition) CreateWithLegacyAPI(t *testing.T, h *apis.K8sTestHelper, parent string) { if f.Name == "" { require.Empty(t, parent, "only the root should be empty") } else { cfg := dynamic.ConfigFor(f.Creator.NewRestConfig()) cfg.GroupVersion = &schema.GroupVersion{Group: "folder.grafana.app", Version: "v1beta1"} // group does not matter client, err := rest.RESTClientFor(cfg) require.NoError(t, err) body, err := json.Marshal(map[string]any{ "uid": f.Name, "title": f.Name, "parentUid": parent, }) require.NoError(t, err) var statusCode int result := client.Post().AbsPath("api", "folders"). Body(body). SetHeader("Content-type", "application/json"). Do(context.Background()). StatusCode(&statusCode) require.NoError(t, result.Error(), f.Name) require.Equal(t, int(http.StatusOK), statusCode, f.Name) parent = f.Name if len(f.Permissions) > 0 { for _, def := range f.Permissions { // http://localhost:3000/api/access-control/folders/feyx0ezuwqwowb/users/aeyx0jzgix9fkd body = []byte(`{"permission": "` + def.Permission + `"}`) require.NotEmpty(t, def.Permission, "invalid permission: %+v", def) if def.User.Identity != nil { result = client.Post().AbsPath( "api", "access-control", "folders", f.Name, "users", def.User.Identity.GetIdentifier()). Body(body). SetHeader("Content-type", "application/json"). Do(context.Background()). StatusCode(&statusCode) err = result.Error() require.NoErrorf(t, err, "legacy access control: %s: %s", f.Name, body) require.Equal(t, int(http.StatusOK), statusCode, f.Name) } } } } for _, child := range f.Children { child.CreateWithLegacyAPI(t, h, parent) } } func (f *FolderDefinition) CreateWithAPIServer(t *testing.T, h *apis.K8sTestHelper, parent string) { if f.Name == "" { require.Empty(t, parent, "only the root should be empty") } else { gvr := schema.GroupVersionResource{Group: "folder.grafana.app", Version: "v1beta1", Resource: "folders"} ns := f.Creator.Identity.GetNamespace() cfg := dynamic.ConfigFor(f.Creator.NewRestConfig()) dyn, err := dynamic.NewForConfig(cfg) require.NoError(t, err) client := dyn.Resource(gvr).Namespace(ns) obj, err := client.Create(context.Background(), &unstructured.Unstructured{ Object: map[string]interface{}{ "metadata": map[string]interface{}{ "name": f.Name, "namespace": ns, "annotations": map[string]string{ utils.AnnoKeyFolder: parent, }, }, "spec": map[string]interface{}{ "title": f.Name, }, }, }, v1.CreateOptions{}) require.NoError(t, err) require.Equal(t, f.Name, obj.GetName()) } for _, child := range f.Children { child.CreateWithAPIServer(t, h, parent) } } func (f *FolderDefinition) RequireUniqueName(t *testing.T, names map[string]bool) { if f.Name != "" && names[f.Name] { t.Fatalf("duplicate name: %s", f.Name) } names[f.Name] = true for _, child := range f.Children { child.RequireUniqueName(t, names) } } type FolderView struct { Name string Parent string Title string Children []*FolderView Access *folderV1.FolderAccessInfo } func (n *FolderView) forEach(cb func(*FolderView)) { for _, child := range n.Children { cb(child) } } func (n *FolderView) requireEqual(t *testing.T, expect string, msg string) { input := strings.Split(expect, "\n") output := make([]string, 0, len(input)) for _, v := range input { v = strings.TrimSpace(v) if len(v) > 0 { output = append(output, v) } } expect = strings.Join(output, "\n") found := dotify(n.build(treeprint.New())) require.Equal(t, expect, found, fmt.Sprintf("%s // EXPECT:\n%s\n\nFOUND:\n%s", msg, expect, found)) } func accessDescription(access *folderV1.FolderAccessInfo) string { if access == nil { return "???" } perms := []string{} if access.CanAdmin { perms = append(perms, "admin") } if access.CanEdit { perms = append(perms, "edit") } if access.CanSave { perms = append(perms, "save") } if access.CanDelete { perms = append(perms, "delete") } if len(perms) == 0 { return "view" // because it was not nil! } return strings.Join(perms, ",") } func (n *FolderView) build(tree treeprint.Tree) treeprint.Tree { for _, child := range n.Children { child.build(tree.AddBranch(fmt.Sprintf("%s (%s)", child.Name, accessDescription(child.Access)))) } return tree } func getFoldersFromLegacyAPISearch(t *testing.T, client *rest.RESTClient) *FolderView { var statusCode int result := client.Get().AbsPath("api", "search"). Param("type", "dash-folder"). Param("limit", "1000"). Do(context.Background()). StatusCode(&statusCode) require.NoError(t, result.Error(), "getting folders with /api/search") require.Equal(t, int(http.StatusOK), statusCode) body, err := result.Raw() require.NoError(t, err) hits := model.HitList{} err = json.Unmarshal(body, &hits) require.NoError(t, err) lookup := make(map[string]*FolderView, len(hits)) for _, hit := range hits { fv := &FolderView{ Name: hit.UID, Title: hit.Title, Parent: hit.FolderUID, } // Read the access info (note not the same model but the fields we care about do overlap) result = client.Get().AbsPath("api", "folders", hit.UID). Do(context.Background()). StatusCode(&statusCode) require.NoError(t, result.Error(), "getting folder access info (/api)") require.Equal(t, int(http.StatusOK), statusCode) body, err := result.Raw() require.NoError(t, err) err = json.Unmarshal(body, &fv.Access) require.NoError(t, err) lookup[hit.UID] = fv } return makeRoot(lookup, "/api/search") } func makeRoot(lookup map[string]*FolderView, name string) *FolderView { shared := &FolderView{} // when not found root := &FolderView{} for _, v := range lookup { if v.Parent == "" { root.Children = append(root.Children, v) } else { p, ok := lookup[v.Parent] if ok { p.Children = append(p.Children, v) } else { shared.Children = append(shared.Children, v) } } } if len(shared.Children) > 0 { shared.Name = folder.SharedWithMeFolderUID root.Children = append([]*FolderView{shared}, root.Children...) } return root } func getFoldersFromDashboardV0Search(t *testing.T, client *rest.RESTClient, ns string) *FolderView { var statusCode int result := client.Get().AbsPath("apis", "dashboard.grafana.app", "v0alpha1", "namespaces", ns, "search"). Param("limit", "1000"). Do(context.Background()). StatusCode(&statusCode) err := result.Error() if err != nil { if apierrors.IsForbidden(err) { return &FolderView{} // empty list } require.NoError(t, err, "getting folders with /apis/dashboard.grafana.app/v0alpha1/.../search") } require.Equal(t, int(http.StatusOK), statusCode) body, err := result.Raw() require.NoError(t, err) results := &dashboardV0.SearchResults{} err = json.Unmarshal(body, &results) require.NoError(t, err) lookup := make(map[string]*FolderView, len(results.Hits)) for _, hit := range results.Hits { fv := &FolderView{ Name: hit.Name, Title: hit.Title, Parent: hit.Folder, } result = client.Get().AbsPath("apis", folderV1.APIGroup, folderV1.APIVersion, "namespaces", ns, "folders", hit.Name, "access"). Do(context.Background()). StatusCode(&statusCode) require.NoError(t, result.Error(), "getting folder access info (/access)") require.Equal(t, int(http.StatusOK), statusCode) body, err := result.Raw() require.NoError(t, err) err = json.Unmarshal(body, &fv.Access) require.NoError(t, err) lookup[hit.Name] = fv } return makeRoot(lookup, "dashboards/search") } func getFolderClients(t *testing.T, who apis.User) (dynamic.ResourceInterface, *rest.RESTClient) { gvr := schema.GroupVersionResource{Group: folderV1.APIGroup, Version: folderV1.APIVersion, Resource: "folders"} ns := who.Identity.GetNamespace() cfg := dynamic.ConfigFor(who.NewRestConfig()) dyn, err := dynamic.NewForConfig(cfg) require.NoError(t, err) dc := dyn.Resource(gvr).Namespace(ns) cfg.GroupVersion = &schema.GroupVersion{Group: gvr.Group, Version: gvr.Version} client, err := rest.RESTClientFor(cfg) require.NoError(t, err) return dc, client } func getFoldersFromAPIServerList(t *testing.T, client dynamic.ResourceInterface) *FolderView { lookup := map[string]*FolderView{} continueToken := "" for { result, err := client.List(context.Background(), v1.ListOptions{Limit: 1000, Continue: continueToken}) if apierrors.IsForbidden(err) { return &FolderView{} // empty list } require.NoError(t, err) for _, hit := range result.Items { obj, err := utils.MetaAccessor(&hit) require.NoError(t, err) title, _, err := unstructured.NestedString(hit.Object, "spec", "title") require.NoError(t, err) fv := &FolderView{ Name: hit.GetName(), Title: title, Parent: obj.GetFolder(), } access, err := client.Get(context.Background(), fv.Name, v1.GetOptions{}, "access") require.NoError(t, err) jj, err := json.Marshal(access) require.NoError(t, err) err = json.Unmarshal(jj, &fv.Access) require.NoError(t, err) lookup[fv.Name] = fv } continueToken = result.GetContinue() if continueToken == "" { break } } return makeRoot(lookup, "folders/list") } func dotify(t treeprint.Tree) string { buff := bytes.Buffer{} for _, line := range strings.Split(t.String(), "\n") { if line == "." || line == " " || len(line) == 0 { continue } runes := []rune(line) for j, r := range runes { if r == rune(' ') { runes[j] = '.' continue } break } if buff.Len() > 0 { buff.WriteRune('\n') } buff.WriteString(string(runes)) } return buff.String() }