Files
zitadel/internal/api/grpc/user/v2/integration_test/query_test.go
T
Tim MöhlmannandGitHub f27ca69749 fix(query): distinct count in user list (#10840)
# Which Problems Are Solved

When listing / searching users, each user got multiplied by the amount
of metadata entries they have, towards the `total_results` count. In
PostgreSQL the `COUNT(*) OVER()` window function does not support
`DISTINCT`. Even tho the query did a distinct select, the count would
still include duplicates.

# How the Problems Are Solved

Wrap the original query in a sub-select, so that the `DISTINCT` gets
handled before the count window function is executed in the outer
function. Filters, permission and solting is applied to the inner query.
Offset, limit and count are applied to the outer query.

# Additional Changes

- none

# Additional Context

- Closes https://github.com/zitadel/zitadel/issues/10825
- Backport to 4v
2025-10-14 09:43:59 +00:00

1571 lines
46 KiB
Go

//go:build integration
package user_test
import (
"context"
"encoding/base64"
"errors"
"fmt"
"slices"
"testing"
"time"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/integration"
"github.com/zitadel/zitadel/pkg/grpc/feature/v2"
"github.com/zitadel/zitadel/pkg/grpc/filter/v2"
v2 "github.com/zitadel/zitadel/pkg/grpc/metadata/v2"
"github.com/zitadel/zitadel/pkg/grpc/object/v2"
"github.com/zitadel/zitadel/pkg/grpc/session/v2"
"github.com/zitadel/zitadel/pkg/grpc/user/v2"
)
var (
permissionCheckV2SetFlagInital bool
permissionCheckV2SetFlag bool
)
type permissionCheckV2SettingsStruct struct {
TestNamePrependString string
SetFlag bool
}
var permissionCheckV2Settings []permissionCheckV2SettingsStruct = []permissionCheckV2SettingsStruct{
{
SetFlag: false,
TestNamePrependString: "permission_check_v2 IS NOT SET" + " ",
},
{
SetFlag: true,
TestNamePrependString: "permission_check_v2 IS SET" + " ",
},
}
func setPermissionCheckV2Flag(t *testing.T, setFlag bool) {
if permissionCheckV2SetFlagInital && permissionCheckV2SetFlag == setFlag {
return
}
_, err := Instance.Client.FeatureV2.SetInstanceFeatures(IamCTX, &feature.SetInstanceFeaturesRequest{
PermissionCheckV2: &setFlag,
})
require.NoError(t, err)
var flagSet bool
for i := 0; !flagSet || i < 6; i++ {
res, err := Instance.Client.FeatureV2.GetInstanceFeatures(IamCTX, &feature.GetInstanceFeaturesRequest{})
require.NoError(t, err)
if res.PermissionCheckV2.Enabled == setFlag {
flagSet = true
continue
}
time.Sleep(10 * time.Second)
}
if !flagSet {
require.NoError(t, errors.New("unable to set permission_check_v2 flag"))
}
permissionCheckV2SetFlagInital = true
permissionCheckV2SetFlag = setFlag
}
func TestServer_GetUserByID(t *testing.T) {
orgResp := Instance.CreateOrganization(IamCTX, integration.OrganizationName(), integration.Email())
type args struct {
ctx context.Context
req *user.GetUserByIDRequest
dep func(ctx context.Context, request *user.GetUserByIDRequest) *userAttr
}
tests := []struct {
name string
args args
want *user.GetUserByIDResponse
wantErr bool
}{
{
name: "user by ID, no id provided",
args: args{
IamCTX,
&user.GetUserByIDRequest{
UserId: "",
},
func(ctx context.Context, request *user.GetUserByIDRequest) *userAttr {
return nil
},
},
wantErr: true,
},
{
name: "user by ID, not found",
args: args{
IamCTX,
&user.GetUserByIDRequest{
UserId: "unknown",
},
func(ctx context.Context, request *user.GetUserByIDRequest) *userAttr {
return nil
},
},
wantErr: true,
},
{
name: "user by ID, ok",
args: args{
IamCTX,
&user.GetUserByIDRequest{},
func(ctx context.Context, request *user.GetUserByIDRequest) *userAttr {
info := createUser(ctx, orgResp.OrganizationId, false)
request.UserId = info.UserID
return &info
},
},
want: &user.GetUserByIDResponse{
User: &user.User{
State: user.UserState_USER_STATE_ACTIVE,
Username: "",
LoginNames: nil,
PreferredLoginName: "",
Type: &user.User_Human{
Human: &user.HumanUser{
Profile: &user.HumanProfile{
GivenName: "Mickey",
FamilyName: "Mouse",
NickName: gu.Ptr("Mickey"),
DisplayName: gu.Ptr("Mickey Mouse"),
PreferredLanguage: gu.Ptr("nl"),
Gender: user.Gender_GENDER_MALE.Enum(),
AvatarUrl: "",
},
Email: &user.HumanEmail{
IsVerified: true,
},
Phone: &user.HumanPhone{
IsVerified: true,
},
},
},
},
Details: &object.Details{
ChangeDate: timestamppb.Now(),
CreationDate: timestamppb.Now(),
ResourceOwner: orgResp.OrganizationId,
},
},
},
{
name: "user by ID, passwordChangeRequired, ok",
args: args{
IamCTX,
&user.GetUserByIDRequest{},
func(ctx context.Context, request *user.GetUserByIDRequest) *userAttr {
info := createUser(ctx, orgResp.OrganizationId, true)
request.UserId = info.UserID
return &info
},
},
want: &user.GetUserByIDResponse{
User: &user.User{
State: user.UserState_USER_STATE_ACTIVE,
Username: "",
LoginNames: nil,
PreferredLoginName: "",
Type: &user.User_Human{
Human: &user.HumanUser{
Profile: &user.HumanProfile{
GivenName: "Mickey",
FamilyName: "Mouse",
NickName: gu.Ptr("Mickey"),
DisplayName: gu.Ptr("Mickey Mouse"),
PreferredLanguage: gu.Ptr("nl"),
Gender: user.Gender_GENDER_MALE.Enum(),
AvatarUrl: "",
},
Email: &user.HumanEmail{
IsVerified: true,
},
Phone: &user.HumanPhone{
IsVerified: true,
},
PasswordChangeRequired: true,
PasswordChanged: timestamppb.Now(),
},
},
},
Details: &object.Details{
ChangeDate: timestamppb.Now(),
CreationDate: timestamppb.Now(),
ResourceOwner: orgResp.OrganizationId,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
userAttr := tt.args.dep(IamCTX, tt.args.req)
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.args.ctx, time.Minute)
require.EventuallyWithT(t, func(ttt *assert.CollectT) {
got, err := Client.GetUserByID(tt.args.ctx, tt.args.req)
if tt.wantErr {
assert.Error(ttt, err)
return
}
if !assert.NoError(ttt, err) {
return
}
tt.want.User.Details = userAttr.Details
tt.want.User.UserId = userAttr.UserID
tt.want.User.Username = userAttr.Username
tt.want.User.PreferredLoginName = userAttr.Username
tt.want.User.LoginNames = []string{userAttr.Username}
if human := tt.want.User.GetHuman(); human != nil {
human.Email.Email = userAttr.Username
human.Phone.Phone = userAttr.Phone
if tt.want.User.GetHuman().GetPasswordChanged() != nil {
human.PasswordChanged = userAttr.Changed
}
}
assert.EqualExportedValues(ttt, tt.want.User, got.User)
integration.AssertDetails(ttt, tt.want, got)
}, retryDuration, tick)
})
}
}
func TestServer_GetUserByID_Permission(t *testing.T) {
newOrgOwnerEmail := integration.Email()
newOrg := Instance.CreateOrganization(IamCTX, integration.OrganizationName(), newOrgOwnerEmail)
newUserID := newOrg.CreatedAdmins[0].GetUserId()
type args struct {
ctx context.Context
req *user.GetUserByIDRequest
}
tests := []struct {
name string
args args
want *user.GetUserByIDResponse
wantErr bool
}{
{
name: "System, ok",
args: args{
SystemCTX,
&user.GetUserByIDRequest{
UserId: newUserID,
},
},
want: &user.GetUserByIDResponse{
User: &user.User{
State: user.UserState_USER_STATE_ACTIVE,
Username: "",
LoginNames: nil,
PreferredLoginName: "",
Type: &user.User_Human{
Human: &user.HumanUser{
Profile: &user.HumanProfile{
GivenName: "firstname",
FamilyName: "lastname",
NickName: gu.Ptr(""),
DisplayName: gu.Ptr("firstname lastname"),
PreferredLanguage: gu.Ptr("und"),
Gender: user.Gender_GENDER_UNSPECIFIED.Enum(),
AvatarUrl: "",
},
Email: &user.HumanEmail{
Email: newOrgOwnerEmail,
},
Phone: &user.HumanPhone{},
},
},
},
Details: &object.Details{
ChangeDate: timestamppb.Now(),
CreationDate: timestamppb.Now(),
ResourceOwner: newOrg.GetOrganizationId(),
},
},
},
{
name: "Instance, ok",
args: args{
IamCTX,
&user.GetUserByIDRequest{
UserId: newUserID,
},
},
want: &user.GetUserByIDResponse{
User: &user.User{
State: user.UserState_USER_STATE_ACTIVE,
Username: "",
LoginNames: nil,
PreferredLoginName: "",
Type: &user.User_Human{
Human: &user.HumanUser{
Profile: &user.HumanProfile{
GivenName: "firstname",
FamilyName: "lastname",
NickName: gu.Ptr(""),
DisplayName: gu.Ptr("firstname lastname"),
PreferredLanguage: gu.Ptr("und"),
Gender: user.Gender_GENDER_UNSPECIFIED.Enum(),
AvatarUrl: "",
},
Email: &user.HumanEmail{
Email: newOrgOwnerEmail,
},
Phone: &user.HumanPhone{},
},
},
},
Details: &object.Details{
ChangeDate: timestamppb.Now(),
CreationDate: timestamppb.Now(),
ResourceOwner: newOrg.GetOrganizationId(),
},
},
},
{
name: "Org, error",
args: args{
CTX,
&user.GetUserByIDRequest{
UserId: newUserID,
},
},
wantErr: true,
},
{
name: "User, error",
args: args{
UserCTX,
&user.GetUserByIDRequest{
UserId: newUserID,
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.args.ctx, time.Minute)
require.EventuallyWithT(t, func(ttt *assert.CollectT) {
got, err := Client.GetUserByID(tt.args.ctx, tt.args.req)
if tt.wantErr {
assert.Error(ttt, err)
return
}
if !assert.NoError(ttt, err) {
return
}
tt.want.User.UserId = tt.args.req.GetUserId()
tt.want.User.Username = newOrgOwnerEmail
tt.want.User.PreferredLoginName = newOrgOwnerEmail
tt.want.User.LoginNames = []string{newOrgOwnerEmail}
if human := tt.want.User.GetHuman(); human != nil {
human.Email.Email = newOrgOwnerEmail
}
// details tested in GetUserByID
tt.want.User.Details = got.User.GetDetails()
assert.Equal(ttt, tt.want.User, got.User)
}, retryDuration, tick, "timeout waiting for expected user result")
})
}
}
type userAttrs []userAttr
func (u userAttrs) userIDs() []string {
ids := make([]string, len(u))
for i := range u {
ids[i] = u[i].UserID
}
return ids
}
func (u userAttrs) emails() []string {
emails := make([]string, len(u))
for i := range u {
emails[i] = u[i].Username
}
return emails
}
type userAttr struct {
UserID string
Username string
Phone string
Changed *timestamppb.Timestamp
Details *object.Details
}
func createUsers(ctx context.Context, orgID string, count int, passwordChangeRequired bool) userAttrs {
infos := make([]userAttr, count)
for i := 0; i < count; i++ {
infos[i] = createUser(ctx, orgID, passwordChangeRequired)
}
slices.Reverse(infos)
return infos
}
func createUser(ctx context.Context, orgID string, passwordChangeRequired bool) userAttr {
username := integration.Email()
return createUserWithUserName(ctx, username, orgID, passwordChangeRequired)
}
func createUserWithUserName(ctx context.Context, username string, orgID string, passwordChangeRequired bool) userAttr {
// used as default country prefix
phone := integration.Phone()
resp := Instance.CreateHumanUserVerified(ctx, orgID, username, phone)
info := userAttr{resp.GetUserId(), username, phone, nil, resp.GetDetails()}
// as the change date of the creation is the creation date
resp.Details.CreationDate = resp.GetDetails().GetChangeDate()
if passwordChangeRequired {
details := Instance.SetUserPassword(ctx, resp.GetUserId(), integration.UserPassword, true)
info.Changed = details.GetChangeDate()
}
return info
}
func TestServer_ListUsers(t *testing.T) {
t.Cleanup(func() {
_, err := Instance.Client.FeatureV2.ResetInstanceFeatures(IamCTX, &feature.ResetInstanceFeaturesRequest{})
require.NoError(t, err)
})
orgResp := Instance.CreateOrganization(IamCTX, integration.OrganizationName(), integration.Email())
type args struct {
ctx context.Context
req *user.ListUsersRequest
dep func(ctx context.Context, request *user.ListUsersRequest) userAttrs
}
tt := []struct {
name string
args args
want *user.ListUsersResponse
wantErr bool
}{
{
name: "list user by id, no permission machine user",
args: args{
UserCTX,
&user.ListUsersRequest{},
func(ctx context.Context, request *user.ListUsersRequest) userAttrs {
info := createUser(ctx, orgResp.OrganizationId, false)
request.Queries = append(request.Queries, InUserIDsQuery([]string{info.UserID}))
return []userAttr{}
},
},
want: &user.ListUsersResponse{
Details: &object.ListDetails{
TotalResult: 0,
Timestamp: timestamppb.Now(),
},
SortingColumn: 0,
Result: []*user.User{},
},
},
{
name: "list user by id, no permission human user",
args: func() args {
info := createUser(IamCTX, orgResp.OrganizationId, true)
// create session to get token
userID := info.UserID
createResp, err := Instance.Client.SessionV2.CreateSession(IamCTX, &session.CreateSessionRequest{
Checks: &session.Checks{
User: &session.CheckUser{
Search: &session.CheckUser_UserId{UserId: userID},
},
Password: &session.CheckPassword{
Password: integration.UserPassword,
},
},
})
if err != nil {
require.NoError(t, err)
}
// use token to get ctx
HumanCTX := integration.WithAuthorizationToken(IamCTX, createResp.GetSessionToken())
return args{
HumanCTX,
&user.ListUsersRequest{},
func(ctx context.Context, request *user.ListUsersRequest) userAttrs {
return []userAttr{info}
},
}
}(),
want: &user.ListUsersResponse{ // human user should return itself when calling ListUsers() even if it has no permissions
Details: &object.ListDetails{
TotalResult: 1,
Timestamp: timestamppb.Now(),
},
SortingColumn: 0,
Result: []*user.User{
{
State: user.UserState_USER_STATE_ACTIVE,
Type: &user.User_Human{
Human: &user.HumanUser{
Profile: &user.HumanProfile{
GivenName: "Mickey",
FamilyName: "Mouse",
NickName: gu.Ptr("Mickey"),
DisplayName: gu.Ptr("Mickey Mouse"),
PreferredLanguage: gu.Ptr("nl"),
Gender: user.Gender_GENDER_MALE.Enum(),
},
Email: &user.HumanEmail{
IsVerified: true,
},
Phone: &user.HumanPhone{
IsVerified: true,
},
PasswordChangeRequired: true,
PasswordChanged: timestamppb.Now(),
},
},
},
},
},
},
{
name: "list user by id, ok",
args: args{
IamCTX,
&user.ListUsersRequest{},
func(ctx context.Context, request *user.ListUsersRequest) userAttrs {
info := createUser(ctx, orgResp.OrganizationId, false)
request.Queries = []*user.SearchQuery{}
request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId))
request.Queries = append(request.Queries, InUserIDsQuery([]string{info.UserID}))
return []userAttr{info}
},
},
want: &user.ListUsersResponse{
Details: &object.ListDetails{
TotalResult: 1,
Timestamp: timestamppb.Now(),
},
SortingColumn: 0,
Result: []*user.User{
{
State: user.UserState_USER_STATE_ACTIVE,
Type: &user.User_Human{
Human: &user.HumanUser{
Profile: &user.HumanProfile{
GivenName: "Mickey",
FamilyName: "Mouse",
NickName: gu.Ptr("Mickey"),
DisplayName: gu.Ptr("Mickey Mouse"),
PreferredLanguage: gu.Ptr("nl"),
Gender: user.Gender_GENDER_MALE.Enum(),
},
Email: &user.HumanEmail{
IsVerified: true,
},
Phone: &user.HumanPhone{
IsVerified: true,
},
},
},
},
},
},
},
{
name: "list user by id, passwordChangeRequired, ok",
args: args{
IamCTX,
&user.ListUsersRequest{},
func(ctx context.Context, request *user.ListUsersRequest) userAttrs {
info := createUser(ctx, orgResp.OrganizationId, true)
request.Queries = []*user.SearchQuery{}
request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId))
request.Queries = append(request.Queries, InUserIDsQuery([]string{info.UserID}))
return []userAttr{info}
},
},
want: &user.ListUsersResponse{
Details: &object.ListDetails{
TotalResult: 1,
Timestamp: timestamppb.Now(),
},
SortingColumn: 0,
Result: []*user.User{
{
State: user.UserState_USER_STATE_ACTIVE,
Type: &user.User_Human{
Human: &user.HumanUser{
Profile: &user.HumanProfile{
GivenName: "Mickey",
FamilyName: "Mouse",
NickName: gu.Ptr("Mickey"),
DisplayName: gu.Ptr("Mickey Mouse"),
PreferredLanguage: gu.Ptr("nl"),
Gender: user.Gender_GENDER_MALE.Enum(),
},
Email: &user.HumanEmail{
IsVerified: true,
},
Phone: &user.HumanPhone{
IsVerified: true,
},
PasswordChangeRequired: true,
PasswordChanged: timestamppb.Now(),
},
},
},
},
},
},
{
name: "list user by id and meta key multiple, ok",
args: args{
IamCTX,
&user.ListUsersRequest{},
func(ctx context.Context, request *user.ListUsersRequest) userAttrs {
infos := createUsers(ctx, orgResp.OrganizationId, 3, false)
request.Queries = []*user.SearchQuery{}
request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId))
request.Queries = append(request.Queries, InUserIDsQuery(infos.userIDs()))
Instance.SetUserMetadata(ctx, infos[0].UserID, "my meta", "my value 1")
Instance.SetUserMetadata(ctx, infos[1].UserID, "my meta 2", "my value 3")
Instance.SetUserMetadata(ctx, infos[2].UserID, "my meta", "my value 2")
request.Queries = append(request.Queries, MetadataKeyContainsQuery("my meta"))
request.SortingColumn = user.UserFieldName_USER_FIELD_NAME_CREATION_DATE
return infos
},
},
want: &user.ListUsersResponse{
Details: &object.ListDetails{
TotalResult: 3,
Timestamp: timestamppb.Now(),
},
SortingColumn: user.UserFieldName_USER_FIELD_NAME_CREATION_DATE,
Result: []*user.User{
{
State: user.UserState_USER_STATE_ACTIVE,
Type: &user.User_Human{
Human: &user.HumanUser{
Profile: &user.HumanProfile{
GivenName: "Mickey",
FamilyName: "Mouse",
NickName: gu.Ptr("Mickey"),
DisplayName: gu.Ptr("Mickey Mouse"),
PreferredLanguage: gu.Ptr("nl"),
Gender: user.Gender_GENDER_MALE.Enum(),
},
Email: &user.HumanEmail{
IsVerified: true,
},
Phone: &user.HumanPhone{
IsVerified: true,
},
},
},
},
{
State: user.UserState_USER_STATE_ACTIVE,
Type: &user.User_Human{
Human: &user.HumanUser{
Profile: &user.HumanProfile{
GivenName: "Mickey",
FamilyName: "Mouse",
NickName: gu.Ptr("Mickey"),
DisplayName: gu.Ptr("Mickey Mouse"),
PreferredLanguage: gu.Ptr("nl"),
Gender: user.Gender_GENDER_MALE.Enum(),
},
Email: &user.HumanEmail{
IsVerified: true,
},
Phone: &user.HumanPhone{
IsVerified: true,
},
},
},
},
{
State: user.UserState_USER_STATE_ACTIVE,
Type: &user.User_Human{
Human: &user.HumanUser{
Profile: &user.HumanProfile{
GivenName: "Mickey",
FamilyName: "Mouse",
NickName: gu.Ptr("Mickey"),
DisplayName: gu.Ptr("Mickey Mouse"),
PreferredLanguage: gu.Ptr("nl"),
Gender: user.Gender_GENDER_MALE.Enum(),
},
Email: &user.HumanEmail{
IsVerified: true,
},
Phone: &user.HumanPhone{
IsVerified: true,
},
},
},
},
},
},
},
{
// https://github.com/zitadel/zitadel/issues/10825
name: "list user with metadata by IDs with offset and limit, ok",
args: args{
IamCTX,
&user.ListUsersRequest{},
func(ctx context.Context, request *user.ListUsersRequest) userAttrs {
infos := createUsers(ctx, orgResp.OrganizationId, 3, false)
request.Queries = []*user.SearchQuery{}
request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId))
request.Queries = append(request.Queries, InUserIDsQuery(infos.userIDs()))
// With the original bug, this would multiply the "TotalResult" by 2.
for _, user := range infos {
for i := 0; i < 2; i++ {
Instance.SetUserMetadata(ctx, user.UserID, fmt.Sprintf("key-%d", i), fmt.Sprintf("value-%d", i))
}
}
infos = infos[1:] // prevent panic in user ID setting below
request.SortingColumn = user.UserFieldName_USER_FIELD_NAME_CREATION_DATE
request.Query = &object.ListQuery{
Offset: 1,
Limit: 2,
}
return infos
},
},
want: &user.ListUsersResponse{
Details: &object.ListDetails{
TotalResult: 3,
Timestamp: timestamppb.Now(),
},
SortingColumn: user.UserFieldName_USER_FIELD_NAME_CREATION_DATE,
Result: []*user.User{
{
State: user.UserState_USER_STATE_ACTIVE,
Type: &user.User_Human{
Human: &user.HumanUser{
Profile: &user.HumanProfile{
GivenName: "Mickey",
FamilyName: "Mouse",
NickName: gu.Ptr("Mickey"),
DisplayName: gu.Ptr("Mickey Mouse"),
PreferredLanguage: gu.Ptr("nl"),
Gender: user.Gender_GENDER_MALE.Enum(),
},
Email: &user.HumanEmail{
IsVerified: true,
},
Phone: &user.HumanPhone{
IsVerified: true,
},
},
},
},
{
State: user.UserState_USER_STATE_ACTIVE,
Type: &user.User_Human{
Human: &user.HumanUser{
Profile: &user.HumanProfile{
GivenName: "Mickey",
FamilyName: "Mouse",
NickName: gu.Ptr("Mickey"),
DisplayName: gu.Ptr("Mickey Mouse"),
PreferredLanguage: gu.Ptr("nl"),
Gender: user.Gender_GENDER_MALE.Enum(),
},
Email: &user.HumanEmail{
IsVerified: true,
},
Phone: &user.HumanPhone{
IsVerified: true,
},
},
},
},
},
},
},
{
name: "list user by username, ok",
args: args{
IamCTX,
&user.ListUsersRequest{},
func(ctx context.Context, request *user.ListUsersRequest) userAttrs {
info := createUser(ctx, orgResp.OrganizationId, false)
request.Queries = []*user.SearchQuery{}
request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId))
request.Queries = append(request.Queries, UsernameQuery(info.Username))
request.SortingColumn = user.UserFieldName_USER_FIELD_NAME_CREATION_DATE
return []userAttr{info}
},
},
want: &user.ListUsersResponse{
Details: &object.ListDetails{
TotalResult: 1,
Timestamp: timestamppb.Now(),
},
SortingColumn: user.UserFieldName_USER_FIELD_NAME_CREATION_DATE,
Result: []*user.User{
{
State: user.UserState_USER_STATE_ACTIVE,
Type: &user.User_Human{
Human: &user.HumanUser{
Profile: &user.HumanProfile{
GivenName: "Mickey",
FamilyName: "Mouse",
NickName: gu.Ptr("Mickey"),
DisplayName: gu.Ptr("Mickey Mouse"),
PreferredLanguage: gu.Ptr("nl"),
Gender: user.Gender_GENDER_MALE.Enum(),
},
Email: &user.HumanEmail{
IsVerified: true,
},
Phone: &user.HumanPhone{
IsVerified: true,
},
},
},
},
},
},
},
{
name: "list user in emails, ok",
args: args{
IamCTX,
&user.ListUsersRequest{},
func(ctx context.Context, request *user.ListUsersRequest) userAttrs {
info := createUser(ctx, orgResp.OrganizationId, false)
request.Queries = []*user.SearchQuery{}
request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId))
request.Queries = append(request.Queries, InUserEmailsQuery([]string{info.Username}))
request.SortingColumn = user.UserFieldName_USER_FIELD_NAME_CREATION_DATE
return []userAttr{info}
},
},
want: &user.ListUsersResponse{
Details: &object.ListDetails{
TotalResult: 1,
Timestamp: timestamppb.Now(),
},
SortingColumn: user.UserFieldName_USER_FIELD_NAME_CREATION_DATE,
Result: []*user.User{
{
State: user.UserState_USER_STATE_ACTIVE,
Type: &user.User_Human{
Human: &user.HumanUser{
Profile: &user.HumanProfile{
GivenName: "Mickey",
FamilyName: "Mouse",
NickName: gu.Ptr("Mickey"),
DisplayName: gu.Ptr("Mickey Mouse"),
PreferredLanguage: gu.Ptr("nl"),
Gender: user.Gender_GENDER_MALE.Enum(),
},
Email: &user.HumanEmail{
IsVerified: true,
},
Phone: &user.HumanPhone{
IsVerified: true,
},
},
},
},
},
},
},
{
name: "list user by emails and meta value multiple, ok",
args: args{
IamCTX,
&user.ListUsersRequest{},
func(ctx context.Context, request *user.ListUsersRequest) userAttrs {
infos := createUsers(ctx, orgResp.OrganizationId, 3, false)
request.Queries = []*user.SearchQuery{}
request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId))
request.Queries = append(request.Queries, InUserEmailsQuery(infos.emails()))
Instance.SetUserMetadata(ctx, infos[0].UserID, "my meta 1", "my value")
Instance.SetUserMetadata(ctx, infos[0].UserID, "my meta 2", "my value")
Instance.SetUserMetadata(ctx, infos[1].UserID, "my meta 2", "my value")
Instance.SetUserMetadata(ctx, infos[2].UserID, "my meta", "my value")
request.Queries = append(request.Queries, MetadataValueQuery("my value"))
request.SortingColumn = user.UserFieldName_USER_FIELD_NAME_CREATION_DATE
return infos
},
},
want: &user.ListUsersResponse{
Details: &object.ListDetails{
TotalResult: 3,
Timestamp: timestamppb.Now(),
},
SortingColumn: user.UserFieldName_USER_FIELD_NAME_CREATION_DATE,
Result: []*user.User{
{
State: user.UserState_USER_STATE_ACTIVE,
Type: &user.User_Human{
Human: &user.HumanUser{
Profile: &user.HumanProfile{
GivenName: "Mickey",
FamilyName: "Mouse",
NickName: gu.Ptr("Mickey"),
DisplayName: gu.Ptr("Mickey Mouse"),
PreferredLanguage: gu.Ptr("nl"),
Gender: user.Gender_GENDER_MALE.Enum(),
},
Email: &user.HumanEmail{
IsVerified: true,
},
Phone: &user.HumanPhone{
IsVerified: true,
},
},
},
}, {
State: user.UserState_USER_STATE_ACTIVE,
Type: &user.User_Human{
Human: &user.HumanUser{
Profile: &user.HumanProfile{
GivenName: "Mickey",
FamilyName: "Mouse",
NickName: gu.Ptr("Mickey"),
DisplayName: gu.Ptr("Mickey Mouse"),
PreferredLanguage: gu.Ptr("nl"),
Gender: user.Gender_GENDER_MALE.Enum(),
},
Email: &user.HumanEmail{
IsVerified: true,
},
Phone: &user.HumanPhone{
IsVerified: true,
},
},
},
}, {
State: user.UserState_USER_STATE_ACTIVE,
Type: &user.User_Human{
Human: &user.HumanUser{
Profile: &user.HumanProfile{
GivenName: "Mickey",
FamilyName: "Mouse",
NickName: gu.Ptr("Mickey"),
DisplayName: gu.Ptr("Mickey Mouse"),
PreferredLanguage: gu.Ptr("nl"),
Gender: user.Gender_GENDER_MALE.Enum(),
},
Email: &user.HumanEmail{
IsVerified: true,
},
Phone: &user.HumanPhone{
IsVerified: true,
},
},
},
},
},
},
},
{
// https://github.com/zitadel/zitadel/issues/10825
name: "list user with metadata by emails with offset and limit, ok",
args: args{
IamCTX,
&user.ListUsersRequest{},
func(ctx context.Context, request *user.ListUsersRequest) userAttrs {
infos := createUsers(ctx, orgResp.OrganizationId, 3, false)
request.Queries = []*user.SearchQuery{}
request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId))
request.Queries = append(request.Queries, InUserEmailsQuery(infos.emails()))
// With the original bug, this would multiply the "TotalResult" by 2.
for _, user := range infos {
for i := 0; i < 2; i++ {
Instance.SetUserMetadata(ctx, user.UserID, fmt.Sprintf("key-%d", i), fmt.Sprintf("value-%d", i))
}
}
infos = infos[1:] // prevent panic in user ID setting below
request.SortingColumn = user.UserFieldName_USER_FIELD_NAME_CREATION_DATE
request.Query = &object.ListQuery{
Offset: 1,
Limit: 2,
}
return infos
},
},
want: &user.ListUsersResponse{
Details: &object.ListDetails{
TotalResult: 3,
Timestamp: timestamppb.Now(),
},
SortingColumn: user.UserFieldName_USER_FIELD_NAME_CREATION_DATE,
Result: []*user.User{
{
State: user.UserState_USER_STATE_ACTIVE,
Type: &user.User_Human{
Human: &user.HumanUser{
Profile: &user.HumanProfile{
GivenName: "Mickey",
FamilyName: "Mouse",
NickName: gu.Ptr("Mickey"),
DisplayName: gu.Ptr("Mickey Mouse"),
PreferredLanguage: gu.Ptr("nl"),
Gender: user.Gender_GENDER_MALE.Enum(),
},
Email: &user.HumanEmail{
IsVerified: true,
},
Phone: &user.HumanPhone{
IsVerified: true,
},
},
},
}, {
State: user.UserState_USER_STATE_ACTIVE,
Type: &user.User_Human{
Human: &user.HumanUser{
Profile: &user.HumanProfile{
GivenName: "Mickey",
FamilyName: "Mouse",
NickName: gu.Ptr("Mickey"),
DisplayName: gu.Ptr("Mickey Mouse"),
PreferredLanguage: gu.Ptr("nl"),
Gender: user.Gender_GENDER_MALE.Enum(),
},
Email: &user.HumanEmail{
IsVerified: true,
},
Phone: &user.HumanPhone{
IsVerified: true,
},
},
},
},
},
},
},
{
name: "list user in emails no found, ok",
args: args{
IamCTX,
&user.ListUsersRequest{
Queries: []*user.SearchQuery{
OrganizationIdQuery(orgResp.OrganizationId),
InUserEmailsQuery([]string{"notfound"}),
},
},
func(ctx context.Context, request *user.ListUsersRequest) userAttrs {
return []userAttr{}
},
},
want: &user.ListUsersResponse{
Details: &object.ListDetails{
TotalResult: 0,
Timestamp: timestamppb.Now(),
},
SortingColumn: 0,
Result: []*user.User{},
},
},
{
name: "list user phone, ok",
args: args{
IamCTX,
&user.ListUsersRequest{},
func(ctx context.Context, request *user.ListUsersRequest) userAttrs {
info := createUser(ctx, orgResp.OrganizationId, false)
request.Queries = []*user.SearchQuery{}
request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId))
request.Queries = append(request.Queries, PhoneQuery(info.Phone))
return []userAttr{info}
},
},
want: &user.ListUsersResponse{
Details: &object.ListDetails{
TotalResult: 1,
Timestamp: timestamppb.Now(),
},
SortingColumn: 0,
Result: []*user.User{
{
State: user.UserState_USER_STATE_ACTIVE,
Type: &user.User_Human{
Human: &user.HumanUser{
Profile: &user.HumanProfile{
GivenName: "Mickey",
FamilyName: "Mouse",
NickName: gu.Ptr("Mickey"),
DisplayName: gu.Ptr("Mickey Mouse"),
PreferredLanguage: gu.Ptr("nl"),
Gender: user.Gender_GENDER_MALE.Enum(),
},
Email: &user.HumanEmail{
IsVerified: true,
},
Phone: &user.HumanPhone{
IsVerified: true,
},
},
},
},
},
},
},
{
name: "list user in emails no found, ok",
args: args{
IamCTX,
&user.ListUsersRequest{
Queries: []*user.SearchQuery{
OrganizationIdQuery(orgResp.OrganizationId),
InUserEmailsQuery([]string{"notfound"}),
},
},
func(ctx context.Context, request *user.ListUsersRequest) userAttrs {
return []userAttr{}
},
},
want: &user.ListUsersResponse{
Details: &object.ListDetails{
TotalResult: 0,
Timestamp: timestamppb.Now(),
},
SortingColumn: 0,
Result: []*user.User{},
},
},
{
name: "list user resourceowner multiple, ok",
args: args{
IamCTX,
&user.ListUsersRequest{},
func(ctx context.Context, request *user.ListUsersRequest) userAttrs {
orgResp := Instance.CreateOrganization(ctx, integration.OrganizationName(), integration.Email())
infos := createUsers(ctx, orgResp.OrganizationId, 3, false)
request.Queries = []*user.SearchQuery{}
request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId))
request.Queries = append(request.Queries, InUserEmailsQuery(infos.emails()))
request.SortingColumn = user.UserFieldName_USER_FIELD_NAME_CREATION_DATE
return infos
},
},
want: &user.ListUsersResponse{
Details: &object.ListDetails{
TotalResult: 3,
Timestamp: timestamppb.Now(),
},
SortingColumn: user.UserFieldName_USER_FIELD_NAME_CREATION_DATE,
Result: []*user.User{
{
State: user.UserState_USER_STATE_ACTIVE,
Type: &user.User_Human{
Human: &user.HumanUser{
Profile: &user.HumanProfile{
GivenName: "Mickey",
FamilyName: "Mouse",
NickName: gu.Ptr("Mickey"),
DisplayName: gu.Ptr("Mickey Mouse"),
PreferredLanguage: gu.Ptr("nl"),
Gender: user.Gender_GENDER_MALE.Enum(),
},
Email: &user.HumanEmail{
IsVerified: true,
},
Phone: &user.HumanPhone{
IsVerified: true,
},
},
},
}, {
State: user.UserState_USER_STATE_ACTIVE,
Type: &user.User_Human{
Human: &user.HumanUser{
Profile: &user.HumanProfile{
GivenName: "Mickey",
FamilyName: "Mouse",
NickName: gu.Ptr("Mickey"),
DisplayName: gu.Ptr("Mickey Mouse"),
PreferredLanguage: gu.Ptr("nl"),
Gender: user.Gender_GENDER_MALE.Enum(),
},
Email: &user.HumanEmail{
IsVerified: true,
},
Phone: &user.HumanPhone{
IsVerified: true,
},
},
},
}, {
State: user.UserState_USER_STATE_ACTIVE,
Type: &user.User_Human{
Human: &user.HumanUser{
Profile: &user.HumanProfile{
GivenName: "Mickey",
FamilyName: "Mouse",
NickName: gu.Ptr("Mickey"),
DisplayName: gu.Ptr("Mickey Mouse"),
PreferredLanguage: gu.Ptr("nl"),
Gender: user.Gender_GENDER_MALE.Enum(),
},
Email: &user.HumanEmail{
IsVerified: true,
},
Phone: &user.HumanPhone{
IsVerified: true,
},
},
},
},
},
},
},
{
name: "list user with org query",
args: args{
IamCTX,
&user.ListUsersRequest{},
func(ctx context.Context, request *user.ListUsersRequest) userAttrs {
orgRespForOrgTests := Instance.CreateOrganization(IamCTX, integration.OrganizationName(), integration.Email())
info := createUser(ctx, orgRespForOrgTests.OrganizationId, false)
request.Queries = []*user.SearchQuery{}
request.Queries = append(request.Queries, OrganizationIdQuery(orgRespForOrgTests.OrganizationId))
request.SortingColumn = user.UserFieldName_USER_FIELD_NAME_CREATION_DATE
return []userAttr{info, {}}
},
},
want: &user.ListUsersResponse{
Details: &object.ListDetails{
TotalResult: 2,
Timestamp: timestamppb.Now(),
},
SortingColumn: user.UserFieldName_USER_FIELD_NAME_CREATION_DATE,
Result: []*user.User{
{
State: user.UserState_USER_STATE_ACTIVE,
Type: &user.User_Human{
Human: &user.HumanUser{
Profile: &user.HumanProfile{
GivenName: "Mickey",
FamilyName: "Mouse",
NickName: gu.Ptr("Mickey"),
DisplayName: gu.Ptr("Mickey Mouse"),
PreferredLanguage: gu.Ptr("nl"),
Gender: user.Gender_GENDER_MALE.Enum(),
},
Email: &user.HumanEmail{
IsVerified: true,
},
Phone: &user.HumanPhone{
IsVerified: true,
},
},
},
},
// this is the admin of the org craated in Instance.CreateOrganization()
nil,
},
},
},
{
name: "list user with wrong org query",
args: args{
IamCTX,
&user.ListUsersRequest{},
func(ctx context.Context, request *user.ListUsersRequest) userAttrs {
orgRespForOrgTests := Instance.CreateOrganization(IamCTX, integration.OrganizationName(), integration.Email())
orgRespForOrgTests2 := Instance.CreateOrganization(IamCTX, integration.OrganizationName(), integration.Email())
createUser(ctx, orgRespForOrgTests.OrganizationId, false)
request.Queries = []*user.SearchQuery{}
request.Queries = append(request.Queries, OrganizationIdQuery(orgRespForOrgTests2.OrganizationId))
return []userAttr{{}}
},
},
want: &user.ListUsersResponse{
Details: &object.ListDetails{
TotalResult: 0,
Timestamp: timestamppb.Now(),
},
SortingColumn: 0,
Result: []*user.User{
// this is the admin of the org craated in Instance.CreateOrganization()
nil,
},
},
},
{
name: "when no users matching meta key should return empty list",
args: args{
IamCTX,
&user.ListUsersRequest{},
func(ctx context.Context, request *user.ListUsersRequest) userAttrs {
createUser(ctx, orgResp.OrganizationId, false)
request.Queries = []*user.SearchQuery{}
request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId))
request.Queries = append(request.Queries, MetadataKeyContainsQuery("some non-existent meta"))
request.SortingColumn = user.UserFieldName_USER_FIELD_NAME_CREATION_DATE
return []userAttr{}
},
},
want: &user.ListUsersResponse{
Details: &object.ListDetails{
TotalResult: 0,
Timestamp: timestamppb.Now(),
},
SortingColumn: user.UserFieldName_USER_FIELD_NAME_CREATION_DATE,
Result: []*user.User{},
},
},
}
for _, f := range permissionCheckV2Settings {
for _, tc := range tt {
t.Run(f.TestNamePrependString+tc.name, func(t1 *testing.T) {
setPermissionCheckV2Flag(t, f.SetFlag)
infos := tc.args.dep(IamCTX, tc.args.req)
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.args.ctx, time.Minute)
require.EventuallyWithT(t1, func(ttt *assert.CollectT) {
got, err := Client.ListUsers(tc.args.ctx, tc.args.req)
if tc.wantErr {
require.Error(ttt, err)
return
}
require.NoError(ttt, err)
// always only give back dependency infos which are required for the response
require.Len(ttt, tc.want.Result, len(infos))
if assert.Len(ttt, got.Result, len(tc.want.Result)) {
tc.want.Details.TotalResult = got.Details.TotalResult
// fill in userid and username as it is generated
for i := range infos {
if tc.want.Result[i] == nil {
continue
}
tc.want.Result[i].UserId = infos[i].UserID
tc.want.Result[i].Username = infos[i].Username
tc.want.Result[i].PreferredLoginName = infos[i].Username
tc.want.Result[i].LoginNames = []string{infos[i].Username}
if human := tc.want.Result[i].GetHuman(); human != nil {
human.Email.Email = infos[i].Username
human.Phone.Phone = infos[i].Phone
if tc.want.Result[i].GetHuman().GetPasswordChanged() != nil {
human.PasswordChanged = infos[i].Changed
}
}
tc.want.Result[i].Details = infos[i].Details
}
for i := range tc.want.Result {
if tc.want.Result[i] == nil {
continue
}
assert.EqualExportedValues(ttt, got.Result[i], tc.want.Result[i])
}
}
integration.AssertListDetails(ttt, tc.want, got)
}, retryDuration, tick, "timeout waiting for expected user result")
})
}
}
}
func TestServer_SystemUsers_ListUsers(t *testing.T) {
defer func() {
_, err := Instance.Client.FeatureV2.ResetInstanceFeatures(IamCTX, &feature.ResetInstanceFeaturesRequest{})
require.NoError(t, err)
}()
org1 := Instance.CreateOrganization(IamCTX, integration.OrganizationName(), integration.Email())
org2 := Instance.CreateOrganization(IamCTX, integration.OrganizationName(), "[email protected]")
org3 := Instance.CreateOrganization(IamCTX, integration.OrganizationName(), integration.Email())
_ = createUserWithUserName(IamCTX, "[email protected]", org1.OrganizationId, false)
_ = createUserWithUserName(IamCTX, "[email protected]", org2.OrganizationId, false)
_ = createUserWithUserName(IamCTX, "[email protected]", org3.OrganizationId, false)
tests := []struct {
name string
ctx context.Context
req *user.ListUsersRequest
expectedFoundUsernames []string
checkNumberOfUsersReturned bool
}{
{
name: "list users with neccessary permissions",
ctx: SystemCTX,
req: &user.ListUsersRequest{},
// the number of users returned will vary from test run to test run,
// so just check the system user gets back users from different orgs whcih it is not a memeber of
checkNumberOfUsersReturned: false,
expectedFoundUsernames: []string{"[email protected]", "[email protected]", "[email protected]"},
},
{
name: "list users without neccessary permissions",
ctx: SystemUserWithNoPermissionsCTX,
req: &user.ListUsersRequest{},
// check no users returned
checkNumberOfUsersReturned: true,
},
{
name: "list users with neccessary permissions specifying org",
req: &user.ListUsersRequest{
Queries: []*user.SearchQuery{OrganizationIdQuery(org2.OrganizationId)},
},
ctx: SystemCTX,
expectedFoundUsernames: []string{"[email protected]", "[email protected]"},
checkNumberOfUsersReturned: true,
},
{
name: "list users without neccessary permissions specifying org",
req: &user.ListUsersRequest{
Queries: []*user.SearchQuery{OrganizationIdQuery(org2.OrganizationId)},
},
ctx: SystemUserWithNoPermissionsCTX,
// check no users returned
checkNumberOfUsersReturned: true,
},
}
for _, f := range permissionCheckV2Settings {
f := f
for _, tt := range tests {
t.Run(f.TestNamePrependString+tt.name, func(t *testing.T) {
setPermissionCheckV2Flag(t, f.SetFlag)
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.ctx, 1*time.Minute)
require.EventuallyWithT(t, func(ttt *assert.CollectT) {
got, err := Client.ListUsers(tt.ctx, tt.req)
require.NoError(ttt, err)
if tt.checkNumberOfUsersReturned {
require.Equal(t, len(tt.expectedFoundUsernames), len(got.Result))
}
if tt.expectedFoundUsernames != nil {
for _, user := range got.Result {
for i, username := range tt.expectedFoundUsernames {
if username == user.Username {
tt.expectedFoundUsernames = tt.expectedFoundUsernames[i+1:]
break
}
}
if len(tt.expectedFoundUsernames) == 0 {
return
}
}
require.FailNow(t, "unable to find all users with specified usernames")
}
}, retryDuration, tick, "timeout waiting for expected user result")
})
}
}
}
func InUserIDsQuery(ids []string) *user.SearchQuery {
return &user.SearchQuery{
Query: &user.SearchQuery_InUserIdsQuery{
InUserIdsQuery: &user.InUserIDQuery{
UserIds: ids,
},
},
}
}
func InUserEmailsQuery(emails []string) *user.SearchQuery {
return &user.SearchQuery{
Query: &user.SearchQuery_InUserEmailsQuery{
InUserEmailsQuery: &user.InUserEmailsQuery{
UserEmails: emails,
},
},
}
}
func PhoneQuery(number string) *user.SearchQuery {
return &user.SearchQuery{
Query: &user.SearchQuery_PhoneQuery{
PhoneQuery: &user.PhoneQuery{
Number: number,
},
},
}
}
func UsernameQuery(username string) *user.SearchQuery {
return &user.SearchQuery{
Query: &user.SearchQuery_UserNameQuery{
UserNameQuery: &user.UserNameQuery{
UserName: username,
},
},
}
}
func OrganizationIdQuery(resourceowner string) *user.SearchQuery {
return &user.SearchQuery{
Query: &user.SearchQuery_OrganizationIdQuery{
OrganizationIdQuery: &user.OrganizationIdQuery{
OrganizationId: resourceowner,
},
},
}
}
func OrQuery(queries []*user.SearchQuery) *user.SearchQuery {
return &user.SearchQuery{
Query: &user.SearchQuery_OrQuery{
OrQuery: &user.OrQuery{
Queries: queries,
},
},
}
}
func MetadataKeyContainsQuery(metadataKey string) *user.SearchQuery {
return &user.SearchQuery{
Query: &user.SearchQuery_MetadataKeyFilter{
MetadataKeyFilter: &v2.MetadataKeyFilter{
Key: metadataKey,
Method: filter.TextFilterMethod_TEXT_FILTER_METHOD_STARTS_WITH},
},
}
}
func MetakeyEqualsQuery(metaKey string) *user.SearchQuery {
return &user.SearchQuery{
Query: &user.SearchQuery_MetadataKeyFilter{
MetadataKeyFilter: &v2.MetadataKeyFilter{
Key: metaKey,
Method: filter.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS},
},
}
}
func MetadataValueQuery(metaValue string) *user.SearchQuery {
return &user.SearchQuery{
Query: &user.SearchQuery_MetadataValueFilter{
MetadataValueFilter: &v2.MetadataValueFilter{
Value: []byte(base64.StdEncoding.EncodeToString([]byte(metaValue))),
Method: filter.ByteFilterMethod_BYTE_FILTER_METHOD_EQUALS,
},
},
}
}