package collections import ( "context" "encoding/json" "fmt" "net/http" "slices" "testing" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" collections "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1" dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/services/featuremgmt" "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/tests/testsuite" "github.com/grafana/grafana/pkg/util/testutil" ) func TestMain(m *testing.M) { testsuite.Run(m) } func TestIntegrationStars(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) for _, mode := range []grafanarest.DualWriterMode{ grafanarest.Mode0, grafanarest.Mode2, grafanarest.Mode3, grafanarest.Mode5, } { flags := []string{featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs} if mode > grafanarest.Mode2 { flags = append(flags, featuremgmt.FlagKubernetesStars) } helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ DisableDataMigrations: true, AppModeProduction: false, // required for experimental APIs DisableAnonymous: true, EnableFeatureToggles: flags, UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ "dashboards.dashboard.grafana.app": { DualWriterMode: mode, }, "folders.folder.grafana.app": { DualWriterMode: mode, }, "stars.collections.grafana.app": { DualWriterMode: mode, }, "collections.collections.grafana.app": { DualWriterMode: mode, }, }, }) t.Run(fmt.Sprintf("test stars (mode:%d)", mode), func(t *testing.T) { ctx := context.Background() starsClient := helper.GetResourceClient(apis.ResourceClientArgs{ User: helper.Org1.Admin, GVR: collections.StarsResourceInfo.GroupVersionResource(), }) starsClientViewer := helper.GetResourceClient(apis.ResourceClientArgs{ User: helper.Org1.Viewer, GVR: collections.StarsResourceInfo.GroupVersionResource(), }) dashboardClient := helper.GetResourceClient(apis.ResourceClientArgs{ User: helper.Org1.Admin, GVR: dashboardV1.DashboardResourceInfo.GroupVersionResource(), }) // Create 5 dashboards for i := range 5 { _, err := dashboardClient.Resource.Create(context.Background(), &unstructured.Unstructured{ Object: map[string]any{ "apiVersion": dashboardV1.DashboardResourceInfo.GroupVersion().String(), "kind": "Dashboard", "metadata": map[string]any{ "name": fmt.Sprintf("test-%d", i), }, "spec": map[string]any{ "title": fmt.Sprintf("test %d", i), "schemaVersion": 42, // not really! "panels": []any{}, }, }, }, metav1.CreateOptions{}) require.NoError(t, err) } found, err := dashboardClient.Resource.List(context.Background(), metav1.ListOptions{}) require.NoError(t, err) require.Len(t, found.Items, 5, "should be 5 dashboards") // List is empty when we start rsp, err := starsClient.Resource.List(ctx, metav1.ListOptions{}) require.NoError(t, err) require.Empty(t, rsp.Items, "no stars saved yet") raw := make(map[string]any) legacyResponse := apis.DoRequest(helper, apis.RequestParams{ User: starsClient.Args.User, Method: http.MethodPost, Path: "/api/user/stars/dashboard/uid/test-2", }, &raw) require.Equal(t, http.StatusOK, legacyResponse.Response.StatusCode, "add dashboard star") legacyResponse = apis.DoRequest(helper, apis.RequestParams{ User: starsClient.Args.User, Method: http.MethodPost, Path: "/api/user/stars/dashboard/uid/test-3", }, &raw) require.Equal(t, http.StatusOK, legacyResponse.Response.StatusCode, "add dashboard star") // List values and compare results rsp, err = starsClient.Resource.List(ctx, metav1.ListOptions{}) require.NoError(t, err) stars := typed(t, rsp, &collections.StarsList{}) require.Len(t, stars.Items, 1, "user stars should exist") require.Equal(t, "user-"+starsClient.Args.User.Identity.GetIdentifier(), stars.Items[0].GetName(), "star resource for user") resources := stars.Items[0].Spec.Resource require.Len(t, resources, 1) require.Equal(t, "dashboard.grafana.app", resources[0].Group) require.Equal(t, "Dashboard", resources[0].Kind) require.ElementsMatch(t, []string{"test-2", "test-3"}, resources[0].Names) // Remove one star legacyResponse = apis.DoRequest(helper, apis.RequestParams{ User: starsClient.Args.User, Method: http.MethodDelete, Path: "/api/user/stars/dashboard/uid/test-3", }, &raw) require.Equal(t, http.StatusOK, legacyResponse.Response.StatusCode, "removed dashboard star") adminStarName := "user-" + starsClient.Args.User.Identity.GetIdentifier() rspObj, err := starsClient.Resource.Get(ctx, adminStarName, metav1.GetOptions{}) require.NoError(t, err) after := typed(t, rspObj, &collections.Stars{}) resources = after.Spec.Resource require.Len(t, resources, 1) require.Equal(t, "dashboard.grafana.app", resources[0].Group) require.Equal(t, "Dashboard", resources[0].Kind) require.Equal(t, []string{"test-2"}, resources[0].Names) // Change stars via k8s update rspObj, err = starsClient.Resource.Update(ctx, &unstructured.Unstructured{ Object: map[string]any{ "metadata": map[string]any{ "name": adminStarName, "namespace": "default", }, "spec": map[string]any{ "resource": []map[string]any{ { "group": "dashboard.grafana.app", "kind": "Dashboard", "names": []string{"test-2", "aaa", "aaa", "bbb"}, }, }, }, }, }, metav1.UpdateOptions{}) require.NoError(t, err) after = typed(t, rspObj, &collections.Stars{}) resources = after.Spec.Resource require.Len(t, resources, 1) require.Equal(t, "dashboard.grafana.app", resources[0].Group) require.Equal(t, "Dashboard", resources[0].Kind) require.ElementsMatch(t, []string{"test-2", "aaa", "bbb"}, // keeps the requested order, removing duplicates resources[0].Names) // Now add a star with the sub-resource route used by the UI client := helper.Org1.Admin.RESTClient(t, &collections.GroupVersion) res := client.Put().AbsPath("apis", "collections.grafana.app", "v1alpha1", "namespaces", "default", "stars", adminStarName, "update", "dashboard.grafana.app", "Dashboard", "xxx").Do(ctx) require.NoError(t, res.Error()) rspObj, err = starsClient.Resource.Get(ctx, adminStarName, metav1.GetOptions{}) require.NoError(t, err) after = typed(t, rspObj, &collections.Stars{}) // FIXME: when we remove legacy support this should not sort! slices.Sort(after.Spec.Resource[0].Names) jj, err := json.MarshalIndent(after.Spec, "", " ") require.NoError(t, err) require.JSONEq(t, `{ "resource": [ { "group": "dashboard.grafana.app", "kind": "Dashboard", "names": [ "aaa", "bbb", "test-2", "xxx" ] } ] }`, string(jj)) // Viewer does not have any stars rsp, err = starsClientViewer.Resource.List(ctx, metav1.ListOptions{}) require.NoError(t, err) require.Empty(t, rsp.Items, "expect empty list") // Not allowed to see another user's stars rspObj, err = starsClientViewer.Resource.Get(ctx, "user-"+starsClient.Args.User.Identity.GetIdentifier(), metav1.GetOptions{}) require.Error(t, err) require.Nil(t, rspObj) // Use the API to star a non-dashboard resource res = client.Put().AbsPath("apis", "collections.grafana.app", "v1alpha1", "namespaces", "default", "stars", adminStarName, "update", "servicemodel.ext.grafana.com", "Component", "xxx").Do(ctx) if mode == grafanarest.Mode5 { require.NoError(t, res.Error()) } else { require.Error(t, res.Error(), "only dashboard stars are supported until the migration to unified storage is complete") } }) } } func typed[T any](t *testing.T, obj any, out T) T { jj, err := json.Marshal(obj) require.NoError(t, err) err = json.Unmarshal(jj, out) require.NoError(t, err) return out }