Files
zitadel/internal/api/grpc/user/v2/integration_test/query_test.go
T
684de2e691 perf(query): use UNION of index seeks for ListUsers login equality (#12701)
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
# Which Problems Are Solved

Login V2 `ListUsers` lookups of the form username OR email (and email OR
phone) hash-join the full `users14` / `users14_humans` tables, then
filter. That cost grows with instance size (~40ms at 10k users, ~650ms
at 200k).

Top-level login-name equals already used an indexed join, but the same
login-name filter inside an `OrQuery` fell back to the slow view-based
exists query.

# How the Problems Are Solved

**TL;DR:** equality lookups on username, email, phone, or login name
become `INNER JOIN (UNION of indexed ID seeks) AS matches`. Mixed /
contains / name searches keep the old WHERE shape.

### Reviewer map

| Read first | Why |
| --- | --- |
| `internal/query/user_login_equality.go` | Typed filters + extractor +
UNION join |
| `internal/query/user.go` (`prepareUsersQuery`, constructors) | Wiring;
username/email/phone equals return `lowerEqualsFilter` |
| `cmd/setup/76.go` + `cmd/setup/76/*.sql` | Concurrent indexes for
existing deployments; drop invalid leftovers |
| `internal/query/projection/user.go` | Same indexes in `Init()` for new
databases |
| `internal/query/user_login_name_exists_test.go` | SQL-shape coverage |
| `internal/api/grpc/user/v2/integration_test/query_test.go` | Nested
username AND/OR user results |

### Query rewrite

- Extract one equals leaf, a flat `OrQuery` of those leaves, or the same
nested under `AndQuery`. Remaining AND conjuncts stay as WHERE. Equality
nested under `OrQuery` / `NotQuery` is left as WHERE.
- Login-name equals is a UNION arm that reuses `login_names3_users` SQL,
so it works inside OR as well as at the top level.
- EQUALS seeks use `LOWER(col)` for the index, then recheck `col =
value` so case-sensitive searches do not match extra rows.
EQUALS_IGNORE_CASE is unchanged.
- Empty OR arms are not skipped (that would change OR meaning); mixed
empty-OR falls back to WHERE. Login UI already omits empty arms.

### Indexes

- Setup 76: `CREATE INDEX CONCURRENTLY IF NOT EXISTS` on
`users14_username_lower_idx` `(instance_id, LOWER(username))` and
`users14_humans_phone_lower_idx` `(instance_id, LOWER(phone))`. Not
wrapped in a transaction.
- Before create, drop any leftover `pg_index.indisvalid = false` indexes
of those names (failed/cancelled `CREATE INDEX CONCURRENTLY`). `IF NOT
EXISTS` would otherwise skip rebuild.
- `Init()` creates the same indexes without `CONCURRENTLY` (empty
tables). Existing `users14_username` and email lower indexes are
unchanged.

# Additional Changes

- Unit tests assert UNION SQL for username OR email, email OR phone,
login name OR email, username OR username, nested `AndQuery(loginName,
org)`, and nested `AndQuery(Or(...), org)`; that contains / display-name
/ mixed OR / nested `OrQuery(AndQuery(...), ...)` do not UNION; that
email EQUALS rechecks the raw column; and that empty-OR is not
extracted.
- Setup 76 sqlmock coverage: missing `users14` skips work; valid/absent
indexes only create; invalid leftover username index is dropped then
both creates run.
- `TestServer_ListUsers_UsernameAndOr` checks live results: org AND
(alice OR bob) returns both users, sibling username AND is empty, nested
`AndQuery(Or(...), org)` returns both.

# Additional Context

- Base branch: `main`
- Extends the login-name equals rewrite from #12460 to
username/email/phone OR trees used by Login V2 search.

<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a
href="https://cursor.com/agents/bc-1f043b1c-6168-45ca-9e9e-c18451994207?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/background-agent?bcId=bc-1f043b1c-6168-45ca-9e9e-c18451994207&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img
alt="Open in Cursor" width="131" height="28"
src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Silvan <[email protected]>
2026-09-08 13:11:16 +00:00

1413 lines
43 KiB
Go

//go:build integration
package user_test
import (
"context"
"encoding/base64"
"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/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"
)
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, Instance, 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, Instance, 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{
OrgCTX,
&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, instance *integration.Instance, orgID string, count int, passwordChangeRequired bool) userAttrs {
infos := make([]userAttr, count)
for i := 0; i < count; i++ {
infos[i] = createUser(ctx, instance, orgID, passwordChangeRequired)
}
slices.Reverse(infos)
return infos
}
func createUser(ctx context.Context, instance *integration.Instance, orgID string, passwordChangeRequired bool) userAttr {
username := integration.Email()
return createUserWithUserName(ctx, instance, username, orgID, passwordChangeRequired)
}
func createUserWithUserName(ctx context.Context, instance *integration.Instance, 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) {
iamOwnerCtx := InstancePermissionV2.WithAuthorizationToken(OrgCTX, integration.UserTypeIAMOwner)
orgResp := InstancePermissionV2.CreateOrganization(iamOwnerCtx, 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 service account",
args: args{
InstancePermissionV2.WithAuthorizationToken(OrgCTX, integration.UserTypeNoPermission),
&user.ListUsersRequest{},
func(ctx context.Context, request *user.ListUsersRequest) userAttrs {
info := createUser(ctx, InstancePermissionV2, 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(iamOwnerCtx, InstancePermissionV2, orgResp.OrganizationId, true)
// create session to get token
userID := info.UserID
createResp, err := InstancePermissionV2.Client.SessionV2.CreateSession(iamOwnerCtx, &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(iamOwnerCtx, createResp.GetSessionToken())
return args{
HumanCTX,
&user.ListUsersRequest{},
func(ctx context.Context, request *user.ListUsersRequest) userAttrs {
return []userAttr{info}
},
}
}(),
want: &user.ListUsersResponse{ // User (Human) 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{
iamOwnerCtx,
&user.ListUsersRequest{},
func(ctx context.Context, request *user.ListUsersRequest) userAttrs {
info := createUser(ctx, InstancePermissionV2, 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{
iamOwnerCtx,
&user.ListUsersRequest{},
func(ctx context.Context, request *user.ListUsersRequest) userAttrs {
info := createUser(ctx, InstancePermissionV2, 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{
iamOwnerCtx,
&user.ListUsersRequest{},
func(ctx context.Context, request *user.ListUsersRequest) userAttrs {
infos := createUsers(ctx, InstancePermissionV2, orgResp.OrganizationId, 3, false)
request.Queries = []*user.SearchQuery{}
request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId))
request.Queries = append(request.Queries, InUserIDsQuery(infos.userIDs()))
InstancePermissionV2.SetUserMetadata(ctx, infos[0].UserID, "my meta", "my value 1")
InstancePermissionV2.SetUserMetadata(ctx, infos[1].UserID, "my meta 2", "my value 3")
InstancePermissionV2.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,
},
},
},
},
},
},
},
{
name: "list user by username, ok",
args: args{
iamOwnerCtx,
&user.ListUsersRequest{},
func(ctx context.Context, request *user.ListUsersRequest) userAttrs {
info := createUser(ctx, InstancePermissionV2, 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{
iamOwnerCtx,
&user.ListUsersRequest{},
func(ctx context.Context, request *user.ListUsersRequest) userAttrs {
info := createUser(ctx, InstancePermissionV2, 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{
iamOwnerCtx,
&user.ListUsersRequest{},
func(ctx context.Context, request *user.ListUsersRequest) userAttrs {
infos := createUsers(ctx, InstancePermissionV2, orgResp.OrganizationId, 3, false)
request.Queries = []*user.SearchQuery{}
request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId))
request.Queries = append(request.Queries, InUserEmailsQuery(infos.emails()))
InstancePermissionV2.SetUserMetadata(ctx, infos[0].UserID, "my meta 1", "my value")
InstancePermissionV2.SetUserMetadata(ctx, infos[0].UserID, "my meta 2", "my value")
InstancePermissionV2.SetUserMetadata(ctx, infos[1].UserID, "my meta 2", "my value")
InstancePermissionV2.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,
},
},
},
},
},
},
},
{
name: "list user in emails no found, ok",
args: args{
iamOwnerCtx,
&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{
iamOwnerCtx,
&user.ListUsersRequest{},
func(ctx context.Context, request *user.ListUsersRequest) userAttrs {
info := createUser(ctx, InstancePermissionV2, 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{
iamOwnerCtx,
&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{
iamOwnerCtx,
&user.ListUsersRequest{},
func(ctx context.Context, request *user.ListUsersRequest) userAttrs {
orgResp := InstancePermissionV2.CreateOrganization(ctx, integration.OrganizationName(), integration.Email())
infos := createUsers(ctx, InstancePermissionV2, 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{
iamOwnerCtx,
&user.ListUsersRequest{},
func(ctx context.Context, request *user.ListUsersRequest) userAttrs {
orgRespForOrgTests := InstancePermissionV2.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email())
info := createUser(ctx, InstancePermissionV2, 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 InstancePermissionV2.CreateOrganization()
nil,
},
},
},
{
name: "list user with wrong org query",
args: args{
iamOwnerCtx,
&user.ListUsersRequest{},
func(ctx context.Context, request *user.ListUsersRequest) userAttrs {
orgRespForOrgTests := InstancePermissionV2.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email())
orgRespForOrgTests2 := InstancePermissionV2.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email())
createUser(ctx, InstancePermissionV2, orgRespForOrgTests.OrganizationId, false)
request.Queries = []*user.SearchQuery{}
request.Queries = append(request.Queries, OrganizationIdQuery(orgRespForOrgTests2.OrganizationId))
return []userAttr{{}}
},
},
want: &user.ListUsersResponse{
Details: &object.ListDetails{
TotalResult: 1,
Timestamp: timestamppb.Now(),
},
SortingColumn: 0,
Result: []*user.User{
// this is the admin of the org created in InstancePermissionV2.CreateOrganization()
nil,
},
},
},
{
name: "when no users matching meta key should return empty list",
args: args{
iamOwnerCtx,
&user.ListUsersRequest{},
func(ctx context.Context, request *user.ListUsersRequest) userAttrs {
createUser(ctx, InstancePermissionV2, 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 _, tc := range tt {
t.Run(tc.name, func(t1 *testing.T) {
infos := tc.args.dep(iamOwnerCtx, tc.args.req)
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.args.ctx, 20*time.Second)
require.EventuallyWithT(t1, func(ttt *assert.CollectT) {
got, err := InstancePermissionV2.Client.UserV2.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)) {
// 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) {
iamOwnerCtx := InstancePermissionV2.WithAuthorizationToken(OrgCTX, integration.UserTypeIAMOwner)
org1 := InstancePermissionV2.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email())
org2 := InstancePermissionV2.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), "[email protected]")
org3 := InstancePermissionV2.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email())
_ = createUserWithUserName(iamOwnerCtx, InstancePermissionV2, "[email protected]", org1.OrganizationId, false)
_ = createUserWithUserName(iamOwnerCtx, InstancePermissionV2, "[email protected]", org2.OrganizationId, false)
_ = createUserWithUserName(iamOwnerCtx, InstancePermissionV2, "[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 _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.ctx, 1*time.Minute)
require.EventuallyWithT(t, func(ttt *assert.CollectT) {
got, err := InstancePermissionV2.Client.UserV2.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 TestServer_ListUsers_UsernameAndOr(t *testing.T) {
iamOwnerCtx := InstancePermissionV2.WithAuthorizationToken(OrgCTX, integration.UserTypeIAMOwner)
orgResp := InstancePermissionV2.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email())
alice := createUser(iamOwnerCtx, InstancePermissionV2, orgResp.OrganizationId, false)
bob := createUser(iamOwnerCtx, InstancePermissionV2, orgResp.OrganizationId, false)
orgQuery := OrganizationIdQuery(orgResp.OrganizationId)
tests := []struct {
name string
queries []*user.SearchQuery
wantUsernames []string
}{
{
name: "org and username or username",
queries: []*user.SearchQuery{
orgQuery,
OrQuery([]*user.SearchQuery{UsernameQuery(alice.Username), UsernameQuery(bob.Username)}),
},
wantUsernames: []string{alice.Username, bob.Username},
},
{
name: "org and sibling usernames",
queries: []*user.SearchQuery{
orgQuery,
UsernameQuery(alice.Username),
UsernameQuery(bob.Username),
},
},
{
name: "nested and of or and org",
queries: []*user.SearchQuery{
AndQuery([]*user.SearchQuery{
OrQuery([]*user.SearchQuery{UsernameQuery(alice.Username), UsernameQuery(bob.Username)}),
orgQuery,
}),
},
wantUsernames: []string{alice.Username, bob.Username},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := &user.ListUsersRequest{Queries: tt.queries}
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(iamOwnerCtx, 20*time.Second)
require.EventuallyWithT(t, func(ttt *assert.CollectT) {
got, err := InstancePermissionV2.Client.UserV2.ListUsers(iamOwnerCtx, req)
require.NoError(ttt, err)
gotUsernames := make([]string, 0, len(got.GetResult()))
for _, u := range got.GetResult() {
gotUsernames = append(gotUsernames, u.GetUsername())
}
assert.ElementsMatch(ttt, tt.wantUsernames, gotUsernames)
}, 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 AndQuery(queries []*user.SearchQuery) *user.SearchQuery {
return &user.SearchQuery{
Query: &user.SearchQuery_AndQuery{
AndQuery: &user.AndQuery{
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,
},
},
}
}