IAM: Add search for teams in app platform (#113503)
* add legacy search (wip) * fix search field name * implement team search endpoint * generate openapi spec * generate endpoints for frontend * minor fixes * fix issues found while testing * add more fields to search result * add basic unit tests * add more unit tests * improve getColumns() func in legacy search * configure search endpoint in team.cue * add team search handler * add the searchTeams endpoint to manifest.cue * make gofmt * update openapi spec * generate frontend endpoints * remove unused field * move fields defiitions to separate builder * fix legacy search * fix unit tests * fix unit test * address feedback * fix unit test * update openapi specs * yarn generate-apis * add missing unit tests
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/search/builders"
|
||||
)
|
||||
|
||||
func ParseResults(result *resourcepb.ResourceSearchResponse, offset int64) (v0alpha1.TeamSearchResults, error) {
|
||||
if result == nil {
|
||||
return v0alpha1.TeamSearchResults{}, nil
|
||||
} else if result.Error != nil {
|
||||
return v0alpha1.TeamSearchResults{}, fmt.Errorf("%d error searching: %s: %s", result.Error.Code, result.Error.Message, result.Error.Details)
|
||||
} else if result.Results == nil {
|
||||
return v0alpha1.TeamSearchResults{}, nil
|
||||
}
|
||||
|
||||
titleIDX := -1
|
||||
emailIDX := -1
|
||||
provisionedIDX := -1
|
||||
externalUIDIDX := -1
|
||||
|
||||
for i, v := range result.Results.Columns {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch v.Name {
|
||||
case resource.SEARCH_FIELD_TITLE:
|
||||
titleIDX = i
|
||||
case builders.TEAM_SEARCH_EMAIL:
|
||||
emailIDX = i
|
||||
case builders.TEAM_SEARCH_PROVISIONED:
|
||||
provisionedIDX = i
|
||||
case builders.TEAM_SEARCH_EXTERNAL_UID:
|
||||
externalUIDIDX = i
|
||||
}
|
||||
}
|
||||
|
||||
sr := v0alpha1.TeamSearchResults{
|
||||
Offset: offset,
|
||||
TotalHits: result.TotalHits,
|
||||
QueryCost: result.QueryCost,
|
||||
MaxScore: result.MaxScore,
|
||||
Hits: make([]v0alpha1.TeamHit, len(result.Results.Rows)),
|
||||
}
|
||||
|
||||
for i, row := range result.Results.Rows {
|
||||
if len(row.Cells) != len(result.Results.Columns) {
|
||||
return v0alpha1.TeamSearchResults{}, fmt.Errorf("error parsing team search response: mismatch number of columns and cells")
|
||||
}
|
||||
|
||||
hit := &v0alpha1.TeamHit{
|
||||
Name: row.Key.Name,
|
||||
}
|
||||
|
||||
if titleIDX >= 0 && row.Cells[titleIDX] != nil {
|
||||
hit.Title = string(row.Cells[titleIDX])
|
||||
} else {
|
||||
hit.Title = "(no title)"
|
||||
}
|
||||
|
||||
if emailIDX >= 0 && row.Cells[emailIDX] != nil {
|
||||
hit.Email = string(row.Cells[emailIDX])
|
||||
}
|
||||
|
||||
if provisionedIDX >= 0 && row.Cells[provisionedIDX] != nil {
|
||||
hit.Provisioned = string(row.Cells[provisionedIDX]) == "true"
|
||||
}
|
||||
|
||||
if externalUIDIDX >= 0 && row.Cells[externalUIDIDX] != nil {
|
||||
hit.ExternalUID = string(row.Cells[externalUIDIDX])
|
||||
}
|
||||
|
||||
sr.Hits[i] = *hit
|
||||
}
|
||||
|
||||
return sr, nil
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
|
||||
)
|
||||
|
||||
func TestParseResults(t *testing.T) {
|
||||
t.Run("should parse results", func(t *testing.T) {
|
||||
searchResp := &resourcepb.ResourceSearchResponse{
|
||||
Results: &resourcepb.ResourceTable{
|
||||
Columns: []*resourcepb.ResourceTableColumnDefinition{
|
||||
{
|
||||
Name: "title",
|
||||
Type: resourcepb.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
{
|
||||
Name: "email",
|
||||
Type: resourcepb.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
{
|
||||
Name: "provisioned",
|
||||
Type: resourcepb.ResourceTableColumnDefinition_BOOLEAN,
|
||||
},
|
||||
{
|
||||
Name: "externalUID",
|
||||
Type: resourcepb.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
},
|
||||
Rows: []*resourcepb.ResourceTableRow{
|
||||
{
|
||||
Key: &resourcepb.ResourceKey{
|
||||
Name: "uid",
|
||||
Resource: "team",
|
||||
},
|
||||
Cells: [][]byte{
|
||||
[]byte("Team 1"),
|
||||
[]byte("team1@example.com"),
|
||||
[]byte("true"),
|
||||
[]byte("team1-uid"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
TotalHits: 1,
|
||||
}
|
||||
|
||||
results, err := ParseResults(searchResp, 0)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results.Hits, 1)
|
||||
require.Equal(t, "Team 1", results.Hits[0].Title)
|
||||
require.Equal(t, "team1@example.com", results.Hits[0].Email)
|
||||
require.True(t, results.Hits[0].Provisioned)
|
||||
require.Equal(t, "team1-uid", results.Hits[0].ExternalUID)
|
||||
})
|
||||
|
||||
t.Run("should handle nil result", func(t *testing.T) {
|
||||
results, err := ParseResults(nil, 0)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, results.Hits)
|
||||
require.Zero(t, results.TotalHits)
|
||||
})
|
||||
|
||||
t.Run("should handle nil Results", func(t *testing.T) {
|
||||
searchResp := &resourcepb.ResourceSearchResponse{
|
||||
Results: nil,
|
||||
TotalHits: 0,
|
||||
}
|
||||
|
||||
results, err := ParseResults(searchResp, 0)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, results.Hits)
|
||||
require.Zero(t, results.TotalHits)
|
||||
})
|
||||
|
||||
t.Run("should handle nil Results.Rows", func(t *testing.T) {
|
||||
searchResp := &resourcepb.ResourceSearchResponse{
|
||||
Results: &resourcepb.ResourceTable{
|
||||
Columns: []*resourcepb.ResourceTableColumnDefinition{
|
||||
{
|
||||
Name: "title",
|
||||
Type: resourcepb.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
},
|
||||
Rows: nil,
|
||||
},
|
||||
TotalHits: 0,
|
||||
}
|
||||
|
||||
results, err := ParseResults(searchResp, 0)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, results.Hits)
|
||||
require.Zero(t, results.TotalHits)
|
||||
})
|
||||
|
||||
t.Run("should return error for mismatched number of columns and cells", func(t *testing.T) {
|
||||
searchResp := &resourcepb.ResourceSearchResponse{
|
||||
Results: &resourcepb.ResourceTable{
|
||||
Columns: []*resourcepb.ResourceTableColumnDefinition{
|
||||
{
|
||||
Name: "title",
|
||||
Type: resourcepb.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
{
|
||||
Name: "email",
|
||||
Type: resourcepb.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
{
|
||||
Name: "provisioned",
|
||||
Type: resourcepb.ResourceTableColumnDefinition_BOOLEAN,
|
||||
},
|
||||
},
|
||||
Rows: []*resourcepb.ResourceTableRow{
|
||||
{
|
||||
Key: &resourcepb.ResourceKey{
|
||||
Name: "uid",
|
||||
Resource: "team",
|
||||
},
|
||||
Cells: [][]byte{
|
||||
[]byte("Team 1"),
|
||||
[]byte("team1@example.com"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
TotalHits: 1,
|
||||
}
|
||||
|
||||
results, err := ParseResults(searchResp, 0)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "mismatch number of columns and cells")
|
||||
require.Empty(t, results.Hits)
|
||||
})
|
||||
|
||||
t.Run("should return error for error response", func(t *testing.T) {
|
||||
searchResp := &resourcepb.ResourceSearchResponse{
|
||||
Error: &resourcepb.ErrorResult{
|
||||
Code: 500,
|
||||
Message: "Internal server error",
|
||||
Details: &resourcepb.ErrorDetails{
|
||||
Name: "test-resource",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
results, err := ParseResults(searchResp, 0)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "500 error searching: Internal server error")
|
||||
require.Empty(t, results.Hits)
|
||||
})
|
||||
|
||||
t.Run("should use (no title) fallback when title cell is nil", func(t *testing.T) {
|
||||
searchResp := &resourcepb.ResourceSearchResponse{
|
||||
Results: &resourcepb.ResourceTable{
|
||||
Columns: []*resourcepb.ResourceTableColumnDefinition{
|
||||
{
|
||||
Name: "title",
|
||||
Type: resourcepb.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
{
|
||||
Name: "email",
|
||||
Type: resourcepb.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
},
|
||||
Rows: []*resourcepb.ResourceTableRow{
|
||||
{
|
||||
Key: &resourcepb.ResourceKey{
|
||||
Name: "uid",
|
||||
Resource: "team",
|
||||
},
|
||||
Cells: [][]byte{
|
||||
nil, // title cell is nil
|
||||
[]byte("team1@example.com"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
TotalHits: 1,
|
||||
}
|
||||
|
||||
results, err := ParseResults(searchResp, 0)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results.Hits, 1)
|
||||
require.Equal(t, "(no title)", results.Hits[0].Title)
|
||||
require.Equal(t, "team1@example.com", results.Hits[0].Email)
|
||||
})
|
||||
|
||||
t.Run("should use (no title) fallback when title column is missing", func(t *testing.T) {
|
||||
searchResp := &resourcepb.ResourceSearchResponse{
|
||||
Results: &resourcepb.ResourceTable{
|
||||
Columns: []*resourcepb.ResourceTableColumnDefinition{
|
||||
{
|
||||
Name: "email",
|
||||
Type: resourcepb.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
{
|
||||
Name: "provisioned",
|
||||
Type: resourcepb.ResourceTableColumnDefinition_BOOLEAN,
|
||||
},
|
||||
},
|
||||
Rows: []*resourcepb.ResourceTableRow{
|
||||
{
|
||||
Key: &resourcepb.ResourceKey{
|
||||
Name: "uid",
|
||||
Resource: "team",
|
||||
},
|
||||
Cells: [][]byte{
|
||||
[]byte("team1@example.com"),
|
||||
[]byte("true"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
TotalHits: 1,
|
||||
}
|
||||
|
||||
results, err := ParseResults(searchResp, 0)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results.Hits, 1)
|
||||
require.Equal(t, "(no title)", results.Hits[0].Title)
|
||||
require.Equal(t, "team1@example.com", results.Hits[0].Email)
|
||||
require.True(t, results.Hits[0].Provisioned)
|
||||
})
|
||||
}
|
||||
@@ -7,13 +7,14 @@ import (
|
||||
)
|
||||
|
||||
type FakeService struct {
|
||||
ExpectedTeam team.Team
|
||||
ExpectedIsMember bool
|
||||
ExpectedIsAdmin bool
|
||||
ExpectedTeamDTO *team.TeamDTO
|
||||
ExpectedTeamsByUser []*team.TeamDTO
|
||||
ExpectedMembers []*team.TeamMemberDTO
|
||||
ExpectedError error
|
||||
ExpectedTeam team.Team
|
||||
ExpectedIsMember bool
|
||||
ExpectedIsAdmin bool
|
||||
ExpectedTeamDTO *team.TeamDTO
|
||||
ExpectedTeamsByUser []*team.TeamDTO
|
||||
ExpectedMembers []*team.TeamMemberDTO
|
||||
ExpectedSearchTeamsResult team.SearchTeamQueryResult
|
||||
ExpectedError error
|
||||
}
|
||||
|
||||
func NewFakeService() *FakeService {
|
||||
@@ -39,7 +40,7 @@ func (s *FakeService) DeleteTeam(ctx context.Context, cmd *team.DeleteTeamComman
|
||||
}
|
||||
|
||||
func (s *FakeService) SearchTeams(ctx context.Context, query *team.SearchTeamsQuery) (team.SearchTeamQueryResult, error) {
|
||||
return team.SearchTeamQueryResult{}, s.ExpectedError
|
||||
return s.ExpectedSearchTeamsResult, s.ExpectedError
|
||||
}
|
||||
|
||||
func (s *FakeService) GetTeamByID(ctx context.Context, query *team.GetTeamByIDQuery) (*team.TeamDTO, error) {
|
||||
|
||||
Reference in New Issue
Block a user