diff --git a/internal/query/users_by_metadata.go b/internal/query/users_by_metadata.go new file mode 100644 index 0000000000..442ed590ee --- /dev/null +++ b/internal/query/users_by_metadata.go @@ -0,0 +1,210 @@ +package query + +import ( + "context" + "database/sql" + "slices" + + sq "github.com/Masterminds/squirrel" + "github.com/zitadel/logging" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type UsersByMetadataSearchQueries struct { + SearchRequest + Queries []SearchQuery +} + +type UserByMetadata struct { + ResourceOwner string `json:"resource_owner,omitempty"` + Key string `json:"key,omitempty"` + Value []byte `json:"value,omitempty"` + User *User `json:"user,omitempty"` +} + +type UsersByMetadata struct { + SearchResponse + UsersByMeta []*UserByMetadata +} + +func (q *Queries) SearchUsersByMetadata(ctx context.Context, queries *UsersByMetadataSearchQueries, permissionCheck domain.PermissionCheck) (usersByMeta *UsersByMetadata, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + permissionCheckV2 := PermissionV2(ctx, permissionCheck) + + query, scan := prepareUserByMetadataListQuery() + query = userPermissionCheckV2(ctx, query, permissionCheckV2, queries.Queries) + + for _, q := range queries.Queries { + query = q.toQuery(query) + } + + eq := sq.Eq{ + UserMetadataInstanceIDCol.identifier(): authz.GetInstance(ctx).InstanceID(), + } + + stmt, args, err := queries.toQuery(query).Where(eq).ToSql() + if err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-J1CvYA", "Errors.Query.SQLStatement") + } + + err = q.client.QueryContext(ctx, func(rows *sql.Rows) error { + usersByMeta, err = scan(rows) + return err + }, stmt, args...) + if err != nil { + logging.Errorf("Got error: %v", err) + return nil, err + } + + if permissionCheck != nil && !authz.GetFeatures(ctx).PermissionCheckV2 { + usersByMetadataCheckPermission(ctx, usersByMeta, permissionCheck) + } + + return usersByMeta, nil +} + +func prepareUserByMetadataListQuery() (sq.SelectBuilder, func(*sql.Rows) (*UsersByMetadata, error)) { + return sq.Select( + UserMetadataKeyCol.identifier(), + UserMetadataValueCol.identifier(), + + UserIDCol.identifier(), + UserStateCol.identifier(), + UserUsernameCol.identifier(), + UserTypeCol.identifier(), + + HumanUserIDCol.identifier(), + HumanFirstNameCol.identifier(), + HumanLastNameCol.identifier(), + HumanNickNameCol.identifier(), + HumanDisplayNameCol.identifier(), + HumanPreferredLanguageCol.identifier(), + HumanGenderCol.identifier(), + HumanAvatarURLCol.identifier(), + HumanEmailCol.identifier(), + HumanIsEmailVerifiedCol.identifier(), + HumanPhoneCol.identifier(), + HumanIsPhoneVerifiedCol.identifier(), + HumanPasswordChangeRequiredCol.identifier(), + HumanPasswordChangedCol.identifier(), + HumanMFAInitSkippedCol.identifier(), + + MachineUserIDCol.identifier(), + MachineNameCol.identifier(), + MachineDescriptionCol.identifier(), + MachineSecretCol.identifier(), + MachineAccessTokenTypeCol.identifier(), + + countColumn.identifier()). + From(userMetadataTable.identifier()). + Join(join(UserIDCol, UserMetadataUserIDCol)). + LeftJoin(join(HumanUserIDCol, UserIDCol)). + LeftJoin(join(MachineUserIDCol, UserIDCol)). + PlaceholderFormat(sq.Dollar), + func(rows *sql.Rows) (*UsersByMetadata, error) { + usersByMeta := make([]*UserByMetadata, 0) + var count uint64 + for rows.Next() { + userByMeta := new(UserByMetadata) + userByMeta.User = new(User) + userByMeta.User.Human = new(Human) + userByMeta.User.Machine = new(Machine) + + human, machine := sqlHuman{}, sqlMachine{} + + err := rows.Scan( + &userByMeta.Key, + &userByMeta.Value, + + &userByMeta.User.ID, + &userByMeta.User.State, + &userByMeta.User.Username, + &userByMeta.User.Type, + + &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 { + return nil, err + } + + if human.humanID.Valid { + userByMeta.User.Human.FirstName = human.firstName.String + userByMeta.User.Human.LastName = human.lastName.String + userByMeta.User.Human.NickName = human.nickName.String + userByMeta.User.Human.DisplayName = human.displayName.String + + userByMeta.User.Human.PreferredLanguage, err = language.Parse(human.preferredLanguage.String) + if err != nil { + return nil, err + } + + userByMeta.User.Human.Gender = domain.Gender(human.gender.Int32) + userByMeta.User.Human.AvatarKey = human.avatarKey.String + userByMeta.User.Human.Email = domain.EmailAddress(human.email.String) + userByMeta.User.Human.IsEmailVerified = human.isEmailVerified.Bool + userByMeta.User.Human.Phone = domain.PhoneNumber(human.phone.String) + userByMeta.User.Human.IsPhoneVerified = human.isPhoneVerified.Bool + userByMeta.User.Human.PasswordChangeRequired = human.passwordChangeRequired.Bool + userByMeta.User.Human.PasswordChanged = human.passwordChanged.Time + userByMeta.User.Human.MFAInitSkipped = human.mfaInitSkipped.Time + } + + if machine.machineID.Valid { + userByMeta.User.Machine.Name = machine.name.String + userByMeta.User.Machine.Description = machine.description.String + userByMeta.User.Machine.EncodedSecret = machine.encodedSecret.String + userByMeta.User.Machine.AccessTokenType = domain.OIDCTokenType(machine.accessTokenType.Int32) + } + + usersByMeta = append(usersByMeta, userByMeta) + } + + if err := rows.Close(); err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-hwTK0J", "Errors.Query.CloseRows") + } + + return &UsersByMetadata{ + UsersByMeta: usersByMeta, + SearchResponse: SearchResponse{ + Count: count, + }, + }, nil + } +} + +func usersByMetadataCheckPermission(ctx context.Context, users *UsersByMetadata, permissionCheck domain.PermissionCheck) { + users.UsersByMeta = slices.DeleteFunc(users.UsersByMeta, + func(user *UserByMetadata) bool { + return userCheckPermission(ctx, user.ResourceOwner, user.User.ID, permissionCheck) != nil + }, + ) +} diff --git a/internal/query/users_by_metadata_test.go b/internal/query/users_by_metadata_test.go new file mode 100644 index 0000000000..074246fe03 --- /dev/null +++ b/internal/query/users_by_metadata_test.go @@ -0,0 +1,847 @@ +package query + +import ( + "context" + "database/sql/driver" + "errors" + "fmt" + "regexp" + "strings" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/domain" + permissionmock "github.com/zitadel/zitadel/internal/domain/mock" + "github.com/zitadel/zitadel/internal/feature" +) + +var columns = []string{ + "projections.user_metadata5.key", + "projections.user_metadata5.value", + "projections.users14.id", + "projections.users14.state", + "projections.users14.username", + "projections.users14.type", + "projections.users14_humans.user_id", + "projections.users14_humans.first_name", + "projections.users14_humans.last_name", + "projections.users14_humans.nick_name", + "projections.users14_humans.display_name", + "projections.users14_humans.preferred_language", + "projections.users14_humans.gender", + "projections.users14_humans.avatar_key", + "projections.users14_humans.email", + "projections.users14_humans.is_email_verified", + "projections.users14_humans.phone", + "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", + "projections.users14_machines.secret", + "projections.users14_machines.access_token_type", + "COUNT(*) OVER ()", +} + +const baseQuery = `SELECT projections.user_metadata5.key, projections.user_metadata5.value, +projections.users14.id, projections.users14.state, projections.users14.username, projections.users14.type, +projections.users14_humans.user_id, projections.users14_humans.first_name, projections.users14_humans.last_name, projections.users14_humans.nick_name, projections.users14_humans.display_name, projections.users14_humans.preferred_language, projections.users14_humans.gender, projections.users14_humans.avatar_key, projections.users14_humans.email, projections.users14_humans.is_email_verified, projections.users14_humans.phone, 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, projections.users14_machines.secret, projections.users14_machines.access_token_type, +COUNT(*) OVER () +FROM projections.user_metadata5 +JOIN projections.users14 ON projections.user_metadata5.user_id = projections.users14.id +AND projections.user_metadata5.instance_id = projections.users14.instance_id +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 +` + +const permissionQuery = `INNER JOIN eventstore.permitted_orgs($1, $2, $3, $4, $5) permissions ON (permissions.instance_permitted OR projections.users14.resource_owner = ANY(permissions.org_ids) OR projections.users14.id = $6)` + +func TestUsersByMetadata(t *testing.T) { + t.Parallel() + + tt := []struct { + testName string + + inputCtx context.Context + inputQueriesFunc func(*testing.T) *UsersByMetadataSearchQueries + inputPermissionCheckMock domain.PermissionCheck + + mockMatcher sqlmock.QueryMatcher + mockExpectations func(*sqlmock.ExpectedQuery) *sqlmock.ExpectedQuery + + expectedQuery string + expectedUsersByMeta *UsersByMetadata + expectedError error + }{ + { + testName: "sql should return error", + inputCtx: authz.NewMockContext("instance-1", "org-1", "user-1"), + inputQueriesFunc: func(t *testing.T) *UsersByMetadataSearchQueries { + keyQ, err := NewUserMetadataKeySearchQuery("test key", TextEquals) + require.NoError(t, err) + + return &UsersByMetadataSearchQueries{ + Queries: []SearchQuery{keyQ}, + } + }, + mockExpectations: func(q *sqlmock.ExpectedQuery) *sqlmock.ExpectedQuery { + return q.WithArgs("test key", "instance-1").WillReturnError(errors.New("mocked error")) + }, + mockMatcher: &baseMatcher{}, + expectedQuery: baseQuery, + expectedError: errors.New("mocked error"), + }, + { + testName: "v2 perms disabled/matching by key should return 2 records/permission granted", + inputCtx: authz.NewMockContext("instance-1", "org-1", "user-1"), + inputPermissionCheckMock: permissionmock.MockPermissionCheckOK(), + inputQueriesFunc: func(t *testing.T) *UsersByMetadataSearchQueries { + keyQ, err := NewUserMetadataKeySearchQuery("test key", TextEquals) + require.NoError(t, err) + + return &UsersByMetadataSearchQueries{ + Queries: []SearchQuery{keyQ}, + } + }, + mockExpectations: func(q *sqlmock.ExpectedQuery) *sqlmock.ExpectedQuery { + results := sqlmock.NewRows(columns) + results.AddRows( + []driver.Value{ + // user_metadata5.key, user_metadata5.value, users14.id, users14.state, users14.username, users14.type + "test key", "test value 1", "id-1", domain.UserStateActive, "username 1", domain.UserTypeHuman, + // users14_humans.user_id, users14_humans.first_name, users14_humans.last_name, users14_humans.nick_name, users14_humans.display_name, users14_humans.preferred_language, users14_humans.gender, users14_humans.avatar_key, users14_humans.email, users14_humans.is_email_verified, users14_humans.phone, users14_humans.is_phone_verified, users14_humans.password_change_required, users14_humans.password_changed, users14_humans.mfa_init_skipped + "id-1", "User", "Name", "Nickyname", "User 'Nickyname' Name", "it", domain.GenderMale, "avatar_key", "nicky@username.com", true, "12345678", true, false, nil, nil, + // users14_machines.user_id, users14_machines.name, users14_machines.description, users14_machines.secret, users14_machines.access_token_type + nil, nil, nil, nil, nil, + // COUNT(*) OVER () + 2, + }, + []driver.Value{ + // user_metadata5.key, user_metadata5.value, users14.id, users14.state, users14.username, users14.type + "test key", "test value 2", "id-2", domain.UserStateActive, "username 2", domain.UserTypeMachine, + // users14_humans.user_id, users14_humans.first_name, users14_humans.last_name, users14_humans.nick_name, users14_humans.display_name, users14_humans.preferred_language, users14_humans.gender, users14_humans.avatar_key, users14_humans.email, users14_humans.is_email_verified, users14_humans.phone, users14_humans.is_phone_verified, users14_humans.password_change_required, users14_humans.password_changed, users14_humans.mfa_init_skipped + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + // users14_machines.user_id, users14_machines.name, users14_machines.description, users14_machines.secret, users14_machines.access_token_type + "id-2", "robot", "this is a robot", "giga secret 1234!", domain.OIDCTokenTypeJWT, + // COUNT(*) OVER () + 2, + }, + ) + return q.WithArgs("test key", "instance-1").WillReturnRows(results) + }, + mockMatcher: &whereMatcher{matcher: &baseMatcher{}, toMatch: "user_metadata5.key"}, + expectedQuery: baseQuery, + expectedUsersByMeta: &UsersByMetadata{ + SearchResponse: SearchResponse{Count: 2}, + UsersByMeta: []*UserByMetadata{ + { + ResourceOwner: "", + Key: "test key", + Value: []byte("test value 1"), + User: humanUser("id-1", "username 1", "User", "Name", "Nickyname", "nicky@username.com", domain.UserStateActive), + }, + { + ResourceOwner: "", + Key: "test key", + Value: []byte("test value 2"), + User: machineUser("id-2", "username 2", "robot", "this is a robot", "giga secret 1234!", domain.OIDCTokenTypeJWT, domain.UserStateActive), + }, + }, + }, + }, + { + testName: "v2 perms disabled/matching by key should return 2 records/permission denied/empty result", + inputCtx: authz.NewMockContext("instance-1", "org-1", "user-1"), + inputPermissionCheckMock: permissionmock.MockPermissionCheckErr(errors.New("permission denied")), + inputQueriesFunc: func(t *testing.T) *UsersByMetadataSearchQueries { + keyQ, err := NewUserMetadataKeySearchQuery("test key", TextEquals) + require.NoError(t, err) + + return &UsersByMetadataSearchQueries{ + Queries: []SearchQuery{keyQ}, + } + }, + mockMatcher: &whereMatcher{matcher: &baseMatcher{}, toMatch: "user_metadata5.key"}, + mockExpectations: func(q *sqlmock.ExpectedQuery) *sqlmock.ExpectedQuery { + results := sqlmock.NewRows(columns) + results.AddRows( + []driver.Value{ + // user_metadata5.key, user_metadata5.value, users14.id, users14.state, users14.username, users14.type + "test key", "test value 1", "id-1", domain.UserStateActive, "username 1", domain.UserTypeHuman, + // users14_humans.user_id, users14_humans.first_name, users14_humans.last_name, users14_humans.nick_name, users14_humans.display_name, users14_humans.preferred_language, users14_humans.gender, users14_humans.avatar_key, users14_humans.email, users14_humans.is_email_verified, users14_humans.phone, users14_humans.is_phone_verified, users14_humans.password_change_required, users14_humans.password_changed, users14_humans.mfa_init_skipped + "id-1", "User", "Name", "Nickyname", "User 'Nickyname' Name", "it", domain.GenderMale, "avatar_key", "nicky@username.com", true, "12345678", true, false, nil, nil, + // users14_machines.user_id, users14_machines.name, users14_machines.description, users14_machines.secret, users14_machines.access_token_type + nil, nil, nil, nil, nil, + // COUNT(*) OVER () + 2, + }, + []driver.Value{ + // user_metadata5.key, user_metadata5.value, users14.id, users14.state, users14.username, users14.type + "test key", "test value 2", "id-2", domain.UserStateActive, "username 2", domain.UserTypeMachine, + // users14_humans.user_id, users14_humans.first_name, users14_humans.last_name, users14_humans.nick_name, users14_humans.display_name, users14_humans.preferred_language, users14_humans.gender, users14_humans.avatar_key, users14_humans.email, users14_humans.is_email_verified, users14_humans.phone, users14_humans.is_phone_verified, users14_humans.password_change_required, users14_humans.password_changed, users14_humans.mfa_init_skipped + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + // users14_machines.user_id, users14_machines.name, users14_machines.description, users14_machines.secret, users14_machines.access_token_type + "id-2", "robot", "this is a robot", "giga secret 1234!", domain.OIDCTokenTypeJWT, + // COUNT(*) OVER () + 2, + }, + ) + return q.WithArgs("test key", "instance-1").WillReturnRows(results) + }, + expectedQuery: baseQuery, + expectedUsersByMeta: &UsersByMetadata{ + SearchResponse: SearchResponse{Count: 2}, + UsersByMeta: []*UserByMetadata{}, + }, + }, + { + testName: "v2 perms disabled/matching by key ignore case should return 2 records/permission granted", + inputCtx: authz.NewMockContext("instance-1", "org-1", "user-1"), + inputPermissionCheckMock: permissionmock.MockPermissionCheckOK(), + inputQueriesFunc: func(t *testing.T) *UsersByMetadataSearchQueries { + keyQ, err := NewUserMetadataKeySearchQuery("Test key", TextEqualsIgnoreCase) + require.NoError(t, err) + + return &UsersByMetadataSearchQueries{ + Queries: []SearchQuery{keyQ}, + } + }, + mockMatcher: &whereMatcher{matcher: &baseMatcher{}, toMatch: "LOWER(projections.user_metadata5.key)"}, + mockExpectations: func(q *sqlmock.ExpectedQuery) *sqlmock.ExpectedQuery { + results := sqlmock.NewRows(columns) + results.AddRows( + []driver.Value{ + // user_metadata5.key, user_metadata5.value, users14.id, users14.state, users14.username, users14.type + "test key", "test value 1", "id-1", domain.UserStateActive, "username 1", domain.UserTypeHuman, + // users14_humans.user_id, users14_humans.first_name, users14_humans.last_name, users14_humans.nick_name, users14_humans.display_name, users14_humans.preferred_language, users14_humans.gender, users14_humans.avatar_key, users14_humans.email, users14_humans.is_email_verified, users14_humans.phone, users14_humans.is_phone_verified, users14_humans.password_change_required, users14_humans.password_changed, users14_humans.mfa_init_skipped + "id-1", "User", "Name", "Nickyname", "User 'Nickyname' Name", "it", domain.GenderMale, "avatar_key", "nicky@username.com", true, "12345678", true, false, nil, nil, + // users14_machines.user_id, users14_machines.name, users14_machines.description, users14_machines.secret, users14_machines.access_token_type + nil, nil, nil, nil, nil, + // COUNT(*) OVER () + 2, + }, + []driver.Value{ + // user_metadata5.key, user_metadata5.value, users14.id, users14.state, users14.username, users14.type + "Test key", "test value 2", "id-2", domain.UserStateActive, "username 2", domain.UserTypeMachine, + // users14_humans.user_id, users14_humans.first_name, users14_humans.last_name, users14_humans.nick_name, users14_humans.display_name, users14_humans.preferred_language, users14_humans.gender, users14_humans.avatar_key, users14_humans.email, users14_humans.is_email_verified, users14_humans.phone, users14_humans.is_phone_verified, users14_humans.password_change_required, users14_humans.password_changed, users14_humans.mfa_init_skipped + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + // users14_machines.user_id, users14_machines.name, users14_machines.description, users14_machines.secret, users14_machines.access_token_type + "id-2", "robot", "this is a robot", "giga secret 1234!", domain.OIDCTokenTypeJWT, + // COUNT(*) OVER () + 2, + }, + ) + return q.WithArgs("test key", "instance-1").WillReturnRows(results) + }, + expectedQuery: baseQuery, + expectedUsersByMeta: &UsersByMetadata{ + SearchResponse: SearchResponse{Count: 2}, + UsersByMeta: []*UserByMetadata{ + { + ResourceOwner: "", + Key: "test key", + Value: []byte("test value 1"), + User: humanUser("id-1", "username 1", "User", "Name", "Nickyname", "nicky@username.com", domain.UserStateActive), + }, + { + ResourceOwner: "", + Key: "Test key", + Value: []byte("test value 2"), + User: machineUser("id-2", "username 2", "robot", "this is a robot", "giga secret 1234!", domain.OIDCTokenTypeJWT, domain.UserStateActive), + }, + }, + }, + }, + { + testName: "v2 perms enabled/matching by key ignore case should return 2 records", + inputCtx: authz.NewMockContext("instance-1", "org-1", "user-1", authz.WithMockFeatures(feature.Features{PermissionCheckV2: true})), + inputPermissionCheckMock: permissionmock.MockPermissionCheckOK(), + inputQueriesFunc: func(t *testing.T) *UsersByMetadataSearchQueries { + keyQ, err := NewUserMetadataKeySearchQuery("Test key", TextEqualsIgnoreCase) + require.NoError(t, err) + + return &UsersByMetadataSearchQueries{ + Queries: []SearchQuery{keyQ}, + } + }, + mockExpectations: func(q *sqlmock.ExpectedQuery) *sqlmock.ExpectedQuery { + results := sqlmock.NewRows(columns) + results.AddRows( + []driver.Value{ + // user_metadata5.key, user_metadata5.value, users14.id, users14.state, users14.username, users14.type + "test key", "test value 1", "id-1", domain.UserStateActive, "username 1", domain.UserTypeHuman, + // users14_humans.user_id, users14_humans.first_name, users14_humans.last_name, users14_humans.nick_name, users14_humans.display_name, users14_humans.preferred_language, users14_humans.gender, users14_humans.avatar_key, users14_humans.email, users14_humans.is_email_verified, users14_humans.phone, users14_humans.is_phone_verified, users14_humans.password_change_required, users14_humans.password_changed, users14_humans.mfa_init_skipped + "id-1", "User", "Name", "Nickyname", "User 'Nickyname' Name", "it", domain.GenderMale, "avatar_key", "nicky@username.com", true, "12345678", true, false, nil, nil, + // users14_machines.user_id, users14_machines.name, users14_machines.description, users14_machines.secret, users14_machines.access_token_type + nil, nil, nil, nil, nil, + // COUNT(*) OVER () + 2, + }, + []driver.Value{ + // user_metadata5.key, user_metadata5.value, users14.id, users14.state, users14.username, users14.type + "Test key", "test value 2", "id-2", domain.UserStateActive, "username 2", domain.UserTypeMachine, + // users14_humans.user_id, users14_humans.first_name, users14_humans.last_name, users14_humans.nick_name, users14_humans.display_name, users14_humans.preferred_language, users14_humans.gender, users14_humans.avatar_key, users14_humans.email, users14_humans.is_email_verified, users14_humans.phone, users14_humans.is_phone_verified, users14_humans.password_change_required, users14_humans.password_changed, users14_humans.mfa_init_skipped + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + // users14_machines.user_id, users14_machines.name, users14_machines.description, users14_machines.secret, users14_machines.access_token_type + "id-2", "robot", "this is a robot", "giga secret 1234!", domain.OIDCTokenTypeJWT, + // COUNT(*) OVER () + 2, + }, + ) + return q. + WithArgs("instance-1", "user-1", sqlmock.AnyArg(), domain.PermissionUserRead, sqlmock.AnyArg(), "user-1", "test key", "instance-1"). + WillReturnRows(results) + }, + mockMatcher: &whereMatcher{matcher: &baseMatcher{}, toMatch: "LOWER(projections.user_metadata5.key)"}, + expectedQuery: baseQuery + permissionQuery, + expectedUsersByMeta: &UsersByMetadata{ + SearchResponse: SearchResponse{Count: 2}, + UsersByMeta: []*UserByMetadata{ + { + ResourceOwner: "", + Key: "test key", + Value: []byte("test value 1"), + User: humanUser("id-1", "username 1", "User", "Name", "Nickyname", "nicky@username.com", domain.UserStateActive), + }, + { + ResourceOwner: "", + Key: "Test key", + Value: []byte("test value 2"), + User: machineUser("id-2", "username 2", "robot", "this is a robot", "giga secret 1234!", domain.OIDCTokenTypeJWT, domain.UserStateActive), + }, + }, + }, + }, + { + testName: "v2 perms enabled/matching by OR-ed keys should return 2 records", + inputCtx: authz.NewMockContext("instance-1", "org-1", "user-1", authz.WithMockFeatures(feature.Features{PermissionCheckV2: true})), + inputPermissionCheckMock: permissionmock.MockPermissionCheckOK(), + inputQueriesFunc: func(t *testing.T) *UsersByMetadataSearchQueries { + keyQ1, err := NewUserMetadataKeySearchQuery("test key 1", TextEqualsIgnoreCase) + require.NoError(t, err) + keyQ2, err := NewUserMetadataKeySearchQuery("test key 2", TextEqualsIgnoreCase) + require.NoError(t, err) + orQ, err := NewOrQuery(keyQ1, keyQ2) + require.NoError(t, err) + + return &UsersByMetadataSearchQueries{ + Queries: []SearchQuery{orQ}, + } + }, + mockExpectations: func(q *sqlmock.ExpectedQuery) *sqlmock.ExpectedQuery { + results := sqlmock.NewRows(columns) + results.AddRows( + []driver.Value{ + // user_metadata5.key, user_metadata5.value, users14.id, users14.state, users14.username, users14.type + "test key 1", "test value 1", "id-1", domain.UserStateActive, "username 1", domain.UserTypeHuman, + // users14_humans.user_id, users14_humans.first_name, users14_humans.last_name, users14_humans.nick_name, users14_humans.display_name, users14_humans.preferred_language, users14_humans.gender, users14_humans.avatar_key, users14_humans.email, users14_humans.is_email_verified, users14_humans.phone, users14_humans.is_phone_verified, users14_humans.password_change_required, users14_humans.password_changed, users14_humans.mfa_init_skipped + "id-1", "User", "Name", "Nickyname", "User 'Nickyname' Name", "it", domain.GenderMale, "avatar_key", "nicky@username.com", true, "12345678", true, false, nil, nil, + // users14_machines.user_id, users14_machines.name, users14_machines.description, users14_machines.secret, users14_machines.access_token_type + nil, nil, nil, nil, nil, + // COUNT(*) OVER () + 2, + }, + []driver.Value{ + // user_metadata5.key, user_metadata5.value, users14.id, users14.state, users14.username, users14.type + "test key 2", "test value 2", "id-2", domain.UserStateActive, "username 2", domain.UserTypeMachine, + // users14_humans.user_id, users14_humans.first_name, users14_humans.last_name, users14_humans.nick_name, users14_humans.display_name, users14_humans.preferred_language, users14_humans.gender, users14_humans.avatar_key, users14_humans.email, users14_humans.is_email_verified, users14_humans.phone, users14_humans.is_phone_verified, users14_humans.password_change_required, users14_humans.password_changed, users14_humans.mfa_init_skipped + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + // users14_machines.user_id, users14_machines.name, users14_machines.description, users14_machines.secret, users14_machines.access_token_type + "id-2", "robot", "this is a robot", "giga secret 1234!", domain.OIDCTokenTypeJWT, + // COUNT(*) OVER () + 2, + }, + ) + return q. + WithArgs("instance-1", "user-1", sqlmock.AnyArg(), domain.PermissionUserRead, sqlmock.AnyArg(), "user-1", "test key 1", "test key 2", "instance-1"). + WillReturnRows(results) + }, + mockMatcher: &whereMatcher{matcher: &baseMatcher{}, toMatch: "LOWER(projections.user_metadata5.key),OR"}, + expectedQuery: baseQuery + permissionQuery, + expectedUsersByMeta: &UsersByMetadata{ + SearchResponse: SearchResponse{Count: 2}, + UsersByMeta: []*UserByMetadata{ + { + ResourceOwner: "", + Key: "test key 1", + Value: []byte("test value 1"), + User: humanUser("id-1", "username 1", "User", "Name", "Nickyname", "nicky@username.com", domain.UserStateActive), + }, + { + ResourceOwner: "", + Key: "test key 2", + Value: []byte("test value 2"), + User: machineUser("id-2", "username 2", "robot", "this is a robot", "giga secret 1234!", domain.OIDCTokenTypeJWT, domain.UserStateActive), + }, + }, + }, + }, + { + testName: "v2 perms enabled/matching by NOT-OR-ed keys should return 2 records", + inputCtx: authz.NewMockContext("instance-1", "org-1", "user-1", authz.WithMockFeatures(feature.Features{PermissionCheckV2: true})), + inputPermissionCheckMock: permissionmock.MockPermissionCheckOK(), + inputQueriesFunc: func(t *testing.T) *UsersByMetadataSearchQueries { + keyQ1, err := NewUserMetadataKeySearchQuery("unmatch key 1", TextEquals) + require.NoError(t, err) + keyQ2, err := NewUserMetadataKeySearchQuery("unmatch key 2", TextEquals) + require.NoError(t, err) + orQ, err := NewOrQuery(keyQ1, keyQ2) + require.NoError(t, err) + + notQ, err := NewNotQuery(orQ) + require.NoError(t, err) + + return &UsersByMetadataSearchQueries{ + Queries: []SearchQuery{notQ}, + } + }, + mockExpectations: func(q *sqlmock.ExpectedQuery) *sqlmock.ExpectedQuery { + results := sqlmock.NewRows(columns) + results.AddRows( + []driver.Value{ + // user_metadata5.key, user_metadata5.value, users14.id, users14.state, users14.username, users14.type + "test key 1", "test value 1", "id-1", domain.UserStateActive, "username 1", domain.UserTypeHuman, + // users14_humans.user_id, users14_humans.first_name, users14_humans.last_name, users14_humans.nick_name, users14_humans.display_name, users14_humans.preferred_language, users14_humans.gender, users14_humans.avatar_key, users14_humans.email, users14_humans.is_email_verified, users14_humans.phone, users14_humans.is_phone_verified, users14_humans.password_change_required, users14_humans.password_changed, users14_humans.mfa_init_skipped + "id-1", "User", "Name", "Nickyname", "User 'Nickyname' Name", "it", domain.GenderMale, "avatar_key", "nicky@username.com", true, "12345678", true, false, nil, nil, + // users14_machines.user_id, users14_machines.name, users14_machines.description, users14_machines.secret, users14_machines.access_token_type + nil, nil, nil, nil, nil, + // COUNT(*) OVER () + 2, + }, + []driver.Value{ + // user_metadata5.key, user_metadata5.value, users14.id, users14.state, users14.username, users14.type + "test key 2", "test value 2", "id-2", domain.UserStateActive, "username 2", domain.UserTypeMachine, + // users14_humans.user_id, users14_humans.first_name, users14_humans.last_name, users14_humans.nick_name, users14_humans.display_name, users14_humans.preferred_language, users14_humans.gender, users14_humans.avatar_key, users14_humans.email, users14_humans.is_email_verified, users14_humans.phone, users14_humans.is_phone_verified, users14_humans.password_change_required, users14_humans.password_changed, users14_humans.mfa_init_skipped + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + // users14_machines.user_id, users14_machines.name, users14_machines.description, users14_machines.secret, users14_machines.access_token_type + "id-2", "robot", "this is a robot", "giga secret 1234!", domain.OIDCTokenTypeJWT, + // COUNT(*) OVER () + 2, + }, + ) + return q. + WithArgs("instance-1", "user-1", sqlmock.AnyArg(), domain.PermissionUserRead, sqlmock.AnyArg(), "user-1", "unmatch key 1", "unmatch key 2", "instance-1"). + WillReturnRows(results) + }, + mockMatcher: &whereMatcher{matcher: &baseMatcher{}, toMatch: "projections.user_metadata5.key,OR,NOT"}, + expectedQuery: baseQuery + permissionQuery, + expectedUsersByMeta: &UsersByMetadata{ + SearchResponse: SearchResponse{Count: 2}, + UsersByMeta: []*UserByMetadata{ + { + ResourceOwner: "", + Key: "test key 1", + Value: []byte("test value 1"), + User: humanUser("id-1", "username 1", "User", "Name", "Nickyname", "nicky@username.com", domain.UserStateActive), + }, + { + ResourceOwner: "", + Key: "test key 2", + Value: []byte("test value 2"), + User: machineUser("id-2", "username 2", "robot", "this is a robot", "giga secret 1234!", domain.OIDCTokenTypeJWT, domain.UserStateActive), + }, + }, + }, + }, + { + testName: "v2 perms enabled/matching by NOT-AND-ed keys should return 2 records with limit", + inputCtx: authz.NewMockContext("instance-1", "org-1", "user-1", authz.WithMockFeatures(feature.Features{PermissionCheckV2: true})), + inputPermissionCheckMock: permissionmock.MockPermissionCheckOK(), + inputQueriesFunc: func(t *testing.T) *UsersByMetadataSearchQueries { + keyQ1, err := NewUserMetadataKeySearchQuery("unmatch key 1", TextEquals) + require.NoError(t, err) + keyQ2, err := NewUserMetadataKeySearchQuery("unmatch key 2", TextEquals) + require.NoError(t, err) + orQ, err := NewAndQuery(keyQ1, keyQ2) + require.NoError(t, err) + + notQ, err := NewNotQuery(orQ) + require.NoError(t, err) + + return &UsersByMetadataSearchQueries{ + Queries: []SearchQuery{notQ}, + SearchRequest: SearchRequest{ + Limit: 2, + }, + } + }, + mockExpectations: func(q *sqlmock.ExpectedQuery) *sqlmock.ExpectedQuery { + results := sqlmock.NewRows(columns) + results.AddRows( + []driver.Value{ + // user_metadata5.key, user_metadata5.value, users14.id, users14.state, users14.username, users14.type + "test key 1", "test value 1", "id-1", domain.UserStateActive, "username 1", domain.UserTypeHuman, + // users14_humans.user_id, users14_humans.first_name, users14_humans.last_name, users14_humans.nick_name, users14_humans.display_name, users14_humans.preferred_language, users14_humans.gender, users14_humans.avatar_key, users14_humans.email, users14_humans.is_email_verified, users14_humans.phone, users14_humans.is_phone_verified, users14_humans.password_change_required, users14_humans.password_changed, users14_humans.mfa_init_skipped + "id-1", "User", "Name", "Nickyname", "User 'Nickyname' Name", "it", domain.GenderMale, "avatar_key", "nicky@username.com", true, "12345678", true, false, nil, nil, + // users14_machines.user_id, users14_machines.name, users14_machines.description, users14_machines.secret, users14_machines.access_token_type + nil, nil, nil, nil, nil, + // COUNT(*) OVER () + 2, + }, + []driver.Value{ + // user_metadata5.key, user_metadata5.value, users14.id, users14.state, users14.username, users14.type + "test key 2", "test value 2", "id-2", domain.UserStateActive, "username 2", domain.UserTypeMachine, + // users14_humans.user_id, users14_humans.first_name, users14_humans.last_name, users14_humans.nick_name, users14_humans.display_name, users14_humans.preferred_language, users14_humans.gender, users14_humans.avatar_key, users14_humans.email, users14_humans.is_email_verified, users14_humans.phone, users14_humans.is_phone_verified, users14_humans.password_change_required, users14_humans.password_changed, users14_humans.mfa_init_skipped + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + // users14_machines.user_id, users14_machines.name, users14_machines.description, users14_machines.secret, users14_machines.access_token_type + "id-2", "robot", "this is a robot", "giga secret 1234!", domain.OIDCTokenTypeJWT, + // COUNT(*) OVER () + 2, + }, + ) + return q. + WithArgs("instance-1", "user-1", sqlmock.AnyArg(), domain.PermissionUserRead, sqlmock.AnyArg(), "user-1", "unmatch key 1", "unmatch key 2", "instance-1"). + WillReturnRows(results) + }, + mockMatcher: &limitMatcher{matcher: &whereMatcher{matcher: &baseMatcher{}, toMatch: "projections.user_metadata5.key,AND,NOT"}, expectedLimit: "2"}, + expectedQuery: baseQuery + permissionQuery, + expectedUsersByMeta: &UsersByMetadata{ + SearchResponse: SearchResponse{Count: 2}, + UsersByMeta: []*UserByMetadata{ + { + ResourceOwner: "", + Key: "test key 1", + Value: []byte("test value 1"), + User: humanUser("id-1", "username 1", "User", "Name", "Nickyname", "nicky@username.com", domain.UserStateActive), + }, + { + ResourceOwner: "", + Key: "test key 2", + Value: []byte("test value 2"), + User: machineUser("id-2", "username 2", "robot", "this is a robot", "giga secret 1234!", domain.OIDCTokenTypeJWT, domain.UserStateActive), + }, + }, + }, + }, + { + testName: "v2 perms enabled/matching by NOT-AND-ed keys should return 2 records with limit and offset", + inputCtx: authz.NewMockContext("instance-1", "org-1", "user-1", authz.WithMockFeatures(feature.Features{PermissionCheckV2: true})), + inputPermissionCheckMock: permissionmock.MockPermissionCheckOK(), + inputQueriesFunc: func(t *testing.T) *UsersByMetadataSearchQueries { + keyQ1, err := NewUserMetadataKeySearchQuery("unmatch key 1", TextEquals) + require.NoError(t, err) + keyQ2, err := NewUserMetadataKeySearchQuery("unmatch key 2", TextEquals) + require.NoError(t, err) + orQ, err := NewAndQuery(keyQ1, keyQ2) + require.NoError(t, err) + + notQ, err := NewNotQuery(orQ) + require.NoError(t, err) + + return &UsersByMetadataSearchQueries{ + Queries: []SearchQuery{notQ}, + SearchRequest: SearchRequest{Limit: 2, Offset: 1}, + } + }, + mockExpectations: func(q *sqlmock.ExpectedQuery) *sqlmock.ExpectedQuery { + results := sqlmock.NewRows(columns) + results.AddRows( + []driver.Value{ + // user_metadata5.key, user_metadata5.value, users14.id, users14.state, users14.username, users14.type + "test key 1", "test value 1", "id-1", domain.UserStateActive, "username 1", domain.UserTypeHuman, + // users14_humans.user_id, users14_humans.first_name, users14_humans.last_name, users14_humans.nick_name, users14_humans.display_name, users14_humans.preferred_language, users14_humans.gender, users14_humans.avatar_key, users14_humans.email, users14_humans.is_email_verified, users14_humans.phone, users14_humans.is_phone_verified, users14_humans.password_change_required, users14_humans.password_changed, users14_humans.mfa_init_skipped + "id-1", "User", "Name", "Nickyname", "User 'Nickyname' Name", "it", domain.GenderMale, "avatar_key", "nicky@username.com", true, "12345678", true, false, nil, nil, + // users14_machines.user_id, users14_machines.name, users14_machines.description, users14_machines.secret, users14_machines.access_token_type + nil, nil, nil, nil, nil, + // COUNT(*) OVER () + 2, + }, + []driver.Value{ + // user_metadata5.key, user_metadata5.value, users14.id, users14.state, users14.username, users14.type + "test key 2", "test value 2", "id-2", domain.UserStateActive, "username 2", domain.UserTypeMachine, + // users14_humans.user_id, users14_humans.first_name, users14_humans.last_name, users14_humans.nick_name, users14_humans.display_name, users14_humans.preferred_language, users14_humans.gender, users14_humans.avatar_key, users14_humans.email, users14_humans.is_email_verified, users14_humans.phone, users14_humans.is_phone_verified, users14_humans.password_change_required, users14_humans.password_changed, users14_humans.mfa_init_skipped + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + // users14_machines.user_id, users14_machines.name, users14_machines.description, users14_machines.secret, users14_machines.access_token_type + "id-2", "robot", "this is a robot", "giga secret 1234!", domain.OIDCTokenTypeJWT, + // COUNT(*) OVER () + 2, + }, + ) + return q. + WithArgs("instance-1", "user-1", sqlmock.AnyArg(), domain.PermissionUserRead, sqlmock.AnyArg(), "user-1", "unmatch key 1", "unmatch key 2", "instance-1"). + WillReturnRows(results) + }, + mockMatcher: &offsetMatcher{matcher: &limitMatcher{matcher: &whereMatcher{matcher: &baseMatcher{}, toMatch: "projections.user_metadata5.key,AND,NOT"}, expectedLimit: "2"}, expectedOffset: "1"}, + expectedQuery: baseQuery + permissionQuery, + expectedUsersByMeta: &UsersByMetadata{ + SearchResponse: SearchResponse{Count: 2}, + UsersByMeta: []*UserByMetadata{ + { + ResourceOwner: "", + Key: "test key 1", + Value: []byte("test value 1"), + User: humanUser("id-1", "username 1", "User", "Name", "Nickyname", "nicky@username.com", domain.UserStateActive), + }, + { + ResourceOwner: "", + Key: "test key 2", + Value: []byte("test value 2"), + User: machineUser("id-2", "username 2", "robot", "this is a robot", "giga secret 1234!", domain.OIDCTokenTypeJWT, domain.UserStateActive), + }, + }, + }, + }, + { + testName: "v2 perms enabled/matching by NOT-AND-ed keys should return 2 records with limit, offset and sorting", + inputCtx: authz.NewMockContext("instance-1", "org-1", "user-1", authz.WithMockFeatures(feature.Features{PermissionCheckV2: true})), + inputPermissionCheckMock: permissionmock.MockPermissionCheckOK(), + inputQueriesFunc: func(t *testing.T) *UsersByMetadataSearchQueries { + keyQ1, err := NewUserMetadataKeySearchQuery("unmatch key 1", TextEquals) + require.NoError(t, err) + keyQ2, err := NewUserMetadataKeySearchQuery("unmatch key 2", TextEquals) + require.NoError(t, err) + orQ, err := NewAndQuery(keyQ1, keyQ2) + require.NoError(t, err) + + notQ, err := NewNotQuery(orQ) + require.NoError(t, err) + + return &UsersByMetadataSearchQueries{ + Queries: []SearchQuery{notQ}, + SearchRequest: SearchRequest{Limit: 2, Offset: 1, SortingColumn: UserMetadataKeyCol}, + } + }, + mockExpectations: func(q *sqlmock.ExpectedQuery) *sqlmock.ExpectedQuery { + results := sqlmock.NewRows(columns) + results.AddRows( + []driver.Value{ + // user_metadata5.key, user_metadata5.value, users14.id, users14.state, users14.username, users14.type + "test key 2", "test value 2", "id-2", domain.UserStateActive, "username 2", domain.UserTypeMachine, + // users14_humans.user_id, users14_humans.first_name, users14_humans.last_name, users14_humans.nick_name, users14_humans.display_name, users14_humans.preferred_language, users14_humans.gender, users14_humans.avatar_key, users14_humans.email, users14_humans.is_email_verified, users14_humans.phone, users14_humans.is_phone_verified, users14_humans.password_change_required, users14_humans.password_changed, users14_humans.mfa_init_skipped + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + // users14_machines.user_id, users14_machines.name, users14_machines.description, users14_machines.secret, users14_machines.access_token_type + "id-2", "robot", "this is a robot", "giga secret 1234!", domain.OIDCTokenTypeJWT, + // COUNT(*) OVER () + 2, + }, + []driver.Value{ + // user_metadata5.key, user_metadata5.value, users14.id, users14.state, users14.username, users14.type + "test key 1", "test value 1", "id-1", domain.UserStateActive, "username 1", domain.UserTypeHuman, + // users14_humans.user_id, users14_humans.first_name, users14_humans.last_name, users14_humans.nick_name, users14_humans.display_name, users14_humans.preferred_language, users14_humans.gender, users14_humans.avatar_key, users14_humans.email, users14_humans.is_email_verified, users14_humans.phone, users14_humans.is_phone_verified, users14_humans.password_change_required, users14_humans.password_changed, users14_humans.mfa_init_skipped + "id-1", "User", "Name", "Nickyname", "User 'Nickyname' Name", "it", domain.GenderMale, "avatar_key", "nicky@username.com", true, "12345678", true, false, nil, nil, + // users14_machines.user_id, users14_machines.name, users14_machines.description, users14_machines.secret, users14_machines.access_token_type + nil, nil, nil, nil, nil, + // COUNT(*) OVER () + 2, + }, + ) + return q. + WithArgs("instance-1", "user-1", sqlmock.AnyArg(), domain.PermissionUserRead, sqlmock.AnyArg(), "user-1", "unmatch key 1", "unmatch key 2", "instance-1"). + WillReturnRows(results) + }, + mockMatcher: &orderMatcher{matcher: &offsetMatcher{matcher: &limitMatcher{matcher: &whereMatcher{matcher: &baseMatcher{}, toMatch: "projections.user_metadata5.key,AND,NOT"}, expectedLimit: "2"}, expectedOffset: "1"}, expectedSorting: []string{"projections.user_metadata5.key DESC"}}, + expectedQuery: baseQuery + permissionQuery, + expectedUsersByMeta: &UsersByMetadata{ + SearchResponse: SearchResponse{Count: 2}, + UsersByMeta: []*UserByMetadata{ + { + ResourceOwner: "", + Key: "test key 2", + Value: []byte("test value 2"), + User: machineUser("id-2", "username 2", "robot", "this is a robot", "giga secret 1234!", domain.OIDCTokenTypeJWT, domain.UserStateActive), + }, + { + ResourceOwner: "", + Key: "test key 1", + Value: []byte("test value 1"), + User: humanUser("id-1", "username 1", "User", "Name", "Nickyname", "nicky@username.com", domain.UserStateActive), + }, + }, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + // t.Parallel() + + // Given + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(tc.mockMatcher)) + require.NoError(t, err) + q := Queries{ + client: &database.DB{DB: db}, + } + defer db.Close() + + tc.mockExpectations(mock.ExpectQuery(tc.expectedQuery)) + + // Test + res, err := q.SearchUsersByMetadata(tc.inputCtx, tc.inputQueriesFunc(t), tc.inputPermissionCheckMock) + + // Verify + require.NoError(t, mock.ExpectationsWereMet()) + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedUsersByMeta, res) + }) + } +} + +type baseMatcher struct{} + +func (m *baseMatcher) Match(expectedSQL, actualSQL string) error { + strippedExpected := strings.ReplaceAll(expectedSQL, "\n", " ") + strippedActual := strings.ReplaceAll(actualSQL, "\n", " ") + + beforeWhere, _, _ := strings.Cut(strippedExpected, "WHERE") + if !strings.Contains(strippedActual, beforeWhere) { + return errors.New("actual SQL query does not contain expected sql") + } + + return nil +} + +type whereMatcher struct { + matcher sqlmock.QueryMatcher + toMatch string +} + +func (m *whereMatcher) Match(expectedSQL, actualSQL string) error { + existingMatchingErrs := m.matcher.Match(expectedSQL, actualSQL) + + strippedActual := strings.ReplaceAll(actualSQL, "\n", " ") + + collector := []error{existingMatchingErrs} + + if !strings.Contains(strippedActual, "WHERE") { + collector = append(collector, fmt.Errorf(`no WHERE clause found in actual SQL "%s"`, strippedActual)) + } + + for _, v := range strings.Split(m.toMatch, ",") { + if !strings.Contains(strippedActual, v) { + collector = append(collector, fmt.Errorf(`value "%s" is not contained in actual SQL "%s"`, v, strippedActual)) + } + } + + return errors.Join(collector...) +} + +type limitMatcher struct { + expectedLimit string + matcher sqlmock.QueryMatcher +} + +func (m *limitMatcher) Match(expectedSQL, actualSQL string) error { + existingMatchingErrs := m.matcher.Match(expectedSQL, actualSQL) + + strippedActual := strings.ReplaceAll(actualSQL, "\n", " ") + + re := regexp.MustCompile(`(?i)LIMIT\s+(\d+)`) + matches := re.FindStringSubmatch(strippedActual) + + collector := []error{existingMatchingErrs} + + if len(matches) == 0 { + return errors.Join(append(collector, fmt.Errorf(`no LIMIT clause found in actual SQL "%s"`, strippedActual))...) + } + + if matches[1] != m.expectedLimit { + collector = append(collector, fmt.Errorf(`limit "%s" is not contained in actual SQL "%s"`, matches[1], strippedActual)) + } + + return errors.Join(collector...) +} + +type offsetMatcher struct { + expectedOffset string + matcher sqlmock.QueryMatcher +} + +func (m *offsetMatcher) Match(expectedSQL, actualSQL string) error { + existingMatchingErrs := m.matcher.Match(expectedSQL, actualSQL) + + strippedActual := strings.ReplaceAll(actualSQL, "\n", " ") + + re := regexp.MustCompile(`(?i)OFFSET\s+(\d+)`) + matches := re.FindStringSubmatch(strippedActual) + + collector := []error{existingMatchingErrs} + + if len(matches) == 0 { + return errors.Join(append(collector, fmt.Errorf(`no OFFSET clause found in actual SQL "%s"`, strippedActual))...) + } + + if matches[1] != m.expectedOffset { + collector = append(collector, fmt.Errorf(`offset "%s" is not contained in actual SQL "%s"`, matches[1], strippedActual)) + } + + return errors.Join(collector...) +} + +type orderMatcher struct { + expectedSorting []string + matcher sqlmock.QueryMatcher +} + +func (m *orderMatcher) Match(expectedSQL, actualSQL string) error { + existingMatchingErrs := m.matcher.Match(expectedSQL, actualSQL) + + strippedActual := strings.ReplaceAll(actualSQL, "\n", " ") + + re := regexp.MustCompile(`(?i)ORDER\s+BY\s+([^\s,]+(?:\s+(?:ASC|DESC))?(?:\s*,\s*[^\s,]+(?:\s+(?:ASC|DESC))?)*)`) + matches := re.FindStringSubmatch(strippedActual) + + collector := []error{existingMatchingErrs} + + if len(matches) == 0 { + return errors.Join(append(collector, fmt.Errorf(`no ORDER BY clause found in actual SQL "%s"`, strippedActual))...) + } + + for _, sortValue := range m.expectedSorting { + if !strings.Contains(matches[1], sortValue) { + collector = append(collector, fmt.Errorf(`ORDER BY value "%s" is not contained in actual SQL "%s"`, sortValue, strippedActual)) + } + } + + return errors.Join(collector...) +} + +func machineUser(id, username, machineName, machineDescription, encodedSecret string, tokenType domain.OIDCTokenType, state domain.UserState) *User { + return &User{ + ID: id, + State: state, + Type: domain.UserTypeMachine, + Username: username, + Human: &Human{}, + Machine: &Machine{ + Name: machineName, + Description: machineDescription, + EncodedSecret: encodedSecret, + AccessTokenType: tokenType, + }, + } +} + +func humanUser(id, username, firstName, lastName, nickname string, email domain.EmailAddress, state domain.UserState) *User { + return &User{ + ID: id, + State: state, + Type: domain.UserTypeHuman, + Username: username, + Human: &Human{ + FirstName: firstName, + LastName: lastName, + NickName: nickname, + DisplayName: fmt.Sprintf("%s '%s' %s", firstName, nickname, lastName), + AvatarKey: "avatar_key", + PreferredLanguage: language.MustParse("it"), + Gender: domain.GenderMale, + Email: email, + IsEmailVerified: true, + Phone: "12345678", + IsPhoneVerified: true, + PasswordChangeRequired: false, + }, + Machine: &Machine{}, + } +}