Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f29e774
fix(store): list org service users that have no project policy
rohilsurana Sep 17, 2026
39dbefb
test(store): cover service user rows with an empty project list
rohilsurana Sep 17, 2026
bc737e8
fix(store): honour the requested sort when searching org service users
rohilsurana Sep 17, 2026
ad298ad
fix(rql): use name and type tags so org service user fields resolve
rohilsurana Sep 17, 2026
33fe1ef
refactor(store): use shared rql helpers for org service user search
rohilsurana Sep 17, 2026
499456a
fix(store): list a project once when a service user has two roles on it
rohilsurana Sep 17, 2026
3ee8db1
feat(store): let org service users be filtered and searched by project
rohilsurana Sep 17, 2026
8c8fb0d
fix(api): validate the rql query before searching org service users
rohilsurana Sep 17, 2026
c314b6b
fix(store): reject group_by instead of building a query postgres cann…
rohilsurana Sep 17, 2026
57ebd28
refactor(store): drop the updated_at field the search cannot serve
rohilsurana Sep 17, 2026
20116c5
test(store): cover org service user search against a real postgres
rohilsurana Sep 17, 2026
0bffb47
ci: raise the per package test timeout so the postgres suites fit
rohilsurana Sep 17, 2026
8b6b863
test: cover the search handler error paths and pagination metadata
rohilsurana Sep 18, 2026
5639efb
fix(api): resolve the organization before searching its service users
rohilsurana Sep 18, 2026
0e47579
fix(store): skip soft-deleted rows in the org service user search
rohilsurana Sep 18, 2026
e29238e
refactor: drop code comments from the org service user search
rohilsurana Sep 21, 2026
3e5b6f3
refactor(store): drop the subquery alias constant and keep one naming…
rohilsurana Sep 21, 2026
7a8c5f4
docs(store): name which title the default sort uses
rohilsurana Sep 21, 2026
3723ba0
refactor(store): select only the columns the search reads back
rohilsurana Sep 21, 2026
bee0719
docs(store): trim the default sort comment
rohilsurana Sep 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 5 additions & 6 deletions core/aggregates/orgserviceuser/orgserviceuser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
20 changes: 17 additions & 3 deletions internal/api/v1beta1connect/organization_serviceuser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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))
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

var orgServiceUsers []*frontierv1beta1.SearchOrganizationServiceUsersResponse_OrganizationServiceUser
Expand Down
153 changes: 153 additions & 0 deletions internal/api/v1beta1connect/organization_serviceuser_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading
Loading