From be00e3861a95ada736a551dc5cf338c6ba97c694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Tue, 16 Apr 2024 11:34:38 +0300 Subject: [PATCH 01/31] fix(oidc): make device auth audience and scope nullable (#7777) This fixes the projection of events that have a null audience or scope. As audience was added in v2.50, legacy events do not have an audience, this made replay of the old events not possible after an upgrade. --- internal/query/device_auth_test.go | 16 ++++++++-------- internal/query/projection/device_auth.go | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/internal/query/device_auth_test.go b/internal/query/device_auth_test.go index de123a3a78..d9773cdc7f 100644 --- a/internal/query/device_auth_test.go +++ b/internal/query/device_auth_test.go @@ -169,15 +169,15 @@ func TestQueries_DeviceAuthByDeviceCode(t *testing.T) { const ( expectedDeviceAuthQueryC = `SELECT` + - ` projections.device_auth_requests1.client_id,` + - ` projections.device_auth_requests1.device_code,` + - ` projections.device_auth_requests1.user_code,` + - ` projections.device_auth_requests1.scopes,` + - ` projections.device_auth_requests1.audience` + - ` FROM projections.device_auth_requests1` + ` projections.device_auth_requests2.client_id,` + + ` projections.device_auth_requests2.device_code,` + + ` projections.device_auth_requests2.user_code,` + + ` projections.device_auth_requests2.scopes,` + + ` projections.device_auth_requests2.audience` + + ` FROM projections.device_auth_requests2` expectedDeviceAuthWhereUserCodeQueryC = expectedDeviceAuthQueryC + - ` WHERE projections.device_auth_requests1.instance_id = $1` + - ` AND projections.device_auth_requests1.user_code = $2` + ` WHERE projections.device_auth_requests2.instance_id = $1` + + ` AND projections.device_auth_requests2.user_code = $2` ) var ( diff --git a/internal/query/projection/device_auth.go b/internal/query/projection/device_auth.go index 88cc127f05..4fd3be5510 100644 --- a/internal/query/projection/device_auth.go +++ b/internal/query/projection/device_auth.go @@ -11,7 +11,7 @@ import ( ) const ( - DeviceAuthRequestProjectionTable = "projections.device_auth_requests1" + DeviceAuthRequestProjectionTable = "projections.device_auth_requests2" DeviceAuthRequestColumnClientID = "client_id" DeviceAuthRequestColumnDeviceCode = "device_code" @@ -44,8 +44,8 @@ func (*deviceAuthRequestProjection) Init() *old_handler.Check { handler.NewColumn(DeviceAuthRequestColumnClientID, handler.ColumnTypeText), handler.NewColumn(DeviceAuthRequestColumnDeviceCode, handler.ColumnTypeText), handler.NewColumn(DeviceAuthRequestColumnUserCode, handler.ColumnTypeText), - handler.NewColumn(DeviceAuthRequestColumnScopes, handler.ColumnTypeTextArray), - handler.NewColumn(DeviceAuthRequestColumnAudience, handler.ColumnTypeTextArray), + handler.NewColumn(DeviceAuthRequestColumnScopes, handler.ColumnTypeTextArray, handler.Nullable()), + handler.NewColumn(DeviceAuthRequestColumnAudience, handler.ColumnTypeTextArray, handler.Nullable()), handler.NewColumn(DeviceAuthRequestColumnCreationDate, handler.ColumnTypeTimestamp), handler.NewColumn(DeviceAuthRequestColumnChangeDate, handler.ColumnTypeTimestamp), handler.NewColumn(DeviceAuthRequestColumnSequence, handler.ColumnTypeInt64), From f4126874271a2239c61f3bde2debdb668c22a366 Mon Sep 17 00:00:00 2001 From: Silvan Date: Tue, 16 Apr 2024 12:42:31 +0200 Subject: [PATCH 02/31] fix(query): query event editors only once per call (#7776) Co-authored-by: Livio Spring --- internal/query/event.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/internal/query/event.go b/internal/query/event.go index b1c910b763..7c37e0e87b 100644 --- a/internal/query/event.go +++ b/internal/query/event.go @@ -28,13 +28,14 @@ type EventEditor struct { } type eventsReducer struct { - ctx context.Context - q *Queries - events []*Event + ctx context.Context + q *Queries + events []*Event + editors map[string]*EventEditor } func (r *eventsReducer) AppendEvents(events ...eventstore.Event) { - r.events = append(r.events, r.q.convertEvents(r.ctx, events)...) + r.events = append(r.events, r.convertEvents(r.ctx, events)...) } func (r *eventsReducer) Reduce() error { return nil } @@ -49,7 +50,7 @@ func (q *Queries) SearchEvents(ctx context.Context, query *eventstore.SearchQuer if auditLogRetention != 0 { query = filterAuditLogRetention(ctx, auditLogRetention, query) } - reducer := &eventsReducer{ctx: ctx, q: q} + reducer := &eventsReducer{ctx: ctx, q: q, editors: make(map[string]*EventEditor, query.GetLimit())} if err = q.eventstore.FilterToReducer(ctx, query, reducer); err != nil { return nil, err } @@ -78,24 +79,23 @@ func (q *Queries) SearchAggregateTypes(ctx context.Context) []string { return q.eventstore.AggregateTypes() } -func (q *Queries) convertEvents(ctx context.Context, events []eventstore.Event) []*Event { +func (er *eventsReducer) convertEvents(ctx context.Context, events []eventstore.Event) []*Event { result := make([]*Event, len(events)) - users := make(map[string]*EventEditor) for i, event := range events { - result[i] = q.convertEvent(ctx, event, users) + result[i] = er.convertEvent(ctx, event) } return result } -func (q *Queries) convertEvent(ctx context.Context, event eventstore.Event, users map[string]*EventEditor) *Event { +func (er *eventsReducer) convertEvent(ctx context.Context, event eventstore.Event) *Event { ctx, span := tracing.NewSpan(ctx) var err error defer func() { span.EndWithError(err) }() - editor, ok := users[event.Creator()] + editor, ok := er.editors[event.Creator()] if !ok { - editor = q.editorUserByID(ctx, event.Creator()) - users[event.Creator()] = editor + editor = er.q.editorUserByID(ctx, event.Creator()) + er.editors[event.Creator()] = editor } return &Event{ From 386addc718f9236c7cbbe77d5aca7dae338c91d4 Mon Sep 17 00:00:00 2001 From: Silvan Date: Tue, 16 Apr 2024 13:19:17 +0200 Subject: [PATCH 03/31] chore: remove bloating span (#7780) * fix(query): query event editors only once per call * remove span --- internal/query/event.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/internal/query/event.go b/internal/query/event.go index 7c37e0e87b..1cce35553a 100644 --- a/internal/query/event.go +++ b/internal/query/event.go @@ -88,10 +88,6 @@ func (er *eventsReducer) convertEvents(ctx context.Context, events []eventstore. } func (er *eventsReducer) convertEvent(ctx context.Context, event eventstore.Event) *Event { - ctx, span := tracing.NewSpan(ctx) - var err error - defer func() { span.EndWithError(err) }() - editor, ok := er.editors[event.Creator()] if !ok { editor = er.q.editorUserByID(ctx, event.Creator()) From 9bcfa12be237ab929a5d82b10d23e765753d3704 Mon Sep 17 00:00:00 2001 From: Silvan Date: Tue, 16 Apr 2024 14:08:18 +0200 Subject: [PATCH 04/31] fix(middleware): init translation messages (#7778) * fix(middleware): init translation messages * revert change * refactor: split loop in separate function * add imports to ensure init of fs --- internal/i18n/bundle.go | 84 +++++++++++++++++++++++-------------- internal/i18n/fs.go | 5 +++ internal/i18n/translator.go | 2 +- 3 files changed, 58 insertions(+), 33 deletions(-) diff --git a/internal/i18n/bundle.go b/internal/i18n/bundle.go index 822b91fa08..3eeff7e239 100644 --- a/internal/i18n/bundle.go +++ b/internal/i18n/bundle.go @@ -3,58 +3,78 @@ package i18n import ( "encoding/json" "io" + "io/fs" "net/http" - "os" "path/filepath" "strings" "github.com/BurntSushi/toml" "github.com/nicksnyder/go-i18n/v2/i18n" + "github.com/zitadel/logging" "golang.org/x/text/language" "sigs.k8s.io/yaml" "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/zerrors" ) const i18nPath = "/i18n" -func newBundle(dir http.FileSystem, defaultLanguage language.Tag, allowedLanguages []language.Tag) (*i18n.Bundle, error) { +var translationMessages = map[Namespace]map[language.Tag]*i18n.MessageFile{ + ZITADEL: make(map[language.Tag]*i18n.MessageFile), + LOGIN: make(map[language.Tag]*i18n.MessageFile), + NOTIFICATION: make(map[language.Tag]*i18n.MessageFile), +} + +func init() { + for ns := range translationMessages { + loadTranslationsFromNamespace(ns) + } +} + +func newBundle(ns Namespace, defaultLanguage language.Tag, allowedLanguages []language.Tag) (*i18n.Bundle, error) { bundle := i18n.NewBundle(defaultLanguage) - bundle.RegisterUnmarshalFunc("yaml", func(data []byte, v interface{}) error { return yaml.Unmarshal(data, v) }) - bundle.RegisterUnmarshalFunc("json", json.Unmarshal) - bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) - i18nDir, err := dir.Open(i18nPath) - if err != nil { - return nil, zerrors.ThrowNotFound(err, "I18N-MnXRie", "path not found") - } - defer i18nDir.Close() - files, err := i18nDir.Readdir(0) - if err != nil { - return nil, zerrors.ThrowNotFound(err, "I18N-Gew23", "cannot read dir") - } - for _, file := range files { - fileLang, _ := strings.CutSuffix(file.Name(), filepath.Ext(file.Name())) - if err = domain.LanguageIsAllowed(false, allowedLanguages, language.Make(fileLang)); err != nil { + + for lang, file := range translationMessages[ns] { + if err := domain.LanguageIsAllowed(false, allowedLanguages, lang); err != nil { continue } - if err := addFileFromFileSystemToBundle(dir, bundle, file); err != nil { - return nil, zerrors.ThrowNotFoundf(err, "I18N-ZS2AW", "cannot append file %s to Bundle", file.Name()) - } + bundle.MustAddMessages(lang, file.Messages...) } + return bundle, nil } -func addFileFromFileSystemToBundle(dir http.FileSystem, bundle *i18n.Bundle, file os.FileInfo) error { - f, err := dir.Open("/i18n/" + file.Name()) - if err != nil { - return err +func loadTranslationsFromNamespace(ns Namespace) { + dir := LoadFilesystem(ns) + i18nDir, err := dir.Open(i18nPath) + logging.WithFields("namespace", ns).OnError(err).Panic("unable to open translation files") + defer i18nDir.Close() + files, err := i18nDir.Readdir(0) + logging.WithFields("namespace", ns).OnError(err).Panic("unable to read translation files") + for _, file := range files { + loadTranslationsFromFile(ns, file, dir) } - defer f.Close() - content, err := io.ReadAll(f) - if err != nil { - return err - } - _, err = bundle.ParseMessageFileBytes(content, file.Name()) - return err +} + +func loadTranslationsFromFile(ns Namespace, fileInfo fs.FileInfo, dir http.FileSystem) { + file, err := dir.Open("/i18n/" + fileInfo.Name()) + logging.WithFields("namespace", ns, "file", fileInfo.Name()).OnError(err).Panic("unable to open translation file") + defer file.Close() + + content, err := io.ReadAll(file) + logging.WithFields("namespace", ns, "file", fileInfo.Name()).OnError(err).Panic("unable to read translation file") + + unmarshaler := map[string]i18n.UnmarshalFunc{ + "yaml": func(data []byte, v interface{}) error { return yaml.Unmarshal(data, v) }, + "json": json.Unmarshal, + "toml": toml.Unmarshal, + } + + messageFile, err := i18n.ParseMessageFileBytes(content, fileInfo.Name(), unmarshaler) + logging.WithFields("namespace", ns, "file", fileInfo.Name()).OnError(err).Panic("unable to parse translation file") + + fileLang, _ := strings.CutSuffix(fileInfo.Name(), filepath.Ext(fileInfo.Name())) + lang := language.Make(fileLang) + + translationMessages[ns][lang] = messageFile } diff --git a/internal/i18n/fs.go b/internal/i18n/fs.go index eac34ba8e6..01497f3761 100644 --- a/internal/i18n/fs.go +++ b/internal/i18n/fs.go @@ -5,6 +5,11 @@ import ( "github.com/rakyll/statik/fs" "github.com/zitadel/logging" + + // ensure fs is setup + _ "github.com/zitadel/zitadel/internal/api/ui/login/statik" + _ "github.com/zitadel/zitadel/internal/notification/statik" + _ "github.com/zitadel/zitadel/internal/statik" ) var zitadelFS, loginFS, notificationFS http.FileSystem diff --git a/internal/i18n/translator.go b/internal/i18n/translator.go index a60932bd6c..74dd65663a 100644 --- a/internal/i18n/translator.go +++ b/internal/i18n/translator.go @@ -51,7 +51,7 @@ func newTranslator(ns Namespace, defaultLanguage language.Tag, allowedLanguages if len(t.allowedLanguages) == 0 { t.allowedLanguages = SupportedLanguages() } - t.bundle, err = newBundle(LoadFilesystem(ns), defaultLanguage, t.allowedLanguages) + t.bundle, err = newBundle(ns, defaultLanguage, t.allowedLanguages) if err != nil { return nil, err } From 9ccbbe05bc602d57e1eb897d4d28e876ff7dea86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Tue, 16 Apr 2024 16:02:38 +0300 Subject: [PATCH 05/31] fix(oidc): roles in userinfo for client credentials token (#7763) * fix(oidc): roles in userinfo for client credentials token When tokens were obtained using the client credentials grant, with audience and role scopes, userinfo would not return the role claims. This had multiple causes: 1. There is no auth request flow, so for legacy userinfo project data was never attached to the token 2. For optimized userinfo, there is no client ID that maps to an application. The client ID for client credentials is the machine user's name. There we can't obtain a project ID. When the project ID remained empty, we always ignored the roleAudience. This PR fixes situation 2, by always taking the roleAudience into account, even when the projectID is empty. The code responsible for the bug is also refactored to be more readable and understandable, including additional godoc. The fix only applies to the optimized userinfo code introduced in #7706 and released in v2.50 (currently in RC). Therefore it can't be back-ported to earlier versions. Fixes #6662 * chore(deps): update all go deps (#7764) This change updates all go modules, including oidc, a major version of go-jose and the go 1.22 release. * Revert "chore(deps): update all go deps" (#7772) Revert "chore(deps): update all go deps (#7764)" This reverts commit 6893e7d060a953d595a18ff8daa979834c4324d5. --------- Co-authored-by: Livio Spring --- internal/api/oidc/client_integration_test.go | 2 +- internal/api/oidc/introspect.go | 2 +- internal/api/oidc/token_exchange.go | 2 +- internal/api/oidc/userinfo.go | 50 +++++--- .../api/oidc/userinfo_integration_test.go | 104 ++++++++++++--- internal/api/oidc/userinfo_test.go | 119 ++++++++++++++---- internal/integration/oidc.go | 8 +- 7 files changed, 218 insertions(+), 69 deletions(-) diff --git a/internal/api/oidc/client_integration_test.go b/internal/api/oidc/client_integration_test.go index c7ace3c097..2ad88cf096 100644 --- a/internal/api/oidc/client_integration_test.go +++ b/internal/api/oidc/client_integration_test.go @@ -348,7 +348,7 @@ func createInvalidKeyData(t testing.TB, client *management.AddOIDCAppResponse) [ } func TestServer_CreateAccessToken_ClientCredentials(t *testing.T) { - clientID, clientSecret, err := Tester.CreateOIDCCredentialsClient(CTX) + _, clientID, clientSecret, err := Tester.CreateOIDCCredentialsClient(CTX) require.NoError(t, err) type clientDetails struct { diff --git a/internal/api/oidc/introspect.go b/internal/api/oidc/introspect.go index 0615193b03..a2e59c9a45 100644 --- a/internal/api/oidc/introspect.go +++ b/internal/api/oidc/introspect.go @@ -99,7 +99,7 @@ func (s *Server) Introspect(ctx context.Context, r *op.Request[op.IntrospectionR if err = validateIntrospectionAudience(token.audience, client.clientID, client.projectID); err != nil { return nil, err } - userInfo, err := s.userInfo(ctx, token.userID, client.projectID, client.projectRoleAssertion, token.scope, []string{client.projectID}) + userInfo, err := s.userInfo(ctx, token.userID, token.scope, client.projectID, client.projectRoleAssertion, true) if err != nil { return nil, err } diff --git a/internal/api/oidc/token_exchange.go b/internal/api/oidc/token_exchange.go index c50cf5859d..e9c6ed27d4 100644 --- a/internal/api/oidc/token_exchange.go +++ b/internal/api/oidc/token_exchange.go @@ -216,7 +216,7 @@ func (s *Server) createExchangeTokens(ctx context.Context, tokenType oidc.TokenT ) if slices.Contains(scopes, oidc.ScopeOpenID) || tokenType == oidc.JWTTokenType || tokenType == oidc.IDTokenType { projectID := client.client.ProjectID - userInfo, err = s.userInfo(ctx, subjectToken.userID, projectID, client.client.ProjectRoleAssertion, scopes, []string{projectID}) + userInfo, err = s.userInfo(ctx, subjectToken.userID, scopes, projectID, client.client.ProjectRoleAssertion, false) if err != nil { return nil, err } diff --git a/internal/api/oidc/userinfo.go b/internal/api/oidc/userinfo.go index e05a1a9f5d..c59e9c0952 100644 --- a/internal/api/oidc/userinfo.go +++ b/internal/api/oidc/userinfo.go @@ -53,18 +53,28 @@ func (s *Server) UserInfo(ctx context.Context, r *op.Request[oidc.UserInfoReques } } - userInfo, err := s.userInfo(ctx, token.userID, projectID, assertion, token.scope, nil) + userInfo, err := s.userInfo(ctx, token.userID, token.scope, projectID, assertion, false) if err != nil { return nil, err } return op.NewResponse(userInfo), nil } -func (s *Server) userInfo(ctx context.Context, userID, projectID string, projectRoleAssertion bool, scope, roleAudience []string) (_ *oidc.UserInfo, err error) { +// userInfo gets the user's data based on the scope. +// The returned UserInfo contains standard and reserved claims, documented +// here: https://zitadel.com/docs/apis/openidoauth/claims. +// +// projectID is an optional parameter which defines the default audience when there are any (or all) role claims requested. +// projectRoleAssertion sets the default of returning all project roles, only if no specific roles were requested in the scope. +// +// currentProjectOnly can be set to use the current project ID only and ignore the audience from the scope. +// It should be set in cases where the client doesn't need to know roles outside its own project, +// for example an introspection client. +func (s *Server) userInfo(ctx context.Context, userID string, scope []string, projectID string, projectRoleAssertion, currentProjectOnly bool) (_ *oidc.UserInfo, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - roleAudience, requestedRoles := prepareRoles(ctx, projectID, projectRoleAssertion, scope, roleAudience) + roleAudience, requestedRoles := prepareRoles(ctx, scope, projectID, projectRoleAssertion, currentProjectOnly) qu, err := s.query.GetOIDCUserInfo(ctx, userID, roleAudience) if err != nil { return nil, err @@ -74,31 +84,35 @@ func (s *Server) userInfo(ctx context.Context, userID, projectID string, project return userInfo, s.userinfoFlows(ctx, qu, userInfo) } -// prepareRoles scans the requested scopes, appends to roleAudience and returns the requestedRoles. +// prepareRoles scans the requested scopes and builds the requested roles +// and the audience for which roles need to be asserted. // // Scopes with [ScopeProjectRolePrefix] are added to requestedRoles. -// When [ScopeProjectsRoles] is present and roleAudience was empty, -// project IDs with the [domain.ProjectIDScope] prefix are added to the roleAudience. +// When [ScopeProjectsRoles] is present project IDs with the [domain.ProjectIDScope] +// prefix are added to the returned audience. // -// If projectRoleAssertion is true and the resulting requestedRoles or roleAudience are not empty, -// the current projectID will always be parts or roleAudience. -// Else nil, nil is returned. -func prepareRoles(ctx context.Context, projectID string, projectRoleAssertion bool, scope, roleAudience []string) (ra, requestedRoles []string) { - // if all roles are requested take the audience for those from the scopes - if slices.Contains(scope, ScopeProjectsRoles) && len(roleAudience) == 0 { - roleAudience = domain.AddAudScopeToAudience(ctx, roleAudience, scope) - } - requestedRoles = make([]string, 0, len(scope)) +// If projectRoleAssertion is true and there were no specific roles requested, +// the current projectID will always be parts of the returned audience. +func prepareRoles(ctx context.Context, scope []string, projectID string, projectRoleAssertion, currentProjectOnly bool) (roleAudience, requestedRoles []string) { for _, s := range scope { if role, ok := strings.CutPrefix(s, ScopeProjectRolePrefix); ok { requestedRoles = append(requestedRoles, role) } } - if !projectRoleAssertion && len(requestedRoles) == 0 && len(roleAudience) == 0 { - return nil, nil + + // If roles are requested take the audience for those from the scopes, + // when currentProjectOnly is not set. + if !currentProjectOnly && (len(requestedRoles) > 0 || slices.Contains(scope, ScopeProjectsRoles)) { + roleAudience = domain.AddAudScopeToAudience(ctx, roleAudience, scope) } - if projectID != "" && !slices.Contains(roleAudience, projectID) { + // When either: + // - Project role assertion is set; + // - Roles for the current project (only) are requested; + // - There is already a roleAudience requested through scope; + // - There are requested roles through the scope; + // and the projectID is not empty, projectID must be part of the roleAudience. + if (projectRoleAssertion || currentProjectOnly || len(roleAudience) > 0 || len(requestedRoles) > 0) && projectID != "" && !slices.Contains(roleAudience, projectID) { roleAudience = append(roleAudience, projectID) } return roleAudience, requestedRoles diff --git a/internal/api/oidc/userinfo_integration_test.go b/internal/api/oidc/userinfo_integration_test.go index 78cd5479ed..22e688ff4b 100644 --- a/internal/api/oidc/userinfo_integration_test.go +++ b/internal/api/oidc/userinfo_integration_test.go @@ -15,6 +15,7 @@ import ( "golang.org/x/oauth2" oidc_api "github.com/zitadel/zitadel/internal/api/oidc" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/integration" feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" "github.com/zitadel/zitadel/pkg/grpc/management" @@ -66,20 +67,13 @@ func TestServer_UserInfo(t *testing.T) { // testServer_UserInfo is the actual userinfo integration test, // which calls the userinfo endpoint with different client configurations, roles and token scopes. func testServer_UserInfo(t *testing.T) { - const role = "testUserRole" + const ( + roleFoo = "foo" + roleBar = "bar" + ) + clientID, projectID := createClient(t) - _, err := Tester.Client.Mgmt.AddProjectRole(CTX, &management.AddProjectRoleRequest{ - ProjectId: projectID, - RoleKey: role, - DisplayName: "test", - }) - require.NoError(t, err) - _, err = Tester.Client.Mgmt.AddUserGrant(CTX, &management.AddUserGrantRequest{ - UserId: User.GetUserId(), - ProjectId: projectID, - RoleKeys: []string{role}, - }) - require.NoError(t, err) + addProjectRolesGrants(t, User.GetUserId(), projectID, roleFoo, roleBar) tests := []struct { name string @@ -149,18 +143,34 @@ func testServer_UserInfo(t *testing.T) { assertions: []func(*testing.T, *oidc.UserInfo){ assertUserinfo, func(t *testing.T, ui *oidc.UserInfo) { - assertProjectRoleClaims(t, projectID, ui.Claims, role) + assertProjectRoleClaims(t, projectID, ui.Claims, true, roleFoo, roleBar) }, }, }, { - name: "projects roles scope", + name: "project role scope", prepare: getTokens, - scope: []string{oidc.ScopeProfile, oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeOfflineAccess, oidc_api.ScopeProjectRolePrefix + role}, + scope: []string{oidc.ScopeProfile, oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeOfflineAccess, + oidc_api.ScopeProjectRolePrefix + roleFoo, + }, assertions: []func(*testing.T, *oidc.UserInfo){ assertUserinfo, func(t *testing.T, ui *oidc.UserInfo) { - assertProjectRoleClaims(t, projectID, ui.Claims, role) + assertProjectRoleClaims(t, projectID, ui.Claims, true, roleFoo) + }, + }, + }, + { + name: "project role and audience scope", + prepare: getTokens, + scope: []string{oidc.ScopeProfile, oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeOfflineAccess, + oidc_api.ScopeProjectRolePrefix + roleFoo, + domain.ProjectIDScope + projectID + domain.AudSuffix, + }, + assertions: []func(*testing.T, *oidc.UserInfo){ + assertUserinfo, + func(t *testing.T, ui *oidc.UserInfo) { + assertProjectRoleClaims(t, projectID, ui.Claims, true, roleFoo) }, }, }, @@ -211,6 +221,57 @@ func testServer_UserInfo(t *testing.T) { } } +// https://github.com/zitadel/zitadel/issues/6662 +func TestServer_UserInfo_Issue6662(t *testing.T) { + const ( + roleFoo = "foo" + roleBar = "bar" + ) + + project, err := Tester.CreateProject(CTX) + projectID := project.GetId() + require.NoError(t, err) + userID, clientID, clientSecret, err := Tester.CreateOIDCCredentialsClient(CTX) + require.NoError(t, err) + addProjectRolesGrants(t, userID, projectID, roleFoo, roleBar) + + scope := []string{oidc.ScopeProfile, oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeOfflineAccess, + oidc_api.ScopeProjectRolePrefix + roleFoo, + domain.ProjectIDScope + projectID + domain.AudSuffix, + } + + provider, err := rp.NewRelyingPartyOIDC(CTX, Tester.OIDCIssuer(), clientID, clientSecret, redirectURI, scope) + require.NoError(t, err) + tokens, err := rp.ClientCredentials(CTX, provider, nil) + require.NoError(t, err) + + userinfo, err := rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, userID, provider) + require.NoError(t, err) + assertProjectRoleClaims(t, projectID, userinfo.Claims, false, roleFoo) +} + +func addProjectRolesGrants(t *testing.T, userID, projectID string, roles ...string) { + t.Helper() + bulkRoles := make([]*management.BulkAddProjectRolesRequest_Role, len(roles)) + for i, role := range roles { + bulkRoles[i] = &management.BulkAddProjectRolesRequest_Role{ + Key: role, + DisplayName: role, + } + } + _, err := Tester.Client.Mgmt.BulkAddProjectRoles(CTX, &management.BulkAddProjectRolesRequest{ + ProjectId: projectID, + Roles: bulkRoles, + }) + require.NoError(t, err) + _, err = Tester.Client.Mgmt.AddUserGrant(CTX, &management.AddUserGrantRequest{ + UserId: userID, + ProjectId: projectID, + RoleKeys: roles, + }) + require.NoError(t, err) +} + func getTokens(t *testing.T, clientID string, scope []string) *oidc.Tokens[*oidc.IDTokenClaims] { authRequestID := createAuthRequest(t, clientID, redirectURI, scope...) sessionID, sessionToken, startTime, changeTime := Tester.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) @@ -255,10 +316,13 @@ func assertNoReservedScopes(t *testing.T, claims map[string]any) { } } -func assertProjectRoleClaims(t *testing.T, projectID string, claims map[string]any, roles ...string) { +func assertProjectRoleClaims(t *testing.T, projectID string, claims map[string]any, claimProjectRole bool, roles ...string) { t.Helper() - projectIDRoleClaim := fmt.Sprintf(oidc_api.ClaimProjectRolesFormat, projectID) - for _, claim := range []string{oidc_api.ClaimProjectRoles, projectIDRoleClaim} { + projectRoleClaims := []string{fmt.Sprintf(oidc_api.ClaimProjectRolesFormat, projectID)} + if claimProjectRole { + projectRoleClaims = append(projectRoleClaims, oidc_api.ClaimProjectRoles) + } + for _, claim := range projectRoleClaims { roleMap, ok := claims[claim].(map[string]any) require.Truef(t, ok, "claim %s not found or wrong type %T", claim, claims[claim]) for _, roleKey := range roles { diff --git a/internal/api/oidc/userinfo_test.go b/internal/api/oidc/userinfo_test.go index 21e06e21c4..65354a4040 100644 --- a/internal/api/oidc/userinfo_test.go +++ b/internal/api/oidc/userinfo_test.go @@ -18,25 +18,25 @@ import ( func Test_prepareRoles(t *testing.T) { type args struct { projectID string - projectRoleAssertion bool scope []string - roleAudience []string + projectRoleAssertion bool + currentProjectOnly bool } tests := []struct { name string args args - wantRa []string + wantRoleAudience []string wantRequestedRoles []string }{ { - name: "empty scope and roleAudience", + name: "empty scope", args: args{ projectID: "projID", - projectRoleAssertion: false, scope: nil, - roleAudience: nil, + projectRoleAssertion: false, + currentProjectOnly: false, }, - wantRa: nil, + wantRoleAudience: nil, wantRequestedRoles: nil, }, { @@ -45,50 +45,121 @@ func Test_prepareRoles(t *testing.T) { projectID: "projID", projectRoleAssertion: true, scope: nil, - roleAudience: nil, + currentProjectOnly: false, }, - wantRa: []string{"projID"}, - wantRequestedRoles: []string{}, + wantRoleAudience: []string{"projID"}, + wantRequestedRoles: nil, }, { - name: "some scope and roleAudience", + name: "some scope, current project only", args: args{ projectID: "projID", projectRoleAssertion: false, scope: []string{"openid", "profile"}, - roleAudience: []string{"project2"}, + currentProjectOnly: true, }, - wantRa: []string{"project2", "projID"}, - wantRequestedRoles: []string{}, + wantRoleAudience: []string{"projID"}, + wantRequestedRoles: nil, }, { name: "scope projects roles", args: args{ projectID: "projID", projectRoleAssertion: false, - scope: []string{ScopeProjectsRoles, domain.ProjectIDScope + "project2" + domain.AudSuffix}, - roleAudience: nil, + scope: []string{ + "openid", "profile", + ScopeProjectsRoles, + domain.ProjectIDScope + "project2" + domain.AudSuffix, + }, + currentProjectOnly: false, }, - wantRa: []string{"project2", "projID"}, - wantRequestedRoles: []string{}, + wantRoleAudience: []string{"project2", "projID"}, + wantRequestedRoles: nil, + }, + { + name: "scope projects roles ignored, current project only", + args: args{ + projectID: "projID", + projectRoleAssertion: false, + scope: []string{ + "openid", "profile", + ScopeProjectsRoles, + domain.ProjectIDScope + "project2" + domain.AudSuffix, + }, + currentProjectOnly: true, + }, + wantRoleAudience: []string{"projID"}, + wantRequestedRoles: nil, }, { name: "scope project role prefix", args: args{ projectID: "projID", projectRoleAssertion: false, - scope: []string{"openid", "profile", ScopeProjectRolePrefix + "foo", ScopeProjectRolePrefix + "bar"}, - roleAudience: nil, + scope: []string{ + "openid", "profile", + ScopeProjectRolePrefix + "foo", + ScopeProjectRolePrefix + "bar", + }, + currentProjectOnly: false, }, - wantRa: []string{"projID"}, + wantRoleAudience: []string{"projID"}, + wantRequestedRoles: []string{"foo", "bar"}, + }, + { + name: "scope project role prefix and audience", + args: args{ + projectID: "projID", + projectRoleAssertion: false, + scope: []string{ + "openid", "profile", + ScopeProjectRolePrefix + "foo", + ScopeProjectRolePrefix + "bar", + domain.ProjectIDScope + "project2" + domain.AudSuffix, + }, + currentProjectOnly: false, + }, + wantRoleAudience: []string{"projID", "project2"}, + wantRequestedRoles: []string{"foo", "bar"}, + }, + { + name: "scope project role prefix and audience ignored, current project only", + args: args{ + projectID: "projID", + projectRoleAssertion: false, + scope: []string{ + "openid", "profile", + ScopeProjectRolePrefix + "foo", + ScopeProjectRolePrefix + "bar", + domain.ProjectIDScope + "project2" + domain.AudSuffix, + }, + currentProjectOnly: true, + }, + wantRoleAudience: []string{"projID"}, + wantRequestedRoles: []string{"foo", "bar"}, + }, + { + name: "no projectID, scope project role prefix and audience", + args: args{ + projectID: "", + projectRoleAssertion: false, + scope: []string{ + "openid", "profile", + ScopeProjectRolePrefix + "foo", + ScopeProjectRolePrefix + "bar", + domain.ProjectIDScope + "project2" + domain.AudSuffix, + }, + currentProjectOnly: false, + }, + wantRoleAudience: []string{"project2"}, wantRequestedRoles: []string{"foo", "bar"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gotRa, gotRequestedRoles := prepareRoles(context.Background(), tt.args.projectID, tt.args.projectRoleAssertion, tt.args.scope, tt.args.roleAudience) - assert.Equal(t, tt.wantRa, gotRa, "roleAudience") - assert.Equal(t, tt.wantRequestedRoles, gotRequestedRoles, "requestedRoles") + gotRoleAudience, gotRequestedRoles := prepareRoles(context.Background(), tt.args.scope, tt.args.projectID, tt.args.projectRoleAssertion, tt.args.currentProjectOnly) + assert.ElementsMatch(t, tt.wantRoleAudience, gotRoleAudience, "roleAudience") + assert.ElementsMatch(t, tt.wantRequestedRoles, gotRequestedRoles, "requestedRoles") }) } } diff --git a/internal/integration/oidc.go b/internal/integration/oidc.go index 765da68b4d..d928c3e004 100644 --- a/internal/integration/oidc.go +++ b/internal/integration/oidc.go @@ -281,7 +281,7 @@ func CheckRedirect(req *http.Request) (*url.URL, error) { return resp.Location() } -func (s *Tester) CreateOIDCCredentialsClient(ctx context.Context) (string, string, error) { +func (s *Tester) CreateOIDCCredentialsClient(ctx context.Context) (userID, clientID, clientSecret string, err error) { name := gofakeit.Username() user, err := s.Client.Mgmt.AddMachineUser(ctx, &management.AddMachineUserRequest{ Name: name, @@ -289,13 +289,13 @@ func (s *Tester) CreateOIDCCredentialsClient(ctx context.Context) (string, strin AccessTokenType: user.AccessTokenType_ACCESS_TOKEN_TYPE_JWT, }) if err != nil { - return "", "", err + return "", "", "", err } secret, err := s.Client.Mgmt.GenerateMachineSecret(ctx, &management.GenerateMachineSecretRequest{ UserId: user.GetUserId(), }) if err != nil { - return "", "", err + return "", "", "", err } - return secret.GetClientId(), secret.GetClientSecret(), nil + return user.GetUserId(), secret.GetClientId(), secret.GetClientSecret(), nil } From dbb824a73f710b40cfaec496e4b55604e7cc6dde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Wed, 17 Apr 2024 11:38:03 +0300 Subject: [PATCH 06/31] chore(oidc): add refresh token error integration test (#7766) We are trying to reproduce a few 500 responses we observe on zitadel cloud's token endpoint. As in the past these were caused by wrongly encoded or encrypted refresh tokens, I created a integration test which tries to reproduce 500 errors by sending invalid refresh tokens. The added test does not reproduce 500s, all returned errors are in the 400 range as they should. However, as the test is already written, we might as well include them. Related to #7765 Co-authored-by: Livio Spring --- internal/api/oidc/server_integration_test.go | 76 ++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 internal/api/oidc/server_integration_test.go diff --git a/internal/api/oidc/server_integration_test.go b/internal/api/oidc/server_integration_test.go new file mode 100644 index 0000000000..8a0c8796fa --- /dev/null +++ b/internal/api/oidc/server_integration_test.go @@ -0,0 +1,76 @@ +//go:build integration + +package oidc_test + +import ( + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/oidc/v3/pkg/client" + "github.com/zitadel/oidc/v3/pkg/client/rp" + "github.com/zitadel/oidc/v3/pkg/oidc" + "github.com/zitadel/schema" +) + +func TestServer_RefreshToken_Status(t *testing.T) { + clientID, _ := createClient(t) + provider, err := Tester.CreateRelyingParty(CTX, clientID, redirectURI) + require.NoError(t, err) + + tests := []struct { + name string + refreshToken string + }{ + { + name: "invalid base64", + refreshToken: "~!~@#$%", + }, + { + name: "invalid after decrypt", + refreshToken: "DEADBEEFDEADBEEF", + }, + { + name: "short input", + refreshToken: "DEAD", + }, + { + name: "empty input", + refreshToken: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + request := rp.RefreshTokenRequest{ + RefreshToken: tt.refreshToken, + ClientID: clientID, + GrantType: oidc.GrantTypeRefreshToken, + } + client.CallTokenEndpoint(CTX, request, tokenEndpointCaller{RelyingParty: provider}) + + values := make(url.Values) + err := schema.NewEncoder().Encode(request, values) + require.NoError(t, err) + + resp, err := http.Post(provider.OAuthConfig().Endpoint.TokenURL, "application/x-www-form-urlencoded", strings.NewReader(values.Encode())) + require.NoError(t, err) + defer resp.Body.Close() + assert.Less(t, resp.StatusCode, 500) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + t.Log(string(body)) + }) + } +} + +type tokenEndpointCaller struct { + rp.RelyingParty +} + +func (t tokenEndpointCaller) TokenEndpoint() string { + return t.OAuthConfig().Endpoint.TokenURL +} From d3376685996f1c0109a1d65424e7c0db52df5052 Mon Sep 17 00:00:00 2001 From: Silvan Date: Thu, 18 Apr 2024 11:21:07 +0200 Subject: [PATCH 07/31] chore: init load tests (#7635) * init load tests * add machine pat * setup app * add introspect * use xk6-modules repo * logging * add teardown * add manipulate user * add manipulate user * remove logs * convert tests to ts * add readme * zitadel * review comments --- .gitignore | 4 + load-test/.babelrc | 8 + load-test/.prettierrc | 5 + load-test/Makefile | 45 + load-test/README.md | 46 + load-test/package-lock.json | 4529 +++++++++++++++++ load-test/package.json | 33 + load-test/src/app.ts | 70 + load-test/src/config.ts | 67 + load-test/src/login_ui.ts | 113 + load-test/src/oidc.ts | 74 + load-test/src/org.ts | 56 + load-test/src/project.ts | 37 + load-test/src/url.ts | 18 + .../src/use_cases/human_password_login.ts | 37 + load-test/src/use_cases/introspection.ts | 54 + load-test/src/use_cases/machine_pat_login.ts | 46 + load-test/src/use_cases/manipulate_user.ts | 47 + load-test/src/use_cases/user_info.ts | 27 + load-test/src/user.ts | 222 + load-test/tsconfig.json | 26 + load-test/webpack.config.js | 48 + 22 files changed, 5612 insertions(+) create mode 100644 load-test/.babelrc create mode 100644 load-test/.prettierrc create mode 100644 load-test/Makefile create mode 100644 load-test/README.md create mode 100644 load-test/package-lock.json create mode 100644 load-test/package.json create mode 100644 load-test/src/app.ts create mode 100644 load-test/src/config.ts create mode 100644 load-test/src/login_ui.ts create mode 100644 load-test/src/oidc.ts create mode 100644 load-test/src/org.ts create mode 100644 load-test/src/project.ts create mode 100644 load-test/src/url.ts create mode 100644 load-test/src/use_cases/human_password_login.ts create mode 100644 load-test/src/use_cases/introspection.ts create mode 100644 load-test/src/use_cases/machine_pat_login.ts create mode 100644 load-test/src/use_cases/manipulate_user.ts create mode 100644 load-test/src/use_cases/user_info.ts create mode 100644 load-test/src/user.ts create mode 100644 load-test/tsconfig.json create mode 100644 load-test/webpack.config.js diff --git a/.gitignore b/.gitignore index fe02b30b6c..9ccc455b50 100644 --- a/.gitignore +++ b/.gitignore @@ -82,3 +82,7 @@ go.work go.work.sum # Local Netlify folder .netlify + +load-test/node_modules +load-test/yarn-error.log +load-test/dist \ No newline at end of file diff --git a/load-test/.babelrc b/load-test/.babelrc new file mode 100644 index 0000000000..32f6cc5f07 --- /dev/null +++ b/load-test/.babelrc @@ -0,0 +1,8 @@ +{ + "presets": ["@babel/env", "@babel/typescript"], + "plugins": [ + "@babel/proposal-class-properties", + + "@babel/proposal-object-rest-spread" + ] + } \ No newline at end of file diff --git a/load-test/.prettierrc b/load-test/.prettierrc new file mode 100644 index 0000000000..7a2a5d19e3 --- /dev/null +++ b/load-test/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 125, + "singleQuote": true, + "trailingComma": "all" +} diff --git a/load-test/Makefile b/load-test/Makefile new file mode 100644 index 0000000000..4d2e9aa18e --- /dev/null +++ b/load-test/Makefile @@ -0,0 +1,45 @@ +VUS ?= 20 +DURATION ?= "200s" +ZITADEL_HOST ?= +ADMIN_LOGIN_NAME ?= +ADMIN_PASSWORD ?= + +.PHONY: human_password_login +human_password_login: bundle + k6 run dist/human_password_login.js --vus ${VUS} --duration ${DURATION} + +.PHONY: machine_pat_login +machine_pat_login: bundle + k6 run dist/machine_pat_login.js --vus ${VUS} --duration ${DURATION} + +.PHONY: user_info +user_info: bundle + k6 run dist/user_info.js --vus ${VUS} --duration ${DURATION} + +.PHONY: manipulate_user +manipulate_user: bundle + k6 run dist/manipulate_user.js --vus ${VUS} --duration ${DURATION} + +.PHONY: introspect +introspect: ensure_modules bundle + go install go.k6.io/xk6/cmd/xk6@latest + cd ../../xk6-modules && xk6 build --with xk6-zitadel=. + ./../../xk6-modules/k6 run dist/introspection.js --vus ${VUS} --duration ${DURATION} + +.PHONY: lint +lint: + npm i + npm run lint:fix + +.PHONY: ensure_modules +ensure_modules: +ifeq (,$(wildcard $(PWD)/../../xk6-modules)) + @echo "cloning xk6-modules" + cd ../.. && git clone https://github.com/zitadel/xk6-modules.git +endif + cd ../../xk6-modules && git pull + +.PHONY: bundle +bundle: + npm i + npm run bundle \ No newline at end of file diff --git a/load-test/README.md b/load-test/README.md new file mode 100644 index 0000000000..6e1542da40 --- /dev/null +++ b/load-test/README.md @@ -0,0 +1,46 @@ +# Load Tests + +This package contains code for load testing specific endpoints of ZITADEL using [k6](https://k6.io). + +## Prerequisite + +* [npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) +* [k6](https://k6.io/docs/get-started/installation/) +* [go](https://go.dev/doc/install) +* running ZITADEL + +## Structure + +The use cases under tests are defined in `src/use_cases`. The implementation of ZITADEL resources and calls are located under `src`. + +## Execution + +### Env vars + +- `VUS`: Amount of parallel processes execute the test (default is 20) +- `DURATION`: Defines how long the tests are executed (default is `200s`) +- `ZITADEL_HOST`: URL of ZITADEL (default is `http://localhost:8080`) + +To setup the tests we use the credentials of console and log in using an admin. The user must be able to create organizations and all resources inside organizations. + +- `ADMIN_LOGIN_NAME`: `zitadel-admin@zitadel.localhost` +- `ADMIN_PASSWORD`: `Password1!` + +### Test + +Before you run the tests you need an initialized user. The tests don't implement the change password screen during login. + +* `make human_password_login` + setup: creates human users + test: uses the previously created humans to sign in using the login ui +* `make machine_pat_login` + setup: creates machines and a pat for each machine + test: calls user info endpoint with the given pats +* `make user_info` + setup: creates human users and signs them in + test: calls user info endpoint using the given humans +* `make manipulate_user` + test: creates a human, updates its profile, locks the user and then deletes it +* `make introspect` + setup: creates projects, one api per project, one key per api and generates the jwt from the given keys + test: calls introspection endpoint using the given JWTs \ No newline at end of file diff --git a/load-test/package-lock.json b/load-test/package-lock.json new file mode 100644 index 0000000000..3f519201bd --- /dev/null +++ b/load-test/package-lock.json @@ -0,0 +1,4529 @@ +{ + "name": "typescript", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "typescript", + "version": "1.0.0", + "license": "Apache-2.0", + "devDependencies": { + "@babel/core": "7.23.7", + "@babel/plugin-proposal-class-properties": "7.13.0", + "@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/webpack": "5.28.5", + "babel-loader": "9.1.3", + "clean-webpack-plugin": "4.0.0", + "copy-webpack-plugin": "^12.0.2", + "prettier": "^3.1.1", + "prettier-plugin-organize-imports": "^3.2.4", + "typescript": "5.4.5", + "webpack": "5.89.0", + "webpack-cli": "5.1.4", + "webpack-glob-entries": "^1.0.1" + }, + "engines": { + "node": "16 || 18 || 20" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.24.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.2.tgz", + "integrity": "sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ==", + "dev": true, + "dependencies": { + "@babel/highlight": "^7.24.2", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.4.tgz", + "integrity": "sha512-vg8Gih2MLK+kOkHJp4gBEIkyaIi00jgWot2D9QOmmfLC8jINSOzmCLta6Bvz/JSBCqnegV0L80jhxkol5GWNfQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.23.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.7.tgz", + "integrity": "sha512-+UpDgowcmqe36d4NwqvKsyPMlOLNGMsfMmQ5WGCu+siCe3t3dfe9njrzGfdN4qq+bcNUt0+Vw6haRxBOycs4dw==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.6", + "@babel/helper-compilation-targets": "^7.23.6", + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helpers": "^7.23.7", + "@babel/parser": "^7.23.6", + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.7", + "@babel/types": "^7.23.6", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.4.tgz", + "integrity": "sha512-Xd6+v6SnjWVx/nus+y0l1sxMOTOMBkyL4+BIdbALyatQnAe/SRVjANeDPSCYaX+i1iJmuGSKf3Z+E+V/va1Hvw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.24.0", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz", + "integrity": "sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.22.15.tgz", + "integrity": "sha512-QkBXwGgaoC2GtGZRoma6kv7Szfv06khvhFav67ZExau2RaXzy8MpHSMO2PNoP2XtmQphJQRHFfg77Bq731Yizw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.15" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", + "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.23.5", + "@babel/helper-validator-option": "^7.23.5", + "browserslist": "^4.22.2", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.24.4.tgz", + "integrity": "sha512-lG75yeuUSVu0pIcbhiYMXBXANHrpUPaOfu7ryAzskCgKUHuAxRQI5ssrtmF0X9UXldPlvT0XM/A4F44OXRt6iQ==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-member-expression-to-functions": "^7.23.0", + "@babel/helper-optimise-call-expression": "^7.22.5", + "@babel/helper-replace-supers": "^7.24.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.22.15.tgz", + "integrity": "sha512-29FkPLFjn4TPEa3RE7GpW+qbE8tlsu3jntNYNfcGsc49LphF1PQIiD+vMZ1z1xVOKt+93khA9tc2JBs3kBjA7w==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "regexpu-core": "^5.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.1.tgz", + "integrity": "sha512-o7SDgTJuvx5vLKD6SFvkydkSMBvahDKGiNJzG22IZYXhiqoe9efY7zocICBgzHV4IRg5wdgl2nEL/tulKIEIbA==", + "dev": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.22.6", + "@babel/helper-plugin-utils": "^7.22.5", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-environment-visitor": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-function-name": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-hoist-variables": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.23.0.tgz", + "integrity": "sha512-6gfrPwh7OuT6gZyJZvd6WbTfrqAo7vm4xCzAXOusKqq/vWdKXphTpj5klHKNmRUU6/QRGlBsyU9mAIPaWHlqJA==", + "dev": true, + "dependencies": { + "@babel/types": "^7.23.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.24.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.3.tgz", + "integrity": "sha512-viKb0F9f2s0BCS22QSF308z/+1YWKV/76mwt61NBzS5izMzDPwdq1pTrzf+Li3npBWX9KdQbkeCt1jSAM7lZqg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", + "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-simple-access": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-validator-identifier": "^7.22.20" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.22.5.tgz", + "integrity": "sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.0.tgz", + "integrity": "sha512-9cUznXMG0+FxRuJfvL82QlTqIzhVW9sL0KjMPHhAOOvpQGL8QtdxnBKILjBqxlHyliz0yCa1G903ZXI/FuHy2w==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.22.20.tgz", + "integrity": "sha512-pBGyV4uBqOns+0UvhsTO8qgl8hO89PmiDYv+/COyp1aeMcmfrfruz+/nCMFiYyFF/Knn0yfrC85ZzNFjembFTw==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-wrap-function": "^7.22.20" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.24.1.tgz", + "integrity": "sha512-QCR1UqC9BzG5vZl8BMicmZ28RuUBnHhAMddD8yHFHDRH9lLTZ9uUPehX8ctVPT8l0TKblJidqcgUUKGVrePleQ==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-member-expression-to-functions": "^7.23.0", + "@babel/helper-optimise-call-expression": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", + "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.22.5.tgz", + "integrity": "sha512-tK14r66JZKiC43p8Ki33yLBVJKlQDFoA8GYN67lWCDCqoL6EMMSuM9b+Iff2jHaM/RRFYl7K+iiru7hbRqNx8Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.1.tgz", + "integrity": "sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", + "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.22.20.tgz", + "integrity": "sha512-pms/UwkOpnQe/PDAEdV/d7dVCoBbB+R4FvYoHGZz+4VPcg7RtYy2KP7S2lbuWM6FCSgob5wshfGESbC/hzNXZw==", + "dev": true, + "dependencies": { + "@babel/helper-function-name": "^7.22.5", + "@babel/template": "^7.22.15", + "@babel/types": "^7.22.19" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.4.tgz", + "integrity": "sha512-FewdlZbSiwaVGlgT1DPANDuCHaDMiOo+D/IDYRFYjHOuv66xMSJ7fQwwODwRNAPkADIO/z1EoF/l2BCWlWABDw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.24.0", + "@babel/traverse": "^7.24.1", + "@babel/types": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.24.2", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.2.tgz", + "integrity": "sha512-Yac1ao4flkTxTteCDZLEvdxg2fZfz1v8M4QpaGypq/WPDqg3ijHYbDfs+LG5hvzSoqaSZ9/Z9lKSP3CjZjv+pA==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.4.tgz", + "integrity": "sha512-zTvEBcghmeBma9QIGunWevvBAp4/Qu9Bdq+2k0Ot4fVMD6v3dsC9WOcRSKk7tRRyBM/53yKMJko9xOatGQAwSg==", + "dev": true, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.24.1.tgz", + "integrity": "sha512-y4HqEnkelJIOQGd+3g1bTeKsA5c6qM7eOn7VggGVbBc0y8MLSKHacwcIE2PplNlQSj0PqS9rrXL/nkPVK+kUNg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.24.1.tgz", + "integrity": "sha512-Hj791Ii4ci8HqnaKHAlLNs+zaLXb0EzSDhiAWp5VNlyvCNymYfacs64pxTxbH1znW/NcArSmwpmG9IKE/TUVVQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", + "@babel/plugin-transform-optional-chaining": "^7.24.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.24.1.tgz", + "integrity": "sha512-m9m/fXsXLiHfwdgydIFnpk+7jlVbnvlK5B2EKiPdLUb6WX654ZaaEWJUjk8TftRbZpK0XibovlLWX4KIZhV6jw==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-class-properties": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.13.0.tgz", + "integrity": "sha512-KnTDjFNC1g+45ka0myZNvSBFLhNCLN+GeGYLDEA8Oq7MZ6yMgfLoIRh86GRT0FjtJhZw8JyUskP9uvj5pHM9Zg==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-class-properties instead.", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.13.0", + "@babel/helper-plugin-utils": "^7.13.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-object-rest-spread": { + "version": "7.13.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.13.8.tgz", + "integrity": "sha512-DhB2EuB1Ih7S3/IRX5AFVgZ16k3EzfRbq97CxAVI1KSYcW+lexV8VZb7G7L8zuPVSdQMRn0kiBpf/Yzu9ZKH0g==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-object-rest-spread instead.", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.13.8", + "@babel/helper-compilation-targets": "^7.13.8", + "@babel/helper-plugin-utils": "^7.13.0", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-transform-parameters": "^7.13.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-export-namespace-from": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz", + "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.3" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.24.1.tgz", + "integrity": "sha512-IuwnI5XnuF189t91XbxmXeCDz3qs6iDRO7GJ++wcfgeXNs/8FmIlKcpDSXNVyuLQxlwvskmI3Ct73wUODkJBlQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.24.1.tgz", + "integrity": "sha512-zhQTMH0X2nVLnb04tz+s7AMuasX8U0FnpE+nHTOhSOINjWMnopoZTxtIKsd45n4GQ/HIZLyfIpoul8e2m0DnRA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.24.1.tgz", + "integrity": "sha512-2eCtxZXf+kbkMIsXS4poTvT4Yu5rXiRa+9xGVT56raghjmBTKMpFNc9R4IDiB4emao9eO22Ox7CxuJG7BgExqA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.24.1.tgz", + "integrity": "sha512-Yhnmvy5HZEnHUty6i++gcfH1/l68AHnItFHnaCv6hn9dNh0hQvvQJsxpi4BMBFN5DLeHBuucT/0DgzXif/OyRw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.24.1.tgz", + "integrity": "sha512-ngT/3NkRhsaep9ck9uj2Xhv9+xB1zShY3tM3g6om4xxCELwCDN4g4Aq5dRn48+0hasAql7s2hdBOysCfNpr4fw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.24.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.24.3.tgz", + "integrity": "sha512-Qe26CMYVjpQxJ8zxM1340JFNjZaF+ISWpr1Kt/jGo+ZTUzKkfw/pphEWbRCb+lmSM6k/TOgfYLvmbHkUQ0asIg==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-remap-async-to-generator": "^7.22.20", + "@babel/plugin-syntax-async-generators": "^7.8.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.24.1.tgz", + "integrity": "sha512-AawPptitRXp1y0n4ilKcGbRYWfbbzFWz2NqNu7dacYDtFtz0CMjG64b3LQsb3KIgnf4/obcUL78hfaOS7iCUfw==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.24.1", + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-remap-async-to-generator": "^7.22.20" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.24.1.tgz", + "integrity": "sha512-TWWC18OShZutrv9C6mye1xwtam+uNi2bnTOCBUd5sZxyHOiWbU6ztSROofIMrK84uweEZC219POICK/sTYwfgg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.24.4.tgz", + "integrity": "sha512-nIFUZIpGKDf9O9ttyRXpHFpKC+X3Y5mtshZONuEUYBomAKoM4y029Jr+uB1bHGPhNmK8YXHevDtKDOLmtRrp6g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.24.1.tgz", + "integrity": "sha512-OMLCXi0NqvJfORTaPQBwqLXHhb93wkBKZ4aNwMl6WtehO7ar+cmp+89iPEQPqxAnxsOKTaMcs3POz3rKayJ72g==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.24.1", + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.24.4.tgz", + "integrity": "sha512-B8q7Pz870Hz/q9UgP8InNpY01CSLDSCyqX7zcRuv3FcPl87A2G17lASroHWaCtbdIcbYzOZ7kWmXFKbijMSmFg==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.24.4", + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/plugin-syntax-class-static-block": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.24.1.tgz", + "integrity": "sha512-ZTIe3W7UejJd3/3R4p7ScyyOoafetUShSf4kCqV0O7F/RiHxVj/wRaRnQlrGwflvcehNA8M42HkAiEDYZu2F1Q==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-compilation-targets": "^7.23.6", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-replace-supers": "^7.24.1", + "@babel/helper-split-export-declaration": "^7.22.6", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.24.1.tgz", + "integrity": "sha512-5pJGVIUfJpOS+pAqBQd+QMaTD2vCL/HcePooON6pDpHgRp4gNRmzyHTPIkXntwKsq3ayUFVfJaIKPw2pOkOcTw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/template": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.24.1.tgz", + "integrity": "sha512-ow8jciWqNxR3RYbSNVuF4U2Jx130nwnBnhRw6N6h1bOejNkABmcI5X5oz29K4alWX7vf1C+o6gtKXikzRKkVdw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.24.1.tgz", + "integrity": "sha512-p7uUxgSoZwZ2lPNMzUkqCts3xlp8n+o05ikjy7gbtFJSt9gdU88jAmtfmOxHM14noQXBxfgzf2yRWECiNVhTCw==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.22.15", + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.24.1.tgz", + "integrity": "sha512-msyzuUnvsjsaSaocV6L7ErfNsa5nDWL1XKNnDePLgmz+WdU4w/J8+AxBMrWfi9m4IxfL5sZQKUPQKDQeeAT6lA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.24.1.tgz", + "integrity": "sha512-av2gdSTyXcJVdI+8aFZsCAtR29xJt0S5tas+Ef8NvBNmD1a+N/3ecMLeMBgfcK+xzsjdLDT6oHt+DFPyeqUbDA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/plugin-syntax-dynamic-import": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.24.1.tgz", + "integrity": "sha512-U1yX13dVBSwS23DEAqU+Z/PkwE9/m7QQy8Y9/+Tdb8UWYaGNDYwTLi19wqIAiROr8sXVum9A/rtiH5H0boUcTw==", + "dev": true, + "dependencies": { + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.22.15", + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.24.1.tgz", + "integrity": "sha512-Ft38m/KFOyzKw2UaJFkWG9QnHPG/Q/2SkOrRk4pNBPg5IPZ+dOxcmkK5IyuBcxiNPyyYowPGUReyBvrvZs7IlQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.24.1.tgz", + "integrity": "sha512-OxBdcnF04bpdQdR3i4giHZNZQn7cm8RQKcSwA17wAAqEELo1ZOwp5FFgeptWUQXFyT9kwHo10aqqauYkRZPCAg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.24.1.tgz", + "integrity": "sha512-BXmDZpPlh7jwicKArQASrj8n22/w6iymRnvHYYd2zO30DbE277JO20/7yXJT3QxDPtiQiOxQBbZH4TpivNXIxA==", + "dev": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.23.6", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.24.1.tgz", + "integrity": "sha512-U7RMFmRvoasscrIFy5xA4gIp8iWnWubnKkKuUGJjsuOH7GfbMkB+XZzeslx2kLdEGdOJDamEmCqOks6e8nv8DQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/plugin-syntax-json-strings": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.24.1.tgz", + "integrity": "sha512-zn9pwz8U7nCqOYIiBaOxoQOtYmMODXTJnkxG4AtX8fPmnCRYWBOHD0qcpwS9e2VDSp1zNJYpdnFMIKb8jmwu6g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.24.1.tgz", + "integrity": "sha512-OhN6J4Bpz+hIBqItTeWJujDOfNP+unqv/NJgyhlpSqgBTPm37KkMmZV6SYcOj+pnDbdcl1qRGV/ZiIjX9Iy34w==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.24.1.tgz", + "integrity": "sha512-4ojai0KysTWXzHseJKa1XPNXKRbuUrhkOPY4rEGeR+7ChlJVKxFa3H3Bz+7tWaGKgJAXUWKOGmltN+u9B3+CVg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.24.1.tgz", + "integrity": "sha512-lAxNHi4HVtjnHd5Rxg3D5t99Xm6H7b04hUS7EHIXcUl2EV4yl1gWdqZrNzXnSrHveL9qMdbODlLF55mvgjAfaQ==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.24.1.tgz", + "integrity": "sha512-szog8fFTUxBfw0b98gEWPaEqF42ZUD/T3bkynW/wtgx2p/XCP55WEsb+VosKceRSd6njipdZvNogqdtI4Q0chw==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-simple-access": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.24.1.tgz", + "integrity": "sha512-mqQ3Zh9vFO1Tpmlt8QPnbwGHzNz3lpNEMxQb1kAemn/erstyqw1r9KeOlOfo3y6xAnFEcOv2tSyrXfmMk+/YZA==", + "dev": true, + "dependencies": { + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-validator-identifier": "^7.22.20" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.24.1.tgz", + "integrity": "sha512-tuA3lpPj+5ITfcCluy6nWonSL7RvaG0AOTeAuvXqEKS34lnLzXpDb0dcP6K8jD0zWZFNDVly90AGFJPnm4fOYg==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.22.5.tgz", + "integrity": "sha512-YgLLKmS3aUBhHaxp5hi1WJTgOUb/NCuDHzGT9z9WTt3YG+CPRhJs6nprbStx6DnWM4dh6gt7SU3sZodbZ08adQ==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.24.1.tgz", + "integrity": "sha512-/rurytBM34hYy0HKZQyA0nHbQgQNFm4Q/BOc9Hflxi2X3twRof7NaE5W46j4kQitm7SvACVRXsa6N/tSZxvPug==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.24.1.tgz", + "integrity": "sha512-iQ+caew8wRrhCikO5DrUYx0mrmdhkaELgFa+7baMcVuhxIkN7oxt06CZ51D65ugIb1UWRQ8oQe+HXAVM6qHFjw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.24.1.tgz", + "integrity": "sha512-7GAsGlK4cNL2OExJH1DzmDeKnRv/LXq0eLUSvudrehVA5Rgg4bIrqEUW29FbKMBRT0ztSqisv7kjP+XIC4ZMNw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/plugin-syntax-numeric-separator": "^7.10.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.24.1.tgz", + "integrity": "sha512-XjD5f0YqOtebto4HGISLNfiNMTTs6tbkFf2TOqJlYKYmbo+mN9Dnpl4SRoofiziuOWMIyq3sZEUqLo3hLITFEA==", + "dev": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.23.6", + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-transform-parameters": "^7.24.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.24.1.tgz", + "integrity": "sha512-oKJqR3TeI5hSLRxudMjFQ9re9fBVUU0GICqM3J1mi8MqlhVr6hC/ZN4ttAyMuQR6EZZIY6h/exe5swqGNNIkWQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-replace-supers": "^7.24.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.24.1.tgz", + "integrity": "sha512-oBTH7oURV4Y+3EUrf6cWn1OHio3qG/PVwO5J03iSJmBg6m2EhKjkAu/xuaXaYwWW9miYtvbWv4LNf0AmR43LUA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.24.1.tgz", + "integrity": "sha512-n03wmDt+987qXwAgcBlnUUivrZBPZ8z1plL0YvgQalLm+ZE5BMhGm94jhxXtA1wzv1Cu2aaOv1BM9vbVttrzSg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", + "@babel/plugin-syntax-optional-chaining": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.24.1.tgz", + "integrity": "sha512-8Jl6V24g+Uw5OGPeWNKrKqXPDw2YDjLc53ojwfMcKwlEoETKU9rU0mHUtcg9JntWI/QYzGAXNWEcVHZ+fR+XXg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.24.1.tgz", + "integrity": "sha512-tGvisebwBO5em4PaYNqt4fkw56K2VALsAbAakY0FjTYqJp7gfdrgr7YX76Or8/cpik0W6+tj3rZ0uHU9Oil4tw==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.24.1", + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.24.1.tgz", + "integrity": "sha512-pTHxDVa0BpUbvAgX3Gat+7cSciXqUcY9j2VZKTbSB6+VQGpNgNO9ailxTGHSXlqOnX1Hcx1Enme2+yv7VqP9bg==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-create-class-features-plugin": "^7.24.1", + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.24.1.tgz", + "integrity": "sha512-LetvD7CrHmEx0G442gOomRr66d7q8HzzGGr4PMHGr+5YIm6++Yke+jxj246rpvsbyhJwCLxcTn6zW1P1BSenqA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.24.1.tgz", + "integrity": "sha512-sJwZBCzIBE4t+5Q4IGLaaun5ExVMRY0lYwos/jNecjMrVCygCdph3IKv0tkP5Fc87e/1+bebAmEAGBfnRD+cnw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0", + "regenerator-transform": "^0.15.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.24.1.tgz", + "integrity": "sha512-JAclqStUfIwKN15HrsQADFgeZt+wexNQ0uLhuqvqAUFoqPMjEcFCYZBhq0LUdz6dZK/mD+rErhW71fbx8RYElg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.24.1.tgz", + "integrity": "sha512-LyjVB1nsJ6gTTUKRjRWx9C1s9hE7dLfP/knKdrfeH9UPtAGjYGgxIbFfx7xyLIEWs7Xe1Gnf8EWiUqfjLhInZA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.24.1.tgz", + "integrity": "sha512-KjmcIM+fxgY+KxPVbjelJC6hrH1CgtPmTvdXAfn3/a9CnWGSTY7nH4zm5+cjmWJybdcPSsD0++QssDsjcpe47g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.24.1.tgz", + "integrity": "sha512-9v0f1bRXgPVcPrngOQvLXeGNNVLc8UjMVfebo9ka0WF3/7+aVUHmaJVT3sa0XCzEFioPfPHZiOcYG9qOsH63cw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.24.1.tgz", + "integrity": "sha512-WRkhROsNzriarqECASCNu/nojeXCDTE/F2HmRgOzi7NGvyfYGq1NEjKBK3ckLfRgGc6/lPAqP0vDOSw3YtG34g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.24.1.tgz", + "integrity": "sha512-CBfU4l/A+KruSUoW+vTQthwcAdwuqbpRNB8HQKlZABwHRhsdHZ9fezp4Sn18PeAlYxTNiLMlx4xUBV3AWfg1BA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.24.4.tgz", + "integrity": "sha512-79t3CQ8+oBGk/80SQ8MN3Bs3obf83zJ0YZjDmDaEZN8MqhMI760apl5z6a20kFeMXBwJX99VpKT8CKxEBp5H1g==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-create-class-features-plugin": "^7.24.4", + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/plugin-syntax-typescript": "^7.24.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.24.1.tgz", + "integrity": "sha512-RlkVIcWT4TLI96zM660S877E7beKlQw7Ig+wqkKBiWfj0zH5Q4h50q6er4wzZKRNSYpfo6ILJ+hrJAGSX2qcNw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.24.1.tgz", + "integrity": "sha512-Ss4VvlfYV5huWApFsF8/Sq0oXnGO+jB+rijFEFugTd3cwSObUSnUi88djgR5528Csl0uKlrI331kRqe56Ov2Ng==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.22.15", + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.24.1.tgz", + "integrity": "sha512-2A/94wgZgxfTsiLaQ2E36XAOdcZmGAaEEgVmxQWwZXWkGhvoHbaqXcKnU8zny4ycpu3vNqg0L/PcCiYtHtA13g==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.22.15", + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.24.1.tgz", + "integrity": "sha512-fqj4WuzzS+ukpgerpAoOnMfQXwUHFxXUZUE84oL2Kao2N8uSlvcpnAidKASgsNgzZHBsHWvcm8s9FPWUhAb8fA==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.22.15", + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.23.8", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.23.8.tgz", + "integrity": "sha512-lFlpmkApLkEP6woIKprO6DO60RImpatTQKtz4sUcDjVcK8M8mQ4sZsuxaTMNOZf0sqAq/ReYW1ZBHnOQwKpLWA==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.23.5", + "@babel/helper-compilation-targets": "^7.23.6", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-validator-option": "^7.23.5", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.23.3", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.23.3", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.23.7", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3", + "@babel/plugin-syntax-import-assertions": "^7.23.3", + "@babel/plugin-syntax-import-attributes": "^7.23.3", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.23.3", + "@babel/plugin-transform-async-generator-functions": "^7.23.7", + "@babel/plugin-transform-async-to-generator": "^7.23.3", + "@babel/plugin-transform-block-scoped-functions": "^7.23.3", + "@babel/plugin-transform-block-scoping": "^7.23.4", + "@babel/plugin-transform-class-properties": "^7.23.3", + "@babel/plugin-transform-class-static-block": "^7.23.4", + "@babel/plugin-transform-classes": "^7.23.8", + "@babel/plugin-transform-computed-properties": "^7.23.3", + "@babel/plugin-transform-destructuring": "^7.23.3", + "@babel/plugin-transform-dotall-regex": "^7.23.3", + "@babel/plugin-transform-duplicate-keys": "^7.23.3", + "@babel/plugin-transform-dynamic-import": "^7.23.4", + "@babel/plugin-transform-exponentiation-operator": "^7.23.3", + "@babel/plugin-transform-export-namespace-from": "^7.23.4", + "@babel/plugin-transform-for-of": "^7.23.6", + "@babel/plugin-transform-function-name": "^7.23.3", + "@babel/plugin-transform-json-strings": "^7.23.4", + "@babel/plugin-transform-literals": "^7.23.3", + "@babel/plugin-transform-logical-assignment-operators": "^7.23.4", + "@babel/plugin-transform-member-expression-literals": "^7.23.3", + "@babel/plugin-transform-modules-amd": "^7.23.3", + "@babel/plugin-transform-modules-commonjs": "^7.23.3", + "@babel/plugin-transform-modules-systemjs": "^7.23.3", + "@babel/plugin-transform-modules-umd": "^7.23.3", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.22.5", + "@babel/plugin-transform-new-target": "^7.23.3", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.23.4", + "@babel/plugin-transform-numeric-separator": "^7.23.4", + "@babel/plugin-transform-object-rest-spread": "^7.23.4", + "@babel/plugin-transform-object-super": "^7.23.3", + "@babel/plugin-transform-optional-catch-binding": "^7.23.4", + "@babel/plugin-transform-optional-chaining": "^7.23.4", + "@babel/plugin-transform-parameters": "^7.23.3", + "@babel/plugin-transform-private-methods": "^7.23.3", + "@babel/plugin-transform-private-property-in-object": "^7.23.4", + "@babel/plugin-transform-property-literals": "^7.23.3", + "@babel/plugin-transform-regenerator": "^7.23.3", + "@babel/plugin-transform-reserved-words": "^7.23.3", + "@babel/plugin-transform-shorthand-properties": "^7.23.3", + "@babel/plugin-transform-spread": "^7.23.3", + "@babel/plugin-transform-sticky-regex": "^7.23.3", + "@babel/plugin-transform-template-literals": "^7.23.3", + "@babel/plugin-transform-typeof-symbol": "^7.23.3", + "@babel/plugin-transform-unicode-escapes": "^7.23.3", + "@babel/plugin-transform-unicode-property-regex": "^7.23.3", + "@babel/plugin-transform-unicode-regex": "^7.23.3", + "@babel/plugin-transform-unicode-sets-regex": "^7.23.3", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.7", + "babel-plugin-polyfill-corejs3": "^0.8.7", + "babel-plugin-polyfill-regenerator": "^0.5.4", + "core-js-compat": "^3.31.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.23.3.tgz", + "integrity": "sha512-17oIGVlqz6CchO9RFYn5U6ZpWRZIngayYCtrPRSgANSwC2V1Jb+iP74nVxzzXJte8b8BYxrL1yY96xfhTBrNNQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-validator-option": "^7.22.15", + "@babel/plugin-syntax-jsx": "^7.23.3", + "@babel/plugin-transform-modules-commonjs": "^7.23.3", + "@babel/plugin-transform-typescript": "^7.23.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@babel/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==", + "dev": true + }, + "node_modules/@babel/runtime": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.4.tgz", + "integrity": "sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA==", + "dev": true, + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz", + "integrity": "sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.23.5", + "@babel/parser": "^7.24.0", + "@babel/types": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.1.tgz", + "integrity": "sha512-xuU6o9m68KeqZbQuDt2TcKSxUw/mrsvavlEqQ1leZ/B+C9tk6E4sRWy97WaXgvq5E+nU3cXMxv3WKOCanVMCmQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.24.1", + "@babel/generator": "^7.24.1", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.24.1", + "@babel/types": "^7.24.0", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", + "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@types/eslint": { + "version": "8.56.9", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.9.tgz", + "integrity": "sha512-W4W3KcqzjJ0sHg2vAq9vfml6OhsJ53TcUjUqfzzZf/EChUtwspszj/S0pzMxnfRcO55/iGq47dscXw71Fxc4Zg==", + "dev": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true + }, + "node_modules/@types/glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==", + "dev": true, + "dependencies": { + "@types/minimatch": "*", + "@types/node": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/k6": { + "version": "0.50.0", + "resolved": "https://registry.npmjs.org/@types/k6/-/k6-0.50.0.tgz", + "integrity": "sha512-+KpsLr549oFSpr80Zk9lf9wsEgESYTYzGeBIlS6NnH4PBiqJuRk+xv3KSFfDOCD0ZHbhUsXMV7mEVhWLOzfzmw==", + "dev": true + }, + "node_modules/@types/minimatch": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", + "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", + "dev": true + }, + "node_modules/@types/node": { + "version": "20.12.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", + "integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/webpack": { + "version": "5.28.5", + "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-5.28.5.tgz", + "integrity": "sha512-wR87cgvxj3p6D0Crt1r5avwqffqPXUkNlnQ1mjU93G7gCuFjufZR4I6j8cz5g1F1tTYpfOOFvly+cmIQwL9wvw==", + "dev": true, + "dependencies": { + "@types/node": "*", + "tapable": "^2.2.0", + "webpack": "^5" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", + "integrity": "sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==", + "dev": true, + "dependencies": { + "@webassemblyjs/helper-numbers": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", + "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", + "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz", + "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", + "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", + "dev": true, + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.11.6", + "@webassemblyjs/helper-api-error": "1.11.6", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", + "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz", + "integrity": "sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/wasm-gen": "1.12.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", + "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", + "dev": true, + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", + "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", + "dev": true, + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", + "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", + "dev": true + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz", + "integrity": "sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/helper-wasm-section": "1.12.1", + "@webassemblyjs/wasm-gen": "1.12.1", + "@webassemblyjs/wasm-opt": "1.12.1", + "@webassemblyjs/wasm-parser": "1.12.1", + "@webassemblyjs/wast-printer": "1.12.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz", + "integrity": "sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz", + "integrity": "sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", + "@webassemblyjs/wasm-gen": "1.12.1", + "@webassemblyjs/wasm-parser": "1.12.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz", + "integrity": "sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-api-error": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz", + "integrity": "sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.12.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webpack-cli/configtest": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-2.1.1.tgz", + "integrity": "sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw==", + "dev": true, + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + } + }, + "node_modules/@webpack-cli/info": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-2.0.2.tgz", + "integrity": "sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A==", + "dev": true, + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + } + }, + "node_modules/@webpack-cli/serve": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-2.0.5.tgz", + "integrity": "sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ==", + "dev": true, + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + }, + "peerDependenciesMeta": { + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true + }, + "node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-assertions": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", + "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", + "dev": true, + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==", + "dev": true, + "dependencies": { + "array-uniq": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/babel-loader": { + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.1.3.tgz", + "integrity": "sha512-xG3ST4DglodGf8qSwv0MdeWLhrDsw/32QMdTO5T1ZIp9gQur0HkCyFs7Awskr10JKXFXwpAhiCuYX5oGXnRGbw==", + "dev": true, + "dependencies": { + "find-cache-dir": "^4.0.0", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 14.15.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0", + "webpack": ">=5" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.10", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.10.tgz", + "integrity": "sha512-rpIuu//y5OX6jVU+a5BCn1R5RSZYWAl2Nar76iwaOdycqb6JPxediskWFMMl7stfwNJR4b7eiQvh5fB5TEQJTQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.22.6", + "@babel/helper-define-polyfill-provider": "^0.6.1", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.8.7", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.8.7.tgz", + "integrity": "sha512-KyDvZYxAzkC0Aj2dAPyDzi2Ym15e5JKZSK+maI7NAwSqofvuFglbSsxE7wUOvTg9oFVnHMzVzBKcqEb4PJgtOA==", + "dev": true, + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.4.4", + "core-js-compat": "^3.33.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3/node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.4.4.tgz", + "integrity": "sha512-QcJMILQCu2jm5TFPGA3lCpJJTeEP+mqeXooG/NZbg/h5FTFi6V0+99ahlRsW8/kRLyb24LZVCCiclDedhLKcBA==", + "dev": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.22.6", + "@babel/helper-plugin-utils": "^7.22.5", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.5.5.tgz", + "integrity": "sha512-OJGYZlhLqBh2DDHeqAxWB1XIvr49CxiJ2gIt61/PU55CQK4Z58OzMqjDe1zwQdQk+rBYsRc+1rJmdajM3gimHg==", + "dev": true, + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.5.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator/node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.5.0.tgz", + "integrity": "sha512-NovQquuQLAQ5HuyjCz7WQP9MjRj7dx++yspwiyUiGl9ZyadHRSql1HZh5ogRd8W8w6YM6EQ/NTB8rgjLt5W65Q==", + "dev": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.22.6", + "@babel/helper-plugin-utils": "^7.22.5", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", + "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001587", + "electron-to-chromium": "^1.4.668", + "node-releases": "^2.0.14", + "update-browserslist-db": "^1.0.13" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001609", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001609.tgz", + "integrity": "sha512-JFPQs34lHKx1B5t1EpQpWH4c+29zIyn/haGsbpfq3suuV9v56enjFt23zqijxGTMwy1p/4H2tjnQMY+p1WoAyA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", + "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", + "dev": true, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/clean-webpack-plugin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/clean-webpack-plugin/-/clean-webpack-plugin-4.0.0.tgz", + "integrity": "sha512-WuWE1nyTNAyW5T7oNyys2EN0cfP2fdRxhxnIQWiAp0bMabPdHhoGxM8A6YL2GhqwgrPnnaemVE7nv5XJ2Fhh2w==", + "dev": true, + "dependencies": { + "del": "^4.1.1" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "webpack": ">=4.0.0 <6.0.0" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, + "node_modules/common-path-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", + "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", + "dev": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/copy-webpack-plugin": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-12.0.2.tgz", + "integrity": "sha512-SNwdBeHyII+rWvee/bTnAYyO8vfVdcSTud4EIb6jcZ8inLeWucJE0DnxXQBjlQ5zlteuuvooGQy3LIyGxhvlOA==", + "dev": true, + "dependencies": { + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.1", + "globby": "^14.0.0", + "normalize-path": "^3.0.0", + "schema-utils": "^4.2.0", + "serialize-javascript": "^6.0.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + } + }, + "node_modules/core-js-compat": { + "version": "3.36.1", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.36.1.tgz", + "integrity": "sha512-Dk997v9ZCt3X/npqzyGdTlq6t7lDBhZwGvV94PKzDArjp7BTRm7WlDAXYd/OWdeFHO8OChQYRJNJvUCqCbrtKA==", + "dev": true, + "dependencies": { + "browserslist": "^4.23.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/del": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/del/-/del-4.1.1.tgz", + "integrity": "sha512-QwGuEUouP2kVwQenAsOof5Fv8K9t3D8Ca8NxcXKrIpEHjTXK5J2nXLdP+ALI1cgv8wj7KuwBhTwBkOZSJKM5XQ==", + "dev": true, + "dependencies": { + "@types/glob": "^7.1.1", + "globby": "^6.1.0", + "is-path-cwd": "^2.0.0", + "is-path-in-cwd": "^2.0.0", + "p-map": "^2.0.0", + "pify": "^4.0.1", + "rimraf": "^2.6.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/del/node_modules/globby": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-6.1.0.tgz", + "integrity": "sha512-KVbFv2TQtbzCoxAnfD6JcHZTYCzyliEaaeM/gH8qQdkKr5s0OP9scEgvdcngyk7AVdY6YVW/TJHd+lQ/Df3Daw==", + "dev": true, + "dependencies": { + "array-union": "^1.0.1", + "glob": "^7.0.3", + "object-assign": "^4.0.1", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/del/node_modules/globby/node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.4.735", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.735.tgz", + "integrity": "sha512-pkYpvwg8VyOTQAeBqZ7jsmpCjko1Qc6We1ZtZCjRyYbT5v4AIUKDy5cQTRotQlSSZmMr8jqpEt6JtOj5k7lR7A==", + "dev": true + }, + "node_modules/enhanced-resolve": { + "version": "5.16.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.16.0.tgz", + "integrity": "sha512-O+QWCviPNSSLAD9Ucn8Awv+poAkqn3T1XY5/N7kR7rQO9yfSGWkYZDwpJ+iKF7B8rxaQKWngSqACpgzeapSyoA==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/envinfo": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.12.0.tgz", + "integrity": "sha512-Iw9rQJBGpJRd3rwXm9ft/JiGoAZmLxxJZELYDQoPRZ4USVhkKtIcNBPw6U+/K2mBpaqM25JSV6Yl4Az9vO2wJg==", + "dev": true, + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.0.tgz", + "integrity": "sha512-pqrTKmwEIgafsYZAGw9kszYzmagcE/n4dbgwGWLEXg7J4QFJVQRBld8j3Q3GNez79jzxZshq0bcT962QHOghjw==", + "dev": true + }, + "node_modules/escalade": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-cache-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-4.0.0.tgz", + "integrity": "sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==", + "dev": true, + "dependencies": { + "common-path-prefix": "^3.0.0", + "pkg-dir": "^7.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz", + "integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==", + "dev": true, + "dependencies": { + "locate-path": "^7.1.0", + "path-exists": "^5.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "bin": { + "flat": "cli.js" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true + }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/globby": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.0.1.tgz", + "integrity": "sha512-jOMLD2Z7MAhyG8aJpNOpmziMOP4rPLcc95oQPKXBazW82z+CEgPFBQvEpRUa1KeIMUJo4Wsm+q6uzO/Q/4BksQ==", + "dev": true, + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.2", + "ignore": "^5.2.4", + "path-type": "^5.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", + "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-local": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", + "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", + "dev": true, + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/import-local/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/import-local/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/import-local/node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/import-local/node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-cwd": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", + "integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/is-path-in-cwd": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-2.1.0.tgz", + "integrity": "sha512-rNocXHgipO+rvnP6dk3zI20RpOtrAM/kzbB258Uw5BWr3TpXi861yzjo16Dn4hUox07iw5AyeMLHWsujkjzvRQ==", + "dev": true, + "dependencies": { + "is-path-inside": "^2.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/is-path-inside": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-2.1.0.tgz", + "integrity": "sha512-wiyhTzfDWsvwAW53OBWF5zuvaOGlZ6PwYxAbPVDhpm+gM09xKQGjBq/8uYN12aDvMxnAnq3dxTyoSoRNmg5YFg==", + "dev": true, + "dependencies": { + "path-is-inside": "^1.0.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/loader-runner": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "dev": true, + "engines": { + "node": ">=6.11.5" + } + }, + "node_modules/locate-path": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "dev": true, + "dependencies": { + "p-locate": "^6.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, + "node_modules/node-releases": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", + "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", + "dev": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "dev": true, + "dependencies": { + "p-limit": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==", + "dev": true + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-type": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-5.0.0.tgz", + "integrity": "sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==", + "dev": true, + "dependencies": { + "pinkie": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pkg-dir": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-7.0.0.tgz", + "integrity": "sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==", + "dev": true, + "dependencies": { + "find-up": "^6.3.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prettier": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", + "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-organize-imports": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-3.2.4.tgz", + "integrity": "sha512-6m8WBhIp0dfwu0SkgfOxJqh+HpdyfqSSLfKKRZSFbDuEQXDDndb8fTpRWkUrX/uBenkex3MgnVk0J3b3Y5byog==", + "dev": true, + "peerDependencies": { + "@volar/vue-language-plugin-pug": "^1.0.4", + "@volar/vue-typescript": "^1.0.4", + "prettier": ">=2.0", + "typescript": ">=2.9" + }, + "peerDependenciesMeta": { + "@volar/vue-language-plugin-pug": { + "optional": true + }, + "@volar/vue-typescript": { + "optional": true + } + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.1.tgz", + "integrity": "sha512-X007RyZLsCJVVrjgEFVpLUTZwyOZk3oiL75ZcuYjlIWd6rNJtOjkBwQc5AsRrpbKVkxN6sklw/k/9m2jJYOf8Q==", + "dev": true, + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "dev": true + }, + "node_modules/regenerator-transform": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", + "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.8.4" + } + }, + "node_modules/regexpu-core": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.3.2.tgz", + "integrity": "sha512-RAM5FlZz+Lhmo7db9L298p2vHP5ZywrVXmVXpmAD9GuL5MPH6t9ROw1iA/wfHkQ76Qe7AaPF0nGuim96/IrQMQ==", + "dev": true, + "dependencies": { + "@babel/regjsgen": "^0.8.0", + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.1.0", + "regjsparser": "^0.9.1", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsparser": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.9.1.tgz", + "integrity": "sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==", + "dev": true, + "dependencies": { + "jsesc": "~0.5.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/regjsparser/node_modules/jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/schema-utils": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", + "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/terser": { + "version": "5.30.3", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.30.3.tgz", + "integrity": "sha512-STdUgOUx8rLbMGO9IOwHLpCqolkDITFFQSMYYwKE1N2lY6MVSaeoi10z/EhWxRc6ybqoVmKSkhKYH/XUpl7vSA==", + "dev": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.10", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", + "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.20", + "jest-worker": "^27.4.5", + "schema-utils": "^3.1.1", + "serialize-javascript": "^6.0.1", + "terser": "^5.26.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", + "integrity": "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.1.0.tgz", + "integrity": "sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", + "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", + "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/watchpack": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.1.tgz", + "integrity": "sha512-8wrBCMtVhqcXP2Sup1ctSkga6uc2Bx0IIvKyT7yTFier5AXHooSI+QyQQAtTb7+E0IUCCKyTFmXqdqgum2XWGg==", + "dev": true, + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack": { + "version": "5.89.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.89.0.tgz", + "integrity": "sha512-qyfIC10pOr70V+jkmud8tMfajraGCZMBWJtrmuBymQKCrLTRejBI8STDp1MCyZu/QTdZSeacCQYpYNQVOzX5kw==", + "dev": true, + "dependencies": { + "@types/eslint-scope": "^3.7.3", + "@types/estree": "^1.0.0", + "@webassemblyjs/ast": "^1.11.5", + "@webassemblyjs/wasm-edit": "^1.11.5", + "@webassemblyjs/wasm-parser": "^1.11.5", + "acorn": "^8.7.1", + "acorn-import-assertions": "^1.9.0", + "browserslist": "^4.14.5", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.15.0", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.9", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.2.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.7", + "watchpack": "^2.4.0", + "webpack-sources": "^3.2.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-cli": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", + "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", + "dev": true, + "dependencies": { + "@discoveryjs/json-ext": "^0.5.0", + "@webpack-cli/configtest": "^2.1.1", + "@webpack-cli/info": "^2.0.2", + "@webpack-cli/serve": "^2.0.5", + "colorette": "^2.0.14", + "commander": "^10.0.1", + "cross-spawn": "^7.0.3", + "envinfo": "^7.7.3", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^3.1.1", + "rechoir": "^0.8.0", + "webpack-merge": "^5.7.3" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "5.x.x" + }, + "peerDependenciesMeta": { + "@webpack-cli/generators": { + "optional": true + }, + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/webpack-cli/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/webpack-glob-entries": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/webpack-glob-entries/-/webpack-glob-entries-1.0.1.tgz", + "integrity": "sha512-ebis0/kd0CxxlZabcnCcKA2Dc5fZJvtadOR+72I8U4z3Umaq9iGQLF69n3p+nMBWK1tKvKkdfZ8Rm2+f6JUCww==", + "dev": true, + "dependencies": { + "glob": "^5.0.15" + } + }, + "node_modules/webpack-glob-entries/node_modules/glob": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz", + "integrity": "sha512-c9IPMazfRITpmAAKi22dK1VKxGDX9ehhqfABDriL/lzO92xcUKEJPQHrVA/2YHSNFB4iFlykVmWvwo48nr3OxA==", + "dev": true, + "dependencies": { + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "2 || 3", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/webpack-merge": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", + "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", + "dev": true, + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/webpack/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yocto-queue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", + "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", + "dev": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/load-test/package.json b/load-test/package.json new file mode 100644 index 0000000000..c471a4a329 --- /dev/null +++ b/load-test/package.json @@ -0,0 +1,33 @@ +{ + "name": "typescript", + "version": "1.0.0", + "repository": "ssh://git@github.com/zitadel/zitadel.git", + "author": "ZITADEL Authors ", + "engines": { + "node": "16 || 18 || 20" + }, + "license": "Apache-2.0", + "devDependencies": { + "@babel/core": "7.23.7", + "@babel/plugin-proposal-class-properties": "7.13.0", + "@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/webpack": "5.28.5", + "babel-loader": "9.1.3", + "clean-webpack-plugin": "4.0.0", + "copy-webpack-plugin": "^12.0.2", + "typescript": "5.4.5", + "webpack": "5.89.0", + "webpack-cli": "5.1.4", + "webpack-glob-entries": "^1.0.1", + "prettier": "^3.1.1", + "prettier-plugin-organize-imports": "^3.2.4" + }, + "scripts": { + "bundle": "webpack", + "lint": "prettier --check src", + "lint:fix": "prettier --write src" + } + } \ No newline at end of file diff --git a/load-test/src/app.ts b/load-test/src/app.ts new file mode 100644 index 0000000000..19dca31117 --- /dev/null +++ b/load-test/src/app.ts @@ -0,0 +1,70 @@ +import { Trend } from 'k6/metrics'; +import { Org } from './org'; +import http from 'k6/http'; +import url from './url'; +import { check } from 'k6'; + +export type API = { + appId: string; +}; + +const addAPITrend = new Trend('app_add_app_duration', true); +export function createAPI(name: string, projectId: string, org: Org, accessToken: string): Promise { + return new Promise((resolve, reject) => { + let response = http.asyncRequest( + 'POST', + url(`/management/v1/projects/${projectId}/apps/api`), + JSON.stringify({ + name: name, + authMethodType: 'API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT', + }), + { + headers: { + authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'x-zitadel-orgid': org.organizationId, + }, + }, + ); + response.then((res) => { + check(res, { + 'add api status ok': (r) => r.status === 200, + }) || reject(`unable to add api project: ${projectId} status: ${res.status} body: ${res.body}`); + resolve(res.json() as API); + + addAPITrend.add(res.timings.duration); + }); + }); +} + +export type AppKey = { + keyDetails: string; +}; + +const addAppKeyTrend = new Trend('app_add_app_key_duration', true); +export function createAppKey(appId: string, projectId: string, org: Org, accessToken: string): Promise { + return new Promise((resolve, reject) => { + let response = http.asyncRequest( + 'POST', + url(`/management/v1/projects/${projectId}/apps/${appId}/keys`), + JSON.stringify({ + type: 'KEY_TYPE_JSON', + }), + { + headers: { + authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'x-zitadel-orgid': org.organizationId, + }, + }, + ); + response.then((res) => { + check(res, { + 'add app key status ok': (r) => r.status === 200, + }) || reject(`unable to add app key project: ${projectId} app: ${appId} status: ${res.status} body: ${res.body}`); + resolve(res.json() as AppKey); + + addAppKeyTrend.add(res.timings.duration); + }); + }); +} diff --git a/load-test/src/config.ts b/load-test/src/config.ts new file mode 100644 index 0000000000..f6020848de --- /dev/null +++ b/load-test/src/config.ts @@ -0,0 +1,67 @@ +// @ts-ignore Import module +import { randomString } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js'; +import crypto from 'k6/crypto'; +import http from 'k6/http'; +import execution from 'k6/execution'; +import { Stage } from 'k6/options'; + +import url from './url'; + +export const Config = { + host: __ENV.ZITADEL_HOST || 'http://localhost:8080', + orgId: '', + codeVerifier: __ENV.CODE_VERIFIER || randomString(10), + admin: { + loginName: __ENV.ADMIN_LOGIN_NAME || 'zitadel-admin@zitadel.localhost', + password: __ENV.ADMIN_PASSWORD || 'Password1!', + }, +}; + +const client = { + response_type: 'code', + scope: 'openid email profile urn:zitadel:iam:org:project:id:zitadel:aud', + prompt: 'login', + code_challenge_method: 'S256', + code_challenge: crypto.sha256(Config.codeVerifier, 'base64rawurl'), + client_id: __ENV.CLIENT_ID || '', + redirect_uri: url('/ui/console/auth/callback'), +}; + +export function Client() { + if (client.client_id) { + return client; + } + const env = http.get(url('/ui/console/assets/environment.json')); + + client.client_id = env.json('clientid') ? env.json('clientid')?.toString()! : ''; + + return client; +} + +let maxVUs: number; +export function MaxVUs() { + if (maxVUs != undefined) { + return maxVUs; + } + + let max: number = execution.test.options.stages + ? execution.test.options.stages + .map((value: Stage): number => value.target) + .reduce((acc: number, value: number): number => { + return acc <= value ? acc : value; + }) + : 1; + + if (execution.test.options.scenarios) { + new Map(Object.entries(execution.test.options.scenarios)).forEach((value) => { + if ('vus' in value) { + max = value.vus && max < value.vus ? value.vus : max; + } else if ('maxVUs' in value) { + max = value.maxVUs && max < value.maxVUs ? value.maxVUs : max; + } + }); + } + + maxVUs = max; + return maxVUs; +} diff --git a/load-test/src/login_ui.ts b/load-test/src/login_ui.ts new file mode 100644 index 0000000000..5ea4afcdf5 --- /dev/null +++ b/load-test/src/login_ui.ts @@ -0,0 +1,113 @@ +import { JSONObject, check, fail } from 'k6'; +import http, { Response } from 'k6/http'; +// @ts-ignore Import module +import { URL } from 'https://jslib.k6.io/url/1.0.0/index.js'; +import { Trend } from 'k6/metrics'; + +import { Config, Client } from './config'; +import url from './url'; +import { User } from './user'; +import { Tokens } from './oidc'; + +export function loginByUsernamePassword(user: User) { + check(user, { + 'user defined': (u) => u !== undefined || fail(`user is undefined`), + }); + + const loginUI = initLogin(); + const loginNameResponse = enterLoginName(loginUI, user); + const passwordResponse = enterPassword(loginNameResponse, user); + return token(new URL(passwordResponse.url).searchParams.get('code')); +} + +const initLoginTrend = new Trend('login_ui_init_login_duration', true); +function initLogin(): Response { + const response = http.get(url('/oauth/v2/authorize', { searchParams: Client() })); + check(response, { + 'authorize status ok': (r) => r.status == 200 || fail(`init login failed: ${r}`), + }); + initLoginTrend.add(response.timings.duration); + return response; +} + +const enterLoginNameTrend = new Trend('login_ui_enter_login_name_duration', true); +function enterLoginName(page: Response, user: User): Response { + const response = page.submitForm({ + formSelector: 'form', + fields: { + loginName: user.loginName, + }, + }); + + check(response, { + 'login name status ok': (r) => (r && r.status == 200) || fail('enter login name failed'), + 'login shows password page': (r) => r && r.body !== null && r.body.toString().includes('password'), + // 'login has no error': (r) => r && r.body != null && r.body.toString().includes('error') || fail(`error in enter login name ${r.body}`) + }); + + enterLoginNameTrend.add(response.timings.duration); + + return response; +} + +const enterPasswordTrend = new Trend('login_ui_enter_password_duration', true); +function enterPassword(page: Response, user: User): Response { + let response = page.submitForm({ + formSelector: 'form', + fields: { + password: user.password, + }, + }); + enterPasswordTrend.add(response.timings.duration); + + // skip 2fa init + if (response.url.endsWith('/password')) { + response = response.submitForm({ + formSelector: 'form', + submitSelector: '[name="skip"]', + }); + } + + check(response, { + 'password status ok': (r) => r.status == 200 || fail('enter password failed'), + 'password callback': (r) => + r.url.startsWith(url('/ui/console/auth/callback?code=')) || fail(`wrong password callback: ${r.url}`), + }); + + return response; +} + +const tokenTrend = new Trend('login_ui_token_duration', true); +function token(code = '') { + check(code, { + 'code set': (c) => (c !== undefined && c !== null) || fail('code was not set'), + }); + const response = http.post( + url('/oauth/v2/token'), + { + grant_type: 'authorization_code', + code: code, + redirect_uri: Client().redirect_uri, + code_verifier: Config.codeVerifier, + client_id: Client().client_id, + }, + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, + ); + + tokenTrend.add(response.timings.duration); + check(response, { + 'token status ok': (r) => r.status == 200 || fail(`invalid token response status: ${r.status} body: ${r.body}`), + }); + const token = new Tokens(response.json() as JSONObject); + check(token, { + 'access token created': (t) => t.accessToken !== undefined, + 'id token created': (t) => t.idToken !== undefined, + 'info created': (t) => t.info !== undefined, + }); + + return token; +} diff --git a/load-test/src/oidc.ts b/load-test/src/oidc.ts new file mode 100644 index 0000000000..0f09fba965 --- /dev/null +++ b/load-test/src/oidc.ts @@ -0,0 +1,74 @@ +import { JSONObject, check, fail } from 'k6'; +import encoding from 'k6/encoding'; +import http from 'k6/http'; +import { Trend } from 'k6/metrics'; +import url from './url'; + +export class Tokens { + idToken?: string; + accessToken?: string; + info?: any; + + constructor(res: JSONObject) { + this.idToken = res.id_token ? res.id_token!.toString() : undefined; + this.accessToken = res.access_token ? res.access_token!.toString() : undefined; + this.info = this.idToken + ? JSON.parse(encoding.b64decode(this.idToken?.split('.')[1].toString(), 'rawstd', 's')) + : undefined; + } +} + +let oidcConfig: any | undefined; + +function configuration() { + if (oidcConfig !== undefined) { + return oidcConfig; + } + + const res = http.get(url('/.well-known/openid-configuration')); + check(res, { + 'openid configuration': (r) => r.status == 200 || fail('unable to load openid configuration'), + }); + + oidcConfig = res.json(); + return oidcConfig; +} + +const userinfoTrend = new Trend('oidc_user_info_duration', true); +export function userinfo(token: string) { + const userinfo = http.get(configuration().userinfo_endpoint, { + headers: { + authorization: 'Bearer ' + token, + 'Content-Type': 'application/json', + }, + }); + + check(userinfo, { + 'userinfo status ok': (r) => r.status === 200, + }); + + userinfoTrend.add(userinfo.timings.duration); +} + +const introspectTrend = new Trend('oidc_introspect_duration', true); +export function introspect(jwt: string, token: string) { + const res = http.post( + configuration().introspection_endpoint, + { + client_assertion: jwt, + token: token, + client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', + }, + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + alg: 'RS256', + }, + }, + ); + check(res, { + 'introspect status ok': (r) => r.status === 200, + }); + + introspectTrend.add(res.timings.duration); +} diff --git a/load-test/src/org.ts b/load-test/src/org.ts new file mode 100644 index 0000000000..f5655432a5 --- /dev/null +++ b/load-test/src/org.ts @@ -0,0 +1,56 @@ +import http from 'k6/http'; +import { Trend } from 'k6/metrics'; +import url from './url'; +import { Config } from './config'; +import { check } from 'k6'; + +export type Org = { + organizationId: string; +}; + +const createOrgTrend = new Trend('org_create_org_duration', true); +export function createOrg(accessToken: string): Promise { + return new Promise((resolve, reject) => { + let response = http.asyncRequest( + 'POST', + url('/v2beta/organizations'), + JSON.stringify({ + name: `load-test-${new Date(Date.now()).toISOString()}`, + }), + { + headers: { + authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'x-zitadel-orgid': Config.orgId, + }, + }, + ); + + response.then((res) => { + check(res, { + 'org created': (r) => { + return r !== undefined && r.status === 201; + }, + }) || reject(`unable to create org status: ${res.status} || body: ${res.body}`); + + createOrgTrend.add(res.timings.duration); + + resolve(res.json() as Org); + }); + }); +} + +export function removeOrg(org: Org, accessToken: string) { + const response = http.del(url('/management/v1/orgs/me'), null, { + headers: { + authorization: `Bearer ${accessToken}`, + 'x-zitadel-orgid': org.organizationId, + }, + }); + + check(response, { + 'org removed': (r) => r.status === 200, + }) || console.log(`status: ${response.status} || body: ${response.body}|| org: ${JSON.stringify(org)}`); + + return response.json(); +} diff --git a/load-test/src/project.ts b/load-test/src/project.ts new file mode 100644 index 0000000000..2e59b8f40a --- /dev/null +++ b/load-test/src/project.ts @@ -0,0 +1,37 @@ +import { Trend } from 'k6/metrics'; +import { Org } from './org'; +import http from 'k6/http'; +import url from './url'; +import { check } from 'k6'; + +export type Project = { + id: string; +}; + +const addProjectTrend = new Trend('project_add_project_duration', true); +export function createProject(name: string, org: Org, accessToken: string): Promise { + return new Promise((resolve, reject) => { + let response = http.asyncRequest( + 'POST', + url('/management/v1/projects'), + JSON.stringify({ + name: name, + }), + { + headers: { + authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'x-zitadel-orgid': org.organizationId, + }, + }, + ); + response.then((res) => { + check(res, { + 'add project status ok': (r) => r.status === 200, + }) || reject(`unable to add project status: ${res.status} body: ${res.body}`); + + addProjectTrend.add(res.timings.duration); + resolve(res.json() as Project); + }); + }); +} diff --git a/load-test/src/url.ts b/load-test/src/url.ts new file mode 100644 index 0000000000..9d25ec51cb --- /dev/null +++ b/load-test/src/url.ts @@ -0,0 +1,18 @@ +import { options } from 'k6/http'; +import { Config } from './config'; + +export type options = { + searchParams?: { [name: string]: string }; +}; + +export default function url(path: string, options: options = {}) { + let url = new URL(Config.host + path); + + if (options.searchParams) { + Object.entries(options.searchParams).forEach(([key, value]) => { + url.searchParams.append(key, value); + }); + } + + return url.toString(); +} diff --git a/load-test/src/use_cases/human_password_login.ts b/load-test/src/use_cases/human_password_login.ts new file mode 100644 index 0000000000..4e3ff9e7c3 --- /dev/null +++ b/load-test/src/use_cases/human_password_login.ts @@ -0,0 +1,37 @@ +import { loginByUsernamePassword } from '../login_ui'; +import { createOrg, removeOrg } from '../org'; +import { User, createHuman } from '../user'; +import { userinfo } from '../oidc'; +import { Trend } from 'k6/metrics'; +import { Config, MaxVUs } from '../config'; + +export async function setup() { + const tokens = loginByUsernamePassword(Config.admin as User); + console.log('setup: admin signed in'); + + const org = await createOrg(tokens.accessToken!); + console.log(`setup: org (${org.organizationId}) created`); + + const humanPromises = Array.from({ length: MaxVUs() }, (_, i) => { + return createHuman(`zitizen-${i}`, org, tokens.accessToken!); + }); + + const humans = (await Promise.all(humanPromises)).map((user) => { + return { userId: user.userId, loginName: user.loginNames[0], password: 'Password1!' }; + }); + console.log(`setup: ${humans.length} users created`); + return { tokens, users: humans, org }; +} + +const humanPasswordLoginTrend = new Trend('human_password_login_duration', true); +export default function (data: any) { + const start = new Date(); + const token = loginByUsernamePassword(data.users[__VU - 1]); + userinfo(token.accessToken!); + + humanPasswordLoginTrend.add(new Date().getTime() - start.getTime()); +} + +export function teardown(data: any) { + removeOrg(data.org, data.tokens.accessToken); +} diff --git a/load-test/src/use_cases/introspection.ts b/load-test/src/use_cases/introspection.ts new file mode 100644 index 0000000000..927e515f4f --- /dev/null +++ b/load-test/src/use_cases/introspection.ts @@ -0,0 +1,54 @@ +import { loginByUsernamePassword } from '../login_ui'; +import { createAPI, createAppKey } from '../app'; +import { createProject } from '../project'; +import { createOrg, removeOrg } from '../org'; +import { introspect } from '../oidc'; +import { Config, MaxVUs } from '../config'; +import { b64decode } from 'k6/encoding'; +// @ts-ignore Import module +import zitadel from 'k6/x/zitadel'; +import { User } from '../user'; + +export async function setup() { + const adminTokens = loginByUsernamePassword(Config.admin as User); + console.info('setup: admin signed in'); + + const org = await createOrg(adminTokens.accessToken!); + console.info(`setup: org (${org.organizationId}) created`); + + const projectPromises = Array.from({ length: MaxVUs() }, (_, i) => { + return createProject(`project-${i}`, org, adminTokens.accessToken!); + }); + const projects = await Promise.all(projectPromises); + console.log(`setup: ${projects.length} projects created`); + + const apis = await Promise.all( + projects.map((project, i) => { + return createAPI(`api-${i}`, project.id, org, adminTokens.accessToken!); + }), + ); + console.info(`setup: ${apis.length} apis created`); + + const keys = await Promise.all( + apis.map((api, i) => { + return createAppKey(api.appId, projects[i].id, org, adminTokens.accessToken!); + }), + ); + console.info(`setup: ${keys.length} keys created`); + + const tokens = keys.map((key) => { + return zitadel.jwtFromKey(b64decode(key.keyDetails, 'url', 's'), Config.host); + }); + console.info(`setup: ${tokens.length} tokens generated`); + + return { adminTokens, tokens, org }; +} + +export default function (data: any) { + introspect(data.tokens[__VU - 1], data.adminTokens.accessToken); +} + +export function teardown(data: any) { + removeOrg(data.org, data.adminTokens.accessToken); + console.info('teardown: org removed'); +} diff --git a/load-test/src/use_cases/machine_pat_login.ts b/load-test/src/use_cases/machine_pat_login.ts new file mode 100644 index 0000000000..00feeeff06 --- /dev/null +++ b/load-test/src/use_cases/machine_pat_login.ts @@ -0,0 +1,46 @@ +import { loginByUsernamePassword } from '../login_ui'; +import { createOrg, removeOrg } from '../org'; +import { createMachine, addMachinePat, User } from '../user'; +import { userinfo } from '../oidc'; +import { Config, MaxVUs } from '../config'; + +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`); + + let machines = ( + await Promise.all( + Array.from({ length: MaxVUs() }, (_, i) => { + return createMachine(`zitachine-${i}`, org, tokens.accessToken!); + }), + ) + ).map((machine) => { + return { userId: machine.userId, loginName: machine.loginNames[0] }; + }); + console.info(`setup: ${machines.length} machines created`); + + let pats = ( + await Promise.all( + machines.map((machine) => { + return addMachinePat(machine.userId, org, tokens.accessToken!); + }), + ) + ).map((pat, i) => { + return { userId: machines[i].userId, loginName: machines[i].loginName, pat: pat.token }; + }); + console.info(`setup: Pats added`); + + return { tokens, machines: pats, org }; +} + +export default function (data: any) { + userinfo(data.machines[__VU - 1].pat); +} + +export function teardown(data: any) { + removeOrg(data.org, data.tokens.accessToken); + console.info('teardown: org removed'); +} diff --git a/load-test/src/use_cases/manipulate_user.ts b/load-test/src/use_cases/manipulate_user.ts new file mode 100644 index 0000000000..2ea53bd324 --- /dev/null +++ b/load-test/src/use_cases/manipulate_user.ts @@ -0,0 +1,47 @@ +import { loginByUsernamePassword } from '../login_ui'; +import { createOrg, removeOrg } from '../org'; +import { createHuman, updateHuman, lockUser, deleteUser, User } from '../user'; +import { Config } from '../config'; +import { check } from 'k6'; + +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`); + + return { tokens, org }; +} + +export default async function (data: any) { + const human = await createHuman(`vu-${__VU}`, data.org, data.tokens.accessToken); + const updateRes = await updateHuman( + { + profile: { + nickName: `${new Date(Date.now()).toISOString()}`, + }, + }, + human.userId, + data.org, + data.tokens.accessToken, + ); + check(updateRes, { + 'update user is status ok': (r) => r.status >= 200 && r.status < 300, + }); + + const lockRes = await lockUser(human.userId, data.org, data.tokens.accessToken); + check(lockRes, { + 'lock user is status ok': (r) => r.status >= 200 && r.status < 300, + }); + + const deleteRes = await deleteUser(human.userId, data.org, data.tokens.accessToken); + check(deleteRes, { + 'delete user is status ok': (r) => r.status >= 200 && r.status < 300, + }); +} + +export function teardown(data: any) { + removeOrg(data.org, data.tokens.accessToken); + console.info('teardown: org removed'); +} diff --git a/load-test/src/use_cases/user_info.ts b/load-test/src/use_cases/user_info.ts new file mode 100644 index 0000000000..4eeb12ee29 --- /dev/null +++ b/load-test/src/use_cases/user_info.ts @@ -0,0 +1,27 @@ +import { loginByUsernamePassword } from '../login_ui'; +import { userinfo } from '../oidc'; +import { Config } from '../config'; +import { User, createHuman } from '../user'; +import { createOrg, removeOrg } from '../org'; + +export async function setup() { + const adminTokens = loginByUsernamePassword(Config.admin as User); + console.info('setup: admin signed in'); + + const org = await createOrg(adminTokens.accessToken!); + console.info(`setup: org (${org.organizationId}) created`); + + const user = await createHuman('gigi', org, adminTokens.accessToken!); + console.info(`setup: user (${user.userId}) created`); + + return { org, tokens: loginByUsernamePassword({ loginName: user.loginNames[0], password: 'Password1!' } as User) }; +} + +export default function (data: any) { + userinfo(data.tokens.accessToken); +} + +export function teardown(data: any) { + removeOrg(data.org, data.tokens.accessToken); + console.info('teardown: org removed'); +} diff --git a/load-test/src/user.ts b/load-test/src/user.ts new file mode 100644 index 0000000000..a4abd09325 --- /dev/null +++ b/load-test/src/user.ts @@ -0,0 +1,222 @@ +import { Trend } from 'k6/metrics'; +import { Org } from './org'; +import http, { RefinedResponse } from 'k6/http'; +import url from './url'; +import { check } from 'k6'; + +export type User = { + userId: string; + loginName: string; + password: string; +}; + +export interface Human extends User { + loginNames: string[]; +} + +const createHumanTrend = new Trend('user_create_human_duration', true); +export function createHuman(username: string, org: Org, accessToken: string): Promise { + return new Promise((resolve, reject) => { + let response = http.asyncRequest( + 'POST', + url('/v2beta/users/human'), + JSON.stringify({ + username: username, + organization: { + orgId: org.organizationId, + }, + profile: { + givenName: 'Gigi', + familyName: 'Zitizen', + }, + email: { + email: `zitizen-@caos.ch`, + isVerified: true, + }, + password: { + password: 'Password1!', + changeRequired: false, + }, + }), + { + headers: { + authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'x-zitadel-orgid': org.organizationId, + }, + }, + ); + + response + .then((res) => { + check(res, { + 'create user is status ok': (r) => r.status === 201, + }) || reject(`unable to create user(username: ${username}) status: ${res.status} body: ${res.body}`); + createHumanTrend.add(res.timings.duration); + + const user = http.get(url(`/v2beta/users/${res.json('userId')!}`), { + headers: { + authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'x-zitadel-orgid': org.organizationId, + }, + }); + resolve(user.json('user')! as unknown as Human); + }) + .catch((reason) => { + reject(reason); + }); + }); +} + +const updateHumanTrend = new Trend('update_human_duration', true); +export function updateHuman( + payload: any = {}, + userId: string, + org: Org, + accessToken: string, +): Promise> { + return new Promise((resolve, reject) => { + let response = http.asyncRequest('PUT', url(`/v2beta/users/${userId}`), JSON.stringify(payload), { + headers: { + authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'x-zitadel-orgid': org.organizationId, + }, + }); + + response + .then((res) => { + check(res, { + 'update user is status ok': (r) => r.status === 201, + }); + updateHumanTrend.add(res.timings.duration); + resolve(res); + }) + .catch((reason) => { + reject(reason); + }); + }); +} + +export interface Machine extends User { + loginNames: string[]; +} + +const createMachineTrend = new Trend('user_create_machine_duration', true); +export function createMachine(username: string, org: Org, accessToken: string): Promise { + return new Promise((resolve, reject) => { + let response = http.asyncRequest( + 'POST', + url('/management/v1/users/machine'), + JSON.stringify({ + userName: username, + name: username, + // bearer + access_token_type: 0, + }), + { + headers: { + authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'x-zitadel-orgid': org.organizationId, + }, + }, + ); + + response + .then((res) => { + check(res, { + 'create user is status ok': (r) => r.status === 200, + }) || reject(`unable to create user(username: ${username}) status: ${res.status} body: ${res.body}`); + createMachineTrend.add(res.timings.duration); + + const user = http.get(url(`/v2beta/users/${res.json('userId')!}`), { + headers: { + authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'x-zitadel-orgid': org.organizationId, + }, + }); + resolve(user.json('user')! as unknown as Machine); + }) + .catch((reason) => { + reject(reason); + }); + }); +} + +export type MachinePat = { + token: string; +}; + +const addMachinePatTrend = new Trend('user_add_machine_pat_duration', true); +export function addMachinePat(userId: string, org: Org, accessToken: string): Promise { + return new Promise((resolve, reject) => { + let response = http.asyncRequest('POST', url(`/management/v1/users/${userId}/pats`), null, { + headers: { + authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'x-zitadel-orgid': org.organizationId, + }, + }); + response.then((res) => { + check(res, { + 'add pat status ok': (r) => r.status === 200, + }) || reject(`unable to add pat (user id: ${userId}) status: ${res.status} body: ${res.body}`); + + addMachinePatTrend.add(res.timings.duration); + resolve(res.json()! as MachinePat); + }); + }); +} + +const lockUserTrend = new Trend('lock_user_duration', true); +export function lockUser(userId: string, org: Org, accessToken: string): Promise> { + return new Promise((resolve, reject) => { + let response = http.asyncRequest('POST', url(`/v2beta/users/${userId}/lock`), null, { + headers: { + authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'x-zitadel-orgid': org.organizationId, + }, + }); + + response + .then((res) => { + check(res, { + 'update user is status ok': (r) => r.status === 201, + }); + lockUserTrend.add(res.timings.duration); + resolve(res); + }) + .catch((reason) => { + reject(reason); + }); + }); +} + +const deleteUserTrend = new Trend('delete_user_duration', true); +export function deleteUser(userId: string, org: Org, accessToken: string): Promise> { + return new Promise((resolve, reject) => { + let response = http.asyncRequest('DELETE', url(`/v2beta/users/${userId}`), null, { + headers: { + authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'x-zitadel-orgid': org.organizationId, + }, + }); + + response + .then((res) => { + check(res, { + 'update user is status ok': (r) => r.status === 201, + }); + deleteUserTrend.add(res.timings.duration); + resolve(res); + }) + .catch((reason) => { + reject(reason); + }); + }); +} diff --git a/load-test/tsconfig.json b/load-test/tsconfig.json new file mode 100644 index 0000000000..53a1794591 --- /dev/null +++ b/load-test/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES6", + "moduleResolution": "node", + "module": "commonjs", + "noEmit": true, + "allowJs": true, + "removeComments": false, + + "strict": true, + "noImplicitAny": true, + "noImplicitThis": true, + + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + + "skipLibCheck": true + } + } \ No newline at end of file diff --git a/load-test/webpack.config.js b/load-test/webpack.config.js new file mode 100644 index 0000000000..4d5a4c6c4c --- /dev/null +++ b/load-test/webpack.config.js @@ -0,0 +1,48 @@ +const path = require('path'); +const { CleanWebpackPlugin } = require('clean-webpack-plugin'); +const CopyPlugin = require('copy-webpack-plugin'); +const GlobEntries = require('webpack-glob-entries'); + +module.exports = { + mode: 'production', + entry: GlobEntries('./src/use_cases/*.ts'), // Generates multiple entry for each test + output: { + path: path.join(__dirname, 'dist'), + libraryTarget: 'commonjs', + filename: '[name].js', + }, + resolve: { + extensions: ['.ts', '.js'], + }, + module: { + rules: [ + { + test: /\.ts$/, + use: 'babel-loader', + exclude: /node_modules/, + }, + ], + }, + target: 'web', + externals: /^(k6|https?\:\/\/)(\/.*)?/, + // Generate map files for compiled scripts + devtool: "source-map", + stats: { + colors: true, + }, + plugins: [ + new CleanWebpackPlugin(), + // Copy assets to the destination folder + // see `src/post-file-test.ts` for an test example using an asset + new CopyPlugin({ + patterns: [{ + from: path.resolve(__dirname, 'assets'), + noErrorOnMissing: true + }], + }), + ], + optimization: { + // Don't minimize, as it's not used in the browser + minimize: false, + }, +}; \ No newline at end of file From 029a6d393a08ead7f6eefaa82971c6da7771c172 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Thu, 18 Apr 2024 16:07:05 +0300 Subject: [PATCH 08/31] fix(crdb): obtain latest sequences when the tx is retried (#7795) --- internal/command/command.go | 2 +- internal/eventstore/eventstore_pusher_test.go | 4 ++-- internal/eventstore/v3/push.go | 6 +----- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/internal/command/command.go b/internal/command/command.go index 86730fabcc..8d77be765e 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -296,7 +296,7 @@ func (c *Commands) asyncPush(ctx context.Context, cmds ...eventstore.Command) { _, err := c.eventstore.Push(localCtx, cmds...) if err != nil { for _, cmd := range cmds { - logging.WithError(err).Errorf("could not push event %q", cmd.Type()) + logging.WithError(err).Warnf("could not push event %q", cmd.Type()) } } diff --git a/internal/eventstore/eventstore_pusher_test.go b/internal/eventstore/eventstore_pusher_test.go index cbd1a72e34..bd97b2e1e6 100644 --- a/internal/eventstore/eventstore_pusher_test.go +++ b/internal/eventstore/eventstore_pusher_test.go @@ -390,10 +390,10 @@ func TestCRDB_Push_Parallel(t *testing.T) { }, }, res: res{ - minErrCount: 1, + minErrCount: 0, eventsRes: eventsRes{ aggIDs: []string{"204"}, - pushedEventsCount: 6, + pushedEventsCount: 8, aggTypes: database.TextArray[eventstore.AggregateType]{eventstore.AggregateType(t.Name())}, }, }, diff --git a/internal/eventstore/v3/push.go b/internal/eventstore/v3/push.go index c217359828..22ea9295f6 100644 --- a/internal/eventstore/v3/push.go +++ b/internal/eventstore/v3/push.go @@ -8,7 +8,6 @@ import ( "fmt" "strconv" "strings" - "sync" "github.com/cockroachdb/cockroach-go/v2/crdb" "github.com/jackc/pgx/v5/pgconn" @@ -29,13 +28,10 @@ func (es *Eventstore) Push(ctx context.Context, commands ...eventstore.Command) // tx is not closed because [crdb.ExecuteInTx] takes care of that var ( sequences []*latestSequence - once sync.Once ) err = crdb.ExecuteInTx(ctx, &transaction{tx}, func() error { - once.Do(func() { - sequences, err = latestSequences(ctx, tx, commands) - }) + sequences, err = latestSequences(ctx, tx, commands) if err != nil { return err } From 4823e4797722949b9b45cc2b9a94c3cd4f419d09 Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Thu, 18 Apr 2024 15:45:15 +0200 Subject: [PATCH 09/31] docs: fix knative docs (#7752) Co-authored-by: Stefan Benz <46600784+stebenz@users.noreply.github.com> --- .../knative/cockroachdb-statefulset-single-node.yaml | 12 ------------ deploy/knative/zitadel-knative-service.yaml | 4 ++-- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/deploy/knative/cockroachdb-statefulset-single-node.yaml b/deploy/knative/cockroachdb-statefulset-single-node.yaml index c18b1df5d3..d0f7bc12b3 100644 --- a/deploy/knative/cockroachdb-statefulset-single-node.yaml +++ b/deploy/knative/cockroachdb-statefulset-single-node.yaml @@ -57,18 +57,6 @@ spec: selector: app: cockroachdb --- -apiVersion: policy/v1beta1 -kind: PodDisruptionBudget -metadata: - name: cockroachdb-budget - labels: - app: cockroachdb -spec: - selector: - matchLabels: - app: cockroachdb - maxUnavailable: 1 ---- apiVersion: apps/v1 kind: StatefulSet metadata: diff --git a/deploy/knative/zitadel-knative-service.yaml b/deploy/knative/zitadel-knative-service.yaml index 05486f49c1..5271f99253 100644 --- a/deploy/knative/zitadel-knative-service.yaml +++ b/deploy/knative/zitadel-knative-service.yaml @@ -7,7 +7,7 @@ spec: template: metadata: annotations: - client.knative.dev/user-image: ghcr.io/zitadel/zitadel:stable + client.knative.dev/user-image: ghcr.io/zitadel/zitadel:latest creationTimestamp: null spec: containerConcurrency: 0 @@ -28,7 +28,7 @@ spec: value: "80" - name: ZITADEL_EXTERNALDOMAIN value: zitadel.default.127.0.0.1.sslip.io - image: ghcr.io/zitadel/zitadel:stable + image: ghcr.io/zitadel/zitadel:latest name: user-container ports: - containerPort: 8080 From a63dceb9bcdb8a435f8d64e3774c11d3d8ac22e0 Mon Sep 17 00:00:00 2001 From: mffap Date: Thu, 18 Apr 2024 21:48:29 +0200 Subject: [PATCH 10/31] chore: Update readme with new features and links (#7798) Update readme with new features and links --- README.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ea4a57d5c2..449ecb3589 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ Yet it offers everything you need for a customer identity ([CIAM](https://zitade - [API-first approach](https://zitadel.com/docs/apis/introduction) - [Multi-tenancy](https://zitadel.com/docs/guides/solution-scenarios/b2b) authentication and access management -- Strong audit trail thanks to [event sourcing](https://zitadel.com/docs/concepts/eventstore/overview) as storage pattern +- [Strong audit trail](https://zitadel.com/docs/concepts/features/audit-trail) thanks to [event sourcing](https://zitadel.com/docs/concepts/eventstore/overview) as storage pattern - [Actions](https://zitadel.com/docs/apis/actions/introduction) to react on events with custom code and extended ZITADEL for you needs - [Branding](https://zitadel.com/docs/guides/manage/customize/branding) for a uniform user experience across multiple organizations - [Self-service](https://zitadel.com/docs/concepts/features/selfservice) for end-users, business customers, and administrators @@ -107,16 +107,17 @@ Yet it offers everything you need for a customer identity ([CIAM](https://zitade Authentication - Single Sign On (SSO) -- Passkeys support (FIDO2 / WebAuthN) +- [Passkeys support (FIDO2 / WebAuthN)](https://zitadel.com/docs/concepts/features/passkeys) - Username / Password - Multifactor authentication with OTP, U2F, Email OTP, SMS OTP -- LDAP -- External enterprise identity providers and social logins +- [LDAP](https://zitadel.com/docs/guides/integrate/identity-providers/ldap) +- [External enterprise identity providers and social logins](https://zitadel.com/docs/guides/integrate/identity-providers/introduction) - [Device authorization](https://zitadel.com/docs/guides/solution-scenarios/device-authorization) - [OpenID Connect certified](https://openid.net/certification/#OPs) => [OIDC Endpoints](https://zitadel.com/docs/apis/openidoauth/endpoints) - [SAML 2.0](http://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0.html) => [SAML Endpoints](https://zitadel.com/docs/apis/saml/endpoints) - [Custom sessions](https://zitadel.com/docs/guides/integrate/login-ui/username-password) if you need to go beyond OIDC or SAML -- [Machine-to-machine](https://zitadel.com/docs/guides/integrate/serviceusers) with JWT profile, Personal Access Tokens (PAT), and Client Credentials +- [Machine-to-machine](https://zitadel.com/docs/guides/integrate/service-users/authenticate-service-users) with JWT profile, Personal Access Tokens (PAT), and Client Credentials +- [Token exchange and impersonation](https://zitadel.com/docs/guides/integrate/token-exchange) Multi-Tenancy @@ -130,6 +131,10 @@ Integration - [GRPC and REST APIs](https://zitadel.com/docs/apis/introduction) for every functionality and resource - [Actions](https://zitadel.com/docs/apis/actions/introduction) to call any API, send webhooks, adjust workflows, or customize tokens - [Role Based Access Control (RBAC)](https://zitadel.com/docs/guides/integrate/retrieve-user-roles) +- [Examples and SDKs](https://zitadel.com/docs/sdk-examples/introduction) +- [Audit Log and SOC/SIEM](https://zitadel.com/docs/guides/integrate/external-audit-log) +- [User registration and onboarding](https://zitadel.com/docs/guides/integrate/onboarding) +- [Hosted and custom login user interface](https://zitadel.com/docs/guides/integrate/login-ui) Self-Service - [Self-registration](https://zitadel.com/docs/concepts/features/selfservice#registration) including verification From cca4b715c0000a9f7ebcb7794ccccf8c8813387f Mon Sep 17 00:00:00 2001 From: Florian Forster Date: Fri, 19 Apr 2024 11:46:05 +0200 Subject: [PATCH 11/31] chore: typo in api docs (#7803) --- proto/zitadel/admin.proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proto/zitadel/admin.proto b/proto/zitadel/admin.proto index 5f5639060a..27e222589f 100644 --- a/proto/zitadel/admin.proto +++ b/proto/zitadel/admin.proto @@ -2040,7 +2040,7 @@ service AdminService { tags: "Settings"; tags: "Domain Settings"; tags: "Organizations"; - summary: "Get Domain Settings for Organization"; + summary: "Set a Domain Settings for an Organization"; description: "Create the domain settings configured on a specific organization. It will overwrite the settings specified on the instance. Domain settings specify how ZITADEL should handle domains, in regards to usernames, emails and validation." responses: { key: "200"; From 13b566e0d98d880cdd1a651433747771bf4d5592 Mon Sep 17 00:00:00 2001 From: Silvan Date: Mon, 22 Apr 2024 11:30:56 +0200 Subject: [PATCH 12/31] fix(query): reduce app query overhead (#7817) * fix(query): reduce app query overhead --- internal/api/oidc/auth_request.go | 7 +- internal/query/app.go | 201 ++++++++++++++++++++++++++---- 2 files changed, 179 insertions(+), 29 deletions(-) diff --git a/internal/api/oidc/auth_request.go b/internal/api/oidc/auth_request.go index 384dc402a5..7cb7ca7af0 100644 --- a/internal/api/oidc/auth_request.go +++ b/internal/api/oidc/auth_request.go @@ -475,11 +475,8 @@ func (o *OPStorage) assertProjectRoleScopes(ctx context.Context, clientID string return scopes, nil } } - projectID, err := o.query.ProjectIDFromOIDCClientID(ctx, clientID) - if err != nil { - return nil, zerrors.ThrowPreconditionFailed(nil, "OIDC-AEG4d", "Errors.Internal") - } - project, err := o.query.ProjectByID(ctx, false, projectID) + + project, err := o.query.ProjectByOIDCClientID(ctx, clientID) if err != nil { return nil, zerrors.ThrowPreconditionFailed(nil, "OIDC-w4wIn", "Errors.Internal") } diff --git a/internal/query/app.go b/internal/query/app.go index 693bc7f0c3..7d69981dd4 100644 --- a/internal/query/app.go +++ b/internal/query/app.go @@ -299,7 +299,7 @@ func (q *Queries) AppBySAMLEntityID(ctx context.Context, entityID string) (app * ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - stmt, scan := prepareAppQuery(ctx, q.client) + stmt, scan := prepareSAMLAppQuery(ctx, q.client) eq := sq.Eq{ AppSAMLConfigColumnEntityID.identifier(): entityID, AppColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), @@ -341,27 +341,6 @@ func (q *Queries) ProjectByClientID(ctx context.Context, appID string) (project return project, err } -func (q *Queries) ProjectIDFromOIDCClientID(ctx context.Context, appID string) (id string, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - - stmt, scan := prepareProjectIDByAppQuery(ctx, q.client) - eq := sq.Eq{ - AppOIDCConfigColumnClientID.identifier(): appID, - AppColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), - } - query, args, err := stmt.Where(eq).ToSql() - if err != nil { - return "", zerrors.ThrowInternal(err, "QUERY-7d92U", "Errors.Query.SQLStatement") - } - - err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { - id, err = scan(row) - return err - }, query, args...) - return id, err -} - func (q *Queries) ProjectIDFromClientID(ctx context.Context, appID string) (id string, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -392,7 +371,7 @@ func (q *Queries) ProjectByOIDCClientID(ctx context.Context, id string) (project ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - stmt, scan := prepareProjectByAppQuery(ctx, q.client) + stmt, scan := prepareProjectByOIDCAppQuery(ctx, q.client) eq := sq.Eq{ AppOIDCConfigColumnClientID.identifier(): id, AppColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), @@ -413,7 +392,7 @@ func (q *Queries) AppByOIDCClientID(ctx context.Context, clientID string) (app * ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - stmt, scan := prepareAppQuery(ctx, q.client) + stmt, scan := prepareOIDCAppQuery() eq := sq.Eq{ AppOIDCConfigColumnClientID.identifier(): clientID, AppColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), @@ -615,6 +594,138 @@ func prepareAppQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, } } +func prepareOIDCAppQuery() (sq.SelectBuilder, func(*sql.Row) (*App, error)) { + return sq.Select( + AppColumnID.identifier(), + AppColumnName.identifier(), + AppColumnProjectID.identifier(), + AppColumnCreationDate.identifier(), + AppColumnChangeDate.identifier(), + AppColumnResourceOwner.identifier(), + AppColumnState.identifier(), + AppColumnSequence.identifier(), + + AppOIDCConfigColumnAppID.identifier(), + AppOIDCConfigColumnVersion.identifier(), + AppOIDCConfigColumnClientID.identifier(), + AppOIDCConfigColumnRedirectUris.identifier(), + AppOIDCConfigColumnResponseTypes.identifier(), + AppOIDCConfigColumnGrantTypes.identifier(), + AppOIDCConfigColumnApplicationType.identifier(), + AppOIDCConfigColumnAuthMethodType.identifier(), + AppOIDCConfigColumnPostLogoutRedirectUris.identifier(), + AppOIDCConfigColumnDevMode.identifier(), + AppOIDCConfigColumnAccessTokenType.identifier(), + AppOIDCConfigColumnAccessTokenRoleAssertion.identifier(), + AppOIDCConfigColumnIDTokenRoleAssertion.identifier(), + AppOIDCConfigColumnIDTokenUserinfoAssertion.identifier(), + AppOIDCConfigColumnClockSkew.identifier(), + AppOIDCConfigColumnAdditionalOrigins.identifier(), + AppOIDCConfigColumnSkipNativeAppSuccessPage.identifier(), + ).From(appsTable.identifier()). + Join(join(AppOIDCConfigColumnAppID, AppColumnID)). + PlaceholderFormat(sq.Dollar), func(row *sql.Row) (*App, error) { + app := new(App) + + var ( + oidcConfig = sqlOIDCConfig{} + ) + + err := row.Scan( + &app.ID, + &app.Name, + &app.ProjectID, + &app.CreationDate, + &app.ChangeDate, + &app.ResourceOwner, + &app.State, + &app.Sequence, + + &oidcConfig.appID, + &oidcConfig.version, + &oidcConfig.clientID, + &oidcConfig.redirectUris, + &oidcConfig.responseTypes, + &oidcConfig.grantTypes, + &oidcConfig.applicationType, + &oidcConfig.authMethodType, + &oidcConfig.postLogoutRedirectUris, + &oidcConfig.devMode, + &oidcConfig.accessTokenType, + &oidcConfig.accessTokenRoleAssertion, + &oidcConfig.iDTokenRoleAssertion, + &oidcConfig.iDTokenUserinfoAssertion, + &oidcConfig.clockSkew, + &oidcConfig.additionalOrigins, + &oidcConfig.skipNativeAppSuccessPage, + ) + + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, zerrors.ThrowNotFound(err, "QUERY-Fdfax", "Errors.App.NotExisting") + } + return nil, zerrors.ThrowInternal(err, "QUERY-aE7iE", "Errors.Internal") + } + + oidcConfig.set(app) + + return app, nil + } +} + +func prepareSAMLAppQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*App, error)) { + return sq.Select( + AppColumnID.identifier(), + AppColumnName.identifier(), + AppColumnProjectID.identifier(), + AppColumnCreationDate.identifier(), + AppColumnChangeDate.identifier(), + AppColumnResourceOwner.identifier(), + AppColumnState.identifier(), + AppColumnSequence.identifier(), + + AppSAMLConfigColumnAppID.identifier(), + AppSAMLConfigColumnEntityID.identifier(), + AppSAMLConfigColumnMetadata.identifier(), + AppSAMLConfigColumnMetadataURL.identifier(), + ).From(appsTable.identifier()). + Join(join(AppSAMLConfigColumnAppID, AppColumnID)). + PlaceholderFormat(sq.Dollar), func(row *sql.Row) (*App, error) { + + app := new(App) + var ( + samlConfig = sqlSAMLConfig{} + ) + + err := row.Scan( + &app.ID, + &app.Name, + &app.ProjectID, + &app.CreationDate, + &app.ChangeDate, + &app.ResourceOwner, + &app.State, + &app.Sequence, + + &samlConfig.appID, + &samlConfig.entityID, + &samlConfig.metadata, + &samlConfig.metadataURL, + ) + + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, zerrors.ThrowNotFound(err, "QUERY-d6TO1", "Errors.App.NotExisting") + } + return nil, zerrors.ThrowInternal(err, "QUERY-NAtPg", "Errors.Internal") + } + + samlConfig.set(app) + + return app, nil + } +} + func prepareProjectIDByAppQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (projectID string, err error)) { return sq.Select( AppColumnProjectID.identifier(), @@ -638,6 +749,48 @@ func prepareProjectIDByAppQuery(ctx context.Context, db prepareDatabase) (sq.Sel } } +func prepareProjectByOIDCAppQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*Project, error)) { + return sq.Select( + ProjectColumnID.identifier(), + ProjectColumnCreationDate.identifier(), + ProjectColumnChangeDate.identifier(), + ProjectColumnResourceOwner.identifier(), + ProjectColumnState.identifier(), + ProjectColumnSequence.identifier(), + ProjectColumnName.identifier(), + ProjectColumnProjectRoleAssertion.identifier(), + ProjectColumnProjectRoleCheck.identifier(), + ProjectColumnHasProjectCheck.identifier(), + ProjectColumnPrivateLabelingSetting.identifier(), + ).From(projectsTable.identifier()). + Join(join(AppColumnProjectID, ProjectColumnID)). + Join(join(AppOIDCConfigColumnAppID, AppColumnID)). + PlaceholderFormat(sq.Dollar), + func(row *sql.Row) (*Project, error) { + p := new(Project) + err := row.Scan( + &p.ID, + &p.CreationDate, + &p.ChangeDate, + &p.ResourceOwner, + &p.State, + &p.Sequence, + &p.Name, + &p.ProjectRoleAssertion, + &p.ProjectRoleCheck, + &p.HasProjectCheck, + &p.PrivateLabelingSetting, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, zerrors.ThrowNotFound(err, "QUERY-yxTMh", "Errors.Project.NotFound") + } + return nil, zerrors.ThrowInternal(err, "QUERY-dj2FF", "Errors.Internal") + } + return p, nil + } +} + func prepareProjectByAppQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*Project, error)) { return sq.Select( ProjectColumnID.identifier(), From 9d754d84b3f24d6347444f1885eb6a61ceaa9a36 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Mon, 22 Apr 2024 13:05:01 +0200 Subject: [PATCH 13/31] chore: update stable to v2.45.6 (#7818) --- release-channels.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release-channels.yaml b/release-channels.yaml index 0830924e47..ec2fad5cd3 100644 --- a/release-channels.yaml +++ b/release-channels.yaml @@ -1 +1 @@ -stable: "v2.44.7" +stable: "v2.45.6" From 74624018c2d5e127105bab5455dc1e01549a959e Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Mon, 22 Apr 2024 13:34:23 +0200 Subject: [PATCH 14/31] feat(actions): allow getting metadata of organizations from user grants (#7782) * feat(actions): allow getting metadata of (other) organizations from user grants * docs add action example --- docs/docs/apis/actions/code-examples.mdx | 13 +++++++++ docs/docs/apis/actions/objects.md | 2 ++ internal/actions/object/metadata.go | 16 +++++++++++ internal/actions/object/user_grant.go | 23 +++++++++++++-- internal/api/oidc/client.go | 36 ++++++------------------ internal/api/oidc/userinfo.go | 15 ++-------- internal/api/saml/storage.go | 15 ++-------- 7 files changed, 64 insertions(+), 56 deletions(-) diff --git a/docs/docs/apis/actions/code-examples.mdx b/docs/docs/apis/actions/code-examples.mdx index 7af3bc99f1..f4fbf75de2 100644 --- a/docs/docs/apis/actions/code-examples.mdx +++ b/docs/docs/apis/actions/code-examples.mdx @@ -68,6 +68,19 @@ https://github.com/zitadel/actions/blob/main/examples/custom_roles.js +### Custom role mapping including org metadata in claims + +There's even a possibility to use the metadata of organizations the user is granted to + +
+Code example + +```js reference +https://github.com/zitadel/actions/blob/main/examples/custom_roles_org_metadata.js +``` + +
+ ## Customize SAML response Append attributes returned on SAML requests. diff --git a/docs/docs/apis/actions/objects.md b/docs/docs/apis/actions/objects.md index 63f5cb5619..41307ee580 100644 --- a/docs/docs/apis/actions/objects.md +++ b/docs/docs/apis/actions/objects.md @@ -210,3 +210,5 @@ This object represents a list of user grant stored in ZITADEL. The name of the organization, where the user was granted - `projectId` *string* - `projectName` *string* + - `getOrgMetadata()` [*metadataResult*](#metadata-result) + Get the metadata of the organization where the user was granted \ No newline at end of file diff --git a/internal/actions/object/metadata.go b/internal/actions/object/metadata.go index 87ad69737a..b768b15808 100644 --- a/internal/actions/object/metadata.go +++ b/internal/actions/object/metadata.go @@ -1,6 +1,7 @@ package object import ( + "context" "encoding/json" "time" @@ -77,6 +78,21 @@ func UserMetadataListFromSlice(c *actions.FieldConfig, metadata []query.UserMeta return c.Runtime.ToValue(result) } +func GetOrganizationMetadata(ctx context.Context, queries *query.Queries, c *actions.FieldConfig, organizationID string) goja.Value { + metadata, err := queries.SearchOrgMetadata( + ctx, + true, + organizationID, + &query.OrgMetadataSearchQueries{}, + false, + ) + if err != nil { + logging.WithError(err).Info("unable to get org metadata in action") + panic(err) + } + return OrgMetadataListFromQuery(c, metadata) +} + func metadataByteArrayToValue(val []byte, runtime *goja.Runtime) goja.Value { var value interface{} if !json.Valid(val) { diff --git a/internal/actions/object/user_grant.go b/internal/actions/object/user_grant.go index 21267a611c..efa8f9cc66 100644 --- a/internal/actions/object/user_grant.go +++ b/internal/actions/object/user_grant.go @@ -1,6 +1,7 @@ package object import ( + "context" "time" "github.com/dop251/goja" @@ -44,6 +45,8 @@ type userGrant struct { ProjectId string ProjectName string + + GetOrgMetadata func(goja.FunctionCall) goja.Value } func AppendGrantFunc(userGrants *UserGrants) func(c *actions.FieldConfig) func(call goja.FunctionCall) goja.Value { @@ -58,10 +61,11 @@ func AppendGrantFunc(userGrants *UserGrants) func(c *actions.FieldConfig) func(c } } -func UserGrantsFromQuery(c *actions.FieldConfig, userGrants *query.UserGrants) goja.Value { +func UserGrantsFromQuery(ctx context.Context, queries *query.Queries, c *actions.FieldConfig, userGrants *query.UserGrants) goja.Value { if userGrants == nil { return c.Runtime.ToValue(nil) } + orgMetadata := make(map[string]goja.Value) grantList := &userGrantList{ Count: userGrants.Count, Sequence: userGrants.Sequence, @@ -84,16 +88,24 @@ func UserGrantsFromQuery(c *actions.FieldConfig, userGrants *query.UserGrants) g UserGrantResourceOwnerName: grant.OrgName, ProjectId: grant.ProjectID, ProjectName: grant.ProjectName, + GetOrgMetadata: func(call goja.FunctionCall) goja.Value { + if md, ok := orgMetadata[grant.ResourceOwner]; ok { + return md + } + orgMetadata[grant.ResourceOwner] = GetOrganizationMetadata(ctx, queries, c, grant.ResourceOwner) + return orgMetadata[grant.ResourceOwner] + }, } } return c.Runtime.ToValue(grantList) } -func UserGrantsFromSlice(c *actions.FieldConfig, userGrants []query.UserGrant) goja.Value { +func UserGrantsFromSlice(ctx context.Context, queries *query.Queries, c *actions.FieldConfig, userGrants []query.UserGrant) goja.Value { if userGrants == nil { return c.Runtime.ToValue(nil) } + orgMetadata := make(map[string]goja.Value) grantList := &userGrantList{ Count: uint64(len(userGrants)), Grants: make([]*userGrant, len(userGrants)), @@ -114,6 +126,13 @@ func UserGrantsFromSlice(c *actions.FieldConfig, userGrants []query.UserGrant) g UserGrantResourceOwnerName: grant.OrgName, ProjectId: grant.ProjectID, ProjectName: grant.ProjectName, + GetOrgMetadata: func(goja.FunctionCall) goja.Value { + if md, ok := orgMetadata[grant.ResourceOwner]; ok { + return md + } + orgMetadata[grant.ResourceOwner] = GetOrganizationMetadata(ctx, queries, c, grant.ResourceOwner) + return orgMetadata[grant.ResourceOwner] + }, } } diff --git a/internal/api/oidc/client.go b/internal/api/oidc/client.go index bdbf6bd67b..a80d4ad95e 100644 --- a/internal/api/oidc/client.go +++ b/internal/api/oidc/client.go @@ -490,25 +490,16 @@ func (o *OPStorage) userinfoFlows(ctx context.Context, user *query.User, userGra return object.UserMetadataListFromQuery(c, metadata) } }), - actions.SetFields("grants", func(c *actions.FieldConfig) interface{} { - return object.UserGrantsFromQuery(c, userGrants) - }), + actions.SetFields("grants", + func(c *actions.FieldConfig) interface{} { + return object.UserGrantsFromQuery(ctx, o.query, c, userGrants) + }, + ), ), actions.SetFields("org", actions.SetFields("getMetadata", func(c *actions.FieldConfig) interface{} { return func(goja.FunctionCall) goja.Value { - metadata, err := o.query.SearchOrgMetadata( - ctx, - true, - user.ResourceOwner, - &query.OrgMetadataSearchQueries{}, - false, - ) - if err != nil { - logging.WithError(err).Info("unable to get org metadata in action") - panic(err) - } - return object.OrgMetadataListFromQuery(c, metadata) + return object.GetOrganizationMetadata(ctx, o.query, c, user.ResourceOwner) } }), ), @@ -714,24 +705,13 @@ func (o *OPStorage) privateClaimsFlows(ctx context.Context, userID string, userG } }), actions.SetFields("grants", func(c *actions.FieldConfig) interface{} { - return object.UserGrantsFromQuery(c, userGrants) + return object.UserGrantsFromQuery(ctx, o.query, c, userGrants) }), ), actions.SetFields("org", actions.SetFields("getMetadata", func(c *actions.FieldConfig) interface{} { return func(goja.FunctionCall) goja.Value { - metadata, err := o.query.SearchOrgMetadata( - ctx, - true, - user.ResourceOwner, - &query.OrgMetadataSearchQueries{}, - false, - ) - if err != nil { - logging.WithError(err).Info("unable to get org metadata in action") - panic(err) - } - return object.OrgMetadataListFromQuery(c, metadata) + return object.GetOrganizationMetadata(ctx, o.query, c, user.ResourceOwner) } }), ), diff --git a/internal/api/oidc/userinfo.go b/internal/api/oidc/userinfo.go index c59e9c0952..6d01e6bf9c 100644 --- a/internal/api/oidc/userinfo.go +++ b/internal/api/oidc/userinfo.go @@ -252,24 +252,13 @@ func (s *Server) userinfoFlows(ctx context.Context, qu *query.OIDCUserInfo, user } }), actions.SetFields("grants", func(c *actions.FieldConfig) interface{} { - return object.UserGrantsFromSlice(c, qu.UserGrants) + return object.UserGrantsFromSlice(ctx, s.query, c, qu.UserGrants) }), ), actions.SetFields("org", actions.SetFields("getMetadata", func(c *actions.FieldConfig) interface{} { return func(goja.FunctionCall) goja.Value { - metadata, err := s.query.SearchOrgMetadata( - ctx, - true, - qu.User.ResourceOwner, - &query.OrgMetadataSearchQueries{}, - false, - ) - if err != nil { - logging.WithError(err).Info("unable to get org metadata in action") - panic(err) - } - return object.OrgMetadataListFromQuery(c, metadata) + return object.GetOrganizationMetadata(ctx, s.query, c, qu.User.ResourceOwner) } }), ), diff --git a/internal/api/saml/storage.go b/internal/api/saml/storage.go index 542be9d6bc..bd8afffe54 100644 --- a/internal/api/saml/storage.go +++ b/internal/api/saml/storage.go @@ -246,24 +246,13 @@ func (p *Storage) getCustomAttributes(ctx context.Context, user *query.User, use } }), actions.SetFields("grants", func(c *actions.FieldConfig) interface{} { - return object.UserGrantsFromQuery(c, userGrants) + return object.UserGrantsFromQuery(ctx, p.query, c, userGrants) }), ), actions.SetFields("org", actions.SetFields("getMetadata", func(c *actions.FieldConfig) interface{} { return func(goja.FunctionCall) goja.Value { - metadata, err := p.query.SearchOrgMetadata( - ctx, - true, - user.ResourceOwner, - &query.OrgMetadataSearchQueries{}, - false, - ) - if err != nil { - logging.WithError(err).Info("unable to get org metadata in action") - panic(err) - } - return object.OrgMetadataListFromQuery(c, metadata) + return object.GetOrganizationMetadata(ctx, p.query, c, user.ResourceOwner) } }), ), From 4520c6fc49abb40866971d8b1d1484b883e248e5 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Mon, 22 Apr 2024 15:10:49 +0200 Subject: [PATCH 15/31] chore: codecov token secret for nested workflow (#7792) fix: codecov token secret for nested workflow --- .github/workflows/build.yml | 2 ++ .github/workflows/core-test.yml | 3 +++ 2 files changed, 5 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ddac45696f..93def78a24 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -52,6 +52,8 @@ jobs: go_version: "1.22" core_cache_key: ${{ needs.core.outputs.cache_key }} core_cache_path: ${{ needs.core.outputs.cache_path }} + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} lint: needs: [core, console] diff --git a/.github/workflows/core-test.yml b/.github/workflows/core-test.yml index 4a27c07404..4d8d978b60 100644 --- a/.github/workflows/core-test.yml +++ b/.github/workflows/core-test.yml @@ -12,6 +12,9 @@ on: core_cache_path: required: true type: string + secrets: + CODECOV_TOKEN: + required: true jobs: postgres: From 66d185d74d842ccf540eaf8e2ae852eb7a01bafe Mon Sep 17 00:00:00 2001 From: mffap Date: Mon, 22 Apr 2024 15:59:11 +0200 Subject: [PATCH 16/31] docs(concepts): identity brokering (#7812) * docs(concepts): identity brokering * add comments from review --- .../concepts/features/identity-brokering.md | 71 ++++++++++++++++-- .../concepts/features/domain-discovery.png | Bin 0 -> 50623 bytes .../concepts/features/identity-brokering.png | Bin 0 -> 43255 bytes docs/static/img/guides/identity_brokering.png | Bin 278510 -> 0 bytes 4 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 docs/static/img/concepts/features/domain-discovery.png create mode 100644 docs/static/img/concepts/features/identity-brokering.png delete mode 100644 docs/static/img/guides/identity_brokering.png diff --git a/docs/docs/concepts/features/identity-brokering.md b/docs/docs/concepts/features/identity-brokering.md index 01b147cd0e..d1fb624c73 100644 --- a/docs/docs/concepts/features/identity-brokering.md +++ b/docs/docs/concepts/features/identity-brokering.md @@ -1,23 +1,78 @@ --- -title: Identity Brokering in ZITADEL +title: Identity Brokering sidebar_label: Identity Brokering --- -## What are Identity Brokering and Federated Identities? +Link social logins and external identity providers with your identity management platform allowing users to login with their preferred identity provider. + +Establish a trusted connection between your central identity provider (IdP) and third party identity providers. + +By using a central identity brokering service you don't need to develop and establish a trust relationship between each application and each identity provider individually. + +## What are federated identities? Federated identity management is an arrangement built upon the trust between two or more domains. Users of these domains are allowed to access applications and services using the same identity. This identity is known as federated identity and the pattern behind this is identity federation. +Compatibility across various IdPs is ensured by using industry standard protocols, such as: + +* OpenID Connect (OIDC): A modern and versatile protocol for secure authentication. +* SAML2: A widely adopted protocol for secure single sign-on (SSO) in enterprise environments. +* LDAP: A lightweight protocol for accessing user data directories commonly used in corporate networks. + +## What is identity brokering? + A service provider that specializes in brokering access control between multiple service providers (also referred to as relying parties) is called an identity broker. Federated identity management is an arrangement that is made between two or more such identity brokers across organizations. -For example, if Google is configured as an identity provider in your organization, the user will get the option to use his Google Account on the Login Screen of ZITADEL. Because Google is registered as a trusted identity provider, the user will be able to login in with the Google account after the user is linked with an existing ZITADEL account (if the user is already registered) or a new one with the claims provided by Google. +For example, if Google is configured as an identity provider in your organization, the user will get the option to use his Google Account on the Login Screen of ZITADEL. +Because Google is registered as a trusted identity provider, the user will be able to login in with the Google account after the user is linked with an existing ZITADEL account (if the user is already registered) or a new one with the claims provided by Google. -![Identity Brokering](/img/guides/identity_brokering.png) +![Diagram of an identity brokering scheme using a central identity provider that has a trust link to the Google IdP and Entra ID](/img/concepts/features/identity-brokering.png) -## How to use external identity providers in ZITADEL +The schema is a very simplified version, but shows the essential steps for identity brokering -Configure external identity providers on the instance level or just for one organization via the [Console](/guides/manage/console/default-settings#identity-providers) or ZITADEL APIs. +1. An unauthenticated user wants to use the alpha.com's application. +2. The application redirects the user to alpha.com's identity provider (IdP). +3. Based on the user's tenants configuration the IdP presents the configured identity providers, or redirects the user directly to the primary external IdP. The user authenticates with their external identity provider (eg, Entra ID). +4. After the authentication, the user is redirected back to alpha.com's identity provider. If the user doesn't exist in the IdP the user will be created just-in-time and linked to the external identity provider for future reference. +5. As with a local authentication, the IdP issues a token to the user that can be used to access the application. The IdP redirects the user, which is now authenticated, eventually to the application. -You will find [detailed integration guides for many Identity Providers](/guides/integrate/identity-providers/introduction) in our docs. -ZITADEL also provides templates to configure generic identity providers, which don't have templates. +## Is single-sign-on (SSO) the same as identity brokering? + +Sometimes single-sign-on (SSO) and login with third party identity providers is used interchangeably. +Typically SSO describes an authentication scheme that allows users to log in once at a central identity provider and access service providers (client applications) without to login again. + +Identity brokering describes an authentication scheme where users can login with external identity providers that have a established trust with an identity provider which facilitates the authentication for the requested applications. + +The connection between the two lies in how SSO can be implemented as part of an identity brokering solution. +In such cases, the identity broker uses SSO to enable seamless access across multiple systems, handling the complexities of different authentication protocols and standards behind the scenes. +This allows users to log in once and gain access to multiple systems that the broker facilitates. + +## Multitenancy and identity brokering + +In a multi-tenancy application, you want to be able to configure an external identity provider per tenant. +For example some organizations might use their EntraID, some other want to login with their OKTA, or Google Workspace. + +Using an identity provider with strong multitenancy capabilities such as ZITADEL, you can configure a different set of external identity providers per organization. + +[Domain discovery](/docs/guides/solution-scenarios/domain-discovery) ensures that users are redirected to their external identity provider based on their email-address or username. +[Managers](../structure/managers) can configure organization domains that are used for domain-based redirection to an external IdP. + +![Diagram explaining domain discovery](/img/concepts/features/domain-discovery.png) + +## Simplify identity brokering with ZITADEL templates + +ZITADEL works with SAML, OpenID Connect, and LDAP external identity providers. + +For popular IdPs such as EntraID, Okta, Google, Facebook, and GitHub, ZITADEL [offers pre-configured templates](/docs/guides/integrate/identity-providers/introduction). +These templates expedite the configuration process, allowing organizations to quickly integrate these providers with minimal effort. + +ZITADEL recognizes that specific needs may extend beyond pre-built templates. +To address this, ZITADEL provides generic templates that enable connection to virtually any IdP. This ensures maximum flexibility and future-proofs login infrastructure, accommodating future integrations with ease. + +### References + +* [Detailed integration guide for many identity providers](/guides/integrate/identity-providers/introduction) +* [Setup identity providers with Console](/guides/manage/console/default-settings#identity-providers) +* [Configure identity providers with the ZITADEL API](/docs/category/apis/resources/mgmt/identity-providers) diff --git a/docs/static/img/concepts/features/domain-discovery.png b/docs/static/img/concepts/features/domain-discovery.png new file mode 100644 index 0000000000000000000000000000000000000000..0e22874159e809ddd6485d1dfacf3b53dadd4d78 GIT binary patch literal 50623 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 z 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 literal 0 HcmV?d00001 diff --git a/docs/static/img/guides/identity_brokering.png b/docs/static/img/guides/identity_brokering.png deleted file mode 100644 index 0a73254df1d0fe78e5043c7ff429775eda9f90e0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 278510 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®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%0D>%@nslN$R}#d4P{ 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 Date: Tue, 23 Apr 2024 09:20:11 +0200 Subject: [PATCH 17/31] test(e2e): fix events flakiness (#7829) --- e2e/cypress/e2e/events/events.cy.ts | 7 +++++-- internal/integration/config/zitadel.yaml | 2 ++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/e2e/cypress/e2e/events/events.cy.ts b/e2e/cypress/e2e/events/events.cy.ts index f7800000e0..8c27f607d3 100644 --- a/e2e/cypress/e2e/events/events.cy.ts +++ b/e2e/cypress/e2e/events/events.cy.ts @@ -9,9 +9,12 @@ describe('events', () => { cy.get('[data-e2e="event-type-cell"]').should('have.length', 20); cy.get('[data-e2e="open-filter-button"]').click(); cy.get('[data-e2e="event-type-filter-checkbox"]').click(); + cy.get('mat-select[name="eventTypesList"]').click(); + cy.contains('mat-option', eventTypeEnglish).click(); + cy.get('body').type('{esc}'); cy.contains('mat-select', 'Descending').click(); - cy.contains('mat-option', 'Ascending').click(); + cy.contains('mat-option', 'Descending').click(); cy.get('[data-e2e="filter-finish-button"]').click(); - cy.contains('[data-e2e="event-type-cell"]', eventTypeEnglish).should('have.length.at.least', 1); + cy.contains('[data-e2e="event-type-cell"]', eventTypeEnglish).should('have.length', 1); }); }); diff --git a/internal/integration/config/zitadel.yaml b/internal/integration/config/zitadel.yaml index 707cf180f1..291099ea1c 100644 --- a/internal/integration/config/zitadel.yaml +++ b/internal/integration/config/zitadel.yaml @@ -49,6 +49,8 @@ SystemAPIUsers: - "SYSTEM_OWNER" - "IAM_OWNER" - "ORG_OWNER" + - cypress: + KeyData: "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUF6aStGRlNKTDdmNXl3NEtUd3pnTQpQMzRlUEd5Y20vTStrVDBNN1Y0Q2d4NVYzRWFESXZUUUtUTGZCYUVCNDV6YjlMdGpJWHpEdzByWFJvUzJoTzZ0CmgrQ1lRQ3ozS0N2aDA5QzBJenhaaUIySVMzSC9hVCs1Qng5RUZZK3ZuQWtaamNjYnlHNVlOUnZtdE9sbnZJZUkKSDdxWjB0RXdrUGZGNUdFWk5QSlB0bXkzVUdWN2lvZmRWUVMxeFJqNzMrYU13NXJ2SDREOElkeWlBQzNWZWtJYgpwdDBWajBTVVgzRHdLdG9nMzM3QnpUaVBrM2FYUkYwc2JGaFFvcWRKUkk4TnFnWmpDd2pxOXlmSTV0eXhZc3duCitKR3pIR2RIdlczaWRPRGxtd0V0NUsycGFzaVJJV0syT0dmcSt3MEVjbHRRSGFidXFFUGdabG1oQ2tSZE5maXgKQndJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg==" InitProjections: Enabled: true \ No newline at end of file From cc0c06f225e21911e935111af06a67a67a010a71 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Tue, 23 Apr 2024 10:35:25 +0200 Subject: [PATCH 18/31] fix: exclude db connection error details (#7785) * fix: exclude db connection error details * remove potential recursive error --- internal/api/assets/asset.go | 28 ++++++++++++++----- internal/api/grpc/gerrors/zitadel_errors.go | 5 ++++ .../server/middleware/instance_interceptor.go | 7 +++-- .../http/middleware/instance_interceptor.go | 9 +++--- 4 files changed, 35 insertions(+), 14 deletions(-) diff --git a/internal/api/assets/asset.go b/internal/api/assets/asset.go index 5836d66e0b..5f30c94a29 100644 --- a/internal/api/assets/asset.go +++ b/internal/api/assets/asset.go @@ -2,6 +2,7 @@ package assets import ( "context" + "errors" "fmt" "io" "net/http" @@ -12,14 +13,17 @@ import ( "github.com/gabriel-vasile/mimetype" "github.com/gorilla/mux" "github.com/zitadel/logging" + "golang.org/x/text/language" "github.com/zitadel/zitadel/internal/api/authz" http_util "github.com/zitadel/zitadel/internal/api/http" http_mw "github.com/zitadel/zitadel/internal/api/http/middleware" "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/i18n" "github.com/zitadel/zitadel/internal/id" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/static" + "github.com/zitadel/zitadel/internal/zerrors" ) const ( @@ -73,19 +77,29 @@ type Downloader interface { type ErrorHandler func(w http.ResponseWriter, r *http.Request, err error, defaultCode int) -func DefaultErrorHandler(w http.ResponseWriter, r *http.Request, err error, defaultCode int) { - logging.WithFields("uri", r.RequestURI).WithError(err).Warn("error occurred on asset api") - code, ok := http_util.ZitadelErrorToHTTPStatusCode(err) - if !ok { - code = defaultCode +func DefaultErrorHandler(translator *i18n.Translator) func(w http.ResponseWriter, r *http.Request, err error, defaultCode int) { + return func(w http.ResponseWriter, r *http.Request, err error, defaultCode int) { + logging.WithFields("uri", r.RequestURI).WithError(err).Warn("error occurred on asset api") + code, ok := http_util.ZitadelErrorToHTTPStatusCode(err) + if !ok { + code = defaultCode + } + zErr := new(zerrors.ZitadelError) + if errors.As(err, &zErr) { + zErr.SetMessage(translator.LocalizeFromCtx(r.Context(), zErr.GetMessage(), nil)) + zErr.Parent = nil // ensuring we don't leak any unwanted information + err = zErr + } + http.Error(w, err.Error(), code) } - http.Error(w, err.Error(), code) } func NewHandler(commands *command.Commands, verifier authz.APITokenVerifier, authConfig authz.Config, idGenerator id.Generator, storage static.Storage, queries *query.Queries, callDurationInterceptor, instanceInterceptor, assetCacheInterceptor, accessInterceptor func(handler http.Handler) http.Handler) http.Handler { + translator, err := i18n.NewZitadelTranslator(language.English) + logging.OnError(err).Panic("unable to get translator") h := &Handler{ commands: commands, - errorHandler: DefaultErrorHandler, + errorHandler: DefaultErrorHandler(translator), authInterceptor: http_mw.AuthorizationInterceptor(verifier, authConfig), idGenerator: idGenerator, storage: storage, diff --git a/internal/api/grpc/gerrors/zitadel_errors.go b/internal/api/grpc/gerrors/zitadel_errors.go index 60e8473898..8e131c2d35 100644 --- a/internal/api/grpc/gerrors/zitadel_errors.go +++ b/internal/api/grpc/gerrors/zitadel_errors.go @@ -3,6 +3,7 @@ package gerrors import ( "errors" + "github.com/jackc/pgx/v5/pgconn" "github.com/zitadel/logging" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -35,6 +36,10 @@ func ExtractZITADELError(err error) (c codes.Code, msg, id string, ok bool) { if err == nil { return codes.OK, "", "", false } + connErr := new(pgconn.ConnectError) + if ok := errors.As(err, &connErr); ok { + return codes.Internal, "db connection error", "", true + } zitadelErr := new(zerrors.ZitadelError) if ok := errors.As(err, &zitadelErr); !ok { return codes.Unknown, err.Error(), "", false diff --git a/internal/api/grpc/server/middleware/instance_interceptor.go b/internal/api/grpc/server/middleware/instance_interceptor.go index f5965beb35..ba637d59fa 100644 --- a/internal/api/grpc/server/middleware/instance_interceptor.go +++ b/internal/api/grpc/server/middleware/instance_interceptor.go @@ -62,14 +62,15 @@ func setInstance(ctx context.Context, req interface{}, info *grpc.UnaryServerInf } instance, err := verifier.InstanceByHost(interceptorCtx, host) if err != nil { - err = fmt.Errorf("unable to set instance using origin %s (ExternalDomain is %s): %w", zitadel_http.ComposedOrigin(ctx), externalDomain, err) + origin := zitadel_http.ComposedOrigin(ctx) + logging.WithFields("origin", origin, "externalDomain", externalDomain).WithError(err).Error("unable to set instance") zErr := new(zerrors.ZitadelError) if errors.As(err, &zErr) { zErr.SetMessage(translator.LocalizeFromCtx(ctx, zErr.GetMessage(), nil)) zErr.Parent = err - err = zErr + return nil, status.Error(codes.NotFound, fmt.Sprintf("unable to set instance using origin %s (ExternalDomain is %s): %s", origin, externalDomain, zErr)) } - return nil, status.Error(codes.NotFound, err.Error()) + return nil, status.Error(codes.NotFound, fmt.Sprintf("unable to set instance using origin %s (ExternalDomain is %s)", origin, externalDomain)) } span.End() return handler(authz.WithInstance(ctx, instance), req) diff --git a/internal/api/http/middleware/instance_interceptor.go b/internal/api/http/middleware/instance_interceptor.go index 4b315d5a7b..2117b98d30 100644 --- a/internal/api/http/middleware/instance_interceptor.go +++ b/internal/api/http/middleware/instance_interceptor.go @@ -56,14 +56,15 @@ func (a *instanceInterceptor) handleInstance(w http.ResponseWriter, r *http.Requ } ctx, err := setInstance(r, a.verifier, a.headerName) if err != nil { - err = fmt.Errorf("unable to set instance using origin %s (ExternalDomain is %s): %w", zitadel_http.ComposedOrigin(r.Context()), a.externalDomain, err) + origin := zitadel_http.ComposedOrigin(r.Context()) + logging.WithFields("origin", origin, "externalDomain", a.externalDomain).WithError(err).Error("unable to set instance") zErr := new(zerrors.ZitadelError) if errors.As(err, &zErr) { zErr.SetMessage(a.translator.LocalizeFromRequest(r, zErr.GetMessage(), nil)) - zErr.Parent = err - err = zErr + http.Error(w, fmt.Sprintf("unable to set instance using origin %s (ExternalDomain is %s): %s", origin, a.externalDomain, zErr), http.StatusNotFound) + return } - http.Error(w, err.Error(), http.StatusNotFound) + http.Error(w, fmt.Sprintf("unable to set instance using origin %s (ExternalDomain is %s)", origin, a.externalDomain), http.StatusNotFound) return } r = r.WithContext(ctx) From df50c3835b7f2e2f84a87cebf6c48907bf1da1b4 Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Tue, 23 Apr 2024 11:09:25 +0200 Subject: [PATCH 19/31] test(e2e): check for exactly one displayed event (#7831) test(e2e): check for exactly once displayed event --- e2e/cypress/e2e/events/events.cy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/cypress/e2e/events/events.cy.ts b/e2e/cypress/e2e/events/events.cy.ts index 8c27f607d3..c271c244a1 100644 --- a/e2e/cypress/e2e/events/events.cy.ts +++ b/e2e/cypress/e2e/events/events.cy.ts @@ -15,6 +15,6 @@ describe('events', () => { cy.contains('mat-select', 'Descending').click(); cy.contains('mat-option', 'Descending').click(); cy.get('[data-e2e="filter-finish-button"]').click(); - cy.contains('[data-e2e="event-type-cell"]', eventTypeEnglish).should('have.length', 1); + cy.get('[data-e2e="event-type-cell"]').should('have.length', 1); }); }); From e46dd121cdd2cbc0042c0aa578cd47f3d7b4e456 Mon Sep 17 00:00:00 2001 From: Ari Date: Tue, 23 Apr 2024 11:38:07 +0200 Subject: [PATCH 20/31] feat: allow using a local RSA key for machine keys (#7671) * Allow using a local RSA key for machine keys * Add check for key validity * Fix naming error * docs: provide translations of invalid key --------- Co-authored-by: Livio Spring --- internal/api/grpc/management/user.go | 15 ++++-- .../api/grpc/management/user_converter.go | 1 + internal/command/user_machine_key.go | 7 +++ internal/command/user_machine_key_test.go | 48 +++++++++++++++---- internal/static/i18n/bg.yaml | 1 + internal/static/i18n/cs.yaml | 1 + internal/static/i18n/de.yaml | 1 + internal/static/i18n/en.yaml | 1 + internal/static/i18n/es.yaml | 1 + internal/static/i18n/fr.yaml | 1 + internal/static/i18n/it.yaml | 1 + internal/static/i18n/ja.yaml | 1 + internal/static/i18n/mk.yaml | 1 + internal/static/i18n/nl.yaml | 1 + internal/static/i18n/pl.yaml | 1 + internal/static/i18n/pt.yaml | 1 + internal/static/i18n/ru.yaml | 1 + internal/static/i18n/zh.yaml | 1 + proto/zitadel/management.proto | 8 +++- 19 files changed, 80 insertions(+), 13 deletions(-) diff --git a/internal/api/grpc/management/user.go b/internal/api/grpc/management/user.go index eae4f40d35..6166bf4a5b 100644 --- a/internal/api/grpc/management/user.go +++ b/internal/api/grpc/management/user.go @@ -774,13 +774,22 @@ func (s *Server) ListMachineKeys(ctx context.Context, req *mgmt_pb.ListMachineKe func (s *Server) AddMachineKey(ctx context.Context, req *mgmt_pb.AddMachineKeyRequest) (*mgmt_pb.AddMachineKeyResponse, error) { machineKey := AddMachineKeyRequestToCommand(req, authz.GetCtxData(ctx).OrgID) + // If there is no pubkey supplied, then AddUserMachineKey will generate a new one + pubkeySupplied := len(machineKey.PublicKey) > 0 details, err := s.command.AddUserMachineKey(ctx, machineKey) if err != nil { return nil, err } - keyDetails, err := machineKey.Detail() - if err != nil { - return nil, err + + // Return key details only if the pubkey wasn't supplied, otherwise the user already has + // private key locally + var keyDetails []byte + if !pubkeySupplied { + var err error + keyDetails, err = machineKey.Detail() + if err != nil { + return nil, err + } } return &mgmt_pb.AddMachineKeyResponse{ KeyId: machineKey.KeyID, diff --git a/internal/api/grpc/management/user_converter.go b/internal/api/grpc/management/user_converter.go index 32496b431c..511e1a4f54 100644 --- a/internal/api/grpc/management/user_converter.go +++ b/internal/api/grpc/management/user_converter.go @@ -237,6 +237,7 @@ func AddMachineKeyRequestToCommand(req *mgmt_pb.AddMachineKeyRequest, resourceOw }, ExpirationDate: expDate, Type: authn.KeyTypeToDomain(req.Type), + PublicKey: req.PublicKey, } } diff --git a/internal/command/user_machine_key.go b/internal/command/user_machine_key.go index d092cc54ef..d6abdd9545 100644 --- a/internal/command/user_machine_key.go +++ b/internal/command/user_machine_key.go @@ -5,6 +5,7 @@ import ( "time" "github.com/zitadel/zitadel/internal/command/preparation" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/v1/models" @@ -78,6 +79,12 @@ func (key *MachineKey) valid() (err error) { if err := key.content(); err != nil { return err } + // If a key is supplied, it should be a valid public key + if len(key.PublicKey) > 0 { + if _, err := crypto.BytesToPublicKey(key.PublicKey); err != nil { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-5F3h1", "Errors.User.Machine.Key.Invalid") + } + } key.ExpirationDate, err = domain.ValidateExpirationDate(key.ExpirationDate) return err } diff --git a/internal/command/user_machine_key_test.go b/internal/command/user_machine_key_test.go index 7e4a0f069a..9e50f81e7c 100644 --- a/internal/command/user_machine_key_test.go +++ b/internal/command/user_machine_key_test.go @@ -18,6 +18,16 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) +const fakePubkey = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAp4qNBuUu/HekF2E5bOtA +oEL76zS0NQdZL3ByEJ3hZplJhE30ITPIOLW3+uaMMM+obl/LLapwG2vdhvutQtx/ +FOLJmXysbG3RL9zjXDBT5IE+nGFC7ctsi5FGbHQbAm45E3HHCSk7gfmTy9hxyk1K +GsyU8BDeOWasJO6aeXqpOnRM8vw/fY+6mHVC9CxcIroSfrIabFGe/mP6qpBGeFSn +APymBc/8lca4JaPv2/u/rBhnaAHZiUuCS1+MonWelOb+MSfq48VgtpiaYIVY9szI +esorA6EJ9pO17ROEUpX5wP5Oir+yGJU27jSvLCjvK6fOFX+OwUM9L8047JKoo+Nf +PwIDAQAB +-----END PUBLIC KEY-----` + func TestCommands_AddMachineKey(t *testing.T) { type fields struct { eventstore *eventstore.Eventstore @@ -145,7 +155,7 @@ func TestCommands_AddMachineKey(t *testing.T) { "key1", domain.AuthNKeyTypeJSON, time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC), - []byte("public"), + []byte(fakePubkey), ), ), ), @@ -161,14 +171,14 @@ func TestCommands_AddMachineKey(t *testing.T) { }, Type: domain.AuthNKeyTypeJSON, ExpirationDate: time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC), - PublicKey: []byte("public"), + PublicKey: []byte(fakePubkey), }, }, res{ want: &domain.ObjectDetails{ ResourceOwner: "org1", }, - key: true, + key: false, }, }, { @@ -194,7 +204,7 @@ func TestCommands_AddMachineKey(t *testing.T) { "key1", domain.AuthNKeyTypeJSON, time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC), - []byte("public"), + []byte(fakePubkey), ), ), ), @@ -210,14 +220,35 @@ func TestCommands_AddMachineKey(t *testing.T) { KeyID: "key1", Type: domain.AuthNKeyTypeJSON, ExpirationDate: time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC), - PublicKey: []byte("public"), + PublicKey: []byte(fakePubkey), }, }, res{ want: &domain.ObjectDetails{ ResourceOwner: "org1", }, - key: true, + key: false, + }, + }, + { + "key added with invalid public key", + fields{ + eventstore: eventstoreExpect(t), + }, + args{ + ctx: context.Background(), + key: &MachineKey{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + KeyID: "key1", + Type: domain.AuthNKeyTypeJSON, + PublicKey: []byte("incorrect"), + }, + }, + res{ + err: zerrors.IsErrorInvalidArgument, }, }, } @@ -237,9 +268,8 @@ func TestCommands_AddMachineKey(t *testing.T) { } if tt.res.err == nil { assert.Equal(t, tt.res.want, got) - if tt.res.key { - assert.NotEqual(t, "", tt.args.key.PrivateKey) - } + receivedKey := len(tt.args.key.PrivateKey) > 0 + assert.Equal(t, tt.res.key, receivedKey) } }) } diff --git a/internal/static/i18n/bg.yaml b/internal/static/i18n/bg.yaml index 899fa9f0ce..5fac59fb9d 100644 --- a/internal/static/i18n/bg.yaml +++ b/internal/static/i18n/bg.yaml @@ -111,6 +111,7 @@ Errors: Key: NotFound: Машинният ключ не е намерен AlreadyExisting: Машинният ключ вече съществува + Invalid: Публичният ключ не е валиден RSA публичен ключ във формат PKIX с PEM кодиране Secret: NotExisting: Тайната не съществува Invalid: Тайната е невалидна diff --git a/internal/static/i18n/cs.yaml b/internal/static/i18n/cs.yaml index b1e83a6d18..d12a1b451e 100644 --- a/internal/static/i18n/cs.yaml +++ b/internal/static/i18n/cs.yaml @@ -109,6 +109,7 @@ Errors: Key: NotFound: Klíč stroje nenalezen AlreadyExisting: Klíč stroje již existuje + Invalid: Veřejný klíč není platný veřejný klíč RSA ve formátu PKIX s kódováním PEM Secret: NotExisting: Tajemství neexistuje Invalid: Tajemství je neplatné diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index 12bd70bc40..408a862b45 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -109,6 +109,7 @@ Errors: Key: NotFound: Maschinen Schlüssel nicht gefunden AlreadyExisting: Machine Schlüssel exisiert bereits + Invalid: Der öffentliche Schlüssel ist kein gültiger öffentlicher RSA-Schlüssel im PKIX-Format mit PEM-Kodierung Secret: NotExisting: Secret existiert nicht Invalid: Secret ist ungültig diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index 511248da6e..937820fdc9 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -109,6 +109,7 @@ Errors: Key: NotFound: Machine key not found AlreadyExisting: Machine key already existing + Invalid: Public key is not a valid RSA public key in PKIX format with PEM encoding Secret: NotExisting: Secret doesn't exist Invalid: Secret is invalid diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index 60115ca865..102d99f086 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -109,6 +109,7 @@ Errors: Key: NotFound: Clave de máquina no encontrada AlreadyExisting: La clave de máquina ya existe + Invalid: La clave pública no es una clave pública RSA válida en formato PKIX con codificación PEM Secret: NotExisting: El secreto no existe Invalid: El secret no es válido diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index 8855ce5c46..631d5be478 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -109,6 +109,7 @@ Errors: Key: NotFound: Clé de la machine non trouvée AlreadyExisting: Clé de la machine déjà existante + Invalid: La clé publique n'est pas une clé publique RSA valide au format PKIX avec encodage PEM Secret: NotExisting: Secret n'existe pas Invalid: Secret n'est pas valide diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index 7005eaf311..064f3929ef 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -109,6 +109,7 @@ Errors: Key: NotFound: Chiave macchina non trovato AlreadyExisting: Chiave macchina già esistente + Invalid: La chiave pubblica non è una chiave pubblica RSA valida in formato PKIX con codifica PEM Secret: NotExisting: Secret non esiste Invalid: Secret non è valido diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index 6eed3af3e7..4ef7b8f985 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -102,6 +102,7 @@ Errors: Key: NotFound: マシーンキーが見つかりません AlreadyExisting: すでに存在しているマシーンキーです + Invalid: 公開キーは、PEM エンコードを使用した PKIX 形式の有効な RSA 公開キーではありません Secret: NotExisting: シークレットは存在しません Invalid: 無効なシークレットです diff --git a/internal/static/i18n/mk.yaml b/internal/static/i18n/mk.yaml index f964ce2d71..deab6d2e37 100644 --- a/internal/static/i18n/mk.yaml +++ b/internal/static/i18n/mk.yaml @@ -109,6 +109,7 @@ Errors: Key: NotFound: Machine key не е пронајден AlreadyExisting: Machine key веќе постои + Invalid: Јавниот клуч не е валиден јавен клуч RSA во формат PKIX со PEM кодирање Secret: NotExisting: Тајната не постои Invalid: Тајната е невалидна diff --git a/internal/static/i18n/nl.yaml b/internal/static/i18n/nl.yaml index 42ca88acf9..a5ff77d710 100644 --- a/internal/static/i18n/nl.yaml +++ b/internal/static/i18n/nl.yaml @@ -108,6 +108,7 @@ Errors: Key: NotFound: Machine sleutel niet gevonden AlreadyExisting: Machine sleutel al bestaand + Invalid: De openbare sleutel is geen geldige openbare RSA-sleutel in PKIX-indeling met PEM-codering Secret: NotExisting: Geheim bestaat niet Invalid: Geheim is ongeldig diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index 33e7b77342..8040183629 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -109,6 +109,7 @@ Errors: Key: NotFound: Klucz maszyny nie znaleziony AlreadyExisting: Klucz maszyny już istnieje + Invalid: Klucz publiczny nie jest prawidłowym kluczem publicznym RSA w formacie PKIX z kodowaniem PEM Secret: NotExisting: Sekret nie istnieje Invalid: Sekret jest nieprawidłowy diff --git a/internal/static/i18n/pt.yaml b/internal/static/i18n/pt.yaml index 89dab225c9..3bcc981d31 100644 --- a/internal/static/i18n/pt.yaml +++ b/internal/static/i18n/pt.yaml @@ -109,6 +109,7 @@ Errors: Key: NotFound: Chave de máquina não encontrada AlreadyExisting: Chave de máquina já existe + Invalid: A chave pública não é uma chave pública RSA válida no formato PKIX com codificação PEM Secret: NotExisting: Segredo não existe Invalid: Segredo é inválido diff --git a/internal/static/i18n/ru.yaml b/internal/static/i18n/ru.yaml index 3bb3cd64dc..5d4182d253 100644 --- a/internal/static/i18n/ru.yaml +++ b/internal/static/i18n/ru.yaml @@ -110,6 +110,7 @@ Errors: Key: NotFound: Машинный ключ не найден AlreadyExisting: Машинный ключ уже существует + Invalid: Открытый ключ не является допустимым открытым ключом RSA в формате PKIX с кодировкой PEM Secret: NotExisting: Ключ не существует Invalid: Ключ недействителен diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index ee7bfec97c..4ec0e24cc1 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -109,6 +109,7 @@ Errors: Key: NotFound: 未找到机器密钥 AlreadyExisting: 已有的机器钥匙 + Invalid: 公钥不是采用 PEM 编码的 PKIX 格式的有效 RSA 公钥 Secret: NotExisting: 秘密并不存在 Invalid: 秘密是无效的 diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index 0b1a3b6a10..5a0f241ff3 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -1734,7 +1734,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { summary: "Create Key for machine user"; - description: "A new key is generated and will be returned in the response. Make sure to store the returned key. Machine keys are used to authenticate with jwt profile." + description: "If a public key is not supplied, a new key is generated and will be returned in the response. Make sure to store the returned key. If an RSA public key is supplied, the private key is omitted from the response. Machine keys are used to authenticate with jwt profile." tags: "Users"; tags: "User Machine"; responses: { @@ -8504,6 +8504,12 @@ message AddMachineKeyRequest { description: "The date the key will expire and no logins will be possible"; } ]; + bytes public_key = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1...\""; + description: "Optionally provide a public key of your own generated RSA private key."; + } + ]; } message AddMachineKeyResponse { From 9fa90e0757c16e54db7d643278914eb69935f5f6 Mon Sep 17 00:00:00 2001 From: Miguel Cabrerizo <30386061+doncicuto@users.noreply.github.com> Date: Tue, 23 Apr 2024 12:17:28 +0200 Subject: [PATCH 21/31] fix: weird issue with service key expirationDate format (#7688) * fix: weird issue with service key expirationDate format for localizedDate * fix: replace YYYY with EEEE dd. MMM yyyy in other cases just in case --------- Co-authored-by: Max Peintner --- .../src/app/modules/machine-keys/machine-keys.component.html | 2 +- console/src/app/modules/paginator/paginator.component.html | 2 +- .../personal-access-tokens.component.html | 2 +- .../modules/show-key-dialog/show-key-dialog.component.html | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/console/src/app/modules/machine-keys/machine-keys.component.html b/console/src/app/modules/machine-keys/machine-keys.component.html index 712bf328ca..e96cad5194 100644 --- a/console/src/app/modules/machine-keys/machine-keys.component.html +++ b/console/src/app/modules/machine-keys/machine-keys.component.html @@ -61,7 +61,7 @@ {{ 'USER.MACHINE.EXPIRATIONDATE' | translate }} - {{ key.expirationDate | timestampToDate | localizedDate: 'EEE dd. MMM YYYY, HH:mm' }} + {{ key.expirationDate | timestampToDate | localizedDate: 'EEE dd. MMM yyyy, HH:mm' }} diff --git a/console/src/app/modules/paginator/paginator.component.html b/console/src/app/modules/paginator/paginator.component.html index 8fa85f0651..9138038437 100644 --- a/console/src/app/modules/paginator/paginator.component.html +++ b/console/src/app/modules/paginator/paginator.component.html @@ -4,7 +4,7 @@ {{ length }} {{ 'PAGINATOR.COUNT' | translate }}

- {{ timestamp | timestampToDate | localizedDate: 'EEEE dd. MMM YYYY, HH:mm' }} + {{ timestamp | timestampToDate | localizedDate: 'EEEE dd. MMM yyyy, HH:mm' }}

diff --git a/console/src/app/modules/personal-access-tokens/personal-access-tokens.component.html b/console/src/app/modules/personal-access-tokens/personal-access-tokens.component.html index 3d2e60adea..4c76a59f83 100644 --- a/console/src/app/modules/personal-access-tokens/personal-access-tokens.component.html +++ b/console/src/app/modules/personal-access-tokens/personal-access-tokens.component.html @@ -56,7 +56,7 @@ {{ 'USER.MACHINE.EXPIRATIONDATE' | translate }} - {{ key.expirationDate | timestampToDate | localizedDate: 'EEE dd. MMM YYYY, HH:mm' }} + {{ key.expirationDate | timestampToDate | localizedDate: 'EEE dd. MMM yyyy, HH:mm' }} diff --git a/console/src/app/modules/show-key-dialog/show-key-dialog.component.html b/console/src/app/modules/show-key-dialog/show-key-dialog.component.html index c5b75a0b60..ce41416225 100644 --- a/console/src/app/modules/show-key-dialog/show-key-dialog.component.html +++ b/console/src/app/modules/show-key-dialog/show-key-dialog.component.html @@ -13,14 +13,14 @@

{{ 'USER.MACHINE.CREATIONDATE' | translate }}

- {{ keyResponse.details.creationDate | timestampToDate | localizedDate: 'EEE dd. MMM YYYY, HH:mm' }} + {{ keyResponse.details.creationDate | timestampToDate | localizedDate: 'EEE dd. MMM yyyy, HH:mm' }}

{{ 'USER.MACHINE.EXPIRATIONDATE' | translate }}

- {{ expirationDate | localizedDate: 'EEE dd. MMM YYYY, HH:mm' }} + {{ expirationDate | localizedDate: 'EEE dd. MMM yyyy, HH:mm' }}

From 25030c69b97b81350a9919faed34e7de9005f725 Mon Sep 17 00:00:00 2001 From: Silvan Date: Tue, 23 Apr 2024 13:23:50 +0200 Subject: [PATCH 22/31] perf: cache auth request in memory (#7824) * perf: cache auth request in memory --- cmd/defaults.yaml | 3 + go.mod | 1 + go.sum | 2 + .../eventsourcing/eventstore/auth_request.go | 87 +++++++++------ .../eventstore/auth_request_test.go | 1 + .../repository/eventsourcing/repository.go | 7 +- .../auth_request/repository/cache/cache.go | 100 +++++++++++++++--- .../repository/mock/repository.mock.go | 12 +++ .../auth_request/repository/repository.go | 1 + internal/database/database.go | 2 +- 10 files changed, 165 insertions(+), 51 deletions(-) diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 2437a4ef3d..5652ca2fcb 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -274,6 +274,9 @@ Auth: # from HandleActiveInstances duration in the past until the projections current time # If set to 0 (default), every instance is always considered active HandleActiveInstances: 0s #ZITADEL_AUTH_SPOOLER_HANDLEACTIVEINSTANCES + # Defines the amount of auth requests stored in the LRU caches. + # There are two caches implemented one for id and one for code + AmountOfCachedAuthRequests: 128 #ZITADEL_AUTH_AMOUNTOFCACHEDAUTHREQUESTS Admin: # See Projections.BulkLimit diff --git a/go.mod b/go.mod index 99fcd1f494..9fdc3a7cf6 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( github.com/grpc-ecosystem/grpc-gateway v1.16.0 github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 github.com/h2non/gock v1.2.0 + github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/improbable-eng/grpc-web v0.15.0 github.com/jackc/pgx/v5 v5.5.5 github.com/jarcoal/jpath v0.0.0-20140328210829-f76b8b2dbf52 diff --git a/go.sum b/go.sum index 4b6f340626..85420a32f2 100644 --- a/go.sum +++ b/go.sum @@ -385,6 +385,8 @@ github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09 github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request.go b/internal/auth/repository/eventsourcing/eventstore/auth_request.go index b7c85ab79e..ca95108205 100644 --- a/internal/auth/repository/eventsourcing/eventstore/auth_request.go +++ b/internal/auth/repository/eventsourcing/eventstore/auth_request.go @@ -656,39 +656,52 @@ func (repo *AuthRequestRepo) fillPolicies(ctx context.Context, request *domain.A } } - loginPolicy, idpProviders, err := repo.getLoginPolicyAndIDPProviders(ctx, orgID) - if err != nil { - return err + if request.LoginPolicy == nil || len(request.AllowedExternalIDPs) == 0 { + loginPolicy, idpProviders, err := repo.getLoginPolicyAndIDPProviders(ctx, orgID) + if err != nil { + return err + } + request.LoginPolicy = queryLoginPolicyToDomain(loginPolicy) + if len(idpProviders) > 0 { + request.AllowedExternalIDPs = idpProviders + } } - request.LoginPolicy = queryLoginPolicyToDomain(loginPolicy) - if idpProviders != nil { - request.AllowedExternalIDPs = idpProviders + if request.LockoutPolicy == nil { + lockoutPolicy, err := repo.getLockoutPolicy(ctx, orgID) + if err != nil { + return err + } + request.LockoutPolicy = lockoutPolicyToDomain(lockoutPolicy) } - lockoutPolicy, err := repo.getLockoutPolicy(ctx, orgID) - if err != nil { - return err + if request.PrivacyPolicy == nil { + privacyPolicy, err := repo.GetPrivacyPolicy(ctx, orgID) + if err != nil { + return err + } + request.PrivacyPolicy = privacyPolicy } - request.LockoutPolicy = lockoutPolicyToDomain(lockoutPolicy) - privacyPolicy, err := repo.GetPrivacyPolicy(ctx, orgID) - if err != nil { - return err + if request.LabelPolicy == nil { + labelPolicy, err := repo.getLabelPolicy(ctx, request.PrivateLabelingOrgID(orgID)) + if err != nil { + return err + } + request.LabelPolicy = labelPolicy } - request.PrivacyPolicy = privacyPolicy - labelPolicy, err := repo.getLabelPolicy(ctx, request.PrivateLabelingOrgID(orgID)) - if err != nil { - return err + if len(request.DefaultTranslations) == 0 { + defaultLoginTranslations, err := repo.getLoginTexts(ctx, instance.InstanceID()) + if err != nil { + return err + } + request.DefaultTranslations = defaultLoginTranslations } - request.LabelPolicy = labelPolicy - defaultLoginTranslations, err := repo.getLoginTexts(ctx, instance.InstanceID()) - if err != nil { - return err + if len(request.OrgTranslations) == 0 { + orgLoginTranslations, err := repo.getLoginTexts(ctx, orgID) + if err != nil { + return err + } + request.OrgTranslations = orgLoginTranslations } - request.DefaultTranslations = defaultLoginTranslations - orgLoginTranslations, err := repo.getLoginTexts(ctx, orgID) - if err != nil { - return err - } - request.OrgTranslations = orgLoginTranslations + repo.AuthRequests.CacheAuthRequest(ctx, request) return nil } @@ -801,6 +814,7 @@ func (repo *AuthRequestRepo) checkDomainDiscovery(ctx context.Context, request * } request.LoginHint = loginName request.Prompt = append(request.Prompt, domain.PromptCreate) // to trigger registration + repo.AuthRequests.CacheAuthRequest(ctx, request) return true, nil } @@ -872,22 +886,25 @@ func (repo *AuthRequestRepo) checkLoginNameInputForResourceOwner(ctx context.Con return nil, err } -func (repo *AuthRequestRepo) checkLoginPolicyWithResourceOwner(ctx context.Context, request *domain.AuthRequest, resourceOwner string) error { - loginPolicy, idpProviders, err := repo.getLoginPolicyAndIDPProviders(ctx, resourceOwner) - if err != nil { - return err +func (repo *AuthRequestRepo) checkLoginPolicyWithResourceOwner(ctx context.Context, request *domain.AuthRequest, resourceOwner string) (err error) { + if request.LoginPolicy == nil { + loginPolicy, idps, err := repo.getLoginPolicyAndIDPProviders(ctx, resourceOwner) + if err != nil { + return err + } + request.LoginPolicy = queryLoginPolicyToDomain(loginPolicy) + request.AllowedExternalIDPs = idps } - if len(request.LinkingUsers) != 0 && !loginPolicy.AllowExternalIDPs { + if len(request.LinkingUsers) != 0 && !request.LoginPolicy.AllowExternalIDP { return zerrors.ThrowInvalidArgument(nil, "LOGIN-s9sio", "Errors.User.NotAllowedToLink") } if len(request.LinkingUsers) != 0 { - exists := linkingIDPConfigExistingInAllowedIDPs(request.LinkingUsers, idpProviders) + exists := linkingIDPConfigExistingInAllowedIDPs(request.LinkingUsers, request.AllowedExternalIDPs) if !exists { return zerrors.ThrowInvalidArgument(nil, "LOGIN-Dj89o", "Errors.User.NotAllowedToLink") } } - request.LoginPolicy = queryLoginPolicyToDomain(loginPolicy) - request.AllowedExternalIDPs = idpProviders + repo.AuthRequests.CacheAuthRequest(ctx, request) return nil } diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go b/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go index 99e0c78ec6..d5dcf0257d 100644 --- a/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go +++ b/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go @@ -486,6 +486,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { AuthRequests: func() cache.AuthRequestCache { m := mock.NewMockAuthRequestCache(gomock.NewController(t)) m.EXPECT().UpdateAuthRequest(gomock.Any(), gomock.Any()) + m.EXPECT().CacheAuthRequest(gomock.Any(), gomock.Any()) return m }(), userSessionViewProvider: &mockViewUserSession{ diff --git a/internal/auth/repository/eventsourcing/repository.go b/internal/auth/repository/eventsourcing/repository.go index 19cea1c6ba..20f753863c 100644 --- a/internal/auth/repository/eventsourcing/repository.go +++ b/internal/auth/repository/eventsourcing/repository.go @@ -17,8 +17,9 @@ import ( ) type Config struct { - SearchLimit uint64 - Spooler auth_handler.Config + SearchLimit uint64 + Spooler auth_handler.Config + AmountOfCachedAuthRequests uint16 } type EsRepository struct { @@ -39,7 +40,7 @@ func Start(ctx context.Context, conf Config, systemDefaults sd.SystemDefaults, c auth_handler.Register(ctx, conf.Spooler, view, queries) auth_handler.Start(ctx) - authReq := cache.Start(dbClient) + authReq := cache.Start(dbClient, conf.AmountOfCachedAuthRequests) userRepo := eventstore.UserRepo{ SearchLimit: conf.SearchLimit, diff --git a/internal/auth_request/repository/cache/cache.go b/internal/auth_request/repository/cache/cache.go index 63c442ef2d..9919d717de 100644 --- a/internal/auth_request/repository/cache/cache.go +++ b/internal/auth_request/repository/cache/cache.go @@ -8,6 +8,9 @@ import ( "fmt" "time" + "github.com/hashicorp/golang-lru/v2" + "github.com/zitadel/logging" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/domain" @@ -15,12 +18,21 @@ import ( ) type AuthRequestCache struct { - client *database.DB + client *database.DB + idCache *lru.Cache[string, *domain.AuthRequest] + codeCache *lru.Cache[string, *domain.AuthRequest] } -func Start(dbClient *database.DB) *AuthRequestCache { +func Start(dbClient *database.DB, amountOfCachedAuthRequests uint16) *AuthRequestCache { + idCache, err := lru.New[string, *domain.AuthRequest](int(amountOfCachedAuthRequests)) + logging.OnError(err).Info("auth request cache disabled") + codeCache, err := lru.New[string, *domain.AuthRequest](int(amountOfCachedAuthRequests)) + logging.OnError(err).Info("auth request cache disabled") + return &AuthRequestCache{ - client: dbClient, + client: dbClient, + idCache: idCache, + codeCache: codeCache, } } @@ -29,22 +41,38 @@ func (c *AuthRequestCache) Health(ctx context.Context) error { } func (c *AuthRequestCache) GetAuthRequestByID(ctx context.Context, id string) (*domain.AuthRequest, error) { - return c.getAuthRequest("id", id, authz.GetInstance(ctx).InstanceID()) + if authRequest, ok := c.getCachedByID(ctx, id); ok { + return authRequest, nil + } + request, err := c.getAuthRequest(ctx, "id", id, authz.GetInstance(ctx).InstanceID()) + if err != nil { + return nil, err + } + c.CacheAuthRequest(ctx, request) + return request, nil } func (c *AuthRequestCache) GetAuthRequestByCode(ctx context.Context, code string) (*domain.AuthRequest, error) { - return c.getAuthRequest("code", code, authz.GetInstance(ctx).InstanceID()) + if authRequest, ok := c.getCachedByCode(ctx, code); ok { + return authRequest, nil + } + request, err := c.getAuthRequest(ctx, "code", code, authz.GetInstance(ctx).InstanceID()) + if err != nil { + return nil, err + } + c.CacheAuthRequest(ctx, request) + return request, nil } -func (c *AuthRequestCache) SaveAuthRequest(_ context.Context, request *domain.AuthRequest) error { - return c.saveAuthRequest(request, "INSERT INTO auth.auth_requests (id, request, instance_id, creation_date, change_date, request_type) VALUES($1, $2, $3, $4, $4, $5)", request.CreationDate, request.Request.Type()) +func (c *AuthRequestCache) SaveAuthRequest(ctx context.Context, request *domain.AuthRequest) error { + return c.saveAuthRequest(ctx, request, "INSERT INTO auth.auth_requests (id, request, instance_id, creation_date, change_date, request_type) VALUES($1, $2, $3, $4, $4, $5)", request.CreationDate, request.Request.Type()) } -func (c *AuthRequestCache) UpdateAuthRequest(_ context.Context, request *domain.AuthRequest) error { +func (c *AuthRequestCache) UpdateAuthRequest(ctx context.Context, request *domain.AuthRequest) error { if request.ChangeDate.IsZero() { request.ChangeDate = time.Now() } - return c.saveAuthRequest(request, "UPDATE auth.auth_requests SET request = $2, instance_id = $3, change_date = $4, code = $5 WHERE id = $1", request.ChangeDate, request.Code) + return c.saveAuthRequest(ctx, request, "UPDATE auth.auth_requests SET request = $2, instance_id = $3, change_date = $4, code = $5 WHERE id = $1", request.ChangeDate, request.Code) } func (c *AuthRequestCache) DeleteAuthRequest(ctx context.Context, id string) error { @@ -52,14 +80,16 @@ func (c *AuthRequestCache) DeleteAuthRequest(ctx context.Context, id string) err if err != nil { return zerrors.ThrowInternal(err, "CACHE-dsHw3", "unable to delete auth request") } + c.deleteFromCache(ctx, id) return nil } -func (c *AuthRequestCache) getAuthRequest(key, value, instanceID string) (*domain.AuthRequest, error) { +func (c *AuthRequestCache) getAuthRequest(ctx context.Context, key, value, instanceID string) (*domain.AuthRequest, error) { var b []byte var requestType domain.AuthRequestType query := fmt.Sprintf("SELECT request, request_type FROM auth.auth_requests WHERE instance_id = $1 and %s = $2", key) - err := c.client.QueryRow( + err := c.client.QueryRowContext( + ctx, func(row *sql.Row) error { return row.Scan(&b, &requestType) }, @@ -81,7 +111,7 @@ func (c *AuthRequestCache) getAuthRequest(key, value, instanceID string) (*domai return request, nil } -func (c *AuthRequestCache) saveAuthRequest(request *domain.AuthRequest, query string, date time.Time, param interface{}) error { +func (c *AuthRequestCache) saveAuthRequest(ctx context.Context, request *domain.AuthRequest, query string, date time.Time, param interface{}) error { b, err := json.Marshal(request) if err != nil { return zerrors.ThrowInternal(err, "CACHE-os0GH", "Errors.Internal") @@ -90,5 +120,51 @@ func (c *AuthRequestCache) saveAuthRequest(request *domain.AuthRequest, query st if err != nil { return zerrors.ThrowInternal(err, "CACHE-su3GK", "Errors.Internal") } + c.CacheAuthRequest(ctx, request) return nil } + +func (c *AuthRequestCache) getCachedByID(ctx context.Context, id string) (*domain.AuthRequest, bool) { + if c.idCache == nil { + return nil, false + } + authRequest, ok := c.idCache.Get(cacheKey(ctx, id)) + logging.WithFields("hit", ok, "type", "id").Info("get from auth request cache") + return authRequest, ok +} + +func (c *AuthRequestCache) getCachedByCode(ctx context.Context, code string) (*domain.AuthRequest, bool) { + if c.codeCache == nil { + return nil, false + } + authRequest, ok := c.codeCache.Get(cacheKey(ctx, code)) + logging.WithFields("hit", ok, "type", "code").Info("get from auth request cache") + return authRequest, ok +} + +func (c *AuthRequestCache) CacheAuthRequest(ctx context.Context, request *domain.AuthRequest) { + if c.idCache == nil { + return + } + c.idCache.Add(cacheKey(ctx, request.ID), request) + if request.Code != "" { + c.codeCache.Add(cacheKey(ctx, request.Code), request) + } +} + +func cacheKey(ctx context.Context, value string) string { + return fmt.Sprintf("%s-%s", authz.GetInstance(ctx).InstanceID(), value) +} + +func (c *AuthRequestCache) deleteFromCache(ctx context.Context, id string) { + if c.idCache == nil { + return + } + idKey := cacheKey(ctx, id) + request, ok := c.idCache.Get(idKey) + if !ok { + return + } + c.idCache.Remove(idKey) + c.codeCache.Remove(cacheKey(ctx, request.Code)) +} diff --git a/internal/auth_request/repository/mock/repository.mock.go b/internal/auth_request/repository/mock/repository.mock.go index 773018b214..c05e5010fe 100644 --- a/internal/auth_request/repository/mock/repository.mock.go +++ b/internal/auth_request/repository/mock/repository.mock.go @@ -40,6 +40,18 @@ func (m *MockAuthRequestCache) EXPECT() *MockAuthRequestCacheMockRecorder { return m.recorder } +// CacheAuthRequest mocks base method. +func (m *MockAuthRequestCache) CacheAuthRequest(arg0 context.Context, arg1 *domain.AuthRequest) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "CacheAuthRequest", arg0, arg1) +} + +// CacheAuthRequest indicates an expected call of CacheAuthRequest. +func (mr *MockAuthRequestCacheMockRecorder) CacheAuthRequest(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CacheAuthRequest", reflect.TypeOf((*MockAuthRequestCache)(nil).CacheAuthRequest), arg0, arg1) +} + // DeleteAuthRequest mocks base method. func (m *MockAuthRequestCache) DeleteAuthRequest(arg0 context.Context, arg1 string) error { m.ctrl.T.Helper() diff --git a/internal/auth_request/repository/repository.go b/internal/auth_request/repository/repository.go index 5c850656be..af9e291fa6 100644 --- a/internal/auth_request/repository/repository.go +++ b/internal/auth_request/repository/repository.go @@ -12,6 +12,7 @@ type AuthRequestCache interface { GetAuthRequestByID(ctx context.Context, id string) (*domain.AuthRequest, error) GetAuthRequestByCode(ctx context.Context, code string) (*domain.AuthRequest, error) SaveAuthRequest(ctx context.Context, request *domain.AuthRequest) error + CacheAuthRequest(ctx context.Context, request *domain.AuthRequest) UpdateAuthRequest(ctx context.Context, request *domain.AuthRequest) error DeleteAuthRequest(ctx context.Context, id string) error } diff --git a/internal/database/database.go b/internal/database/database.go index e64645294b..77baaa7bd2 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -40,7 +40,7 @@ func (db *DB) Query(scan func(*sql.Rows) error, query string, args ...any) error func (db *DB) QueryContext(ctx context.Context, scan func(rows *sql.Rows) error, query string, args ...any) (err error) { ctx, spanBeginTx := tracing.NewNamedSpan(ctx, "db.BeginTx") - tx, err := db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + tx, err := db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true, Isolation: sql.LevelReadCommitted}) spanBeginTx.EndWithError(err) if err != nil { return err From ac985e2dfb43afb41ecf6c47a5bc04ce20042632 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Wed, 24 Apr 2024 10:44:55 +0200 Subject: [PATCH 23/31] fix(login): correctly reload policies on auth request (#7839) --- .../eventsourcing/eventstore/auth_request.go | 13 +++++++------ internal/auth_request/repository/cache/cache.go | 14 +++++++++----- internal/domain/auth_request.go | 10 ++++++++++ 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request.go b/internal/auth/repository/eventsourcing/eventstore/auth_request.go index ca95108205..eea29a167f 100644 --- a/internal/auth/repository/eventsourcing/eventstore/auth_request.go +++ b/internal/auth/repository/eventsourcing/eventstore/auth_request.go @@ -656,7 +656,7 @@ func (repo *AuthRequestRepo) fillPolicies(ctx context.Context, request *domain.A } } - if request.LoginPolicy == nil || len(request.AllowedExternalIDPs) == 0 { + if request.LoginPolicy == nil || len(request.AllowedExternalIDPs) == 0 || request.PolicyOrgID() != orgID { loginPolicy, idpProviders, err := repo.getLoginPolicyAndIDPProviders(ctx, orgID) if err != nil { return err @@ -666,21 +666,21 @@ func (repo *AuthRequestRepo) fillPolicies(ctx context.Context, request *domain.A request.AllowedExternalIDPs = idpProviders } } - if request.LockoutPolicy == nil { + if request.LockoutPolicy == nil || request.PolicyOrgID() != orgID { lockoutPolicy, err := repo.getLockoutPolicy(ctx, orgID) if err != nil { return err } request.LockoutPolicy = lockoutPolicyToDomain(lockoutPolicy) } - if request.PrivacyPolicy == nil { + if request.PrivacyPolicy == nil || request.PolicyOrgID() != orgID { privacyPolicy, err := repo.GetPrivacyPolicy(ctx, orgID) if err != nil { return err } request.PrivacyPolicy = privacyPolicy } - if request.LabelPolicy == nil { + if request.LabelPolicy == nil || request.PolicyOrgID() != orgID { labelPolicy, err := repo.getLabelPolicy(ctx, request.PrivateLabelingOrgID(orgID)) if err != nil { return err @@ -694,13 +694,14 @@ func (repo *AuthRequestRepo) fillPolicies(ctx context.Context, request *domain.A } request.DefaultTranslations = defaultLoginTranslations } - if len(request.OrgTranslations) == 0 { + if len(request.OrgTranslations) == 0 || request.PolicyOrgID() != orgID { orgLoginTranslations, err := repo.getLoginTexts(ctx, orgID) if err != nil { return err } request.OrgTranslations = orgLoginTranslations } + request.SetPolicyOrgID(orgID) repo.AuthRequests.CacheAuthRequest(ctx, request) return nil } @@ -887,7 +888,7 @@ func (repo *AuthRequestRepo) checkLoginNameInputForResourceOwner(ctx context.Con } func (repo *AuthRequestRepo) checkLoginPolicyWithResourceOwner(ctx context.Context, request *domain.AuthRequest, resourceOwner string) (err error) { - if request.LoginPolicy == nil { + if request.LoginPolicy == nil || request.PolicyOrgID() != resourceOwner { loginPolicy, idps, err := repo.getLoginPolicyAndIDPProviders(ctx, resourceOwner) if err != nil { return err diff --git a/internal/auth_request/repository/cache/cache.go b/internal/auth_request/repository/cache/cache.go index 9919d717de..a59f291d53 100644 --- a/internal/auth_request/repository/cache/cache.go +++ b/internal/auth_request/repository/cache/cache.go @@ -24,16 +24,20 @@ type AuthRequestCache struct { } func Start(dbClient *database.DB, amountOfCachedAuthRequests uint16) *AuthRequestCache { + cache := &AuthRequestCache{ + client: dbClient, + } idCache, err := lru.New[string, *domain.AuthRequest](int(amountOfCachedAuthRequests)) logging.OnError(err).Info("auth request cache disabled") + if err == nil { + cache.idCache = idCache + } codeCache, err := lru.New[string, *domain.AuthRequest](int(amountOfCachedAuthRequests)) logging.OnError(err).Info("auth request cache disabled") - - return &AuthRequestCache{ - client: dbClient, - idCache: idCache, - codeCache: codeCache, + if err == nil { + cache.codeCache = codeCache } + return cache } func (c *AuthRequestCache) Health(ctx context.Context) error { diff --git a/internal/domain/auth_request.go b/internal/domain/auth_request.go index cf406c7625..0a7704d691 100644 --- a/internal/domain/auth_request.go +++ b/internal/domain/auth_request.go @@ -56,6 +56,16 @@ type AuthRequest struct { DefaultTranslations []*CustomText OrgTranslations []*CustomText SAMLRequestID string + // orgID the policies were last loaded with + policyOrgID string +} + +func (a *AuthRequest) SetPolicyOrgID(id string) { + a.policyOrgID = id +} + +func (a *AuthRequest) PolicyOrgID() string { + return a.policyOrgID } type ExternalUser struct { From d016379e2a2383a994ce44491caafe5a76037e20 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Wed, 24 Apr 2024 17:50:58 +0200 Subject: [PATCH 24/31] feat: pass and handle auth request context for email links (#7815) * pass and handle auth request context * tests and cleanup * cleanup --- cmd/setup/config.go | 2 + internal/api/grpc/auth/email.go | 2 +- internal/api/grpc/management/user.go | 6 +- internal/api/ui/login/auth_request.go | 22 + .../api/ui/login/init_password_handler.go | 16 +- internal/api/ui/login/init_user_handler.go | 18 +- internal/api/ui/login/mail_verify_handler.go | 18 +- .../api/ui/login/password_reset_handler.go | 2 +- internal/api/ui/login/register_handler.go | 35 +- .../eventsourcing/eventstore/auth_request.go | 24 +- .../eventsourcing/handler/user_session.go | 63 +- internal/command/org_test.go | 1 + internal/command/user_human.go | 192 +-- internal/command/user_human_email.go | 9 +- internal/command/user_human_email_model.go | 2 + internal/command/user_human_email_test.go | 155 +- internal/command/user_human_init.go | 7 +- internal/command/user_human_init_model.go | 2 + internal/command/user_human_init_test.go | 195 ++- internal/command/user_human_password.go | 4 +- internal/command/user_human_password_test.go | 188 ++- internal/command/user_human_test.go | 1504 +---------------- internal/command/user_test.go | 1 + internal/command/user_v2_email_test.go | 12 +- internal/command/user_v2_human.go | 7 +- internal/command/user_v2_human_test.go | 9 + internal/command/user_v2_model_test.go | 3 + internal/command/user_v2_password_test.go | 9 +- internal/command/user_v2_test.go | 134 +- .../notification/handlers/user_notifier.go | 6 +- .../handlers/user_notifier_test.go | 137 +- .../types/email_verification_code.go | 4 +- .../types/email_verification_code_test.go | 34 +- internal/notification/types/init_code.go | 4 +- internal/notification/types/password_code.go | 4 +- internal/repository/user/human.go | 7 + internal/repository/user/human_email.go | 15 +- internal/repository/user/human_password.go | 16 +- 38 files changed, 851 insertions(+), 2018 deletions(-) diff --git a/cmd/setup/config.go b/cmd/setup/config.go index f5547d21ca..00ab64e2b9 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -18,6 +18,7 @@ import ( "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/config/hook" "github.com/zitadel/zitadel/internal/config/systemdefaults" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/id" @@ -69,6 +70,7 @@ func MustNewConfig(v *viper.Viper) *Config { hook.EnumHookFunc(authz.MemberTypeString), actions.HTTPConfigDecodeHook, hooks.MapTypeStringDecode[string, *authz.SystemAPIUser], + hooks.MapTypeStringDecode[string, crypto.HasherConfig], hooks.SliceTypeStringDecode[authz.RoleMapping], )), ) diff --git a/internal/api/grpc/auth/email.go b/internal/api/grpc/auth/email.go index 3e4f636c7f..c6153546e7 100644 --- a/internal/api/grpc/auth/email.go +++ b/internal/api/grpc/auth/email.go @@ -65,7 +65,7 @@ func (s *Server) ResendMyEmailVerification(ctx context.Context, _ *auth_pb.Resen if err != nil { return nil, err } - objectDetails, err := s.command.CreateHumanEmailVerificationCode(ctx, ctxData.UserID, ctxData.ResourceOwner, emailCodeGenerator) + objectDetails, err := s.command.CreateHumanEmailVerificationCode(ctx, ctxData.UserID, ctxData.ResourceOwner, emailCodeGenerator, "") if err != nil { return nil, err } diff --git a/internal/api/grpc/management/user.go b/internal/api/grpc/management/user.go index 6166bf4a5b..f662b8c592 100644 --- a/internal/api/grpc/management/user.go +++ b/internal/api/grpc/management/user.go @@ -473,7 +473,7 @@ func (s *Server) ResendHumanInitialization(ctx context.Context, req *mgmt_pb.Res if err != nil { return nil, err } - details, err := s.command.ResendInitialMail(ctx, req.UserId, domain.EmailAddress(req.Email), authz.GetCtxData(ctx).OrgID, initCodeGenerator) + details, err := s.command.ResendInitialMail(ctx, req.UserId, domain.EmailAddress(req.Email), authz.GetCtxData(ctx).OrgID, initCodeGenerator, "") if err != nil { return nil, err } @@ -487,7 +487,7 @@ func (s *Server) ResendHumanEmailVerification(ctx context.Context, req *mgmt_pb. if err != nil { return nil, err } - objectDetails, err := s.command.CreateHumanEmailVerificationCode(ctx, req.UserId, authz.GetCtxData(ctx).OrgID, emailCodeGenerator) + objectDetails, err := s.command.CreateHumanEmailVerificationCode(ctx, req.UserId, authz.GetCtxData(ctx).OrgID, emailCodeGenerator, "") if err != nil { return nil, err } @@ -590,7 +590,7 @@ func (s *Server) SendHumanResetPasswordNotification(ctx context.Context, req *mg if err != nil { return nil, err } - objectDetails, err := s.command.RequestSetPassword(ctx, req.UserId, authz.GetCtxData(ctx).OrgID, notifyTypeToDomain(req.Type), passwordCodeGenerator) + objectDetails, err := s.command.RequestSetPassword(ctx, req.UserId, authz.GetCtxData(ctx).OrgID, notifyTypeToDomain(req.Type), passwordCodeGenerator, "") if err != nil { return nil, err } diff --git a/internal/api/ui/login/auth_request.go b/internal/api/ui/login/auth_request.go index 15a39b0d83..f2dcce57da 100644 --- a/internal/api/ui/login/auth_request.go +++ b/internal/api/ui/login/auth_request.go @@ -3,6 +3,8 @@ package login import ( "net/http" + "github.com/zitadel/logging" + http_mw "github.com/zitadel/zitadel/internal/api/http/middleware" "github.com/zitadel/zitadel/internal/domain" ) @@ -33,3 +35,23 @@ func (l *Login) getAuthRequestAndParseData(r *http.Request, data interface{}) (* func (l *Login) getParseData(r *http.Request, data interface{}) error { return l.parser.Parse(r, data) } + +// checkOptionalAuthRequestOfEmailLinks tries to get the [domain.AuthRequest] from the request. +// In case any error occurs, e.g. if the user agent does not correspond, the `authRequestID` query parameter will be +// removed from the request URL and form to ensure subsequent functions and pages do not use it. +// This function is used for handling links in emails, which could possibly be opened on another device than the +// auth request was initiated. +func (l *Login) checkOptionalAuthRequestOfEmailLinks(r *http.Request) *domain.AuthRequest { + authReq, err := l.getAuthRequest(r) + if err == nil { + return authReq + } + logging.WithError(err).Infof("authrequest could not be found for email link on path %s", r.URL.RequestURI()) + queries := r.URL.Query() + queries.Del(QueryAuthRequestID) + r.URL.RawQuery = queries.Encode() + r.RequestURI = r.URL.RequestURI() + r.Form.Del(QueryAuthRequestID) + r.PostForm.Del(QueryAuthRequestID) + return nil +} diff --git a/internal/api/ui/login/init_password_handler.go b/internal/api/ui/login/init_password_handler.go index db2518c437..37fb9470c5 100644 --- a/internal/api/ui/login/init_password_handler.go +++ b/internal/api/ui/login/init_password_handler.go @@ -1,8 +1,8 @@ package login import ( - "fmt" "net/http" + "net/url" http_mw "github.com/zitadel/zitadel/internal/api/http/middleware" "github.com/zitadel/zitadel/internal/domain" @@ -38,14 +38,20 @@ type initPasswordData struct { HasSymbol string } -func InitPasswordLink(origin, userID, code, orgID string) string { - return fmt.Sprintf("%s%s?userID=%s&code=%s&orgID=%s", externalLink(origin), EndpointInitPassword, userID, code, orgID) +func InitPasswordLink(origin, userID, code, orgID, authRequestID string) string { + v := url.Values{} + v.Set(queryInitPWUserID, userID) + v.Set(queryInitPWCode, code) + v.Set(queryOrgID, orgID) + v.Set(QueryAuthRequestID, authRequestID) + return externalLink(origin) + EndpointInitPassword + "?" + v.Encode() } func (l *Login) handleInitPassword(w http.ResponseWriter, r *http.Request) { + authReq := l.checkOptionalAuthRequestOfEmailLinks(r) userID := r.FormValue(queryInitPWUserID) code := r.FormValue(queryInitPWCode) - l.renderInitPassword(w, r, nil, userID, code, nil) + l.renderInitPassword(w, r, authReq, userID, code, nil) } func (l *Login) handleInitPasswordCheck(w http.ResponseWriter, r *http.Request) { @@ -94,7 +100,7 @@ func (l *Login) resendPasswordSet(w http.ResponseWriter, r *http.Request, authRe l.renderInitPassword(w, r, authReq, userID, "", err) return } - _, err = l.command.RequestSetPassword(setContext(r.Context(), userOrg), userID, userOrg, domain.NotificationTypeEmail, passwordCodeGenerator) + _, err = l.command.RequestSetPassword(setContext(r.Context(), userOrg), userID, userOrg, domain.NotificationTypeEmail, passwordCodeGenerator, authReq.ID) l.renderInitPassword(w, r, authReq, userID, "", err) } diff --git a/internal/api/ui/login/init_user_handler.go b/internal/api/ui/login/init_user_handler.go index 119460a8ae..fe01447c4e 100644 --- a/internal/api/ui/login/init_user_handler.go +++ b/internal/api/ui/login/init_user_handler.go @@ -1,8 +1,8 @@ package login import ( - "fmt" "net/http" + "net/url" "strconv" http_mw "github.com/zitadel/zitadel/internal/api/http/middleware" @@ -44,16 +44,24 @@ type initUserData struct { HasSymbol string } -func InitUserLink(origin, userID, loginName, code, orgID string, passwordSet bool) string { - return fmt.Sprintf("%s%s?userID=%s&loginname=%s&code=%s&orgID=%s&passwordset=%t", externalLink(origin), EndpointInitUser, userID, loginName, code, orgID, passwordSet) +func InitUserLink(origin, userID, loginName, code, orgID string, passwordSet bool, authRequestID string) string { + v := url.Values{} + v.Set(queryInitUserUserID, userID) + v.Set(queryInitUserLoginName, loginName) + v.Set(queryInitUserCode, code) + v.Set(queryOrgID, orgID) + v.Set(queryInitUserPassword, strconv.FormatBool(passwordSet)) + v.Set(QueryAuthRequestID, authRequestID) + return externalLink(origin) + EndpointInitUser + "?" + v.Encode() } func (l *Login) handleInitUser(w http.ResponseWriter, r *http.Request) { + authReq := l.checkOptionalAuthRequestOfEmailLinks(r) userID := r.FormValue(queryInitUserUserID) code := r.FormValue(queryInitUserCode) loginName := r.FormValue(queryInitUserLoginName) passwordSet, _ := strconv.ParseBool(r.FormValue(queryInitUserPassword)) - l.renderInitUser(w, r, nil, userID, loginName, code, passwordSet, nil) + l.renderInitUser(w, r, authReq, userID, loginName, code, passwordSet, nil) } func (l *Login) handleInitUserCheck(w http.ResponseWriter, r *http.Request) { @@ -105,7 +113,7 @@ func (l *Login) resendUserInit(w http.ResponseWriter, r *http.Request, authReq * l.renderInitUser(w, r, authReq, userID, loginName, "", showPassword, err) return } - _, err = l.command.ResendInitialMail(setContext(r.Context(), userOrgID), userID, "", userOrgID, initCodeGenerator) + _, err = l.command.ResendInitialMail(setContext(r.Context(), userOrgID), userID, "", userOrgID, initCodeGenerator, authReq.ID) l.renderInitUser(w, r, authReq, userID, loginName, "", showPassword, err) } diff --git a/internal/api/ui/login/mail_verify_handler.go b/internal/api/ui/login/mail_verify_handler.go index 50f03df811..327b8a1182 100644 --- a/internal/api/ui/login/mail_verify_handler.go +++ b/internal/api/ui/login/mail_verify_handler.go @@ -1,8 +1,8 @@ package login import ( - "fmt" "net/http" + "net/url" "github.com/zitadel/zitadel/internal/domain" ) @@ -27,18 +27,24 @@ type mailVerificationData struct { UserID string } -func MailVerificationLink(origin, userID, code, orgID string) string { - return fmt.Sprintf("%s%s?userID=%s&code=%s&orgID=%s", externalLink(origin), EndpointMailVerification, userID, code, orgID) +func MailVerificationLink(origin, userID, code, orgID, authRequestID string) string { + v := url.Values{} + v.Set(queryUserID, userID) + v.Set(queryCode, code) + v.Set(queryOrgID, orgID) + v.Set(QueryAuthRequestID, authRequestID) + return externalLink(origin) + EndpointMailVerification + "?" + v.Encode() } func (l *Login) handleMailVerification(w http.ResponseWriter, r *http.Request) { + authReq := l.checkOptionalAuthRequestOfEmailLinks(r) userID := r.FormValue(queryUserID) code := r.FormValue(queryCode) if code != "" { - l.checkMailCode(w, r, nil, userID, code) + l.checkMailCode(w, r, authReq, userID, code) return } - l.renderMailVerification(w, r, nil, userID, nil) + l.renderMailVerification(w, r, authReq, userID, nil) } func (l *Login) handleMailVerificationCheck(w http.ResponseWriter, r *http.Request) { @@ -61,7 +67,7 @@ func (l *Login) handleMailVerificationCheck(w http.ResponseWriter, r *http.Reque l.checkMailCode(w, r, authReq, data.UserID, data.Code) return } - _, err = l.command.CreateHumanEmailVerificationCode(setContext(r.Context(), userOrg), data.UserID, userOrg, emailCodeGenerator) + _, err = l.command.CreateHumanEmailVerificationCode(setContext(r.Context(), userOrg), data.UserID, userOrg, emailCodeGenerator, authReq.ID) l.renderMailVerification(w, r, authReq, data.UserID, err) } diff --git a/internal/api/ui/login/password_reset_handler.go b/internal/api/ui/login/password_reset_handler.go index ad5782db51..ff57321e92 100644 --- a/internal/api/ui/login/password_reset_handler.go +++ b/internal/api/ui/login/password_reset_handler.go @@ -33,7 +33,7 @@ func (l *Login) handlePasswordReset(w http.ResponseWriter, r *http.Request) { l.renderPasswordResetDone(w, r, authReq, err) return } - _, err = l.command.RequestSetPassword(setContext(r.Context(), authReq.UserOrgID), user.ID, authReq.UserOrgID, domain.NotificationTypeEmail, passwordCodeGenerator) + _, err = l.command.RequestSetPassword(setContext(r.Context(), authReq.UserOrgID), user.ID, authReq.UserOrgID, domain.NotificationTypeEmail, passwordCodeGenerator, authReq.ID) l.renderPasswordResetDone(w, r, authReq, err) } diff --git a/internal/api/ui/login/register_handler.go b/internal/api/ui/login/register_handler.go index 8fed0d46e7..d487903a85 100644 --- a/internal/api/ui/login/register_handler.go +++ b/internal/api/ui/login/register_handler.go @@ -7,6 +7,7 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" http_mw "github.com/zitadel/zitadel/internal/api/http/middleware" + "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -67,22 +68,6 @@ func (l *Login) handleRegisterCheck(w http.ResponseWriter, r *http.Request) { if authRequest != nil && authRequest.RequestedOrgID != "" && authRequest.RequestedOrgID != resourceOwner { resourceOwner = authRequest.RequestedOrgID } - initCodeGenerator, err := l.query.InitEncryptionGenerator(r.Context(), domain.SecretGeneratorTypeInitCode, l.userCodeAlg) - if err != nil { - l.renderRegister(w, r, authRequest, data, err) - return - } - emailCodeGenerator, err := l.query.InitEncryptionGenerator(r.Context(), domain.SecretGeneratorTypeVerifyEmailCode, l.userCodeAlg) - if err != nil { - l.renderRegister(w, r, authRequest, data, err) - return - } - phoneCodeGenerator, err := l.query.InitEncryptionGenerator(r.Context(), domain.SecretGeneratorTypeVerifyPhoneCode, l.userCodeAlg) - if err != nil { - l.renderRegister(w, r, authRequest, data, err) - return - } - // For consistency with the external authentication flow, // the setMetadata() function is provided on the pre creation hook, for now, // like for the ExternalAuthentication flow. @@ -96,22 +81,14 @@ func (l *Login) handleRegisterCheck(w http.ResponseWriter, r *http.Request) { l.renderRegister(w, r, authRequest, data, err) return } - user, err = l.command.RegisterHuman(setContext(r.Context(), resourceOwner), resourceOwner, user, nil, nil, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator) + + human := command.AddHumanFromDomain(user, metadatas, authRequest, nil) + err = l.command.AddUserHuman(setContext(r.Context(), resourceOwner), resourceOwner, human, true, l.userCodeAlg) if err != nil { l.renderRegister(w, r, authRequest, data, err) return } - - if len(metadatas) > 0 { - _, err = l.command.BulkSetUserMetadata(r.Context(), user.AggregateID, resourceOwner, metadatas...) - if err != nil { - // TODO: What if action is configured to be allowed to fail? Same question for external registration. - l.renderRegister(w, r, authRequest, data, err) - return - } - } - - userGrants, err := l.runPostCreationActions(user.AggregateID, authRequest, r, resourceOwner, domain.FlowTypeInternalAuthentication) + userGrants, err := l.runPostCreationActions(human.ID, authRequest, r, resourceOwner, domain.FlowTypeInternalAuthentication) if err != nil { l.renderError(w, r, authRequest, err) return @@ -128,7 +105,7 @@ func (l *Login) handleRegisterCheck(w http.ResponseWriter, r *http.Request) { return } userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context()) - err = l.authRepo.SelectUser(r.Context(), authRequest.ID, user.AggregateID, userAgentID) + err = l.authRepo.SelectUser(r.Context(), authRequest.ID, human.ID, userAgentID) if err != nil { l.renderRegister(w, r, authRequest, data, err) return diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request.go b/internal/auth/repository/eventsourcing/eventstore/auth_request.go index eea29a167f..1d0446249d 100644 --- a/internal/auth/repository/eventsourcing/eventstore/auth_request.go +++ b/internal/auth/repository/eventsourcing/eventstore/auth_request.go @@ -543,23 +543,19 @@ func (repo *AuthRequestRepo) AutoRegisterExternalUser(ctx context.Context, regis if err != nil { return err } - initCodeGenerator, err := repo.Query.InitEncryptionGenerator(ctx, domain.SecretGeneratorTypeInitCode, repo.UserCodeAlg) + addMetadata := make([]*command.AddMetadataEntry, len(metadatas)) + for i, metadata := range metadatas { + addMetadata[i] = &command.AddMetadataEntry{ + Key: metadata.Key, + Value: metadata.Value, + } + } + human := command.AddHumanFromDomain(registerUser, metadatas, request, externalIDP) + err = repo.Command.AddUserHuman(ctx, resourceOwner, human, true, repo.UserCodeAlg) if err != nil { return err } - emailCodeGenerator, err := repo.Query.InitEncryptionGenerator(ctx, domain.SecretGeneratorTypeVerifyEmailCode, repo.UserCodeAlg) - if err != nil { - return err - } - phoneCodeGenerator, err := repo.Query.InitEncryptionGenerator(ctx, domain.SecretGeneratorTypeVerifyPhoneCode, repo.UserCodeAlg) - if err != nil { - return err - } - human, err := repo.Command.RegisterHuman(ctx, resourceOwner, registerUser, externalIDP, orgMemberRoles, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator) - if err != nil { - return err - } - request.SetUserInfo(human.AggregateID, human.Username, human.PreferredLoginName, human.DisplayName, "", human.ResourceOwner) + request.SetUserInfo(human.ID, human.Username, human.Username, human.DisplayName, "", resourceOwner) request.SelectedIDPConfigID = externalIDP.IDPConfigID request.LinkingUsers = nil err = repo.Command.UserIDPLoginChecked(ctx, request.UserOrgID, request.UserID, request.WithCurrentInfo(info)) diff --git a/internal/auth/repository/eventsourcing/handler/user_session.go b/internal/auth/repository/eventsourcing/handler/user_session.go index 12cb98f9db..fc22856846 100644 --- a/internal/auth/repository/eventsourcing/handler/user_session.go +++ b/internal/auth/repository/eventsourcing/handler/user_session.go @@ -160,6 +160,10 @@ func (s *UserSession) Reducers() []handler.AggregateReducer { Event: user.UserRemovedType, Reduce: s.Reduce, }, + { + Event: user.HumanRegisteredType, + Reduce: s.Reduce, + }, }, }, { @@ -234,6 +238,8 @@ func (u *UserSession) Reduce(event eventstore.Event) (_ *handler.Statement, err handler.NewCol("multi_factor_verification_type", domain.MFALevelNotSetUp), handler.NewCol("external_login_verification", time.Time{}), handler.NewCol("state", domain.UserSessionStateTerminated), + handler.NewCol("change_date", event.CreatedAt()), + handler.NewCol("sequence", event.Sequence()), }, []handler.Condition{ handler.NewCond("instance_id", event.Aggregate().InstanceID), @@ -247,16 +253,30 @@ func (u *UserSession) Reduce(event eventstore.Event) (_ *handler.Statement, err if err != nil { return nil, err } - return handler.NewUpdateStatement(event, - []handler.Column{ - handler.NewCol("password_verification", time.Time{}), - }, - []handler.Condition{ - handler.NewCond("instance_id", event.Aggregate().InstanceID), - handler.NewCond("user_id", event.Aggregate().ID), - handler.Not(handler.NewCond("user_agent_id", userAgent)), - handler.Not(handler.NewCond("state", domain.UserSessionStateTerminated)), - }, + return handler.NewMultiStatement(event, + handler.AddUpdateStatement( + []handler.Column{ + handler.NewCol("password_verification", event.CreatedAt()), + handler.NewCol("change_date", event.CreatedAt()), + handler.NewCol("sequence", event.Sequence()), + }, + []handler.Condition{ + handler.NewCond("instance_id", event.Aggregate().InstanceID), + handler.NewCond("user_id", event.Aggregate().ID), + handler.NewCond("user_agent_id", userAgent), + }), + handler.AddUpdateStatement( + []handler.Column{ + handler.NewCol("password_verification", time.Time{}), + handler.NewCol("change_date", event.CreatedAt()), + handler.NewCol("sequence", event.Sequence()), + }, + []handler.Condition{ + handler.NewCond("instance_id", event.Aggregate().InstanceID), + handler.NewCond("user_id", event.Aggregate().ID), + handler.Not(handler.NewCond("user_agent_id", userAgent)), + handler.Not(handler.NewCond("state", domain.UserSessionStateTerminated)), + }), ), nil case user.UserV1MFAOTPRemovedType, user.HumanMFAOTPRemovedType, @@ -264,6 +284,8 @@ func (u *UserSession) Reduce(event eventstore.Event) (_ *handler.Statement, err return handler.NewUpdateStatement(event, []handler.Column{ handler.NewCol("second_factor_verification", time.Time{}), + handler.NewCol("change_date", event.CreatedAt()), + handler.NewCol("sequence", event.Sequence()), }, []handler.Condition{ handler.NewCond("instance_id", event.Aggregate().InstanceID), @@ -277,6 +299,8 @@ func (u *UserSession) Reduce(event eventstore.Event) (_ *handler.Statement, err []handler.Column{ handler.NewCol("external_login_verification", time.Time{}), handler.NewCol("selected_idp_config_id", ""), + handler.NewCol("change_date", event.CreatedAt()), + handler.NewCol("sequence", event.Sequence()), }, []handler.Condition{ handler.NewCond("instance_id", event.Aggregate().InstanceID), @@ -289,6 +313,8 @@ func (u *UserSession) Reduce(event eventstore.Event) (_ *handler.Statement, err []handler.Column{ handler.NewCol("passwordless_verification", time.Time{}), handler.NewCol("multi_factor_verification", time.Time{}), + handler.NewCol("change_date", event.CreatedAt()), + handler.NewCol("sequence", event.Sequence()), }, []handler.Condition{ handler.NewCond("instance_id", event.Aggregate().InstanceID), @@ -300,6 +326,23 @@ func (u *UserSession) Reduce(event eventstore.Event) (_ *handler.Statement, err return handler.NewStatement(event, func(ex handler.Executer, projectionName string) error { return u.view.DeleteUserSessions(event.Aggregate().ID, event.Aggregate().InstanceID) }), nil + case user.HumanRegisteredType: + return handler.NewStatement(event, func(ex handler.Executer, projectionName string) error { + eventData, err := view_model.UserSessionFromEvent(event) + if err != nil { + return err + } + session := &view_model.UserSessionView{ + CreationDate: event.CreatedAt(), + ResourceOwner: event.Aggregate().ResourceOwner, + UserAgentID: eventData.UserAgentID, + UserID: event.Aggregate().ID, + State: int32(domain.UserSessionStateActive), + InstanceID: event.Aggregate().InstanceID, + PasswordVerification: event.CreatedAt(), + } + return u.updateSession(session, event) + }), nil case instance.InstanceRemovedEventType: return handler.NewStatement(event, func(ex handler.Executer, projectionName string) error { return u.view.DeleteInstanceUserSessions(event.Aggregate().InstanceID) diff --git a/internal/command/org_test.go b/internal/command/org_test.go index 919c86b80f..5c69034b5c 100644 --- a/internal/command/org_test.go +++ b/internal/command/org_test.go @@ -1427,6 +1427,7 @@ func TestCommandSide_SetUpOrg(t *testing.T) { Crypted: []byte("userinit"), }, 1*time.Hour, + "", ), ), eventFromEventPusher(org.NewMemberAddedEvent(context.Background(), diff --git a/internal/command/user_human.go b/internal/command/user_human.go index a0cfff5bd3..bfceb6ffcc 100644 --- a/internal/command/user_human.go +++ b/internal/command/user_human.go @@ -10,7 +10,6 @@ import ( "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" - "github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/zerrors" @@ -57,7 +56,13 @@ type AddHuman struct { Passwordless bool ExternalIDP bool Register bool - Metadata []*AddMetadataEntry + // UserAgentID is optional and can be passed in case the user registered themselves. + // This will be used in the login UI to handle authentication automatically. + UserAgentID string + // AuthRequestID is optional and can be passed in case the user registered themselves. + // This will be used to pass the information in notifications for links to the login UI. + AuthRequestID string + Metadata []*AddMetadataEntry // Links are optional Links []*AddLink @@ -200,6 +205,7 @@ func (c *Commands) AddHumanCommand(human *AddHuman, orgID string, hasher *crypto human.Gender, human.Email.Address, domainPolicy.UserLoginMustBeDomain, + "", // no user agent id available ) } else { createCmd = user.NewHumanAddedEvent( @@ -272,7 +278,7 @@ func (c *Commands) addHumanCommandEmail(ctx context.Context, filter preparation. if err != nil { return nil, err } - return append(cmds, user.NewHumanInitialCodeAddedEvent(ctx, &a.Aggregate, initCode.Crypted, initCode.Expiry)), nil + return append(cmds, user.NewHumanInitialCodeAddedEvent(ctx, &a.Aggregate, initCode.Crypted, initCode.Expiry, human.AuthRequestID)), nil } if !human.Email.Verified { emailCode, err := c.newEmailCode(ctx, filter, codeAlg) @@ -460,61 +466,6 @@ func (c *Commands) ImportHuman(ctx context.Context, orgID string, human *domain. return writeModelToHuman(addedHuman), passwordlessCode, nil } -// Deprecated: use commands.AddUserHuman -func (c *Commands) RegisterHuman(ctx context.Context, orgID string, human *domain.Human, link *domain.UserIDPLink, orgMemberRoles []string, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator crypto.Generator) (*domain.Human, error) { - if orgID == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-GEdf2", "Errors.ResourceOwnerMissing") - } - domainPolicy, err := c.getOrgDomainPolicy(ctx, orgID) - if err != nil { - return nil, zerrors.ThrowPreconditionFailed(err, "COMMAND-33M9f", "Errors.Org.DomainPolicy.NotFound") - } - pwPolicy, err := c.getOrgPasswordComplexityPolicy(ctx, orgID) - if err != nil { - return nil, zerrors.ThrowPreconditionFailed(err, "COMMAND-M5Fsd", "Errors.Org.PasswordComplexityPolicy.NotFound") - } - loginPolicy, err := c.getOrgLoginPolicy(ctx, orgID) - if err != nil { - return nil, zerrors.ThrowPreconditionFailed(err, "COMMAND-Dfg3g", "Errors.Org.LoginPolicy.NotFound") - } - // check only if local registration is allowed, the idp will be checked separately - if !loginPolicy.AllowRegister && link == nil { - return nil, zerrors.ThrowPreconditionFailed(err, "COMMAND-SAbr3", "Errors.Org.LoginPolicy.RegistrationNotAllowed") - } - userEvents, registeredHuman, err := c.registerHuman(ctx, orgID, human, link, domainPolicy, pwPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator) - if err != nil { - return nil, err - } - - orgMemberWriteModel := NewOrgMemberWriteModel(orgID, registeredHuman.AggregateID) - orgAgg := OrgAggregateFromWriteModel(&orgMemberWriteModel.WriteModel) - if len(orgMemberRoles) > 0 { - orgMember := &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: orgID, - }, - UserID: human.AggregateID, - Roles: orgMemberRoles, - } - memberEvent, err := c.addOrgMember(ctx, orgAgg, orgMemberWriteModel, orgMember) - if err != nil { - return nil, err - } - userEvents = append(userEvents, memberEvent) - } - - pushedEvents, err := c.eventstore.Push(ctx, userEvents...) - if err != nil { - return nil, err - } - - err = AppendAndReduce(registeredHuman, pushedEvents...) - if err != nil { - return nil, err - } - return writeModelToHuman(registeredHuman), nil -} - func (c *Commands) importHuman(ctx context.Context, orgID string, human *domain.Human, passwordless bool, links []*domain.UserIDPLink, domainPolicy *domain.DomainPolicy, pwPolicy *domain.PasswordComplexityPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator, passwordlessCodeGenerator crypto.Generator) (events []eventstore.Command, humanWriteModel *HumanWriteModel, passwordlessCodeWriteModel *HumanPasswordlessInitCodeWriteModel, code string, err error) { if orgID == "" { return nil, nil, nil, "", zerrors.ThrowInvalidArgument(nil, "COMMAND-00p2b", "Errors.Org.Empty") @@ -522,7 +473,7 @@ func (c *Commands) importHuman(ctx context.Context, orgID string, human *domain. if err := human.Normalize(); err != nil { return nil, nil, nil, "", err } - events, humanWriteModel, err = c.createHuman(ctx, orgID, human, links, false, passwordless, domainPolicy, pwPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator) + events, humanWriteModel, err = c.createHuman(ctx, orgID, human, links, passwordless, domainPolicy, pwPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator) if err != nil { return nil, nil, nil, "", err } @@ -537,33 +488,8 @@ func (c *Commands) importHuman(ctx context.Context, orgID string, human *domain. return events, humanWriteModel, passwordlessCodeWriteModel, code, nil } -func (c *Commands) registerHuman(ctx context.Context, orgID string, human *domain.Human, link *domain.UserIDPLink, domainPolicy *domain.DomainPolicy, pwPolicy *domain.PasswordComplexityPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator crypto.Generator) ([]eventstore.Command, *HumanWriteModel, error) { - if human == nil { - return nil, nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-JKefw", "Errors.User.Invalid") - } - if human.Username = strings.TrimSpace(human.Username); human.Username == "" { - human.Username = string(human.EmailAddress) - } - if orgID == "" { - return nil, nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-hYsVH", "Errors.Org.Empty") - } - if err := human.Normalize(); err != nil { - return nil, nil, err - } - if link == nil && (human.Password == nil || human.Password.SecretString == "") { - return nil, nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-X23na", "Errors.User.Password.Empty") - } - if human.Password != nil && human.Password.SecretString != "" { - human.Password.ChangeRequired = false - } - var links []*domain.UserIDPLink - if link != nil { - links = append(links, link) - } - return c.createHuman(ctx, orgID, human, links, true, false, domainPolicy, pwPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator) -} - -func (c *Commands) createHuman(ctx context.Context, orgID string, human *domain.Human, links []*domain.UserIDPLink, selfregister, passwordless bool, domainPolicy *domain.DomainPolicy, pwPolicy *domain.PasswordComplexityPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator crypto.Generator) (events []eventstore.Command, addedHuman *HumanWriteModel, err error) { +//nolint:gocognit +func (c *Commands) createHuman(ctx context.Context, orgID string, human *domain.Human, links []*domain.UserIDPLink, passwordless bool, domainPolicy *domain.DomainPolicy, pwPolicy *domain.PasswordComplexityPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator crypto.Generator) (events []eventstore.Command, addedHuman *HumanWriteModel, err error) { if err := human.CheckDomainPolicy(domainPolicy); err != nil { return nil, nil, err } @@ -601,11 +527,7 @@ func (c *Commands) createHuman(ctx context.Context, orgID string, human *domain. //TODO: adlerhurst maybe we could simplify the code below userAgg := UserAggregateFromWriteModel(&addedHuman.WriteModel) - if selfregister { - events = append(events, createRegisterHumanEvent(ctx, userAgg, human, domainPolicy.UserLoginMustBeDomain)) - } else { - events = append(events, createAddHumanEvent(ctx, userAgg, human, domainPolicy.UserLoginMustBeDomain)) - } + events = append(events, createAddHumanEvent(ctx, userAgg, human, domainPolicy.UserLoginMustBeDomain)) for _, link := range links { event, err := c.addUserIDPLink(ctx, userAgg, link, false) @@ -620,7 +542,7 @@ func (c *Commands) createHuman(ctx context.Context, orgID string, human *domain. if err != nil { return nil, nil, err } - events = append(events, user.NewHumanInitialCodeAddedEvent(ctx, userAgg, initCode.Code, initCode.Expiry)) + events = append(events, user.NewHumanInitialCodeAddedEvent(ctx, userAgg, initCode.Code, initCode.Expiry, "")) } else { if human.Email != nil && human.EmailAddress != "" && human.IsEmailVerified { events = append(events, user.NewHumanEmailVerifiedEvent(ctx, userAgg)) @@ -629,7 +551,7 @@ func (c *Commands) createHuman(ctx context.Context, orgID string, human *domain. if err != nil { return nil, nil, err } - events = append(events, user.NewHumanEmailCodeAddedEvent(ctx, userAgg, emailCode.Code, emailCode.Expiry)) + events = append(events, user.NewHumanEmailCodeAddedEvent(ctx, userAgg, emailCode.Code, emailCode.Expiry, "")) } } @@ -699,40 +621,6 @@ func createAddHumanEvent(ctx context.Context, aggregate *eventstore.Aggregate, h return addEvent } -func createRegisterHumanEvent(ctx context.Context, aggregate *eventstore.Aggregate, human *domain.Human, userLoginMustBeDomain bool) *user.HumanRegisteredEvent { - addEvent := user.NewHumanRegisteredEvent( - ctx, - aggregate, - human.Username, - human.FirstName, - human.LastName, - human.NickName, - human.DisplayName, - human.PreferredLanguage, - human.Gender, - human.EmailAddress, - userLoginMustBeDomain, - ) - if human.Phone != nil { - addEvent.AddPhoneData(human.PhoneNumber) - } - if human.Address != nil { - addEvent.AddAddressData( - human.Country, - human.Locality, - human.PostalCode, - human.Region, - human.StreetAddress) - } - if human.Password != nil { - addEvent.AddPasswordData(human.Password.EncodedSecret, human.Password.ChangeRequired) - } - if human.HashedPassword != "" { - addEvent.AddPasswordData(human.HashedPassword, false) - } - return addEvent -} - func (c *Commands) HumansSignOut(ctx context.Context, agentID string, userIDs []string) error { if agentID == "" { return zerrors.ThrowInvalidArgument(nil, "COMMAND-2M0ds", "Errors.User.UserIDMissing") @@ -783,3 +671,53 @@ func humanWriteModelByID(ctx context.Context, filter preparation.FilterToQueryRe err = humanWriteModel.Reduce() return humanWriteModel, err } + +func AddHumanFromDomain(user *domain.Human, metadataList []*domain.Metadata, authRequest *domain.AuthRequest, idp *domain.UserIDPLink) *AddHuman { + addMetadata := make([]*AddMetadataEntry, len(metadataList)) + for i, metadata := range metadataList { + addMetadata[i] = &AddMetadataEntry{ + Key: metadata.Key, + Value: metadata.Value, + } + } + human := new(AddHuman) + if user.Profile != nil { + human.Username = user.Username + human.FirstName = user.FirstName + human.LastName = user.LastName + human.NickName = user.NickName + human.DisplayName = user.DisplayName + human.PreferredLanguage = user.PreferredLanguage + human.Gender = user.Gender + human.Password = user.Password.SecretString + human.Register = true + human.Metadata = addMetadata + human.UserAgentID = authRequest.AgentID + human.AuthRequestID = authRequest.ID + } + if user.Email != nil { + human.Email = Email{ + Address: user.EmailAddress, + Verified: user.IsEmailVerified, + } + } + if user.Phone != nil { + human.Phone = Phone{ + Number: user.Phone.PhoneNumber, + Verified: user.Phone.IsPhoneVerified, + } + } + if idp != nil { + human.Links = []*AddLink{ + { + IDPID: idp.IDPConfigID, + DisplayName: idp.DisplayName, + IDPExternalID: idp.ExternalUserID, + }, + } + } + if human.Username = strings.TrimSpace(human.Username); human.Username == "" { + human.Username = string(human.Email.Address) + } + return human +} diff --git a/internal/command/user_human_email.go b/internal/command/user_human_email.go index 4607b1294e..18bdc1caad 100644 --- a/internal/command/user_human_email.go +++ b/internal/command/user_human_email.go @@ -50,7 +50,7 @@ func (c *Commands) ChangeHumanEmail(ctx context.Context, email *domain.Email, em if err != nil { return nil, err } - events = append(events, user.NewHumanEmailCodeAddedEvent(ctx, userAgg, emailCode.Code, emailCode.Expiry)) + events = append(events, user.NewHumanEmailCodeAddedEvent(ctx, userAgg, emailCode.Code, emailCode.Expiry, "")) } pushedEvents, err := c.eventstore.Push(ctx, events...) @@ -99,7 +99,7 @@ func (c *Commands) VerifyHumanEmail(ctx context.Context, userID, code, resourceo return nil, zerrors.ThrowInvalidArgument(err, "COMMAND-Gdsgs", "Errors.User.Code.Invalid") } -func (c *Commands) CreateHumanEmailVerificationCode(ctx context.Context, userID, resourceOwner string, emailCodeGenerator crypto.Generator) (*domain.ObjectDetails, error) { +func (c *Commands) CreateHumanEmailVerificationCode(ctx context.Context, userID, resourceOwner string, emailCodeGenerator crypto.Generator, authRequestID string) (*domain.ObjectDetails, error) { if userID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-4M0ds", "Errors.User.UserIDMissing") } @@ -122,7 +122,10 @@ func (c *Commands) CreateHumanEmailVerificationCode(ctx context.Context, userID, if err != nil { return nil, err } - pushedEvents, err := c.eventstore.Push(ctx, user.NewHumanEmailCodeAddedEvent(ctx, userAgg, emailCode.Code, emailCode.Expiry)) + if authRequestID == "" { + authRequestID = existingEmail.AuthRequestID + } + pushedEvents, err := c.eventstore.Push(ctx, user.NewHumanEmailCodeAddedEvent(ctx, userAgg, emailCode.Code, emailCode.Expiry, authRequestID)) if err != nil { return nil, err } diff --git a/internal/command/user_human_email_model.go b/internal/command/user_human_email_model.go index d05881a79a..b60eef5d2b 100644 --- a/internal/command/user_human_email_model.go +++ b/internal/command/user_human_email_model.go @@ -19,6 +19,7 @@ type HumanEmailWriteModel struct { Code *crypto.CryptoValue CodeCreationDate time.Time CodeExpiry time.Duration + AuthRequestID string UserState domain.UserState } @@ -53,6 +54,7 @@ func (wm *HumanEmailWriteModel) Reduce() error { wm.Code = e.Code wm.CodeCreationDate = e.CreationDate() wm.CodeExpiry = e.Expiry + wm.AuthRequestID = e.AuthRequestID case *user.HumanEmailVerifiedEvent: wm.IsEmailVerified = true wm.Code = nil diff --git a/internal/command/user_human_email_test.go b/internal/command/user_human_email_test.go index 7abd65efec..53b95ac802 100644 --- a/internal/command/user_human_email_test.go +++ b/internal/command/user_human_email_test.go @@ -18,7 +18,7 @@ import ( func TestCommandSide_ChangeHumanEmail(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -39,9 +39,7 @@ func TestCommandSide_ChangeHumanEmail(t *testing.T) { { name: "invalid email, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -59,8 +57,7 @@ func TestCommandSide_ChangeHumanEmail(t *testing.T) { { name: "user not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -81,8 +78,7 @@ func TestCommandSide_ChangeHumanEmail(t *testing.T) { { name: "user not initialized, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -102,6 +98,7 @@ func TestCommandSide_ChangeHumanEmail(t *testing.T) { user.NewHumanInitialCodeAddedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, nil, time.Hour*1, + "", ), ), ), @@ -124,8 +121,7 @@ func TestCommandSide_ChangeHumanEmail(t *testing.T) { { name: "email not changed, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -161,8 +157,7 @@ func TestCommandSide_ChangeHumanEmail(t *testing.T) { { name: "verified email changed, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -215,8 +210,7 @@ func TestCommandSide_ChangeHumanEmail(t *testing.T) { { name: "email verified, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -265,8 +259,7 @@ func TestCommandSide_ChangeHumanEmail(t *testing.T) { { name: "email verified, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -315,8 +308,7 @@ func TestCommandSide_ChangeHumanEmail(t *testing.T) { { name: "email changed with code, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -347,6 +339,7 @@ func TestCommandSide_ChangeHumanEmail(t *testing.T) { Crypted: []byte("a"), }, time.Hour*1, + "", ), ), ), @@ -376,7 +369,7 @@ func TestCommandSide_ChangeHumanEmail(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } got, err := r.ChangeHumanEmail(tt.args.ctx, tt.args.email, tt.args.secretGenerator) if tt.res.err == nil { @@ -394,7 +387,7 @@ func TestCommandSide_ChangeHumanEmail(t *testing.T) { func TestCommandSide_VerifyHumanEmail(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -416,9 +409,7 @@ func TestCommandSide_VerifyHumanEmail(t *testing.T) { { name: "userid missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -432,9 +423,7 @@ func TestCommandSide_VerifyHumanEmail(t *testing.T) { { name: "code missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -448,8 +437,7 @@ func TestCommandSide_VerifyHumanEmail(t *testing.T) { { name: "user not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -466,8 +454,7 @@ func TestCommandSide_VerifyHumanEmail(t *testing.T) { { name: "code not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -499,8 +486,7 @@ func TestCommandSide_VerifyHumanEmail(t *testing.T) { { name: "invalid code, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -526,6 +512,7 @@ func TestCommandSide_VerifyHumanEmail(t *testing.T) { Crypted: []byte("a"), }, time.Hour*1, + "", ), ), ), @@ -550,8 +537,7 @@ func TestCommandSide_VerifyHumanEmail(t *testing.T) { { name: "valid code, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -577,6 +563,7 @@ func TestCommandSide_VerifyHumanEmail(t *testing.T) { Crypted: []byte("a"), }, time.Hour*1, + "", ), ), ), @@ -604,7 +591,7 @@ func TestCommandSide_VerifyHumanEmail(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } got, err := r.VerifyHumanEmail(tt.args.ctx, tt.args.userID, tt.args.code, tt.args.resourceOwner, tt.args.secretGenerator) if tt.res.err == nil { @@ -622,13 +609,14 @@ func TestCommandSide_VerifyHumanEmail(t *testing.T) { func TestCommandSide_CreateVerificationCodeHumanEmail(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { ctx context.Context userID string resourceOwner string secretGenerator crypto.Generator + authRequestID string } type res struct { want *domain.ObjectDetails @@ -643,9 +631,7 @@ func TestCommandSide_CreateVerificationCodeHumanEmail(t *testing.T) { { name: "userid missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -658,8 +644,7 @@ func TestCommandSide_CreateVerificationCodeHumanEmail(t *testing.T) { { name: "user not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -675,8 +660,7 @@ func TestCommandSide_CreateVerificationCodeHumanEmail(t *testing.T) { { name: "user not initialized, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -696,6 +680,7 @@ func TestCommandSide_CreateVerificationCodeHumanEmail(t *testing.T) { user.NewHumanInitialCodeAddedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, nil, time.Hour*1, + "", ), ), ), @@ -713,8 +698,7 @@ func TestCommandSide_CreateVerificationCodeHumanEmail(t *testing.T) { { name: "email already verified, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -750,8 +734,7 @@ func TestCommandSide_CreateVerificationCodeHumanEmail(t *testing.T) { { name: "new code, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -789,6 +772,7 @@ func TestCommandSide_CreateVerificationCodeHumanEmail(t *testing.T) { Crypted: []byte("a"), }, time.Hour*1, + "", ), ), ), @@ -805,13 +789,72 @@ func TestCommandSide_CreateVerificationCodeHumanEmail(t *testing.T) { }, }, }, + { + name: "new code with authRequestID, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + user.NewHumanEmailVerifiedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + eventFromEventPusher( + user.NewHumanEmailChangedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "email2@test.ch", + ), + ), + ), + expectPush( + user.NewHumanEmailCodeAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + time.Hour*1, + "authRequestID", + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + resourceOwner: "org1", + secretGenerator: GetMockSecretGenerator(t), + authRequestID: "authRequestID", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } - got, err := r.CreateHumanEmailVerificationCode(tt.args.ctx, tt.args.userID, tt.args.resourceOwner, tt.args.secretGenerator) + got, err := r.CreateHumanEmailVerificationCode(tt.args.ctx, tt.args.userID, tt.args.resourceOwner, tt.args.secretGenerator, tt.args.authRequestID) if tt.res.err == nil { assert.NoError(t, err) } @@ -827,7 +870,7 @@ func TestCommandSide_CreateVerificationCodeHumanEmail(t *testing.T) { func TestCommandSide_EmailVerificationCodeSent(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -846,9 +889,7 @@ func TestCommandSide_EmailVerificationCodeSent(t *testing.T) { { name: "userid missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -861,8 +902,7 @@ func TestCommandSide_EmailVerificationCodeSent(t *testing.T) { { name: "user not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -878,8 +918,7 @@ func TestCommandSide_EmailVerificationCodeSent(t *testing.T) { { name: "code sent, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -925,7 +964,7 @@ func TestCommandSide_EmailVerificationCodeSent(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } err := r.HumanEmailVerificationCodeSent(tt.args.ctx, tt.args.resourceOwner, tt.args.userID) if tt.res.err == nil { diff --git a/internal/command/user_human_init.go b/internal/command/user_human_init.go index f3ec27a199..60160743c6 100644 --- a/internal/command/user_human_init.go +++ b/internal/command/user_human_init.go @@ -13,7 +13,7 @@ import ( ) // ResendInitialMail resend initial mail and changes email if provided -func (c *Commands) ResendInitialMail(ctx context.Context, userID string, email domain.EmailAddress, resourceOwner string, initCodeGenerator crypto.Generator) (objectDetails *domain.ObjectDetails, err error) { +func (c *Commands) ResendInitialMail(ctx context.Context, userID string, email domain.EmailAddress, resourceOwner string, initCodeGenerator crypto.Generator, authRequestID string) (objectDetails *domain.ObjectDetails, err error) { if userID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-2n8vs", "Errors.User.UserIDMissing") } @@ -38,7 +38,10 @@ func (c *Commands) ResendInitialMail(ctx context.Context, userID string, email d if err != nil { return nil, err } - events = append(events, user.NewHumanInitialCodeAddedEvent(ctx, userAgg, initCode.Code, initCode.Expiry)) + if authRequestID == "" { + authRequestID = existingCode.AuthRequestID + } + events = append(events, user.NewHumanInitialCodeAddedEvent(ctx, userAgg, initCode.Code, initCode.Expiry, authRequestID)) pushedEvents, err := c.eventstore.Push(ctx, events...) if err != nil { return nil, err diff --git a/internal/command/user_human_init_model.go b/internal/command/user_human_init_model.go index c1276ec7ca..37c60a4b0c 100644 --- a/internal/command/user_human_init_model.go +++ b/internal/command/user_human_init_model.go @@ -19,6 +19,7 @@ type HumanInitCodeWriteModel struct { Code *crypto.CryptoValue CodeCreationDate time.Time CodeExpiry time.Duration + AuthRequestID string UserState domain.UserState } @@ -50,6 +51,7 @@ func (wm *HumanInitCodeWriteModel) Reduce() error { wm.Code = e.Code wm.CodeCreationDate = e.CreationDate() wm.CodeExpiry = e.Expiry + wm.AuthRequestID = e.AuthRequestID wm.UserState = domain.UserStateInitial case *user.HumanInitializedCheckSucceededEvent: wm.Code = nil diff --git a/internal/command/user_human_init_test.go b/internal/command/user_human_init_test.go index d89671d768..382d2892b4 100644 --- a/internal/command/user_human_init_test.go +++ b/internal/command/user_human_init_test.go @@ -18,7 +18,7 @@ import ( func TestCommandSide_ResendInitialMail(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -26,6 +26,7 @@ func TestCommandSide_ResendInitialMail(t *testing.T) { email string resourceOwner string secretGenerator crypto.Generator + authRequestID string } type res struct { want *domain.ObjectDetails @@ -40,9 +41,7 @@ func TestCommandSide_ResendInitialMail(t *testing.T) { { name: "userid missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -55,8 +54,7 @@ func TestCommandSide_ResendInitialMail(t *testing.T) { { name: "user not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -72,8 +70,7 @@ func TestCommandSide_ResendInitialMail(t *testing.T) { { name: "user not initialized, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -107,8 +104,7 @@ func TestCommandSide_ResendInitialMail(t *testing.T) { { name: "new code email not changed, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -128,6 +124,7 @@ func TestCommandSide_ResendInitialMail(t *testing.T) { user.NewHumanInitialCodeAddedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, nil, time.Hour*1, + "", ), ), ), @@ -141,6 +138,7 @@ func TestCommandSide_ResendInitialMail(t *testing.T) { Crypted: []byte("a"), }, time.Hour*1, + "", ), ), ), @@ -159,10 +157,9 @@ func TestCommandSide_ResendInitialMail(t *testing.T) { }, }, { - name: "new code, ok", + name: "new code email not changed with authRequestID, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -182,6 +179,7 @@ func TestCommandSide_ResendInitialMail(t *testing.T) { user.NewHumanInitialCodeAddedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, nil, time.Hour*1, + "authRequestID", ), ), ), @@ -195,6 +193,63 @@ func TestCommandSide_ResendInitialMail(t *testing.T) { Crypted: []byte("a"), }, time.Hour*1, + "authRequestID", + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + resourceOwner: "org1", + email: "email@test.ch", + secretGenerator: GetMockSecretGenerator(t), + authRequestID: "authRequestID", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "new code, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + user.NewHumanInitialCodeAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + nil, time.Hour*1, + "", + ), + ), + ), + expectPush( + user.NewHumanInitialCodeAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + time.Hour*1, + "", ), ), ), @@ -212,10 +267,9 @@ func TestCommandSide_ResendInitialMail(t *testing.T) { }, }, { - name: "new code with change email, ok", + name: "new code with authRequestID, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -235,6 +289,62 @@ func TestCommandSide_ResendInitialMail(t *testing.T) { user.NewHumanInitialCodeAddedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, nil, time.Hour*1, + "authRequestID", + ), + ), + ), + expectPush( + user.NewHumanInitialCodeAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + time.Hour*1, + "authRequestID", + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + resourceOwner: "org1", + secretGenerator: GetMockSecretGenerator(t), + authRequestID: "authRequestID", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "new code with change email, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + user.NewHumanInitialCodeAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + nil, time.Hour*1, + "", ), ), ), @@ -252,6 +362,7 @@ func TestCommandSide_ResendInitialMail(t *testing.T) { Crypted: []byte("a"), }, time.Hour*1, + "", ), ), ), @@ -273,9 +384,9 @@ func TestCommandSide_ResendInitialMail(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } - got, err := r.ResendInitialMail(tt.args.ctx, tt.args.userID, domain.EmailAddress(tt.args.email), tt.args.resourceOwner, tt.args.secretGenerator) + got, err := r.ResendInitialMail(tt.args.ctx, tt.args.userID, domain.EmailAddress(tt.args.email), tt.args.resourceOwner, tt.args.secretGenerator, tt.args.authRequestID) if tt.res.err == nil { assert.NoError(t, err) } @@ -291,7 +402,7 @@ func TestCommandSide_ResendInitialMail(t *testing.T) { func TestCommandSide_VerifyInitCode(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore userPasswordHasher *crypto.Hasher } type args struct { @@ -316,9 +427,7 @@ func TestCommandSide_VerifyInitCode(t *testing.T) { { name: "userid missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -332,9 +441,7 @@ func TestCommandSide_VerifyInitCode(t *testing.T) { { name: "code missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -348,8 +455,7 @@ func TestCommandSide_VerifyInitCode(t *testing.T) { { name: "user not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -366,8 +472,7 @@ func TestCommandSide_VerifyInitCode(t *testing.T) { { name: "code not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -399,8 +504,7 @@ func TestCommandSide_VerifyInitCode(t *testing.T) { { name: "invalid code, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -426,6 +530,7 @@ func TestCommandSide_VerifyInitCode(t *testing.T) { Crypted: []byte("a"), }, time.Hour*1, + "", ), ), ), @@ -450,8 +555,7 @@ func TestCommandSide_VerifyInitCode(t *testing.T) { { name: "valid code, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -477,6 +581,7 @@ func TestCommandSide_VerifyInitCode(t *testing.T) { Crypted: []byte("a"), }, time.Hour*1, + "", ), ), ), @@ -506,8 +611,7 @@ func TestCommandSide_VerifyInitCode(t *testing.T) { { name: "valid code with password, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -536,6 +640,7 @@ func TestCommandSide_VerifyInitCode(t *testing.T) { Crypted: []byte("a"), }, time.Hour*1, + "", ), ), ), @@ -582,8 +687,7 @@ func TestCommandSide_VerifyInitCode(t *testing.T) { { name: "valid code with password and userAgentID, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -612,6 +716,7 @@ func TestCommandSide_VerifyInitCode(t *testing.T) { Crypted: []byte("a"), }, time.Hour*1, + "", ), ), ), @@ -660,7 +765,7 @@ func TestCommandSide_VerifyInitCode(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), userPasswordHasher: tt.fields.userPasswordHasher, } err := r.HumanVerifyInitCode(tt.args.ctx, tt.args.userID, tt.args.resourceOwner, tt.args.code, tt.args.password, tt.args.userAgentID, tt.args.secretGenerator) @@ -676,7 +781,7 @@ func TestCommandSide_VerifyInitCode(t *testing.T) { func TestCommandSide_InitCodeSent(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -695,9 +800,7 @@ func TestCommandSide_InitCodeSent(t *testing.T) { { name: "userid missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -710,8 +813,7 @@ func TestCommandSide_InitCodeSent(t *testing.T) { { name: "user not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -727,8 +829,7 @@ func TestCommandSide_InitCodeSent(t *testing.T) { { name: "code sent, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -763,7 +864,7 @@ func TestCommandSide_InitCodeSent(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } err := r.HumanInitCodeSent(tt.args.ctx, tt.args.resourceOwner, tt.args.userID) if tt.res.err == nil { diff --git a/internal/command/user_human_password.go b/internal/command/user_human_password.go index 8831bff907..d7567c6478 100644 --- a/internal/command/user_human_password.go +++ b/internal/command/user_human_password.go @@ -165,7 +165,7 @@ func (c *Commands) canUpdatePassword(ctx context.Context, newPassword string, re } // RequestSetPassword generate and send out new code to change password for a specific user -func (c *Commands) RequestSetPassword(ctx context.Context, userID, resourceOwner string, notifyType domain.NotificationType, passwordVerificationCode crypto.Generator) (objectDetails *domain.ObjectDetails, err error) { +func (c *Commands) RequestSetPassword(ctx context.Context, userID, resourceOwner string, notifyType domain.NotificationType, passwordVerificationCode crypto.Generator, authRequestID string) (objectDetails *domain.ObjectDetails, err error) { if userID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-M00oL", "Errors.User.UserIDMissing") } @@ -185,7 +185,7 @@ func (c *Commands) RequestSetPassword(ctx context.Context, userID, resourceOwner if err != nil { return nil, err } - pushedEvents, err := c.eventstore.Push(ctx, user.NewHumanPasswordCodeAddedEvent(ctx, userAgg, passwordCode.Code, passwordCode.Expiry, notifyType)) + pushedEvents, err := c.eventstore.Push(ctx, user.NewHumanPasswordCodeAddedEvent(ctx, userAgg, passwordCode.Code, passwordCode.Expiry, notifyType, authRequestID)) if err != nil { return nil, err } diff --git a/internal/command/user_human_password_test.go b/internal/command/user_human_password_test.go index 0c2b21b3d8..965309ef50 100644 --- a/internal/command/user_human_password_test.go +++ b/internal/command/user_human_password_test.go @@ -22,7 +22,7 @@ import ( func TestCommandSide_SetOneTimePassword(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore userPasswordHasher *crypto.Hasher checkPermission domain.PermissionCheck } @@ -46,9 +46,7 @@ func TestCommandSide_SetOneTimePassword(t *testing.T) { { name: "userid missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -61,8 +59,7 @@ func TestCommandSide_SetOneTimePassword(t *testing.T) { { name: "user not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -78,8 +75,7 @@ func TestCommandSide_SetOneTimePassword(t *testing.T) { { name: "missing permission, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -121,8 +117,7 @@ func TestCommandSide_SetOneTimePassword(t *testing.T) { { name: "change password onetime, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -184,8 +179,7 @@ func TestCommandSide_SetOneTimePassword(t *testing.T) { { name: "change password no one time, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -248,7 +242,7 @@ func TestCommandSide_SetOneTimePassword(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), userPasswordHasher: tt.fields.userPasswordHasher, checkPermission: tt.fields.checkPermission, } @@ -268,7 +262,7 @@ func TestCommandSide_SetOneTimePassword(t *testing.T) { func TestCommandSide_SetPasswordWithVerifyCode(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore userEncryption crypto.EncryptionAlgorithm userPasswordHasher *crypto.Hasher } @@ -293,9 +287,7 @@ func TestCommandSide_SetPasswordWithVerifyCode(t *testing.T) { { name: "userid missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -308,9 +300,7 @@ func TestCommandSide_SetPasswordWithVerifyCode(t *testing.T) { { name: "password missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -324,8 +314,7 @@ func TestCommandSide_SetPasswordWithVerifyCode(t *testing.T) { { name: "user not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -342,8 +331,7 @@ func TestCommandSide_SetPasswordWithVerifyCode(t *testing.T) { { name: "code not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -376,8 +364,7 @@ func TestCommandSide_SetPasswordWithVerifyCode(t *testing.T) { { name: "invalid code, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -404,6 +391,7 @@ func TestCommandSide_SetPasswordWithVerifyCode(t *testing.T) { }, time.Hour*1, domain.NotificationTypeEmail, + "", ), ), ), @@ -424,8 +412,7 @@ func TestCommandSide_SetPasswordWithVerifyCode(t *testing.T) { { name: "set password, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -457,6 +444,7 @@ func TestCommandSide_SetPasswordWithVerifyCode(t *testing.T) { }, time.Hour*1, domain.NotificationTypeEmail, + "", ), ), ), @@ -500,8 +488,7 @@ func TestCommandSide_SetPasswordWithVerifyCode(t *testing.T) { { name: "set password with userAgentID, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -533,6 +520,7 @@ func TestCommandSide_SetPasswordWithVerifyCode(t *testing.T) { }, time.Hour*1, domain.NotificationTypeEmail, + "", ), ), ), @@ -578,7 +566,7 @@ func TestCommandSide_SetPasswordWithVerifyCode(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), userPasswordHasher: tt.fields.userPasswordHasher, userEncryption: tt.fields.userEncryption, } @@ -915,7 +903,7 @@ func TestCommandSide_ChangePassword(t *testing.T) { func TestCommandSide_RequestSetPassword(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -923,6 +911,7 @@ func TestCommandSide_RequestSetPassword(t *testing.T) { resourceOwner string notifyType domain.NotificationType secretGenerator crypto.Generator + authRequestID string } type res struct { want *domain.ObjectDetails @@ -937,9 +926,7 @@ func TestCommandSide_RequestSetPassword(t *testing.T) { { name: "userid missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -952,8 +939,7 @@ func TestCommandSide_RequestSetPassword(t *testing.T) { { name: "user not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -969,8 +955,7 @@ func TestCommandSide_RequestSetPassword(t *testing.T) { { name: "user initial, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -990,6 +975,7 @@ func TestCommandSide_RequestSetPassword(t *testing.T) { user.NewHumanInitialCodeAddedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, nil, time.Hour*1, + "", ), ), eventFromEventPusher( @@ -1018,8 +1004,7 @@ func TestCommandSide_RequestSetPassword(t *testing.T) { { name: "new code, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -1055,6 +1040,7 @@ func TestCommandSide_RequestSetPassword(t *testing.T) { }, time.Hour*1, domain.NotificationTypeEmail, + "", ), ), ), @@ -1071,13 +1057,70 @@ func TestCommandSide_RequestSetPassword(t *testing.T) { }, }, }, + { + name: "new code with authRequestID, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + user.NewHumanEmailVerifiedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + eventFromEventPusher( + user.NewHumanInitializedCheckSucceededEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate)), + ), + expectPush( + user.NewHumanPasswordCodeAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + time.Hour*1, + domain.NotificationTypeEmail, + "authRequestID", + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + resourceOwner: "org1", + secretGenerator: GetMockSecretGenerator(t), + authRequestID: "authRequestID", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } - got, err := r.RequestSetPassword(tt.args.ctx, tt.args.userID, tt.args.resourceOwner, tt.args.notifyType, tt.args.secretGenerator) + got, err := r.RequestSetPassword(tt.args.ctx, tt.args.userID, tt.args.resourceOwner, tt.args.notifyType, tt.args.secretGenerator, tt.args.authRequestID) if tt.res.err == nil { assert.NoError(t, err) } @@ -1093,7 +1136,7 @@ func TestCommandSide_RequestSetPassword(t *testing.T) { func TestCommandSide_PasswordCodeSent(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -1112,9 +1155,7 @@ func TestCommandSide_PasswordCodeSent(t *testing.T) { { name: "userid missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -1127,8 +1168,7 @@ func TestCommandSide_PasswordCodeSent(t *testing.T) { { name: "user not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -1144,8 +1184,7 @@ func TestCommandSide_PasswordCodeSent(t *testing.T) { { name: "code sent, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -1186,7 +1225,7 @@ func TestCommandSide_PasswordCodeSent(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } err := r.PasswordCodeSent(tt.args.ctx, tt.args.resourceOwner, tt.args.userID) if tt.res.err == nil { @@ -1201,7 +1240,7 @@ func TestCommandSide_PasswordCodeSent(t *testing.T) { func TestCommandSide_CheckPassword(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore userPasswordHasher *crypto.Hasher } type args struct { @@ -1224,9 +1263,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { { name: "userid missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -1240,9 +1277,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { { name: "password missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -1256,8 +1291,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { { name: "login policy not found, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), expectFilter(), ), @@ -1275,8 +1309,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { { name: "login policy login password not allowed, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewLoginPolicyAddedEvent(context.Background(), @@ -1316,8 +1349,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { { name: "user not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewLoginPolicyAddedEvent(context.Background(), @@ -1358,8 +1390,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { { name: "user locked, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewLoginPolicyAddedEvent(context.Background(), @@ -1420,8 +1451,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { { name: "existing password empty, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewLoginPolicyAddedEvent(context.Background(), @@ -1478,8 +1508,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { { name: "password not matching lockout policy not relevant, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewLoginPolicyAddedEvent(context.Background(), @@ -1562,8 +1591,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { { name: "password not matching, max password attempts reached - user locked, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewLoginPolicyAddedEvent(context.Background(), @@ -1653,8 +1681,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { { name: "check password, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewLoginPolicyAddedEvent(context.Background(), @@ -1734,8 +1761,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { { name: "check password, ok, updated hash", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewLoginPolicyAddedEvent(context.Background(), @@ -1820,8 +1846,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { { name: "check password ok, locked in the mean time", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewLoginPolicyAddedEvent(context.Background(), @@ -1900,8 +1925,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { { name: "regression test old version event", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewLoginPolicyAddedEvent(context.Background(), @@ -1996,7 +2020,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), userPasswordHasher: tt.fields.userPasswordHasher, } err := r.HumanCheckPassword(tt.args.ctx, tt.args.resourceOwner, tt.args.userID, tt.args.password, tt.args.authReq, tt.args.lockoutPolicy) diff --git a/internal/command/user_human_test.go b/internal/command/user_human_test.go index 689d8419b9..7010d41756 100644 --- a/internal/command/user_human_test.go +++ b/internal/command/user_human_test.go @@ -240,6 +240,7 @@ func TestCommandSide_AddHuman(t *testing.T) { Crypted: []byte("userinit"), }, time.Hour*1, + "", ), ), ), @@ -307,6 +308,7 @@ func TestCommandSide_AddHuman(t *testing.T) { Crypted: []byte("userinit"), }, time.Hour*1, + "", ), ), ), @@ -375,6 +377,7 @@ func TestCommandSide_AddHuman(t *testing.T) { Crypted: []byte("userinit"), }, time.Hour*1, + "", ), ), ), @@ -444,6 +447,7 @@ func TestCommandSide_AddHuman(t *testing.T) { Crypted: []byte("userinit"), }, 1*time.Hour, + "", ), ), ), @@ -1052,6 +1056,7 @@ func TestCommandSide_AddHuman(t *testing.T) { Crypted: []byte("userinit"), }, 1*time.Hour, + "", ), user.NewHumanPhoneVerifiedEvent( context.Background(), @@ -1193,6 +1198,7 @@ func TestCommandSide_AddHuman(t *testing.T) { Crypted: []byte("userinit"), }, 1*time.Hour, + "", ), user.NewMetadataSetEvent( context.Background(), @@ -1469,6 +1475,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { Crypted: []byte("a"), }, time.Hour*1, + "", ), ), ), @@ -1840,6 +1847,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { Crypted: []byte("a"), }, time.Hour*1, + "", ), user.NewHumanPhoneCodeAddedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, @@ -1941,6 +1949,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { Crypted: []byte("a"), }, time.Hour*1, + "", ), user.NewHumanPhoneVerifiedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate), @@ -2036,6 +2045,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { Crypted: []byte("a"), }, time.Hour*1, + "", ), ), ), @@ -2121,6 +2131,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { Crypted: []byte("a"), }, time.Hour*1, + "", ), ), ), @@ -2479,1498 +2490,6 @@ func TestCommandSide_ImportHuman(t *testing.T) { } } -func TestCommandSide_RegisterHuman(t *testing.T) { - type fields struct { - eventstore *eventstore.Eventstore - idGenerator id.Generator - userPasswordHasher *crypto.Hasher - } - type args struct { - ctx context.Context - orgID string - human *domain.Human - link *domain.UserIDPLink - orgMemberRoles []string - secretGenerator crypto.Generator - } - type res struct { - want *domain.Human - err func(error) bool - } - tests := []struct { - name string - fields fields - args args - res res - }{ - { - name: "orgid missing, invalid argument error", - fields: fields{ - eventstore: eventstoreExpect( - t, - ), - }, - args: args{ - ctx: context.Background(), - orgID: "", - human: &domain.Human{ - Username: "username", - Profile: &domain.Profile{ - FirstName: "firstname", - LastName: "lastname", - }, - Email: &domain.Email{ - EmailAddress: "email@test.ch", - }, - }, - }, - res: res{ - err: zerrors.IsErrorInvalidArgument, - }, - }, - { - name: "org policy not found, precondition error", - fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter(), - expectFilter(), - ), - }, - args: args{ - ctx: context.Background(), - orgID: "org1", - human: &domain.Human{ - Username: "username", - Profile: &domain.Profile{ - FirstName: "firstname", - LastName: "lastname", - }, - Email: &domain.Email{ - EmailAddress: "email@test.ch", - }, - Password: &domain.Password{ - SecretString: "password", - }, - }, - }, - res: res{ - err: zerrors.IsPreconditionFailed, - }, - }, - { - name: "password policy not found, precondition error", - fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter( - eventFromEventPusher( - org.NewDomainPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - true, - true, - true, - ), - ), - ), - expectFilter(), - expectFilter(), - ), - }, - args: args{ - ctx: context.Background(), - orgID: "org1", - human: &domain.Human{ - Username: "username", - Profile: &domain.Profile{ - FirstName: "firstname", - LastName: "lastname", - }, - Email: &domain.Email{ - EmailAddress: "email@test.ch", - }, - Password: &domain.Password{ - SecretString: "password", - }, - }, - }, - res: res{ - err: zerrors.IsPreconditionFailed, - }, - }, - { - name: "login policy not found, precondition error", - fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter( - eventFromEventPusher( - org.NewDomainPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - true, - true, - true, - ), - ), - ), - expectFilter( - eventFromEventPusher( - org.NewPasswordComplexityPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - 1, - false, - false, - false, - false, - ), - ), - ), - expectFilter(), - expectFilter(), - ), - }, - args: args{ - ctx: context.Background(), - orgID: "org1", - human: &domain.Human{ - Username: "username", - Profile: &domain.Profile{ - FirstName: "firstname", - }, - Password: &domain.Password{ - SecretString: "password", - }, - }, - }, - res: res{ - err: zerrors.IsPreconditionFailed, - }, - }, - { - name: "login policy registration not allowed, precondition error", - fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter( - eventFromEventPusher( - org.NewDomainPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - true, - true, - true, - ), - ), - ), - expectFilter( - eventFromEventPusher( - org.NewPasswordComplexityPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - 1, - false, - false, - false, - false, - ), - ), - ), - expectFilter( - eventFromEventPusher( - org.NewLoginPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - domain.PasswordlessTypeNotAllowed, - "", - time.Hour*1, - time.Hour*2, - time.Hour*3, - time.Hour*4, - time.Hour*5, - ), - ), - ), - ), - }, - args: args{ - ctx: context.Background(), - orgID: "org1", - human: &domain.Human{ - Username: "username", - Profile: &domain.Profile{ - FirstName: "firstname", - }, - Password: &domain.Password{ - SecretString: "password", - }, - }, - }, - res: res{ - err: zerrors.IsPreconditionFailed, - }, - }, - { - name: "user invalid, invalid argument error", - fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter( - eventFromEventPusher( - org.NewDomainPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - true, - true, - true, - ), - ), - ), - expectFilter( - eventFromEventPusher( - org.NewPasswordComplexityPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - 1, - false, - false, - false, - false, - ), - ), - ), - expectFilter( - eventFromEventPusher( - org.NewLoginPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - false, - true, - false, - false, - false, - false, - false, - false, - false, - false, - domain.PasswordlessTypeNotAllowed, - "", - time.Hour*1, - time.Hour*2, - time.Hour*3, - time.Hour*4, - time.Hour*5, - ), - ), - ), - ), - }, - args: args{ - ctx: context.Background(), - orgID: "org1", - human: &domain.Human{ - Username: "username", - Profile: &domain.Profile{ - FirstName: "firstname", - }, - Password: &domain.Password{ - SecretString: "password", - }, - }, - }, - res: res{ - err: zerrors.IsErrorInvalidArgument, - }, - }, - { - name: "email domain reserved, invalid argument error", - fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter( - eventFromEventPusher( - org.NewDomainPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - false, - true, - true, - ), - ), - ), - expectFilter( - eventFromEventPusher( - org.NewPasswordComplexityPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - 1, - false, - false, - false, - false, - ), - ), - ), - expectFilter( - eventFromEventPusher( - org.NewLoginPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - false, - true, - false, - false, - false, - false, - false, - false, - false, - false, - domain.PasswordlessTypeNotAllowed, - "", - time.Hour*1, - time.Hour*2, - time.Hour*3, - time.Hour*4, - time.Hour*5, - ), - ), - ), - expectFilter( - eventFromEventPusher( - org.NewDomainAddedEvent(context.Background(), - &org.NewAggregate("org2").Aggregate, - "test.ch", - ), - ), - eventFromEventPusher( - org.NewDomainVerifiedEvent(context.Background(), - &org.NewAggregate("org2").Aggregate, - "test.ch", - ), - ), - ), - ), - }, - args: args{ - ctx: context.Background(), - orgID: "org1", - human: &domain.Human{ - Password: &domain.Password{ - SecretString: "password", - }, - Profile: &domain.Profile{ - FirstName: "firstname", - LastName: "lastname", - }, - Email: &domain.Email{ - EmailAddress: "email@test.ch", - }, - }, - }, - res: res{ - err: zerrors.IsErrorInvalidArgument, - }, - }, - { - name: "email domain reserved, same org, ok", - fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter( - eventFromEventPusher( - org.NewDomainPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - false, - true, - true, - ), - ), - ), - expectFilter( - eventFromEventPusher( - org.NewPasswordComplexityPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - 1, - false, - false, - false, - false, - ), - ), - ), - expectFilter( - eventFromEventPusher( - org.NewLoginPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - false, - true, - false, - false, - false, - false, - false, - false, - false, - false, - domain.PasswordlessTypeNotAllowed, - "", - time.Hour*1, - time.Hour*2, - time.Hour*3, - time.Hour*4, - time.Hour*5, - ), - ), - ), - expectFilter( - eventFromEventPusher( - org.NewDomainAddedEvent(context.Background(), - &org.NewAggregate("org2").Aggregate, - "test.ch", - ), - ), - eventFromEventPusher( - org.NewDomainVerifiedEvent(context.Background(), - &org.NewAggregate("org2").Aggregate, - "test.ch", - ), - ), - eventFromEventPusher( - org.NewDomainRemovedEvent(context.Background(), - &org.NewAggregate("org2").Aggregate, - "test.ch", - true, - ), - ), - eventFromEventPusher( - org.NewDomainAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - "test.ch", - ), - ), - eventFromEventPusher( - org.NewDomainVerifiedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - "test.ch", - ), - ), - ), - expectPush( - newRegisterHumanEvent("email@test.ch", "$plain$x$password", false, false, "", AllowedLanguage), - user.NewHumanInitialCodeAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "enc", - KeyID: "id", - Crypted: []byte("a"), - }, - time.Hour*1, - ), - ), - ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordHasher: mockPasswordHasher("x"), - }, - args: args{ - ctx: context.Background(), - orgID: "org1", - human: &domain.Human{ - Password: &domain.Password{ - SecretString: "password", - }, - Profile: &domain.Profile{ - FirstName: "firstname", - LastName: "lastname", - PreferredLanguage: AllowedLanguage, - }, - Email: &domain.Email{ - EmailAddress: "email@test.ch", - }, - }, - secretGenerator: GetMockSecretGenerator(t), - }, - res: res{ - want: &domain.Human{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "user1", - ResourceOwner: "org1", - }, - Username: "email@test.ch", - Profile: &domain.Profile{ - FirstName: "firstname", - LastName: "lastname", - DisplayName: "firstname lastname", - PreferredLanguage: AllowedLanguage, - }, - Email: &domain.Email{ - EmailAddress: "email@test.ch", - }, - State: domain.UserStateInitial, - }, - }, - }, - { - name: "username without @, ok", - fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter( - eventFromEventPusher( - org.NewDomainPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - false, - true, - true, - ), - ), - ), - expectFilter( - eventFromEventPusher( - org.NewPasswordComplexityPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - 1, - false, - false, - false, - false, - ), - ), - ), - expectFilter( - eventFromEventPusher( - org.NewLoginPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - false, - true, - false, - false, - false, - false, - false, - false, - false, - false, - domain.PasswordlessTypeNotAllowed, - "", - time.Hour*1, - time.Hour*2, - time.Hour*3, - time.Hour*4, - time.Hour*5, - ), - ), - ), - expectPush( - newRegisterHumanEvent("username", "$plain$x$password", false, false, "", AllowedLanguage), - user.NewHumanInitialCodeAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "enc", - KeyID: "id", - Crypted: []byte("a"), - }, - time.Hour*1, - ), - ), - ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordHasher: mockPasswordHasher("x"), - }, - args: args{ - ctx: context.Background(), - orgID: "org1", - human: &domain.Human{ - Password: &domain.Password{ - SecretString: "password", - }, - Profile: &domain.Profile{ - FirstName: "firstname", - LastName: "lastname", - PreferredLanguage: AllowedLanguage, - }, - Email: &domain.Email{ - EmailAddress: "email@test.ch", - }, - Username: "username", - }, - secretGenerator: GetMockSecretGenerator(t), - }, - res: res{ - want: &domain.Human{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "user1", - ResourceOwner: "org1", - }, - Username: "username", - Profile: &domain.Profile{ - FirstName: "firstname", - LastName: "lastname", - DisplayName: "firstname lastname", - PreferredLanguage: AllowedLanguage, - }, - Email: &domain.Email{ - EmailAddress: "email@test.ch", - }, - State: domain.UserStateInitial, - }, - }, - }, - { - name: "add human (with password and initial code), ok", - fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter( - eventFromEventPusher( - org.NewDomainPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - true, - true, - true, - ), - ), - ), - expectFilter( - eventFromEventPusher( - org.NewPasswordComplexityPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - 1, - false, - false, - false, - false, - ), - ), - ), - expectFilter( - eventFromEventPusher( - org.NewLoginPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - false, - true, - false, - false, - false, - false, - false, - false, - false, - false, - domain.PasswordlessTypeNotAllowed, - "", - time.Hour*1, - time.Hour*2, - time.Hour*3, - time.Hour*4, - time.Hour*5, - ), - ), - ), - expectPush( - newRegisterHumanEvent("username", "$plain$x$password", false, true, "", AllowedLanguage), - user.NewHumanInitialCodeAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "enc", - KeyID: "id", - Crypted: []byte("a"), - }, - time.Hour*1, - ), - ), - ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordHasher: mockPasswordHasher("x"), - }, - args: args{ - ctx: context.Background(), - orgID: "org1", - human: &domain.Human{ - Username: "username", - Password: &domain.Password{ - SecretString: "password", - }, - Profile: &domain.Profile{ - FirstName: "firstname", - LastName: "lastname", - PreferredLanguage: AllowedLanguage, - }, - Email: &domain.Email{ - EmailAddress: "email@test.ch", - }, - }, - secretGenerator: GetMockSecretGenerator(t), - }, - res: res{ - want: &domain.Human{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "user1", - ResourceOwner: "org1", - }, - Username: "username", - Profile: &domain.Profile{ - FirstName: "firstname", - LastName: "lastname", - DisplayName: "firstname lastname", - PreferredLanguage: AllowedLanguage, - }, - Email: &domain.Email{ - EmailAddress: "email@test.ch", - }, - State: domain.UserStateInitial, - }, - }, - }, - { - name: "add human email verified, ok", - fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter( - eventFromEventPusher( - org.NewDomainPolicyAddedEvent(context.Background(), - &user.NewAggregate("org1", "org1").Aggregate, - true, - true, - true, - ), - ), - ), - expectFilter( - eventFromEventPusher( - org.NewPasswordComplexityPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - 1, - false, - false, - false, - false, - ), - ), - ), - expectFilter( - eventFromEventPusher( - org.NewLoginPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - false, - true, - false, - false, - false, - false, - false, - false, - false, - false, - domain.PasswordlessTypeNotAllowed, - "", - time.Hour*1, - time.Hour*2, - time.Hour*3, - time.Hour*4, - time.Hour*5, - ), - ), - ), - expectPush( - newRegisterHumanEvent("username", "$plain$x$password", false, true, "", AllowedLanguage), - user.NewHumanEmailVerifiedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate), - ), - ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordHasher: mockPasswordHasher("x"), - }, - args: args{ - ctx: context.Background(), - orgID: "org1", - human: &domain.Human{ - Username: "username", - Password: &domain.Password{ - SecretString: "password", - }, - Profile: &domain.Profile{ - FirstName: "firstname", - LastName: "lastname", - PreferredLanguage: AllowedLanguage, - }, - Email: &domain.Email{ - EmailAddress: "email@test.ch", - IsEmailVerified: true, - }, - }, - secretGenerator: GetMockSecretGenerator(t), - }, - res: res{ - want: &domain.Human{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "user1", - ResourceOwner: "org1", - }, - Username: "username", - Profile: &domain.Profile{ - FirstName: "firstname", - LastName: "lastname", - DisplayName: "firstname lastname", - PreferredLanguage: AllowedLanguage, - }, - Email: &domain.Email{ - EmailAddress: "email@test.ch", - IsEmailVerified: true, - }, - State: domain.UserStateActive, - }, - }, - }, - { - name: "add human (with phone), ok", - fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter( - eventFromEventPusher( - org.NewDomainPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - true, - true, - true, - ), - ), - ), - expectFilter( - eventFromEventPusher( - org.NewPasswordComplexityPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - 1, - false, - false, - false, - false, - ), - ), - ), - expectFilter( - eventFromEventPusher( - org.NewLoginPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - false, - true, - false, - false, - false, - false, - false, - false, - false, - false, - domain.PasswordlessTypeNotAllowed, - "", - time.Hour*1, - time.Hour*2, - time.Hour*3, - time.Hour*4, - time.Hour*5, - ), - ), - ), - expectPush( - newRegisterHumanEvent("username", "$plain$x$password", false, true, "+41711234567", AllowedLanguage), - user.NewHumanInitialCodeAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "enc", - KeyID: "id", - Crypted: []byte("a"), - }, - time.Hour*1, - ), - user.NewHumanPhoneCodeAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "enc", - KeyID: "id", - Crypted: []byte("a"), - }, - time.Hour*1, - ), - ), - ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordHasher: mockPasswordHasher("x"), - }, - args: args{ - ctx: context.Background(), - orgID: "org1", - human: &domain.Human{ - Username: "username", - Profile: &domain.Profile{ - FirstName: "firstname", - LastName: "lastname", - PreferredLanguage: AllowedLanguage, - }, - Email: &domain.Email{ - EmailAddress: "email@test.ch", - }, - Phone: &domain.Phone{ - PhoneNumber: "+41711234567", - }, - Password: &domain.Password{ - SecretString: "password", - }, - }, - secretGenerator: GetMockSecretGenerator(t), - }, - res: res{ - want: &domain.Human{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "user1", - ResourceOwner: "org1", - }, - Username: "username", - Profile: &domain.Profile{ - FirstName: "firstname", - LastName: "lastname", - DisplayName: "firstname lastname", - PreferredLanguage: AllowedLanguage, - }, - Email: &domain.Email{ - EmailAddress: "email@test.ch", - }, - Phone: &domain.Phone{ - PhoneNumber: "+41711234567", - }, - State: domain.UserStateInitial, - }, - }, - }, - { - name: "add human (with verified phone), ok", - fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter( - eventFromEventPusher( - org.NewDomainPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - true, - true, - true, - ), - ), - ), - expectFilter( - eventFromEventPusher( - org.NewPasswordComplexityPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - 1, - false, - false, - false, - false, - ), - ), - ), - expectFilter( - eventFromEventPusher( - org.NewLoginPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - false, - true, - false, - false, - false, - false, - false, - false, - false, - false, - domain.PasswordlessTypeNotAllowed, - "", - time.Hour*1, - time.Hour*2, - time.Hour*3, - time.Hour*4, - time.Hour*5, - ), - ), - ), - expectPush( - newRegisterHumanEvent("username", "$plain$x$password", false, true, "+41711234567", AllowedLanguage), - user.NewHumanInitialCodeAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "enc", - KeyID: "id", - Crypted: []byte("a"), - }, - time.Hour*1, - ), - user.NewHumanPhoneVerifiedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, - ), - ), - ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordHasher: mockPasswordHasher("x"), - }, - args: args{ - ctx: context.Background(), - orgID: "org1", - human: &domain.Human{ - Username: "username", - Profile: &domain.Profile{ - FirstName: "firstname", - LastName: "lastname", - PreferredLanguage: AllowedLanguage, - }, - Email: &domain.Email{ - EmailAddress: "email@test.ch", - }, - Phone: &domain.Phone{ - PhoneNumber: "+41711234567", - IsPhoneVerified: true, - }, - Password: &domain.Password{ - SecretString: "password", - }, - }, - secretGenerator: GetMockSecretGenerator(t), - }, - res: res{ - want: &domain.Human{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "user1", - ResourceOwner: "org1", - }, - Username: "username", - Profile: &domain.Profile{ - FirstName: "firstname", - LastName: "lastname", - DisplayName: "firstname lastname", - PreferredLanguage: AllowedLanguage, - }, - Email: &domain.Email{ - EmailAddress: "email@test.ch", - }, - Phone: &domain.Phone{ - PhoneNumber: "+41711234567", - }, - State: domain.UserStateInitial, - }, - }, - }, - { - name: "add human (with unsupported preferred language), ok", - fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter( - eventFromEventPusher( - org.NewDomainPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - true, - true, - true, - ), - ), - ), - expectFilter( - eventFromEventPusher( - org.NewPasswordComplexityPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - 1, - false, - false, - false, - false, - ), - ), - ), - expectFilter( - eventFromEventPusher( - org.NewLoginPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - false, - true, - false, - false, - false, - false, - false, - false, - false, - false, - domain.PasswordlessTypeNotAllowed, - "", - time.Hour*1, - time.Hour*2, - time.Hour*3, - time.Hour*4, - time.Hour*5, - ), - ), - ), - expectPush( - newRegisterHumanEvent("username", "$plain$x$password", false, true, "", UnsupportedLanguage), - user.NewHumanInitialCodeAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "enc", - KeyID: "id", - Crypted: []byte("a"), - }, - time.Hour*1, - ), - ), - ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordHasher: mockPasswordHasher("x"), - }, - args: args{ - ctx: context.Background(), - orgID: "org1", - human: &domain.Human{ - Username: "username", - Profile: &domain.Profile{ - FirstName: "firstname", - LastName: "lastname", - PreferredLanguage: UnsupportedLanguage, - }, - Email: &domain.Email{ - EmailAddress: "email@test.ch", - }, - Password: &domain.Password{ - SecretString: "password", - }, - }, - secretGenerator: GetMockSecretGenerator(t), - }, - res: res{ - want: &domain.Human{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "user1", - ResourceOwner: "org1", - }, - Username: "username", - Profile: &domain.Profile{ - FirstName: "firstname", - LastName: "lastname", - DisplayName: "firstname lastname", - PreferredLanguage: UnsupportedLanguage, - }, - Email: &domain.Email{ - EmailAddress: "email@test.ch", - }, - State: domain.UserStateInitial, - }, - }, - }, - { - name: "add human (with undefined preferred language), ok", - fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter( - eventFromEventPusher( - org.NewDomainPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - true, - true, - true, - ), - ), - ), - expectFilter( - eventFromEventPusher( - org.NewPasswordComplexityPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - 1, - false, - false, - false, - false, - ), - ), - ), - expectFilter( - eventFromEventPusher( - org.NewLoginPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - false, - true, - false, - false, - false, - false, - false, - false, - false, - false, - domain.PasswordlessTypeNotAllowed, - "", - time.Hour*1, - time.Hour*2, - time.Hour*3, - time.Hour*4, - time.Hour*5, - ), - ), - ), - expectPush( - newRegisterHumanEvent("username", "$plain$x$password", false, true, "", language.Und), - user.NewHumanInitialCodeAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "enc", - KeyID: "id", - Crypted: []byte("a"), - }, - time.Hour*1, - ), - ), - ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordHasher: mockPasswordHasher("x"), - }, - args: args{ - ctx: context.Background(), - orgID: "org1", - human: &domain.Human{ - Username: "username", - Profile: &domain.Profile{ - FirstName: "firstname", - LastName: "lastname", - }, - Email: &domain.Email{ - EmailAddress: "email@test.ch", - }, - Password: &domain.Password{ - SecretString: "password", - }, - }, - secretGenerator: GetMockSecretGenerator(t), - }, - res: res{ - want: &domain.Human{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "user1", - ResourceOwner: "org1", - }, - Username: "username", - Profile: &domain.Profile{ - FirstName: "firstname", - LastName: "lastname", - DisplayName: "firstname lastname", - }, - Email: &domain.Email{ - EmailAddress: "email@test.ch", - }, - State: domain.UserStateInitial, - }, - }, - }, - { - name: "add with idp link, email verified, ok", - fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter( - eventFromEventPusher( - org.NewDomainPolicyAddedEvent(context.Background(), - &user.NewAggregate("org1", "org1").Aggregate, - true, - true, - true, - ), - ), - ), - expectFilter( - eventFromEventPusher( - org.NewPasswordComplexityPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - 1, - false, - false, - false, - false, - ), - ), - ), - expectFilter( - eventFromEventPusher( - org.NewLoginPolicyAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - false, - true, - true, - false, - false, - false, - false, - false, - false, - false, - domain.PasswordlessTypeNotAllowed, - "", - time.Hour*1, - time.Hour*2, - time.Hour*3, - time.Hour*4, - time.Hour*5, - ), - ), - ), - expectFilter( - eventFromEventPusher( - org.NewIDPConfigAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - "idpID", - "name", - domain.IDPConfigTypeOIDC, - domain.IDPConfigStylingTypeUnspecified, - false, - ), - ), - eventFromEventPusher( - org.NewIDPOIDCConfigAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - "clientID", - "idpID", - "issuer", - "authEndpoint", - "tokenEndpoint", - nil, - domain.OIDCMappingFieldUnspecified, - domain.OIDCMappingFieldUnspecified, - ), - ), - eventFromEventPusher( - org.NewIdentityProviderAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - "idpID", - domain.IdentityProviderTypeOrg, - ), - ), - ), - expectFilter( - eventFromEventPusher( - org.NewIDPConfigAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - "idpID", - "name", - domain.IDPConfigTypeOIDC, - domain.IDPConfigStylingTypeUnspecified, - false, - ), - ), - eventFromEventPusher( - org.NewIDPOIDCConfigAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - "clientID", - "idpID", - "issuer", - "authEndpoint", - "tokenEndpoint", - nil, - domain.OIDCMappingFieldUnspecified, - domain.OIDCMappingFieldUnspecified, - ), - ), - ), - expectPush( - newRegisterHumanEvent("username", "$plain$x$password", false, true, "", AllowedLanguage), - user.NewUserIDPLinkAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, - "idpID", - "displayName", - "externalID", - ), - user.NewHumanEmailVerifiedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, - ), - ), - ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), - userPasswordHasher: mockPasswordHasher("x"), - }, - args: args{ - ctx: context.Background(), - orgID: "org1", - human: &domain.Human{ - Username: "username", - Password: &domain.Password{ - SecretString: "password", - }, - Profile: &domain.Profile{ - FirstName: "firstname", - LastName: "lastname", - PreferredLanguage: AllowedLanguage, - }, - Email: &domain.Email{ - EmailAddress: "email@test.ch", - IsEmailVerified: true, - }, - }, - link: &domain.UserIDPLink{ - IDPConfigID: "idpID", - ExternalUserID: "externalID", - DisplayName: "displayName", - }, - secretGenerator: GetMockSecretGenerator(t), - }, - res: res{ - want: &domain.Human{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "user1", - ResourceOwner: "org1", - }, - Username: "username", - Profile: &domain.Profile{ - FirstName: "firstname", - LastName: "lastname", - DisplayName: "firstname lastname", - PreferredLanguage: AllowedLanguage, - }, - Email: &domain.Email{ - EmailAddress: "email@test.ch", - IsEmailVerified: true, - }, - State: domain.UserStateActive, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := &Commands{ - eventstore: tt.fields.eventstore, - idGenerator: tt.fields.idGenerator, - userPasswordHasher: tt.fields.userPasswordHasher, - } - got, err := r.RegisterHuman(tt.args.ctx, tt.args.orgID, tt.args.human, tt.args.link, tt.args.orgMemberRoles, tt.args.secretGenerator, tt.args.secretGenerator, tt.args.secretGenerator) - if tt.res.err == nil { - assert.NoError(t, err) - } - if tt.res.err != nil && !tt.res.err(err) { - t.Errorf("got wrong err: %v ", err) - } - if tt.res.err == nil { - assert.Equal(t, tt.res.want, got) - } - }) - } -} - func TestCommandSide_HumanMFASkip(t *testing.T) { type fields struct { eventstore *eventstore.Eventstore @@ -4311,6 +2830,7 @@ func newRegisterHumanEvent(username, password string, changeRequired, userLoginM domain.GenderUnspecified, "email@test.ch", userLoginMustBeUnique, + "", ) if password != "" { event.AddPasswordData(password, changeRequired) diff --git a/internal/command/user_test.go b/internal/command/user_test.go index 6538e00660..b906b7b5b5 100644 --- a/internal/command/user_test.go +++ b/internal/command/user_test.go @@ -1797,6 +1797,7 @@ func TestExistsUser(t *testing.T) { domain.GenderFemale, "support@zitadel.com", true, + "userAgentID", ), }, nil }, diff --git a/internal/command/user_v2_email_test.go b/internal/command/user_v2_email_test.go index c26d0c7493..fc8fc1c703 100644 --- a/internal/command/user_v2_email_test.go +++ b/internal/command/user_v2_email_test.go @@ -1838,7 +1838,7 @@ func TestCommands_verifyUserEmailWithGenerator(t *testing.T) { func TestCommands_NewUserEmailEvents(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { userID string @@ -1852,7 +1852,7 @@ func TestCommands_NewUserEmailEvents(t *testing.T) { { name: "missing userID", fields: fields{ - eventstore: eventstoreExpect(t), + eventstore: expectEventstore(), }, args: args{ userID: "", @@ -1862,7 +1862,7 @@ func TestCommands_NewUserEmailEvents(t *testing.T) { { name: "not found", fields: fields{ - eventstore: eventstoreExpect(t, expectFilter()), + eventstore: expectEventstore(expectFilter()), }, args: args{ userID: "user1", @@ -1872,8 +1872,7 @@ func TestCommands_NewUserEmailEvents(t *testing.T) { { name: "user not initialized", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -1893,6 +1892,7 @@ func TestCommands_NewUserEmailEvents(t *testing.T) { user.NewHumanInitialCodeAddedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, nil, time.Hour*1, + "", ), ), ), @@ -1907,7 +1907,7 @@ func TestCommands_NewUserEmailEvents(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } _, err := c.NewUserEmailEvents(context.Background(), tt.args.userID) require.ErrorIs(t, err, tt.wantErr) diff --git a/internal/command/user_v2_human.go b/internal/command/user_v2_human.go index ab05492b51..dfd8470a65 100644 --- a/internal/command/user_v2_human.go +++ b/internal/command/user_v2_human.go @@ -131,8 +131,10 @@ func (c *Commands) AddUserHuman(ctx context.Context, resourceOwner string, human return zerrors.ThrowPreconditionFailed(nil, "COMMAND-7yiox1isql", "Errors.User.AlreadyExisting") } // check for permission to create user on resourceOwner - if err := c.checkPermission(ctx, domain.PermissionUserWrite, resourceOwner, human.ID); err != nil { - return err + if !human.Register { + if err := c.checkPermission(ctx, domain.PermissionUserWrite, resourceOwner, human.ID); err != nil { + return err + } } // add resourceowner for the events with the aggregate existingHuman.ResourceOwner = resourceOwner @@ -159,6 +161,7 @@ func (c *Commands) AddUserHuman(ctx context.Context, resourceOwner string, human human.Gender, human.Email.Address, domainPolicy.UserLoginMustBeDomain, + human.UserAgentID, ) } else { createCmd = user.NewHumanAddedEvent( diff --git a/internal/command/user_v2_human_test.go b/internal/command/user_v2_human_test.go index 32a838b27e..f0ea86c42a 100644 --- a/internal/command/user_v2_human_test.go +++ b/internal/command/user_v2_human_test.go @@ -232,6 +232,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { domain.GenderUnspecified, "email@test.ch", true, + "userAgentID", ), user.NewHumanInitialCodeAddedEvent(context.Background(), &userAgg.Aggregate, @@ -242,6 +243,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { Crypted: []byte("userinit"), }, time.Hour*1, + "authRequestID", ), ), ), @@ -261,6 +263,8 @@ func TestCommandSide_AddUserHuman(t *testing.T) { }, PreferredLanguage: language.English, Register: true, + UserAgentID: "userAgentID", + AuthRequestID: "authRequestID", }, secretGenerator: GetMockSecretGenerator(t), allowInitMail: true, @@ -344,6 +348,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { Crypted: []byte("userinit"), }, time.Hour*1, + "", ), ), ), @@ -414,6 +419,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { Crypted: []byte("userinit"), }, 1*time.Hour, + "", ), ), ), @@ -1031,6 +1037,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { Crypted: []byte("userinit"), }, 1*time.Hour, + "", ), user.NewHumanPhoneVerifiedEvent( context.Background(), @@ -1174,6 +1181,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { Crypted: []byte("userinit"), }, 1*time.Hour, + "", ), user.NewMetadataSetEvent( context.Background(), @@ -1993,6 +2001,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { user.NewHumanInitialCodeAddedEvent(context.Background(), &userAgg.Aggregate, nil, time.Hour*1, + "", ), ), ), diff --git a/internal/command/user_v2_model_test.go b/internal/command/user_v2_model_test.go index 7c77e491fd..73513a86a6 100644 --- a/internal/command/user_v2_model_test.go +++ b/internal/command/user_v2_model_test.go @@ -167,6 +167,7 @@ func TestCommandSide_userExistsWriteModel(t *testing.T) { Crypted: []byte("a"), }, time.Hour*1, + "authRequestID", ), ), ), @@ -225,6 +226,7 @@ func TestCommandSide_userExistsWriteModel(t *testing.T) { Crypted: []byte("a"), }, time.Hour*1, + "authRequestID", ), ), eventFromEventPusher( @@ -280,6 +282,7 @@ func TestCommandSide_userExistsWriteModel(t *testing.T) { Crypted: []byte("a"), }, time.Hour*1, + "authRequestID", ), ), eventFromEventPusher( diff --git a/internal/command/user_v2_password_test.go b/internal/command/user_v2_password_test.go index b07fbb3de7..a870376251 100644 --- a/internal/command/user_v2_password_test.go +++ b/internal/command/user_v2_password_test.go @@ -10,6 +10,7 @@ import ( "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" @@ -69,7 +70,7 @@ func TestCommands_RequestPasswordReset(t *testing.T) { ), eventFromEventPusher( user.NewHumanInitialCodeAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate, - &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "keyID", Crypted: []byte("code")}, 10*time.Second), + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "keyID", Crypted: []byte("code")}, 10*time.Second, ""), ), ), ), @@ -167,7 +168,7 @@ func TestCommands_RequestPasswordResetReturnCode(t *testing.T) { ), eventFromEventPusher( user.NewHumanInitialCodeAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate, - &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "keyID", Crypted: []byte("code")}, 10*time.Second), + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "keyID", Crypted: []byte("code")}, 10*time.Second, ""), ), ), ), @@ -279,7 +280,7 @@ func TestCommands_RequestPasswordResetURLTemplate(t *testing.T) { ), eventFromEventPusher( user.NewHumanInitialCodeAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate, - &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "keyID", Crypted: []byte("code")}, 10*time.Second), + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "keyID", Crypted: []byte("code")}, 10*time.Second, ""), ), ), ), @@ -390,7 +391,7 @@ func TestCommands_requestPasswordReset(t *testing.T) { ), eventFromEventPusher( user.NewHumanInitialCodeAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate, - &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "keyID", Crypted: []byte("code")}, 10*time.Second), + &crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "keyID", Crypted: []byte("code")}, 10*time.Second, ""), ), ), ), diff --git a/internal/command/user_v2_test.go b/internal/command/user_v2_test.go index 15597b7dd7..0ee9fe6946 100644 --- a/internal/command/user_v2_test.go +++ b/internal/command/user_v2_test.go @@ -18,7 +18,7 @@ import ( func TestCommandSide_LockUserV2(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore checkPermission domain.PermissionCheck } type ( @@ -40,9 +40,7 @@ func TestCommandSide_LockUserV2(t *testing.T) { { name: "userid missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), checkPermission: newMockPermissionCheckAllowed(), }, args: args{ @@ -58,8 +56,7 @@ func TestCommandSide_LockUserV2(t *testing.T) { { name: "user not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), checkPermission: newMockPermissionCheckAllowed(), @@ -77,8 +74,7 @@ func TestCommandSide_LockUserV2(t *testing.T) { { name: "user already locked, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -116,8 +112,7 @@ func TestCommandSide_LockUserV2(t *testing.T) { { name: "user already locked, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewMachineAddedEvent(context.Background(), @@ -151,8 +146,7 @@ func TestCommandSide_LockUserV2(t *testing.T) { { name: "lock user, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -190,8 +184,7 @@ func TestCommandSide_LockUserV2(t *testing.T) { { name: "lock user, no permission", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -224,8 +217,7 @@ func TestCommandSide_LockUserV2(t *testing.T) { { name: "lock user machine, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewMachineAddedEvent(context.Background(), @@ -260,7 +252,7 @@ func TestCommandSide_LockUserV2(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), checkPermission: tt.fields.checkPermission, } got, err := r.LockUserV2(tt.args.ctx, tt.args.userID) @@ -279,7 +271,7 @@ func TestCommandSide_LockUserV2(t *testing.T) { func TestCommandSide_UnlockUserV2(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore checkPermission domain.PermissionCheck } type ( @@ -301,9 +293,7 @@ func TestCommandSide_UnlockUserV2(t *testing.T) { { name: "userid missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), checkPermission: newMockPermissionCheckAllowed(), }, args: args{ @@ -319,8 +309,7 @@ func TestCommandSide_UnlockUserV2(t *testing.T) { { name: "user not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), checkPermission: newMockPermissionCheckAllowed(), @@ -338,8 +327,7 @@ func TestCommandSide_UnlockUserV2(t *testing.T) { { name: "user already active, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -372,8 +360,7 @@ func TestCommandSide_UnlockUserV2(t *testing.T) { { name: "user already active, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( user.NewMachineAddedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, @@ -400,8 +387,7 @@ func TestCommandSide_UnlockUserV2(t *testing.T) { { name: "unlock user, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -443,8 +429,7 @@ func TestCommandSide_UnlockUserV2(t *testing.T) { { name: "unlock user, no permission", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -481,8 +466,7 @@ func TestCommandSide_UnlockUserV2(t *testing.T) { { name: "unlock user machine, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewMachineAddedEvent(context.Background(), @@ -521,7 +505,7 @@ func TestCommandSide_UnlockUserV2(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), checkPermission: tt.fields.checkPermission, } got, err := r.UnlockUserV2(tt.args.ctx, tt.args.userID) @@ -540,7 +524,7 @@ func TestCommandSide_UnlockUserV2(t *testing.T) { func TestCommandSide_DeactivateUserV2(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore checkPermission domain.PermissionCheck } type ( @@ -562,9 +546,7 @@ func TestCommandSide_DeactivateUserV2(t *testing.T) { { name: "userid missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), checkPermission: newMockPermissionCheckAllowed(), }, args: args{ @@ -580,8 +562,7 @@ func TestCommandSide_DeactivateUserV2(t *testing.T) { { name: "user not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), checkPermission: newMockPermissionCheckAllowed(), @@ -599,8 +580,7 @@ func TestCommandSide_DeactivateUserV2(t *testing.T) { { name: "user initial, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -620,6 +600,7 @@ func TestCommandSide_DeactivateUserV2(t *testing.T) { user.NewHumanInitialCodeAddedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, nil, time.Hour*1, + "", ), ), ), @@ -639,8 +620,7 @@ func TestCommandSide_DeactivateUserV2(t *testing.T) { { name: "user already inactive, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -678,8 +658,7 @@ func TestCommandSide_DeactivateUserV2(t *testing.T) { { name: "deactivate user, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -722,8 +701,7 @@ func TestCommandSide_DeactivateUserV2(t *testing.T) { { name: "deactivate user, no permission", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -761,8 +739,7 @@ func TestCommandSide_DeactivateUserV2(t *testing.T) { { name: "user machine already inactive, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewMachineAddedEvent(context.Background(), @@ -796,8 +773,7 @@ func TestCommandSide_DeactivateUserV2(t *testing.T) { { name: "deactivate user machine, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewMachineAddedEvent(context.Background(), @@ -832,7 +808,7 @@ func TestCommandSide_DeactivateUserV2(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), checkPermission: tt.fields.checkPermission, } got, err := r.DeactivateUserV2(tt.args.ctx, tt.args.userID) @@ -851,7 +827,7 @@ func TestCommandSide_DeactivateUserV2(t *testing.T) { func TestCommandSide_ReactivateUserV2(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore checkPermission domain.PermissionCheck } type ( @@ -873,9 +849,7 @@ func TestCommandSide_ReactivateUserV2(t *testing.T) { { name: "userid missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), checkPermission: newMockPermissionCheckAllowed(), }, args: args{ @@ -891,8 +865,7 @@ func TestCommandSide_ReactivateUserV2(t *testing.T) { { name: "user not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), checkPermission: newMockPermissionCheckAllowed(), @@ -910,8 +883,7 @@ func TestCommandSide_ReactivateUserV2(t *testing.T) { { name: "user already active, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -944,8 +916,7 @@ func TestCommandSide_ReactivateUserV2(t *testing.T) { { name: "user machine already active, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewMachineAddedEvent(context.Background(), @@ -974,8 +945,7 @@ func TestCommandSide_ReactivateUserV2(t *testing.T) { { name: "reactivate user, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -1017,8 +987,7 @@ func TestCommandSide_ReactivateUserV2(t *testing.T) { { name: "reactivate user, no permission", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -1055,8 +1024,7 @@ func TestCommandSide_ReactivateUserV2(t *testing.T) { { name: "reactivate user machine, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewMachineAddedEvent(context.Background(), @@ -1095,7 +1063,7 @@ func TestCommandSide_ReactivateUserV2(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), checkPermission: tt.fields.checkPermission, } got, err := r.ReactivateUserV2(tt.args.ctx, tt.args.userID) @@ -1114,7 +1082,7 @@ func TestCommandSide_ReactivateUserV2(t *testing.T) { func TestCommandSide_RemoveUserV2(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore checkPermission domain.PermissionCheck } type ( @@ -1138,9 +1106,7 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { { name: "userid missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), checkPermission: newMockPermissionCheckAllowed(), }, args: args{ @@ -1156,8 +1122,7 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { { name: "user not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), checkPermission: newMockPermissionCheckAllowed(), @@ -1175,8 +1140,7 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { { name: "user removed, notfound error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -1217,8 +1181,7 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { { name: "remove user, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -1269,8 +1232,7 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { { name: "remove user, no permission", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -1308,8 +1270,7 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { { name: "user machine already removed, notfound error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewMachineAddedEvent(context.Background(), @@ -1346,8 +1307,7 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { { name: "remove user machine, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewMachineAddedEvent(context.Background(), @@ -1395,7 +1355,7 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), checkPermission: tt.fields.checkPermission, } got, err := r.RemoveUserV2(tt.args.ctx, tt.args.userID, tt.args.cascadingMemberships, tt.args.grantIDs...) diff --git a/internal/notification/handlers/user_notifier.go b/internal/notification/handlers/user_notifier.go index f55345bff6..fd3acc84aa 100644 --- a/internal/notification/handlers/user_notifier.go +++ b/internal/notification/handlers/user_notifier.go @@ -169,7 +169,7 @@ func (u *userNotifier) reduceInitCodeAdded(event eventstore.Event) (*handler.Sta return err } err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e). - SendUserInitCode(ctx, notifyUser, code) + SendUserInitCode(ctx, notifyUser, code, e.AuthRequestID) if err != nil { return err } @@ -226,7 +226,7 @@ func (u *userNotifier) reduceEmailCodeAdded(event eventstore.Event) (*handler.St return err } err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e). - SendEmailVerificationCode(ctx, notifyUser, code, e.URLTemplate) + SendEmailVerificationCode(ctx, notifyUser, code, e.URLTemplate, e.AuthRequestID) if err != nil { return err } @@ -285,7 +285,7 @@ func (u *userNotifier) reducePasswordCodeAdded(event eventstore.Event) (*handler if e.NotificationType == domain.NotificationTypeSms { notify = types.SendSMSTwilio(ctx, u.channels, translator, notifyUser, colors, e) } - err = notify.SendPasswordCode(ctx, notifyUser, code, e.URLTemplate) + err = notify.SendPasswordCode(ctx, notifyUser, code, e.URLTemplate, e.AuthRequestID) if err != nil { return err } diff --git a/internal/notification/handlers/user_notifier_test.go b/internal/notification/handlers/user_notifier_test.go index 55751beb13..8b2155cc27 100644 --- a/internal/notification/handlers/user_notifier_test.go +++ b/internal/notification/handlers/user_notifier_test.go @@ -45,6 +45,7 @@ const ( externalSecure = false externalProtocol = "http" defaultOTPEmailTemplate = "/otp/verify?loginName={{.LoginName}}&code={{.Code}}" + authRequestID = "authRequestID" ) func Test_userNotifier_reduceInitCodeAdded(t *testing.T) { @@ -128,7 +129,7 @@ func Test_userNotifier_reduceInitCodeAdded(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.URL}}" testCode := "testcode" - expectContent := fmt.Sprintf("%s/ui/login/user/init?userID=%s&loginname=%s&code=%s&orgID=%s&passwordset=%t", eventOrigin, userID, preferredLoginName, testCode, orgID, false) + expectContent := fmt.Sprintf("%s/ui/login/user/init?authRequestID=%s&code=%s&loginname=%s&orgID=%s&passwordset=%t&userID=%s", eventOrigin, "", testCode, preferredLoginName, orgID, false, userID) w.message = messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, @@ -162,7 +163,7 @@ func Test_userNotifier_reduceInitCodeAdded(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.URL}}" testCode := "testcode" - expectContent := fmt.Sprintf("%s://%s:%d/ui/login/user/init?userID=%s&loginname=%s&code=%s&orgID=%s&passwordset=%t", externalProtocol, instancePrimaryDomain, externalPort, userID, preferredLoginName, testCode, orgID, false) + expectContent := fmt.Sprintf("%s://%s:%d/ui/login/user/init?authRequestID=%s&code=%s&loginname=%s&orgID=%s&passwordset=%t&userID=%s", externalProtocol, instancePrimaryDomain, externalPort, "", testCode, preferredLoginName, orgID, false, userID) w.message = messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, @@ -196,6 +197,46 @@ func Test_userNotifier_reduceInitCodeAdded(t *testing.T) { }, }, w }, + }, { + name: "button url without event trigger url with authRequestID", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.URL}}" + testCode := "testcode" + expectContent := fmt.Sprintf("%s://%s:%d/ui/login/user/init?authRequestID=%s&code=%s&loginname=%s&orgID=%s&passwordset=%t&userID=%s", externalProtocol, instancePrimaryDomain, externalPort, authRequestID, testCode, preferredLoginName, orgID, false, userID) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().HumanInitCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanInitialCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + AuthRequestID: authRequestID, + }, + }, w + }, }} // TODO: Why don't we have an url template on user.HumanInitialCodeAddedEvent? for _, tt := range tests { @@ -305,7 +346,7 @@ func Test_userNotifier_reduceEmailCodeAdded(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.URL}}" testCode := "testcode" - expectContent := fmt.Sprintf("%s/ui/login/mail/verification?userID=%s&code=%s&orgID=%s", eventOrigin, userID, testCode, orgID) + expectContent := fmt.Sprintf("%s/ui/login/mail/verification?authRequestID=%s&code=%s&orgID=%s&userID=%s", eventOrigin, "", testCode, orgID, userID) w.message = messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, @@ -342,7 +383,7 @@ func Test_userNotifier_reduceEmailCodeAdded(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.URL}}" testCode := "testcode" - expectContent := fmt.Sprintf("%s://%s:%d/ui/login/mail/verification?userID=%s&code=%s&orgID=%s", externalProtocol, instancePrimaryDomain, externalPort, userID, testCode, orgID) + expectContent := fmt.Sprintf("%s://%s:%d/ui/login/mail/verification?authRequestID=%s&code=%s&orgID=%s&userID=%s", externalProtocol, instancePrimaryDomain, externalPort, "", testCode, orgID, userID) w.message = messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, @@ -378,6 +419,48 @@ func Test_userNotifier_reduceEmailCodeAdded(t *testing.T) { }, }, w }, + }, { + name: "button url without event trigger url with authRequestID", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.URL}}" + testCode := "testcode" + expectContent := fmt.Sprintf("%s://%s:%d/ui/login/mail/verification?authRequestID=%s&code=%s&orgID=%s&userID=%s", externalProtocol, instancePrimaryDomain, externalPort, authRequestID, testCode, orgID, userID) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().HumanEmailVerificationCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanEmailCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + AuthRequestID: authRequestID, + }, + }, w + }, }, { name: "button url with url template and event trigger url", test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { @@ -524,7 +607,7 @@ func Test_userNotifier_reducePasswordCodeAdded(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.URL}}" testCode := "testcode" - expectContent := fmt.Sprintf("%s/ui/login/password/init?userID=%s&code=%s&orgID=%s", eventOrigin, userID, testCode, orgID) + expectContent := fmt.Sprintf("%s/ui/login/password/init?authRequestID=%s&code=%s&orgID=%s&userID=%s", eventOrigin, "", testCode, orgID, userID) w.message = messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, @@ -561,7 +644,7 @@ func Test_userNotifier_reducePasswordCodeAdded(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.URL}}" testCode := "testcode" - expectContent := fmt.Sprintf("%s://%s:%d/ui/login/password/init?userID=%s&code=%s&orgID=%s", externalProtocol, instancePrimaryDomain, externalPort, userID, testCode, orgID) + expectContent := fmt.Sprintf("%s://%s:%d/ui/login/password/init?authRequestID=%s&code=%s&orgID=%s&userID=%s", externalProtocol, instancePrimaryDomain, externalPort, "", testCode, orgID, userID) w.message = messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, @@ -597,6 +680,48 @@ func Test_userNotifier_reducePasswordCodeAdded(t *testing.T) { }, }, w }, + }, { + name: "button url without event trigger url with authRequestID", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.URL}}" + testCode := "testcode" + expectContent := fmt.Sprintf("%s://%s:%d/ui/login/password/init?authRequestID=%s&code=%s&orgID=%s&userID=%s", externalProtocol, instancePrimaryDomain, externalPort, authRequestID, testCode, orgID, userID) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanPasswordCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + AuthRequestID: authRequestID, + }, + }, w + }, }, { name: "button url with url template and event trigger url", test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { diff --git a/internal/notification/types/email_verification_code.go b/internal/notification/types/email_verification_code.go index b2f34acd6c..912e846a92 100644 --- a/internal/notification/types/email_verification_code.go +++ b/internal/notification/types/email_verification_code.go @@ -10,10 +10,10 @@ import ( "github.com/zitadel/zitadel/internal/query" ) -func (notify Notify) SendEmailVerificationCode(ctx context.Context, user *query.NotifyUser, code string, urlTmpl string) error { +func (notify Notify) SendEmailVerificationCode(ctx context.Context, user *query.NotifyUser, code string, urlTmpl, authRequestID string) error { var url string if urlTmpl == "" { - url = login.MailVerificationLink(http_utils.ComposedOrigin(ctx), user.ID, code, user.ResourceOwner) + url = login.MailVerificationLink(http_utils.ComposedOrigin(ctx), user.ID, code, user.ResourceOwner, authRequestID) } else { var buf strings.Builder if err := domain.RenderConfirmURLTemplate(&buf, urlTmpl, user.ID, code, user.ResourceOwner); err != nil { diff --git a/internal/notification/types/email_verification_code_test.go b/internal/notification/types/email_verification_code_test.go index 38aeb987ac..3f538b3fb6 100644 --- a/internal/notification/types/email_verification_code_test.go +++ b/internal/notification/types/email_verification_code_test.go @@ -15,10 +15,11 @@ import ( func TestNotify_SendEmailVerificationCode(t *testing.T) { type args struct { - user *query.NotifyUser - origin string - code string - urlTmpl string + user *query.NotifyUser + origin string + code string + urlTmpl string + authRequestID string } tests := []struct { name string @@ -33,12 +34,13 @@ func TestNotify_SendEmailVerificationCode(t *testing.T) { ID: "user1", ResourceOwner: "org1", }, - origin: "https://example.com", - code: "123", - urlTmpl: "", + origin: "https://example.com", + code: "123", + urlTmpl: "", + authRequestID: "authRequestID", }, want: ¬ifyResult{ - url: "https://example.com/ui/login/mail/verification?userID=user1&code=123&orgID=org1", + url: "https://example.com/ui/login/mail/verification?authRequestID=authRequestID&code=123&orgID=org1&userID=user1", args: map[string]interface{}{"Code": "123"}, messageType: domain.VerifyEmailMessageType, allowUnverifiedNotificationChannel: true, @@ -51,9 +53,10 @@ func TestNotify_SendEmailVerificationCode(t *testing.T) { ID: "user1", ResourceOwner: "org1", }, - origin: "https://example.com", - code: "123", - urlTmpl: "{{", + origin: "https://example.com", + code: "123", + urlTmpl: "{{", + authRequestID: "authRequestID", }, want: ¬ifyResult{}, wantErr: zerrors.ThrowInvalidArgument(nil, "DOMAIN-oGh5e", "Errors.User.InvalidURLTemplate"), @@ -65,9 +68,10 @@ func TestNotify_SendEmailVerificationCode(t *testing.T) { ID: "user1", ResourceOwner: "org1", }, - origin: "https://example.com", - code: "123", - urlTmpl: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + origin: "https://example.com", + code: "123", + urlTmpl: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + authRequestID: "authRequestID", }, want: ¬ifyResult{ url: "https://example.com/email/verify?userID=user1&code=123&orgID=org1", @@ -80,7 +84,7 @@ func TestNotify_SendEmailVerificationCode(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, notify := mockNotify() - err := notify.SendEmailVerificationCode(http_utils.WithComposedOrigin(context.Background(), tt.args.origin), tt.args.user, tt.args.code, tt.args.urlTmpl) + err := notify.SendEmailVerificationCode(http_utils.WithComposedOrigin(context.Background(), tt.args.origin), tt.args.user, tt.args.code, tt.args.urlTmpl, tt.args.authRequestID) require.ErrorIs(t, err, tt.wantErr) assert.Equal(t, tt.want, got) }) diff --git a/internal/notification/types/init_code.go b/internal/notification/types/init_code.go index 0e30e991d0..11ea75ab27 100644 --- a/internal/notification/types/init_code.go +++ b/internal/notification/types/init_code.go @@ -9,8 +9,8 @@ import ( "github.com/zitadel/zitadel/internal/query" ) -func (notify Notify) SendUserInitCode(ctx context.Context, user *query.NotifyUser, code string) error { - url := login.InitUserLink(http_utils.ComposedOrigin(ctx), user.ID, user.PreferredLoginName, code, user.ResourceOwner, user.PasswordSet) +func (notify Notify) SendUserInitCode(ctx context.Context, user *query.NotifyUser, code, authRequestID string) error { + url := login.InitUserLink(http_utils.ComposedOrigin(ctx), user.ID, user.PreferredLoginName, code, user.ResourceOwner, user.PasswordSet, authRequestID) args := make(map[string]interface{}) args["Code"] = code return notify(url, args, domain.InitCodeMessageType, true) diff --git a/internal/notification/types/password_code.go b/internal/notification/types/password_code.go index b1121f9d14..c5616e6e24 100644 --- a/internal/notification/types/password_code.go +++ b/internal/notification/types/password_code.go @@ -10,10 +10,10 @@ import ( "github.com/zitadel/zitadel/internal/query" ) -func (notify Notify) SendPasswordCode(ctx context.Context, user *query.NotifyUser, code, urlTmpl string) error { +func (notify Notify) SendPasswordCode(ctx context.Context, user *query.NotifyUser, code, urlTmpl, authRequestID string) error { var url string if urlTmpl == "" { - url = login.InitPasswordLink(http_utils.ComposedOrigin(ctx), user.ID, code, user.ResourceOwner) + url = login.InitPasswordLink(http_utils.ComposedOrigin(ctx), user.ID, code, user.ResourceOwner, authRequestID) } else { var buf strings.Builder if err := domain.RenderConfirmURLTemplate(&buf, urlTmpl, user.ID, code, user.ResourceOwner); err != nil { diff --git a/internal/repository/user/human.go b/internal/repository/user/human.go index c53b9e03d6..ab5b76d8ae 100644 --- a/internal/repository/user/human.go +++ b/internal/repository/user/human.go @@ -157,6 +157,8 @@ type HumanRegisteredEvent struct { Secret *crypto.CryptoValue `json:"secret,omitempty"` // legacy EncodedHash string `json:"encodedHash,omitempty"` ChangeRequired bool `json:"changeRequired,omitempty"` + + UserAgentID string `json:"userAgentID,omitempty"` } func (e *HumanRegisteredEvent) Payload() interface{} { @@ -208,6 +210,7 @@ func NewHumanRegisteredEvent( gender domain.Gender, emailAddress domain.EmailAddress, userLoginMustBeDomain bool, + userAgentID string, ) *HumanRegisteredEvent { return &HumanRegisteredEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -224,6 +227,7 @@ func NewHumanRegisteredEvent( Gender: gender, EmailAddress: emailAddress, userLoginMustBeDomain: userLoginMustBeDomain, + UserAgentID: userAgentID, } } @@ -244,6 +248,7 @@ type HumanInitialCodeAddedEvent struct { Code *crypto.CryptoValue `json:"code,omitempty"` Expiry time.Duration `json:"expiry,omitempty"` TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` + AuthRequestID string `json:"authRequestID,omitempty"` } func (e *HumanInitialCodeAddedEvent) Payload() interface{} { @@ -263,6 +268,7 @@ func NewHumanInitialCodeAddedEvent( aggregate *eventstore.Aggregate, code *crypto.CryptoValue, expiry time.Duration, + authRequestID string, ) *HumanInitialCodeAddedEvent { return &HumanInitialCodeAddedEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -273,6 +279,7 @@ func NewHumanInitialCodeAddedEvent( Code: code, Expiry: expiry, TriggeredAtOrigin: http.ComposedOrigin(ctx), + AuthRequestID: authRequestID, } } diff --git a/internal/repository/user/human_email.go b/internal/repository/user/human_email.go index b78017aed4..aeb2011c7a 100644 --- a/internal/repository/user/human_email.go +++ b/internal/repository/user/human_email.go @@ -126,6 +126,8 @@ type HumanEmailCodeAddedEvent struct { URLTemplate string `json:"url_template,omitempty"` CodeReturned bool `json:"code_returned,omitempty"` TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` + // AuthRequest is only used in V1 Login UI + AuthRequestID string `json:"authRequestID,omitempty"` } func (e *HumanEmailCodeAddedEvent) Payload() interface{} { @@ -145,8 +147,19 @@ func NewHumanEmailCodeAddedEvent( aggregate *eventstore.Aggregate, code *crypto.CryptoValue, expiry time.Duration, + authRequestID string, ) *HumanEmailCodeAddedEvent { - return NewHumanEmailCodeAddedEventV2(ctx, aggregate, code, expiry, "", false) + return &HumanEmailCodeAddedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanEmailCodeAddedType, + ), + Code: code, + Expiry: expiry, + TriggeredAtOrigin: http.ComposedOrigin(ctx), + AuthRequestID: authRequestID, + } } func NewHumanEmailCodeAddedEventV2( diff --git a/internal/repository/user/human_password.go b/internal/repository/user/human_password.go index e468cc8324..bf7ba278cd 100644 --- a/internal/repository/user/human_password.go +++ b/internal/repository/user/human_password.go @@ -87,6 +87,8 @@ type HumanPasswordCodeAddedEvent struct { URLTemplate string `json:"url_template,omitempty"` CodeReturned bool `json:"code_returned,omitempty"` TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` + // AuthRequest is only used in V1 Login UI + AuthRequestID string `json:"authRequestID,omitempty"` } func (e *HumanPasswordCodeAddedEvent) Payload() interface{} { @@ -107,8 +109,20 @@ func NewHumanPasswordCodeAddedEvent( code *crypto.CryptoValue, expiry time.Duration, notificationType domain.NotificationType, + authRequestID string, ) *HumanPasswordCodeAddedEvent { - return NewHumanPasswordCodeAddedEventV2(ctx, aggregate, code, expiry, notificationType, "", false) + return &HumanPasswordCodeAddedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanPasswordCodeAddedType, + ), + Code: code, + Expiry: expiry, + NotificationType: notificationType, + TriggeredAtOrigin: http.ComposedOrigin(ctx), + AuthRequestID: authRequestID, + } } func NewHumanPasswordCodeAddedEventV2( From 207b20ff0feb86533cb76d03e29d88e34e8503b8 Mon Sep 17 00:00:00 2001 From: Miguel Cabrerizo <30386061+doncicuto@users.noreply.github.com> Date: Thu, 25 Apr 2024 07:02:20 +0200 Subject: [PATCH 25/31] fix(console): orgs list is shown empty when org is removed (#7781) fix:active orgs not shown when org is removed Co-authored-by: Livio Spring --- .../orgs/org-detail/org-detail.component.ts | 35 +++++++++++++++---- console/src/app/services/admin.service.ts | 7 ++++ console/src/app/services/mgmt.service.ts | 3 +- console/src/assets/i18n/bg.json | 1 + console/src/assets/i18n/cs.json | 1 + console/src/assets/i18n/de.json | 1 + console/src/assets/i18n/en.json | 5 +-- console/src/assets/i18n/es.json | 1 + console/src/assets/i18n/fr.json | 1 + console/src/assets/i18n/it.json | 1 + console/src/assets/i18n/ja.json | 1 + console/src/assets/i18n/mk.json | 1 + console/src/assets/i18n/nl.json | 1 + console/src/assets/i18n/pl.json | 1 + console/src/assets/i18n/pt.json | 1 + console/src/assets/i18n/ru.json | 1 + console/src/assets/i18n/zh.json | 1 + 17 files changed, 53 insertions(+), 10 deletions(-) diff --git a/console/src/app/pages/orgs/org-detail/org-detail.component.ts b/console/src/app/pages/orgs/org-detail/org-detail.component.ts index 81413bafef..0de6696ac3 100644 --- a/console/src/app/pages/orgs/org-detail/org-detail.component.ts +++ b/console/src/app/pages/orgs/org-detail/org-detail.component.ts @@ -15,6 +15,7 @@ import { Member } from 'src/app/proto/generated/zitadel/member_pb'; import { Metadata } from 'src/app/proto/generated/zitadel/metadata_pb'; import { Org, OrgState } from 'src/app/proto/generated/zitadel/org_pb'; import { User } from 'src/app/proto/generated/zitadel/user_pb'; +import { AdminService } from 'src/app/services/admin.service'; import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service'; import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; import { ManagementService } from 'src/app/services/mgmt.service'; @@ -48,6 +49,7 @@ export class OrgDetailComponent implements OnInit, OnDestroy { private auth: GrpcAuthService, private dialog: MatDialog, public mgmtService: ManagementService, + private adminService: AdminService, private toast: ToastService, private router: Router, breadcrumbService: BreadcrumbService, @@ -146,15 +148,34 @@ export class OrgDetailComponent implements OnInit, OnDestroy { width: '400px', }); + // Before we remove the org we get the current default org + // we have to query before the current org is removed dialogRef.afterClosed().subscribe((resp) => { if (resp) { - this.mgmtService - .removeOrg() - .then(() => { - setTimeout(() => { - this.router.navigate(['/orgs']); - }, 1000); - this.toast.showInfo('ORG.TOAST.DELETED', true); + this.adminService + .getDefaultOrg() + .then((response) => { + const org = response?.org; + if (org) { + // We now remove the org + this.mgmtService + .removeOrg() + .then(() => { + setTimeout(() => { + // We change active org to default org as + // current org was deleted to avoid Organization doesn't exist + this.auth.setActiveOrg(org); + // Now we visit orgs + this.router.navigate(['/orgs']); + }, 1000); + this.toast.showInfo('ORG.TOAST.DELETED', true); + }) + .catch((error) => { + this.toast.showError(error); + }); + } else { + this.toast.showError('ORG.TOAST.DEFAULTORGNOTFOUND', false, true); + } }) .catch((error) => { this.toast.showError(error); diff --git a/console/src/app/services/admin.service.ts b/console/src/app/services/admin.service.ts index d1460b06f2..ca4c12b890 100644 --- a/console/src/app/services/admin.service.ts +++ b/console/src/app/services/admin.service.ts @@ -90,6 +90,8 @@ import { GetDefaultLanguageResponse, GetDefaultLoginTextsRequest, GetDefaultLoginTextsResponse, + GetDefaultOrgRequest, + GetDefaultOrgResponse, GetDefaultPasswordChangeMessageTextRequest, GetDefaultPasswordChangeMessageTextResponse, GetDefaultPasswordlessRegistrationMessageTextRequest, @@ -429,6 +431,11 @@ export class AdminService { this.storageService.getItem('onboarding-dismissed', StorageLocation.local) === 'true' ? true : false; } + public getDefaultOrg(): Promise { + const req = new GetDefaultOrgRequest(); + return this.grpcService.admin.getDefaultOrg(req, null).then((resp) => resp.toObject()); + } + public setDefaultOrg(orgId: string): Promise { const req = new SetDefaultOrgRequest(); req.setOrgId(orgId); diff --git a/console/src/app/services/mgmt.service.ts b/console/src/app/services/mgmt.service.ts index 0e3c0b3062..b167799b23 100644 --- a/console/src/app/services/mgmt.service.ts +++ b/console/src/app/services/mgmt.service.ts @@ -353,6 +353,7 @@ import { RemoveOrgMetadataRequest, RemoveOrgMetadataResponse, RemoveOrgRequest, + RemoveOrgResponse, RemovePersonalAccessTokenRequest, RemovePersonalAccessTokenResponse, RemoveProjectGrantMemberRequest, @@ -1749,7 +1750,7 @@ export class ManagementService { return this.grpcService.mgmt.removeUser(req, null).then((resp) => resp.toObject()); } - public removeOrg(): Promise { + public removeOrg(): Promise { const req = new RemoveOrgRequest(); return this.grpcService.mgmt.removeOrg(req, null).then((resp) => resp.toObject()); } diff --git a/console/src/assets/i18n/bg.json b/console/src/assets/i18n/bg.json index d606ceb64a..9ea937ec22 100644 --- a/console/src/assets/i18n/bg.json +++ b/console/src/assets/i18n/bg.json @@ -1285,6 +1285,7 @@ "MEMBERCHANGED": "Сменен управител.", "SETPRIMARY": "Основен набор от домейни.", "DELETED": "Организацията е изтрита успешно", + "DEFAULTORGNOTFOUND": "Организацията по подразбиране не беше намерена", "ORG_WAS_DELETED": "Организацията е изтрита." }, "DIALOG": { diff --git a/console/src/assets/i18n/cs.json b/console/src/assets/i18n/cs.json index c9a01468ac..b1c2e1c2f0 100644 --- a/console/src/assets/i18n/cs.json +++ b/console/src/assets/i18n/cs.json @@ -1292,6 +1292,7 @@ "MEMBERCHANGED": "Manažer změněn.", "SETPRIMARY": "Nastavena primární doména.", "DELETED": "Organizace úspěšně smazána", + "DEFAULTORGNOTFOUND": "Výchozí organizace nebyla nalezena", "ORG_WAS_DELETED": "Organizace byla smazána." }, "DIALOG": { diff --git a/console/src/assets/i18n/de.json b/console/src/assets/i18n/de.json index 7ea9423b05..4936d66aa2 100644 --- a/console/src/assets/i18n/de.json +++ b/console/src/assets/i18n/de.json @@ -1291,6 +1291,7 @@ "MEMBERCHANGED": "Manager geändert.", "SETPRIMARY": "Primäre Domain gesetzt.", "DELETED": "Organisation erfolgreich gelöscht", + "DEFAULTORGNOTFOUND": "Die Standardorganisation wurde nicht gefunden", "ORG_WAS_DELETED": "Organisation wurde gelöscht." }, "DIALOG": { diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json index 658f59ba9e..0a541cf418 100644 --- a/console/src/assets/i18n/en.json +++ b/console/src/assets/i18n/en.json @@ -1291,8 +1291,9 @@ "MEMBERREMOVED": "Manager removed.", "MEMBERCHANGED": "Manager changed.", "SETPRIMARY": "Primary domain set.", - "DELETED": "Organisation deleted successfully", - "ORG_WAS_DELETED": "Organisation has been deleted." + "DELETED": "Organization deleted successfully", + "DEFAULTORGNOTFOUND": "The default organization was not found", + "ORG_WAS_DELETED": "Organization has been deleted." }, "DIALOG": { "DEACTIVATE": { diff --git a/console/src/assets/i18n/es.json b/console/src/assets/i18n/es.json index ce50afa77d..9e9f2766e1 100644 --- a/console/src/assets/i18n/es.json +++ b/console/src/assets/i18n/es.json @@ -1293,6 +1293,7 @@ "MEMBERCHANGED": "Mánager modificado.", "SETPRIMARY": "Dominio primario establecido.", "DELETED": "Organización borrada con éxito", + "DEFAULTORGNOTFOUND": "No se encontró la organización por defecto", "ORG_WAS_DELETED": "La organización ha sido borrada." }, "DIALOG": { diff --git a/console/src/assets/i18n/fr.json b/console/src/assets/i18n/fr.json index 33a9bc11bb..ae81a43e06 100644 --- a/console/src/assets/i18n/fr.json +++ b/console/src/assets/i18n/fr.json @@ -1291,6 +1291,7 @@ "MEMBERCHANGED": "Gestionnaire modifié.", "SETPRIMARY": "Domaine primaire défini.", "DELETED": "Organisation supprimée avec succès", + "DEFAULTORGNOTFOUND": "L'organisation par défaut est introuvable", "ORG_WAS_DELETED": "L'organisation a été supprimée" }, "DIALOG": { diff --git a/console/src/assets/i18n/it.json b/console/src/assets/i18n/it.json index 3a1e32a0d0..55eddea64d 100644 --- a/console/src/assets/i18n/it.json +++ b/console/src/assets/i18n/it.json @@ -1291,6 +1291,7 @@ "MEMBERCHANGED": "Manager cambiato con successo", "SETPRIMARY": "Dominio primario cambiato con successo", "DELETED": "Organizzazione eliminata con successo", + "DEFAULTORGNOTFOUND": "Impossibile trovare l'organizzazione predefinita", "ORG_WAS_DELETED": "Organizzazione è stata eliminata" }, "DIALOG": { diff --git a/console/src/assets/i18n/ja.json b/console/src/assets/i18n/ja.json index 35b93bbb6b..3e30cffb11 100644 --- a/console/src/assets/i18n/ja.json +++ b/console/src/assets/i18n/ja.json @@ -1292,6 +1292,7 @@ "MEMBERCHANGED": "マネージャーが変更されました。", "SETPRIMARY": "プライマリドメインが設定されました。", "DELETED": "組織が正常に削除されました。", + "DEFAULTORGNOTFOUND": "デフォルトの組織が見つかりませんでした", "ORG_WAS_DELETED": "組織が削除されました。" }, "DIALOG": { diff --git a/console/src/assets/i18n/mk.json b/console/src/assets/i18n/mk.json index 03447afd33..c5bcd375da 100644 --- a/console/src/assets/i18n/mk.json +++ b/console/src/assets/i18n/mk.json @@ -1293,6 +1293,7 @@ "MEMBERCHANGED": "Променет менаџер.", "SETPRIMARY": "Поставен основен домен.", "DELETED": "Организацијата успешно избришана", + "DEFAULTORGNOTFOUND": "Стандардната организација не беше пронајдена", "ORG_WAS_DELETED": "Организацијата е избришана." }, "DIALOG": { diff --git a/console/src/assets/i18n/nl.json b/console/src/assets/i18n/nl.json index 767db991fd..c8ea11d950 100644 --- a/console/src/assets/i18n/nl.json +++ b/console/src/assets/i18n/nl.json @@ -1292,6 +1292,7 @@ "MEMBERCHANGED": "Beheerder gewijzigd.", "SETPRIMARY": "Primaire domein ingesteld.", "DELETED": "Organisatie succesvol verwijderd", + "DEFAULTORGNOTFOUND": "De standaardorganisatie is niet gevonden", "ORG_WAS_DELETED": "Organisatie is verwijderd." }, "DIALOG": { diff --git a/console/src/assets/i18n/pl.json b/console/src/assets/i18n/pl.json index c4f9591f3d..8e15255e5c 100644 --- a/console/src/assets/i18n/pl.json +++ b/console/src/assets/i18n/pl.json @@ -1291,6 +1291,7 @@ "MEMBERCHANGED": "Zmieniono managera.", "SETPRIMARY": "Ustawiono domenę podstawową.", "DELETED": "Organizacja została usunięta pomyślnie", + "DEFAULTORGNOTFOUND": "Nie znaleziono organizacji domyślnej", "ORG_WAS_DELETED": "Organizacja została usunięta." }, "DIALOG": { diff --git a/console/src/assets/i18n/pt.json b/console/src/assets/i18n/pt.json index 930fa9795d..4809efb02f 100644 --- a/console/src/assets/i18n/pt.json +++ b/console/src/assets/i18n/pt.json @@ -1293,6 +1293,7 @@ "MEMBERCHANGED": "Gerente alterado.", "SETPRIMARY": "Domínio principal definido.", "DELETED": "Organização excluída com sucesso", + "DEFAULTORGNOTFOUND": "A organização padrão não foi encontrada", "ORG_WAS_DELETED": "Organização foi excluída." }, "DIALOG": { diff --git a/console/src/assets/i18n/ru.json b/console/src/assets/i18n/ru.json index 491cbf120c..943de8dd1b 100644 --- a/console/src/assets/i18n/ru.json +++ b/console/src/assets/i18n/ru.json @@ -1335,6 +1335,7 @@ "MEMBERCHANGED": "Менеджер изменён.", "SETPRIMARY": "Установлен основной домен.", "DELETED": "Организация успешно удалена", + "DEFAULTORGNOTFOUND": "Организация по умолчанию не найдена", "ORG_WAS_DELETED": "Организация удалена." }, "DIALOG": { diff --git a/console/src/assets/i18n/zh.json b/console/src/assets/i18n/zh.json index 659a25907e..31d9dcb09a 100644 --- a/console/src/assets/i18n/zh.json +++ b/console/src/assets/i18n/zh.json @@ -1291,6 +1291,7 @@ "MEMBERCHANGED": "管理者以改变。", "SETPRIMARY": "已设为主域名。", "DELETED": "成功删除的组织", + "DEFAULTORGNOTFOUND": "未找到默认组织", "ORG_WAS_DELETED": "组织被删除" }, "DIALOG": { From 5131328291c5e134ca1f08b0e2bbda95f6efa108 Mon Sep 17 00:00:00 2001 From: Silvan Date: Thu, 25 Apr 2024 08:45:34 +0200 Subject: [PATCH 26/31] refactor(v2): init database package (#7802) --- internal/v2/database/filter.go | 33 ++ internal/v2/database/list_filter.go | 57 +++ internal/v2/database/list_filter_test.go | 122 +++++ internal/v2/database/mock/sql_mock.go | 139 ++++++ internal/v2/database/mock/type_converter.go | 78 +++ internal/v2/database/number_filter.go | 100 ++++ internal/v2/database/number_filter_test.go | 216 +++++++++ internal/v2/database/pagination.go | 17 + internal/v2/database/pagination_test.go | 73 +++ internal/v2/database/sql_helper.go | 75 +++ internal/v2/database/sql_helper_test.go | 512 ++++++++++++++++++++ internal/v2/database/statement.go | 222 +++++++++ internal/v2/database/statement_test.go | 73 +++ internal/v2/database/text_filter.go | 132 +++++ internal/v2/database/text_filter_test.go | 351 ++++++++++++++ 15 files changed, 2200 insertions(+) create mode 100644 internal/v2/database/filter.go create mode 100644 internal/v2/database/list_filter.go create mode 100644 internal/v2/database/list_filter_test.go create mode 100644 internal/v2/database/mock/sql_mock.go create mode 100644 internal/v2/database/mock/type_converter.go create mode 100644 internal/v2/database/number_filter.go create mode 100644 internal/v2/database/number_filter_test.go create mode 100644 internal/v2/database/pagination.go create mode 100644 internal/v2/database/pagination_test.go create mode 100644 internal/v2/database/sql_helper.go create mode 100644 internal/v2/database/sql_helper_test.go create mode 100644 internal/v2/database/statement.go create mode 100644 internal/v2/database/statement_test.go create mode 100644 internal/v2/database/text_filter.go create mode 100644 internal/v2/database/text_filter_test.go diff --git a/internal/v2/database/filter.go b/internal/v2/database/filter.go new file mode 100644 index 0000000000..3653abee79 --- /dev/null +++ b/internal/v2/database/filter.go @@ -0,0 +1,33 @@ +package database + +type Condition interface { + Write(stmt *Statement, columnName string) +} + +type Filter[C compare, V value] struct { + comp C + value V +} + +func (f Filter[C, V]) Write(stmt *Statement, columnName string) { + prepareWrite(stmt, columnName, f.comp) + stmt.WriteArg(f.value) +} + +func prepareWrite[C compare](stmt *Statement, columnName string, comp C) { + stmt.WriteString(columnName) + stmt.WriteRune(' ') + stmt.WriteString(comp.String()) + stmt.WriteRune(' ') +} + +type compare interface { + numberCompare | textCompare | listCompare + String() string +} + +type value interface { + number | text + // TODO: condition must know if it's args are named parameters or not + // number | text | placeholder +} diff --git a/internal/v2/database/list_filter.go b/internal/v2/database/list_filter.go new file mode 100644 index 0000000000..834a49ae0b --- /dev/null +++ b/internal/v2/database/list_filter.go @@ -0,0 +1,57 @@ +package database + +import "github.com/zitadel/logging" + +type ListFilter[V value] struct { + comp listCompare + list []V +} + +func NewListEquals[V value](list ...V) *ListFilter[V] { + return newListFilter[V](listEqual, list) +} + +func NewListContains[V value](list ...V) *ListFilter[V] { + return newListFilter[V](listContain, list) +} + +func NewListNotContains[V value](list ...V) *ListFilter[V] { + return newListFilter[V](listNotContain, list) +} + +func newListFilter[V value](comp listCompare, list []V) *ListFilter[V] { + return &ListFilter[V]{ + comp: comp, + list: list, + } +} + +func (f ListFilter[V]) Write(stmt *Statement, columnName string) { + if len(f.list) == 0 { + logging.WithFields("column", columnName).Debug("skip list filter because no entries defined") + return + } + if f.comp == listNotContain { + stmt.WriteString("NOT(") + } + stmt.WriteString(columnName) + stmt.WriteString(" = ") + if f.comp != listEqual { + stmt.WriteString("ANY(") + } + stmt.WriteArg(f.list) + if f.comp != listEqual { + stmt.WriteString(")") + } + if f.comp == listNotContain { + stmt.WriteRune(')') + } +} + +type listCompare uint8 + +const ( + listEqual listCompare = iota + listContain + listNotContain +) diff --git a/internal/v2/database/list_filter_test.go b/internal/v2/database/list_filter_test.go new file mode 100644 index 0000000000..9f5ffaad60 --- /dev/null +++ b/internal/v2/database/list_filter_test.go @@ -0,0 +1,122 @@ +package database + +import ( + "reflect" + "testing" +) + +func TestNewListConstructors(t *testing.T) { + type args struct { + constructor func(t ...string) *ListFilter[string] + t []string + } + tests := []struct { + name string + args args + want *ListFilter[string] + }{ + { + name: "NewListEquals", + args: args{ + constructor: NewListEquals[string], + t: []string{"as", "df"}, + }, + want: &ListFilter[string]{ + comp: listEqual, + list: []string{"as", "df"}, + }, + }, + { + name: "NewListContains", + args: args{ + constructor: NewListContains[string], + t: []string{"as", "df"}, + }, + want: &ListFilter[string]{ + comp: listContain, + list: []string{"as", "df"}, + }, + }, + { + name: "NewListNotContains", + args: args{ + constructor: NewListNotContains[string], + t: []string{"as", "df"}, + }, + want: &ListFilter[string]{ + comp: listNotContain, + list: []string{"as", "df"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.args.constructor(tt.args.t...); !reflect.DeepEqual(got, tt.want) { + t.Errorf("number constructor = %v, want %v", got, tt.want) + } + }) + } +} + +func TestNewListConditionWrite(t *testing.T) { + type args struct { + constructor func(t ...string) *ListFilter[string] + t []string + } + tests := []struct { + name string + args args + want wantQuery + }{ + { + name: "ListEquals", + args: args{ + constructor: NewListEquals[string], + t: []string{"as", "df"}, + }, + want: wantQuery{ + query: "test = $1", + args: []any{[]string{"as", "df"}}, + }, + }, + { + name: "ListContains", + args: args{ + constructor: NewListContains[string], + t: []string{"as", "df"}, + }, + want: wantQuery{ + query: "test = ANY($1)", + args: []any{[]string{"as", "df"}}, + }, + }, + { + name: "ListNotContains", + args: args{ + constructor: NewListNotContains[string], + t: []string{"as", "df"}, + }, + want: wantQuery{ + query: "NOT(test = ANY($1))", + args: []any{[]string{"as", "df"}}, + }, + }, + { + name: "empty list", + args: args{ + constructor: NewListNotContains[string], + }, + want: wantQuery{ + query: "", + args: nil, + }, + }, + } + for _, tt := range tests { + var stmt Statement + t.Run(tt.name, func(t *testing.T) { + tt.args.constructor(tt.args.t...).Write(&stmt, "test") + assertQuery(t, &stmt, tt.want) + }) + } +} diff --git a/internal/v2/database/mock/sql_mock.go b/internal/v2/database/mock/sql_mock.go new file mode 100644 index 0000000000..c693671d6f --- /dev/null +++ b/internal/v2/database/mock/sql_mock.go @@ -0,0 +1,139 @@ +package mock + +import ( + "database/sql" + "database/sql/driver" + "reflect" + "testing" + + "github.com/DATA-DOG/go-sqlmock" +) + +type SQLMock struct { + DB *sql.DB + mock sqlmock.Sqlmock +} + +type Expectation func(m sqlmock.Sqlmock) + +func NewSQLMock(t *testing.T, expectations ...Expectation) *SQLMock { + db, mock, err := sqlmock.New( + sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual), + sqlmock.ValueConverterOption(new(TypeConverter)), + ) + if err != nil { + t.Fatal("create mock failed", err) + } + + for _, expectation := range expectations { + expectation(mock) + } + + return &SQLMock{ + DB: db, + mock: mock, + } +} + +func (m *SQLMock) Assert(t *testing.T) { + t.Helper() + + if err := m.mock.ExpectationsWereMet(); err != nil { + t.Errorf("expectations not met: %v", err) + } + + m.DB.Close() +} + +func ExpectBegin(err error) Expectation { + return func(m sqlmock.Sqlmock) { + e := m.ExpectBegin() + if err != nil { + e.WillReturnError(err) + } + } +} + +func ExpectCommit(err error) Expectation { + return func(m sqlmock.Sqlmock) { + e := m.ExpectCommit() + if err != nil { + e.WillReturnError(err) + } + } +} + +type ExecOpt func(e *sqlmock.ExpectedExec) *sqlmock.ExpectedExec + +func WithExecArgs(args ...driver.Value) ExecOpt { + return func(e *sqlmock.ExpectedExec) *sqlmock.ExpectedExec { + return e.WithArgs(args...) + } +} + +func WithExecErr(err error) ExecOpt { + return func(e *sqlmock.ExpectedExec) *sqlmock.ExpectedExec { + return e.WillReturnError(err) + } +} + +func WithExecNoRowsAffected() ExecOpt { + return func(e *sqlmock.ExpectedExec) *sqlmock.ExpectedExec { + return e.WillReturnResult(driver.ResultNoRows) + } +} + +func WithExecRowsAffected(affected driver.RowsAffected) ExecOpt { + return func(e *sqlmock.ExpectedExec) *sqlmock.ExpectedExec { + return e.WillReturnResult(affected) + } +} + +func ExpectExec(stmt string, opts ...ExecOpt) Expectation { + return func(m sqlmock.Sqlmock) { + e := m.ExpectExec(stmt) + for _, opt := range opts { + e = opt(e) + } + } +} + +type QueryOpt func(m sqlmock.Sqlmock, e *sqlmock.ExpectedQuery) *sqlmock.ExpectedQuery + +func WithQueryArgs(args ...driver.Value) QueryOpt { + return func(_ sqlmock.Sqlmock, e *sqlmock.ExpectedQuery) *sqlmock.ExpectedQuery { + return e.WithArgs(args...) + } +} + +func WithQueryErr(err error) QueryOpt { + return func(_ sqlmock.Sqlmock, e *sqlmock.ExpectedQuery) *sqlmock.ExpectedQuery { + return e.WillReturnError(err) + } +} + +func WithQueryResult(columns []string, rows [][]driver.Value) QueryOpt { + return func(m sqlmock.Sqlmock, e *sqlmock.ExpectedQuery) *sqlmock.ExpectedQuery { + mockedRows := m.NewRows(columns) + for _, row := range rows { + mockedRows = mockedRows.AddRow(row...) + } + return e.WillReturnRows(mockedRows) + } +} + +func ExpectQuery(stmt string, opts ...QueryOpt) Expectation { + return func(m sqlmock.Sqlmock) { + e := m.ExpectQuery(stmt) + for _, opt := range opts { + e = opt(m, e) + } + } +} + +type AnyType[T interface{}] struct{} + +// Match satisfies sqlmock.Argument interface +func (a AnyType[T]) Match(v driver.Value) bool { + return reflect.TypeOf(new(T)).Elem().Kind().String() == reflect.TypeOf(v).Kind().String() +} diff --git a/internal/v2/database/mock/type_converter.go b/internal/v2/database/mock/type_converter.go new file mode 100644 index 0000000000..f27fc3456f --- /dev/null +++ b/internal/v2/database/mock/type_converter.go @@ -0,0 +1,78 @@ +package mock + +import ( + "database/sql/driver" + "encoding/hex" + "encoding/json" + "reflect" + "strconv" + "strings" +) + +var _ driver.ValueConverter = (*TypeConverter)(nil) + +type TypeConverter struct{} + +// ConvertValue converts a value to a driver Value. +func (s TypeConverter) ConvertValue(v any) (driver.Value, error) { + if driver.IsValue(v) { + return v, nil + } + value := reflect.ValueOf(v) + + if rawMessage, ok := v.(json.RawMessage); ok { + return convertBytes(rawMessage), nil + } + + if value.Kind() == reflect.Slice { + //nolint: exhaustive + // only defined types + switch value.Type().Elem().Kind() { + case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int: + return convertSigned(value), nil + case reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uint: + return convertUnsigned(value), nil + case reflect.String: + return convertText(value), nil + } + } + return v, nil +} + +// converts a text array to valid pgx v5 representation +func convertSigned(array reflect.Value) string { + slice := make([]string, array.Len()) + for i := 0; i < array.Len(); i++ { + slice[i] = strconv.FormatInt(array.Index(i).Int(), 10) + } + + return "{" + strings.Join(slice, ",") + "}" +} + +// converts a text array to valid pgx v5 representation +func convertUnsigned(array reflect.Value) string { + slice := make([]string, array.Len()) + for i := 0; i < array.Len(); i++ { + slice[i] = strconv.FormatUint(array.Index(i).Uint(), 10) + } + + return "{" + strings.Join(slice, ",") + "}" +} + +// converts a text array to valid pgx v5 representation +func convertText(array reflect.Value) string { + slice := make([]string, array.Len()) + for i := 0; i < array.Len(); i++ { + slice[i] = array.Index(i).String() + } + + return "{" + strings.Join(slice, ",") + "}" +} + +func convertBytes(array []byte) string { + var builder strings.Builder + builder.Grow(hex.EncodedLen(len(array)) + 4) + builder.WriteString(`\x`) + builder.Write(hex.AppendEncode(nil, array)) + return builder.String() +} diff --git a/internal/v2/database/number_filter.go b/internal/v2/database/number_filter.go new file mode 100644 index 0000000000..ce263ceeee --- /dev/null +++ b/internal/v2/database/number_filter.go @@ -0,0 +1,100 @@ +package database + +import ( + "time" + + "github.com/zitadel/logging" + "golang.org/x/exp/constraints" +) + +type NumberFilter[N number] struct { + Filter[numberCompare, N] +} + +func NewNumberEquals[N number](n N) *NumberFilter[N] { + return newNumberFilter(numberEqual, n) +} + +func NewNumberAtLeast[N number](n N) *NumberFilter[N] { + return newNumberFilter(numberAtLeast, n) +} + +func NewNumberAtMost[N number](n N) *NumberFilter[N] { + return newNumberFilter(numberAtMost, n) +} + +func NewNumberGreater[N number](n N) *NumberFilter[N] { + return newNumberFilter(numberGreater, n) +} + +func NewNumberLess[N number](n N) *NumberFilter[N] { + return newNumberFilter(numberLess, n) +} + +func NewNumberUnequal[N number](n N) *NumberFilter[N] { + return newNumberFilter(numberUnequal, n) +} + +func newNumberFilter[N number](comp numberCompare, n N) *NumberFilter[N] { + return &NumberFilter[N]{ + Filter: Filter[numberCompare, N]{ + comp: comp, + value: n, + }, + } +} + +// NumberBetweenFilter combines [AtLeast] and [AtMost] comparisons +type NumberBetweenFilter[N number] struct { + min, max N +} + +func NewNumberBetween[N number](min, max N) *NumberBetweenFilter[N] { + return &NumberBetweenFilter[N]{ + min: min, + max: max, + } +} + +func (f NumberBetweenFilter[N]) Write(stmt *Statement, columnName string) { + NewNumberAtLeast[N](f.min).Write(stmt, columnName) + stmt.WriteString(" AND ") + NewNumberAtMost[N](f.max).Write(stmt, columnName) +} + +type numberCompare uint8 + +const ( + numberEqual numberCompare = iota + numberAtLeast + numberAtMost + numberGreater + numberLess + numberUnequal +) + +func (c numberCompare) String() string { + switch c { + case numberEqual: + return "=" + case numberAtLeast: + return ">=" + case numberAtMost: + return "<=" + case numberGreater: + return ">" + case numberLess: + return "<" + case numberUnequal: + return "<>" + default: + logging.WithFields("compare", c).Panic("comparison type not implemented") + return "" + } +} + +type number interface { + constraints.Integer | constraints.Float | time.Time + // TODO: condition must know if it's args are named parameters or not + // constraints.Integer | constraints.Float | time.Time | placeholder +} diff --git a/internal/v2/database/number_filter_test.go b/internal/v2/database/number_filter_test.go new file mode 100644 index 0000000000..5f934b88e1 --- /dev/null +++ b/internal/v2/database/number_filter_test.go @@ -0,0 +1,216 @@ +package database + +import ( + "reflect" + "testing" +) + +func TestNewNumberConstructors(t *testing.T) { + type args struct { + constructor func(t int8) *NumberFilter[int8] + t int8 + } + tests := []struct { + name string + args args + want *NumberFilter[int8] + }{ + { + name: "NewNumberEqual", + args: args{ + constructor: NewNumberEquals[int8], + t: 10, + }, + want: &NumberFilter[int8]{ + Filter: Filter[numberCompare, int8]{ + comp: numberEqual, + value: 10, + }, + }, + }, + { + name: "NewNumberAtLeast", + args: args{ + constructor: NewNumberAtLeast[int8], + t: 10, + }, + want: &NumberFilter[int8]{ + Filter: Filter[numberCompare, int8]{ + comp: numberAtLeast, + value: 10, + }, + }, + }, + { + name: "NewNumberAtMost", + args: args{ + constructor: NewNumberAtMost[int8], + t: 10, + }, + want: &NumberFilter[int8]{ + Filter: Filter[numberCompare, int8]{ + comp: numberAtMost, + value: 10, + }, + }, + }, + { + name: "NewNumberGreater", + args: args{ + constructor: NewNumberGreater[int8], + t: 10, + }, + want: &NumberFilter[int8]{ + Filter: Filter[numberCompare, int8]{ + comp: numberGreater, + value: 10, + }, + }, + }, + { + name: "NewNumberLess", + args: args{ + constructor: NewNumberLess[int8], + t: 10, + }, + want: &NumberFilter[int8]{ + Filter: Filter[numberCompare, int8]{ + comp: numberLess, + value: 10, + }, + }, + }, + { + name: "NewNumberUnequal", + args: args{ + constructor: NewNumberUnequal[int8], + t: 10, + }, + want: &NumberFilter[int8]{ + Filter: Filter[numberCompare, int8]{ + comp: numberUnequal, + value: 10, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.args.constructor(tt.args.t); !reflect.DeepEqual(got, tt.want) { + t.Errorf("number constructor = %v, want %v", got, tt.want) + } + }) + } +} + +func TestNewNumberConditionWrite(t *testing.T) { + type args struct { + constructor func(t int8) *NumberFilter[int8] + t int8 + } + tests := []struct { + name string + args args + want wantQuery + }{ + { + name: "NewNumberEqual", + args: args{ + constructor: NewNumberEquals[int8], + t: 10, + }, + want: wantQuery{ + query: "test = $1", + args: []any{int8(10)}, + }, + }, + { + name: "NewNumberAtLeast", + args: args{ + constructor: NewNumberAtLeast[int8], + t: 10, + }, + want: wantQuery{ + query: "test >= $1", + args: []any{int8(10)}, + }, + }, + { + name: "NewNumberAtMost", + args: args{ + constructor: NewNumberAtMost[int8], + t: 10, + }, + want: wantQuery{ + query: "test <= $1", + args: []any{int8(10)}, + }, + }, + { + name: "NewNumberGreater", + args: args{ + constructor: NewNumberGreater[int8], + t: 10, + }, + want: wantQuery{ + query: "test > $1", + args: []any{int8(10)}, + }, + }, + { + name: "NewNumberLess", + args: args{ + constructor: NewNumberLess[int8], + t: 10, + }, + want: wantQuery{ + query: "test < $1", + args: []any{int8(10)}, + }, + }, + { + name: "NewNumberUnequal", + args: args{ + constructor: NewNumberUnequal[int8], + t: 10, + }, + want: wantQuery{ + query: "test <> $1", + args: []any{int8(10)}, + }, + }, + } + for _, tt := range tests { + var stmt Statement + t.Run(tt.name, func(t *testing.T) { + tt.args.constructor(tt.args.t).Write(&stmt, "test") + assertQuery(t, &stmt, tt.want) + }) + } +} + +func TestNumberBetween(t *testing.T) { + filter := NewNumberBetween[int8](10, 20) + + if !reflect.DeepEqual(filter, &NumberBetweenFilter[int8]{min: 10, max: 20}) { + t.Errorf("unexpected filter: %v", filter) + } + + var stmt Statement + filter.Write(&stmt, "test") + if stmt.String() != "test >= $1 AND test <= $2" { + t.Errorf("unexpected query: got: %q", stmt.String()) + } + + if len(stmt.Args()) != 2 { + t.Errorf("unexpected length of args: got %d", len(stmt.Args())) + return + } + + if !reflect.DeepEqual(int8(10), stmt.Args()[0]) { + t.Errorf("unexpected arg at position 0: want: 10, got: %v", stmt.Args()[0]) + } + if !reflect.DeepEqual(int8(20), stmt.Args()[1]) { + t.Errorf("unexpected arg at position 1: want: 20, got: %v", stmt.Args()[1]) + } +} diff --git a/internal/v2/database/pagination.go b/internal/v2/database/pagination.go new file mode 100644 index 0000000000..07d83fb2e3 --- /dev/null +++ b/internal/v2/database/pagination.go @@ -0,0 +1,17 @@ +package database + +type Pagination struct { + Limit uint32 + Offset uint32 +} + +func (p *Pagination) Write(stmt *Statement) { + if p.Limit > 0 { + stmt.WriteString(" LIMIT ") + stmt.WriteArg(p.Limit) + } + if p.Offset > 0 { + stmt.WriteString(" OFFSET ") + stmt.WriteArg(p.Offset) + } +} diff --git a/internal/v2/database/pagination_test.go b/internal/v2/database/pagination_test.go new file mode 100644 index 0000000000..2158e8f8f4 --- /dev/null +++ b/internal/v2/database/pagination_test.go @@ -0,0 +1,73 @@ +package database + +import ( + "testing" +) + +func TestPagination_Write(t *testing.T) { + type fields struct { + Limit uint32 + Offset uint32 + } + tests := []struct { + name string + fields fields + want wantQuery + }{ + { + name: "no values", + fields: fields{ + Limit: 0, + Offset: 0, + }, + want: wantQuery{ + query: "", + args: []any{}, + }, + }, + { + name: "limit", + fields: fields{ + Limit: 10, + Offset: 0, + }, + want: wantQuery{ + query: " LIMIT $1", + args: []any{uint32(10)}, + }, + }, + { + name: "offset", + fields: fields{ + Limit: 0, + Offset: 10, + }, + want: wantQuery{ + query: " OFFSET $1", + args: []any{uint32(10)}, + }, + }, + { + name: "both", + fields: fields{ + Limit: 10, + Offset: 10, + }, + want: wantQuery{ + query: " LIMIT $1 OFFSET $2", + args: []any{uint32(10), uint32(10)}, + }, + }, + } + for _, tt := range tests { + var stmt Statement + t.Run(tt.name, func(t *testing.T) { + p := &Pagination{ + Limit: tt.fields.Limit, + Offset: tt.fields.Offset, + } + p.Write(&stmt) + assertQuery(t, &stmt, tt.want) + }) + } +} diff --git a/internal/v2/database/sql_helper.go b/internal/v2/database/sql_helper.go new file mode 100644 index 0000000000..4efa2f6d92 --- /dev/null +++ b/internal/v2/database/sql_helper.go @@ -0,0 +1,75 @@ +package database + +import ( + "context" + "database/sql" + + "github.com/zitadel/logging" +) + +type Tx interface { + Commit() error + Rollback() error +} + +func CloseTx(tx Tx, err error) error { + if err != nil { + rollbackErr := tx.Rollback() + logging.OnError(rollbackErr).Debug("unable to rollback") + return err + } + + return tx.Commit() +} + +type DestMapper[R any] func(index int, scan func(dest ...any) error) (*R, error) + +type Rows interface { + Close() error + Err() error + Next() bool + Scan(dest ...any) error +} + +func MapRows[R any](rows Rows, mapper DestMapper[R]) (result []*R, err error) { + defer func() { + closeErr := rows.Close() + logging.OnError(closeErr).Debug("unable to close rows") + + if err == nil && rows.Err() != nil { + result = nil + err = rows.Err() + } + }() + for i := 0; rows.Next(); i++ { + res, err := mapper(i, rows.Scan) + if err != nil { + return nil, err + } + result = append(result, res) + } + + return result, nil +} + +func MapRowsToObject(rows Rows, mapper func(scan func(dest ...any) error) error) (err error) { + defer func() { + closeErr := rows.Close() + logging.OnError(closeErr).Debug("unable to close rows") + + if err == nil && rows.Err() != nil { + err = rows.Err() + } + }() + for rows.Next() { + err = mapper(rows.Scan) + if err != nil { + return err + } + } + return nil +} + +type Querier interface { + QueryContext(context.Context, string, ...any) (*sql.Rows, error) +} diff --git a/internal/v2/database/sql_helper_test.go b/internal/v2/database/sql_helper_test.go new file mode 100644 index 0000000000..c59e4221ec --- /dev/null +++ b/internal/v2/database/sql_helper_test.go @@ -0,0 +1,512 @@ +package database + +import ( + "errors" + "reflect" + "testing" +) + +func TestCloseTx(t *testing.T) { + type args struct { + tx *testTx + err error + } + tests := []struct { + name string + args args + assertErr func(t *testing.T, err error) bool + }{ + { + name: "exec err", + args: args{ + tx: &testTx{ + rollback: execution{ + shouldExecute: true, + }, + }, + err: errExec, + }, + assertErr: func(t *testing.T, err error) bool { + is := errors.Is(err, errExec) + if !is { + t.Errorf("execution error expected, got: %v", err) + } + return is + }, + }, + { + name: "exec err and rollback err", + args: args{ + tx: &testTx{ + rollback: execution{ + err: true, + shouldExecute: true, + }, + }, + err: errExec, + }, + assertErr: func(t *testing.T, err error) bool { + is := errors.Is(err, errExec) + if !is { + t.Errorf("execution error expected, got: %v", err) + } + return is + }, + }, + { + name: "commit Err", + args: args{ + tx: &testTx{ + commit: execution{ + err: true, + shouldExecute: true, + }, + }, + err: nil, + }, + assertErr: func(t *testing.T, err error) bool { + is := errors.Is(err, errCommit) + if !is { + t.Errorf("commit error expected, got: %v", err) + } + return is + }, + }, + { + name: "no err", + args: args{ + tx: &testTx{ + commit: execution{ + shouldExecute: true, + }, + }, + err: nil, + }, + assertErr: func(t *testing.T, err error) bool { + is := err == nil + if !is { + t.Errorf("no error expected, got: %v", err) + } + return is + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := CloseTx(tt.args.tx, tt.args.err) + tt.assertErr(t, err) + tt.args.tx.assert(t) + }) + } +} + +func TestMapRows(t *testing.T) { + type args struct { + rows *testRows + mapper DestMapper[string] + } + var emptyString string + tests := []struct { + name string + args args + wantResult []*string + assertErr func(t *testing.T, err error) bool + }{ + { + name: "no rows, close err", + args: args{ + rows: &testRows{ + closeErr: true, + }, + mapper: nil, + }, + wantResult: nil, + assertErr: func(t *testing.T, err error) bool { + is := errors.Is(err, errClose) + if !is { + t.Errorf("close error expected, got: %v", err) + } + return is + }, + }, + { + name: "no rows, close err", + args: args{ + rows: &testRows{ + hasErr: true, + }, + mapper: nil, + }, + wantResult: nil, + assertErr: func(t *testing.T, err error) bool { + is := errors.Is(err, errRows) + if !is { + t.Errorf("rows error expected, got: %v", err) + } + return is + }, + }, + { + name: "scan err", + args: args{ + rows: &testRows{ + scanErr: true, + nextCount: 1, + }, + mapper: func(index int, scan func(dest ...any) error) (*string, error) { + var s string + if err := scan(&s); err != nil { + return nil, err + } + return &s, nil + }, + }, + wantResult: nil, + assertErr: func(t *testing.T, err error) bool { + is := errors.Is(err, errScan) + if !is { + t.Errorf("scan error expected, got: %v", err) + } + return is + }, + }, + { + name: "exec err", + args: args{ + rows: &testRows{ + nextCount: 1, + }, + mapper: func(index int, scan func(dest ...any) error) (*string, error) { + return nil, errExec + }, + }, + wantResult: nil, + assertErr: func(t *testing.T, err error) bool { + is := errors.Is(err, errExec) + if !is { + t.Errorf("exec error expected, got: %v", err) + } + return is + }, + }, + { + name: "exec err, close err", + args: args{ + rows: &testRows{ + closeErr: true, + nextCount: 1, + }, + mapper: func(index int, scan func(dest ...any) error) (*string, error) { + return nil, errExec + }, + }, + wantResult: nil, + assertErr: func(t *testing.T, err error) bool { + is := errors.Is(err, errExec) + if !is { + t.Errorf("exec error expected, got: %v", err) + } + return is + }, + }, + { + name: "rows err", + args: args{ + rows: &testRows{ + nextCount: 1, + hasErr: true, + }, + mapper: func(index int, scan func(dest ...any) error) (*string, error) { + var s string + if err := scan(&s); err != nil { + return nil, err + } + return &s, nil + }, + }, + wantResult: nil, + assertErr: func(t *testing.T, err error) bool { + is := errors.Is(err, errRows) + if !is { + t.Errorf("rows error expected, got: %v", err) + } + return is + }, + }, + { + name: "no err", + args: args{ + rows: &testRows{ + nextCount: 1, + }, + mapper: func(index int, scan func(dest ...any) error) (*string, error) { + var s string + if err := scan(&s); err != nil { + return nil, err + } + return &s, nil + }, + }, + wantResult: []*string{&emptyString}, + assertErr: func(t *testing.T, err error) bool { + is := err == nil + if !is { + t.Errorf("no error expected, got: %v", err) + } + return is + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotResult, err := MapRows(tt.args.rows, tt.args.mapper) + tt.assertErr(t, err) + if !reflect.DeepEqual(gotResult, tt.wantResult) { + t.Errorf("MapRows() = %v, want %v", gotResult, tt.wantResult) + } + }) + } +} + +func TestMapRowsToObject(t *testing.T) { + type args struct { + rows *testRows + mapper func(scan func(dest ...any) error) error + } + tests := []struct { + name string + args args + assertErr func(t *testing.T, err error) bool + }{ + { + name: "no rows, close err", + args: args{ + rows: &testRows{ + closeErr: true, + }, + mapper: nil, + }, + assertErr: func(t *testing.T, err error) bool { + is := errors.Is(err, errClose) + if !is { + t.Errorf("close error expected, got: %v", err) + } + return is + }, + }, + { + name: "no rows, close err", + args: args{ + rows: &testRows{ + hasErr: true, + }, + mapper: nil, + }, + assertErr: func(t *testing.T, err error) bool { + is := errors.Is(err, errRows) + if !is { + t.Errorf("rows error expected, got: %v", err) + } + return is + }, + }, + { + name: "scan err", + args: args{ + rows: &testRows{ + scanErr: true, + nextCount: 1, + }, + mapper: func(scan func(dest ...any) error) error { + var s string + if err := scan(&s); err != nil { + return err + } + return nil + }, + }, + assertErr: func(t *testing.T, err error) bool { + is := errors.Is(err, errScan) + if !is { + t.Errorf("scan error expected, got: %v", err) + } + return is + }, + }, + { + name: "exec err", + args: args{ + rows: &testRows{ + nextCount: 1, + }, + mapper: func(scan func(dest ...any) error) error { + return errExec + }, + }, + assertErr: func(t *testing.T, err error) bool { + is := errors.Is(err, errExec) + if !is { + t.Errorf("exec error expected, got: %v", err) + } + return is + }, + }, + { + name: "exec err, close err", + args: args{ + rows: &testRows{ + closeErr: true, + nextCount: 1, + }, + mapper: func(scan func(dest ...any) error) error { + return errExec + }, + }, + assertErr: func(t *testing.T, err error) bool { + is := errors.Is(err, errExec) + if !is { + t.Errorf("exec error expected, got: %v", err) + } + return is + }, + }, + { + name: "rows err", + args: args{ + rows: &testRows{ + nextCount: 1, + hasErr: true, + }, + mapper: func(scan func(dest ...any) error) error { + var s string + return scan(&s) + }, + }, + assertErr: func(t *testing.T, err error) bool { + is := errors.Is(err, errRows) + if !is { + t.Errorf("rows error expected, got: %v", err) + } + return is + }, + }, + { + name: "no err", + args: args{ + rows: &testRows{ + nextCount: 1, + }, + mapper: func(scan func(dest ...any) error) error { + var s string + return scan(&s) + }, + }, + assertErr: func(t *testing.T, err error) bool { + is := err == nil + if !is { + t.Errorf("no error expected, got: %v", err) + } + return is + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := MapRowsToObject(tt.args.rows, tt.args.mapper) + tt.assertErr(t, err) + }) + } +} + +var _ Tx = (*testTx)(nil) + +type testTx struct { + commit, rollback execution +} + +type execution struct { + err bool + didExecute bool + shouldExecute bool +} + +var ( + errCommit = errors.New("commit err") + errRollback = errors.New("rollback err") + errExec = errors.New("exec err") +) + +// Commit implements Tx. +func (t *testTx) Commit() error { + t.commit.didExecute = true + if t.commit.err { + return errCommit + } + return nil +} + +// Rollback implements Tx. +func (t *testTx) Rollback() error { + t.rollback.didExecute = true + if t.rollback.err { + return errRollback + } + return nil +} + +func (tx *testTx) assert(t *testing.T) { + if tx.commit.didExecute != tx.commit.shouldExecute { + t.Errorf("unexpected execution of commit: should %v, did: %v", tx.commit.shouldExecute, tx.commit.didExecute) + } + if tx.rollback.didExecute != tx.rollback.shouldExecute { + t.Errorf("unexpected execution of rollback: should %v, did: %v", tx.rollback.shouldExecute, tx.rollback.didExecute) + } +} + +var _ Rows = (*testRows)(nil) + +var ( + errClose = errors.New("err close") + errRows = errors.New("err rows") + errScan = errors.New("err scan") +) + +type testRows struct { + closeErr bool + scanErr bool + hasErr bool + nextCount int +} + +// Close implements Rows. +func (t *testRows) Close() error { + if t.closeErr { + return errClose + } + return nil +} + +// Err implements Rows. +func (t *testRows) Err() error { + if t.hasErr { + return errRows + } + if t.closeErr { + return errClose + } + return nil +} + +// Next implements Rows. +func (t *testRows) Next() bool { + t.nextCount-- + return t.nextCount >= 0 +} + +// Scan implements Rows. +func (t *testRows) Scan(dest ...any) error { + if t.scanErr { + return errScan + } + return nil +} diff --git a/internal/v2/database/statement.go b/internal/v2/database/statement.go new file mode 100644 index 0000000000..08fbe8aa6c --- /dev/null +++ b/internal/v2/database/statement.go @@ -0,0 +1,222 @@ +package database + +import ( + "fmt" + "slices" + "strconv" + "strings" + "time" + "unsafe" + + "github.com/zitadel/logging" +) + +type Statement struct { + addr *Statement + builder strings.Builder + + args []any + // key is the name of the arg and value is the placeholder + // TODO: condition must know if it's args are named parameters or not + // namedArgs map[placeholder]string +} + +func (stmt *Statement) Args() []any { + if stmt == nil { + return nil + } + return stmt.args +} + +func (stmt *Statement) Reset() { + stmt.builder.Reset() + stmt.addr = nil + stmt.args = nil +} + +// TODO: condition must know if it's args are named parameters or not +// SetNamedArg sets the arg and makes it available for query construction +// func (stmt *Statement) SetNamedArg(name placeholder, value any) (placeholder string) { +// stmt.copyCheck() +// stmt.args = append(stmt.args, value) +// placeholder = fmt.Sprintf("$%d", len(stmt.args)) +// if !strings.HasPrefix(name.string, "@") { +// name.string = "@" + name.string +// } +// stmt.namedArgs[name] = placeholder +// return placeholder +// } + +// AppendArgs appends the args without writing it to Builder +// if any arg is a [placeholder] it's replaced with the placeholders parameter +func (stmt *Statement) AppendArgs(args ...any) { + stmt.copyCheck() + stmt.args = slices.Grow(stmt.args, len(args)) + for _, arg := range args { + stmt.AppendArg(arg) + } +} + +// AppendArg appends the arg without writing it to Builder +// if the arg is a [placeholder] it's replaced with the placeholders parameter +func (stmt *Statement) AppendArg(arg any) int { + stmt.copyCheck() + + // TODO: condition must know if it's args are named parameters or not + // if namedArg, ok := arg.(sql.NamedArg); ok { + // stmt.SetNamedArg(placeholder{namedArg.Name}, namedArg.Value) + // return + // } + stmt.args = append(stmt.args, arg) + return len(stmt.args) +} + +// TODO: condition must know if it's args are named parameters or not +// func Placeholder(name string) placeholder { +// return placeholder{name} +// } + +// TODO: condition must know if it's args are named parameters or not +// type placeholder struct { +// string +// } + +// WriteArgs appends the args and adds the placeholders comma separated to [stmt.Builder] +// if any arg is a [placeholder] it's replaced with the placeholders parameter +func (stmt *Statement) WriteArgs(args ...any) { + stmt.copyCheck() + stmt.args = slices.Grow(stmt.args, len(args)) + for i, arg := range args { + if i > 0 { + stmt.WriteString(", ") + } + stmt.WriteArg(arg) + } +} + +// WriteArg appends the arg and adds the placeholder to [stmt.Builder] +// if the arg is a [placeholder] it's replaced with the placeholders parameter +func (stmt *Statement) WriteArg(arg any) { + stmt.copyCheck() + // TODO: condition must know if it's args are named parameters or not + // if namedPlaceholder, ok := arg.(placeholder); ok { + // stmt.writeNamedPlaceholder(namedPlaceholder) + // return + // } + placeholder := stmt.AppendArg(arg) + stmt.WriteString("$") + stmt.WriteString(strconv.Itoa(placeholder)) +} + +// WriteString extends [strings.Builder.WriteString] +// it replaces named args with the previously provided named args +func (stmt *Statement) WriteString(s string) { + // TODO: condition must know if it's args are named parameters or not + // for name, placeholder := range stmt.namedArgs { + // s = strings.ReplaceAll(s, name.string, placeholder) + // } + stmt.builder.WriteString(s) +} + +// WriteRune extends [strings.Builder.WriteRune] +func (stmt *Statement) WriteRune(r rune) { + // TODO: condition must know if it's args are named parameters or not + // for name, placeholder := range stmt.namedArgs { + // s = strings.ReplaceAll(s, name.string, placeholder) + // } + stmt.builder.WriteRune(r) +} + +// WriteByte extends [strings.Builder.WriteByte] +func (stmt *Statement) WriteByte(b byte) { + // TODO: condition must know if it's args are named parameters or not + // for name, placeholder := range stmt.namedArgs { + // s = strings.ReplaceAll(s, name.string, placeholder) + // } + err := stmt.builder.WriteByte(b) + logging.OnError(err).Warn("unable to write bytes") +} + +// Write extends [strings.Builder.Write] +// it replaces named args with the previously provided named args +func (stmt *Statement) Write(b []byte) { + // TODO: condition must know if it's args are named parameters or not + // for name, placeholder := range stmt.namedArgs { + // bytes.ReplaceAll(b, []byte(name.string), []byte(placeholder)) + // } + stmt.builder.Write(b) +} + +// String builds the query and replaces placeholders starting with "@" +// with the corresponding named arg placeholder +func (stmt *Statement) String() string { + return stmt.builder.String() +} + +// Debug builds the statement and replaces the placeholders with the parameters +func (stmt *Statement) Debug() string { + query := stmt.String() + + for i := len(stmt.args) - 1; i >= 0; i-- { + var argText string + switch arg := stmt.args[i].(type) { + case time.Time: + argText = "'" + arg.Format("2006-01-02 15:04:05Z07:00") + "'" + case string: + argText = "'" + arg + "'" + case []string: + argText = "ARRAY[" + for i, a := range arg { + if i > 0 { + argText += ", " + } + argText += "'" + a + "'" + } + argText += "]" + default: + argText = fmt.Sprint(arg) + } + query = strings.ReplaceAll(query, "$"+strconv.Itoa(i+1), argText) + } + + return query +} + +// TODO: condition must know if it's args are named parameters or not +// func (stmt *Statement) writeNamedPlaceholder(arg placeholder) { +// placeholder, ok := stmt.namedArgs[arg] +// if !ok { +// logging.WithFields("named_placeholder", arg).Fatal("named placeholder not defined") +// } +// stmt.Builder.WriteString(placeholder) +// } + +// copyCheck allows uninitialized usage of stmt +func (stmt *Statement) copyCheck() { + if stmt.addr == nil { + // This hack works around a failing of Go's escape analysis + // that was causing b to escape and be heap allocated. + // See issue 23382. + // TODO: once issue 7921 is fixed, this should be reverted to + // just "stmt.addr = stmt". + stmt.addr = (*Statement)(noescape(unsafe.Pointer(stmt))) + // TODO: condition must know if it's args are named parameters or not + // stmt.namedArgs = make(map[placeholder]string) + } else if stmt.addr != stmt { + panic("statement: illegal use of non-zero Builder copied by value") + } +} + +// noescape hides a pointer from escape analysis. It is the identity function +// but escape analysis doesn't think the output depends on the input. +// noescape is inlined and currently compiles down to zero instructions. +// USE CAREFULLY! +// This was copied from the runtime; see issues 23382 and 7921. +// +//go:nosplit +//go:nocheckptr +func noescape(p unsafe.Pointer) unsafe.Pointer { + x := uintptr(p) + //nolint: staticcheck + return unsafe.Pointer(x ^ 0) +} diff --git a/internal/v2/database/statement_test.go b/internal/v2/database/statement_test.go new file mode 100644 index 0000000000..85407b91c7 --- /dev/null +++ b/internal/v2/database/statement_test.go @@ -0,0 +1,73 @@ +package database + +import ( + "reflect" + "testing" +) + +func TestStatement_WriteArgs(t *testing.T) { + type args struct { + args []any + } + tests := []struct { + name string + args args + want wantQuery + }{ + { + name: "no args", + args: args{ + args: nil, + }, + }, + { + name: "1 arg", + args: args{ + args: []any{"asdf"}, + }, + want: wantQuery{ + query: "$1", + args: []any{"asdf"}, + }, + }, + { + name: "n args", + args: args{ + args: []any{"asdf", "jkl", 1}, + }, + want: wantQuery{ + query: "$1, $2, $3", + args: []any{"asdf", "jkl", 1}, + }, + }, + } + for _, tt := range tests { + var stmt Statement + t.Run(tt.name, func(t *testing.T) { + stmt.WriteArgs(tt.args.args...) + assertQuery(t, &stmt, tt.want) + }) + } +} + +type wantQuery struct { + query string + args []any +} + +func assertQuery(t *testing.T, stmt *Statement, want wantQuery) { + if want.query != stmt.String() { + t.Errorf("unexpected query: want: %q got: %q", want.query, stmt.String()) + } + + if len(want.args) != len(stmt.Args()) { + t.Errorf("unexpected length of args: want %d, got %d", len(want.args), len(stmt.Args())) + return + } + + for i, wantArg := range want.args { + if !reflect.DeepEqual(wantArg, stmt.Args()[i]) { + t.Errorf("unexpected arg at position %d: want: %v, got: %v", i, wantArg, stmt.Args()[i]) + } + } +} diff --git a/internal/v2/database/text_filter.go b/internal/v2/database/text_filter.go new file mode 100644 index 0000000000..a44adbf976 --- /dev/null +++ b/internal/v2/database/text_filter.go @@ -0,0 +1,132 @@ +package database + +import ( + "fmt" + "strings" + + "github.com/zitadel/logging" +) + +type TextFilter[T text] struct { + Filter[textCompare, T] +} + +func NewTextEqual[T text](t T) *TextFilter[T] { + return newTextFilter(textEqual, t) +} + +func NewTextUnequal[T text](t T) *TextFilter[T] { + return newTextFilter(textUnequal, t) +} + +func NewTextEqualInsensitive[T text](t T) *TextFilter[string] { + return newTextFilter(textEqualInsensitive, strings.ToLower(string(t))) +} + +func NewTextUnequalInsensitive[T text](t T) *TextFilter[string] { + return newTextFilter(textUnequalInsensitive, strings.ToLower(string(t))) +} + +func NewTextStartsWith[T text](t T) *TextFilter[T] { + return newTextFilter(textStartsWith, t) +} + +func NewTextStartsWithInsensitive[T text](t T) *TextFilter[string] { + return newTextFilter(textStartsWithInsensitive, strings.ToLower(string(t))) +} + +func NewTextEndsWith[T text](t T) *TextFilter[T] { + return newTextFilter(textEndsWith, t) +} + +func NewTextEndsWithInsensitive[T text](t T) *TextFilter[string] { + return newTextFilter(textEndsWithInsensitive, strings.ToLower(string(t))) +} + +func NewTextContains[T text](t T) *TextFilter[T] { + return newTextFilter(textContains, t) +} + +func NewTextContainsInsensitive[T text](t T) *TextFilter[string] { + return newTextFilter(textContainsInsensitive, strings.ToLower(string(t))) +} + +func newTextFilter[T text](comp textCompare, t T) *TextFilter[T] { + return &TextFilter[T]{ + Filter: Filter[textCompare, T]{ + comp: comp, + value: t, + }, + } +} + +func (f *TextFilter[T]) Write(stmt *Statement, columnName string) { + if f.comp.isInsensitive() { + f.writeCaseInsensitive(stmt, columnName) + return + } + f.Filter.Write(stmt, columnName) +} + +func (f *TextFilter[T]) writeCaseInsensitive(stmt *Statement, columnName string) { + stmt.WriteString("LOWER(") + stmt.WriteString(columnName) + stmt.WriteString(") ") + stmt.WriteString(f.comp.String()) + stmt.WriteRune(' ') + f.writeArg(stmt) +} + +func (f *TextFilter[T]) writeArg(stmt *Statement) { + // TODO: condition must know if it's args are named parameters or not + // var v any = f.value + // workaround for placeholder + // if placeholder, ok := v.(placeholder); ok { + // stmt.Builder.WriteString(" LOWER(") + // stmt.WriteArg(placeholder) + // stmt.Builder.WriteString(")") + // } + stmt.WriteArg(strings.ToLower(fmt.Sprint(f.value))) +} + +type textCompare uint8 + +const ( + textEqual textCompare = iota + textUnequal + textEqualInsensitive + textUnequalInsensitive + textStartsWith + textStartsWithInsensitive + textEndsWith + textEndsWithInsensitive + textContains + textContainsInsensitive +) + +func (c textCompare) String() string { + switch c { + case textEqual, textEqualInsensitive: + return "=" + case textUnequal, textUnequalInsensitive: + return "<>" + case textStartsWith, textStartsWithInsensitive, textEndsWith, textEndsWithInsensitive, textContains, textContainsInsensitive: + return "LIKE" + default: + logging.WithFields("compare", c).Panic("comparison type not implemented") + return "" + } +} + +func (c textCompare) isInsensitive() bool { + return c == textEqualInsensitive || + c == textStartsWithInsensitive || + c == textEndsWithInsensitive || + c == textContainsInsensitive +} + +type text interface { + ~string + // TODO: condition must know if it's args are named parameters or not + // ~string | placeholder +} diff --git a/internal/v2/database/text_filter_test.go b/internal/v2/database/text_filter_test.go new file mode 100644 index 0000000000..e5365c8d66 --- /dev/null +++ b/internal/v2/database/text_filter_test.go @@ -0,0 +1,351 @@ +package database + +import ( + "reflect" + "testing" +) + +func TestNewTextEqual(t *testing.T) { + type args struct { + constructor func(t string) *TextFilter[string] + t string + } + tests := []struct { + name string + args args + want *TextFilter[string] + }{ + { + name: "NewTextEqual", + args: args{ + constructor: NewTextEqual[string], + t: "text", + }, + want: &TextFilter[string]{ + Filter: Filter[textCompare, string]{ + comp: textEqual, + value: "text", + }, + }, + }, + { + name: "NewTextUnequal", + args: args{ + constructor: NewTextUnequal[string], + t: "text", + }, + want: &TextFilter[string]{ + Filter: Filter[textCompare, string]{ + comp: textUnequal, + value: "text", + }, + }, + }, + { + name: "NewTextEqualInsensitive", + args: args{ + constructor: NewTextEqualInsensitive[string], + t: "text", + }, + want: &TextFilter[string]{ + Filter: Filter[textCompare, string]{ + comp: textEqualInsensitive, + value: "text", + }, + }, + }, + { + name: "NewTextEqualInsensitive check lower", + args: args{ + constructor: NewTextEqualInsensitive[string], + t: "tEXt", + }, + want: &TextFilter[string]{ + Filter: Filter[textCompare, string]{ + comp: textEqualInsensitive, + value: "text", + }, + }, + }, + { + name: "NewTextUnequalInsensitive", + args: args{ + constructor: NewTextUnequalInsensitive[string], + t: "text", + }, + want: &TextFilter[string]{ + Filter: Filter[textCompare, string]{ + comp: textUnequalInsensitive, + value: "text", + }, + }, + }, + { + name: "NewTextUnequalInsensitive check lower", + args: args{ + constructor: NewTextUnequalInsensitive[string], + t: "tEXt", + }, + want: &TextFilter[string]{ + Filter: Filter[textCompare, string]{ + comp: textUnequalInsensitive, + value: "text", + }, + }, + }, + { + name: "NewTextStartsWith", + args: args{ + constructor: NewTextStartsWith[string], + t: "text", + }, + want: &TextFilter[string]{ + Filter: Filter[textCompare, string]{ + comp: textStartsWith, + value: "text", + }, + }, + }, + { + name: "NewTextStartsWithInsensitive", + args: args{ + constructor: NewTextStartsWithInsensitive[string], + t: "text", + }, + want: &TextFilter[string]{ + Filter: Filter[textCompare, string]{ + comp: textStartsWithInsensitive, + value: "text", + }, + }, + }, + { + name: "NewTextStartsWithInsensitive check lower", + args: args{ + constructor: NewTextStartsWithInsensitive[string], + t: "tEXt", + }, + want: &TextFilter[string]{ + Filter: Filter[textCompare, string]{ + comp: textStartsWithInsensitive, + value: "text", + }, + }, + }, + { + name: "NewTextEndsWith", + args: args{ + constructor: NewTextEndsWith[string], + t: "text", + }, + want: &TextFilter[string]{ + Filter: Filter[textCompare, string]{ + comp: textEndsWith, + value: "text", + }, + }, + }, + { + name: "NewTextEndsWithInsensitive", + args: args{ + constructor: NewTextEndsWithInsensitive[string], + t: "text", + }, + want: &TextFilter[string]{ + Filter: Filter[textCompare, string]{ + comp: textEndsWithInsensitive, + value: "text", + }, + }, + }, + { + name: "NewTextEndsWithInsensitive check lower", + args: args{ + constructor: NewTextEndsWithInsensitive[string], + t: "tEXt", + }, + want: &TextFilter[string]{ + Filter: Filter[textCompare, string]{ + comp: textEndsWithInsensitive, + value: "text", + }, + }, + }, + { + name: "NewTextContains", + args: args{ + constructor: NewTextContains[string], + t: "text", + }, + want: &TextFilter[string]{ + Filter: Filter[textCompare, string]{ + comp: textContains, + value: "text", + }, + }, + }, + { + name: "NewTextContainsInsensitive", + args: args{ + constructor: NewTextContainsInsensitive[string], + t: "text", + }, + want: &TextFilter[string]{ + Filter: Filter[textCompare, string]{ + comp: textContainsInsensitive, + value: "text", + }, + }, + }, + { + name: "NewTextContainsInsensitive to lower", + args: args{ + constructor: NewTextContainsInsensitive[string], + t: "tEXt", + }, + want: &TextFilter[string]{ + Filter: Filter[textCompare, string]{ + comp: textContainsInsensitive, + value: "text", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.args.constructor(tt.args.t); !reflect.DeepEqual(got, tt.want) { + t.Errorf("NewTextEqual() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestTextConditionWrite(t *testing.T) { + type args struct { + constructor func(t string) *TextFilter[string] + t string + } + tests := []struct { + name string + args args + want wantQuery + }{ + { + name: "NewTextEqual", + args: args{ + constructor: NewTextEqual[string], + t: "text", + }, + want: wantQuery{ + query: "test = $1", + args: []any{"text"}, + }, + }, + { + name: "NewTextUnequal", + args: args{ + constructor: NewTextUnequal[string], + t: "text", + }, + want: wantQuery{ + query: "test <> $1", + args: []any{"text"}, + }, + }, + { + name: "NewTextEqualInsensitive", + args: args{ + constructor: NewTextEqualInsensitive[string], + t: "text", + }, + want: wantQuery{ + query: "LOWER(test) = $1", + args: []any{"text"}, + }, + }, + { + name: "NewTextUnequalInsensitive", + args: args{ + constructor: NewTextUnequalInsensitive[string], + t: "text", + }, + want: wantQuery{ + query: "test <> $1", + args: []any{"text"}, + }, + }, + { + name: "NewTextStartsWith", + args: args{ + constructor: NewTextStartsWith[string], + t: "text", + }, + want: wantQuery{ + query: "test LIKE $1", + args: []any{"text"}, + }, + }, + { + name: "NewTextStartsWithInsensitive", + args: args{ + constructor: NewTextStartsWithInsensitive[string], + t: "text", + }, + want: wantQuery{ + query: "LOWER(test) LIKE $1", + args: []any{"text"}, + }, + }, + { + name: "NewTextEndsWith", + args: args{ + constructor: NewTextEndsWith[string], + t: "text", + }, + want: wantQuery{ + query: "test LIKE $1", + args: []any{"text"}, + }, + }, + { + name: "NewTextEndsWithInsensitive", + args: args{ + constructor: NewTextEndsWithInsensitive[string], + t: "text", + }, + want: wantQuery{ + query: "LOWER(test) LIKE $1", + args: []any{"text"}, + }, + }, + { + name: "NewTextContains", + args: args{ + constructor: NewTextContains[string], + t: "text", + }, + want: wantQuery{ + query: "test LIKE $1", + args: []any{"text"}, + }, + }, + { + name: "NewTextContainsInsensitive", + args: args{ + constructor: NewTextContainsInsensitive[string], + t: "text", + }, + want: wantQuery{ + query: "LOWER(test) LIKE $1", + args: []any{"text"}, + }, + }, + } + for _, tt := range tests { + var stmt Statement + t.Run(tt.name, func(t *testing.T) { + tt.args.constructor(tt.args.t).Write(&stmt, "test") + assertQuery(t, &stmt, tt.want) + }) + } +} From 251d855f5d4afbfa989e43213380c4bb6b400c96 Mon Sep 17 00:00:00 2001 From: Dakshitha Ratnayake Date: Fri, 26 Apr 2024 09:37:37 +0530 Subject: [PATCH 27/31] docs(integrate): Add google login video (#7836) * Update google.mdx * Update google.mdx --- docs/docs/guides/integrate/identity-providers/google.mdx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/docs/guides/integrate/identity-providers/google.mdx b/docs/docs/guides/integrate/identity-providers/google.mdx index 59c42366e0..ef191054a6 100644 --- a/docs/docs/guides/integrate/identity-providers/google.mdx +++ b/docs/docs/guides/integrate/identity-providers/google.mdx @@ -9,9 +9,13 @@ import CustomLoginPolicy from './_custom_login_policy.mdx'; import IDPsOverview from './_idps_overview.mdx'; import Activate from './_activate.mdx'; import TestSetup from './_test_setup.mdx'; +import { ResponsivePlayer } from "../../../../src/components/player"; + + + ## Open the Google Identity Provider Template From 4f3564e4e9bca13a67eb5f621093533b12c552a1 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Fri, 26 Apr 2024 09:30:35 +0200 Subject: [PATCH 28/31] fix: disable auth cache by default (#7845) --- cmd/defaults.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 5652ca2fcb..6c7e1cf530 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -276,7 +276,7 @@ Auth: HandleActiveInstances: 0s #ZITADEL_AUTH_SPOOLER_HANDLEACTIVEINSTANCES # Defines the amount of auth requests stored in the LRU caches. # There are two caches implemented one for id and one for code - AmountOfCachedAuthRequests: 128 #ZITADEL_AUTH_AMOUNTOFCACHEDAUTHREQUESTS + AmountOfCachedAuthRequests: 0 #ZITADEL_AUTH_AMOUNTOFCACHEDAUTHREQUESTS Admin: # See Projections.BulkLimit From 225443469291605b839c8fa89a1605dae326dba7 Mon Sep 17 00:00:00 2001 From: Miguel Cabrerizo <30386061+doncicuto@users.noreply.github.com> Date: Fri, 26 Apr 2024 16:00:47 +0200 Subject: [PATCH 29/31] fix: remove email validation for SearchUsers v2beta/users (#7855) fix: remove email validation + homogeneous requirements --- proto/zitadel/member.proto | 2 +- proto/zitadel/user.proto | 2 +- proto/zitadel/user/v2beta/query.proto | 5 ++--- proto/zitadel/user/v3alpha/query.proto | 4 ++-- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/proto/zitadel/member.proto b/proto/zitadel/member.proto index 940ffcd5a2..07091e195e 100644 --- a/proto/zitadel/member.proto +++ b/proto/zitadel/member.proto @@ -112,7 +112,7 @@ message EmailQuery { string email = 1 [ (validate.rules).string = {max_len: 200}, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - description: "email address of the user. (spec: https://tools.ietf.org/html/rfc2822#section-3.4.1)" + description: "email address of the user" max_length: 200; example: "\"gigi@zitadel.com\""; } diff --git a/proto/zitadel/user.proto b/proto/zitadel/user.proto index 4991ca4903..c38f4f78af 100644 --- a/proto/zitadel/user.proto +++ b/proto/zitadel/user.proto @@ -311,7 +311,7 @@ message EmailQuery { string email_address = 1 [ (validate.rules).string = {max_len: 200}, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - description: "email address of the user. (spec: https://tools.ietf.org/html/rfc2822#section-3.4.1)" + description: "email address of the user" max_length: 200; example: "\"gigi@zitadel.com\""; } diff --git a/proto/zitadel/user/v2beta/query.proto b/proto/zitadel/user/v2beta/query.proto index 5f42ad7c32..e339cdde71 100644 --- a/proto/zitadel/user/v2beta/query.proto +++ b/proto/zitadel/user/v2beta/query.proto @@ -168,11 +168,10 @@ message DisplayNameQuery { // Query for users with a specific email. message EmailQuery { string email_address = 1 [ - (validate.rules).string = {min_len: 1, max_len: 200, email: true}, + (validate.rules).string = {max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - description: "email address of the user. (spec: https://tools.ietf.org/html/rfc2822#section-3.4.1)" - min_length: 1; + description: "email address of the user" max_length: 200; example: "\"gigi@zitadel.com\""; } diff --git a/proto/zitadel/user/v3alpha/query.proto b/proto/zitadel/user/v3alpha/query.proto index aa0fffbd91..6be060b0b1 100644 --- a/proto/zitadel/user/v3alpha/query.proto +++ b/proto/zitadel/user/v3alpha/query.proto @@ -122,10 +122,10 @@ message UsernameQuery { message EmailQuery { // Defines the email of the user to query for. string address = 1 [ - (validate.rules).string = {min_len: 1, max_len: 200}, + (validate.rules).string = {max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - min_length: 1; + description: "email address of the user" max_length: 200; example: "\"gigi@zitadel.com\""; } From 5811a7b6a58150ca4304ac1fe825ff6679eb3bac Mon Sep 17 00:00:00 2001 From: Silvan Date: Fri, 26 Apr 2024 17:05:21 +0200 Subject: [PATCH 30/31] refactor(v2): init eventstore package (#7806) * refactor(v2): init database package * refactor(v2): init eventstore package * add mock package * test query constructors * option based push analog to query --- internal/v2/eventstore/aggregate.go | 24 + internal/v2/eventstore/current_sequence.go | 29 + internal/v2/eventstore/event.go | 36 + internal/v2/eventstore/event_store.go | 41 + internal/v2/eventstore/postgres/event.go | 64 + internal/v2/eventstore/postgres/intent.go | 42 + .../v2/eventstore/postgres/intent_test.go | 122 ++ internal/v2/eventstore/postgres/push.go | 245 +++ internal/v2/eventstore/postgres/push_test.go | 1292 +++++++++++++++ internal/v2/eventstore/postgres/query.go | 289 ++++ internal/v2/eventstore/postgres/query_test.go | 1380 +++++++++++++++++ internal/v2/eventstore/postgres/storage.go | 28 + internal/v2/eventstore/push.go | 190 +++ internal/v2/eventstore/query.go | 756 +++++++++ internal/v2/eventstore/query_test.go | 1063 +++++++++++++ internal/v2/eventstore/unique_constraint.go | 80 + 16 files changed, 5681 insertions(+) create mode 100644 internal/v2/eventstore/aggregate.go create mode 100644 internal/v2/eventstore/current_sequence.go create mode 100644 internal/v2/eventstore/event.go create mode 100644 internal/v2/eventstore/event_store.go create mode 100644 internal/v2/eventstore/postgres/event.go create mode 100644 internal/v2/eventstore/postgres/intent.go create mode 100644 internal/v2/eventstore/postgres/intent_test.go create mode 100644 internal/v2/eventstore/postgres/push.go create mode 100644 internal/v2/eventstore/postgres/push_test.go create mode 100644 internal/v2/eventstore/postgres/query.go create mode 100644 internal/v2/eventstore/postgres/query_test.go create mode 100644 internal/v2/eventstore/postgres/storage.go create mode 100644 internal/v2/eventstore/push.go create mode 100644 internal/v2/eventstore/query.go create mode 100644 internal/v2/eventstore/query_test.go create mode 100644 internal/v2/eventstore/unique_constraint.go diff --git a/internal/v2/eventstore/aggregate.go b/internal/v2/eventstore/aggregate.go new file mode 100644 index 0000000000..c4ab597aef --- /dev/null +++ b/internal/v2/eventstore/aggregate.go @@ -0,0 +1,24 @@ +package eventstore + +type Aggregate struct { + ID string + Type string + Instance string + Owner string +} + +func (agg *Aggregate) Equals(aggregate *Aggregate) bool { + if aggregate.ID != "" && aggregate.ID != agg.ID { + return false + } + if aggregate.Type != "" && aggregate.Type != agg.Type { + return false + } + if aggregate.Instance != "" && aggregate.Instance != agg.Instance { + return false + } + if aggregate.Owner != "" && aggregate.Owner != agg.Owner { + return false + } + return true +} diff --git a/internal/v2/eventstore/current_sequence.go b/internal/v2/eventstore/current_sequence.go new file mode 100644 index 0000000000..3fcdcf5904 --- /dev/null +++ b/internal/v2/eventstore/current_sequence.go @@ -0,0 +1,29 @@ +package eventstore + +type CurrentSequence func(current uint32) bool + +func CheckSequence(current uint32, check CurrentSequence) bool { + if check == nil { + return true + } + return check(current) +} + +// SequenceIgnore doesn't check the current sequence +func SequenceIgnore() CurrentSequence { + return nil +} + +// SequenceMatches exactly the provided sequence +func SequenceMatches(sequence uint32) CurrentSequence { + return func(current uint32) bool { + return current == sequence + } +} + +// SequenceAtLeast matches the given sequence <= the current sequence +func SequenceAtLeast(sequence uint32) CurrentSequence { + return func(current uint32) bool { + return current >= sequence + } +} diff --git a/internal/v2/eventstore/event.go b/internal/v2/eventstore/event.go new file mode 100644 index 0000000000..b452093305 --- /dev/null +++ b/internal/v2/eventstore/event.go @@ -0,0 +1,36 @@ +package eventstore + +import "time" + +type Event[P any] struct { + Aggregate Aggregate + CreatedAt time.Time + Creator string + Position GlobalPosition + Revision uint16 + Sequence uint32 + Type string + Payload P +} + +type StoragePayload interface { + Unmarshal(ptr any) error +} + +func EventFromStorage[E Event[P], P any](event *Event[StoragePayload]) (*E, error) { + var payload P + + if err := event.Payload.Unmarshal(&payload); err != nil { + return nil, err + } + return &E{ + Aggregate: event.Aggregate, + CreatedAt: event.CreatedAt, + Creator: event.Creator, + Position: event.Position, + Revision: event.Revision, + Sequence: event.Sequence, + Type: event.Type, + Payload: payload, + }, nil +} diff --git a/internal/v2/eventstore/event_store.go b/internal/v2/eventstore/event_store.go new file mode 100644 index 0000000000..fe70bb36a3 --- /dev/null +++ b/internal/v2/eventstore/event_store.go @@ -0,0 +1,41 @@ +package eventstore + +import ( + "context" +) + +func NewEventstore(querier Querier, pusher Pusher) *EventStore { + return &EventStore{ + Pusher: pusher, + Querier: querier, + } +} + +func NewEventstoreFromOne(o one) *EventStore { + return NewEventstore(o, o) +} + +type EventStore struct { + Pusher + Querier +} + +type one interface { + Pusher + Querier +} + +type healthier interface { + Health(ctx context.Context) error +} + +type GlobalPosition struct { + Position float64 + InPositionOrder uint32 +} + +type Reducer interface { + Reduce(events ...*Event[StoragePayload]) error +} + +type Reduce func(events ...*Event[StoragePayload]) error diff --git a/internal/v2/eventstore/postgres/event.go b/internal/v2/eventstore/postgres/event.go new file mode 100644 index 0000000000..9970dd14ea --- /dev/null +++ b/internal/v2/eventstore/postgres/event.go @@ -0,0 +1,64 @@ +package postgres + +import ( + "encoding/json" + + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/v2/eventstore" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func intentToCommands(intent *intent) (commands []*command, err error) { + commands = make([]*command, len(intent.Commands())) + + for i, cmd := range intent.Commands() { + var payload unmarshalPayload + if cmd.Payload() != nil { + payload, err = json.Marshal(cmd.Payload()) + if err != nil { + logging.WithError(err).Warn("marshal payload failed") + return nil, zerrors.ThrowInternal(err, "POSTG-MInPK", "Errors.Internal") + } + } + + commands[i] = &command{ + Event: &eventstore.Event[eventstore.StoragePayload]{ + Aggregate: *intent.Aggregate(), + Creator: cmd.Creator(), + Revision: cmd.Revision(), + Type: cmd.Type(), + // always add at least 1 to the currently stored sequence + Sequence: intent.sequence + uint32(i) + 1, + Payload: payload, + }, + intent: intent, + uniqueConstraints: cmd.UniqueConstraints(), + } + } + + return commands, nil +} + +type command struct { + *eventstore.Event[eventstore.StoragePayload] + + intent *intent + uniqueConstraints []*eventstore.UniqueConstraint +} + +var _ eventstore.StoragePayload = (unmarshalPayload)(nil) + +type unmarshalPayload []byte + +// Unmarshal implements eventstore.StoragePayload. +func (p unmarshalPayload) Unmarshal(ptr any) error { + if len(p) == 0 { + return nil + } + if err := json.Unmarshal(p, ptr); err != nil { + return zerrors.ThrowInternal(err, "POSTG-u8qVo", "Errors.Internal") + } + + return nil +} diff --git a/internal/v2/eventstore/postgres/intent.go b/internal/v2/eventstore/postgres/intent.go new file mode 100644 index 0000000000..9ab259ada8 --- /dev/null +++ b/internal/v2/eventstore/postgres/intent.go @@ -0,0 +1,42 @@ +package postgres + +import ( + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/v2/eventstore" +) + +type intent struct { + *eventstore.PushAggregate + + sequence uint32 +} + +func makeIntents(pushIntent *eventstore.PushIntent) []*intent { + res := make([]*intent, len(pushIntent.Aggregates())) + + for i, aggregate := range pushIntent.Aggregates() { + res[i] = &intent{PushAggregate: aggregate} + } + + return res +} + +func intentByAggregate(intents []*intent, aggregate *eventstore.Aggregate) *intent { + for _, intent := range intents { + if intent.PushAggregate.Aggregate().Equals(aggregate) { + return intent + } + } + logging.WithFields("instance", aggregate.Instance, "owner", aggregate.Owner, "type", aggregate.Type, "id", aggregate.ID).Panic("no intent found") + return nil +} + +func checkSequences(intents []*intent) bool { + for _, intent := range intents { + if !eventstore.CheckSequence(intent.sequence, intent.PushAggregate.CurrentSequence()) { + return false + } + } + return true +} diff --git a/internal/v2/eventstore/postgres/intent_test.go b/internal/v2/eventstore/postgres/intent_test.go new file mode 100644 index 0000000000..93b3aa2162 --- /dev/null +++ b/internal/v2/eventstore/postgres/intent_test.go @@ -0,0 +1,122 @@ +package postgres + +import ( + "testing" + + "github.com/zitadel/zitadel/internal/v2/eventstore" +) + +func Test_checkSequences(t *testing.T) { + type args struct { + intents []*intent + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "ignore", + args: args{ + intents: []*intent{ + { + sequence: 1, + PushAggregate: eventstore.NewPushAggregate( + "", "", "", + eventstore.IgnoreCurrentSequence(), + ), + }, + }, + }, + want: true, + }, + { + name: "ignores", + args: args{ + intents: []*intent{ + { + sequence: 1, + PushAggregate: eventstore.NewPushAggregate( + "", "", "", + eventstore.IgnoreCurrentSequence(), + ), + }, + { + sequence: 1, + PushAggregate: eventstore.NewPushAggregate( + "", "", "", + ), + }, + }, + }, + want: true, + }, + { + name: "matches", + args: args{ + intents: []*intent{ + { + sequence: 0, + PushAggregate: eventstore.NewPushAggregate( + "", "", "", + eventstore.CurrentSequenceMatches(0), + ), + }, + }, + }, + want: true, + }, + { + name: "does not match", + args: args{ + intents: []*intent{ + { + sequence: 1, + PushAggregate: eventstore.NewPushAggregate( + "", "", "", + eventstore.CurrentSequenceMatches(2), + ), + }, + }, + }, + want: false, + }, + { + name: "at least", + args: args{ + intents: []*intent{ + { + sequence: 10, + PushAggregate: eventstore.NewPushAggregate( + "", "", "", + eventstore.CurrentSequenceAtLeast(0), + ), + }, + }, + }, + want: true, + }, + { + name: "at least too low", + args: args{ + intents: []*intent{ + { + sequence: 1, + PushAggregate: eventstore.NewPushAggregate( + "", "", "", + eventstore.CurrentSequenceAtLeast(2), + ), + }, + }, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := checkSequences(tt.args.intents); got != tt.want { + t.Errorf("checkSequences() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/v2/eventstore/postgres/push.go b/internal/v2/eventstore/postgres/push.go new file mode 100644 index 0000000000..7ae64fd41d --- /dev/null +++ b/internal/v2/eventstore/postgres/push.go @@ -0,0 +1,245 @@ +package postgres + +import ( + "context" + "database/sql" + + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/v2/database" + "github.com/zitadel/zitadel/internal/v2/eventstore" + "github.com/zitadel/zitadel/internal/zerrors" +) + +// Push implements eventstore.Pusher. +func (s *Storage) Push(ctx context.Context, intent *eventstore.PushIntent) (err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + tx := intent.Tx() + if tx == nil { + tx, err = s.client.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable, ReadOnly: false}) + if err != nil { + return err + } + defer func() { + err = database.CloseTx(tx, err) + }() + } + + // allows smaller wait times on query side for instances which are not actively writing + if err := setAppName(ctx, tx, "es_pusher_"+intent.Instance()); err != nil { + return err + } + + intents, err := lockAggregates(ctx, tx, intent) + if err != nil { + return err + } + + if !checkSequences(intents) { + return zerrors.ThrowInvalidArgument(nil, "POSTG-KOM6E", "Errors.Internal.Eventstore.SequenceNotMatched") + } + + commands := make([]*command, 0, len(intents)) + for _, intent := range intents { + additionalCommands, err := intentToCommands(intent) + if err != nil { + return err + } + commands = append(commands, additionalCommands...) + } + + err = uniqueConstraints(ctx, tx, commands) + if err != nil { + return err + } + + return push(ctx, tx, intent, commands) +} + +// setAppName for the the current transaction +func setAppName(ctx context.Context, tx *sql.Tx, name string) error { + _, err := tx.ExecContext(ctx, "SET LOCAL application_name TO $1", name) + if err != nil { + logging.WithFields("name", name).WithError(err).Debug("setting app name failed") + return zerrors.ThrowInternal(err, "POSTG-G3OmZ", "Errors.Internal") + } + + return nil +} + +func lockAggregates(ctx context.Context, tx *sql.Tx, intent *eventstore.PushIntent) (_ []*intent, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + var stmt database.Statement + + stmt.WriteString("WITH existing AS (") + for i, aggregate := range intent.Aggregates() { + if i > 0 { + stmt.WriteString(" UNION ALL ") + } + stmt.WriteString(`(SELECT instance_id, aggregate_type, aggregate_id, "sequence" FROM eventstore.events2 WHERE instance_id = `) + stmt.WriteArgs(intent.Instance()) + stmt.WriteString(` AND aggregate_type = `) + stmt.WriteArgs(aggregate.Type()) + stmt.WriteString(` AND aggregate_id = `) + stmt.WriteArgs(aggregate.ID()) + stmt.WriteString(` AND owner = `) + stmt.WriteArgs(aggregate.Owner()) + stmt.WriteString(` ORDER BY "sequence" DESC LIMIT 1)`) + } + stmt.WriteString(") SELECT e.instance_id, e.owner, e.aggregate_type, e.aggregate_id, e.sequence FROM eventstore.events2 e JOIN existing ON e.instance_id = existing.instance_id AND e.aggregate_type = existing.aggregate_type AND e.aggregate_id = existing.aggregate_id AND e.sequence = existing.sequence FOR UPDATE") + + //nolint:rowserrcheck + // rows is checked by database.MapRowsToObject + rows, err := tx.QueryContext(ctx, stmt.String(), stmt.Args()...) + if err != nil { + return nil, err + } + + res := makeIntents(intent) + + err = database.MapRowsToObject(rows, func(scan func(dest ...any) error) error { + var sequence sql.Null[uint32] + agg := new(eventstore.Aggregate) + + err := scan( + &agg.Instance, + &agg.Owner, + &agg.Type, + &agg.ID, + &sequence, + ) + if err != nil { + return err + } + + intentByAggregate(res, agg).sequence = sequence.V + + return nil + }) + if err != nil { + return nil, err + } + + return res, nil +} + +func push(ctx context.Context, tx *sql.Tx, reducer eventstore.Reducer, commands []*command) (err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + var stmt database.Statement + + stmt.WriteString(`INSERT INTO eventstore.events2 (instance_id, "owner", aggregate_type, aggregate_id, revision, creator, event_type, payload, "sequence", in_tx_order, created_at, "position") VALUES `) + for i, cmd := range commands { + if i > 0 { + stmt.WriteString(", ") + } + + cmd.Position.InPositionOrder = uint32(i) + stmt.WriteString(`(`) + stmt.WriteArgs( + cmd.Aggregate.Instance, + cmd.Aggregate.Owner, + cmd.Aggregate.Type, + cmd.Aggregate.ID, + cmd.Revision, + cmd.Creator, + cmd.Type, + cmd.Payload, + cmd.Sequence, + i, + ) + stmt.WriteString(", statement_timestamp(), EXTRACT(EPOCH FROM clock_timestamp())") + stmt.WriteString(`)`) + } + stmt.WriteString(` RETURNING created_at, "position"`) + + //nolint:rowserrcheck + // rows is checked by database.MapRowsToObject + rows, err := tx.QueryContext(ctx, stmt.String(), stmt.Args()...) + if err != nil { + return err + } + + var i int + return database.MapRowsToObject(rows, func(scan func(dest ...any) error) error { + defer func() { i++ }() + + err := scan( + &commands[i].CreatedAt, + &commands[i].Position.Position, + ) + if err != nil { + return err + } + return reducer.Reduce(commands[i].Event) + }) +} + +func uniqueConstraints(ctx context.Context, tx *sql.Tx, commands []*command) (err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + var stmt database.Statement + + for _, cmd := range commands { + if len(cmd.uniqueConstraints) == 0 { + continue + } + for _, constraint := range cmd.uniqueConstraints { + stmt.Reset() + instance := cmd.Aggregate.Instance + if constraint.IsGlobal { + instance = "" + } + switch constraint.Action { + case eventstore.UniqueConstraintAdd: + stmt.WriteString(`INSERT INTO eventstore.unique_constraints (instance_id, unique_type, unique_field) VALUES (`) + stmt.WriteArgs(instance, constraint.UniqueType, constraint.UniqueField) + stmt.WriteRune(')') + case eventstore.UniqueConstraintInstanceRemove: + stmt.WriteString(`DELETE FROM eventstore.unique_constraints WHERE instance_id = `) + stmt.WriteArgs(instance) + case eventstore.UniqueConstraintRemove: + stmt.WriteString(`DELETE FROM eventstore.unique_constraints WHERE `) + stmt.WriteString(deleteUniqueConstraintClause) + stmt.AppendArgs( + instance, + constraint.UniqueType, + constraint.UniqueField, + ) + } + _, err := tx.ExecContext(ctx, stmt.String(), stmt.Args()...) + if err != nil { + logging.WithFields("action", constraint.Action).Warn("handling of unique constraint failed") + errMessage := constraint.ErrorMessage + if errMessage == "" { + errMessage = "Errors.Internal" + } + return zerrors.ThrowAlreadyExists(err, "POSTG-QzjyP", errMessage) + } + } + } + + return nil +} + +// the query is so complex because we accidentally stored unique constraint case sensitive +// the query checks first if there is a case sensitive match and afterwards if there is a case insensitive match +var deleteUniqueConstraintClause = ` +(instance_id = $1 AND unique_type = $2 AND unique_field = ( + SELECT unique_field from ( + SELECT instance_id, unique_type, unique_field + FROM eventstore.unique_constraints + WHERE instance_id = $1 AND unique_type = $2 AND unique_field = $3 + UNION ALL + SELECT instance_id, unique_type, unique_field + FROM eventstore.unique_constraints + WHERE instance_id = $1 AND unique_type = $2 AND unique_field = LOWER($3) + ) AS case_insensitive_constraints LIMIT 1) +)` diff --git a/internal/v2/eventstore/postgres/push_test.go b/internal/v2/eventstore/postgres/push_test.go new file mode 100644 index 0000000000..819add334f --- /dev/null +++ b/internal/v2/eventstore/postgres/push_test.go @@ -0,0 +1,1292 @@ +package postgres + +import ( + "context" + "database/sql/driver" + "errors" + "reflect" + "testing" + "time" + + "github.com/zitadel/zitadel/internal/v2/database/mock" + "github.com/zitadel/zitadel/internal/v2/eventstore" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func Test_uniqueConstraints(t *testing.T) { + type args struct { + commands []*command + expectations []mock.Expectation + } + execErr := errors.New("exec err") + tests := []struct { + name string + args args + assertErr func(t *testing.T, err error) bool + }{ + { + name: "no commands", + args: args{ + commands: []*command{}, + expectations: []mock.Expectation{}, + }, + assertErr: expectNoErr, + }, + { + name: "command without constraints", + args: args{ + commands: []*command{ + {}, + }, + expectations: []mock.Expectation{}, + }, + assertErr: expectNoErr, + }, + { + name: "add 1 constraint 1 command", + args: args{ + commands: []*command{ + { + Event: &eventstore.Event[eventstore.StoragePayload]{ + Aggregate: eventstore.Aggregate{ + Instance: "instance", + }, + }, + uniqueConstraints: []*eventstore.UniqueConstraint{ + eventstore.NewAddEventUniqueConstraint("test", "id", "error"), + }, + }, + }, + expectations: []mock.Expectation{ + mock.ExpectExec( + "INSERT INTO eventstore.unique_constraints (instance_id, unique_type, unique_field) VALUES ($1, $2, $3)", + mock.WithExecArgs("instance", "test", "id"), + mock.WithExecRowsAffected(1), + ), + }, + }, + assertErr: expectNoErr, + }, + { + name: "add 1 global constraint 1 command", + args: args{ + commands: []*command{ + { + Event: &eventstore.Event[eventstore.StoragePayload]{ + Aggregate: eventstore.Aggregate{ + Instance: "instance", + }, + }, + uniqueConstraints: []*eventstore.UniqueConstraint{ + eventstore.NewAddGlobalUniqueConstraint("test", "id", "error"), + }, + }, + }, + expectations: []mock.Expectation{ + mock.ExpectExec( + "INSERT INTO eventstore.unique_constraints (instance_id, unique_type, unique_field) VALUES ($1, $2, $3)", + mock.WithExecArgs("", "test", "id"), + mock.WithExecRowsAffected(1), + ), + }, + }, + assertErr: expectNoErr, + }, + { + name: "add 2 constraint 1 command", + args: args{ + commands: []*command{ + { + Event: &eventstore.Event[eventstore.StoragePayload]{ + Aggregate: eventstore.Aggregate{ + Instance: "instance", + }, + }, + uniqueConstraints: []*eventstore.UniqueConstraint{ + eventstore.NewAddEventUniqueConstraint("test", "id", "error"), + eventstore.NewAddEventUniqueConstraint("test", "id2", "error"), + }, + }, + }, + expectations: []mock.Expectation{ + mock.ExpectExec( + "INSERT INTO eventstore.unique_constraints (instance_id, unique_type, unique_field) VALUES ($1, $2, $3)", + mock.WithExecArgs("instance", "test", "id"), + mock.WithExecRowsAffected(1), + ), + mock.ExpectExec( + "INSERT INTO eventstore.unique_constraints (instance_id, unique_type, unique_field) VALUES ($1, $2, $3)", + mock.WithExecArgs("instance", "test", "id2"), + mock.WithExecRowsAffected(1), + ), + }, + }, + assertErr: expectNoErr, + }, + { + name: "add 1 constraint per command", + args: args{ + commands: []*command{ + { + Event: &eventstore.Event[eventstore.StoragePayload]{ + Aggregate: eventstore.Aggregate{ + Instance: "instance", + }, + }, + uniqueConstraints: []*eventstore.UniqueConstraint{ + eventstore.NewAddEventUniqueConstraint("test", "id", "error"), + }, + }, + { + Event: &eventstore.Event[eventstore.StoragePayload]{ + Aggregate: eventstore.Aggregate{ + Instance: "instance", + }, + }, + uniqueConstraints: []*eventstore.UniqueConstraint{ + eventstore.NewAddEventUniqueConstraint("test", "id2", "error"), + }, + }, + }, + expectations: []mock.Expectation{ + mock.ExpectExec( + "INSERT INTO eventstore.unique_constraints (instance_id, unique_type, unique_field) VALUES ($1, $2, $3)", + mock.WithExecArgs("instance", "test", "id"), + mock.WithExecRowsAffected(1), + ), + mock.ExpectExec( + "INSERT INTO eventstore.unique_constraints (instance_id, unique_type, unique_field) VALUES ($1, $2, $3)", + mock.WithExecArgs("instance", "test", "id2"), + mock.WithExecRowsAffected(1), + ), + }, + }, + assertErr: expectNoErr, + }, + { + name: "remove instance constraints 1 command", + args: args{ + commands: []*command{ + { + Event: &eventstore.Event[eventstore.StoragePayload]{ + Aggregate: eventstore.Aggregate{ + Instance: "instance", + }, + }, + uniqueConstraints: []*eventstore.UniqueConstraint{ + eventstore.NewRemoveInstanceUniqueConstraints(), + }, + }, + }, + expectations: []mock.Expectation{ + mock.ExpectExec( + "DELETE FROM eventstore.unique_constraints WHERE instance_id = $1", + mock.WithExecArgs("instance"), + mock.WithExecRowsAffected(10), + ), + }, + }, + assertErr: expectNoErr, + }, + { + name: "remove instance constraints 2 commands", + args: args{ + commands: []*command{ + { + Event: &eventstore.Event[eventstore.StoragePayload]{ + Aggregate: eventstore.Aggregate{ + Instance: "instance", + }, + }, + uniqueConstraints: []*eventstore.UniqueConstraint{ + eventstore.NewRemoveInstanceUniqueConstraints(), + }, + }, + { + Event: &eventstore.Event[eventstore.StoragePayload]{ + Aggregate: eventstore.Aggregate{ + Instance: "instance", + }, + }, + uniqueConstraints: []*eventstore.UniqueConstraint{ + eventstore.NewRemoveInstanceUniqueConstraints(), + }, + }, + }, + expectations: []mock.Expectation{ + mock.ExpectExec( + "DELETE FROM eventstore.unique_constraints WHERE instance_id = $1", + mock.WithExecArgs("instance"), + mock.WithExecRowsAffected(10), + ), + mock.ExpectExec( + "DELETE FROM eventstore.unique_constraints WHERE instance_id = $1", + mock.WithExecArgs("instance"), + mock.WithExecRowsAffected(0), + ), + }, + }, + assertErr: expectNoErr, + }, + { + name: "remove 1 constraint 1 command", + args: args{ + commands: []*command{ + { + Event: &eventstore.Event[eventstore.StoragePayload]{ + Aggregate: eventstore.Aggregate{ + Instance: "instance", + }, + }, + uniqueConstraints: []*eventstore.UniqueConstraint{ + eventstore.NewRemoveUniqueConstraint("test", "id"), + }, + }, + }, + expectations: []mock.Expectation{ + mock.ExpectExec( + `DELETE FROM eventstore.unique_constraints WHERE (instance_id = $1 AND unique_type = $2 AND unique_field = ( SELECT unique_field from ( SELECT instance_id, unique_type, unique_field FROM eventstore.unique_constraints WHERE instance_id = $1 AND unique_type = $2 AND unique_field = $3 UNION ALL SELECT instance_id, unique_type, unique_field FROM eventstore.unique_constraints WHERE instance_id = $1 AND unique_type = $2 AND unique_field = LOWER($3) ) AS case_insensitive_constraints LIMIT 1) )`, + mock.WithExecArgs("instance", "test", "id"), + mock.WithExecRowsAffected(1), + ), + }, + }, + assertErr: expectNoErr, + }, + { + name: "remove 1 global constraint 1 command", + args: args{ + commands: []*command{ + { + Event: &eventstore.Event[eventstore.StoragePayload]{ + Aggregate: eventstore.Aggregate{ + Instance: "instance", + }, + }, + uniqueConstraints: []*eventstore.UniqueConstraint{ + eventstore.NewRemoveGlobalUniqueConstraint("test", "id"), + }, + }, + }, + expectations: []mock.Expectation{ + mock.ExpectExec( + `DELETE FROM eventstore.unique_constraints WHERE (instance_id = $1 AND unique_type = $2 AND unique_field = ( SELECT unique_field from ( SELECT instance_id, unique_type, unique_field FROM eventstore.unique_constraints WHERE instance_id = $1 AND unique_type = $2 AND unique_field = $3 UNION ALL SELECT instance_id, unique_type, unique_field FROM eventstore.unique_constraints WHERE instance_id = $1 AND unique_type = $2 AND unique_field = LOWER($3) ) AS case_insensitive_constraints LIMIT 1) )`, + mock.WithExecArgs("", "test", "id"), + mock.WithExecRowsAffected(1), + ), + }, + }, + assertErr: expectNoErr, + }, + { + name: "remove 2 constraints 1 command", + args: args{ + commands: []*command{ + { + Event: &eventstore.Event[eventstore.StoragePayload]{ + Aggregate: eventstore.Aggregate{ + Instance: "instance", + }, + }, + uniqueConstraints: []*eventstore.UniqueConstraint{ + eventstore.NewRemoveUniqueConstraint("test", "id"), + eventstore.NewRemoveUniqueConstraint("test", "id2"), + }, + }, + }, + expectations: []mock.Expectation{ + mock.ExpectExec( + `DELETE FROM eventstore.unique_constraints WHERE (instance_id = $1 AND unique_type = $2 AND unique_field = ( SELECT unique_field from ( SELECT instance_id, unique_type, unique_field FROM eventstore.unique_constraints WHERE instance_id = $1 AND unique_type = $2 AND unique_field = $3 UNION ALL SELECT instance_id, unique_type, unique_field FROM eventstore.unique_constraints WHERE instance_id = $1 AND unique_type = $2 AND unique_field = LOWER($3) ) AS case_insensitive_constraints LIMIT 1) )`, + mock.WithExecArgs("instance", "test", "id"), + mock.WithExecRowsAffected(1), + ), + mock.ExpectExec( + `DELETE FROM eventstore.unique_constraints WHERE (instance_id = $1 AND unique_type = $2 AND unique_field = ( SELECT unique_field from ( SELECT instance_id, unique_type, unique_field FROM eventstore.unique_constraints WHERE instance_id = $1 AND unique_type = $2 AND unique_field = $3 UNION ALL SELECT instance_id, unique_type, unique_field FROM eventstore.unique_constraints WHERE instance_id = $1 AND unique_type = $2 AND unique_field = LOWER($3) ) AS case_insensitive_constraints LIMIT 1) )`, + mock.WithExecArgs("instance", "test", "id2"), + mock.WithExecRowsAffected(1), + ), + }, + }, + assertErr: expectNoErr, + }, + { + name: "remove 1 constraints per command", + args: args{ + commands: []*command{ + { + Event: &eventstore.Event[eventstore.StoragePayload]{ + Aggregate: eventstore.Aggregate{ + Instance: "instance", + }, + }, + uniqueConstraints: []*eventstore.UniqueConstraint{ + eventstore.NewRemoveUniqueConstraint("test", "id"), + }, + }, + { + Event: &eventstore.Event[eventstore.StoragePayload]{ + Aggregate: eventstore.Aggregate{ + Instance: "instance", + }, + }, + uniqueConstraints: []*eventstore.UniqueConstraint{ + eventstore.NewRemoveUniqueConstraint("test", "id2"), + }, + }, + }, + expectations: []mock.Expectation{ + mock.ExpectExec( + `DELETE FROM eventstore.unique_constraints WHERE (instance_id = $1 AND unique_type = $2 AND unique_field = ( SELECT unique_field from ( SELECT instance_id, unique_type, unique_field FROM eventstore.unique_constraints WHERE instance_id = $1 AND unique_type = $2 AND unique_field = $3 UNION ALL SELECT instance_id, unique_type, unique_field FROM eventstore.unique_constraints WHERE instance_id = $1 AND unique_type = $2 AND unique_field = LOWER($3) ) AS case_insensitive_constraints LIMIT 1) )`, + mock.WithExecArgs("instance", "test", "id"), + mock.WithExecRowsAffected(1), + ), + mock.ExpectExec( + `DELETE FROM eventstore.unique_constraints WHERE (instance_id = $1 AND unique_type = $2 AND unique_field = ( SELECT unique_field from ( SELECT instance_id, unique_type, unique_field FROM eventstore.unique_constraints WHERE instance_id = $1 AND unique_type = $2 AND unique_field = $3 UNION ALL SELECT instance_id, unique_type, unique_field FROM eventstore.unique_constraints WHERE instance_id = $1 AND unique_type = $2 AND unique_field = LOWER($3) ) AS case_insensitive_constraints LIMIT 1) )`, + mock.WithExecArgs("instance", "test", "id2"), + mock.WithExecRowsAffected(1), + ), + }, + }, + assertErr: expectNoErr, + }, + { + name: "exec fails no error specified", + args: args{ + commands: []*command{ + { + Event: &eventstore.Event[eventstore.StoragePayload]{ + Aggregate: eventstore.Aggregate{ + Instance: "instance", + }, + }, + uniqueConstraints: []*eventstore.UniqueConstraint{ + eventstore.NewAddEventUniqueConstraint("test", "id", ""), + }, + }, + }, + expectations: []mock.Expectation{ + mock.ExpectExec( + "INSERT INTO eventstore.unique_constraints (instance_id, unique_type, unique_field) VALUES ($1, $2, $3)", + mock.WithExecArgs("instance", "test", "id"), + mock.WithExecErr(execErr), + ), + }, + }, + assertErr: func(t *testing.T, err error) bool { + is := errors.Is(err, zerrors.ThrowAlreadyExists(execErr, "POSTG-QzjyP", "Errors.Internal")) + if !is { + t.Errorf("no error expected got: %v", err) + } + return is + }, + }, + { + name: "exec fails error specified", + args: args{ + commands: []*command{ + { + Event: &eventstore.Event[eventstore.StoragePayload]{ + Aggregate: eventstore.Aggregate{ + Instance: "instance", + }, + }, + uniqueConstraints: []*eventstore.UniqueConstraint{ + eventstore.NewAddEventUniqueConstraint("test", "id", "My.Error"), + }, + }, + }, + expectations: []mock.Expectation{ + mock.ExpectExec( + "INSERT INTO eventstore.unique_constraints (instance_id, unique_type, unique_field) VALUES ($1, $2, $3)", + mock.WithExecArgs("instance", "test", "id"), + mock.WithExecErr(execErr), + ), + }, + }, + assertErr: func(t *testing.T, err error) bool { + is := errors.Is(err, zerrors.ThrowAlreadyExists(execErr, "POSTG-QzjyP", "My.Error")) + if !is { + t.Errorf("no error expected got: %v", err) + } + return is + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dbMock := mock.NewSQLMock(t, append([]mock.Expectation{mock.ExpectBegin(nil)}, tt.args.expectations...)...) + tx, err := dbMock.DB.Begin() + if err != nil { + t.Errorf("unexpected error in begin: %v", err) + t.FailNow() + } + err = uniqueConstraints(context.Background(), tx, tt.args.commands) + tt.assertErr(t, err) + dbMock.Assert(t) + }) + } +} + +var errReduce = errors.New("reduce err") + +func Test_lockAggregates(t *testing.T) { + type args struct { + pushIntent *eventstore.PushIntent + expectations []mock.Expectation + } + type want struct { + intents []*intent + assertErr func(t *testing.T, err error) bool + } + tests := []struct { + name string + args args + want want + }{ + { + name: "1 intent", + args: args{ + pushIntent: eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "testType", "testID"), + ), + expectations: []mock.Expectation{ + mock.ExpectQuery( + `WITH existing AS ((SELECT instance_id, aggregate_type, aggregate_id, "sequence" FROM eventstore.events2 WHERE instance_id = $1 AND aggregate_type = $2 AND aggregate_id = $3 AND owner = $4 ORDER BY "sequence" DESC LIMIT 1)) SELECT e.instance_id, e.owner, e.aggregate_type, e.aggregate_id, e.sequence FROM eventstore.events2 e JOIN existing ON e.instance_id = existing.instance_id AND e.aggregate_type = existing.aggregate_type AND e.aggregate_id = existing.aggregate_id AND e.sequence = existing.sequence FOR UPDATE`, + mock.WithQueryArgs("instance", "testType", "testID", "owner"), + mock.WithQueryResult( + []string{"instance_id", "owner", "aggregate_type", "aggregate_id", "sequence"}, + [][]driver.Value{ + { + "instance", + "owner", + "testType", + "testID", + 42, + }, + }, + ), + ), + }, + }, + want: want{ + intents: []*intent{ + { + PushAggregate: eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "testType", "testID"), + ).Aggregates()[0], + sequence: 42, + }, + }, + assertErr: expectNoErr, + }, + }, + { + name: "two intents", + args: args{ + pushIntent: eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "testType", "testID"), + eventstore.AppendAggregate("owner", "myType", "id"), + ), + expectations: []mock.Expectation{ + mock.ExpectQuery( + `WITH existing AS ((SELECT instance_id, aggregate_type, aggregate_id, "sequence" FROM eventstore.events2 WHERE instance_id = $1 AND aggregate_type = $2 AND aggregate_id = $3 AND owner = $4 ORDER BY "sequence" DESC LIMIT 1) UNION ALL (SELECT instance_id, aggregate_type, aggregate_id, "sequence" FROM eventstore.events2 WHERE instance_id = $5 AND aggregate_type = $6 AND aggregate_id = $7 AND owner = $8 ORDER BY "sequence" DESC LIMIT 1)) SELECT e.instance_id, e.owner, e.aggregate_type, e.aggregate_id, e.sequence FROM eventstore.events2 e JOIN existing ON e.instance_id = existing.instance_id AND e.aggregate_type = existing.aggregate_type AND e.aggregate_id = existing.aggregate_id AND e.sequence = existing.sequence FOR UPDATE`, + mock.WithQueryArgs( + "instance", "testType", "testID", "owner", + "instance", "myType", "id", "owner", + ), + mock.WithQueryResult( + []string{"instance_id", "owner", "aggregate_type", "aggregate_id", "sequence"}, + [][]driver.Value{ + { + "instance", + "owner", + "testType", + "testID", + 42, + }, + { + "instance", + "owner", + "myType", + "id", + 17, + }, + }, + ), + ), + }, + }, + want: want{ + intents: []*intent{ + { + PushAggregate: eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "testType", "testID"), + ).Aggregates()[0], + sequence: 42, + }, + { + PushAggregate: eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "myType", "id"), + ).Aggregates()[0], + sequence: 17, + }, + }, + assertErr: expectNoErr, + }, + }, + { + name: "1 intent aggregate not found", + args: args{ + pushIntent: eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "testType", "testID"), + ), + expectations: []mock.Expectation{ + mock.ExpectQuery( + `WITH existing AS ((SELECT instance_id, aggregate_type, aggregate_id, "sequence" FROM eventstore.events2 WHERE instance_id = $1 AND aggregate_type = $2 AND aggregate_id = $3 AND owner = $4 ORDER BY "sequence" DESC LIMIT 1)) SELECT e.instance_id, e.owner, e.aggregate_type, e.aggregate_id, e.sequence FROM eventstore.events2 e JOIN existing ON e.instance_id = existing.instance_id AND e.aggregate_type = existing.aggregate_type AND e.aggregate_id = existing.aggregate_id AND e.sequence = existing.sequence FOR UPDATE`, + mock.WithQueryArgs("instance", "testType", "testID", "owner"), + mock.WithQueryResult( + []string{"instance_id", "owner", "aggregate_type", "aggregate_id", "sequence"}, + [][]driver.Value{}, + ), + ), + }, + }, + want: want{ + intents: []*intent{ + { + PushAggregate: eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "testType", "testID"), + ).Aggregates()[0], + sequence: 0, + }, + }, + assertErr: expectNoErr, + }, + }, + { + name: "two intents none found", + args: args{ + pushIntent: eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "testType", "testID"), + eventstore.AppendAggregate("owner", "myType", "id"), + ), + expectations: []mock.Expectation{ + mock.ExpectQuery( + `WITH existing AS ((SELECT instance_id, aggregate_type, aggregate_id, "sequence" FROM eventstore.events2 WHERE instance_id = $1 AND aggregate_type = $2 AND aggregate_id = $3 AND owner = $4 ORDER BY "sequence" DESC LIMIT 1) UNION ALL (SELECT instance_id, aggregate_type, aggregate_id, "sequence" FROM eventstore.events2 WHERE instance_id = $5 AND aggregate_type = $6 AND aggregate_id = $7 AND owner = $8 ORDER BY "sequence" DESC LIMIT 1)) SELECT e.instance_id, e.owner, e.aggregate_type, e.aggregate_id, e.sequence FROM eventstore.events2 e JOIN existing ON e.instance_id = existing.instance_id AND e.aggregate_type = existing.aggregate_type AND e.aggregate_id = existing.aggregate_id AND e.sequence = existing.sequence FOR UPDATE`, + mock.WithQueryArgs( + "instance", "testType", "testID", "owner", + "instance", "myType", "id", "owner", + ), + mock.WithQueryResult( + []string{"instance_id", "owner", "aggregate_type", "aggregate_id", "sequence"}, + [][]driver.Value{}, + ), + ), + }, + }, + want: want{ + intents: []*intent{ + { + PushAggregate: eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "testType", "testID"), + ).Aggregates()[0], + sequence: 0, + }, + { + PushAggregate: eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "myType", "id"), + ).Aggregates()[0], + sequence: 0, + }, + }, + assertErr: expectNoErr, + }, + }, + { + name: "two intents 1 found", + args: args{ + pushIntent: eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "testType", "testID"), + eventstore.AppendAggregate("owner", "myType", "id"), + ), + expectations: []mock.Expectation{ + mock.ExpectQuery( + `WITH existing AS ((SELECT instance_id, aggregate_type, aggregate_id, "sequence" FROM eventstore.events2 WHERE instance_id = $1 AND aggregate_type = $2 AND aggregate_id = $3 AND owner = $4 ORDER BY "sequence" DESC LIMIT 1) UNION ALL (SELECT instance_id, aggregate_type, aggregate_id, "sequence" FROM eventstore.events2 WHERE instance_id = $5 AND aggregate_type = $6 AND aggregate_id = $7 AND owner = $8 ORDER BY "sequence" DESC LIMIT 1)) SELECT e.instance_id, e.owner, e.aggregate_type, e.aggregate_id, e.sequence FROM eventstore.events2 e JOIN existing ON e.instance_id = existing.instance_id AND e.aggregate_type = existing.aggregate_type AND e.aggregate_id = existing.aggregate_id AND e.sequence = existing.sequence FOR UPDATE`, + mock.WithQueryArgs( + "instance", "testType", "testID", "owner", + "instance", "myType", "id", "owner", + ), + mock.WithQueryResult( + []string{"instance_id", "owner", "aggregate_type", "aggregate_id", "sequence"}, + [][]driver.Value{ + { + "instance", + "owner", + "myType", + "id", + 17, + }, + }, + ), + ), + }, + }, + want: want{ + intents: []*intent{ + { + PushAggregate: eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "testType", "testID"), + ).Aggregates()[0], + sequence: 0, + }, + { + PushAggregate: eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "myType", "id"), + ).Aggregates()[0], + sequence: 17, + }, + }, + assertErr: expectNoErr, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dbMock := mock.NewSQLMock(t, append([]mock.Expectation{mock.ExpectBegin(nil)}, tt.args.expectations...)...) + tx, err := dbMock.DB.Begin() + if err != nil { + t.Errorf("unexpected error in begin: %v", err) + t.FailNow() + } + got, err := lockAggregates(context.Background(), tx, tt.args.pushIntent) + tt.want.assertErr(t, err) + dbMock.Assert(t) + if len(got) != len(tt.want.intents) { + t.Errorf("unexpected length of intents %d, want: %d", len(got), len(tt.want.intents)) + return + } + for i, gotten := range got { + assertIntent(t, gotten, tt.want.intents[i]) + } + }) + } +} + +func assertIntent(t *testing.T, got, want *intent) { + if got.sequence != want.sequence { + t.Errorf("unexpected sequence %d want %d", got.sequence, want.sequence) + } + assertPushAggregate(t, got.PushAggregate, want.PushAggregate) +} + +func assertPushAggregate(t *testing.T, got, want *eventstore.PushAggregate) { + if !reflect.DeepEqual(got.Type(), want.Type()) { + t.Errorf("unexpected Type %v, want: %v", got.Type(), want.Type()) + } + if !reflect.DeepEqual(got.ID(), want.ID()) { + t.Errorf("unexpected ID %v, want: %v", got.ID(), want.ID()) + } + if !reflect.DeepEqual(got.Owner(), want.Owner()) { + t.Errorf("unexpected Owner %v, want: %v", got.Owner(), want.Owner()) + } + if !reflect.DeepEqual(got.Commands(), want.Commands()) { + t.Errorf("unexpected Commands %v, want: %v", got.Commands(), want.Commands()) + } + if !reflect.DeepEqual(got.Aggregate(), want.Aggregate()) { + t.Errorf("unexpected Aggregate %v, want: %v", got.Aggregate(), want.Aggregate()) + } + if !reflect.DeepEqual(got.CurrentSequence(), want.CurrentSequence()) { + t.Errorf("unexpected CurrentSequence %v, want: %v", got.CurrentSequence(), want.CurrentSequence()) + } +} + +func Test_push(t *testing.T) { + type args struct { + commands []*command + expectations []mock.Expectation + reducer *testReducer + } + type want struct { + assertErr func(t *testing.T, err error) bool + } + tests := []struct { + name string + args args + want want + }{ + { + name: "1 aggregate 1 command", + args: args{ + reducer: &testReducer{ + expectedReduces: 1, + }, + commands: []*command{ + { + intent: &intent{ + PushAggregate: eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "testType", "testID"), + ).Aggregates()[0], + }, + Event: &eventstore.Event[eventstore.StoragePayload]{ + Aggregate: *eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "testType", "testID"), + ).Aggregates()[0].Aggregate(), + Creator: "gigi", + Revision: 1, + Type: "test.type", + Sequence: 1, + }, + }, + }, + expectations: []mock.Expectation{ + mock.ExpectQuery( + `INSERT INTO eventstore.events2 (instance_id, "owner", aggregate_type, aggregate_id, revision, creator, event_type, payload, "sequence", in_tx_order, created_at, "position") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, statement_timestamp(), EXTRACT(EPOCH FROM clock_timestamp())) RETURNING created_at, "position"`, + mock.WithQueryArgs( + "instance", + "owner", + "testType", + "testID", + uint16(1), + "gigi", + "test.type", + nil, + uint32(1), + 0, + ), + mock.WithQueryResult( + []string{"created_at", "position"}, + [][]driver.Value{ + { + time.Now(), + float64(123), + }, + }, + ), + ), + }, + }, + want: want{ + assertErr: expectNoErr, + }, + }, + { + name: "1 aggregate 2 commands", + args: args{ + reducer: &testReducer{ + expectedReduces: 2, + }, + commands: []*command{ + { + intent: &intent{ + PushAggregate: eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "testType", "testID"), + ).Aggregates()[0], + }, + Event: &eventstore.Event[eventstore.StoragePayload]{ + Aggregate: *eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "testType", "testID"), + ).Aggregates()[0].Aggregate(), + Creator: "gigi", + Revision: 1, + Type: "test.type", + Sequence: 1, + }, + }, + { + intent: &intent{ + PushAggregate: eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "testType", "testID"), + ).Aggregates()[0], + }, + Event: &eventstore.Event[eventstore.StoragePayload]{ + Aggregate: *eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "testType", "testID"), + ).Aggregates()[0].Aggregate(), + Creator: "gigi", + Revision: 1, + Type: "test.type2", + Sequence: 2, + }, + }, + }, + expectations: []mock.Expectation{ + mock.ExpectQuery( + `INSERT INTO eventstore.events2 (instance_id, "owner", aggregate_type, aggregate_id, revision, creator, event_type, payload, "sequence", in_tx_order, created_at, "position") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, statement_timestamp(), EXTRACT(EPOCH FROM clock_timestamp())), ($11, $12, $13, $14, $15, $16, $17, $18, $19, $20, statement_timestamp(), EXTRACT(EPOCH FROM clock_timestamp())) RETURNING created_at, "position"`, + mock.WithQueryArgs( + "instance", + "owner", + "testType", + "testID", + uint16(1), + "gigi", + "test.type", + nil, + uint32(1), + 0, + "instance", + "owner", + "testType", + "testID", + uint16(1), + "gigi", + "test.type2", + nil, + uint32(2), + 1, + ), + mock.WithQueryResult( + []string{"created_at", "position"}, + [][]driver.Value{ + { + time.Now(), + float64(123), + }, + { + time.Now(), + float64(123.1), + }, + }, + ), + ), + }, + }, + want: want{ + assertErr: expectNoErr, + }, + }, + { + name: "1 command per aggregate 2 aggregates", + args: args{ + reducer: &testReducer{ + expectedReduces: 2, + }, + commands: []*command{ + { + intent: &intent{ + PushAggregate: eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "testType", "testID"), + ).Aggregates()[0], + }, + Event: &eventstore.Event[eventstore.StoragePayload]{ + Aggregate: *eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "testType", "testID"), + ).Aggregates()[0].Aggregate(), + Creator: "gigi", + Revision: 1, + Type: "test.type", + Sequence: 1, + }, + }, + { + intent: &intent{ + PushAggregate: eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "testType", "testID"), + ).Aggregates()[0], + }, + Event: &eventstore.Event[eventstore.StoragePayload]{ + Aggregate: eventstore.Aggregate{ + ID: "id2", + Type: "type2", + Instance: "instance", + Owner: "owner", + }, + Creator: "gigi", + Revision: 1, + Type: "test.type2", + Sequence: 10, + }, + }, + }, + expectations: []mock.Expectation{ + mock.ExpectQuery( + `INSERT INTO eventstore.events2 (instance_id, "owner", aggregate_type, aggregate_id, revision, creator, event_type, payload, "sequence", in_tx_order, created_at, "position") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, statement_timestamp(), EXTRACT(EPOCH FROM clock_timestamp())), ($11, $12, $13, $14, $15, $16, $17, $18, $19, $20, statement_timestamp(), EXTRACT(EPOCH FROM clock_timestamp())) RETURNING created_at, "position"`, + mock.WithQueryArgs( + "instance", + "owner", + "testType", + "testID", + uint16(1), + "gigi", + "test.type", + nil, + uint32(1), + 0, + "instance", + "owner", + "type2", + "id2", + uint16(1), + "gigi", + "test.type2", + nil, + uint32(10), + 1, + ), + mock.WithQueryResult( + []string{"created_at", "position"}, + [][]driver.Value{ + { + time.Now(), + float64(123), + }, + { + time.Now(), + float64(123.1), + }, + }, + ), + ), + }, + }, + want: want{ + assertErr: expectNoErr, + }, + }, + { + name: "1 aggregate 1 command with payload", + args: args{ + reducer: &testReducer{ + expectedReduces: 1, + }, + commands: []*command{ + { + intent: &intent{ + PushAggregate: eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "testType", "testID"), + ).Aggregates()[0], + }, + Event: &eventstore.Event[eventstore.StoragePayload]{ + Aggregate: *eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "testType", "testID"), + ).Aggregates()[0].Aggregate(), + Creator: "gigi", + Revision: 1, + Type: "test.type", + Sequence: 1, + Payload: unmarshalPayload(`{"name": "gigi"}`), + }, + }, + }, + expectations: []mock.Expectation{ + mock.ExpectQuery( + `INSERT INTO eventstore.events2 (instance_id, "owner", aggregate_type, aggregate_id, revision, creator, event_type, payload, "sequence", in_tx_order, created_at, "position") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, statement_timestamp(), EXTRACT(EPOCH FROM clock_timestamp())) RETURNING created_at, "position"`, + mock.WithQueryArgs( + "instance", + "owner", + "testType", + "testID", + uint16(1), + "gigi", + "test.type", + unmarshalPayload(`{"name": "gigi"}`), + uint32(1), + 0, + ), + mock.WithQueryResult( + []string{"created_at", "position"}, + [][]driver.Value{ + { + time.Now(), + float64(123), + }, + }, + ), + ), + }, + }, + want: want{ + assertErr: expectNoErr, + }, + }, + { + name: "command reducer", + args: args{ + reducer: &testReducer{ + expectedReduces: 1, + }, + commands: []*command{ + { + intent: &intent{ + PushAggregate: eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "testType", "testID"), + ).Aggregates()[0], + }, + Event: &eventstore.Event[eventstore.StoragePayload]{ + Aggregate: *eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "testType", "testID"), + ).Aggregates()[0].Aggregate(), + Creator: "gigi", + Revision: 1, + Type: "test.type", + Sequence: 1, + }, + }, + }, + expectations: []mock.Expectation{ + mock.ExpectQuery( + `INSERT INTO eventstore.events2 (instance_id, "owner", aggregate_type, aggregate_id, revision, creator, event_type, payload, "sequence", in_tx_order, created_at, "position") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, statement_timestamp(), EXTRACT(EPOCH FROM clock_timestamp())) RETURNING created_at, "position"`, + mock.WithQueryArgs( + "instance", + "owner", + "testType", + "testID", + uint16(1), + "gigi", + "test.type", + nil, + uint32(1), + 0, + ), + mock.WithQueryResult( + []string{"created_at", "position"}, + [][]driver.Value{ + { + time.Now(), + float64(123), + }, + }, + ), + ), + }, + }, + want: want{ + assertErr: expectNoErr, + }, + }, + { + name: "command reducer err", + args: args{ + reducer: &testReducer{ + expectedReduces: 1, + shouldErr: true, + }, + commands: []*command{ + { + intent: &intent{ + PushAggregate: eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "testType", "testID"), + ).Aggregates()[0], + }, + Event: &eventstore.Event[eventstore.StoragePayload]{ + Aggregate: *eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "testType", "testID"), + ).Aggregates()[0].Aggregate(), + Creator: "gigi", + Revision: 1, + Type: "test.type", + Sequence: 1, + }, + }, + { + intent: &intent{ + PushAggregate: eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "testType", "testID"), + ).Aggregates()[0], + }, + Event: &eventstore.Event[eventstore.StoragePayload]{ + Aggregate: *eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "testType", "testID"), + ).Aggregates()[0].Aggregate(), + Creator: "gigi", + Revision: 1, + Type: "test.type2", + Sequence: 2, + }, + }, + }, + expectations: []mock.Expectation{ + mock.ExpectQuery( + `INSERT INTO eventstore.events2 (instance_id, "owner", aggregate_type, aggregate_id, revision, creator, event_type, payload, "sequence", in_tx_order, created_at, "position") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, statement_timestamp(), EXTRACT(EPOCH FROM clock_timestamp())), ($11, $12, $13, $14, $15, $16, $17, $18, $19, $20, statement_timestamp(), EXTRACT(EPOCH FROM clock_timestamp())) RETURNING created_at, "position"`, + mock.WithQueryArgs( + "instance", + "owner", + "testType", + "testID", + uint16(1), + "gigi", + "test.type", + nil, + uint32(1), + 0, + "instance", + "owner", + "testType", + "testID", + uint16(1), + "gigi", + "test.type2", + nil, + uint32(2), + 1, + ), + mock.WithQueryResult( + []string{"created_at", "position"}, + [][]driver.Value{ + { + time.Now(), + float64(123), + }, + { + time.Now(), + float64(123.1), + }, + }, + ), + ), + }, + }, + want: want{ + assertErr: func(t *testing.T, err error) bool { + is := errors.Is(err, errReduce) + if !is { + t.Errorf("no error expected got: %v", err) + } + return is + }, + }, + }, + { + name: "1 aggregate 2 commands", + args: args{ + reducer: &testReducer{ + expectedReduces: 2, + }, + commands: []*command{ + { + intent: &intent{ + PushAggregate: eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "testType", "testID"), + ).Aggregates()[0], + }, + Event: &eventstore.Event[eventstore.StoragePayload]{ + Aggregate: *eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "testType", "testID"), + ).Aggregates()[0].Aggregate(), + Creator: "gigi", + Revision: 1, + Type: "test.type", + Sequence: 1, + }, + }, + { + intent: &intent{ + PushAggregate: eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "testType", "testID"), + ).Aggregates()[0], + }, + Event: &eventstore.Event[eventstore.StoragePayload]{ + Aggregate: *eventstore.NewPushIntent( + "instance", + eventstore.AppendAggregate("owner", "testType", "testID"), + ).Aggregates()[0].Aggregate(), + Creator: "gigi", + Revision: 1, + Type: "test.type2", + Sequence: 2, + }, + }, + }, + expectations: []mock.Expectation{ + mock.ExpectQuery( + `INSERT INTO eventstore.events2 (instance_id, "owner", aggregate_type, aggregate_id, revision, creator, event_type, payload, "sequence", in_tx_order, created_at, "position") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, statement_timestamp(), EXTRACT(EPOCH FROM clock_timestamp())), ($11, $12, $13, $14, $15, $16, $17, $18, $19, $20, statement_timestamp(), EXTRACT(EPOCH FROM clock_timestamp())) RETURNING created_at, "position"`, + mock.WithQueryArgs( + "instance", + "owner", + "testType", + "testID", + uint16(1), + "gigi", + "test.type", + nil, + uint32(1), + 0, + "instance", + "owner", + "testType", + "testID", + uint16(1), + "gigi", + "test.type2", + nil, + uint32(2), + 1, + ), + mock.WithQueryResult( + []string{"created_at", "position"}, + [][]driver.Value{ + { + time.Now(), + float64(123), + }, + { + time.Now(), + float64(123.1), + }, + }, + ), + ), + }, + }, + want: want{ + assertErr: expectNoErr, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dbMock := mock.NewSQLMock(t, append([]mock.Expectation{mock.ExpectBegin(nil)}, tt.args.expectations...)...) + tx, err := dbMock.DB.Begin() + if err != nil { + t.Errorf("unexpected error in begin: %v", err) + t.FailNow() + } + err = push(context.Background(), tx, tt.args.reducer, tt.args.commands) + tt.want.assertErr(t, err) + dbMock.Assert(t) + if tt.args.reducer != nil { + tt.args.reducer.assert(t) + } + }) + } +} + +func expectNoErr(t *testing.T, err error) bool { + is := err == nil + if !is { + t.Errorf("no error expected got: %v", err) + } + return is +} diff --git a/internal/v2/eventstore/postgres/query.go b/internal/v2/eventstore/postgres/query.go new file mode 100644 index 0000000000..608b31e533 --- /dev/null +++ b/internal/v2/eventstore/postgres/query.go @@ -0,0 +1,289 @@ +package postgres + +import ( + "context" + "database/sql" + "slices" + + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/v2/database" + "github.com/zitadel/zitadel/internal/v2/eventstore" +) + +func (s *Storage) Query(ctx context.Context, query *eventstore.Query) (eventCount int, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + var stmt database.Statement + writeQuery(&stmt, query) + + if query.Tx() != nil { + return executeQuery(ctx, query.Tx(), &stmt, query) + } + + return executeQuery(ctx, s.client.DB, &stmt, query) +} + +func executeQuery(ctx context.Context, tx database.Querier, stmt *database.Statement, reducer eventstore.Reducer) (eventCount int, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + //nolint:rowserrcheck + // rows is checked by database.MapRowsToObject + rows, err := tx.QueryContext(ctx, stmt.String(), stmt.Args()...) + if err != nil { + return 0, err + } + + err = database.MapRowsToObject(rows, func(scan func(dest ...any) error) error { + e := new(eventstore.Event[eventstore.StoragePayload]) + + var payload sql.Null[[]byte] + + err := scan( + &e.CreatedAt, + &e.Type, + &e.Sequence, + &e.Position.Position, + &e.Position.InPositionOrder, + &payload, + &e.Creator, + &e.Aggregate.Owner, + &e.Aggregate.Instance, + &e.Aggregate.Type, + &e.Aggregate.ID, + &e.Revision, + ) + if err != nil { + return err + } + e.Payload = unmarshalPayload(payload.V) + eventCount++ + + return reducer.Reduce(e) + }) + + return eventCount, err +} + +var ( + selectColumns = `SELECT created_at, event_type, "sequence", "position", in_tx_order, payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision` + // TODO: condition must know if it's args are named parameters or not + // instancePlaceholder = database.Placeholder("@instance_id") +) + +func writeQuery(stmt *database.Statement, query *eventstore.Query) { + stmt.WriteString(selectColumns) + // stmt.SetNamedArg(instancePlaceholder, query.Instance()) + + stmt.WriteString(" FROM (") + writeFilters(stmt, query.Filters()) + stmt.WriteRune(')') + writePagination(stmt, query.Pagination()) +} + +var from = " FROM eventstore.events2" + +func writeFilters(stmt *database.Statement, filters []*eventstore.Filter) { + if len(filters) == 0 { + logging.Fatal("query does not contain filters") + } + + for i, filter := range filters { + if i > 0 { + stmt.WriteString(" UNION ALL ") + } + stmt.WriteRune('(') + stmt.WriteString(selectColumns) + stmt.WriteString(from) + + writeFilter(stmt, filter) + + stmt.WriteString(")") + } +} + +func writeFilter(stmt *database.Statement, filter *eventstore.Filter) { + stmt.WriteString(" WHERE ") + filter.Parent().Instance().Write(stmt, "instance_id") + + writeAggregateFilters(stmt, filter.AggregateFilters()) + writePagination(stmt, filter.Pagination()) +} + +func writePagination(stmt *database.Statement, pagination *eventstore.Pagination) { + writePosition(stmt, pagination.Position()) + writeOrdering(stmt, pagination.Desc()) + if pagination.Pagination() != nil { + pagination.Pagination().Write(stmt) + } +} + +func writePosition(stmt *database.Statement, position *eventstore.PositionCondition) { + if position == nil { + return + } + + max := position.Max() + min := position.Min() + + stmt.WriteString(" AND ") + + if max != nil { + if max.InPositionOrder > 0 { + stmt.WriteString("((") + database.NewNumberEquals(max.Position).Write(stmt, "position") + stmt.WriteString(" AND ") + database.NewNumberLess(max.InPositionOrder).Write(stmt, "in_tx_order") + stmt.WriteRune(')') + stmt.WriteString(" OR ") + } + database.NewNumberLess(max.Position).Write(stmt, "position") + if max.InPositionOrder > 0 { + stmt.WriteRune(')') + } + } + + if max != nil && min != nil { + stmt.WriteString(" AND ") + } + + if min != nil { + if min.InPositionOrder > 0 { + stmt.WriteString("((") + database.NewNumberEquals(min.Position).Write(stmt, "position") + stmt.WriteString(" AND ") + database.NewNumberGreater(min.InPositionOrder).Write(stmt, "in_tx_order") + stmt.WriteRune(')') + stmt.WriteString(" OR ") + } + database.NewNumberGreater(min.Position).Write(stmt, "position") + if min.InPositionOrder > 0 { + stmt.WriteRune(')') + } + } +} + +func writeAggregateFilters(stmt *database.Statement, filters []*eventstore.AggregateFilter) { + if len(filters) == 0 { + return + } + + stmt.WriteString(" AND ") + if len(filters) > 1 { + stmt.WriteRune('(') + } + for i, filter := range filters { + if i > 0 { + stmt.WriteString(" OR ") + } + writeAggregateFilter(stmt, filter) + } + if len(filters) > 1 { + stmt.WriteRune(')') + } +} + +func writeAggregateFilter(stmt *database.Statement, filter *eventstore.AggregateFilter) { + conditions := definedConditions([]*condition{ + {column: "aggregate_type", condition: filter.Type()}, + {column: "aggregate_id", condition: filter.IDs()}, + }) + + if len(conditions) > 1 || len(filter.Events()) > 0 { + stmt.WriteRune('(') + } + + writeConditions( + stmt, + conditions, + " AND ", + ) + writeEventFilters(stmt, filter.Events()) + + if len(conditions) > 1 || len(filter.Events()) > 0 { + stmt.WriteRune(')') + } +} + +func writeEventFilters(stmt *database.Statement, filters []*eventstore.EventFilter) { + if len(filters) == 0 { + return + } + + stmt.WriteString(" AND ") + if len(filters) > 1 { + stmt.WriteRune('(') + } + + for i, filter := range filters { + if i > 0 { + stmt.WriteString(" OR ") + } + writeEventFilter(stmt, filter) + } + + if len(filters) > 1 { + stmt.WriteRune(')') + } +} + +func writeEventFilter(stmt *database.Statement, filter *eventstore.EventFilter) { + conditions := definedConditions([]*condition{ + {column: "event_type", condition: filter.Types()}, + {column: "created_at", condition: filter.CreatedAt()}, + {column: "sequence", condition: filter.Sequence()}, + {column: "revision", condition: filter.Revision()}, + {column: "creator", condition: filter.Creators()}, + }) + + if len(conditions) > 1 { + stmt.WriteRune('(') + } + + writeConditions( + stmt, + conditions, + " AND ", + ) + + if len(conditions) > 1 { + stmt.WriteRune(')') + } +} + +type condition struct { + column string + condition database.Condition +} + +func writeConditions(stmt *database.Statement, conditions []*condition, sep string) { + var i int + for _, cond := range conditions { + if i > 0 { + stmt.WriteString(sep) + } + cond.condition.Write(stmt, cond.column) + i++ + } +} + +func definedConditions(conditions []*condition) []*condition { + return slices.DeleteFunc(conditions, func(cond *condition) bool { + return cond.condition == nil + }) +} + +func writeOrdering(stmt *database.Statement, descending bool) { + stmt.WriteString(" ORDER BY position") + if descending { + stmt.WriteString(" DESC") + } + + stmt.WriteString(", in_tx_order") + if descending { + stmt.WriteString(" DESC") + } +} diff --git a/internal/v2/eventstore/postgres/query_test.go b/internal/v2/eventstore/postgres/query_test.go new file mode 100644 index 0000000000..c6e2c6f8a3 --- /dev/null +++ b/internal/v2/eventstore/postgres/query_test.go @@ -0,0 +1,1380 @@ +package postgres + +import ( + "context" + "database/sql/driver" + "errors" + "reflect" + "testing" + "time" + + "github.com/zitadel/zitadel/internal/v2/database" + "github.com/zitadel/zitadel/internal/v2/database/mock" + "github.com/zitadel/zitadel/internal/v2/eventstore" +) + +func Test_writeOrdering(t *testing.T) { + type args struct { + descending bool + } + tests := []struct { + name string + args args + want wantQuery + }{ + { + name: "asc", + args: args{ + descending: false, + }, + want: wantQuery{ + query: " ORDER BY position, in_tx_order", + }, + }, + { + name: "desc", + args: args{ + descending: true, + }, + want: wantQuery{ + query: " ORDER BY position DESC, in_tx_order DESC", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stmt database.Statement + writeOrdering(&stmt, tt.args.descending) + assertQuery(t, &stmt, tt.want) + }) + } +} + +func Test_writeConditionsIfSet(t *testing.T) { + type args struct { + conditions []*condition + sep string + } + tests := []struct { + name string + args args + want wantQuery + }{ + { + name: "no condition", + args: args{ + conditions: []*condition{}, + sep: " AND ", + }, + want: wantQuery{ + query: "", + args: []any{}, + }, + }, + { + name: "1 condition set", + args: args{ + conditions: []*condition{ + {column: "column", condition: database.NewTextEqual("asdf")}, + }, + sep: " AND ", + }, + want: wantQuery{ + query: "column = $1", + args: []any{"asdf"}, + }, + }, + { + name: "multiple conditions set", + args: args{ + conditions: []*condition{ + {column: "column1", condition: database.NewTextEqual("asdf")}, + {column: "column2", condition: database.NewNumberAtLeast(12)}, + {column: "column3", condition: database.NewNumberBetween(1, 100)}, + }, + sep: " AND ", + }, + want: wantQuery{ + query: "column1 = $1 AND column2 >= $2 AND column3 >= $3 AND column3 <= $4", + args: []any{"asdf", 12, 1, 100}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stmt database.Statement + writeConditions(&stmt, tt.args.conditions, tt.args.sep) + assertQuery(t, &stmt, tt.want) + }) + } +} + +func Test_writeEventFilter(t *testing.T) { + now := time.Now() + type args struct { + filter *eventstore.EventFilter + } + tests := []struct { + name string + args args + want wantQuery + }{ + { + name: "no filters", + args: args{ + filter: &eventstore.EventFilter{}, + }, + want: wantQuery{ + query: "", + args: []any{}, + }, + }, + { + name: "event_type", + args: args{ + filter: eventstore.NewEventFilter( + eventstore.SetEventType("user.added"), + ), + }, + want: wantQuery{ + query: "event_type = $1", + args: []any{"user.added"}, + }, + }, + { + name: "created_at", + args: args{ + filter: eventstore.NewEventFilter( + eventstore.EventCreatedAtEquals(now), + ), + }, + want: wantQuery{ + query: "created_at = $1", + args: []any{now}, + }, + }, + { + name: "created_at between", + args: args{ + filter: eventstore.NewEventFilter( + eventstore.EventCreatedAtBetween(now, now.Add(time.Second)), + ), + }, + want: wantQuery{ + query: "created_at >= $1 AND created_at <= $2", + args: []any{now, now.Add(time.Second)}, + }, + }, + { + name: "sequence", + args: args{ + filter: eventstore.NewEventFilter( + eventstore.EventSequenceEquals(100), + ), + }, + want: wantQuery{ + query: "sequence = $1", + args: []any{uint32(100)}, + }, + }, + { + name: "sequence between", + args: args{ + filter: eventstore.NewEventFilter( + eventstore.EventSequenceBetween(0, 10), + ), + }, + want: wantQuery{ + query: "sequence >= $1 AND sequence <= $2", + args: []any{uint32(0), uint32(10)}, + }, + }, + { + name: "revision", + args: args{ + filter: eventstore.NewEventFilter( + eventstore.EventRevisionAtLeast(2), + ), + }, + want: wantQuery{ + query: "revision >= $1", + args: []any{uint16(2)}, + }, + }, + { + name: "creator", + args: args{ + filter: eventstore.NewEventFilter( + eventstore.EventCreatorsEqual("user-123"), + ), + }, + want: wantQuery{ + query: "creator = $1", + args: []any{"user-123"}, + }, + }, + { + name: "all", + args: args{ + filter: eventstore.NewEventFilter( + eventstore.SetEventType("user.added"), + eventstore.EventCreatedAtAtLeast(now), + eventstore.EventSequenceGreater(10), + eventstore.EventRevisionEquals(1), + eventstore.EventCreatorsEqual("user-123"), + ), + }, + want: wantQuery{ + query: "(event_type = $1 AND created_at >= $2 AND sequence > $3 AND revision = $4 AND creator = $5)", + args: []any{"user.added", now, uint32(10), uint16(1), "user-123"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stmt database.Statement + writeEventFilter(&stmt, tt.args.filter) + assertQuery(t, &stmt, tt.want) + }) + } +} + +func Test_writeEventFilters(t *testing.T) { + type args struct { + filters []*eventstore.EventFilter + } + tests := []struct { + name string + args args + want wantQuery + }{ + { + name: "no filters", + args: args{}, + want: wantQuery{ + query: "", + args: []any{}, + }, + }, + { + name: "1 filter", + args: args{ + filters: []*eventstore.EventFilter{ + eventstore.NewEventFilter( + eventstore.SetEventType("user.added"), + ), + }, + }, + want: wantQuery{ + query: " AND event_type = $1", + args: []any{"user.added"}, + }, + }, + { + name: "multiple filters", + args: args{ + filters: []*eventstore.EventFilter{ + eventstore.NewEventFilter( + eventstore.SetEventType("user.added"), + ), + eventstore.NewEventFilter( + eventstore.SetEventType("org.added"), + eventstore.EventSequenceGreater(4), + ), + }, + }, + want: wantQuery{ + query: " AND (event_type = $1 OR (event_type = $2 AND sequence > $3))", + args: []any{"user.added", "org.added", uint32(4)}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stmt database.Statement + writeEventFilters(&stmt, tt.args.filters) + assertQuery(t, &stmt, tt.want) + }) + } +} + +func Test_writeAggregateFilter(t *testing.T) { + type args struct { + filter *eventstore.AggregateFilter + } + tests := []struct { + name string + args args + want wantQuery + }{ + { + name: "minimal", + args: args{ + filter: eventstore.NewAggregateFilter( + "user", + ), + }, + want: wantQuery{ + query: "aggregate_type = $1", + args: []any{"user"}, + }, + }, + { + name: "all on aggregate", + args: args{ + filter: eventstore.NewAggregateFilter( + "user", + eventstore.SetAggregateID("234"), + ), + }, + want: wantQuery{ + query: "(aggregate_type = $1 AND aggregate_id = $2)", + args: []any{"user", "234"}, + }, + }, + { + name: "1 event filter minimal aggregate", + args: args{ + filter: eventstore.NewAggregateFilter( + "user", + eventstore.AppendEvent( + eventstore.SetEventType("user.added"), + ), + ), + }, + want: wantQuery{ + query: "(aggregate_type = $1 AND event_type = $2)", + args: []any{"user", "user.added"}, + }, + }, + { + name: "1 event filter all aggregate", + args: args{ + filter: eventstore.NewAggregateFilter( + "user", + eventstore.SetAggregateID("123"), + eventstore.AppendEvent( + eventstore.SetEventType("user.added"), + ), + ), + }, + want: wantQuery{ + query: "(aggregate_type = $1 AND aggregate_id = $2 AND event_type = $3)", + args: []any{"user", "123", "user.added"}, + }, + }, + { + name: "1 event filter with multiple conditions all aggregate", + args: args{ + filter: eventstore.NewAggregateFilter( + "user", + eventstore.SetAggregateID("123"), + eventstore.AppendEvent( + eventstore.SetEventType("user.added"), + eventstore.EventSequenceGreater(1), + ), + ), + }, + want: wantQuery{ + query: "(aggregate_type = $1 AND aggregate_id = $2 AND (event_type = $3 AND sequence > $4))", + args: []any{"user", "123", "user.added", uint32(1)}, + }, + }, + { + name: "2 event filters all aggregate", + args: args{ + filter: eventstore.NewAggregateFilter( + "user", + eventstore.SetAggregateID("123"), + eventstore.AppendEvent( + eventstore.SetEventType("user.added"), + ), + eventstore.AppendEvent( + eventstore.EventSequenceGreater(1), + ), + ), + }, + want: wantQuery{ + query: "(aggregate_type = $1 AND aggregate_id = $2 AND (event_type = $3 OR sequence > $4))", + args: []any{"user", "123", "user.added", uint32(1)}, + }, + }, + { + name: "2 event filters with multiple conditions all aggregate", + args: args{ + filter: eventstore.NewAggregateFilter( + "user", + eventstore.SetAggregateID("123"), + eventstore.AppendEvents( + eventstore.NewEventFilter( + eventstore.SetEventType("user.added"), + eventstore.EventSequenceGreater(1), + ), + ), + eventstore.AppendEvent( + eventstore.SetEventType("user.changed"), + eventstore.EventSequenceGreater(4), + ), + ), + }, + want: wantQuery{ + query: "(aggregate_type = $1 AND aggregate_id = $2 AND ((event_type = $3 AND sequence > $4) OR (event_type = $5 AND sequence > $6)))", + args: []any{"user", "123", "user.added", uint32(1), "user.changed", uint32(4)}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stmt database.Statement + writeAggregateFilter(&stmt, tt.args.filter) + assertQuery(t, &stmt, tt.want) + }) + } +} + +func Test_writeAggregateFilters(t *testing.T) { + type args struct { + filters []*eventstore.AggregateFilter + } + tests := []struct { + name string + args args + want wantQuery + }{ + { + name: "no filters", + args: args{}, + want: wantQuery{ + query: "", + args: []any{}, + }, + }, + { + name: "1 filter", + args: args{ + filters: []*eventstore.AggregateFilter{ + eventstore.NewAggregateFilter("user"), + }, + }, + want: wantQuery{ + query: " AND aggregate_type = $1", + args: []any{"user"}, + }, + }, + { + name: "multiple filters", + args: args{ + filters: []*eventstore.AggregateFilter{ + eventstore.NewAggregateFilter("user"), + eventstore.NewAggregateFilter("org", + eventstore.AppendEvent( + eventstore.SetEventType("org.added"), + ), + ), + }, + }, + want: wantQuery{ + query: " AND (aggregate_type = $1 OR (aggregate_type = $2 AND event_type = $3))", + args: []any{"user", "org", "org.added"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stmt database.Statement + writeAggregateFilters(&stmt, tt.args.filters) + assertQuery(t, &stmt, tt.want) + }) + } +} + +func Test_writeFilter(t *testing.T) { + type args struct { + filter *eventstore.Filter + } + tests := []struct { + name string + args args + want wantQuery + }{ + { + name: "empty filters", + args: args{ + filter: eventstore.NewFilter(), + }, + want: wantQuery{ + query: " WHERE instance_id = $1 ORDER BY position, in_tx_order", + args: []any{"i1"}, + }, + }, + { + name: "descending", + args: args{ + filter: eventstore.NewFilter( + eventstore.FilterPagination( + eventstore.Descending(), + ), + ), + }, + want: wantQuery{ + query: " WHERE instance_id = $1 ORDER BY position DESC, in_tx_order DESC", + args: []any{"i1"}, + }, + }, + { + name: "database pagination", + args: args{ + filter: eventstore.NewFilter( + eventstore.FilterPagination( + eventstore.Limit(10), + eventstore.Offset(3), + ), + ), + }, + want: wantQuery{ + query: " WHERE instance_id = $1 ORDER BY position, in_tx_order LIMIT $2 OFFSET $3", + args: []any{"i1", uint32(10), uint32(3)}, + }, + }, + { + name: "position pagination", + args: args{ + filter: eventstore.NewFilter( + eventstore.FilterPagination( + eventstore.PositionGreater(123.4, 0), + ), + ), + }, + want: wantQuery{ + query: " WHERE instance_id = $1 AND position > $2 ORDER BY position, in_tx_order", + args: []any{"i1", 123.4}, + }, + }, + { + name: "position pagination between", + args: args{ + filter: eventstore.NewFilter( + eventstore.FilterPagination( + // eventstore.PositionGreater(123.4, 0), + // eventstore.PositionLess(125.4, 10), + eventstore.PositionBetween( + &eventstore.GlobalPosition{Position: 123.4}, + &eventstore.GlobalPosition{Position: 125.4, InPositionOrder: 10}, + ), + ), + ), + }, + want: wantQuery{ + query: " WHERE instance_id = $1 AND ((position = $2 AND in_tx_order < $3) OR position < $4) AND position > $5 ORDER BY position, in_tx_order", + args: []any{"i1", 125.4, uint32(10), 125.4, 123.4}, + // TODO: (adlerhurst) would require some refactoring to reuse existing args + // query: " WHERE instance_id = $1 AND position > $2 AND ((position = $3 AND in_tx_order < $4) OR position < $3) ORDER BY position, in_tx_order", + // args: []any{"i1", 123.4, 125.4, uint32(10)}, + }, + }, + { + name: "position and inPositionOrder pagination", + args: args{ + filter: eventstore.NewFilter( + eventstore.FilterPagination( + eventstore.PositionGreater(123.4, 12), + ), + ), + }, + want: wantQuery{ + query: " WHERE instance_id = $1 AND ((position = $2 AND in_tx_order > $3) OR position > $4) ORDER BY position, in_tx_order", + args: []any{"i1", 123.4, uint32(12), 123.4}, + }, + }, + { + name: "pagination", + args: args{ + filter: eventstore.NewFilter( + eventstore.FilterPagination( + eventstore.Limit(10), + eventstore.Offset(3), + eventstore.PositionGreater(123.4, 12), + ), + ), + }, + want: wantQuery{ + query: " WHERE instance_id = $1 AND ((position = $2 AND in_tx_order > $3) OR position > $4) ORDER BY position, in_tx_order LIMIT $5 OFFSET $6", + args: []any{"i1", 123.4, uint32(12), 123.4, uint32(10), uint32(3)}, + }, + }, + { + name: "aggregate and pagination", + args: args{ + filter: eventstore.NewFilter( + eventstore.FilterPagination( + eventstore.Limit(10), + eventstore.Offset(3), + eventstore.PositionGreater(123.4, 12), + ), + eventstore.AppendAggregateFilter("user"), + ), + }, + want: wantQuery{ + query: " WHERE instance_id = $1 AND aggregate_type = $2 AND ((position = $3 AND in_tx_order > $4) OR position > $5) ORDER BY position, in_tx_order LIMIT $6 OFFSET $7", + args: []any{"i1", "user", 123.4, uint32(12), 123.4, uint32(10), uint32(3)}, + }, + }, + { + name: "aggregates and pagination", + args: args{ + filter: eventstore.NewFilter( + eventstore.FilterPagination( + eventstore.Limit(10), + eventstore.Offset(3), + eventstore.PositionGreater(123.4, 12), + ), + eventstore.AppendAggregateFilter("user"), + eventstore.AppendAggregateFilter( + "org", + eventstore.SetAggregateID("o1"), + ), + ), + }, + want: wantQuery{ + query: " WHERE instance_id = $1 AND (aggregate_type = $2 OR (aggregate_type = $3 AND aggregate_id = $4)) AND ((position = $5 AND in_tx_order > $6) OR position > $7) ORDER BY position, in_tx_order LIMIT $8 OFFSET $9", + args: []any{"i1", "user", "org", "o1", 123.4, uint32(12), 123.4, uint32(10), uint32(3)}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stmt database.Statement + eventstore.NewQuery("i1", nil, eventstore.AppendFilters(tt.args.filter)) + + writeFilter(&stmt, tt.args.filter) + assertQuery(t, &stmt, tt.want) + }) + } +} + +func Test_writeQuery(t *testing.T) { + type args struct { + query *eventstore.Query + } + tests := []struct { + name string + args args + want wantQuery + }{ + { + name: "empty filter", + args: args{ + query: eventstore.NewQuery( + "i1", + nil, + eventstore.AppendFilters( + eventstore.NewFilter(), + ), + ), + }, + want: wantQuery{ + query: `SELECT created_at, event_type, "sequence", "position", in_tx_order, payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM ((SELECT created_at, event_type, "sequence", "position", in_tx_order, payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2 WHERE instance_id = $1 ORDER BY position, in_tx_order)) ORDER BY position, in_tx_order`, + args: []any{"i1"}, + }, + }, + { + name: "1 filter", + args: args{ + query: eventstore.NewQuery( + "i1", + nil, + eventstore.AppendFilters( + eventstore.NewFilter( + eventstore.AppendAggregateFilter( + "user", + eventstore.AggregateIDs("a", "b"), + ), + ), + ), + ), + }, + want: wantQuery{ + query: `SELECT created_at, event_type, "sequence", "position", in_tx_order, payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM ((SELECT created_at, event_type, "sequence", "position", in_tx_order, payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2 WHERE instance_id = $1 AND (aggregate_type = $2 AND aggregate_id = ANY($3)) ORDER BY position, in_tx_order)) ORDER BY position, in_tx_order`, + args: []any{"i1", "user", []string{"a", "b"}}, + }, + }, + { + name: "multiple filters", + args: args{ + query: eventstore.NewQuery( + "i1", + nil, + eventstore.AppendFilters( + eventstore.NewFilter( + eventstore.AppendAggregateFilter( + "user", + eventstore.AggregateIDs("a", "b"), + ), + ), + eventstore.NewFilter( + eventstore.AppendAggregateFilter( + "org", + eventstore.AppendEvent( + eventstore.SetEventType("org.added"), + ), + ), + ), + ), + ), + }, + want: wantQuery{ + query: `SELECT created_at, event_type, "sequence", "position", in_tx_order, payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM ((SELECT created_at, event_type, "sequence", "position", in_tx_order, payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2 WHERE instance_id = $1 AND (aggregate_type = $2 AND aggregate_id = ANY($3)) ORDER BY position, in_tx_order) UNION ALL (SELECT created_at, event_type, "sequence", "position", in_tx_order, payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2 WHERE instance_id = $4 AND (aggregate_type = $5 AND event_type = $6) ORDER BY position, in_tx_order)) ORDER BY position, in_tx_order`, + args: []any{"i1", "user", []string{"a", "b"}, "i1", "org", "org.added"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stmt database.Statement + writeQuery(&stmt, tt.args.query) + assertQuery(t, &stmt, tt.want) + }) + } +} + +func Test_writeQueryUse_examples(t *testing.T) { + type args struct { + query *eventstore.Query + } + tests := []struct { + name string + args args + want wantQuery + }{ + { + name: "aggregate type", + args: args{ + query: eventstore.NewQuery( + "instance", + nil, + eventstore.AppendFilters( + eventstore.NewFilter( + eventstore.AppendAggregateFilter("aggregate"), + ), + ), + ), + }, + want: wantQuery{ + query: `SELECT created_at, event_type, "sequence", "position", in_tx_order, payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM ((SELECT created_at, event_type, "sequence", "position", in_tx_order, payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2 WHERE instance_id = $1 AND aggregate_type = $2 ORDER BY position, in_tx_order)) ORDER BY position, in_tx_order`, + args: []any{ + "instance", + "aggregate", + }, + }, + }, + { + name: "descending", + args: args{ + query: eventstore.NewQuery( + "instance", + nil, + eventstore.QueryPagination( + eventstore.Descending(), + ), + eventstore.AppendFilter( + eventstore.AppendAggregateFilter("aggregate"), + ), + ), + }, + want: wantQuery{ + query: `SELECT created_at, event_type, "sequence", "position", in_tx_order, payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM ((SELECT created_at, event_type, "sequence", "position", in_tx_order, payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2 WHERE instance_id = $1 AND aggregate_type = $2 ORDER BY position DESC, in_tx_order DESC)) ORDER BY position DESC, in_tx_order DESC`, + args: []any{ + "instance", + "aggregate", + }, + }, + }, + { + name: "multiple aggregates", + args: args{ + query: eventstore.NewQuery( + "instance", + nil, + eventstore.AppendFilters( + eventstore.NewFilter( + eventstore.AppendAggregateFilter("agg1"), + ), + eventstore.NewFilter( + eventstore.AppendAggregateFilter("agg2"), + eventstore.AppendAggregateFilter("agg3"), + ), + ), + ), + }, + want: wantQuery{ + query: `SELECT created_at, event_type, "sequence", "position", in_tx_order, payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM ((SELECT created_at, event_type, "sequence", "position", in_tx_order, payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2 WHERE instance_id = $1 AND aggregate_type = $2 ORDER BY position, in_tx_order) UNION ALL (SELECT created_at, event_type, "sequence", "position", in_tx_order, payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2 WHERE instance_id = $3 AND (aggregate_type = $4 OR aggregate_type = $5) ORDER BY position, in_tx_order)) ORDER BY position, in_tx_order`, + args: []any{ + "instance", + "agg1", + "instance", + "agg2", + "agg3", + }, + }, + }, + { + name: "multiple aggregates with ids", + args: args{ + query: eventstore.NewQuery( + "instance", + nil, + eventstore.AppendFilters( + eventstore.NewFilter( + eventstore.AppendAggregateFilter("agg1", eventstore.SetAggregateID("id")), + ), + eventstore.NewFilter( + eventstore.AppendAggregateFilter("agg2", eventstore.SetAggregateID("id2")), + eventstore.AppendAggregateFilter("agg3"), + ), + ), + ), + }, + want: wantQuery{ + query: `SELECT created_at, event_type, "sequence", "position", in_tx_order, payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM ((SELECT created_at, event_type, "sequence", "position", in_tx_order, payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2 WHERE instance_id = $1 AND (aggregate_type = $2 AND aggregate_id = $3) ORDER BY position, in_tx_order) UNION ALL (SELECT created_at, event_type, "sequence", "position", in_tx_order, payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2 WHERE instance_id = $4 AND ((aggregate_type = $5 AND aggregate_id = $6) OR aggregate_type = $7) ORDER BY position, in_tx_order)) ORDER BY position, in_tx_order`, + args: []any{ + "instance", + "agg1", + "id", + "instance", + "agg2", + "id2", + "agg3", + }, + }, + }, + { + name: "multiple event queries and multiple filter in queries", + args: args{ + query: eventstore.NewQuery( + "instance", + nil, + eventstore.AppendFilter( + eventstore.AppendAggregateFilter( + "agg1", + eventstore.AggregateIDs("1", "2"), + ), + eventstore.AppendAggregateFilter( + "agg2", + eventstore.SetAggregateID("3"), + ), + eventstore.AppendAggregateFilter( + "agg3", + eventstore.SetAggregateID("3"), + ), + ), + ), + }, + want: wantQuery{ + query: `SELECT created_at, event_type, "sequence", "position", in_tx_order, payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM ((SELECT created_at, event_type, "sequence", "position", in_tx_order, payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2 WHERE instance_id = $1 AND ((aggregate_type = $2 AND aggregate_id = ANY($3)) OR (aggregate_type = $4 AND aggregate_id = $5) OR (aggregate_type = $6 AND aggregate_id = $7)) ORDER BY position, in_tx_order)) ORDER BY position, in_tx_order`, + args: []any{ + "instance", + "agg1", + []string{"1", "2"}, + "agg2", + "3", + "agg3", + "3", + }, + }, + }, + { + name: "milestones", + args: args{ + query: eventstore.NewQuery( + "instance", + nil, + eventstore.AppendFilters( + eventstore.NewFilter( + eventstore.AppendAggregateFilter( + "instance", + eventstore.AppendEvent( + eventstore.SetEventType("instance.added"), + ), + ), + eventstore.FilterPagination( + eventstore.Limit(1), + ), + ), + eventstore.NewFilter( + eventstore.AppendAggregateFilter( + "instance", + eventstore.AppendEvent( + eventstore.SetEventType("instance.removed"), + ), + ), + eventstore.FilterPagination( + eventstore.Limit(1), + ), + ), + eventstore.NewFilter( + eventstore.AppendAggregateFilter( + "instance", + eventstore.AppendEvent( + eventstore.SetEventType("instance.domain.primary.set"), + eventstore.EventCreatorsNotContains("", "SYSTEM"), + ), + ), + eventstore.FilterPagination( + eventstore.Limit(1), + ), + ), + eventstore.NewFilter( + eventstore.AppendAggregateFilter( + "project", + eventstore.AppendEvent( + eventstore.SetEventType("project.added"), + eventstore.EventCreatorsNotContains("", "SYSTEM"), + ), + ), + eventstore.FilterPagination( + eventstore.Limit(1), + ), + ), + eventstore.NewFilter( + eventstore.AppendAggregateFilter( + "project", + eventstore.AppendEvent( + eventstore.EventCreatorsNotContains("", "SYSTEM"), + eventstore.SetEventType("project.application.added"), + ), + ), + eventstore.FilterPagination( + eventstore.Limit(1), + ), + ), + eventstore.NewFilter( + eventstore.AppendAggregateFilter( + "user", + eventstore.AppendEvent( + eventstore.SetEventType("user.token.added"), + ), + ), + eventstore.FilterPagination( + // used because we need to check for first login and an app which is not console + eventstore.PositionGreater(12, 4), + ), + ), + eventstore.NewFilter( + eventstore.AppendAggregateFilter( + "instance", + eventstore.AppendEvent( + eventstore.SetEventTypes( + "instance.idp.config.added", + "instance.idp.oauth.added", + "instance.idp.oidc.added", + "instance.idp.jwt.added", + "instance.idp.azure.added", + "instance.idp.github.added", + "instance.idp.github.enterprise.added", + "instance.idp.gitlab.added", + "instance.idp.gitlab.selfhosted.added", + "instance.idp.google.added", + "instance.idp.ldap.added", + "instance.idp.config.apple.added", + "instance.idp.saml.added", + ), + ), + ), + eventstore.AppendAggregateFilter( + "org", + eventstore.AppendEvent( + eventstore.SetEventTypes( + "org.idp.config.added", + "org.idp.oauth.added", + "org.idp.oidc.added", + "org.idp.jwt.added", + "org.idp.azure.added", + "org.idp.github.added", + "org.idp.github.enterprise.added", + "org.idp.gitlab.added", + "org.idp.gitlab.selfhosted.added", + "org.idp.google.added", + "org.idp.ldap.added", + "org.idp.config.apple.added", + "org.idp.saml.added", + ), + ), + ), + eventstore.FilterPagination( + eventstore.Limit(1), + ), + ), + eventstore.NewFilter( + eventstore.AppendAggregateFilter( + "instance", + eventstore.AppendEvent( + eventstore.SetEventType("instance.login.policy.idp.added"), + ), + ), + eventstore.AppendAggregateFilter( + "org", + eventstore.AppendEvent( + eventstore.SetEventType("org.login.policy.idp.added"), + ), + ), + eventstore.FilterPagination( + eventstore.Limit(1), + ), + ), + eventstore.NewFilter( + eventstore.AppendAggregateFilter( + "instance", + eventstore.AppendEvent( + eventstore.SetEventType("instance.smtp.config.added"), + eventstore.EventCreatorsNotContains("", "SYSTEM", ""), + ), + ), + eventstore.FilterPagination( + eventstore.Limit(1), + ), + ), + ), + ), + }, + want: wantQuery{ + query: `SELECT created_at, event_type, "sequence", "position", in_tx_order, payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM ((SELECT created_at, event_type, "sequence", "position", in_tx_order, payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2 WHERE instance_id = $1 AND (aggregate_type = $2 AND event_type = $3) ORDER BY position, in_tx_order LIMIT $4) UNION ALL (SELECT created_at, event_type, "sequence", "position", in_tx_order, payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2 WHERE instance_id = $5 AND (aggregate_type = $6 AND event_type = $7) ORDER BY position, in_tx_order LIMIT $8) UNION ALL (SELECT created_at, event_type, "sequence", "position", in_tx_order, payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2 WHERE instance_id = $9 AND (aggregate_type = $10 AND (event_type = $11 AND NOT(creator = ANY($12)))) ORDER BY position, in_tx_order LIMIT $13) UNION ALL (SELECT created_at, event_type, "sequence", "position", in_tx_order, payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2 WHERE instance_id = $14 AND (aggregate_type = $15 AND (event_type = $16 AND NOT(creator = ANY($17)))) ORDER BY position, in_tx_order LIMIT $18) UNION ALL (SELECT created_at, event_type, "sequence", "position", in_tx_order, payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2 WHERE instance_id = $19 AND (aggregate_type = $20 AND (event_type = $21 AND NOT(creator = ANY($22)))) ORDER BY position, in_tx_order LIMIT $23) UNION ALL (SELECT created_at, event_type, "sequence", "position", in_tx_order, payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2 WHERE instance_id = $24 AND (aggregate_type = $25 AND event_type = $26) AND ((position = $27 AND in_tx_order > $28) OR position > $29) ORDER BY position, in_tx_order) UNION ALL (SELECT created_at, event_type, "sequence", "position", in_tx_order, payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2 WHERE instance_id = $30 AND ((aggregate_type = $31 AND event_type = ANY($32)) OR (aggregate_type = $33 AND event_type = ANY($34))) ORDER BY position, in_tx_order LIMIT $35) UNION ALL (SELECT created_at, event_type, "sequence", "position", in_tx_order, payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2 WHERE instance_id = $36 AND ((aggregate_type = $37 AND event_type = $38) OR (aggregate_type = $39 AND event_type = $40)) ORDER BY position, in_tx_order LIMIT $41) UNION ALL (SELECT created_at, event_type, "sequence", "position", in_tx_order, payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2 WHERE instance_id = $42 AND (aggregate_type = $43 AND (event_type = $44 AND NOT(creator = ANY($45)))) ORDER BY position, in_tx_order LIMIT $46)) ORDER BY position, in_tx_order`, + args: []any{ + "instance", + "instance", + "instance.added", + uint32(1), + "instance", + "instance", + "instance.removed", + uint32(1), + "instance", + "instance", + "instance.domain.primary.set", + []string{"", "SYSTEM"}, + uint32(1), + "instance", + "project", + "project.added", + []string{"", "SYSTEM"}, + uint32(1), + "instance", + "project", + "project.application.added", + []string{"", "SYSTEM"}, + uint32(1), + "instance", + "user", + "user.token.added", + float64(12), + uint32(4), + float64(12), + "instance", + "instance", + []string{"instance.idp.config.added", "instance.idp.oauth.added", "instance.idp.oidc.added", "instance.idp.jwt.added", "instance.idp.azure.added", "instance.idp.github.added", "instance.idp.github.enterprise.added", "instance.idp.gitlab.added", "instance.idp.gitlab.selfhosted.added", "instance.idp.google.added", "instance.idp.ldap.added", "instance.idp.config.apple.added", "instance.idp.saml.added"}, + "org", + []string{"org.idp.config.added", "org.idp.oauth.added", "org.idp.oidc.added", "org.idp.jwt.added", "org.idp.azure.added", "org.idp.github.added", "org.idp.github.enterprise.added", "org.idp.gitlab.added", "org.idp.gitlab.selfhosted.added", "org.idp.google.added", "org.idp.ldap.added", "org.idp.config.apple.added", "org.idp.saml.added"}, + uint32(1), + "instance", + "instance", + "instance.login.policy.idp.added", + "org", + "org.login.policy.idp.added", + uint32(1), + "instance", + "instance", + "instance.smtp.config.added", + []string{"", "SYSTEM", ""}, + uint32(1), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stmt database.Statement + writeQuery(&stmt, tt.args.query) + assertQuery(t, &stmt, tt.want) + }) + } +} + +type wantQuery struct { + query string + args []any +} + +func assertQuery(t *testing.T, stmt *database.Statement, want wantQuery) bool { + t.Helper() + ok := true + + defer func() { + if !ok { + t.Logf("generated statement: %s\n", stmt.Debug()) + } + }() + + got := stmt.String() + if got != want.query { + t.Errorf("unexpected query:\n want: %q\n got: %q", want.query, got) + ok = false + } + + if len(want.args) != len(stmt.Args()) { + t.Errorf("unexpected length of args, want: %d got: %d", len(want.args), len(stmt.Args())) + return false + } + + for i, arg := range want.args { + if !reflect.DeepEqual(arg, stmt.Args()[i]) { + t.Errorf("unexpected arg at %d, want %v got: %v", i, arg, stmt.Args()[i]) + ok = false + } + } + + return ok +} + +var _ eventstore.Reducer = (*testReducer)(nil) + +type testReducer struct { + expectedReduces int + reduceCount int + shouldErr bool +} + +// Reduce implements eventstore.Reducer. +func (r *testReducer) Reduce(events ...*eventstore.Event[eventstore.StoragePayload]) error { + if r == nil { + return nil + } + r.reduceCount++ + if r.shouldErr { + return errReduce + } + return nil +} + +func (r *testReducer) assert(t *testing.T) { + if r.expectedReduces == r.reduceCount { + return + } + + t.Errorf("unexpected reduces, want %d, got %d", r.expectedReduces, r.reduceCount) +} + +func Test_executeQuery(t *testing.T) { + type args struct { + values [][]driver.Value + reducer *testReducer + } + type want struct { + eventCount int + assertErr func(t *testing.T, err error) bool + } + tests := []struct { + name string + args args + want want + }{ + { + name: "no result", + args: args{ + values: [][]driver.Value{}, + reducer: &testReducer{}, + }, + want: want{ + eventCount: 0, + assertErr: func(t *testing.T, err error) bool { + is := errors.Is(err, nil) + if !is { + t.Errorf("no error expected got: %v", err) + } + return is + }, + }, + }, + { + name: "1 event without payload", + args: args{ + values: [][]driver.Value{ + { + time.Now(), + "event.type", + uint32(23), + float64(123), + uint32(0), + nil, + "gigi", + "owner", + "instance", + "aggregate.type", + "aggregate.id", + uint16(1), + }, + }, + reducer: &testReducer{ + expectedReduces: 1, + }, + }, + want: want{ + eventCount: 1, + assertErr: func(t *testing.T, err error) bool { + is := errors.Is(err, nil) + if !is { + t.Errorf("no error expected got: %v", err) + } + return is + }, + }, + }, + { + name: "1 event with payload", + args: args{ + values: [][]driver.Value{ + { + time.Now(), + "event.type", + uint32(23), + float64(123), + uint32(0), + []byte(`{"name": "gigi"}`), + "gigi", + "owner", + "instance", + "aggregate.type", + "aggregate.id", + uint16(1), + }, + }, + reducer: &testReducer{ + expectedReduces: 1, + }, + }, + want: want{ + eventCount: 1, + assertErr: func(t *testing.T, err error) bool { + is := errors.Is(err, nil) + if !is { + t.Errorf("no error expected got: %v", err) + } + return is + }, + }, + }, + { + name: "multiple events", + args: args{ + values: [][]driver.Value{ + { + time.Now(), + "event.type", + uint32(23), + float64(123), + uint32(0), + nil, + "gigi", + "owner", + "instance", + "aggregate.type", + "aggregate.id", + uint16(1), + }, + { + time.Now(), + "event.type", + uint32(24), + float64(124), + uint32(0), + []byte(`{"name": "gigi"}`), + "gigi", + "owner", + "instance", + "aggregate.type", + "aggregate.id", + uint16(1), + }, + }, + reducer: &testReducer{ + expectedReduces: 2, + }, + }, + want: want{ + eventCount: 2, + assertErr: func(t *testing.T, err error) bool { + is := errors.Is(err, nil) + if !is { + t.Errorf("no error expected got: %v", err) + } + return is + }, + }, + }, + { + name: "reduce error", + args: args{ + values: [][]driver.Value{ + { + time.Now(), + "event.type", + uint32(23), + float64(123), + uint32(0), + nil, + "gigi", + "owner", + "instance", + "aggregate.type", + "aggregate.id", + uint16(1), + }, + { + time.Now(), + "event.type", + uint32(24), + float64(124), + uint32(0), + []byte(`{"name": "gigi"}`), + "gigi", + "owner", + "instance", + "aggregate.type", + "aggregate.id", + uint16(1), + }, + }, + reducer: &testReducer{ + expectedReduces: 1, + shouldErr: true, + }, + }, + want: want{ + eventCount: 1, + assertErr: func(t *testing.T, err error) bool { + is := errors.Is(err, errReduce) + if !is { + t.Errorf("no error expected got: %v", err) + } + return is + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockDB := mock.NewSQLMock(t, + mock.ExpectQuery( + "", + mock.WithQueryResult( + []string{"created_at", "event_type", "sequence", "position", "in_tx_order", "payload", "creator", "owner", "instance_id", "aggregate_type", "aggregate_id", "revision"}, + tt.args.values, + ), + ), + ) + gotEventCount, err := executeQuery(context.Background(), mockDB.DB, &database.Statement{}, tt.args.reducer) + tt.want.assertErr(t, err) + if gotEventCount != tt.want.eventCount { + t.Errorf("executeQuery() = %v, want %v", gotEventCount, tt.want.eventCount) + } + }) + } +} diff --git a/internal/v2/eventstore/postgres/storage.go b/internal/v2/eventstore/postgres/storage.go new file mode 100644 index 0000000000..d2bf2a1195 --- /dev/null +++ b/internal/v2/eventstore/postgres/storage.go @@ -0,0 +1,28 @@ +package postgres + +import ( + "context" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/v2/eventstore" +) + +var ( + _ eventstore.Pusher = (*Storage)(nil) + _ eventstore.Querier = (*Storage)(nil) +) + +type Storage struct { + client *database.DB +} + +func New(client *database.DB) *Storage { + return &Storage{ + client: client, + } +} + +// Health implements eventstore.Pusher. +func (s *Storage) Health(ctx context.Context) error { + return s.client.PingContext(ctx) +} diff --git a/internal/v2/eventstore/push.go b/internal/v2/eventstore/push.go new file mode 100644 index 0000000000..6260315b82 --- /dev/null +++ b/internal/v2/eventstore/push.go @@ -0,0 +1,190 @@ +package eventstore + +import ( + "context" + "database/sql" +) + +type Pusher interface { + healthier + // Push writes the intents to the storage + // if an intent implements [PushReducerIntent] [PushReducerIntent.Reduce] is called after + // the intent was stored + Push(ctx context.Context, intent *PushIntent) error +} + +func NewPushIntent(instance string, opts ...PushOpt) *PushIntent { + intent := &PushIntent{ + instance: instance, + } + + for _, opt := range opts { + opt(intent) + } + + return intent +} + +type PushIntent struct { + instance string + reducer Reducer + tx *sql.Tx + aggregates []*PushAggregate +} + +func (pi *PushIntent) Instance() string { + return pi.instance +} + +func (pi *PushIntent) Reduce(events ...*Event[StoragePayload]) error { + if pi.reducer == nil { + return nil + } + return pi.reducer.Reduce(events...) +} + +func (pi *PushIntent) Tx() *sql.Tx { + return pi.tx +} + +func (pi *PushIntent) Aggregates() []*PushAggregate { + return pi.aggregates +} + +type PushOpt func(pi *PushIntent) + +func PushReducer(reducer Reducer) PushOpt { + return func(pi *PushIntent) { + pi.reducer = reducer + } +} + +func PushTx(tx *sql.Tx) PushOpt { + return func(pi *PushIntent) { + pi.tx = tx + } +} + +func AppendAggregate(owner, typ, id string, opts ...PushAggregateOpt) PushOpt { + return AppendAggregates(NewPushAggregate(owner, typ, id, opts...)) +} + +func AppendAggregates(aggregates ...*PushAggregate) PushOpt { + return func(pi *PushIntent) { + for _, aggregate := range aggregates { + aggregate.parent = pi + } + pi.aggregates = append(pi.aggregates, aggregates...) + } +} + +type PushAggregate struct { + parent *PushIntent + // typ of the aggregate + typ string + // id of the aggregate + id string + // owner of the aggregate + owner string + // Commands is an ordered list of changes on the aggregate + commands []Command + // CurrentSequence checks the current state of the aggregate. + // The following types match the current sequence of the aggregate as described: + // * nil or [SequenceIgnore]: Not relevant to add the commands + // * [SequenceMatches]: Must exactly match + // * [SequenceAtLeast]: Must be >= the given sequence + currentSequence CurrentSequence +} + +func NewPushAggregate(owner, typ, id string, opts ...PushAggregateOpt) *PushAggregate { + pa := &PushAggregate{ + typ: typ, + id: id, + owner: owner, + } + + for _, opt := range opts { + opt(pa) + } + + return pa +} + +func (pa *PushAggregate) Type() string { + return pa.typ +} + +func (pa *PushAggregate) ID() string { + return pa.id +} + +func (pa *PushAggregate) Owner() string { + return pa.owner +} + +func (pa *PushAggregate) Commands() []Command { + return pa.commands +} + +func (pa *PushAggregate) Aggregate() *Aggregate { + return &Aggregate{ + ID: pa.id, + Type: pa.typ, + Owner: pa.owner, + Instance: pa.parent.instance, + } +} + +func (pa *PushAggregate) CurrentSequence() CurrentSequence { + return pa.currentSequence +} + +type PushAggregateOpt func(pa *PushAggregate) + +func SetCurrentSequence(currentSequence CurrentSequence) PushAggregateOpt { + return func(pa *PushAggregate) { + pa.currentSequence = currentSequence + } +} + +func IgnoreCurrentSequence() PushAggregateOpt { + return func(pa *PushAggregate) { + pa.currentSequence = SequenceIgnore() + } +} + +func CurrentSequenceMatches(sequence uint32) PushAggregateOpt { + return func(pa *PushAggregate) { + pa.currentSequence = SequenceMatches(sequence) + } +} + +func CurrentSequenceAtLeast(sequence uint32) PushAggregateOpt { + return func(pa *PushAggregate) { + pa.currentSequence = SequenceAtLeast(sequence) + } +} + +func AppendCommands(commands ...Command) PushAggregateOpt { + return func(pa *PushAggregate) { + pa.commands = append(pa.commands, commands...) + } +} + +type Command interface { + // Creator is the id of the user which created the action + Creator() string + // Type describes the action it's in the past (e.g. user.created) + Type() string + // Revision of the action + Revision() uint16 + // Payload returns the payload of the event. It represent the changed fields by the event + // valid types are: + // * nil: no payload + // * struct: which can be marshalled to json + // * pointer to struct: which can be marshalled to json + // * []byte: json marshalled data + Payload() any + // UniqueConstraints should be added for unique attributes of an event, if nil constraints will not be checked + UniqueConstraints() []*UniqueConstraint +} diff --git a/internal/v2/eventstore/query.go b/internal/v2/eventstore/query.go new file mode 100644 index 0000000000..0dd23ea898 --- /dev/null +++ b/internal/v2/eventstore/query.go @@ -0,0 +1,756 @@ +package eventstore + +import ( + "context" + "database/sql" + "errors" + "slices" + "time" + + "github.com/zitadel/zitadel/internal/v2/database" +) + +type Querier interface { + healthier + Query(ctx context.Context, query *Query) (eventCount int, err error) +} + +type Query struct { + instances *filter[[]string] + filters []*Filter + tx *sql.Tx + pagination *Pagination + reducer Reducer + // TODO: await push +} + +func (q *Query) Instance() database.Condition { + return q.instances.condition +} + +func (q *Query) Filters() []*Filter { + return q.filters +} + +func (q *Query) Tx() *sql.Tx { + return q.tx +} + +func (q *Query) Pagination() *Pagination { + q.ensurePagination() + return q.pagination +} + +func (q *Query) Reduce(events ...*Event[StoragePayload]) error { + return q.reducer.Reduce(events...) +} + +func NewQuery(instance string, reducer Reducer, opts ...QueryOpt) *Query { + query := &Query{ + reducer: reducer, + } + + for _, opt := range append([]QueryOpt{SetInstance(instance)}, opts...) { + opt(query) + } + + return query +} + +type QueryOpt func(q *Query) + +func SetInstance(instance string) QueryOpt { + return InstancesEqual(instance) +} + +func InstancesEqual(instances ...string) QueryOpt { + return func(q *Query) { + var cond database.Condition + switch len(instances) { + case 0: + return + case 1: + cond = database.NewTextEqual(instances[0]) + default: + cond = database.NewListEquals(instances...) + } + q.instances = &filter[[]string]{ + condition: cond, + value: &instances, + } + } +} + +func InstancesContains(instances ...string) QueryOpt { + return func(f *Query) { + var cond database.Condition + switch len(instances) { + case 0: + return + case 1: + cond = database.NewTextEqual(instances[0]) + default: + cond = database.NewListContains(instances...) + } + + f.instances = &filter[[]string]{ + condition: cond, + value: &instances, + } + } +} + +func InstancesNotContains(instances ...string) QueryOpt { + return func(f *Query) { + var cond database.Condition + switch len(instances) { + case 0: + return + case 1: + cond = database.NewTextUnequal(instances[0]) + default: + cond = database.NewListNotContains(instances...) + } + f.instances = &filter[[]string]{ + condition: cond, + value: &instances, + } + } +} + +func SetQueryTx(tx *sql.Tx) QueryOpt { + return func(query *Query) { + query.tx = tx + } +} + +func QueryPagination(opts ...paginationOpt) QueryOpt { + return func(query *Query) { + query.ensurePagination() + + for _, opt := range opts { + opt(query.pagination) + } + } +} + +func (q *Query) ensurePagination() { + if q.pagination != nil { + return + } + q.pagination = new(Pagination) +} + +func AppendFilters(filters ...*Filter) QueryOpt { + return func(query *Query) { + for _, filter := range filters { + filter.parent = query + } + query.filters = append(query.filters, filters...) + } +} + +func SetFilters(filters ...*Filter) QueryOpt { + return func(query *Query) { + for _, filter := range filters { + filter.parent = query + } + query.filters = filters + } +} + +func AppendFilter(opts ...FilterOpt) QueryOpt { + return AppendFilters(NewFilter(opts...)) +} + +var ErrFilterMerge = errors.New("merge failed") + +type FilterCreator func() []*Filter + +func MergeFilters(filters ...[]*Filter) []*Filter { + // TODO: improve merge by checking fields of filters and merge filters if possible + // this will reduce cost of queries which do multiple filters + return slices.Concat(filters...) +} + +type Filter struct { + parent *Query + pagination *Pagination + + aggregateFilters []*AggregateFilter +} + +func (f *Filter) Parent() *Query { + return f.parent +} + +func (f *Filter) Pagination() *Pagination { + if f.pagination == nil { + return f.parent.Pagination() + } + return f.pagination +} + +func (f *Filter) AggregateFilters() []*AggregateFilter { + return f.aggregateFilters +} + +func NewFilter(opts ...FilterOpt) *Filter { + f := new(Filter) + + for _, opt := range opts { + opt(f) + } + + return f +} + +type FilterOpt func(f *Filter) + +func AppendAggregateFilter(typ string, opts ...AggregateFilterOpt) FilterOpt { + return AppendAggregateFilters(NewAggregateFilter(typ, opts...)) +} + +func AppendAggregateFilters(filters ...*AggregateFilter) FilterOpt { + return func(mf *Filter) { + mf.aggregateFilters = append(mf.aggregateFilters, filters...) + } +} + +func SetAggregateFilters(filters ...*AggregateFilter) FilterOpt { + return func(mf *Filter) { + mf.aggregateFilters = filters + } +} + +func FilterPagination(opts ...paginationOpt) FilterOpt { + return func(filter *Filter) { + filter.ensurePagination() + + for _, opt := range opts { + opt(filter.pagination) + } + } +} + +func (f *Filter) ensurePagination() { + if f.pagination != nil { + return + } + f.pagination = new(Pagination) +} + +func NewAggregateFilter(typ string, opts ...AggregateFilterOpt) *AggregateFilter { + filter := &AggregateFilter{ + typ: typ, + } + + for _, opt := range opts { + opt(filter) + } + + return filter +} + +type AggregateFilter struct { + typ string + ids []string + events []*EventFilter +} + +func (f *AggregateFilter) Type() *database.TextFilter[string] { + return database.NewTextEqual(f.typ) +} + +func (f *AggregateFilter) IDs() database.Condition { + if len(f.ids) == 0 { + return nil + } + if len(f.ids) == 1 { + return database.NewTextEqual(f.ids[0]) + } + + return database.NewListContains(f.ids...) +} + +func (f *AggregateFilter) Events() []*EventFilter { + return f.events +} + +type AggregateFilterOpt func(f *AggregateFilter) + +func SetAggregateID(id string) AggregateFilterOpt { + return func(filter *AggregateFilter) { + filter.ids = []string{id} + } +} + +func AppendAggregateIDs(ids ...string) AggregateFilterOpt { + return func(f *AggregateFilter) { + f.ids = append(f.ids, ids...) + } +} + +// AggregateIDs sets the given ids as search param +func AggregateIDs(ids ...string) AggregateFilterOpt { + return func(f *AggregateFilter) { + f.ids = ids + } +} + +func AppendEvent(opts ...EventFilterOpt) AggregateFilterOpt { + return AppendEvents(NewEventFilter(opts...)) +} + +func AppendEvents(events ...*EventFilter) AggregateFilterOpt { + return func(filter *AggregateFilter) { + filter.events = append(filter.events, events...) + } +} + +func SetEvents(events ...*EventFilter) AggregateFilterOpt { + return func(filter *AggregateFilter) { + filter.events = events + } +} + +func NewEventFilter(opts ...EventFilterOpt) *EventFilter { + filter := new(EventFilter) + + for _, opt := range opts { + opt(filter) + } + + return filter +} + +type EventFilter struct { + types []string + revision *filter[uint16] + createdAt *filter[time.Time] + sequence *filter[uint32] + creators *filter[[]string] +} + +type filter[T any] struct { + condition database.Condition + // the following fields are considered as one of + // you can either have value and max or value + min, max *T + value *T +} + +func (f *EventFilter) Types() database.Condition { + switch len(f.types) { + case 0: + return nil + case 1: + return database.NewTextEqual(f.types[0]) + default: + return database.NewListContains(f.types...) + } +} + +func (f *EventFilter) Revision() database.Condition { + if f.revision == nil { + return nil + } + return f.revision.condition +} + +func (f *EventFilter) CreatedAt() database.Condition { + if f.createdAt == nil { + return nil + } + return f.createdAt.condition +} + +func (f *EventFilter) Sequence() database.Condition { + if f.sequence == nil { + return nil + } + return f.sequence.condition +} + +func (f *EventFilter) Creators() database.Condition { + if f.creators == nil { + return nil + } + return f.creators.condition +} + +type EventFilterOpt func(f *EventFilter) + +func SetEventType(typ string) EventFilterOpt { + return func(filter *EventFilter) { + filter.types = []string{typ} + } +} + +// SetEventTypes overwrites the currently set types +func SetEventTypes(types ...string) EventFilterOpt { + return func(filter *EventFilter) { + filter.types = types + } +} + +// AppendEventTypes appends the types the currently set types +func AppendEventTypes(types ...string) EventFilterOpt { + return func(filter *EventFilter) { + filter.types = append(filter.types, types...) + } +} + +func EventRevisionEquals(revision uint16) EventFilterOpt { + return func(f *EventFilter) { + f.revision = &filter[uint16]{ + condition: database.NewNumberEquals(revision), + value: &revision, + } + } +} + +func EventRevisionAtLeast(revision uint16) EventFilterOpt { + return func(f *EventFilter) { + f.revision = &filter[uint16]{ + condition: database.NewNumberAtLeast(revision), + value: &revision, + } + } +} + +func EventRevisionGreater(revision uint16) EventFilterOpt { + return func(f *EventFilter) { + f.revision = &filter[uint16]{ + condition: database.NewNumberGreater(revision), + value: &revision, + } + } +} + +func EventRevisionAtMost(revision uint16) EventFilterOpt { + return func(f *EventFilter) { + f.revision = &filter[uint16]{ + condition: database.NewNumberAtMost(revision), + value: &revision, + } + } +} + +func EventRevisionLess(revision uint16) EventFilterOpt { + return func(f *EventFilter) { + f.revision = &filter[uint16]{ + condition: database.NewNumberLess(revision), + value: &revision, + } + } +} + +func EventRevisionBetween(min, max uint16) EventFilterOpt { + return func(f *EventFilter) { + f.revision = &filter[uint16]{ + condition: database.NewNumberBetween(min, max), + min: &min, + max: &max, + } + } +} + +func EventCreatedAtEquals(createdAt time.Time) EventFilterOpt { + return func(f *EventFilter) { + f.createdAt = &filter[time.Time]{ + condition: database.NewNumberEquals(createdAt), + value: &createdAt, + } + } +} + +func EventCreatedAtAtLeast(createdAt time.Time) EventFilterOpt { + return func(f *EventFilter) { + f.createdAt = &filter[time.Time]{ + condition: database.NewNumberAtLeast(createdAt), + value: &createdAt, + } + } +} + +func EventCreatedAtGreater(createdAt time.Time) EventFilterOpt { + return func(f *EventFilter) { + f.createdAt = &filter[time.Time]{ + condition: database.NewNumberGreater(createdAt), + value: &createdAt, + } + } +} + +func EventCreatedAtAtMost(createdAt time.Time) EventFilterOpt { + return func(f *EventFilter) { + f.createdAt = &filter[time.Time]{ + condition: database.NewNumberAtMost(createdAt), + value: &createdAt, + } + } +} + +func EventCreatedAtLess(createdAt time.Time) EventFilterOpt { + return func(f *EventFilter) { + f.createdAt = &filter[time.Time]{ + condition: database.NewNumberLess(createdAt), + value: &createdAt, + } + } +} + +func EventCreatedAtBetween(min, max time.Time) EventFilterOpt { + return func(f *EventFilter) { + f.createdAt = &filter[time.Time]{ + condition: database.NewNumberBetween(min, max), + min: &min, + max: &max, + } + } +} + +func EventSequenceEquals(sequence uint32) EventFilterOpt { + return func(f *EventFilter) { + f.sequence = &filter[uint32]{ + condition: database.NewNumberEquals(sequence), + value: &sequence, + } + } +} + +func EventSequenceAtLeast(sequence uint32) EventFilterOpt { + return func(f *EventFilter) { + f.sequence = &filter[uint32]{ + condition: database.NewNumberAtLeast(sequence), + value: &sequence, + } + } +} + +func EventSequenceGreater(sequence uint32) EventFilterOpt { + return func(f *EventFilter) { + f.sequence = &filter[uint32]{ + condition: database.NewNumberGreater(sequence), + value: &sequence, + } + } +} + +func EventSequenceAtMost(sequence uint32) EventFilterOpt { + return func(f *EventFilter) { + f.sequence = &filter[uint32]{ + condition: database.NewNumberAtMost(sequence), + value: &sequence, + } + } +} + +func EventSequenceLess(sequence uint32) EventFilterOpt { + return func(f *EventFilter) { + f.sequence = &filter[uint32]{ + condition: database.NewNumberLess(sequence), + value: &sequence, + } + } +} + +func EventSequenceBetween(min, max uint32) EventFilterOpt { + return func(f *EventFilter) { + f.sequence = &filter[uint32]{ + condition: database.NewNumberBetween(min, max), + min: &min, + max: &max, + } + } +} + +func EventCreatorsEqual(creators ...string) EventFilterOpt { + return func(f *EventFilter) { + var cond database.Condition + switch len(creators) { + case 0: + return + case 1: + cond = database.NewTextEqual(creators[0]) + default: + cond = database.NewListEquals(creators...) + } + f.creators = &filter[[]string]{ + condition: cond, + value: &creators, + } + } +} + +func EventCreatorsContains(creators ...string) EventFilterOpt { + return func(f *EventFilter) { + var cond database.Condition + switch len(creators) { + case 0: + return + case 1: + cond = database.NewTextEqual(creators[0]) + default: + cond = database.NewListContains(creators...) + } + + f.creators = &filter[[]string]{ + condition: cond, + value: &creators, + } + } +} + +func EventCreatorsNotContains(creators ...string) EventFilterOpt { + return func(f *EventFilter) { + var cond database.Condition + switch len(creators) { + case 0: + return + case 1: + cond = database.NewTextUnequal(creators[0]) + default: + cond = database.NewListNotContains(creators...) + } + f.creators = &filter[[]string]{ + condition: cond, + value: &creators, + } + } +} + +func Limit(limit uint32) paginationOpt { + return func(p *Pagination) { + p.ensurePagination() + + p.pagination.Limit = limit + } +} + +func Offset(offset uint32) paginationOpt { + return func(p *Pagination) { + p.ensurePagination() + + p.pagination.Offset = offset + } +} + +type PositionCondition struct { + min, max *GlobalPosition +} + +func (pc *PositionCondition) Max() *GlobalPosition { + if pc == nil || pc.max == nil { + return nil + } + max := *pc.max + return &max +} + +func (pc *PositionCondition) Min() *GlobalPosition { + if pc == nil || pc.min == nil { + return nil + } + min := *pc.min + return &min +} + +// PositionGreater prepares the condition as follows +// if inPositionOrder is set: position = AND in_tx_order > OR or position > +// if inPositionOrder is NOT set: position > +func PositionGreater(position float64, inPositionOrder uint32) paginationOpt { + return func(p *Pagination) { + p.ensurePosition() + p.position.min = &GlobalPosition{ + Position: position, + InPositionOrder: inPositionOrder, + } + } +} + +// GlobalPositionGreater prepares the condition as follows +// if inPositionOrder is set: position = AND in_tx_order > OR or position > +// if inPositionOrder is NOT set: position > +func GlobalPositionGreater(position *GlobalPosition) paginationOpt { + return PositionGreater(position.Position, position.InPositionOrder) +} + +// PositionLess prepares the condition as follows +// if inPositionOrder is set: position = AND in_tx_order > OR or position > +// if inPositionOrder is NOT set: position > +func PositionLess(position float64, inPositionOrder uint32) paginationOpt { + return func(p *Pagination) { + p.ensurePosition() + p.position.max = &GlobalPosition{ + Position: position, + InPositionOrder: inPositionOrder, + } + } +} + +func PositionBetween(min, max *GlobalPosition) paginationOpt { + return func(p *Pagination) { + GlobalPositionGreater(min)(p) + GlobalPositionLess(max)(p) + } +} + +// GlobalPositionLess prepares the condition as follows +// if inPositionOrder is set: position = AND in_tx_order > OR or position > +// if inPositionOrder is NOT set: position > +func GlobalPositionLess(position *GlobalPosition) paginationOpt { + return PositionLess(position.Position, position.InPositionOrder) +} + +type Pagination struct { + pagination *database.Pagination + position *PositionCondition + + desc bool +} + +type paginationOpt func(*Pagination) + +func (p *Pagination) Pagination() *database.Pagination { + if p == nil { + return nil + } + return p.pagination +} + +func (p *Pagination) Position() *PositionCondition { + if p == nil { + return nil + } + return p.position +} + +func (p *Pagination) Desc() bool { + if p == nil { + return false + } + + return p.desc +} + +func (p *Pagination) ensurePagination() { + if p.pagination != nil { + return + } + p.pagination = new(database.Pagination) +} + +func (p *Pagination) ensurePosition() { + if p.position != nil { + return + } + p.position = new(PositionCondition) +} + +func Descending() paginationOpt { + return func(p *Pagination) { + p.desc = true + } +} diff --git a/internal/v2/eventstore/query_test.go b/internal/v2/eventstore/query_test.go new file mode 100644 index 0000000000..00c08914c1 --- /dev/null +++ b/internal/v2/eventstore/query_test.go @@ -0,0 +1,1063 @@ +package eventstore + +import ( + "database/sql" + "reflect" + "testing" + "time" + + "github.com/zitadel/zitadel/internal/v2/database" +) + +func TestPaginationOpt(t *testing.T) { + type args struct { + opts []paginationOpt + } + tests := []struct { + name string + args args + want *Pagination + }{ + { + name: "desc", + args: args{ + opts: []paginationOpt{ + Descending(), + }, + }, + want: &Pagination{ + desc: true, + }, + }, + { + name: "limit", + args: args{ + opts: []paginationOpt{ + Limit(10), + }, + }, + want: &Pagination{ + pagination: &database.Pagination{ + Limit: 10, + }, + }, + }, + { + name: "offset", + args: args{ + opts: []paginationOpt{ + Offset(10), + }, + }, + want: &Pagination{ + pagination: &database.Pagination{ + Offset: 10, + }, + }, + }, + { + name: "limit and offset", + args: args{ + opts: []paginationOpt{ + Limit(10), + Offset(20), + }, + }, + want: &Pagination{ + pagination: &database.Pagination{ + Limit: 10, + Offset: 20, + }, + }, + }, + { + name: "global position greater", + args: args{ + opts: []paginationOpt{ + GlobalPositionGreater(&GlobalPosition{Position: 10}), + }, + }, + want: &Pagination{ + position: &PositionCondition{ + min: &GlobalPosition{ + Position: 10, + InPositionOrder: 0, + }, + }, + }, + }, + { + name: "position greater", + args: args{ + opts: []paginationOpt{ + PositionGreater(10, 0), + }, + }, + want: &Pagination{ + position: &PositionCondition{ + min: &GlobalPosition{ + Position: 10, + InPositionOrder: 0, + }, + }, + desc: false, + }, + }, + { + name: "position less", + args: args{ + opts: []paginationOpt{ + PositionLess(10, 12), + }, + }, + want: &Pagination{ + position: &PositionCondition{ + max: &GlobalPosition{ + Position: 10, + InPositionOrder: 12, + }, + }, + }, + }, + { + name: "global position less", + args: args{ + opts: []paginationOpt{ + GlobalPositionLess(&GlobalPosition{Position: 12, InPositionOrder: 24}), + }, + }, + want: &Pagination{ + position: &PositionCondition{ + max: &GlobalPosition{ + Position: 12, + InPositionOrder: 24, + }, + }, + }, + }, + { + name: "position between", + args: args{ + opts: []paginationOpt{ + PositionBetween( + &GlobalPosition{10, 12}, + &GlobalPosition{20, 0}, + ), + }, + }, + want: &Pagination{ + position: &PositionCondition{ + min: &GlobalPosition{ + Position: 10, + InPositionOrder: 12, + }, + max: &GlobalPosition{ + Position: 20, + InPositionOrder: 0, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := new(Pagination) + for _, opt := range tt.args.opts { + opt(got) + } + + if tt.want.Desc() != got.Desc() { + t.Errorf("unexpected desc %v, want: %v", got.desc, tt.want.desc) + } + if !reflect.DeepEqual(tt.want.Pagination(), got.Pagination()) { + t.Errorf("unexpected pagination %v, want: %v", got.pagination, tt.want.pagination) + } + if !reflect.DeepEqual(tt.want.Position(), got.Position()) { + t.Errorf("unexpected position %v, want: %v", got.position, tt.want.position) + } + if !reflect.DeepEqual(tt.want.Position().Max(), got.Position().Max()) { + t.Errorf("unexpected position.max %v, want: %v", got.Position().max, tt.want.Position().max) + } + if !reflect.DeepEqual(tt.want.Position().Min(), got.Position().Min()) { + t.Errorf("unexpected position.min %v, want: %v", got.Position().min, tt.want.Position().min) + } + }) + } +} + +func TestEventFilterOpt(t *testing.T) { + type args struct { + opts []EventFilterOpt + } + now := time.Now() + tests := []struct { + name string + args args + want *EventFilter + }{ + { + name: "EventType", + args: args{ + opts: []EventFilterOpt{ + SetEventType("test"), + SetEventType("test2"), + }, + }, + want: &EventFilter{ + types: []string{"test2"}, + }, + }, + { + name: "EventTypes", + args: args{ + opts: []EventFilterOpt{ + SetEventTypes("a", "s"), + SetEventTypes("d", "f"), + }, + }, + want: &EventFilter{ + types: []string{"d", "f"}, + }, + }, + { + name: "AppendEventTypes", + args: args{ + opts: []EventFilterOpt{ + AppendEventTypes("a", "s"), + AppendEventTypes("d", "f"), + }, + }, + want: &EventFilter{ + types: []string{"a", "s", "d", "f"}, + }, + }, + { + name: "EventRevisionEquals", + args: args{ + opts: []EventFilterOpt{ + EventRevisionEquals(12), + }, + }, + want: &EventFilter{ + revision: &filter[uint16]{ + condition: database.NewNumberEquals[uint16](12), + value: toPtr(uint16(12)), + }, + }, + }, + { + name: "EventRevisionAtLeast", + args: args{ + opts: []EventFilterOpt{ + EventRevisionAtLeast(12), + }, + }, + want: &EventFilter{ + revision: &filter[uint16]{ + condition: database.NewNumberAtLeast[uint16](12), + value: toPtr(uint16(12)), + }, + }, + }, + { + name: "EventRevisionGreater", + args: args{ + opts: []EventFilterOpt{ + EventRevisionGreater(12), + }, + }, + want: &EventFilter{ + revision: &filter[uint16]{ + condition: database.NewNumberGreater[uint16](12), + value: toPtr(uint16(12)), + }, + }, + }, + { + name: "EventRevisionAtMost", + args: args{ + opts: []EventFilterOpt{ + EventRevisionAtMost(12), + }, + }, + want: &EventFilter{ + revision: &filter[uint16]{ + condition: database.NewNumberAtMost[uint16](12), + value: toPtr(uint16(12)), + }, + }, + }, + { + name: "EventRevisionLess", + args: args{ + opts: []EventFilterOpt{ + EventRevisionLess(12), + }, + }, + want: &EventFilter{ + revision: &filter[uint16]{ + condition: database.NewNumberLess[uint16](12), + value: toPtr(uint16(12)), + }, + }, + }, + { + name: "EventRevisionBetween", + args: args{ + opts: []EventFilterOpt{ + EventRevisionBetween(12, 20), + }, + }, + want: &EventFilter{ + revision: &filter[uint16]{ + condition: database.NewNumberBetween[uint16](12, 20), + min: toPtr(uint16(12)), + max: toPtr(uint16(20)), + }, + }, + }, + { + name: "EventCreatedAtEquals", + args: args{ + opts: []EventFilterOpt{ + EventCreatedAtEquals(now), + }, + }, + want: &EventFilter{ + createdAt: &filter[time.Time]{ + condition: database.NewNumberEquals(now), + value: toPtr(now), + }, + }, + }, + { + name: "EventCreatedAtAtLeast", + args: args{ + opts: []EventFilterOpt{ + EventCreatedAtAtLeast(now), + }, + }, + want: &EventFilter{ + createdAt: &filter[time.Time]{ + condition: database.NewNumberAtLeast(now), + value: toPtr(now), + }, + }, + }, + { + name: "EventCreatedAtGreater", + args: args{ + opts: []EventFilterOpt{ + EventCreatedAtGreater(now), + }, + }, + want: &EventFilter{ + createdAt: &filter[time.Time]{ + condition: database.NewNumberGreater(now), + value: toPtr(now), + }, + }, + }, + { + name: "EventCreatedAtAtMost", + args: args{ + opts: []EventFilterOpt{ + EventCreatedAtAtMost(now), + }, + }, + want: &EventFilter{ + createdAt: &filter[time.Time]{ + condition: database.NewNumberAtMost(now), + value: toPtr(now), + }, + }, + }, + { + name: "EventCreatedAtLess", + args: args{ + opts: []EventFilterOpt{ + EventCreatedAtLess(now), + }, + }, + want: &EventFilter{ + createdAt: &filter[time.Time]{ + condition: database.NewNumberLess(now), + value: toPtr(now), + }, + }, + }, + { + name: "EventCreatedAtBetween", + args: args{ + opts: []EventFilterOpt{ + EventCreatedAtBetween(now, now.Add(1*time.Second)), + }, + }, + want: &EventFilter{ + createdAt: &filter[time.Time]{ + condition: database.NewNumberBetween(now, now.Add(1*time.Second)), + min: toPtr(now), + max: toPtr(now.Add(1 * time.Second)), + }, + }, + }, + { + name: "EventSequenceEquals", + args: args{ + opts: []EventFilterOpt{ + EventSequenceEquals(12), + }, + }, + want: &EventFilter{ + sequence: &filter[uint32]{ + condition: database.NewNumberEquals[uint32](12), + value: toPtr(uint32(12)), + }, + }, + }, + { + name: "EventSequenceAtLeast", + args: args{ + opts: []EventFilterOpt{ + EventSequenceAtLeast(12), + }, + }, + want: &EventFilter{ + sequence: &filter[uint32]{ + condition: database.NewNumberAtLeast[uint32](12), + value: toPtr(uint32(12)), + }, + }, + }, + { + name: "EventSequenceGreater", + args: args{ + opts: []EventFilterOpt{ + EventSequenceGreater(12), + }, + }, + want: &EventFilter{ + sequence: &filter[uint32]{ + condition: database.NewNumberGreater[uint32](12), + value: toPtr(uint32(12)), + }, + }, + }, + { + name: "EventSequenceAtMost", + args: args{ + opts: []EventFilterOpt{ + EventSequenceAtMost(12), + }, + }, + want: &EventFilter{ + sequence: &filter[uint32]{ + condition: database.NewNumberAtMost[uint32](12), + value: toPtr(uint32(12)), + }, + }, + }, + { + name: "EventSequenceLess", + args: args{ + opts: []EventFilterOpt{ + EventSequenceLess(12), + }, + }, + want: &EventFilter{ + sequence: &filter[uint32]{ + condition: database.NewNumberLess[uint32](12), + value: toPtr(uint32(12)), + }, + }, + }, + { + name: "EventSequenceBetween", + args: args{ + opts: []EventFilterOpt{ + EventSequenceBetween(12, 24), + }, + }, + want: &EventFilter{ + sequence: &filter[uint32]{ + condition: database.NewNumberBetween[uint32](12, 24), + min: toPtr(uint32(12)), + max: toPtr(uint32(24)), + }, + }, + }, + { + name: "EventCreatorsEqual", + args: args{ + opts: []EventFilterOpt{ + EventCreatorsEqual("cr", "ea", "tor"), + }, + }, + want: &EventFilter{ + creators: &filter[[]string]{ + condition: database.NewListEquals("cr", "ea", "tor"), + value: toPtr([]string{"cr", "ea", "tor"}), + }, + }, + }, + { + name: "EventCreatorsEqual no params", + args: args{ + opts: []EventFilterOpt{ + EventCreatorsEqual(), + }, + }, + want: &EventFilter{}, + }, + { + name: "EventCreatorsEqual one params", + args: args{ + opts: []EventFilterOpt{ + EventCreatorsEqual("asdf"), + }, + }, + want: &EventFilter{ + creators: &filter[[]string]{ + condition: database.NewTextEqual("asdf"), + value: toPtr([]string{"asdf"}), + }, + }, + }, + { + name: "EventCreatorsContains", + args: args{ + opts: []EventFilterOpt{ + EventCreatorsContains("cr", "ea", "tor"), + }, + }, + want: &EventFilter{ + creators: &filter[[]string]{ + condition: database.NewListContains("cr", "ea", "tor"), + value: toPtr([]string{"cr", "ea", "tor"}), + }, + }, + }, + { + name: "EventCreatorsContains no params", + args: args{ + opts: []EventFilterOpt{ + EventCreatorsContains(), + }, + }, + want: &EventFilter{}, + }, + { + name: "EventCreatorsContains one params", + args: args{ + opts: []EventFilterOpt{ + EventCreatorsContains("asdf"), + }, + }, + want: &EventFilter{ + creators: &filter[[]string]{ + condition: database.NewTextEqual("asdf"), + value: toPtr([]string{"asdf"}), + }, + }, + }, + { + name: "EventCreatorsNotContains", + args: args{ + opts: []EventFilterOpt{ + EventCreatorsNotContains("cr", "ea", "tor"), + }, + }, + want: &EventFilter{ + creators: &filter[[]string]{ + condition: database.NewListNotContains("cr", "ea", "tor"), + value: toPtr([]string{"cr", "ea", "tor"}), + }, + }, + }, + { + name: "EventCreatorsNotContains no params", + args: args{ + opts: []EventFilterOpt{ + EventCreatorsNotContains(), + }, + }, + want: &EventFilter{}, + }, + { + name: "EventCreatorsNotContains one params", + args: args{ + opts: []EventFilterOpt{ + EventCreatorsNotContains("asdf"), + }, + }, + want: &EventFilter{ + creators: &filter[[]string]{ + condition: database.NewTextUnequal("asdf"), + value: toPtr([]string{"asdf"}), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NewEventFilter(tt.args.opts...) + + if !reflect.DeepEqual(tt.want.Types(), got.Types()) { + t.Errorf("unexpected types %v, want: %v", got.types, tt.want.types) + } + if !reflect.DeepEqual(tt.want.Revision(), got.Revision()) { + t.Errorf("unexpected revision %v, want: %v", got.revision, tt.want.revision) + } + if !reflect.DeepEqual(tt.want.CreatedAt(), got.CreatedAt()) { + t.Errorf("unexpected createdAt %v, want: %v", got.createdAt, tt.want.createdAt) + } + if !reflect.DeepEqual(tt.want.Sequence(), got.Sequence()) { + t.Errorf("unexpected sequence %v, want: %v", got.sequence, tt.want.sequence) + } + if !reflect.DeepEqual(tt.want.Creators(), got.Creators()) { + t.Errorf("unexpected creators %v, want: %v", got.creators, tt.want.creators) + } + }) + } +} + +func TestAggregateFilter(t *testing.T) { + type args struct { + opts []AggregateFilterOpt + } + tests := []struct { + name string + args args + want *AggregateFilter + }{ + { + name: "AggregateID", + args: args{ + opts: []AggregateFilterOpt{ + SetAggregateID("asdf"), + }, + }, + want: &AggregateFilter{ + ids: []string{"asdf"}, + }, + }, + { + name: "AggregateIDs", + args: args{ + opts: []AggregateFilterOpt{ + AggregateIDs("a", "s"), + AggregateIDs("d", "f"), + }, + }, + want: &AggregateFilter{ + ids: []string{"d", "f"}, + }, + }, + { + name: "AggregateIDs", + args: args{ + opts: []AggregateFilterOpt{ + AppendAggregateIDs("a", "s"), + AppendAggregateIDs("d", "f"), + }, + }, + want: &AggregateFilter{ + ids: []string{"a", "s", "d", "f"}, + }, + }, + { + name: "AppendEvent", + args: args{ + opts: []AggregateFilterOpt{ + AppendEvent(AppendEventTypes("asdf")), + AppendEvent(AppendEventTypes("asdf")), + }, + }, + want: &AggregateFilter{ + events: make([]*EventFilter, 2), + }, + }, + { + name: "AppendEvents", + args: args{ + opts: []AggregateFilterOpt{ + AppendEvents(NewEventFilter()), + AppendEvents(NewEventFilter()), + }, + }, + want: &AggregateFilter{ + events: make([]*EventFilter, 2), + }, + }, + { + name: "Events", + args: args{ + opts: []AggregateFilterOpt{ + SetEvents(NewEventFilter()), + SetEvents(NewEventFilter()), + }, + }, + want: &AggregateFilter{ + events: make([]*EventFilter, 1), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NewAggregateFilter("", tt.args.opts...) + + if tt.want.typ != got.typ { + t.Errorf("unexpected typ %v, want: %v", got.typ, tt.want.typ) + } + if !reflect.DeepEqual(tt.want.Type(), got.Type()) { + t.Errorf("unexpected typ %v, want: %v", got.typ, tt.want.typ) + } + if !reflect.DeepEqual(tt.want.IDs(), got.IDs()) { + t.Errorf("unexpected ids %v, want: %v", got.ids, tt.want.ids) + } + if len(tt.want.Events()) != len(got.Events()) { + t.Errorf("unexpected length of events %v, want: %v", len(got.events), len(tt.want.events)) + } + }) + } +} + +func TestFilterOpt(t *testing.T) { + type args struct { + opts []FilterOpt + } + tests := []struct { + name string + args args + want *Filter + }{ + { + name: "limit 1", + args: args{ + opts: []FilterOpt{ + FilterPagination(Limit(10)), + FilterPagination(Limit(1)), + }, + }, + want: &Filter{ + pagination: &Pagination{ + pagination: &database.Pagination{ + Limit: 1, + }, + }, + }, + }, + { + name: "AppendAggregateFilter", + args: args{ + opts: []FilterOpt{ + AppendAggregateFilter("typ"), + AppendAggregateFilter("typ2"), + }, + }, + want: &Filter{ + aggregateFilters: make([]*AggregateFilter, 2), + }, + }, + { + name: "AppendAggregateFilters", + args: args{ + opts: []FilterOpt{ + AppendAggregateFilters(NewAggregateFilter("typ")), + AppendAggregateFilters(NewAggregateFilter("typ2")), + }, + }, + want: &Filter{ + aggregateFilters: make([]*AggregateFilter, 2), + }, + }, + { + name: "AggregateFilters", + args: args{ + opts: []FilterOpt{ + SetAggregateFilters(NewAggregateFilter("typ")), + SetAggregateFilters(NewAggregateFilter("typ2")), + }, + }, + want: &Filter{ + aggregateFilters: make([]*AggregateFilter, 1), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NewFilter(tt.args.opts...) + parent := NewQuery("instance", nil) + got.parent = parent + tt.want.parent = parent + + if !reflect.DeepEqual(tt.want.Pagination(), got.Pagination()) { + t.Errorf("unexpected pagination %v, want: %v", got.pagination, tt.want.pagination) + } + if len(tt.want.AggregateFilters()) != len(got.AggregateFilters()) { + t.Errorf("unexpected length of aggregateFilters %v, want: %v", len(got.aggregateFilters), len(tt.want.aggregateFilters)) + } + }) + } +} + +func TestQueryOpt(t *testing.T) { + type args struct { + opts []QueryOpt + } + var tx sql.Tx + tests := []struct { + name string + args args + want *Query + }{ + { + name: "limit 1", + args: args{ + opts: []QueryOpt{ + QueryPagination(Limit(10)), + QueryPagination(Limit(1)), + }, + }, + want: &Query{ + instances: &filter[[]string]{ + condition: database.NewTextEqual("instance"), + value: toPtr([]string{"instance"}), + }, + pagination: &Pagination{ + pagination: &database.Pagination{ + Limit: 1, + }, + }, + }, + }, + { + name: "with tx", + args: args{ + opts: []QueryOpt{ + SetQueryTx(&tx), + }, + }, + want: &Query{ + instances: &filter[[]string]{ + condition: database.NewTextEqual("instance"), + value: toPtr([]string{"instance"}), + }, + tx: &tx, + }, + }, + { + name: "instance", + args: args{ + opts: []QueryOpt{ + SetInstance("instance2"), + }, + }, + want: &Query{ + instances: &filter[[]string]{ + condition: database.NewTextEqual("instance2"), + value: toPtr([]string{"instance2"}), + }, + }, + }, + { + name: "InstanceEqual no param", + args: args{ + opts: []QueryOpt{ + InstancesEqual(), + }, + }, + want: &Query{ + instances: &filter[[]string]{ + condition: database.NewTextEqual("instance"), + value: toPtr([]string{"instance"}), + }, + }, + }, + { + name: "InstanceEqual 1 param", + args: args{ + opts: []QueryOpt{ + InstancesEqual("instance2"), + }, + }, + want: &Query{ + instances: &filter[[]string]{ + condition: database.NewTextEqual("instance2"), + value: toPtr([]string{"instance2"}), + }, + }, + }, + { + name: "InstanceEqual 2 params", + args: args{ + opts: []QueryOpt{ + InstancesEqual("instance2"), + InstancesEqual("inst", "ancestor"), + }, + }, + want: &Query{ + instances: &filter[[]string]{ + condition: database.NewListEquals("inst", "ancestor"), + value: toPtr([]string{"inst", "ancestor"}), + }, + }, + }, + { + name: "InstancesContains no param", + args: args{ + opts: []QueryOpt{ + InstancesContains(), + }, + }, + want: &Query{ + instances: &filter[[]string]{ + condition: database.NewTextEqual("instance"), + value: toPtr([]string{"instance"}), + }, + }, + }, + { + name: "InstancesContains 1 param", + args: args{ + opts: []QueryOpt{ + InstancesContains("instance2"), + }, + }, + want: &Query{ + instances: &filter[[]string]{ + condition: database.NewTextEqual("instance2"), + value: toPtr([]string{"instance2"}), + }, + }, + }, + { + name: "InstancesContains 2 params", + args: args{ + opts: []QueryOpt{ + InstancesContains("instance2"), + InstancesContains("inst", "ancestor"), + }, + }, + want: &Query{ + instances: &filter[[]string]{ + condition: database.NewListContains("inst", "ancestor"), + value: toPtr([]string{"inst", "ancestor"}), + }, + }, + }, + { + name: "InstancesNotContains no param", + args: args{ + opts: []QueryOpt{ + InstancesNotContains(), + }, + }, + want: &Query{ + instances: &filter[[]string]{ + condition: database.NewTextEqual("instance"), + value: toPtr([]string{"instance"}), + }, + }, + }, + { + name: "InstancesNotContains 1 param", + args: args{ + opts: []QueryOpt{ + InstancesNotContains("instance2"), + }, + }, + want: &Query{ + instances: &filter[[]string]{ + condition: database.NewTextUnequal("instance2"), + value: toPtr([]string{"instance2"}), + }, + }, + }, + { + name: "InstancesNotContains 2 params", + args: args{ + opts: []QueryOpt{ + InstancesNotContains("instance2"), + InstancesNotContains("inst", "ancestor"), + }, + }, + want: &Query{ + instances: &filter[[]string]{ + condition: database.NewListNotContains("inst", "ancestor"), + value: toPtr([]string{"inst", "ancestor"}), + }, + }, + }, + { + name: "AppendFilters", + args: args{ + opts: []QueryOpt{ + AppendFilters(NewFilter()), + AppendFilters(NewFilter()), + }, + }, + want: &Query{ + instances: &filter[[]string]{ + condition: database.NewTextEqual("instance"), + value: toPtr([]string{"instance"}), + }, + filters: make([]*Filter, 2), + }, + }, + { + name: "AppendFilter", + args: args{ + opts: []QueryOpt{ + AppendFilter(), + AppendFilter(), + }, + }, + want: &Query{ + instances: &filter[[]string]{ + condition: database.NewTextEqual("instance"), + value: toPtr([]string{"instance"}), + }, + filters: make([]*Filter, 2), + }, + }, + { + name: "Filter", + args: args{ + opts: []QueryOpt{ + SetFilters(NewFilter()), + SetFilters(NewFilter()), + }, + }, + want: &Query{ + instances: &filter[[]string]{ + condition: database.NewTextEqual("instance"), + value: toPtr([]string{"instance"}), + }, + filters: make([]*Filter, 1), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NewQuery("instance", nil, tt.args.opts...) + + if !reflect.DeepEqual(tt.want.Instance(), got.Instance()) { + t.Errorf("unexpected instances %v, want: %v", got.instances, tt.want.instances) + } + if len(tt.want.Filters()) != len(got.Filters()) { + t.Errorf("unexpected length of filters %v, want: %v", len(got.filters), len(tt.want.filters)) + } + if !reflect.DeepEqual(tt.want.Tx(), got.Tx()) { + t.Errorf("unexpected tx %v, want: %v", got.tx, tt.want.tx) + } + if !reflect.DeepEqual(tt.want.Pagination(), got.Pagination()) { + t.Errorf("unexpected pagination %v, want: %v", got.pagination, tt.want.pagination) + } + }) + } +} + +func toPtr[T any](value T) *T { + return &value +} diff --git a/internal/v2/eventstore/unique_constraint.go b/internal/v2/eventstore/unique_constraint.go new file mode 100644 index 0000000000..4486e19e5d --- /dev/null +++ b/internal/v2/eventstore/unique_constraint.go @@ -0,0 +1,80 @@ +package eventstore + +type UniqueConstraint struct { + // UniqueType is the table name for the unique constraint + UniqueType string + // UniqueField is the unique key + UniqueField string + // Action defines if unique constraint should be added or removed + Action UniqueConstraintAction + // ErrorMessage defines the translation file key for the error message + ErrorMessage string + // IsGlobal defines if the unique constraint is globally unique or just within a single instance + IsGlobal bool +} + +type UniqueConstraintAction int8 + +const ( + UniqueConstraintAdd UniqueConstraintAction = iota + UniqueConstraintRemove + UniqueConstraintInstanceRemove + + uniqueConstraintActionCount +) + +func (f UniqueConstraintAction) Valid() bool { + return f >= 0 && f < uniqueConstraintActionCount +} + +func NewAddEventUniqueConstraint( + uniqueType, + uniqueField, + errMessage string) *UniqueConstraint { + return &UniqueConstraint{ + UniqueType: uniqueType, + UniqueField: uniqueField, + ErrorMessage: errMessage, + Action: UniqueConstraintAdd, + } +} + +func NewRemoveUniqueConstraint( + uniqueType, + uniqueField string) *UniqueConstraint { + return &UniqueConstraint{ + UniqueType: uniqueType, + UniqueField: uniqueField, + Action: UniqueConstraintRemove, + } +} + +func NewRemoveInstanceUniqueConstraints() *UniqueConstraint { + return &UniqueConstraint{ + Action: UniqueConstraintInstanceRemove, + } +} + +func NewAddGlobalUniqueConstraint( + uniqueType, + uniqueField, + errMessage string) *UniqueConstraint { + return &UniqueConstraint{ + UniqueType: uniqueType, + UniqueField: uniqueField, + ErrorMessage: errMessage, + IsGlobal: true, + Action: UniqueConstraintAdd, + } +} + +func NewRemoveGlobalUniqueConstraint( + uniqueType, + uniqueField string) *UniqueConstraint { + return &UniqueConstraint{ + UniqueType: uniqueType, + UniqueField: uniqueField, + IsGlobal: true, + Action: UniqueConstraintRemove, + } +} From 6ab06aa249e759b9939d3fadb6d0fcea71539fc8 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Fri, 26 Apr 2024 17:46:15 +0200 Subject: [PATCH 31/31] fix: improve secret generation for apple idp (#7843) * fix: improve secret generation for apple idp * remove accidental commit * change exp time * change exp time * change exp time * change exp time --- cmd/setup/config.go | 2 -- internal/api/ui/login/external_provider_handler.go | 4 ++++ internal/idp/providers/apple/apple.go | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/cmd/setup/config.go b/cmd/setup/config.go index 00ab64e2b9..f5547d21ca 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -18,7 +18,6 @@ import ( "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/config/hook" "github.com/zitadel/zitadel/internal/config/systemdefaults" - "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/id" @@ -70,7 +69,6 @@ func MustNewConfig(v *viper.Viper) *Config { hook.EnumHookFunc(authz.MemberTypeString), actions.HTTPConfigDecodeHook, hooks.MapTypeStringDecode[string, *authz.SystemAPIUser], - hooks.MapTypeStringDecode[string, crypto.HasherConfig], hooks.SliceTypeStringDecode[authz.RoleMapping], )), ) diff --git a/internal/api/ui/login/external_provider_handler.go b/internal/api/ui/login/external_provider_handler.go index 98c2dde6ff..3cd14c0a72 100644 --- a/internal/api/ui/login/external_provider_handler.go +++ b/internal/api/ui/login/external_provider_handler.go @@ -336,6 +336,10 @@ func (l *Login) handleExternalLoginCallback(w http.ResponseWriter, r *http.Reque user, err := session.FetchUser(r.Context()) if err != nil { + logging.WithFields( + "instance", authz.GetInstance(r.Context()).InstanceID(), + "providerID", identityProvider.ID, + ).WithError(err).Info("external authentication failed") l.externalAuthFailed(w, r, authReq, tokens(session), user, err) return } diff --git a/internal/idp/providers/apple/apple.go b/internal/idp/providers/apple/apple.go index 65debed1a3..57023410d1 100644 --- a/internal/idp/providers/apple/apple.go +++ b/internal/idp/providers/apple/apple.go @@ -56,7 +56,7 @@ func clientSecretFromPrivateKey(key []byte, teamID, clientID, keyID string) (str if err != nil { return "", err } - iat := time.Now() + iat := time.Now().Add(-2 * time.Second) exp := iat.Add(time.Hour) return crypto.Sign(&openid.JWTTokenRequest{ Issuer: teamID,