Files
grafana/pkg/tests/apis/folder/folders_test.go
T

2186 lines
74 KiB
Go

package folder
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/apimachinery/utils"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/expr"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
alerting "github.com/grafana/grafana/pkg/tests/api/alerting"
"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)
}
var gvr = schema.GroupVersionResource{
Group: folders.GROUP,
Version: folders.VERSION,
Resource: "folders",
}
func TestIntegrationFoldersApp(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
if !db.IsTestDbSQLite() {
t.Skip("test only on sqlite for now")
}
t.Run("Check discovery client", func(t *testing.T) {
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
EnableFeatureToggles: []string{},
})
disco := helper.NewDiscoveryClient()
resources, err := disco.ServerResourcesForGroupVersion("folder.grafana.app/v1beta1")
require.NoError(t, err)
v1Disco, err := json.MarshalIndent(resources, "", " ")
require.NoError(t, err)
require.JSONEq(t, `{
"kind": "APIResourceList",
"apiVersion": "v1",
"groupVersion": "folder.grafana.app/v1beta1",
"resources": [
{
"name": "folders",
"singularName": "folder",
"namespaced": true,
"kind": "Folder",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update"
]
},
{
"name": "folders/access",
"singularName": "",
"namespaced": true,
"kind": "FolderAccessInfo",
"verbs": [
"get"
]
},
{
"name": "folders/children",
"singularName": "",
"namespaced": true,
"kind": "FolderList",
"verbs": [
"get"
]
},
{
"name": "folders/counts",
"singularName": "",
"namespaced": true,
"kind": "DescendantCounts",
"verbs": [
"get"
]
},
{
"name": "folders/parents",
"singularName": "",
"namespaced": true,
"kind": "FolderInfoList",
"verbs": [
"get"
]
}
]
}`, string(v1Disco))
})
// test on all dualwriter modes
for mode := 0; mode <= 4; mode++ {
modeDw := grafanarest.DualWriterMode(mode)
t.Run(fmt.Sprintf("with dual write (unified storage, mode %v)", modeDw), func(t *testing.T) {
doFolderTests(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: modeDw,
},
},
EnableFeatureToggles: []string{},
}))
})
t.Run(fmt.Sprintf("with dual write (unified storage, mode %v, create nested folders)", modeDw), func(t *testing.T) {
doNestedCreateTest(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: modeDw,
},
},
}))
})
t.Run(fmt.Sprintf("with dual write (unified storage, mode %v, create existing folder)", modeDw), func(t *testing.T) {
doCreateDuplicateFolderTest(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: modeDw,
},
},
}))
})
t.Run(fmt.Sprintf("when creating a folder, mode %v, it should trim leading and trailing spaces", modeDw), func(t *testing.T) {
doCreateEnsureTitleIsTrimmedTest(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: modeDw,
},
},
}))
})
t.Run(fmt.Sprintf("with dual write (unified storage, mode %v, create circular reference folder)", modeDw), func(t *testing.T) {
doCreateCircularReferenceFolderTest(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: modeDw,
},
},
}))
})
}
// This is a general test for the unified storage list operation. We don't have a common test
// directory for now, so we (search and storage) keep it here as we own this part of the tests.
t.Run("make sure list works with continue tokens", func(t *testing.T) {
t.Skip("Skipping flaky test - list works with continue tokens")
modes := []grafanarest.DualWriterMode{
grafanarest.Mode1,
grafanarest.Mode2,
grafanarest.Mode3,
grafanarest.Mode4,
grafanarest.Mode5,
}
for _, mode := range modes {
t.Run(fmt.Sprintf("mode %d", mode), func(t *testing.T) {
doListFoldersTest(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: mode,
},
},
// We set it to 1 here, so we always get forced pagination based on the response size.
UnifiedStorageMaxPageSizeBytes: 1,
}), mode)
})
}
})
}
// Validates that folder delete checks alert_rule stats and blocks deletion
func TestIntegrationFolderDeletionBlockedByAlertRules(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
if !db.IsTestDbSQLite() {
t.Skip("test only on sqlite for now")
}
t.Run("should be blocked by alert rules", func(t *testing.T) {
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {DualWriterMode: grafanarest.Mode5},
},
UnifiedStorageEnableSearch: true,
})
client := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: gvr,
})
// Create a folder via legacy API so it is visible everywhere.
folderUID := "alertrule-del-test"
legacyPayload := fmt.Sprintf(`{"title": "Folder With Alert Rule", "uid": "%s"}`, folderUID)
legacyCreate := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodPost,
Path: "/api/folders",
Body: []byte(legacyPayload),
}, &folder.Folder{})
require.NotNil(t, legacyCreate.Result)
require.Equal(t, folderUID, legacyCreate.Result.UID)
// Create one alert rule in that folder namespace via ruler API.
addr := helper.GetEnv().Server.HTTPServer.Listener.Addr().String()
api := alerting.NewAlertingLegacyAPIClient(addr, "admin", "admin")
// simple always-true rule
forDuration := model.Duration(10 * time.Second)
rule := apimodels.PostableExtendedRuleNode{
ApiRuleNode: &apimodels.ApiRuleNode{For: &forDuration},
GrafanaManagedAlert: &apimodels.PostableGrafanaRule{
Title: "rule-in-folder",
Condition: "A",
Data: []apimodels.AlertQuery{
{
RefID: "A",
DatasourceUID: expr.DatasourceUID,
RelativeTimeRange: apimodels.RelativeTimeRange{
From: apimodels.Duration(600 * time.Second),
To: 0,
},
Model: json.RawMessage(`{"type":"math","expression":"2 + 3 > 1"}`),
},
},
},
}
group := apimodels.PostableRuleGroupConfig{
Name: "arulegroup",
Interval: model.Duration(10 * time.Second),
Rules: []apimodels.PostableExtendedRuleNode{rule},
}
_ = api.PostRulesGroup(t, folderUID, &group, false)
// Attempt to delete the folder via K8s API. This should be blocked by alert rules.
err := client.Resource.Delete(context.Background(), folderUID, metav1.DeleteOptions{})
require.Error(t, err, "expected folder deletion to be blocked when alert rules exist")
// Delete the rule group from ruler.
status, body := api.DeleteRulesGroup(t, folderUID, group.Name, true)
require.Equalf(t, http.StatusAccepted, status, body)
// Now we should be able to delete the folder.
err = client.Resource.Delete(context.Background(), folderUID, metav1.DeleteOptions{})
require.NoError(t, err)
})
}
func doFolderTests(t *testing.T, helper *apis.K8sTestHelper) *apis.K8sTestHelper {
t.Run("Check folder CRUD (just create for now) in legacy API appears in k8s apis", func(t *testing.T) {
client := helper.GetResourceClient(apis.ResourceClientArgs{
// #TODO: figure out permissions topic
User: helper.Org1.Admin,
GVR: gvr,
})
// #TODO fill out the payload: parentUID, description
// and check about uid orgid and siU
legacyPayload := `{
"title": "Test",
"uid": ""
}`
legacyCreate := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodPost,
Path: "/api/folders",
Body: []byte(legacyPayload),
}, &folder.Folder{})
require.NotNil(t, legacyCreate.Result)
uid := legacyCreate.Result.UID
require.NotEmpty(t, uid)
//nolint:staticcheck
id := legacyCreate.Result.ID
require.NotEmpty(t, id)
idStr := fmt.Sprintf("%d", id)
expectedResult := `{
"apiVersion": "folder.grafana.app/v1beta1",
"kind": "Folder",
"metadata": {
"creationTimestamp": "${creationTimestamp}",
"labels": {"grafana.app/deprecatedInternalID":"` + idStr + `"},
"name": "` + uid + `",
"namespace": "default",
"resourceVersion": "${resourceVersion}",
"uid": "${uid}"
},
"spec": {
"title": "Test",
"description": ""
}}`
// Get should return the same result
found, err := client.Resource.Get(context.Background(), uid, metav1.GetOptions{})
require.NoError(t, err)
require.JSONEq(t, expectedResult, client.SanitizeJSON(found))
})
t.Run("Do CRUD (just CR+List for now) via k8s (and check that legacy api still works)", func(t *testing.T) {
client := helper.GetResourceClient(apis.ResourceClientArgs{
// #TODO: figure out permissions topic
User: helper.Org1.Admin,
GVR: gvr,
})
// Create the folder "test"
first, err := client.Resource.Create(context.Background(),
helper.LoadYAMLOrJSONFile("testdata/folder-test-create.yaml"),
metav1.CreateOptions{},
)
require.NoError(t, err)
require.Equal(t, "test", first.GetName())
uids := []string{first.GetName()}
// Create (with name generation) two folders
for i := 0; i < 2; i++ {
out, err := client.Resource.Create(context.Background(),
helper.LoadYAMLOrJSONFile("testdata/folder-generate.yaml"),
metav1.CreateOptions{},
)
require.NoError(t, err)
uids = append(uids, out.GetName())
}
slices.Sort(uids) // make list compare stable
// Check all folders
for _, uid := range uids {
getFromBothAPIs(t, helper, client, uid, nil)
}
// PUT :: Update the title
updated, err := client.Resource.Update(context.Background(),
helper.LoadYAMLOrJSONFile("testdata/folder-test-replace.yaml"),
metav1.UpdateOptions{},
)
require.NoError(t, err)
spec, ok := updated.Object["spec"].(map[string]any)
require.True(t, ok)
title, ok := spec["title"].(string)
require.True(t, ok)
description, ok := spec["description"].(string)
require.True(t, ok)
require.Equal(t, first.GetName(), updated.GetName())
require.Equal(t, first.GetUID(), updated.GetUID())
require.Equal(t, "Test folder (replaced from k8s; 1 item; PUT)", title)
require.Equal(t, "New description", description)
// #TODO figure out why this breaks just for MySQL integration tests
// require.Less(t, first.GetResourceVersion(), updated.GetResourceVersion())
// ensure that we get 4 items when listing via k8s
l, err := client.Resource.List(context.Background(), metav1.ListOptions{})
require.NoError(t, err)
folders, err := meta.ExtractList(l)
require.NoError(t, err)
require.NotNil(t, folders)
require.Equal(t, len(folders), 4)
// delete test
errDelete := client.Resource.Delete(context.Background(), first.GetName(), metav1.DeleteOptions{})
require.NoError(t, errDelete)
})
return helper
}
// This does a get with both k8s and legacy API, and verifies the results are the same
func getFromBothAPIs(t *testing.T,
helper *apis.K8sTestHelper,
client *apis.K8sResourceClient,
uid string,
// Optionally match some expect some values
expect *folder.Folder,
) *unstructured.Unstructured {
t.Helper()
found, err := client.Resource.Get(context.Background(), uid, metav1.GetOptions{})
require.NoError(t, err)
require.Equal(t, uid, found.GetName())
dto := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodGet,
Path: "/api/folders/" + uid,
}, &folder.Folder{}).Result
require.NotNil(t, dto)
require.Equal(t, uid, dto.UID)
spec, ok := found.Object["spec"].(map[string]any)
require.True(t, ok)
require.Equal(t, dto.UID, found.GetName())
require.Equal(t, dto.Title, spec["title"])
// #TODO add checks for other fields
if expect != nil {
if expect.Title != "" {
require.Equal(t, expect.Title, dto.Title)
require.Equal(t, expect.Title, spec["title"])
}
if expect.UID != "" {
require.Equal(t, expect.UID, dto.UID)
require.Equal(t, expect.UID, found.GetName())
}
}
return found
}
func doNestedCreateTest(t *testing.T, helper *apis.K8sTestHelper) {
client := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: gvr,
})
parentPayload := `{
"title": "Test/parent",
"uid": ""
}`
parentCreate := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodPost,
Path: "/api/folders",
Body: []byte(parentPayload),
}, &folder.Folder{})
require.NotNil(t, parentCreate.Result)
// creating a folder without providing a parent should default to the empty parent folder
require.Empty(t, parentCreate.Result.ParentUID)
parentUID := parentCreate.Result.UID
require.NotEmpty(t, parentUID)
childPayload := fmt.Sprintf(`{
"title": "Test/child",
"uid": "",
"parentUid": "%s"
}`, parentUID)
childCreate := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodPost,
Path: "/api/folders",
Body: []byte(childPayload),
}, &dtos.Folder{})
require.NotNil(t, childCreate.Result)
childUID := childCreate.Result.UID
require.NotEmpty(t, childUID)
require.Equal(t, "Test/child", childCreate.Result.Title)
require.Equal(t, 1, len(childCreate.Result.Parents))
parent := childCreate.Result.Parents[0]
// creating a folder with a known parent should succeed
require.Equal(t, parentUID, childCreate.Result.ParentUID)
require.Equal(t, parentUID, parent.UID)
require.Equal(t, "Test/parent", parent.Title)
require.Equal(t, parentCreate.Result.URL, parent.URL)
}
func doCreateDuplicateFolderTest(t *testing.T, helper *apis.K8sTestHelper) {
client := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: gvr,
})
payload := `{
"title": "Test",
"uid": ""
}`
create := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodPost,
Path: "/api/folders",
Body: []byte(payload),
}, &folder.Folder{})
require.NotNil(t, create.Result)
parentUID := create.Result.UID
require.NotEmpty(t, parentUID)
create2 := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodPost,
Path: "/api/folders",
Body: []byte(payload),
}, &folder.Folder{})
require.NotEmpty(t, create2.Response)
require.Equal(t, 200, create2.Response.StatusCode) // it is OK
}
func doCreateEnsureTitleIsTrimmedTest(t *testing.T, helper *apis.K8sTestHelper) {
client := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: gvr,
})
payload := `{
"title": " my folder ",
"uid": ""
}`
// When creating a folder it should trim leading and trailing spaces in both dashboard and folder tables
create := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodPost,
Path: "/api/folders",
Body: []byte(payload),
}, &folder.Folder{})
require.NotNil(t, create.Result)
require.Equal(t, "my folder", create.Result.Title)
}
func doCreateCircularReferenceFolderTest(t *testing.T, helper *apis.K8sTestHelper) {
client := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: gvr,
})
payload := `{
"title": "Test",
"uid": "newFolder",
"parentUid: "newFolder",
}`
create := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodPost,
Path: "/api/folders",
Body: []byte(payload),
}, &folder.Folder{})
require.NotEmpty(t, create.Response)
require.Equal(t, 400, create.Response.StatusCode)
}
func doListFoldersTest(t *testing.T, helper *apis.K8sTestHelper, mode grafanarest.DualWriterMode) {
client := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: gvr,
})
foldersCount := 3
for i := 0; i < foldersCount; i++ {
payload, err := json.Marshal(map[string]interface{}{
"title": fmt.Sprintf("Test-%d", i),
"uid": fmt.Sprintf("uid-%d", i),
})
require.NoError(t, err)
parentCreate := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodPost,
Path: "/api/folders",
Body: payload,
}, &folder.Folder{})
require.NotNil(t, parentCreate.Result)
require.Equal(t, http.StatusOK, parentCreate.Response.StatusCode)
}
fetchedFolders, fetchItemsPerCall := checkListRequest(t, 1, client)
require.Equal(t, []string{"uid-0", "uid-1", "uid-2"}, fetchedFolders)
require.Equal(t, []int{1, 1, 1}, fetchItemsPerCall[:3])
// Now let's see if the iterator also works when we are limited by the page size, which should be set
// to 1 byte for this test. We only need to check that if we test unified storage as the primary storage,
// as legacy doesn't have such a page size limit.
if mode == grafanarest.Mode3 || mode == grafanarest.Mode4 || mode == grafanarest.Mode5 {
t.Run("check page size iterator", func(t *testing.T) {
fetchedFolders, fetchItemsPerCall := checkListRequest(t, 3, client)
require.Equal(t, []string{"uid-0", "uid-1", "uid-2"}, fetchedFolders)
require.Equal(t, []int{1, 1, 1}, fetchItemsPerCall[:3])
})
}
}
func checkListRequest(t *testing.T, limit int64, client *apis.K8sResourceClient) ([]string, []int) {
fetchedFolders := make([]string, 0, 3)
fetchItemsPerCall := make([]int, 0, 3)
continueToken := ""
for {
res, err := client.Resource.List(context.Background(), metav1.ListOptions{
Limit: limit,
Continue: continueToken,
})
require.NoError(t, err)
fetchItemsPerCall = append(fetchItemsPerCall, len(res.Items))
for _, item := range res.Items {
fetchedFolders = append(fetchedFolders, item.GetName())
}
continueToken = res.GetContinue()
if continueToken == "" {
break
}
}
return fetchedFolders, fetchItemsPerCall
}
func TestIntegrationFolderCreatePermissions(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
if !db.IsTestDbSQLite() {
t.Skip("test only on sqlite for now")
}
folderWithoutParentInput := "{ \"uid\": \"uid\", \"title\": \"Folder\"}"
folderWithParentInput := "{ \"uid\": \"uid\", \"title\": \"Folder\", \"parentUid\": \"parentuid\"}"
type testCase struct {
description string
input string
permissions []resourcepermissions.SetResourcePermissionCommand
expectedCode int
}
tcs := []testCase{
{
description: "creation of folder without parent succeeds given the correct request for creating a folder",
input: folderWithoutParentInput,
expectedCode: http.StatusOK,
permissions: []resourcepermissions.SetResourcePermissionCommand{
{
Actions: []string{"folders:create"},
Resource: "folders",
ResourceAttribute: "uid",
ResourceID: "*",
},
},
},
{
description: "Should not be able to create a folder under the root with subfolder creation permissions",
input: folderWithoutParentInput,
expectedCode: http.StatusForbidden,
permissions: []resourcepermissions.SetResourcePermissionCommand{
{
Actions: []string{"folders:create"},
Resource: "folders",
ResourceAttribute: "uid",
ResourceID: "subfolder_uid",
},
},
},
{
description: "Should not be able to create new folder under another folder without the right permissions",
input: folderWithParentInput,
expectedCode: http.StatusForbidden,
permissions: []resourcepermissions.SetResourcePermissionCommand{
{
Actions: []string{"folders:create"},
Resource: "folders",
ResourceAttribute: "uid",
ResourceID: "wrong_uid",
},
},
},
{
description: "creation of folder without parent fails without permissions to create a folder",
input: folderWithoutParentInput,
expectedCode: http.StatusForbidden,
permissions: []resourcepermissions.SetResourcePermissionCommand{},
},
{
description: "creation of folder with parent succeeds given the correct request for creating a folder",
input: folderWithParentInput,
expectedCode: http.StatusOK,
permissions: []resourcepermissions.SetResourcePermissionCommand{
{
Actions: []string{"folders:create"},
Resource: "folders",
ResourceAttribute: "uid",
ResourceID: "parentuid",
},
},
},
}
// test on all dualwriter modes
for mode := 0; mode <= 4; mode++ {
t.Run(fmt.Sprintf("Mode_%d", mode), func(t *testing.T) {
modeDw := grafanarest.DualWriterMode(mode)
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: modeDw,
},
},
})
for i, tc := range tcs {
t.Run(fmt.Sprintf("[Mode: %v] "+tc.description, mode), func(t *testing.T) {
username := fmt.Sprintf("user-%d", i)
parentUID := fmt.Sprintf("parentuid-%d", i)
childUID := fmt.Sprintf("uid-%d", i)
// Update permissions to use unique parent UID
permissions := make([]resourcepermissions.SetResourcePermissionCommand, len(tc.permissions))
for j, perm := range tc.permissions {
permissions[j] = perm
if perm.ResourceID == "parentuid" {
permissions[j].ResourceID = parentUID
}
}
user := helper.CreateUser(username, apis.Org1, org.RoleViewer, permissions)
// Get user ID for cleanup
userID, _ := user.Identity.GetInternalID()
// Register cleanup for this test case
t.Cleanup(helper.CleanupTestResources([]string{parentUID, childUID}, []int64{userID}))
parentPayload := fmt.Sprintf(`{
"title": "Test/parent",
"uid": "%s"
}`, parentUID)
parentCreate := apis.DoRequest(helper, apis.RequestParams{
User: helper.Org1.Admin,
Method: http.MethodPost,
Path: "/api/folders",
Body: []byte(parentPayload),
}, &folder.Folder{})
require.NotNil(t, parentCreate.Result)
createdParentUID := parentCreate.Result.UID
require.NotEmpty(t, createdParentUID)
// Update input to use unique UIDs
input := strings.ReplaceAll(tc.input, "parentuid", parentUID)
input = strings.ReplaceAll(input, `"uid": "uid"`, fmt.Sprintf(`"uid": "%s"`, childUID))
resp := apis.DoRequest(helper, apis.RequestParams{
User: user,
Method: http.MethodPost,
Path: "/api/folders",
Body: []byte(input),
}, &dtos.Folder{})
require.Equal(t, tc.expectedCode, resp.Response.StatusCode)
if tc.expectedCode == http.StatusOK {
require.Equal(t, childUID, resp.Result.UID)
require.Equal(t, "Folder", resp.Result.Title)
}
})
}
})
}
}
func TestIntegrationFolderGetPermissions(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
if !db.IsTestDbSQLite() {
t.Skip("test only on sqlite for now")
}
type testCase struct {
description string
permissions []resourcepermissions.SetResourcePermissionCommand
expectedCode int
expectedParentUIDs []string
expectedParentTitles []string
checkAccessControl bool
}
tcs := []testCase{
{
description: "get folder by UID should return parent folders if nested folder are enabled",
expectedCode: http.StatusOK,
expectedParentUIDs: []string{"parentuid"},
expectedParentTitles: []string{"testparent"},
permissions: []resourcepermissions.SetResourcePermissionCommand{
{
Actions: []string{dashboards.ActionFoldersRead},
Resource: "folders",
ResourceAttribute: "uid",
ResourceID: "*",
},
},
checkAccessControl: true,
},
{
description: "get folder by UID should not return parent folders if nested folder are enabled and user does not have read access to parent folders",
expectedCode: http.StatusOK,
expectedParentUIDs: []string{},
expectedParentTitles: []string{},
permissions: []resourcepermissions.SetResourcePermissionCommand{
{
Actions: []string{dashboards.ActionFoldersRead},
Resource: "folders",
ResourceAttribute: "uid",
ResourceID: "descuid",
},
},
},
{
description: "get folder by UID should not succeed if user doesn't have permissions for the folder",
expectedCode: http.StatusForbidden,
expectedParentUIDs: []string{},
expectedParentTitles: []string{},
permissions: []resourcepermissions.SetResourcePermissionCommand{},
},
}
// test on all dualwriter modes
for mode := 0; mode <= 4; mode++ {
t.Run(fmt.Sprintf("Mode_%d", mode), func(t *testing.T) {
modeDw := grafanarest.DualWriterMode(mode)
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: modeDw,
},
},
})
// Run all test cases within the same server instance
for i, tc := range tcs {
t.Run(tc.description, func(t *testing.T) {
// Use unique UIDs per test case to avoid conflicts
parentUID := fmt.Sprintf("parentuid-%d", i)
descUID := fmt.Sprintf("descuid-%d", i)
parentTitle := fmt.Sprintf("testparent-%d", i)
userLogin := fmt.Sprintf("user-%d", i)
acUserLogin := fmt.Sprintf("acuser-%d", i)
// Create parent folder
parentPayload := fmt.Sprintf(`{
"title": "%s",
"uid": "%s"
}`, parentTitle, parentUID)
parentCreate := apis.DoRequest(helper, apis.RequestParams{
User: helper.Org1.Admin,
Method: http.MethodPost,
Path: "/api/folders",
Body: []byte(parentPayload),
}, &folder.Folder{})
require.NotNil(t, parentCreate.Result)
require.Equal(t, parentUID, parentCreate.Result.UID)
// Create descendant folder
payload := fmt.Sprintf(`{ "uid": "%s", "title": "Folder-%d", "parentUid": "%s"}`, descUID, i, parentUID)
resp := apis.DoRequest(helper, apis.RequestParams{
User: helper.Org1.Admin,
Method: http.MethodPost,
Path: "/api/folders",
Body: []byte(payload),
}, &dtos.Folder{})
require.Equal(t, http.StatusOK, resp.Response.StatusCode)
// Update permissions to use unique descUID where needed
permissions := tc.permissions
for j := range permissions {
if permissions[j].ResourceID == "descuid" {
permissions[j].ResourceID = descUID
}
}
user := helper.CreateUser(userLogin, apis.Org1, org.RoleNone, permissions)
// Get user ID for cleanup
userID, err := user.Identity.GetInternalID()
require.NoError(t, err)
// Register cleanup to delete created resources
t.Cleanup(helper.CleanupTestResources([]string{descUID, parentUID}, []int64{userID}))
// Adjust expected UIDs and titles
expectedParentUIDs := tc.expectedParentUIDs
expectedParentTitles := tc.expectedParentTitles
if len(expectedParentUIDs) > 0 {
expectedParentUIDs = []string{parentUID}
expectedParentTitles = []string{parentTitle}
}
// Get with accesscontrol disabled
getResp := apis.DoRequest(helper, apis.RequestParams{
User: user,
Method: http.MethodGet,
Path: "/api/folders/" + descUID,
}, &dtos.Folder{})
require.Equal(t, tc.expectedCode, getResp.Response.StatusCode)
if tc.expectedCode == http.StatusOK {
require.NotNil(t, getResp.Result)
require.False(t, getResp.Result.AccessControl[dashboards.ActionFoldersRead])
require.False(t, getResp.Result.AccessControl[dashboards.ActionFoldersWrite])
parents := getResp.Result.Parents
require.Equal(t, len(expectedParentUIDs), len(parents))
require.Equal(t, len(expectedParentTitles), len(parents))
for j := 0; j < len(expectedParentUIDs); j++ {
require.Equal(t, expectedParentUIDs[j], parents[j].UID)
require.Equal(t, expectedParentTitles[j], parents[j].Title)
}
// Get with accesscontrol enabled
if tc.checkAccessControl {
acPerms := []resourcepermissions.SetResourcePermissionCommand{
{
Actions: []string{dashboards.ActionFoldersRead},
Resource: "folders",
ResourceAttribute: "uid",
ResourceID: "*",
},
{
Actions: []string{dashboards.ActionFoldersWrite},
Resource: "folders",
ResourceAttribute: "uid",
ResourceID: parentUID,
},
}
acUser := helper.CreateUser(acUserLogin, apis.Org1, org.RoleNone, acPerms)
// Get user ID for cleanup
acUserID, err := acUser.Identity.GetInternalID()
require.NoError(t, err)
t.Cleanup(helper.CleanupTestResources([]string{}, []int64{acUserID}))
getWithAC := apis.DoRequest(helper, apis.RequestParams{
User: acUser,
Method: http.MethodGet,
Path: "/api/folders/" + descUID + "?accesscontrol=true",
}, &dtos.Folder{})
require.Equal(t, tc.expectedCode, getWithAC.Response.StatusCode)
require.NotNil(t, getWithAC.Result)
require.True(t, getWithAC.Result.AccessControl[dashboards.ActionFoldersRead])
require.True(t, getWithAC.Result.AccessControl[dashboards.ActionFoldersWrite])
}
}
})
}
})
}
}
// TestFoldersCreateAPIEndpointK8S is the counterpart of pkg/api/folder_test.go TestFoldersCreateAPIEndpoint
func TestIntegrationFoldersCreateAPIEndpointK8S(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
if !db.IsTestDbSQLite() {
t.Skip("test only on sqlite for now")
}
folderWithoutParentInput := "{ \"uid\": \"uid\", \"title\": \"Folder\"}"
folderWithTitleEmpty := "{ \"title\": \"\"}"
folderWithInvalidUid := "{ \"uid\": \"::::::::::::\", \"title\": \"Another folder\"}"
folderWithUIDTooLong := "{ \"uid\": \"asdfghjklqwertyuiopzxcvbnmasdfghjklqwertyuiopzxcvbnmasdfghjklqwertyuiopzxcvbnm\", \"title\": \"Third folder\"}"
type testCase struct {
description string
expectedCode int
expectedMessage string
expectedFolderSvcError error
permissions []resourcepermissions.SetResourcePermissionCommand
input string
createSecondRecord bool
}
folderCreatePermission := []resourcepermissions.SetResourcePermissionCommand{
{
Actions: []string{"folders:create"},
Resource: "folders",
ResourceAttribute: "uid",
ResourceID: "*",
},
}
// NOTE: folder creation does not return ErrFolderAccessDenied neither ErrFolderNotFound
tcs := []testCase{
{
description: "folder creation succeeds given the correct request for creating a folder",
input: folderWithoutParentInput,
expectedCode: http.StatusOK,
permissions: folderCreatePermission,
},
{
description: "folder creation fails without permissions to create a folder",
input: folderWithoutParentInput,
expectedCode: http.StatusForbidden,
expectedMessage: fmt.Sprintf("You'll need additional permissions to perform this action. Permissions needed: %s", "folders:create"),
permissions: []resourcepermissions.SetResourcePermissionCommand{},
},
{
description: "folder creation fails given folder service error %s",
input: folderWithTitleEmpty,
expectedCode: http.StatusBadRequest,
expectedMessage: dashboards.ErrFolderTitleEmpty.Error(),
expectedFolderSvcError: dashboards.ErrFolderTitleEmpty,
permissions: folderCreatePermission,
},
{
description: "folder creation fails given folder service error %s",
input: folderWithInvalidUid,
expectedCode: http.StatusBadRequest,
expectedMessage: dashboards.ErrDashboardInvalidUid.Error(),
expectedFolderSvcError: dashboards.ErrDashboardInvalidUid,
permissions: folderCreatePermission,
},
{
description: "folder creation fails given folder service error %s",
input: folderWithUIDTooLong,
expectedCode: http.StatusBadRequest,
expectedMessage: dashboards.ErrDashboardUidTooLong.Error(),
expectedFolderSvcError: dashboards.ErrDashboardUidTooLong,
permissions: folderCreatePermission,
},
{
description: "folder creation fails given folder service error %s",
input: folderWithoutParentInput,
expectedCode: http.StatusPreconditionFailed,
expectedMessage: dashboards.ErrFolderVersionMismatch.Error(),
expectedFolderSvcError: dashboards.ErrFolderVersionMismatch,
createSecondRecord: true,
permissions: folderCreatePermission,
},
}
// test on all dualwriter modes
for mode := 0; mode <= 4; mode++ {
modeDw := grafanarest.DualWriterMode(mode)
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: modeDw,
},
},
})
for i, tc := range tcs {
t.Run(fmt.Sprintf("[Mode: %v] "+testDescription(tc.description, tc.expectedFolderSvcError), mode), func(t *testing.T) {
username := fmt.Sprintf("user-%d", i)
folderUID := fmt.Sprintf("uid-%d", i)
// Update input to use unique UIDs
input := strings.ReplaceAll(tc.input, `"uid": "uid"`, fmt.Sprintf(`"uid": "%s"`, folderUID))
userTest := helper.CreateUser(username, apis.Org1, org.RoleViewer, tc.permissions)
if tc.createSecondRecord {
client := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: gvr,
})
create2 := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodPost,
Path: "/api/folders",
Body: []byte(input),
}, &folder.Folder{})
require.NotEmpty(t, create2.Response)
require.Equal(t, http.StatusOK, create2.Response.StatusCode)
}
addr := helper.GetEnv().Server.HTTPServer.Listener.Addr()
login := userTest.Identity.GetLogin()
baseUrl := fmt.Sprintf("http://%s:%s@%s", login, user.Password(username), addr)
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf(
"%s%s",
baseUrl,
"/api/folders",
), bytes.NewBuffer([]byte(input)))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.NotNil(t, resp)
require.Equal(t, tc.expectedCode, resp.StatusCode)
type folderWithMessage struct {
dtos.Folder
Message string `json:"message"`
}
folder := folderWithMessage{}
err = json.NewDecoder(resp.Body).Decode(&folder)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
if tc.expectedCode == http.StatusOK {
require.Equal(t, folderUID, folder.UID)
require.Equal(t, "Folder", folder.Title)
}
if tc.expectedMessage != "" {
require.Equal(t, tc.expectedMessage, folder.Message)
}
})
}
}
}
func testDescription(description string, expectedErr error) string {
if expectedErr != nil {
return fmt.Sprintf(description, expectedErr.Error())
} else {
return description
}
}
// There are no counterpart of TestFoldersGetAPIEndpointK8S in pkg/api/folder_test.go
func TestIntegrationFoldersGetAPIEndpointK8S(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
if !db.IsTestDbSQLite() {
t.Skip("test only on sqlite for now")
}
type testCase struct {
description string
expectedCode int
params string
createFolders []string
expectedOutput []dtos.FolderSearchHit
permissions []resourcepermissions.SetResourcePermissionCommand
requestToAnotherOrg bool
}
folderReadAndCreatePermission := []resourcepermissions.SetResourcePermissionCommand{
{
Actions: []string{"folders:create", "folders:read"},
Resource: "folders",
ResourceAttribute: "uid",
ResourceID: "*",
},
}
folder1 := "{ \"uid\": \"foo\", \"title\": \"Folder 1\"}"
folder2 := "{ \"uid\": \"bar\", \"title\": \"Folder 2\", \"parentUid\": \"foo\"}"
folder3 := "{ \"uid\": \"qux\", \"title\": \"Folder 3\"}"
tcs := []testCase{
{
description: "listing folders at root level succeeds",
createFolders: []string{
folder1,
folder2,
folder3,
},
expectedCode: http.StatusOK,
expectedOutput: []dtos.FolderSearchHit{
{UID: "foo", Title: "Folder 1"},
{UID: "qux", Title: "Folder 3"},
{UID: folder.SharedWithMeFolder.UID, Title: folder.SharedWithMeFolder.Title},
},
permissions: folderReadAndCreatePermission,
},
{
description: "listing subfolders succeeds",
createFolders: []string{
folder1,
folder2,
folder3,
},
params: "?parentUid=foo",
expectedCode: http.StatusOK,
expectedOutput: []dtos.FolderSearchHit{
{UID: "bar", Title: "Folder 2", ParentUID: "foo"},
},
permissions: folderReadAndCreatePermission,
},
{
description: "listing subfolders for a parent that does not exists",
createFolders: []string{
folder1,
folder2,
folder3,
},
params: "?parentUid=notexists",
expectedCode: http.StatusNotFound,
expectedOutput: []dtos.FolderSearchHit{},
permissions: folderReadAndCreatePermission,
},
{
description: "listing folders at root level fails without the right permissions",
createFolders: []string{
folder1,
folder2,
folder3,
},
params: "?parentUid=notfound",
expectedCode: http.StatusForbidden,
expectedOutput: []dtos.FolderSearchHit{},
permissions: folderReadAndCreatePermission,
requestToAnotherOrg: true,
},
}
for mode := 0; mode <= 4; mode++ {
t.Run(fmt.Sprintf("Mode_%d", mode), func(t *testing.T) {
modeDw := grafanarest.DualWriterMode(mode)
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: modeDw,
},
},
UnifiedStorageEnableSearch: true,
})
// Run all test cases within the same server instance
for i, tc := range tcs {
t.Run(tc.description, func(t *testing.T) {
// Use unique UIDs for folders to avoid conflicts between test cases
userTest := helper.CreateUser(fmt.Sprintf("user-%d", i), apis.Org1, org.RoleNone, tc.permissions)
// Create folders with unique UIDs per test case
for _, f := range tc.createFolders {
// Replace hardcoded UIDs with unique ones
uniqueFolder := f
uniqueFolder = strings.Replace(uniqueFolder, `"foo"`, fmt.Sprintf(`"foo-%d"`, i), 1)
uniqueFolder = strings.Replace(uniqueFolder, `"bar"`, fmt.Sprintf(`"bar-%d"`, i), 1)
uniqueFolder = strings.Replace(uniqueFolder, `"qux"`, fmt.Sprintf(`"qux-%d"`, i), 1)
uniqueFolder = strings.Replace(uniqueFolder, `"parentUid": "foo"`, fmt.Sprintf(`"parentUid": "foo-%d"`, i), 1)
client := helper.GetResourceClient(apis.ResourceClientArgs{
User: userTest,
GVR: gvr,
})
create2 := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodPost,
Path: "/api/folders",
Body: []byte(uniqueFolder),
}, &folder.Folder{})
require.NotEmpty(t, create2.Response)
require.Equal(t, http.StatusOK, create2.Response.StatusCode)
}
addr := helper.GetEnv().Server.HTTPServer.Listener.Addr()
login := userTest.Identity.GetLogin()
baseUrl := fmt.Sprintf("http://%s:%s@%s", login, user.Password(fmt.Sprintf("user-%d", i)), addr)
// Adjust params with unique UIDs
params := tc.params
params = strings.ReplaceAll(params, "foo", fmt.Sprintf("foo-%d", i))
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf(
"%s%s",
baseUrl,
fmt.Sprintf("/api/folders%s", params),
), nil)
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
if tc.requestToAnotherOrg {
req.Header.Set("x-grafana-org-id", "2")
}
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.NotNil(t, resp)
require.Equal(t, tc.expectedCode, resp.StatusCode)
if tc.expectedCode == http.StatusOK {
list := []dtos.FolderSearchHit{}
err = json.NewDecoder(resp.Body).Decode(&list)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
// Adjust expected output with unique UIDs
expectedOutput := make([]dtos.FolderSearchHit, len(tc.expectedOutput))
for j, output := range tc.expectedOutput {
expectedOutput[j] = output
expectedOutput[j].ID = 0 // ignore IDs
switch output.UID {
case "foo":
expectedOutput[j].UID = fmt.Sprintf("foo-%d", i)
case "bar":
expectedOutput[j].UID = fmt.Sprintf("bar-%d", i)
expectedOutput[j].ParentUID = fmt.Sprintf("foo-%d", i)
case "qux":
expectedOutput[j].UID = fmt.Sprintf("qux-%d", i)
}
}
// ignore IDs in actual list
for j := 0; j < len(list); j++ {
list[j].ID = 0
}
require.ElementsMatch(t, expectedOutput, list)
}
})
}
})
}
}
// Reproduces a bug where folder deletion does not check for attached library panels.
func TestIntegrationFolderDeletionBlockedByLibraryElements(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
if !db.IsTestDbSQLite() {
t.Skip("test only on sqlite for now")
}
for mode := 0; mode <= 5; mode++ {
t.Run(fmt.Sprintf("with dual write (unified storage, mode %v, delete blocked by library elements)", grafanarest.DualWriterMode(mode)), func(t *testing.T) {
modeDw := grafanarest.DualWriterMode(mode)
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: modeDw,
},
},
EnableFeatureToggles: []string{
featuremgmt.FlagKubernetesLibraryPanels,
},
UnifiedStorageEnableSearch: true,
})
client := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: gvr,
})
// Create a folder via legacy API (/api/folders) so it is visible to both paths
folderUID := fmt.Sprintf("libpanel-del-%d", mode)
legacyPayload := fmt.Sprintf(`{
"title": "Folder With Library Panel %d",
"uid": "%s"
}`, mode, folderUID)
legacyCreate := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodPost,
Path: "/api/folders",
Body: []byte(legacyPayload),
}, &folder.Folder{})
require.NotNil(t, legacyCreate.Result)
require.Equal(t, folderUID, legacyCreate.Result.UID)
// Create a library element inside the folder via /api to simulate an attached library panel
libElementPayload := fmt.Sprintf(`{
"kind": 1,
"name": "LP in %s",
"folderUid": "%s",
"model": {
"type": "text",
"title": "LP in %s"
}
}`, folderUID, folderUID, folderUID)
libCreate := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodPost,
Path: "/api/library-elements",
Body: []byte(libElementPayload),
}, &struct{}{})
require.NotNil(t, libCreate.Response)
require.Equal(t, http.StatusOK, libCreate.Response.StatusCode)
// Attempt to delete the folder via K8s API. This should be blocked (ErrFolderNotEmpty)
err := client.Resource.Delete(context.Background(), folderUID, metav1.DeleteOptions{})
require.Error(t, err, "expected folder deletion to be blocked when library panels exist")
// Verify the folder still exists
_, getErr := client.Resource.Get(context.Background(), folderUID, metav1.GetOptions{})
require.NoError(t, getErr, "folder should still exist after failed deletion")
})
}
}
func TestIntegrationRootFolderDeletionBlockedByLibraryElementsInSubfolder(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
if !db.IsTestDbSQLite() {
t.Skip("test only on sqlite for now")
}
// TODO: re-enable on mode 4 and 5 when we migrate /api to /apis for library connections, and begin to
// use search to return the connections, rather than the connections table.
for mode := 0; mode <= 3; mode++ {
t.Run(fmt.Sprintf("with dual write (unified storage, mode %v, delete parent blocked by library elements in child)", grafanarest.DualWriterMode(mode)), func(t *testing.T) {
modeDw := grafanarest.DualWriterMode(mode)
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: modeDw,
},
},
EnableFeatureToggles: []string{
featuremgmt.FlagKubernetesLibraryPanels,
},
UnifiedStorageEnableSearch: true,
})
client := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: gvr,
})
parentUID := fmt.Sprintf("libpanel-parent-%d", mode)
parentPayload := fmt.Sprintf(`{
"title": "Parent Folder %d",
"uid": "%s"
}`, mode, parentUID)
parentCreate := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodPost,
Path: "/api/folders",
Body: []byte(parentPayload),
}, &folder.Folder{})
require.NotNil(t, parentCreate.Result)
require.Equal(t, parentUID, parentCreate.Result.UID)
childUID := fmt.Sprintf("libpanel-child-%d", mode)
childPayload := fmt.Sprintf(`{
"title": "Child Folder %d",
"uid": "%s",
"parentUid": "%s"
}`, mode, childUID, parentUID)
childCreate := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodPost,
Path: "/api/folders",
Body: []byte(childPayload),
}, &folder.Folder{})
require.NotNil(t, childCreate.Result)
require.Equal(t, childUID, childCreate.Result.UID)
require.Equal(t, parentUID, childCreate.Result.ParentUID)
libElementPayload := fmt.Sprintf(`{
"kind": 1,
"name": "LP in %s",
"folderUid": "%s",
"model": {
"type": "text",
"title": "LP in %s"
}
}`, childUID, childUID, childUID)
libCreate := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodPost,
Path: "/api/library-elements",
Body: []byte(libElementPayload),
}, &struct{}{})
require.NotNil(t, libCreate.Response)
require.Equal(t, http.StatusOK, libCreate.Response.StatusCode)
// Attempt to delete the parent folder; should be blocked because child folder contains a library panel
err := client.Resource.Delete(context.Background(), parentUID, metav1.DeleteOptions{})
require.Error(t, err, "expected parent folder deletion to be blocked when child contains library panels")
// Verify both folders still exist
_, getParentErr := client.Resource.Get(context.Background(), parentUID, metav1.GetOptions{})
require.NoError(t, getParentErr, "parent folder should still exist after failed deletion")
_, getChildErr := client.Resource.Get(context.Background(), childUID, metav1.GetOptions{})
require.NoError(t, getChildErr, "child folder should still exist after failed deletion")
})
}
}
// Test folder deletion with connected (in-use) library panels - should be blocked
func TestIntegrationFolderDeletionBlockedByConnectedLibraryPanels(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
if !db.IsTestDbSQLite() {
t.Skip("test only on sqlite for now")
}
// TODO: re-enable on mode 4 and 5 when we migrate /api to /apis for library connections, and begin to
// use search to return the connections, rather than the connections table.
for mode := 0; mode <= 3; mode++ {
t.Run(fmt.Sprintf("mode %v - delete blocked by connected library panels in folder and subfolder", grafanarest.DualWriterMode(mode)), func(t *testing.T) {
modeDw := grafanarest.DualWriterMode(mode)
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: modeDw,
},
"dashboards.dashboard.grafana.app": {
DualWriterMode: modeDw,
},
},
UnifiedStorageEnableSearch: true,
})
client := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: gvr,
})
// Create parent and child folders
uid := uuid.NewString()[:8]
parentUID := fmt.Sprintf("connected-parent-%d-%s", mode, uid)
childUID := fmt.Sprintf("connected-child-%d-%s", mode, uid)
createTestFolder(t, helper, client, parentUID, fmt.Sprintf("Parent Folder %d-%s", mode, uid), "")
createTestFolder(t, helper, client, childUID, fmt.Sprintf("Child Folder %d-%s", mode, uid), parentUID)
// Create library panels in both folders
parentLibPanelName := fmt.Sprintf("Connected LP in parent %d-%s", mode, uid)
childLibPanelName := fmt.Sprintf("Connected LP in child %d-%s", mode, uid)
parentLibPanelUID := createTestLibraryPanel(t, helper, client, parentLibPanelName, parentUID)
childLibPanelUID := createTestLibraryPanel(t, helper, client, childLibPanelName, childUID)
// Create dashboards using library panels (makes them connected)
parentDashUID := createDashboardWithLibraryPanel(t, helper, client,
fmt.Sprintf("Dashboard with LP in parent %d-%s", mode, uid),
parentLibPanelUID, "Connected LP in parent", parentUID)
childDashUID := createDashboardWithLibraryPanel(t, helper, client,
fmt.Sprintf("Dashboard with LP in child %d-%s", mode, uid),
childLibPanelUID, "Connected LP in child", childUID)
// Attempt to delete the parent folder - should be blocked because library panels are connected
parentDelete := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodDelete,
Path: "/api/folders/" + parentUID,
}, &folder.Folder{})
require.Equal(t, http.StatusForbidden, parentDelete.Response.StatusCode)
// Verify both folders still exist
_, getParentErr := client.Resource.Get(context.Background(), parentUID, metav1.GetOptions{})
require.NoError(t, getParentErr, "parent folder should still exist after failed deletion")
_, getChildErr := client.Resource.Get(context.Background(), childUID, metav1.GetOptions{})
require.NoError(t, getChildErr, "child folder should still exist after failed deletion")
// Verify library panels still exist
verifyLibraryPanelExists(t, helper, client, parentLibPanelUID)
verifyLibraryPanelExists(t, helper, client, childLibPanelUID)
// Verify dashboards still exist
verifyDashboardExists(t, helper, client, parentDashUID)
verifyDashboardExists(t, helper, client, childDashUID)
})
}
}
// Test folder deletion with dangling (unconnected) library panels - should succeed and clean up
func TestIntegrationFolderDeletionWithDanglingLibraryPanels(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
if !db.IsTestDbSQLite() {
t.Skip("test only on sqlite for now")
}
for mode := 0; mode <= 5; mode++ {
t.Run(fmt.Sprintf("mode %v - delete succeeds and cleans up dangling library panels in folder and subfolder", grafanarest.DualWriterMode(mode)), func(t *testing.T) {
modeDw := grafanarest.DualWriterMode(mode)
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: modeDw,
},
},
UnifiedStorageEnableSearch: true,
})
client := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: gvr,
})
// Create parent and child folders
uid := uuid.NewString()[:8]
parentUID := fmt.Sprintf("dangling-parent-%d-%s", mode, uid)
childUID := fmt.Sprintf("dangling-child-%d-%s", mode, uid)
createTestFolder(t, helper, client, parentUID, fmt.Sprintf("Parent Folder %d-%s", mode, uid), "")
createTestFolder(t, helper, client, childUID, fmt.Sprintf("Child Folder %d-%s", mode, uid), parentUID)
// Create dangling library panels in both folders (not connected to any dashboard)
parentLibPanelUID := createTestLibraryPanel(t, helper, client,
fmt.Sprintf("Dangling LP in parent %d-%s", mode, uid), parentUID)
childLibPanelUID := createTestLibraryPanel(t, helper, client,
fmt.Sprintf("Dangling LP in child %d-%s", mode, uid), childUID)
// Verify library panels exist before deletion
verifyLibraryPanelExists(t, helper, client, parentLibPanelUID)
verifyLibraryPanelExists(t, helper, client, childLibPanelUID)
// Attempt to delete the parent folder - should be blocked because library panels are connected
parentDelete := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodDelete,
Path: "/api/folders/" + parentUID,
}, &folder.Folder{})
require.Equal(t, http.StatusOK, parentDelete.Response.StatusCode, parentDelete.Body)
// Verify folders are deleted
_, getParentErr := client.Resource.Get(context.Background(), parentUID, metav1.GetOptions{})
require.Error(t, getParentErr, "parent folder should not exist after deletion")
_, getChildErr := client.Resource.Get(context.Background(), childUID, metav1.GetOptions{})
require.Error(t, getChildErr, "child folder should not exist after deletion")
// Verify dangling library panels were cleaned up
verifyLibraryPanelDeleted(t, helper, client, parentLibPanelUID, "dangling library panel in parent should be deleted")
verifyLibraryPanelDeleted(t, helper, client, childLibPanelUID, "dangling library panel in child should be deleted")
})
}
}
// Helper function to create a folder with specified UID and optional parent
func createTestFolder(t *testing.T, helper *apis.K8sTestHelper, client *apis.K8sResourceClient, uid, title, parentUID string) *folder.Folder {
t.Helper()
payload := fmt.Sprintf(`{
"title": "%s",
"uid": "%s"`, title, uid)
if parentUID != "" {
payload += fmt.Sprintf(`,
"parentUid": "%s"`, parentUID)
}
payload += "}"
folderCreate := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodPost,
Path: "/api/folders",
Body: []byte(payload),
}, &folder.Folder{})
require.NotNil(t, folderCreate.Result)
require.Equal(t, uid, folderCreate.Result.UID)
return folderCreate.Result
}
// Helper function to create a library panel in a folder
func createTestLibraryPanel(t *testing.T, helper *apis.K8sTestHelper, client *apis.K8sResourceClient, name, folderUID string) string {
t.Helper()
libPanelPayload := fmt.Sprintf(`{
"kind": 1,
"name": "%s",
"folderUid": "%s",
"model": {
"type": "text",
"title": "%s"
}
}`, name, folderUID, name)
libCreate := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodPost,
Path: "/api/library-elements",
Body: []byte(libPanelPayload),
}, &map[string]interface{}{})
require.NotNil(t, libCreate.Response)
require.Equal(t, http.StatusOK, libCreate.Response.StatusCode)
libPanelUID := (*libCreate.Result)["result"].(map[string]interface{})["uid"].(string)
require.NotEmpty(t, libPanelUID)
return libPanelUID
}
// Helper function to create a dashboard that uses a library panel
func createDashboardWithLibraryPanel(t *testing.T, helper *apis.K8sTestHelper, client *apis.K8sResourceClient, dashTitle, libPanelUID, libPanelName, folderUID string) string {
t.Helper()
dashPayload := fmt.Sprintf(`{
"dashboard": {
"title": "%s",
"panels": [{
"id": 1,
"libraryPanel": {
"uid": "%s",
"name": "%s"
}
}]
},
"folderUid": "%s",
"overwrite": false
}`, dashTitle, libPanelUID, libPanelName, folderUID)
dashCreate := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodPost,
Path: "/api/dashboards/db",
Body: []byte(dashPayload),
}, &map[string]interface{}{})
require.NotNil(t, dashCreate.Response)
require.Equal(t, http.StatusOK, dashCreate.Response.StatusCode)
// Extract dashboard UID from response
dashUID := (*dashCreate.Result)["uid"].(string)
require.NotEmpty(t, dashUID)
return dashUID
}
// Helper function to verify library panel exists
func verifyLibraryPanelExists(t *testing.T, helper *apis.K8sTestHelper, client *apis.K8sResourceClient, libPanelUID string) {
t.Helper()
libGet := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodGet,
Path: fmt.Sprintf("/api/library-elements/%s", libPanelUID),
}, &map[string]interface{}{})
require.Equal(t, http.StatusOK, libGet.Response.StatusCode)
}
// Helper function to verify library panel does not exist
func verifyLibraryPanelDeleted(t *testing.T, helper *apis.K8sTestHelper, client *apis.K8sResourceClient, libPanelUID, message string) {
t.Helper()
libGet := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodGet,
Path: fmt.Sprintf("/api/library-elements/%s", libPanelUID),
}, &map[string]interface{}{})
require.Equal(t, http.StatusNotFound, libGet.Response.StatusCode, message)
}
// Helper function to verify dashboard exists by UID
func verifyDashboardExists(t *testing.T, helper *apis.K8sTestHelper, client *apis.K8sResourceClient, dashUID string) {
t.Helper()
dashGet := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodGet,
Path: fmt.Sprintf("/api/dashboards/uid/%s", dashUID),
}, &map[string]interface{}{})
require.Equal(t, http.StatusOK, dashGet.Response.StatusCode, fmt.Sprintf("dashboard %s should still exist", dashUID))
}
// Test moving folders to root.
func TestIntegrationMoveNestedFolderToRootK8S(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
if !db.IsTestDbSQLite() {
t.Skip("test only on sqlite for now")
}
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: grafanarest.Mode5,
},
},
UnifiedStorageEnableSearch: true,
})
client := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: gvr,
})
// Create f1 under root
f1Payload := `{"title":"Folder 1","uid":"f1"}`
createF1 := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodPost,
Path: "/api/folders",
Body: []byte(f1Payload),
}, &dtos.Folder{})
require.NotNil(t, createF1.Result)
require.Equal(t, http.StatusOK, createF1.Response.StatusCode)
require.Equal(t, "f1", createF1.Result.UID)
require.Equal(t, "", createF1.Result.ParentUID)
// Create f2 under f1
f2Payload := `{"title":"Folder 2","uid":"f2","parentUid":"f1"}`
createF2 := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodPost,
Path: "/api/folders",
Body: []byte(f2Payload),
}, &dtos.Folder{})
require.NotNil(t, createF2.Result)
require.Equal(t, http.StatusOK, createF2.Response.StatusCode)
require.Equal(t, "f2", createF2.Result.UID)
require.Equal(t, "f1", createF2.Result.ParentUID)
// Move f2 to the root by having parentUid being empty in the request body
move := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodPost,
Path: "/api/folders/f2/move",
Body: []byte(`{"parentUid":""}`),
}, &dtos.Folder{})
require.NotNil(t, move.Result)
require.Equal(t, http.StatusOK, move.Response.StatusCode)
require.Equal(t, "f2", move.Result.UID)
require.Equal(t, "", move.Result.ParentUID)
// Fetch the folder to confirm it is now at root
get := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodGet,
Path: "/api/folders/f2",
}, &dtos.Folder{})
require.NotNil(t, get.Result)
require.Equal(t, http.StatusOK, get.Response.StatusCode)
require.Equal(t, "f2", get.Result.UID)
require.Equal(t, "", get.Result.ParentUID)
// Check that we can get the same folder using metadata.name selector
results, err := client.Resource.List(context.Background(), metav1.ListOptions{
FieldSelector: "metadata.name=" + get.Result.UID,
})
require.NoError(t, err)
require.Len(t, results.Items, 1)
require.Equal(t, "f2", results.Items[0].GetName())
}
// Test deleting nested folders ensures postorder deletion
func TestIntegrationDeleteNestedFoldersPostorder(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
if !db.IsTestDbSQLite() {
t.Skip("test only on sqlite for now")
}
for mode := 0; mode <= 5; mode++ {
t.Run(fmt.Sprintf("Mode %d: Delete nested folder hierarchy in postorder", mode), func(t *testing.T) {
modeDw := grafanarest.DualWriterMode(mode)
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: modeDw,
},
},
UnifiedStorageEnableSearch: true,
})
client := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: gvr,
})
// Helper function to create a folder and return its UID and ParentUID
createFolder := func(title, uid, parentUid string) (string, string) {
payload := fmt.Sprintf(`{"title":"%s","uid":"%s"%s}`, title, uid, func() string {
if parentUid != "" {
return fmt.Sprintf(`,"parentUid":"%s"`, parentUid)
}
return ""
}())
create := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodPost,
Path: "/api/folders",
Body: []byte(payload),
}, &folder.Folder{})
require.NotNil(t, create.Result)
require.Equal(t, http.StatusOK, create.Response.StatusCode)
return create.Result.UID, create.Result.ParentUID
}
// Create a nested folder structure:
// parent
// / \
// child1 child2
// |
// grandchild
// Create parent folder
parentUID, _ := createFolder(fmt.Sprintf("Parent-%d", mode), fmt.Sprintf("parent-%d", mode), "")
// Create child1 folder
child1UID, child1ParentUID := createFolder(fmt.Sprintf("Child1-%d", mode), fmt.Sprintf("child1-%d", mode), parentUID)
require.Equal(t, parentUID, child1ParentUID)
// Create child2 folder
child2UID, child2ParentUID := createFolder(fmt.Sprintf("Child2-%d", mode), fmt.Sprintf("child2-%d", mode), parentUID)
require.Equal(t, parentUID, child2ParentUID)
// Create grandchild folder under child1
grandchildUID, grandchildParentUID := createFolder(fmt.Sprintf("Grandchild-%d", mode), fmt.Sprintf("grandchild-%d", mode), child1UID)
require.Equal(t, child1UID, grandchildParentUID)
// Verify the structure before deletion
verifyFolderExists := func(uid string, shouldExist bool) {
_, err := client.Resource.Get(context.Background(), uid, metav1.GetOptions{})
if shouldExist {
require.NoError(t, err, "folder %s should exist", uid)
} else {
require.Error(t, err, "folder %s should not exist", uid)
}
}
// All folders should exist
verifyFolderExists(parentUID, true)
verifyFolderExists(child1UID, true)
verifyFolderExists(child2UID, true)
verifyFolderExists(grandchildUID, true)
// Delete the parent folder - this should trigger postorder deletion
parentDelete := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodDelete,
Path: "/api/folders/" + parentUID,
}, &folder.Folder{})
require.NotNil(t, parentDelete.Result)
require.Equal(t, http.StatusOK, parentDelete.Response.StatusCode)
// All folders should now be deleted (postorder deletion: grandchild, child1, child2, parent)
verifyFolderExists(grandchildUID, false)
verifyFolderExists(child1UID, false)
verifyFolderExists(child2UID, false)
verifyFolderExists(parentUID, false)
})
}
}
func setupProvisioningDir(t *testing.T, opts *testinfra.GrafanaOpts) {
opts.Dir, opts.DirPath = testinfra.CreateGrafDir(t, *opts)
// Create provisioning directories
provDashboardsDir := fmt.Sprintf("%s/conf/provisioning/dashboards", opts.Dir)
provDashboardsCfg := fmt.Sprintf("%s/dev.yaml", provDashboardsDir)
blob := []byte(fmt.Sprintf(`
apiVersion: 1
providers:
- name: 'provisioned dashboards'
type: file
orgId: 1
folder: 'GrafanaCloud'
options:
path: %s`, provDashboardsDir))
err := os.WriteFile(provDashboardsCfg, blob, 0o644)
require.NoError(t, err)
input, err := os.ReadFile(filepath.Join("testdata/dashboard.json"))
require.NoError(t, err)
provDashboardFile := filepath.Join(provDashboardsDir, "dashboard.json")
err = os.WriteFile(provDashboardFile, input, 0o644)
require.NoError(t, err)
}
// Test deleting folder with provisioned dashboard has proper handling with forceDeleteRules
func TestIntegrationDeleteFolderWithProvisionedDashboards(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
if !db.IsTestDbSQLite() {
t.Skip("test only on sqlite for now")
}
for mode := 0; mode <= 5; mode++ {
t.Run(fmt.Sprintf("Mode %d: Delete provisioned folders and dashboards", mode), func(t *testing.T) {
modeDw := grafanarest.DualWriterMode(mode)
ops := testinfra.GrafanaOpts{
DisableAnonymous: true,
AppModeProduction: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: modeDw,
},
"dashboards.dashboard.grafana.app": {
DualWriterMode: modeDw,
},
},
EnableFeatureToggles: []string{
featuremgmt.FlagUnifiedStorageSearch,
},
}
setupProvisioningDir(t, &ops)
helper := apis.NewK8sTestHelper(t, ops)
client := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: gvr,
})
var folderUID string
var dashboardUID string
require.EventuallyWithT(t, func(collect *assert.CollectT) {
resp := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodGet,
Path: fmt.Sprintf("/apis/dashboard.grafana.app/v0alpha1/namespaces/%s/search?query=dashboard&limit=50&type=dashboard", client.Args.Namespace),
}, &map[string]interface{}{})
var list v0alpha1.SearchResults
require.NotNil(t, resp.Response)
assert.Equal(t, http.StatusOK, resp.Response.StatusCode)
assert.NoError(t, json.Unmarshal(resp.Body, &list))
assert.Equal(collect, list.TotalHits, int64(1), "Dashboard should be ready")
for _, d := range list.Hits {
folderUID = d.Folder
dashboardUID = d.Name
}
}, 10*time.Second, 25*time.Millisecond)
_, err := client.Resource.Get(context.Background(), folderUID, metav1.GetOptions{})
require.NoError(t, err, "folder %s should exist", folderUID)
// Verify dashboards exist
verifyDashboardExists := func(shouldExist bool) {
getDash := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodGet,
Path: fmt.Sprintf("/apis/dashboard.grafana.app/v1beta1/namespaces/default/dashboards/%s", dashboardUID),
}, &map[string]interface{}{})
if shouldExist {
require.Equal(t, http.StatusOK, getDash.Response.StatusCode, "dashboard %s should exist", dashboardUID)
} else {
require.Equal(t, http.StatusNotFound, getDash.Response.StatusCode, "dashboard %s should not exist", dashboardUID)
}
}
verifyDashboardExists(true)
t.Run("Deletion should fail when forceDeleteRules=false with provisioned dashboards", func(t *testing.T) {
// Attempt to delete the parent folder without forceDeleteRules
parentDeleteNoForce := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodDelete,
Path: fmt.Sprintf("/api/folders/%s?forceDeleteRules=false", folderUID),
}, &folder.Folder{})
// Should fail because provisioned dashboards cannot be deleted without forceDeleteRules
require.Equal(t, http.StatusBadRequest, parentDeleteNoForce.Response.StatusCode, "deletion should fail without forceDeleteRules")
require.Contains(t, string(parentDeleteNoForce.Body), dashboards.ErrDashboardCannotDeleteProvisionedDashboard.Reason, "error message should indicate provisioned dashboards cannot be deleted")
// Verify folders still exist
_, err := client.Resource.Get(context.Background(), folderUID, metav1.GetOptions{})
require.NoError(t, err, "parent folder %d should still exist", folderUID)
// Verify dashboard still exist
verifyDashboardExists(true)
})
t.Run("Deletion should succeed when forceDeleteRules=true and delete provisioned dashboards", func(t *testing.T) {
_, err = client.Resource.Get(context.Background(), folderUID, metav1.GetOptions{})
require.NoError(t, err, "parent folder %d should still exist", folderUID)
// Delete the parent folder with forceDeleteRules=true
parentDelete := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodDelete,
Path: fmt.Sprintf("/api/folders/%s?forceDeleteRules=true", folderUID),
}, &folder.Folder{})
require.Equal(t, http.StatusOK, parentDelete.Response.StatusCode, "deletion should succeed with forceDeleteRules")
// Verify folders was deleted
_, err := client.Resource.Get(context.Background(), folderUID, metav1.GetOptions{})
require.Error(t, err, "parent folder %s should not exist", folderUID)
// Verify provisioned dashboard is deleted
verifyDashboardExists(false)
})
})
}
}
// Test that folders created during provisioning using the dual writer have the
// appropriate labels and annotations in unified storage.
func TestIntegrationProvisionedFolderPropagatesLabelsAndAnnotations(t *testing.T) {
mode3 := grafanarest.DualWriterMode(3)
ops := testinfra.GrafanaOpts{
DisableAnonymous: true,
AppModeProduction: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: mode3,
},
"dashboards.dashboard.grafana.app": {
DualWriterMode: mode3,
},
},
EnableFeatureToggles: []string{
featuremgmt.FlagUnifiedStorageSearch,
},
}
setupProvisioningDir(t, &ops)
helper := apis.NewK8sTestHelper(t, ops)
client := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: gvr,
})
var folderList folders.FolderList
require.EventuallyWithT(t, func(collect *assert.CollectT) {
resp := apis.DoRequest(helper, apis.RequestParams{
User: client.Args.User,
Method: http.MethodGet,
Path: fmt.Sprintf("/apis/folder.grafana.app/v1beta1/namespaces/%s/folders", client.Args.Namespace),
}, &map[string]interface{}{})
require.NotNil(t, resp.Response)
require.Equal(t, http.StatusOK, resp.Response.StatusCode)
require.NoError(t, json.Unmarshal(resp.Body, &folderList))
}, 10*time.Second, 25*time.Millisecond)
require.Len(t, folderList.Items, 1)
accessor, err := utils.MetaAccessor(&folderList.Items[0])
require.NoError(t, err)
expectedLabels := map[string]string{"grafana.app/deprecatedInternalID": "1"}
expectedAnnotations := map[string]string{
"grafana.app/createdBy": "access-policy:service",
"grafana.app/managedBy": "classic-file-provisioning",
"grafana.app/managerId": "provisioned dashboards",
}
require.Equal(t, expectedLabels, accessor.GetLabels())
require.Equal(t, expectedAnnotations, accessor.GetAnnotations())
}