2025-01-21 16:14:31 +08:00
package folder
2023-12-21 02:28:56 +08:00
import (
2024-10-24 23:04:32 +08:00
"bytes"
2024-09-12 20:36:46 +08:00
"context"
2023-12-21 02:28:56 +08:00
"encoding/json"
2024-10-10 19:22:57 +08:00
"fmt"
2024-09-12 20:36:46 +08:00
"net/http"
"slices"
2023-12-21 02:28:56 +08:00
"testing"
2025-09-30 05:12:34 +08:00
"time"
2023-12-21 02:28:56 +08:00
"github.com/stretchr/testify/require"
2024-11-22 21:38:00 +08:00
"k8s.io/apimachinery/pkg/api/meta"
2024-09-12 20:36:46 +08:00
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
2023-12-21 02:28:56 +08:00
2025-09-30 05:12:34 +08:00
"github.com/grafana/grafana/pkg/expr"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/prometheus/common/model"
2025-04-15 04:20:10 +08:00
folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
2024-10-10 19:22:57 +08:00
"github.com/grafana/grafana/pkg/api/dtos"
2024-09-12 20:36:46 +08:00
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
2025-07-10 20:58:35 +08:00
"github.com/grafana/grafana/pkg/infra/db"
2024-10-11 21:13:56 +08:00
"github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions"
2024-10-24 23:04:32 +08:00
"github.com/grafana/grafana/pkg/services/dashboards"
2023-12-21 02:28:56 +08:00
"github.com/grafana/grafana/pkg/services/featuremgmt"
2024-09-12 20:36:46 +08:00
"github.com/grafana/grafana/pkg/services/folder"
2024-10-11 21:13:56 +08:00
"github.com/grafana/grafana/pkg/services/org"
2024-10-24 23:04:32 +08:00
"github.com/grafana/grafana/pkg/services/user"
2024-09-12 20:36:46 +08:00
"github.com/grafana/grafana/pkg/setting"
2025-09-30 05:12:34 +08:00
alerting "github.com/grafana/grafana/pkg/tests/api/alerting"
2023-12-21 02:28:56 +08:00
"github.com/grafana/grafana/pkg/tests/apis"
"github.com/grafana/grafana/pkg/tests/testinfra"
2024-03-01 06:58:49 +08:00
"github.com/grafana/grafana/pkg/tests/testsuite"
2025-09-08 18:50:31 +08:00
"github.com/grafana/grafana/pkg/util/testutil"
2023-12-21 02:28:56 +08:00
)
2024-03-01 06:58:49 +08:00
func TestMain ( m * testing . M ) {
testsuite . Run ( m )
}
2024-09-12 20:36:46 +08:00
var gvr = schema . GroupVersionResource {
2025-04-15 04:20:10 +08:00
Group : folders . GROUP ,
Version : folders . VERSION ,
2024-09-12 20:36:46 +08:00
Resource : "folders" ,
}
2024-03-01 06:58:49 +08:00
func TestIntegrationFoldersApp ( t * testing . T ) {
2025-09-08 21:49:49 +08:00
testutil . SkipIntegrationTestInShortMode ( t )
2025-07-11 01:48:14 +08:00
if ! db . IsTestDbSQLite ( ) {
t . Skip ( "test only on sqlite for now" )
}
2023-12-21 02:28:56 +08:00
helper := apis . NewK8sTestHelper ( t , testinfra . GrafanaOpts {
2025-07-30 05:52:57 +08:00
AppModeProduction : true ,
EnableFeatureToggles : [ ] string { } ,
2023-12-21 02:28:56 +08:00
} )
t . Run ( "Check discovery client" , func ( t * testing . T ) {
disco := helper . NewDiscoveryClient ( )
2025-04-15 04:20:10 +08:00
resources , err := disco . ServerResourcesForGroupVersion ( "folder.grafana.app/v1beta1" )
2023-12-21 02:28:56 +08:00
require . NoError ( t , err )
v1Disco , err := json . MarshalIndent ( resources , "" , " " )
require . NoError ( t , err )
require . JSONEq ( t , ` {
"kind" : "APIResourceList" ,
"apiVersion" : "v1" ,
2025-04-15 04:20:10 +08:00
"groupVersion" : "folder.grafana.app/v1beta1" ,
2023-12-21 02:28:56 +08:00
"resources" : [
2025-04-15 04:20:10 +08:00
{
"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"
]
} ,
2025-07-03 22:15:23 +08:00
{
"name" : "folders/children" ,
"singularName" : "" ,
"namespaced" : true ,
"kind" : "FolderList" ,
"verbs" : [
"get"
]
} ,
2025-04-15 04:20:10 +08:00
{
"name" : "folders/counts" ,
"singularName" : "" ,
"namespaced" : true ,
"kind" : "DescendantCounts" ,
"verbs" : [
"get"
]
} ,
{
"name" : "folders/parents" ,
"singularName" : "" ,
"namespaced" : true ,
"kind" : "FolderInfoList" ,
"verbs" : [
"get"
]
}
2023-12-21 02:28:56 +08:00
]
2025-04-15 04:20:10 +08:00
} ` , string ( v1Disco ) )
2023-12-21 02:28:56 +08:00
} )
2024-09-12 20:36:46 +08:00
2025-07-11 02:16:27 +08:00
// 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 ,
} ,
2024-09-12 20:36:46 +08:00
} ,
2025-07-30 05:52:57 +08:00
EnableFeatureToggles : [ ] string { } ,
2025-07-11 02:16:27 +08:00
} ) )
} )
2024-09-12 20:36:46 +08:00
2025-07-11 02:16:27 +08:00
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 ,
} ,
2024-09-12 20:36:46 +08:00
} ,
2025-07-11 02:16:27 +08:00
} ) )
} )
2024-09-12 20:36:46 +08:00
2025-07-11 02:16:27 +08:00
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 ,
} ,
2024-10-10 19:22:57 +08:00
} ,
2025-07-11 02:16:27 +08:00
} ) )
} )
2024-10-22 00:07:11 +08:00
2025-07-11 02:16:27 +08:00
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 ,
} ,
2024-10-22 00:07:11 +08:00
} ,
2025-07-11 02:16:27 +08:00
} ) )
} )
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 ,
} ,
} ,
} ) )
} )
}
2024-10-28 21:24:28 +08:00
2025-06-13 21:59:46 +08:00
// 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 ) {
2025-09-18 23:03:32 +08:00
t . Skip ( "Skipping flaky test - list works with continue tokens" )
2025-06-13 21:59:46 +08:00
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 )
} )
}
} )
2024-09-12 20:36:46 +08:00
}
2025-09-30 05:12:34 +08:00
// 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 } ,
} ,
EnableFeatureToggles : [ ] string {
featuremgmt . FlagUnifiedStorageSearch ,
} ,
} )
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 )
} )
}
2024-09-12 20:36:46 +08:00
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 )
2025-07-11 02:16:27 +08:00
//nolint:staticcheck
id := legacyCreate . Result . ID
require . NotEmpty ( t , id )
idStr := fmt . Sprintf ( "%d" , id )
2024-09-12 20:36:46 +08:00
expectedResult := ` {
2025-04-15 04:20:10 +08:00
"apiVersion" : "folder.grafana.app/v1beta1" ,
2024-09-12 20:36:46 +08:00
"kind" : "Folder" ,
"metadata" : {
"creationTimestamp" : "${creationTimestamp}" ,
2025-07-11 02:16:27 +08:00
"labels" : { "grafana.app/deprecatedInternalID" : "` + idStr + `" } ,
2024-09-12 20:36:46 +08:00
"name" : "` + uid + `" ,
"namespace" : "default" ,
"resourceVersion" : "${resourceVersion}" ,
"uid" : "${uid}"
} ,
"spec" : {
2025-07-11 02:16:27 +08:00
"title" : "Test" ,
"description" : ""
2025-04-15 04:20:10 +08:00
} ,
"status" : { }
2024-09-12 20:36:46 +08:00
} `
// 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 ) )
} )
2024-11-22 21:38:00 +08:00
t . Run ( "Do CRUD (just CR+List for now) via k8s (and check that legacy api still works)" , func ( t * testing . T ) {
2024-09-12 20:36:46 +08:00
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
2024-11-22 23:51:53 +08:00
// Check all folders
2024-09-12 20:36:46 +08:00
for _ , uid := range uids {
getFromBothAPIs ( t , helper , client , uid , nil )
}
2024-10-22 01:08:03 +08:00
// 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 )
2024-11-22 23:51:53 +08:00
2024-10-22 01:08:03 +08:00
// #TODO figure out why this breaks just for MySQL integration tests
// require.Less(t, first.GetResourceVersion(), updated.GetResourceVersion())
2024-11-22 21:38:00 +08:00
// 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 )
2024-11-22 23:51:53 +08:00
// delete test
errDelete := client . Resource . Delete ( context . Background ( ) , first . GetName ( ) , metav1 . DeleteOptions { } )
require . NoError ( t , errDelete )
2024-09-12 20:36:46 +08:00
} )
return helper
}
2024-10-11 21:13:56 +08:00
// 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 ) {
2024-10-10 19:22:57 +08:00
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 )
2024-10-25 06:54:33 +08:00
// creating a folder without providing a parent should default to the empty parent folder
require . Empty ( t , parentCreate . Result . ParentUID )
2024-10-10 19:22:57 +08:00
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 ]
2024-10-25 06:54:33 +08:00
// creating a folder with a known parent should succeed
require . Equal ( t , parentUID , childCreate . Result . ParentUID )
2024-10-10 19:22:57 +08:00
require . Equal ( t , parentUID , parent . UID )
2025-01-23 22:25:03 +08:00
require . Equal ( t , "Test/parent" , parent . Title )
2024-10-10 19:22:57 +08:00
require . Equal ( t , parentCreate . Result . URL , parent . URL )
}
2024-10-22 00:07:11 +08:00
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 )
2024-10-29 13:58:39 +08:00
require . Equal ( t , 200 , create2 . Response . StatusCode ) // it is OK
2024-10-22 00:07:11 +08:00
}
2024-10-10 19:22:57 +08:00
2024-10-28 21:24:28 +08:00
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 )
}
2024-10-30 05:06:55 +08:00
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 )
}
2025-06-13 21:59:46 +08:00
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
}
2024-10-11 21:13:56 +08:00
func TestIntegrationFolderCreatePermissions ( t * testing . T ) {
2025-09-08 21:49:49 +08:00
testutil . SkipIntegrationTestInShortMode ( t )
2025-07-11 01:48:14 +08:00
if ! db . IsTestDbSQLite ( ) {
t . Skip ( "test only on sqlite for now" )
}
2024-09-12 20:36:46 +08:00
2024-10-11 21:13:56 +08:00
folderWithoutParentInput := "{ \"uid\": \"uid\", \"title\": \"Folder\"}"
folderWithParentInput := "{ \"uid\": \"uid\", \"title\": \"Folder\", \"parentUid\": \"parentuid\"}"
2024-09-12 20:36:46 +08:00
2024-10-11 21:13:56 +08:00
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 : "*" ,
} ,
} ,
} ,
2024-10-30 05:06:55 +08:00
{
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" ,
} ,
} ,
} ,
2024-10-11 21:13:56 +08:00
{
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" ,
} ,
} ,
} ,
}
2024-09-12 20:36:46 +08:00
2025-07-11 20:20:01 +08:00
// test on all dualwriter modes
for mode := 0 ; mode <= 4 ; mode ++ {
for _ , tc := range tcs {
t . Run ( fmt . Sprintf ( "[Mode: %v] " + tc . description , 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 ,
} ,
2024-10-11 21:13:56 +08:00
} ,
2025-07-11 20:20:01 +08:00
} )
2024-10-11 21:13:56 +08:00
2025-07-11 20:20:01 +08:00
user := helper . CreateUser ( "user" , apis . Org1 , org . RoleViewer , tc . permissions )
2024-10-11 21:13:56 +08:00
2025-07-11 20:20:01 +08:00
parentPayload := ` {
2024-10-11 21:13:56 +08:00
"title" : "Test/parent" ,
"uid" : "parentuid"
} `
2025-07-11 20:20:01 +08:00
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 )
parentUID := parentCreate . Result . UID
require . NotEmpty ( t , parentUID )
resp := apis . DoRequest ( helper , apis . RequestParams {
User : user ,
Method : http . MethodPost ,
Path : "/api/folders" ,
Body : [ ] byte ( tc . input ) ,
} , & dtos . Folder { } )
require . Equal ( t , tc . expectedCode , resp . Response . StatusCode )
if tc . expectedCode == http . StatusOK {
require . Equal ( t , "uid" , resp . Result . UID )
require . Equal ( t , "Folder" , resp . Result . Title )
}
} )
}
2024-09-12 20:36:46 +08:00
}
2023-12-21 02:28:56 +08:00
}
2024-10-24 23:04:32 +08:00
2024-11-26 22:20:00 +08:00
func TestIntegrationFolderGetPermissions ( t * testing . T ) {
2025-09-08 21:49:49 +08:00
testutil . SkipIntegrationTestInShortMode ( t )
2025-07-11 01:48:14 +08:00
if ! db . IsTestDbSQLite ( ) {
t . Skip ( "test only on sqlite for now" )
}
2024-11-26 22:20:00 +08:00
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 ,
} ,
{
2025-06-30 21:32:12 +08:00
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" ,
2024-11-26 22:20:00 +08:00
expectedCode : http . StatusOK ,
2025-06-30 21:32:12 +08:00
expectedParentUIDs : [ ] string { } ,
expectedParentTitles : [ ] string { } ,
2024-11-26 22:20:00 +08:00
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 { } ,
} ,
}
2025-07-11 20:20:01 +08:00
// test on all dualwriter modes
for mode := 0 ; mode <= 4 ; mode ++ {
for _ , tc := range tcs {
t . Run ( fmt . Sprintf ( "[Mode: %v] " + tc . description , 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 ,
} ,
2024-11-26 22:20:00 +08:00
} ,
2025-07-11 20:20:01 +08:00
} )
2024-11-26 22:20:00 +08:00
2025-07-11 20:20:01 +08:00
// Create parent folder
parentPayload := ` {
2024-11-26 22:20:00 +08:00
"title" : "testparent" ,
"uid" : "parentuid"
} `
2025-07-11 20:20:01 +08:00
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 )
parentUID := parentCreate . Result . UID
require . NotEmpty ( t , parentUID )
// Create descendant folder
payload := "{ \"uid\": \"descuid\", \"title\": \"Folder\", \"parentUid\": \"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 )
user := helper . CreateUser ( "user" , apis . Org1 , org . RoleNone , tc . permissions )
2024-11-26 22:20:00 +08:00
2025-07-11 20:20:01 +08:00
// Get with accesscontrol disabled
getResp := apis . DoRequest ( helper , apis . RequestParams {
User : user ,
2024-11-26 22:20:00 +08:00
Method : http . MethodGet ,
2025-07-11 20:20:01 +08:00
Path : "/api/folders/descuid" ,
2024-11-26 22:20:00 +08:00
} , & dtos . Folder { } )
2025-07-11 20:20:01 +08:00
require . Equal ( t , tc . expectedCode , getResp . Response . StatusCode )
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 ( tc . expectedParentUIDs ) , len ( parents ) )
require . Equal ( t , len ( tc . expectedParentTitles ) , len ( parents ) )
for i := 0 ; i < len ( tc . expectedParentUIDs ) ; i ++ {
require . Equal ( t , tc . expectedParentUIDs [ i ] , parents [ i ] . UID )
require . Equal ( t , tc . expectedParentTitles [ i ] , parents [ i ] . Title )
}
2024-11-26 22:20:00 +08:00
2025-07-11 20:20:01 +08:00
// 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 ( "acuser" , apis . Org1 , org . RoleNone , acPerms )
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 ] )
}
} )
}
2024-11-26 22:20:00 +08:00
}
}
2024-10-24 23:04:32 +08:00
// TestFoldersCreateAPIEndpointK8S is the counterpart of pkg/api/folder_test.go TestFoldersCreateAPIEndpoint
2025-07-09 03:32:41 +08:00
func TestIntegrationFoldersCreateAPIEndpointK8S ( t * testing . T ) {
2025-09-08 21:49:49 +08:00
testutil . SkipIntegrationTestInShortMode ( t )
2025-07-11 01:48:14 +08:00
if ! db . IsTestDbSQLite ( ) {
t . Skip ( "test only on sqlite for now" )
}
2025-07-09 03:32:41 +08:00
2024-10-24 23:04:32 +08:00
folderWithoutParentInput := "{ \"uid\": \"uid\", \"title\": \"Folder\"}"
folderWithTitleEmpty := "{ \"title\": \"\"}"
2024-10-28 19:33:56 +08:00
folderWithInvalidUid := "{ \"uid\": \"::::::::::::\", \"title\": \"Another folder\"}"
folderWithUIDTooLong := "{ \"uid\": \"asdfghjklqwertyuiopzxcvbnmasdfghjklqwertyuiopzxcvbnmasdfghjklqwertyuiopzxcvbnm\", \"title\": \"Third folder\"}"
2024-10-24 23:04:32 +08:00
type testCase struct {
description string
expectedCode int
2024-10-28 19:33:56 +08:00
expectedMessage string
2024-10-24 23:04:32 +08:00
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 ,
} ,
{
2024-10-28 19:33:56 +08:00
description : "folder creation fails without permissions to create a folder" ,
input : folderWithoutParentInput ,
expectedCode : http . StatusForbidden ,
2025-07-08 22:26:45 +08:00
expectedMessage : fmt . Sprintf ( "You'll need additional permissions to perform this action. Permissions needed: %s" , "folders:create" ) ,
2024-10-28 19:33:56 +08:00
permissions : [ ] resourcepermissions . SetResourcePermissionCommand { } ,
2024-10-24 23:04:32 +08:00
} ,
{
description : "folder creation fails given folder service error %s" ,
input : folderWithTitleEmpty ,
expectedCode : http . StatusBadRequest ,
2024-10-28 19:33:56 +08:00
expectedMessage : dashboards . ErrFolderTitleEmpty . Error ( ) ,
2024-10-24 23:04:32 +08:00
expectedFolderSvcError : dashboards . ErrFolderTitleEmpty ,
permissions : folderCreatePermission ,
} ,
{
description : "folder creation fails given folder service error %s" ,
input : folderWithInvalidUid ,
expectedCode : http . StatusBadRequest ,
2024-10-28 19:33:56 +08:00
expectedMessage : dashboards . ErrDashboardInvalidUid . Error ( ) ,
2024-10-24 23:04:32 +08:00
expectedFolderSvcError : dashboards . ErrDashboardInvalidUid ,
permissions : folderCreatePermission ,
} ,
{
description : "folder creation fails given folder service error %s" ,
input : folderWithUIDTooLong ,
expectedCode : http . StatusBadRequest ,
2024-10-28 19:33:56 +08:00
expectedMessage : dashboards . ErrDashboardUidTooLong . Error ( ) ,
2024-10-24 23:04:32 +08:00
expectedFolderSvcError : dashboards . ErrDashboardUidTooLong ,
permissions : folderCreatePermission ,
} ,
{
description : "folder creation fails given folder service error %s" ,
input : folderWithoutParentInput ,
expectedCode : http . StatusPreconditionFailed ,
2024-10-28 19:33:56 +08:00
expectedMessage : dashboards . ErrFolderVersionMismatch . Error ( ) ,
2024-10-24 23:04:32 +08:00
expectedFolderSvcError : dashboards . ErrFolderVersionMismatch ,
createSecondRecord : true ,
permissions : folderCreatePermission ,
} ,
}
2025-07-11 20:20:01 +08:00
// test on all dualwriter modes
for mode := 0 ; mode <= 4 ; mode ++ {
for _ , tc := range tcs {
t . Run ( fmt . Sprintf ( "[Mode: %v] " + testDescription ( tc . description , tc . expectedFolderSvcError ) , 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 ,
} ,
} ,
2024-10-24 23:04:32 +08:00
} )
2025-07-11 20:20:01 +08:00
userTest := helper . CreateUser ( "user" , 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 ( tc . input ) ,
} , & folder . Folder { } )
require . NotEmpty ( t , create2 . Response )
require . Equal ( t , http . StatusOK , create2 . Response . StatusCode )
}
2024-10-24 23:04:32 +08:00
2025-07-11 20:20:01 +08:00
addr := helper . GetEnv ( ) . Server . HTTPServer . Listener . Addr ( )
login := userTest . Identity . GetLogin ( )
baseUrl := fmt . Sprintf ( "http://%s:%s@%s" , login , user . Password ( "user" ) , addr )
req , err := http . NewRequest ( http . MethodPost , fmt . Sprintf (
"%s%s" ,
baseUrl ,
"/api/folders" ,
) , bytes . NewBuffer ( [ ] byte ( tc . 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" `
}
2024-10-28 19:33:56 +08:00
2025-07-11 20:20:01 +08:00
folder := folderWithMessage { }
err = json . NewDecoder ( resp . Body ) . Decode ( & folder )
require . NoError ( t , err )
require . NoError ( t , resp . Body . Close ( ) )
2024-10-24 23:04:32 +08:00
2025-07-11 20:20:01 +08:00
if tc . expectedCode == http . StatusOK {
require . Equal ( t , "uid" , folder . UID )
require . Equal ( t , "Folder" , folder . Title )
}
2024-10-28 19:33:56 +08:00
2025-07-11 20:20:01 +08:00
if tc . expectedMessage != "" {
require . Equal ( t , tc . expectedMessage , folder . Message )
}
} )
}
2024-10-24 23:04:32 +08:00
}
}
func testDescription ( description string , expectedErr error ) string {
if expectedErr != nil {
return fmt . Sprintf ( description , expectedErr . Error ( ) )
} else {
return description
}
}
2024-11-22 21:38:00 +08:00
// There are no counterpart of TestFoldersGetAPIEndpointK8S in pkg/api/folder_test.go
2025-07-09 03:32:41 +08:00
func TestIntegrationFoldersGetAPIEndpointK8S ( t * testing . T ) {
2025-09-08 21:49:49 +08:00
testutil . SkipIntegrationTestInShortMode ( t )
2025-07-10 20:58:35 +08:00
if ! db . IsTestDbSQLite ( ) {
t . Skip ( "test only on sqlite for now" )
}
2025-07-09 03:32:41 +08:00
2024-11-22 21:38:00 +08:00
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 {
2024-11-28 17:45:31 +08:00
{ UID : "foo" , Title : "Folder 1" } ,
{ UID : "qux" , Title : "Folder 3" } ,
2025-07-08 22:26:45 +08:00
{ UID : folder . SharedWithMeFolder . UID , Title : folder . SharedWithMeFolder . Title } ,
2024-11-22 21:38:00 +08:00
} ,
permissions : folderReadAndCreatePermission ,
} ,
{
description : "listing subfolders succeeds" ,
createFolders : [ ] string {
folder1 ,
folder2 ,
folder3 ,
} ,
params : "?parentUid=foo" ,
expectedCode : http . StatusOK ,
expectedOutput : [ ] dtos . FolderSearchHit {
2024-11-28 17:45:31 +08:00
{ UID : "bar" , Title : "Folder 2" , ParentUID : "foo" } ,
2024-11-22 21:38:00 +08:00
} ,
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 ,
} ,
}
// test on all dualwriter modes
2025-07-08 22:26:45 +08:00
for mode := 0 ; mode <= 4 ; mode ++ {
2024-11-22 21:38:00 +08:00
for _ , tc := range tcs {
t . Run ( fmt . Sprintf ( "Mode: %d, %s" , mode , tc . description ) , 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 {
2025-04-11 20:09:52 +08:00
folders . RESOURCEGROUP : {
2024-11-22 21:38:00 +08:00
DualWriterMode : modeDw ,
} ,
} ,
EnableFeatureToggles : [ ] string {
2025-07-08 22:26:45 +08:00
featuremgmt . FlagUnifiedStorageSearch ,
2024-11-22 21:38:00 +08:00
} ,
} )
userTest := helper . CreateUser ( "user" , apis . Org1 , org . RoleNone , tc . permissions )
for _ , f := range tc . createFolders {
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 ( f ) ,
} , & 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 ( "user" ) , addr )
req , err := http . NewRequest ( http . MethodGet , fmt . Sprintf (
"%s%s" ,
baseUrl ,
fmt . Sprintf ( "/api/folders%s" , tc . 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 ( ) )
// ignore IDs
for i := 0 ; i < len ( list ) ; i ++ {
list [ i ] . ID = 0
}
require . ElementsMatch ( t , tc . expectedOutput , list )
}
} )
}
}
}
2025-08-19 23:32:35 +08:00
// Reproduces a bug where folder deletion does not check for attached library panels.
func TestIntegrationFolderDeletionBlockedByLibraryElements ( t * testing . T ) {
2025-09-08 18:50:31 +08:00
testutil . SkipIntegrationTestInShortMode ( t )
2025-08-19 23:32:35 +08:00
if ! db . IsTestDbSQLite ( ) {
t . Skip ( "test only on sqlite for now" )
}
// test on all dualwriter modes
for mode := 0 ; mode <= 2 ; 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 . FlagUnifiedStorageSearch ,
featuremgmt . FlagKubernetesLibraryPanels ,
} ,
} )
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 ) {
2025-09-08 18:50:31 +08:00
testutil . SkipIntegrationTestInShortMode ( t )
2025-08-19 23:32:35 +08:00
if ! db . IsTestDbSQLite ( ) {
t . Skip ( "test only on sqlite for now" )
}
2025-09-12 02:44:14 +08:00
for mode := 0 ; mode <= 5 ; mode ++ {
2025-08-19 23:32:35 +08:00
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 . FlagUnifiedStorageSearch ,
featuremgmt . FlagKubernetesLibraryPanels ,
} ,
} )
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" )
} )
}
}
2025-09-30 16:52:06 +08:00
// 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 ,
EnableFeatureToggles : [ ] string { featuremgmt . FlagUnifiedStorageSearch } ,
APIServerStorageType : "unified" ,
UnifiedStorageConfig : map [ string ] setting . UnifiedStorageConfig {
folders . RESOURCEGROUP : {
DualWriterMode : grafanarest . Mode5 ,
} ,
} ,
} )
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 )
}