Files
grafana/pkg/registry/apis/provisioning/jobs/sync/changes_test.go

570 lines
18 KiB
Go

package sync
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
)
func TestChanges(t *testing.T) {
t.Run("start the same", func(t *testing.T) {
source := []repository.FileTreeEntry{
{Path: "simplelocal/dashboard.json", Hash: "xyz", Blob: true},
}
target := &provisioning.ResourceList{
Items: []provisioning.ResourceListItem{
{Path: "simplelocal/dashboard.json", Hash: "xyz"},
},
}
changes, err := Changes(source, target)
require.NoError(t, err)
require.Empty(t, changes)
})
t.Run("create a source file", func(t *testing.T) {
source := []repository.FileTreeEntry{
{Path: "muta.json", Hash: "xyz", Blob: true},
}
target := &provisioning.ResourceList{}
changes, err := Changes(source, target)
require.NoError(t, err)
require.Len(t, changes, 1)
require.Equal(t, ResourceFileChange{
Action: repository.FileActionCreated,
Path: "muta.json",
}, changes[0])
})
t.Run("empty file path", func(t *testing.T) {
source := []repository.FileTreeEntry{}
target := &provisioning.ResourceList{
Items: []provisioning.ResourceListItem{
{Path: "", Resource: "dashboard", Group: "dashboard.grafana.app"},
},
}
_, err := Changes(source, target)
require.EqualError(t, err, "empty path on a non folder")
})
t.Run("empty path with folder resource", func(t *testing.T) {
source := []repository.FileTreeEntry{}
target := &provisioning.ResourceList{
Items: []provisioning.ResourceListItem{
{Path: "", Resource: resources.FolderResource.Resource, Group: resources.FolderResource.Group},
},
}
changes, err := Changes(source, target)
require.NoError(t, err)
require.Empty(t, changes)
})
t.Run("create empty folder structure for folders with unsupported file types", func(t *testing.T) {
source := []repository.FileTreeEntry{
{Path: "one/two/first.md", Hash: "xyz", Blob: true},
{Path: "other/second.md", Hash: "xyz", Blob: true},
}
target := &provisioning.ResourceList{}
changes, err := Changes(source, target)
require.NoError(t, err)
require.Len(t, changes, 2)
require.Equal(t, ResourceFileChange{
Action: repository.FileActionCreated,
Path: "one/two/",
}, changes[0])
require.Equal(t, ResourceFileChange{
Action: repository.FileActionCreated,
Path: "other/",
}, changes[1])
})
t.Run("keep empty folders when unsupported file types are present", func(t *testing.T) {
source := []repository.FileTreeEntry{
{Path: "one/two/first.md", Hash: "xyz", Blob: true},
{Path: "other/second.md", Hash: "xyz", Blob: true},
}
target := &provisioning.ResourceList{
Items: []provisioning.ResourceListItem{
{Path: "one/two/", Resource: "folders"},
{Path: "other/", Resource: "folders"},
},
}
changes, err := Changes(source, target)
require.NoError(t, err)
require.Empty(t, changes)
})
t.Run("keep common path to unsupported file types", func(t *testing.T) {
source := []repository.FileTreeEntry{
{Path: "common/first.md", Hash: "xyz", Blob: true},
{Path: "alsocommon/second.md", Hash: "xyz", Blob: true},
}
target := &provisioning.ResourceList{
Items: []provisioning.ResourceListItem{
{Path: "common/", Resource: "folders"},
{Path: "common/not-common/", Resource: "folders", Name: "uncommon-name", Hash: "xyz"},
{Path: "alsocommon/", Resource: "folders"},
},
}
changes, err := Changes(source, target)
require.NoError(t, err)
require.Len(t, changes, 1)
require.Equal(t, ResourceFileChange{
Action: repository.FileActionDeleted,
Path: "common/not-common/",
Existing: &provisioning.ResourceListItem{
Path: "common/not-common/",
Resource: "folders",
Name: "uncommon-name",
Hash: "xyz",
},
}, changes[0], "the uncommon path should be deleted")
})
t.Run("delete a source file", func(t *testing.T) {
source := []repository.FileTreeEntry{}
target := &provisioning.ResourceList{
Items: []provisioning.ResourceListItem{
{Path: "adsl62h.yaml", Hash: "xyz", Group: "dashboard.grafana.app", Resource: "dashboards", Name: "adsl62h-hrw-f-fvlt2dghp-gufrc4lisksgmq-c"},
},
}
changes, err := Changes(source, target)
require.NoError(t, err)
require.Len(t, changes, 1)
require.Equal(t, ResourceFileChange{
Action: repository.FileActionDeleted,
Path: "adsl62h.yaml",
Existing: &provisioning.ResourceListItem{
Path: "adsl62h.yaml",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Name: "adsl62h-hrw-f-fvlt2dghp-gufrc4lisksgmq-c",
Hash: "xyz",
},
}, changes[0])
})
t.Run("folder deletion order", func(t *testing.T) {
source := []repository.FileTreeEntry{
{Path: "x/y/z/ignored.md", Blob: true}, // ignored
{Path: "aaa/bbb.yaml", Hash: "xyz", Blob: true},
}
target := &provisioning.ResourceList{
Items: []provisioning.ResourceListItem{
{Path: "a.json"},
{Path: "x/y/file.json"},
{Path: "aaa/", Resource: "folders"}, // Folder... no action required
{Path: "aaa/bbb.yaml", Hash: "xyz"},
{Path: "zzz/longest/path/here.json"},
{Path: "short/file.yml"},
},
}
changes, err := Changes(source, target)
require.NoError(t, err)
order := make([]string, len(changes))
for i := range changes {
order[i] = changes[i].Path
}
require.Equal(t, []string{
"zzz/longest/path/here.json", // not sorted yet
"x/y/file.json",
"x/y/z/",
"short/file.yml",
"a.json",
}, order)
})
t.Run("modify a file", func(t *testing.T) {
source := []repository.FileTreeEntry{
{Path: "adsl62h.yaml", Hash: "modified", Blob: true},
}
target := &provisioning.ResourceList{
Items: []provisioning.ResourceListItem{
{
Path: "adsl62h.yaml",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Name: "adsl62h-hrw-f-fvlt2dghp-gufrc4lisksgmq-c",
Hash: "original",
},
},
}
changes, err := Changes(source, target)
require.NoError(t, err)
require.Len(t, changes, 1)
require.Equal(t, ResourceFileChange{
Action: repository.FileActionUpdated,
Path: "adsl62h.yaml",
Existing: &provisioning.ResourceListItem{
Path: "adsl62h.yaml",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Name: "adsl62h-hrw-f-fvlt2dghp-gufrc4lisksgmq-c",
Hash: "original",
},
}, changes[0])
})
t.Run("keep folder with hidden files", func(t *testing.T) {
source := []repository.FileTreeEntry{
{Path: "folder/.hidden.json", Hash: "xyz", Blob: true},
}
target := &provisioning.ResourceList{
Items: []provisioning.ResourceListItem{
{Path: "folder/", Resource: "folders"},
},
}
changes, err := Changes(source, target)
require.NoError(t, err)
require.Empty(t, changes, "folder should be kept when it contains hidden files")
})
t.Run("keep folder with invalid hidden paths", func(t *testing.T) {
source := []repository.FileTreeEntry{
{Path: "folder/.invalid/path.json", Hash: "xyz", Blob: true},
}
target := &provisioning.ResourceList{
Items: []provisioning.ResourceListItem{
{Path: "folder/", Resource: "folders"},
},
}
changes, err := Changes(source, target)
require.NoError(t, err)
require.Empty(t, changes, "folder should be kept when it contains invalid hidden paths")
})
t.Run("keep folder with hidden folders", func(t *testing.T) {
source := []repository.FileTreeEntry{
{Path: "folder/.hidden/valid.json", Hash: "xyz", Blob: true},
}
target := &provisioning.ResourceList{
Items: []provisioning.ResourceListItem{
{Path: "folder/", Resource: "folders"},
},
}
changes, err := Changes(source, target)
require.NoError(t, err)
require.Empty(t, changes, "folder should be kept when it contains hidden folders")
})
t.Run("unhidden path from hidden file", func(t *testing.T) {
source := []repository.FileTreeEntry{
{Path: "folder/.hidden/dashboard.json", Hash: "xyz", Blob: true},
}
target := &provisioning.ResourceList{
Items: []provisioning.ResourceListItem{
{Path: "folder/", Resource: "folders"},
},
}
changes, err := Changes(source, target)
require.NoError(t, err)
require.Empty(t, changes, "hidden file should not be unhidden")
})
t.Run("hidden path to file", func(t *testing.T) {
source := []repository.FileTreeEntry{
{Path: "one/two/.hidden/dashboard.json", Hash: "xyz", Blob: true},
}
target := &provisioning.ResourceList{}
expected := []ResourceFileChange{
{
Action: repository.FileActionCreated,
Path: "one/two/",
},
}
changes, err := Changes(source, target)
require.NoError(t, err)
require.Equal(t, expected, changes)
})
t.Run("hidden path to folder", func(t *testing.T) {
source := []repository.FileTreeEntry{
{Path: "one/two/.hidden/folder/", Hash: "xyz", Blob: true},
}
target := &provisioning.ResourceList{}
expected := []ResourceFileChange{
{
Action: repository.FileActionCreated,
Path: "one/two/",
},
}
changes, err := Changes(source, target)
require.NoError(t, err)
require.Equal(t, expected, changes)
})
t.Run("hidden at the root", func(t *testing.T) {
source := []repository.FileTreeEntry{
{Path: ".hidden/dashboard.json", Hash: "xyz", Blob: true},
}
target := &provisioning.ResourceList{}
changes, err := Changes(source, target)
require.NoError(t, err)
require.Empty(t, changes)
})
t.Run("nested folder with space is created correctly", func(t *testing.T) {
source := []repository.FileTreeEntry{
{Path: "abc/dash.json", Hash: "abc", Blob: true},
{Path: "abc/nested folder/nested-dashboard.json", Hash: "xyz", Blob: true},
}
target := &provisioning.ResourceList{}
expected := []ResourceFileChange{
{
Action: repository.FileActionCreated,
Path: "abc/nested folder/nested-dashboard.json",
},
{
Action: repository.FileActionCreated,
Path: "abc/dash.json",
},
}
changes, err := Changes(source, target)
require.NoError(t, err)
require.Equal(t, expected, changes, "Expected diff to correctly include nested folder contents")
})
t.Run("error on empty path for non-folder resource", func(t *testing.T) {
source := []repository.FileTreeEntry{
{Path: "", Hash: "xyz", Blob: true},
}
target := &provisioning.ResourceList{
Items: []provisioning.ResourceListItem{
{Path: "", Resource: "dashboard", Group: "dashboard.grafana.app"},
},
}
changes, err := Changes(source, target)
require.Error(t, err)
require.Contains(t, err.Error(), "empty path on a non folder")
require.Nil(t, changes)
})
t.Run("complex nested folder hierarchy with mixed file types", func(t *testing.T) {
source := []repository.FileTreeEntry{
{Path: "root/folder1/dashboard.json", Hash: "abc", Blob: true},
{Path: "root/folder1/subfolder/.gitkeep", Hash: "def", Blob: true},
{Path: "root/folder2/alert.json", Hash: "ghi", Blob: true},
{Path: "root/folder2/README.md", Hash: "jkl", Blob: true},
}
target := &provisioning.ResourceList{
Items: []provisioning.ResourceListItem{
{Path: "root/", Resource: "folders"},
{Path: "root/folder1/", Resource: "folders"},
{Path: "root/folder2/", Resource: "folders"},
{Path: "root/folder1/dashboard.json", Hash: "abc", Resource: "dashboard"},
{Path: "root/folder2/alert.json", Hash: "old", Resource: "alert"},
},
}
changes, err := Changes(source, target)
require.NoError(t, err)
require.Len(t, changes, 2)
require.Equal(t, "root/folder1/subfolder/", changes[0].Path)
require.Equal(t, "root/folder2/alert.json", changes[1].Path)
})
t.Run("folder path suffix handling", func(t *testing.T) {
source := []repository.FileTreeEntry{
{Path: "folder1/", Hash: "abc", Blob: false},
{Path: "folder2/", Hash: "def", Blob: false},
{Path: "folder3", Hash: "ghi", Blob: false},
{Path: "folder4/", Hash: "jkl", Blob: false},
}
target := &provisioning.ResourceList{
Items: []provisioning.ResourceListItem{
{Path: "folder1", Resource: resources.FolderResource.Resource, Group: resources.FolderResource.Group},
{Path: "folder2/", Resource: "folders", Group: resources.FolderResource.Group},
{Path: "folder3", Resource: "folders", Group: resources.FolderResource.Group},
{Path: "folder4/", Resource: "folders", Group: resources.FolderResource.Group},
},
}
changes, err := Changes(source, target)
require.NoError(t, err)
require.Empty(t, changes, "Should handle folder paths with and without trailing slash")
})
t.Run("empty source with populated target", func(t *testing.T) {
source := []repository.FileTreeEntry{}
target := &provisioning.ResourceList{
Items: []provisioning.ResourceListItem{
{Path: "folder1/", Resource: "folders"},
{Path: "folder1/dashboard.json", Resource: "dashboard"},
{Path: "folder2/", Resource: "folders"},
},
}
changes, err := Changes(source, target)
require.NoError(t, err)
require.Len(t, changes, 3)
// Verify deletion order (deepest first)
require.Equal(t, "folder1/dashboard.json", changes[0].Path)
require.Equal(t, "folder1/", changes[1].Path)
require.Equal(t, "folder2/", changes[2].Path)
for _, change := range changes {
require.Equal(t, repository.FileActionDeleted, change.Action)
}
})
t.Run("empty target with populated source", func(t *testing.T) {
source := []repository.FileTreeEntry{
{Path: "folder1/", Hash: "abc", Blob: false},
{Path: "folder1/dashboard.json", Hash: "def", Blob: true},
{Path: "folder2/", Hash: "ghi", Blob: false},
}
target := &provisioning.ResourceList{}
changes, err := Changes(source, target)
require.NoError(t, err)
require.Len(t, changes, 3) // Only non-blob entries should create changes
require.Equal(t, ResourceFileChange{
Action: repository.FileActionCreated,
Path: "folder1/dashboard.json",
}, changes[0])
require.Equal(t, ResourceFileChange{
Action: repository.FileActionCreated,
Path: "folder1/",
}, changes[1])
require.Equal(t, ResourceFileChange{
Action: repository.FileActionCreated,
Path: "folder2/",
}, changes[2])
})
t.Run("report correct changes with .keep files", func(t *testing.T) {
// Replicating how `source` is actually being passed in `Changes` function
source := []repository.FileTreeEntry{
{Path: "folder1/", Hash: "abc", Blob: false},
{Path: "folder1/.keep", Hash: "abc", Blob: true},
{Path: "folder1/dashboard.json", Hash: "def", Blob: true},
{Path: "folder2/", Hash: "ghi", Blob: false},
{Path: "folder2/.keep", Hash: "ghi", Blob: true},
}
target := &provisioning.ResourceList{}
changes, err := Changes(source, target)
require.NoError(t, err)
// 2 folders and 1 file, so 3 changes in total
require.Len(t, changes, 3)
require.Equal(t, ResourceFileChange{
Action: repository.FileActionCreated,
Path: "folder1/dashboard.json",
}, changes[0])
require.Equal(t, ResourceFileChange{
Action: repository.FileActionCreated,
Path: "folder1/",
}, changes[1])
require.Equal(t, ResourceFileChange{
Action: repository.FileActionCreated,
Path: "folder2/",
}, changes[2])
})
}
func TestCompare(t *testing.T) {
tests := []struct {
name string
setupMocks func(*repository.MockRepository, *resources.MockRepositoryResources)
expectedError string
expectedChanges []ResourceFileChange
description string
}{
{
name: "error listing current resources",
description: "Should return error when listing current resources fails",
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources) {
repoResources.On("List", mock.Anything).Return(nil, fmt.Errorf("listing failed"))
},
expectedError: "error listing current: listing failed",
},
{
name: "error reading tree",
description: "Should return error when reading tree fails",
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources) {
repoResources.On("List", mock.Anything).Return(&provisioning.ResourceList{}, nil)
repo.On("ReadTree", mock.Anything, "current-ref").Return(nil, fmt.Errorf("read tree failed"))
},
expectedError: "error reading tree: read tree failed",
},
{
name: "no changes between source and target",
description: "Should return empty changes when source and target are identical",
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources) {
target := &provisioning.ResourceList{
Items: []provisioning.ResourceListItem{
{Path: "dashboard.json", Hash: "xyz"},
},
}
source := []repository.FileTreeEntry{
{Path: "dashboard.json", Hash: "xyz", Blob: true},
}
repoResources.On("List", mock.Anything).Return(target, nil)
repo.On("ReadTree", mock.Anything, "current-ref").Return(source, nil)
},
expectedChanges: []ResourceFileChange{},
},
{
name: "compare function error",
description: "Should return error when comparing fails",
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources) {
target := &provisioning.ResourceList{
Items: []provisioning.ResourceListItem{
// Empty path to trigger error
{Path: "", Hash: "xyz", Resource: "dashboard", Group: "dashboard.grafana.app"},
},
}
source := []repository.FileTreeEntry{
{Path: "dashboard.json", Hash: "xyz", Blob: true},
}
repoResources.On("List", mock.Anything).Return(target, nil)
repo.On("ReadTree", mock.Anything, "current-ref").Return(source, nil)
},
expectedError: "calculate changes: empty path on a non folder",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := repository.NewMockRepository(t)
repoResources := resources.NewMockRepositoryResources(t)
tt.setupMocks(repo, repoResources)
changes, err := Compare(context.Background(), repo, repoResources, "current-ref")
if tt.expectedError != "" {
require.EqualError(t, err, tt.expectedError, tt.description)
require.Nil(t, changes)
} else {
require.NoError(t, err, tt.description)
require.Equal(t, tt.expectedChanges, changes, tt.description)
}
})
}
}