diff --git a/Makefile b/Makefile index 24a9fe6deb..ac5c1dd6b2 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ lint-fix: golangci-lint run --fix test: ## Run tests - @go test -race $(shell go list ./... | grep -v /web/apps/admin | grep -v /vendor/ | grep -v /test/ | grep -v /mocks | grep -v postgres/migrations | grep -v /proto) -coverprofile=coverage.out -count 2 -timeout 150s + @go test -race $(shell go list ./... | grep -v /web/apps/admin | grep -v /vendor/ | grep -v /test/ | grep -v /mocks | grep -v postgres/migrations | grep -v /proto) -coverprofile=coverage.out -count 2 -timeout 600s test-all: lint test e2e-test ## Run all tests diff --git a/core/aggregates/orgserviceuser/orgserviceuser.go b/core/aggregates/orgserviceuser/orgserviceuser.go index e129e91864..08b9f03217 100644 --- a/core/aggregates/orgserviceuser/orgserviceuser.go +++ b/core/aggregates/orgserviceuser/orgserviceuser.go @@ -43,12 +43,11 @@ type Project struct { } type AggregatedServiceUser struct { - ID string `rql:"filter,sort,column=id"` - OrgID string `rql:"filter,sort,column=org_id"` - Title string `rql:"filter,sort,column=title"` - Projects []Project `rql:"filter,sort,column=projects"` - CreatedAt time.Time `rql:"filter,sort,column=created_at"` - UpdatedAt time.Time `rql:"filter,sort,column=updated_at"` + ID string `rql:"name=id,type=string"` + OrgID string `rql:"name=org_id,type=string"` + Title string `rql:"name=title,type=string"` + Projects []Project `rql:"name=projects,type=string"` + CreatedAt time.Time `rql:"name=created_at,type=datetime"` } func (s Service) Search(ctx context.Context, orgID string, query *rql.Query) (OrganizationServiceUsers, error) { diff --git a/internal/api/v1beta1connect/organization_serviceuser.go b/internal/api/v1beta1connect/organization_serviceuser.go index 1546ec5210..a48e3c53c6 100644 --- a/internal/api/v1beta1connect/organization_serviceuser.go +++ b/internal/api/v1beta1connect/organization_serviceuser.go @@ -6,10 +6,12 @@ import ( "connectrpc.com/connect" "github.com/raystack/frontier/core/aggregates/orgserviceuser" + "github.com/raystack/frontier/core/organization" "github.com/raystack/frontier/internal/store/postgres" "github.com/raystack/frontier/pkg/errors" "github.com/raystack/frontier/pkg/utils" frontierv1beta1 "github.com/raystack/frontier/proto/v1beta1" + "github.com/raystack/salt/rql" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -19,12 +21,24 @@ func (h *ConnectHandler) SearchOrganizationServiceUsers(ctx context.Context, req return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("failed to read rql query: %v", err)) } - serviceUsersData, err := h.orgServiceUserService.Search(ctx, request.Msg.GetId(), rqlQuery) + if err := rql.ValidateQuery(rqlQuery, orgserviceuser.AggregatedServiceUser{}); err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("failed to validate rql query: %v", err)) + } + + org, err := h.orgService.GetRaw(ctx, request.Msg.GetId()) + if err != nil { + if errors.Is(err, organization.ErrNotExist) || errors.Is(err, organization.ErrInvalidUUID) { + return nil, connect.NewError(connect.CodeNotFound, ErrNotFound) + } + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("SearchOrganizationServiceUsers.GetRaw: org_id=%s: %w", request.Msg.GetId(), err)) + } + + serviceUsersData, err := h.orgServiceUserService.Search(ctx, org.ID, rqlQuery) if err != nil { if errors.Is(err, postgres.ErrBadInput) { - return nil, connect.NewError(connect.CodeInvalidArgument, ErrBadRequest) + return nil, connect.NewError(connect.CodeInvalidArgument, err) } - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("SearchOrganizationServiceUsers.Search: org_id=%s: %w", request.Msg.GetId(), err)) + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("SearchOrganizationServiceUsers.Search: org_id=%s: %w", org.ID, err)) } var orgServiceUsers []*frontierv1beta1.SearchOrganizationServiceUsersResponse_OrganizationServiceUser diff --git a/internal/api/v1beta1connect/organization_serviceuser_test.go b/internal/api/v1beta1connect/organization_serviceuser_test.go new file mode 100644 index 0000000000..58345baef6 --- /dev/null +++ b/internal/api/v1beta1connect/organization_serviceuser_test.go @@ -0,0 +1,153 @@ +package v1beta1connect + +import ( + "context" + "fmt" + "testing" + + "connectrpc.com/connect" + "github.com/raystack/frontier/core/aggregates/orgserviceuser" + "github.com/raystack/frontier/core/organization" + "github.com/raystack/frontier/internal/api/v1beta1connect/mocks" + "github.com/raystack/frontier/internal/store/postgres" + frontierv1beta1 "github.com/raystack/frontier/proto/v1beta1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestSearchOrganizationServiceUsers(t *testing.T) { + const orgID = "1a02f4ae-9d76-446d-bc9c-a42c46e4f852" + + stringFilter := func(name, op, value string) *frontierv1beta1.RQLFilter { + return &frontierv1beta1.RQLFilter{ + Name: name, + Operator: op, + Value: &frontierv1beta1.RQLFilter_StringValue{StringValue: value}, + } + } + + tests := []struct { + name string + setup func(*mocks.OrgServiceUserService) + orgSetup func(*mocks.OrganizationService) + id string + query *frontierv1beta1.RQLRequest + wantCode connect.Code + wantMsg string + wantLen int + }{ + { + name: "returns the rows the service gives back", + setup: func(s *mocks.OrgServiceUserService) { + s.EXPECT().Search(mock.Anything, orgID, mock.Anything).Return(orgserviceuser.OrganizationServiceUsers{ + ServiceUsers: []orgserviceuser.AggregatedServiceUser{ + {ID: "su-1", Title: "first", OrgID: orgID}, + {ID: "su-2", Title: "second", OrgID: orgID, Projects: []orgserviceuser.Project{{ID: "p1", Name: "proj", Title: "Proj"}}}, + }, + Pagination: orgserviceuser.Page{Limit: 50, Offset: 0}, + }, nil) + }, + query: &frontierv1beta1.RQLRequest{Limit: 50}, + wantLen: 2, + }, + { + name: "unparsable created_at is rejected before reaching the service", + query: &frontierv1beta1.RQLRequest{Limit: 50, Filters: []*frontierv1beta1.RQLFilter{stringFilter("created_at", "gt", "yesterday-ish")}}, + wantCode: connect.CodeInvalidArgument, + wantMsg: "failed to validate rql query", + }, + { + name: "an operator the field does not allow is rejected", + query: &frontierv1beta1.RQLRequest{Limit: 50, Filters: []*frontierv1beta1.RQLFilter{stringFilter("created_at", "in", "2026-01-01T00:00:00Z,2026-03-01T00:00:00Z")}}, + wantCode: connect.CodeInvalidArgument, + wantMsg: "failed to validate rql query", + }, + { + name: "a field that is not on the aggregate is rejected", + query: &frontierv1beta1.RQLRequest{Limit: 50, Filters: []*frontierv1beta1.RQLFilter{stringFilter("nonsense", "eq", "x")}}, + wantCode: connect.CodeInvalidArgument, + wantMsg: "failed to read rql query", + }, + { + name: "an unsortable field is rejected", + query: &frontierv1beta1.RQLRequest{Limit: 50, Sort: []*frontierv1beta1.RQLSort{{Name: "nonsense", Order: "asc"}}}, + wantCode: connect.CodeInvalidArgument, + wantMsg: "failed to validate rql query", + }, + { + name: "bad input from the store maps to invalid argument and keeps the reason", + setup: func(s *mocks.OrgServiceUserService) { + s.EXPECT().Search(mock.Anything, orgID, mock.Anything).Return( + orgserviceuser.OrganizationServiceUsers{}, + fmt.Errorf("%w: group_by is not supported", postgres.ErrBadInput)) + }, + query: &frontierv1beta1.RQLRequest{Limit: 50}, + wantCode: connect.CodeInvalidArgument, + wantMsg: "group_by is not supported", + }, + { + name: "org addressed by name is resolved to its id", + id: "compare-org", + orgSetup: func(o *mocks.OrganizationService) { + o.EXPECT().GetRaw(mock.Anything, "compare-org").Return(organization.Organization{ID: orgID}, nil) + }, + setup: func(s *mocks.OrgServiceUserService) { + s.EXPECT().Search(mock.Anything, orgID, mock.Anything).Return(orgserviceuser.OrganizationServiceUsers{}, nil) + }, + query: &frontierv1beta1.RQLRequest{Limit: 50}, + wantLen: 0, + }, + { + name: "org that does not exist is not found", + orgSetup: func(o *mocks.OrganizationService) { + o.EXPECT().GetRaw(mock.Anything, mock.Anything).Return(organization.Organization{}, organization.ErrNotExist) + }, + query: &frontierv1beta1.RQLRequest{Limit: 50}, + wantCode: connect.CodeNotFound, + }, + { + name: "any other store failure is internal", + setup: func(s *mocks.OrgServiceUserService) { + s.EXPECT().Search(mock.Anything, orgID, mock.Anything).Return( + orgserviceuser.OrganizationServiceUsers{}, fmt.Errorf("connection refused")) + }, + query: &frontierv1beta1.RQLRequest{Limit: 50}, + wantCode: connect.CodeInternal, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := mocks.NewOrgServiceUserService(t) + if tt.setup != nil { + tt.setup(svc) + } + orgSvc := mocks.NewOrganizationService(t) + if tt.orgSetup != nil { + tt.orgSetup(orgSvc) + } else if tt.setup != nil { + orgSvc.EXPECT().GetRaw(mock.Anything, mock.Anything).Return(organization.Organization{ID: orgID}, nil) + } + handler := &ConnectHandler{orgServiceUserService: svc, orgService: orgSvc} + + id := tt.id + if id == "" { + id = orgID + } + resp, err := handler.SearchOrganizationServiceUsers(context.Background(), + connect.NewRequest(&frontierv1beta1.SearchOrganizationServiceUsersRequest{Id: id, Query: tt.query})) + + if tt.wantMsg == "" && tt.wantCode == 0 { + assert.NoError(t, err) + assert.Len(t, resp.Msg.GetOrganizationServiceUsers(), tt.wantLen) + return + } + + assert.Error(t, err) + assert.Equal(t, tt.wantCode, connect.CodeOf(err)) + if tt.wantMsg != "" { + assert.Contains(t, err.Error(), tt.wantMsg) + } + }) + } +} diff --git a/internal/store/postgres/org_serviceuser_repository.go b/internal/store/postgres/org_serviceuser_repository.go index 9f78653829..f602c896a8 100644 --- a/internal/store/postgres/org_serviceuser_repository.go +++ b/internal/store/postgres/org_serviceuser_repository.go @@ -5,12 +5,14 @@ import ( "database/sql" "encoding/json" "fmt" + "slices" "github.com/doug-martin/goqu/v9" "github.com/jmoiron/sqlx" svc "github.com/raystack/frontier/core/aggregates/orgserviceuser" "github.com/raystack/frontier/internal/bootstrap/schema" "github.com/raystack/frontier/pkg/db" + "github.com/raystack/frontier/pkg/utils" "github.com/raystack/salt/rql" ) @@ -25,10 +27,8 @@ type ServiceUserRow struct { func (c *ServiceUserRow) transformToAggregatedServiceUser(orgID string) svc.AggregatedServiceUser { var projects []svc.Project if c.ProjectData != "" && c.ProjectData != "null" { - // Parse JSON array of project objects err := json.Unmarshal([]byte(c.ProjectData), &projects) if err != nil { - // If JSON parsing fails, return empty projects array projects = []svc.Project{} } } @@ -42,6 +42,17 @@ func (c *ServiceUserRow) transformToAggregatedServiceUser(orgID string) svc.Aggr } } +const ( + COLUMN_PROJECT_DATA = "project_data" + COLUMN_PROJECTS = "projects" +) + +var ( + orgServiceUserRQLFilterSupportedColumns = []string{COLUMN_ID, COLUMN_TITLE, COLUMN_ORG_ID, COLUMN_CREATED_AT, COLUMN_PROJECTS} + orgServiceUserRQLSearchSupportedColumns = []string{COLUMN_TITLE, COLUMN_PROJECTS} + orgServiceUserRQLSortSupportedColumns = []string{COLUMN_ID, COLUMN_TITLE, COLUMN_CREATED_AT} +) + type OrgServiceUserRepository struct { dbc *db.Client } @@ -52,8 +63,8 @@ func NewOrgServiceUserRepository(dbc *db.Client) *OrgServiceUserRepository { } } -func (r OrgServiceUserRepository) Search(ctx context.Context, orgID string, rql *rql.Query) (svc.OrganizationServiceUsers, error) { - dataQuery, params, err := r.prepareDataQuery(orgID, rql) +func (r OrgServiceUserRepository) Search(ctx context.Context, orgID string, rqlQuery *rql.Query) (svc.OrganizationServiceUsers, error) { + dataQuery, params, page, err := r.prepareDataQuery(orgID, rqlQuery) if err != nil { return svc.OrganizationServiceUsers{}, err } @@ -84,125 +95,87 @@ func (r OrgServiceUserRepository) Search(ctx context.Context, orgID string, rql return svc.OrganizationServiceUsers{ ServiceUsers: res, Pagination: svc.Page{ - Offset: rql.Offset, - Limit: rql.Limit, + Offset: page.Offset, + Limit: page.Limit, }, }, nil } -func (r OrgServiceUserRepository) prepareDataQuery(orgID string, rql *rql.Query) (string, []any, error) { - query := r.buildBaseQuery(orgID) +func (r OrgServiceUserRepository) prepareDataQuery(orgID string, rqlQuery *rql.Query) (string, []any, utils.Page, error) { + query := dialect.From(r.buildBaseQuery(orgID).As("org_service_users")).Prepared(true). + Select(COLUMN_ID, COLUMN_TITLE, COLUMN_ORG_ID, COLUMN_CREATED_AT, COLUMN_PROJECT_DATA) - if rql != nil { - for _, filter := range rql.Filters { - query = r.addFilter(query, filter) - } - if rql.Search != "" { - query = r.addSearch(query, rql.Search) - } - if len(rql.Sort) > 0 { - var err error - query, err = r.addSort(query, rql.Sort) - if err != nil { - return "", nil, err - } - } - if rql.Limit > 0 { - query = query.Limit(uint(rql.Limit)) - } - if rql.Offset > 0 { - query = query.Offset(uint(rql.Offset)) + if rqlQuery == nil { + rqlQuery = &rql.Query{} + } + + if len(rqlQuery.GroupBy) > 0 { + return "", nil, utils.Page{}, fmt.Errorf("%w: group_by is not supported", ErrBadInput) + } + + query, err := utils.AddRQLFiltersInQuery(query, rqlQuery, orgServiceUserRQLFilterSupportedColumns, svc.AggregatedServiceUser{}) + if err != nil { + return "", nil, utils.Page{}, fmt.Errorf("%w: %w", ErrBadInput, err) + } + + query, err = utils.AddRQLSearchInQuery(query, rqlQuery, orgServiceUserRQLSearchSupportedColumns) + if err != nil { + return "", nil, utils.Page{}, fmt.Errorf("%w: %w", ErrBadInput, err) + } + + for _, sortItem := range rqlQuery.Sort { + if !slices.Contains(orgServiceUserRQLSortSupportedColumns, sortItem.Name) { + return "", nil, utils.Page{}, fmt.Errorf("%w: %s is not supported in sort", ErrBadInput, sortItem.Name) } } + query, err = utils.AddRQLSortInQuery(query, rqlQuery) + if err != nil { + return "", nil, utils.Page{}, fmt.Errorf("%w: %w", ErrBadInput, err) + } + + // the fallback sorts on the service user title + if len(rqlQuery.Sort) == 0 { + query = query.OrderAppend(goqu.C(COLUMN_TITLE).Asc()) + } + query = query.OrderAppend(goqu.C(COLUMN_ID).Asc()) + + query, page := utils.AddRQLPaginationInQuery(query, rqlQuery) - return query.ToSQL() + sql, params, err := query.ToSQL() + return sql, params, page, err } func (r OrgServiceUserRepository) buildBaseQuery(orgID string) *goqu.SelectDataset { return dialect.From(TABLE_SERVICE_USERS).Prepared(true). Select( - goqu.I(TABLE_SERVICE_USERS+"."+COLUMN_ID).As("id"), - goqu.I(TABLE_SERVICE_USERS+"."+COLUMN_TITLE).As("title"), - goqu.I(TABLE_SERVICE_USERS+"."+COLUMN_ORG_ID).As("org_id"), - goqu.I(TABLE_SERVICE_USERS+"."+COLUMN_CREATED_AT).As("created_at"), - goqu.L("JSON_AGG(JSON_BUILD_OBJECT('id', "+TABLE_PROJECTS+"."+COLUMN_ID+", 'title', "+TABLE_PROJECTS+"."+COLUMN_TITLE+", 'name', "+TABLE_PROJECTS+"."+COLUMN_NAME+"))").As("project_data"), + goqu.I(TABLE_SERVICE_USERS+"."+COLUMN_ID).As(COLUMN_ID), + goqu.I(TABLE_SERVICE_USERS+"."+COLUMN_TITLE).As(COLUMN_TITLE), + goqu.I(TABLE_SERVICE_USERS+"."+COLUMN_ORG_ID).As(COLUMN_ORG_ID), + goqu.I(TABLE_SERVICE_USERS+"."+COLUMN_CREATED_AT).As(COLUMN_CREATED_AT), + goqu.L("COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT('id', "+TABLE_PROJECTS+"."+COLUMN_ID+", 'title', "+TABLE_PROJECTS+"."+COLUMN_TITLE+", 'name', "+TABLE_PROJECTS+"."+COLUMN_NAME+")) FILTER (WHERE "+TABLE_PROJECTS+"."+COLUMN_ID+" IS NOT NULL), '[]')").As(COLUMN_PROJECT_DATA), + goqu.L("COALESCE(STRING_AGG(DISTINCT "+TABLE_PROJECTS+"."+COLUMN_TITLE+", ', ') FILTER (WHERE "+TABLE_PROJECTS+"."+COLUMN_ID+" IS NOT NULL), '')").As(COLUMN_PROJECTS), ). - InnerJoin( + LeftJoin( goqu.T(TABLE_POLICIES), goqu.On( goqu.I(TABLE_SERVICE_USERS+"."+COLUMN_ID).Eq(goqu.I(TABLE_POLICIES+"."+COLUMN_PRINCIPAL_ID)), goqu.I(TABLE_POLICIES+"."+COLUMN_PRINCIPAL_TYPE).Eq(schema.ServiceUserPrincipal), goqu.I(TABLE_POLICIES+"."+COLUMN_RESOURCE_TYPE).Eq(schema.ProjectNamespace), + live(TABLE_POLICIES), ), ). - InnerJoin( + LeftJoin( goqu.T(TABLE_PROJECTS), - goqu.On(goqu.I(TABLE_POLICIES+"."+COLUMN_RESOURCE_ID).Eq(goqu.I(TABLE_PROJECTS+"."+COLUMN_ID))), + goqu.On( + goqu.I(TABLE_POLICIES+"."+COLUMN_RESOURCE_ID).Eq(goqu.I(TABLE_PROJECTS+"."+COLUMN_ID)), + live(TABLE_PROJECTS), + ), + ). + Where( + goqu.Ex{TABLE_SERVICE_USERS + "." + COLUMN_ORG_ID: orgID}, + live(TABLE_SERVICE_USERS), ). - Where(goqu.Ex{ - TABLE_SERVICE_USERS + "." + COLUMN_ORG_ID: orgID, - }). GroupBy( TABLE_SERVICE_USERS + "." + COLUMN_ID, - ). - Order(goqu.I(TABLE_SERVICE_USERS + "." + COLUMN_TITLE).Asc()) -} - -func (r OrgServiceUserRepository) addFilter(query *goqu.SelectDataset, filter rql.Filter) *goqu.SelectDataset { - var field string - // Map field names to their table-qualified names - switch filter.Name { - case "title": - field = TABLE_SERVICE_USERS + "." + COLUMN_TITLE - case "created_at": - field = TABLE_SERVICE_USERS + "." + COLUMN_CREATED_AT - default: - return query - } - - switch filter.Operator { - case "empty": - return query.Where(goqu.Or(goqu.I(field).IsNull(), goqu.I(field).Eq(""))) - case "notempty": - return query.Where(goqu.And(goqu.I(field).IsNotNull(), goqu.I(field).Neq(""))) - case "like", "notlike": - value := "%" + filter.Value.(string) + "%" - return query.Where(goqu.Ex{field: goqu.Op{filter.Operator: value}}) - default: - return query.Where(goqu.Ex{field: goqu.Op{filter.Operator: filter.Value}}) - } -} - -func (r OrgServiceUserRepository) addSearch(query *goqu.SelectDataset, search string) *goqu.SelectDataset { - searchPattern := "%" + search + "%" - searchExpressions := make([]goqu.Expression, 0) - - searchExpressions = append(searchExpressions, - goqu.Cast(goqu.I(TABLE_SERVICE_USERS+"."+COLUMN_TITLE), "TEXT").ILike(searchPattern), - ) - - return query.Where(goqu.Or(searchExpressions...)) -} - -func (r OrgServiceUserRepository) addSort(query *goqu.SelectDataset, sorts []rql.Sort) (*goqu.SelectDataset, error) { - validSortFields := map[string]string{ - "title": TABLE_SERVICE_USERS + "." + COLUMN_TITLE, - "created_at": TABLE_SERVICE_USERS + "." + COLUMN_CREATED_AT, - } - - for _, sort := range sorts { - field, exists := validSortFields[sort.Name] - if !exists { - return nil, fmt.Errorf("invalid sort field: %s", sort.Name) - } - - switch sort.Order { - case "asc": - query = query.OrderAppend(goqu.I(field).Asc()) - case "desc": - query = query.OrderAppend(goqu.I(field).Desc()) - } - } - - return query, nil + ) } diff --git a/internal/store/postgres/org_serviceuser_repository_pg_test.go b/internal/store/postgres/org_serviceuser_repository_pg_test.go new file mode 100644 index 0000000000..2178ad6684 --- /dev/null +++ b/internal/store/postgres/org_serviceuser_repository_pg_test.go @@ -0,0 +1,265 @@ +package postgres_test + +import ( + "context" + "fmt" + "io" + "log/slog" + "testing" + + "github.com/ory/dockertest" + "github.com/raystack/frontier/internal/store/postgres" + "github.com/raystack/frontier/pkg/db" + "github.com/raystack/salt/rql" + "github.com/stretchr/testify/suite" +) + +/* +The SQL-string tests in org_serviceuser_repository_test.go pin the generated query, +but they would happily pass with the inner joins that caused the original bug. These +run the query against a real postgres with the shapes that actually broke: + + a-with-project one policy on a live project + b-org-policy-only a policy, but on the org rather than a project + c-no-policy no policy at all + d-deleted-project a policy on a project that has been soft deleted + e-two-roles two policies, two roles, one and the same project +*/ +type OrgServiceUserRepositoryPGTestSuite struct { + suite.Suite + ctx context.Context + client *db.Client + pool *dockertest.Pool + resource *dockertest.Resource + repository *postgres.OrgServiceUserRepository + orgID string +} + +func (s *OrgServiceUserRepositoryPGTestSuite) SetupSuite() { + var err error + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + s.client, s.pool, s.resource, err = newTestClient(logger) + if err != nil { + s.T().Fatal(err) + } + s.ctx = context.TODO() + s.repository = postgres.NewOrgServiceUserRepository(s.client) +} + +func (s *OrgServiceUserRepositoryPGTestSuite) TearDownSuite() { + if err := purgeDocker(s.pool, s.resource); err != nil { + s.T().Fatal(err) + } +} + +func (s *OrgServiceUserRepositoryPGTestSuite) SetupTest() { + s.exec(`INSERT INTO namespaces (name) VALUES ('app/project'), ('app/organization'), ('app/serviceuser') + ON CONFLICT (name) DO NOTHING`) + + s.exec(`INSERT INTO organizations (name, title) VALUES ('su-search-org', 'SU Search Org')`) + s.orgID = s.scalar(`SELECT id FROM organizations WHERE name = 'su-search-org'`) + + s.exec(`INSERT INTO projects (name, title, org_id) VALUES + ('su-proj-live', 'Live Project', $1), ('su-proj-gone', 'Gone Project', $1)`, s.orgID) + s.exec(`UPDATE projects SET deleted_at = now() WHERE name = 'su-proj-gone'`) + + s.exec(`INSERT INTO serviceusers (org_id, title, created_at) VALUES + ($1, 'a-with-project', '2026-01-01'), + ($1, 'b-org-policy-only', '2026-06-01'), + ($1, 'c-no-policy', '2026-03-01'), + ($1, 'd-deleted-project', '2026-09-01'), + ($1, 'e-two-roles', '2026-04-01')`, s.orgID) + + s.exec(`INSERT INTO roles (name, org_id) VALUES ('su-role-one', $1), ('su-role-two', $1)`, s.orgID) + + s.policy("su-role-one", "su-proj-live", "app/project", "a-with-project") + s.orgPolicy("su-role-one", "b-org-policy-only") + s.policy("su-role-one", "su-proj-gone", "app/project", "d-deleted-project") + s.policy("su-role-one", "su-proj-live", "app/project", "e-two-roles") + s.policy("su-role-two", "su-proj-live", "app/project", "e-two-roles") +} + +func (s *OrgServiceUserRepositoryPGTestSuite) TearDownTest() { + for _, table := range []string{"policies", "roles", "serviceusers", "projects", "organizations", "namespaces"} { + s.exec(fmt.Sprintf("TRUNCATE TABLE %s RESTART IDENTITY CASCADE", table)) + } +} + +func (s *OrgServiceUserRepositoryPGTestSuite) exec(query string, args ...any) { + s.T().Helper() + if _, err := s.client.DB.ExecContext(s.ctx, query, args...); err != nil { + s.T().Fatalf("%s: %v", query, err) + } +} + +func (s *OrgServiceUserRepositoryPGTestSuite) scalar(query string, args ...any) string { + s.T().Helper() + var out string + if err := s.client.DB.QueryRowxContext(s.ctx, query, args...).Scan(&out); err != nil { + s.T().Fatalf("%s: %v", query, err) + } + return out +} + +func (s *OrgServiceUserRepositoryPGTestSuite) policy(role, project, resourceType, serviceUser string) { + s.T().Helper() + s.exec(`INSERT INTO policies (role_id, resource_id, resource_type, principal_id, principal_type) + VALUES ((SELECT id FROM roles WHERE name = $1), (SELECT id FROM projects WHERE name = $2), $3, + (SELECT id FROM serviceusers WHERE title = $4), 'app/serviceuser')`, + role, project, resourceType, serviceUser) +} + +func (s *OrgServiceUserRepositoryPGTestSuite) orgPolicy(role, serviceUser string) { + s.T().Helper() + s.exec(`INSERT INTO policies (role_id, resource_id, resource_type, principal_id, principal_type) + VALUES ((SELECT id FROM roles WHERE name = $1), $2, 'app/organization', + (SELECT id FROM serviceusers WHERE title = $3), 'app/serviceuser')`, + role, s.orgID, serviceUser) +} + +func (s *OrgServiceUserRepositoryPGTestSuite) titles(q *rql.Query) []string { + s.T().Helper() + res, err := s.repository.Search(s.ctx, s.orgID, q) + s.Require().NoError(err) + out := make([]string, 0, len(res.ServiceUsers)) + for _, su := range res.ServiceUsers { + out = append(out, su.Title) + } + return out +} + +func (s *OrgServiceUserRepositoryPGTestSuite) TestReturnsEveryServiceUserInTheOrg() { + s.Equal( + []string{"a-with-project", "b-org-policy-only", "c-no-policy", "d-deleted-project", "e-two-roles"}, + s.titles(&rql.Query{Limit: 50}), + ) +} + +func (s *OrgServiceUserRepositoryPGTestSuite) TestProjectsPerServiceUser() { + res, err := s.repository.Search(s.ctx, s.orgID, &rql.Query{Limit: 50}) + s.Require().NoError(err) + + got := map[string][]string{} + for _, su := range res.ServiceUsers { + names := []string{} + for _, p := range su.Projects { + names = append(names, p.Name) + } + got[su.Title] = names + } + + s.Equal([]string{"su-proj-live"}, got["a-with-project"]) + s.Empty(got["b-org-policy-only"]) + s.Empty(got["c-no-policy"]) + s.Empty(got["d-deleted-project"]) + s.Equal([]string{"su-proj-live"}, got["e-two-roles"]) +} + +func (s *OrgServiceUserRepositoryPGTestSuite) TestSortIsTheOneThatWasAskedFor() { + s.Equal( + []string{"d-deleted-project", "b-org-policy-only", "e-two-roles", "c-no-policy", "a-with-project"}, + s.titles(&rql.Query{Limit: 50, Sort: []rql.Sort{{Name: "created_at", Order: "desc"}}}), + "created_at desc should be newest first, not alphabetical", + ) + s.Equal( + []string{"e-two-roles", "d-deleted-project", "c-no-policy", "b-org-policy-only", "a-with-project"}, + s.titles(&rql.Query{Limit: 50, Sort: []rql.Sort{{Name: "title", Order: "desc"}}}), + "title desc should be descending", + ) +} + +func (s *OrgServiceUserRepositoryPGTestSuite) TestFilterOnCreatedAt() { + s.Equal( + []string{"b-org-policy-only", "d-deleted-project"}, + s.titles(&rql.Query{Limit: 50, Filters: []rql.Filter{ + {Name: "created_at", Operator: "gt", Value: "2026-05-01T00:00:00Z"}, + }}), + ) +} + +func (s *OrgServiceUserRepositoryPGTestSuite) TestFilterOnProjects() { + s.Equal( + []string{"a-with-project", "e-two-roles"}, + s.titles(&rql.Query{Limit: 50, Filters: []rql.Filter{ + {Name: "projects", Operator: "ilike", Value: "%Live%"}, + }}), + ) + s.Empty( + s.titles(&rql.Query{Limit: 50, Filters: []rql.Filter{ + {Name: "projects", Operator: "ilike", Value: "%nothing-matches%"}, + }}), + "a filter that matches nothing must return nothing, not everything", + ) +} + +func (s *OrgServiceUserRepositoryPGTestSuite) TestSearchCoversTitleAndProjects() { + s.Equal([]string{"b-org-policy-only", "c-no-policy"}, s.titles(&rql.Query{Limit: 50, Search: "policy"})) + s.Equal([]string{"a-with-project", "e-two-roles"}, s.titles(&rql.Query{Limit: 50, Search: "Live"})) +} + +func (s *OrgServiceUserRepositoryPGTestSuite) TestPagesDoNotOverlap() { + sort := []rql.Sort{{Name: "title", Order: "asc"}} + first := s.titles(&rql.Query{Limit: 2, Offset: 0, Sort: sort}) + second := s.titles(&rql.Query{Limit: 2, Offset: 2, Sort: sort}) + third := s.titles(&rql.Query{Limit: 2, Offset: 4, Sort: sort}) + + s.Equal([]string{"a-with-project", "b-org-policy-only"}, first) + s.Equal([]string{"c-no-policy", "d-deleted-project"}, second) + s.Equal([]string{"e-two-roles"}, third) +} + +func (s *OrgServiceUserRepositoryPGTestSuite) TestRejectsUnsupportedInput() { + _, err := s.repository.Search(s.ctx, s.orgID, &rql.Query{Limit: 50, GroupBy: []string{"title"}}) + s.ErrorIs(err, postgres.ErrBadInput) + + _, err = s.repository.Search(s.ctx, s.orgID, &rql.Query{Limit: 50, Sort: []rql.Sort{{Name: "state", Order: "asc"}}}) + s.ErrorIs(err, postgres.ErrBadInput) +} + +func (s *OrgServiceUserRepositoryPGTestSuite) TestPaginationMetadata() { + res, err := s.repository.Search(s.ctx, s.orgID, &rql.Query{}) + s.Require().NoError(err) + s.Equal(50, res.Pagination.Limit, "a request with no limit reports the default that was applied") + s.Equal(0, res.Pagination.Offset) + s.Len(res.ServiceUsers, 5) + + res, err = s.repository.Search(s.ctx, s.orgID, &rql.Query{Limit: 2, Offset: 2}) + s.Require().NoError(err) + s.Equal(2, res.Pagination.Limit) + s.Equal(2, res.Pagination.Offset) + s.Len(res.ServiceUsers, 2) + + res, err = s.repository.Search(s.ctx, s.orgID, &rql.Query{Limit: 2, Offset: 4}) + s.Require().NoError(err) + s.Equal(2, res.Pagination.Limit) + s.Equal(4, res.Pagination.Offset) + s.Len(res.ServiceUsers, 1) +} + +func (s *OrgServiceUserRepositoryPGTestSuite) TestSoftDeletedRowsAreSkipped() { + s.Equal( + []string{"a-with-project", "b-org-policy-only", "c-no-policy", "d-deleted-project", "e-two-roles"}, + s.titles(&rql.Query{Limit: 50}), + ) + + s.exec(`UPDATE serviceusers SET deleted_at = now() WHERE title = 'c-no-policy'`) + s.Equal( + []string{"a-with-project", "b-org-policy-only", "d-deleted-project", "e-two-roles"}, + s.titles(&rql.Query{Limit: 50}), + ) + + s.exec(`UPDATE policies SET deleted_at = now() + WHERE principal_id = (SELECT id FROM serviceusers WHERE title = 'a-with-project')`) + res, err := s.repository.Search(s.ctx, s.orgID, &rql.Query{Limit: 50}) + s.Require().NoError(err) + for _, su := range res.ServiceUsers { + if su.Title == "a-with-project" { + s.Empty(su.Projects, "a project granted by a soft-deleted policy must not be listed") + } + } + s.Contains(s.titles(&rql.Query{Limit: 50}), "a-with-project") +} + +func TestOrgServiceUserRepositoryPG(t *testing.T) { + suite.Run(t, new(OrgServiceUserRepositoryPGTestSuite)) +} diff --git a/internal/store/postgres/org_serviceuser_repository_test.go b/internal/store/postgres/org_serviceuser_repository_test.go index 65b7c19d41..0c0ee08463 100644 --- a/internal/store/postgres/org_serviceuser_repository_test.go +++ b/internal/store/postgres/org_serviceuser_repository_test.go @@ -3,371 +3,184 @@ package postgres import ( "testing" + svc "github.com/raystack/frontier/core/aggregates/orgserviceuser" + "github.com/raystack/frontier/pkg/utils" "github.com/raystack/salt/rql" "github.com/stretchr/testify/assert" ) +const orgServiceUserBaseSQL = `SELECT "id", "title", "org_id", "created_at", "project_data" FROM (` + + `SELECT "serviceusers"."id" AS "id", "serviceusers"."title" AS "title", "serviceusers"."org_id" AS "org_id", "serviceusers"."created_at" AS "created_at", ` + + `COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT('id', projects.id, 'title', projects.title, 'name', projects.name)) FILTER (WHERE projects.id IS NOT NULL), '[]') AS "project_data", ` + + `COALESCE(STRING_AGG(DISTINCT projects.title, ', ') FILTER (WHERE projects.id IS NOT NULL), '') AS "projects" ` + + `FROM "serviceusers" ` + + `LEFT JOIN "policies" ON (("serviceusers"."id" = "policies"."principal_id") AND ("policies"."principal_type" = $1) AND ("policies"."resource_type" = $2) AND ("policies"."deleted_at" IS NULL)) ` + + `LEFT JOIN "projects" ON (("policies"."resource_id" = "projects"."id") AND ("projects"."deleted_at" IS NULL)) ` + + `WHERE (("serviceusers"."org_id" = $3) AND ("serviceusers"."deleted_at" IS NULL)) ` + + `GROUP BY "serviceusers"."id"` + + `) AS "org_service_users"` + +func baseParams(rest ...any) []any { + return append([]any{"app/serviceuser", "app/project", "org1"}, rest...) +} + func TestOrgServiceUserRepository_prepareDataQuery(t *testing.T) { tests := []struct { name string - orgID string rql *rql.Query wantSQL string wantParams []any - wantErr bool + wantPage utils.Page + wantErr string }{ { - name: "basic query without filters", - orgID: "org1", - rql: &rql.Query{ - Limit: 10, - Offset: 5, - }, - wantSQL: `SELECT "serviceusers"."id" AS "id", "serviceusers"."title" AS "title", "serviceusers"."org_id" AS "org_id", "serviceusers"."created_at" AS "created_at", JSON_AGG(JSON_BUILD_OBJECT('id', projects.id, 'title', projects.title, 'name', projects.name)) AS "project_data" FROM "serviceusers" INNER JOIN "policies" ON (("serviceusers"."id" = "policies"."principal_id") AND ("policies"."principal_type" = $1) AND ("policies"."resource_type" = $2)) INNER JOIN "projects" ON ("policies"."resource_id" = "projects"."id") WHERE ("serviceusers"."org_id" = $3) GROUP BY "serviceusers"."id" ORDER BY "serviceusers"."title" ASC LIMIT $4 OFFSET $5`, - wantParams: []any{ - "app/serviceuser", // principal_type - "app/project", // resource_type - "org1", // org_id - int64(10), // limit - int64(5), // offset - }, - wantErr: false, + name: "no options falls back to title asc and the default limit", + rql: &rql.Query{}, + wantSQL: orgServiceUserBaseSQL + ` ORDER BY "title" ASC, "id" ASC LIMIT $4`, + wantParams: baseParams(int64(50)), + wantPage: utils.Page{Limit: 50, Offset: 0}, }, { - name: "query with search", - orgID: "org1", - rql: &rql.Query{ - Search: "test", - Limit: 10, - Offset: 5, - }, - wantSQL: `SELECT "serviceusers"."id" AS "id", "serviceusers"."title" AS "title", "serviceusers"."org_id" AS "org_id", "serviceusers"."created_at" AS "created_at", JSON_AGG(JSON_BUILD_OBJECT('id', projects.id, 'title', projects.title, 'name', projects.name)) AS "project_data" FROM "serviceusers" INNER JOIN "policies" ON (("serviceusers"."id" = "policies"."principal_id") AND ("policies"."principal_type" = $1) AND ("policies"."resource_type" = $2)) INNER JOIN "projects" ON ("policies"."resource_id" = "projects"."id") WHERE (("serviceusers"."org_id" = $3) AND (CAST("serviceusers"."title" AS TEXT) ILIKE $4)) GROUP BY "serviceusers"."id" ORDER BY "serviceusers"."title" ASC LIMIT $5 OFFSET $6`, - wantParams: []any{ - "app/serviceuser", // principal_type - "app/project", // resource_type - "org1", // org_id - "%test%", // search pattern for title - int64(10), // limit - int64(5), // offset - }, - wantErr: false, + name: "nil query behaves like an empty one", + rql: nil, + wantSQL: orgServiceUserBaseSQL + ` ORDER BY "title" ASC, "id" ASC LIMIT $4`, + wantParams: baseParams(int64(50)), + wantPage: utils.Page{Limit: 50, Offset: 0}, }, { - name: "query with title filter", - orgID: "org1", - rql: &rql.Query{ - Filters: []rql.Filter{ - { - Name: "title", - Operator: "eq", - Value: "test-title", - }, - }, - Limit: 10, - Offset: 5, - }, - wantSQL: `SELECT "serviceusers"."id" AS "id", "serviceusers"."title" AS "title", "serviceusers"."org_id" AS "org_id", "serviceusers"."created_at" AS "created_at", JSON_AGG(JSON_BUILD_OBJECT('id', projects.id, 'title', projects.title, 'name', projects.name)) AS "project_data" FROM "serviceusers" INNER JOIN "policies" ON (("serviceusers"."id" = "policies"."principal_id") AND ("policies"."principal_type" = $1) AND ("policies"."resource_type" = $2)) INNER JOIN "projects" ON ("policies"."resource_id" = "projects"."id") WHERE (("serviceusers"."org_id" = $3) AND ("serviceusers"."title" = $4)) GROUP BY "serviceusers"."id" ORDER BY "serviceusers"."title" ASC LIMIT $5 OFFSET $6`, - wantParams: []any{ - "app/serviceuser", // principal_type - "app/project", // resource_type - "org1", // org_id - "test-title", // filter value - int64(10), // limit - int64(5), // offset - }, - wantErr: false, + name: "requested sort leads the order by", + rql: &rql.Query{Limit: 50, Sort: []rql.Sort{{Name: "created_at", Order: "desc"}}}, + wantSQL: orgServiceUserBaseSQL + ` ORDER BY "created_at" DESC, "id" ASC LIMIT $4`, + wantParams: baseParams(int64(50)), + wantPage: utils.Page{Limit: 50, Offset: 0}, }, { - name: "query with like filter", - orgID: "org1", - rql: &rql.Query{ - Filters: []rql.Filter{ - { - Name: "title", - Operator: "like", - Value: "api", - }, - }, - Limit: 10, - Offset: 5, - }, - wantSQL: `SELECT "serviceusers"."id" AS "id", "serviceusers"."title" AS "title", "serviceusers"."org_id" AS "org_id", "serviceusers"."created_at" AS "created_at", JSON_AGG(JSON_BUILD_OBJECT('id', projects.id, 'title', projects.title, 'name', projects.name)) AS "project_data" FROM "serviceusers" INNER JOIN "policies" ON (("serviceusers"."id" = "policies"."principal_id") AND ("policies"."principal_type" = $1) AND ("policies"."resource_type" = $2)) INNER JOIN "projects" ON ("policies"."resource_id" = "projects"."id") WHERE (("serviceusers"."org_id" = $3) AND ("serviceusers"."title" LIKE $4)) GROUP BY "serviceusers"."id" ORDER BY "serviceusers"."title" ASC LIMIT $5 OFFSET $6`, - wantParams: []any{ - "app/serviceuser", // principal_type - "app/project", // resource_type - "org1", // org_id - "%api%", // like pattern for title - int64(10), // limit - int64(5), // offset - }, - wantErr: false, + name: "sorting by title descending really is descending", + rql: &rql.Query{Limit: 10, Sort: []rql.Sort{{Name: "title", Order: "desc"}}}, + wantSQL: orgServiceUserBaseSQL + ` ORDER BY "title" DESC, "id" ASC LIMIT $4`, + wantParams: baseParams(int64(10)), + wantPage: utils.Page{Limit: 10, Offset: 0}, }, { - name: "query with created_at filter", - orgID: "org1", - rql: &rql.Query{ - Filters: []rql.Filter{ - { - Name: "created_at", - Operator: "gt", - Value: "2023-01-01T00:00:00Z", - }, - }, - Limit: 10, - Offset: 5, - }, - wantSQL: `SELECT "serviceusers"."id" AS "id", "serviceusers"."title" AS "title", "serviceusers"."org_id" AS "org_id", "serviceusers"."created_at" AS "created_at", JSON_AGG(JSON_BUILD_OBJECT('id', projects.id, 'title', projects.title, 'name', projects.name)) AS "project_data" FROM "serviceusers" INNER JOIN "policies" ON (("serviceusers"."id" = "policies"."principal_id") AND ("policies"."principal_type" = $1) AND ("policies"."resource_type" = $2)) INNER JOIN "projects" ON ("policies"."resource_id" = "projects"."id") WHERE (("serviceusers"."org_id" = $3) AND ("serviceusers"."created_at" > $4)) GROUP BY "serviceusers"."id" ORDER BY "serviceusers"."title" ASC LIMIT $5 OFFSET $6`, - wantParams: []any{ - "app/serviceuser", // principal_type - "app/project", // resource_type - "org1", // org_id - "2023-01-01T00:00:00Z", // created_at value - int64(10), // limit - int64(5), // offset - }, - wantErr: false, + name: "several sort terms keep their order and still end on id", + rql: &rql.Query{Limit: 10, Sort: []rql.Sort{{Name: "title", Order: "asc"}, {Name: "created_at", Order: "desc"}}}, + wantSQL: orgServiceUserBaseSQL + ` ORDER BY "title" ASC, "created_at" DESC, "id" ASC LIMIT $4`, + wantParams: baseParams(int64(10)), + wantPage: utils.Page{Limit: 10, Offset: 0}, }, { - name: "query with valid sort by title desc", - orgID: "org1", - rql: &rql.Query{ - Sort: []rql.Sort{ - { - Name: "title", - Order: "desc", - }, - }, - Limit: 10, - Offset: 5, - }, - wantSQL: `SELECT "serviceusers"."id" AS "id", "serviceusers"."title" AS "title", "serviceusers"."org_id" AS "org_id", "serviceusers"."created_at" AS "created_at", JSON_AGG(JSON_BUILD_OBJECT('id', projects.id, 'title', projects.title, 'name', projects.name)) AS "project_data" FROM "serviceusers" INNER JOIN "policies" ON (("serviceusers"."id" = "policies"."principal_id") AND ("policies"."principal_type" = $1) AND ("policies"."resource_type" = $2)) INNER JOIN "projects" ON ("policies"."resource_id" = "projects"."id") WHERE ("serviceusers"."org_id" = $3) GROUP BY "serviceusers"."id" ORDER BY "serviceusers"."title" ASC, "serviceusers"."title" DESC LIMIT $4 OFFSET $5`, - wantParams: []any{ - "app/serviceuser", // principal_type - "app/project", // resource_type - "org1", // org_id - int64(10), // limit - int64(5), // offset - }, - wantErr: false, + name: "search matches on title or project titles", + rql: &rql.Query{Search: "test", Limit: 10, Offset: 5}, + wantSQL: orgServiceUserBaseSQL + ` WHERE ((CAST("title" AS TEXT) ILIKE $4) OR (CAST("projects" AS TEXT) ILIKE $5)) ORDER BY "title" ASC, "id" ASC LIMIT $6 OFFSET $7`, + wantParams: baseParams("%test%", "%test%", int64(10), int64(5)), + wantPage: utils.Page{Limit: 10, Offset: 5}, }, { - name: "query with valid sort by created_at asc", - orgID: "org1", - rql: &rql.Query{ - Sort: []rql.Sort{ - { - Name: "created_at", - Order: "asc", - }, - }, - Limit: 10, - Offset: 5, - }, - wantSQL: `SELECT "serviceusers"."id" AS "id", "serviceusers"."title" AS "title", "serviceusers"."org_id" AS "org_id", "serviceusers"."created_at" AS "created_at", JSON_AGG(JSON_BUILD_OBJECT('id', projects.id, 'title', projects.title, 'name', projects.name)) AS "project_data" FROM "serviceusers" INNER JOIN "policies" ON (("serviceusers"."id" = "policies"."principal_id") AND ("policies"."principal_type" = $1) AND ("policies"."resource_type" = $2)) INNER JOIN "projects" ON ("policies"."resource_id" = "projects"."id") WHERE ("serviceusers"."org_id" = $3) GROUP BY "serviceusers"."id" ORDER BY "serviceusers"."title" ASC, "serviceusers"."created_at" ASC LIMIT $4 OFFSET $5`, - wantParams: []any{ - "app/serviceuser", // principal_type - "app/project", // resource_type - "org1", // org_id - int64(10), // limit - int64(5), // offset - }, - wantErr: false, + name: "equality filter on title", + rql: &rql.Query{Limit: 10, Filters: []rql.Filter{{Name: "title", Operator: "eq", Value: "svc"}}}, + wantSQL: orgServiceUserBaseSQL + ` WHERE ("title" = $4) ORDER BY "title" ASC, "id" ASC LIMIT $5`, + wantParams: baseParams("svc", int64(10)), + wantPage: utils.Page{Limit: 10, Offset: 0}, }, { - name: "query with invalid sort field", - orgID: "org1", - rql: &rql.Query{ - Sort: []rql.Sort{ - { - Name: "invalid_field", - Order: "desc", - }, - }, - Limit: 10, - Offset: 5, - }, - wantSQL: "", - wantParams: nil, - wantErr: true, + name: "empty filter on title", + rql: &rql.Query{Limit: 10, Filters: []rql.Filter{{Name: "title", Operator: "empty", Value: ""}}}, + wantSQL: orgServiceUserBaseSQL + ` WHERE (("title" IS NULL) OR ("title" = $4)) ORDER BY "title" ASC, "id" ASC LIMIT $5`, + wantParams: baseParams("", int64(10)), + wantPage: utils.Page{Limit: 10, Offset: 0}, }, { - name: "query with empty check filter", - orgID: "org1", - rql: &rql.Query{ - Filters: []rql.Filter{ - { - Name: "title", - Operator: "empty", - }, - }, - Limit: 10, - Offset: 5, - }, - wantSQL: `SELECT "serviceusers"."id" AS "id", "serviceusers"."title" AS "title", "serviceusers"."org_id" AS "org_id", "serviceusers"."created_at" AS "created_at", JSON_AGG(JSON_BUILD_OBJECT('id', projects.id, 'title', projects.title, 'name', projects.name)) AS "project_data" FROM "serviceusers" INNER JOIN "policies" ON (("serviceusers"."id" = "policies"."principal_id") AND ("policies"."principal_type" = $1) AND ("policies"."resource_type" = $2)) INNER JOIN "projects" ON ("policies"."resource_id" = "projects"."id") WHERE (("serviceusers"."org_id" = $3) AND (("serviceusers"."title" IS NULL) OR ("serviceusers"."title" = $4))) GROUP BY "serviceusers"."id" ORDER BY "serviceusers"."title" ASC LIMIT $5 OFFSET $6`, - wantParams: []any{ - "app/serviceuser", // principal_type - "app/project", // resource_type - "org1", // org_id - "", // empty string for comparison - int64(10), // limit - int64(5), // offset - }, - wantErr: false, + name: "datetime filter on created_at", + rql: &rql.Query{Limit: 10, Filters: []rql.Filter{{Name: "created_at", Operator: "gt", Value: "2026-01-01"}}}, + wantSQL: orgServiceUserBaseSQL + ` WHERE ("created_at" > $4) ORDER BY "title" ASC, "id" ASC LIMIT $5`, + wantParams: baseParams("2026-01-01", int64(10)), + wantPage: utils.Page{Limit: 10, Offset: 0}, }, { - name: "query with notempty check filter", - orgID: "org1", - rql: &rql.Query{ - Filters: []rql.Filter{ - { - Name: "title", - Operator: "notempty", - }, - }, - Limit: 10, - Offset: 5, - }, - wantSQL: `SELECT "serviceusers"."id" AS "id", "serviceusers"."title" AS "title", "serviceusers"."org_id" AS "org_id", "serviceusers"."created_at" AS "created_at", JSON_AGG(JSON_BUILD_OBJECT('id', projects.id, 'title', projects.title, 'name', projects.name)) AS "project_data" FROM "serviceusers" INNER JOIN "policies" ON (("serviceusers"."id" = "policies"."principal_id") AND ("policies"."principal_type" = $1) AND ("policies"."resource_type" = $2)) INNER JOIN "projects" ON ("policies"."resource_id" = "projects"."id") WHERE (("serviceusers"."org_id" = $3) AND (("serviceusers"."title" IS NOT NULL) AND ("serviceusers"."title" != $4))) GROUP BY "serviceusers"."id" ORDER BY "serviceusers"."title" ASC LIMIT $5 OFFSET $6`, - wantParams: []any{ - "app/serviceuser", // principal_type - "app/project", // resource_type - "org1", // org_id - "", // empty string for comparison - int64(10), // limit - int64(5), // offset - }, - wantErr: false, + name: "filter on projects, which the admin ui offers", + rql: &rql.Query{Limit: 10, Filters: []rql.Filter{{Name: "projects", Operator: "ilike", Value: "%Live%"}}}, + wantSQL: orgServiceUserBaseSQL + ` WHERE ("projects" ILIKE $4) ORDER BY "title" ASC, "id" ASC LIMIT $5`, + wantParams: baseParams("%Live%", int64(10)), + wantPage: utils.Page{Limit: 10, Offset: 0}, }, { - name: "query with notlike filter", - orgID: "org1", - rql: &rql.Query{ - Filters: []rql.Filter{ - { - Name: "title", - Operator: "notlike", - Value: "test", - }, - }, - Limit: 10, - Offset: 5, - }, - wantSQL: `SELECT "serviceusers"."id" AS "id", "serviceusers"."title" AS "title", "serviceusers"."org_id" AS "org_id", "serviceusers"."created_at" AS "created_at", JSON_AGG(JSON_BUILD_OBJECT('id', projects.id, 'title', projects.title, 'name', projects.name)) AS "project_data" FROM "serviceusers" INNER JOIN "policies" ON (("serviceusers"."id" = "policies"."principal_id") AND ("policies"."principal_type" = $1) AND ("policies"."resource_type" = $2)) INNER JOIN "projects" ON ("policies"."resource_id" = "projects"."id") WHERE (("serviceusers"."org_id" = $3) AND ("serviceusers"."title" NOT LIKE $4)) GROUP BY "serviceusers"."id" ORDER BY "serviceusers"."title" ASC LIMIT $5 OFFSET $6`, - wantParams: []any{ - "app/serviceuser", // principal_type - "app/project", // resource_type - "org1", // org_id - "%test%", // notlike pattern for title - int64(10), // limit - int64(5), // offset - }, - wantErr: false, + name: "group_by is rejected rather than producing broken sql", + rql: &rql.Query{Limit: 10, GroupBy: []string{"title"}}, + wantErr: "bad input: group_by is not supported", }, { - name: "query with unknown filter field (should be ignored)", - orgID: "org1", - rql: &rql.Query{ - Filters: []rql.Filter{ - { - Name: "unknown_field", - Operator: "eq", - Value: "value", - }, - }, - Limit: 10, - Offset: 5, - }, - wantSQL: `SELECT "serviceusers"."id" AS "id", "serviceusers"."title" AS "title", "serviceusers"."org_id" AS "org_id", "serviceusers"."created_at" AS "created_at", JSON_AGG(JSON_BUILD_OBJECT('id', projects.id, 'title', projects.title, 'name', projects.name)) AS "project_data" FROM "serviceusers" INNER JOIN "policies" ON (("serviceusers"."id" = "policies"."principal_id") AND ("policies"."principal_type" = $1) AND ("policies"."resource_type" = $2)) INNER JOIN "projects" ON ("policies"."resource_id" = "projects"."id") WHERE ("serviceusers"."org_id" = $3) GROUP BY "serviceusers"."id" ORDER BY "serviceusers"."title" ASC LIMIT $4 OFFSET $5`, - wantParams: []any{ - "app/serviceuser", // principal_type - "app/project", // resource_type - "org1", // org_id - int64(10), // limit - int64(5), // offset - }, - wantErr: false, + name: "filtering on a column the query does not select is rejected", + rql: &rql.Query{Limit: 10, Filters: []rql.Filter{{Name: "state", Operator: "eq", Value: "enabled"}}}, + wantErr: "bad input: state is not supported in filters", }, { - name: "complex query with multiple conditions", - orgID: "org1", - rql: &rql.Query{ - Search: "test", - Filters: []rql.Filter{ - { - Name: "title", - Operator: "like", - Value: "api", - }, - { - Name: "created_at", - Operator: "gt", - Value: "2023-01-01T00:00:00Z", - }, - }, - Sort: []rql.Sort{ - { - Name: "created_at", - Order: "desc", - }, - }, - Limit: 20, - Offset: 5, - }, - wantSQL: `SELECT "serviceusers"."id" AS "id", "serviceusers"."title" AS "title", "serviceusers"."org_id" AS "org_id", "serviceusers"."created_at" AS "created_at", JSON_AGG(JSON_BUILD_OBJECT('id', projects.id, 'title', projects.title, 'name', projects.name)) AS "project_data" FROM "serviceusers" INNER JOIN "policies" ON (("serviceusers"."id" = "policies"."principal_id") AND ("policies"."principal_type" = $1) AND ("policies"."resource_type" = $2)) INNER JOIN "projects" ON ("policies"."resource_id" = "projects"."id") WHERE (("serviceusers"."org_id" = $3) AND ("serviceusers"."title" LIKE $4) AND ("serviceusers"."created_at" > $5) AND (CAST("serviceusers"."title" AS TEXT) ILIKE $6)) GROUP BY "serviceusers"."id" ORDER BY "serviceusers"."title" ASC, "serviceusers"."created_at" DESC LIMIT $7 OFFSET $8`, - wantParams: []any{ - "app/serviceuser", // principal_type - "app/project", // resource_type - "org1", // org_id - "%api%", // like pattern for title - "2023-01-01T00:00:00Z", // created_at value - "%test%", // search pattern for title - int64(20), // limit - int64(5), // offset - }, - wantErr: false, + name: "sorting on a column the query does not select is rejected", + rql: &rql.Query{Limit: 10, Sort: []rql.Sort{{Name: "state", Order: "asc"}}}, + wantErr: "bad input: state is not supported in sort", }, { - name: "query with no limit or offset", - orgID: "org1", - rql: &rql.Query{ - Search: "test", - }, - wantSQL: `SELECT "serviceusers"."id" AS "id", "serviceusers"."title" AS "title", "serviceusers"."org_id" AS "org_id", "serviceusers"."created_at" AS "created_at", JSON_AGG(JSON_BUILD_OBJECT('id', projects.id, 'title', projects.title, 'name', projects.name)) AS "project_data" FROM "serviceusers" INNER JOIN "policies" ON (("serviceusers"."id" = "policies"."principal_id") AND ("policies"."principal_type" = $1) AND ("policies"."resource_type" = $2)) INNER JOIN "projects" ON ("policies"."resource_id" = "projects"."id") WHERE (("serviceusers"."org_id" = $3) AND (CAST("serviceusers"."title" AS TEXT) ILIKE $4)) GROUP BY "serviceusers"."id" ORDER BY "serviceusers"."title" ASC`, - wantParams: []any{ - "app/serviceuser", // principal_type - "app/project", // resource_type - "org1", // org_id - "%test%", // search pattern for title - }, - wantErr: false, - }, - { - name: "query with nil rql", - orgID: "org1", - rql: nil, - wantSQL: `SELECT "serviceusers"."id" AS "id", "serviceusers"."title" AS "title", "serviceusers"."org_id" AS "org_id", "serviceusers"."created_at" AS "created_at", JSON_AGG(JSON_BUILD_OBJECT('id', projects.id, 'title', projects.title, 'name', projects.name)) AS "project_data" FROM "serviceusers" INNER JOIN "policies" ON (("serviceusers"."id" = "policies"."principal_id") AND ("policies"."principal_type" = $1) AND ("policies"."resource_type" = $2)) INNER JOIN "projects" ON ("policies"."resource_id" = "projects"."id") WHERE ("serviceusers"."org_id" = $3) GROUP BY "serviceusers"."id" ORDER BY "serviceusers"."title" ASC`, - wantParams: []any{ - "app/serviceuser", // principal_type - "app/project", // resource_type - "org1", // org_id - }, - wantErr: false, + name: "a limit of zero falls back to the default rather than returning everything", + rql: &rql.Query{Limit: 0, Offset: 20}, + wantSQL: orgServiceUserBaseSQL + ` ORDER BY "title" ASC, "id" ASC LIMIT $4 OFFSET $5`, + wantParams: baseParams(int64(50), int64(20)), + wantPage: utils.Page{Limit: 50, Offset: 20}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &OrgServiceUserRepository{} - gotSQL, gotParams, err := r.prepareDataQuery(tt.orgID, tt.rql) + gotSQL, gotParams, gotPage, err := r.prepareDataQuery("org1", tt.rql) - if tt.wantErr { - assert.Error(t, err) + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) return } assert.NoError(t, err) assert.Equal(t, tt.wantSQL, gotSQL) assert.Equal(t, tt.wantParams, gotParams) + assert.Equal(t, tt.wantPage, gotPage) + }) + } +} + +func TestServiceUserRow_transformToAggregatedServiceUser(t *testing.T) { + tests := []struct { + name string + row ServiceUserRow + wantProjects []svc.Project + }{ + { + name: "service user with no project policy", + row: ServiceUserRow{ + ID: "su1", + Title: "no project service user", + ProjectData: "[]", + }, + wantProjects: []svc.Project{}, + }, + { + name: "service user with projects", + row: ServiceUserRow{ + ID: "su2", + Title: "service user with projects", + ProjectData: `[{"id":"p1","title":"Project One","name":"project-one"}]`, + }, + wantProjects: []svc.Project{ + {ID: "p1", Title: "Project One", Name: "project-one"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.row.transformToAggregatedServiceUser("org1") + + assert.Equal(t, tt.row.ID, got.ID) + assert.Equal(t, tt.row.Title, got.Title) + assert.Equal(t, "org1", got.OrgID) + assert.Equal(t, tt.wantProjects, got.Projects) }) } }