diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 128eb61a5..990e759aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -101,7 +101,8 @@ Please make sure you cover your changes with tests before marking a Pull Request - [ ] Integration tests against the gRPC server ensure that probable good and bad read and write permissions are tested. - [ ] Integration tests against the gRPC server ensure that the API is easily usable despite eventual consistency. - [ ] Integration tests against the gRPC server ensure that all probable login and registration flows are covered." -- [ ] Integration tests ensure that certain commands send expected notifications. +- [ ] Integration tests ensure that certain commands emit expected events that trigger notifications. +- [ ] Integration tests ensure that certain events trigger expected notifications. ## Contribute diff --git a/cmd/start/start.go b/cmd/start/start.go index 4c6a92c4e..6a017af0d 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -235,7 +235,6 @@ func startZitadel(config *Config, masterKey string, server chan<- *Server) error commands, queries, eventstoreClient, - assets.AssetAPIFromDomain(config.ExternalSecure, config.ExternalPort), config.Login.DefaultOTPEmailURLV2, config.SystemDefaults.Notifications.FileSystemPath, keys.User, @@ -311,6 +310,8 @@ func startAPIs( authZRepo, queries, } + // always set the origin in the context if available in the http headers, no matter for what protocol + router.Use(middleware.OriginHandler) verifier := internal_authz.Start(repo, http_util.BuildHTTP(config.ExternalDomain, config.ExternalPort, config.ExternalSecure), config.SystemAPIUsers) tlsConfig, err := config.TLS.Config() if err != nil { @@ -444,7 +445,6 @@ func startAPIs( if err := apis.RegisterService(ctx, oidc_v2.CreateServer(commands, queries, oidcProvider, config.ExternalSecure)); err != nil { return err } - // handle grpc at last to be able to handle the root, because grpc and gateway require a lot of different prefixes apis.RouteGRPC() return nil diff --git a/internal/api/assets/asset.go b/internal/api/assets/asset.go index 3dd03b3f2..9830baeb9 100644 --- a/internal/api/assets/asset.go +++ b/internal/api/assets/asset.go @@ -55,12 +55,6 @@ func AssetAPI(externalSecure bool) func(context.Context) string { } } -func AssetAPIFromDomain(externalSecure bool, externalPort uint16) func(context.Context) string { - return func(ctx context.Context) string { - return http_util.BuildHTTP(authz.GetInstance(ctx).RequestedDomain(), externalPort, externalSecure) + HandlerPrefix - } -} - type Uploader interface { UploadAsset(ctx context.Context, info string, asset *command.AssetUpload, commands *command.Commands) error ObjectName(data authz.CtxData) (string, error) diff --git a/internal/api/authz/context.go b/internal/api/authz/context.go index bfdd648fe..0d022879c 100644 --- a/internal/api/authz/context.go +++ b/internal/api/authz/context.go @@ -133,7 +133,7 @@ func GetAllPermissionsFromCtx(ctx context.Context) []string { func checkOrigin(ctx context.Context, origins []string) error { origin := grpc.GetGatewayHeader(ctx, http_util.Origin) if origin == "" { - origin = http_util.OriginFromCtx(ctx) + origin = http_util.OriginHeader(ctx) if origin == "" { return nil } diff --git a/internal/api/http/header.go b/internal/api/http/header.go index f2d3e19c1..14ae3dfb6 100644 --- a/internal/api/http/header.go +++ b/internal/api/http/header.go @@ -45,6 +45,7 @@ type key int const ( httpHeaders key = iota remoteAddr + origin ) func CopyHeadersToContext(h http.Handler) http.Handler { @@ -61,7 +62,7 @@ func HeadersFromCtx(ctx context.Context) (http.Header, bool) { return headers, ok } -func OriginFromCtx(ctx context.Context) string { +func OriginHeader(ctx context.Context) string { headers, ok := ctx.Value(httpHeaders).(http.Header) if !ok { return "" @@ -69,6 +70,18 @@ func OriginFromCtx(ctx context.Context) string { return headers.Get(Origin) } +func ComposedOrigin(ctx context.Context) string { + o, ok := ctx.Value(origin).(string) + if !ok { + return "" + } + return o +} + +func WithComposedOrigin(ctx context.Context, composed string) context.Context { + return context.WithValue(ctx, origin, composed) +} + func RemoteIPFromCtx(ctx context.Context) string { ctxHeaders, ok := HeadersFromCtx(ctx) if !ok { diff --git a/internal/api/http/middleware/origin_interceptor.go b/internal/api/http/middleware/origin_interceptor.go new file mode 100644 index 000000000..2cf9a644f --- /dev/null +++ b/internal/api/http/middleware/origin_interceptor.go @@ -0,0 +1,103 @@ +package middleware + +import ( + "fmt" + "net/http" + "net/url" + + "github.com/muhlemmer/httpforwarded" + "github.com/zitadel/logging" + + http_util "github.com/zitadel/zitadel/internal/api/http" +) + +func OriginHandler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := composeOrigin(r) + if !http_util.IsOrigin(origin) { + logging.Debugf("extracted origin is not valid: %s", origin) + next.ServeHTTP(w, r) + return + } + next.ServeHTTP(w, r.WithContext(http_util.WithComposedOrigin(r.Context(), origin))) + }) +} + +func composeOrigin(r *http.Request) string { + if origin, err := originFromForwardedHeader(r); err != nil { + logging.OnError(err).Debug("failed to build origin from forwarded header, trying x-forwarded-* headers") + } else { + return origin + } + if origin, err := originFromXForwardedHeaders(r); err != nil { + logging.OnError(err).Debug("failed to build origin from x-forwarded-* headers, using host header") + } else { + return origin + } + scheme := "https" + if r.TLS == nil { + scheme = "http" + } + return fmt.Sprintf("%s://%s", scheme, r.Host) +} + +func originFromForwardedHeader(r *http.Request) (string, error) { + fwd, err := httpforwarded.ParseFromRequest(r) + if err != nil { + return "", err + } + var fwdProto, fwdHost, fwdPort string + if fwdProto = mostRecentValue(fwd, "proto"); fwdProto == "" { + return "", fmt.Errorf("no proto in forwarded header") + } + if fwdHost = mostRecentValue(fwd, "host"); fwdHost == "" { + return "", fmt.Errorf("no host in forwarded header") + } + fwdPort, foundFwdFor := extractPort(mostRecentValue(fwd, "for")) + if !foundFwdFor { + return "", fmt.Errorf("no for in forwarded header") + } + o := fmt.Sprintf("%s://%s", fwdProto, fwdHost) + if fwdPort != "" { + o += ":" + fwdPort + } + return o, nil +} + +func originFromXForwardedHeaders(r *http.Request) (string, error) { + scheme := r.Header.Get("X-Forwarded-Proto") + if scheme == "" { + return "", fmt.Errorf("no X-Forwarded-Proto header") + } + host := r.Header.Get("X-Forwarded-Host") + if host == "" { + return "", fmt.Errorf("no X-Forwarded-Host header") + } + return fmt.Sprintf("%s://%s", scheme, host), nil +} + +func extractPort(raw string) (string, bool) { + if u, ok := parseURL(raw); ok { + return u.Port(), ok + } + return "", false +} + +func parseURL(raw string) (*url.URL, bool) { + if raw == "" { + return nil, false + } + u, err := url.Parse(raw) + return u, err == nil +} + +func mostRecentValue(forwarded map[string][]string, key string) string { + if forwarded == nil { + return "" + } + values := forwarded[key] + if len(values) == 0 { + return "" + } + return values[len(values)-1] +} diff --git a/internal/notification/channels.go b/internal/notification/channels.go new file mode 100644 index 000000000..68ad67347 --- /dev/null +++ b/internal/notification/channels.go @@ -0,0 +1,106 @@ +package notification + +import ( + "context" + + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/notification/channels/smtp" + "github.com/zitadel/zitadel/internal/notification/channels/twilio" + "github.com/zitadel/zitadel/internal/notification/channels/webhook" + "github.com/zitadel/zitadel/internal/notification/handlers" + "github.com/zitadel/zitadel/internal/notification/senders" + "github.com/zitadel/zitadel/internal/notification/types" + "github.com/zitadel/zitadel/internal/telemetry/metrics" +) + +var _ types.ChannelChains = (*channels)(nil) + +type counters struct { + success deliveryMetrics + failed deliveryMetrics +} + +type deliveryMetrics struct { + email string + sms string + json string +} + +type channels struct { + q *handlers.NotificationQueries + counters counters +} + +func newChannels(q *handlers.NotificationQueries) *channels { + c := &channels{ + q: q, + counters: counters{ + success: deliveryMetrics{ + email: "successful_deliveries_email", + sms: "successful_deliveries_sms", + json: "successful_deliveries_json", + }, + failed: deliveryMetrics{ + email: "failed_deliveries_email", + sms: "failed_deliveries_sms", + json: "failed_deliveries_json", + }, + }, + } + registerCounter(c.counters.success.email, "Successfully delivered emails") + registerCounter(c.counters.failed.email, "Failed email deliveries") + registerCounter(c.counters.success.sms, "Successfully delivered SMS") + registerCounter(c.counters.failed.sms, "Failed SMS deliveries") + registerCounter(c.counters.success.json, "Successfully delivered JSON messages") + registerCounter(c.counters.failed.json, "Failed JSON message deliveries") + return c +} + +func registerCounter(counter, desc string) { + err := metrics.RegisterCounter(counter, desc) + logging.WithFields("metric", counter).OnError(err).Panic("unable to register counter") +} + +func (c *channels) Email(ctx context.Context) (*senders.Chain, *smtp.Config, error) { + smtpCfg, err := c.q.GetSMTPConfig(ctx) + if err != nil { + return nil, nil, err + } + chain, err := senders.EmailChannels( + ctx, + smtpCfg, + c.q.GetFileSystemProvider, + c.q.GetLogProvider, + c.counters.success.email, + c.counters.failed.email, + ) + return chain, smtpCfg, err +} + +func (c *channels) SMS(ctx context.Context) (*senders.Chain, *twilio.Config, error) { + twilioCfg, err := c.q.GetTwilioConfig(ctx) + if err != nil { + return nil, nil, err + } + chain, err := senders.SMSChannels( + ctx, + twilioCfg, + c.q.GetFileSystemProvider, + c.q.GetLogProvider, + c.counters.success.sms, + c.counters.failed.sms, + ) + return chain, twilioCfg, err +} + +func (c *channels) Webhook(ctx context.Context, cfg webhook.Config) (*senders.Chain, error) { + return senders.WebhookChannels( + ctx, + cfg, + c.q.GetFileSystemProvider, + c.q.GetLogProvider, + c.counters.success.json, + c.counters.failed.json, + ) +} diff --git a/internal/notification/channels/mock/channel.mock.go b/internal/notification/channels/mock/channel.mock.go index 47d50e08a..6a51073b4 100644 --- a/internal/notification/channels/mock/channel.mock.go +++ b/internal/notification/channels/mock/channel.mock.go @@ -5,8 +5,8 @@ package mock import ( - channels "github.com/zitadel/zitadel/internal/notification/channels" gomock "github.com/golang/mock/gomock" + channels "github.com/zitadel/zitadel/internal/notification/channels" reflect "reflect" ) diff --git a/internal/notification/channels/mock/message.mock.go b/internal/notification/channels/mock/message.mock.go index a6189fe41..986bb1b0b 100644 --- a/internal/notification/channels/mock/message.mock.go +++ b/internal/notification/channels/mock/message.mock.go @@ -6,6 +6,7 @@ package mock import ( gomock "github.com/golang/mock/gomock" + eventstore "github.com/zitadel/zitadel/internal/eventstore" reflect "reflect" ) @@ -33,11 +34,12 @@ func (m *MockMessage) EXPECT() *MockMessageMockRecorder { } // GetContent mocks base method -func (m *MockMessage) GetContent() string { +func (m *MockMessage) GetContent() (string, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetContent") ret0, _ := ret[0].(string) - return ret0 + ret1, _ := ret[1].(error) + return ret0, ret1 } // GetContent indicates an expected call of GetContent @@ -45,3 +47,17 @@ func (mr *MockMessageMockRecorder) GetContent() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetContent", reflect.TypeOf((*MockMessage)(nil).GetContent)) } + +// GetTriggeringEvent mocks base method +func (m *MockMessage) GetTriggeringEvent() eventstore.Event { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTriggeringEvent") + ret0, _ := ret[0].(eventstore.Event) + return ret0 +} + +// GetTriggeringEvent indicates an expected call of GetTriggeringEvent +func (mr *MockMessageMockRecorder) GetTriggeringEvent() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTriggeringEvent", reflect.TypeOf((*MockMessage)(nil).GetTriggeringEvent)) +} diff --git a/internal/notification/channels/smtp/channel.go b/internal/notification/channels/smtp/channel.go index 5d8542ea3..ceea5c9df 100644 --- a/internal/notification/channels/smtp/channel.go +++ b/internal/notification/channels/smtp/channel.go @@ -1,7 +1,6 @@ package smtp import ( - "context" "crypto/tls" "net" "net/smtp" @@ -23,24 +22,18 @@ type Email struct { replyToAddress string } -func InitChannel(ctx context.Context, getSMTPConfig func(ctx context.Context) (*Config, error)) (*Email, error) { - smtpConfig, err := getSMTPConfig(ctx) - if err != nil { - return nil, err - } - client, err := smtpConfig.SMTP.connectToSMTP(smtpConfig.Tls) +func InitChannel(cfg *Config) (*Email, error) { + client, err := cfg.SMTP.connectToSMTP(cfg.Tls) if err != nil { logging.New().WithError(err).Error("could not connect to smtp") return nil, err } - logging.New().Debug("successfully initialized smtp email channel") - return &Email{ smtpClient: client, - senderName: smtpConfig.FromName, - senderAddress: smtpConfig.From, - replyToAddress: smtpConfig.ReplyToAddress, + senderName: cfg.FromName, + senderAddress: cfg.From, + replyToAddress: cfg.ReplyToAddress, }, nil } diff --git a/internal/notification/handlers/commands.go b/internal/notification/handlers/commands.go new file mode 100644 index 000000000..5308a27c4 --- /dev/null +++ b/internal/notification/handlers/commands.go @@ -0,0 +1,24 @@ +package handlers + +import ( + "context" + + "github.com/zitadel/zitadel/internal/repository/milestone" + "github.com/zitadel/zitadel/internal/repository/quota" +) + +type Commands interface { + HumanInitCodeSent(ctx context.Context, orgID, userID string) error + HumanEmailVerificationCodeSent(ctx context.Context, orgID, userID string) error + PasswordCodeSent(ctx context.Context, orgID, userID string) error + HumanOTPSMSCodeSent(ctx context.Context, userID, resourceOwner string) error + HumanOTPEmailCodeSent(ctx context.Context, userID, resourceOwner string) error + OTPSMSSent(ctx context.Context, sessionID, resourceOwner string) error + OTPEmailSent(ctx context.Context, sessionID, resourceOwner string) error + UserDomainClaimedSent(ctx context.Context, orgID, userID string) error + HumanPasswordlessInitCodeSent(ctx context.Context, userID, resourceOwner, codeID string) error + PasswordChangeSent(ctx context.Context, orgID, userID string) error + HumanPhoneVerificationCodeSent(ctx context.Context, orgID, userID string) error + UsageNotificationSent(ctx context.Context, dueEvent *quota.NotificationDueEvent) error + MilestonePushed(ctx context.Context, msType milestone.Type, endpoints []string, primaryDomain string) error +} diff --git a/internal/notification/handlers/gen_mock.go b/internal/notification/handlers/gen_mock.go new file mode 100644 index 000000000..e24863336 --- /dev/null +++ b/internal/notification/handlers/gen_mock.go @@ -0,0 +1,4 @@ +package handlers + +//go:generate mockgen -package mock -destination ./mock/queries.mock.go github.com/zitadel/zitadel/internal/notification/handlers Queries +//go:generate mockgen -package mock -destination ./mock/commands.mock.go github.com/zitadel/zitadel/internal/notification/handlers Commands diff --git a/internal/notification/handlers/mock/commands.mock.go b/internal/notification/handlers/mock/commands.mock.go new file mode 100644 index 000000000..b8584c7da --- /dev/null +++ b/internal/notification/handlers/mock/commands.mock.go @@ -0,0 +1,218 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/zitadel/zitadel/internal/notification/handlers (interfaces: Commands) + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + gomock "github.com/golang/mock/gomock" + milestone "github.com/zitadel/zitadel/internal/repository/milestone" + quota "github.com/zitadel/zitadel/internal/repository/quota" + reflect "reflect" +) + +// MockCommands is a mock of Commands interface +type MockCommands struct { + ctrl *gomock.Controller + recorder *MockCommandsMockRecorder +} + +// MockCommandsMockRecorder is the mock recorder for MockCommands +type MockCommandsMockRecorder struct { + mock *MockCommands +} + +// NewMockCommands creates a new mock instance +func NewMockCommands(ctrl *gomock.Controller) *MockCommands { + mock := &MockCommands{ctrl: ctrl} + mock.recorder = &MockCommandsMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockCommands) EXPECT() *MockCommandsMockRecorder { + return m.recorder +} + +// HumanEmailVerificationCodeSent mocks base method +func (m *MockCommands) HumanEmailVerificationCodeSent(arg0 context.Context, arg1, arg2 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HumanEmailVerificationCodeSent", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// HumanEmailVerificationCodeSent indicates an expected call of HumanEmailVerificationCodeSent +func (mr *MockCommandsMockRecorder) HumanEmailVerificationCodeSent(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanEmailVerificationCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanEmailVerificationCodeSent), arg0, arg1, arg2) +} + +// HumanInitCodeSent mocks base method +func (m *MockCommands) HumanInitCodeSent(arg0 context.Context, arg1, arg2 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HumanInitCodeSent", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// HumanInitCodeSent indicates an expected call of HumanInitCodeSent +func (mr *MockCommandsMockRecorder) HumanInitCodeSent(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanInitCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanInitCodeSent), arg0, arg1, arg2) +} + +// HumanOTPEmailCodeSent mocks base method +func (m *MockCommands) HumanOTPEmailCodeSent(arg0 context.Context, arg1, arg2 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HumanOTPEmailCodeSent", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// HumanOTPEmailCodeSent indicates an expected call of HumanOTPEmailCodeSent +func (mr *MockCommandsMockRecorder) HumanOTPEmailCodeSent(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanOTPEmailCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanOTPEmailCodeSent), arg0, arg1, arg2) +} + +// HumanOTPSMSCodeSent mocks base method +func (m *MockCommands) HumanOTPSMSCodeSent(arg0 context.Context, arg1, arg2 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HumanOTPSMSCodeSent", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// HumanOTPSMSCodeSent indicates an expected call of HumanOTPSMSCodeSent +func (mr *MockCommandsMockRecorder) HumanOTPSMSCodeSent(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanOTPSMSCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanOTPSMSCodeSent), arg0, arg1, arg2) +} + +// HumanPasswordlessInitCodeSent mocks base method +func (m *MockCommands) HumanPasswordlessInitCodeSent(arg0 context.Context, arg1, arg2, arg3 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HumanPasswordlessInitCodeSent", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(error) + return ret0 +} + +// HumanPasswordlessInitCodeSent indicates an expected call of HumanPasswordlessInitCodeSent +func (mr *MockCommandsMockRecorder) HumanPasswordlessInitCodeSent(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanPasswordlessInitCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanPasswordlessInitCodeSent), arg0, arg1, arg2, arg3) +} + +// HumanPhoneVerificationCodeSent mocks base method +func (m *MockCommands) HumanPhoneVerificationCodeSent(arg0 context.Context, arg1, arg2 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HumanPhoneVerificationCodeSent", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// HumanPhoneVerificationCodeSent indicates an expected call of HumanPhoneVerificationCodeSent +func (mr *MockCommandsMockRecorder) HumanPhoneVerificationCodeSent(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanPhoneVerificationCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanPhoneVerificationCodeSent), arg0, arg1, arg2) +} + +// MilestonePushed mocks base method +func (m *MockCommands) MilestonePushed(arg0 context.Context, arg1 milestone.Type, arg2 []string, arg3 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MilestonePushed", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(error) + return ret0 +} + +// MilestonePushed indicates an expected call of MilestonePushed +func (mr *MockCommandsMockRecorder) MilestonePushed(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MilestonePushed", reflect.TypeOf((*MockCommands)(nil).MilestonePushed), arg0, arg1, arg2, arg3) +} + +// OTPEmailSent mocks base method +func (m *MockCommands) OTPEmailSent(arg0 context.Context, arg1, arg2 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "OTPEmailSent", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// OTPEmailSent indicates an expected call of OTPEmailSent +func (mr *MockCommandsMockRecorder) OTPEmailSent(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OTPEmailSent", reflect.TypeOf((*MockCommands)(nil).OTPEmailSent), arg0, arg1, arg2) +} + +// OTPSMSSent mocks base method +func (m *MockCommands) OTPSMSSent(arg0 context.Context, arg1, arg2 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "OTPSMSSent", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// OTPSMSSent indicates an expected call of OTPSMSSent +func (mr *MockCommandsMockRecorder) OTPSMSSent(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OTPSMSSent", reflect.TypeOf((*MockCommands)(nil).OTPSMSSent), arg0, arg1, arg2) +} + +// PasswordChangeSent mocks base method +func (m *MockCommands) PasswordChangeSent(arg0 context.Context, arg1, arg2 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PasswordChangeSent", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// PasswordChangeSent indicates an expected call of PasswordChangeSent +func (mr *MockCommandsMockRecorder) PasswordChangeSent(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordChangeSent", reflect.TypeOf((*MockCommands)(nil).PasswordChangeSent), arg0, arg1, arg2) +} + +// PasswordCodeSent mocks base method +func (m *MockCommands) PasswordCodeSent(arg0 context.Context, arg1, arg2 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PasswordCodeSent", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// PasswordCodeSent indicates an expected call of PasswordCodeSent +func (mr *MockCommandsMockRecorder) PasswordCodeSent(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordCodeSent", reflect.TypeOf((*MockCommands)(nil).PasswordCodeSent), arg0, arg1, arg2) +} + +// UsageNotificationSent mocks base method +func (m *MockCommands) UsageNotificationSent(arg0 context.Context, arg1 *quota.NotificationDueEvent) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UsageNotificationSent", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// UsageNotificationSent indicates an expected call of UsageNotificationSent +func (mr *MockCommandsMockRecorder) UsageNotificationSent(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UsageNotificationSent", reflect.TypeOf((*MockCommands)(nil).UsageNotificationSent), arg0, arg1) +} + +// UserDomainClaimedSent mocks base method +func (m *MockCommands) UserDomainClaimedSent(arg0 context.Context, arg1, arg2 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UserDomainClaimedSent", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// UserDomainClaimedSent indicates an expected call of UserDomainClaimedSent +func (mr *MockCommandsMockRecorder) UserDomainClaimedSent(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserDomainClaimedSent", reflect.TypeOf((*MockCommands)(nil).UserDomainClaimedSent), arg0, arg1, arg2) +} diff --git a/internal/notification/handlers/mock/queries.mock.go b/internal/notification/handlers/mock/queries.mock.go new file mode 100644 index 000000000..f9c2b2d89 --- /dev/null +++ b/internal/notification/handlers/mock/queries.mock.go @@ -0,0 +1,226 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/zitadel/zitadel/internal/notification/handlers (interfaces: Queries) + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + gomock "github.com/golang/mock/gomock" + domain "github.com/zitadel/zitadel/internal/domain" + query "github.com/zitadel/zitadel/internal/query" + language "golang.org/x/text/language" + reflect "reflect" +) + +// MockQueries is a mock of Queries interface +type MockQueries struct { + ctrl *gomock.Controller + recorder *MockQueriesMockRecorder +} + +// MockQueriesMockRecorder is the mock recorder for MockQueries +type MockQueriesMockRecorder struct { + mock *MockQueries +} + +// NewMockQueries creates a new mock instance +func NewMockQueries(ctrl *gomock.Controller) *MockQueries { + mock := &MockQueries{ctrl: ctrl} + mock.recorder = &MockQueriesMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockQueries) EXPECT() *MockQueriesMockRecorder { + return m.recorder +} + +// ActiveLabelPolicyByOrg mocks base method +func (m *MockQueries) ActiveLabelPolicyByOrg(arg0 context.Context, arg1 string, arg2 bool) (*query.LabelPolicy, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ActiveLabelPolicyByOrg", arg0, arg1, arg2) + ret0, _ := ret[0].(*query.LabelPolicy) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ActiveLabelPolicyByOrg indicates an expected call of ActiveLabelPolicyByOrg +func (mr *MockQueriesMockRecorder) ActiveLabelPolicyByOrg(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ActiveLabelPolicyByOrg", reflect.TypeOf((*MockQueries)(nil).ActiveLabelPolicyByOrg), arg0, arg1, arg2) +} + +// CustomTextListByTemplate mocks base method +func (m *MockQueries) CustomTextListByTemplate(arg0 context.Context, arg1, arg2 string, arg3 bool) (*query.CustomTexts, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CustomTextListByTemplate", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(*query.CustomTexts) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CustomTextListByTemplate indicates an expected call of CustomTextListByTemplate +func (mr *MockQueriesMockRecorder) CustomTextListByTemplate(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CustomTextListByTemplate", reflect.TypeOf((*MockQueries)(nil).CustomTextListByTemplate), arg0, arg1, arg2, arg3) +} + +// GetDefaultLanguage mocks base method +func (m *MockQueries) GetDefaultLanguage(arg0 context.Context) language.Tag { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetDefaultLanguage", arg0) + ret0, _ := ret[0].(language.Tag) + return ret0 +} + +// GetDefaultLanguage indicates an expected call of GetDefaultLanguage +func (mr *MockQueriesMockRecorder) GetDefaultLanguage(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDefaultLanguage", reflect.TypeOf((*MockQueries)(nil).GetDefaultLanguage), arg0) +} + +// GetNotifyUserByID mocks base method +func (m *MockQueries) GetNotifyUserByID(arg0 context.Context, arg1 bool, arg2 string, arg3 bool, arg4 ...query.SearchQuery) (*query.NotifyUser, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1, arg2, arg3} + for _, a := range arg4 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "GetNotifyUserByID", varargs...) + ret0, _ := ret[0].(*query.NotifyUser) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNotifyUserByID indicates an expected call of GetNotifyUserByID +func (mr *MockQueriesMockRecorder) GetNotifyUserByID(arg0, arg1, arg2, arg3 interface{}, arg4 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1, arg2, arg3}, arg4...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotifyUserByID", reflect.TypeOf((*MockQueries)(nil).GetNotifyUserByID), varargs...) +} + +// MailTemplateByOrg mocks base method +func (m *MockQueries) MailTemplateByOrg(arg0 context.Context, arg1 string, arg2 bool) (*query.MailTemplate, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MailTemplateByOrg", arg0, arg1, arg2) + ret0, _ := ret[0].(*query.MailTemplate) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// MailTemplateByOrg indicates an expected call of MailTemplateByOrg +func (mr *MockQueriesMockRecorder) MailTemplateByOrg(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MailTemplateByOrg", reflect.TypeOf((*MockQueries)(nil).MailTemplateByOrg), arg0, arg1, arg2) +} + +// NotificationPolicyByOrg mocks base method +func (m *MockQueries) NotificationPolicyByOrg(arg0 context.Context, arg1 bool, arg2 string, arg3 bool) (*query.NotificationPolicy, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NotificationPolicyByOrg", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(*query.NotificationPolicy) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// NotificationPolicyByOrg indicates an expected call of NotificationPolicyByOrg +func (mr *MockQueriesMockRecorder) NotificationPolicyByOrg(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationPolicyByOrg", reflect.TypeOf((*MockQueries)(nil).NotificationPolicyByOrg), arg0, arg1, arg2, arg3) +} + +// NotificationProviderByIDAndType mocks base method +func (m *MockQueries) NotificationProviderByIDAndType(arg0 context.Context, arg1 string, arg2 domain.NotificationProviderType) (*query.DebugNotificationProvider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NotificationProviderByIDAndType", arg0, arg1, arg2) + ret0, _ := ret[0].(*query.DebugNotificationProvider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// NotificationProviderByIDAndType indicates an expected call of NotificationProviderByIDAndType +func (mr *MockQueriesMockRecorder) NotificationProviderByIDAndType(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationProviderByIDAndType", reflect.TypeOf((*MockQueries)(nil).NotificationProviderByIDAndType), arg0, arg1, arg2) +} + +// SMSProviderConfig mocks base method +func (m *MockQueries) SMSProviderConfig(arg0 context.Context, arg1 ...query.SearchQuery) (*query.SMSConfig, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "SMSProviderConfig", varargs...) + ret0, _ := ret[0].(*query.SMSConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SMSProviderConfig indicates an expected call of SMSProviderConfig +func (mr *MockQueriesMockRecorder) SMSProviderConfig(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SMSProviderConfig", reflect.TypeOf((*MockQueries)(nil).SMSProviderConfig), varargs...) +} + +// SMTPConfigByAggregateID mocks base method +func (m *MockQueries) SMTPConfigByAggregateID(arg0 context.Context, arg1 string) (*query.SMTPConfig, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SMTPConfigByAggregateID", arg0, arg1) + ret0, _ := ret[0].(*query.SMTPConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SMTPConfigByAggregateID indicates an expected call of SMTPConfigByAggregateID +func (mr *MockQueriesMockRecorder) SMTPConfigByAggregateID(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SMTPConfigByAggregateID", reflect.TypeOf((*MockQueries)(nil).SMTPConfigByAggregateID), arg0, arg1) +} + +// SearchInstanceDomains mocks base method +func (m *MockQueries) SearchInstanceDomains(arg0 context.Context, arg1 *query.InstanceDomainSearchQueries) (*query.InstanceDomains, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SearchInstanceDomains", arg0, arg1) + ret0, _ := ret[0].(*query.InstanceDomains) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SearchInstanceDomains indicates an expected call of SearchInstanceDomains +func (mr *MockQueriesMockRecorder) SearchInstanceDomains(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchInstanceDomains", reflect.TypeOf((*MockQueries)(nil).SearchInstanceDomains), arg0, arg1) +} + +// SearchMilestones mocks base method +func (m *MockQueries) SearchMilestones(arg0 context.Context, arg1 []string, arg2 *query.MilestonesSearchQueries) (*query.Milestones, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SearchMilestones", arg0, arg1, arg2) + ret0, _ := ret[0].(*query.Milestones) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SearchMilestones indicates an expected call of SearchMilestones +func (mr *MockQueriesMockRecorder) SearchMilestones(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchMilestones", reflect.TypeOf((*MockQueries)(nil).SearchMilestones), arg0, arg1, arg2) +} + +// SessionByID mocks base method +func (m *MockQueries) SessionByID(arg0 context.Context, arg1 bool, arg2, arg3 string) (*query.Session, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SessionByID", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(*query.Session) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SessionByID indicates an expected call of SessionByID +func (mr *MockQueriesMockRecorder) SessionByID(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SessionByID", reflect.TypeOf((*MockQueries)(nil).SessionByID), arg0, arg1, arg2, arg3) +} diff --git a/internal/notification/handlers/origin.go b/internal/notification/handlers/origin.go index 2e8549a18..a807edd2d 100644 --- a/internal/notification/handlers/origin.go +++ b/internal/notification/handlers/origin.go @@ -2,27 +2,56 @@ package handlers import ( "context" + "fmt" + "net/url" "github.com/zitadel/zitadel/internal/api/authz" http_utils "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/query" ) -func (n *NotificationQueries) Origin(ctx context.Context) (context.Context, string, error) { +type OriginEvent interface { + eventstore.Event + TriggerOrigin() string +} + +func (n *NotificationQueries) Origin(ctx context.Context, e eventstore.Event) (context.Context, error) { + originEvent, ok := e.(OriginEvent) + if !ok { + return ctx, errors.ThrowInternal(fmt.Errorf("event of type %T doesn't implement OriginEvent", e), "NOTIF-3m9fs", "Errors.Internal") + } + origin := originEvent.TriggerOrigin() + if origin != "" { + originURL, err := url.Parse(origin) + if err != nil { + return ctx, err + } + return enrichCtx(ctx, originURL.Hostname(), origin), nil + } primary, err := query.NewInstanceDomainPrimarySearchQuery(true) if err != nil { - return ctx, "", err + return ctx, err } domains, err := n.SearchInstanceDomains(ctx, &query.InstanceDomainSearchQueries{ Queries: []query.SearchQuery{primary}, }) if err != nil { - return ctx, "", err + return ctx, err } if len(domains.Domains) < 1 { - return ctx, "", errors.ThrowInternal(nil, "NOTIF-Ef3r1", "Errors.Notification.NoDomain") + return ctx, errors.ThrowInternal(nil, "NOTIF-Ef3r1", "Errors.Notification.NoDomain") } - ctx = authz.WithRequestedDomain(ctx, domains.Domains[0].Domain) - return ctx, http_utils.BuildHTTP(domains.Domains[0].Domain, n.externalPort, n.externalSecure), nil + return enrichCtx( + ctx, + domains.Domains[0].Domain, + http_utils.BuildHTTP(domains.Domains[0].Domain, n.externalPort, n.externalSecure), + ), nil +} + +func enrichCtx(ctx context.Context, host, origin string) context.Context { + ctx = authz.WithRequestedDomain(ctx, host) + ctx = http_utils.WithComposedOrigin(ctx, origin) + return ctx } diff --git a/internal/notification/handlers/queries.go b/internal/notification/handlers/queries.go index 96880445b..8e4154e12 100644 --- a/internal/notification/handlers/queries.go +++ b/internal/notification/handlers/queries.go @@ -1,16 +1,34 @@ package handlers import ( + "context" "net/http" + "golang.org/x/text/language" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" - _ "github.com/zitadel/zitadel/internal/notification/statik" "github.com/zitadel/zitadel/internal/query" ) +type Queries interface { + ActiveLabelPolicyByOrg(ctx context.Context, orgID string, withOwnerRemoved bool) (*query.LabelPolicy, error) + MailTemplateByOrg(ctx context.Context, orgID string, withOwnerRemoved bool) (*query.MailTemplate, error) + GetNotifyUserByID(ctx context.Context, shouldTriggered bool, userID string, withOwnerRemoved bool, queries ...query.SearchQuery) (*query.NotifyUser, error) + CustomTextListByTemplate(ctx context.Context, aggregateID, template string, withOwnerRemoved bool) (*query.CustomTexts, error) + SearchInstanceDomains(ctx context.Context, queries *query.InstanceDomainSearchQueries) (*query.InstanceDomains, error) + SessionByID(ctx context.Context, shouldTriggerBulk bool, id, sessionToken string) (*query.Session, error) + NotificationPolicyByOrg(ctx context.Context, shouldTriggerBulk bool, orgID string, withOwnerRemoved bool) (*query.NotificationPolicy, error) + SearchMilestones(ctx context.Context, instanceIDs []string, queries *query.MilestonesSearchQueries) (*query.Milestones, error) + NotificationProviderByIDAndType(ctx context.Context, aggID string, providerType domain.NotificationProviderType) (*query.DebugNotificationProvider, error) + SMSProviderConfig(ctx context.Context, queries ...query.SearchQuery) (*query.SMSConfig, error) + SMTPConfigByAggregateID(ctx context.Context, aggregateID string) (*query.SMTPConfig, error) + GetDefaultLanguage(ctx context.Context) language.Tag +} + type NotificationQueries struct { - *query.Queries + Queries es *eventstore.Eventstore externalDomain string externalPort uint16 @@ -23,7 +41,7 @@ type NotificationQueries struct { } func NewNotificationQueries( - baseQueries *query.Queries, + baseQueries Queries, es *eventstore.Eventstore, externalDomain string, externalPort uint16, diff --git a/internal/notification/handlers/quota_notifier.go b/internal/notification/handlers/quota_notifier.go index 21daa92ff..b8f59a70e 100644 --- a/internal/notification/handlers/quota_notifier.go +++ b/internal/notification/handlers/quota_notifier.go @@ -22,10 +22,9 @@ const ( type quotaNotifier struct { crdb.StatementHandler - commands *command.Commands - queries *NotificationQueries - metricSuccessfulDeliveriesJSON string - metricFailedDeliveriesJSON string + commands *command.Commands + queries *NotificationQueries + channels types.ChannelChains } func NewQuotaNotifier( @@ -33,8 +32,7 @@ func NewQuotaNotifier( config crdb.StatementHandlerConfig, commands *command.Commands, queries *NotificationQueries, - metricSuccessfulDeliveriesJSON, - metricFailedDeliveriesJSON string, + channels types.ChannelChains, ) *quotaNotifier { p := new(quotaNotifier) config.ProjectionName = QuotaNotificationsProjectionTable @@ -42,8 +40,7 @@ func NewQuotaNotifier( p.StatementHandler = crdb.NewStatementHandler(ctx, config) p.commands = commands p.queries = queries - p.metricSuccessfulDeliveriesJSON = metricSuccessfulDeliveriesJSON - p.metricFailedDeliveriesJSON = metricFailedDeliveriesJSON + p.channels = channels projection.NotificationsQuotaProjection = p return p } @@ -75,19 +72,7 @@ func (u *quotaNotifier) reduceNotificationDue(event eventstore.Event) (*handler. if alreadyHandled { return crdb.NewNoOpStatement(e), nil } - err = types.SendJSON( - ctx, - webhook.Config{ - CallURL: e.CallURL, - Method: http.MethodPost, - }, - u.queries.GetFileSystemProvider, - u.queries.GetLogProvider, - e, - e, - u.metricSuccessfulDeliveriesJSON, - u.metricFailedDeliveriesJSON, - ).WithoutTemplate() + err = types.SendJSON(ctx, webhook.Config{CallURL: e.CallURL, Method: http.MethodPost}, u.channels, e, e).WithoutTemplate() if err != nil { return nil, err } diff --git a/internal/notification/handlers/telemetry_pusher.go b/internal/notification/handlers/telemetry_pusher.go index 837399b02..5b802a050 100644 --- a/internal/notification/handlers/telemetry_pusher.go +++ b/internal/notification/handlers/telemetry_pusher.go @@ -38,11 +38,10 @@ type TelemetryPusherConfig struct { type telemetryPusher struct { crdb.StatementHandler - cfg TelemetryPusherConfig - commands *command.Commands - queries *NotificationQueries - metricSuccessfulDeliveriesJSON string - metricFailedDeliveriesJSON string + cfg TelemetryPusherConfig + commands *command.Commands + queries *NotificationQueries + channels types.ChannelChains } func NewTelemetryPusher( @@ -51,8 +50,7 @@ func NewTelemetryPusher( handlerCfg crdb.StatementHandlerConfig, commands *command.Commands, queries *NotificationQueries, - metricSuccessfulDeliveriesJSON, - metricFailedDeliveriesJSON string, + channels types.ChannelChains, ) *telemetryPusher { p := new(telemetryPusher) handlerCfg.ProjectionName = TelemetryProjectionTable @@ -62,8 +60,7 @@ func NewTelemetryPusher( p.StatementHandler = crdb.NewStatementHandler(ctx, handlerCfg) p.commands = commands p.queries = queries - p.metricSuccessfulDeliveriesJSON = metricSuccessfulDeliveriesJSON - p.metricFailedDeliveriesJSON = metricFailedDeliveriesJSON + p.channels = channels projection.TelemetryPusherProjection = p return p } @@ -132,8 +129,7 @@ func (t *telemetryPusher) pushMilestone(ctx context.Context, event *pseudo.Sched Method: http.MethodPost, Headers: t.cfg.Headers, }, - t.queries.GetFileSystemProvider, - t.queries.GetLogProvider, + t.channels, &struct { InstanceID string `json:"instanceId"` ExternalDomain string `json:"externalDomain"` @@ -148,8 +144,6 @@ func (t *telemetryPusher) pushMilestone(ctx context.Context, event *pseudo.Sched ReachedDate: ms.ReachedDate, }, event, - t.metricSuccessfulDeliveriesJSON, - t.metricFailedDeliveriesJSON, ).WithoutTemplate(); err != nil { return err } diff --git a/internal/notification/handlers/user_notifier.go b/internal/notification/handlers/user_notifier.go index 7b1d40766..1c60fea8c 100644 --- a/internal/notification/handlers/user_notifier.go +++ b/internal/notification/handlers/user_notifier.go @@ -5,9 +5,8 @@ import ( "strings" "time" - "github.com/zitadel/zitadel/internal/api/authz" + http_util "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/ui/login" - "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/errors" @@ -27,27 +26,19 @@ const ( type userNotifier struct { crdb.StatementHandler - commands *command.Commands + commands Commands queries *NotificationQueries - assetsPrefix func(context.Context) string + channels types.ChannelChains otpEmailTmpl string - metricSuccessfulDeliveriesEmail, - metricFailedDeliveriesEmail, - metricSuccessfulDeliveriesSMS, - metricFailedDeliveriesSMS string } func NewUserNotifier( ctx context.Context, config crdb.StatementHandlerConfig, - commands *command.Commands, + commands Commands, queries *NotificationQueries, - assetsPrefix func(context.Context) string, + channels types.ChannelChains, otpEmailTmpl string, - metricSuccessfulDeliveriesEmail, - metricFailedDeliveriesEmail, - metricSuccessfulDeliveriesSMS, - metricFailedDeliveriesSMS string, ) *userNotifier { p := new(userNotifier) config.ProjectionName = UserNotificationsProjectionTable @@ -55,12 +46,8 @@ func NewUserNotifier( p.StatementHandler = crdb.NewStatementHandler(ctx, config) p.commands = commands p.queries = queries - p.assetsPrefix = assetsPrefix + p.channels = channels p.otpEmailTmpl = otpEmailTmpl - p.metricSuccessfulDeliveriesEmail = metricSuccessfulDeliveriesEmail - p.metricFailedDeliveriesEmail = metricFailedDeliveriesEmail - p.metricSuccessfulDeliveriesSMS = metricSuccessfulDeliveriesSMS - p.metricFailedDeliveriesSMS = metricFailedDeliveriesSMS projection.NotificationsProjection = p return p } @@ -177,25 +164,12 @@ func (u *userNotifier) reduceInitCodeAdded(event eventstore.Event) (*handler.Sta if err != nil { return nil, err } - - ctx, origin, err := u.queries.Origin(ctx) + ctx, err = u.queries.Origin(ctx, e) if err != nil { return nil, err } - err = types.SendEmail( - ctx, - string(template.Template), - translator, - notifyUser, - u.queries.GetSMTPConfig, - u.queries.GetFileSystemProvider, - u.queries.GetLogProvider, - colors, - u.assetsPrefix(ctx), - e, - u.metricSuccessfulDeliveriesEmail, - u.metricFailedDeliveriesEmail, - ).SendUserInitCode(notifyUser, origin, code) + err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e). + SendUserInitCode(ctx, notifyUser, code) if err != nil { return nil, err } @@ -247,25 +221,12 @@ func (u *userNotifier) reduceEmailCodeAdded(event eventstore.Event) (*handler.St if err != nil { return nil, err } - - ctx, origin, err := u.queries.Origin(ctx) + ctx, err = u.queries.Origin(ctx, e) if err != nil { return nil, err } - err = types.SendEmail( - ctx, - string(template.Template), - translator, - notifyUser, - u.queries.GetSMTPConfig, - u.queries.GetFileSystemProvider, - u.queries.GetLogProvider, - colors, - u.assetsPrefix(ctx), - e, - u.metricSuccessfulDeliveriesEmail, - u.metricFailedDeliveriesEmail, - ).SendEmailVerificationCode(notifyUser, origin, code, e.URLTemplate) + err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e). + SendEmailVerificationCode(ctx, notifyUser, code, e.URLTemplate) if err != nil { return nil, err } @@ -316,41 +277,15 @@ func (u *userNotifier) reducePasswordCodeAdded(event eventstore.Event) (*handler if err != nil { return nil, err } - - ctx, origin, err := u.queries.Origin(ctx) + ctx, err = u.queries.Origin(ctx, e) if err != nil { return nil, err } - notify := types.SendEmail( - ctx, - string(template.Template), - translator, - notifyUser, - u.queries.GetSMTPConfig, - u.queries.GetFileSystemProvider, - u.queries.GetLogProvider, - colors, - u.assetsPrefix(ctx), - e, - u.metricSuccessfulDeliveriesEmail, - u.metricFailedDeliveriesEmail, - ) + notify := types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e) if e.NotificationType == domain.NotificationTypeSms { - notify = types.SendSMSTwilio( - ctx, - translator, - notifyUser, - u.queries.GetTwilioConfig, - u.queries.GetFileSystemProvider, - u.queries.GetLogProvider, - colors, - u.assetsPrefix(ctx), - e, - u.metricSuccessfulDeliveriesSMS, - u.metricFailedDeliveriesSMS, - ) + notify = types.SendSMSTwilio(ctx, u.channels, translator, notifyUser, colors, e) } - err = notify.SendPasswordCode(notifyUser, origin, code, e.URLTemplate) + err = notify.SendPasswordCode(ctx, notifyUser, code, e.URLTemplate) if err != nil { return nil, err } @@ -437,25 +372,12 @@ func (u *userNotifier) reduceOTPSMS( if err != nil { return nil, err } - - ctx, origin, err := u.queries.Origin(ctx) + ctx, err = u.queries.Origin(ctx, event) if err != nil { return nil, err } - notify := types.SendSMSTwilio( - ctx, - translator, - notifyUser, - u.queries.GetTwilioConfig, - u.queries.GetFileSystemProvider, - u.queries.GetLogProvider, - colors, - u.assetsPrefix(ctx), - event, - u.metricSuccessfulDeliveriesSMS, - u.metricFailedDeliveriesSMS, - ) - err = notify.SendOTPSMSCode(authz.GetInstance(ctx).RequestedDomain(), origin, plainCode, expiry) + notify := types.SendSMSTwilio(ctx, u.channels, translator, notifyUser, colors, event) + err = notify.SendOTPSMSCode(ctx, plainCode, expiry) if err != nil { return nil, err } @@ -568,30 +490,16 @@ func (u *userNotifier) reduceOTPEmail( if err != nil { return nil, err } - - ctx, origin, err := u.queries.Origin(ctx) + ctx, err = u.queries.Origin(ctx, event) if err != nil { return nil, err } - url, err := urlTmpl(plainCode, origin, notifyUser) + url, err := urlTmpl(plainCode, http_util.ComposedOrigin(ctx), notifyUser) if err != nil { return nil, err } - notify := types.SendEmail( - ctx, - string(template.Template), - translator, - notifyUser, - u.queries.GetSMTPConfig, - u.queries.GetFileSystemProvider, - u.queries.GetLogProvider, - colors, - u.assetsPrefix(ctx), - event, - u.metricSuccessfulDeliveriesEmail, - u.metricFailedDeliveriesEmail, - ) - err = notify.SendOTPEmailCode(notifyUser, url, authz.GetInstance(ctx).RequestedDomain(), origin, plainCode, expiry) + notify := types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, event) + err = notify.SendOTPEmailCode(ctx, url, plainCode, expiry) if err != nil { return nil, err } @@ -634,25 +542,12 @@ func (u *userNotifier) reduceDomainClaimed(event eventstore.Event) (*handler.Sta if err != nil { return nil, err } - - ctx, origin, err := u.queries.Origin(ctx) + ctx, err = u.queries.Origin(ctx, e) if err != nil { return nil, err } - err = types.SendEmail( - ctx, - string(template.Template), - translator, - notifyUser, - u.queries.GetSMTPConfig, - u.queries.GetFileSystemProvider, - u.queries.GetLogProvider, - colors, - u.assetsPrefix(ctx), - e, - u.metricSuccessfulDeliveriesEmail, - u.metricFailedDeliveriesEmail, - ).SendDomainClaimed(notifyUser, origin, e.UserName) + err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e). + SendDomainClaimed(ctx, notifyUser, e.UserName) if err != nil { return nil, err } @@ -701,25 +596,12 @@ func (u *userNotifier) reducePasswordlessCodeRequested(event eventstore.Event) ( if err != nil { return nil, err } - - ctx, origin, err := u.queries.Origin(ctx) + ctx, err = u.queries.Origin(ctx, e) if err != nil { return nil, err } - err = types.SendEmail( - ctx, - string(template.Template), - translator, - notifyUser, - u.queries.GetSMTPConfig, - u.queries.GetFileSystemProvider, - u.queries.GetLogProvider, - colors, - u.assetsPrefix(ctx), - e, - u.metricSuccessfulDeliveriesEmail, - u.metricFailedDeliveriesEmail, - ).SendPasswordlessRegistrationLink(notifyUser, origin, code, e.ID, e.URLTemplate) + err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e). + SendPasswordlessRegistrationLink(ctx, notifyUser, code, e.ID, e.URLTemplate) if err != nil { return nil, err } @@ -771,25 +653,12 @@ func (u *userNotifier) reducePasswordChanged(event eventstore.Event) (*handler.S if err != nil { return nil, err } - - ctx, origin, err := u.queries.Origin(ctx) + ctx, err = u.queries.Origin(ctx, e) if err != nil { return nil, err } - err = types.SendEmail( - ctx, - string(template.Template), - translator, - notifyUser, - u.queries.GetSMTPConfig, - u.queries.GetFileSystemProvider, - u.queries.GetLogProvider, - colors, - u.assetsPrefix(ctx), - e, - u.metricSuccessfulDeliveriesEmail, - u.metricFailedDeliveriesEmail, - ).SendPasswordChange(notifyUser, origin) + err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e). + SendPasswordChange(ctx, notifyUser) if err != nil { return nil, err } @@ -836,24 +705,12 @@ func (u *userNotifier) reducePhoneCodeAdded(event eventstore.Event) (*handler.St if err != nil { return nil, err } - - ctx, origin, err := u.queries.Origin(ctx) + ctx, err = u.queries.Origin(ctx, e) if err != nil { return nil, err } - err = types.SendSMSTwilio( - ctx, - translator, - notifyUser, - u.queries.GetTwilioConfig, - u.queries.GetFileSystemProvider, - u.queries.GetLogProvider, - colors, - u.assetsPrefix(ctx), - e, - u.metricSuccessfulDeliveriesSMS, - u.metricFailedDeliveriesSMS, - ).SendPhoneVerificationCode(notifyUser, origin, code, authz.GetInstance(ctx).RequestedDomain()) + err = types.SendSMSTwilio(ctx, u.channels, translator, notifyUser, colors, e). + SendPhoneVerificationCode(ctx, code) if err != nil { return nil, err } diff --git a/internal/notification/handlers/user_notifier_test.go b/internal/notification/handlers/user_notifier_test.go new file mode 100644 index 000000000..db3569ad2 --- /dev/null +++ b/internal/notification/handlers/user_notifier_test.go @@ -0,0 +1,1361 @@ +package handlers + +import ( + "context" + "database/sql" + "fmt" + "net/http" + "testing" + "time" + + "github.com/zitadel/zitadel/internal/repository/session" + + "github.com/zitadel/zitadel/internal/notification/messages" + + "github.com/golang/mock/gomock" + statik_fs "github.com/rakyll/statik/fs" + "github.com/stretchr/testify/assert" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/repository" + es_repo_mock "github.com/zitadel/zitadel/internal/eventstore/repository/mock" + channel_mock "github.com/zitadel/zitadel/internal/notification/channels/mock" + "github.com/zitadel/zitadel/internal/notification/channels/smtp" + "github.com/zitadel/zitadel/internal/notification/channels/twilio" + "github.com/zitadel/zitadel/internal/notification/channels/webhook" + "github.com/zitadel/zitadel/internal/notification/handlers/mock" + "github.com/zitadel/zitadel/internal/notification/senders" + "github.com/zitadel/zitadel/internal/notification/types" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/repository/user" +) + +const ( + orgID = "org1" + policyID = "policy1" + userID = "user1" + codeID = "event1" + logoURL = "logo.png" + eventOrigin = "https://triggered.here" + assetsPath = "/assets/v1" + preferredLoginName = "loginName1" + lastEmail = "last@email.com" + verifiedEmail = "verified@email.com" + instancePrimaryDomain = "primary.domain" + externalDomain = "external.domain" + externalPort = 3000 + externalSecure = false + externalProtocol = "http" + defaultOTPEmailTemplate = "/otp/verify?loginName={{.LoginName}}&code={{.Code}}" +) + +func Test_userNotifier_reduceInitCodeAdded(t *testing.T) { + expectMailSubject := "Initialize User" + tests := []struct { + name string + test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, want) + }{{ + name: "asset url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, "testcode") + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().HumanInitCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + 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, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "asset url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) + 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.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + 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, + }, + }, w + }, + }, { + name: "button url with event trigger url", + 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) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().HumanInitCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + 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, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "button url without event trigger url", + 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) + 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.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + 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, + }, + }, w + }, + }} + // TODO: Why don't we have an url template on user.HumanInitialCodeAddedEvent? + fs, err := statik_fs.NewWithNamespace("notification") + assert.NoError(t, err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + queries := mock.NewMockQueries(ctrl) + commands := mock.NewMockCommands(ctrl) + f, a, w := tt.test(ctrl, queries, commands) + _, err = newUserNotifier(t, ctrl, queries, fs, f, a, w).reduceInitCodeAdded(a.event) + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func Test_userNotifier_reduceEmailCodeAdded(t *testing.T) { + expectMailSubject := "Verify email" + tests := []struct { + name string + test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, want) + }{{ + name: "asset url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, "testcode") + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().HumanEmailVerificationCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + 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, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "asset url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) + 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.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + 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, + }, + }, w + }, + }, { + name: "button url with event trigger url", + 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) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().HumanEmailVerificationCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + SMSTokenCrypto: nil, + }, 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, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "button url without event trigger url", + 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) + 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.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + 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, + }, + }, 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) { + givenTemplate := "{{.URL}}" + urlTemplate := "https://my.custom.url/org/{{.OrgID}}/user/{{.UserID}}/verify/{{.Code}}" + testCode := "testcode" + expectContent := fmt.Sprintf("https://my.custom.url/org/%s/user/%s/verify/%s", orgID, userID, testCode) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().HumanEmailVerificationCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + SMSTokenCrypto: nil, + }, 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: urlTemplate, + CodeReturned: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }} + fs, err := statik_fs.NewWithNamespace("notification") + assert.NoError(t, err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + queries := mock.NewMockQueries(ctrl) + commands := mock.NewMockCommands(ctrl) + f, a, w := tt.test(ctrl, queries, commands) + _, err = newUserNotifier(t, ctrl, queries, fs, f, a, w).reduceEmailCodeAdded(a.event) + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func Test_userNotifier_reducePasswordCodeAdded(t *testing.T) { + expectMailSubject := "Reset password" + tests := []struct { + name string + test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, want) + }{{ + name: "asset url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, "testcode") + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + 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, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "asset url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) + 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.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + 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, + }, + }, w + }, + }, { + name: "button url with event trigger url", + 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) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + SMSTokenCrypto: nil, + }, 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, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "button url without event trigger url", + 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) + 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.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + 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, + }, + }, 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) { + givenTemplate := "{{.URL}}" + urlTemplate := "https://my.custom.url/org/{{.OrgID}}/user/{{.UserID}}/verify/{{.Code}}" + testCode := "testcode" + expectContent := fmt.Sprintf("https://my.custom.url/org/%s/user/%s/verify/%s", orgID, userID, testCode) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + SMSTokenCrypto: nil, + }, 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: urlTemplate, + CodeReturned: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }} + fs, err := statik_fs.NewWithNamespace("notification") + assert.NoError(t, err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + queries := mock.NewMockQueries(ctrl) + commands := mock.NewMockCommands(ctrl) + f, a, w := tt.test(ctrl, queries, commands) + _, err = newUserNotifier(t, ctrl, queries, fs, f, a, w).reducePasswordCodeAdded(a.event) + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func Test_userNotifier_reduceDomainClaimed(t *testing.T) { + expectMailSubject := "Domain has been claimed" + tests := []struct { + name string + test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, want) + }{{ + name: "asset url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().UserDomainClaimedSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + }, args{ + event: &user.DomainClaimedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "asset url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().UserDomainClaimedSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + }, args{ + event: &user.DomainClaimedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + }, + }, w + }, + }} + fs, err := statik_fs.NewWithNamespace("notification") + assert.NoError(t, err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + queries := mock.NewMockQueries(ctrl) + commands := mock.NewMockCommands(ctrl) + f, a, w := tt.test(ctrl, queries, commands) + _, err = newUserNotifier(t, ctrl, queries, fs, f, a, w).reduceDomainClaimed(a.event) + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func Test_userNotifier_reducePasswordlessCodeRequested(t *testing.T) { + expectMailSubject := "Add Passwordless Login" + tests := []struct { + name string + test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, want) + }{{ + name: "asset url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, "testcode") + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().HumanPasswordlessInitCodeSent(gomock.Any(), userID, orgID, codeID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanPasswordlessInitCodeRequestedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + ID: codeID, + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "asset url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) + 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().HumanPasswordlessInitCodeSent(gomock.Any(), userID, orgID, codeID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanPasswordlessInitCodeRequestedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + ID: codeID, + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + }, + }, w + }, + }, { + name: "button url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.URL}}" + testCode := "testcode" + codeAlg, code := cryptoValue(t, ctrl, testCode) + expectContent := fmt.Sprintf("%s/ui/login/login/passwordless/init?userID=%s&orgID=%s&codeID=%s&code=%s", eventOrigin, userID, orgID, codeID, testCode) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().HumanPasswordlessInitCodeSent(gomock.Any(), userID, orgID, codeID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + SMSTokenCrypto: nil, + }, args{ + event: &user.HumanPasswordlessInitCodeRequestedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + ID: codeID, + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "button url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.URL}}" + testCode := "testcode" + codeAlg, code := cryptoValue(t, ctrl, testCode) + expectContent := fmt.Sprintf("%s://%s:%d/ui/login/login/passwordless/init?userID=%s&orgID=%s&codeID=%s&code=%s", externalProtocol, instancePrimaryDomain, externalPort, userID, orgID, codeID, testCode) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().HumanPasswordlessInitCodeSent(gomock.Any(), userID, orgID, codeID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanPasswordlessInitCodeRequestedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + ID: codeID, + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + }, + }, 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) { + givenTemplate := "{{.URL}}" + urlTemplate := "https://my.custom.url/org/{{.OrgID}}/user/{{.UserID}}/verify/{{.Code}}" + testCode := "testcode" + expectContent := fmt.Sprintf("https://my.custom.url/org/%s/user/%s/verify/%s", orgID, userID, testCode) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().HumanPasswordlessInitCodeSent(gomock.Any(), userID, orgID, codeID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + SMSTokenCrypto: nil, + }, args{ + event: &user.HumanPasswordlessInitCodeRequestedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + ID: codeID, + Code: code, + Expiry: time.Hour, + URLTemplate: urlTemplate, + CodeReturned: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }} + fs, err := statik_fs.NewWithNamespace("notification") + assert.NoError(t, err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + queries := mock.NewMockQueries(ctrl) + commands := mock.NewMockCommands(ctrl) + f, a, w := tt.test(ctrl, queries, commands) + _, err = newUserNotifier(t, ctrl, queries, fs, f, a, w).reducePasswordlessCodeRequested(a.event) + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func Test_userNotifier_reducePasswordChanged(t *testing.T) { + expectMailSubject := "Password of user has changed" + tests := []struct { + name string + test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, want) + }{{ + name: "asset url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + queries.EXPECT().NotificationPolicyByOrg(gomock.Any(), gomock.Any(), orgID, gomock.Any()).Return(&query.NotificationPolicy{ + PasswordChange: true, + }, nil) + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().PasswordChangeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + }, args{ + event: &user.HumanPasswordChangedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "asset url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + queries.EXPECT().NotificationPolicyByOrg(gomock.Any(), gomock.Any(), orgID, gomock.Any()).Return(&query.NotificationPolicy{ + PasswordChange: true, + }, nil) + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().PasswordChangeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + }, args{ + event: &user.HumanPasswordChangedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + }, + }, w + }, + }} + fs, err := statik_fs.NewWithNamespace("notification") + assert.NoError(t, err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + queries := mock.NewMockQueries(ctrl) + commands := mock.NewMockCommands(ctrl) + f, a, w := tt.test(ctrl, queries, commands) + _, err = newUserNotifier(t, ctrl, queries, fs, f, a, w).reducePasswordChanged(a.event) + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func Test_userNotifier_reduceOTPEmailChallenged(t *testing.T) { + expectMailSubject := "Verify One-Time Password" + tests := []struct { + name string + test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, want) + }{{ + name: "asset url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) + w.message = messages.Email{ + Recipients: []string{verifiedEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, "testcode") + expectTemplateQueries(queries, givenTemplate) + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) + commands.EXPECT().OTPEmailSent(gomock.Any(), userID, orgID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + }, args{ + event: &session.OTPEmailChallengedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTmpl: "", + ReturnCode: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "asset url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) + w.message = messages.Email{ + Recipients: []string{verifiedEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, "testcode") + expectTemplateQueries(queries, givenTemplate) + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + commands.EXPECT().OTPEmailSent(gomock.Any(), userID, orgID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + }, args{ + event: &session.OTPEmailChallengedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTmpl: "", + ReturnCode: false, + }, + }, w + }, + }, { + name: "button url with event trigger url", + 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/otp/verify?loginName=%s&code=%s", eventOrigin, preferredLoginName, testCode) + w.message = messages.Email{ + Recipients: []string{verifiedEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + expectTemplateQueries(queries, givenTemplate) + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) + commands.EXPECT().OTPEmailSent(gomock.Any(), userID, orgID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + SMSTokenCrypto: nil, + }, args{ + event: &session.OTPEmailChallengedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTmpl: "", + ReturnCode: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "button url without event trigger url", + 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/otp/verify?loginName=%s&code=%s", externalProtocol, instancePrimaryDomain, externalPort, preferredLoginName, testCode) + w.message = messages.Email{ + Recipients: []string{verifiedEmail}, + 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) + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) + commands.EXPECT().OTPEmailSent(gomock.Any(), userID, orgID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + }, args{ + event: &session.OTPEmailChallengedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + ReturnCode: false, + }, + }, 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) { + givenTemplate := "{{.URL}}" + urlTemplate := "https://my.custom.url/user/{{.LoginName}}/verify" + testCode := "testcode" + expectContent := fmt.Sprintf("https://my.custom.url/user/%s/verify", preferredLoginName) + w.message = messages.Email{ + Recipients: []string{verifiedEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + expectTemplateQueries(queries, givenTemplate) + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) + commands.EXPECT().OTPEmailSent(gomock.Any(), userID, orgID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + SMSTokenCrypto: nil, + }, args{ + event: &session.OTPEmailChallengedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + ReturnCode: false, + URLTmpl: urlTemplate, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }} + fs, err := statik_fs.NewWithNamespace("notification") + assert.NoError(t, err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + queries := mock.NewMockQueries(ctrl) + commands := mock.NewMockCommands(ctrl) + f, a, w := tt.test(ctrl, queries, commands) + _, err = newUserNotifier(t, ctrl, queries, fs, f, a, w).reduceSessionOTPEmailChallenged(a.event) + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +type fields struct { + queries *mock.MockQueries + commands *mock.MockCommands + es *eventstore.Eventstore + userDataCrypto crypto.EncryptionAlgorithm + SMSTokenCrypto crypto.EncryptionAlgorithm +} +type args struct { + event eventstore.Event +} +type want struct { + message messages.Email + err assert.ErrorAssertionFunc +} + +func newUserNotifier(t *testing.T, ctrl *gomock.Controller, queries *mock.MockQueries, fs http.FileSystem, f fields, a args, w want) *userNotifier { + queries.EXPECT().NotificationProviderByIDAndType(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().Return(&query.DebugNotificationProvider{}, nil) + smtpAlg, _ := cryptoValue(t, ctrl, "smtppw") + channel := channel_mock.NewMockNotificationChannel(ctrl) + if w.err == nil { + w.message.TriggeringEvent = a.event + channel.EXPECT().HandleMessage(&w.message).Return(nil) + } + return &userNotifier{ + commands: f.commands, + queries: NewNotificationQueries( + f.queries, + f.es, + externalDomain, + externalPort, + externalSecure, + "", + f.userDataCrypto, + smtpAlg, + f.SMSTokenCrypto, + fs, + ), + otpEmailTmpl: defaultOTPEmailTemplate, + channels: &channels{Chain: *senders.ChainChannels(channel)}, + } +} + +var _ types.ChannelChains = (*channels)(nil) + +type channels struct { + senders.Chain +} + +func (c *channels) Email(context.Context) (*senders.Chain, *smtp.Config, error) { + return &c.Chain, nil, nil +} + +func (c *channels) SMS(context.Context) (*senders.Chain, *twilio.Config, error) { + return &c.Chain, nil, nil +} + +func (c *channels) Webhook(context.Context, webhook.Config) (*senders.Chain, error) { + return &c.Chain, nil +} + +func expectTemplateQueries(queries *mock.MockQueries, template string) { + queries.EXPECT().ActiveLabelPolicyByOrg(gomock.Any(), gomock.Any(), gomock.Any()).Return(&query.LabelPolicy{ + ID: policyID, + Light: query.Theme{ + LogoURL: logoURL, + }, + }, nil) + queries.EXPECT().MailTemplateByOrg(gomock.Any(), gomock.Any(), gomock.Any()).Return(&query.MailTemplate{Template: []byte(template)}, nil) + queries.EXPECT().GetNotifyUserByID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(&query.NotifyUser{ + ID: userID, + ResourceOwner: orgID, + LastEmail: lastEmail, + VerifiedEmail: verifiedEmail, + PreferredLoginName: preferredLoginName, + }, nil) + queries.EXPECT().GetDefaultLanguage(gomock.Any()).Return(language.English) + queries.EXPECT().CustomTextListByTemplate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(2).Return(&query.CustomTexts{}, nil) +} + +func cryptoValue(t *testing.T, ctrl *gomock.Controller, value string) (*crypto.MockEncryptionAlgorithm, *crypto.CryptoValue) { + encAlg := crypto.NewMockEncryptionAlgorithm(ctrl) + encAlg.EXPECT().Algorithm().AnyTimes().Return("enc") + encAlg.EXPECT().EncryptionKeyID().AnyTimes().Return("id") + encAlg.EXPECT().DecryptionKeyIDs().AnyTimes().Return([]string{"id"}) + encAlg.EXPECT().DecryptString(gomock.Any(), gomock.Any()).AnyTimes().Return(value, nil) + encAlg.EXPECT().Encrypt(gomock.Any()).AnyTimes().Return(make([]byte, 0), nil) + code, err := crypto.Encrypt(nil, encAlg) + assert.NoError(t, err) + return encAlg, code +} diff --git a/internal/notification/projections.go b/internal/notification/projections.go index 380775254..f647a8c41 100644 --- a/internal/notification/projections.go +++ b/internal/notification/projections.go @@ -13,16 +13,6 @@ import ( _ "github.com/zitadel/zitadel/internal/notification/statik" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/query/projection" - "github.com/zitadel/zitadel/internal/telemetry/metrics" -) - -const ( - metricSuccessfulDeliveriesEmail = "successful_deliveries_email" - metricFailedDeliveriesEmail = "failed_deliveries_email" - metricSuccessfulDeliveriesSMS = "successful_deliveries_sms" - metricFailedDeliveriesSMS = "failed_deliveries_sms" - metricSuccessfulDeliveriesJSON = "successful_deliveries_json" - metricFailedDeliveriesJSON = "failed_deliveries_json" ) func Start( @@ -35,55 +25,17 @@ func Start( commands *command.Commands, queries *query.Queries, es *eventstore.Eventstore, - assetsPrefix func(context.Context) string, otpEmailTmpl string, fileSystemPath string, userEncryption, smtpEncryption, smsEncryption crypto.EncryptionAlgorithm, ) { statikFS, err := statik_fs.NewWithNamespace("notification") logging.OnError(err).Panic("unable to start listener") - err = metrics.RegisterCounter(metricSuccessfulDeliveriesEmail, "Successfully delivered emails") - logging.WithFields("metric", metricSuccessfulDeliveriesEmail).OnError(err).Panic("unable to register counter") - err = metrics.RegisterCounter(metricFailedDeliveriesEmail, "Failed email deliveries") - logging.WithFields("metric", metricFailedDeliveriesEmail).OnError(err).Panic("unable to register counter") - err = metrics.RegisterCounter(metricSuccessfulDeliveriesSMS, "Successfully delivered SMS") - logging.WithFields("metric", metricSuccessfulDeliveriesSMS).OnError(err).Panic("unable to register counter") - err = metrics.RegisterCounter(metricFailedDeliveriesSMS, "Failed SMS deliveries") - logging.WithFields("metric", metricFailedDeliveriesSMS).OnError(err).Panic("unable to register counter") - err = metrics.RegisterCounter(metricSuccessfulDeliveriesJSON, "Successfully delivered JSON messages") - logging.WithFields("metric", metricSuccessfulDeliveriesJSON).OnError(err).Panic("unable to register counter") - err = metrics.RegisterCounter(metricFailedDeliveriesJSON, "Failed JSON message deliveries") - logging.WithFields("metric", metricFailedDeliveriesJSON).OnError(err).Panic("unable to register counter") q := handlers.NewNotificationQueries(queries, es, externalDomain, externalPort, externalSecure, fileSystemPath, userEncryption, smtpEncryption, smsEncryption, statikFS) - handlers.NewUserNotifier( - ctx, - projection.ApplyCustomConfig(userHandlerCustomConfig), - commands, - q, - assetsPrefix, - otpEmailTmpl, - metricSuccessfulDeliveriesEmail, - metricFailedDeliveriesEmail, - metricSuccessfulDeliveriesSMS, - metricFailedDeliveriesSMS, - ).Start() - handlers.NewQuotaNotifier( - ctx, - projection.ApplyCustomConfig(quotaHandlerCustomConfig), - commands, - q, - metricSuccessfulDeliveriesJSON, - metricFailedDeliveriesJSON, - ).Start() + c := newChannels(q) + handlers.NewUserNotifier(ctx, projection.ApplyCustomConfig(userHandlerCustomConfig), commands, q, c, otpEmailTmpl).Start() + handlers.NewQuotaNotifier(ctx, projection.ApplyCustomConfig(quotaHandlerCustomConfig), commands, q, c).Start() if telemetryCfg.Enabled { - handlers.NewTelemetryPusher( - ctx, - telemetryCfg, - projection.ApplyCustomConfig(telemetryHandlerCustomConfig), - commands, - q, - metricSuccessfulDeliveriesJSON, - metricFailedDeliveriesJSON, - ).Start() + handlers.NewTelemetryPusher(ctx, telemetryCfg, projection.ApplyCustomConfig(telemetryHandlerCustomConfig), commands, q, c).Start() } } diff --git a/internal/notification/senders/chain.go b/internal/notification/senders/chain.go index 8241bc052..626437f0f 100644 --- a/internal/notification/senders/chain.go +++ b/internal/notification/senders/chain.go @@ -8,12 +8,12 @@ type Chain struct { channels []channels.NotificationChannel } -func chainChannels(channel ...channels.NotificationChannel) *Chain { +func ChainChannels(channel ...channels.NotificationChannel) *Chain { return &Chain{channels: channel} } // HandleMessage returns a non nil error from a provider immediately if any occurs -// messages are sent to channels in the same order they were provided to chainChannels() +// messages are sent to channels in the same order they were provided to ChainChannels() func (c *Chain) HandleMessage(message channels.Message) error { for i := range c.channels { if err := c.channels[i].HandleMessage(message); err != nil { diff --git a/internal/notification/senders/email.go b/internal/notification/senders/email.go index ddaa5a5c2..ea93c0911 100644 --- a/internal/notification/senders/email.go +++ b/internal/notification/senders/email.go @@ -17,14 +17,14 @@ const smtpSpanName = "smtp.NotificationChannel" func EmailChannels( ctx context.Context, - emailConfig func(ctx context.Context) (*smtp.Config, error), + emailConfig *smtp.Config, getFileSystemProvider func(ctx context.Context) (*fs.Config, error), getLogProvider func(ctx context.Context) (*log.Config, error), successMetricName, failureMetricName string, ) (chain *Chain, err error) { channels := make([]channels.NotificationChannel, 0, 3) - p, err := smtp.InitChannel(ctx, emailConfig) + p, err := smtp.InitChannel(emailConfig) logging.WithFields( "instance", authz.GetInstance(ctx).InstanceID(), ).OnError(err).Debug("initializing SMTP channel failed") @@ -41,5 +41,5 @@ func EmailChannels( ) } channels = append(channels, debugChannels(ctx, getFileSystemProvider, getLogProvider)...) - return chainChannels(channels...), nil + return ChainChannels(channels...), nil } diff --git a/internal/notification/senders/sms.go b/internal/notification/senders/sms.go index 22d4a4720..361fc5650 100644 --- a/internal/notification/senders/sms.go +++ b/internal/notification/senders/sms.go @@ -34,5 +34,5 @@ func SMSChannels( ) } channels = append(channels, debugChannels(ctx, getFileSystemProvider, getLogProvider)...) - return chainChannels(channels...), nil + return ChainChannels(channels...), nil } diff --git a/internal/notification/senders/json.go b/internal/notification/senders/webhook.go similarity index 95% rename from internal/notification/senders/json.go rename to internal/notification/senders/webhook.go index 73f6a92ac..d677f9a80 100644 --- a/internal/notification/senders/json.go +++ b/internal/notification/senders/webhook.go @@ -15,7 +15,7 @@ import ( const webhookSpanName = "webhook.NotificationChannel" -func JSONChannels( +func WebhookChannels( ctx context.Context, webhookConfig webhook.Config, getFileSystemProvider func(ctx context.Context) (*fs.Config, error), @@ -45,5 +45,5 @@ func JSONChannels( ) } channels = append(channels, debugChannels(ctx, getFileSystemProvider, getLogProvider)...) - return chainChannels(channels...), nil + return ChainChannels(channels...), nil } diff --git a/internal/notification/types/domain_claimed.go b/internal/notification/types/domain_claimed.go index 69ab8413c..975cc74db 100644 --- a/internal/notification/types/domain_claimed.go +++ b/internal/notification/types/domain_claimed.go @@ -1,15 +1,17 @@ package types import ( + "context" "strings" + http_utils "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/ui/login" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" ) -func (notify Notify) SendDomainClaimed(user *query.NotifyUser, origin, username string) error { - url := login.LoginLink(origin, user.ResourceOwner) +func (notify Notify) SendDomainClaimed(ctx context.Context, user *query.NotifyUser, username string) error { + url := login.LoginLink(http_utils.ComposedOrigin(ctx), user.ResourceOwner) index := strings.LastIndex(user.LastEmail, "@") args := make(map[string]interface{}) args["TempUsername"] = username diff --git a/internal/notification/types/email_verification_code.go b/internal/notification/types/email_verification_code.go index 112da75dd..b2f34acd6 100644 --- a/internal/notification/types/email_verification_code.go +++ b/internal/notification/types/email_verification_code.go @@ -1,17 +1,19 @@ package types import ( + "context" "strings" + http_utils "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/ui/login" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" ) -func (notify Notify) SendEmailVerificationCode(user *query.NotifyUser, origin, code string, urlTmpl string) error { +func (notify Notify) SendEmailVerificationCode(ctx context.Context, user *query.NotifyUser, code string, urlTmpl string) error { var url string if urlTmpl == "" { - url = login.MailVerificationLink(origin, user.ID, code, user.ResourceOwner) + url = login.MailVerificationLink(http_utils.ComposedOrigin(ctx), user.ID, code, user.ResourceOwner) } 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 71cebaca5..ab1b4e181 100644 --- a/internal/notification/types/email_verification_code_test.go +++ b/internal/notification/types/email_verification_code_test.go @@ -1,11 +1,13 @@ package types import ( + "context" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + http_utils "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/domain" caos_errs "github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/query" @@ -78,7 +80,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(tt.args.user, tt.args.origin, 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) 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 10d68cf34..0e30e991d 100644 --- a/internal/notification/types/init_code.go +++ b/internal/notification/types/init_code.go @@ -1,13 +1,16 @@ package types import ( + "context" + + http_utils "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/ui/login" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" ) -func (notify Notify) SendUserInitCode(user *query.NotifyUser, origin, code string) error { - url := login.InitUserLink(origin, user.ID, user.PreferredLoginName, code, user.ResourceOwner, user.PasswordSet) +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) args := make(map[string]interface{}) args["Code"] = code return notify(url, args, domain.InitCodeMessageType, true) diff --git a/internal/notification/types/json.go b/internal/notification/types/json.go deleted file mode 100644 index 8c5e18166..000000000 --- a/internal/notification/types/json.go +++ /dev/null @@ -1,40 +0,0 @@ -package types - -import ( - "context" - - "github.com/zitadel/zitadel/internal/eventstore" - "github.com/zitadel/zitadel/internal/notification/channels/fs" - "github.com/zitadel/zitadel/internal/notification/channels/log" - "github.com/zitadel/zitadel/internal/notification/channels/webhook" - "github.com/zitadel/zitadel/internal/notification/messages" - "github.com/zitadel/zitadel/internal/notification/senders" -) - -func handleJSON( - ctx context.Context, - webhookConfig webhook.Config, - getFileSystemProvider func(ctx context.Context) (*fs.Config, error), - getLogProvider func(ctx context.Context) (*log.Config, error), - serializable interface{}, - triggeringEvent eventstore.Event, - successMetricName, - failureMetricName string, -) error { - message := &messages.JSON{ - Serializable: serializable, - TriggeringEvent: triggeringEvent, - } - channelChain, err := senders.JSONChannels( - ctx, - webhookConfig, - getFileSystemProvider, - getLogProvider, - successMetricName, - failureMetricName, - ) - if err != nil { - return err - } - return channelChain.HandleMessage(message) -} diff --git a/internal/notification/types/notification.go b/internal/notification/types/notification.go index 7c4fc3c14..1a5ccebb2 100644 --- a/internal/notification/types/notification.go +++ b/internal/notification/types/notification.go @@ -5,11 +5,10 @@ import ( "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/i18n" - "github.com/zitadel/zitadel/internal/notification/channels/fs" - "github.com/zitadel/zitadel/internal/notification/channels/log" "github.com/zitadel/zitadel/internal/notification/channels/smtp" "github.com/zitadel/zitadel/internal/notification/channels/twilio" "github.com/zitadel/zitadel/internal/notification/channels/webhook" + "github.com/zitadel/zitadel/internal/notification/senders" "github.com/zitadel/zitadel/internal/notification/templates" "github.com/zitadel/zitadel/internal/query" ) @@ -21,19 +20,20 @@ type Notify func( allowUnverifiedNotificationChannel bool, ) error +type ChannelChains interface { + Email(context.Context) (*senders.Chain, *smtp.Config, error) + SMS(context.Context) (*senders.Chain, *twilio.Config, error) + Webhook(context.Context, webhook.Config) (*senders.Chain, error) +} + func SendEmail( ctx context.Context, + channels ChannelChains, mailhtml string, translator *i18n.Translator, user *query.NotifyUser, - emailConfig func(ctx context.Context) (*smtp.Config, error), - getFileSystemProvider func(ctx context.Context) (*fs.Config, error), - getLogProvider func(ctx context.Context) (*log.Config, error), colors *query.LabelPolicy, - assetsPrefix string, triggeringEvent eventstore.Event, - successMetricName, - failureMetricName string, ) Notify { return func( url string, @@ -42,39 +42,30 @@ func SendEmail( allowUnverifiedNotificationChannel bool, ) error { args = mapNotifyUserToArgs(user, args) - data := GetTemplateData(translator, args, assetsPrefix, url, messageType, user.PreferredLanguage.String(), colors) + data := GetTemplateData(ctx, translator, args, url, messageType, user.PreferredLanguage.String(), colors) template, err := templates.GetParsedTemplate(mailhtml, data) if err != nil { return err } return generateEmail( ctx, + channels, user, data.Subject, template, - emailConfig, - getFileSystemProvider, - getLogProvider, allowUnverifiedNotificationChannel, triggeringEvent, - successMetricName, - failureMetricName, ) } } func SendSMSTwilio( ctx context.Context, + channels ChannelChains, translator *i18n.Translator, user *query.NotifyUser, - twilioConfig func(ctx context.Context) (*twilio.Config, error), - getFileSystemProvider func(ctx context.Context) (*fs.Config, error), - getLogProvider func(ctx context.Context) (*log.Config, error), colors *query.LabelPolicy, - assetsPrefix string, triggeringEvent eventstore.Event, - successMetricName, - failureMetricName string, ) Notify { return func( url string, @@ -83,18 +74,14 @@ func SendSMSTwilio( allowUnverifiedNotificationChannel bool, ) error { args = mapNotifyUserToArgs(user, args) - data := GetTemplateData(translator, args, assetsPrefix, url, messageType, user.PreferredLanguage.String(), colors) + data := GetTemplateData(ctx, translator, args, url, messageType, user.PreferredLanguage.String(), colors) return generateSms( ctx, + channels, user, data.Text, - twilioConfig, - getFileSystemProvider, - getLogProvider, allowUnverifiedNotificationChannel, triggeringEvent, - successMetricName, - failureMetricName, ) } } @@ -102,23 +89,17 @@ func SendSMSTwilio( func SendJSON( ctx context.Context, webhookConfig webhook.Config, - getFileSystemProvider func(ctx context.Context) (*fs.Config, error), - getLogProvider func(ctx context.Context) (*log.Config, error), + channels ChannelChains, serializable interface{}, triggeringEvent eventstore.Event, - successMetricName, - failureMetricName string, ) Notify { return func(_ string, _ map[string]interface{}, _ string, _ bool) error { - return handleJSON( + return handleWebhook( ctx, webhookConfig, - getFileSystemProvider, - getLogProvider, + channels, serializable, triggeringEvent, - successMetricName, - failureMetricName, ) } } diff --git a/internal/notification/types/otp.go b/internal/notification/types/otp.go index 2079ecce0..7919984f7 100644 --- a/internal/notification/types/otp.go +++ b/internal/notification/types/otp.go @@ -1,27 +1,31 @@ package types import ( + "context" "time" + http_utils "github.com/zitadel/zitadel/internal/api/http" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/query" ) -func (notify Notify) SendOTPSMSCode(requestedDomain, origin, code string, expiry time.Duration) error { - args := otpArgs(code, origin, requestedDomain, expiry) +func (notify Notify) SendOTPSMSCode(ctx context.Context, code string, expiry time.Duration) error { + args := otpArgs(ctx, code, expiry) return notify("", args, domain.VerifySMSOTPMessageType, false) } -func (notify Notify) SendOTPEmailCode(user *query.NotifyUser, url, requestedDomain, origin, code string, expiry time.Duration) error { - args := otpArgs(code, origin, requestedDomain, expiry) +func (notify Notify) SendOTPEmailCode(ctx context.Context, url, code string, expiry time.Duration) error { + args := otpArgs(ctx, code, expiry) return notify(url, args, domain.VerifyEmailOTPMessageType, false) } -func otpArgs(code, origin, requestedDomain string, expiry time.Duration) map[string]interface{} { +func otpArgs(ctx context.Context, code string, expiry time.Duration) map[string]interface{} { args := make(map[string]interface{}) args["OTP"] = code - args["Origin"] = origin - args["Domain"] = requestedDomain + args["Origin"] = http_utils.ComposedOrigin(ctx) + args["Domain"] = authz.GetInstance(ctx).RequestedDomain() args["Expiry"] = expiry return args } diff --git a/internal/notification/types/password_change.go b/internal/notification/types/password_change.go index 7b963c2d0..61483f047 100644 --- a/internal/notification/types/password_change.go +++ b/internal/notification/types/password_change.go @@ -1,13 +1,16 @@ package types import ( + "context" + + http_utils "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/ui/console" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" ) -func (notify Notify) SendPasswordChange(user *query.NotifyUser, origin string) error { - url := console.LoginHintLink(origin, user.PreferredLoginName) +func (notify Notify) SendPasswordChange(ctx context.Context, user *query.NotifyUser) error { + url := console.LoginHintLink(http_utils.ComposedOrigin(ctx), user.PreferredLoginName) args := make(map[string]interface{}) return notify(url, args, domain.PasswordChangeMessageType, true) } diff --git a/internal/notification/types/password_code.go b/internal/notification/types/password_code.go index 2285ba439..b1121f9d1 100644 --- a/internal/notification/types/password_code.go +++ b/internal/notification/types/password_code.go @@ -1,17 +1,19 @@ package types import ( + "context" "strings" + http_utils "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/ui/login" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" ) -func (notify Notify) SendPasswordCode(user *query.NotifyUser, origin, code, urlTmpl string) error { +func (notify Notify) SendPasswordCode(ctx context.Context, user *query.NotifyUser, code, urlTmpl string) error { var url string if urlTmpl == "" { - url = login.InitPasswordLink(origin, user.ID, code, user.ResourceOwner) + url = login.InitPasswordLink(http_utils.ComposedOrigin(ctx), user.ID, code, user.ResourceOwner) } else { var buf strings.Builder if err := domain.RenderConfirmURLTemplate(&buf, urlTmpl, user.ID, code, user.ResourceOwner); err != nil { diff --git a/internal/notification/types/passwordless_registration_link.go b/internal/notification/types/passwordless_registration_link.go index 7f4ab0040..940aeaec0 100644 --- a/internal/notification/types/passwordless_registration_link.go +++ b/internal/notification/types/passwordless_registration_link.go @@ -1,17 +1,19 @@ package types import ( + "context" "strings" + http_utils "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/ui/login" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" ) -func (notify Notify) SendPasswordlessRegistrationLink(user *query.NotifyUser, origin, code, codeID, urlTmpl string) error { +func (notify Notify) SendPasswordlessRegistrationLink(ctx context.Context, user *query.NotifyUser, code, codeID, urlTmpl string) error { var url string if urlTmpl == "" { - url = domain.PasswordlessInitCodeLink(origin+login.HandlerPrefix+login.EndpointPasswordlessRegistration, user.ID, user.ResourceOwner, codeID, code) + url = domain.PasswordlessInitCodeLink(http_utils.ComposedOrigin(ctx)+login.HandlerPrefix+login.EndpointPasswordlessRegistration, user.ID, user.ResourceOwner, codeID, code) } else { var buf strings.Builder if err := domain.RenderPasskeyURLTemplate(&buf, urlTmpl, user.ID, user.ResourceOwner, codeID, code); err != nil { @@ -19,6 +21,5 @@ func (notify Notify) SendPasswordlessRegistrationLink(user *query.NotifyUser, or } url = buf.String() } - return notify(url, nil, domain.PasswordlessRegistrationMessageType, true) } diff --git a/internal/notification/types/passwordless_registration_link_test.go b/internal/notification/types/passwordless_registration_link_test.go index eb13b94a4..489a50567 100644 --- a/internal/notification/types/passwordless_registration_link_test.go +++ b/internal/notification/types/passwordless_registration_link_test.go @@ -1,11 +1,13 @@ package types import ( + "context" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + http_utils "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/domain" caos_errs "github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/query" @@ -80,7 +82,7 @@ func TestNotify_SendPasswordlessRegistrationLink(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, notify := mockNotify() - err := notify.SendPasswordlessRegistrationLink(tt.args.user, tt.args.origin, tt.args.code, tt.args.codeID, tt.args.urlTmpl) + err := notify.SendPasswordlessRegistrationLink(http_utils.WithComposedOrigin(context.Background(), tt.args.origin), tt.args.user, tt.args.code, tt.args.codeID, tt.args.urlTmpl) require.ErrorIs(t, err, tt.wantErr) assert.Equal(t, tt.want, got) }) diff --git a/internal/notification/types/phone_verification_code.go b/internal/notification/types/phone_verification_code.go index f833aeb33..4b583c1e4 100644 --- a/internal/notification/types/phone_verification_code.go +++ b/internal/notification/types/phone_verification_code.go @@ -1,13 +1,15 @@ package types import ( + "context" + + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/query" ) -func (notify Notify) SendPhoneVerificationCode(user *query.NotifyUser, origin, code, requestedDomain string) error { +func (notify Notify) SendPhoneVerificationCode(ctx context.Context, code string) error { args := make(map[string]interface{}) args["Code"] = code - args["Domain"] = requestedDomain + args["Domain"] = authz.GetInstance(ctx).RequestedDomain() return notify("", args, domain.VerifyPhoneMessageType, true) } diff --git a/internal/notification/types/templateData.go b/internal/notification/types/templateData.go index 465e17555..d020d977c 100644 --- a/internal/notification/types/templateData.go +++ b/internal/notification/types/templateData.go @@ -1,15 +1,20 @@ package types import ( + "context" "fmt" "strings" + http_util "github.com/zitadel/zitadel/internal/api/http" + + "github.com/zitadel/zitadel/internal/api/assets" "github.com/zitadel/zitadel/internal/i18n" "github.com/zitadel/zitadel/internal/notification/templates" "github.com/zitadel/zitadel/internal/query" ) -func GetTemplateData(translator *i18n.Translator, translateArgs map[string]interface{}, assetsPrefix, href, msgType, lang string, policy *query.LabelPolicy) templates.TemplateData { +func GetTemplateData(ctx context.Context, translator *i18n.Translator, translateArgs map[string]interface{}, href, msgType, lang string, policy *query.LabelPolicy) templates.TemplateData { + assetsPrefix := http_util.ComposedOrigin(ctx) + assets.HandlerPrefix templateData := templates.TemplateData{ URL: href, PrimaryColor: templates.DefaultPrimaryColor, @@ -28,9 +33,6 @@ func GetTemplateData(translator *i18n.Translator, translateArgs map[string]inter if policy.Light.FontColor != "" { templateData.FontColor = policy.Light.FontColor } - if assetsPrefix == "" { - return templateData - } if policy.Light.LogoURL != "" { templateData.LogoURL = fmt.Sprintf("%s/%s/%s", assetsPrefix, policy.ID, policy.Light.LogoURL) } diff --git a/internal/notification/types/user_email.go b/internal/notification/types/user_email.go index 916844d76..7cb3498e4 100644 --- a/internal/notification/types/user_email.go +++ b/internal/notification/types/user_email.go @@ -6,26 +6,18 @@ import ( "github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/eventstore" - "github.com/zitadel/zitadel/internal/notification/channels/fs" - "github.com/zitadel/zitadel/internal/notification/channels/log" - "github.com/zitadel/zitadel/internal/notification/channels/smtp" "github.com/zitadel/zitadel/internal/notification/messages" - "github.com/zitadel/zitadel/internal/notification/senders" "github.com/zitadel/zitadel/internal/query" ) func generateEmail( ctx context.Context, + channels ChannelChains, user *query.NotifyUser, subject, content string, - smtpConfig func(ctx context.Context) (*smtp.Config, error), - getFileSystemProvider func(ctx context.Context) (*fs.Config, error), - getLogProvider func(ctx context.Context) (*log.Config, error), lastEmail bool, triggeringEvent eventstore.Event, - successMetricName, - failureMetricName string, ) error { content = html.UnescapeString(content) message := &messages.Email{ @@ -37,23 +29,14 @@ func generateEmail( if lastEmail { message.Recipients = []string{user.LastEmail} } - - channelChain, err := senders.EmailChannels( - ctx, - smtpConfig, - getFileSystemProvider, - getLogProvider, - successMetricName, - failureMetricName, - ) + emailChannels, _, err := channels.Email(ctx) if err != nil { return err } - - if channelChain.Len() == 0 { + if emailChannels.Len() == 0 { return errors.ThrowPreconditionFailed(nil, "MAIL-83nof", "Errors.Notification.Channels.NotPresent") } - return channelChain.HandleMessage(message) + return emailChannels.HandleMessage(message) } func mapNotifyUserToArgs(user *query.NotifyUser, args map[string]interface{}) map[string]interface{} { diff --git a/internal/notification/types/user_phone.go b/internal/notification/types/user_phone.go index a3407119a..50b7f8637 100644 --- a/internal/notification/types/user_phone.go +++ b/internal/notification/types/user_phone.go @@ -4,31 +4,26 @@ import ( "context" "github.com/zitadel/logging" - "github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/eventstore" - "github.com/zitadel/zitadel/internal/notification/channels/fs" - "github.com/zitadel/zitadel/internal/notification/channels/log" - "github.com/zitadel/zitadel/internal/notification/channels/twilio" "github.com/zitadel/zitadel/internal/notification/messages" - "github.com/zitadel/zitadel/internal/notification/senders" "github.com/zitadel/zitadel/internal/query" ) func generateSms( ctx context.Context, + channels ChannelChains, user *query.NotifyUser, content string, - getTwilioProvider func(ctx context.Context) (*twilio.Config, error), - getFileSystemProvider func(ctx context.Context) (*fs.Config, error), - getLogProvider func(ctx context.Context) (*log.Config, error), lastPhone bool, triggeringEvent eventstore.Event, - successMetricName, - failureMetricName string, ) error { number := "" - twilioConfig, err := getTwilioProvider(ctx) + smsChannels, twilioConfig, err := channels.SMS(ctx) + logging.OnError(err).Error("could not create sms channel") + if smsChannels.Len() == 0 { + return errors.ThrowPreconditionFailed(nil, "PHONE-w8nfow", "Errors.Notification.Channels.NotPresent") + } if err == nil { number = twilioConfig.SenderNumber } @@ -41,19 +36,5 @@ func generateSms( if lastPhone { message.RecipientPhoneNumber = user.LastPhone } - - channelChain, err := senders.SMSChannels( - ctx, - twilioConfig, - getFileSystemProvider, - getLogProvider, - successMetricName, - failureMetricName, - ) - logging.OnError(err).Error("could not create sms channel") - - if channelChain.Len() == 0 { - return errors.ThrowPreconditionFailed(nil, "PHONE-w8nfow", "Errors.Notification.Channels.NotPresent") - } - return channelChain.HandleMessage(message) + return smsChannels.HandleMessage(message) } diff --git a/internal/notification/types/webhook.go b/internal/notification/types/webhook.go new file mode 100644 index 000000000..465be289d --- /dev/null +++ b/internal/notification/types/webhook.go @@ -0,0 +1,27 @@ +package types + +import ( + "context" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/notification/channels/webhook" + "github.com/zitadel/zitadel/internal/notification/messages" +) + +func handleWebhook( + ctx context.Context, + webhookConfig webhook.Config, + channels ChannelChains, + serializable interface{}, + triggeringEvent eventstore.Event, +) error { + message := &messages.JSON{ + Serializable: serializable, + TriggeringEvent: triggeringEvent, + } + webhookChannels, err := channels.Webhook(ctx, webhookConfig) + if err != nil { + return err + } + return webhookChannels.HandleMessage(message) +} diff --git a/internal/repository/session/session.go b/internal/repository/session/session.go index 76f4984d1..c34ef5242 100644 --- a/internal/repository/session/session.go +++ b/internal/repository/session/session.go @@ -5,6 +5,7 @@ import ( "encoding/json" "time" + "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/errors" @@ -409,10 +410,11 @@ func NewOTPSMSCheckedEvent( type OTPEmailChallengedEvent struct { eventstore.BaseEvent `json:"-"` - Code *crypto.CryptoValue `json:"code"` - Expiry time.Duration `json:"expiry"` - ReturnCode bool `json:"returnCode,omitempty"` - URLTmpl string `json:"urlTmpl,omitempty"` + Code *crypto.CryptoValue `json:"code"` + Expiry time.Duration `json:"expiry"` + ReturnCode bool `json:"returnCode,omitempty"` + URLTmpl string `json:"urlTmpl,omitempty"` + TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` } func (e *OTPEmailChallengedEvent) Data() interface{} { @@ -427,6 +429,10 @@ func (e *OTPEmailChallengedEvent) SetBaseEvent(base *eventstore.BaseEvent) { e.BaseEvent = *base } +func (e *OTPEmailChallengedEvent) TriggerOrigin() string { + return e.TriggeredAtOrigin +} + func NewOTPEmailChallengedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -441,10 +447,11 @@ func NewOTPEmailChallengedEvent( aggregate, OTPEmailChallengedType, ), - Code: code, - Expiry: expiry, - ReturnCode: returnCode, - URLTmpl: urlTmpl, + Code: code, + Expiry: expiry, + ReturnCode: returnCode, + URLTmpl: urlTmpl, + TriggeredAtOrigin: http.ComposedOrigin(ctx), } } diff --git a/internal/repository/user/human.go b/internal/repository/user/human.go index 075fd55ca..f314cd8df 100644 --- a/internal/repository/user/human.go +++ b/internal/repository/user/human.go @@ -7,6 +7,7 @@ import ( "golang.org/x/text/language" + "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/errors" @@ -244,6 +245,7 @@ type HumanInitialCodeAddedEvent struct { eventstore.BaseEvent `json:"-"` Code *crypto.CryptoValue `json:"code,omitempty"` Expiry time.Duration `json:"expiry,omitempty"` + TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` } func (e *HumanInitialCodeAddedEvent) Data() interface{} { @@ -254,6 +256,10 @@ func (e *HumanInitialCodeAddedEvent) UniqueConstraints() []*eventstore.EventUniq return nil } +func (e *HumanInitialCodeAddedEvent) TriggerOrigin() string { + return e.TriggeredAtOrigin +} + func NewHumanInitialCodeAddedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -266,8 +272,9 @@ func NewHumanInitialCodeAddedEvent( aggregate, HumanInitialCodeAddedType, ), - Code: code, - Expiry: expiry, + Code: code, + Expiry: expiry, + TriggeredAtOrigin: http.ComposedOrigin(ctx), } } diff --git a/internal/repository/user/human_email.go b/internal/repository/user/human_email.go index a6a5b8a18..51081dc6e 100644 --- a/internal/repository/user/human_email.go +++ b/internal/repository/user/human_email.go @@ -5,6 +5,7 @@ import ( "encoding/json" "time" + "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/errors" @@ -122,10 +123,11 @@ func HumanEmailVerificationFailedEventMapper(event *repository.Event) (eventstor type HumanEmailCodeAddedEvent struct { eventstore.BaseEvent `json:"-"` - Code *crypto.CryptoValue `json:"code,omitempty"` - Expiry time.Duration `json:"expiry,omitempty"` - URLTemplate string `json:"url_template,omitempty"` - CodeReturned bool `json:"code_returned,omitempty"` + Code *crypto.CryptoValue `json:"code,omitempty"` + Expiry time.Duration `json:"expiry,omitempty"` + URLTemplate string `json:"url_template,omitempty"` + CodeReturned bool `json:"code_returned,omitempty"` + TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` } func (e *HumanEmailCodeAddedEvent) Data() interface{} { @@ -136,6 +138,10 @@ func (e *HumanEmailCodeAddedEvent) UniqueConstraints() []*eventstore.EventUnique return nil } +func (e *HumanEmailCodeAddedEvent) TriggerOrigin() string { + return e.TriggeredAtOrigin +} + func NewHumanEmailCodeAddedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -159,10 +165,11 @@ func NewHumanEmailCodeAddedEventV2( aggregate, HumanEmailCodeAddedType, ), - Code: code, - Expiry: expiry, - URLTemplate: urlTemplate, - CodeReturned: codeReturned, + Code: code, + Expiry: expiry, + URLTemplate: urlTemplate, + CodeReturned: codeReturned, + TriggeredAtOrigin: http.ComposedOrigin(ctx), } } diff --git a/internal/repository/user/human_mfa_passwordless.go b/internal/repository/user/human_mfa_passwordless.go index 1ffa14af3..abdee0c3d 100644 --- a/internal/repository/user/human_mfa_passwordless.go +++ b/internal/repository/user/human_mfa_passwordless.go @@ -5,6 +5,7 @@ import ( "encoding/json" "time" + "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/errors" @@ -317,11 +318,12 @@ func HumanPasswordlessInitCodeAddedEventMapper(event *repository.Event) (eventst type HumanPasswordlessInitCodeRequestedEvent struct { eventstore.BaseEvent `json:"-"` - ID string `json:"id"` - Code *crypto.CryptoValue `json:"code"` - Expiry time.Duration `json:"expiry"` - URLTemplate string `json:"url_template,omitempty"` - CodeReturned bool `json:"code_returned,omitempty"` + ID string `json:"id"` + Code *crypto.CryptoValue `json:"code"` + Expiry time.Duration `json:"expiry"` + URLTemplate string `json:"url_template,omitempty"` + CodeReturned bool `json:"code_returned,omitempty"` + TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` } func (e *HumanPasswordlessInitCodeRequestedEvent) Data() interface{} { @@ -332,6 +334,10 @@ func (e *HumanPasswordlessInitCodeRequestedEvent) UniqueConstraints() []*eventst return nil } +func (e *HumanPasswordlessInitCodeRequestedEvent) TriggerOrigin() string { + return e.TriggeredAtOrigin +} + func NewHumanPasswordlessInitCodeRequestedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -347,11 +353,12 @@ func NewHumanPasswordlessInitCodeRequestedEvent( aggregate, HumanPasswordlessInitCodeRequestedType, ), - ID: id, - Code: code, - Expiry: expiry, - URLTemplate: urlTmpl, - CodeReturned: codeReturned, + ID: id, + Code: code, + Expiry: expiry, + URLTemplate: urlTmpl, + CodeReturned: codeReturned, + TriggeredAtOrigin: http.ComposedOrigin(ctx), } } diff --git a/internal/repository/user/human_password.go b/internal/repository/user/human_password.go index 909da2426..051914517 100644 --- a/internal/repository/user/human_password.go +++ b/internal/repository/user/human_password.go @@ -5,11 +5,11 @@ import ( "encoding/json" "time" - "github.com/zitadel/zitadel/internal/eventstore" - + "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/repository" ) @@ -29,10 +29,11 @@ type HumanPasswordChangedEvent struct { // New events only use EncodedHash. However, the secret field // is preserved to handle events older than the switch to Passwap. - Secret *crypto.CryptoValue `json:"secret,omitempty"` - EncodedHash string `json:"encodedHash,omitempty"` - ChangeRequired bool `json:"changeRequired"` - UserAgentID string `json:"userAgentID,omitempty"` + Secret *crypto.CryptoValue `json:"secret,omitempty"` + EncodedHash string `json:"encodedHash,omitempty"` + ChangeRequired bool `json:"changeRequired"` + UserAgentID string `json:"userAgentID,omitempty"` + TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` } func (e *HumanPasswordChangedEvent) Data() interface{} { @@ -43,6 +44,10 @@ func (e *HumanPasswordChangedEvent) UniqueConstraints() []*eventstore.EventUniqu return nil } +func (e *HumanPasswordChangedEvent) TriggerOrigin() string { + return e.TriggeredAtOrigin +} + func NewHumanPasswordChangedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -56,9 +61,10 @@ func NewHumanPasswordChangedEvent( aggregate, HumanPasswordChangedType, ), - EncodedHash: encodeHash, - ChangeRequired: changeRequired, - UserAgentID: userAgentID, + EncodedHash: encodeHash, + ChangeRequired: changeRequired, + UserAgentID: userAgentID, + TriggeredAtOrigin: http.ComposedOrigin(ctx), } } @@ -77,11 +83,12 @@ func HumanPasswordChangedEventMapper(event *repository.Event) (eventstore.Event, type HumanPasswordCodeAddedEvent struct { eventstore.BaseEvent `json:"-"` - Code *crypto.CryptoValue `json:"code,omitempty"` - Expiry time.Duration `json:"expiry,omitempty"` - NotificationType domain.NotificationType `json:"notificationType,omitempty"` - URLTemplate string `json:"url_template,omitempty"` - CodeReturned bool `json:"code_returned,omitempty"` + Code *crypto.CryptoValue `json:"code,omitempty"` + Expiry time.Duration `json:"expiry,omitempty"` + NotificationType domain.NotificationType `json:"notificationType,omitempty"` + URLTemplate string `json:"url_template,omitempty"` + CodeReturned bool `json:"code_returned,omitempty"` + TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` } func (e *HumanPasswordCodeAddedEvent) Data() interface{} { @@ -92,6 +99,10 @@ func (e *HumanPasswordCodeAddedEvent) UniqueConstraints() []*eventstore.EventUni return nil } +func (e *HumanPasswordCodeAddedEvent) TriggerOrigin() string { + return e.TriggeredAtOrigin +} + func NewHumanPasswordCodeAddedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -117,11 +128,12 @@ func NewHumanPasswordCodeAddedEventV2( aggregate, HumanPasswordCodeAddedType, ), - Code: code, - Expiry: expiry, - NotificationType: notificationType, - URLTemplate: urlTemplate, - CodeReturned: codeReturned, + Code: code, + Expiry: expiry, + NotificationType: notificationType, + URLTemplate: urlTemplate, + CodeReturned: codeReturned, + TriggeredAtOrigin: http.ComposedOrigin(ctx), } } diff --git a/internal/repository/user/user.go b/internal/repository/user/user.go index a35ebe809..1e6e7fbe6 100644 --- a/internal/repository/user/user.go +++ b/internal/repository/user/user.go @@ -5,10 +5,10 @@ import ( "encoding/json" "time" + "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/eventstore" - "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/repository" ) @@ -315,6 +315,7 @@ type DomainClaimedEvent struct { eventstore.BaseEvent `json:"-"` UserName string `json:"userName"` + TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` oldUserName string userLoginMustBeDomain bool } @@ -330,6 +331,10 @@ func (e *DomainClaimedEvent) UniqueConstraints() []*eventstore.EventUniqueConstr } } +func (e *DomainClaimedEvent) TriggerOrigin() string { + return e.TriggeredAtOrigin +} + func NewDomainClaimedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -346,6 +351,7 @@ func NewDomainClaimedEvent( UserName: userName, oldUserName: oldUserName, userLoginMustBeDomain: userLoginMustBeDomain, + TriggeredAtOrigin: http.ComposedOrigin(ctx), } }