From 911967c8658511e1074680ddf5c40ab414e6e339 Mon Sep 17 00:00:00 2001 From: Iraq <66622793+kkrime@users.noreply.github.com> Date: Tue, 2 Dec 2025 08:16:21 +0100 Subject: [PATCH] feat(settings_relational): adding settings table for relational db (#10769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved As part of https://github.com/zitadel/zitadel/issues/9599 to push to move to a relational db table model, this is the migration of the settings # Additional Changes I had to add 2 non source code files `backend/v3/storage/database/events_testing/font.otf` and `backend/v3/storage/database/events_testing/picture.png`, these are used as part of the reduce tests, hence why they're sourced inside `backend/v3/storage/database/events_testing/settings_instance_test.go` - Closes https://github.com/zitadel/zitadel/issues/10207 --------- Co-authored-by: adlerhurst <27845747+adlerhurst@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Tim Möhlmann Co-authored-by: Stefan Benz <46600784+stebenz@users.noreply.github.com> --- backend/v3/domain/errors.go | 3 - backend/v3/domain/settings.go | 430 ++ backend/v3/domain/settingstate_enumer.go | 109 + backend/v3/domain/settingtype_enumer.go | 141 + backend/v3/storage/database/change.go | 27 +- backend/v3/storage/database/change_test.go | 3 +- backend/v3/storage/database/condition.go | 6 +- .../database/dialect/postgres/error.go | 3 + .../postgres/migration/009_settings_table.go | 16 + .../migration/009_settings_table/down.sql | 3 + .../migration/009_settings_table/up.sql | 95 + .../storage/database/events_testing/font.otf | Bin 0 -> 354856 bytes .../database/events_testing/picture.png | 97 + .../events_testing/settings_instance_test.go | 1389 +++++ .../events_testing/settings_org_test.go | 1550 ++++++ backend/v3/storage/database/json/json.go | 225 + backend/v3/storage/database/json/json_test.go | 250 + .../database/repository/id_provider.go | 5 +- .../storage/database/repository/instance.go | 10 +- .../database/repository/instance_domain.go | 5 +- .../repository/instance_domain_test.go | 3 +- backend/v3/storage/database/repository/org.go | 5 +- .../storage/database/repository/org_domain.go | 5 +- .../database/repository/org_domain_test.go | 3 +- .../database/repository/project_role.go | 5 +- .../storage/database/repository/settings.go | 1407 +++++ .../database/repository/settings_test.go | 4756 +++++++++++++++++ .../config/systemdefaults/system_defaults.go | 2 +- internal/query/projection/projection.go | 3 + .../query/projection/settings_relational.go | 1787 +++++++ 30 files changed, 12316 insertions(+), 27 deletions(-) create mode 100644 backend/v3/domain/settings.go create mode 100644 backend/v3/domain/settingstate_enumer.go create mode 100644 backend/v3/domain/settingtype_enumer.go create mode 100644 backend/v3/storage/database/dialect/postgres/migration/009_settings_table.go create mode 100644 backend/v3/storage/database/dialect/postgres/migration/009_settings_table/down.sql create mode 100644 backend/v3/storage/database/dialect/postgres/migration/009_settings_table/up.sql create mode 100644 backend/v3/storage/database/events_testing/font.otf create mode 100644 backend/v3/storage/database/events_testing/picture.png create mode 100644 backend/v3/storage/database/events_testing/settings_instance_test.go create mode 100644 backend/v3/storage/database/events_testing/settings_org_test.go create mode 100644 backend/v3/storage/database/json/json.go create mode 100644 backend/v3/storage/database/json/json_test.go create mode 100644 backend/v3/storage/database/repository/settings.go create mode 100644 backend/v3/storage/database/repository/settings_test.go create mode 100644 internal/query/projection/settings_relational.go diff --git a/backend/v3/domain/errors.go b/backend/v3/domain/errors.go index 77ed61312..76c588c54 100644 --- a/backend/v3/domain/errors.go +++ b/backend/v3/domain/errors.go @@ -1,12 +1,9 @@ package domain import ( - "errors" "fmt" ) -var ErrNoAdminSpecified = errors.New("at least one admin must be specified") - type wrongIDPTypeError struct { expected IDPType got string diff --git a/backend/v3/domain/settings.go b/backend/v3/domain/settings.go new file mode 100644 index 000000000..2edc06c2d --- /dev/null +++ b/backend/v3/domain/settings.go @@ -0,0 +1,430 @@ +package domain + +import ( + "context" + "net/url" + "time" + + "github.com/zitadel/zitadel/backend/v3/storage/database" + db_json "github.com/zitadel/zitadel/backend/v3/storage/database/json" +) + +//go:generate enumer -type SettingType -transform snake -trimprefix SettingType -sql +type SettingType uint8 + +const ( + SettingTypeLogin SettingType = iota + SettingTypeBranding + SettingTypePasswordComplexity + SettingTypePasswordExpiry + SettingTypeDomain + SettingTypeLockout + SettingTypeSecurity + SettingTypeOrganization + SettingTypeNotification + SettingTypeLegalAndSupport +) + +//go:generate enumer -type SettingState -transform snake -trimprefix SettingState -sql +type SettingState int32 + +const ( + SettingStateActive SettingState = iota + SettingStatePreview +) + +type Settings struct { + ID string `json:"id,omitempty" db:"id"` + InstanceID string `json:"instanceId,omitempty" db:"instance_id"` + OrganizationID *string `json:"organizationId,omitempty" db:"organization_id"` + Type SettingType `json:"type,omitempty" db:"type"` + State SettingState `json:"state,omitempty" db:"state"` + Settings []byte `json:"settings,omitempty" db:"settings"` + CreatedAt time.Time `json:"createdAt,omitzero" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt,omitzero" db:"updated_at"` +} + +type settingsColumns interface { + IDColumn() database.Column + InstanceIDColumn() database.Column + OrganizationIDColumn() database.Column + TypeColumn() database.Column + StateColumn() database.Column + SettingsColumn() database.Column + CreatedAtColumn() database.Column + UpdatedAtColumn() database.Column + PrimaryKeyColumns() []database.Column + UniqueColumns() []database.Column +} + +type settingsConditions interface { + InstanceIDCondition(id string) database.Condition + OrganizationIDCondition(id *string) database.Condition + IDCondition(id string) database.Condition + PrimaryKeyCondition(instanceID, id string) database.Condition + UniqueCondition(instanceID string, orgID *string, typ SettingType, state SettingState) database.Condition +} + +type settingsChanges interface { + // SetSettings(settings string) database.Change + SetUpdatedAt(updatedAt *time.Time) database.Change +} + +type settingsRepository[T any] interface { + settingsColumns + settingsConditions + settingsChanges + + Get(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) (*T, error) + List(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*T, error) + + Set(ctx context.Context, client database.QueryExecutor, settings *T) error + Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition) (int64, error) +} + +type PasswordlessType int32 + +const ( + PasswordlessTypeNotAllowed PasswordlessType = iota + PasswordlessTypeAllowed +) + +type MultiFactorType int32 + +const ( + MultiFactorTypeUnspecified MultiFactorType = iota + MultiFactorTypeU2FWithPIN +) + +type SecondFactorType int32 + +const ( + SecondFactorTypeUnspecified SecondFactorType = iota + SecondFactorTypeTOTP + SecondFactorTypeU2F + SecondFactorTypeOTPEmail + SecondFactorTypeOTPSMS +) + +type loginSettingsJSONChanges interface { + SetSettingFields(value LoginSettingsAttributes) database.Change + + SetAllowUserNamePassword(value bool) db_json.JsonUpdate + SetAllowRegister(value bool) db_json.JsonUpdate + SetAllowExternalIDP(value bool) db_json.JsonUpdate + SetForceMFA(value bool) db_json.JsonUpdate + SetForceMFALocalOnly(value bool) db_json.JsonUpdate + SetHidePasswordReset(value bool) db_json.JsonUpdate + SetIgnoreUnknownUsernames(value bool) db_json.JsonUpdate + SetAllowDomainDiscovery(value bool) db_json.JsonUpdate + SetDisableLoginWithEmail(value bool) db_json.JsonUpdate + SetDisableLoginWithPhone(value bool) db_json.JsonUpdate + SetPasswordlessType(value PasswordlessType) db_json.JsonUpdate + SetDefaultRedirectURI(value string) db_json.JsonUpdate + SetPasswordCheckLifetime(value time.Duration) db_json.JsonUpdate + SetExternalLoginCheckLifetime(value time.Duration) db_json.JsonUpdate + SetMFAInitSkipLifetime(value time.Duration) db_json.JsonUpdate + SetSecondFactorCheckLifetime(value time.Duration) db_json.JsonUpdate + SetMultiFactorCheckLifetime(value time.Duration) db_json.JsonUpdate + + AddMFAType(value MultiFactorType) database.Change + RemoveMFAType(value MultiFactorType) database.Change + AddSecondFactorTypes(value SecondFactorType) database.Change + RemoveSecondFactorTypes(value SecondFactorType) database.Change +} + +type LoginSettings struct { + Settings + LoginSettingsAttributes +} + +type LoginSettingsAttributes struct { + AllowUserNamePassword *bool `json:"allowUsernamePassword,omitempty"` + AllowRegister *bool `json:"allowRegister,omitempty"` + AllowExternalIDP *bool `json:"allowExternalIdp,omitempty"` + ForceMFA *bool `json:"forceMfa,omitempty"` + ForceMFALocalOnly *bool `json:"forceMfaLocalOnly,omitempty"` + HidePasswordReset *bool `json:"hidePasswordReset,omitempty"` + IgnoreUnknownUsernames *bool `json:"ignoreUnknownUsernames,omitempty"` + AllowDomainDiscovery *bool `json:"allowDomainDiscovery,omitempty"` + DisableLoginWithEmail *bool `json:"disableLoginWithEmail,omitempty"` + DisableLoginWithPhone *bool `json:"disableLoginWithPhone,omitempty"` + PasswordlessType *PasswordlessType `json:"passwordlessType,omitempty"` + DefaultRedirectURI *string `json:"defaultRedirectUri,omitempty"` + PasswordCheckLifetime *time.Duration `json:"passwordCheckLifetime,omitempty"` + ExternalLoginCheckLifetime *time.Duration `json:"externalLoginCheckLifetime,omitempty"` + MFAInitSkipLifetime *time.Duration `json:"mfaInitSkipLifetime,omitempty"` + SecondFactorCheckLifetime *time.Duration `json:"secondFactorCheckLifetime,omitempty"` + MultiFactorCheckLifetime *time.Duration `json:"multiFactorCheckLifetime,omitempty"` + + MFAType []MultiFactorType `json:"mfaType,omitempty"` + SecondFactorTypes []SecondFactorType `json:"secondFactors,omitempty"` +} + +type LoginSettingsRepository interface { + settingsRepository[LoginSettings] + loginSettingsJSONChanges + + SetColumns(ctx context.Context, client database.QueryExecutor, settings *Settings, changes ...database.Change) error +} + +type BrandingPolicyThemeMode int32 + +const ( + BrandingPolicyThemeAuto BrandingPolicyThemeMode = iota + BrandingPolicyThemeLight + BrandingPolicyThemeDark +) + +type brandingSettingsJSONChanges interface { + SetSettingFields(value BrandingSettingsAttributes) database.Change + + SetPrimaryColorLight(value string) db_json.JsonUpdate + SetBackgroundColorLight(value string) db_json.JsonUpdate + SetWarnColorLight(value string) db_json.JsonUpdate + SetFontColorLight(value string) db_json.JsonUpdate + SetLogoURLLight(value url.URL) db_json.JsonUpdate + SetIconURLLight(value url.URL) db_json.JsonUpdate + + SetPrimaryColorDark(value string) db_json.JsonUpdate + SetBackgroundColorDark(value string) db_json.JsonUpdate + SetWarnColorDark(value string) db_json.JsonUpdate + SetFontColorDark(value string) db_json.JsonUpdate + SetLogoURLDark(value url.URL) db_json.JsonUpdate + SetIconURLDark(value url.URL) db_json.JsonUpdate + + SetHideLoginNameSuffix(value bool) db_json.JsonUpdate + SetErrorMsgPopup(value bool) db_json.JsonUpdate + SetDisableWatermark(value bool) db_json.JsonUpdate + SetThemeMode(value BrandingPolicyThemeMode) db_json.JsonUpdate + SetFontURL(value url.URL) db_json.JsonUpdate +} + +type BrandingSettings struct { + Settings + BrandingSettingsAttributes +} + +type BrandingSettingsAttributes struct { + PrimaryColorLight *string `json:"primaryColorLight,omitempty"` + BackgroundColorLight *string `json:"backgroundColorLight,omitempty"` + WarnColorLight *string `json:"warnColorLight,omitempty"` + FontColorLight *string `json:"fontColorLight,omitempty"` + LogoURLLight *url.URL `json:"logoUrlLight,omitempty"` + IconURLLight *url.URL `json:"iconUrlLight,omitempty"` + + PrimaryColorDark *string `json:"primaryColorDark,omitempty"` + BackgroundColorDark *string `json:"backgroundColorDark,omitempty"` + WarnColorDark *string `json:"warnColorDark,omitempty"` + FontColorDark *string `json:"fontColorDark,omitempty"` + LogoURLDark *url.URL `json:"logoUrlDark,omitempty"` + IconURLDark *url.URL `json:"iconUrlDark,omitempty"` + + HideLoginNameSuffix *bool `json:"hideLoginNameSuffix,omitempty"` + ErrorMsgPopup *bool `json:"errorMsgPopup,omitempty"` + DisableWatermark *bool `json:"disableMsgPopup,omitempty"` + ThemeMode *BrandingPolicyThemeMode `json:"themeMode,omitempty"` + FontURL *url.URL `json:"fontUrl,omitempty"` +} + +type BrandingSettingsRepository interface { + settingsRepository[BrandingSettings] + brandingSettingsJSONChanges + + StateCondition(typ SettingState) database.Condition + Activate(ctx context.Context, client database.QueryExecutor, condition database.Condition) (int64, error) + ActivateAt(ctx context.Context, client database.QueryExecutor, condition database.Condition, updatedAt time.Time) (int64, error) + SetColumns(ctx context.Context, client database.QueryExecutor, settings *Settings, changes ...database.Change) error +} + +type passwordComplexitySettingsJSONChanges interface { + SetSettingFields(value PasswordComplexitySettingsAttributes) database.Change + + SetMinLength(value uint64) db_json.JsonUpdate + SetHasLowercase(value bool) db_json.JsonUpdate + SetHasUppercase(value bool) db_json.JsonUpdate + SetHasNumber(value bool) db_json.JsonUpdate + SetHasSymbol(value bool) db_json.JsonUpdate +} + +type PasswordComplexitySettings struct { + Settings + PasswordComplexitySettingsAttributes +} + +type PasswordComplexitySettingsAttributes struct { + MinLength *uint64 `json:"minLength,omitempty"` + HasLowercase *bool `json:"hasLowercase,omitempty"` + HasUppercase *bool `json:"hasUppercase,omitempty"` + HasNumber *bool `json:"hasNumber,omitempty"` + HasSymbol *bool `json:"hasSymbol,omitempty"` +} + +type PasswordComplexitySettingsRepository interface { + settingsRepository[PasswordComplexitySettings] + passwordComplexitySettingsJSONChanges +} + +type passwordExpirySettingsJSONChanges interface { + SetSettingFields(value PasswordExpirySettingsAttributes) database.Change + + SetExpireWarnDays(value uint64) db_json.JsonUpdate + SetMaxAgeDays(value uint64) db_json.JsonUpdate +} + +type PasswordExpirySettings struct { + Settings + PasswordExpirySettingsAttributes +} + +type PasswordExpirySettingsAttributes struct { + ExpireWarnDays *uint64 `json:"expireWarnDays,omitempty"` + MaxAgeDays *uint64 `json:"maxAgeDays,omitempty"` +} + +type PasswordExpirySettingsRepository interface { + settingsRepository[PasswordExpirySettings] + passwordExpirySettingsJSONChanges +} + +type lockoutSettingsJSONChanges interface { + SetSettingFields(value LockoutSettingsAttributes) database.Change + + SetMaxPasswordAttempts(value uint64) db_json.JsonUpdate + SetMaxOTPAttempts(value uint64) db_json.JsonUpdate + SetShowLockOutFailures(value bool) db_json.JsonUpdate +} + +type LockoutSettings struct { + Settings + LockoutSettingsAttributes +} + +type LockoutSettingsAttributes struct { + MaxPasswordAttempts *uint64 `json:"maxPasswordAttempts,omitempty"` + MaxOTPAttempts *uint64 `json:"maxOtpAttempts,omitempty"` + ShowLockOutFailures *bool `json:"showLockOutFailures,omitempty"` +} + +type LockoutSettingsRepository interface { + settingsRepository[LockoutSettings] + lockoutSettingsJSONChanges +} + +type securitySettingsJSONChanges interface { + SetSettingFields(value SecuritySettingsAttributes) database.Change + + SetEnableIframeEmbedding(value bool) db_json.JsonUpdate + SetAllowedOrigins(values []string) db_json.JsonUpdate + SetEnableImpersonation(value bool) db_json.JsonUpdate +} + +type SecuritySettings struct { + Settings + SecuritySettingsAttributes +} + +type SecuritySettingsAttributes struct { + EnableIframeEmbedding *bool `json:"enableIframeEmbedding,omitempty"` + AllowedOrigins []string `json:"allowedOrigins,omitempty"` + EnableImpersonation *bool `json:"enableImpersonation,omitempty"` +} + +type SecuritySettingsRepository interface { + settingsRepository[SecuritySettings] + securitySettingsJSONChanges +} + +type domainSettingsJSONChanges interface { + SetSettingFields(value DomainSettingsAttributes) database.Change + + SetLoginNameIncludesDomain(value bool) db_json.JsonUpdate + SetRequireOrgDomainVerification(value bool) db_json.JsonUpdate + SetSMTPSenderAddressMatchesInstanceDomain(value bool) db_json.JsonUpdate +} + +type DomainSettings struct { + Settings + DomainSettingsAttributes +} +type DomainSettingsAttributes struct { + LoginNameIncludesDomain *bool `json:"loginNameIncludesDomain,omitempty"` + RequireOrgDomainVerification *bool `json:"requireOrgDomainVerification,omitempty"` + SMTPSenderAddressMatchesInstanceDomain *bool `json:"smtpSenderAddressMatchesInstanceDomain,omitempty"` +} + +type DomainSettingsRepository interface { + settingsRepository[DomainSettings] + domainSettingsJSONChanges +} + +type organizationSettingsJSONChanges interface { + SetSettingFields(value OrganizationSettingsAttributes) database.Change + + SetOrganizationScopedUsernames(value bool) db_json.JsonUpdate +} + +type OrganizationSettings struct { + Settings + OrganizationSettingsAttributes +} +type OrganizationSettingsAttributes struct { + OrganizationScopedUsernames *bool `json:"organizationScopedUsernames,omitempty"` +} + +type OrganizationSettingsRepository interface { + settingsRepository[OrganizationSettings] + organizationSettingsJSONChanges +} + +type notificationSettingsJSONChanges interface { + SetSettingFields(value NotificationSettingsAttributes) database.Change + + SetPasswordChange(value bool) db_json.JsonUpdate +} + +type NotificationSettings struct { + Settings + NotificationSettingsAttributes +} + +type NotificationSettingsAttributes struct { + PasswordChange *bool `json:"passwordChange,omitempty"` +} + +type NotificationSettingsRepository interface { + settingsRepository[NotificationSettings] + notificationSettingsJSONChanges +} + +type legalAndSupportSettingsJSONChanges interface { + SetSettingFields(value LegalAndSupportSettingsAttributes) database.Change + + SetTOSLink(value string) db_json.JsonUpdate + SetPrivacyPolicyLink(value string) db_json.JsonUpdate + SetHelpLink(value string) db_json.JsonUpdate + SetSupportEmail(value string) db_json.JsonUpdate + SetDocsLink(value string) db_json.JsonUpdate + SetCustomLink(value string) db_json.JsonUpdate + SetCustomLinkText(value string) db_json.JsonUpdate +} + +type LegalAndSupportSettings struct { + Settings + LegalAndSupportSettingsAttributes +} + +type LegalAndSupportSettingsAttributes struct { + TOSLink *string `json:"tosLink,omitempty"` + PrivacyPolicyLink *string `json:"privacyPolicyLink,omitempty"` + HelpLink *string `json:"helpLink,omitempty"` + SupportEmail *string `json:"supportEmail,omitempty"` + DocsLink *string `json:"docsLink,omitempty"` + CustomLink *string `json:"customLink,omitempty"` + CustomLinkText *string `json:"customLinkText,omitempty"` +} + +type LegalAndSupportSettingsRepository interface { + settingsRepository[LegalAndSupportSettings] + legalAndSupportSettingsJSONChanges +} diff --git a/backend/v3/domain/settingstate_enumer.go b/backend/v3/domain/settingstate_enumer.go new file mode 100644 index 000000000..e86c0045c --- /dev/null +++ b/backend/v3/domain/settingstate_enumer.go @@ -0,0 +1,109 @@ +// Code generated by "enumer -type SettingState -transform snake -trimprefix SettingState -sql"; DO NOT EDIT. + +package domain + +import ( + "database/sql/driver" + "fmt" + "strings" +) + +const _SettingStateName = "activepreview" + +var _SettingStateIndex = [...]uint8{0, 6, 13} + +const _SettingStateLowerName = "activepreview" + +func (i SettingState) String() string { + if i < 0 || i >= SettingState(len(_SettingStateIndex)-1) { + return fmt.Sprintf("SettingState(%d)", i) + } + return _SettingStateName[_SettingStateIndex[i]:_SettingStateIndex[i+1]] +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _SettingStateNoOp() { + var x [1]struct{} + _ = x[SettingStateActive-(0)] + _ = x[SettingStatePreview-(1)] +} + +var _SettingStateValues = []SettingState{SettingStateActive, SettingStatePreview} + +var _SettingStateNameToValueMap = map[string]SettingState{ + _SettingStateName[0:6]: SettingStateActive, + _SettingStateLowerName[0:6]: SettingStateActive, + _SettingStateName[6:13]: SettingStatePreview, + _SettingStateLowerName[6:13]: SettingStatePreview, +} + +var _SettingStateNames = []string{ + _SettingStateName[0:6], + _SettingStateName[6:13], +} + +// SettingStateString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func SettingStateString(s string) (SettingState, error) { + if val, ok := _SettingStateNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _SettingStateNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to SettingState values", s) +} + +// SettingStateValues returns all values of the enum +func SettingStateValues() []SettingState { + return _SettingStateValues +} + +// SettingStateStrings returns a slice of all String values of the enum +func SettingStateStrings() []string { + strs := make([]string, len(_SettingStateNames)) + copy(strs, _SettingStateNames) + return strs +} + +// IsASettingState returns "true" if the value is listed in the enum definition. "false" otherwise +func (i SettingState) IsASettingState() bool { + for _, v := range _SettingStateValues { + if i == v { + return true + } + } + return false +} + +func (i SettingState) Value() (driver.Value, error) { + return i.String(), nil +} + +func (i *SettingState) Scan(value interface{}) error { + if value == nil { + return nil + } + + var str string + switch v := value.(type) { + case []byte: + str = string(v) + case string: + str = v + case fmt.Stringer: + str = v.String() + default: + return fmt.Errorf("invalid value of SettingState: %[1]T(%[1]v)", value) + } + + val, err := SettingStateString(str) + if err != nil { + return err + } + + *i = val + return nil +} diff --git a/backend/v3/domain/settingtype_enumer.go b/backend/v3/domain/settingtype_enumer.go new file mode 100644 index 000000000..cb668be68 --- /dev/null +++ b/backend/v3/domain/settingtype_enumer.go @@ -0,0 +1,141 @@ +// Code generated by "enumer -type SettingType -transform snake -trimprefix SettingType -sql"; DO NOT EDIT. + +package domain + +import ( + "database/sql/driver" + "fmt" + "strings" +) + +const _SettingTypeName = "loginbrandingpassword_complexitypassword_expirydomainlockoutsecurityorganizationnotificationlegal_and_support" + +var _SettingTypeIndex = [...]uint8{0, 5, 13, 32, 47, 53, 60, 68, 80, 92, 109} + +const _SettingTypeLowerName = "loginbrandingpassword_complexitypassword_expirydomainlockoutsecurityorganizationnotificationlegal_and_support" + +func (i SettingType) String() string { + if i >= SettingType(len(_SettingTypeIndex)-1) { + return fmt.Sprintf("SettingType(%d)", i) + } + return _SettingTypeName[_SettingTypeIndex[i]:_SettingTypeIndex[i+1]] +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _SettingTypeNoOp() { + var x [1]struct{} + _ = x[SettingTypeLogin-(0)] + _ = x[SettingTypeBranding-(1)] + _ = x[SettingTypePasswordComplexity-(2)] + _ = x[SettingTypePasswordExpiry-(3)] + _ = x[SettingTypeDomain-(4)] + _ = x[SettingTypeLockout-(5)] + _ = x[SettingTypeSecurity-(6)] + _ = x[SettingTypeOrganization-(7)] + _ = x[SettingTypeNotification-(8)] + _ = x[SettingTypeLegalAndSupport-(9)] +} + +var _SettingTypeValues = []SettingType{SettingTypeLogin, SettingTypeBranding, SettingTypePasswordComplexity, SettingTypePasswordExpiry, SettingTypeDomain, SettingTypeLockout, SettingTypeSecurity, SettingTypeOrganization, SettingTypeNotification, SettingTypeLegalAndSupport} + +var _SettingTypeNameToValueMap = map[string]SettingType{ + _SettingTypeName[0:5]: SettingTypeLogin, + _SettingTypeLowerName[0:5]: SettingTypeLogin, + _SettingTypeName[5:13]: SettingTypeBranding, + _SettingTypeLowerName[5:13]: SettingTypeBranding, + _SettingTypeName[13:32]: SettingTypePasswordComplexity, + _SettingTypeLowerName[13:32]: SettingTypePasswordComplexity, + _SettingTypeName[32:47]: SettingTypePasswordExpiry, + _SettingTypeLowerName[32:47]: SettingTypePasswordExpiry, + _SettingTypeName[47:53]: SettingTypeDomain, + _SettingTypeLowerName[47:53]: SettingTypeDomain, + _SettingTypeName[53:60]: SettingTypeLockout, + _SettingTypeLowerName[53:60]: SettingTypeLockout, + _SettingTypeName[60:68]: SettingTypeSecurity, + _SettingTypeLowerName[60:68]: SettingTypeSecurity, + _SettingTypeName[68:80]: SettingTypeOrganization, + _SettingTypeLowerName[68:80]: SettingTypeOrganization, + _SettingTypeName[80:92]: SettingTypeNotification, + _SettingTypeLowerName[80:92]: SettingTypeNotification, + _SettingTypeName[92:109]: SettingTypeLegalAndSupport, + _SettingTypeLowerName[92:109]: SettingTypeLegalAndSupport, +} + +var _SettingTypeNames = []string{ + _SettingTypeName[0:5], + _SettingTypeName[5:13], + _SettingTypeName[13:32], + _SettingTypeName[32:47], + _SettingTypeName[47:53], + _SettingTypeName[53:60], + _SettingTypeName[60:68], + _SettingTypeName[68:80], + _SettingTypeName[80:92], + _SettingTypeName[92:109], +} + +// SettingTypeString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func SettingTypeString(s string) (SettingType, error) { + if val, ok := _SettingTypeNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _SettingTypeNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to SettingType values", s) +} + +// SettingTypeValues returns all values of the enum +func SettingTypeValues() []SettingType { + return _SettingTypeValues +} + +// SettingTypeStrings returns a slice of all String values of the enum +func SettingTypeStrings() []string { + strs := make([]string, len(_SettingTypeNames)) + copy(strs, _SettingTypeNames) + return strs +} + +// IsASettingType returns "true" if the value is listed in the enum definition. "false" otherwise +func (i SettingType) IsASettingType() bool { + for _, v := range _SettingTypeValues { + if i == v { + return true + } + } + return false +} + +func (i SettingType) Value() (driver.Value, error) { + return i.String(), nil +} + +func (i *SettingType) Scan(value interface{}) error { + if value == nil { + return nil + } + + var str string + switch v := value.(type) { + case []byte: + str = string(v) + case string: + str = v + case fmt.Stringer: + str = v.String() + default: + return fmt.Errorf("invalid value of SettingType: %[1]T(%[1]v)", value) + } + + val, err := SettingTypeString(str) + if err != nil { + return err + } + + *i = val + return nil +} diff --git a/backend/v3/storage/database/change.go b/backend/v3/storage/database/change.go index 30d6dbf5e..849dd90d7 100644 --- a/backend/v3/storage/database/change.go +++ b/backend/v3/storage/database/change.go @@ -12,7 +12,7 @@ import ( type Change interface { gomock.Matcher // Write writes the change to the given statement builder. - Write(builder *StatementBuilder) + Write(builder *StatementBuilder) error // IsOnColumn checks if the change is on the given column. IsOnColumn(col Column) bool } @@ -62,10 +62,11 @@ func NewChangePtr[V Value](col Column, value *V) Change { } // Write implements [Change]. -func (c change[V]) Write(builder *StatementBuilder) { +func (c change[V]) Write(builder *StatementBuilder) error { c.column.WriteUnqualified(builder) builder.WriteString(" = ") builder.WriteArg(c.value) + return nil } // IsOnColumn implements [Change]. @@ -87,13 +88,17 @@ func (c Changes) IsOnColumn(col Column) bool { } // Write implements [Change]. -func (m Changes) Write(builder *StatementBuilder) { +func (m Changes) Write(builder *StatementBuilder) error { for i, change := range m { if i > 0 { builder.WriteString(", ") } - change.Write(builder) + err := change.Write(builder) + if err != nil { + return err + } } + return nil } // Matches implements [gomock.Matcher]. @@ -153,10 +158,11 @@ func (c *changeToColumn) String() string { } // Write implements [Change]. -func (c *changeToColumn) Write(builder *StatementBuilder) { +func (c *changeToColumn) Write(builder *StatementBuilder) error { c.to.WriteUnqualified(builder) builder.WriteString(" = ") c.from.WriteQualified(builder) + return nil } var _ Change = (*changeToColumn)(nil) @@ -191,11 +197,12 @@ func (i *incrementColumnChange) String() string { } // Write implements [Change]. -func (i *incrementColumnChange) Write(builder *StatementBuilder) { +func (i *incrementColumnChange) Write(builder *StatementBuilder) error { i.column.WriteUnqualified(builder) builder.WriteString(" = ") i.column.WriteUnqualified(builder) builder.WriteString(" + 1") + return nil } var _ Change = (*incrementColumnChange)(nil) @@ -242,7 +249,7 @@ func (c *changeToStatement) String() string { } // Write implements [Change]. -func (c *changeToStatement) Write(builder *StatementBuilder) { +func (c *changeToStatement) Write(builder *StatementBuilder) error { _, ok := c.column.(Columns) if ok { builder.WriteRune('(') @@ -254,6 +261,7 @@ func (c *changeToStatement) Write(builder *StatementBuilder) { builder.WriteString(" = (") c.stmt(builder) builder.WriteString(")") + return nil } var _ Change = (*changeToStatement)(nil) @@ -322,11 +330,12 @@ func (c *cteChange) String() string { } // Write implements [CTEChange]. -func (c *cteChange) Write(builder *StatementBuilder) { +func (c *cteChange) Write(builder *StatementBuilder) error { if c.change == nil { - return + return nil } c.change(c.name).Write(builder) + return nil } // WriteCTE implements [CTEChange]. diff --git a/backend/v3/storage/database/change_test.go b/backend/v3/storage/database/change_test.go index eea71e036..beaf2aef9 100644 --- a/backend/v3/storage/database/change_test.go +++ b/backend/v3/storage/database/change_test.go @@ -64,7 +64,8 @@ func TestChangeWrite(t *testing.T) { } { t.Run(test.name, func(t *testing.T) { var builder StatementBuilder - test.change.Write(&builder) + err := test.change.Write(&builder) + require.NoError(t, err) assert.Equal(t, test.want.stmt, builder.String()) require.Len(t, builder.Args(), len(test.want.args)) for i, arg := range test.want.args { diff --git a/backend/v3/storage/database/condition.go b/backend/v3/storage/database/condition.go index 877c10601..b000018d0 100644 --- a/backend/v3/storage/database/condition.go +++ b/backend/v3/storage/database/condition.go @@ -157,9 +157,8 @@ func IsNull(column Column) *isNull { } // IsRestrictingColumn implements [Condition]. -// It returns false because it cannot be used for restricting a column. func (i isNull) IsRestrictingColumn(col Column) bool { - return false + return true } var _ Condition = (*isNull)(nil) @@ -194,9 +193,8 @@ func IsNotNull(column Column) *isNotNull { } // IsRestrictingColumn implements [Condition]. -// It returns false because it cannot be used for restricting a column. func (i isNotNull) IsRestrictingColumn(col Column) bool { - return false + return true } var _ Condition = (*isNotNull)(nil) diff --git a/backend/v3/storage/database/dialect/postgres/error.go b/backend/v3/storage/database/dialect/postgres/error.go index 9710c1e53..433db5adc 100644 --- a/backend/v3/storage/database/dialect/postgres/error.go +++ b/backend/v3/storage/database/dialect/postgres/error.go @@ -51,6 +51,9 @@ func wrapPgError(err *pgconn.PgError) error { // 23502: not_null_violation - A value violates a NOT NULL constraint. case "23502": return database.NewNotNullError(err.TableName, err.ConstraintName, err) + // 22P02: integrity violation - A value is being inserted into a enum that is not valid + case "22P02": + return database.NewCheckError(err.TableName, err.ConstraintName, err) default: return database.NewUnknownError(err) } diff --git a/backend/v3/storage/database/dialect/postgres/migration/009_settings_table.go b/backend/v3/storage/database/dialect/postgres/migration/009_settings_table.go new file mode 100644 index 000000000..653ebd2f2 --- /dev/null +++ b/backend/v3/storage/database/dialect/postgres/migration/009_settings_table.go @@ -0,0 +1,16 @@ +package migration + +import ( + _ "embed" +) + +var ( + //go:embed 009_settings_table/up.sql + up009SettingsTable string + //go:embed 009_settings_table/down.sql + down009SettingsTable string +) + +func init() { + registerSQLMigration(9, up009SettingsTable, down009SettingsTable) +} diff --git a/backend/v3/storage/database/dialect/postgres/migration/009_settings_table/down.sql b/backend/v3/storage/database/dialect/postgres/migration/009_settings_table/down.sql new file mode 100644 index 000000000..cd15acc90 --- /dev/null +++ b/backend/v3/storage/database/dialect/postgres/migration/009_settings_table/down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS zitadel.settings; +DROP TYPE IF EXISTS zitadel.settings_type; +DROP TYPE IF EXISTS zitadel.settings_state; diff --git a/backend/v3/storage/database/dialect/postgres/migration/009_settings_table/up.sql b/backend/v3/storage/database/dialect/postgres/migration/009_settings_table/up.sql new file mode 100644 index 000000000..17b16fd92 --- /dev/null +++ b/backend/v3/storage/database/dialect/postgres/migration/009_settings_table/up.sql @@ -0,0 +1,95 @@ +CREATE TYPE zitadel.settings_type AS ENUM ( + 'login', + 'branding', + 'password_complexity', + 'password_expiry', + 'domain', + 'lockout', + 'security', + 'organization', + 'notification', + 'legal_and_support' +); + +CREATE TYPE zitadel.settings_state AS ENUM ( + 'active', + 'preview' +); + +CREATE TABLE zitadel.settings ( + instance_id TEXT NOT NULL + , organization_id TEXT + , id TEXT NOT NULL DEFAULT gen_random_uuid() + , type zitadel.settings_type NOT NULL + , state zitadel.settings_state NOT NULL + , settings JSONB -- the storage does not really care about what is configured so we store it as json + + , created_at TIMESTAMPTZ NOT NULL DEFAULT now() + , updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + + , PRIMARY KEY (instance_id, id, type, state) + , FOREIGN KEY (instance_id) REFERENCES zitadel.instances(id) ON DELETE CASCADE + , FOREIGN KEY (instance_id, organization_id) REFERENCES zitadel.organizations(instance_id, id) ON DELETE CASCADE + + , UNIQUE (instance_id, organization_id, type, state) +); + +CREATE UNIQUE INDEX idx_settings_unique_type ON zitadel.settings (instance_id, organization_id, type, state) NULLS NOT DISTINCT; + +CREATE TRIGGER trigger_set_updated_at +BEFORE UPDATE ON zitadel.settings +FOR EACH ROW +WHEN (NEW.updated_at IS NULL) +EXECUTE FUNCTION zitadel.set_updated_at(); + +CREATE OR REPLACE FUNCTION zitadel.jsonb_array_remove( + source JSONB + , path TEXT[] + , value anyelement + + , outt out jsonb +) + language 'plpgsql' +AS $$ + BEGIN + outt = jsonb_set(source, path, + (CASE WHEN (SELECT source ?& path) then + coalesce( + (SELECT jsonb_agg(v) + FROM jsonb_array_elements(source #>path) AS elem(v) + WHERE v::text <> value::text), + jsonb_build_array()) + ELSe + jsonb_build_array() + END)::jsonb, TRUE); + END; +$$; + +CREATE OR REPLACE FUNCTION zitadel.jsonb_array_append( + source jsonb + , path text[] + , value anyelement + + , outt out jsonb +) + language 'plpgsql' +AS $$ + BEGIN + outt := jsonb_insert( + source, + (CASE WHEN (SELECT source ?& path) then + array_append(path, '-1') + ELSE + path + END)::text[], + (CASE WHEN (select source ?& path) then + value::TEXT::jsonb + ELSE + jsonb_build_array(value) + END), + TRUE + ); + + END; +$$; + diff --git a/backend/v3/storage/database/events_testing/font.otf b/backend/v3/storage/database/events_testing/font.otf new file mode 100644 index 0000000000000000000000000000000000000000..9651a3f8a995fe3314cd0b8f28bb5a227a8ba4db GIT binary patch literal 354856 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 Lg&8lli?9D5!daQC literal 0 HcmV?d00001 diff --git a/backend/v3/storage/database/events_testing/picture.png b/backend/v3/storage/database/events_testing/picture.png new file mode 100644 index 000000000..0d67cacb4 --- /dev/null +++ b/backend/v3/storage/database/events_testing/picture.png @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/v3/storage/database/events_testing/settings_instance_test.go b/backend/v3/storage/database/events_testing/settings_instance_test.go new file mode 100644 index 000000000..87a9ce595 --- /dev/null +++ b/backend/v3/storage/database/events_testing/settings_instance_test.go @@ -0,0 +1,1389 @@ +//go:build integration + +package events_test + +import ( + "bytes" + "context" + _ "embed" + "io" + "mime/multipart" + "net/http" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/metadata" + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/zitadel/zitadel/backend/v3/domain" + "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/backend/v3/storage/database/repository" + http_util "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/admin" + instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/policy" +) + +//go:embed picture.png +var picture []byte + +//go:embed font.otf +var font []byte + +func uploadInstanceAsset(ctx context.Context, t *testing.T, instance *integration.Instance, path string, asset io.Reader) { + url := http_util.BuildOrigin(instance.Host(), instance.Config.Secure) + "/assets/v1" + path + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("file", "filename") + require.NoError(t, err) + + _, err = io.Copy(part, asset) + require.NoError(t, err) + + err = writer.Close() + require.NoError(t, err) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body) + require.NoError(t, err) + md, ok := metadata.FromOutgoingContext(ctx) + require.True(t, ok) + // context information has to be HTTP headers, as the asset API is only HTTP + req.Header.Set("authorization", md.Get("authorization")[0]) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestServer_TestInstanceLoginSettingsReduces(t *testing.T) { + t.Parallel() + + settingsRepo := repository.LoginSettingsRepository() + before := time.Now() + newInstance := integration.NewInstance(t.Context()) + after := time.Now() + + IAMCTX := newInstance.WithAuthorizationToken(t.Context(), integration.UserTypeIAMOwner) + + t.Run("test adding login settings reduces", func(t *testing.T) { + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeLogin, domain.SettingStateActive), + ), + ) + require.NoError(collect, err) + + // event instance.policy.login.added + // these values are found in default.yaml + assert.Equal(collect, true, *setting.AllowRegister) + assert.Equal(collect, true, *setting.AllowExternalIDP) + assert.Equal(collect, domain.PasswordlessTypeAllowed, *setting.PasswordlessType) + assert.Equal(collect, true, *setting.AllowDomainDiscovery) + assert.Equal(collect, true, *setting.AllowUserNamePassword) + assert.Equal(collect, time.Duration(time.Hour*240), *setting.PasswordCheckLifetime) + assert.Equal(collect, time.Duration(time.Hour*12), *setting.MultiFactorCheckLifetime) + assert.Equal(collect, time.Duration(time.Hour*18), *setting.SecondFactorCheckLifetime) + assert.Equal(collect, time.Duration(time.Hour*240), *setting.ExternalLoginCheckLifetime) + assert.WithinRange(collect, setting.CreatedAt, before, after) + assert.WithinRange(collect, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test login settings change reduces", func(t *testing.T) { + _, err := newInstance.Client.Admin.UpdateLoginPolicy(IAMCTX, &admin.UpdateLoginPolicyRequest{ + AllowUsernamePassword: false, + AllowRegister: false, + AllowExternalIdp: true, + ForceMfa: true, + PasswordlessType: policy.PasswordlessType_PASSWORDLESS_TYPE_NOT_ALLOWED, + HidePasswordReset: true, + IgnoreUnknownUsernames: true, + DefaultRedirectUri: "http://www.example.com", + PasswordCheckLifetime: durationpb.New(time.Second * 20 * 20), + ExternalLoginCheckLifetime: durationpb.New(time.Second * 20 * 21), + MfaInitSkipLifetime: durationpb.New(time.Second * 20 * 22), + SecondFactorCheckLifetime: durationpb.New(time.Second * 20 * 23), + MultiFactorCheckLifetime: durationpb.New(time.Second * 20 * 24), + AllowDomainDiscovery: false, + DisableLoginWithEmail: true, + DisableLoginWithPhone: true, + ForceMfaLocalOnly: true, + }) + require.NoError(t, err) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeLogin, domain.SettingStateActive), + ), + ) + require.NoError(collect, err) + + require.NotNil(collect, setting.ForceMFA) + + // event instance.policy.login.changed + assert.Equal(collect, false, *setting.AllowRegister) + assert.Equal(collect, true, *setting.AllowExternalIDP) + assert.Equal(collect, true, *setting.ForceMFA) + assert.Equal(collect, domain.PasswordlessTypeNotAllowed, *setting.PasswordlessType) + assert.Equal(collect, true, *setting.HidePasswordReset) + assert.Equal(collect, true, *setting.IgnoreUnknownUsernames) + assert.Equal(collect, "http://www.example.com", *setting.DefaultRedirectURI) + assert.Equal(collect, false, *setting.AllowDomainDiscovery) + assert.Equal(collect, false, *setting.AllowUserNamePassword) + assert.Equal(collect, time.Duration(time.Second*20*20), *setting.PasswordCheckLifetime) + assert.Equal(collect, time.Duration(time.Second*20*21), *setting.ExternalLoginCheckLifetime) + assert.Equal(collect, time.Duration(time.Second*20*22), *setting.MFAInitSkipLifetime) + assert.Equal(collect, time.Duration(time.Second*20*23), *setting.SecondFactorCheckLifetime) + assert.Equal(collect, time.Duration(time.Second*20*24), *setting.MultiFactorCheckLifetime) + assert.Equal(collect, true, *setting.DisableLoginWithEmail) + assert.Equal(collect, true, *setting.DisableLoginWithPhone) + assert.Equal(collect, true, *setting.ForceMFALocalOnly) + assert.WithinRange(collect, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test added/remove login multifactor type reduces", func(t *testing.T) { + // check inital MFAType value + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeLogin, domain.SettingStateActive), + ), + ) + require.NoError(collect, err) + + assert.Equal(collect, []domain.MultiFactorType{domain.MultiFactorType(policy.MultiFactorType_MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION)}, setting.MFAType) + }, retryDuration, tick) + + // remove MFAType + _, err := newInstance.Client.Admin.RemoveMultiFactorFromLoginPolicy(IAMCTX, &admin.RemoveMultiFactorFromLoginPolicyRequest{ + Type: policy.MultiFactorType_MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION, + }) + require.NoError(t, err) + + retryDuration, tick = integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeLogin, domain.SettingStateActive), + ), + ) + require.NoError(collect, err) + + // event instance.policy.login.multifactor.remove + assert.Equal(collect, []domain.MultiFactorType{}, setting.MFAType) + }, retryDuration, tick) + + before := time.Now() + _, err = newInstance.Client.Admin.AddMultiFactorToLoginPolicy(IAMCTX, &admin.AddMultiFactorToLoginPolicyRequest{ + Type: policy.MultiFactorType_MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION, + }) + require.NoError(t, err) + after := time.Now() + + // add MFAType + retryDuration, tick = integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeLogin, domain.SettingStateActive), + ), + ) + require.NoError(collect, err) + + // event instance.policy.login.multifactor.added + assert.Equal(collect, []domain.MultiFactorType{domain.MultiFactorType(policy.MultiFactorType_MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION)}, setting.MFAType) + assert.WithinRange(collect, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test added/removed second multifactor reduces", func(t *testing.T) { + before := time.Now() + // get current second factor types + var secondFactorTypes []domain.SecondFactorType + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeLogin, domain.SettingStateActive), + ), + ) + require.NoError(collect, err) + + secondFactorTypes = setting.SecondFactorTypes + }, retryDuration, tick) + + // add new second factor type + before = time.Now() + _, err := newInstance.Client.Admin.AddSecondFactorToLoginPolicy(IAMCTX, &admin.AddSecondFactorToLoginPolicyRequest{ + Type: policy.SecondFactorType_SECOND_FACTOR_TYPE_OTP_SMS, + }) + require.NoError(t, err) + after := time.Now() + + secondFactorTypes = append(secondFactorTypes, domain.SecondFactorType(policy.SecondFactorType_SECOND_FACTOR_TYPE_OTP_SMS)) + + // check new second factor type is added + retryDuration, tick = integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeLogin, domain.SettingStateActive), + ), + ) + require.NoError(collect, err) + + // event instance.policy.login.multifactor.secondfactor.added + assert.Equal(collect, secondFactorTypes, setting.SecondFactorTypes) + assert.WithinRange(collect, setting.UpdatedAt, before, after) + }, retryDuration, tick) + + // remove second factor type + before = time.Now() + _, err = newInstance.Client.Admin.RemoveSecondFactorFromLoginPolicy(IAMCTX, &admin.RemoveSecondFactorFromLoginPolicyRequest{ + Type: policy.SecondFactorType_SECOND_FACTOR_TYPE_OTP_SMS, + }) + require.NoError(t, err) + after = time.Now() + + secondFactorTypes = secondFactorTypes[0 : len(secondFactorTypes)-1] + + // check new second factor type is removed + retryDuration, tick = integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeLogin, domain.SettingStateActive), + ), + ) + require.NoError(collect, err) + + // event instance.policy.login.multifactor.secondfactor.removed + assert.Equal(collect, secondFactorTypes, setting.SecondFactorTypes) + assert.WithinRange(collect, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test delete instance reduces", func(t *testing.T) { + // check login settings exist + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeLogin, domain.SettingStateActive), + ), + ) + require.NoError(collect, err) + + require.NotNil(collect, setting) + }, retryDuration, tick) + + // delete instance + _, err := newInstance.Client.InstanceV2Beta.DeleteInstance(CTX, &instance.DeleteInstanceRequest{ + InstanceId: newInstance.ID(), + }) + require.NoError(t, err) + + // check login settings removed + retryDuration, tick = integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + _, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeLogin, domain.SettingStateActive), + ), + ) + + // event instance.removed + require.ErrorIs(collect, err, new(database.NoRowFoundError)) + }, retryDuration, tick) + }) +} + +func TestServer_TestInstanceLabelSettingsReduces(t *testing.T) { + t.Parallel() + + settingsRepo := repository.BrandingSettingsRepository() + + before := time.Now() + newInstance := integration.NewInstance(t.Context()) + after := time.Now() + + IAMCTX := newInstance.WithAuthorizationToken(t.Context(), integration.UserTypeIAMOwner) + + t.Run("test adding label settings reduces", func(t *testing.T) { + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeBranding, domain.SettingStatePreview), + ), + ) + require.NoError(collect, err) + + // event instance.policy.label.added + // these values are found in default.yaml + assert.Equal(collect, "#5469d4", *setting.PrimaryColorLight) + assert.Equal(collect, "#fafafa", *setting.BackgroundColorLight) + assert.Equal(collect, "#cd3d56", *setting.WarnColorLight) + assert.Equal(collect, "#000000", *setting.FontColorLight) + assert.Equal(collect, "#2073c4", *setting.PrimaryColorDark) + assert.Equal(collect, "#111827", *setting.BackgroundColorDark) + assert.Equal(collect, "#ff3b5b", *setting.WarnColorDark) + assert.Equal(collect, "#ff3b5b", *setting.WarnColorDark) + assert.Equal(collect, "#ffffff", *setting.FontColorDark) + assert.Equal(collect, false, *setting.HideLoginNameSuffix) + assert.Equal(collect, false, *setting.ErrorMsgPopup) + assert.Equal(collect, false, *setting.DisableWatermark) + assert.Equal(collect, domain.SettingStatePreview, setting.State) + assert.WithinRange(collect, setting.CreatedAt, before, after) + assert.WithinRange(collect, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test policy label change", func(t *testing.T) { + before := time.Now() + _, err := newInstance.Client.Admin.UpdateLabelPolicy(IAMCTX, &admin.UpdateLabelPolicyRequest{ + PrimaryColor: "#055000", + HideLoginNameSuffix: true, + WarnColor: "#055000", + BackgroundColor: "#055000", + FontColor: "#055000", + PrimaryColorDark: "#055000", + BackgroundColorDark: "#055000", + WarnColorDark: "#055000", + FontColorDark: "#055000", + DisableWatermark: true, + ThemeMode: policy.ThemeMode_THEME_MODE_LIGHT, + }) + require.NoError(t, err) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeBranding, domain.SettingStatePreview), + ), + ) + require.NoError(collect, err) + + // event instance.policy.label.change + assert.Equal(collect, "#055000", *setting.PrimaryColorLight) + assert.Equal(collect, "#055000", *setting.BackgroundColorLight) + assert.Equal(collect, "#055000", *setting.WarnColorLight) + assert.Equal(collect, "#055000", *setting.FontColorLight) + assert.Equal(collect, "#055000", *setting.PrimaryColorDark) + assert.Equal(collect, "#055000", *setting.BackgroundColorDark) + assert.Equal(collect, "#055000", *setting.WarnColorDark) + assert.Equal(collect, "#055000", *setting.WarnColorDark) + assert.Equal(collect, "#055000", *setting.FontColorDark) + assert.Equal(collect, true, *setting.HideLoginNameSuffix) + assert.Equal(collect, false, *setting.ErrorMsgPopup) + assert.Equal(collect, true, *setting.DisableWatermark) + assert.Equal(collect, domain.BrandingPolicyThemeLight, *setting.ThemeMode) + assert.Equal(collect, domain.SettingStatePreview, setting.State) + assert.WithinRange(collect, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test label settings activated", func(t *testing.T) { + // activate label + before := time.Now() + _, err := newInstance.Client.Admin.ActivateLabelPolicy(IAMCTX, &admin.ActivateLabelPolicyRequest{}) + require.NoError(t, err) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeBranding, domain.SettingStateActive), + ), + ) + require.NoError(collect, err) + + // event instance.policy.label.activated + assert.Equal(collect, domain.SettingStateActive, setting.State) + assert.WithinRange(collect, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test policy label logo light added", func(t *testing.T) { + // set logo light + before := time.Now() + uploadInstanceAsset(IAMCTX, t, newInstance, "/instance/policy/label/logo", bytes.NewReader(picture)) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeBranding, domain.SettingStatePreview), + ), + ) + require.NoError(collect, err) + + // event instance.policy.label.logo.added + assert.NotNil(collect, setting.LogoURLLight) + assert.Equal(collect, domain.SettingStatePreview, setting.State) + assert.WithinRange(collect, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test policy label logo light removed", func(t *testing.T) { + before := time.Now() + _, err := newInstance.Client.Admin.RemoveLabelPolicyLogo(IAMCTX, &admin.RemoveLabelPolicyLogoRequest{}) + require.NoError(t, err) + after := time.Now() + + // check light logo removed + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeBranding, domain.SettingStatePreview), + ), + ) + require.NoError(collect, err) + + // event instance.policy.label.logo.removed + assert.Equal(collect, url.URL{}, *setting.LogoURLLight) + assert.Equal(collect, domain.SettingStatePreview, setting.State) + assert.WithinRange(collect, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test policy label logo dark added", func(t *testing.T) { + before := time.Now() + uploadInstanceAsset(IAMCTX, t, newInstance, "/instance/policy/label/logo/dark", bytes.NewReader(picture)) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeBranding, domain.SettingStatePreview), + ), + ) + require.NoError(collect, err) + + // event instance.policy.label.logo.dark.added + assert.NotNil(collect, setting.LogoURLDark) + assert.Equal(collect, domain.SettingStatePreview, setting.State) + assert.WithinRange(collect, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test policy label logo dark removed", func(t *testing.T) { + before := time.Now() + _, err := newInstance.Client.Admin.RemoveLabelPolicyLogoDark(IAMCTX, &admin.RemoveLabelPolicyLogoDarkRequest{}) + require.NoError(t, err) + after := time.Now() + + // check dark logo removed + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeBranding, domain.SettingStatePreview), + ), + ) + require.NoError(collect, err) + + // event instance.policy.label.logo.dark.removed + assert.Equal(collect, url.URL{}, *setting.LogoURLDark) + assert.Equal(collect, domain.SettingStatePreview, setting.State) + assert.WithinRange(collect, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test policy label icon light added", func(t *testing.T) { + before := time.Now() + uploadInstanceAsset(IAMCTX, t, newInstance, "/instance/policy/label/icon", bytes.NewReader(picture)) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeBranding, domain.SettingStatePreview), + ), + ) + require.NoError(collect, err) + + // event instance.policy.label.icon.added + assert.NotNil(collect, setting.IconURLLight) + assert.Equal(collect, domain.SettingStatePreview, setting.State) + assert.WithinRange(collect, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test policy label icon dark added", func(t *testing.T) { + before := time.Now() + uploadInstanceAsset(IAMCTX, t, newInstance, "/instance/policy/label/icon/dark", bytes.NewReader(picture)) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeBranding, domain.SettingStatePreview), + ), + ) + require.NoError(collect, err) + + // event instance.policy.label.icon.dark.added + assert.NotNil(collect, setting.IconURLDark) + assert.Equal(collect, domain.SettingStatePreview, setting.State) + assert.WithinRange(collect, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test policy label icon light removed", func(t *testing.T) { + // remote logo light + before := time.Now() + _, err := newInstance.Client.Admin.RemoveLabelPolicyIcon(IAMCTX, &admin.RemoveLabelPolicyIconRequest{}) + require.NoError(t, err) + after := time.Now() + + // check light icon removed + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeBranding, domain.SettingStatePreview), + ), + ) + require.NoError(collect, err) + + // event instance.policy.label.icon.removed + assert.Equal(collect, url.URL{}, *setting.IconURLLight) + assert.Equal(collect, domain.SettingStatePreview, setting.State) + assert.WithinRange(collect, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test policy label icon dark removed", func(t *testing.T) { + before := time.Now() + _, err := newInstance.Client.Admin.RemoveLabelPolicyIconDark(IAMCTX, &admin.RemoveLabelPolicyIconDarkRequest{}) + require.NoError(t, err) + after := time.Now() + + // check dark icon removed + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeBranding, domain.SettingStatePreview), + ), + ) + require.NoError(collect, err) + + // event instance.policy.label.icon.dark.removed + assert.Equal(collect, url.URL{}, *setting.IconURLDark) + assert.Equal(collect, domain.SettingStatePreview, setting.State) + assert.WithinRange(collect, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test policy font added", func(t *testing.T) { + before := time.Now() + uploadInstanceAsset(IAMCTX, t, newInstance, "/instance/policy/label/font", bytes.NewReader(font)) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeBranding, domain.SettingStatePreview), + ), + ) + require.NoError(collect, err) + + // event instance.policy.label.font.added + assert.NotNil(collect, setting.FontURL) + assert.Equal(collect, domain.SettingStatePreview, setting.State) + assert.WithinRange(collect, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test policy font removed", func(t *testing.T) { + // remote logo light + before := time.Now() + _, err := newInstance.Client.Admin.RemoveLabelPolicyFont(IAMCTX, &admin.RemoveLabelPolicyFontRequest{}) + require.NoError(t, err) + after := time.Now() + + // check font policy removed + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeBranding, domain.SettingStatePreview), + ), + ) + require.NoError(collect, err) + + // event instance.policy.label.font.removed + assert.Equal(collect, url.URL{}, *setting.FontURL) + assert.Equal(collect, domain.SettingStatePreview, setting.State) + assert.WithinRange(collect, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test delete instance reduces", func(t *testing.T) { + // check label preview settings exist + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeBranding, domain.SettingStateActive), + ), + ) + require.NoError(collect, err) + + require.NotNil(collect, setting) + }, retryDuration, tick) + + // check label activated settings exist + retryDuration, tick = integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*50) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeBranding, domain.SettingStateActive), + ), + ) + require.NoError(collect, err) + + require.NotNil(collect, setting) + }, retryDuration, tick) + + // delete instance + _, err := newInstance.Client.InstanceV2Beta.DeleteInstance(CTX, &instance.DeleteInstanceRequest{ + InstanceId: newInstance.ID(), + }) + + // check label preview settings deleted + retryDuration, tick = integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + _, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeBranding, domain.SettingStateActive), + ), + ) + + require.ErrorIs(collect, err, new(database.NoRowFoundError)) + }, retryDuration, tick) + + // check label activated settings deleted + retryDuration, tick = integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + _, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeBranding, domain.SettingStateActive), + ), + ) + + require.ErrorIs(collect, err, new(database.NoRowFoundError)) + }, retryDuration, tick) + require.NoError(t, err) + }) +} + +func TestServer_TestInstancePasswordComplexitySettingsReduces(t *testing.T) { + t.Parallel() + + settingsRepo := repository.PasswordComplexitySettingsRepository() + + before := time.Now() + newInstance := integration.NewInstance(t.Context()) + after := time.Now() + + IAMCTX := newInstance.WithAuthorizationToken(t.Context(), integration.UserTypeIAMOwner) + t.Run("test password complexity added", func(t *testing.T) { + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypePasswordComplexity, domain.SettingStateActive), + ), + ) + require.NoError(collect, err) + + // event instance.policy.password.complexity.added + // these values are found in default.yaml + assert.Equal(collect, uint64(8), *setting.MinLength) + assert.Equal(collect, true, *setting.HasUppercase) + assert.Equal(collect, true, *setting.HasLowercase) + assert.Equal(collect, true, *setting.HasNumber) + assert.Equal(collect, true, *setting.HasSymbol) + assert.WithinRange(collect, setting.CreatedAt, before, after) + assert.WithinRange(collect, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test password complexity change", func(t *testing.T) { + before := time.Now() + _, err := newInstance.Client.Admin.UpdatePasswordComplexityPolicy(IAMCTX, &admin.UpdatePasswordComplexityPolicyRequest{ + MinLength: 5, + HasUppercase: false, + HasLowercase: false, + HasNumber: false, + HasSymbol: false, + }) + require.NoError(t, err) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypePasswordComplexity, domain.SettingStateActive), + ), + ) + require.NoError(collect, err) + + // event instance.policy.password.complexity.changed + assert.Equal(collect, uint64(5), *setting.MinLength) + assert.Equal(collect, false, *setting.HasUppercase) + assert.Equal(collect, false, *setting.HasLowercase) + assert.Equal(collect, false, *setting.HasNumber) + assert.Equal(collect, false, *setting.HasSymbol) + assert.WithinRange(collect, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test delete instance reduces", func(t *testing.T) { + // check login settings exist + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypePasswordComplexity, domain.SettingStateActive), + ), + ) + require.NoError(collect, err) + + require.NotNil(t, setting) + }, retryDuration, tick) + + // delete instance + _, err := newInstance.Client.InstanceV2Beta.DeleteInstance(CTX, &instance.DeleteInstanceRequest{ + InstanceId: newInstance.ID(), + }) + require.NoError(t, err) + + // check password complexity settings removed + retryDuration, tick = integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + _, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypePasswordComplexity, domain.SettingStateActive), + ), + ) + + // event instance.removed + require.ErrorIs(collect, err, new(database.NoRowFoundError)) + }, retryDuration, tick) + }) +} + +func TestServer_TestInstancePasswordPolicySettingsReduces(t *testing.T) { + t.Parallel() + + settingsRepo := repository.PasswordExpirySettingsRepository() + before := time.Now() + newInstance := integration.NewInstance(t.Context()) + after := time.Now() + + IAMCTX := newInstance.WithAuthorizationToken(t.Context(), integration.UserTypeIAMOwner) + + t.Run("test password policy added", func(t *testing.T) { + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypePasswordExpiry, domain.SettingStateActive), + ), + ) + require.NoError(collect, err) + + // event instance.policy.password.age.added + assert.Equal(collect, uint64(0), *setting.ExpireWarnDays) + assert.Equal(collect, uint64(0), *setting.MaxAgeDays) + assert.WithinRange(collect, setting.CreatedAt, before, after) + assert.WithinRange(collect, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test password policy changed", func(t *testing.T) { + before := time.Now() + _, err := newInstance.Client.Admin.UpdatePasswordAgePolicy(IAMCTX, &admin.UpdatePasswordAgePolicyRequest{ + MaxAgeDays: 30, + ExpireWarnDays: 30, + }) + require.NoError(t, err) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypePasswordExpiry, domain.SettingStateActive), + ), + ) + require.NoError(collect, err) + + // event instance.policy.password.age.changed + assert.Equal(collect, uint64(30), *setting.ExpireWarnDays) + assert.Equal(collect, uint64(30), *setting.MaxAgeDays) + assert.WithinRange(collect, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test delete instance reduces", func(t *testing.T) { + // check login settings exist + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypePasswordExpiry, domain.SettingStateActive), + ), + ) + require.NoError(collect, err) + + require.NotNil(t, setting) + }, retryDuration, tick) + + // delete instance + _, err := newInstance.Client.InstanceV2Beta.DeleteInstance(CTX, &instance.DeleteInstanceRequest{ + InstanceId: newInstance.ID(), + }) + require.NoError(t, err) + + // check password expiry settings removed + retryDuration, tick = integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + _, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypePasswordExpiry, domain.SettingStateActive), + ), + ) + + // event instance.removed + require.ErrorIs(collect, err, new(database.NoRowFoundError)) + }, retryDuration, tick) + }) +} + +func TestServer_TestInstanceDomainSettingsReduces(t *testing.T) { + t.Parallel() + + settingsRepo := repository.DomainSettingsRepository() + before := time.Now() + newInstance := integration.NewInstance(t.Context()) + after := time.Now() + + IAMCTX := newInstance.WithAuthorizationToken(t.Context(), integration.UserTypeIAMOwner) + + t.Run("test domain policy added", func(t *testing.T) { + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeDomain, domain.SettingStateActive), + ), + ) + require.NoError(collect, err) + + // event instance.policy.domain.added + assert.Equal(collect, false, *setting.SMTPSenderAddressMatchesInstanceDomain) + assert.Equal(collect, false, *setting.LoginNameIncludesDomain) + assert.Equal(collect, false, *setting.RequireOrgDomainVerification) + assert.WithinRange(collect, setting.CreatedAt, before, after) + assert.WithinRange(collect, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test domain policy changed", func(t *testing.T) { + before := time.Now() + _, err := newInstance.Client.Admin.UpdateDomainPolicy(IAMCTX, &admin.UpdateDomainPolicyRequest{ + UserLoginMustBeDomain: true, + ValidateOrgDomains: true, + SmtpSenderAddressMatchesInstanceDomain: true, + }) + require.NoError(t, err) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeDomain, domain.SettingStateActive), + ), + ) + require.NoError(collect, err) + + // event instance.policy.changed + assert.Equal(collect, true, *setting.SMTPSenderAddressMatchesInstanceDomain) + assert.Equal(collect, true, *setting.LoginNameIncludesDomain) + assert.Equal(collect, true, *setting.RequireOrgDomainVerification) + assert.WithinRange(collect, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test delete instance reduces", func(t *testing.T) { + // check login settings exist + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeDomain, domain.SettingStateActive), + ), + ) + require.NoError(collect, err) + + require.NotNil(t, setting) + }, retryDuration, tick) + + // delete instance + _, err := newInstance.Client.InstanceV2Beta.DeleteInstance(CTX, &instance.DeleteInstanceRequest{ + InstanceId: newInstance.ID(), + }) + require.NoError(t, err) + + // check domain settings removed + retryDuration, tick = integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + _, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeDomain, domain.SettingStateActive), + ), + ) + + // event instance.removed + require.ErrorIs(collect, err, new(database.NoRowFoundError)) + }, retryDuration, tick) + }) +} + +func TestServer_TestInstanceLockoutSettingsReduces(t *testing.T) { + t.Parallel() + + settingsRepo := repository.LockoutSettingsRepository() + + before := time.Now() + newInstance := integration.NewInstance(t.Context()) + after := time.Now() + + IAMCTX := newInstance.WithAuthorizationToken(t.Context(), integration.UserTypeIAMOwner) + + t.Run("test lockout policy added", func(t *testing.T) { + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeLockout, domain.SettingStateActive), + ), + ) + require.NoError(collect, err) + + // event instance.policy.lockout.added + assert.Equal(collect, uint64(0), *setting.MaxOTPAttempts) + assert.Equal(collect, uint64(0), *setting.MaxPasswordAttempts) + assert.Equal(collect, true, *setting.ShowLockOutFailures) + assert.WithinRange(collect, setting.CreatedAt, before, after) + assert.WithinRange(collect, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test password policy changed", func(t *testing.T) { + before := time.Now() + _, err := newInstance.Client.Admin.UpdateLockoutPolicy(IAMCTX, &admin.UpdateLockoutPolicyRequest{ + MaxPasswordAttempts: 5, + MaxOtpAttempts: 5, + }) + require.NoError(t, err) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeLockout, domain.SettingStateActive), + ), + ) + require.NoError(collect, err) + + // event instance.policy.lockout.changed + assert.Equal(collect, uint64(5), *setting.MaxOTPAttempts) + assert.Equal(collect, uint64(5), *setting.MaxPasswordAttempts) + assert.WithinRange(collect, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test delete instance reduces", func(t *testing.T) { + // check login settings exist + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeLockout, domain.SettingStateActive), + ), + ) + require.NoError(collect, err) + + require.NotNil(t, setting) + }, retryDuration, tick) + + // delete instance + _, err := newInstance.Client.InstanceV2Beta.DeleteInstance(CTX, &instance.DeleteInstanceRequest{ + InstanceId: newInstance.ID(), + }) + require.NoError(t, err) + + // check password complexity settings removed + retryDuration, tick = integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + _, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeLockout, domain.SettingStateActive), + ), + ) + + // event instance.removed + require.ErrorIs(collect, err, new(database.NoRowFoundError)) + }, retryDuration, tick) + }) +} + +func TestServer_TestInstanceSecuritySettingsReduces(t *testing.T) { + t.Parallel() + + settingsRepo := repository.SecuritySettingsRepository() + + newInstance := integration.NewInstance(t.Context()) + IAMCTX := newInstance.WithAuthorizationToken(t.Context(), integration.UserTypeIAMOwner) + + t.Run("test security policy set", func(t *testing.T) { + // 1. set security policy + before := time.Now() + _, err := newInstance.Client.Admin.SetSecurityPolicy(IAMCTX, &admin.SetSecurityPolicyRequest{ + EnableIframeEmbedding: true, + AllowedOrigins: []string{"value"}, + EnableImpersonation: true, + }) + require.NoError(t, err) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeSecurity, domain.SettingStateActive), + ), + ) + require.NoError(collect, err) + + // event instance.policy.security.set + assert.Equal(collect, true, *setting.EnableIframeEmbedding) + assert.Equal(collect, []string{"value"}, setting.AllowedOrigins) + assert.Equal(collect, true, *setting.EnableImpersonation) + assert.WithinRange(collect, setting.CreatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test security policy re-set", func(t *testing.T) { + // 2. re-set security policy + before := time.Now() + _, err := newInstance.Client.Admin.SetSecurityPolicy(IAMCTX, &admin.SetSecurityPolicyRequest{ + EnableIframeEmbedding: false, + AllowedOrigins: []string{"new_value"}, + EnableImpersonation: false, + }) + require.NoError(t, err) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeSecurity, domain.SettingStateActive), + ), + ) + require.NoError(collect, err) + + // event instance.policy.security.set + assert.Equal(collect, false, *setting.EnableIframeEmbedding) + assert.Equal(collect, []string{"new_value"}, setting.AllowedOrigins) + assert.Equal(collect, false, *setting.EnableImpersonation) + assert.WithinRange(collect, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test delete instance reduces", func(t *testing.T) { + // 3. delete instance + _, err := newInstance.Client.InstanceV2Beta.DeleteInstance(CTX, &instance.DeleteInstanceRequest{ + InstanceId: newInstance.ID(), + }) + require.NoError(t, err) + + // 4. check security settings removed + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*10) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + _, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeSecurity, domain.SettingStateActive), + ), + ) + + // event instance.removed + require.ErrorIs(collect, err, new(database.NoRowFoundError)) + }, retryDuration, tick) + }) +} + +func TestServer_TestInstanceNotificationSettingsReduces(t *testing.T) { + t.Parallel() + + settingsRepo := repository.NotificationSettingsRepository() + + before := time.Now() + newInstance := integration.NewInstance(t.Context()) + after := time.Now() + IAMCTX := newInstance.WithAuthorizationToken(t.Context(), integration.UserTypeIAMOwner) + + t.Run("test add notification settings set", func(t *testing.T) { + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeNotification, domain.SettingStateActive), + ), + ) + require.NoError(t, err) + + assert.Equal(t, true, *setting.PasswordChange) + assert.WithinRange(t, setting.CreatedAt, before, after) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test add notification settings re-set", func(t *testing.T) { + before := time.Now() + _, err := newInstance.Client.Admin.UpdateNotificationPolicy(IAMCTX, &admin.UpdateNotificationPolicyRequest{ + PasswordChange: false, + }) + require.NoError(t, err) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeNotification, domain.SettingStateActive), + ), + ) + require.NoError(t, err) + + assert.Equal(t, false, *setting.PasswordChange) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test delete instance reduces", func(t *testing.T) { + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeNotification, domain.SettingStateActive), + ), + ) + require.NoError(collect, err) + + require.NotNil(t, setting) + }, retryDuration, tick) + + _, err := newInstance.Client.InstanceV2Beta.DeleteInstance(CTX, &instance.DeleteInstanceRequest{ + InstanceId: newInstance.ID(), + }) + require.NoError(t, err) + + retryDuration, tick = integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + _, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeNotification, domain.SettingStateActive), + ), + ) + + require.ErrorIs(collect, err, new(database.NoRowFoundError)) + }, retryDuration, tick) + }) +} + +func TestServer_TestInstanceLegalAndSupportSettingsReduces(t *testing.T) { + t.Parallel() + + settingsRepo := repository.LegalAndSupportSettingsRepository() + + before := time.Now() + newInstance := integration.NewInstance(t.Context()) + after := time.Now() + IAMCTX := newInstance.WithAuthorizationToken(t.Context(), integration.UserTypeIAMOwner) + + t.Run("test add legal and support settings set", func(t *testing.T) { + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeLegalAndSupport, domain.SettingStateActive), + ), + ) + require.NoError(t, err) + + assert.Equal(t, "", *setting.TOSLink) + assert.Equal(t, "", *setting.PrivacyPolicyLink) + assert.Equal(t, "", *setting.HelpLink) + assert.Equal(t, "", *setting.SupportEmail) + assert.Equal(t, "https://zitadel.com/docs", *setting.DocsLink) + assert.Equal(t, "", *setting.CustomLink) + assert.Equal(t, "", *setting.CustomLinkText) + assert.WithinRange(t, setting.CreatedAt, before, after) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test add legal and support settings re-set", func(t *testing.T) { + before := time.Now() + _, err := newInstance.Client.Admin.UpdatePrivacyPolicy(IAMCTX, &admin.UpdatePrivacyPolicyRequest{ + TosLink: "https://tos.example2.com", + PrivacyLink: "https://privacy.example2.com", + HelpLink: "https://help.example2.com", + SupportEmail: "support@example2.com", + DocsLink: "https://docs.example2.com", + CustomLink: "https://custom.example2.com", + CustomLinkText: "Custom link text2", + }) + require.NoError(t, err) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeLegalAndSupport, domain.SettingStateActive), + ), + ) + require.NoError(t, err) + + assert.Equal(t, "https://tos.example2.com", *setting.TOSLink) + assert.Equal(t, "https://privacy.example2.com", *setting.PrivacyPolicyLink) + assert.Equal(t, "https://help.example2.com", *setting.HelpLink) + assert.Equal(t, "support@example2.com", *setting.SupportEmail) + assert.Equal(t, "https://docs.example2.com", *setting.DocsLink) + assert.Equal(t, "https://custom.example2.com", *setting.CustomLink) + assert.Equal(t, "Custom link text2", *setting.CustomLinkText) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test delete instance reduces", func(t *testing.T) { + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeLegalAndSupport, domain.SettingStateActive), + ), + ) + require.NoError(collect, err) + + require.NotNil(t, setting) + }, retryDuration, tick) + + _, err := newInstance.Client.InstanceV2Beta.DeleteInstance(CTX, &instance.DeleteInstanceRequest{ + InstanceId: newInstance.ID(), + }) + require.NoError(t, err) + + retryDuration, tick = integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + _, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), nil, domain.SettingTypeLegalAndSupport, domain.SettingStateActive), + ), + ) + + require.ErrorIs(collect, err, new(database.NoRowFoundError)) + }, retryDuration, tick) + }) +} diff --git a/backend/v3/storage/database/events_testing/settings_org_test.go b/backend/v3/storage/database/events_testing/settings_org_test.go new file mode 100644 index 000000000..f99c02c04 --- /dev/null +++ b/backend/v3/storage/database/events_testing/settings_org_test.go @@ -0,0 +1,1550 @@ +//go:build integration + +package events_test + +import ( + "bytes" + "context" + "io" + "mime/multipart" + "net/http" + "net/url" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/metadata" + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/zitadel/zitadel/backend/v3/domain" + "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/backend/v3/storage/database/repository" + http_util "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/admin" + "github.com/zitadel/zitadel/pkg/grpc/management" + v2beta_org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/policy" + settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/system" +) + +func createInstanceWithOrg(t *testing.T) (context.Context, *integration.Instance, string) { + newInstance := integration.NewInstance(t.Context()) + + IAMCTX := newInstance.WithAuthorizationToken(t.Context(), integration.UserTypeIAMOwner) + + organization, err := newInstance.Client.OrgV2beta.CreateOrganization(IAMCTX, &v2beta_org.CreateOrganizationRequest{ + Name: integration.OrganizationName(), + }) + require.NoError(t, err) + orgId := organization.GetId() + IAMCTX = integration.SetOrgID(IAMCTX, orgId) + + cleanupInstance(t, newInstance) + return IAMCTX, newInstance, orgId +} + +func cleanupInstance(t *testing.T, instance *integration.Instance) { + t.Cleanup(func() { + _, err := SystemClient.RemoveInstance(CTX, &system.RemoveInstanceRequest{ + InstanceId: instance.ID(), + }) + if err != nil { + t.Logf("Failed to delete instance on cleanup: %v", err) + } + }) +} + +func uploadOrganizationAsset(ctx context.Context, t *testing.T, instance *integration.Instance, path string, asset io.Reader) { + url := http_util.BuildOrigin(instance.Host(), instance.Config.Secure) + "/assets/v1" + path + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("file", "filename") + require.NoError(t, err) + + _, err = io.Copy(part, asset) + require.NoError(t, err) + + err = writer.Close() + require.NoError(t, err) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body) + require.NoError(t, err) + md, ok := metadata.FromOutgoingContext(ctx) + require.True(t, ok) + // context information has to be HTTP headers, as the asset API is only HTTP + req.Header.Set("x-zitadel-orgid", md.Get("x-zitadel-orgid")[0]) + req.Header.Set("authorization", md.Get("authorization")[0]) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestServer_TestOrgLoginSettingsReduces(t *testing.T) { + t.Parallel() + + settingsRepo := repository.LoginSettingsRepository() + IAMCTX, newInstance, orgId := createInstanceWithOrg(t) + + t.Run("test adding login settings reduces", func(t *testing.T) { + before := time.Now() + _, err := newInstance.Client.Mgmt.AddCustomLoginPolicy(IAMCTX, &management.AddCustomLoginPolicyRequest{ + AllowUsernamePassword: false, + AllowRegister: false, + AllowExternalIdp: false, + ForceMfa: false, + PasswordlessType: policy.PasswordlessType_PASSWORDLESS_TYPE_NOT_ALLOWED, + HidePasswordReset: false, + IgnoreUnknownUsernames: false, + DefaultRedirectUri: "http://www.example.com", + PasswordCheckLifetime: durationpb.New(time.Second * 10), + ExternalLoginCheckLifetime: durationpb.New(time.Second * 10), + MfaInitSkipLifetime: durationpb.New(time.Second * 10), + SecondFactorCheckLifetime: durationpb.New(time.Second * 10), + MultiFactorCheckLifetime: durationpb.New(time.Second * 10), + AllowDomainDiscovery: false, + DisableLoginWithEmail: false, + DisableLoginWithPhone: false, + ForceMfaLocalOnly: false, + }) + require.NoError(t, err) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeLogin, domain.SettingStateActive), + ), + ) + require.NoError(t, err) + + // event org.policy.login.added + // these values are found in default.yaml + assert.Equal(t, false, *setting.AllowRegister) + assert.Equal(t, false, *setting.AllowExternalIDP) + assert.Equal(t, domain.PasswordlessTypeNotAllowed, *setting.PasswordlessType) + assert.Equal(t, false, *setting.AllowDomainDiscovery) + assert.Equal(t, false, *setting.AllowUserNamePassword) + assert.Equal(t, time.Duration(time.Second*10), *setting.PasswordCheckLifetime) + assert.Equal(t, time.Duration(time.Second*10), *setting.ExternalLoginCheckLifetime) + assert.Equal(t, time.Duration(time.Second*10), *setting.MFAInitSkipLifetime) + assert.Equal(t, time.Duration(time.Second*10), *setting.SecondFactorCheckLifetime) + assert.Equal(t, time.Duration(time.Second*10), *setting.MultiFactorCheckLifetime) + assert.WithinRange(t, setting.CreatedAt, before, after) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test change login settings reduces", func(t *testing.T) { + // update org policy + before := time.Now() + _, err := newInstance.Client.Mgmt.UpdateCustomLoginPolicy(IAMCTX, &management.UpdateCustomLoginPolicyRequest{ + AllowUsernamePassword: true, + AllowRegister: true, + AllowExternalIdp: true, + ForceMfa: true, + PasswordlessType: policy.PasswordlessType_PASSWORDLESS_TYPE_ALLOWED, + HidePasswordReset: true, + IgnoreUnknownUsernames: true, + DefaultRedirectUri: "http://www.new_example.com", + PasswordCheckLifetime: durationpb.New(time.Second * 5 * 20), + ExternalLoginCheckLifetime: durationpb.New(time.Second * 5 * 21), + MfaInitSkipLifetime: durationpb.New(time.Second * 5 * 22), + SecondFactorCheckLifetime: durationpb.New(time.Second * 5 * 23), + MultiFactorCheckLifetime: durationpb.New(time.Second * 5 * 24), + AllowDomainDiscovery: true, + DisableLoginWithEmail: true, + DisableLoginWithPhone: true, + ForceMfaLocalOnly: true, + }) + require.NoError(t, err) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeLogin, domain.SettingStateActive), + ), + ) + require.NoError(t, err) + + // event org.policy.login.changed + assert.Equal(t, true, *setting.AllowRegister) + assert.Equal(t, true, *setting.AllowExternalIDP) + assert.Equal(t, true, *setting.ForceMFA) + assert.Equal(t, domain.PasswordlessTypeAllowed, *setting.PasswordlessType) + assert.Equal(t, true, *setting.HidePasswordReset) + assert.Equal(t, true, *setting.IgnoreUnknownUsernames) + assert.Equal(t, "http://www.new_example.com", *setting.DefaultRedirectURI) + assert.Equal(t, true, *setting.AllowDomainDiscovery) + assert.Equal(t, true, *setting.AllowUserNamePassword) + assert.Equal(t, time.Duration(time.Second*5*20), *setting.PasswordCheckLifetime) + assert.Equal(t, time.Duration(time.Second*5*21), *setting.ExternalLoginCheckLifetime) + assert.Equal(t, time.Duration(time.Second*5*22), *setting.MFAInitSkipLifetime) + assert.Equal(t, time.Duration(time.Second*5*23), *setting.SecondFactorCheckLifetime) + assert.Equal(t, time.Duration(time.Second*5*24), *setting.MultiFactorCheckLifetime) + assert.Equal(t, true, *setting.DisableLoginWithEmail) + assert.Equal(t, true, *setting.DisableLoginWithPhone) + assert.Equal(t, true, *setting.ForceMFALocalOnly) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test added/remove multifactor type reduces", func(t *testing.T) { + // check initial MFAType value + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeLogin, domain.SettingStateActive), + ), + ) + require.NoError(t, err) + + assert.Nil(t, setting.MFAType) + }, retryDuration, tick) + + // add MFAType + before := time.Now() + _, err := newInstance.Client.Mgmt.AddMultiFactorToLoginPolicy(IAMCTX, &management.AddMultiFactorToLoginPolicyRequest{ + Type: policy.MultiFactorType_MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION, + }) + require.NoError(t, err) + after := time.Now() + + retryDuration, tick = integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeLogin, domain.SettingStateActive), + ), + ) + require.NoError(t, err) + + // event org.policy.login.multifactor.added + assert.Equal(t, []domain.MultiFactorType{domain.MultiFactorType(policy.MultiFactorType_MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION)}, setting.MFAType) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + + // remove MFAType + _, err = newInstance.Client.Mgmt.RemoveMultiFactorFromLoginPolicy(IAMCTX, &management.RemoveMultiFactorFromLoginPolicyRequest{ + Type: policy.MultiFactorType_MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION, + }) + require.NoError(t, err) + + retryDuration, tick = integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeLogin, domain.SettingStateActive), + ), + ) + require.NoError(t, err) + + // event org.policy.login.multifactor.remove + assert.Equal(t, []domain.MultiFactorType{}, setting.MFAType) + }, retryDuration, tick) + }) + + t.Run("test added/removed second multifactor reduces", func(t *testing.T) { + before := time.Now() + // get current second factor types + var secondFactorTypes []domain.SecondFactorType + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeLogin, domain.SettingStateActive), + ), + ) + require.NoError(t, err) + + secondFactorTypes = setting.SecondFactorTypes + }, retryDuration, tick) + + // add new second factor type + before = time.Now() + _, err := newInstance.Client.Mgmt.AddSecondFactorToLoginPolicy(IAMCTX, &management.AddSecondFactorToLoginPolicyRequest{ + Type: policy.SecondFactorType_SECOND_FACTOR_TYPE_OTP_SMS, + }) + require.NoError(t, err) + after := time.Now() + + secondFactorTypes = append(secondFactorTypes, domain.SecondFactorType(policy.SecondFactorType_SECOND_FACTOR_TYPE_OTP_SMS)) + + // check new second factor type is added + retryDuration, tick = integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeLogin, domain.SettingStateActive), + ), + ) + require.NoError(t, err) + + // event org.policy.login.multifactor.secondfactor.added + assert.Equal(t, secondFactorTypes, setting.SecondFactorTypes) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + + // remove second factor type + before = time.Now() + _, err = newInstance.Client.Mgmt.RemoveSecondFactorFromLoginPolicy(IAMCTX, &management.RemoveSecondFactorFromLoginPolicyRequest{ + Type: policy.SecondFactorType_SECOND_FACTOR_TYPE_OTP_SMS, + }) + require.NoError(t, err) + after = time.Now() + + secondFactorTypes = secondFactorTypes[0 : len(secondFactorTypes)-1] + + // check new second factor type is removed + retryDuration, tick = integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeLogin, domain.SettingStateActive), + ), + ) + require.NoError(t, err) + + // event org.policy.login.multifactor.secondfactor.removed + assert.Equal(t, secondFactorTypes, setting.SecondFactorTypes) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test login settings removed reduces", func(t *testing.T) { + _, err := newInstance.Client.Mgmt.ResetLoginPolicyToDefault(IAMCTX, &management.ResetLoginPolicyToDefaultRequest{}) + require.NoError(t, err) + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + _, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeLogin, domain.SettingStateActive), + ), + ) + + // event org.policy.login.removed + require.ErrorIs(t, err, new(database.NoRowFoundError)) + }, retryDuration, tick) + }) + + t.Run("test delete org reduces", func(t *testing.T) { + // add delete org + _, err := newInstance.Client.OrgV2beta.DeleteOrganization(IAMCTX, &v2beta_org.DeleteOrganizationRequest{ + Id: orgId, + }) + require.NoError(t, err) + + // check login settings removed + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + _, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeLogin, domain.SettingStateActive), + ), + ) + + // event org.removed + require.ErrorIs(t, err, new(database.NoRowFoundError)) + }, retryDuration, tick) + }) +} + +func TestServer_TestOrgLabelSettingsReduces(t *testing.T) { + t.Parallel() + + settingsRepo := repository.BrandingSettingsRepository() + + IAMCTX, newInstance, orgId := createInstanceWithOrg(t) + + t.Run("test adding label settings reduces", func(t *testing.T) { + before := time.Now() + _, err := newInstance.Client.Mgmt.AddCustomLabelPolicy(IAMCTX, &management.AddCustomLabelPolicyRequest{ + PrimaryColor: "#055090", + HideLoginNameSuffix: false, + WarnColor: "#055090", + BackgroundColor: "#055090", + FontColor: "#055090", + PrimaryColorDark: "#055090", + BackgroundColorDark: "#055090", + WarnColorDark: "#055090", + FontColorDark: "#055090", + DisableWatermark: false, + ThemeMode: policy.ThemeMode_THEME_MODE_DARK, + }) + require.NoError(t, err) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeBranding, domain.SettingStatePreview), + ), + ) + require.NoError(t, err) + + // event org.policy.label.added + assert.Equal(t, "#055090", *setting.PrimaryColorLight) + assert.Equal(t, "#055090", *setting.BackgroundColorLight) + assert.Equal(t, "#055090", *setting.WarnColorLight) + assert.Equal(t, "#055090", *setting.FontColorLight) + assert.Equal(t, "#055090", *setting.PrimaryColorDark) + assert.Equal(t, "#055090", *setting.BackgroundColorDark) + assert.Equal(t, "#055090", *setting.WarnColorDark) + assert.Equal(t, "#055090", *setting.WarnColorDark) + assert.Equal(t, "#055090", *setting.FontColorDark) + assert.Equal(t, false, *setting.HideLoginNameSuffix) + assert.Equal(t, false, *setting.ErrorMsgPopup) + assert.Equal(t, false, *setting.DisableWatermark) + assert.Equal(t, domain.BrandingPolicyThemeDark, *setting.ThemeMode) + assert.Equal(t, domain.SettingStatePreview, setting.State) + assert.WithinRange(t, setting.CreatedAt, before, after) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test policy label change", func(t *testing.T) { + before := time.Now() + _, err := newInstance.Client.Mgmt.UpdateCustomLabelPolicy(IAMCTX, &management.UpdateCustomLabelPolicyRequest{ + PrimaryColor: "#055000", + HideLoginNameSuffix: true, + WarnColor: "#055000", + BackgroundColor: "#055000", + FontColor: "#055000", + PrimaryColorDark: "#055000", + BackgroundColorDark: "#055000", + WarnColorDark: "#055000", + FontColorDark: "#055000", + DisableWatermark: true, + ThemeMode: policy.ThemeMode_THEME_MODE_LIGHT, + }) + require.NoError(t, err) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeBranding, domain.SettingStatePreview), + ), + ) + require.NoError(t, err) + + // event org.policy.label.change + assert.Equal(t, "#055000", *setting.PrimaryColorLight) + assert.Equal(t, "#055000", *setting.BackgroundColorLight) + assert.Equal(t, "#055000", *setting.WarnColorLight) + assert.Equal(t, "#055000", *setting.FontColorLight) + assert.Equal(t, "#055000", *setting.PrimaryColorDark) + assert.Equal(t, "#055000", *setting.BackgroundColorDark) + assert.Equal(t, "#055000", *setting.WarnColorDark) + assert.Equal(t, "#055000", *setting.WarnColorDark) + assert.Equal(t, "#055000", *setting.FontColorDark) + assert.Equal(t, true, *setting.HideLoginNameSuffix) + assert.Equal(t, false, *setting.ErrorMsgPopup) + assert.Equal(t, true, *setting.DisableWatermark) + assert.Equal(t, domain.BrandingPolicyThemeLight, *setting.ThemeMode) + assert.Equal(t, domain.SettingStatePreview, setting.State) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test label settings activated", func(t *testing.T) { + // activate label + before := time.Now() + _, err := newInstance.Client.Mgmt.ActivateCustomLabelPolicy(IAMCTX, &management.ActivateCustomLabelPolicyRequest{}) + require.NoError(t, err) + after := time.Now() + + _, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeBranding, domain.SettingStateActive), + ), + ) + require.NoError(t, err) + + // event org.policy.label.activated + assert.Equal(t, domain.SettingStateActive, setting.State) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, time.Minute*5, tick) + }) + + t.Run("test policy label logo light added", func(t *testing.T) { + // set logo light + before := time.Now() + uploadOrganizationAsset(IAMCTX, t, newInstance, "/org/policy/label/logo", bytes.NewReader(picture)) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeBranding, domain.SettingStatePreview), + ), + ) + require.NoError(t, err) + + // event org.policy.label.logo.added + assert.NotNil(t, setting.LogoURLLight) + assert.Equal(t, domain.SettingStatePreview, setting.State) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test policy label logo light removed", func(t *testing.T) { + // remote logo light + before := time.Now() + _, err := newInstance.Client.Mgmt.RemoveCustomLabelPolicyLogo(IAMCTX, &management.RemoveCustomLabelPolicyLogoRequest{}) + require.NoError(t, err) + after := time.Now() + + // check light logo removed + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeBranding, domain.SettingStatePreview), + ), + ) + require.NoError(t, err) + + // event org.policy.label.logo.removed + assert.Equal(t, url.URL{}, *setting.LogoURLLight) + assert.Equal(t, domain.SettingStatePreview, setting.State) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test policy label logo dark added", func(t *testing.T) { + // set logo dark + before := time.Now() + uploadOrganizationAsset(IAMCTX, t, newInstance, "/org/policy/label/logo/dark", bytes.NewReader(picture)) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeBranding, domain.SettingStatePreview), + ), + ) + require.NoError(t, err) + + // event org.policy.label.logo.dark.added + assert.NotNil(t, setting.LogoURLDark) + assert.Equal(t, domain.SettingStatePreview, setting.State) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test policy label logo dark removed", func(t *testing.T) { + // remove logo dark + before := time.Now() + _, err := newInstance.Client.Mgmt.RemoveCustomLabelPolicyLogoDark(IAMCTX, &management.RemoveCustomLabelPolicyLogoDarkRequest{}) + require.NoError(t, err) + after := time.Now() + + // check dark logo removed + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeBranding, domain.SettingStatePreview), + ), + ) + require.NoError(t, err) + + // event org.policy.label.logo.dark.removed + assert.Equal(t, url.URL{}, *setting.LogoURLDark) + assert.Equal(t, domain.SettingStatePreview, setting.State) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test policy label icon light added", func(t *testing.T) { + // set icon light + before := time.Now() + uploadOrganizationAsset(IAMCTX, t, newInstance, "/org/policy/label/icon", bytes.NewReader(picture)) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeBranding, domain.SettingStatePreview), + ), + ) + require.NoError(t, err) + + // event org.policy.label.icon.added + assert.NotNil(t, setting.IconURLLight) + assert.Equal(t, domain.SettingStatePreview, setting.State) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test policy label icon light removed", func(t *testing.T) { + // remote icon light + before := time.Now() + _, err := newInstance.Client.Mgmt.RemoveCustomLabelPolicyIcon(IAMCTX, &management.RemoveCustomLabelPolicyIconRequest{}) + require.NoError(t, err) + after := time.Now() + + // check light icon removed + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeBranding, domain.SettingStatePreview), + ), + ) + require.NoError(t, err) + + // event org.policy.label.icon.removed + assert.Equal(t, url.URL{}, *setting.IconURLLight) + assert.Equal(t, domain.SettingStatePreview, setting.State) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test policy label icon dark added", func(t *testing.T) { + // set icon dark + before := time.Now() + uploadOrganizationAsset(IAMCTX, t, newInstance, "/org/policy/label/icon/dark", bytes.NewReader(picture)) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeBranding, domain.SettingStatePreview), + ), + ) + require.NoError(t, err) + + // event org.policy.label.icon.dark.added + assert.NotNil(t, setting.IconURLDark) + assert.Equal(t, domain.SettingStatePreview, setting.State) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test policy label icon dark removed", func(t *testing.T) { + // remove icon dark + before := time.Now() + _, err := newInstance.Client.Mgmt.RemoveCustomLabelPolicyIconDark(IAMCTX, &management.RemoveCustomLabelPolicyIconDarkRequest{}) + require.NoError(t, err) + after := time.Now() + + // check dark icon removed + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeBranding, domain.SettingStatePreview), + ), + ) + require.NoError(t, err) + + // event org.policy.label.icon.dark.removed + assert.Equal(t, url.URL{}, *setting.IconURLDark) + assert.Equal(t, domain.SettingStatePreview, setting.State) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test policy font added", func(t *testing.T) { + // set font + before := time.Now() + uploadOrganizationAsset(IAMCTX, t, newInstance, "/org/policy/label/font", bytes.NewReader(font)) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeBranding, domain.SettingStatePreview), + ), + ) + require.NoError(t, err) + + // event org.policy.label.font.added + assert.NotNil(t, setting.FontURL) + assert.Equal(t, domain.SettingStatePreview, setting.State) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test policy font removed", func(t *testing.T) { + // remove font + before := time.Now() + _, err := newInstance.Client.Mgmt.RemoveCustomLabelPolicyFont(IAMCTX, &management.RemoveCustomLabelPolicyFontRequest{}) + require.NoError(t, err) + after := time.Now() + + // check font policy removed + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeBranding, domain.SettingStatePreview), + ), + ) + require.NoError(t, err) + + // event org.policy.label.font.removed + assert.Equal(t, url.URL{}, *setting.FontURL) + assert.Equal(t, domain.SettingStatePreview, setting.State) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test label settings remove reduces", func(t *testing.T) { + // remove label policy + _, err := newInstance.Client.Mgmt.ResetLabelPolicyToDefault(IAMCTX, &management.ResetLabelPolicyToDefaultRequest{}) + require.NoError(t, err) + + // check label settings removed + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + _, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeBranding, domain.SettingStateActive), + ), + ) + + // event org.policy.label.removed + require.ErrorIs(t, err, new(database.NoRowFoundError)) + }, retryDuration, tick) + }) + + t.Run("test org delete reduces", func(t *testing.T) { + // delete org + _, err := newInstance.Client.OrgV2beta.DeleteOrganization(IAMCTX, &v2beta_org.DeleteOrganizationRequest{ + Id: orgId, + }) + require.NoError(t, err) + + // check label settings removed + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + _, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeBranding, domain.SettingStatePreview), + ), + ) + + // event org.removed + require.ErrorIs(t, err, new(database.NoRowFoundError)) + }, retryDuration, tick) + }) +} + +func TestServer_TestOrgPasswordComplexitySettingsReduces(t *testing.T) { + t.Parallel() + + settingsRepo := repository.PasswordComplexitySettingsRepository() + + IAMCTX, newInstance, orgId := createInstanceWithOrg(t) + + t.Run("test password complexity added", func(t *testing.T) { + before := time.Now() + _, err := newInstance.Client.Mgmt.AddCustomPasswordComplexityPolicy(IAMCTX, &management.AddCustomPasswordComplexityPolicyRequest{ + MinLength: 10, + HasUppercase: true, + HasLowercase: true, + HasNumber: true, + HasSymbol: true, + }) + require.NoError(t, err) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypePasswordComplexity, domain.SettingStateActive), + ), + ) + require.NoError(t, err) + + // event org.policy.password.complexity.added + assert.Equal(t, uint64(10), *setting.MinLength) + assert.Equal(t, true, *setting.HasUppercase) + assert.Equal(t, true, *setting.HasLowercase) + assert.Equal(t, true, *setting.HasNumber) + assert.Equal(t, true, *setting.HasSymbol) + assert.WithinRange(t, setting.CreatedAt, before, after) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test password complexity change", func(t *testing.T) { + // update password compexity + before := time.Now() + _, err := newInstance.Client.Mgmt.UpdateCustomPasswordComplexityPolicy(IAMCTX, &management.UpdateCustomPasswordComplexityPolicyRequest{ + MinLength: 5, + HasUppercase: true, + HasLowercase: true, + HasNumber: true, + HasSymbol: true, + }) + require.NoError(t, err) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypePasswordComplexity, domain.SettingStateActive), + ), + ) + require.NoError(t, err) + + // event org.policy.password.complexity.changed + assert.Equal(t, uint64(5), *setting.MinLength) + assert.Equal(t, true, *setting.HasUppercase) + assert.Equal(t, true, *setting.HasLowercase) + assert.Equal(t, true, *setting.HasNumber) + assert.Equal(t, true, *setting.HasSymbol) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test password complexity removed", func(t *testing.T) { + // delete password complexity policy + _, err := newInstance.Client.Mgmt.ResetPasswordComplexityPolicyToDefault(IAMCTX, &management.ResetPasswordComplexityPolicyToDefaultRequest{}) + require.NoError(t, err) + + // check login settings removed + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + _, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypePasswordComplexity, domain.SettingStateActive), + ), + ) + + // event org.removed + require.ErrorIs(t, err, new(database.NoRowFoundError)) + }, retryDuration, tick) + }) + + t.Run("test delete org reduces", func(t *testing.T) { + // create password complexity + _, err := newInstance.Client.Mgmt.AddCustomPasswordComplexityPolicy(IAMCTX, &management.AddCustomPasswordComplexityPolicyRequest{ + MinLength: 10, + HasUppercase: false, + HasLowercase: false, + HasNumber: false, + HasSymbol: false, + }) + require.NoError(t, err) + + // check password complexity settings exist + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypePasswordComplexity, domain.SettingStateActive), + ), + ) + require.NoError(t, err) + + require.NotNil(t, setting) + }, retryDuration, tick) + + // add delete org + _, err = newInstance.Client.OrgV2beta.DeleteOrganization(IAMCTX, &v2beta_org.DeleteOrganizationRequest{ + Id: orgId, + }) + require.NoError(t, err) + + // check login settings removed + retryDuration, tick = integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + _, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypePasswordComplexity, domain.SettingStateActive), + ), + ) + + // event org.removed + require.ErrorIs(t, err, new(database.NoRowFoundError)) + }, retryDuration, tick) + }) +} + +func TestServer_TestOrgPasswordPolicySettingsReduces(t *testing.T) { + t.Parallel() + + settingsRepo := repository.PasswordExpirySettingsRepository() + + IAMCTX, newInstance, orgId := createInstanceWithOrg(t) + + t.Run("test password policy added", func(t *testing.T) { + // add password policy + before := time.Now() + _, err := newInstance.Client.Mgmt.AddCustomPasswordAgePolicy(IAMCTX, &management.AddCustomPasswordAgePolicyRequest{ + MaxAgeDays: 10, + ExpireWarnDays: 10, + }) + require.NoError(t, err) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypePasswordExpiry, domain.SettingStateActive), + ), + ) + require.NoError(t, err) + + // event org.policy.password.age.added + assert.Equal(t, uint64(10), *setting.ExpireWarnDays) + assert.Equal(t, uint64(10), *setting.MaxAgeDays) + assert.WithinRange(t, setting.CreatedAt, before, after) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test password policy change reduces", func(t *testing.T) { + before := time.Now() + _, err := newInstance.Client.Mgmt.UpdateCustomPasswordAgePolicy(IAMCTX, &management.UpdateCustomPasswordAgePolicyRequest{ + MaxAgeDays: 40, + ExpireWarnDays: 40, + }) + require.NoError(t, err) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypePasswordExpiry, domain.SettingStateActive), + ), + ) + require.NoError(t, err) + + // event org.policy.password.age.changed + assert.Equal(t, uint64(40), *setting.ExpireWarnDays) + assert.Equal(t, uint64(40), *setting.MaxAgeDays) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test password policy removed reduces", func(t *testing.T) { + // remove password policy delete org + _, err := newInstance.Client.Mgmt.ResetPasswordAgePolicyToDefault(IAMCTX, &management.ResetPasswordAgePolicyToDefaultRequest{}) + require.NoError(t, err) + + // check password complexity settings removed + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + _, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypePasswordExpiry, domain.SettingStateActive), + ), + ) + + // event org.policy.password.age.removed + require.ErrorIs(t, err, new(database.NoRowFoundError)) + }, retryDuration, tick) + }) + + t.Run("test delete org reduces", func(t *testing.T) { + // add delete org + _, err := newInstance.Client.OrgV2beta.DeleteOrganization(IAMCTX, &v2beta_org.DeleteOrganizationRequest{ + Id: orgId, + }) + require.NoError(t, err) + + // check password complexity settings removed + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + _, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypePasswordExpiry, domain.SettingStateActive), + ), + ) + + // event org.removed + require.ErrorIs(t, err, new(database.NoRowFoundError)) + }, retryDuration, tick) + }) +} + +func TestServer_TestOrgLockoutSettingsReduces(t *testing.T) { + t.Parallel() + + settingsRepo := repository.LockoutSettingsRepository() + + IAMCTX, newInstance, orgId := createInstanceWithOrg(t) + + t.Run("test lockout policy added", func(t *testing.T) { + before := time.Now() + _, err := newInstance.Client.Mgmt.AddCustomLockoutPolicy(IAMCTX, &management.AddCustomLockoutPolicyRequest{ + MaxPasswordAttempts: 1, + MaxOtpAttempts: 1, + }) + require.NoError(t, err) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeLockout, domain.SettingStateActive), + ), + ) + require.NoError(t, err) + + // event org.policy.lockout.added + assert.Equal(t, uint64(1), *setting.MaxOTPAttempts) + assert.Equal(t, uint64(1), *setting.MaxPasswordAttempts) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test lockout policy changed", func(t *testing.T) { + before := time.Now() + _, err := newInstance.Client.Mgmt.UpdateCustomLockoutPolicy(IAMCTX, &management.UpdateCustomLockoutPolicyRequest{ + MaxPasswordAttempts: 5, + MaxOtpAttempts: 5, + }) + require.NoError(t, err) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeLockout, domain.SettingStateActive), + ), + ) + require.NoError(t, err) + + // event org.policy.lockout.changed + assert.Equal(t, uint64(5), *setting.MaxOTPAttempts) + assert.Equal(t, uint64(5), *setting.MaxPasswordAttempts) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test remove lockout policy reduces", func(t *testing.T) { + // remove lockout policy org + _, err := newInstance.Client.Mgmt.ResetLockoutPolicyToDefault(IAMCTX, &management.ResetLockoutPolicyToDefaultRequest{}) + require.NoError(t, err) + + // check password complexity settings removed + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + _, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeLockout, domain.SettingStateActive), + ), + ) + + // event org.removed + require.ErrorIs(t, err, new(database.NoRowFoundError)) + }, retryDuration, tick) + }) + + t.Run("test delete org reduces", func(t *testing.T) { + // add delete org + _, err := newInstance.Client.OrgV2beta.DeleteOrganization(IAMCTX, &v2beta_org.DeleteOrganizationRequest{ + Id: orgId, + }) + require.NoError(t, err) + + // check password complexity settings removed + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + _, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeLockout, domain.SettingStateActive), + ), + ) + + // event org.removed + require.ErrorIs(t, err, new(database.NoRowFoundError)) + }, retryDuration, tick) + }) +} + +func TestServer_TestOrgDomainSettingsReduces(t *testing.T) { + t.Parallel() + + settingsRepo := repository.DomainSettingsRepository() + + IAMCTX, newInstance, orgId := createInstanceWithOrg(t) + + t.Run("test domain policy added", func(t *testing.T) { + before := time.Now() + _, err := newInstance.Client.Admin.AddCustomDomainPolicy(IAMCTX, &admin.AddCustomDomainPolicyRequest{ + OrgId: orgId, + UserLoginMustBeDomain: false, + ValidateOrgDomains: false, + SmtpSenderAddressMatchesInstanceDomain: false, + }) + require.NoError(t, err) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeDomain, domain.SettingStateActive), + ), + ) + require.NoError(t, err) + + // event org.policy.domain.added + assert.Equal(t, false, *setting.SMTPSenderAddressMatchesInstanceDomain) + assert.Equal(t, false, *setting.LoginNameIncludesDomain) + assert.Equal(t, false, *setting.RequireOrgDomainVerification) + assert.WithinRange(t, setting.CreatedAt, before, after) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test domain policy changed", func(t *testing.T) { + // update domain policy + before := time.Now() + _, err := newInstance.Client.Admin.UpdateCustomDomainPolicy(IAMCTX, &admin.UpdateCustomDomainPolicyRequest{ + OrgId: orgId, + UserLoginMustBeDomain: true, + ValidateOrgDomains: true, + SmtpSenderAddressMatchesInstanceDomain: true, + }) + require.NoError(t, err) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeDomain, domain.SettingStateActive), + ), + ) + require.NoError(t, err) + + // event org.policy.domain.changed + assert.Equal(t, true, *setting.SMTPSenderAddressMatchesInstanceDomain) + assert.Equal(t, true, *setting.LoginNameIncludesDomain) + assert.Equal(t, true, *setting.RequireOrgDomainVerification) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test remove domain policy reduces", func(t *testing.T) { + // remove domain policy org + _, err := newInstance.Client.Admin.ResetCustomDomainPolicyToDefault(IAMCTX, &admin.ResetCustomDomainPolicyToDefaultRequest{ + OrgId: orgId, + }) + require.NoError(t, err) + + // check domain settings removed + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + _, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeDomain, domain.SettingStateActive), + ), + ) + + // event org.policy.domain.removed + require.ErrorIs(t, err, new(database.NoRowFoundError)) + }, retryDuration, tick) + }) + + t.Run("test delete org reduces", func(t *testing.T) { + // add delete org + _, err := newInstance.Client.OrgV2beta.DeleteOrganization(IAMCTX, &v2beta_org.DeleteOrganizationRequest{ + Id: orgId, + }) + require.NoError(t, err) + + // check password complexity settings removed + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + _, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeDomain, domain.SettingStateActive), + ), + ) + + // event org.removed + require.ErrorIs(t, err, new(database.NoRowFoundError)) + }, retryDuration, tick) + }) +} + +func TestServer_TestOrgSettingsReduces(t *testing.T) { + t.Parallel() + + settingsRepo := repository.OrganizationSettingsRepository() + + IAMCTX, newInstance, orgId := createInstanceWithOrg(t) + + t.Run("test add org settings set", func(t *testing.T) { + before := time.Now() + _, err := newInstance.Client.SettingsV2beta.SetOrganizationSettings(IAMCTX, &settings.SetOrganizationSettingsRequest{ + OrganizationId: orgId, + OrganizationScopedUsernames: gu.Ptr(true), + }) + require.NoError(t, err) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeOrganization, domain.SettingStateActive), + ), + ) + require.NoError(t, err) + + // event settings.organization.set + assert.Equal(t, true, *setting.OrganizationScopedUsernames) + assert.WithinRange(t, setting.CreatedAt, before, after) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test add org settings re-set", func(t *testing.T) { + before := time.Now() + _, err := newInstance.Client.SettingsV2beta.SetOrganizationSettings(IAMCTX, &settings.SetOrganizationSettingsRequest{ + OrganizationId: orgId, + OrganizationScopedUsernames: gu.Ptr(false), + }) + require.NoError(t, err) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeOrganization, domain.SettingStateActive), + ), + ) + require.NoError(t, err) + + // event settings.organization.set + assert.Equal(t, false, *setting.OrganizationScopedUsernames) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test organization settings removed reduces", func(t *testing.T) { + // delete organization setting + _, err := newInstance.Client.SettingsV2beta.DeleteOrganizationSettings(IAMCTX, &settings.DeleteOrganizationSettingsRequest{ + OrganizationId: orgId, + }) + require.NoError(t, err) + + // check organization settings removed + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + _, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeOrganization, domain.SettingStateActive), + ), + ) + + // event settings.organization.removed + require.ErrorIs(t, err, new(database.NoRowFoundError)) + }, retryDuration, tick) + }) + + t.Run("test organization removed reduces", func(t *testing.T) { + // add delete org + _, err := newInstance.Client.OrgV2beta.DeleteOrganization(IAMCTX, &v2beta_org.DeleteOrganizationRequest{ + Id: orgId, + }) + require.NoError(t, err) + + // check organization settings removed + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + _, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeOrganization, domain.SettingStateActive), + ), + ) + + // event org.removed + require.ErrorIs(t, err, new(database.NoRowFoundError)) + }, retryDuration, tick) + }) +} + +func TestServer_TestNotificationSettingsReduces(t *testing.T) { + t.Parallel() + + settingsRepo := repository.NotificationSettingsRepository() + + IAMCTX, newInstance, orgId := createInstanceWithOrg(t) + + t.Run("test add notification settings set", func(t *testing.T) { + before := time.Now() + _, err := newInstance.Client.Mgmt.AddCustomNotificationPolicy(IAMCTX, &management.AddCustomNotificationPolicyRequest{ + PasswordChange: true, + }) + require.NoError(t, err) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeNotification, domain.SettingStateActive), + ), + ) + require.NoError(t, err) + + assert.Equal(t, true, *setting.PasswordChange) + assert.WithinRange(t, setting.CreatedAt, before, after) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test add notification settings re-set", func(t *testing.T) { + before := time.Now() + _, err := newInstance.Client.Mgmt.UpdateCustomNotificationPolicy(IAMCTX, &management.UpdateCustomNotificationPolicyRequest{ + PasswordChange: false, + }) + require.NoError(t, err) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeNotification, domain.SettingStateActive), + ), + ) + require.NoError(t, err) + + assert.Equal(t, false, *setting.PasswordChange) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test notification settings removed reduces", func(t *testing.T) { + _, err := newInstance.Client.Mgmt.ResetNotificationPolicyToDefault(IAMCTX, &management.ResetNotificationPolicyToDefaultRequest{}) + require.NoError(t, err) + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + _, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeNotification, domain.SettingStateActive), + ), + ) + + require.ErrorIs(t, err, new(database.NoRowFoundError)) + }, retryDuration, tick) + }) + + t.Run("test organization removed reduces", func(t *testing.T) { + // add delete org + _, err := newInstance.Client.OrgV2beta.DeleteOrganization(IAMCTX, &v2beta_org.DeleteOrganizationRequest{ + Id: orgId, + }) + require.NoError(t, err) + + // check organization settings removed + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + _, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeNotification, domain.SettingStateActive), + ), + ) + + require.ErrorIs(t, err, new(database.NoRowFoundError)) + }, retryDuration, tick) + }) +} + +func TestServer_TestLegalAndSupportSettingsReduces(t *testing.T) { + t.Parallel() + + settingsRepo := repository.LegalAndSupportSettingsRepository() + + IAMCTX, newInstance, orgId := createInstanceWithOrg(t) + + t.Run("test add legal and support settings set", func(t *testing.T) { + before := time.Now() + _, err := newInstance.Client.Mgmt.AddCustomPrivacyPolicy(IAMCTX, &management.AddCustomPrivacyPolicyRequest{ + TosLink: "https://tos.example.com", + PrivacyLink: "https://privacy.example.com", + HelpLink: "https://help.example.com", + SupportEmail: "support@example.com", + DocsLink: "https://docs.example.com", + CustomLink: "https://custom.example.com", + CustomLinkText: "Custom link text", + }) + require.NoError(t, err) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeLegalAndSupport, domain.SettingStateActive), + ), + ) + require.NoError(t, err) + + assert.Equal(t, "https://tos.example.com", *setting.TOSLink) + assert.Equal(t, "https://privacy.example.com", *setting.PrivacyPolicyLink) + assert.Equal(t, "https://help.example.com", *setting.HelpLink) + assert.Equal(t, "support@example.com", *setting.SupportEmail) + assert.Equal(t, "https://docs.example.com", *setting.DocsLink) + assert.Equal(t, "https://custom.example.com", *setting.CustomLink) + assert.Equal(t, "Custom link text", *setting.CustomLinkText) + assert.WithinRange(t, setting.CreatedAt, before, after) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test add legal and support settings re-set", func(t *testing.T) { + before := time.Now() + _, err := newInstance.Client.Mgmt.UpdateCustomPrivacyPolicy(IAMCTX, &management.UpdateCustomPrivacyPolicyRequest{ + TosLink: "https://tos.example2.com", + PrivacyLink: "https://privacy.example2.com", + HelpLink: "https://help.example2.com", + SupportEmail: "support@example2.com", + DocsLink: "https://docs.example2.com", + CustomLink: "https://custom.example2.com", + CustomLinkText: "Custom link text2", + }) + require.NoError(t, err) + after := time.Now() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + setting, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeLegalAndSupport, domain.SettingStateActive), + ), + ) + require.NoError(t, err) + + assert.Equal(t, "https://tos.example2.com", *setting.TOSLink) + assert.Equal(t, "https://privacy.example2.com", *setting.PrivacyPolicyLink) + assert.Equal(t, "https://help.example2.com", *setting.HelpLink) + assert.Equal(t, "support@example2.com", *setting.SupportEmail) + assert.Equal(t, "https://docs.example2.com", *setting.DocsLink) + assert.Equal(t, "https://custom.example2.com", *setting.CustomLink) + assert.Equal(t, "Custom link text2", *setting.CustomLinkText) + assert.WithinRange(t, setting.UpdatedAt, before, after) + }, retryDuration, tick) + }) + + t.Run("test legal and support settings removed reduces", func(t *testing.T) { + _, err := newInstance.Client.Mgmt.ResetPrivacyPolicyToDefault(IAMCTX, &management.ResetPrivacyPolicyToDefaultRequest{}) + require.NoError(t, err) + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + _, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeLegalAndSupport, domain.SettingStateActive), + ), + ) + + require.ErrorIs(t, err, new(database.NoRowFoundError)) + }, retryDuration, tick) + }) + + t.Run("test organization removed reduces", func(t *testing.T) { + // add delete org + _, err := newInstance.Client.OrgV2beta.DeleteOrganization(IAMCTX, &v2beta_org.DeleteOrganizationRequest{ + Id: orgId, + }) + require.NoError(t, err) + + // check organization settings removed + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMCTX, time.Second*20) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + _, err := settingsRepo.Get( + IAMCTX, pool, + database.WithCondition( + settingsRepo.UniqueCondition(newInstance.ID(), &orgId, domain.SettingTypeLegalAndSupport, domain.SettingStateActive), + ), + ) + + require.ErrorIs(t, err, new(database.NoRowFoundError)) + }, retryDuration, tick) + }) +} diff --git a/backend/v3/storage/database/json/json.go b/backend/v3/storage/database/json/json.go new file mode 100644 index 000000000..55942e6e4 --- /dev/null +++ b/backend/v3/storage/database/json/json.go @@ -0,0 +1,225 @@ +package json + +import ( + "encoding/json" + "reflect" + + "github.com/zitadel/zitadel/backend/v3/storage/database" +) + +var _ database.Change = (*jsonChanges)(nil) + +type JsonUpdate interface { + writeUpdate(builder *database.StatementBuilder, changes jsonChanges, i int) error + getPathValue() ([]string, string, error) +} + +type FieldChange struct { + path []string + value any +} + +func NewFieldChange(path []string, value any) JsonUpdate { + return &FieldChange{ + path: path, + value: value, + } +} + +func (c *FieldChange) getPathValue() ([]string, string, error) { + path, v := c.path, c.value + + value, err := json.Marshal(v) + if err != nil { + return nil, "", err + } + + return path, string(value), nil +} + +func (c *FieldChange) writeUpdate(builder *database.StatementBuilder, changes jsonChanges, i int) error { + path, value, err := c.getPathValue() + if err != nil { + return err + } + + builder.WriteString("jsonb_set_lax(") + if i < 0 { + changes.column.WriteQualified(builder) + } else { + err := changes.changes[i].writeUpdate(builder, changes, i-1) + if err != nil { + return err + } + } + builder.WriteString(", ") + builder.WriteArg(path) + builder.WriteString(", ") + builder.WriteArg(value) + builder.WriteString(", true, 'delete_key')") + + return nil +} + +type ArrayChange struct { + path []string + value any + remove bool +} + +func NewArrayChange(path []string, value any, remove bool) JsonUpdate { + return &ArrayChange{ + path: path, + value: value, + remove: remove, + } +} + +func (c *ArrayChange) getPathValue() ([]string, string, error) { + path, v := c.path, c.value + + value, err := json.Marshal(v) + if err != nil { + return nil, "", err + } + + return path, string(value), nil +} + +func (c *ArrayChange) writeUpdate(builder *database.StatementBuilder, changes jsonChanges, i int) error { + if c.remove { + return c.removeFromArray(builder, changes, i) + } + return c.addToArray(builder, changes, i) +} + +func (c *ArrayChange) addToArray(builder *database.StatementBuilder, changes jsonChanges, i int) error { + path, value, err := c.getPathValue() + if err != nil { + return err + } + + builder.WriteString("zitadel.jsonb_array_append(") + + // to mitigate the possibility of having duplicate values in the array + builder.WriteString("zitadel.jsonb_array_remove(") + if i < 0 { + changes.column.WriteQualified(builder) + } else { + err := changes.changes[i].writeUpdate(builder, changes, i-1) + if err != nil { + return err + } + } + + // zitadel.jsonb_array_remove + builder.WriteString(", ") + builder.WriteArg(path) + builder.WriteString(", ") + builder.WriteArg(value) + builder.WriteString("::TEXT)") + + // zitadel.jsonb_array_append + builder.WriteString(", ") + builder.WriteArg(path) + builder.WriteString(", ") + builder.WriteArg(value) + if len(value) > 2 && value[0] == '"' && value[len(value)-1] == '"' { + builder.WriteString("::TEXT") + } else { + builder.WriteString("::NUMERIC") + } + + builder.WriteString(")") + + return nil +} + +func (c *ArrayChange) removeFromArray(builder *database.StatementBuilder, changes jsonChanges, i int) error { + path, value, err := c.getPathValue() + if err != nil { + return err + } + + builder.WriteString("zitadel.jsonb_array_remove(") + if i < 0 { + changes.column.WriteQualified(builder) + } else { + err := changes.changes[i].writeUpdate(builder, changes, i-1) + if err != nil { + return err + } + } + builder.WriteString(", ") + builder.WriteArg(path) + builder.WriteString(", ") + builder.WriteArg(value) + builder.WriteString("::TEXT") + builder.WriteString(")") + + return nil +} + +var _ JsonUpdate = (*ArrayChange)(nil) + +type jsonChanges struct { + column database.Column + changes []JsonUpdate +} + +var _ database.Change = (*jsonChanges)(nil) + +func NewJsonChanges(col database.Column, changes ...JsonUpdate) database.Change { + return &jsonChanges{ + column: col, + changes: changes, + } +} + +func (c *jsonChanges) Matches(x any) bool { + toMatch, ok := x.(*jsonChanges) + if !ok { + return false + } + + if !c.column.Equals(toMatch.column) { + return false + } + + if len(c.changes) != len(toMatch.changes) { + return false + } + + for _, c1 := range c.changes { + found := false + for _, c2 := range toMatch.changes { + if reflect.DeepEqual(c1, c2) { + found = true + } + } + if !found { + return false + } + } + + return true +} + +func (c *jsonChanges) String() string { + return "database.json.jsonChange" +} + +func (c jsonChanges) Write(builder *database.StatementBuilder) error { + c.column.WriteUnqualified(builder) + builder.WriteString(" = ") + + if c.changes == nil { + return nil + } + + return c.changes[len(c.changes)-1].writeUpdate(builder, c, len(c.changes)-2) +} + +func (c jsonChanges) IsOnColumn(col database.Column) bool { + return c.column.Equals(col) +} diff --git a/backend/v3/storage/database/json/json_test.go b/backend/v3/storage/database/json/json_test.go new file mode 100644 index 000000000..6df635b1c --- /dev/null +++ b/backend/v3/storage/database/json/json_test.go @@ -0,0 +1,250 @@ +package json + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/backend/v3/storage/database" +) + +func TestFieldChange(t *testing.T) { + for _, test := range []struct { + name string + change database.Change + output string + }{ + { + name: "single json update", + change: func() database.Change { + col := database.NewColumn("table", "column") + change := NewFieldChange([]string{"path"}, "value") + changes := NewJsonChanges(col, change) + + return changes + }(), + output: `column = jsonb_set_lax(table.column, $1, $2, true, 'delete_key')`, + }, + { + name: "two json update", + change: func() database.Change { + col := database.NewColumn("table", "column") + change1 := NewFieldChange([]string{"path"}, "value") + change2 := NewFieldChange([]string{"path"}, "value") + changes := NewJsonChanges(col, change1, change2) + + return changes + }(), + output: `column = jsonb_set_lax(jsonb_set_lax(table.column, $1, $2, true, 'delete_key'), $3, $4, true, 'delete_key')`, + }, + } { + t.Run(test.name, func(t *testing.T) { + builder := database.StatementBuilder{} + err := test.change.Write(&builder) + require.NoError(t, err) + + assert.Equal(t, test.output, builder.String()) + }) + } +} + +func TestArrayChange(t *testing.T) { + for _, test := range []struct { + name string + change database.Change + output string + }{ + { + name: "one json array add", + change: func() database.Change { + col := database.NewColumn("table", "column") + change := NewArrayChange([]string{"path"}, "value", false) + changes := NewJsonChanges(col, change) + + return changes + }(), + output: `column = zitadel.jsonb_array_append(zitadel.jsonb_array_remove(table.column, $1, $2::TEXT), $3, $4::TEXT)`, + }, + { + name: "two json array add", + change: func() database.Change { + col := database.NewColumn("table", "column") + change1 := NewArrayChange([]string{"path)"}, "value", false) + change2 := NewArrayChange([]string{"path"}, "value", false) + changes := NewJsonChanges(col, change1, change2) + + return changes + }(), + output: `column = zitadel.jsonb_array_append(zitadel.jsonb_array_remove(zitadel.jsonb_array_append(zitadel.jsonb_array_remove(table.column, $1, $2::TEXT), $3, $4::TEXT), $5, $6::TEXT), $7, $8::TEXT)`, + }, + { + name: "one json array remove", + change: func() database.Change { + col := database.NewColumn("table", "column") + change := NewArrayChange([]string{"path"}, "value", true) + changes := NewJsonChanges(col, change) + + return changes + }(), + output: `column = zitadel.jsonb_array_remove(table.column, $1, $2::TEXT)`, + }, + { + name: "two json array remove", + change: func() database.Change { + col := database.NewColumn("table", "column") + change1 := NewArrayChange([]string{"path)"}, "value", true) + change2 := NewArrayChange([]string{"path"}, "value", true) + changes := NewJsonChanges(col, change1, change2) + + return changes + }(), + output: `column = zitadel.jsonb_array_remove(zitadel.jsonb_array_remove(table.column, $1, $2::TEXT), $3, $4::TEXT)`, + }, + } { + t.Run(test.name, func(t *testing.T) { + builder := database.StatementBuilder{} + err := test.change.Write(&builder) + require.NoError(t, err) + + assert.Equal(t, test.output, builder.String()) + }) + } +} + +func TestArrayMixedChange(t *testing.T) { + for _, test := range []struct { + name string + change database.Change + output string + }{ + { + name: "one json array add, one json remove", + change: func() database.Change { + col := database.NewColumn("table", "column") + change1 := NewArrayChange([]string{"path)"}, "value", false) + change2 := NewArrayChange([]string{"path"}, "value", true) + changes := NewJsonChanges(col, change1, change2) + + return changes + }(), + output: `column = zitadel.jsonb_array_remove(zitadel.jsonb_array_append(zitadel.jsonb_array_remove(table.column, $1, $2::TEXT), $3, $4::TEXT), $5, $6::TEXT)`, + }, + { + name: "one json array remove, one json array add", + change: func() database.Change { + col := database.NewColumn("table", "column") + change1 := NewArrayChange([]string{"path)"}, "value", true) + change2 := NewArrayChange([]string{"path"}, "value", false) + changes := NewJsonChanges(col, change1, change2) + + return changes + }(), + output: `column = zitadel.jsonb_array_append(zitadel.jsonb_array_remove(zitadel.jsonb_array_remove(table.column, $1, $2::TEXT), $3, $4::TEXT), $5, $6::TEXT)`, + }, + { + name: "one json array add, one json array remove, one array add", + change: func() database.Change { + col := database.NewColumn("table", "column") + change1 := NewArrayChange([]string{"path)"}, "value", false) + change2 := NewArrayChange([]string{"path"}, "value", true) + change3 := NewArrayChange([]string{"path)"}, "value", false) + changes := NewJsonChanges(col, change1, change2, change3) + + return changes + }(), + output: `column = zitadel.jsonb_array_append(zitadel.jsonb_array_remove(zitadel.jsonb_array_remove(zitadel.jsonb_array_append(zitadel.jsonb_array_remove(table.column, $1, $2::TEXT), $3, $4::TEXT), $5, $6::TEXT), $7, $8::TEXT), $9, $10::TEXT)`, + }, + { + name: "one json array remove, one json array add, one array remove", + change: func() database.Change { + col := database.NewColumn("table", "column") + change1 := NewArrayChange([]string{"path)"}, "value", true) + change2 := NewArrayChange([]string{"path"}, "value", false) + change3 := NewArrayChange([]string{"path)"}, "value", true) + changes := NewJsonChanges(col, change1, change2, change3) + + return changes + }(), + output: `column = zitadel.jsonb_array_remove(zitadel.jsonb_array_append(zitadel.jsonb_array_remove(zitadel.jsonb_array_remove(table.column, $1, $2::TEXT), $3, $4::TEXT), $5, $6::TEXT), $7, $8::TEXT)`, + }, + } { + t.Run(test.name, func(t *testing.T) { + builder := database.StatementBuilder{} + err := test.change.Write(&builder) + require.NoError(t, err) + + assert.Equal(t, test.output, builder.String()) + }) + } +} + +// TODO +func TestFieldArrayMixedChange(t *testing.T) { + for _, test := range []struct { + name string + change database.Change + output string + }{ + { + name: "one field change, one json array add, one json remove", + change: func() database.Change { + col := database.NewColumn("table", "column") + change1 := NewFieldChange([]string{"path"}, "value") + change2 := NewArrayChange([]string{"path)"}, "value", false) + change3 := NewArrayChange([]string{"path"}, "value", true) + changes := NewJsonChanges(col, change1, change2, change3) + + return changes + }(), + output: `column = zitadel.jsonb_array_remove(zitadel.jsonb_array_append(zitadel.jsonb_array_remove(jsonb_set_lax(table.column, $1, $2, true, 'delete_key'), $3, $4::TEXT), $5, $6::TEXT), $7, $8::TEXT)`, + }, + { + name: "one json array add, one field change, one json remove", + change: func() database.Change { + col := database.NewColumn("table", "column") + change1 := NewArrayChange([]string{"path)"}, "value", false) + change2 := NewFieldChange([]string{"path"}, "value") + change3 := NewArrayChange([]string{"path"}, "value", true) + changes := NewJsonChanges(col, change1, change2, change3) + + return changes + }(), + output: `column = zitadel.jsonb_array_remove(jsonb_set_lax(zitadel.jsonb_array_append(zitadel.jsonb_array_remove(table.column, $1, $2::TEXT), $3, $4::TEXT), $5, $6, true, 'delete_key'), $7, $8::TEXT)`, + }, + { + name: "one json remove, one json array, add one field change", + change: func() database.Change { + col := database.NewColumn("table", "column") + change1 := NewArrayChange([]string{"path"}, "value", true) + change2 := NewArrayChange([]string{"path)"}, "value", false) + change3 := NewFieldChange([]string{"path"}, "value") + changes := NewJsonChanges(col, change1, change2, change3) + + return changes + }(), + output: `column = jsonb_set_lax(zitadel.jsonb_array_append(zitadel.jsonb_array_remove(zitadel.jsonb_array_remove(table.column, $1, $2::TEXT), $3, $4::TEXT), $5, $6::TEXT), $7, $8, true, 'delete_key')`, + }, + { + name: "one json remove, one field change, one json array add", + change: func() database.Change { + col := database.NewColumn("table", "column") + change1 := NewArrayChange([]string{"path"}, "value", true) + change2 := NewFieldChange([]string{"path"}, "value") + change3 := NewArrayChange([]string{"path)"}, "value", false) + changes := NewJsonChanges(col, change1, change2, change3) + + return changes + }(), + output: `column = zitadel.jsonb_array_append(zitadel.jsonb_array_remove(jsonb_set_lax(zitadel.jsonb_array_remove(table.column, $1, $2::TEXT), $3, $4, true, 'delete_key'), $5, $6::TEXT), $7, $8::TEXT)`, + }, + } { + t.Run(test.name, func(t *testing.T) { + builder := database.StatementBuilder{} + err := test.change.Write(&builder) + require.NoError(t, err) + + assert.Equal(t, test.output, builder.String()) + }) + } +} diff --git a/backend/v3/storage/database/repository/id_provider.go b/backend/v3/storage/database/repository/id_provider.go index 54decf427..930246b6f 100644 --- a/backend/v3/storage/database/repository/id_provider.go +++ b/backend/v3/storage/database/repository/id_provider.go @@ -92,7 +92,10 @@ func (i *idProvider) Update(ctx context.Context, client database.QueryExecutor, i.InstanceIDCondition(instanceID), i.OrgIDCondition(orgID), } - database.Changes(changes).Write(&builder) + err := database.Changes(changes).Write(&builder) + if err != nil { + return 0, err + } writeCondition(&builder, database.And(conditions...)) stmt := builder.String() diff --git a/backend/v3/storage/database/repository/instance.go b/backend/v3/storage/database/repository/instance.go index 63f5ba5b6..a088172cb 100644 --- a/backend/v3/storage/database/repository/instance.go +++ b/backend/v3/storage/database/repository/instance.go @@ -110,7 +110,10 @@ func (i instance) Update(ctx context.Context, client database.QueryExecutor, id var builder database.StatementBuilder builder.WriteString(`UPDATE zitadel.instances SET `) - database.Changes(changes).Write(&builder) + err := database.Changes(changes).Write(&builder) + if err != nil { + return 0, err + } idCondition := i.IDCondition(id) writeCondition(&builder, idCondition) @@ -149,15 +152,19 @@ func (i instance) SetUpdatedAt(time time.Time) database.Change { func (i instance) SetIAMProject(id string) database.Change { return database.NewChange(i.IAMProjectIDColumn(), id) } + func (i instance) SetDefaultOrg(id string) database.Change { return database.NewChange(i.DefaultOrgIDColumn(), id) } + func (i instance) SetDefaultLanguage(lang language.Tag) database.Change { return database.NewChange(i.DefaultLanguageColumn(), lang.String()) } + func (i instance) SetConsoleClientID(id string) database.Change { return database.NewChange(i.ConsoleClientIDColumn(), id) } + func (i instance) SetConsoleAppID(id string) database.Change { return database.NewChange(i.ConsoleAppIDColumn(), id) } @@ -182,7 +189,6 @@ func (i instance) NameCondition(op database.TextOperation, name string) database // ExistsDomain creates a correlated [database.Exists] condition on instance_domains. // Use this filter to make sure the Instance returned contains a specific domain. -// of the instance in the aggregated result. // Example usage: // // domainRepo := instanceRepo.Domains(true) // ensure domains are loaded/aggregated diff --git a/backend/v3/storage/database/repository/instance_domain.go b/backend/v3/storage/database/repository/instance_domain.go index 5edaf0af6..709be82b5 100644 --- a/backend/v3/storage/database/repository/instance_domain.go +++ b/backend/v3/storage/database/repository/instance_domain.go @@ -93,7 +93,10 @@ func (i instanceDomain) Update(ctx context.Context, client database.QueryExecuto var builder database.StatementBuilder builder.WriteString(`UPDATE zitadel.instance_domains SET `) - database.Changes(changes).Write(&builder) + err := database.Changes(changes).Write(&builder) + if err != nil { + return 0, err + } writeCondition(&builder, condition) diff --git a/backend/v3/storage/database/repository/instance_domain_test.go b/backend/v3/storage/database/repository/instance_domain_test.go index 1900e6484..2b57dd6ed 100644 --- a/backend/v3/storage/database/repository/instance_domain_test.go +++ b/backend/v3/storage/database/repository/instance_domain_test.go @@ -721,7 +721,8 @@ func TestInstanceDomainChanges(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { var builder database.StatementBuilder - test.change.Write(&builder) + err := test.change.Write(&builder) + require.NoError(t, err) assert.Equal(t, test.expected, builder.String()) }) } diff --git a/backend/v3/storage/database/repository/org.go b/backend/v3/storage/database/repository/org.go index 014ca25bc..8a3c63246 100644 --- a/backend/v3/storage/database/repository/org.go +++ b/backend/v3/storage/database/repository/org.go @@ -108,7 +108,10 @@ func (o org) Update(ctx context.Context, client database.QueryExecutor, conditio var builder database.StatementBuilder builder.WriteString(`UPDATE zitadel.organizations SET `) - database.Changes(changes).Write(&builder) + err := database.Changes(changes).Write(&builder) + if err != nil { + return 0, err + } writeCondition(&builder, condition) stmt := builder.String() diff --git a/backend/v3/storage/database/repository/org_domain.go b/backend/v3/storage/database/repository/org_domain.go index 2ecaf0961..ed21439ef 100644 --- a/backend/v3/storage/database/repository/org_domain.go +++ b/backend/v3/storage/database/repository/org_domain.go @@ -107,7 +107,10 @@ func (o orgDomain) Update(ctx context.Context, client database.QueryExecutor, co var builder database.StatementBuilder builder.WriteString(`UPDATE zitadel.org_domains SET `) - database.Changes(changes).Write(&builder) + err := database.Changes(changes).Write(&builder) + if err != nil { + return 0, err + } writeCondition(&builder, condition) return client.Exec(ctx, builder.String(), builder.Args()...) diff --git a/backend/v3/storage/database/repository/org_domain_test.go b/backend/v3/storage/database/repository/org_domain_test.go index c0a108a27..ae7e1a49a 100644 --- a/backend/v3/storage/database/repository/org_domain_test.go +++ b/backend/v3/storage/database/repository/org_domain_test.go @@ -928,7 +928,8 @@ func TestOrganizationDomainChanges(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { var builder database.StatementBuilder - test.change.Write(&builder) + err := test.change.Write(&builder) + require.NoError(t, err) assert.Equal(t, test.expected, builder.String()) }) } diff --git a/backend/v3/storage/database/repository/project_role.go b/backend/v3/storage/database/repository/project_role.go index 98ebd8734..f50759196 100644 --- a/backend/v3/storage/database/repository/project_role.go +++ b/backend/v3/storage/database/repository/project_role.go @@ -60,7 +60,10 @@ func (p projectRole) Update(ctx context.Context, client database.QueryExecutor, changes = append(changes, database.NewChange(p.UpdatedAtColumn(), database.NullInstruction)) } builder := database.NewStatementBuilder(`UPDATE zitadel.project_roles SET `) - database.Changes(changes).Write(builder) + err := database.Changes(changes).Write(builder) + if err != nil { + return 0, err + } writeCondition(builder, condition) return client.Exec(ctx, builder.String(), builder.Args()...) diff --git a/backend/v3/storage/database/repository/settings.go b/backend/v3/storage/database/repository/settings.go new file mode 100644 index 000000000..5496e1811 --- /dev/null +++ b/backend/v3/storage/database/repository/settings.go @@ -0,0 +1,1407 @@ +package repository + +import ( + "context" + "encoding/json" + "net/url" + "time" + + "github.com/zitadel/zitadel/backend/v3/domain" + "github.com/zitadel/zitadel/backend/v3/storage/database" + db_json "github.com/zitadel/zitadel/backend/v3/storage/database/json" +) + +type settings struct{} + +func (s settings) qualifiedTableName() string { + return "zitadel." + s.unqualifiedTableName() +} + +func (settings) unqualifiedTableName() string { + return "settings" +} + +// ------------------------------------------------------------- +// columns +// ------------------------------------------------------------- + +// PrimaryKeyColumns implements [domain.Repository]. +func (s settings) PrimaryKeyColumns() []database.Column { + return []database.Column{ + s.InstanceIDColumn(), + s.IDColumn(), + } +} + +func (s settings) UniqueColumns() []database.Column { + return []database.Column{ + s.InstanceIDColumn(), + s.OrganizationIDColumn(), + s.TypeColumn(), + s.StateColumn(), + } +} + +func (s settings) IDColumn() database.Column { + return database.NewColumn(s.unqualifiedTableName(), "id") +} + +func (s settings) InstanceIDColumn() database.Column { + return database.NewColumn(s.unqualifiedTableName(), "instance_id") +} + +func (s settings) OrganizationIDColumn() database.Column { + return database.NewColumn(s.unqualifiedTableName(), "organization_id") +} + +func (s settings) TypeColumn() database.Column { + return database.NewColumn(s.unqualifiedTableName(), "type") +} + +func (s settings) StateColumn() database.Column { + return database.NewColumn(s.unqualifiedTableName(), "state") +} + +func (s settings) SettingsColumn() database.Column { + return database.NewColumn(s.unqualifiedTableName(), "settings") +} + +func (s settings) CreatedAtColumn() database.Column { + return database.NewColumn(s.unqualifiedTableName(), "created_at") +} + +func (s settings) UpdatedAtColumn() database.Column { + return database.NewColumn(s.unqualifiedTableName(), "updated_at") +} + +// ------------------------------------------------------------- +// conditions +// ------------------------------------------------------------- + +func (s settings) PrimaryKeyCondition(instanceID, id string) database.Condition { + return database.And( + s.InstanceIDCondition(instanceID), + s.IDCondition(id), + ) +} + +func (s settings) UniqueCondition(instanceID string, orgID *string, typ domain.SettingType, state domain.SettingState) database.Condition { + return database.And( + s.InstanceIDCondition(instanceID), + s.OrganizationIDCondition(orgID), + s.TypeCondition(typ), + s.StateCondition(state), + ) +} + +func (s settings) InstanceIDCondition(id string) database.Condition { + return database.NewTextCondition(s.InstanceIDColumn(), database.TextOperationEqual, id) +} + +func (s settings) OrganizationIDCondition(id *string) database.Condition { + if id == nil { + return database.IsNull(s.OrganizationIDColumn()) + } + return database.NewTextCondition(s.OrganizationIDColumn(), database.TextOperationEqual, *id) +} + +func (s settings) IDCondition(id string) database.Condition { + return database.NewTextCondition(s.IDColumn(), database.TextOperationEqual, id) +} + +func (s settings) TypeCondition(typ domain.SettingType) database.Condition { + return database.NewTextCondition(s.TypeColumn(), database.TextOperationEqual, typ.String()) +} + +func (s settings) StateCondition(typ domain.SettingState) database.Condition { + return database.NewTextCondition(s.StateColumn(), database.TextOperationEqual, typ.String()) +} + +// ------------------------------------------------------------- +// changes +// ------------------------------------------------------------- + +// func (s settings) SetSettings(settings string) database.Change { +// return database.NewChange(s.SettingsColumn(), settings) +// } + +func (s settings) SetUpdatedAt(updatedAt *time.Time) database.Change { + return database.NewChangePtr(s.UpdatedAtColumn(), updatedAt) +} + +func (s settings) get(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) (*domain.Settings, error) { + builder, err := s.prepareQuery(opts) + if err != nil { + return nil, err + } + return getOne[domain.Settings](ctx, client, builder) +} + +func (s settings) list(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*domain.Settings, error) { + builder, err := s.prepareQuery(opts) + if err != nil { + return nil, err + } + return getMany[domain.Settings](ctx, client, builder) +} + +func (s settings) set(ctx context.Context, client database.QueryExecutor, settings *domain.Settings, changes ...database.Change) error { + var ( + id, createdAt, updatedAt any = database.DefaultInstruction, database.DefaultInstruction, database.DefaultInstruction + ) + + if settings.ID != "" { + id = settings.ID + } + if !settings.CreatedAt.IsZero() { + createdAt = settings.CreatedAt + } + if !settings.UpdatedAt.IsZero() { + updatedAt = settings.UpdatedAt + changes = append(changes, s.SetUpdatedAt(&settings.UpdatedAt)) + } + + builder := database.NewStatementBuilder(`INSERT INTO ` + s.qualifiedTableName()) + builder.WriteString(` (id, instance_id, organization_id, type, state, settings, created_at, updated_at) VALUES (`) + builder.WriteArgs( + id, + settings.InstanceID, + settings.OrganizationID, + settings.Type.String(), + settings.State.String(), + settings.Settings, + createdAt, + updatedAt, + ) + builder.WriteString(`) ON CONFLICT (instance_id, organization_id, type, state) DO UPDATE SET `) + err := database.Changes(changes).Write(builder) + if err != nil { + return err + } + builder.WriteString(` RETURNING id, created_at, updated_at`) + return client.QueryRow(ctx, builder.String(), builder.Args()...).Scan(&settings.ID, &settings.CreatedAt, &settings.UpdatedAt) +} + +func (s settings) Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition) (int64, error) { + if err := checkRestrictingColumns(condition, s.UniqueColumns()...); err != nil { + return 0, err + } + + var builder database.StatementBuilder + builder.WriteString(`DELETE FROM ` + s.qualifiedTableName()) + + writeCondition(&builder, condition) + return client.Exec(ctx, builder.String(), builder.Args()...) +} + +// ------------------------------------------------------------- +// login +// ------------------------------------------------------------- +type loginSettings struct { + settings +} + +func (l loginSettings) SetSettingFields(value domain.LoginSettingsAttributes) database.Change { + changes := make([]db_json.JsonUpdate, 0) + if value.AllowUserNamePassword != nil { + changes = append(changes, l.SetAllowUserNamePassword(*value.AllowUserNamePassword)) + } + if value.AllowRegister != nil { + changes = append(changes, l.SetAllowRegister(*value.AllowRegister)) + } + if value.AllowExternalIDP != nil { + changes = append(changes, l.SetAllowExternalIDP(*value.AllowExternalIDP)) + } + if value.ForceMFA != nil { + changes = append(changes, l.SetForceMFA(*value.ForceMFA)) + } + if value.ForceMFALocalOnly != nil { + changes = append(changes, l.SetForceMFALocalOnly(*value.ForceMFALocalOnly)) + } + if value.HidePasswordReset != nil { + changes = append(changes, l.SetHidePasswordReset(*value.HidePasswordReset)) + } + if value.IgnoreUnknownUsernames != nil { + changes = append(changes, l.SetIgnoreUnknownUsernames(*value.IgnoreUnknownUsernames)) + } + if value.AllowDomainDiscovery != nil { + changes = append(changes, l.SetAllowDomainDiscovery(*value.AllowDomainDiscovery)) + } + if value.DisableLoginWithEmail != nil { + changes = append(changes, l.SetDisableLoginWithEmail(*value.DisableLoginWithEmail)) + } + if value.DisableLoginWithPhone != nil { + changes = append(changes, l.SetDisableLoginWithPhone(*value.DisableLoginWithPhone)) + } + if value.PasswordlessType != nil { + changes = append(changes, l.SetPasswordlessType(*value.PasswordlessType)) + } + if value.DefaultRedirectURI != nil { + changes = append(changes, l.SetDefaultRedirectURI(*value.DefaultRedirectURI)) + } + if value.PasswordCheckLifetime != nil { + changes = append(changes, l.SetPasswordCheckLifetime(*value.PasswordCheckLifetime)) + } + if value.ExternalLoginCheckLifetime != nil { + changes = append(changes, l.SetExternalLoginCheckLifetime(*value.ExternalLoginCheckLifetime)) + } + if value.MFAInitSkipLifetime != nil { + changes = append(changes, l.SetMFAInitSkipLifetime(*value.MFAInitSkipLifetime)) + } + if value.SecondFactorCheckLifetime != nil { + changes = append(changes, l.SetSecondFactorCheckLifetime(*value.SecondFactorCheckLifetime)) + } + if value.MultiFactorCheckLifetime != nil { + changes = append(changes, l.SetMultiFactorCheckLifetime(*value.MultiFactorCheckLifetime)) + } + if value.DisableLoginWithEmail != nil { + changes = append(changes, l.SetDisableLoginWithEmail(*value.DisableLoginWithEmail)) + } + if value.MFAType != nil { + changes = append(changes, l.SetMFAType(value.MFAType)) + } + if value.SecondFactorTypes != nil { + changes = append(changes, l.SetSecondFactorTypes(value.SecondFactorTypes)) + } + return db_json.NewJsonChanges(l.SettingsColumn(), changes...) +} + +func (loginSettings) SetAllowUserNamePassword(value bool) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"allowUsernamePassword"}, value) +} + +func (loginSettings) SetAllowRegister(value bool) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"allowRegister"}, value) +} + +func (loginSettings) SetAllowExternalIDP(value bool) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"allowExternalIdp"}, value) +} + +func (loginSettings) SetForceMFA(value bool) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"forceMfa"}, value) +} + +func (loginSettings) SetForceMFALocalOnly(value bool) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"forceMfaLocalOnly"}, value) +} + +func (loginSettings) SetHidePasswordReset(value bool) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"hidePasswordReset"}, value) +} + +func (loginSettings) SetIgnoreUnknownUsernames(value bool) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"ignoreUnknownUsernames"}, value) +} + +func (loginSettings) SetAllowDomainDiscovery(value bool) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"allowDomainDiscovery"}, value) +} + +func (loginSettings) SetDisableLoginWithEmail(value bool) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"disableLoginWithEmail"}, value) +} + +func (loginSettings) SetDisableLoginWithPhone(value bool) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"disableLoginWithPhone"}, value) +} + +func (loginSettings) SetPasswordlessType(value domain.PasswordlessType) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"passwordlessType"}, value) +} + +func (loginSettings) SetDefaultRedirectURI(value string) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"defaultRedirectUri"}, value) +} + +func (loginSettings) SetPasswordCheckLifetime(value time.Duration) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"passwordCheckLifetime"}, value) +} + +func (loginSettings) SetExternalLoginCheckLifetime(value time.Duration) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"externalLoginCheckLifetime"}, value) +} + +func (loginSettings) SetMFAInitSkipLifetime(value time.Duration) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"mfaInitSkipLifetime"}, value) +} + +func (loginSettings) SetSecondFactorCheckLifetime(value time.Duration) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"secondFactorCheckLifetime"}, value) +} + +func (loginSettings) SetMultiFactorCheckLifetime(value time.Duration) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"multiFactorCheckLifetime"}, value) +} + +func (loginSettings) SetMFAType(values []domain.MultiFactorType) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"mfaType"}, values) +} + +func (l loginSettings) AddMFAType(value domain.MultiFactorType) database.Change { + return db_json.NewJsonChanges(l.SettingsColumn(), + db_json.NewArrayChange([]string{"mfaType"}, value, false), + ) +} + +func (l loginSettings) RemoveMFAType(value domain.MultiFactorType) database.Change { + return db_json.NewJsonChanges(l.SettingsColumn(), + db_json.NewArrayChange([]string{"mfaType"}, value, true), + ) +} + +func (loginSettings) SetSecondFactorTypes(values []domain.SecondFactorType) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"secondFactors"}, values) +} + +func (l loginSettings) AddSecondFactorTypes(value domain.SecondFactorType) database.Change { + return db_json.NewJsonChanges(l.SettingsColumn(), + db_json.NewArrayChange([]string{"secondFactors"}, value, false), + ) +} + +func (l loginSettings) RemoveSecondFactorTypes(value domain.SecondFactorType) database.Change { + return db_json.NewJsonChanges(l.SettingsColumn(), + db_json.NewArrayChange([]string{"secondFactors"}, value, true), + ) +} + +func LoginSettingsRepository() domain.LoginSettingsRepository { + return &loginSettings{ + settings{}, + } +} + +var _ domain.LoginSettingsRepository = (*loginSettings)(nil) + +func (s loginSettings) Set(ctx context.Context, client database.QueryExecutor, settings *domain.LoginSettings) error { + settings.Type = domain.SettingTypeLogin + settings.State = domain.SettingStateActive + + settingsJSON, err := json.Marshal(settings.LoginSettingsAttributes) + if err != nil { + return err + } + settings.Settings.Settings = settingsJSON + if err := s.set(ctx, client, &settings.Settings, s.SetSettingFields(settings.LoginSettingsAttributes)); err != nil { + return err + } + settings.Settings.Settings = nil + return nil +} + +func (s loginSettings) SetColumns(ctx context.Context, client database.QueryExecutor, settings *domain.Settings, changes ...database.Change) error { + settings.Type = domain.SettingTypeLogin + settings.State = domain.SettingStateActive + + return s.set(ctx, client, settings, changes...) +} + +func (s loginSettings) List(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*domain.LoginSettings, error) { + settings, err := s.list(ctx, client, opts...) + if err != nil { + return nil, err + } + + list := make([]*domain.LoginSettings, len(settings)) + for i := range settings { + list[i] = &domain.LoginSettings{Settings: *settings[i]} + if err := json.Unmarshal(list[i].Settings.Settings, &list[i].LoginSettingsAttributes); err != nil { + return nil, err + } + list[i].Settings.Settings = nil + } + return list, nil +} + +func (s loginSettings) Get(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) (*domain.LoginSettings, error) { + settings, err := s.get(ctx, client, opts...) + if err != nil { + return nil, err + } + + loginSettings := &domain.LoginSettings{Settings: *settings} + if err = json.Unmarshal(loginSettings.Settings.Settings, &loginSettings.LoginSettingsAttributes); err != nil { + return nil, err + } + loginSettings.Settings.Settings = nil + return loginSettings, nil +} + +// ------------------------------------------------------------- +// branding +// ------------------------------------------------------------- + +type brandingSettings struct { + settings +} + +func (b brandingSettings) SetSettingFields(value domain.BrandingSettingsAttributes) database.Change { + changes := make([]db_json.JsonUpdate, 0) + if value.PrimaryColorLight != nil { + changes = append(changes, b.SetPrimaryColorLight(*value.PrimaryColorLight)) + } + if value.BackgroundColorLight != nil { + changes = append(changes, b.SetBackgroundColorLight(*value.BackgroundColorLight)) + } + if value.WarnColorLight != nil { + changes = append(changes, b.SetWarnColorLight(*value.WarnColorLight)) + } + if value.FontColorLight != nil { + changes = append(changes, b.SetFontColorLight(*value.FontColorLight)) + } + if value.PrimaryColorDark != nil { + changes = append(changes, b.SetPrimaryColorDark(*value.PrimaryColorDark)) + } + if value.BackgroundColorDark != nil { + changes = append(changes, b.SetBackgroundColorDark(*value.BackgroundColorDark)) + } + if value.WarnColorDark != nil { + changes = append(changes, b.SetWarnColorDark(*value.WarnColorDark)) + } + if value.FontColorDark != nil { + changes = append(changes, b.SetFontColorDark(*value.FontColorDark)) + } + if value.HideLoginNameSuffix != nil { + changes = append(changes, b.SetHideLoginNameSuffix(*value.HideLoginNameSuffix)) + } + if value.ErrorMsgPopup != nil { + changes = append(changes, b.SetErrorMsgPopup(*value.ErrorMsgPopup)) + } + if value.DisableWatermark != nil { + changes = append(changes, b.SetDisableWatermark(*value.DisableWatermark)) + } + if value.ThemeMode != nil { + changes = append(changes, b.SetThemeMode(*value.ThemeMode)) + } + if value.LogoURLLight != nil { + changes = append(changes, b.SetLogoURLLight(*value.LogoURLLight)) + } + if value.LogoURLDark != nil { + changes = append(changes, b.SetLogoURLDark(*value.LogoURLDark)) + } + if value.IconURLLight != nil { + changes = append(changes, b.SetIconURLLight(*value.IconURLLight)) + } + if value.IconURLDark != nil { + changes = append(changes, b.SetIconURLDark(*value.IconURLDark)) + } + if value.FontURL != nil { + changes = append(changes, b.SetFontURL(*value.FontURL)) + } + return db_json.NewJsonChanges(b.SettingsColumn(), changes...) +} + +func (brandingSettings) SetPrimaryColorLight(value string) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"primaryColorLight"}, value) +} + +func (brandingSettings) SetBackgroundColorLight(value string) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"backgroundColorLight"}, value) +} + +func (brandingSettings) SetWarnColorLight(value string) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"warnColorLight"}, value) +} + +func (brandingSettings) SetFontColorLight(value string) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"fontColorLight"}, value) +} + +func (brandingSettings) SetPrimaryColorDark(value string) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"primaryColorDark"}, value) +} + +func (brandingSettings) SetBackgroundColorDark(value string) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"backgroundColorDark"}, value) +} + +func (brandingSettings) SetWarnColorDark(value string) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"warnColorDark"}, value) +} + +func (brandingSettings) SetFontColorDark(value string) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"fontColorDark"}, value) +} + +func (brandingSettings) SetHideLoginNameSuffix(value bool) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"hideLoginNameSuffix"}, value) +} + +func (brandingSettings) SetErrorMsgPopup(value bool) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"errorMsgPopup"}, value) +} + +func (brandingSettings) SetDisableWatermark(value bool) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"disableMsgPopup"}, value) +} + +func (brandingSettings) SetThemeMode(value domain.BrandingPolicyThemeMode) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"themeMode"}, value) +} + +func (brandingSettings) SetLogoURLLight(value url.URL) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"logoURLLight"}, value) +} + +func (brandingSettings) SetLogoURLDark(value url.URL) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"logoUrlDark"}, value) +} + +func (brandingSettings) SetIconURLLight(value url.URL) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"iconUrlLight"}, value) +} + +func (brandingSettings) SetIconURLDark(value url.URL) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"iconUrlDark"}, value) +} + +func (brandingSettings) SetFontURL(value url.URL) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"fontUrl"}, value) +} + +func BrandingSettingsRepository() domain.BrandingSettingsRepository { + return &brandingSettings{ + settings{}, + } +} + +var _ domain.BrandingSettingsRepository = (*brandingSettings)(nil) + +func (s brandingSettings) Set(ctx context.Context, client database.QueryExecutor, settings *domain.BrandingSettings) (err error) { + settings.Type = domain.SettingTypeBranding + settings.State = domain.SettingStatePreview + + settingsJSON, err := json.Marshal(settings.BrandingSettingsAttributes) + if err != nil { + return err + } + settings.Settings.Settings = settingsJSON + if err := s.set(ctx, client, &settings.Settings, s.SetSettingFields(settings.BrandingSettingsAttributes)); err != nil { + return err + } + settings.Settings.Settings = nil + return nil +} + +func (s brandingSettings) SetColumns(ctx context.Context, client database.QueryExecutor, settings *domain.Settings, changes ...database.Change) error { + settings.Type = domain.SettingTypeBranding + settings.State = domain.SettingStatePreview + + return s.set(ctx, client, settings, changes...) +} + +func (s brandingSettings) List(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*domain.BrandingSettings, error) { + settings, err := s.list(ctx, client, opts...) + if err != nil { + return nil, err + } + + list := make([]*domain.BrandingSettings, len(settings)) + for i := range settings { + list[i] = &domain.BrandingSettings{Settings: *settings[i]} + if err := json.Unmarshal(list[i].Settings.Settings, &list[i].BrandingSettingsAttributes); err != nil { + return nil, err + } + list[i].Settings.Settings = nil + } + return list, nil +} + +func (s brandingSettings) Get(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) (*domain.BrandingSettings, error) { + settings, err := s.get(ctx, client, opts...) + if err != nil { + return nil, err + } + + brandingSettings := &domain.BrandingSettings{Settings: *settings} + if err = json.Unmarshal(brandingSettings.Settings.Settings, &brandingSettings.BrandingSettingsAttributes); err != nil { + return nil, err + } + brandingSettings.Settings.Settings = nil + return brandingSettings, nil +} + +const queryActivateBrandingSettingsStmt = `SELECT instance_id, organization_id, id, type, created_at, updated_at, settings FROM ` + +func (s brandingSettings) Activate(ctx context.Context, client database.QueryExecutor, condition database.Condition) (int64, error) { + return s.activateAt(ctx, client, condition, time.Now()) +} + +func (s brandingSettings) ActivateAt(ctx context.Context, client database.QueryExecutor, condition database.Condition, updatedAt time.Time) (int64, error) { + return s.activateAt(ctx, client, condition, updatedAt) +} + +func (s brandingSettings) activateAt(ctx context.Context, client database.QueryExecutor, condition database.Condition, updatedAt time.Time) (int64, error) { + condition = database.And( + condition, + s.TypeCondition(domain.SettingTypeBranding), + s.StateCondition(domain.SettingStatePreview), + ) + // if you want to update the roles you have to have a unique condition, otherwise multiple branding settings get activated + if err := checkRestrictingColumns(condition, s.UniqueColumns()...); err != nil { + return 0, err + } + + // the statement begins with getting settings in preview state + builder := database.NewStatementBuilder("WITH preview AS (") + builder.WriteString(queryActivateBrandingSettingsStmt + s.qualifiedTableName()) + writeCondition(builder, condition) + builder.WriteString(" ) ") + + // insert or update settings as active + builder.WriteString(`INSERT INTO ` + s.qualifiedTableName()) + builder.WriteString(` (instance_id, organization_id, id, type, state, created_at, updated_at, settings)`) + + builder.WriteString(` SELECT preview.instance_id, preview.organization_id, preview.id, preview.type, `) + builder.WriteArg(domain.SettingStateActive) + builder.WriteString(`, preview.created_at, `) + builder.WriteArg(updatedAt) + builder.WriteString(`, preview.settings FROM preview`) + + builder.WriteString(` ON CONFLICT (instance_id, organization_id, type, state) DO UPDATE SET settings = EXCLUDED.settings, updated_at = `) + builder.WriteArg(updatedAt) + + return client.Exec(ctx, builder.String(), builder.Args()...) +} + +// ------------------------------------------------------------- +// password complexity +// ------------------------------------------------------------- +type passwordComplexitySettings struct { + settings +} + +func (s passwordComplexitySettings) SetSettingFields(value domain.PasswordComplexitySettingsAttributes) database.Change { + changes := make([]db_json.JsonUpdate, 0) + if value.MinLength != nil { + changes = append(changes, s.SetMinLength(*value.MinLength)) + } + if value.HasLowercase != nil { + changes = append(changes, s.SetHasLowercase(*value.HasLowercase)) + } + if value.HasUppercase != nil { + changes = append(changes, s.SetHasUppercase(*value.HasUppercase)) + } + if value.HasNumber != nil { + changes = append(changes, s.SetHasNumber(*value.HasNumber)) + } + if value.HasSymbol != nil { + changes = append(changes, s.SetHasSymbol(*value.HasSymbol)) + } + return db_json.NewJsonChanges(s.SettingsColumn(), changes...) +} + +func (passwordComplexitySettings) SetMinLength(value uint64) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"minLength"}, value) +} + +func (passwordComplexitySettings) SetHasLowercase(value bool) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"hasLowercase"}, value) +} + +func (passwordComplexitySettings) SetHasUppercase(value bool) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"hasUppercase"}, value) +} + +func (passwordComplexitySettings) SetHasNumber(value bool) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"hasNumber"}, value) +} + +func (passwordComplexitySettings) SetHasSymbol(value bool) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"hasSymbol"}, value) +} + +func PasswordComplexitySettingsRepository() domain.PasswordComplexitySettingsRepository { + return &passwordComplexitySettings{ + settings{}, + } +} + +var _ domain.PasswordComplexitySettingsRepository = (*passwordComplexitySettings)(nil) + +func (s passwordComplexitySettings) Set(ctx context.Context, client database.QueryExecutor, settings *domain.PasswordComplexitySettings) (err error) { + settings.Type = domain.SettingTypePasswordComplexity + settings.State = domain.SettingStateActive + + settingsJSON, err := json.Marshal(settings.PasswordComplexitySettingsAttributes) + if err != nil { + return err + } + settings.Settings.Settings = settingsJSON + if err := s.set(ctx, client, &settings.Settings, s.SetSettingFields(settings.PasswordComplexitySettingsAttributes)); err != nil { + return err + } + settings.Settings.Settings = nil + return nil +} + +func (s passwordComplexitySettings) List(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*domain.PasswordComplexitySettings, error) { + settings, err := s.list(ctx, client, opts...) + if err != nil { + return nil, err + } + + list := make([]*domain.PasswordComplexitySettings, len(settings)) + for i := range settings { + list[i] = &domain.PasswordComplexitySettings{Settings: *settings[i]} + if err := json.Unmarshal(list[i].Settings.Settings, &list[i].PasswordComplexitySettingsAttributes); err != nil { + return nil, err + } + list[i].Settings.Settings = nil + } + return list, nil +} + +func (s passwordComplexitySettings) Get(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) (*domain.PasswordComplexitySettings, error) { + settings, err := s.get(ctx, client, opts...) + if err != nil { + return nil, err + } + + item := &domain.PasswordComplexitySettings{Settings: *settings} + if err := json.Unmarshal(item.Settings.Settings, &item.PasswordComplexitySettingsAttributes); err != nil { + return nil, err + } + item.Settings.Settings = nil + return item, nil +} + +// ------------------------------------------------------------- +// password expiry +// ------------------------------------------------------------- + +type passwordExpirySettings struct { + settings +} + +func (s passwordExpirySettings) SetSettingFields(value domain.PasswordExpirySettingsAttributes) database.Change { + changes := make([]db_json.JsonUpdate, 0) + if value.ExpireWarnDays != nil { + changes = append(changes, s.SetExpireWarnDays(*value.ExpireWarnDays)) + } + if value.MaxAgeDays != nil { + changes = append(changes, s.SetMaxAgeDays(*value.MaxAgeDays)) + } + return db_json.NewJsonChanges(s.SettingsColumn(), changes...) +} + +func (passwordExpirySettings) SetExpireWarnDays(value uint64) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"expireWarnDays"}, value) +} + +func (passwordExpirySettings) SetMaxAgeDays(value uint64) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"maxAgeDays"}, value) +} + +var _ domain.PasswordExpirySettingsRepository = (*passwordExpirySettings)(nil) + +func PasswordExpirySettingsRepository() domain.PasswordExpirySettingsRepository { + return &passwordExpirySettings{ + settings{}, + } +} + +func (s passwordExpirySettings) Set(ctx context.Context, client database.QueryExecutor, settings *domain.PasswordExpirySettings) (err error) { + settings.Type = domain.SettingTypePasswordExpiry + settings.State = domain.SettingStateActive + + settingsJSON, err := json.Marshal(settings.PasswordExpirySettingsAttributes) + if err != nil { + return err + } + settings.Settings.Settings = settingsJSON + if err := s.set(ctx, client, &settings.Settings, s.SetSettingFields(settings.PasswordExpirySettingsAttributes)); err != nil { + return err + } + settings.Settings.Settings = nil + return nil +} + +func (s passwordExpirySettings) List(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*domain.PasswordExpirySettings, error) { + settings, err := s.list(ctx, client, opts...) + if err != nil { + return nil, err + } + + list := make([]*domain.PasswordExpirySettings, len(settings)) + for i := range settings { + list[i] = &domain.PasswordExpirySettings{Settings: *settings[i]} + if err := json.Unmarshal(list[i].Settings.Settings, &list[i].PasswordExpirySettingsAttributes); err != nil { + return nil, err + } + list[i].Settings.Settings = nil + } + return list, nil +} + +func (s passwordExpirySettings) Get(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) (*domain.PasswordExpirySettings, error) { + settings, err := s.get(ctx, client, opts...) + if err != nil { + return nil, err + } + + item := &domain.PasswordExpirySettings{Settings: *settings} + if err = json.Unmarshal(item.Settings.Settings, &item.PasswordExpirySettingsAttributes); err != nil { + return nil, err + } + item.Settings.Settings = nil + return item, nil +} + +// ------------------------------------------------------------- +// lockout +// ------------------------------------------------------------- +type lockoutSettings struct { + settings +} + +func (s lockoutSettings) SetSettingFields(value domain.LockoutSettingsAttributes) database.Change { + changes := make([]db_json.JsonUpdate, 0) + if value.MaxPasswordAttempts != nil { + changes = append(changes, s.SetMaxPasswordAttempts(*value.MaxPasswordAttempts)) + } + if value.MaxOTPAttempts != nil { + changes = append(changes, s.SetMaxOTPAttempts(*value.MaxOTPAttempts)) + } + if value.ShowLockOutFailures != nil { + changes = append(changes, s.SetShowLockOutFailures(*value.ShowLockOutFailures)) + } + return db_json.NewJsonChanges(s.SettingsColumn(), changes...) +} + +func (lockoutSettings) SetMaxPasswordAttempts(value uint64) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"maxPasswordAttempts"}, value) +} + +func (lockoutSettings) SetMaxOTPAttempts(value uint64) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"maxOtpAttempts"}, value) +} + +func (lockoutSettings) SetShowLockOutFailures(value bool) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"showLockOutFailures"}, value) +} + +func LockoutSettingsRepository() domain.LockoutSettingsRepository { + return &lockoutSettings{ + settings{}, + } +} + +var _ domain.LockoutSettingsRepository = (*lockoutSettings)(nil) + +func (s lockoutSettings) Set(ctx context.Context, client database.QueryExecutor, settings *domain.LockoutSettings) error { + settings.Type = domain.SettingTypeLockout + settings.State = domain.SettingStateActive + + settingsJSON, err := json.Marshal(settings.LockoutSettingsAttributes) + if err != nil { + return err + } + settings.Settings.Settings = settingsJSON + if err := s.set(ctx, client, &settings.Settings, s.SetSettingFields(settings.LockoutSettingsAttributes)); err != nil { + return err + } + settings.Settings.Settings = nil + return nil +} + +func (s lockoutSettings) List(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*domain.LockoutSettings, error) { + settings, err := s.list(ctx, client, opts...) + if err != nil { + return nil, err + } + + list := make([]*domain.LockoutSettings, len(settings)) + for i := range settings { + list[i] = &domain.LockoutSettings{Settings: *settings[i]} + if err := json.Unmarshal(list[i].Settings.Settings, &list[i].LockoutSettingsAttributes); err != nil { + return nil, err + } + list[i].Settings.Settings = nil + } + return list, nil +} + +func (s lockoutSettings) Get(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) (*domain.LockoutSettings, error) { + settings, err := s.get(ctx, client, opts...) + if err != nil { + return nil, err + } + + item := &domain.LockoutSettings{Settings: *settings} + if err = json.Unmarshal(item.Settings.Settings, &item.LockoutSettingsAttributes); err != nil { + return nil, err + } + item.Settings.Settings = nil + return item, nil +} + +// ------------------------------------------------------------- +// security +// ------------------------------------------------------------- +type securitySettings struct { + settings +} + +func (s securitySettings) SetSettingFields(value domain.SecuritySettingsAttributes) database.Change { + changes := make([]db_json.JsonUpdate, 0) + if value.EnableIframeEmbedding != nil { + changes = append(changes, s.SetEnableIframeEmbedding(*value.EnableIframeEmbedding)) + } + if value.AllowedOrigins != nil { + changes = append(changes, s.SetAllowedOrigins(value.AllowedOrigins)) + } + if value.EnableImpersonation != nil { + changes = append(changes, s.SetEnableImpersonation(*value.EnableImpersonation)) + } + return db_json.NewJsonChanges(s.SettingsColumn(), changes...) +} + +func (securitySettings) SetEnableIframeEmbedding(value bool) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"enableIframeEmbedding"}, value) +} + +func (securitySettings) SetAllowedOrigins(value []string) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"allowedOrigins"}, value) +} + +func (securitySettings) SetEnableImpersonation(value bool) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"enableImpersonation"}, value) +} + +func SecuritySettingsRepository() domain.SecuritySettingsRepository { + return &securitySettings{ + settings{}, + } +} + +var _ domain.SecuritySettingsRepository = (*securitySettings)(nil) + +func (s securitySettings) Set(ctx context.Context, client database.QueryExecutor, settings *domain.SecuritySettings) (err error) { + settings.Type = domain.SettingTypeSecurity + settings.State = domain.SettingStateActive + + settingsJSON, err := json.Marshal(settings.SecuritySettingsAttributes) + if err != nil { + return err + } + settings.Settings.Settings = settingsJSON + if err := s.set(ctx, client, &settings.Settings, s.SetSettingFields(settings.SecuritySettingsAttributes)); err != nil { + return err + } + settings.Settings.Settings = nil + return nil +} + +func (s securitySettings) List(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*domain.SecuritySettings, error) { + settings, err := s.list(ctx, client, opts...) + if err != nil { + return nil, err + } + + list := make([]*domain.SecuritySettings, len(settings)) + for i := range settings { + list[i] = &domain.SecuritySettings{Settings: *settings[i]} + if err := json.Unmarshal(list[i].Settings.Settings, &list[i].SecuritySettingsAttributes); err != nil { + return nil, err + } + list[i].Settings.Settings = nil + } + return list, nil +} + +func (s securitySettings) Get(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) (*domain.SecuritySettings, error) { + settings, err := s.get(ctx, client, opts...) + if err != nil { + return nil, err + } + + item := &domain.SecuritySettings{Settings: *settings} + if err = json.Unmarshal(item.Settings.Settings, &item.SecuritySettingsAttributes); err != nil { + return nil, err + } + item.Settings.Settings = nil + return item, nil +} + +// ------------------------------------------------------------- +// domain +// ------------------------------------------------------------- +type domainSettings struct { + settings +} + +func (s domainSettings) SetSettingFields(value domain.DomainSettingsAttributes) database.Change { + changes := make([]db_json.JsonUpdate, 0) + if value.LoginNameIncludesDomain != nil { + changes = append(changes, s.SetLoginNameIncludesDomain(*value.LoginNameIncludesDomain)) + } + if value.RequireOrgDomainVerification != nil { + changes = append(changes, s.SetRequireOrgDomainVerification(*value.RequireOrgDomainVerification)) + } + if value.SMTPSenderAddressMatchesInstanceDomain != nil { + changes = append(changes, s.SetSMTPSenderAddressMatchesInstanceDomain(*value.SMTPSenderAddressMatchesInstanceDomain)) + } + return db_json.NewJsonChanges(s.SettingsColumn(), changes...) +} + +func (domainSettings) SetLoginNameIncludesDomain(value bool) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"loginNameIncludesDomain"}, value) +} + +func (domainSettings) SetRequireOrgDomainVerification(value bool) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"requireOrgDomainVerification"}, value) +} + +func (domainSettings) SetSMTPSenderAddressMatchesInstanceDomain(value bool) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"smtpSenderAddressMatchesInstanceDomain"}, value) +} + +func DomainSettingsRepository() domain.DomainSettingsRepository { + return &domainSettings{ + settings{}, + } +} + +var _ domain.DomainSettingsRepository = (*domainSettings)(nil) + +func (s domainSettings) Set(ctx context.Context, client database.QueryExecutor, settings *domain.DomainSettings) (err error) { + settings.Type = domain.SettingTypeDomain + settings.State = domain.SettingStateActive + + settingsJSON, err := json.Marshal(settings.DomainSettingsAttributes) + if err != nil { + return err + } + settings.Settings.Settings = settingsJSON + if err := s.set(ctx, client, &settings.Settings, s.SetSettingFields(settings.DomainSettingsAttributes)); err != nil { + return err + } + settings.Settings.Settings = nil + return nil +} + +func (s domainSettings) List(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*domain.DomainSettings, error) { + settings, err := s.list(ctx, client, opts...) + if err != nil { + return nil, err + } + + list := make([]*domain.DomainSettings, len(settings)) + for i := range settings { + list[i] = &domain.DomainSettings{Settings: *settings[i]} + if err := json.Unmarshal(list[i].Settings.Settings, &list[i].DomainSettingsAttributes); err != nil { + return nil, err + } + list[i].Settings.Settings = nil + } + return list, nil +} + +func (s domainSettings) Get(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) (*domain.DomainSettings, error) { + settings, err := s.get(ctx, client, opts...) + if err != nil { + return nil, err + } + + item := &domain.DomainSettings{Settings: *settings} + if err = json.Unmarshal(item.Settings.Settings, &item.DomainSettingsAttributes); err != nil { + return nil, err + } + item.Settings.Settings = nil + return item, nil +} + +// ------------------------------------------------------------- +// organization +// ------------------------------------------------------------- +type organizationSettings struct { + settings +} + +func (s organizationSettings) SetSettingFields(value domain.OrganizationSettingsAttributes) database.Change { + changes := make([]db_json.JsonUpdate, 0) + if value.OrganizationScopedUsernames != nil { + changes = append(changes, s.SetOrganizationScopedUsernames(*value.OrganizationScopedUsernames)) + } + return db_json.NewJsonChanges(s.SettingsColumn(), changes...) +} + +func (l organizationSettings) SetOrganizationScopedUsernames(value bool) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"organizationScopedUsernames"}, value) +} + +func OrganizationSettingsRepository() domain.OrganizationSettingsRepository { + return &organizationSettings{ + settings{}, + } +} + +var _ domain.OrganizationSettingsRepository = (*organizationSettings)(nil) + +func (s organizationSettings) Set(ctx context.Context, client database.QueryExecutor, settings *domain.OrganizationSettings) (err error) { + settings.Type = domain.SettingTypeOrganization + settings.State = domain.SettingStateActive + + settingsJSON, err := json.Marshal(settings.OrganizationSettingsAttributes) + if err != nil { + return err + } + settings.Settings.Settings = settingsJSON + if err := s.set(ctx, client, &settings.Settings, s.SetSettingFields(settings.OrganizationSettingsAttributes)); err != nil { + return err + } + settings.Settings.Settings = nil + return nil +} + +func (s organizationSettings) List(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*domain.OrganizationSettings, error) { + settings, err := s.list(ctx, client, opts...) + if err != nil { + return nil, err + } + + list := make([]*domain.OrganizationSettings, len(settings)) + for i := range settings { + list[i] = &domain.OrganizationSettings{Settings: *settings[i]} + if err := json.Unmarshal(list[i].Settings.Settings, &list[i].OrganizationSettingsAttributes); err != nil { + return nil, err + } + list[i].Settings.Settings = nil + } + return list, nil +} + +func (s organizationSettings) Get(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) (*domain.OrganizationSettings, error) { + settings, err := s.get(ctx, client, opts...) + if err != nil { + return nil, err + } + + item := &domain.OrganizationSettings{Settings: *settings} + if err = json.Unmarshal(item.Settings.Settings, &item.OrganizationSettingsAttributes); err != nil { + return nil, err + } + item.Settings.Settings = nil + return item, nil +} + +// ------------------------------------------------------------- +// notification +// ------------------------------------------------------------- +type notificationSettings struct { + settings +} + +func (s notificationSettings) SetSettingFields(value domain.NotificationSettingsAttributes) database.Change { + changes := make([]db_json.JsonUpdate, 0) + if value.PasswordChange != nil { + changes = append(changes, s.SetPasswordChange(*value.PasswordChange)) + } + return db_json.NewJsonChanges(s.SettingsColumn(), changes...) +} + +func (s notificationSettings) SetPasswordChange(value bool) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"passwordChange"}, value) +} + +func NotificationSettingsRepository() domain.NotificationSettingsRepository { + return ¬ificationSettings{ + settings{}, + } +} + +var _ domain.NotificationSettingsRepository = (*notificationSettings)(nil) + +func (s notificationSettings) Set(ctx context.Context, client database.QueryExecutor, settings *domain.NotificationSettings) (err error) { + settings.Type = domain.SettingTypeNotification + settings.State = domain.SettingStateActive + + settingsJSON, err := json.Marshal(settings.NotificationSettingsAttributes) + if err != nil { + return err + } + settings.Settings.Settings = settingsJSON + if err := s.set(ctx, client, &settings.Settings, s.SetSettingFields(settings.NotificationSettingsAttributes)); err != nil { + return err + } + settings.Settings.Settings = nil + return nil +} + +func (s notificationSettings) List(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*domain.NotificationSettings, error) { + settings, err := s.list(ctx, client, opts...) + if err != nil { + return nil, err + } + + list := make([]*domain.NotificationSettings, len(settings)) + for i := range settings { + list[i] = &domain.NotificationSettings{Settings: *settings[i]} + if err := json.Unmarshal(list[i].Settings.Settings, &list[i].NotificationSettingsAttributes); err != nil { + return nil, err + } + list[i].Settings.Settings = nil + } + return list, nil +} + +func (s notificationSettings) Get(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) (*domain.NotificationSettings, error) { + settings, err := s.get(ctx, client, opts...) + if err != nil { + return nil, err + } + + item := &domain.NotificationSettings{Settings: *settings} + if err = json.Unmarshal(item.Settings.Settings, &item.NotificationSettingsAttributes); err != nil { + return nil, err + } + item.Settings.Settings = nil + return item, nil +} + +// ------------------------------------------------------------- +// legal and support +// ------------------------------------------------------------- +type legalAndSupportSettings struct { + settings +} + +func (s legalAndSupportSettings) SetSettingFields(value domain.LegalAndSupportSettingsAttributes) database.Change { + changes := make([]db_json.JsonUpdate, 0) + if value.TOSLink != nil { + changes = append(changes, s.SetTOSLink(*value.TOSLink)) + } + if value.PrivacyPolicyLink != nil { + changes = append(changes, s.SetPrivacyPolicyLink(*value.PrivacyPolicyLink)) + } + if value.HelpLink != nil { + changes = append(changes, s.SetHelpLink(*value.HelpLink)) + } + if value.SupportEmail != nil { + changes = append(changes, s.SetSupportEmail(*value.SupportEmail)) + } + if value.DocsLink != nil { + changes = append(changes, s.SetDocsLink(*value.DocsLink)) + } + if value.CustomLink != nil { + changes = append(changes, s.SetCustomLink(*value.CustomLink)) + } + if value.CustomLinkText != nil { + changes = append(changes, s.SetCustomLinkText(*value.CustomLinkText)) + } + return db_json.NewJsonChanges(s.SettingsColumn(), changes...) +} + +func (s legalAndSupportSettings) SetTOSLink(value string) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"tosLink"}, value) +} + +func (s legalAndSupportSettings) SetPrivacyPolicyLink(value string) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"privacyPolicyLink"}, value) +} + +func (s legalAndSupportSettings) SetHelpLink(value string) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"helpLink"}, value) +} + +func (s legalAndSupportSettings) SetSupportEmail(value string) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"supportEmail"}, value) +} + +func (s legalAndSupportSettings) SetDocsLink(value string) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"docsLink"}, value) +} + +func (s legalAndSupportSettings) SetCustomLink(value string) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"customLink"}, value) +} + +func (s legalAndSupportSettings) SetCustomLinkText(value string) db_json.JsonUpdate { + return db_json.NewFieldChange([]string{"customLinkText"}, value) +} + +func LegalAndSupportSettingsRepository() domain.LegalAndSupportSettingsRepository { + return &legalAndSupportSettings{ + settings{}, + } +} + +var _ domain.LegalAndSupportSettingsRepository = (*legalAndSupportSettings)(nil) + +func (s legalAndSupportSettings) Set(ctx context.Context, client database.QueryExecutor, settings *domain.LegalAndSupportSettings) (err error) { + settings.Type = domain.SettingTypeLegalAndSupport + settings.State = domain.SettingStateActive + + settingsJSON, err := json.Marshal(settings.LegalAndSupportSettingsAttributes) + if err != nil { + return err + } + settings.Settings.Settings = settingsJSON + if err := s.set(ctx, client, &settings.Settings, s.SetSettingFields(settings.LegalAndSupportSettingsAttributes)); err != nil { + return err + } + settings.Settings.Settings = nil + return nil +} + +func (s legalAndSupportSettings) List(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*domain.LegalAndSupportSettings, error) { + settings, err := s.list(ctx, client, opts...) + if err != nil { + return nil, err + } + + list := make([]*domain.LegalAndSupportSettings, len(settings)) + for i := range settings { + list[i] = &domain.LegalAndSupportSettings{Settings: *settings[i]} + if err := json.Unmarshal(list[i].Settings.Settings, &list[i].LegalAndSupportSettingsAttributes); err != nil { + return nil, err + } + list[i].Settings.Settings = nil + } + return list, nil +} + +func (s legalAndSupportSettings) Get(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) (*domain.LegalAndSupportSettings, error) { + settings, err := s.get(ctx, client, opts...) + if err != nil { + return nil, err + } + + item := &domain.LegalAndSupportSettings{Settings: *settings} + if err = json.Unmarshal(item.Settings.Settings, &item.LegalAndSupportSettingsAttributes); err != nil { + return nil, err + } + item.Settings.Settings = nil + return item, nil +} + +// ------------------------------------------------------------- +// helpers +// ------------------------------------------------------------- + +const querySettingsStmt = `SELECT + settings.instance_id, + settings.organization_id, + settings.id, + settings.type, + settings.state, + settings.settings, + settings.created_at, + settings.updated_at + FROM ` + +func (s settings) prepareQuery(opts []database.QueryOption) (*database.StatementBuilder, error) { + options := new(database.QueryOpts) + for _, opt := range opts { + opt(options) + } + if err := checkRestrictingColumns(options.Condition, s.InstanceIDColumn()); err != nil { + return nil, err + } + builder := database.NewStatementBuilder(querySettingsStmt + s.qualifiedTableName()) + options.Write(builder) + + return builder, nil +} diff --git a/backend/v3/storage/database/repository/settings_test.go b/backend/v3/storage/database/repository/settings_test.go new file mode 100644 index 000000000..c8af17090 --- /dev/null +++ b/backend/v3/storage/database/repository/settings_test.go @@ -0,0 +1,4756 @@ +package repository_test + +import ( + "net/url" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/backend/v3/domain" + "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/backend/v3/storage/database/repository" +) + +func TestGetLoginSettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + firstInstanceID := createInstance(t, tx) + secondInstanceID := createInstance(t, tx) + firstOrgID := createOrganization(t, tx, firstInstanceID) + secondOrgID := createOrganization(t, tx, secondInstanceID) + repo := repository.LoginSettingsRepository() + + settings := []*domain.LoginSettings{ + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: nil, + }, + LoginSettingsAttributes: domain.LoginSettingsAttributes{ + AllowUserNamePassword: gu.Ptr(true), + AllowRegister: gu.Ptr(true), + AllowExternalIDP: gu.Ptr(true), + ForceMFA: gu.Ptr(true), + ForceMFALocalOnly: gu.Ptr(true), + HidePasswordReset: gu.Ptr(true), + IgnoreUnknownUsernames: gu.Ptr(true), + AllowDomainDiscovery: gu.Ptr(true), + DisableLoginWithEmail: gu.Ptr(true), + DisableLoginWithPhone: gu.Ptr(true), + PasswordlessType: gu.Ptr(domain.PasswordlessTypeAllowed), + DefaultRedirectURI: gu.Ptr("uri"), + PasswordCheckLifetime: gu.Ptr(time.Hour), + ExternalLoginCheckLifetime: gu.Ptr(time.Hour), + MFAInitSkipLifetime: gu.Ptr(time.Hour), + SecondFactorCheckLifetime: gu.Ptr(time.Hour), + MultiFactorCheckLifetime: gu.Ptr(time.Hour), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: nil, + }, + LoginSettingsAttributes: domain.LoginSettingsAttributes{ + AllowUserNamePassword: gu.Ptr(false), + AllowRegister: gu.Ptr(false), + AllowExternalIDP: gu.Ptr(false), + ForceMFA: gu.Ptr(false), + ForceMFALocalOnly: gu.Ptr(false), + HidePasswordReset: gu.Ptr(false), + IgnoreUnknownUsernames: gu.Ptr(false), + AllowDomainDiscovery: gu.Ptr(false), + DisableLoginWithEmail: gu.Ptr(false), + DisableLoginWithPhone: gu.Ptr(false), + PasswordlessType: gu.Ptr(domain.PasswordlessTypeNotAllowed), + DefaultRedirectURI: gu.Ptr("uri2"), + PasswordCheckLifetime: gu.Ptr(time.Minute), + ExternalLoginCheckLifetime: gu.Ptr(time.Minute), + MFAInitSkipLifetime: gu.Ptr(time.Minute), + SecondFactorCheckLifetime: gu.Ptr(time.Minute), + MultiFactorCheckLifetime: gu.Ptr(time.Minute), + }, + }, + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: gu.Ptr(firstOrgID), + }, + LoginSettingsAttributes: domain.LoginSettingsAttributes{ + AllowUserNamePassword: gu.Ptr(true), + AllowRegister: gu.Ptr(true), + AllowExternalIDP: gu.Ptr(true), + ForceMFA: gu.Ptr(true), + ForceMFALocalOnly: gu.Ptr(true), + HidePasswordReset: gu.Ptr(true), + IgnoreUnknownUsernames: gu.Ptr(true), + AllowDomainDiscovery: gu.Ptr(true), + DisableLoginWithEmail: gu.Ptr(true), + DisableLoginWithPhone: gu.Ptr(true), + PasswordlessType: gu.Ptr(domain.PasswordlessTypeAllowed), + DefaultRedirectURI: gu.Ptr("uri"), + PasswordCheckLifetime: gu.Ptr(time.Hour), + ExternalLoginCheckLifetime: gu.Ptr(time.Hour), + MFAInitSkipLifetime: gu.Ptr(time.Hour), + SecondFactorCheckLifetime: gu.Ptr(time.Hour), + MultiFactorCheckLifetime: gu.Ptr(time.Hour), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: gu.Ptr(secondOrgID), + }, + LoginSettingsAttributes: domain.LoginSettingsAttributes{ + AllowUserNamePassword: gu.Ptr(false), + AllowRegister: gu.Ptr(false), + AllowExternalIDP: gu.Ptr(false), + ForceMFA: gu.Ptr(false), + ForceMFALocalOnly: gu.Ptr(false), + HidePasswordReset: gu.Ptr(false), + IgnoreUnknownUsernames: gu.Ptr(false), + AllowDomainDiscovery: gu.Ptr(false), + DisableLoginWithEmail: gu.Ptr(false), + DisableLoginWithPhone: gu.Ptr(false), + PasswordlessType: gu.Ptr(domain.PasswordlessTypeNotAllowed), + DefaultRedirectURI: gu.Ptr("uri2"), + PasswordCheckLifetime: gu.Ptr(time.Minute), + ExternalLoginCheckLifetime: gu.Ptr(time.Minute), + MFAInitSkipLifetime: gu.Ptr(time.Minute), + SecondFactorCheckLifetime: gu.Ptr(time.Minute), + MultiFactorCheckLifetime: gu.Ptr(time.Minute), + }, + }, + } + + for _, setting := range settings { + err := repo.Set(t.Context(), tx, setting) + require.NoError(t, err) + } + + tests := []struct { + name string + condition database.Condition + want *domain.LoginSettings + wantErr error + }{ + { + name: "incomplete condition", + condition: repo.IDCondition(settings[0].ID), + wantErr: database.NewMissingConditionError(repo.IDColumn()), + }, + { + name: "not found", + condition: repo.PrimaryKeyCondition(firstInstanceID, "nix"), + wantErr: database.NewNoRowFoundError(nil), + }, + { + name: "too many", + condition: repo.InstanceIDCondition(firstInstanceID), + wantErr: database.NewMultipleRowsFoundError(nil), + }, + { + name: "ok, instance", + condition: repo.PrimaryKeyCondition(firstInstanceID, settings[0].ID), + want: settings[0], + }, + { + name: "ok, organization", + condition: repo.PrimaryKeyCondition(secondInstanceID, settings[3].ID), + want: settings[3], + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := repo.Get(t.Context(), tx, database.WithCondition(tt.condition)) + require.ErrorIs(t, err, tt.wantErr) + assert.EqualExportedValues(t, tt.want, got) + }) + } +} + +func TestListLoginSettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + firstInstanceID := createInstance(t, tx) + secondInstanceID := createInstance(t, tx) + firstOrgID := createOrganization(t, tx, firstInstanceID) + secondOrgID := createOrganization(t, tx, secondInstanceID) + repo := repository.LoginSettingsRepository() + + settings := []*domain.LoginSettings{ + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: nil, + }, + LoginSettingsAttributes: domain.LoginSettingsAttributes{ + AllowUserNamePassword: gu.Ptr(true), + AllowRegister: gu.Ptr(true), + AllowExternalIDP: gu.Ptr(true), + ForceMFA: gu.Ptr(true), + ForceMFALocalOnly: gu.Ptr(true), + HidePasswordReset: gu.Ptr(true), + IgnoreUnknownUsernames: gu.Ptr(true), + AllowDomainDiscovery: gu.Ptr(true), + DisableLoginWithEmail: gu.Ptr(true), + DisableLoginWithPhone: gu.Ptr(true), + PasswordlessType: gu.Ptr(domain.PasswordlessTypeAllowed), + DefaultRedirectURI: gu.Ptr("uri"), + PasswordCheckLifetime: gu.Ptr(time.Hour), + ExternalLoginCheckLifetime: gu.Ptr(time.Hour), + MFAInitSkipLifetime: gu.Ptr(time.Hour), + SecondFactorCheckLifetime: gu.Ptr(time.Hour), + MultiFactorCheckLifetime: gu.Ptr(time.Hour), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: nil, + }, + LoginSettingsAttributes: domain.LoginSettingsAttributes{ + AllowUserNamePassword: gu.Ptr(false), + AllowRegister: gu.Ptr(false), + AllowExternalIDP: gu.Ptr(false), + ForceMFA: gu.Ptr(false), + ForceMFALocalOnly: gu.Ptr(false), + HidePasswordReset: gu.Ptr(false), + IgnoreUnknownUsernames: gu.Ptr(false), + AllowDomainDiscovery: gu.Ptr(false), + DisableLoginWithEmail: gu.Ptr(false), + DisableLoginWithPhone: gu.Ptr(false), + PasswordlessType: gu.Ptr(domain.PasswordlessTypeNotAllowed), + DefaultRedirectURI: gu.Ptr("uri2"), + PasswordCheckLifetime: gu.Ptr(time.Minute), + ExternalLoginCheckLifetime: gu.Ptr(time.Minute), + MFAInitSkipLifetime: gu.Ptr(time.Minute), + SecondFactorCheckLifetime: gu.Ptr(time.Minute), + MultiFactorCheckLifetime: gu.Ptr(time.Minute), + }, + }, + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: gu.Ptr(firstOrgID), + }, + LoginSettingsAttributes: domain.LoginSettingsAttributes{ + AllowUserNamePassword: gu.Ptr(true), + AllowRegister: gu.Ptr(true), + AllowExternalIDP: gu.Ptr(true), + ForceMFA: gu.Ptr(true), + ForceMFALocalOnly: gu.Ptr(true), + HidePasswordReset: gu.Ptr(true), + IgnoreUnknownUsernames: gu.Ptr(true), + AllowDomainDiscovery: gu.Ptr(true), + DisableLoginWithEmail: gu.Ptr(true), + DisableLoginWithPhone: gu.Ptr(true), + PasswordlessType: gu.Ptr(domain.PasswordlessTypeAllowed), + DefaultRedirectURI: gu.Ptr("uri"), + PasswordCheckLifetime: gu.Ptr(time.Hour), + ExternalLoginCheckLifetime: gu.Ptr(time.Hour), + MFAInitSkipLifetime: gu.Ptr(time.Hour), + SecondFactorCheckLifetime: gu.Ptr(time.Hour), + MultiFactorCheckLifetime: gu.Ptr(time.Hour), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: gu.Ptr(secondOrgID), + }, + LoginSettingsAttributes: domain.LoginSettingsAttributes{ + AllowUserNamePassword: gu.Ptr(false), + AllowRegister: gu.Ptr(false), + AllowExternalIDP: gu.Ptr(false), + ForceMFA: gu.Ptr(false), + ForceMFALocalOnly: gu.Ptr(false), + HidePasswordReset: gu.Ptr(false), + IgnoreUnknownUsernames: gu.Ptr(false), + AllowDomainDiscovery: gu.Ptr(false), + DisableLoginWithEmail: gu.Ptr(false), + DisableLoginWithPhone: gu.Ptr(false), + PasswordlessType: gu.Ptr(domain.PasswordlessTypeNotAllowed), + DefaultRedirectURI: gu.Ptr("uri2"), + PasswordCheckLifetime: gu.Ptr(time.Minute), + ExternalLoginCheckLifetime: gu.Ptr(time.Minute), + MFAInitSkipLifetime: gu.Ptr(time.Minute), + SecondFactorCheckLifetime: gu.Ptr(time.Minute), + MultiFactorCheckLifetime: gu.Ptr(time.Minute), + }, + }, + } + + for _, setting := range settings { + err := repo.Set(t.Context(), tx, setting) + require.NoError(t, err) + } + + tests := []struct { + name string + condition database.Condition + want []*domain.LoginSettings + wantErr error + }{ + { + name: "incomplete condition", + condition: repo.OrganizationIDCondition(gu.Ptr(firstOrgID)), + wantErr: database.NewMissingConditionError(repo.IDColumn()), + }, + { + name: "no results, ok", + condition: repo.PrimaryKeyCondition(firstInstanceID, "nix"), + want: []*domain.LoginSettings{}, + }, + { + name: "all from instance", + condition: repo.InstanceIDCondition(firstInstanceID), + want: []*domain.LoginSettings{settings[2], settings[0]}, + }, + { + name: "only from instance", + condition: database.And( + repo.InstanceIDCondition(firstInstanceID), + repo.OrganizationIDCondition(nil), + ), + want: []*domain.LoginSettings{settings[0]}, + }, + { + name: "all from first org", + condition: database.And( + repo.InstanceIDCondition(firstInstanceID), + repo.OrganizationIDCondition(gu.Ptr(firstOrgID)), + ), + want: []*domain.LoginSettings{settings[2]}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := repo.List(t.Context(), tx, + database.WithCondition(tt.condition), + database.WithOrderByAscending(repo.InstanceIDColumn(), repo.OrganizationIDColumn()), + ) + require.ErrorIs(t, err, tt.wantErr) + assert.EqualExportedValues(t, tt.want, got) + }) + } +} + +func TestSetLoginSettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + instanceID := createInstance(t, tx) + orgID := createOrganization(t, tx, instanceID) + repo := repository.LoginSettingsRepository() + + existingSettings := &domain.LoginSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr(orgID), + }, + LoginSettingsAttributes: domain.LoginSettingsAttributes{ + AllowUserNamePassword: gu.Ptr(false), + AllowRegister: gu.Ptr(false), + AllowExternalIDP: gu.Ptr(false), + ForceMFA: gu.Ptr(false), + ForceMFALocalOnly: gu.Ptr(false), + HidePasswordReset: gu.Ptr(false), + IgnoreUnknownUsernames: gu.Ptr(false), + AllowDomainDiscovery: gu.Ptr(false), + DisableLoginWithEmail: gu.Ptr(false), + DisableLoginWithPhone: gu.Ptr(false), + PasswordlessType: gu.Ptr(domain.PasswordlessTypeNotAllowed), + DefaultRedirectURI: gu.Ptr("uri2"), + PasswordCheckLifetime: gu.Ptr(time.Minute), + ExternalLoginCheckLifetime: gu.Ptr(time.Minute), + MFAInitSkipLifetime: gu.Ptr(time.Minute), + SecondFactorCheckLifetime: gu.Ptr(time.Minute), + MultiFactorCheckLifetime: gu.Ptr(time.Minute), + }, + } + + err := repo.Set(t.Context(), tx, existingSettings) + require.NoError(t, err) + + tests := []struct { + name string + settings *domain.LoginSettings + wantErr error + }{ + { + name: "create instance", + settings: &domain.LoginSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: nil, + }, + LoginSettingsAttributes: domain.LoginSettingsAttributes{ + AllowUserNamePassword: gu.Ptr(true), + AllowRegister: gu.Ptr(true), + AllowExternalIDP: gu.Ptr(true), + ForceMFA: gu.Ptr(true), + ForceMFALocalOnly: gu.Ptr(true), + HidePasswordReset: gu.Ptr(true), + IgnoreUnknownUsernames: gu.Ptr(true), + AllowDomainDiscovery: gu.Ptr(true), + DisableLoginWithEmail: gu.Ptr(true), + DisableLoginWithPhone: gu.Ptr(true), + PasswordlessType: gu.Ptr(domain.PasswordlessTypeAllowed), + DefaultRedirectURI: gu.Ptr("uri"), + PasswordCheckLifetime: gu.Ptr(time.Hour), + ExternalLoginCheckLifetime: gu.Ptr(time.Hour), + MFAInitSkipLifetime: gu.Ptr(time.Hour), + SecondFactorCheckLifetime: gu.Ptr(time.Hour), + MultiFactorCheckLifetime: gu.Ptr(time.Hour), + }, + }, + }, + { + name: "update organization", + settings: &domain.LoginSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr(orgID), + }, + LoginSettingsAttributes: domain.LoginSettingsAttributes{ + AllowUserNamePassword: gu.Ptr(true), + AllowRegister: gu.Ptr(true), + AllowExternalIDP: gu.Ptr(true), + ForceMFA: gu.Ptr(true), + ForceMFALocalOnly: gu.Ptr(true), + HidePasswordReset: gu.Ptr(true), + IgnoreUnknownUsernames: gu.Ptr(true), + AllowDomainDiscovery: gu.Ptr(true), + DisableLoginWithEmail: gu.Ptr(true), + DisableLoginWithPhone: gu.Ptr(true), + PasswordlessType: gu.Ptr(domain.PasswordlessTypeAllowed), + DefaultRedirectURI: gu.Ptr("uri"), + PasswordCheckLifetime: gu.Ptr(time.Hour), + ExternalLoginCheckLifetime: gu.Ptr(time.Hour), + MFAInitSkipLifetime: gu.Ptr(time.Hour), + SecondFactorCheckLifetime: gu.Ptr(time.Hour), + MultiFactorCheckLifetime: gu.Ptr(time.Hour), + }, + }, + }, + { + name: "non-existing instance", + settings: &domain.LoginSettings{ + Settings: domain.Settings{ + InstanceID: "foo", + OrganizationID: nil, + }, + LoginSettingsAttributes: domain.LoginSettingsAttributes{ + AllowUserNamePassword: gu.Ptr(true), + AllowRegister: gu.Ptr(true), + AllowExternalIDP: gu.Ptr(true), + ForceMFA: gu.Ptr(true), + ForceMFALocalOnly: gu.Ptr(true), + HidePasswordReset: gu.Ptr(true), + IgnoreUnknownUsernames: gu.Ptr(true), + AllowDomainDiscovery: gu.Ptr(true), + DisableLoginWithEmail: gu.Ptr(true), + DisableLoginWithPhone: gu.Ptr(true), + PasswordlessType: gu.Ptr(domain.PasswordlessTypeAllowed), + DefaultRedirectURI: gu.Ptr("uri"), + PasswordCheckLifetime: gu.Ptr(time.Hour), + ExternalLoginCheckLifetime: gu.Ptr(time.Hour), + MFAInitSkipLifetime: gu.Ptr(time.Hour), + SecondFactorCheckLifetime: gu.Ptr(time.Hour), + MultiFactorCheckLifetime: gu.Ptr(time.Hour), + }, + }, + wantErr: new(database.ForeignKeyError), + }, + { + name: "non-existing org", + settings: &domain.LoginSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr("foo"), + }, + LoginSettingsAttributes: domain.LoginSettingsAttributes{ + AllowUserNamePassword: gu.Ptr(true), + AllowRegister: gu.Ptr(true), + AllowExternalIDP: gu.Ptr(true), + ForceMFA: gu.Ptr(true), + ForceMFALocalOnly: gu.Ptr(true), + HidePasswordReset: gu.Ptr(true), + IgnoreUnknownUsernames: gu.Ptr(true), + AllowDomainDiscovery: gu.Ptr(true), + DisableLoginWithEmail: gu.Ptr(true), + DisableLoginWithPhone: gu.Ptr(true), + PasswordlessType: gu.Ptr(domain.PasswordlessTypeAllowed), + DefaultRedirectURI: gu.Ptr("uri"), + PasswordCheckLifetime: gu.Ptr(time.Hour), + ExternalLoginCheckLifetime: gu.Ptr(time.Hour), + MFAInitSkipLifetime: gu.Ptr(time.Hour), + SecondFactorCheckLifetime: gu.Ptr(time.Hour), + MultiFactorCheckLifetime: gu.Ptr(time.Hour), + }, + }, + wantErr: new(database.ForeignKeyError), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + savepoint, rollback := savepointForRollback(t, tx) + defer rollback() + err := repo.Set(t.Context(), savepoint, tt.settings) + require.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestDeleteLoginSettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + instanceID := createInstance(t, tx) + orgID := createOrganization(t, tx, instanceID) + repo := repository.LoginSettingsRepository() + + existingInstanceSettings := &domain.LoginSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: nil, + }, + LoginSettingsAttributes: domain.LoginSettingsAttributes{ + AllowUserNamePassword: gu.Ptr(false), + AllowRegister: gu.Ptr(false), + AllowExternalIDP: gu.Ptr(false), + ForceMFA: gu.Ptr(false), + ForceMFALocalOnly: gu.Ptr(false), + HidePasswordReset: gu.Ptr(false), + IgnoreUnknownUsernames: gu.Ptr(false), + AllowDomainDiscovery: gu.Ptr(false), + DisableLoginWithEmail: gu.Ptr(false), + DisableLoginWithPhone: gu.Ptr(false), + PasswordlessType: gu.Ptr(domain.PasswordlessTypeNotAllowed), + DefaultRedirectURI: gu.Ptr("uri"), + PasswordCheckLifetime: gu.Ptr(time.Minute), + ExternalLoginCheckLifetime: gu.Ptr(time.Minute), + MFAInitSkipLifetime: gu.Ptr(time.Minute), + SecondFactorCheckLifetime: gu.Ptr(time.Minute), + MultiFactorCheckLifetime: gu.Ptr(time.Minute), + }, + } + err := repo.Set(t.Context(), tx, existingInstanceSettings) + require.NoError(t, err) + + existingOrganizationSettings := &domain.LoginSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr(orgID), + }, + LoginSettingsAttributes: domain.LoginSettingsAttributes{ + AllowUserNamePassword: gu.Ptr(false), + AllowRegister: gu.Ptr(false), + AllowExternalIDP: gu.Ptr(false), + ForceMFA: gu.Ptr(false), + ForceMFALocalOnly: gu.Ptr(false), + HidePasswordReset: gu.Ptr(false), + IgnoreUnknownUsernames: gu.Ptr(false), + AllowDomainDiscovery: gu.Ptr(false), + DisableLoginWithEmail: gu.Ptr(false), + DisableLoginWithPhone: gu.Ptr(false), + PasswordlessType: gu.Ptr(domain.PasswordlessTypeNotAllowed), + DefaultRedirectURI: gu.Ptr("uri"), + PasswordCheckLifetime: gu.Ptr(time.Minute), + ExternalLoginCheckLifetime: gu.Ptr(time.Minute), + MFAInitSkipLifetime: gu.Ptr(time.Minute), + SecondFactorCheckLifetime: gu.Ptr(time.Minute), + MultiFactorCheckLifetime: gu.Ptr(time.Minute), + }, + } + err = repo.Set(t.Context(), tx, existingOrganizationSettings) + require.NoError(t, err) + + tests := []struct { + name string + condition database.Condition + wantRowsAffected int64 + wantErr error + }{ + { + name: "incomplete condition", + condition: repo.InstanceIDCondition(instanceID), + wantRowsAffected: 0, + wantErr: database.NewMissingConditionError(repo.IDColumn()), + }, + { + name: "not found", + condition: repo.UniqueCondition(instanceID, gu.Ptr("foo"), domain.SettingTypeLogin, domain.SettingStateActive), + wantRowsAffected: 0, + }, + { + name: "delete instance", + condition: repo.UniqueCondition(existingInstanceSettings.InstanceID, existingInstanceSettings.OrganizationID, domain.SettingTypeLogin, domain.SettingStateActive), + wantRowsAffected: 1, + }, + { + name: "delete instance twice", + condition: repo.UniqueCondition(existingInstanceSettings.InstanceID, existingInstanceSettings.OrganizationID, domain.SettingTypeLogin, domain.SettingStateActive), + wantRowsAffected: 0, + }, + { + name: "delete organization", + condition: repo.UniqueCondition(existingOrganizationSettings.InstanceID, existingOrganizationSettings.OrganizationID, domain.SettingTypeLogin, domain.SettingStateActive), + wantRowsAffected: 1, + }, + { + name: "delete organization twice", + condition: repo.UniqueCondition(existingOrganizationSettings.InstanceID, existingOrganizationSettings.OrganizationID, domain.SettingTypeLogin, domain.SettingStateActive), + wantRowsAffected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rowsAffected, err := repo.Delete(t.Context(), tx, tt.condition) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.wantRowsAffected, rowsAffected) + }) + } +} + +func TestGetBrandingSettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + firstInstanceID := createInstance(t, tx) + secondInstanceID := createInstance(t, tx) + firstOrgID := createOrganization(t, tx, firstInstanceID) + secondOrgID := createOrganization(t, tx, secondInstanceID) + repo := repository.BrandingSettingsRepository() + + settings := []*domain.BrandingSettings{ + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: nil, + }, + BrandingSettingsAttributes: domain.BrandingSettingsAttributes{ + PrimaryColorLight: gu.Ptr("color"), + BackgroundColorLight: gu.Ptr("color"), + WarnColorLight: gu.Ptr("color"), + FontColorLight: gu.Ptr("color"), + LogoURLLight: &url.URL{Scheme: "https", Host: "host"}, + IconURLLight: &url.URL{Scheme: "https", Host: "host"}, + PrimaryColorDark: gu.Ptr("color"), + BackgroundColorDark: gu.Ptr("color"), + WarnColorDark: gu.Ptr("color"), + FontColorDark: gu.Ptr("color"), + LogoURLDark: &url.URL{Scheme: "https", Host: "host"}, + IconURLDark: &url.URL{Scheme: "https", Host: "host"}, + HideLoginNameSuffix: gu.Ptr(true), + ErrorMsgPopup: gu.Ptr(true), + DisableWatermark: gu.Ptr(true), + ThemeMode: gu.Ptr(domain.BrandingPolicyThemeAuto), + FontURL: &url.URL{Scheme: "https", Host: "host"}, + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: nil, + }, + BrandingSettingsAttributes: domain.BrandingSettingsAttributes{ + PrimaryColorLight: gu.Ptr("color"), + BackgroundColorLight: gu.Ptr("color"), + WarnColorLight: gu.Ptr("color"), + FontColorLight: gu.Ptr("color"), + LogoURLLight: &url.URL{Scheme: "https", Host: "host"}, + IconURLLight: &url.URL{Scheme: "https", Host: "host"}, + PrimaryColorDark: gu.Ptr("color"), + BackgroundColorDark: gu.Ptr("color"), + WarnColorDark: gu.Ptr("color"), + FontColorDark: gu.Ptr("color"), + LogoURLDark: &url.URL{Scheme: "https", Host: "host"}, + IconURLDark: &url.URL{Scheme: "https", Host: "host"}, + HideLoginNameSuffix: gu.Ptr(true), + ErrorMsgPopup: gu.Ptr(true), + DisableWatermark: gu.Ptr(true), + ThemeMode: gu.Ptr(domain.BrandingPolicyThemeAuto), + FontURL: &url.URL{Scheme: "https", Host: "host"}, + }, + }, + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: gu.Ptr(firstOrgID), + }, + BrandingSettingsAttributes: domain.BrandingSettingsAttributes{ + PrimaryColorLight: gu.Ptr("color"), + BackgroundColorLight: gu.Ptr("color"), + WarnColorLight: gu.Ptr("color"), + FontColorLight: gu.Ptr("color"), + LogoURLLight: &url.URL{Scheme: "https", Host: "host"}, + IconURLLight: &url.URL{Scheme: "https", Host: "host"}, + PrimaryColorDark: gu.Ptr("color"), + BackgroundColorDark: gu.Ptr("color"), + WarnColorDark: gu.Ptr("color"), + FontColorDark: gu.Ptr("color"), + LogoURLDark: &url.URL{Scheme: "https", Host: "host"}, + IconURLDark: &url.URL{Scheme: "https", Host: "host"}, + HideLoginNameSuffix: gu.Ptr(true), + ErrorMsgPopup: gu.Ptr(true), + DisableWatermark: gu.Ptr(true), + ThemeMode: gu.Ptr(domain.BrandingPolicyThemeAuto), + FontURL: &url.URL{Scheme: "https", Host: "host"}, + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: gu.Ptr(secondOrgID), + }, + BrandingSettingsAttributes: domain.BrandingSettingsAttributes{ + PrimaryColorLight: gu.Ptr("color"), + BackgroundColorLight: gu.Ptr("color"), + WarnColorLight: gu.Ptr("color"), + FontColorLight: gu.Ptr("color"), + LogoURLLight: &url.URL{Scheme: "https", Host: "host"}, + IconURLLight: &url.URL{Scheme: "https", Host: "host"}, + PrimaryColorDark: gu.Ptr("color"), + BackgroundColorDark: gu.Ptr("color"), + WarnColorDark: gu.Ptr("color"), + FontColorDark: gu.Ptr("color"), + LogoURLDark: &url.URL{Scheme: "https", Host: "host"}, + IconURLDark: &url.URL{Scheme: "https", Host: "host"}, + HideLoginNameSuffix: gu.Ptr(true), + ErrorMsgPopup: gu.Ptr(true), + DisableWatermark: gu.Ptr(true), + ThemeMode: gu.Ptr(domain.BrandingPolicyThemeAuto), + FontURL: &url.URL{Scheme: "https", Host: "host"}, + }, + }, + } + + for _, setting := range settings { + err := repo.Set(t.Context(), tx, setting) + require.NoError(t, err) + } + _, err := repo.Activate(t.Context(), tx, repo.UniqueCondition(settings[0].InstanceID, settings[0].OrganizationID, domain.SettingTypeBranding, domain.SettingStatePreview)) + require.NoError(t, err) + activeInstanceSetting := *settings[0] + activeInstanceSetting.State = domain.SettingStateActive + + _, err = repo.Activate(t.Context(), tx, repo.UniqueCondition(settings[3].InstanceID, settings[3].OrganizationID, domain.SettingTypeBranding, domain.SettingStatePreview)) + require.NoError(t, err) + activeOrganizationSetting := *settings[3] + activeOrganizationSetting.State = domain.SettingStateActive + + tests := []struct { + name string + condition database.Condition + want *domain.BrandingSettings + wantErr error + }{ + { + name: "incomplete condition", + condition: repo.IDCondition(settings[0].ID), + wantErr: database.NewMissingConditionError(repo.IDColumn()), + }, + { + name: "not found", + condition: repo.PrimaryKeyCondition(firstInstanceID, "nix"), + wantErr: database.NewNoRowFoundError(nil), + }, + { + name: "too many", + condition: repo.InstanceIDCondition(firstInstanceID), + wantErr: database.NewMultipleRowsFoundError(nil), + }, + { + name: "ok, instance", + condition: database.And( + repo.PrimaryKeyCondition(settings[0].InstanceID, settings[0].ID), + repo.StateCondition(domain.SettingStatePreview), + ), + want: settings[0], + }, + { + name: "ok, instance, active", + condition: database.And( + repo.PrimaryKeyCondition(activeInstanceSetting.InstanceID, activeInstanceSetting.ID), + repo.StateCondition(domain.SettingStateActive), + ), + want: &activeInstanceSetting, + }, + { + name: "ok, organization", + condition: database.And( + repo.PrimaryKeyCondition(settings[3].InstanceID, settings[3].ID), + repo.StateCondition(domain.SettingStatePreview), + ), + want: settings[3], + }, + { + name: "ok, organization", + condition: database.And( + repo.PrimaryKeyCondition(activeOrganizationSetting.InstanceID, activeOrganizationSetting.ID), + repo.StateCondition(domain.SettingStateActive), + ), + want: &activeOrganizationSetting, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := repo.Get(t.Context(), tx, database.WithCondition(tt.condition)) + require.ErrorIs(t, err, tt.wantErr) + assert.EqualExportedValues(t, tt.want, got) + }) + } +} + +func TestListBrandingSettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + firstInstanceID := createInstance(t, tx) + secondInstanceID := createInstance(t, tx) + firstOrgID := createOrganization(t, tx, firstInstanceID) + secondOrgID := createOrganization(t, tx, secondInstanceID) + repo := repository.BrandingSettingsRepository() + + settings := []*domain.BrandingSettings{ + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: nil, + }, + BrandingSettingsAttributes: domain.BrandingSettingsAttributes{ + PrimaryColorLight: gu.Ptr("color"), + BackgroundColorLight: gu.Ptr("color"), + WarnColorLight: gu.Ptr("color"), + FontColorLight: gu.Ptr("color"), + LogoURLLight: &url.URL{Scheme: "https", Host: "host"}, + IconURLLight: &url.URL{Scheme: "https", Host: "host"}, + PrimaryColorDark: gu.Ptr("color"), + BackgroundColorDark: gu.Ptr("color"), + WarnColorDark: gu.Ptr("color"), + FontColorDark: gu.Ptr("color"), + LogoURLDark: &url.URL{Scheme: "https", Host: "host"}, + IconURLDark: &url.URL{Scheme: "https", Host: "host"}, + HideLoginNameSuffix: gu.Ptr(true), + ErrorMsgPopup: gu.Ptr(true), + DisableWatermark: gu.Ptr(true), + ThemeMode: gu.Ptr(domain.BrandingPolicyThemeAuto), + FontURL: &url.URL{Scheme: "https", Host: "host"}, + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: nil, + }, + BrandingSettingsAttributes: domain.BrandingSettingsAttributes{ + PrimaryColorLight: gu.Ptr("color"), + BackgroundColorLight: gu.Ptr("color"), + WarnColorLight: gu.Ptr("color"), + FontColorLight: gu.Ptr("color"), + LogoURLLight: &url.URL{Scheme: "https", Host: "host"}, + IconURLLight: &url.URL{Scheme: "https", Host: "host"}, + PrimaryColorDark: gu.Ptr("color"), + BackgroundColorDark: gu.Ptr("color"), + WarnColorDark: gu.Ptr("color"), + FontColorDark: gu.Ptr("color"), + LogoURLDark: &url.URL{Scheme: "https", Host: "host"}, + IconURLDark: &url.URL{Scheme: "https", Host: "host"}, + HideLoginNameSuffix: gu.Ptr(true), + ErrorMsgPopup: gu.Ptr(true), + DisableWatermark: gu.Ptr(true), + ThemeMode: gu.Ptr(domain.BrandingPolicyThemeAuto), + FontURL: &url.URL{Scheme: "https", Host: "host"}, + }, + }, + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: gu.Ptr(firstOrgID), + }, + BrandingSettingsAttributes: domain.BrandingSettingsAttributes{ + PrimaryColorLight: gu.Ptr("color"), + BackgroundColorLight: gu.Ptr("color"), + WarnColorLight: gu.Ptr("color"), + FontColorLight: gu.Ptr("color"), + LogoURLLight: &url.URL{Scheme: "https", Host: "host"}, + IconURLLight: &url.URL{Scheme: "https", Host: "host"}, + PrimaryColorDark: gu.Ptr("color"), + BackgroundColorDark: gu.Ptr("color"), + WarnColorDark: gu.Ptr("color"), + FontColorDark: gu.Ptr("color"), + LogoURLDark: &url.URL{Scheme: "https", Host: "host"}, + IconURLDark: &url.URL{Scheme: "https", Host: "host"}, + HideLoginNameSuffix: gu.Ptr(true), + ErrorMsgPopup: gu.Ptr(true), + DisableWatermark: gu.Ptr(true), + ThemeMode: gu.Ptr(domain.BrandingPolicyThemeAuto), + FontURL: &url.URL{Scheme: "https", Host: "host"}, + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: gu.Ptr(secondOrgID), + }, + BrandingSettingsAttributes: domain.BrandingSettingsAttributes{ + PrimaryColorLight: gu.Ptr("color"), + BackgroundColorLight: gu.Ptr("color"), + WarnColorLight: gu.Ptr("color"), + FontColorLight: gu.Ptr("color"), + LogoURLLight: &url.URL{Scheme: "https", Host: "host"}, + IconURLLight: &url.URL{Scheme: "https", Host: "host"}, + PrimaryColorDark: gu.Ptr("color"), + BackgroundColorDark: gu.Ptr("color"), + WarnColorDark: gu.Ptr("color"), + FontColorDark: gu.Ptr("color"), + LogoURLDark: &url.URL{Scheme: "https", Host: "host"}, + IconURLDark: &url.URL{Scheme: "https", Host: "host"}, + HideLoginNameSuffix: gu.Ptr(true), + ErrorMsgPopup: gu.Ptr(true), + DisableWatermark: gu.Ptr(true), + ThemeMode: gu.Ptr(domain.BrandingPolicyThemeAuto), + FontURL: &url.URL{Scheme: "https", Host: "host"}, + }, + }, + } + + for _, setting := range settings { + err := repo.Set(t.Context(), tx, setting) + require.NoError(t, err) + } + _, err := repo.Activate(t.Context(), tx, repo.UniqueCondition(settings[0].InstanceID, settings[0].OrganizationID, domain.SettingTypeBranding, domain.SettingStatePreview)) + require.NoError(t, err) + activeInstanceSetting := *settings[0] + activeInstanceSetting.State = domain.SettingStateActive + + _, err = repo.Activate(t.Context(), tx, repo.UniqueCondition(settings[2].InstanceID, settings[2].OrganizationID, domain.SettingTypeBranding, domain.SettingStatePreview)) + require.NoError(t, err) + activeOrganizationSetting := *settings[2] + activeOrganizationSetting.State = domain.SettingStateActive + + tests := []struct { + name string + condition database.Condition + want []*domain.BrandingSettings + wantErr error + }{ + { + name: "incomplete condition", + condition: repo.OrganizationIDCondition(gu.Ptr(firstOrgID)), + wantErr: database.NewMissingConditionError(repo.IDColumn()), + }, + { + name: "no results, ok", + condition: repo.PrimaryKeyCondition(firstInstanceID, "nix"), + want: []*domain.BrandingSettings{}, + }, + { + name: "all from instance", + condition: repo.InstanceIDCondition(firstInstanceID), + want: []*domain.BrandingSettings{&activeOrganizationSetting, settings[2], &activeInstanceSetting, settings[0]}, + }, + { + name: "all from instance, preview", + condition: database.And( + repo.InstanceIDCondition(firstInstanceID), + repo.StateCondition(domain.SettingStatePreview), + ), + want: []*domain.BrandingSettings{settings[2], settings[0]}, + }, + { + name: "all from instance, active", + condition: database.And( + repo.InstanceIDCondition(firstInstanceID), + repo.StateCondition(domain.SettingStateActive), + ), + want: []*domain.BrandingSettings{&activeOrganizationSetting, &activeInstanceSetting}, + }, + { + name: "only from instance", + condition: database.And( + repo.InstanceIDCondition(firstInstanceID), + repo.OrganizationIDCondition(nil), + ), + want: []*domain.BrandingSettings{&activeInstanceSetting, settings[0]}, + }, + { + name: "all from first org", + condition: database.And( + repo.InstanceIDCondition(firstInstanceID), + repo.OrganizationIDCondition(gu.Ptr(firstOrgID)), + ), + want: []*domain.BrandingSettings{&activeOrganizationSetting, settings[2]}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := repo.List(t.Context(), tx, + database.WithCondition(tt.condition), + database.WithOrderByAscending(repo.InstanceIDColumn(), repo.OrganizationIDColumn(), repo.StateColumn()), + ) + require.ErrorIs(t, err, tt.wantErr) + + if !assert.Len(t, got, len(tt.want)) { + return + } + for i := range got { + assert.EqualExportedValues(t, tt.want[i], got[i]) + } + }) + } +} + +func TestSetBrandingSettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + instanceID := createInstance(t, tx) + orgID := createOrganization(t, tx, instanceID) + repo := repository.BrandingSettingsRepository() + + existingSettings := &domain.BrandingSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr(orgID), + }, + BrandingSettingsAttributes: domain.BrandingSettingsAttributes{ + PrimaryColorLight: gu.Ptr("color"), + BackgroundColorLight: gu.Ptr("color"), + WarnColorLight: gu.Ptr("color"), + FontColorLight: gu.Ptr("color"), + LogoURLLight: &url.URL{Scheme: "https", Host: "host"}, + IconURLLight: &url.URL{Scheme: "https", Host: "host"}, + PrimaryColorDark: gu.Ptr("color"), + BackgroundColorDark: gu.Ptr("color"), + WarnColorDark: gu.Ptr("color"), + FontColorDark: gu.Ptr("color"), + LogoURLDark: &url.URL{Scheme: "https", Host: "host"}, + IconURLDark: &url.URL{Scheme: "https", Host: "host"}, + HideLoginNameSuffix: gu.Ptr(true), + ErrorMsgPopup: gu.Ptr(true), + DisableWatermark: gu.Ptr(true), + ThemeMode: gu.Ptr(domain.BrandingPolicyThemeAuto), + FontURL: &url.URL{Scheme: "https", Host: "host"}, + }, + } + + err := repo.Set(t.Context(), tx, existingSettings) + require.NoError(t, err) + + tests := []struct { + name string + settings *domain.BrandingSettings + wantErr error + }{ + { + name: "create instance", + settings: &domain.BrandingSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: nil, + }, + BrandingSettingsAttributes: domain.BrandingSettingsAttributes{ + PrimaryColorLight: gu.Ptr("color"), + BackgroundColorLight: gu.Ptr("color"), + WarnColorLight: gu.Ptr("color"), + FontColorLight: gu.Ptr("color"), + LogoURLLight: &url.URL{Scheme: "https", Host: "host"}, + IconURLLight: &url.URL{Scheme: "https", Host: "host"}, + PrimaryColorDark: gu.Ptr("color"), + BackgroundColorDark: gu.Ptr("color"), + WarnColorDark: gu.Ptr("color"), + FontColorDark: gu.Ptr("color"), + LogoURLDark: &url.URL{Scheme: "https", Host: "host"}, + IconURLDark: &url.URL{Scheme: "https", Host: "host"}, + HideLoginNameSuffix: gu.Ptr(true), + ErrorMsgPopup: gu.Ptr(true), + DisableWatermark: gu.Ptr(true), + ThemeMode: gu.Ptr(domain.BrandingPolicyThemeAuto), + FontURL: &url.URL{Scheme: "https", Host: "host"}, + }, + }, + }, + { + name: "update organization", + settings: &domain.BrandingSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr(orgID), + }, + BrandingSettingsAttributes: domain.BrandingSettingsAttributes{ + PrimaryColorLight: gu.Ptr("color"), + BackgroundColorLight: gu.Ptr("color"), + WarnColorLight: gu.Ptr("color"), + FontColorLight: gu.Ptr("color"), + LogoURLLight: &url.URL{Scheme: "https", Host: "host"}, + IconURLLight: &url.URL{Scheme: "https", Host: "host"}, + PrimaryColorDark: gu.Ptr("color"), + BackgroundColorDark: gu.Ptr("color"), + WarnColorDark: gu.Ptr("color"), + FontColorDark: gu.Ptr("color"), + LogoURLDark: &url.URL{Scheme: "https", Host: "host"}, + IconURLDark: &url.URL{Scheme: "https", Host: "host"}, + HideLoginNameSuffix: gu.Ptr(true), + ErrorMsgPopup: gu.Ptr(true), + DisableWatermark: gu.Ptr(true), + ThemeMode: gu.Ptr(domain.BrandingPolicyThemeAuto), + FontURL: &url.URL{Scheme: "https", Host: "host"}, + }, + }, + }, + { + name: "non-existing instance", + settings: &domain.BrandingSettings{ + Settings: domain.Settings{ + InstanceID: "foo", + OrganizationID: nil, + }, + BrandingSettingsAttributes: domain.BrandingSettingsAttributes{ + PrimaryColorLight: gu.Ptr("color"), + BackgroundColorLight: gu.Ptr("color"), + WarnColorLight: gu.Ptr("color"), + FontColorLight: gu.Ptr("color"), + LogoURLLight: &url.URL{Scheme: "https", Host: "host"}, + IconURLLight: &url.URL{Scheme: "https", Host: "host"}, + PrimaryColorDark: gu.Ptr("color"), + BackgroundColorDark: gu.Ptr("color"), + WarnColorDark: gu.Ptr("color"), + FontColorDark: gu.Ptr("color"), + LogoURLDark: &url.URL{Scheme: "https", Host: "host"}, + IconURLDark: &url.URL{Scheme: "https", Host: "host"}, + HideLoginNameSuffix: gu.Ptr(true), + ErrorMsgPopup: gu.Ptr(true), + DisableWatermark: gu.Ptr(true), + ThemeMode: gu.Ptr(domain.BrandingPolicyThemeAuto), + FontURL: &url.URL{Scheme: "https", Host: "host"}, + }, + }, + wantErr: new(database.ForeignKeyError), + }, + { + name: "non-existing org", + settings: &domain.BrandingSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr("foo"), + }, + BrandingSettingsAttributes: domain.BrandingSettingsAttributes{ + PrimaryColorLight: gu.Ptr("color"), + BackgroundColorLight: gu.Ptr("color"), + WarnColorLight: gu.Ptr("color"), + FontColorLight: gu.Ptr("color"), + LogoURLLight: &url.URL{Scheme: "https", Host: "host"}, + IconURLLight: &url.URL{Scheme: "https", Host: "host"}, + PrimaryColorDark: gu.Ptr("color"), + BackgroundColorDark: gu.Ptr("color"), + WarnColorDark: gu.Ptr("color"), + FontColorDark: gu.Ptr("color"), + LogoURLDark: &url.URL{Scheme: "https", Host: "host"}, + IconURLDark: &url.URL{Scheme: "https", Host: "host"}, + HideLoginNameSuffix: gu.Ptr(true), + ErrorMsgPopup: gu.Ptr(true), + DisableWatermark: gu.Ptr(true), + ThemeMode: gu.Ptr(domain.BrandingPolicyThemeAuto), + FontURL: &url.URL{Scheme: "https", Host: "host"}, + }, + }, + wantErr: new(database.ForeignKeyError), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + savepoint, rollback := savepointForRollback(t, tx) + defer rollback() + err := repo.Set(t.Context(), savepoint, tt.settings) + require.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestActivateBrandingSettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + instanceID := createInstance(t, tx) + orgID := createOrganization(t, tx, instanceID) + repo := repository.BrandingSettingsRepository() + + existingInstanceSettings := &domain.BrandingSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: nil, + }, + BrandingSettingsAttributes: domain.BrandingSettingsAttributes{ + PrimaryColorLight: gu.Ptr("color"), + BackgroundColorLight: gu.Ptr("color"), + WarnColorLight: gu.Ptr("color"), + FontColorLight: gu.Ptr("color"), + LogoURLLight: &url.URL{Scheme: "https", Host: "host"}, + IconURLLight: &url.URL{Scheme: "https", Host: "host"}, + PrimaryColorDark: gu.Ptr("color"), + BackgroundColorDark: gu.Ptr("color"), + WarnColorDark: gu.Ptr("color"), + FontColorDark: gu.Ptr("color"), + LogoURLDark: &url.URL{Scheme: "https", Host: "host"}, + IconURLDark: &url.URL{Scheme: "https", Host: "host"}, + HideLoginNameSuffix: gu.Ptr(true), + ErrorMsgPopup: gu.Ptr(true), + DisableWatermark: gu.Ptr(true), + ThemeMode: gu.Ptr(domain.BrandingPolicyThemeAuto), + FontURL: &url.URL{Scheme: "https", Host: "host"}, + }, + } + + err := repo.Set(t.Context(), tx, existingInstanceSettings) + require.NoError(t, err) + + existingOrganizationSettings := &domain.BrandingSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr(orgID), + }, + BrandingSettingsAttributes: domain.BrandingSettingsAttributes{ + PrimaryColorLight: gu.Ptr("color"), + BackgroundColorLight: gu.Ptr("color"), + WarnColorLight: gu.Ptr("color"), + FontColorLight: gu.Ptr("color"), + LogoURLLight: &url.URL{Scheme: "https", Host: "host"}, + IconURLLight: &url.URL{Scheme: "https", Host: "host"}, + PrimaryColorDark: gu.Ptr("color"), + BackgroundColorDark: gu.Ptr("color"), + WarnColorDark: gu.Ptr("color"), + FontColorDark: gu.Ptr("color"), + LogoURLDark: &url.URL{Scheme: "https", Host: "host"}, + IconURLDark: &url.URL{Scheme: "https", Host: "host"}, + HideLoginNameSuffix: gu.Ptr(true), + ErrorMsgPopup: gu.Ptr(true), + DisableWatermark: gu.Ptr(true), + ThemeMode: gu.Ptr(domain.BrandingPolicyThemeAuto), + FontURL: &url.URL{Scheme: "https", Host: "host"}, + }, + } + + err = repo.Set(t.Context(), tx, existingOrganizationSettings) + require.NoError(t, err) + + tests := []struct { + name string + wantRowsAffected int64 + condition database.Condition + wantErr error + }{ + { + name: "activate instance", + condition: database.And( + repo.InstanceIDCondition(existingInstanceSettings.InstanceID), + repo.OrganizationIDCondition(nil), + ), + wantRowsAffected: 1, + }, + { + name: "activate organization", + condition: database.And( + repo.InstanceIDCondition(existingOrganizationSettings.InstanceID), + repo.OrganizationIDCondition(existingOrganizationSettings.OrganizationID), + ), + wantRowsAffected: 1, + }, + { + name: "non-existing instance", + condition: database.And( + repo.InstanceIDCondition("foo"), + repo.OrganizationIDCondition(nil), + ), + wantRowsAffected: 0, + }, + + { + name: "non-existing organization", + condition: database.And( + repo.InstanceIDCondition(existingOrganizationSettings.InstanceID), + repo.OrganizationIDCondition(gu.Ptr("foo")), + ), + wantRowsAffected: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + savepoint, rollback := savepointForRollback(t, tx) + defer rollback() + rowsAffected, err := repo.Activate(t.Context(), savepoint, tt.condition) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.wantRowsAffected, rowsAffected) + }) + } +} + +func TestActivateAtBrandingSettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + instanceID := createInstance(t, tx) + orgID := createOrganization(t, tx, instanceID) + repo := repository.BrandingSettingsRepository() + + existingInstanceSettings := &domain.BrandingSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: nil, + }, + BrandingSettingsAttributes: domain.BrandingSettingsAttributes{ + PrimaryColorLight: gu.Ptr("color"), + BackgroundColorLight: gu.Ptr("color"), + WarnColorLight: gu.Ptr("color"), + FontColorLight: gu.Ptr("color"), + LogoURLLight: &url.URL{Scheme: "https", Host: "host"}, + IconURLLight: &url.URL{Scheme: "https", Host: "host"}, + PrimaryColorDark: gu.Ptr("color"), + BackgroundColorDark: gu.Ptr("color"), + WarnColorDark: gu.Ptr("color"), + FontColorDark: gu.Ptr("color"), + LogoURLDark: &url.URL{Scheme: "https", Host: "host"}, + IconURLDark: &url.URL{Scheme: "https", Host: "host"}, + HideLoginNameSuffix: gu.Ptr(true), + ErrorMsgPopup: gu.Ptr(true), + DisableWatermark: gu.Ptr(true), + ThemeMode: gu.Ptr(domain.BrandingPolicyThemeAuto), + FontURL: &url.URL{Scheme: "https", Host: "host"}, + }, + } + + err := repo.Set(t.Context(), tx, existingInstanceSettings) + require.NoError(t, err) + + existingOrganizationSettings := &domain.BrandingSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr(orgID), + }, + BrandingSettingsAttributes: domain.BrandingSettingsAttributes{ + PrimaryColorLight: gu.Ptr("color"), + BackgroundColorLight: gu.Ptr("color"), + WarnColorLight: gu.Ptr("color"), + FontColorLight: gu.Ptr("color"), + LogoURLLight: &url.URL{Scheme: "https", Host: "host"}, + IconURLLight: &url.URL{Scheme: "https", Host: "host"}, + PrimaryColorDark: gu.Ptr("color"), + BackgroundColorDark: gu.Ptr("color"), + WarnColorDark: gu.Ptr("color"), + FontColorDark: gu.Ptr("color"), + LogoURLDark: &url.URL{Scheme: "https", Host: "host"}, + IconURLDark: &url.URL{Scheme: "https", Host: "host"}, + HideLoginNameSuffix: gu.Ptr(true), + ErrorMsgPopup: gu.Ptr(true), + DisableWatermark: gu.Ptr(true), + ThemeMode: gu.Ptr(domain.BrandingPolicyThemeAuto), + FontURL: &url.URL{Scheme: "https", Host: "host"}, + }, + } + + err = repo.Set(t.Context(), tx, existingOrganizationSettings) + require.NoError(t, err) + + tests := []struct { + name string + wantRowsAffected int64 + condition database.Condition + updatedAt time.Time + wantErr error + }{ + { + name: "activate instance", + condition: database.And( + repo.InstanceIDCondition(existingInstanceSettings.InstanceID), + repo.OrganizationIDCondition(nil), + ), + updatedAt: time.Now(), + wantRowsAffected: 1, + }, + { + name: "activate organization", + condition: database.And( + repo.InstanceIDCondition(existingOrganizationSettings.InstanceID), + repo.OrganizationIDCondition(existingOrganizationSettings.OrganizationID), + ), + updatedAt: time.Now(), + wantRowsAffected: 1, + }, + { + name: "non-existing instance", + condition: database.And( + repo.InstanceIDCondition("foo"), + repo.OrganizationIDCondition(nil), + ), + updatedAt: time.Now(), + wantRowsAffected: 0, + }, + + { + name: "non-existing organization", + condition: database.And( + repo.InstanceIDCondition(existingOrganizationSettings.InstanceID), + repo.OrganizationIDCondition(gu.Ptr("foo")), + ), + updatedAt: time.Now(), + wantRowsAffected: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + savepoint, rollback := savepointForRollback(t, tx) + defer rollback() + rowsAffected, err := repo.ActivateAt(t.Context(), savepoint, tt.condition, tt.updatedAt) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.wantRowsAffected, rowsAffected) + }) + } +} + +func TestDeleteBrandingSettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + instanceID := createInstance(t, tx) + orgID := createOrganization(t, tx, instanceID) + repo := repository.BrandingSettingsRepository() + + existingInstanceSettings := &domain.BrandingSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: nil, + }, + BrandingSettingsAttributes: domain.BrandingSettingsAttributes{ + PrimaryColorLight: gu.Ptr("color"), + BackgroundColorLight: gu.Ptr("color"), + WarnColorLight: gu.Ptr("color"), + FontColorLight: gu.Ptr("color"), + LogoURLLight: &url.URL{Scheme: "https", Host: "host"}, + IconURLLight: &url.URL{Scheme: "https", Host: "host"}, + PrimaryColorDark: gu.Ptr("color"), + BackgroundColorDark: gu.Ptr("color"), + WarnColorDark: gu.Ptr("color"), + FontColorDark: gu.Ptr("color"), + LogoURLDark: &url.URL{Scheme: "https", Host: "host"}, + IconURLDark: &url.URL{Scheme: "https", Host: "host"}, + HideLoginNameSuffix: gu.Ptr(true), + ErrorMsgPopup: gu.Ptr(true), + DisableWatermark: gu.Ptr(true), + ThemeMode: gu.Ptr(domain.BrandingPolicyThemeAuto), + FontURL: &url.URL{Scheme: "https", Host: "host"}, + }, + } + err := repo.Set(t.Context(), tx, existingInstanceSettings) + require.NoError(t, err) + + _, err = repo.Activate(t.Context(), tx, + database.And( + repo.InstanceIDCondition(existingInstanceSettings.InstanceID), + repo.OrganizationIDCondition(existingInstanceSettings.OrganizationID), + ), + ) + require.NoError(t, err) + activeInstanceSetting := *existingInstanceSettings + activeInstanceSetting.State = domain.SettingStateActive + + existingOrganizationSettings := &domain.BrandingSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr(orgID), + }, + BrandingSettingsAttributes: domain.BrandingSettingsAttributes{ + PrimaryColorLight: gu.Ptr("color"), + BackgroundColorLight: gu.Ptr("color"), + WarnColorLight: gu.Ptr("color"), + FontColorLight: gu.Ptr("color"), + LogoURLLight: &url.URL{Scheme: "https", Host: "host"}, + IconURLLight: &url.URL{Scheme: "https", Host: "host"}, + PrimaryColorDark: gu.Ptr("color"), + BackgroundColorDark: gu.Ptr("color"), + WarnColorDark: gu.Ptr("color"), + FontColorDark: gu.Ptr("color"), + LogoURLDark: &url.URL{Scheme: "https", Host: "host"}, + IconURLDark: &url.URL{Scheme: "https", Host: "host"}, + HideLoginNameSuffix: gu.Ptr(true), + ErrorMsgPopup: gu.Ptr(true), + DisableWatermark: gu.Ptr(true), + ThemeMode: gu.Ptr(domain.BrandingPolicyThemeAuto), + FontURL: &url.URL{Scheme: "https", Host: "host"}, + }, + } + err = repo.Set(t.Context(), tx, existingOrganizationSettings) + require.NoError(t, err) + + _, err = repo.Activate(t.Context(), tx, + database.And( + repo.InstanceIDCondition(existingOrganizationSettings.InstanceID), + repo.OrganizationIDCondition(existingOrganizationSettings.OrganizationID), + ), + ) + require.NoError(t, err) + activeOrganizationSetting := *existingOrganizationSettings + activeOrganizationSetting.State = domain.SettingStateActive + + tests := []struct { + name string + condition database.Condition + wantRowsAffected int64 + wantErr error + }{ + { + name: "incomplete condition", + condition: repo.InstanceIDCondition(instanceID), + wantRowsAffected: 0, + wantErr: database.NewMissingConditionError(repo.IDColumn()), + }, + { + name: "not found", + condition: repo.UniqueCondition(existingInstanceSettings.InstanceID, gu.Ptr("foo"), domain.SettingTypeBranding, domain.SettingStateActive), + wantRowsAffected: 0, + }, + { + name: "delete instance, active", + condition: repo.UniqueCondition(existingInstanceSettings.InstanceID, existingInstanceSettings.OrganizationID, domain.SettingTypeBranding, domain.SettingStateActive), + wantRowsAffected: 1, + }, + { + name: "delete instance", + condition: repo.UniqueCondition(existingInstanceSettings.InstanceID, existingInstanceSettings.OrganizationID, domain.SettingTypeBranding, domain.SettingStatePreview), + wantRowsAffected: 1, + }, + { + name: "delete instance twice", + condition: repo.UniqueCondition(existingInstanceSettings.InstanceID, existingInstanceSettings.OrganizationID, domain.SettingTypeBranding, domain.SettingStatePreview), + wantRowsAffected: 0, + }, + { + name: "delete organization, active", + condition: repo.UniqueCondition(existingOrganizationSettings.InstanceID, existingOrganizationSettings.OrganizationID, domain.SettingTypeBranding, domain.SettingStateActive), + wantRowsAffected: 1, + }, + { + name: "delete organization twice, active", + condition: repo.UniqueCondition(existingOrganizationSettings.InstanceID, existingOrganizationSettings.OrganizationID, domain.SettingTypeBranding, domain.SettingStateActive), + wantRowsAffected: 0, + }, + { + name: "delete organization", + condition: repo.UniqueCondition(existingOrganizationSettings.InstanceID, existingOrganizationSettings.OrganizationID, domain.SettingTypeBranding, domain.SettingStatePreview), + wantRowsAffected: 1, + }, + { + name: "delete organization twice", + condition: repo.UniqueCondition(existingOrganizationSettings.InstanceID, existingOrganizationSettings.OrganizationID, domain.SettingTypeBranding, domain.SettingStatePreview), + wantRowsAffected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rowsAffected, err := repo.Delete(t.Context(), tx, tt.condition) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.wantRowsAffected, rowsAffected) + }) + } +} + +func TestGetPasswordComplexitySettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + firstInstanceID := createInstance(t, tx) + secondInstanceID := createInstance(t, tx) + firstOrgID := createOrganization(t, tx, firstInstanceID) + secondOrgID := createOrganization(t, tx, secondInstanceID) + repo := repository.PasswordComplexitySettingsRepository() + + settings := []*domain.PasswordComplexitySettings{ + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: nil, + }, + PasswordComplexitySettingsAttributes: domain.PasswordComplexitySettingsAttributes{ + MinLength: gu.Ptr(uint64(8)), + HasLowercase: gu.Ptr(true), + HasUppercase: gu.Ptr(true), + HasNumber: gu.Ptr(true), + HasSymbol: gu.Ptr(true), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: nil, + }, + PasswordComplexitySettingsAttributes: domain.PasswordComplexitySettingsAttributes{ + MinLength: gu.Ptr(uint64(10)), + HasLowercase: gu.Ptr(true), + HasUppercase: gu.Ptr(true), + HasNumber: gu.Ptr(true), + HasSymbol: gu.Ptr(true), + }, + }, + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: gu.Ptr(firstOrgID), + }, + PasswordComplexitySettingsAttributes: domain.PasswordComplexitySettingsAttributes{ + MinLength: gu.Ptr(uint64(12)), + HasLowercase: gu.Ptr(true), + HasUppercase: gu.Ptr(true), + HasNumber: gu.Ptr(true), + HasSymbol: gu.Ptr(true), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: gu.Ptr(secondOrgID), + }, + PasswordComplexitySettingsAttributes: domain.PasswordComplexitySettingsAttributes{ + MinLength: gu.Ptr(uint64(14)), + HasLowercase: gu.Ptr(true), + HasUppercase: gu.Ptr(true), + HasNumber: gu.Ptr(true), + HasSymbol: gu.Ptr(true), + }, + }, + } + + for _, setting := range settings { + err := repo.Set(t.Context(), tx, setting) + require.NoError(t, err) + } + + tests := []struct { + name string + condition database.Condition + want *domain.PasswordComplexitySettings + wantErr error + }{ + { + name: "incomplete condition", + condition: repo.IDCondition(settings[0].ID), + wantErr: database.NewMissingConditionError(repo.IDColumn()), + }, + { + name: "not found", + condition: repo.PrimaryKeyCondition(firstInstanceID, "nix"), + wantErr: database.NewNoRowFoundError(nil), + }, + { + name: "too many", + condition: repo.InstanceIDCondition(firstInstanceID), + wantErr: database.NewMultipleRowsFoundError(nil), + }, + { + name: "ok, instance", + condition: repo.PrimaryKeyCondition(firstInstanceID, settings[0].ID), + want: settings[0], + }, + { + name: "ok, organization", + condition: repo.PrimaryKeyCondition(secondInstanceID, settings[3].ID), + want: settings[3], + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := repo.Get(t.Context(), tx, database.WithCondition(tt.condition)) + require.ErrorIs(t, err, tt.wantErr) + assert.EqualExportedValues(t, tt.want, got) + }) + } +} + +func TestListPasswordComplexitySettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + firstInstanceID := createInstance(t, tx) + secondInstanceID := createInstance(t, tx) + firstOrgID := createOrganization(t, tx, firstInstanceID) + secondOrgID := createOrganization(t, tx, secondInstanceID) + repo := repository.PasswordComplexitySettingsRepository() + + settings := []*domain.PasswordComplexitySettings{ + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: nil, + }, + PasswordComplexitySettingsAttributes: domain.PasswordComplexitySettingsAttributes{ + MinLength: gu.Ptr(uint64(8)), + HasLowercase: gu.Ptr(true), + HasUppercase: gu.Ptr(true), + HasNumber: gu.Ptr(true), + HasSymbol: gu.Ptr(true), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: nil, + }, + PasswordComplexitySettingsAttributes: domain.PasswordComplexitySettingsAttributes{ + MinLength: gu.Ptr(uint64(10)), + HasLowercase: gu.Ptr(true), + HasUppercase: gu.Ptr(true), + HasNumber: gu.Ptr(true), + HasSymbol: gu.Ptr(true), + }, + }, + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: gu.Ptr(firstOrgID), + }, + PasswordComplexitySettingsAttributes: domain.PasswordComplexitySettingsAttributes{ + MinLength: gu.Ptr(uint64(10)), + HasLowercase: gu.Ptr(true), + HasUppercase: gu.Ptr(true), + HasNumber: gu.Ptr(true), + HasSymbol: gu.Ptr(true), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: gu.Ptr(secondOrgID), + }, + PasswordComplexitySettingsAttributes: domain.PasswordComplexitySettingsAttributes{ + MinLength: gu.Ptr(uint64(12)), + HasLowercase: gu.Ptr(true), + HasUppercase: gu.Ptr(true), + HasNumber: gu.Ptr(true), + HasSymbol: gu.Ptr(true), + }, + }, + } + + for _, setting := range settings { + err := repo.Set(t.Context(), tx, setting) + require.NoError(t, err) + } + + tests := []struct { + name string + condition database.Condition + want []*domain.PasswordComplexitySettings + wantErr error + }{ + { + name: "incomplete condition", + condition: repo.OrganizationIDCondition(gu.Ptr(firstOrgID)), + wantErr: database.NewMissingConditionError(repo.IDColumn()), + }, + { + name: "no results, ok", + condition: repo.PrimaryKeyCondition(firstInstanceID, "nix"), + want: []*domain.PasswordComplexitySettings{}, + }, + { + name: "all from instance", + condition: repo.InstanceIDCondition(firstInstanceID), + want: []*domain.PasswordComplexitySettings{settings[2], settings[0]}, + }, + { + name: "only from instance", + condition: database.And( + repo.InstanceIDCondition(firstInstanceID), + repo.OrganizationIDCondition(nil), + ), + want: []*domain.PasswordComplexitySettings{settings[0]}, + }, + { + name: "all from first org", + condition: database.And( + repo.InstanceIDCondition(firstInstanceID), + repo.OrganizationIDCondition(gu.Ptr(firstOrgID)), + ), + want: []*domain.PasswordComplexitySettings{settings[2]}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := repo.List(t.Context(), tx, + database.WithCondition(tt.condition), + database.WithOrderByAscending(repo.InstanceIDColumn(), repo.OrganizationIDColumn()), + ) + require.ErrorIs(t, err, tt.wantErr) + assert.EqualExportedValues(t, tt.want, got) + }) + } +} + +func TestSetPasswordComplexitySettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + instanceID := createInstance(t, tx) + orgID := createOrganization(t, tx, instanceID) + repo := repository.PasswordComplexitySettingsRepository() + + existingSettings := &domain.PasswordComplexitySettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr(orgID), + }, + PasswordComplexitySettingsAttributes: domain.PasswordComplexitySettingsAttributes{ + MinLength: gu.Ptr(uint64(8)), + HasLowercase: gu.Ptr(true), + HasUppercase: gu.Ptr(true), + HasNumber: gu.Ptr(true), + HasSymbol: gu.Ptr(true), + }, + } + + err := repo.Set(t.Context(), tx, existingSettings) + require.NoError(t, err) + + tests := []struct { + name string + settings *domain.PasswordComplexitySettings + wantErr error + }{ + { + name: "create instance", + settings: &domain.PasswordComplexitySettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: nil, + }, + PasswordComplexitySettingsAttributes: domain.PasswordComplexitySettingsAttributes{ + MinLength: gu.Ptr(uint64(8)), + HasLowercase: gu.Ptr(true), + HasUppercase: gu.Ptr(true), + HasNumber: gu.Ptr(true), + HasSymbol: gu.Ptr(true), + }, + }, + }, + { + name: "update organization", + settings: &domain.PasswordComplexitySettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr(orgID), + }, + PasswordComplexitySettingsAttributes: domain.PasswordComplexitySettingsAttributes{ + MinLength: gu.Ptr(uint64(10)), + HasLowercase: gu.Ptr(true), + HasUppercase: gu.Ptr(true), + HasNumber: gu.Ptr(true), + HasSymbol: gu.Ptr(true), + }, + }, + }, + { + name: "non-existing instance", + settings: &domain.PasswordComplexitySettings{ + Settings: domain.Settings{ + InstanceID: "foo", + OrganizationID: nil, + }, + PasswordComplexitySettingsAttributes: domain.PasswordComplexitySettingsAttributes{ + MinLength: gu.Ptr(uint64(8)), + HasLowercase: gu.Ptr(true), + HasUppercase: gu.Ptr(true), + HasNumber: gu.Ptr(true), + HasSymbol: gu.Ptr(true), + }, + }, + wantErr: new(database.ForeignKeyError), + }, + { + name: "non-existing org", + settings: &domain.PasswordComplexitySettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr("foo"), + }, + PasswordComplexitySettingsAttributes: domain.PasswordComplexitySettingsAttributes{ + MinLength: gu.Ptr(uint64(8)), + HasLowercase: gu.Ptr(true), + HasUppercase: gu.Ptr(true), + HasNumber: gu.Ptr(true), + HasSymbol: gu.Ptr(true), + }, + }, + wantErr: new(database.ForeignKeyError), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + savepoint, rollback := savepointForRollback(t, tx) + defer rollback() + err := repo.Set(t.Context(), savepoint, tt.settings) + require.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestDeletePasswordComplexitySettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + instanceID := createInstance(t, tx) + orgID := createOrganization(t, tx, instanceID) + repo := repository.PasswordComplexitySettingsRepository() + + existingInstanceSettings := &domain.PasswordComplexitySettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: nil, + }, + PasswordComplexitySettingsAttributes: domain.PasswordComplexitySettingsAttributes{ + MinLength: gu.Ptr(uint64(8)), + HasLowercase: gu.Ptr(true), + HasUppercase: gu.Ptr(true), + HasNumber: gu.Ptr(true), + HasSymbol: gu.Ptr(true), + }, + } + err := repo.Set(t.Context(), tx, existingInstanceSettings) + require.NoError(t, err) + + existingOrganizationSettings := &domain.PasswordComplexitySettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr(orgID), + }, + PasswordComplexitySettingsAttributes: domain.PasswordComplexitySettingsAttributes{ + MinLength: gu.Ptr(uint64(8)), + HasLowercase: gu.Ptr(true), + HasUppercase: gu.Ptr(true), + HasNumber: gu.Ptr(true), + HasSymbol: gu.Ptr(true), + }, + } + err = repo.Set(t.Context(), tx, existingOrganizationSettings) + require.NoError(t, err) + + tests := []struct { + name string + condition database.Condition + wantRowsAffected int64 + wantErr error + }{ + { + name: "incomplete condition", + condition: repo.InstanceIDCondition(instanceID), + wantRowsAffected: 0, + wantErr: database.NewMissingConditionError(repo.IDColumn()), + }, + { + name: "not found", + condition: repo.UniqueCondition(existingInstanceSettings.InstanceID, gu.Ptr("foo"), domain.SettingTypePasswordComplexity, domain.SettingStateActive), + wantRowsAffected: 0, + }, + { + name: "delete instance", + condition: repo.UniqueCondition(existingInstanceSettings.InstanceID, existingInstanceSettings.OrganizationID, domain.SettingTypePasswordComplexity, domain.SettingStateActive), + wantRowsAffected: 1, + }, + { + name: "delete instance twice", + condition: repo.UniqueCondition(existingInstanceSettings.InstanceID, existingInstanceSettings.OrganizationID, domain.SettingTypePasswordComplexity, domain.SettingStateActive), + wantRowsAffected: 0, + }, + { + name: "delete organization", + condition: repo.UniqueCondition(existingOrganizationSettings.InstanceID, existingOrganizationSettings.OrganizationID, domain.SettingTypePasswordComplexity, domain.SettingStateActive), + wantRowsAffected: 1, + }, + { + name: "delete organization twice", + condition: repo.UniqueCondition(existingOrganizationSettings.InstanceID, existingOrganizationSettings.OrganizationID, domain.SettingTypePasswordComplexity, domain.SettingStateActive), + wantRowsAffected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rowsAffected, err := repo.Delete(t.Context(), tx, tt.condition) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.wantRowsAffected, rowsAffected) + }) + } +} + +func TestGetPasswordExpirySettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + firstInstanceID := createInstance(t, tx) + secondInstanceID := createInstance(t, tx) + firstOrgID := createOrganization(t, tx, firstInstanceID) + secondOrgID := createOrganization(t, tx, secondInstanceID) + repo := repository.PasswordExpirySettingsRepository() + + settings := []*domain.PasswordExpirySettings{ + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: nil, + }, + PasswordExpirySettingsAttributes: domain.PasswordExpirySettingsAttributes{ + ExpireWarnDays: gu.Ptr(uint64(10)), + MaxAgeDays: gu.Ptr(uint64(30)), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: nil, + }, + PasswordExpirySettingsAttributes: domain.PasswordExpirySettingsAttributes{ + ExpireWarnDays: gu.Ptr(uint64(11)), + MaxAgeDays: gu.Ptr(uint64(31)), + }, + }, + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: gu.Ptr(firstOrgID), + }, + PasswordExpirySettingsAttributes: domain.PasswordExpirySettingsAttributes{ + ExpireWarnDays: gu.Ptr(uint64(12)), + MaxAgeDays: gu.Ptr(uint64(32)), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: gu.Ptr(secondOrgID), + }, + PasswordExpirySettingsAttributes: domain.PasswordExpirySettingsAttributes{ + ExpireWarnDays: gu.Ptr(uint64(13)), + MaxAgeDays: gu.Ptr(uint64(33)), + }, + }, + } + + for _, setting := range settings { + err := repo.Set(t.Context(), tx, setting) + require.NoError(t, err) + } + + tests := []struct { + name string + condition database.Condition + want *domain.PasswordExpirySettings + wantErr error + }{ + { + name: "incomplete condition", + condition: repo.IDCondition(settings[0].ID), + wantErr: database.NewMissingConditionError(repo.IDColumn()), + }, + { + name: "not found", + condition: repo.PrimaryKeyCondition(firstInstanceID, "nix"), + wantErr: database.NewNoRowFoundError(nil), + }, + { + name: "too many", + condition: repo.InstanceIDCondition(firstInstanceID), + wantErr: database.NewMultipleRowsFoundError(nil), + }, + { + name: "ok, instance", + condition: repo.PrimaryKeyCondition(firstInstanceID, settings[0].ID), + want: settings[0], + }, + { + name: "ok, organization", + condition: repo.PrimaryKeyCondition(secondInstanceID, settings[3].ID), + want: settings[3], + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := repo.Get(t.Context(), tx, database.WithCondition(tt.condition)) + require.ErrorIs(t, err, tt.wantErr) + assert.EqualExportedValues(t, tt.want, got) + }) + } +} + +func TestListPasswordExpirySettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + firstInstanceID := createInstance(t, tx) + secondInstanceID := createInstance(t, tx) + firstOrgID := createOrganization(t, tx, firstInstanceID) + secondOrgID := createOrganization(t, tx, secondInstanceID) + repo := repository.PasswordExpirySettingsRepository() + + settings := []*domain.PasswordExpirySettings{ + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: nil, + }, + PasswordExpirySettingsAttributes: domain.PasswordExpirySettingsAttributes{ + ExpireWarnDays: gu.Ptr(uint64(10)), + MaxAgeDays: gu.Ptr(uint64(30)), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: nil, + }, + PasswordExpirySettingsAttributes: domain.PasswordExpirySettingsAttributes{ + ExpireWarnDays: gu.Ptr(uint64(11)), + MaxAgeDays: gu.Ptr(uint64(31)), + }, + }, + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: gu.Ptr(firstOrgID), + }, + PasswordExpirySettingsAttributes: domain.PasswordExpirySettingsAttributes{ + ExpireWarnDays: gu.Ptr(uint64(12)), + MaxAgeDays: gu.Ptr(uint64(32)), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: gu.Ptr(secondOrgID), + }, + PasswordExpirySettingsAttributes: domain.PasswordExpirySettingsAttributes{ + ExpireWarnDays: gu.Ptr(uint64(13)), + MaxAgeDays: gu.Ptr(uint64(33)), + }, + }, + } + + for _, setting := range settings { + err := repo.Set(t.Context(), tx, setting) + require.NoError(t, err) + } + + tests := []struct { + name string + condition database.Condition + want []*domain.PasswordExpirySettings + wantErr error + }{ + { + name: "incomplete condition", + condition: repo.OrganizationIDCondition(gu.Ptr(firstOrgID)), + wantErr: database.NewMissingConditionError(repo.IDColumn()), + }, + { + name: "no results, ok", + condition: repo.PrimaryKeyCondition(firstInstanceID, "nix"), + want: []*domain.PasswordExpirySettings{}, + }, + { + name: "all from instance", + condition: repo.InstanceIDCondition(firstInstanceID), + want: []*domain.PasswordExpirySettings{settings[2], settings[0]}, + }, + { + name: "only from instance", + condition: database.And( + repo.InstanceIDCondition(firstInstanceID), + repo.OrganizationIDCondition(nil), + ), + want: []*domain.PasswordExpirySettings{settings[0]}, + }, + { + name: "all from first org", + condition: database.And( + repo.InstanceIDCondition(firstInstanceID), + repo.OrganizationIDCondition(gu.Ptr(firstOrgID)), + ), + want: []*domain.PasswordExpirySettings{settings[2]}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := repo.List(t.Context(), tx, + database.WithCondition(tt.condition), + database.WithOrderByAscending(repo.InstanceIDColumn(), repo.OrganizationIDColumn()), + ) + require.ErrorIs(t, err, tt.wantErr) + assert.EqualExportedValues(t, tt.want, got) + }) + } +} + +func TestSetPasswordExpirySettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + instanceID := createInstance(t, tx) + orgID := createOrganization(t, tx, instanceID) + repo := repository.PasswordExpirySettingsRepository() + + existingSettings := &domain.PasswordExpirySettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr(orgID), + }, + PasswordExpirySettingsAttributes: domain.PasswordExpirySettingsAttributes{ + ExpireWarnDays: gu.Ptr(uint64(10)), + MaxAgeDays: gu.Ptr(uint64(30)), + }, + } + + err := repo.Set(t.Context(), tx, existingSettings) + require.NoError(t, err) + + tests := []struct { + name string + settings *domain.PasswordExpirySettings + wantErr error + }{ + { + name: "create instance", + settings: &domain.PasswordExpirySettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: nil, + }, + PasswordExpirySettingsAttributes: domain.PasswordExpirySettingsAttributes{ + ExpireWarnDays: gu.Ptr(uint64(10)), + MaxAgeDays: gu.Ptr(uint64(30)), + }, + }, + }, + { + name: "update organization", + settings: &domain.PasswordExpirySettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr(orgID), + }, + PasswordExpirySettingsAttributes: domain.PasswordExpirySettingsAttributes{ + ExpireWarnDays: gu.Ptr(uint64(10)), + MaxAgeDays: gu.Ptr(uint64(30)), + }, + }, + }, + { + name: "non-existing instance", + settings: &domain.PasswordExpirySettings{ + Settings: domain.Settings{ + InstanceID: "foo", + OrganizationID: nil, + }, + PasswordExpirySettingsAttributes: domain.PasswordExpirySettingsAttributes{ + ExpireWarnDays: gu.Ptr(uint64(10)), + MaxAgeDays: gu.Ptr(uint64(30)), + }, + }, + wantErr: new(database.ForeignKeyError), + }, + { + name: "non-existing org", + settings: &domain.PasswordExpirySettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr("foo"), + }, + PasswordExpirySettingsAttributes: domain.PasswordExpirySettingsAttributes{ + ExpireWarnDays: gu.Ptr(uint64(10)), + MaxAgeDays: gu.Ptr(uint64(30)), + }, + }, + wantErr: new(database.ForeignKeyError), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + savepoint, rollback := savepointForRollback(t, tx) + defer rollback() + err := repo.Set(t.Context(), savepoint, tt.settings) + require.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestDeletePasswordExpirySettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + instanceID := createInstance(t, tx) + orgID := createOrganization(t, tx, instanceID) + repo := repository.PasswordExpirySettingsRepository() + + existingInstanceSettings := &domain.PasswordExpirySettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: nil, + }, + PasswordExpirySettingsAttributes: domain.PasswordExpirySettingsAttributes{ + ExpireWarnDays: gu.Ptr(uint64(10)), + MaxAgeDays: gu.Ptr(uint64(30)), + }, + } + err := repo.Set(t.Context(), tx, existingInstanceSettings) + require.NoError(t, err) + + existingOrganizationSettings := &domain.PasswordExpirySettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr(orgID), + }, + PasswordExpirySettingsAttributes: domain.PasswordExpirySettingsAttributes{ + ExpireWarnDays: gu.Ptr(uint64(10)), + MaxAgeDays: gu.Ptr(uint64(30)), + }, + } + err = repo.Set(t.Context(), tx, existingOrganizationSettings) + require.NoError(t, err) + + tests := []struct { + name string + condition database.Condition + wantRowsAffected int64 + wantErr error + }{ + { + name: "incomplete condition", + condition: repo.InstanceIDCondition(instanceID), + wantRowsAffected: 0, + wantErr: database.NewMissingConditionError(repo.IDColumn()), + }, + { + name: "not found", + condition: repo.UniqueCondition(existingInstanceSettings.InstanceID, gu.Ptr("foo"), domain.SettingTypePasswordExpiry, domain.SettingStateActive), + wantRowsAffected: 0, + }, + { + name: "delete instance", + condition: repo.UniqueCondition(existingInstanceSettings.InstanceID, existingInstanceSettings.OrganizationID, domain.SettingTypePasswordExpiry, domain.SettingStateActive), + wantRowsAffected: 1, + }, + { + name: "delete instance twice", + condition: repo.UniqueCondition(existingInstanceSettings.InstanceID, existingInstanceSettings.OrganizationID, domain.SettingTypePasswordExpiry, domain.SettingStateActive), + wantRowsAffected: 0, + }, + { + name: "delete organization", + condition: repo.UniqueCondition(existingOrganizationSettings.InstanceID, existingOrganizationSettings.OrganizationID, domain.SettingTypePasswordExpiry, domain.SettingStateActive), + wantRowsAffected: 1, + }, + { + name: "delete organization twice", + condition: repo.UniqueCondition(existingOrganizationSettings.InstanceID, existingOrganizationSettings.OrganizationID, domain.SettingTypePasswordExpiry, domain.SettingStateActive), + wantRowsAffected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rowsAffected, err := repo.Delete(t.Context(), tx, tt.condition) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.wantRowsAffected, rowsAffected) + }) + } +} + +func TestGetLockoutSettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + firstInstanceID := createInstance(t, tx) + secondInstanceID := createInstance(t, tx) + firstOrgID := createOrganization(t, tx, firstInstanceID) + secondOrgID := createOrganization(t, tx, secondInstanceID) + repo := repository.LockoutSettingsRepository() + + settings := []*domain.LockoutSettings{ + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: nil, + }, + LockoutSettingsAttributes: domain.LockoutSettingsAttributes{ + MaxPasswordAttempts: gu.Ptr(uint64(3)), + MaxOTPAttempts: gu.Ptr(uint64(3)), + ShowLockOutFailures: gu.Ptr(true), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: nil, + }, + LockoutSettingsAttributes: domain.LockoutSettingsAttributes{ + MaxPasswordAttempts: gu.Ptr(uint64(4)), + MaxOTPAttempts: gu.Ptr(uint64(4)), + ShowLockOutFailures: gu.Ptr(true), + }, + }, + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: gu.Ptr(firstOrgID), + }, + LockoutSettingsAttributes: domain.LockoutSettingsAttributes{ + MaxPasswordAttempts: gu.Ptr(uint64(5)), + MaxOTPAttempts: gu.Ptr(uint64(5)), + ShowLockOutFailures: gu.Ptr(false), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: gu.Ptr(secondOrgID), + }, + LockoutSettingsAttributes: domain.LockoutSettingsAttributes{ + MaxPasswordAttempts: gu.Ptr(uint64(6)), + MaxOTPAttempts: gu.Ptr(uint64(6)), + ShowLockOutFailures: gu.Ptr(true), + }, + }, + } + + for _, setting := range settings { + err := repo.Set(t.Context(), tx, setting) + require.NoError(t, err) + } + + tests := []struct { + name string + condition database.Condition + want *domain.LockoutSettings + wantErr error + }{ + { + name: "incomplete condition", + condition: repo.IDCondition(settings[0].ID), + wantErr: database.NewMissingConditionError(repo.IDColumn()), + }, + { + name: "not found", + condition: repo.PrimaryKeyCondition(firstInstanceID, "nix"), + wantErr: database.NewNoRowFoundError(nil), + }, + { + name: "too many", + condition: repo.InstanceIDCondition(firstInstanceID), + wantErr: database.NewMultipleRowsFoundError(nil), + }, + { + name: "ok, instance", + condition: repo.PrimaryKeyCondition(firstInstanceID, settings[0].ID), + want: settings[0], + }, + { + name: "ok, organization", + condition: repo.PrimaryKeyCondition(secondInstanceID, settings[3].ID), + want: settings[3], + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := repo.Get(t.Context(), tx, database.WithCondition(tt.condition)) + require.ErrorIs(t, err, tt.wantErr) + assert.EqualExportedValues(t, tt.want, got) + }) + } +} + +func TestListLockoutSettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + firstInstanceID := createInstance(t, tx) + secondInstanceID := createInstance(t, tx) + firstOrgID := createOrganization(t, tx, firstInstanceID) + secondOrgID := createOrganization(t, tx, secondInstanceID) + repo := repository.LockoutSettingsRepository() + + settings := []*domain.LockoutSettings{ + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: nil, + }, + LockoutSettingsAttributes: domain.LockoutSettingsAttributes{ + MaxPasswordAttempts: gu.Ptr(uint64(3)), + MaxOTPAttempts: gu.Ptr(uint64(3)), + ShowLockOutFailures: gu.Ptr(true), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: nil, + }, + LockoutSettingsAttributes: domain.LockoutSettingsAttributes{ + MaxPasswordAttempts: gu.Ptr(uint64(4)), + MaxOTPAttempts: gu.Ptr(uint64(4)), + ShowLockOutFailures: gu.Ptr(false), + }, + }, + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: gu.Ptr(firstOrgID), + }, + LockoutSettingsAttributes: domain.LockoutSettingsAttributes{ + MaxPasswordAttempts: gu.Ptr(uint64(5)), + MaxOTPAttempts: gu.Ptr(uint64(5)), + ShowLockOutFailures: gu.Ptr(true), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: gu.Ptr(secondOrgID), + }, + LockoutSettingsAttributes: domain.LockoutSettingsAttributes{ + MaxPasswordAttempts: gu.Ptr(uint64(6)), + MaxOTPAttempts: gu.Ptr(uint64(6)), + ShowLockOutFailures: gu.Ptr(true), + }, + }, + } + + for _, setting := range settings { + err := repo.Set(t.Context(), tx, setting) + require.NoError(t, err) + } + + tests := []struct { + name string + condition database.Condition + want []*domain.LockoutSettings + wantErr error + }{ + { + name: "incomplete condition", + condition: repo.OrganizationIDCondition(gu.Ptr(firstOrgID)), + wantErr: database.NewMissingConditionError(repo.IDColumn()), + }, + { + name: "no results, ok", + condition: repo.PrimaryKeyCondition(firstInstanceID, "nix"), + want: []*domain.LockoutSettings{}, + }, + { + name: "all from instance", + condition: repo.InstanceIDCondition(firstInstanceID), + want: []*domain.LockoutSettings{settings[2], settings[0]}, + }, + { + name: "only from instance", + condition: database.And( + repo.InstanceIDCondition(firstInstanceID), + repo.OrganizationIDCondition(nil), + ), + want: []*domain.LockoutSettings{settings[0]}, + }, + { + name: "all from first org", + condition: database.And( + repo.InstanceIDCondition(firstInstanceID), + repo.OrganizationIDCondition(gu.Ptr(firstOrgID)), + ), + want: []*domain.LockoutSettings{settings[2]}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := repo.List(t.Context(), tx, + database.WithCondition(tt.condition), + database.WithOrderByAscending(repo.InstanceIDColumn(), repo.OrganizationIDColumn()), + ) + require.ErrorIs(t, err, tt.wantErr) + assert.EqualExportedValues(t, tt.want, got) + }) + } +} + +func TestSetLockoutSettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + instanceID := createInstance(t, tx) + orgID := createOrganization(t, tx, instanceID) + repo := repository.LockoutSettingsRepository() + + existingSettings := &domain.LockoutSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr(orgID), + }, + LockoutSettingsAttributes: domain.LockoutSettingsAttributes{ + MaxPasswordAttempts: gu.Ptr(uint64(3)), + MaxOTPAttempts: gu.Ptr(uint64(3)), + ShowLockOutFailures: gu.Ptr(true), + }, + } + + err := repo.Set(t.Context(), tx, existingSettings) + require.NoError(t, err) + + tests := []struct { + name string + settings *domain.LockoutSettings + wantErr error + }{ + { + name: "create instance", + settings: &domain.LockoutSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: nil, + }, + LockoutSettingsAttributes: domain.LockoutSettingsAttributes{ + MaxPasswordAttempts: gu.Ptr(uint64(4)), + MaxOTPAttempts: gu.Ptr(uint64(4)), + ShowLockOutFailures: gu.Ptr(true), + }, + }, + }, + { + name: "update organization", + settings: &domain.LockoutSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr(orgID), + }, + LockoutSettingsAttributes: domain.LockoutSettingsAttributes{ + MaxPasswordAttempts: gu.Ptr(uint64(5)), + MaxOTPAttempts: gu.Ptr(uint64(5)), + ShowLockOutFailures: gu.Ptr(true), + }, + }, + }, + { + name: "non-existing instance", + settings: &domain.LockoutSettings{ + Settings: domain.Settings{ + InstanceID: "foo", + OrganizationID: nil, + }, + LockoutSettingsAttributes: domain.LockoutSettingsAttributes{ + MaxPasswordAttempts: gu.Ptr(uint64(3)), + MaxOTPAttempts: gu.Ptr(uint64(3)), + ShowLockOutFailures: gu.Ptr(true), + }, + }, + wantErr: new(database.ForeignKeyError), + }, + { + name: "non-existing org", + settings: &domain.LockoutSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr("foo"), + }, + LockoutSettingsAttributes: domain.LockoutSettingsAttributes{ + MaxPasswordAttempts: gu.Ptr(uint64(3)), + MaxOTPAttempts: gu.Ptr(uint64(3)), + ShowLockOutFailures: gu.Ptr(true), + }, + }, + wantErr: new(database.ForeignKeyError), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + savepoint, rollback := savepointForRollback(t, tx) + defer rollback() + err := repo.Set(t.Context(), savepoint, tt.settings) + require.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestDeleteLockoutSettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + instanceID := createInstance(t, tx) + orgID := createOrganization(t, tx, instanceID) + repo := repository.LockoutSettingsRepository() + + existingInstanceSettings := &domain.LockoutSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: nil, + }, + LockoutSettingsAttributes: domain.LockoutSettingsAttributes{ + MaxPasswordAttempts: gu.Ptr(uint64(3)), + MaxOTPAttempts: gu.Ptr(uint64(3)), + ShowLockOutFailures: gu.Ptr(true), + }, + } + err := repo.Set(t.Context(), tx, existingInstanceSettings) + require.NoError(t, err) + + existingOrganizationSettings := &domain.LockoutSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr(orgID), + }, + LockoutSettingsAttributes: domain.LockoutSettingsAttributes{ + MaxPasswordAttempts: gu.Ptr(uint64(4)), + MaxOTPAttempts: gu.Ptr(uint64(4)), + ShowLockOutFailures: gu.Ptr(true), + }, + } + err = repo.Set(t.Context(), tx, existingOrganizationSettings) + require.NoError(t, err) + + tests := []struct { + name string + condition database.Condition + wantRowsAffected int64 + wantErr error + }{ + { + name: "incomplete condition", + condition: repo.InstanceIDCondition(instanceID), + wantRowsAffected: 0, + wantErr: database.NewMissingConditionError(repo.IDColumn()), + }, + { + name: "not found", + condition: repo.UniqueCondition(existingInstanceSettings.InstanceID, gu.Ptr("foo"), domain.SettingTypeLockout, domain.SettingStateActive), + wantRowsAffected: 0, + }, + { + name: "delete instance", + condition: repo.UniqueCondition(existingInstanceSettings.InstanceID, existingInstanceSettings.OrganizationID, domain.SettingTypeLockout, domain.SettingStateActive), + wantRowsAffected: 1, + }, + { + name: "delete instance twice", + condition: repo.UniqueCondition(existingInstanceSettings.InstanceID, existingInstanceSettings.OrganizationID, domain.SettingTypeLockout, domain.SettingStateActive), + wantRowsAffected: 0, + }, + { + name: "delete organization", + condition: repo.UniqueCondition(existingOrganizationSettings.InstanceID, existingOrganizationSettings.OrganizationID, domain.SettingTypeLockout, domain.SettingStateActive), + wantRowsAffected: 1, + }, + { + name: "delete organization twice", + condition: repo.UniqueCondition(existingOrganizationSettings.InstanceID, existingOrganizationSettings.OrganizationID, domain.SettingTypeLockout, domain.SettingStateActive), + wantRowsAffected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rowsAffected, err := repo.Delete(t.Context(), tx, tt.condition) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.wantRowsAffected, rowsAffected) + }) + } +} + +func TestGetSecuritySettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + firstInstanceID := createInstance(t, tx) + secondInstanceID := createInstance(t, tx) + firstOrgID := createOrganization(t, tx, firstInstanceID) + secondOrgID := createOrganization(t, tx, secondInstanceID) + repo := repository.SecuritySettingsRepository() + + settings := []*domain.SecuritySettings{ + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: nil, + }, + SecuritySettingsAttributes: domain.SecuritySettingsAttributes{ + EnableIframeEmbedding: gu.Ptr(true), + AllowedOrigins: []string{"origin1", "origin2"}, + EnableImpersonation: gu.Ptr(true), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: nil, + }, + SecuritySettingsAttributes: domain.SecuritySettingsAttributes{ + EnableIframeEmbedding: gu.Ptr(true), + AllowedOrigins: []string{"origin3", "origin4"}, + EnableImpersonation: gu.Ptr(false), + }, + }, + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: gu.Ptr(firstOrgID), + }, + SecuritySettingsAttributes: domain.SecuritySettingsAttributes{ + EnableIframeEmbedding: gu.Ptr(true), + AllowedOrigins: []string{"origin5", "origin6"}, + EnableImpersonation: gu.Ptr(true), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: gu.Ptr(secondOrgID), + }, + SecuritySettingsAttributes: domain.SecuritySettingsAttributes{ + EnableIframeEmbedding: gu.Ptr(false), + AllowedOrigins: []string{"origin7", "origin8"}, + EnableImpersonation: gu.Ptr(true), + }, + }, + } + + for _, setting := range settings { + err := repo.Set(t.Context(), tx, setting) + require.NoError(t, err) + } + + tests := []struct { + name string + condition database.Condition + want *domain.SecuritySettings + wantErr error + }{ + { + name: "incomplete condition", + condition: repo.IDCondition(settings[0].ID), + wantErr: database.NewMissingConditionError(repo.IDColumn()), + }, + { + name: "not found", + condition: repo.PrimaryKeyCondition(firstInstanceID, "nix"), + wantErr: database.NewNoRowFoundError(nil), + }, + { + name: "too many", + condition: repo.InstanceIDCondition(firstInstanceID), + wantErr: database.NewMultipleRowsFoundError(nil), + }, + { + name: "ok, instance", + condition: repo.PrimaryKeyCondition(firstInstanceID, settings[0].ID), + want: settings[0], + }, + { + name: "ok, organization", + condition: repo.PrimaryKeyCondition(secondInstanceID, settings[3].ID), + want: settings[3], + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := repo.Get(t.Context(), tx, database.WithCondition(tt.condition)) + require.ErrorIs(t, err, tt.wantErr) + assert.EqualExportedValues(t, tt.want, got) + }) + } +} + +func TestListSecuritySettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + firstInstanceID := createInstance(t, tx) + secondInstanceID := createInstance(t, tx) + firstOrgID := createOrganization(t, tx, firstInstanceID) + secondOrgID := createOrganization(t, tx, secondInstanceID) + repo := repository.SecuritySettingsRepository() + + settings := []*domain.SecuritySettings{ + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: nil, + }, + SecuritySettingsAttributes: domain.SecuritySettingsAttributes{ + EnableIframeEmbedding: gu.Ptr(true), + AllowedOrigins: []string{"origin1", "origin2"}, + EnableImpersonation: gu.Ptr(true), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: nil, + }, + SecuritySettingsAttributes: domain.SecuritySettingsAttributes{ + EnableIframeEmbedding: gu.Ptr(true), + AllowedOrigins: []string{"origin3", "origin4"}, + EnableImpersonation: gu.Ptr(false), + }, + }, + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: gu.Ptr(firstOrgID), + }, + SecuritySettingsAttributes: domain.SecuritySettingsAttributes{ + EnableIframeEmbedding: gu.Ptr(true), + AllowedOrigins: []string{"origin5", "origin6"}, + EnableImpersonation: gu.Ptr(true), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: gu.Ptr(secondOrgID), + }, + SecuritySettingsAttributes: domain.SecuritySettingsAttributes{ + EnableIframeEmbedding: gu.Ptr(false), + AllowedOrigins: []string{"origin7", "origin8"}, + EnableImpersonation: gu.Ptr(true), + }, + }, + } + + for _, setting := range settings { + err := repo.Set(t.Context(), tx, setting) + require.NoError(t, err) + } + + tests := []struct { + name string + condition database.Condition + want []*domain.SecuritySettings + wantErr error + }{ + { + name: "incomplete condition", + condition: repo.OrganizationIDCondition(gu.Ptr(firstOrgID)), + wantErr: database.NewMissingConditionError(repo.IDColumn()), + }, + { + name: "no results, ok", + condition: repo.PrimaryKeyCondition(firstInstanceID, "nix"), + want: []*domain.SecuritySettings{}, + }, + { + name: "all from instance", + condition: repo.InstanceIDCondition(firstInstanceID), + want: []*domain.SecuritySettings{settings[2], settings[0]}, + }, + { + name: "only from instance", + condition: database.And( + repo.InstanceIDCondition(firstInstanceID), + repo.OrganizationIDCondition(nil), + ), + want: []*domain.SecuritySettings{settings[0]}, + }, + { + name: "all from first org", + condition: database.And( + repo.InstanceIDCondition(firstInstanceID), + repo.OrganizationIDCondition(gu.Ptr(firstOrgID)), + ), + want: []*domain.SecuritySettings{settings[2]}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := repo.List(t.Context(), tx, + database.WithCondition(tt.condition), + database.WithOrderByAscending(repo.InstanceIDColumn(), repo.OrganizationIDColumn()), + ) + require.ErrorIs(t, err, tt.wantErr) + assert.EqualExportedValues(t, tt.want, got) + }) + } +} + +func TestSetSecuritySettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + instanceID := createInstance(t, tx) + orgID := createOrganization(t, tx, instanceID) + repo := repository.SecuritySettingsRepository() + + existingSettings := &domain.SecuritySettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr(orgID), + }, + SecuritySettingsAttributes: domain.SecuritySettingsAttributes{ + EnableIframeEmbedding: gu.Ptr(true), + AllowedOrigins: []string{"origin1", "origin2"}, + EnableImpersonation: gu.Ptr(true), + }, + } + + err := repo.Set(t.Context(), tx, existingSettings) + require.NoError(t, err) + + tests := []struct { + name string + settings *domain.SecuritySettings + wantErr error + }{ + { + name: "create instance", + settings: &domain.SecuritySettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: nil, + }, + SecuritySettingsAttributes: domain.SecuritySettingsAttributes{ + EnableIframeEmbedding: gu.Ptr(true), + AllowedOrigins: []string{"origin1", "origin2"}, + EnableImpersonation: gu.Ptr(true), + }, + }, + }, + { + name: "update organization", + settings: &domain.SecuritySettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr(orgID), + }, + SecuritySettingsAttributes: domain.SecuritySettingsAttributes{ + EnableIframeEmbedding: gu.Ptr(true), + AllowedOrigins: []string{"origin1", "origin2"}, + EnableImpersonation: gu.Ptr(true), + }, + }, + }, + { + name: "non-existing instance", + settings: &domain.SecuritySettings{ + Settings: domain.Settings{ + InstanceID: "foo", + OrganizationID: nil, + }, + SecuritySettingsAttributes: domain.SecuritySettingsAttributes{ + EnableIframeEmbedding: gu.Ptr(true), + AllowedOrigins: []string{"origin1", "origin2"}, + EnableImpersonation: gu.Ptr(true), + }, + }, + wantErr: new(database.ForeignKeyError), + }, + { + name: "non-existing org", + settings: &domain.SecuritySettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr("foo"), + }, + SecuritySettingsAttributes: domain.SecuritySettingsAttributes{ + EnableIframeEmbedding: gu.Ptr(true), + AllowedOrigins: []string{"origin1", "origin2"}, + EnableImpersonation: gu.Ptr(true), + }, + }, + wantErr: new(database.ForeignKeyError), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + savepoint, rollback := savepointForRollback(t, tx) + defer rollback() + err := repo.Set(t.Context(), savepoint, tt.settings) + require.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestDeleteSecuritySettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + instanceID := createInstance(t, tx) + orgID := createOrganization(t, tx, instanceID) + repo := repository.SecuritySettingsRepository() + + existingInstanceSettings := &domain.SecuritySettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: nil, + }, + SecuritySettingsAttributes: domain.SecuritySettingsAttributes{ + EnableIframeEmbedding: gu.Ptr(true), + AllowedOrigins: []string{"origin1", "origin2"}, + EnableImpersonation: gu.Ptr(true), + }, + } + err := repo.Set(t.Context(), tx, existingInstanceSettings) + require.NoError(t, err) + + existingOrganizationSettings := &domain.SecuritySettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr(orgID), + }, + SecuritySettingsAttributes: domain.SecuritySettingsAttributes{ + EnableIframeEmbedding: gu.Ptr(true), + AllowedOrigins: []string{"origin3", "origin4"}, + EnableImpersonation: gu.Ptr(true), + }, + } + err = repo.Set(t.Context(), tx, existingOrganizationSettings) + require.NoError(t, err) + + tests := []struct { + name string + condition database.Condition + wantRowsAffected int64 + wantErr error + }{ + { + name: "incomplete condition", + condition: repo.InstanceIDCondition(instanceID), + wantRowsAffected: 0, + wantErr: database.NewMissingConditionError(repo.IDColumn()), + }, + { + name: "not found", + condition: repo.UniqueCondition(existingInstanceSettings.InstanceID, gu.Ptr("foo"), domain.SettingTypeSecurity, domain.SettingStateActive), + wantRowsAffected: 0, + }, + { + name: "delete instance", + condition: repo.UniqueCondition(existingInstanceSettings.InstanceID, existingInstanceSettings.OrganizationID, domain.SettingTypeSecurity, domain.SettingStateActive), + wantRowsAffected: 1, + }, + { + name: "delete instance twice", + condition: repo.UniqueCondition(existingInstanceSettings.InstanceID, existingInstanceSettings.OrganizationID, domain.SettingTypeSecurity, domain.SettingStateActive), + wantRowsAffected: 0, + }, + { + name: "delete organization", + condition: repo.UniqueCondition(existingOrganizationSettings.InstanceID, existingOrganizationSettings.OrganizationID, domain.SettingTypeSecurity, domain.SettingStateActive), + wantRowsAffected: 1, + }, + { + name: "delete organization twice", + condition: repo.UniqueCondition(existingOrganizationSettings.InstanceID, existingOrganizationSettings.OrganizationID, domain.SettingTypeSecurity, domain.SettingStateActive), + wantRowsAffected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rowsAffected, err := repo.Delete(t.Context(), tx, tt.condition) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.wantRowsAffected, rowsAffected) + }) + } +} + +func TestGetDomainSettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + firstInstanceID := createInstance(t, tx) + secondInstanceID := createInstance(t, tx) + firstOrgID := createOrganization(t, tx, firstInstanceID) + secondOrgID := createOrganization(t, tx, secondInstanceID) + repo := repository.DomainSettingsRepository() + + settings := []*domain.DomainSettings{ + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: nil, + }, + DomainSettingsAttributes: domain.DomainSettingsAttributes{ + LoginNameIncludesDomain: gu.Ptr(true), + RequireOrgDomainVerification: gu.Ptr(true), + SMTPSenderAddressMatchesInstanceDomain: gu.Ptr(true), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: nil, + }, + DomainSettingsAttributes: domain.DomainSettingsAttributes{ + LoginNameIncludesDomain: gu.Ptr(false), + RequireOrgDomainVerification: gu.Ptr(true), + SMTPSenderAddressMatchesInstanceDomain: gu.Ptr(true), + }, + }, + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: gu.Ptr(firstOrgID), + }, + DomainSettingsAttributes: domain.DomainSettingsAttributes{ + LoginNameIncludesDomain: gu.Ptr(true), + RequireOrgDomainVerification: gu.Ptr(false), + SMTPSenderAddressMatchesInstanceDomain: gu.Ptr(true), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: gu.Ptr(secondOrgID), + }, + DomainSettingsAttributes: domain.DomainSettingsAttributes{ + LoginNameIncludesDomain: gu.Ptr(true), + RequireOrgDomainVerification: gu.Ptr(true), + SMTPSenderAddressMatchesInstanceDomain: gu.Ptr(false), + }, + }, + } + + for _, setting := range settings { + err := repo.Set(t.Context(), tx, setting) + require.NoError(t, err) + } + + tests := []struct { + name string + condition database.Condition + want *domain.DomainSettings + wantErr error + }{ + { + name: "incomplete condition", + condition: repo.IDCondition(settings[0].ID), + wantErr: database.NewMissingConditionError(repo.IDColumn()), + }, + { + name: "not found", + condition: repo.PrimaryKeyCondition(firstInstanceID, "nix"), + wantErr: database.NewNoRowFoundError(nil), + }, + { + name: "too many", + condition: repo.InstanceIDCondition(firstInstanceID), + wantErr: database.NewMultipleRowsFoundError(nil), + }, + { + name: "ok, instance", + condition: repo.PrimaryKeyCondition(firstInstanceID, settings[0].ID), + want: settings[0], + }, + { + name: "ok, organization", + condition: repo.PrimaryKeyCondition(secondInstanceID, settings[3].ID), + want: settings[3], + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := repo.Get(t.Context(), tx, database.WithCondition(tt.condition)) + require.ErrorIs(t, err, tt.wantErr) + assert.EqualExportedValues(t, tt.want, got) + }) + } +} + +func TestListDomainSettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + firstInstanceID := createInstance(t, tx) + secondInstanceID := createInstance(t, tx) + firstOrgID := createOrganization(t, tx, firstInstanceID) + secondOrgID := createOrganization(t, tx, secondInstanceID) + repo := repository.DomainSettingsRepository() + + settings := []*domain.DomainSettings{ + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: nil, + }, + DomainSettingsAttributes: domain.DomainSettingsAttributes{ + LoginNameIncludesDomain: gu.Ptr(true), + RequireOrgDomainVerification: gu.Ptr(true), + SMTPSenderAddressMatchesInstanceDomain: gu.Ptr(true), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: nil, + }, + DomainSettingsAttributes: domain.DomainSettingsAttributes{ + LoginNameIncludesDomain: gu.Ptr(false), + RequireOrgDomainVerification: gu.Ptr(true), + SMTPSenderAddressMatchesInstanceDomain: gu.Ptr(true), + }, + }, + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: gu.Ptr(firstOrgID), + }, + DomainSettingsAttributes: domain.DomainSettingsAttributes{ + LoginNameIncludesDomain: gu.Ptr(true), + RequireOrgDomainVerification: gu.Ptr(false), + SMTPSenderAddressMatchesInstanceDomain: gu.Ptr(true), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: gu.Ptr(secondOrgID), + }, + DomainSettingsAttributes: domain.DomainSettingsAttributes{ + LoginNameIncludesDomain: gu.Ptr(true), + RequireOrgDomainVerification: gu.Ptr(true), + SMTPSenderAddressMatchesInstanceDomain: gu.Ptr(false), + }, + }, + } + + for _, setting := range settings { + err := repo.Set(t.Context(), tx, setting) + require.NoError(t, err) + } + + tests := []struct { + name string + condition database.Condition + want []*domain.DomainSettings + wantErr error + }{ + { + name: "incomplete condition", + condition: repo.OrganizationIDCondition(gu.Ptr(firstOrgID)), + wantErr: database.NewMissingConditionError(repo.IDColumn()), + }, + { + name: "no results, ok", + condition: repo.PrimaryKeyCondition(firstInstanceID, "nix"), + want: []*domain.DomainSettings{}, + }, + { + name: "all from instance", + condition: repo.InstanceIDCondition(firstInstanceID), + want: []*domain.DomainSettings{settings[2], settings[0]}, + }, + { + name: "only from instance", + condition: database.And( + repo.InstanceIDCondition(firstInstanceID), + repo.OrganizationIDCondition(nil), + ), + want: []*domain.DomainSettings{settings[0]}, + }, + { + name: "all from first org", + condition: database.And( + repo.InstanceIDCondition(firstInstanceID), + repo.OrganizationIDCondition(gu.Ptr(firstOrgID)), + ), + want: []*domain.DomainSettings{settings[2]}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := repo.List(t.Context(), tx, + database.WithCondition(tt.condition), + database.WithOrderByAscending(repo.InstanceIDColumn(), repo.OrganizationIDColumn()), + ) + require.ErrorIs(t, err, tt.wantErr) + assert.EqualExportedValues(t, tt.want, got) + }) + } +} + +func TestSetDomainSettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + instanceID := createInstance(t, tx) + orgID := createOrganization(t, tx, instanceID) + repo := repository.DomainSettingsRepository() + + existingSettings := &domain.DomainSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr(orgID), + }, + DomainSettingsAttributes: domain.DomainSettingsAttributes{ + LoginNameIncludesDomain: gu.Ptr(true), + RequireOrgDomainVerification: gu.Ptr(true), + SMTPSenderAddressMatchesInstanceDomain: gu.Ptr(true), + }, + } + + err := repo.Set(t.Context(), tx, existingSettings) + require.NoError(t, err) + + tests := []struct { + name string + settings *domain.DomainSettings + wantErr error + }{ + { + name: "create instance", + settings: &domain.DomainSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: nil, + }, + DomainSettingsAttributes: domain.DomainSettingsAttributes{ + LoginNameIncludesDomain: gu.Ptr(true), + RequireOrgDomainVerification: gu.Ptr(true), + SMTPSenderAddressMatchesInstanceDomain: gu.Ptr(true), + }, + }, + }, + { + name: "update organization", + settings: &domain.DomainSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr(orgID), + }, + DomainSettingsAttributes: domain.DomainSettingsAttributes{ + LoginNameIncludesDomain: gu.Ptr(true), + RequireOrgDomainVerification: gu.Ptr(true), + SMTPSenderAddressMatchesInstanceDomain: gu.Ptr(true), + }, + }, + }, + { + name: "non-existing instance", + settings: &domain.DomainSettings{ + Settings: domain.Settings{ + InstanceID: "foo", + OrganizationID: nil, + }, + DomainSettingsAttributes: domain.DomainSettingsAttributes{ + LoginNameIncludesDomain: gu.Ptr(true), + RequireOrgDomainVerification: gu.Ptr(true), + SMTPSenderAddressMatchesInstanceDomain: gu.Ptr(true), + }, + }, + wantErr: new(database.ForeignKeyError), + }, + { + name: "non-existing org", + settings: &domain.DomainSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr("foo"), + }, + DomainSettingsAttributes: domain.DomainSettingsAttributes{ + LoginNameIncludesDomain: gu.Ptr(true), + RequireOrgDomainVerification: gu.Ptr(true), + SMTPSenderAddressMatchesInstanceDomain: gu.Ptr(true), + }, + }, + wantErr: new(database.ForeignKeyError), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + savepoint, rollback := savepointForRollback(t, tx) + defer rollback() + err := repo.Set(t.Context(), savepoint, tt.settings) + require.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestDeleteDomainSettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + instanceID := createInstance(t, tx) + orgID := createOrganization(t, tx, instanceID) + repo := repository.DomainSettingsRepository() + + existingInstanceSettings := &domain.DomainSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: nil, + }, + DomainSettingsAttributes: domain.DomainSettingsAttributes{ + LoginNameIncludesDomain: gu.Ptr(true), + RequireOrgDomainVerification: gu.Ptr(true), + SMTPSenderAddressMatchesInstanceDomain: gu.Ptr(true), + }, + } + err := repo.Set(t.Context(), tx, existingInstanceSettings) + require.NoError(t, err) + + existingOrganizationSettings := &domain.DomainSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr(orgID), + }, + DomainSettingsAttributes: domain.DomainSettingsAttributes{ + LoginNameIncludesDomain: gu.Ptr(true), + RequireOrgDomainVerification: gu.Ptr(false), + SMTPSenderAddressMatchesInstanceDomain: gu.Ptr(true), + }, + } + err = repo.Set(t.Context(), tx, existingOrganizationSettings) + require.NoError(t, err) + + tests := []struct { + name string + condition database.Condition + wantRowsAffected int64 + wantErr error + }{ + { + name: "incomplete condition", + condition: repo.InstanceIDCondition(instanceID), + wantRowsAffected: 0, + wantErr: database.NewMissingConditionError(repo.IDColumn()), + }, + { + name: "not found", + condition: repo.UniqueCondition(existingInstanceSettings.InstanceID, gu.Ptr("foo"), domain.SettingTypeDomain, domain.SettingStateActive), + wantRowsAffected: 0, + }, + { + name: "delete instance", + condition: repo.UniqueCondition(existingInstanceSettings.InstanceID, existingInstanceSettings.OrganizationID, domain.SettingTypeDomain, domain.SettingStateActive), + wantRowsAffected: 1, + }, + { + name: "delete instance twice", + condition: repo.UniqueCondition(existingInstanceSettings.InstanceID, existingInstanceSettings.OrganizationID, domain.SettingTypeDomain, domain.SettingStateActive), + wantRowsAffected: 0, + }, + { + name: "delete organization", + condition: repo.UniqueCondition(existingOrganizationSettings.InstanceID, existingOrganizationSettings.OrganizationID, domain.SettingTypeDomain, domain.SettingStateActive), + wantRowsAffected: 1, + }, + { + name: "delete organization twice", + condition: repo.UniqueCondition(existingOrganizationSettings.InstanceID, existingOrganizationSettings.OrganizationID, domain.SettingTypeDomain, domain.SettingStateActive), + wantRowsAffected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rowsAffected, err := repo.Delete(t.Context(), tx, tt.condition) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.wantRowsAffected, rowsAffected) + }) + } +} + +func TestGetOrganizationSettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + firstInstanceID := createInstance(t, tx) + secondInstanceID := createInstance(t, tx) + firstOrgID := createOrganization(t, tx, firstInstanceID) + secondOrgID := createOrganization(t, tx, secondInstanceID) + repo := repository.OrganizationSettingsRepository() + + settings := []*domain.OrganizationSettings{ + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: nil, + }, + OrganizationSettingsAttributes: domain.OrganizationSettingsAttributes{ + OrganizationScopedUsernames: gu.Ptr(true), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: nil, + }, + OrganizationSettingsAttributes: domain.OrganizationSettingsAttributes{ + OrganizationScopedUsernames: gu.Ptr(true), + }, + }, + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: gu.Ptr(firstOrgID), + }, + OrganizationSettingsAttributes: domain.OrganizationSettingsAttributes{ + OrganizationScopedUsernames: gu.Ptr(true), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: gu.Ptr(secondOrgID), + }, + OrganizationSettingsAttributes: domain.OrganizationSettingsAttributes{ + OrganizationScopedUsernames: gu.Ptr(true), + }, + }, + } + + for _, setting := range settings { + err := repo.Set(t.Context(), tx, setting) + require.NoError(t, err) + } + + tests := []struct { + name string + condition database.Condition + want *domain.OrganizationSettings + wantErr error + }{ + { + name: "incomplete condition", + condition: repo.IDCondition(settings[0].ID), + wantErr: database.NewMissingConditionError(repo.IDColumn()), + }, + { + name: "not found", + condition: repo.PrimaryKeyCondition(firstInstanceID, "nix"), + wantErr: database.NewNoRowFoundError(nil), + }, + { + name: "too many", + condition: repo.InstanceIDCondition(firstInstanceID), + wantErr: database.NewMultipleRowsFoundError(nil), + }, + { + name: "ok, instance", + condition: repo.PrimaryKeyCondition(firstInstanceID, settings[0].ID), + want: settings[0], + }, + { + name: "ok, organization", + condition: repo.PrimaryKeyCondition(secondInstanceID, settings[3].ID), + want: settings[3], + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := repo.Get(t.Context(), tx, database.WithCondition(tt.condition)) + require.ErrorIs(t, err, tt.wantErr) + assert.EqualExportedValues(t, tt.want, got) + }) + } +} + +func TestListOrganizationSettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + firstInstanceID := createInstance(t, tx) + secondInstanceID := createInstance(t, tx) + firstOrgID := createOrganization(t, tx, firstInstanceID) + secondOrgID := createOrganization(t, tx, secondInstanceID) + repo := repository.OrganizationSettingsRepository() + + settings := []*domain.OrganizationSettings{ + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: nil, + }, + OrganizationSettingsAttributes: domain.OrganizationSettingsAttributes{ + OrganizationScopedUsernames: gu.Ptr(true), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: nil, + }, + OrganizationSettingsAttributes: domain.OrganizationSettingsAttributes{ + OrganizationScopedUsernames: gu.Ptr(true), + }, + }, + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: gu.Ptr(firstOrgID), + }, + OrganizationSettingsAttributes: domain.OrganizationSettingsAttributes{ + OrganizationScopedUsernames: gu.Ptr(true), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: gu.Ptr(secondOrgID), + }, + OrganizationSettingsAttributes: domain.OrganizationSettingsAttributes{ + OrganizationScopedUsernames: gu.Ptr(true), + }, + }, + } + + for _, setting := range settings { + err := repo.Set(t.Context(), tx, setting) + require.NoError(t, err) + } + + tests := []struct { + name string + condition database.Condition + want []*domain.OrganizationSettings + wantErr error + }{ + { + name: "incomplete condition", + condition: repo.OrganizationIDCondition(gu.Ptr(firstOrgID)), + wantErr: database.NewMissingConditionError(repo.IDColumn()), + }, + { + name: "no results, ok", + condition: repo.PrimaryKeyCondition(firstInstanceID, "nix"), + want: []*domain.OrganizationSettings{}, + }, + { + name: "all from instance", + condition: repo.InstanceIDCondition(firstInstanceID), + want: []*domain.OrganizationSettings{settings[2], settings[0]}, + }, + { + name: "only from instance", + condition: database.And( + repo.InstanceIDCondition(firstInstanceID), + repo.OrganizationIDCondition(nil), + ), + want: []*domain.OrganizationSettings{settings[0]}, + }, + { + name: "all from first org", + condition: database.And( + repo.InstanceIDCondition(firstInstanceID), + repo.OrganizationIDCondition(gu.Ptr(firstOrgID)), + ), + want: []*domain.OrganizationSettings{settings[2]}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := repo.List(t.Context(), tx, + database.WithCondition(tt.condition), + database.WithOrderByAscending(repo.InstanceIDColumn(), repo.OrganizationIDColumn()), + ) + require.ErrorIs(t, err, tt.wantErr) + assert.EqualExportedValues(t, tt.want, got) + }) + } +} + +func TestSetOrganizationSettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + instanceID := createInstance(t, tx) + orgID := createOrganization(t, tx, instanceID) + repo := repository.OrganizationSettingsRepository() + + existingSettings := &domain.OrganizationSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr(orgID), + }, + OrganizationSettingsAttributes: domain.OrganizationSettingsAttributes{ + OrganizationScopedUsernames: gu.Ptr(true), + }, + } + + err := repo.Set(t.Context(), tx, existingSettings) + require.NoError(t, err) + + tests := []struct { + name string + settings *domain.OrganizationSettings + wantErr error + }{ + { + name: "create instance", + settings: &domain.OrganizationSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: nil, + }, + OrganizationSettingsAttributes: domain.OrganizationSettingsAttributes{ + OrganizationScopedUsernames: gu.Ptr(true), + }, + }, + }, + { + name: "update organization", + settings: &domain.OrganizationSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr(orgID), + }, + OrganizationSettingsAttributes: domain.OrganizationSettingsAttributes{ + OrganizationScopedUsernames: gu.Ptr(true), + }, + }, + }, + { + name: "non-existing instance", + settings: &domain.OrganizationSettings{ + Settings: domain.Settings{ + InstanceID: "foo", + OrganizationID: nil, + }, + OrganizationSettingsAttributes: domain.OrganizationSettingsAttributes{ + OrganizationScopedUsernames: gu.Ptr(true), + }, + }, + wantErr: new(database.ForeignKeyError), + }, + { + name: "non-existing org", + settings: &domain.OrganizationSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr("foo"), + }, + OrganizationSettingsAttributes: domain.OrganizationSettingsAttributes{ + OrganizationScopedUsernames: gu.Ptr(true), + }, + }, + wantErr: new(database.ForeignKeyError), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + savepoint, rollback := savepointForRollback(t, tx) + defer rollback() + err := repo.Set(t.Context(), savepoint, tt.settings) + require.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestDeleteOrganizationSettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + instanceID := createInstance(t, tx) + orgID := createOrganization(t, tx, instanceID) + repo := repository.OrganizationSettingsRepository() + + existingInstanceSettings := &domain.OrganizationSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: nil, + }, + OrganizationSettingsAttributes: domain.OrganizationSettingsAttributes{ + OrganizationScopedUsernames: gu.Ptr(true), + }, + } + err := repo.Set(t.Context(), tx, existingInstanceSettings) + require.NoError(t, err) + + existingOrganizationSettings := &domain.OrganizationSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr(orgID), + }, + OrganizationSettingsAttributes: domain.OrganizationSettingsAttributes{ + OrganizationScopedUsernames: gu.Ptr(true), + }, + } + err = repo.Set(t.Context(), tx, existingOrganizationSettings) + require.NoError(t, err) + + tests := []struct { + name string + condition database.Condition + wantRowsAffected int64 + wantErr error + }{ + { + name: "incomplete condition", + condition: repo.InstanceIDCondition(instanceID), + wantRowsAffected: 0, + wantErr: database.NewMissingConditionError(repo.IDColumn()), + }, + { + name: "not found", + condition: repo.UniqueCondition(existingInstanceSettings.InstanceID, gu.Ptr("foo"), domain.SettingTypeOrganization, domain.SettingStateActive), + wantRowsAffected: 0, + }, + { + name: "delete instance", + condition: repo.UniqueCondition(existingInstanceSettings.InstanceID, existingInstanceSettings.OrganizationID, domain.SettingTypeOrganization, domain.SettingStateActive), + wantRowsAffected: 1, + }, + { + name: "delete instance twice", + condition: repo.UniqueCondition(existingInstanceSettings.InstanceID, existingInstanceSettings.OrganizationID, domain.SettingTypeOrganization, domain.SettingStateActive), + wantRowsAffected: 0, + }, + { + name: "delete organization", + condition: repo.UniqueCondition(existingOrganizationSettings.InstanceID, existingOrganizationSettings.OrganizationID, domain.SettingTypeOrganization, domain.SettingStateActive), + wantRowsAffected: 1, + }, + { + name: "delete organization twice", + condition: repo.UniqueCondition(existingOrganizationSettings.InstanceID, existingOrganizationSettings.OrganizationID, domain.SettingTypeOrganization, domain.SettingStateActive), + wantRowsAffected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rowsAffected, err := repo.Delete(t.Context(), tx, tt.condition) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.wantRowsAffected, rowsAffected) + }) + } +} + +func TestGetNotificationSettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + firstInstanceID := createInstance(t, tx) + secondInstanceID := createInstance(t, tx) + firstOrgID := createOrganization(t, tx, firstInstanceID) + secondOrgID := createOrganization(t, tx, secondInstanceID) + repo := repository.NotificationSettingsRepository() + + settings := []*domain.NotificationSettings{ + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: nil, + }, + NotificationSettingsAttributes: domain.NotificationSettingsAttributes{ + PasswordChange: gu.Ptr(true), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: nil, + }, + NotificationSettingsAttributes: domain.NotificationSettingsAttributes{ + PasswordChange: gu.Ptr(true), + }, + }, + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: gu.Ptr(firstOrgID), + }, + NotificationSettingsAttributes: domain.NotificationSettingsAttributes{ + PasswordChange: gu.Ptr(true), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: gu.Ptr(secondOrgID), + }, + NotificationSettingsAttributes: domain.NotificationSettingsAttributes{ + PasswordChange: gu.Ptr(true), + }, + }, + } + + for _, setting := range settings { + err := repo.Set(t.Context(), tx, setting) + require.NoError(t, err) + } + + tests := []struct { + name string + condition database.Condition + want *domain.NotificationSettings + wantErr error + }{ + { + name: "incomplete condition", + condition: repo.IDCondition(settings[0].ID), + wantErr: database.NewMissingConditionError(repo.IDColumn()), + }, + { + name: "not found", + condition: repo.PrimaryKeyCondition(firstInstanceID, "nix"), + wantErr: database.NewNoRowFoundError(nil), + }, + { + name: "too many", + condition: repo.InstanceIDCondition(firstInstanceID), + wantErr: database.NewMultipleRowsFoundError(nil), + }, + { + name: "ok, instance", + condition: repo.PrimaryKeyCondition(firstInstanceID, settings[0].ID), + want: settings[0], + }, + { + name: "ok, organization", + condition: repo.PrimaryKeyCondition(secondInstanceID, settings[3].ID), + want: settings[3], + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := repo.Get(t.Context(), tx, database.WithCondition(tt.condition)) + require.ErrorIs(t, err, tt.wantErr) + assert.EqualExportedValues(t, tt.want, got) + }) + } +} + +func TestListNotificationSettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + firstInstanceID := createInstance(t, tx) + secondInstanceID := createInstance(t, tx) + firstOrgID := createOrganization(t, tx, firstInstanceID) + secondOrgID := createOrganization(t, tx, secondInstanceID) + repo := repository.NotificationSettingsRepository() + + settings := []*domain.NotificationSettings{ + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: nil, + }, + NotificationSettingsAttributes: domain.NotificationSettingsAttributes{ + PasswordChange: gu.Ptr(true), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: nil, + }, + NotificationSettingsAttributes: domain.NotificationSettingsAttributes{ + PasswordChange: gu.Ptr(true), + }, + }, + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: gu.Ptr(firstOrgID), + }, + NotificationSettingsAttributes: domain.NotificationSettingsAttributes{ + PasswordChange: gu.Ptr(true), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: gu.Ptr(secondOrgID), + }, + NotificationSettingsAttributes: domain.NotificationSettingsAttributes{ + PasswordChange: gu.Ptr(true), + }, + }, + } + + for _, setting := range settings { + err := repo.Set(t.Context(), tx, setting) + require.NoError(t, err) + } + + tests := []struct { + name string + condition database.Condition + want []*domain.NotificationSettings + wantErr error + }{ + { + name: "incomplete condition", + condition: repo.OrganizationIDCondition(gu.Ptr(firstOrgID)), + wantErr: database.NewMissingConditionError(repo.IDColumn()), + }, + { + name: "no results, ok", + condition: repo.PrimaryKeyCondition(firstInstanceID, "nix"), + want: []*domain.NotificationSettings{}, + }, + { + name: "all from instance", + condition: repo.InstanceIDCondition(firstInstanceID), + want: []*domain.NotificationSettings{settings[2], settings[0]}, + }, + { + name: "only from instance", + condition: database.And( + repo.InstanceIDCondition(firstInstanceID), + repo.OrganizationIDCondition(nil), + ), + want: []*domain.NotificationSettings{settings[0]}, + }, + { + name: "all from first org", + condition: database.And( + repo.InstanceIDCondition(firstInstanceID), + repo.OrganizationIDCondition(gu.Ptr(firstOrgID)), + ), + want: []*domain.NotificationSettings{settings[2]}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := repo.List(t.Context(), tx, + database.WithCondition(tt.condition), + database.WithOrderByAscending(repo.InstanceIDColumn(), repo.OrganizationIDColumn()), + ) + require.ErrorIs(t, err, tt.wantErr) + assert.EqualExportedValues(t, tt.want, got) + }) + } +} + +func TestSetNotificationSettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + instanceID := createInstance(t, tx) + orgID := createOrganization(t, tx, instanceID) + repo := repository.NotificationSettingsRepository() + + existingSettings := &domain.NotificationSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr(orgID), + }, + NotificationSettingsAttributes: domain.NotificationSettingsAttributes{ + PasswordChange: gu.Ptr(true), + }, + } + + err := repo.Set(t.Context(), tx, existingSettings) + require.NoError(t, err) + + tests := []struct { + name string + settings *domain.NotificationSettings + wantErr error + }{ + { + name: "create instance", + settings: &domain.NotificationSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: nil, + }, + NotificationSettingsAttributes: domain.NotificationSettingsAttributes{ + PasswordChange: gu.Ptr(true), + }, + }, + }, + { + name: "update organization", + settings: &domain.NotificationSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr(orgID), + }, + NotificationSettingsAttributes: domain.NotificationSettingsAttributes{ + PasswordChange: gu.Ptr(true), + }, + }, + }, + { + name: "non-existing instance", + settings: &domain.NotificationSettings{ + Settings: domain.Settings{ + InstanceID: "foo", + OrganizationID: nil, + }, + NotificationSettingsAttributes: domain.NotificationSettingsAttributes{ + PasswordChange: gu.Ptr(true), + }, + }, + wantErr: new(database.ForeignKeyError), + }, + { + name: "non-existing org", + settings: &domain.NotificationSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr("foo"), + }, + NotificationSettingsAttributes: domain.NotificationSettingsAttributes{ + PasswordChange: gu.Ptr(true), + }, + }, + wantErr: new(database.ForeignKeyError), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + savepoint, rollback := savepointForRollback(t, tx) + defer rollback() + err := repo.Set(t.Context(), savepoint, tt.settings) + require.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestDeleteNotificationSettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + instanceID := createInstance(t, tx) + orgID := createOrganization(t, tx, instanceID) + repo := repository.NotificationSettingsRepository() + + existingInstanceSettings := &domain.NotificationSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: nil, + }, + NotificationSettingsAttributes: domain.NotificationSettingsAttributes{ + PasswordChange: gu.Ptr(true), + }, + } + err := repo.Set(t.Context(), tx, existingInstanceSettings) + require.NoError(t, err) + + existingOrganizationSettings := &domain.NotificationSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr(orgID), + }, + NotificationSettingsAttributes: domain.NotificationSettingsAttributes{ + PasswordChange: gu.Ptr(true), + }, + } + err = repo.Set(t.Context(), tx, existingOrganizationSettings) + require.NoError(t, err) + + tests := []struct { + name string + condition database.Condition + wantRowsAffected int64 + wantErr error + }{ + { + name: "incomplete condition", + condition: repo.InstanceIDCondition(instanceID), + wantRowsAffected: 0, + wantErr: database.NewMissingConditionError(repo.IDColumn()), + }, + { + name: "not found", + condition: repo.UniqueCondition(existingInstanceSettings.InstanceID, gu.Ptr("foo"), domain.SettingTypeNotification, domain.SettingStateActive), + wantRowsAffected: 0, + }, + { + name: "delete instance", + condition: repo.UniqueCondition(existingInstanceSettings.InstanceID, existingInstanceSettings.OrganizationID, domain.SettingTypeNotification, domain.SettingStateActive), + wantRowsAffected: 1, + }, + { + name: "delete instance twice", + condition: repo.UniqueCondition(existingInstanceSettings.InstanceID, existingInstanceSettings.OrganizationID, domain.SettingTypeNotification, domain.SettingStateActive), + wantRowsAffected: 0, + }, + { + name: "delete organization", + condition: repo.UniqueCondition(existingOrganizationSettings.InstanceID, existingOrganizationSettings.OrganizationID, domain.SettingTypeNotification, domain.SettingStateActive), + wantRowsAffected: 1, + }, + { + name: "delete organization twice", + condition: repo.UniqueCondition(existingOrganizationSettings.InstanceID, existingOrganizationSettings.OrganizationID, domain.SettingTypeNotification, domain.SettingStateActive), + wantRowsAffected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rowsAffected, err := repo.Delete(t.Context(), tx, tt.condition) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.wantRowsAffected, rowsAffected) + }) + } +} + +func TestGetLegalAndSupportSettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + firstInstanceID := createInstance(t, tx) + secondInstanceID := createInstance(t, tx) + firstOrgID := createOrganization(t, tx, firstInstanceID) + secondOrgID := createOrganization(t, tx, secondInstanceID) + repo := repository.LegalAndSupportSettingsRepository() + + settings := []*domain.LegalAndSupportSettings{ + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: nil, + }, + LegalAndSupportSettingsAttributes: domain.LegalAndSupportSettingsAttributes{ + TOSLink: gu.Ptr("https://host"), + PrivacyPolicyLink: gu.Ptr("https://host"), + HelpLink: gu.Ptr("https://host"), + SupportEmail: gu.Ptr("email"), + DocsLink: gu.Ptr("https://host"), + CustomLink: gu.Ptr("https://host"), + CustomLinkText: gu.Ptr("linktext"), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: nil, + }, + LegalAndSupportSettingsAttributes: domain.LegalAndSupportSettingsAttributes{ + TOSLink: gu.Ptr("https://host"), + PrivacyPolicyLink: gu.Ptr("https://host"), + HelpLink: gu.Ptr("https://host"), + SupportEmail: gu.Ptr("email"), + DocsLink: gu.Ptr("https://host"), + CustomLink: gu.Ptr("https://host"), + CustomLinkText: gu.Ptr("linktext"), + }, + }, + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: gu.Ptr(firstOrgID), + }, + LegalAndSupportSettingsAttributes: domain.LegalAndSupportSettingsAttributes{ + TOSLink: gu.Ptr("https://host"), + PrivacyPolicyLink: gu.Ptr("https://host"), + HelpLink: gu.Ptr("https://host"), + SupportEmail: gu.Ptr("email"), + DocsLink: gu.Ptr("https://host"), + CustomLink: gu.Ptr("https://host"), + CustomLinkText: gu.Ptr("linktext"), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: gu.Ptr(secondOrgID), + }, + LegalAndSupportSettingsAttributes: domain.LegalAndSupportSettingsAttributes{ + TOSLink: gu.Ptr("https://host"), + PrivacyPolicyLink: gu.Ptr("https://host"), + HelpLink: gu.Ptr("https://host"), + SupportEmail: gu.Ptr("email"), + DocsLink: gu.Ptr("https://host"), + CustomLink: gu.Ptr("https://host"), + CustomLinkText: gu.Ptr("linktext"), + }, + }, + } + + for _, setting := range settings { + err := repo.Set(t.Context(), tx, setting) + require.NoError(t, err) + } + + tests := []struct { + name string + condition database.Condition + want *domain.LegalAndSupportSettings + wantErr error + }{ + { + name: "incomplete condition", + condition: repo.IDCondition(settings[0].ID), + wantErr: database.NewMissingConditionError(repo.IDColumn()), + }, + { + name: "not found", + condition: repo.PrimaryKeyCondition(firstInstanceID, "nix"), + wantErr: database.NewNoRowFoundError(nil), + }, + { + name: "too many", + condition: repo.InstanceIDCondition(firstInstanceID), + wantErr: database.NewMultipleRowsFoundError(nil), + }, + { + name: "ok, instance", + condition: repo.PrimaryKeyCondition(firstInstanceID, settings[0].ID), + want: settings[0], + }, + { + name: "ok, organization", + condition: repo.PrimaryKeyCondition(secondInstanceID, settings[3].ID), + want: settings[3], + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := repo.Get(t.Context(), tx, database.WithCondition(tt.condition)) + require.ErrorIs(t, err, tt.wantErr) + assert.EqualExportedValues(t, tt.want, got) + }) + } +} + +func TestListLegalAndSupportSettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + firstInstanceID := createInstance(t, tx) + secondInstanceID := createInstance(t, tx) + firstOrgID := createOrganization(t, tx, firstInstanceID) + secondOrgID := createOrganization(t, tx, secondInstanceID) + repo := repository.LegalAndSupportSettingsRepository() + + settings := []*domain.LegalAndSupportSettings{ + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: nil, + }, + LegalAndSupportSettingsAttributes: domain.LegalAndSupportSettingsAttributes{ + TOSLink: gu.Ptr("https://host"), + PrivacyPolicyLink: gu.Ptr("https://host"), + HelpLink: gu.Ptr("https://host"), + SupportEmail: gu.Ptr("email"), + DocsLink: gu.Ptr("https://host"), + CustomLink: gu.Ptr("https://host"), + CustomLinkText: gu.Ptr("linktext"), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: nil, + }, + LegalAndSupportSettingsAttributes: domain.LegalAndSupportSettingsAttributes{ + TOSLink: gu.Ptr("https://host"), + PrivacyPolicyLink: gu.Ptr("https://host"), + HelpLink: gu.Ptr("https://host"), + SupportEmail: gu.Ptr("email"), + DocsLink: gu.Ptr("https://host"), + CustomLink: gu.Ptr("https://host"), + CustomLinkText: gu.Ptr("linktext"), + }, + }, + { + Settings: domain.Settings{ + InstanceID: firstInstanceID, + OrganizationID: gu.Ptr(firstOrgID), + }, + LegalAndSupportSettingsAttributes: domain.LegalAndSupportSettingsAttributes{ + TOSLink: gu.Ptr("https://host"), + PrivacyPolicyLink: gu.Ptr("https://host"), + HelpLink: gu.Ptr("https://host"), + SupportEmail: gu.Ptr("email"), + DocsLink: gu.Ptr("https://host"), + CustomLink: gu.Ptr("https://host"), + CustomLinkText: gu.Ptr("linktext"), + }, + }, + { + Settings: domain.Settings{ + InstanceID: secondInstanceID, + OrganizationID: gu.Ptr(secondOrgID), + }, + LegalAndSupportSettingsAttributes: domain.LegalAndSupportSettingsAttributes{ + TOSLink: gu.Ptr("https://host"), + PrivacyPolicyLink: gu.Ptr("https://host"), + HelpLink: gu.Ptr("https://host"), + SupportEmail: gu.Ptr("email"), + DocsLink: gu.Ptr("https://host"), + CustomLink: gu.Ptr("https://host"), + CustomLinkText: gu.Ptr("linktext"), + }, + }, + } + + for _, setting := range settings { + err := repo.Set(t.Context(), tx, setting) + require.NoError(t, err) + } + + tests := []struct { + name string + condition database.Condition + want []*domain.LegalAndSupportSettings + wantErr error + }{ + { + name: "incomplete condition", + condition: repo.OrganizationIDCondition(gu.Ptr(firstOrgID)), + wantErr: database.NewMissingConditionError(repo.IDColumn()), + }, + { + name: "no results, ok", + condition: repo.PrimaryKeyCondition(firstInstanceID, "nix"), + want: []*domain.LegalAndSupportSettings{}, + }, + { + name: "all from instance", + condition: repo.InstanceIDCondition(firstInstanceID), + want: []*domain.LegalAndSupportSettings{settings[2], settings[0]}, + }, + { + name: "only from instance", + condition: database.And( + repo.InstanceIDCondition(firstInstanceID), + repo.OrganizationIDCondition(nil), + ), + want: []*domain.LegalAndSupportSettings{settings[0]}, + }, + { + name: "all from first org", + condition: database.And( + repo.InstanceIDCondition(firstInstanceID), + repo.OrganizationIDCondition(gu.Ptr(firstOrgID)), + ), + want: []*domain.LegalAndSupportSettings{settings[2]}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := repo.List(t.Context(), tx, + database.WithCondition(tt.condition), + database.WithOrderByAscending(repo.InstanceIDColumn(), repo.OrganizationIDColumn()), + ) + require.ErrorIs(t, err, tt.wantErr) + assert.EqualExportedValues(t, tt.want, got) + }) + } +} + +func TestSetLegalAndSupportSettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + instanceID := createInstance(t, tx) + orgID := createOrganization(t, tx, instanceID) + repo := repository.LegalAndSupportSettingsRepository() + + existingSettings := &domain.LegalAndSupportSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr(orgID), + }, + LegalAndSupportSettingsAttributes: domain.LegalAndSupportSettingsAttributes{ + TOSLink: gu.Ptr("https://host"), + PrivacyPolicyLink: gu.Ptr("https://host"), + HelpLink: gu.Ptr("https://host"), + SupportEmail: gu.Ptr("email"), + DocsLink: gu.Ptr("https://host"), + CustomLink: gu.Ptr("https://host"), + CustomLinkText: gu.Ptr("linktext"), + }, + } + + err := repo.Set(t.Context(), tx, existingSettings) + require.NoError(t, err) + + tests := []struct { + name string + settings *domain.LegalAndSupportSettings + wantErr error + }{ + { + name: "create instance", + settings: &domain.LegalAndSupportSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: nil, + }, + LegalAndSupportSettingsAttributes: domain.LegalAndSupportSettingsAttributes{ + TOSLink: gu.Ptr("https://host"), + PrivacyPolicyLink: gu.Ptr("https://host"), + HelpLink: gu.Ptr("https://host"), + SupportEmail: gu.Ptr("email"), + DocsLink: gu.Ptr("https://host"), + CustomLink: gu.Ptr("https://host"), + CustomLinkText: gu.Ptr("linktext"), + }, + }, + }, + { + name: "update organization", + settings: &domain.LegalAndSupportSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr(orgID), + }, + LegalAndSupportSettingsAttributes: domain.LegalAndSupportSettingsAttributes{ + TOSLink: gu.Ptr("https://host"), + PrivacyPolicyLink: gu.Ptr("https://host"), + HelpLink: gu.Ptr("https://host"), + SupportEmail: gu.Ptr("email"), + DocsLink: gu.Ptr("https://host"), + CustomLink: gu.Ptr("https://host"), + CustomLinkText: gu.Ptr("linktext"), + }, + }, + }, + { + name: "non-existing instance", + settings: &domain.LegalAndSupportSettings{ + Settings: domain.Settings{ + InstanceID: "foo", + OrganizationID: nil, + }, + LegalAndSupportSettingsAttributes: domain.LegalAndSupportSettingsAttributes{ + TOSLink: gu.Ptr("https://host"), + PrivacyPolicyLink: gu.Ptr("https://host"), + HelpLink: gu.Ptr("https://host"), + SupportEmail: gu.Ptr("email"), + DocsLink: gu.Ptr("https://host"), + CustomLink: gu.Ptr("https://host"), + CustomLinkText: gu.Ptr("linktext"), + }, + }, + wantErr: new(database.ForeignKeyError), + }, + { + name: "non-existing org", + settings: &domain.LegalAndSupportSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr("foo"), + }, + LegalAndSupportSettingsAttributes: domain.LegalAndSupportSettingsAttributes{ + TOSLink: gu.Ptr("https://host"), + PrivacyPolicyLink: gu.Ptr("https://host"), + HelpLink: gu.Ptr("https://host"), + SupportEmail: gu.Ptr("email"), + DocsLink: gu.Ptr("https://host"), + CustomLink: gu.Ptr("https://host"), + CustomLinkText: gu.Ptr("linktext"), + }, + }, + wantErr: new(database.ForeignKeyError), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + savepoint, rollback := savepointForRollback(t, tx) + defer rollback() + err := repo.Set(t.Context(), savepoint, tt.settings) + require.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestDeleteLegalAndSupportSettings(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + instanceID := createInstance(t, tx) + orgID := createOrganization(t, tx, instanceID) + repo := repository.LegalAndSupportSettingsRepository() + + existingInstanceSettings := &domain.LegalAndSupportSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: nil, + }, + LegalAndSupportSettingsAttributes: domain.LegalAndSupportSettingsAttributes{ + TOSLink: gu.Ptr("https://host"), + PrivacyPolicyLink: gu.Ptr("https://host"), + HelpLink: gu.Ptr("https://host"), + SupportEmail: gu.Ptr("email"), + DocsLink: gu.Ptr("https://host"), + CustomLink: gu.Ptr("https://host"), + CustomLinkText: gu.Ptr("linktext"), + }, + } + err := repo.Set(t.Context(), tx, existingInstanceSettings) + require.NoError(t, err) + + existingOrganizationSettings := &domain.LegalAndSupportSettings{ + Settings: domain.Settings{ + InstanceID: instanceID, + OrganizationID: gu.Ptr(orgID), + }, + LegalAndSupportSettingsAttributes: domain.LegalAndSupportSettingsAttributes{ + TOSLink: gu.Ptr("https://host"), + PrivacyPolicyLink: gu.Ptr("https://host"), + HelpLink: gu.Ptr("https://host"), + SupportEmail: gu.Ptr("email"), + DocsLink: gu.Ptr("https://host"), + CustomLink: gu.Ptr("https://host"), + CustomLinkText: gu.Ptr("linktext"), + }, + } + err = repo.Set(t.Context(), tx, existingOrganizationSettings) + require.NoError(t, err) + + tests := []struct { + name string + condition database.Condition + wantRowsAffected int64 + wantErr error + }{ + { + name: "incomplete condition", + condition: repo.InstanceIDCondition(instanceID), + wantRowsAffected: 0, + wantErr: database.NewMissingConditionError(repo.IDColumn()), + }, + { + name: "not found", + condition: repo.UniqueCondition(existingInstanceSettings.InstanceID, gu.Ptr("foo"), domain.SettingTypeLegalAndSupport, domain.SettingStateActive), + wantRowsAffected: 0, + }, + { + name: "delete instance", + condition: repo.UniqueCondition(existingInstanceSettings.InstanceID, existingInstanceSettings.OrganizationID, domain.SettingTypeLegalAndSupport, domain.SettingStateActive), + wantRowsAffected: 1, + }, + { + name: "delete instance twice", + condition: repo.UniqueCondition(existingInstanceSettings.InstanceID, existingInstanceSettings.OrganizationID, domain.SettingTypeLegalAndSupport, domain.SettingStateActive), + wantRowsAffected: 0, + }, + { + name: "delete organization", + condition: repo.UniqueCondition(existingOrganizationSettings.InstanceID, existingOrganizationSettings.OrganizationID, domain.SettingTypeLegalAndSupport, domain.SettingStateActive), + wantRowsAffected: 1, + }, + { + name: "delete organization twice", + condition: repo.UniqueCondition(existingOrganizationSettings.InstanceID, existingOrganizationSettings.OrganizationID, domain.SettingTypeLegalAndSupport, domain.SettingStateActive), + wantRowsAffected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rowsAffected, err := repo.Delete(t.Context(), tx, tt.condition) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.wantRowsAffected, rowsAffected) + }) + } +} diff --git a/internal/config/systemdefaults/system_defaults.go b/internal/config/systemdefaults/system_defaults.go index 64eb10129..7b905c780 100644 --- a/internal/config/systemdefaults/system_defaults.go +++ b/internal/config/systemdefaults/system_defaults.go @@ -3,7 +3,7 @@ package systemdefaults import ( "fmt" "time" - + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" ) diff --git a/internal/query/projection/projection.go b/internal/query/projection/projection.go index b0ea3f48b..45b9b436d 100644 --- a/internal/query/projection/projection.go +++ b/internal/query/projection/projection.go @@ -98,6 +98,7 @@ var ( ProjectRoleRelationalProjection *handler.Handler OrganizationMetadataRelationalProjection *handler.Handler ProjectGrantRelationalProjection *handler.Handler + SettingsRelationalProjection *handler.Handler ProjectGrantFields *handler.FieldHandler OrgDomainVerifiedFields *handler.FieldHandler @@ -219,6 +220,7 @@ func Create(ctx context.Context, sqlClient *database.DB, es handler.EventStore, OrganizationRelationalProjection = newOrgRelationalProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["organizations_relational"])) InstanceDomainRelationalProjection = newInstanceDomainRelationalProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["instance_domains_relational"])) OrganizationDomainRelationalProjection = newOrgDomainRelationalProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["organization_domains_relational"])) + SettingsRelationalProjection = newSettingsRelationalProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["settings_relational"])) IDPTemplateRelationalProjection = newIDPTemplateRelationalProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["idp_templates_relational"])) ProjectRelationalProjection = newProjectRelationalProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["projects_relational"])) ProjectRoleRelationalProjection = newProjectRoleRelationalProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["project_roles_relational"])) @@ -413,6 +415,7 @@ func newProjectionsList() { OrganizationRelationalProjection, InstanceDomainRelationalProjection, OrganizationDomainRelationalProjection, + SettingsRelationalProjection, IDPTemplateRelationalProjection, ProjectRelationalProjection, ProjectRoleRelationalProjection, diff --git a/internal/query/projection/settings_relational.go b/internal/query/projection/settings_relational.go new file mode 100644 index 000000000..8409c3eae --- /dev/null +++ b/internal/query/projection/settings_relational.go @@ -0,0 +1,1787 @@ +package projection + +import ( + "context" + "database/sql" + "net/url" + + "github.com/muhlemmer/gu" + + "github.com/zitadel/zitadel/backend/v3/domain" + "github.com/zitadel/zitadel/backend/v3/storage/database" + v3_sql "github.com/zitadel/zitadel/backend/v3/storage/database/dialect/sql" + "github.com/zitadel/zitadel/backend/v3/storage/database/repository" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/org" + settings "github.com/zitadel/zitadel/internal/repository/organization_settings" + "github.com/zitadel/zitadel/internal/repository/policy" + "github.com/zitadel/zitadel/internal/zerrors" +) + +const ( + SettingsRelationalProjectionTable = "zitadel.settings" +) + +type settingsRelationalProjection struct{} + +func newSettingsRelationalProjection(ctx context.Context, config handler.Config) *handler.Handler { + return handler.NewHandler(ctx, &config, new(settingsRelationalProjection)) +} + +func (*settingsRelationalProjection) Name() string { + return SettingsRelationalProjectionTable +} + +func (s *settingsRelationalProjection) Reducers() []handler.AggregateReducer { + return []handler.AggregateReducer{ + { + Aggregate: org.AggregateType, + EventReducers: []handler.EventReducer{ + // // Login + { + Event: org.LoginPolicyAddedEventType, + Reduce: s.reduceLoginPolicyAdded, + }, + { + Event: org.LoginPolicyChangedEventType, + Reduce: s.reduceLoginPolicyChanged, + }, + { + Event: org.LoginPolicyRemovedEventType, + Reduce: s.reduceLoginPolicyRemoved, + }, + { + Event: org.LoginPolicyMultiFactorAddedEventType, + Reduce: s.reduceMFAAdded, + }, + { + Event: org.LoginPolicyMultiFactorRemovedEventType, + Reduce: s.reduceMFARemoved, + }, + { + Event: org.LoginPolicySecondFactorAddedEventType, + Reduce: s.reduceSecondFactorAdded, + }, + { + Event: org.LoginPolicySecondFactorRemovedEventType, + Reduce: s.reduceSecondFactorRemoved, + }, + // label + { + Event: org.LabelPolicyAddedEventType, + Reduce: s.reduceLabelAdded, + }, + { + Event: org.LabelPolicyChangedEventType, + Reduce: s.reduceLabelChanged, + }, + { + Event: org.LabelPolicyRemovedEventType, + Reduce: s.reduceLabelPolicyRemoved, + }, + { + Event: org.LabelPolicyActivatedEventType, + Reduce: s.reduceLabelActivated, + }, + { + Event: org.LabelPolicyLogoAddedEventType, + Reduce: s.reduceLogoAdded, + }, + { + Event: org.LabelPolicyLogoRemovedEventType, + Reduce: s.reduceLogoRemoved, + }, + { + Event: org.LabelPolicyLogoDarkAddedEventType, + Reduce: s.reduceLogoDarkAdded, + }, + { + Event: org.LabelPolicyLogoDarkRemovedEventType, + Reduce: s.reduceLogoDarkRemoved, + }, + { + Event: org.LabelPolicyIconAddedEventType, + Reduce: s.reduceIconAdded, + }, + { + Event: org.LabelPolicyIconRemovedEventType, + Reduce: s.reduceIconRemoved, + }, + { + Event: org.LabelPolicyIconDarkAddedEventType, + Reduce: s.reduceIconDarkAdded, + }, + { + Event: org.LabelPolicyIconDarkRemovedEventType, + Reduce: s.reduceIconDarkRemoved, + }, + { + Event: org.LabelPolicyFontAddedEventType, + Reduce: s.reduceFontAdded, + }, + { + Event: org.LabelPolicyFontRemovedEventType, + Reduce: s.reduceFontRemoved, + }, + // // Password Complexity + { + Event: org.PasswordComplexityPolicyAddedEventType, + Reduce: s.reducePasswordComplexityAdded, + }, + { + Event: org.PasswordComplexityPolicyChangedEventType, + Reduce: s.reducePasswordComplexityChanged, + }, + { + Event: org.PasswordComplexityPolicyRemovedEventType, + Reduce: s.reducePasswordComplexityRemoved, + }, + // // Password Policy + { + Event: org.PasswordAgePolicyAddedEventType, + Reduce: s.reducePasswordPolicyAdded, + }, + { + Event: org.PasswordAgePolicyChangedEventType, + Reduce: s.reducePasswordPolicyChanged, + }, + { + Event: org.PasswordAgePolicyRemovedEventType, + Reduce: s.reducePasswordPolicyRemoved, + }, + // // Lockout Policy + { + Event: org.LockoutPolicyAddedEventType, + Reduce: s.reduceLockoutPolicyAdded, + }, + { + Event: org.LockoutPolicyChangedEventType, + Reduce: s.reduceLockoutPolicyChanged, + }, + { + Event: org.LockoutPolicyRemovedEventType, + Reduce: s.reduceOrgLockoutPolicyRemoved, + }, + // // Domain Policy + { + Event: org.DomainPolicyAddedEventType, + Reduce: s.reduceDomainPolicyAdded, + }, + { + Event: org.DomainPolicyChangedEventType, + Reduce: s.reduceDomainPolicyChanged, + }, + { + Event: org.DomainPolicyRemovedEventType, + Reduce: s.reduceOrgDomainPolicyRemoved, + }, + // Notification + { + Event: org.NotificationPolicyAddedEventType, + Reduce: s.reduceNotificationPolicyAdded, + }, + { + Event: org.NotificationPolicyChangedEventType, + Reduce: s.reduceNotificationPolicyChanged, + }, + { + Event: org.NotificationPolicyRemovedEventType, + Reduce: s.reduceOrgNotificationPolicyRemoved, + }, + // Privacy + { + Event: org.PrivacyPolicyAddedEventType, + Reduce: s.reducePrivacyPolicyAdded, + }, + { + Event: org.PrivacyPolicyChangedEventType, + Reduce: s.reducePrivacyPolicyChanged, + }, + { + Event: org.PrivacyPolicyRemovedEventType, + Reduce: s.reduceOrgPrivacyPolicyRemoved, + }, + }, + }, + // // settings + { + Aggregate: settings.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: settings.OrganizationSettingsSetEventType, + Reduce: s.reduceOrganizationSettingsSet, + }, + { + Event: settings.OrganizationSettingsRemovedEventType, + Reduce: s.reduceOrganizationSettingsRemoved, + }, + }, + }, + { + Aggregate: instance.AggregateType, + EventReducers: []handler.EventReducer{ + // // Login + { + Event: instance.LoginPolicyAddedEventType, + Reduce: s.reduceLoginPolicyAdded, + }, + { + Event: instance.LoginPolicyChangedEventType, + Reduce: s.reduceLoginPolicyChanged, + }, + { + Event: instance.LoginPolicyMultiFactorAddedEventType, + Reduce: s.reduceMFAAdded, + }, + { + Event: instance.LoginPolicyMultiFactorRemovedEventType, + Reduce: s.reduceMFARemoved, + }, + { + Event: instance.LoginPolicySecondFactorAddedEventType, + Reduce: s.reduceSecondFactorAdded, + }, + { + Event: instance.LoginPolicySecondFactorRemovedEventType, + Reduce: s.reduceSecondFactorRemoved, + }, + // // Label + { + Event: instance.LabelPolicyAddedEventType, + Reduce: s.reduceLabelAdded, + }, + { + Event: instance.LabelPolicyChangedEventType, + Reduce: s.reduceLabelChanged, + }, + { + Event: instance.LabelPolicyActivatedEventType, + Reduce: s.reduceLabelActivated, + }, + { + Event: instance.LabelPolicyLogoAddedEventType, + Reduce: s.reduceLogoAdded, + }, + { + Event: instance.LabelPolicyLogoRemovedEventType, + Reduce: s.reduceLogoRemoved, + }, + { + Event: instance.LabelPolicyLogoDarkAddedEventType, + Reduce: s.reduceLogoDarkAdded, + }, + { + Event: instance.LabelPolicyLogoDarkRemovedEventType, + Reduce: s.reduceLogoDarkRemoved, + }, + { + Event: instance.LabelPolicyIconAddedEventType, + Reduce: s.reduceIconAdded, + }, + { + Event: instance.LabelPolicyIconRemovedEventType, + Reduce: s.reduceIconRemoved, + }, + { + Event: instance.LabelPolicyIconDarkAddedEventType, + Reduce: s.reduceIconDarkAdded, + }, + { + Event: instance.LabelPolicyIconDarkRemovedEventType, + Reduce: s.reduceIconDarkRemoved, + }, + { + Event: instance.LabelPolicyFontAddedEventType, + Reduce: s.reduceFontAdded, + }, + { + Event: instance.LabelPolicyFontRemovedEventType, + Reduce: s.reduceFontRemoved, + }, + // // Password Complexity + { + Event: instance.PasswordComplexityPolicyAddedEventType, + Reduce: s.reducePasswordComplexityAdded, + }, + { + Event: instance.PasswordComplexityPolicyChangedEventType, + Reduce: s.reducePasswordComplexityChanged, + }, + // // Password Policy + { + Event: instance.PasswordAgePolicyAddedEventType, + Reduce: s.reducePasswordPolicyAdded, + }, + { + Event: instance.PasswordAgePolicyChangedEventType, + Reduce: s.reducePasswordPolicyChanged, + }, + // // Lockout Policy + { + Event: instance.LockoutPolicyAddedEventType, + Reduce: s.reduceLockoutPolicyAdded, + }, + { + Event: instance.LockoutPolicyChangedEventType, + Reduce: s.reduceLockoutPolicyChanged, + }, + // // Domain Policy + { + Event: instance.DomainPolicyAddedEventType, + Reduce: s.reduceDomainPolicyAdded, + }, + { + Event: instance.DomainPolicyChangedEventType, + Reduce: s.reduceDomainPolicyChanged, + }, + // // Security Policy + { + Event: instance.SecurityPolicySetEventType, + Reduce: s.reduceSecurityPolicySet, + }, + // Notification + { + Event: instance.NotificationPolicyAddedEventType, + Reduce: s.reduceNotificationPolicyAdded, + }, + { + Event: instance.NotificationPolicyChangedEventType, + Reduce: s.reduceNotificationPolicyChanged, + }, + // Privacy policy + { + Event: instance.PrivacyPolicyAddedEventType, + Reduce: s.reducePrivacyPolicyAdded, + }, + { + Event: instance.PrivacyPolicyChangedEventType, + Reduce: s.reducePrivacyPolicyChanged, + }, + }, + }, + } +} + +func (s *settingsRelationalProjection) reduceLoginPolicyAdded(event eventstore.Event) (*handler.Statement, error) { + var orgId *string + var policyEvent policy.LoginPolicyAddedEvent + switch e := event.(type) { + case *instance.LoginPolicyAddedEvent: + policyEvent = e.LoginPolicyAddedEvent + case *org.LoginPolicyAddedEvent: + policyEvent = e.LoginPolicyAddedEvent + orgId = &policyEvent.Aggregate().ResourceOwner + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-YYPxS", "reduce.wrong.event.type %v", []eventstore.EventType{org.LoginPolicyAddedEventType, instance.LoginPolicyAddedEventType}) + } + + return handler.NewStatement(&policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-5hONE", "reduce.wrong.db.pool %T", ex) + } + settingsRepo := repository.LoginSettingsRepository() + + passwordlessType := domain.PasswordlessType(policyEvent.PasswordlessType) + setting := domain.LoginSettings{ + Settings: domain.Settings{ + InstanceID: policyEvent.Aggregate().InstanceID, + OrganizationID: orgId, + CreatedAt: policyEvent.Creation, + UpdatedAt: policyEvent.Creation, + }, + LoginSettingsAttributes: domain.LoginSettingsAttributes{ + AllowUserNamePassword: &policyEvent.AllowUserNamePassword, + AllowRegister: &policyEvent.AllowRegister, + AllowExternalIDP: &policyEvent.AllowExternalIDP, + ForceMFA: &policyEvent.ForceMFA, + ForceMFALocalOnly: &policyEvent.ForceMFALocalOnly, + HidePasswordReset: &policyEvent.HidePasswordReset, + IgnoreUnknownUsernames: &policyEvent.IgnoreUnknownUsernames, + AllowDomainDiscovery: &policyEvent.AllowDomainDiscovery, + DisableLoginWithEmail: &policyEvent.DisableLoginWithEmail, + DisableLoginWithPhone: &policyEvent.DisableLoginWithPhone, + PasswordlessType: &passwordlessType, + DefaultRedirectURI: &policyEvent.DefaultRedirectURI, + PasswordCheckLifetime: &policyEvent.PasswordCheckLifetime, + ExternalLoginCheckLifetime: &policyEvent.ExternalLoginCheckLifetime, + MFAInitSkipLifetime: &policyEvent.MFAInitSkipLifetime, + SecondFactorCheckLifetime: &policyEvent.SecondFactorCheckLifetime, + MultiFactorCheckLifetime: &policyEvent.MultiFactorCheckLifetime, + MFAType: nil, + SecondFactorTypes: nil, + }, + } + return settingsRepo.Set(ctx, v3_sql.SQLTx(tx), &setting) + }), nil +} + +// //nolint:gocognit +func (s *settingsRelationalProjection) reduceLoginPolicyChanged(event eventstore.Event) (*handler.Statement, error) { + var orgId *string + var policyEvent policy.LoginPolicyChangedEvent + switch e := event.(type) { + case *instance.LoginPolicyChangedEvent: + policyEvent = e.LoginPolicyChangedEvent + case *org.LoginPolicyChangedEvent: + policyEvent = e.LoginPolicyChangedEvent + orgId = &policyEvent.Aggregate().ResourceOwner + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-BHd86", "reduce.wrong.event.type %v", []eventstore.EventType{org.LoginPolicyChangedEventType, instance.LoginPolicyChangedEventType}) + } + + return handler.NewStatement(&policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-rLk9y", "reduce.wrong.db.pool %T", ex) + } + + settingsRepo := repository.LoginSettingsRepository() + + var passwordlessType *domain.PasswordlessType + if policyEvent.PasswordlessType != nil { + passwordlessType = gu.Ptr(domain.PasswordlessType(*policyEvent.PasswordlessType)) + } + + settings := domain.LoginSettings{ + Settings: domain.Settings{ + InstanceID: policyEvent.Aggregate().InstanceID, + OrganizationID: orgId, + UpdatedAt: policyEvent.Creation, + }, + LoginSettingsAttributes: domain.LoginSettingsAttributes{ + AllowUserNamePassword: policyEvent.AllowUserNamePassword, + AllowRegister: policyEvent.AllowRegister, + AllowExternalIDP: policyEvent.AllowExternalIDP, + ForceMFA: policyEvent.ForceMFA, + ForceMFALocalOnly: policyEvent.ForceMFALocalOnly, + HidePasswordReset: policyEvent.HidePasswordReset, + IgnoreUnknownUsernames: policyEvent.IgnoreUnknownUsernames, + AllowDomainDiscovery: policyEvent.AllowDomainDiscovery, + DisableLoginWithEmail: policyEvent.DisableLoginWithEmail, + DisableLoginWithPhone: policyEvent.DisableLoginWithPhone, + PasswordlessType: passwordlessType, + DefaultRedirectURI: policyEvent.DefaultRedirectURI, + PasswordCheckLifetime: policyEvent.PasswordCheckLifetime, + ExternalLoginCheckLifetime: policyEvent.ExternalLoginCheckLifetime, + MFAInitSkipLifetime: policyEvent.MFAInitSkipLifetime, + SecondFactorCheckLifetime: policyEvent.SecondFactorCheckLifetime, + MultiFactorCheckLifetime: policyEvent.MultiFactorCheckLifetime, + MFAType: nil, + SecondFactorTypes: nil, + }, + } + return settingsRepo.Set(ctx, v3_sql.SQLTx(tx), &settings) + }), nil +} + +func (s *settingsRelationalProjection) reduceMFAAdded(event eventstore.Event) (*handler.Statement, error) { + var orgId *string + var policyEvent policy.MultiFactorAddedEvent + switch e := event.(type) { + case *instance.LoginPolicyMultiFactorAddedEvent: + policyEvent = e.MultiFactorAddedEvent + case *org.LoginPolicyMultiFactorAddedEvent: + policyEvent = e.MultiFactorAddedEvent + orgId = &policyEvent.Aggregate().ID + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-WghuV", "reduce.wrong.event.type %v", []eventstore.EventType{org.LoginPolicyMultiFactorAddedEventType, instance.LoginPolicyMultiFactorAddedEventType}) + } + + return handler.NewStatement(&policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-rLw7y", "reduce.wrong.db.pool %T", ex) + } + + settingsRepo := repository.LoginSettingsRepository() + return settingsRepo.SetColumns(ctx, v3_sql.SQLTx(tx), + &domain.Settings{ + InstanceID: policyEvent.Agg.InstanceID, + OrganizationID: orgId, + UpdatedAt: policyEvent.Creation, + }, + settingsRepo.AddMFAType(domain.MultiFactorType(policyEvent.MFAType)), + ) + }), nil +} + +func (s *settingsRelationalProjection) reduceMFARemoved(event eventstore.Event) (*handler.Statement, error) { + var orgId *string + var policyEvent policy.MultiFactorRemovedEvent + switch e := event.(type) { + case *instance.LoginPolicyMultiFactorRemovedEvent: + policyEvent = e.MultiFactorRemovedEvent + case *org.LoginPolicyMultiFactorRemovedEvent: + policyEvent = e.MultiFactorRemovedEvent + orgId = &policyEvent.Aggregate().ResourceOwner + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-cHU7u", "reduce.wrong.event.type %v", []eventstore.EventType{org.LoginPolicyMultiFactorRemovedEventType, instance.LoginPolicyMultiFactorRemovedEventType}) + } + + return handler.NewStatement(&policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-rLi9y", "reduce.wrong.db.pool %T", ex) + } + + settingsRepo := repository.LoginSettingsRepository() + return settingsRepo.SetColumns(ctx, v3_sql.SQLTx(tx), + &domain.Settings{ + InstanceID: policyEvent.Agg.InstanceID, + OrganizationID: orgId, + UpdatedAt: policyEvent.Creation, + }, + settingsRepo.RemoveMFAType(domain.MultiFactorType(policyEvent.MFAType)), + ) + }), nil +} + +func (*settingsRelationalProjection) reduceLoginPolicyRemoved(event eventstore.Event) (*handler.Statement, error) { + loginPolicyRemovedEvent, ok := event.(*org.LoginPolicyRemovedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-oRSvD", "reduce.wrong.event.type %s", org.LoginPolicyRemovedEventType) + } + return handler.NewStatement(loginPolicyRemovedEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-arg9y", "reduce.wrong.db.pool %T", ex) + } + + settingsRepo := repository.LoginSettingsRepository() + _, err := settingsRepo.Delete( + ctx, v3_sql.SQLTx(tx), + settingsRepo.UniqueCondition(loginPolicyRemovedEvent.Aggregate().InstanceID, &loginPolicyRemovedEvent.Aggregate().ID, domain.SettingTypeLogin, domain.SettingStateActive), + ) + return err + }), nil +} + +func (s *settingsRelationalProjection) reduceSecondFactorAdded(event eventstore.Event) (*handler.Statement, error) { + var orgId *string + var policyEvent policy.SecondFactorAddedEvent + switch e := event.(type) { + case *instance.LoginPolicySecondFactorAddedEvent: + policyEvent = e.SecondFactorAddedEvent + case *org.LoginPolicySecondFactorAddedEvent: + policyEvent = e.SecondFactorAddedEvent + orgId = &policyEvent.Aggregate().ResourceOwner + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-apB2E", "reduce.wrong.event.type %v", []eventstore.EventType{org.LoginPolicySecondFactorAddedEventType, instance.LoginPolicySecondFactorAddedEventType}) + } + + return handler.NewStatement(&policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-iLk4m", "reduce.wrong.db.pool %T", ex) + } + + settingsRepo := repository.LoginSettingsRepository() + return settingsRepo.SetColumns(ctx, v3_sql.SQLTx(tx), + &domain.Settings{ + InstanceID: policyEvent.Agg.InstanceID, + OrganizationID: orgId, + UpdatedAt: policyEvent.Creation, + }, + settingsRepo.AddSecondFactorTypes(domain.SecondFactorType(policyEvent.MFAType)), + ) + }), nil +} + +func (s *settingsRelationalProjection) reduceSecondFactorRemoved(event eventstore.Event) (*handler.Statement, error) { + var orgId *string + var policyEvent policy.SecondFactorRemovedEvent + switch e := event.(type) { + case *instance.LoginPolicySecondFactorRemovedEvent: + policyEvent = e.SecondFactorRemovedEvent + case *org.LoginPolicySecondFactorRemovedEvent: + policyEvent = e.SecondFactorRemovedEvent + orgId = &policyEvent.Aggregate().ResourceOwner + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-bYpmA", "reduce.wrong.event.type %v", []eventstore.EventType{org.LoginPolicySecondFactorRemovedEventType, instance.LoginPolicySecondFactorRemovedEventType}) + } + + return handler.NewStatement(&policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-rnd0y", "reduce.wrong.db.pool %T", ex) + } + settingsRepo := repository.LoginSettingsRepository() + return settingsRepo.SetColumns(ctx, v3_sql.SQLTx(tx), + &domain.Settings{ + InstanceID: policyEvent.Agg.InstanceID, + OrganizationID: orgId, + UpdatedAt: policyEvent.Creation, + }, + settingsRepo.RemoveSecondFactorTypes(domain.SecondFactorType(policyEvent.MFAType)), + ) + }), nil +} + +// label +func (s *settingsRelationalProjection) reduceLabelAdded(event eventstore.Event) (*handler.Statement, error) { + var orgId *string + var policyEvent policy.LabelPolicyAddedEvent + switch e := event.(type) { + case *org.LabelPolicyAddedEvent: + policyEvent = e.LabelPolicyAddedEvent + orgId = &policyEvent.Aggregate().ResourceOwner + case *instance.LabelPolicyAddedEvent: + policyEvent = e.LabelPolicyAddedEvent + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-CSE7A", "reduce.wrong.event.type %v", []eventstore.EventType{org.LabelPolicyAddedEventType, instance.LabelPolicyAddedEventType}) + } + + return handler.NewStatement(&policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-5hONE", "reduce.wrong.db.pool %T", ex) + } + settingsRepo := repository.BrandingSettingsRepository() + themeMode := domain.BrandingPolicyThemeMode(policyEvent.ThemeMode) + settings := domain.BrandingSettings{ + Settings: domain.Settings{ + InstanceID: policyEvent.Aggregate().InstanceID, + OrganizationID: orgId, + CreatedAt: policyEvent.Creation, + UpdatedAt: policyEvent.Creation, + }, + BrandingSettingsAttributes: domain.BrandingSettingsAttributes{ + PrimaryColorLight: &policyEvent.PrimaryColor, + BackgroundColorLight: &policyEvent.BackgroundColor, + WarnColorLight: &policyEvent.WarnColor, + FontColorLight: &policyEvent.FontColor, + PrimaryColorDark: &policyEvent.PrimaryColorDark, + BackgroundColorDark: &policyEvent.BackgroundColorDark, + WarnColorDark: &policyEvent.WarnColorDark, + FontColorDark: &policyEvent.FontColorDark, + HideLoginNameSuffix: &policyEvent.HideLoginNameSuffix, + ErrorMsgPopup: &policyEvent.ErrorMsgPopup, + DisableWatermark: &policyEvent.DisableWatermark, + ThemeMode: &themeMode, + LogoURLLight: nil, + IconURLLight: nil, + LogoURLDark: nil, + IconURLDark: nil, + FontURL: nil, + }, + } + return settingsRepo.Set(ctx, v3_sql.SQLTx(tx), &settings) + }), nil +} + +func (s *settingsRelationalProjection) reduceLabelChanged(event eventstore.Event) (*handler.Statement, error) { + var orgId *string + var policyEvent policy.LabelPolicyChangedEvent + switch e := event.(type) { + case *org.LabelPolicyChangedEvent: + policyEvent = e.LabelPolicyChangedEvent + orgId = &policyEvent.Aggregate().ResourceOwner + case *instance.LabelPolicyChangedEvent: + policyEvent = e.LabelPolicyChangedEvent + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-qgVug", "reduce.wrong.event.type %v", []eventstore.EventType{org.LabelPolicyChangedEventType, instance.LabelPolicyChangedEventType}) + } + + return handler.NewStatement(&policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-lhb9y", "reduce.wrong.db.pool %T", ex) + } + settingsRepo := repository.BrandingSettingsRepository() + var themeMode *domain.BrandingPolicyThemeMode + if policyEvent.ThemeMode != nil { + themeMode = gu.Ptr(domain.BrandingPolicyThemeMode(*policyEvent.ThemeMode)) + } + settings := domain.BrandingSettings{ + Settings: domain.Settings{ + InstanceID: policyEvent.Aggregate().InstanceID, + OrganizationID: orgId, + UpdatedAt: policyEvent.Creation, + }, + BrandingSettingsAttributes: domain.BrandingSettingsAttributes{ + PrimaryColorLight: policyEvent.PrimaryColor, + BackgroundColorLight: policyEvent.BackgroundColor, + WarnColorLight: policyEvent.WarnColor, + FontColorLight: policyEvent.FontColor, + PrimaryColorDark: policyEvent.PrimaryColorDark, + BackgroundColorDark: policyEvent.BackgroundColorDark, + WarnColorDark: policyEvent.WarnColorDark, + FontColorDark: policyEvent.FontColorDark, + HideLoginNameSuffix: policyEvent.HideLoginNameSuffix, + ErrorMsgPopup: policyEvent.ErrorMsgPopup, + DisableWatermark: policyEvent.DisableWatermark, + ThemeMode: themeMode, + LogoURLLight: nil, + IconURLLight: nil, + LogoURLDark: nil, + IconURLDark: nil, + FontURL: nil, + }, + } + return settingsRepo.Set(ctx, v3_sql.SQLTx(tx), &settings) + }), nil +} + +func (s *settingsRelationalProjection) reduceLabelPolicyRemoved(event eventstore.Event) (*handler.Statement, error) { + policyEvent, ok := event.(*org.LabelPolicyRemovedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-ATMBz", "reduce.wrong.event.type %s", org.LabelPolicyRemovedEventType) + } + + return handler.NewStatement(policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-r7k0y", "reduce.wrong.db.pool %T", ex) + } + + settingsRepo := repository.BrandingSettingsRepository() + _, err := settingsRepo.Delete(ctx, v3_sql.SQLTx(tx), + settingsRepo.UniqueCondition(policyEvent.Aggregate().InstanceID, &policyEvent.Aggregate().ID, domain.SettingTypeBranding, domain.SettingStateActive), + ) + return err + }), nil +} + +func (s *settingsRelationalProjection) reduceLabelActivated(event eventstore.Event) (*handler.Statement, error) { + var orgId *string + var policyEvent policy.LabelPolicyActivatedEvent + switch e := event.(type) { + case *org.LabelPolicyActivatedEvent: + orgId = &event.Aggregate().ID + policyEvent = e.LabelPolicyActivatedEvent + case *instance.LabelPolicyActivatedEvent: + policyEvent = e.LabelPolicyActivatedEvent + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-7Kd8U", "reduce.wrong.event.type %v", []eventstore.EventType{org.LabelPolicyActivatedEventType, instance.LabelPolicyActivatedEventType}) + } + + return handler.NewStatement(&policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-r7k0y", "reduce.wrong.db.pool %T", ex) + } + + settingsRepo := repository.BrandingSettingsRepository() + _, err := settingsRepo.ActivateAt(ctx, v3_sql.SQLTx(tx), + database.And( + settingsRepo.InstanceIDCondition(event.Aggregate().InstanceID), + settingsRepo.OrganizationIDCondition(orgId), + ), + policyEvent.Creation, + ) + return err + }), nil +} + +func (p *settingsRelationalProjection) reduceLogoAdded(event eventstore.Event) (*handler.Statement, error) { + var orgId *string + var policyEvent policy.LabelPolicyLogoAddedEvent + switch e := event.(type) { + case *org.LabelPolicyLogoAddedEvent: + orgId = &e.Aggregate().ResourceOwner + policyEvent = e.LabelPolicyLogoAddedEvent + case *instance.LabelPolicyLogoAddedEvent: + policyEvent = e.LabelPolicyLogoAddedEvent + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-kg8H4", "reduce.wrong.event.type %v", []eventstore.EventType{org.LabelPolicyLogoAddedEventType, instance.LabelPolicyLogoAddedEventType}) + } + return handler.NewStatement(&policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-5hONE", "reduce.wrong.db.pool %T", ex) + } + + url, err := url.Parse(policyEvent.StoreKey) + if err != nil { + return err + } + + settingsRepo := repository.BrandingSettingsRepository() + settings := domain.BrandingSettings{ + Settings: domain.Settings{ + InstanceID: event.Aggregate().InstanceID, + OrganizationID: orgId, + UpdatedAt: policyEvent.Creation, + }, + BrandingSettingsAttributes: domain.BrandingSettingsAttributes{ + LogoURLLight: url, + }, + } + return settingsRepo.Set(ctx, v3_sql.SQLTx(tx), &settings) + }), nil +} + +func (p *settingsRelationalProjection) reduceLogoDarkAdded(event eventstore.Event) (*handler.Statement, error) { + var orgId *string + var policyEvent policy.LabelPolicyLogoDarkAddedEvent + switch e := event.(type) { + case *org.LabelPolicyLogoDarkAddedEvent: + orgId = &e.Aggregate().ResourceOwner + policyEvent = e.LabelPolicyLogoDarkAddedEvent + case *instance.LabelPolicyLogoDarkAddedEvent: + policyEvent = e.LabelPolicyLogoDarkAddedEvent + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-kg8H4", "reduce.wrong.event.type %v", []eventstore.EventType{org.LabelPolicyLogoDarkAddedEventType, instance.LabelPolicyLogoDarkAddedEventType}) + } + + return handler.NewStatement(&policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-5hONE", "reduce.wrong.db.pool %T", ex) + } + + url, err := url.Parse(policyEvent.StoreKey) + if err != nil { + return err + } + + settingsRepo := repository.BrandingSettingsRepository() + settings := domain.BrandingSettings{ + Settings: domain.Settings{ + InstanceID: event.Aggregate().InstanceID, + OrganizationID: orgId, + UpdatedAt: policyEvent.Creation, + }, + BrandingSettingsAttributes: domain.BrandingSettingsAttributes{ + LogoURLDark: url, + }, + } + return settingsRepo.Set(ctx, v3_sql.SQLTx(tx), &settings) + }), nil +} + +func (p *settingsRelationalProjection) reduceLogoRemoved(event eventstore.Event) (*handler.Statement, error) { + var orgId *string + var policyEvent policy.LabelPolicyLogoRemovedEvent + switch e := event.(type) { + case *org.LabelPolicyLogoRemovedEvent: + orgId = &event.Aggregate().ID + policyEvent = e.LabelPolicyLogoRemovedEvent + case *instance.LabelPolicyLogoRemovedEvent: + policyEvent = e.LabelPolicyLogoRemovedEvent + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-kg8H4", "reduce.wrong.event.type %v", []eventstore.EventType{org.LabelPolicyLogoRemovedEventType, instance.LabelPolicyLogoRemovedEventType}) + } + + return handler.NewStatement(&policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-5hONE", "reduce.wrong.db.pool %T", ex) + } + + settingsRepo := repository.BrandingSettingsRepository() + settings := domain.BrandingSettings{ + Settings: domain.Settings{ + InstanceID: event.Aggregate().InstanceID, + OrganizationID: orgId, + UpdatedAt: policyEvent.Creation, + }, + BrandingSettingsAttributes: domain.BrandingSettingsAttributes{ + LogoURLLight: &url.URL{}, + }, + } + return settingsRepo.Set(ctx, v3_sql.SQLTx(tx), &settings) + }), nil +} + +func (p *settingsRelationalProjection) reduceLogoDarkRemoved(event eventstore.Event) (*handler.Statement, error) { + var orgId *string + var policyEvent policy.LabelPolicyLogoDarkRemovedEvent + switch e := event.(type) { + case *org.LabelPolicyLogoDarkRemovedEvent: + orgId = &event.Aggregate().ID + policyEvent = e.LabelPolicyLogoDarkRemovedEvent + case *instance.LabelPolicyLogoDarkRemovedEvent: + policyEvent = e.LabelPolicyLogoDarkRemovedEvent + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-kg8H4", "reduce.wrong.event.type %v", []eventstore.EventType{org.LabelPolicyLogoDarkRemovedEventType, instance.LabelPolicyLogoDarkRemovedEventType}) + } + + return handler.NewStatement(&policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-5hONE", "reduce.wrong.db.pool %T", ex) + } + settingsRepo := repository.BrandingSettingsRepository() + settings := domain.BrandingSettings{ + Settings: domain.Settings{ + InstanceID: event.Aggregate().InstanceID, + OrganizationID: orgId, + UpdatedAt: policyEvent.Creation, + }, + BrandingSettingsAttributes: domain.BrandingSettingsAttributes{ + LogoURLDark: &url.URL{}, + }, + } + return settingsRepo.Set(ctx, v3_sql.SQLTx(tx), &settings) + }), nil +} + +func (p *settingsRelationalProjection) reduceIconAdded(event eventstore.Event) (*handler.Statement, error) { + var orgId *string + var policyEvent policy.LabelPolicyIconAddedEvent + switch e := event.(type) { + case *org.LabelPolicyIconAddedEvent: + orgId = &event.Aggregate().ID + policyEvent = e.LabelPolicyIconAddedEvent + case *instance.LabelPolicyIconAddedEvent: + policyEvent = e.LabelPolicyIconAddedEvent + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-e2JFz", "reduce.wrong.event.type %v", []eventstore.EventType{org.LabelPolicyIconDarkAddedEventType, instance.LabelPolicyIconDarkAddedEventType}) + } + + return handler.NewStatement(&policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-5hONE", "reduce.wrong.db.pool %T", ex) + } + + url, err := url.Parse(policyEvent.StoreKey) + if err != nil { + return err + } + + settingsRepo := repository.BrandingSettingsRepository() + settings := domain.BrandingSettings{ + Settings: domain.Settings{ + InstanceID: event.Aggregate().InstanceID, + OrganizationID: orgId, + UpdatedAt: policyEvent.Creation, + }, + BrandingSettingsAttributes: domain.BrandingSettingsAttributes{ + IconURLLight: url, + }, + } + return settingsRepo.Set(ctx, v3_sql.SQLTx(tx), &settings) + }), nil +} + +func (p *settingsRelationalProjection) reduceIconDarkAdded(event eventstore.Event) (*handler.Statement, error) { + var orgId *string + var policyEvent policy.LabelPolicyIconDarkAddedEvent + switch e := event.(type) { + case *org.LabelPolicyIconDarkAddedEvent: + orgId = &event.Aggregate().ID + policyEvent = e.LabelPolicyIconDarkAddedEvent + case *instance.LabelPolicyIconDarkAddedEvent: + policyEvent = e.LabelPolicyIconDarkAddedEvent + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-e2JFz", "reduce.wrong.event.type %v", []eventstore.EventType{org.LabelPolicyIconDarkAddedEventType, instance.LabelPolicyIconDarkAddedEventType}) + } + + return handler.NewStatement(&policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-5hONE", "reduce.wrong.db.pool %T", ex) + } + + url, err := url.Parse(policyEvent.StoreKey) + if err != nil { + return err + } + + settingsRepo := repository.BrandingSettingsRepository() + settings := domain.BrandingSettings{ + Settings: domain.Settings{ + InstanceID: event.Aggregate().InstanceID, + OrganizationID: orgId, + UpdatedAt: policyEvent.Creation, + }, + BrandingSettingsAttributes: domain.BrandingSettingsAttributes{ + IconURLDark: url, + }, + } + return settingsRepo.Set(ctx, v3_sql.SQLTx(tx), &settings) + }), nil +} + +func (p *settingsRelationalProjection) reduceIconRemoved(event eventstore.Event) (*handler.Statement, error) { + var orgId *string + var policyEvent policy.LabelPolicyIconRemovedEvent + switch e := event.(type) { + case *org.LabelPolicyIconRemovedEvent: + orgId = &event.Aggregate().ID + policyEvent = e.LabelPolicyIconRemovedEvent + case *instance.LabelPolicyIconRemovedEvent: + policyEvent = e.LabelPolicyIconRemovedEvent + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-gfgbY", "reduce.wrong.event.type %v", []eventstore.EventType{org.LabelPolicyIconRemovedEventType, instance.LabelPolicyIconRemovedEventType}) + } + + return handler.NewStatement(&policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-5hONE", "reduce.wrong.db.pool %T", ex) + } + + settingsRepo := repository.BrandingSettingsRepository() + settings := domain.BrandingSettings{ + Settings: domain.Settings{ + InstanceID: event.Aggregate().InstanceID, + OrganizationID: orgId, + UpdatedAt: policyEvent.Creation, + }, + BrandingSettingsAttributes: domain.BrandingSettingsAttributes{ + IconURLLight: &url.URL{}, + }, + } + return settingsRepo.Set(ctx, v3_sql.SQLTx(tx), &settings) + }), nil +} + +func (p *settingsRelationalProjection) reduceIconDarkRemoved(event eventstore.Event) (*handler.Statement, error) { + var orgId *string + var policyEvent policy.LabelPolicyIconDarkRemovedEvent + switch e := event.(type) { + case *org.LabelPolicyIconDarkRemovedEvent: + orgId = &event.Aggregate().ID + policyEvent = e.LabelPolicyIconDarkRemovedEvent + case *instance.LabelPolicyIconDarkRemovedEvent: + policyEvent = e.LabelPolicyIconDarkRemovedEvent + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-gfgbY", "reduce.wrong.event.type %v", []eventstore.EventType{org.LabelPolicyIconDarkRemovedEventType, instance.LabelPolicyIconDarkRemovedEventType}) + } + + return handler.NewStatement(&policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-5hONE", "reduce.wrong.db.pool %T", ex) + } + + settingsRepo := repository.BrandingSettingsRepository() + settings := domain.BrandingSettings{ + Settings: domain.Settings{ + InstanceID: event.Aggregate().InstanceID, + OrganizationID: orgId, + UpdatedAt: policyEvent.Creation, + }, + BrandingSettingsAttributes: domain.BrandingSettingsAttributes{ + IconURLDark: &url.URL{}, + }, + } + return settingsRepo.Set(ctx, v3_sql.SQLTx(tx), &settings) + }), nil +} + +func (p *settingsRelationalProjection) reduceFontAdded(event eventstore.Event) (*handler.Statement, error) { + var orgId *string + var policyEvent policy.LabelPolicyFontAddedEvent + switch e := event.(type) { + case *org.LabelPolicyFontAddedEvent: + orgId = &event.Aggregate().ID + policyEvent = e.LabelPolicyFontAddedEvent + case *instance.LabelPolicyFontAddedEvent: + policyEvent = e.LabelPolicyFontAddedEvent + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-65i9W", "reduce.wrong.event.type %v", []eventstore.EventType{org.LabelPolicyFontAddedEventType, instance.LabelPolicyFontAddedEventType}) + } + + return handler.NewStatement(&policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-5hONE", "reduce.wrong.db.pool %T", ex) + } + + url, err := url.Parse(policyEvent.StoreKey) + if err != nil { + return err + } + + settingsRepo := repository.BrandingSettingsRepository() + settings := domain.BrandingSettings{ + Settings: domain.Settings{ + InstanceID: event.Aggregate().InstanceID, + OrganizationID: orgId, + UpdatedAt: policyEvent.Creation, + }, + BrandingSettingsAttributes: domain.BrandingSettingsAttributes{ + FontURL: url, + }, + } + return settingsRepo.Set(ctx, v3_sql.SQLTx(tx), &settings) + }), nil +} + +func (p *settingsRelationalProjection) reduceFontRemoved(event eventstore.Event) (*handler.Statement, error) { + var orgId *string + var policyEvent policy.LabelPolicyFontRemovedEvent + switch e := event.(type) { + case *org.LabelPolicyFontRemovedEvent: + orgId = &event.Aggregate().ID + policyEvent = e.LabelPolicyFontRemovedEvent + case *instance.LabelPolicyFontRemovedEvent: + policyEvent = e.LabelPolicyFontRemovedEvent + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-xf32J", "reduce.wrong.event.type %v", []eventstore.EventType{org.LabelPolicyFontRemovedEventType, instance.LabelPolicyFontRemovedEventType}) + } + + return handler.NewStatement(&policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-5hONE", "reduce.wrong.db.pool %T", ex) + } + + settingsRepo := repository.BrandingSettingsRepository() + settings := domain.BrandingSettings{ + Settings: domain.Settings{ + InstanceID: event.Aggregate().InstanceID, + OrganizationID: orgId, + UpdatedAt: policyEvent.Creation, + }, + BrandingSettingsAttributes: domain.BrandingSettingsAttributes{ + FontURL: &url.URL{}, + }, + } + return settingsRepo.Set(ctx, v3_sql.SQLTx(tx), &settings) + }), nil +} + +func (p *settingsRelationalProjection) reducePasswordComplexityAdded(event eventstore.Event) (*handler.Statement, error) { + var orgId *string + var policyEvent policy.PasswordComplexityPolicyAddedEvent + switch e := event.(type) { + case *org.PasswordComplexityPolicyAddedEvent: + policyEvent = e.PasswordComplexityPolicyAddedEvent + orgId = &policyEvent.Aggregate().ResourceOwner + case *instance.PasswordComplexityPolicyAddedEvent: + policyEvent = e.PasswordComplexityPolicyAddedEvent + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-KTHmJ", "reduce.wrong.event.type %v", []eventstore.EventType{org.PasswordComplexityPolicyAddedEventType, instance.PasswordComplexityPolicyAddedEventType}) + } + + return handler.NewStatement(&policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-5hONE", "reduce.wrong.db.pool %T", ex) + } + + settingsRepo := repository.PasswordComplexitySettingsRepository() + settings := domain.PasswordComplexitySettings{ + Settings: domain.Settings{ + InstanceID: event.Aggregate().InstanceID, + OrganizationID: orgId, + CreatedAt: policyEvent.Creation, + UpdatedAt: policyEvent.Creation, + }, + PasswordComplexitySettingsAttributes: domain.PasswordComplexitySettingsAttributes{ + MinLength: &policyEvent.MinLength, + HasLowercase: &policyEvent.HasLowercase, + HasUppercase: &policyEvent.HasUppercase, + HasNumber: &policyEvent.HasNumber, + HasSymbol: &policyEvent.HasSymbol, + }, + } + return settingsRepo.Set(ctx, v3_sql.SQLTx(tx), &settings) + }), nil +} + +func (s *settingsRelationalProjection) reducePasswordComplexityChanged(event eventstore.Event) (*handler.Statement, error) { + var orgId *string + var policyEvent policy.PasswordComplexityPolicyChangedEvent + switch e := event.(type) { + case *org.PasswordComplexityPolicyChangedEvent: + policyEvent = e.PasswordComplexityPolicyChangedEvent + orgId = &policyEvent.Aggregate().ResourceOwner + case *instance.PasswordComplexityPolicyChangedEvent: + policyEvent = e.PasswordComplexityPolicyChangedEvent + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-cf3Xb", "reduce.wrong.event.type %v", []eventstore.EventType{org.PasswordComplexityPolicyChangedEventType, instance.PasswordComplexityPolicyChangedEventType}) + } + + return handler.NewStatement(&policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-rLrfy", "reduce.wrong.db.pool %T", ex) + } + + settingsRepo := repository.PasswordComplexitySettingsRepository() + settings := domain.PasswordComplexitySettings{ + Settings: domain.Settings{ + InstanceID: event.Aggregate().InstanceID, + OrganizationID: orgId, + UpdatedAt: policyEvent.Creation, + }, + PasswordComplexitySettingsAttributes: domain.PasswordComplexitySettingsAttributes{ + MinLength: policyEvent.MinLength, + HasLowercase: policyEvent.HasLowercase, + HasUppercase: policyEvent.HasUppercase, + HasNumber: policyEvent.HasNumber, + HasSymbol: policyEvent.HasSymbol, + }, + } + return settingsRepo.Set(ctx, v3_sql.SQLTx(tx), &settings) + }), nil +} + +func (s *settingsRelationalProjection) reducePasswordComplexityRemoved(event eventstore.Event) (*handler.Statement, error) { + policyEvent, ok := event.(*org.PasswordComplexityPolicyRemovedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-wttCd", "reduce.wrong.event.type %s", org.PasswordComplexityPolicyRemovedEventType) + } + + return handler.NewStatement(policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-UrdHy", "reduce.wrong.db.pool %T", ex) + } + + settingsRepo := repository.PasswordComplexitySettingsRepository() + _, err := settingsRepo.Delete(ctx, v3_sql.SQLTx(tx), + settingsRepo.UniqueCondition(policyEvent.Aggregate().InstanceID, &policyEvent.Aggregate().ID, domain.SettingTypePasswordComplexity, domain.SettingStateActive), + ) + return err + }), nil +} + +func (p *settingsRelationalProjection) reducePasswordPolicyAdded(event eventstore.Event) (*handler.Statement, error) { + var orgId *string + var policyEvent policy.PasswordAgePolicyAddedEvent + switch e := event.(type) { + case *org.PasswordAgePolicyAddedEvent: + policyEvent = e.PasswordAgePolicyAddedEvent + orgId = &policyEvent.Aggregate().ResourceOwner + case *instance.PasswordAgePolicyAddedEvent: + policyEvent = e.PasswordAgePolicyAddedEvent + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-CJqF0", "reduce.wrong.event.type %v", []eventstore.EventType{org.PasswordAgePolicyAddedEventType, instance.PasswordAgePolicyAddedEventType}) + } + + return handler.NewStatement(&policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-5hONE", "reduce.wrong.db.pool %T", ex) + } + + settingsRepo := repository.PasswordExpirySettingsRepository() + settings := domain.PasswordExpirySettings{ + Settings: domain.Settings{ + InstanceID: event.Aggregate().InstanceID, + OrganizationID: orgId, + CreatedAt: policyEvent.Creation, + UpdatedAt: policyEvent.Creation, + }, + PasswordExpirySettingsAttributes: domain.PasswordExpirySettingsAttributes{ + ExpireWarnDays: &policyEvent.ExpireWarnDays, + MaxAgeDays: &policyEvent.MaxAgeDays, + }, + } + return settingsRepo.Set(ctx, v3_sql.SQLTx(tx), &settings) + }), nil +} + +func (p *settingsRelationalProjection) reducePasswordPolicyChanged(event eventstore.Event) (*handler.Statement, error) { + var orgId *string + var policyEvent policy.PasswordAgePolicyChangedEvent + switch e := event.(type) { + case *org.PasswordAgePolicyChangedEvent: + policyEvent = e.PasswordAgePolicyChangedEvent + orgId = &policyEvent.Aggregate().ResourceOwner + case *instance.PasswordAgePolicyChangedEvent: + policyEvent = e.PasswordAgePolicyChangedEvent + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-i7FZt", "reduce.wrong.event.type %v", []eventstore.EventType{org.PasswordAgePolicyChangedEventType, instance.PasswordAgePolicyChangedEventType}) + } + return handler.NewStatement(&policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-Mlk6y", "reduce.wrong.db.pool %T", ex) + } + + settingsRepo := repository.PasswordExpirySettingsRepository() + settings := domain.PasswordExpirySettings{ + Settings: domain.Settings{ + InstanceID: event.Aggregate().InstanceID, + OrganizationID: orgId, + UpdatedAt: policyEvent.Creation, + }, + PasswordExpirySettingsAttributes: domain.PasswordExpirySettingsAttributes{ + ExpireWarnDays: policyEvent.ExpireWarnDays, + MaxAgeDays: policyEvent.MaxAgeDays, + }, + } + return settingsRepo.Set(ctx, v3_sql.SQLTx(tx), &settings) + }), nil +} + +func (p *settingsRelationalProjection) reducePasswordPolicyRemoved(event eventstore.Event) (*handler.Statement, error) { + policyEvent, ok := event.(*org.PasswordAgePolicyRemovedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-EtHWB", "reduce.wrong.event.type %s", org.PasswordAgePolicyRemovedEventType) + } + return handler.NewStatement(policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-UrdHy", "reduce.wrong.db.pool %T", ex) + } + + settingsRepo := repository.PasswordExpirySettingsRepository() + _, err := settingsRepo.Delete(ctx, v3_sql.SQLTx(tx), + settingsRepo.UniqueCondition(policyEvent.Aggregate().InstanceID, &policyEvent.Aggregate().ID, domain.SettingTypePasswordExpiry, domain.SettingStateActive), + ) + return err + }), nil +} + +func (p *settingsRelationalProjection) reduceOrgLockoutPolicyRemoved(event eventstore.Event) (*handler.Statement, error) { + policyEvent, ok := event.(*org.LockoutPolicyRemovedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-Bqut9", "reduce.wrong.event.type %s", org.LockoutPolicyRemovedEventType) + } + return handler.NewStatement(policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-UrdHy", "reduce.wrong.db.pool %T", ex) + } + + settingsRepo := repository.LockoutSettingsRepository() + _, err := settingsRepo.Delete(ctx, v3_sql.SQLTx(tx), + settingsRepo.UniqueCondition(policyEvent.Aggregate().InstanceID, &policyEvent.Aggregate().ID, domain.SettingTypeLockout, domain.SettingStateActive), + ) + return err + }), nil +} + +func (p *settingsRelationalProjection) reduceLockoutPolicyAdded(event eventstore.Event) (*handler.Statement, error) { + var orgId *string + var policyEvent policy.LockoutPolicyAddedEvent + switch e := event.(type) { + case *org.LockoutPolicyAddedEvent: + policyEvent = e.LockoutPolicyAddedEvent + orgId = &policyEvent.Aggregate().ResourceOwner + case *instance.LockoutPolicyAddedEvent: + policyEvent = e.LockoutPolicyAddedEvent + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-d8mZO", "reduce.wrong.event.type, %v", []eventstore.EventType{org.LockoutPolicyAddedEventType, instance.LockoutPolicyAddedEventType}) + } + + return handler.NewStatement(&policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-5hnNE", "reduce.wrong.db.pool %T", ex) + } + settingsRepo := repository.LockoutSettingsRepository() + settings := domain.LockoutSettings{ + Settings: domain.Settings{ + InstanceID: event.Aggregate().InstanceID, + OrganizationID: orgId, + CreatedAt: policyEvent.Creation, + UpdatedAt: policyEvent.Creation, + }, + LockoutSettingsAttributes: domain.LockoutSettingsAttributes{ + MaxPasswordAttempts: &policyEvent.MaxPasswordAttempts, + MaxOTPAttempts: &policyEvent.MaxOTPAttempts, + ShowLockOutFailures: &policyEvent.ShowLockOutFailures, + }, + } + return settingsRepo.Set(ctx, v3_sql.SQLTx(tx), &settings) + }), nil +} + +func (p *settingsRelationalProjection) reduceLockoutPolicyChanged(event eventstore.Event) (*handler.Statement, error) { + var orgId *string + var policyEvent policy.LockoutPolicyChangedEvent + switch e := event.(type) { + case *org.LockoutPolicyChangedEvent: + policyEvent = e.LockoutPolicyChangedEvent + orgId = &policyEvent.Aggregate().ResourceOwner + case *instance.LockoutPolicyChangedEvent: + policyEvent = e.LockoutPolicyChangedEvent + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-gT3BQ", "reduce.wrong.event.type, %v", []eventstore.EventType{org.LockoutPolicyChangedEventType, instance.LockoutPolicyChangedEventType}) + } + + return handler.NewStatement(&policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-rbsxy", "reduce.wrong.db.pool %T", ex) + } + + settingsRepo := repository.LockoutSettingsRepository() + settings := domain.LockoutSettings{ + Settings: domain.Settings{ + InstanceID: event.Aggregate().InstanceID, + OrganizationID: orgId, + CreatedAt: policyEvent.Creation, + UpdatedAt: policyEvent.Creation, + }, + LockoutSettingsAttributes: domain.LockoutSettingsAttributes{ + MaxPasswordAttempts: policyEvent.MaxPasswordAttempts, + MaxOTPAttempts: policyEvent.MaxOTPAttempts, + ShowLockOutFailures: policyEvent.ShowLockOutFailures, + }, + } + return settingsRepo.Set(ctx, v3_sql.SQLTx(tx), &settings) + }), nil +} + +func (p *settingsRelationalProjection) reduceDomainPolicyAdded(event eventstore.Event) (*handler.Statement, error) { + var orgId *string + var policyEvent policy.DomainPolicyAddedEvent + switch e := event.(type) { + case *org.DomainPolicyAddedEvent: + policyEvent = e.DomainPolicyAddedEvent + orgId = &policyEvent.Aggregate().ResourceOwner + case *instance.DomainPolicyAddedEvent: + policyEvent = e.DomainPolicyAddedEvent + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-8se7M", "reduce.wrong.event.type %v", []eventstore.EventType{org.DomainPolicyAddedEventType, instance.DomainPolicyAddedEventType}) + } + + return handler.NewStatement(&policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-chduE", "reduce.wrong.db.pool %T", ex) + } + + settingsRepo := repository.DomainSettingsRepository() + settings := domain.DomainSettings{ + Settings: domain.Settings{ + InstanceID: event.Aggregate().InstanceID, + OrganizationID: orgId, + CreatedAt: policyEvent.Creation, + UpdatedAt: policyEvent.Creation, + }, + DomainSettingsAttributes: domain.DomainSettingsAttributes{ + LoginNameIncludesDomain: &policyEvent.UserLoginMustBeDomain, + RequireOrgDomainVerification: &policyEvent.ValidateOrgDomains, + SMTPSenderAddressMatchesInstanceDomain: &policyEvent.SMTPSenderAddressMatchesInstanceDomain, + }, + } + return settingsRepo.Set(ctx, v3_sql.SQLTx(tx), &settings) + }), nil +} + +func (s *settingsRelationalProjection) reduceDomainPolicyChanged(event eventstore.Event) (*handler.Statement, error) { + var orgId *string + var policyEvent policy.DomainPolicyChangedEvent + switch e := event.(type) { + case *org.DomainPolicyChangedEvent: + policyEvent = e.DomainPolicyChangedEvent + orgId = &policyEvent.Aggregate().ResourceOwner + case *instance.DomainPolicyChangedEvent: + policyEvent = e.DomainPolicyChangedEvent + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-qgVug", "reduce.wrong.event.type %v", []eventstore.EventType{org.DomainPolicyChangedEventType, instance.DomainPolicyChangedEventType}) + } + + return handler.NewStatement(&policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-rbsxy", "reduce.wrong.db.pool %T", ex) + } + + settingsRepo := repository.DomainSettingsRepository() + settings := domain.DomainSettings{ + Settings: domain.Settings{ + InstanceID: event.Aggregate().InstanceID, + OrganizationID: orgId, + UpdatedAt: policyEvent.Creation, + }, + DomainSettingsAttributes: domain.DomainSettingsAttributes{ + LoginNameIncludesDomain: policyEvent.UserLoginMustBeDomain, + RequireOrgDomainVerification: policyEvent.ValidateOrgDomains, + SMTPSenderAddressMatchesInstanceDomain: policyEvent.SMTPSenderAddressMatchesInstanceDomain, + }, + } + return settingsRepo.Set(ctx, v3_sql.SQLTx(tx), &settings) + }), nil +} + +func (p *settingsRelationalProjection) reduceOrgDomainPolicyRemoved(event eventstore.Event) (*handler.Statement, error) { + policyEvent, ok := event.(*org.DomainPolicyRemovedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-Bqut9", "reduce.wrong.event.type %s", org.LockoutPolicyRemovedEventType) + } + return handler.NewStatement(policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-UrdHy", "reduce.wrong.db.pool %T", ex) + } + + settingsRepo := repository.DomainSettingsRepository() + _, err := settingsRepo.Delete(ctx, v3_sql.SQLTx(tx), + settingsRepo.UniqueCondition(policyEvent.Aggregate().InstanceID, &policyEvent.Aggregate().ID, domain.SettingTypeDomain, domain.SettingStateActive), + ) + return err + }), nil +} + +func (p *settingsRelationalProjection) reduceNotificationPolicyAdded(event eventstore.Event) (*handler.Statement, error) { + var orgId *string + var policyEvent policy.NotificationPolicyAddedEvent + switch e := event.(type) { + case *org.NotificationPolicyAddedEvent: + policyEvent = e.NotificationPolicyAddedEvent + orgId = &policyEvent.Aggregate().ResourceOwner + case *instance.NotificationPolicyAddedEvent: + policyEvent = e.NotificationPolicyAddedEvent + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-8se7M", "reduce.wrong.event.type %v", []eventstore.EventType{org.NotificationPolicyAddedEventType, instance.NotificationPolicyAddedEventType}) + } + + return handler.NewStatement(&policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-chduE", "reduce.wrong.db.pool %T", ex) + } + + settingsRepo := repository.NotificationSettingsRepository() + settings := domain.NotificationSettings{ + Settings: domain.Settings{ + InstanceID: event.Aggregate().InstanceID, + OrganizationID: orgId, + CreatedAt: policyEvent.Creation, + UpdatedAt: policyEvent.Creation, + }, + NotificationSettingsAttributes: domain.NotificationSettingsAttributes{ + PasswordChange: &policyEvent.PasswordChange, + }, + } + return settingsRepo.Set(ctx, v3_sql.SQLTx(tx), &settings) + }), nil +} + +func (s *settingsRelationalProjection) reduceNotificationPolicyChanged(event eventstore.Event) (*handler.Statement, error) { + var orgId *string + var policyEvent policy.NotificationPolicyChangedEvent + switch e := event.(type) { + case *org.NotificationPolicyChangedEvent: + policyEvent = e.NotificationPolicyChangedEvent + orgId = &policyEvent.Aggregate().ResourceOwner + case *instance.NotificationPolicyChangedEvent: + policyEvent = e.NotificationPolicyChangedEvent + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-qgVug", "reduce.wrong.event.type %v", []eventstore.EventType{org.NotificationPolicyChangedEventType, instance.NotificationPolicyChangedEventType}) + } + + return handler.NewStatement(&policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-rbsxy", "reduce.wrong.db.pool %T", ex) + } + + settingsRepo := repository.NotificationSettingsRepository() + settings := domain.NotificationSettings{ + Settings: domain.Settings{ + InstanceID: event.Aggregate().InstanceID, + OrganizationID: orgId, + CreatedAt: policyEvent.Creation, + UpdatedAt: policyEvent.Creation, + }, + NotificationSettingsAttributes: domain.NotificationSettingsAttributes{ + PasswordChange: policyEvent.PasswordChange, + }, + } + return settingsRepo.Set(ctx, v3_sql.SQLTx(tx), &settings) + }), nil +} + +func (p *settingsRelationalProjection) reduceOrgNotificationPolicyRemoved(event eventstore.Event) (*handler.Statement, error) { + policyEvent, ok := event.(*org.NotificationPolicyRemovedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-Bqut9", "reduce.wrong.event.type %s", org.NotificationPolicyRemovedEventType) + } + return handler.NewStatement(policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-UrdHy", "reduce.wrong.db.pool %T", ex) + } + + settingsRepo := repository.NotificationSettingsRepository() + _, err := settingsRepo.Delete(ctx, v3_sql.SQLTx(tx), + settingsRepo.UniqueCondition(policyEvent.Aggregate().InstanceID, &policyEvent.Aggregate().ID, domain.SettingTypeNotification, domain.SettingStateActive), + ) + return err + }), nil +} + +func (p *settingsRelationalProjection) reducePrivacyPolicyAdded(event eventstore.Event) (*handler.Statement, error) { + var orgId *string + var policyEvent policy.PrivacyPolicyAddedEvent + switch e := event.(type) { + case *org.PrivacyPolicyAddedEvent: + policyEvent = e.PrivacyPolicyAddedEvent + orgId = &policyEvent.Aggregate().ResourceOwner + case *instance.PrivacyPolicyAddedEvent: + policyEvent = e.PrivacyPolicyAddedEvent + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-8se7M", "reduce.wrong.event.type %v", []eventstore.EventType{org.PrivacyPolicyAddedEventType, instance.PrivacyPolicyAddedEventType}) + } + + return handler.NewStatement(&policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-chduE", "reduce.wrong.db.pool %T", ex) + } + + settingsRepo := repository.LegalAndSupportSettingsRepository() + email := string(policyEvent.SupportEmail) + settings := domain.LegalAndSupportSettings{ + Settings: domain.Settings{ + InstanceID: event.Aggregate().InstanceID, + OrganizationID: orgId, + CreatedAt: policyEvent.Creation, + UpdatedAt: policyEvent.Creation, + }, + LegalAndSupportSettingsAttributes: domain.LegalAndSupportSettingsAttributes{ + TOSLink: &policyEvent.TOSLink, + PrivacyPolicyLink: &policyEvent.PrivacyLink, + HelpLink: &policyEvent.HelpLink, + SupportEmail: &email, + DocsLink: &policyEvent.DocsLink, + CustomLink: &policyEvent.CustomLink, + CustomLinkText: &policyEvent.CustomLinkText, + }, + } + return settingsRepo.Set(ctx, v3_sql.SQLTx(tx), &settings) + }), nil +} + +func (s *settingsRelationalProjection) reducePrivacyPolicyChanged(event eventstore.Event) (*handler.Statement, error) { + var orgId *string + var policyEvent policy.PrivacyPolicyChangedEvent + switch e := event.(type) { + case *org.PrivacyPolicyChangedEvent: + policyEvent = e.PrivacyPolicyChangedEvent + orgId = &policyEvent.Aggregate().ResourceOwner + case *instance.PrivacyPolicyChangedEvent: + policyEvent = e.PrivacyPolicyChangedEvent + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-qgVug", "reduce.wrong.event.type %v", []eventstore.EventType{org.PrivacyPolicyChangedEventType, instance.PrivacyPolicyChangedEventType}) + } + + return handler.NewStatement(&policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-rbsxy", "reduce.wrong.db.pool %T", ex) + } + + settingsRepo := repository.LegalAndSupportSettingsRepository() + var email string + if policyEvent.SupportEmail != nil { + email = string(*policyEvent.SupportEmail) + } + settings := domain.LegalAndSupportSettings{ + Settings: domain.Settings{ + InstanceID: event.Aggregate().InstanceID, + OrganizationID: orgId, + UpdatedAt: policyEvent.Creation, + }, + LegalAndSupportSettingsAttributes: domain.LegalAndSupportSettingsAttributes{ + TOSLink: policyEvent.TOSLink, + PrivacyPolicyLink: policyEvent.PrivacyLink, + HelpLink: policyEvent.HelpLink, + SupportEmail: &email, + DocsLink: policyEvent.DocsLink, + CustomLink: policyEvent.CustomLink, + CustomLinkText: policyEvent.CustomLinkText, + }, + } + return settingsRepo.Set(ctx, v3_sql.SQLTx(tx), &settings) + }), nil +} + +func (p *settingsRelationalProjection) reduceOrgPrivacyPolicyRemoved(event eventstore.Event) (*handler.Statement, error) { + policyEvent, ok := event.(*org.PrivacyPolicyRemovedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-Bqut9", "reduce.wrong.event.type %s", org.PrivacyPolicyRemovedEventType) + } + return handler.NewStatement(policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-UrdHy", "reduce.wrong.db.pool %T", ex) + } + + settingsRepo := repository.LegalAndSupportSettingsRepository() + _, err := settingsRepo.Delete(ctx, v3_sql.SQLTx(tx), + settingsRepo.UniqueCondition(policyEvent.Aggregate().InstanceID, &policyEvent.Aggregate().ID, domain.SettingTypeLegalAndSupport, domain.SettingStateActive), + ) + return err + }), nil +} + +func (s *settingsRelationalProjection) reduceSecurityPolicySet(event eventstore.Event) (*handler.Statement, error) { + policyEvent, ok := event.(*instance.SecurityPolicySetEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-83U8p", "reduce.wrong.event.type %s", instance.SecurityPolicySetEventType) + } + + return handler.NewStatement(policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-lhPul", "reduce.wrong.db.pool %T", ex) + } + + var allowedOrigins []string + if policyEvent.AllowedOrigins != nil { + allowedOrigins = *policyEvent.AllowedOrigins + } + + settingsRepo := repository.SecuritySettingsRepository() + settings := domain.SecuritySettings{ + Settings: domain.Settings{ + InstanceID: event.Aggregate().InstanceID, + OrganizationID: nil, + CreatedAt: policyEvent.Creation, + UpdatedAt: policyEvent.Creation, + }, + SecuritySettingsAttributes: domain.SecuritySettingsAttributes{ + EnableIframeEmbedding: policyEvent.EnableIframeEmbedding, + AllowedOrigins: allowedOrigins, + EnableImpersonation: policyEvent.EnableImpersonation, + }, + } + return settingsRepo.Set(ctx, v3_sql.SQLTx(tx), &settings) + }), nil +} + +func (s *settingsRelationalProjection) reduceOrganizationSettingsSet(event eventstore.Event) (*handler.Statement, error) { + policyEvent, err := assertEvent[*settings.OrganizationSettingsSetEvent](event) + if err != nil { + return nil, err + } + + return handler.NewStatement(policyEvent, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-lhPul", "reduce.wrong.db.pool %T", ex) + } + + settingsRepo := repository.OrganizationSettingsRepository() + settings := domain.OrganizationSettings{ + Settings: domain.Settings{ + InstanceID: event.Aggregate().InstanceID, + OrganizationID: &event.Aggregate().ID, + CreatedAt: policyEvent.Creation, + UpdatedAt: policyEvent.Creation, + }, + OrganizationSettingsAttributes: domain.OrganizationSettingsAttributes{ + OrganizationScopedUsernames: &policyEvent.OrganizationScopedUsernames, + }, + } + return settingsRepo.Set(ctx, v3_sql.SQLTx(tx), &settings) + }), nil +} + +func (s *settingsRelationalProjection) reduceOrganizationSettingsRemoved(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*settings.OrganizationSettingsRemovedEvent](event) + if err != nil { + return nil, err + } + + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-rHiHb", "reduce.wrong.db.pool %T", ex) + } + + settingsRepo := repository.OrganizationSettingsRepository() + _, err := settingsRepo.Delete(ctx, v3_sql.SQLTx(tx), + settingsRepo.UniqueCondition(e.Aggregate().InstanceID, &e.Aggregate().ID, domain.SettingTypeOrganization, domain.SettingStateActive), + ) + return err + }), nil +}