mirror of
https://github.com/zitadel/zitadel.git
synced 2025-11-02 07:58:46 +00:00
feat: List users by metadata (#10415)
# Which Problems Are Solved
Some users have reported the need of retrieving users given a metadata
key, metadata value or both. This change introduces metadata search
filter on the `ListUsers()` endpoint to allow Zitadel users to search
for user records by metadata.
The changes affect only v2 APIs.
# How the Problems Are Solved
- Add new search filter to `ListUserRequest`: `MetaKey` and `MetaValue`
- Add SQL indices on metadata key and metadata value
- Update query to left join `user_metadata` table
# Additional Context
- Closes #9053
- Depends on https://github.com/zitadel/zitadel/pull/10567
---------
Co-authored-by: Silvan <27845747+adlerhurst@users.noreply.github.com>
Co-authored-by: Stefan Benz <46600784+stebenz@users.noreply.github.com>
(cherry picked from commit 8df402fb4f)
This commit is contained in:
@@ -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 {
|
||||
|
||||
164
internal/api/grpc/filter/v2/converter_test.go
Normal file
164
internal/api/grpc/filter/v2/converter_test.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
|
||||
func TestServer_Limits_AuditLogRetention(t *testing.T) {
|
||||
isoInstance := integration.NewInstance(CTX)
|
||||
iamOwnerCtx := isoInstance.WithAuthorization(CTX, integration.UserTypeIAMOwner)
|
||||
iamOwnerCtx := isoInstance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner)
|
||||
userID, projectID, appID, projectGrantID := seedObjects(iamOwnerCtx, t, isoInstance.Client)
|
||||
beforeTime := time.Now()
|
||||
farPast := timestamppb.New(beforeTime.Add(-10 * time.Hour).UTC())
|
||||
@@ -37,7 +37,7 @@ func TestServer_Limits_AuditLogRetention(t *testing.T) {
|
||||
}, "wait for added event assertions to pass")
|
||||
_, err := integration.SystemClient().SetLimits(CTX, &system.SetLimitsRequest{
|
||||
InstanceId: isoInstance.ID(),
|
||||
AuditLogRetention: durationpb.New(time.Now().Sub(beforeTime)),
|
||||
AuditLogRetention: durationpb.New(time.Since(beforeTime)),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
var limitedCounts *eventCounts
|
||||
|
||||
@@ -4,6 +4,7 @@ package user_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
@@ -16,6 +17,8 @@ import (
|
||||
|
||||
"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"
|
||||
@@ -630,6 +633,13 @@ func TestServer_ListUsers(t *testing.T) {
|
||||
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
|
||||
},
|
||||
},
|
||||
@@ -807,6 +817,15 @@ func TestServer_ListUsers(t *testing.T) {
|
||||
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
|
||||
},
|
||||
},
|
||||
@@ -1131,6 +1150,30 @@ func TestServer_ListUsers(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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 {
|
||||
@@ -1324,3 +1367,44 @@ func OrganizationIdQuery(resourceowner string) *user.SearchQuery {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,11 +44,11 @@ func TestMain(m *testing.M) {
|
||||
|
||||
Instance = integration.NewInstance(ctx)
|
||||
|
||||
UserCTX = Instance.WithAuthorization(ctx, integration.UserTypeNoPermission)
|
||||
IamCTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner)
|
||||
LoginCTX = Instance.WithAuthorization(ctx, integration.UserTypeLogin)
|
||||
UserCTX = Instance.WithAuthorizationToken(ctx, integration.UserTypeNoPermission)
|
||||
IamCTX = Instance.WithAuthorizationToken(ctx, integration.UserTypeIAMOwner)
|
||||
LoginCTX = Instance.WithAuthorizationToken(ctx, integration.UserTypeLogin)
|
||||
SystemCTX = integration.WithSystemAuthorization(ctx)
|
||||
CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner)
|
||||
CTX = Instance.WithAuthorizationToken(ctx, integration.UserTypeOrgOwner)
|
||||
Client = Instance.Client.UserV2beta
|
||||
return m.Run()
|
||||
}())
|
||||
|
||||
@@ -49,6 +49,8 @@ func (*userMetadataProjection) Init() *old_handler.Check {
|
||||
},
|
||||
handler.NewPrimaryKey(UserMetadataColumnInstanceID, UserMetadataColumnUserID, UserMetadataColumnKey),
|
||||
handler.WithIndex(handler.NewIndex("resource_owner", []string{UserGrantResourceOwner})),
|
||||
handler.WithIndex(handler.NewIndex("metadata_key", []string{UserMetadataColumnKey})),
|
||||
handler.WithIndex(handler.NewIndex("metadata_value", []string{UserMetadataColumnValue})),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -863,27 +863,7 @@ func scanUser(row *sql.Row) (*User, error) {
|
||||
var count int
|
||||
preferredLoginName := sql.NullString{}
|
||||
|
||||
humanID := sql.NullString{}
|
||||
firstName := sql.NullString{}
|
||||
lastName := sql.NullString{}
|
||||
nickName := sql.NullString{}
|
||||
displayName := sql.NullString{}
|
||||
preferredLanguage := sql.NullString{}
|
||||
gender := sql.NullInt32{}
|
||||
avatarKey := sql.NullString{}
|
||||
email := sql.NullString{}
|
||||
isEmailVerified := sql.NullBool{}
|
||||
phone := sql.NullString{}
|
||||
isPhoneVerified := sql.NullBool{}
|
||||
passwordChangeRequired := sql.NullBool{}
|
||||
passwordChanged := sql.NullTime{}
|
||||
mfaInitSkipped := sql.NullTime{}
|
||||
|
||||
machineID := sql.NullString{}
|
||||
name := sql.NullString{}
|
||||
description := sql.NullString{}
|
||||
encodedHash := sql.NullString{}
|
||||
accessTokenType := sql.NullInt32{}
|
||||
human, machine := sqlHuman{}, sqlMachine{}
|
||||
|
||||
err := row.Scan(
|
||||
&u.ID,
|
||||
@@ -896,26 +876,26 @@ func scanUser(row *sql.Row) (*User, error) {
|
||||
&u.Username,
|
||||
&u.LoginNames,
|
||||
&preferredLoginName,
|
||||
&humanID,
|
||||
&firstName,
|
||||
&lastName,
|
||||
&nickName,
|
||||
&displayName,
|
||||
&preferredLanguage,
|
||||
&gender,
|
||||
&avatarKey,
|
||||
&email,
|
||||
&isEmailVerified,
|
||||
&phone,
|
||||
&isPhoneVerified,
|
||||
&passwordChangeRequired,
|
||||
&passwordChanged,
|
||||
&mfaInitSkipped,
|
||||
&machineID,
|
||||
&name,
|
||||
&description,
|
||||
&encodedHash,
|
||||
&accessTokenType,
|
||||
&human.humanID,
|
||||
&human.firstName,
|
||||
&human.lastName,
|
||||
&human.nickName,
|
||||
&human.displayName,
|
||||
&human.preferredLanguage,
|
||||
&human.gender,
|
||||
&human.avatarKey,
|
||||
&human.email,
|
||||
&human.isEmailVerified,
|
||||
&human.phone,
|
||||
&human.isPhoneVerified,
|
||||
&human.passwordChangeRequired,
|
||||
&human.passwordChanged,
|
||||
&human.mfaInitSkipped,
|
||||
&machine.machineID,
|
||||
&machine.name,
|
||||
&machine.description,
|
||||
&machine.encodedSecret,
|
||||
&machine.accessTokenType,
|
||||
&count,
|
||||
)
|
||||
|
||||
@@ -928,29 +908,29 @@ func scanUser(row *sql.Row) (*User, error) {
|
||||
|
||||
u.PreferredLoginName = preferredLoginName.String
|
||||
|
||||
if humanID.Valid {
|
||||
if human.humanID.Valid {
|
||||
u.Human = &Human{
|
||||
FirstName: firstName.String,
|
||||
LastName: lastName.String,
|
||||
NickName: nickName.String,
|
||||
DisplayName: displayName.String,
|
||||
AvatarKey: avatarKey.String,
|
||||
PreferredLanguage: language.Make(preferredLanguage.String),
|
||||
Gender: domain.Gender(gender.Int32),
|
||||
Email: domain.EmailAddress(email.String),
|
||||
IsEmailVerified: isEmailVerified.Bool,
|
||||
Phone: domain.PhoneNumber(phone.String),
|
||||
IsPhoneVerified: isPhoneVerified.Bool,
|
||||
PasswordChangeRequired: passwordChangeRequired.Bool,
|
||||
PasswordChanged: passwordChanged.Time,
|
||||
MFAInitSkipped: mfaInitSkipped.Time,
|
||||
FirstName: human.firstName.String,
|
||||
LastName: human.lastName.String,
|
||||
NickName: human.nickName.String,
|
||||
DisplayName: human.displayName.String,
|
||||
AvatarKey: human.avatarKey.String,
|
||||
PreferredLanguage: language.Make(human.preferredLanguage.String),
|
||||
Gender: domain.Gender(human.gender.Int32),
|
||||
Email: domain.EmailAddress(human.email.String),
|
||||
IsEmailVerified: human.isEmailVerified.Bool,
|
||||
Phone: domain.PhoneNumber(human.phone.String),
|
||||
IsPhoneVerified: human.isPhoneVerified.Bool,
|
||||
PasswordChangeRequired: human.passwordChangeRequired.Bool,
|
||||
PasswordChanged: human.passwordChanged.Time,
|
||||
MFAInitSkipped: human.mfaInitSkipped.Time,
|
||||
}
|
||||
} else if machineID.Valid {
|
||||
} else if machine.machineID.Valid {
|
||||
u.Machine = &Machine{
|
||||
Name: name.String,
|
||||
Description: description.String,
|
||||
EncodedSecret: encodedHash.String,
|
||||
AccessTokenType: domain.OIDCTokenType(accessTokenType.Int32),
|
||||
Name: machine.name.String,
|
||||
Description: machine.description.String,
|
||||
EncodedSecret: machine.encodedSecret.String,
|
||||
AccessTokenType: domain.OIDCTokenType(machine.accessTokenType.Int32),
|
||||
}
|
||||
}
|
||||
return u, nil
|
||||
@@ -1316,15 +1296,18 @@ func prepareUsersQuery() (sq.SelectBuilder, func(*sql.Rows) (*Users, error)) {
|
||||
HumanIsPhoneVerifiedCol.identifier(),
|
||||
HumanPasswordChangeRequiredCol.identifier(),
|
||||
HumanPasswordChangedCol.identifier(),
|
||||
HumanMFAInitSkippedCol.identifier(),
|
||||
MachineUserIDCol.identifier(),
|
||||
MachineNameCol.identifier(),
|
||||
MachineDescriptionCol.identifier(),
|
||||
MachineSecretCol.identifier(),
|
||||
MachineAccessTokenTypeCol.identifier(),
|
||||
countColumn.identifier()).
|
||||
Distinct().
|
||||
From(userTable.identifier()).
|
||||
LeftJoin(join(HumanUserIDCol, UserIDCol)).
|
||||
LeftJoin(join(MachineUserIDCol, UserIDCol)).
|
||||
LeftJoin(join(UserMetadataUserIDCol, UserIDCol)).
|
||||
JoinClause(joinLoginNames).
|
||||
PlaceholderFormat(sq.Dollar),
|
||||
func(rows *sql.Rows) (*Users, error) {
|
||||
@@ -1335,26 +1318,7 @@ func prepareUsersQuery() (sq.SelectBuilder, func(*sql.Rows) (*Users, error)) {
|
||||
loginNames := database.TextArray[string]{}
|
||||
preferredLoginName := sql.NullString{}
|
||||
|
||||
humanID := sql.NullString{}
|
||||
firstName := sql.NullString{}
|
||||
lastName := sql.NullString{}
|
||||
nickName := sql.NullString{}
|
||||
displayName := sql.NullString{}
|
||||
preferredLanguage := sql.NullString{}
|
||||
gender := sql.NullInt32{}
|
||||
avatarKey := sql.NullString{}
|
||||
email := sql.NullString{}
|
||||
isEmailVerified := sql.NullBool{}
|
||||
phone := sql.NullString{}
|
||||
isPhoneVerified := sql.NullBool{}
|
||||
passwordChangeRequired := sql.NullBool{}
|
||||
passwordChanged := sql.NullTime{}
|
||||
|
||||
machineID := sql.NullString{}
|
||||
name := sql.NullString{}
|
||||
description := sql.NullString{}
|
||||
encodedHash := sql.NullString{}
|
||||
accessTokenType := sql.NullInt32{}
|
||||
human, machine := sqlHuman{}, sqlMachine{}
|
||||
|
||||
err := rows.Scan(
|
||||
&u.ID,
|
||||
@@ -1367,25 +1331,29 @@ func prepareUsersQuery() (sq.SelectBuilder, func(*sql.Rows) (*Users, error)) {
|
||||
&u.Username,
|
||||
&loginNames,
|
||||
&preferredLoginName,
|
||||
&humanID,
|
||||
&firstName,
|
||||
&lastName,
|
||||
&nickName,
|
||||
&displayName,
|
||||
&preferredLanguage,
|
||||
&gender,
|
||||
&avatarKey,
|
||||
&email,
|
||||
&isEmailVerified,
|
||||
&phone,
|
||||
&isPhoneVerified,
|
||||
&passwordChangeRequired,
|
||||
&passwordChanged,
|
||||
&machineID,
|
||||
&name,
|
||||
&description,
|
||||
&encodedHash,
|
||||
&accessTokenType,
|
||||
|
||||
&human.humanID,
|
||||
&human.firstName,
|
||||
&human.lastName,
|
||||
&human.nickName,
|
||||
&human.displayName,
|
||||
&human.preferredLanguage,
|
||||
&human.gender,
|
||||
&human.avatarKey,
|
||||
&human.email,
|
||||
&human.isEmailVerified,
|
||||
&human.phone,
|
||||
&human.isPhoneVerified,
|
||||
&human.passwordChangeRequired,
|
||||
&human.passwordChanged,
|
||||
&human.mfaInitSkipped,
|
||||
|
||||
&machine.machineID,
|
||||
&machine.name,
|
||||
&machine.description,
|
||||
&machine.encodedSecret,
|
||||
&machine.accessTokenType,
|
||||
|
||||
&count,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -1397,28 +1365,29 @@ func prepareUsersQuery() (sq.SelectBuilder, func(*sql.Rows) (*Users, error)) {
|
||||
u.PreferredLoginName = preferredLoginName.String
|
||||
}
|
||||
|
||||
if humanID.Valid {
|
||||
if human.humanID.Valid {
|
||||
u.Human = &Human{
|
||||
FirstName: firstName.String,
|
||||
LastName: lastName.String,
|
||||
NickName: nickName.String,
|
||||
DisplayName: displayName.String,
|
||||
AvatarKey: avatarKey.String,
|
||||
PreferredLanguage: language.Make(preferredLanguage.String),
|
||||
Gender: domain.Gender(gender.Int32),
|
||||
Email: domain.EmailAddress(email.String),
|
||||
IsEmailVerified: isEmailVerified.Bool,
|
||||
Phone: domain.PhoneNumber(phone.String),
|
||||
IsPhoneVerified: isPhoneVerified.Bool,
|
||||
PasswordChangeRequired: passwordChangeRequired.Bool,
|
||||
PasswordChanged: passwordChanged.Time,
|
||||
FirstName: human.firstName.String,
|
||||
LastName: human.lastName.String,
|
||||
NickName: human.nickName.String,
|
||||
DisplayName: human.displayName.String,
|
||||
AvatarKey: human.avatarKey.String,
|
||||
PreferredLanguage: language.Make(human.preferredLanguage.String),
|
||||
Gender: domain.Gender(human.gender.Int32),
|
||||
Email: domain.EmailAddress(human.email.String),
|
||||
IsEmailVerified: human.isEmailVerified.Bool,
|
||||
Phone: domain.PhoneNumber(human.phone.String),
|
||||
IsPhoneVerified: human.isPhoneVerified.Bool,
|
||||
PasswordChangeRequired: human.passwordChangeRequired.Bool,
|
||||
PasswordChanged: human.passwordChanged.Time,
|
||||
MFAInitSkipped: human.mfaInitSkipped.Time,
|
||||
}
|
||||
} else if machineID.Valid {
|
||||
} else if machine.machineID.Valid {
|
||||
u.Machine = &Machine{
|
||||
Name: name.String,
|
||||
Description: description.String,
|
||||
EncodedSecret: encodedHash.String,
|
||||
AccessTokenType: domain.OIDCTokenType(accessTokenType.Int32),
|
||||
Name: machine.name.String,
|
||||
Description: machine.description.String,
|
||||
EncodedSecret: machine.encodedSecret.String,
|
||||
AccessTokenType: domain.OIDCTokenType(machine.accessTokenType.Int32),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1437,3 +1406,29 @@ func prepareUsersQuery() (sq.SelectBuilder, func(*sql.Rows) (*Users, error)) {
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
type sqlHuman struct {
|
||||
humanID sql.NullString
|
||||
firstName sql.NullString
|
||||
lastName sql.NullString
|
||||
nickName sql.NullString
|
||||
displayName sql.NullString
|
||||
preferredLanguage sql.NullString
|
||||
gender sql.NullInt32
|
||||
avatarKey sql.NullString
|
||||
email sql.NullString
|
||||
isEmailVerified sql.NullBool
|
||||
phone sql.NullString
|
||||
isPhoneVerified sql.NullBool
|
||||
passwordChangeRequired sql.NullBool
|
||||
passwordChanged sql.NullTime
|
||||
mfaInitSkipped sql.NullTime
|
||||
}
|
||||
|
||||
type sqlMachine struct {
|
||||
machineID sql.NullString
|
||||
name sql.NullString
|
||||
description sql.NullString
|
||||
encodedSecret sql.NullString
|
||||
accessTokenType sql.NullInt32
|
||||
}
|
||||
|
||||
@@ -366,7 +366,7 @@ var (
|
||||
"password_set",
|
||||
"count",
|
||||
}
|
||||
usersQuery = `SELECT projections.users14.id,` +
|
||||
usersQuery = `SELECT DISTINCT projections.users14.id,` +
|
||||
` projections.users14.creation_date,` +
|
||||
` projections.users14.change_date,` +
|
||||
` projections.users14.resource_owner,` +
|
||||
@@ -390,6 +390,7 @@ var (
|
||||
` projections.users14_humans.is_phone_verified,` +
|
||||
` projections.users14_humans.password_change_required,` +
|
||||
` projections.users14_humans.password_changed,` +
|
||||
` projections.users14_humans.mfa_init_skipped,` +
|
||||
` projections.users14_machines.user_id,` +
|
||||
` projections.users14_machines.name,` +
|
||||
` projections.users14_machines.description,` +
|
||||
@@ -399,6 +400,7 @@ var (
|
||||
` FROM projections.users14` +
|
||||
` LEFT JOIN projections.users14_humans ON projections.users14.id = projections.users14_humans.user_id AND projections.users14.instance_id = projections.users14_humans.instance_id` +
|
||||
` LEFT JOIN projections.users14_machines ON projections.users14.id = projections.users14_machines.user_id AND projections.users14.instance_id = projections.users14_machines.instance_id` +
|
||||
` LEFT JOIN projections.user_metadata5 ON projections.users14.id = projections.user_metadata5.user_id AND projections.users14.instance_id = projections.user_metadata5.instance_id` +
|
||||
` LEFT JOIN LATERAL (SELECT ARRAY_AGG(ln.login_name ORDER BY ln.login_name) AS login_names, MAX(CASE WHEN ln.is_primary THEN ln.login_name ELSE NULL END) AS preferred_login_name FROM projections.login_names3 AS ln WHERE ln.user_id = projections.users14.id AND ln.instance_id = projections.users14.instance_id) AS login_names ON TRUE`
|
||||
usersCols = []string{
|
||||
"id",
|
||||
@@ -426,6 +428,7 @@ var (
|
||||
"is_phone_verified",
|
||||
"password_change_required",
|
||||
"password_changed",
|
||||
"mfa_init_skipped",
|
||||
// machine
|
||||
"user_id",
|
||||
"name",
|
||||
@@ -992,6 +995,7 @@ func Test_UserPrepares(t *testing.T) {
|
||||
true,
|
||||
true,
|
||||
testNow,
|
||||
testNow,
|
||||
// machine
|
||||
nil,
|
||||
nil,
|
||||
@@ -1032,6 +1036,7 @@ func Test_UserPrepares(t *testing.T) {
|
||||
IsPhoneVerified: true,
|
||||
PasswordChangeRequired: true,
|
||||
PasswordChanged: testNow,
|
||||
MFAInitSkipped: testNow,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1071,6 +1076,7 @@ func Test_UserPrepares(t *testing.T) {
|
||||
true,
|
||||
true,
|
||||
testNow,
|
||||
testNow,
|
||||
// machine
|
||||
nil,
|
||||
nil,
|
||||
@@ -1104,6 +1110,7 @@ func Test_UserPrepares(t *testing.T) {
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
// machine
|
||||
"id",
|
||||
"name",
|
||||
@@ -1144,6 +1151,7 @@ func Test_UserPrepares(t *testing.T) {
|
||||
IsPhoneVerified: true,
|
||||
PasswordChangeRequired: true,
|
||||
PasswordChanged: testNow,
|
||||
MFAInitSkipped: testNow,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -61,6 +61,21 @@ machine_jwt_profile_grant_single_user: ensure_modules ensure_key_pair bundle
|
||||
cd ../../xk6-modules && xk6 build --with xk6-zitadel=.
|
||||
${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/machine_jwt_profile_grant_single_user.js --vus ${VUS} --duration ${DURATION} --out csv=output/machine_jwt_profile_grant_single_user_${DATE}.csv
|
||||
|
||||
.PHONY: verify_all_user_grants_exist
|
||||
verify_all_user_grants_exist: ensure_modules bundle
|
||||
${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/verify_all_user_grants_exist.js --vus ${VUS} --duration ${DURATION}
|
||||
# --out csv=output/verify_all_user_grants_exist_${DATE}.csv
|
||||
|
||||
.PHONY: users_by_metadata_key
|
||||
users_by_metadata_key: ensure_modules bundle
|
||||
${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/users_by_metadata_key.js --vus ${VUS} --duration ${DURATION}
|
||||
# --out csv=output/users_by_metadata_${DATE}.csv
|
||||
|
||||
.PHONY: users_by_metadata_value
|
||||
users_by_metadata_value: ensure_modules bundle
|
||||
${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/users_by_metadata_value.js --vus ${VUS} --duration ${DURATION}
|
||||
# --out csv=output/users_by_metadata_${DATE}.csv
|
||||
|
||||
.PHONY: lint
|
||||
lint:
|
||||
npm i
|
||||
|
||||
11
load-test/package-lock.json
generated
11
load-test/package-lock.json
generated
@@ -14,7 +14,7 @@
|
||||
"@babel/plugin-proposal-object-rest-spread": "7.13.8",
|
||||
"@babel/preset-env": "7.23.8",
|
||||
"@babel/preset-typescript": "7.23.3",
|
||||
"@types/k6": ">=0.50.0",
|
||||
"@types/k6": ">=1.2.0",
|
||||
"@types/webpack": "5.28.5",
|
||||
"babel-loader": "9.1.3",
|
||||
"clean-webpack-plugin": "4.0.0",
|
||||
@@ -1880,10 +1880,11 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/k6": {
|
||||
"version": "0.54.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/k6/-/k6-0.54.1.tgz",
|
||||
"integrity": "sha512-ezTMhuWr3TZIMOqAv51/4rD5T4FE1pSryrh5BCgxsniuqxbi5jQ3YEiOlO9C1+LvJcliC2byyd2Cw6cnUY7CLg==",
|
||||
"dev": true
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/k6/-/k6-1.2.0.tgz",
|
||||
"integrity": "sha512-7F/vjekOUCzFi5AY+yYBOL38iEtN9EXufdKOAMFkJsdLryJ3/bB/W9jppED1FO5o7zeaPf8M46ardYPD8xgy/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/minimatch": {
|
||||
"version": "5.1.2",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"@babel/plugin-proposal-object-rest-spread": "7.13.8",
|
||||
"@babel/preset-env": "7.23.8",
|
||||
"@babel/preset-typescript": "7.23.3",
|
||||
"@types/k6": ">=0.50.0",
|
||||
"@types/k6": ">=1.2.0",
|
||||
"@types/webpack": "5.28.5",
|
||||
"babel-loader": "9.1.3",
|
||||
"clean-webpack-plugin": "4.0.0",
|
||||
|
||||
@@ -86,7 +86,7 @@ function enterPassword(page: Response, user: User): Response {
|
||||
}
|
||||
|
||||
check(response, {
|
||||
'password status ok': (r) => r.status >= 200 && r.status < 300 || fail('enter password failed'),
|
||||
'password status ok': (r) => (r.status >= 200 && r.status < 300) || fail('enter password failed'),
|
||||
'password callback': (r) =>
|
||||
r.url.startsWith(url('/ui/console/auth/callback?code=')) || fail(`wrong password callback: ${r.url}`),
|
||||
});
|
||||
@@ -117,7 +117,8 @@ function token(code = '') {
|
||||
|
||||
tokenTrend.add(response.timings.duration);
|
||||
check(response, {
|
||||
'token status ok': (r) => r.status >= 200 && r.status < 300 || fail(`invalid token response status: ${r.status} body: ${r.body}`),
|
||||
'token status ok': (r) =>
|
||||
(r.status >= 200 && r.status < 300) || fail(`invalid token response status: ${r.status} body: ${r.body}`),
|
||||
});
|
||||
const token = new Tokens(response.json() as JSONObject);
|
||||
check(token, {
|
||||
|
||||
@@ -19,7 +19,8 @@ export async function addIAMMember(userId: string, roles: string[], accessToken:
|
||||
},
|
||||
);
|
||||
check(res, {
|
||||
'member added successful': (r) => r.status >= 200 && r.status < 300 || fail(`unable add member: ${JSON.stringify(res)}`),
|
||||
'member added successful': (r) =>
|
||||
(r.status >= 200 && r.status < 300) || fail(`unable add member: ${JSON.stringify(res)}`),
|
||||
});
|
||||
addIAMMemberTrend.add(res.timings.duration);
|
||||
}
|
||||
|
||||
4
load-test/src/metadata.ts
Normal file
4
load-test/src/metadata.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export type Metadata = {
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
@@ -30,7 +30,7 @@ function configuration() {
|
||||
|
||||
const res = http.get(url('/.well-known/openid-configuration'));
|
||||
check(res, {
|
||||
'openid configuration': (r) => r.status >= 200 && r.status < 300 || fail('unable to load openid configuration'),
|
||||
'openid configuration': (r) => (r.status >= 200 && r.status < 300) || fail('unable to load openid configuration'),
|
||||
});
|
||||
|
||||
oidcConfig = res.json();
|
||||
@@ -176,7 +176,8 @@ export async function authRequestByID(id: string, tokens: any): Promise<Response
|
||||
},
|
||||
});
|
||||
check(response, {
|
||||
'authorize status ok': (r) => r.status >= 200 && r.status < 300 || fail(`auth request by failed: ${JSON.stringify(r)}`),
|
||||
'authorize status ok': (r) =>
|
||||
(r.status >= 200 && r.status < 300) || fail(`auth request by failed: ${JSON.stringify(r)}`),
|
||||
});
|
||||
authRequestByIDTrend.add(response.timings.duration);
|
||||
return response;
|
||||
@@ -202,7 +203,8 @@ export async function finalizeAuthRequest(id: string, session: any, tokens: any)
|
||||
},
|
||||
);
|
||||
check(res, {
|
||||
'finalize auth request status ok': (r) => r.status >= 200 && r.status < 300 || fail(`finalize auth request failed: ${JSON.stringify(r)}`),
|
||||
'finalize auth request status ok': (r) =>
|
||||
(r.status >= 200 && r.status < 300) || fail(`finalize auth request failed: ${JSON.stringify(r)}`),
|
||||
});
|
||||
finalizeAuthRequestTrend.add(res.timings.duration);
|
||||
|
||||
|
||||
65
load-test/src/use_cases/users_by_metadata_key.ts
Normal file
65
load-test/src/use_cases/users_by_metadata_key.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { loginByUsernamePassword } from '../login_ui';
|
||||
import { createOrg, removeOrg } from '../org';
|
||||
import { createHuman, User, createMachine, setUserMetadata, listUsers } from '../user';
|
||||
import { Config } from '../config';
|
||||
import { check } from 'k6';
|
||||
import encoding from 'k6/encoding';
|
||||
|
||||
const userAmount = parseInt(__ENV.USER_AMOUNT) || 2500;
|
||||
|
||||
export async function setup() {
|
||||
const tokens = loginByUsernamePassword(Config.admin as User);
|
||||
console.info('setup: admin signed in');
|
||||
|
||||
const org = await createOrg(tokens.accessToken!);
|
||||
console.info(`setup: org (${org.organizationId}) created`);
|
||||
|
||||
const users: User[] = [];
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: userAmount }, async (_, i) => {
|
||||
let user: User;
|
||||
let type: 'human' | 'machine';
|
||||
if (i % 2 === 0) {
|
||||
user = await createHuman(`zitizen-${i}`, org, tokens.accessToken!);
|
||||
type = 'human';
|
||||
} else {
|
||||
user = await createMachine(`zitachine-${i}`, org, tokens.accessToken!);
|
||||
type = 'machine';
|
||||
}
|
||||
users.push(user);
|
||||
await setUserMetadata(
|
||||
[
|
||||
{ key: 'type', value: encoding.b64encode(type, 'rawurl') },
|
||||
{ key: 'org', value: encoding.b64encode(org.organizationId, 'rawurl') },
|
||||
{ key: 'id', value: encoding.b64encode(user.userId, 'rawurl') },
|
||||
],
|
||||
user.userId,
|
||||
tokens.accessToken!,
|
||||
);
|
||||
if (i % 10 === 0) {
|
||||
console.log(`setup: ${i} of ${userAmount} users setup`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
console.info(`setup: ${users.length} users created`);
|
||||
|
||||
return { tokens, org, users };
|
||||
}
|
||||
|
||||
export default async function (data: any) {
|
||||
const result = await listUsers(
|
||||
{
|
||||
queries: [{ metadataKeyFilter: { key: 'org', method: 'TEXT_FILTER_METHOD_EQUALS' } }],
|
||||
},
|
||||
data.tokens.accessToken!,
|
||||
);
|
||||
check(result, {
|
||||
'total result length': (res) => res.details.totalResult == userAmount,
|
||||
}) || console.log(`unexpected amount of users. expected ${userAmount} but got ${result.details.totalResult}`);
|
||||
}
|
||||
|
||||
export function teardown(data: any) {
|
||||
removeOrg(data.org, data.tokens.accessToken);
|
||||
console.info('teardown: org removed');
|
||||
}
|
||||
67
load-test/src/use_cases/users_by_metadata_value.ts
Normal file
67
load-test/src/use_cases/users_by_metadata_value.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { loginByUsernamePassword } from '../login_ui';
|
||||
import { createOrg, removeOrg } from '../org';
|
||||
import { createHuman, User, createMachine, setUserMetadata, listUsers } from '../user';
|
||||
import { Config } from '../config';
|
||||
import { check } from 'k6';
|
||||
import encoding from 'k6/encoding';
|
||||
|
||||
const userAmount = parseInt(__ENV.USER_AMOUNT) || 2500;
|
||||
|
||||
export async function setup() {
|
||||
const tokens = loginByUsernamePassword(Config.admin as User);
|
||||
console.info('setup: admin signed in');
|
||||
|
||||
const org = await createOrg(tokens.accessToken!);
|
||||
console.info(`setup: org (${org.organizationId}) created`);
|
||||
|
||||
const users: User[] = [];
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: userAmount }, async (_, i) => {
|
||||
let user: User;
|
||||
let type: 'human' | 'machine';
|
||||
if (i % 2 === 0) {
|
||||
user = await createHuman(`zitizen-${i}`, org, tokens.accessToken!);
|
||||
type = 'human';
|
||||
} else {
|
||||
user = await createMachine(`zitachine-${i}`, org, tokens.accessToken!);
|
||||
type = 'machine';
|
||||
}
|
||||
users.push(user);
|
||||
await setUserMetadata(
|
||||
[
|
||||
{ key: 'type', value: encoding.b64encode(type, 'rawurl') },
|
||||
{ key: 'org', value: encoding.b64encode(org.organizationId, 'rawurl') },
|
||||
{ key: 'id', value: encoding.b64encode(user.userId, 'rawurl') },
|
||||
],
|
||||
user.userId,
|
||||
tokens.accessToken!,
|
||||
);
|
||||
if (i % 10 === 0) {
|
||||
console.log(`setup: ${i} of ${userAmount} users setup`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
console.info(`setup: ${users.length} users created`);
|
||||
|
||||
return { tokens, org, users };
|
||||
}
|
||||
|
||||
export default async function (data: any) {
|
||||
const result = await listUsers(
|
||||
{
|
||||
queries: [
|
||||
{ metadataValueFilter: { value: encoding.b64encode('human', 'rawurl'), method: 'BYTE_FILTER_METHOD_EQUALS' } },
|
||||
],
|
||||
},
|
||||
data.tokens.accessToken!,
|
||||
);
|
||||
check(result, {
|
||||
'total result length': (res) => res.details.totalResult == userAmount / 2,
|
||||
}) || console.log(`unexpected amount of users. expected ${userAmount / 2} but got ${result.details.totalResult}`);
|
||||
}
|
||||
|
||||
export function teardown(data: any) {
|
||||
removeOrg(data.org, data.tokens.accessToken);
|
||||
console.info('teardown: org removed');
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Org } from './org';
|
||||
import http, { RefinedResponse } from 'k6/http';
|
||||
import url from './url';
|
||||
import { check } from 'k6';
|
||||
import { Metadata } from './metadata';
|
||||
|
||||
export type User = {
|
||||
userId: string;
|
||||
@@ -298,3 +299,70 @@ export function deleteUser(userId: string, org: Org, accessToken: string): Promi
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const setUserMetadataTrend = new Trend('set_user_metadata_duration', true);
|
||||
export function setUserMetadata(metadata: Metadata[], userId: string, accessToken: string): Promise<RefinedResponse<any>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let response = http.asyncRequest('POST', url(`/v2/users/${userId}/metadata`), JSON.stringify({ metadata: metadata }), {
|
||||
headers: {
|
||||
authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
response
|
||||
.then((res) => {
|
||||
check(res, {
|
||||
'set user metadata is status ok': (r) => r.status >= 200 && r.status < 300,
|
||||
}) || console.log(`unable to set user metadata (user id: ${userId}) status: ${res.status} body: ${res.body}`);
|
||||
setUserMetadataTrend.add(res.timings.duration);
|
||||
resolve(res);
|
||||
})
|
||||
.catch((reason) => {
|
||||
reject(reason);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export type ListUsersRequest = {
|
||||
queries: {
|
||||
metadataKeyFilter?: {
|
||||
key: string;
|
||||
method: 'TEXT_FILTER_METHOD_EQUALS' | 'TEXT_FILTER_METHOD_CONTAINS' | 'TEXT_FILTER_METHOD_CONTAINS_IGNORE_CASE';
|
||||
};
|
||||
metadataValueFilter?: {
|
||||
value: string;
|
||||
method: 'BYTE_FILTER_METHOD_EQUALS' | 'BYTE_FILTER_METHOD_NOT_EQUALS';
|
||||
};
|
||||
}[];
|
||||
};
|
||||
|
||||
export type ListUsersResult = {
|
||||
details: {
|
||||
totalResult: number;
|
||||
};
|
||||
};
|
||||
|
||||
const listUsersTrend = new Trend('list_users_duration', true);
|
||||
export function listUsers(body: ListUsersRequest, accessToken: string): Promise<ListUsersResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let response = http.asyncRequest('POST', url(`/v2/users`), JSON.stringify(body), {
|
||||
headers: {
|
||||
authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
response
|
||||
.then((res) => {
|
||||
check(res, {
|
||||
'list users is status ok': (r) => r.status >= 200 && r.status < 300,
|
||||
}) || console.log(`unable to list users status: ${res.status} body: ${res.body}`);
|
||||
listUsersTrend.add(res.timings.duration);
|
||||
resolve(res.json()! as ListUsersResult);
|
||||
})
|
||||
.catch((reason) => {
|
||||
reject(reason);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
46
load-test/src/user_grant.ts
Normal file
46
load-test/src/user_grant.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import http from 'k6/http';
|
||||
import { Trend } from 'k6/metrics';
|
||||
import url from './url';
|
||||
import { check, fail } from 'k6';
|
||||
import { Project } from './project';
|
||||
import { Org } from './org';
|
||||
|
||||
export type UserGrant = {
|
||||
userGrantId: string;
|
||||
};
|
||||
|
||||
const addUserGrantTrend = new Trend('user_grant_add', true);
|
||||
export async function addUserGrant(
|
||||
org: Org,
|
||||
userId: string,
|
||||
project: Project,
|
||||
roles: string[],
|
||||
accessToken: string,
|
||||
): Promise<UserGrant> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let response = http.asyncRequest(
|
||||
'POST',
|
||||
url(`/management/v1/users/${userId}/grants`),
|
||||
JSON.stringify({
|
||||
projectId: project.id,
|
||||
roleKeys: roles,
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'x-zitadel-orgid': org.organizationId,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
response.then((res) => {
|
||||
check(res, {
|
||||
'add User Grant status ok': (r) => r.status >= 200 && r.status < 300,
|
||||
}) || reject(`unable to add User Grant status: ${res.status} body: ${res.body}`);
|
||||
|
||||
addUserGrantTrend.add(res.timings.duration);
|
||||
resolve(res.json() as UserGrant);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1065,6 +1065,7 @@ service UserService {
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
message AddHumanUserRequest{
|
||||
@@ -1947,4 +1948,4 @@ enum AuthenticationMethodType {
|
||||
AUTHENTICATION_METHOD_TYPE_U2F = 5;
|
||||
AUTHENTICATION_METHOD_TYPE_OTP_SMS = 6;
|
||||
AUTHENTICATION_METHOD_TYPE_OTP_EMAIL = 7;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user