From e633ef87b39b841566212b3b7e4cbba954a3f353 Mon Sep 17 00:00:00 2001 From: Marco Ardizzone Date: Fri, 1 Aug 2025 12:41:45 +0200 Subject: [PATCH] Add convert method for ListUsersByMetadata request --- internal/api/grpc/filter/v2/converter.go | 11 + internal/api/grpc/filter/v2/converter_test.go | 164 +++++++++++ .../api/grpc/user/v2beta/convert/metadata.go | 115 ++++++++ .../grpc/user/v2beta/convert/metadata_test.go | 273 ++++++++++++++++++ internal/query/user_metadata.go | 16 + 5 files changed, 579 insertions(+) create mode 100644 internal/api/grpc/filter/v2/converter_test.go create mode 100644 internal/api/grpc/user/v2beta/convert/metadata.go create mode 100644 internal/api/grpc/user/v2beta/convert/metadata_test.go diff --git a/internal/api/grpc/filter/v2/converter.go b/internal/api/grpc/filter/v2/converter.go index 98e8bdd4f8..533f660758 100644 --- a/internal/api/grpc/filter/v2/converter.go +++ b/internal/api/grpc/filter/v2/converter.go @@ -49,6 +49,17 @@ func TimestampMethodPbToQuery(method filter.TimestampFilterMethod) query.Timesta } } +func ByteMethodPbToQuery(method filter.ByteFilterMethod) query.BytesComparison { + switch method { + case filter.ByteFilterMethod_BYTE_FILTER_METHOD_EQUALS: + return query.BytesEquals + case filter.ByteFilterMethod_BYTE_FILTER_METHOD_NOT_EQUALS: + return query.BytesNotEquals + default: + return -1 + } +} + func PaginationPbToQuery(defaults systemdefaults.SystemDefaults, query *filter.PaginationRequest) (offset, limit uint64, asc bool, err error) { limit = defaults.DefaultQueryLimit if query == nil { diff --git a/internal/api/grpc/filter/v2/converter_test.go b/internal/api/grpc/filter/v2/converter_test.go new file mode 100644 index 0000000000..42bebc1ed5 --- /dev/null +++ b/internal/api/grpc/filter/v2/converter_test.go @@ -0,0 +1,164 @@ +package filter + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/config/systemdefaults" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/pkg/grpc/filter/v2" +) + +func TestTextMethodPbToQuery(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + input filter.TextFilterMethod + output query.TextComparison + }{ + {"Equals", filter.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS, query.TextEquals}, + {"EqualsIgnoreCase", filter.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS_IGNORE_CASE, query.TextEqualsIgnoreCase}, + {"StartsWith", filter.TextFilterMethod_TEXT_FILTER_METHOD_STARTS_WITH, query.TextStartsWith}, + {"StartsWithIgnoreCase", filter.TextFilterMethod_TEXT_FILTER_METHOD_STARTS_WITH_IGNORE_CASE, query.TextStartsWithIgnoreCase}, + {"Contains", filter.TextFilterMethod_TEXT_FILTER_METHOD_CONTAINS, query.TextContains}, + {"ContainsIgnoreCase", filter.TextFilterMethod_TEXT_FILTER_METHOD_CONTAINS_IGNORE_CASE, query.TextContainsIgnoreCase}, + {"EndsWith", filter.TextFilterMethod_TEXT_FILTER_METHOD_ENDS_WITH, query.TextEndsWith}, + {"EndsWithIgnoreCase", filter.TextFilterMethod_TEXT_FILTER_METHOD_ENDS_WITH_IGNORE_CASE, query.TextEndsWithIgnoreCase}, + {"Unknown", filter.TextFilterMethod(999), -1}, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := TextMethodPbToQuery(tc.input) + + assert.Equal(t, tc.output, got) + }) + } +} + +func TestTimestampMethodPbToQuery(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + input filter.TimestampFilterMethod + output query.TimestampComparison + }{ + {"Equals", filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_EQUALS, query.TimestampEquals}, + {"Before", filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_BEFORE, query.TimestampLess}, + {"After", filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_AFTER, query.TimestampGreater}, + {"BeforeOrEquals", filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_BEFORE_OR_EQUALS, query.TimestampLessOrEquals}, + {"AfterOrEquals", filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_AFTER_OR_EQUALS, query.TimestampGreaterOrEquals}, + {"Unknown", filter.TimestampFilterMethod(999), -1}, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := TimestampMethodPbToQuery(tc.input) + + assert.Equal(t, tc.output, got) + }) + } +} + +func TestByteMethodPbToQuery(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + input filter.ByteFilterMethod + output query.BytesComparison + }{ + {"Equals", filter.ByteFilterMethod_BYTE_FILTER_METHOD_EQUALS, query.BytesEquals}, + {"NotEquals", filter.ByteFilterMethod_BYTE_FILTER_METHOD_NOT_EQUALS, query.BytesNotEquals}, + {"Unknown", filter.ByteFilterMethod(999), -1}, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := ByteMethodPbToQuery(tc.input) + + assert.Equal(t, tc.output, got) + }) + } +} + +func TestPaginationPbToQuery(t *testing.T) { + t.Parallel() + + defaults := systemdefaults.SystemDefaults{ + DefaultQueryLimit: 10, + MaxQueryLimit: 100, + } + tt := []struct { + name string + query *filter.PaginationRequest + wantOff uint64 + wantLim uint64 + wantAsc bool + wantErr bool + }{ + { + name: "nil query", + query: nil, + wantOff: 0, + wantLim: 10, + wantAsc: false, + wantErr: false, + }, + { + name: "limit not set", + query: &filter.PaginationRequest{Offset: 5, Limit: 0, Asc: true}, + wantOff: 5, + wantLim: 10, + wantAsc: true, + wantErr: false, + }, + { + name: "limit set below max", + query: &filter.PaginationRequest{Offset: 2, Limit: 50, Asc: false}, + wantOff: 2, + wantLim: 50, + wantAsc: false, + wantErr: false, + }, + { + name: "limit exceeds max", + query: &filter.PaginationRequest{Offset: 1, Limit: 101, Asc: true}, + wantOff: 0, + wantLim: 0, + wantAsc: false, + wantErr: true, + }, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + off, lim, asc, err := PaginationPbToQuery(defaults, tc.query) + + require.Equal(t, tc.wantErr, err != nil) + + assert.Equal(t, tc.wantOff, off) + assert.Equal(t, tc.wantLim, lim) + assert.Equal(t, tc.wantAsc, asc) + }) + } +} + +func TestQueryToPaginationPb(t *testing.T) { + t.Parallel() + + req := query.SearchRequest{Limit: 20} + resp := query.SearchResponse{Count: 123} + got := QueryToPaginationPb(req, resp) + + assert.Equal(t, req.Limit, got.AppliedLimit) + assert.Equal(t, resp.Count, got.TotalResult) +} diff --git a/internal/api/grpc/user/v2beta/convert/metadata.go b/internal/api/grpc/user/v2beta/convert/metadata.go new file mode 100644 index 0000000000..afb3bab949 --- /dev/null +++ b/internal/api/grpc/user/v2beta/convert/metadata.go @@ -0,0 +1,115 @@ +package convert + +import ( + "github.com/zitadel/zitadel/internal/api/grpc/filter/v2" + "github.com/zitadel/zitadel/internal/config/systemdefaults" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + metadata "github.com/zitadel/zitadel/pkg/grpc/metadata/v2" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" +) + +func ListUsersByMetadataRequestToModel(req *user.ListUsersByMetadataRequest, sysDefaults systemdefaults.SystemDefaults) (*query.UserSearchQueries, error) { + offset, limit, asc, err := filter.PaginationPbToQuery(sysDefaults, req.GetPagination()) + if err != nil { + return nil, err + } + + queries, err := usersByMetadataQueries(req.GetFilters(), 0) + if err != nil { + return nil, err + } + + return &query.UserSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: usersByMetadataSorting(req.GetSortingColumn()), + }, + Queries: queries, + }, nil + +} + +func usersByMetadataSorting(sortingColumn user.UsersByMetadataSorting) query.Column { + switch sortingColumn { + case user.UsersByMetadataSorting_USERS_BY_METADATA_SORT_BY_DISPLAY_NAME: + return query.HumanDisplayNameCol + case user.UsersByMetadataSorting_USERS_BY_METADATA_SORT_BY_EMAIL: + return query.HumanEmailCol + case user.UsersByMetadataSorting_USERS_BY_METADATA_SORT_BY_FIRST_NAME: + return query.HumanFirstNameCol + case user.UsersByMetadataSorting_USERS_BY_METADATA_SORT_BY_LAST_NAME: + return query.HumanLastNameCol + case user.UsersByMetadataSorting_USERS_BY_METADATA_SORT_BY_METADATA_KEY: + return query.UserMetadataKeyCol + case user.UsersByMetadataSorting_USERS_BY_METADATA_SORT_BY_METADATA_VALUE: + return query.UserMetadataValueCol + case user.UsersByMetadataSorting_USERS_BY_METADATA_SORT_BY_NICK_NAME: + return query.HumanNickNameCol + case user.UsersByMetadataSorting_USERS_BY_METADATA_SORT_BY_STATE: + return query.UserStateCol + case user.UsersByMetadataSorting_USERS_BY_METADATA_SORT_BY_TYPE: + return query.UserTypeCol + case user.UsersByMetadataSorting_USERS_BY_METADATA_SORT_BY_USER_NAME: + return query.UserUsernameCol + case user.UsersByMetadataSorting_USERS_BY_METADATA_SORT_BY_USER_ID, user.UsersByMetadataSorting_USERS_BY_METADATA_SORT_BY_UNSPECIFIED: + fallthrough + default: + return query.UserIDCol + } +} + +func usersByMetadataQueries(queries []*metadata.UserByMetadataSearchFilter, nesting uint) ([]query.SearchQuery, error) { + toReturn := make([]query.SearchQuery, len(queries)) + + for i, query := range queries { + res, err := userByMetadataQuery(query, nesting) + if err != nil { + return nil, err + } + toReturn[i] = res + } + + return toReturn, nil +} + +func userByMetadataQuery(q *metadata.UserByMetadataSearchFilter, nesting uint) (query.SearchQuery, error) { + if nesting > 20 { + return nil, zerrors.ThrowInvalidArgument(nil, "CONV-Jhaltm", "Errors.Query.TooManyNestingLevels") + } + + switch t := q.GetFilter().(type) { + + case *metadata.UserByMetadataSearchFilter_KeyFilter: + return query.NewUserMetadataKeySearchQuery(t.KeyFilter.GetKey(), filter.TextMethodPbToQuery(t.KeyFilter.GetMethod())) + + case *metadata.UserByMetadataSearchFilter_ValueFilter: + return query.NewUserMetadataValueSearchQuery(t.ValueFilter.GetValue(), filter.ByteMethodPbToQuery(t.ValueFilter.GetMethod())) + + case *metadata.UserByMetadataSearchFilter_AndFilter: + mappedQueries, err := usersByMetadataQueries(t.AndFilter.GetQueries(), nesting+1) + if err != nil { + return nil, err + } + return query.NewUserMetadataAndSearchQuery(mappedQueries) + + case *metadata.UserByMetadataSearchFilter_OrFilter: + mappedQueries, err := usersByMetadataQueries(t.OrFilter.GetQueries(), nesting+1) + if err != nil { + return nil, err + } + return query.NewUserMetadataOrSearchQuery(mappedQueries) + + case *metadata.UserByMetadataSearchFilter_NotFilter: + mappedQuery, err := userByMetadataQuery(t.NotFilter.GetQuery(), nesting+1) + if err != nil { + return nil, err + } + return query.NewUserMetadataNotSearchQuery(mappedQuery) + + default: + return nil, zerrors.ThrowInvalidArgument(nil, "CONV-GG1Jnh", "List.Query.Invalid") + } +} diff --git a/internal/api/grpc/user/v2beta/convert/metadata_test.go b/internal/api/grpc/user/v2beta/convert/metadata_test.go new file mode 100644 index 0000000000..d10fe65664 --- /dev/null +++ b/internal/api/grpc/user/v2beta/convert/metadata_test.go @@ -0,0 +1,273 @@ +package convert + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/config/systemdefaults" + "github.com/zitadel/zitadel/internal/query" + filter "github.com/zitadel/zitadel/pkg/grpc/filter/v2" + metadata "github.com/zitadel/zitadel/pkg/grpc/metadata/v2" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" +) + +func Test_usersByMetadataSorting(t *testing.T) { + t.Parallel() + tt := []struct { + name string + input user.UsersByMetadataSorting + expected query.Column + }{ + {"DisplayName", user.UsersByMetadataSorting_USERS_BY_METADATA_SORT_BY_DISPLAY_NAME, query.HumanDisplayNameCol}, + {"Email", user.UsersByMetadataSorting_USERS_BY_METADATA_SORT_BY_EMAIL, query.HumanEmailCol}, + {"FirstName", user.UsersByMetadataSorting_USERS_BY_METADATA_SORT_BY_FIRST_NAME, query.HumanFirstNameCol}, + {"LastName", user.UsersByMetadataSorting_USERS_BY_METADATA_SORT_BY_LAST_NAME, query.HumanLastNameCol}, + {"MetadataKey", user.UsersByMetadataSorting_USERS_BY_METADATA_SORT_BY_METADATA_KEY, query.UserMetadataKeyCol}, + {"MetadataValue", user.UsersByMetadataSorting_USERS_BY_METADATA_SORT_BY_METADATA_VALUE, query.UserMetadataValueCol}, + {"NickName", user.UsersByMetadataSorting_USERS_BY_METADATA_SORT_BY_NICK_NAME, query.HumanNickNameCol}, + {"State", user.UsersByMetadataSorting_USERS_BY_METADATA_SORT_BY_STATE, query.UserStateCol}, + {"Type", user.UsersByMetadataSorting_USERS_BY_METADATA_SORT_BY_TYPE, query.UserTypeCol}, + {"UserName", user.UsersByMetadataSorting_USERS_BY_METADATA_SORT_BY_USER_NAME, query.UserUsernameCol}, + {"UserID", user.UsersByMetadataSorting_USERS_BY_METADATA_SORT_BY_USER_ID, query.UserIDCol}, + {"Unspecified", user.UsersByMetadataSorting_USERS_BY_METADATA_SORT_BY_UNSPECIFIED, query.UserIDCol}, + {"Default", user.UsersByMetadataSorting(999), query.UserIDCol}, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := usersByMetadataSorting(tc.input) + assert.Equal(t, tc.expected, got) + }) + } +} + +func Test_userByMetadataQuery(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + input *metadata.UserByMetadataSearchFilter + wantErr bool + }{ + { + name: "KeyFilter", + input: &metadata.UserByMetadataSearchFilter{ + Filter: &metadata.UserByMetadataSearchFilter_KeyFilter{ + KeyFilter: &metadata.MetadataKeyFilter{ + Key: "foo", + Method: filter.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS, + }, + }, + }, + wantErr: false, + }, + { + name: "ValueFilter", + input: &metadata.UserByMetadataSearchFilter{ + Filter: &metadata.UserByMetadataSearchFilter_ValueFilter{ + ValueFilter: &metadata.MetadataValueFilter{ + Value: []byte("bar"), + Method: filter.ByteFilterMethod_BYTE_FILTER_METHOD_EQUALS, + }, + }, + }, + wantErr: false, + }, + { + name: "AndFilter", + input: &metadata.UserByMetadataSearchFilter{ + Filter: &metadata.UserByMetadataSearchFilter_AndFilter{ + AndFilter: &metadata.MetadataAndFilter{ + Queries: []*metadata.UserByMetadataSearchFilter{ + { + Filter: &metadata.UserByMetadataSearchFilter_KeyFilter{ + KeyFilter: &metadata.MetadataKeyFilter{ + Key: "foo", + Method: filter.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS, + }, + }, + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "OrFilter", + input: &metadata.UserByMetadataSearchFilter{ + Filter: &metadata.UserByMetadataSearchFilter_OrFilter{ + OrFilter: &metadata.MetadataOrFilter{ + Queries: []*metadata.UserByMetadataSearchFilter{ + { + Filter: &metadata.UserByMetadataSearchFilter_ValueFilter{ + ValueFilter: &metadata.MetadataValueFilter{ + Value: []byte("baz"), + Method: filter.ByteFilterMethod_BYTE_FILTER_METHOD_EQUALS, + }, + }, + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "NotFilter", + input: &metadata.UserByMetadataSearchFilter{ + Filter: &metadata.UserByMetadataSearchFilter_NotFilter{ + NotFilter: &metadata.MetadataNotFilter{ + Query: &metadata.UserByMetadataSearchFilter{ + Filter: &metadata.UserByMetadataSearchFilter_KeyFilter{ + KeyFilter: &metadata.MetadataKeyFilter{ + Key: "not", + Method: filter.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS, + }, + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "TooManyNestingLevels", + input: &metadata.UserByMetadataSearchFilter{ + Filter: &metadata.UserByMetadataSearchFilter_AndFilter{ + AndFilter: &metadata.MetadataAndFilter{ + Queries: []*metadata.UserByMetadataSearchFilter{}, + }, + }, + }, + wantErr: true, + }, + { + name: "InvalidFilter", + input: &metadata.UserByMetadataSearchFilter{}, + wantErr: true, + }, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + nesting := uint(0) + if tc.name == "TooManyNestingLevels" { + nesting = 21 + } + got, err := userByMetadataQuery(tc.input, nesting) + if tc.wantErr { + require.Error(t, err) + assert.Nil(t, got) + } else { + require.NoError(t, err) + assert.NotNil(t, got) + } + }) + } +} + +func Test_usersByMetadataQueries(t *testing.T) { + t.Parallel() + + t.Run("empty", func(t *testing.T) { + t.Parallel() + queries, err := usersByMetadataQueries([]*metadata.UserByMetadataSearchFilter{}, 0) + require.NoError(t, err) + assert.Len(t, queries, 0) + }) + + t.Run("single valid", func(t *testing.T) { + t.Parallel() + queries, err := usersByMetadataQueries([]*metadata.UserByMetadataSearchFilter{ + { + Filter: &metadata.UserByMetadataSearchFilter_KeyFilter{ + KeyFilter: &metadata.MetadataKeyFilter{ + Key: "foo", + Method: filter.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS, + }, + }, + }, + }, 0) + require.NoError(t, err) + assert.Len(t, queries, 1) + }) + + t.Run("invalid filter", func(t *testing.T) { + t.Parallel() + queries, err := usersByMetadataQueries([]*metadata.UserByMetadataSearchFilter{ + {}, + }, 0) + require.Error(t, err) + assert.Nil(t, queries) + }) +} + +func Test_ListUsersByMetadataRequestToModel(t *testing.T) { + t.Parallel() + + sysDefaults := systemdefaults.SystemDefaults{ + MaxQueryLimit: 100, + } + + t.Run("valid", func(t *testing.T) { + t.Parallel() + req := &user.ListUsersByMetadataRequest{ + Pagination: &filter.PaginationRequest{ + Limit: 10, + Offset: 5, + Asc: true, + }, + SortingColumn: user.UsersByMetadataSorting_USERS_BY_METADATA_SORT_BY_EMAIL, + Filters: []*metadata.UserByMetadataSearchFilter{ + { + Filter: &metadata.UserByMetadataSearchFilter_KeyFilter{ + KeyFilter: &metadata.MetadataKeyFilter{ + Key: "foo", + Method: filter.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS, + }, + }, + }, + }, + } + model, err := ListUsersByMetadataRequestToModel(req, sysDefaults) + require.NoError(t, err) + assert.NotNil(t, model) + assert.EqualValues(t, 5, model.Offset) + assert.EqualValues(t, 10, model.Limit) + assert.True(t, model.Asc) + assert.Equal(t, query.HumanEmailCol, model.SortingColumn) + assert.Len(t, model.Queries, 1) + }) + + t.Run("invalid pagination", func(t *testing.T) { + t.Parallel() + req := &user.ListUsersByMetadataRequest{ + Pagination: &filter.PaginationRequest{ + + Limit: 200, + }, + Filters: []*metadata.UserByMetadataSearchFilter{}, + } + model, err := ListUsersByMetadataRequestToModel(req, sysDefaults) + require.Error(t, err) + assert.Nil(t, model) + }) + + t.Run("invalid filter", func(t *testing.T) { + t.Parallel() + req := &user.ListUsersByMetadataRequest{ + Pagination: &filter.PaginationRequest{ + Limit: 1, + }, + Filters: []*metadata.UserByMetadataSearchFilter{ + {}, + }, + } + model, err := ListUsersByMetadataRequestToModel(req, sysDefaults) + require.Error(t, err) + assert.Nil(t, model) + }) +} diff --git a/internal/query/user_metadata.go b/internal/query/user_metadata.go index 385c176e0a..992269a264 100644 --- a/internal/query/user_metadata.go +++ b/internal/query/user_metadata.go @@ -233,6 +233,22 @@ func NewUserMetadataKeySearchQuery(value string, comparison TextComparison) (Sea return NewTextQuery(UserMetadataKeyCol, value, comparison) } +func NewUserMetadataValueSearchQuery(value []byte, comparison BytesComparison) (SearchQuery, error) { + return NewBytesQuery(UserMetadataValueCol, value, comparison) +} + +func NewUserMetadataAndSearchQuery(values []SearchQuery) (SearchQuery, error) { + return NewAndQuery(values...) +} + +func NewUserMetadataOrSearchQuery(values []SearchQuery) (SearchQuery, error) { + return NewOrQuery(values...) +} + +func NewUserMetadataNotSearchQuery(value SearchQuery) (SearchQuery, error) { + return NewNotQuery(value) +} + func NewUserMetadataExistsQuery(key string, value []byte, keyComparison TextComparison, valueComparison BytesComparison) (SearchQuery, error) { // linking queries for the subselect instanceQuery, err := NewColumnComparisonQuery(UserMetadataInstanceIDCol, UserInstanceIDCol, ColumnEquals)