Files
grafana/pkg/registry/apis/dashboard/search_test.go
Will Assis 69ccfd6bfc unified-storage: fix sharedwithme search not returning folders (#116089)
* unified-storage: fix dashboard sharedwithme search not returning folders shared with the user
2026-01-12 15:33:34 -05:00

1166 lines
35 KiB
Go

package dashboard
import (
"context"
"encoding/json"
"fmt"
"net/http/httptest"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
)
func TestSearchFallback(t *testing.T) {
t.Run("should hit legacy search handler on mode 0", func(t *testing.T) {
mockClient := &MockClient{}
mockLegacyClient := &MockClient{}
cfg := &setting.Cfg{
UnifiedStorage: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode0},
},
}
dual := dualwrite.ProvideStaticServiceForTests(cfg)
searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), dual, mockLegacyClient, mockClient, nil)
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/search", nil)
req.Header.Add("content-type", "application/json")
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
searchHandler.DoSearch(rr, req)
if mockClient.LastSearchRequest != nil {
t.Fatalf("expected Search NOT to be called, but it was")
}
if mockLegacyClient.LastSearchRequest == nil {
t.Fatalf("expected Search to be called, but it was not")
}
})
t.Run("should hit legacy search handler on mode 1", func(t *testing.T) {
mockClient := &MockClient{}
mockLegacyClient := &MockClient{}
cfg := &setting.Cfg{
UnifiedStorage: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode1},
},
}
dual := dualwrite.ProvideStaticServiceForTests(cfg)
searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), dual, mockLegacyClient, mockClient, nil)
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/search", nil)
req.Header.Add("content-type", "application/json")
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
searchHandler.DoSearch(rr, req)
if mockClient.LastSearchRequest != nil {
t.Fatalf("expected Search NOT to be called, but it was")
}
if mockLegacyClient.LastSearchRequest == nil {
t.Fatalf("expected Search to be called, but it was not")
}
})
t.Run("should hit legacy search handler on mode 2", func(t *testing.T) {
mockClient := &MockClient{}
mockLegacyClient := &MockClient{}
cfg := &setting.Cfg{
UnifiedStorage: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode2},
},
}
dual := dualwrite.ProvideStaticServiceForTests(cfg)
searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), dual, mockLegacyClient, mockClient, nil)
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/search", nil)
req.Header.Add("content-type", "application/json")
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
searchHandler.DoSearch(rr, req)
if mockClient.LastSearchRequest != nil {
t.Fatalf("expected Search NOT to be called, but it was")
}
if mockLegacyClient.LastSearchRequest == nil {
t.Fatalf("expected Search to be called, but it was not")
}
})
t.Run("should hit unified storage search handler on mode 3", func(t *testing.T) {
mockClient := &MockClient{}
mockLegacyClient := &MockClient{}
cfg := &setting.Cfg{
UnifiedStorage: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode3},
},
}
dual := dualwrite.ProvideStaticServiceForTests(cfg)
searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), dual, mockLegacyClient, mockClient, nil)
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/search", nil)
req.Header.Add("content-type", "application/json")
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
searchHandler.DoSearch(rr, req)
if mockClient.LastSearchRequest == nil {
t.Fatalf("expected Search to be called, but it was not")
}
if mockLegacyClient.LastSearchRequest != nil {
t.Fatalf("expected Search NOT to be called, but it was")
}
})
t.Run("should hit unified storage search handler on mode 4", func(t *testing.T) {
mockClient := &MockClient{}
mockLegacyClient := &MockClient{}
cfg := &setting.Cfg{
UnifiedStorage: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode4},
},
}
dual := dualwrite.ProvideStaticServiceForTests(cfg)
searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), dual, mockLegacyClient, mockClient, nil)
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/search", nil)
req.Header.Add("content-type", "application/json")
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
searchHandler.DoSearch(rr, req)
if mockClient.LastSearchRequest == nil {
t.Fatalf("expected Search to be called, but it was not")
}
if mockLegacyClient.LastSearchRequest != nil {
t.Fatalf("expected Search NOT to be called, but it was")
}
})
t.Run("should hit unified storage search handler on mode 5", func(t *testing.T) {
mockClient := &MockClient{}
mockLegacyClient := &MockClient{}
cfg := &setting.Cfg{
UnifiedStorage: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode5},
},
}
dual := dualwrite.ProvideStaticServiceForTests(cfg)
searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), dual, mockLegacyClient, mockClient, nil)
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/search", nil)
req.Header.Add("content-type", "application/json")
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
searchHandler.DoSearch(rr, req)
if mockClient.LastSearchRequest == nil {
t.Fatalf("expected Search to be called, but it was not")
}
if mockLegacyClient.LastSearchRequest != nil {
t.Fatalf("expected Search NOT to be called, but it was")
}
})
}
func TestSearchHandlerPagination(t *testing.T) {
t.Run("should calculate offset and page parameters", func(t *testing.T) {
limit := 50
for i, tt := range []struct {
offset int
page int
expectedOffset int
expectedPage int
}{
{
offset: 0,
page: 0,
expectedOffset: 0,
expectedPage: 1,
},
{
offset: 0,
page: 1,
expectedOffset: 0,
expectedPage: 1,
},
{
offset: 0,
page: 2,
expectedOffset: 50,
expectedPage: 2,
},
{
offset: 0,
page: 3,
expectedOffset: 100,
expectedPage: 3,
},
{
offset: 50,
page: 0,
expectedOffset: 50,
expectedPage: 2,
},
{
offset: 100,
page: 0,
expectedOffset: 100,
expectedPage: 3,
},
{
offset: 149,
page: 0,
expectedOffset: 149,
expectedPage: 3,
},
{
offset: 150,
page: 0,
expectedOffset: 150,
expectedPage: 4,
},
} {
mockClient := &MockClient{}
cfg := &setting.Cfg{
UnifiedStorage: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode0},
},
}
dual := dualwrite.ProvideStaticServiceForTests(cfg)
searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), dual, mockClient, mockClient, nil)
rr := httptest.NewRecorder()
endpoint := fmt.Sprintf("/search?limit=%d", limit)
if tt.offset > 0 {
endpoint = fmt.Sprintf("%s&offset=%d", endpoint, tt.offset)
}
if tt.page > 0 {
endpoint = fmt.Sprintf("%s&page=%d", endpoint, tt.page)
}
req := httptest.NewRequest("GET", endpoint, nil)
req.Header.Add("content-type", "application/json")
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
searchHandler.DoSearch(rr, req)
if mockClient.LastSearchRequest == nil {
t.Fatalf("expected Search to be called, but it was not")
}
require.Equal(t, int(mockClient.LastSearchRequest.Offset), tt.expectedOffset, fmt.Sprintf("mismatch offset in test %d", i))
require.Equal(t, int(mockClient.LastSearchRequest.Page), tt.expectedPage, fmt.Sprintf("mismatch page in test %d", i))
}
})
}
func TestSearchHandler(t *testing.T) {
t.Run("Sort - default sort by resource", func(t *testing.T) {
rows := make([]*resourcepb.ResourceTableRow, len(mockResults))
for i, r := range mockResults {
rows[i] = &resourcepb.ResourceTableRow{
Key: &resourcepb.ResourceKey{
Name: r.Name,
Resource: r.Resource,
},
Cells: [][]byte{
[]byte(r.Value),
},
}
}
mockResponse := &resourcepb.ResourceSearchResponse{
Results: &resourcepb.ResourceTable{
Columns: []*resourcepb.ResourceTableColumnDefinition{
{Name: resource.SEARCH_FIELD_TITLE},
},
Rows: rows,
},
}
// Create a mock client
mockClient := &MockClient{
MockResponses: []*resourcepb.ResourceSearchResponse{mockResponse},
}
features := featuremgmt.WithFeatures()
// Initialize the search handler with the mock client
searchHandler := SearchHandler{
log: log.New("test", "test"),
client: mockClient,
tracer: tracing.NewNoopTracerService(),
features: features,
}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/search", nil)
req.Header.Add("content-type", "application/json")
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
searchHandler.DoSearch(rr, req)
if mockClient.LastSearchRequest == nil {
t.Fatalf("expected Search to be called, but it was not")
}
resp := rr.Result()
defer func() {
if err := resp.Body.Close(); err != nil {
t.Fatal(err)
}
}()
p := &v0alpha1.SearchResults{}
err := json.NewDecoder(resp.Body).Decode(p)
require.NoError(t, err)
assert.Equal(t, len(mockResults), len(p.Hits))
assert.Equal(t, mockResults[2].Value, p.Hits[0].Title)
assert.Equal(t, mockResults[1].Value, p.Hits[3].Title)
})
}
func TestSearchHandlerSharedDashboards(t *testing.T) {
t.Run("should return empty result without searching if user does not have shared dashboards", func(t *testing.T) {
mockClient := &MockClient{}
features := featuremgmt.WithFeatures()
searchHandler := SearchHandler{
log: log.New("test", "test"),
client: mockClient,
tracer: tracing.NewNoopTracerService(),
features: features,
}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/search?folder=sharedwithme", nil)
req.Header.Add("content-type", "application/json")
// "Permissions" prop in "SignedInUser" is where we store the uid of dashboards shared with the user
// doesn't exist here, which represents a user without any shared dashboards
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
searchHandler.DoSearch(rr, req)
assert.Equal(t, mockClient.CallCount, 0)
resp := rr.Result()
defer func() {
if err := resp.Body.Close(); err != nil {
t.Fatal(err)
}
}()
p := &v0alpha1.SearchResults{}
err := json.NewDecoder(resp.Body).Decode(p)
require.NoError(t, err)
assert.Equal(t, 0, len(p.Hits))
})
t.Run("should return empty result if user has access to folder of all shared dashboards", func(t *testing.T) {
// dashboardSearchRequest
mockResponse1 := &resourcepb.ResourceSearchResponse{
Results: &resourcepb.ResourceTable{
Columns: []*resourcepb.ResourceTableColumnDefinition{
{
Name: "folder",
},
},
Rows: []*resourcepb.ResourceTableRow{
{
Key: &resourcepb.ResourceKey{
Name: "dashboardinroot",
Resource: "dashboard",
},
Cells: [][]byte{[]byte("")}, // root folder doesn't have uid
},
{
Key: &resourcepb.ResourceKey{
Name: "dashboardinpublicfolder",
Resource: "dashboard",
},
Cells: [][]byte{
[]byte("publicfolder"), // folder uid
},
},
},
},
}
// folderSearchRequest
mockResponse2 := &resourcepb.ResourceSearchResponse{
Results: &resourcepb.ResourceTable{
Columns: []*resourcepb.ResourceTableColumnDefinition{
{
Name: "folder",
},
},
Rows: []*resourcepb.ResourceTableRow{
{
Key: &resourcepb.ResourceKey{
Name: "publicfolder",
Resource: "folder",
},
Cells: [][]byte{
[]byte(""), // root folder uid
},
},
},
},
}
mockClient := &MockClient{
MockResponses: []*resourcepb.ResourceSearchResponse{mockResponse1, mockResponse2},
}
features := featuremgmt.WithFeatures()
searchHandler := SearchHandler{
log: log.New("test", "test"),
client: mockClient,
tracer: tracing.NewNoopTracerService(),
features: features,
}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/search?folder=sharedwithme", nil)
req.Header.Add("content-type", "application/json")
allPermissions := make(map[int64]map[string][]string)
permissions := make(map[string][]string)
permissions[dashboards.ActionDashboardsRead] = []string{"dashboards:uid:dashboardinroot", "dashboards:uid:dashboardinpublicfolder"}
allPermissions[1] = permissions
// "Permissions" is where we store the uid of dashboards shared with the user
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test", OrgID: 1, Permissions: allPermissions}))
searchHandler.DoSearch(rr, req)
assert.Equal(t, mockClient.CallCount, 2)
resp := rr.Result()
defer func() {
if err := resp.Body.Close(); err != nil {
t.Fatal(err)
}
}()
p := &v0alpha1.SearchResults{}
err := json.NewDecoder(resp.Body).Decode(p)
require.NoError(t, err)
assert.Equal(t, 0, len(p.Hits))
})
t.Run("should return the dashboards shared with the user", func(t *testing.T) {
// dashboardSearchRequest
mockResponse1 := &resourcepb.ResourceSearchResponse{
Results: &resourcepb.ResourceTable{
Columns: []*resourcepb.ResourceTableColumnDefinition{
{
Name: "folder",
},
},
Rows: []*resourcepb.ResourceTableRow{
{
Key: &resourcepb.ResourceKey{
Name: "dashboardinroot",
Resource: "dashboard",
},
Cells: [][]byte{[]byte("")}, // root folder doesn't have uid
},
{
Key: &resourcepb.ResourceKey{
Name: "dashboardinprivatefolder",
Resource: "dashboard",
},
Cells: [][]byte{
[]byte("privatefolder"), // folder uid
},
},
{
Key: &resourcepb.ResourceKey{
Name: "dashboardinpublicfolder",
Resource: "dashboard",
},
Cells: [][]byte{
[]byte("publicfolder"), // folder uid
},
},
{
Key: &resourcepb.ResourceKey{
Name: "sharedfolder",
Resource: "folder",
},
Cells: [][]byte{
[]byte("privatefolder"), // folder uid
},
},
},
},
}
// folderSearchRequest
mockResponse2 := &resourcepb.ResourceSearchResponse{
Results: &resourcepb.ResourceTable{
Columns: []*resourcepb.ResourceTableColumnDefinition{
{
Name: "folder",
},
},
Rows: []*resourcepb.ResourceTableRow{
{
Key: &resourcepb.ResourceKey{
Name: "publicfolder",
Resource: "folder",
},
Cells: [][]byte{
[]byte(""), // root folder uid
},
},
},
},
}
mockResponse3 := &resourcepb.ResourceSearchResponse{
Results: &resourcepb.ResourceTable{
Columns: []*resourcepb.ResourceTableColumnDefinition{
{
Name: "folder",
},
},
Rows: []*resourcepb.ResourceTableRow{
{
Key: &resourcepb.ResourceKey{
Name: "dashboardinprivatefolder",
Resource: "dashboard",
},
Cells: [][]byte{
[]byte("privatefolder"), // folder uid
},
},
{
Key: &resourcepb.ResourceKey{
Name: "sharedfolder",
Resource: "folder",
},
Cells: [][]byte{
[]byte("privatefolder"), // folder uid
},
},
},
},
}
mockClient := &MockClient{
MockResponses: []*resourcepb.ResourceSearchResponse{mockResponse1, mockResponse2, mockResponse3},
}
features := featuremgmt.WithFeatures()
searchHandler := SearchHandler{
log: log.New("test", "test"),
client: mockClient,
tracer: tracing.NewNoopTracerService(),
features: features,
}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/search?folder=sharedwithme", nil)
req.Header.Add("content-type", "application/json")
allPermissions := make(map[int64]map[string][]string)
permissions := make(map[string][]string)
permissions[dashboards.ActionDashboardsRead] = []string{"dashboards:uid:dashboardinroot", "dashboards:uid:dashboardinprivatefolder", "dashboards:uid:dashboardinpublicfolder"}
permissions[dashboards.ActionFoldersRead] = []string{"folders:uid:sharedfolder"}
allPermissions[1] = permissions
// "Permissions" is where we store the uid of dashboards shared with the user
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test", OrgID: 1, Permissions: allPermissions}))
searchHandler.DoSearch(rr, req)
assert.Equal(t, mockClient.CallCount, 3)
// first call gets all dashboards user has permission for
firstCall := mockClient.MockCalls[0]
assert.Equal(t, firstCall.Options.Fields[0].Values, []string{"dashboardinroot", "dashboardinprivatefolder", "dashboardinpublicfolder", "sharedfolder"})
// verify federated field is set to include folders
assert.NotNil(t, firstCall.Federated)
assert.Equal(t, 1, len(firstCall.Federated))
assert.Equal(t, "folder.grafana.app", firstCall.Federated[0].Group)
assert.Equal(t, "folders", firstCall.Federated[0].Resource)
// second call gets folders associated with the previous dashboards
secondCall := mockClient.MockCalls[1]
assert.Equal(t, secondCall.Options.Fields[0].Values, []string{"privatefolder", "publicfolder"})
// lastly, search ONLY for dashboards and folders user has permission to read that are within folders the user does NOT have
// permission to read
thirdCall := mockClient.MockCalls[2]
assert.Equal(t, thirdCall.Options.Fields[0].Values, []string{"dashboardinprivatefolder", "sharedfolder"})
resp := rr.Result()
defer func() {
if err := resp.Body.Close(); err != nil {
t.Fatal(err)
}
}()
p := &v0alpha1.SearchResults{}
err := json.NewDecoder(resp.Body).Decode(p)
require.NoError(t, err)
assert.Equal(t, len(mockResponse3.Results.Rows), len(p.Hits))
})
}
func TestConvertHttpSearchRequestToResourceSearchRequest(t *testing.T) {
testUser := &user.SignedInUser{
Namespace: "test-namespace",
OrgID: 1,
}
dashboardKey := &resourcepb.ResourceKey{
Group: "dashboard.grafana.app",
Resource: "dashboards",
Namespace: "test-namespace",
}
folderKey := &resourcepb.ResourceKey{
Group: "folder.grafana.app",
Resource: "folders",
Namespace: "test-namespace",
}
defaultFields := []string{"title", "folder", "tags", "description", "manager.kind", "manager.id"}
tests := map[string]struct {
queryString string
sharedDashboards []string
sharedDashboardsError error
expected *resourcepb.ResourceSearchRequest
expectedError error
}{
"default values with no query params": {
queryString: "",
expected: &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{Key: dashboardKey},
Query: "",
Limit: 50,
Offset: 0,
Page: 1,
Explain: false,
Fields: defaultFields,
Federated: []*resourcepb.ResourceKey{folderKey},
},
},
"custom limit and offset": {
queryString: "limit=100&offset=50",
expected: &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{Key: dashboardKey},
Query: "",
Limit: 100,
Offset: 50,
Page: 1,
Explain: false,
Fields: defaultFields,
Federated: []*resourcepb.ResourceKey{folderKey},
},
},
"pagination with page parameter": {
queryString: "limit=25&page=3",
expected: &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{Key: dashboardKey},
Query: "",
Limit: 25,
Offset: 50,
Page: 3,
Explain: false,
Fields: defaultFields,
Federated: []*resourcepb.ResourceKey{folderKey},
},
},
"query string and explain": {
queryString: "query=test-query&explain=true",
expected: &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{Key: dashboardKey},
Query: "test-query",
Limit: 50,
Offset: 0,
Page: 1,
Explain: true,
Fields: defaultFields,
Federated: []*resourcepb.ResourceKey{folderKey},
},
},
"additional fields": {
queryString: "field=custom1&field=custom2",
expected: &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{Key: dashboardKey},
Query: "",
Limit: 50,
Offset: 0,
Page: 1,
Explain: false,
Fields: append(defaultFields, "custom1", "custom2"),
Federated: []*resourcepb.ResourceKey{folderKey},
},
},
"view permission": {
queryString: "permission=view",
expected: &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{Key: dashboardKey},
Query: "",
Limit: 50,
Offset: 0,
Page: 1,
Explain: false,
Fields: defaultFields,
Permission: int64(dashboardaccess.PERMISSION_VIEW),
Federated: []*resourcepb.ResourceKey{folderKey},
},
},
"edit permission": {
queryString: "permission=Edit",
expected: &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{Key: dashboardKey},
Query: "",
Limit: 50,
Offset: 0,
Page: 1,
Explain: false,
Fields: defaultFields,
Permission: int64(dashboardaccess.PERMISSION_EDIT),
Federated: []*resourcepb.ResourceKey{folderKey},
},
},
"admin permission": {
queryString: "permission=ADMIN",
expected: &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{Key: dashboardKey},
Query: "",
Limit: 50,
Offset: 0,
Page: 1,
Explain: false,
Fields: defaultFields,
Permission: int64(dashboardaccess.PERMISSION_ADMIN),
Federated: []*resourcepb.ResourceKey{folderKey},
},
},
"type dashboard only": {
queryString: "type=dashboard",
expected: &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{Key: dashboardKey},
Query: "",
Limit: 50,
Offset: 0,
Page: 1,
Explain: false,
Fields: defaultFields,
},
},
"type folder only": {
queryString: "type=folder",
expected: &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{Key: folderKey},
Query: "",
Limit: 50,
Offset: 0,
Page: 1,
Explain: false,
Fields: defaultFields,
},
},
"both types should include federated": {
queryString: "type=dashboard&type=folder",
expected: &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{Key: dashboardKey},
Query: "",
Limit: 50,
Offset: 0,
Page: 1,
Explain: false,
Fields: defaultFields,
Federated: []*resourcepb.ResourceKey{folderKey},
},
},
"sort ascending": {
queryString: "sort=title",
expected: &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{Key: dashboardKey},
Query: "",
Limit: 50,
Offset: 0,
Page: 1,
Explain: false,
Fields: defaultFields,
SortBy: []*resourcepb.ResourceSearchRequest_Sort{{Field: "title", Desc: false}},
Federated: []*resourcepb.ResourceKey{folderKey},
},
},
"sort descending": {
queryString: "sort=-title",
expected: &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{Key: dashboardKey},
Query: "",
Limit: 50,
Offset: 0,
Page: 1,
Explain: false,
Fields: defaultFields,
SortBy: []*resourcepb.ResourceSearchRequest_Sort{{Field: "title", Desc: true}},
Federated: []*resourcepb.ResourceKey{folderKey},
},
},
"facet fields": {
queryString: "facet=tags&facet=folder",
expected: &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{Key: dashboardKey},
Query: "",
Limit: 50,
Offset: 0,
Page: 1,
Explain: false,
Fields: defaultFields,
Facet: map[string]*resourcepb.ResourceSearchRequest_Facet{
"tags": {Field: "tags", Limit: 50},
"folder": {Field: "folder", Limit: 50},
},
Federated: []*resourcepb.ResourceKey{folderKey},
},
},
"facet fields with custom limit": {
queryString: "facet=tags&facetLimit=500",
expected: &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{Key: dashboardKey},
Query: "",
Limit: 50,
Offset: 0,
Page: 1,
Explain: false,
Fields: defaultFields,
Facet: map[string]*resourcepb.ResourceSearchRequest_Facet{
"tags": {Field: "tags", Limit: 500},
},
Federated: []*resourcepb.ResourceKey{folderKey},
},
},
"facet fields with limit exceeding max": {
queryString: "facet=tags&facetLimit=5000",
expected: &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{Key: dashboardKey},
Query: "",
Limit: 50,
Offset: 0,
Page: 1,
Explain: false,
Fields: defaultFields,
Facet: map[string]*resourcepb.ResourceSearchRequest_Facet{
"tags": {Field: "tags", Limit: 1000},
},
Federated: []*resourcepb.ResourceKey{folderKey},
},
},
"tag filter": {
queryString: "tag=tag1&tag=tag2",
expected: &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
Key: dashboardKey,
Fields: []*resourcepb.Requirement{{Key: "tags", Operator: "=", Values: []string{"tag1", "tag2"}}},
},
Query: "",
Limit: 50,
Offset: 0,
Page: 1,
Explain: false,
Fields: defaultFields,
Federated: []*resourcepb.ResourceKey{folderKey},
},
},
"folder filter": {
queryString: "folder=my-folder",
expected: &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
Key: dashboardKey,
Fields: []*resourcepb.Requirement{{Key: "folder", Operator: "=", Values: []string{"my-folder"}}},
},
Query: "",
Limit: 50,
Offset: 0,
Page: 1,
Explain: false,
Fields: defaultFields,
Federated: []*resourcepb.ResourceKey{folderKey},
},
},
"tag and folder filter together": {
queryString: "tag=tag1&tag=tag2&folder=my-folder",
expected: &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
Key: dashboardKey,
Fields: []*resourcepb.Requirement{
{Key: "tags", Operator: "=", Values: []string{"tag1", "tag2"}},
{Key: "folder", Operator: "=", Values: []string{"my-folder"}},
},
},
Query: "",
Limit: 50,
Offset: 0,
Page: 1,
Explain: false,
Fields: defaultFields,
Federated: []*resourcepb.ResourceKey{folderKey},
},
},
"root folder should be converted to empty string": {
queryString: "folder=general",
expected: &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
Key: dashboardKey,
Fields: []*resourcepb.Requirement{{Key: "folder", Operator: "=", Values: []string{""}}},
},
Query: "",
Limit: 50,
Offset: 0,
Page: 1,
Explain: false,
Fields: defaultFields,
Federated: []*resourcepb.ResourceKey{folderKey},
},
},
"shared with me folder with dashboards": {
queryString: "folder=sharedwithme",
sharedDashboards: []string{"dash1", "dash2", "dash3"},
expected: &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
Key: dashboardKey,
Fields: []*resourcepb.Requirement{{Key: "name", Operator: "in", Values: []string{"dash1", "dash2", "dash3"}}},
},
Query: "",
Limit: 50,
Offset: 0,
Page: 1,
Explain: false,
Fields: defaultFields,
Federated: []*resourcepb.ResourceKey{folderKey},
},
},
"shared with me folder without dashboards returns error": {
queryString: "folder=sharedwithme",
sharedDashboards: []string{},
expectedError: errEmptyResults,
},
"name filter": {
queryString: "name=name1&name=name2",
expected: &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
Key: dashboardKey,
Fields: []*resourcepb.Requirement{{Key: "name", Operator: "in", Values: []string{"name1", "name2"}}},
},
Query: "",
Limit: 50,
Offset: 0,
Page: 1,
Explain: false,
Fields: defaultFields,
Federated: []*resourcepb.ResourceKey{folderKey},
},
},
"comprehensive filter with query, tags, folder, and name": {
queryString: "query=search-term&tag=monitoring&tag=prod&folder=my-folder&name=dash1&name=dash2",
expected: &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
Key: dashboardKey,
Fields: []*resourcepb.Requirement{
{Key: "tags", Operator: "=", Values: []string{"monitoring", "prod"}},
{Key: "folder", Operator: "=", Values: []string{"my-folder"}},
{Key: "name", Operator: "in", Values: []string{"dash1", "dash2"}},
},
},
Query: "search-term",
Limit: 50,
Offset: 0,
Page: 1,
Explain: false,
Fields: defaultFields,
Federated: []*resourcepb.ResourceKey{folderKey},
},
},
"libraryPanel filter": {
queryString: "libraryPanel=panel1&libraryPanel=panel2",
expected: &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
Key: dashboardKey,
Fields: []*resourcepb.Requirement{{Key: "reference.LibraryPanel", Operator: "=", Values: []string{"panel1", "panel2"}}},
},
Query: "",
Limit: 50,
Offset: 0,
Page: 1,
Explain: false,
Fields: defaultFields,
Federated: []*resourcepb.ResourceKey{folderKey},
},
},
"libraryPanel and tag filter together": {
queryString: "libraryPanel=panel1&tag=monitoring&tag=prod",
expected: &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
Key: dashboardKey,
Fields: []*resourcepb.Requirement{
{Key: "tags", Operator: "=", Values: []string{"monitoring", "prod"}},
{Key: "reference.LibraryPanel", Operator: "=", Values: []string{"panel1"}},
},
},
Query: "",
Limit: 50,
Offset: 0,
Page: 1,
Explain: false,
Fields: defaultFields,
Federated: []*resourcepb.ResourceKey{folderKey},
},
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
queryParams, err := url.ParseQuery(tt.queryString)
require.NoError(t, err)
getDashboardsFunc := func() ([]string, error) {
if tt.sharedDashboardsError != nil {
return nil, tt.sharedDashboardsError
}
return tt.sharedDashboards, nil
}
result, err := convertHttpSearchRequestToResourceSearchRequest(queryParams, testUser, getDashboardsFunc)
if tt.expectedError != nil {
assert.ErrorIs(t, err, tt.expectedError)
return
}
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, tt.expected, result)
})
}
}
// MockClient implements the ResourceIndexClient interface for testing
type MockClient struct {
resourcepb.ResourceIndexClient
resource.ResourceIndex
// Capture the last SearchRequest for assertions
LastSearchRequest *resourcepb.ResourceSearchRequest
MockResponses []*resourcepb.ResourceSearchResponse
MockCalls []*resourcepb.ResourceSearchRequest
CallCount int
}
type MockResult struct {
Name string
Resource string
Value string
}
var mockResults = []MockResult{
{
Name: "d1",
Resource: "dashboard",
Value: "Dashboard 1",
},
{
Name: "d2",
Resource: "dashboard",
Value: "Dashboard 2",
},
{
Name: "f1",
Resource: "folder",
Value: "Folder 1",
},
{
Name: "f2",
Resource: "folder",
Value: "Folder 2",
},
}
func (m *MockClient) Search(ctx context.Context, in *resourcepb.ResourceSearchRequest, opts ...grpc.CallOption) (*resourcepb.ResourceSearchResponse, error) {
m.LastSearchRequest = in
m.MockCalls = append(m.MockCalls, in)
var response *resourcepb.ResourceSearchResponse
if m.CallCount < len(m.MockResponses) {
response = m.MockResponses[m.CallCount]
}
m.CallCount = m.CallCount + 1
return response, nil
}
func (m *MockClient) GetStats(ctx context.Context, in *resourcepb.ResourceStatsRequest, opts ...grpc.CallOption) (*resourcepb.ResourceStatsResponse, error) {
return nil, nil
}
func (m *MockClient) CountManagedObjects(ctx context.Context, in *resourcepb.CountManagedObjectsRequest, opts ...grpc.CallOption) (*resourcepb.CountManagedObjectsResponse, error) {
return nil, nil
}
func (m *MockClient) Watch(ctx context.Context, in *resourcepb.WatchRequest, opts ...grpc.CallOption) (resourcepb.ResourceStore_WatchClient, error) {
return nil, nil
}
func (m *MockClient) Delete(ctx context.Context, in *resourcepb.DeleteRequest, opts ...grpc.CallOption) (*resourcepb.DeleteResponse, error) {
return nil, nil
}
func (m *MockClient) Create(ctx context.Context, in *resourcepb.CreateRequest, opts ...grpc.CallOption) (*resourcepb.CreateResponse, error) {
return nil, nil
}
func (m *MockClient) Update(ctx context.Context, in *resourcepb.UpdateRequest, opts ...grpc.CallOption) (*resourcepb.UpdateResponse, error) {
return nil, nil
}
func (m *MockClient) Read(ctx context.Context, in *resourcepb.ReadRequest, opts ...grpc.CallOption) (*resourcepb.ReadResponse, error) {
return nil, nil
}
func (m *MockClient) GetBlob(ctx context.Context, in *resourcepb.GetBlobRequest, opts ...grpc.CallOption) (*resourcepb.GetBlobResponse, error) {
return nil, nil
}
func (m *MockClient) PutBlob(ctx context.Context, in *resourcepb.PutBlobRequest, opts ...grpc.CallOption) (*resourcepb.PutBlobResponse, error) {
return nil, nil
}
func (m *MockClient) List(ctx context.Context, in *resourcepb.ListRequest, opts ...grpc.CallOption) (*resourcepb.ListResponse, error) {
return nil, nil
}
func (m *MockClient) ListManagedObjects(ctx context.Context, in *resourcepb.ListManagedObjectsRequest, opts ...grpc.CallOption) (*resourcepb.ListManagedObjectsResponse, error) {
return nil, nil
}
func (m *MockClient) IsHealthy(ctx context.Context, in *resourcepb.HealthCheckRequest, opts ...grpc.CallOption) (*resourcepb.HealthCheckResponse, error) {
return nil, nil
}
func (m *MockClient) BulkProcess(ctx context.Context, opts ...grpc.CallOption) (resourcepb.BulkStore_BulkProcessClient, error) {
return nil, nil
}
func (m *MockClient) UpdateIndex(ctx context.Context, reason string) error {
return nil
}
func (m *MockClient) GetQuotaUsage(ctx context.Context, req *resourcepb.QuotaUsageRequest, opts ...grpc.CallOption) (*resourcepb.QuotaUsageResponse, error) {
return nil, nil
}