568 lines
18 KiB
Go
568 lines
18 KiB
Go
package resources
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/mock"
|
|
"github.com/stretchr/testify/require"
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
"k8s.io/apimachinery/pkg/watch"
|
|
"k8s.io/client-go/dynamic"
|
|
|
|
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
|
)
|
|
|
|
func TestRepositoryResources_FindResourcePath(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
resourceName string
|
|
gvk schema.GroupVersionKind
|
|
expectedGVR schema.GroupVersionResource
|
|
forKindError error
|
|
getError error
|
|
resourceObj *unstructured.Unstructured
|
|
expectedPath string
|
|
expectedError string
|
|
}{
|
|
{
|
|
name: "dashboard found successfully",
|
|
resourceName: "test-dashboard",
|
|
gvk: schema.GroupVersionKind{
|
|
Group: "dashboard.grafana.app",
|
|
Kind: "Dashboard",
|
|
},
|
|
expectedGVR: schema.GroupVersionResource{
|
|
Group: "dashboard.grafana.app",
|
|
Version: "v0alpha1",
|
|
Resource: "dashboards",
|
|
},
|
|
resourceObj: &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"metadata": map[string]interface{}{
|
|
"name": "test-dashboard",
|
|
"namespace": "test-namespace",
|
|
"annotations": map[string]interface{}{
|
|
utils.AnnoKeySourcePath: "dashboards/test-dashboard.json",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expectedPath: "dashboards/test-dashboard.json",
|
|
},
|
|
{
|
|
name: "folder found successfully",
|
|
resourceName: "test-folder",
|
|
gvk: schema.GroupVersionKind{
|
|
Group: "folder.grafana.app",
|
|
Kind: "Folder",
|
|
},
|
|
expectedGVR: schema.GroupVersionResource{
|
|
Group: "folder.grafana.app",
|
|
Version: "v0alpha1",
|
|
Resource: "folders",
|
|
},
|
|
resourceObj: &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"metadata": map[string]interface{}{
|
|
"name": "test-folder",
|
|
"namespace": "test-namespace",
|
|
"annotations": map[string]interface{}{
|
|
utils.AnnoKeySourcePath: "folders/test-folder",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expectedPath: "folders/test-folder/", // Trailing slash added for folder resources
|
|
},
|
|
{
|
|
name: "ForKind fails",
|
|
resourceName: "test-dashboard",
|
|
gvk: schema.GroupVersionKind{
|
|
Group: "dashboard.grafana.app",
|
|
Kind: "Dashboard",
|
|
},
|
|
forKindError: errors.New("kind not found"),
|
|
expectedError: "get client for kind Dashboard: kind not found",
|
|
},
|
|
{
|
|
name: "resource not found",
|
|
resourceName: "nonexistent-dashboard",
|
|
gvk: schema.GroupVersionKind{
|
|
Group: "dashboard.grafana.app",
|
|
Kind: "Dashboard",
|
|
},
|
|
expectedGVR: schema.GroupVersionResource{
|
|
Group: "dashboard.grafana.app",
|
|
Version: "v0alpha1",
|
|
Resource: "dashboards",
|
|
},
|
|
getError: apierrors.NewNotFound(schema.GroupResource{Group: "dashboard.grafana.app", Resource: "dashboards"}, "nonexistent-dashboard"),
|
|
expectedError: "resource not found: dashboard.grafana.app/dashboards/nonexistent-dashboard",
|
|
},
|
|
{
|
|
name: "Get operation fails with other error",
|
|
resourceName: "test-dashboard",
|
|
gvk: schema.GroupVersionKind{
|
|
Group: "dashboard.grafana.app",
|
|
Kind: "Dashboard",
|
|
},
|
|
expectedGVR: schema.GroupVersionResource{
|
|
Group: "dashboard.grafana.app",
|
|
Version: "v0alpha1",
|
|
Resource: "dashboards",
|
|
},
|
|
getError: errors.New("internal server error"),
|
|
expectedError: "failed to get resource dashboard.grafana.app/dashboards/test-dashboard: internal server error",
|
|
},
|
|
{
|
|
name: "resource has no annotations",
|
|
resourceName: "test-dashboard",
|
|
gvk: schema.GroupVersionKind{
|
|
Group: "dashboard.grafana.app",
|
|
Kind: "Dashboard",
|
|
},
|
|
expectedGVR: schema.GroupVersionResource{
|
|
Group: "dashboard.grafana.app",
|
|
Version: "v0alpha1",
|
|
Resource: "dashboards",
|
|
},
|
|
resourceObj: &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"metadata": map[string]interface{}{
|
|
"name": "test-dashboard",
|
|
"namespace": "test-namespace",
|
|
// No annotations
|
|
},
|
|
},
|
|
},
|
|
expectedError: "resource dashboard.grafana.app/dashboards/test-dashboard has no annotations",
|
|
},
|
|
{
|
|
name: "resource has empty annotations",
|
|
resourceName: "test-dashboard",
|
|
gvk: schema.GroupVersionKind{
|
|
Group: "dashboard.grafana.app",
|
|
Kind: "Dashboard",
|
|
},
|
|
expectedGVR: schema.GroupVersionResource{
|
|
Group: "dashboard.grafana.app",
|
|
Version: "v0alpha1",
|
|
Resource: "dashboards",
|
|
},
|
|
resourceObj: &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"metadata": map[string]interface{}{
|
|
"name": "test-dashboard",
|
|
"namespace": "test-namespace",
|
|
"annotations": map[string]interface{}{},
|
|
},
|
|
},
|
|
},
|
|
expectedError: "resource dashboard.grafana.app/dashboards/test-dashboard has no source path annotation",
|
|
},
|
|
{
|
|
name: "resource has empty source path annotation",
|
|
resourceName: "test-dashboard",
|
|
gvk: schema.GroupVersionKind{
|
|
Group: "dashboard.grafana.app",
|
|
Kind: "Dashboard",
|
|
},
|
|
expectedGVR: schema.GroupVersionResource{
|
|
Group: "dashboard.grafana.app",
|
|
Version: "v0alpha1",
|
|
Resource: "dashboards",
|
|
},
|
|
resourceObj: &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"metadata": map[string]interface{}{
|
|
"name": "test-dashboard",
|
|
"namespace": "test-namespace",
|
|
"annotations": map[string]interface{}{
|
|
utils.AnnoKeySourcePath: "", // Empty path
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expectedError: "resource dashboard.grafana.app/dashboards/test-dashboard has no source path annotation",
|
|
},
|
|
{
|
|
name: "resource with nested folder path",
|
|
resourceName: "nested-dashboard",
|
|
gvk: schema.GroupVersionKind{
|
|
Group: "dashboard.grafana.app",
|
|
Kind: "Dashboard",
|
|
},
|
|
expectedGVR: schema.GroupVersionResource{
|
|
Group: "dashboard.grafana.app",
|
|
Version: "v0alpha1",
|
|
Resource: "dashboards",
|
|
},
|
|
resourceObj: &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"metadata": map[string]interface{}{
|
|
"name": "nested-dashboard",
|
|
"namespace": "test-namespace",
|
|
"annotations": map[string]interface{}{
|
|
utils.AnnoKeySourcePath: "team-a/subfolder/nested-dashboard.json",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expectedPath: "team-a/subfolder/nested-dashboard.json",
|
|
},
|
|
{
|
|
name: "folder without trailing slash gets slash added",
|
|
resourceName: "test-folder",
|
|
gvk: schema.GroupVersionKind{
|
|
Group: "folder.grafana.app",
|
|
Kind: "Folder",
|
|
},
|
|
expectedGVR: schema.GroupVersionResource{
|
|
Group: "folder.grafana.app",
|
|
Version: "v0alpha1",
|
|
Resource: "folders",
|
|
},
|
|
resourceObj: &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"metadata": map[string]interface{}{
|
|
"name": "test-folder",
|
|
"namespace": "test-namespace",
|
|
"annotations": map[string]interface{}{
|
|
utils.AnnoKeySourcePath: "folders/test-folder", // No trailing slash
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expectedPath: "folders/test-folder/", // Should have trailing slash added
|
|
},
|
|
{
|
|
name: "folder with trailing slash keeps slash",
|
|
resourceName: "test-folder-2",
|
|
gvk: schema.GroupVersionKind{
|
|
Group: "folder.grafana.app",
|
|
Kind: "Folder",
|
|
},
|
|
expectedGVR: schema.GroupVersionResource{
|
|
Group: "folder.grafana.app",
|
|
Version: "v0alpha1",
|
|
Resource: "folders",
|
|
},
|
|
resourceObj: &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"metadata": map[string]interface{}{
|
|
"name": "test-folder-2",
|
|
"namespace": "test-namespace",
|
|
"annotations": map[string]interface{}{
|
|
utils.AnnoKeySourcePath: "folders/test-folder-2/", // Already has trailing slash
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expectedPath: "folders/test-folder-2/", // Should keep existing trailing slash
|
|
},
|
|
{
|
|
name: "non-folder resource keeps path unchanged",
|
|
resourceName: "test-dashboard",
|
|
gvk: schema.GroupVersionKind{
|
|
Group: "dashboard.grafana.app",
|
|
Kind: "Dashboard", // Not a folder
|
|
},
|
|
expectedGVR: schema.GroupVersionResource{
|
|
Group: "dashboard.grafana.app",
|
|
Version: "v0alpha1",
|
|
Resource: "dashboards",
|
|
},
|
|
resourceObj: &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"metadata": map[string]interface{}{
|
|
"name": "test-dashboard",
|
|
"namespace": "test-namespace",
|
|
"annotations": map[string]interface{}{
|
|
utils.AnnoKeySourcePath: "dashboards/test-dashboard", // No trailing slash
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expectedPath: "dashboards/test-dashboard", // Should remain unchanged for non-folders
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Create mocks
|
|
mockClients := NewMockResourceClients(t)
|
|
mockClient := &MockDynamicResourceInterface{}
|
|
|
|
// Create repository resources with mocked dependencies
|
|
resourcesManager := &ResourcesManager{
|
|
clients: mockClients,
|
|
}
|
|
|
|
repositoryResources := &repositoryResources{
|
|
ResourcesManager: resourcesManager,
|
|
namespace: "test-namespace",
|
|
repoName: "test-repo",
|
|
}
|
|
|
|
// Mock ForKind call
|
|
if tt.forKindError != nil {
|
|
mockClients.On("ForKind", mock.Anything, tt.gvk).Return(nil, schema.GroupVersionResource{}, tt.forKindError)
|
|
} else {
|
|
mockClients.On("ForKind", mock.Anything, tt.gvk).Return(mockClient, tt.expectedGVR, nil)
|
|
|
|
// Mock Get call if ForKind succeeds
|
|
if tt.getError != nil {
|
|
mockClient.On("Get", mock.Anything, tt.resourceName, metav1.GetOptions{}, mock.Anything).Return(nil, tt.getError)
|
|
} else {
|
|
mockClient.On("Get", mock.Anything, tt.resourceName, metav1.GetOptions{}, mock.Anything).Return(tt.resourceObj, nil)
|
|
}
|
|
}
|
|
|
|
// Execute the method
|
|
result, err := repositoryResources.FindResourcePath(context.Background(), tt.resourceName, tt.gvk)
|
|
|
|
// Assert results
|
|
if tt.expectedError != "" {
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), tt.expectedError)
|
|
require.Empty(t, result)
|
|
} else {
|
|
require.NoError(t, err)
|
|
require.Equal(t, tt.expectedPath, result)
|
|
}
|
|
|
|
// Verify all mocks were called as expected
|
|
mockClients.AssertExpectations(t)
|
|
if tt.forKindError == nil {
|
|
mockClient.AssertExpectations(t)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// MockDynamicResourceInterface is a mock for dynamic.ResourceInterface
|
|
type MockDynamicResourceInterface struct {
|
|
mock.Mock
|
|
}
|
|
|
|
func (m *MockDynamicResourceInterface) Create(ctx context.Context, obj *unstructured.Unstructured, options metav1.CreateOptions, subresources ...string) (*unstructured.Unstructured, error) {
|
|
args := m.Called(ctx, obj, options, subresources)
|
|
if args.Get(0) == nil {
|
|
return nil, args.Error(1)
|
|
}
|
|
return args.Get(0).(*unstructured.Unstructured), args.Error(1)
|
|
}
|
|
|
|
func (m *MockDynamicResourceInterface) Update(ctx context.Context, obj *unstructured.Unstructured, options metav1.UpdateOptions, subresources ...string) (*unstructured.Unstructured, error) {
|
|
args := m.Called(ctx, obj, options, subresources)
|
|
if args.Get(0) == nil {
|
|
return nil, args.Error(1)
|
|
}
|
|
return args.Get(0).(*unstructured.Unstructured), args.Error(1)
|
|
}
|
|
|
|
func (m *MockDynamicResourceInterface) UpdateStatus(ctx context.Context, obj *unstructured.Unstructured, options metav1.UpdateOptions) (*unstructured.Unstructured, error) {
|
|
args := m.Called(ctx, obj, options)
|
|
if args.Get(0) == nil {
|
|
return nil, args.Error(1)
|
|
}
|
|
return args.Get(0).(*unstructured.Unstructured), args.Error(1)
|
|
}
|
|
|
|
func (m *MockDynamicResourceInterface) Delete(ctx context.Context, name string, options metav1.DeleteOptions, subresources ...string) error {
|
|
args := m.Called(ctx, name, options, subresources)
|
|
return args.Error(0)
|
|
}
|
|
|
|
func (m *MockDynamicResourceInterface) DeleteCollection(ctx context.Context, options metav1.DeleteOptions, listOptions metav1.ListOptions) error {
|
|
args := m.Called(ctx, options, listOptions)
|
|
return args.Error(0)
|
|
}
|
|
|
|
func (m *MockDynamicResourceInterface) Get(ctx context.Context, name string, options metav1.GetOptions, subresources ...string) (*unstructured.Unstructured, error) {
|
|
args := m.Called(ctx, name, options, subresources)
|
|
if args.Get(0) == nil {
|
|
return nil, args.Error(1)
|
|
}
|
|
return args.Get(0).(*unstructured.Unstructured), args.Error(1)
|
|
}
|
|
|
|
func (m *MockDynamicResourceInterface) List(ctx context.Context, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) {
|
|
args := m.Called(ctx, opts)
|
|
if args.Get(0) == nil {
|
|
return nil, args.Error(1)
|
|
}
|
|
return args.Get(0).(*unstructured.UnstructuredList), args.Error(1)
|
|
}
|
|
|
|
func (m *MockDynamicResourceInterface) Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) {
|
|
args := m.Called(ctx, opts)
|
|
if args.Get(0) == nil {
|
|
return nil, args.Error(1)
|
|
}
|
|
return args.Get(0).(watch.Interface), args.Error(1)
|
|
}
|
|
|
|
func (m *MockDynamicResourceInterface) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, options metav1.PatchOptions, subresources ...string) (*unstructured.Unstructured, error) {
|
|
args := m.Called(ctx, name, pt, data, options, subresources)
|
|
if args.Get(0) == nil {
|
|
return nil, args.Error(1)
|
|
}
|
|
return args.Get(0).(*unstructured.Unstructured), args.Error(1)
|
|
}
|
|
|
|
func (m *MockDynamicResourceInterface) Apply(ctx context.Context, name string, obj *unstructured.Unstructured, options metav1.ApplyOptions, subresources ...string) (*unstructured.Unstructured, error) {
|
|
args := m.Called(ctx, name, obj, options, subresources)
|
|
if args.Get(0) == nil {
|
|
return nil, args.Error(1)
|
|
}
|
|
return args.Get(0).(*unstructured.Unstructured), args.Error(1)
|
|
}
|
|
|
|
func (m *MockDynamicResourceInterface) ApplyStatus(ctx context.Context, name string, obj *unstructured.Unstructured, options metav1.ApplyOptions) (*unstructured.Unstructured, error) {
|
|
args := m.Called(ctx, name, obj, options)
|
|
if args.Get(0) == nil {
|
|
return nil, args.Error(1)
|
|
}
|
|
return args.Get(0).(*unstructured.Unstructured), args.Error(1)
|
|
}
|
|
|
|
// Ensure MockDynamicResourceInterface implements dynamic.ResourceInterface
|
|
var _ dynamic.ResourceInterface = (*MockDynamicResourceInterface)(nil)
|
|
|
|
func TestCheckResourceOwnership(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
existingResource *unstructured.Unstructured
|
|
requestingManager utils.ManagerProperties
|
|
expectError bool
|
|
expectedMessage string
|
|
}{
|
|
{
|
|
name: "no existing resource - allow operation",
|
|
existingResource: nil, // Explicitly nil to represent non-existing resource
|
|
requestingManager: utils.ManagerProperties{
|
|
Kind: utils.ManagerKindRepo,
|
|
Identity: "repo-1",
|
|
},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "existing resource with no manager - allow operation",
|
|
existingResource: &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"metadata": map[string]interface{}{
|
|
"name": "test-resource",
|
|
},
|
|
},
|
|
},
|
|
requestingManager: utils.ManagerProperties{
|
|
Kind: utils.ManagerKindRepo,
|
|
Identity: "repo-1",
|
|
},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "same manager - allow operation",
|
|
existingResource: &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"metadata": map[string]interface{}{
|
|
"name": "test-resource",
|
|
"annotations": map[string]interface{}{
|
|
utils.AnnoKeyManagerKind: "repo",
|
|
utils.AnnoKeyManagerIdentity: "repo-1",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
requestingManager: utils.ManagerProperties{
|
|
Kind: utils.ManagerKindRepo,
|
|
Identity: "repo-1",
|
|
},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "different manager but allows edits - allow operation",
|
|
existingResource: &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"metadata": map[string]interface{}{
|
|
"name": "test-resource",
|
|
"annotations": map[string]interface{}{
|
|
utils.AnnoKeyManagerKind: "repo",
|
|
utils.AnnoKeyManagerIdentity: "repo-1",
|
|
utils.AnnoKeyManagerAllowsEdits: "true",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
requestingManager: utils.ManagerProperties{
|
|
Kind: utils.ManagerKindRepo,
|
|
Identity: "repo-2",
|
|
},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "different manager and doesn't allow edits - deny operation",
|
|
existingResource: &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"metadata": map[string]interface{}{
|
|
"name": "test-resource",
|
|
"annotations": map[string]interface{}{
|
|
utils.AnnoKeyManagerKind: "repo",
|
|
utils.AnnoKeyManagerIdentity: "repo-1",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
requestingManager: utils.ManagerProperties{
|
|
Kind: utils.ManagerKindRepo,
|
|
Identity: "repo-2",
|
|
},
|
|
expectError: true,
|
|
expectedMessage: "resource 'test-resource' is managed by repo 'repo-1' and cannot be modified by repo 'repo-2'",
|
|
},
|
|
{
|
|
name: "different manager types - deny operation",
|
|
existingResource: &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"metadata": map[string]interface{}{
|
|
"name": "test-resource",
|
|
"annotations": map[string]interface{}{
|
|
utils.AnnoKeyManagerKind: "terraform",
|
|
utils.AnnoKeyManagerIdentity: "tf-stacks-1",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
requestingManager: utils.ManagerProperties{
|
|
Kind: utils.ManagerKindRepo,
|
|
Identity: "repo-1",
|
|
},
|
|
expectError: true,
|
|
expectedMessage: "resource 'test-resource' is managed by terraform 'tf-stacks-1' and cannot be modified by repo 'repo-1'",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Test the package-level ownership check function directly
|
|
err := CheckResourceOwnership(tt.existingResource, "test-resource", tt.requestingManager)
|
|
|
|
if tt.expectError {
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), tt.expectedMessage)
|
|
assert.True(t, apierrors.IsBadRequest(err))
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
})
|
|
}
|
|
}
|