Files
zitadel/backend/v3/domain/session_challenge_otp_sms_test.go
T
Gayathri VijayanandMarco A. 7c3d26b23b feat: rt sessions OTP email challenge (#11941)
# Which Problems Are Solved

Add OTP Email challenge needed for Create/Set session in the RT model.

# How the Problems Are Solved

Introduce `OTPEmailChallengeCommand` (validate/execute/events) for
session creation checks
Add unit tests

# Additional Changes
Remove unused fields from `session_challenge_otp_sms.go`

# Additional Context
- Related to https://github.com/zitadel/zitadel/issues/11035

---------

Co-authored-by: Marco A. <kwbmm1990@gmail.com>
2026-03-31 11:50:53 +00:00

1232 lines
42 KiB
Go

package domain_test
import (
"context"
"errors"
"testing"
"time"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"github.com/zitadel/zitadel/backend/v3/domain"
domainmock "github.com/zitadel/zitadel/backend/v3/domain/mock"
"github.com/zitadel/zitadel/backend/v3/storage/database"
"github.com/zitadel/zitadel/backend/v3/storage/database/dbmock"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/session"
"github.com/zitadel/zitadel/internal/zerrors"
)
func TestOTPSMSChallengeCommand_Validate(t *testing.T) {
t.Parallel()
otpEnabledAt := time.Now().Add(-30 * time.Minute)
tests := []struct {
name string
sessionID string
instanceID string
secretGeneratorConfig *crypto.GeneratorConfig
otpAlgorithm crypto.EncryptionAlgorithm
challengeTypeOTPSMS *domain.ChallengeTypeOTPSMS
userRepo func(ctrl *gomock.Controller) domain.UserRepository
sessionRepo func(ctrl *gomock.Controller) domain.SessionRepository
smsProvider func(ctx context.Context, instanceID string) (string, error)
wantErr error
}{
{
name: "no request otpsms challenge",
challengeTypeOTPSMS: nil,
wantErr: nil,
},
{
name: "no session id",
sessionID: "",
instanceID: "instance-1",
challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{},
wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-3XpM6A", "Errors.Missing.SessionID"),
},
{
name: "no instance id",
sessionID: "session-1",
instanceID: "",
challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{},
wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-jNNJ9f", "Errors.Missing.InstanceID"),
},
{
name: "no sms provider",
sessionID: "session-1",
instanceID: "instance-1",
challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{},
smsProvider: nil,
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: 10 * time.Minute,
},
wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-1aGWeE", "missing sms provider"),
},
{
name: "no default secret generator config",
sessionID: "session-1",
instanceID: "instance-1",
challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{},
smsProvider: func(ctx context.Context, instanceID string) (string, error) {
return "", nil
},
wantErr: zerrors.ThrowInternal(nil, "DOM-IDcOzP", "missing default secret generator config"),
},
{
name: "no otp algorithm",
sessionID: "session-1",
instanceID: "instance-1",
challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{},
smsProvider: func(ctx context.Context, instanceID string) (string, error) {
return "", nil
},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: 10 * time.Minute,
},
wantErr: zerrors.ThrowInternal(nil, "DOM-VeBPmV", "missing MFA encryption algorithm"),
},
{
name: "failed to get session",
sessionID: "session-1",
instanceID: "instance-1",
challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: 10 * time.Minute,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
smsProvider: func(ctx context.Context, instanceID string) (string, error) {
return "", nil
},
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
repo.EXPECT().
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(
database.WithCondition(
repo.PrimaryKeyCondition("instance-1", "session-1"),
),
)).
Times(1).
Return(nil, assert.AnError)
return repo
},
wantErr: zerrors.ThrowInternal(assert.AnError, "DOM-2aGWWE", "failed fetching Session"),
},
{
name: "session not found",
sessionID: "session-1",
instanceID: "instance-1",
challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: 10 * time.Minute,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
smsProvider: func(ctx context.Context, instanceID string) (string, error) {
return "", nil
},
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
repo.EXPECT().
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(
database.WithCondition(
repo.PrimaryKeyCondition("instance-1", "session-1"),
),
)).
Times(1).
Return(nil, new(database.NoRowFoundError))
return repo
},
wantErr: zerrors.ThrowNotFound(new(database.NoRowFoundError), "DOM-2aGWWE", "Session not found"),
},
{
name: "missing user id in session",
sessionID: "session-1",
instanceID: "instance-1",
challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: 10 * time.Minute,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
smsProvider: func(ctx context.Context, instanceID string) (string, error) {
return "", nil
},
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
repo.EXPECT().
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(
database.WithCondition(
repo.PrimaryKeyCondition("instance-1", "session-1"),
),
)).
Times(1).
Return(&domain.Session{
UserID: "",
}, nil)
return repo
},
wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-Vi16Fs", "Errors.Missing.Session.UserID"),
},
{
name: "failed to get user",
sessionID: "session-1",
instanceID: "instance-1",
challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: 10 * time.Minute,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
smsProvider: func(ctx context.Context, instanceID string) (string, error) {
return "", nil
},
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
getSessionSucceededExpectation(repo)
return repo
},
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
repo := domainmock.NewUserRepo(ctrl)
repo.EXPECT().
Get(gomock.Any(), gomock.Any(),
dbmock.QueryOptions(
database.WithCondition(
repo.PrimaryKeyCondition("instance-1", "user-1"),
),
)).
Times(1).
Return(nil, assert.AnError)
return repo
},
wantErr: zerrors.ThrowInternal(assert.AnError, "DOM-3aGHDs", "failed fetching User"),
},
{
name: "user not found",
sessionID: "session-1",
instanceID: "instance-1",
challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: 10 * time.Minute,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
smsProvider: func(ctx context.Context, instanceID string) (string, error) {
return "", nil
},
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
getSessionSucceededExpectation(repo)
return repo
},
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
repo := domainmock.NewUserRepo(ctrl)
repo.EXPECT().
Get(gomock.Any(), gomock.Any(),
dbmock.QueryOptions(
database.WithCondition(
repo.PrimaryKeyCondition("instance-1", "user-1"),
),
)).
Times(1).
Return(nil, new(database.NoRowFoundError))
return repo
},
wantErr: zerrors.ThrowNotFound(new(database.NoRowFoundError), "DOM-3aGHDs", "User not found"),
},
{
name: "human user not set",
sessionID: "session-1",
instanceID: "instance-1",
challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: 10 * time.Minute,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
smsProvider: func(ctx context.Context, instanceID string) (string, error) {
return "", nil
},
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
getSessionSucceededExpectation(repo)
return repo
},
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
repo := domainmock.NewUserRepo(ctrl)
repo.EXPECT().
Get(gomock.Any(), gomock.Any(),
dbmock.QueryOptions(
database.WithCondition(
repo.PrimaryKeyCondition("instance-1", "user-1"),
),
)).
Times(1).
Return(&domain.User{
InstanceID: "instance-1",
OrganizationID: "org-id",
ID: "123",
Username: "username",
Human: nil,
}, nil)
return repo
},
wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-7hG2w", "Errors.NotFound.User.Human.Phone"),
},
{
name: "phone not set",
sessionID: "session-1",
instanceID: "instance-1",
challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: 10 * time.Minute,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
smsProvider: func(ctx context.Context, instanceID string) (string, error) {
return "", nil
},
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
getSessionSucceededExpectation(repo)
return repo
},
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
repo := domainmock.NewUserRepo(ctrl)
repo.EXPECT().
Get(gomock.Any(), gomock.Any(),
dbmock.QueryOptions(
database.WithCondition(
repo.PrimaryKeyCondition("instance-1", "user-1"),
),
)).
Times(1).
Return(&domain.User{
InstanceID: "instance-1",
OrganizationID: "org-id",
ID: "123",
Username: "username",
Human: &domain.HumanUser{
FirstName: "first",
LastName: "last",
},
}, nil)
return repo
},
wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-7hG2w", "Errors.NotFound.User.Human.Phone"),
},
{
name: "phone otp not enabled",
sessionID: "session-1",
instanceID: "instance-1",
challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: 10 * time.Minute,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
smsProvider: func(ctx context.Context, instanceID string) (string, error) {
return "", nil
},
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
getSessionSucceededExpectation(repo)
return repo
},
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
repo := domainmock.NewUserRepo(ctrl)
repo.EXPECT().
Get(gomock.Any(), gomock.Any(),
dbmock.QueryOptions(
database.WithCondition(
repo.PrimaryKeyCondition("instance-1", "user-1"),
),
)).
Times(1).
Return(&domain.User{
InstanceID: "instance-1",
OrganizationID: "org-id",
ID: "123",
Username: "username",
Human: &domain.HumanUser{
FirstName: "first",
LastName: "last",
Phone: &domain.HumanPhone{
Number: "09080706050",
},
},
}, nil)
return repo
},
wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-9kL4m", "Errors.User.MFA.OTP.NotReady"),
},
{
name: "valid OTP SMS challenge request",
sessionID: "session-1",
instanceID: "instance-1",
challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: 10 * time.Minute,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
smsProvider: func(ctx context.Context, instanceID string) (string, error) {
return "", nil
},
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
getSessionSucceededExpectation(repo)
return repo
},
userRepo: getUser(otpEnabledAt),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := authz.NewMockContext("instance-1", "", "")
ctrl := gomock.NewController(t)
cmd := domain.NewOTPSMSChallengeCommand(
tt.challengeTypeOTPSMS,
tt.sessionID,
tt.instanceID,
tt.secretGeneratorConfig,
tt.otpAlgorithm,
tt.smsProvider,
nil,
)
opts := &domain.InvokeOpts{
Invoker: domain.NewTransactionInvoker(nil),
}
if tt.userRepo != nil {
domain.WithUserRepo(tt.userRepo(ctrl))(opts)
}
if tt.sessionRepo != nil {
domain.WithSessionRepo(tt.sessionRepo(ctrl))(opts)
}
err := cmd.Validate(ctx, opts)
assert.ErrorIs(t, err, tt.wantErr)
})
}
}
func TestOTPSMSChallengeCommand_Execute(t *testing.T) {
t.Parallel()
challengedAt := time.Now()
otpEnabledAt := time.Now().Add(-30 * time.Minute)
smsProviderErr := errors.New("failed to get active sms provider")
codeErr := errors.New("failed to create code")
defaultExpiry := 10 * time.Minute
expiry := 30 * time.Minute
tests := []struct {
name string
challengeTypeOTPSMS *domain.ChallengeTypeOTPSMS
sessionRepo func(ctrl *gomock.Controller) domain.SessionRepository
userRepo func(ctrl *gomock.Controller) domain.UserRepository
secretGeneratorSettingsRepo func(ctrl *gomock.Controller) domain.SecretGeneratorSettingsRepository
secretGeneratorConfig *crypto.GeneratorConfig
otpAlgorithm crypto.EncryptionAlgorithm
smsProviderFn func(ctx context.Context, instanceID string) (string, error)
newPhoneCodeFn func(g crypto.Generator) (*crypto.CryptoValue, string, error)
wantErr error
wantOTPSMSChallenge *string
}{
{
name: "no otp sms challenge request",
challengeTypeOTPSMS: nil,
wantErr: nil,
},
{
name: "failed to get active sms provider",
challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{
ReturnCode: false,
},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: defaultExpiry,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
getSessionSucceededExpectation(repo)
return repo
},
userRepo: getUser(otpEnabledAt),
smsProviderFn: func(ctx context.Context, instanceID string) (string, error) {
return "", smsProviderErr
},
wantErr: smsProviderErr,
},
{
name: "failed to retrieve secret generator config from settings DB",
challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{
ReturnCode: false,
},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: defaultExpiry,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
smsProviderFn: func(ctx context.Context, instanceID string) (string, error) {
return "", nil // no external sms provider
},
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
getSessionSucceededExpectation(repo)
return repo
},
userRepo: getUser(otpEnabledAt),
secretGeneratorSettingsRepo: func(ctrl *gomock.Controller) domain.SecretGeneratorSettingsRepository {
repo := domainmock.NewSecretGeneratorSettingsRepo(ctrl)
repo.EXPECT().
Get(gomock.Any(), gomock.Any(),
dbmock.QueryOptions(
database.WithCondition(
repo.UniqueCondition("instance-1", nil, domain.SettingTypeSecretGenerator, domain.SettingStateActive),
),
),
).Times(1).
Return(nil, assert.AnError)
return repo
},
wantErr: zerrors.ThrowInternal(assert.AnError, "DOM-x7Yd3E", "failed fetching SecretGeneratorSettings"),
},
{
name: "failed to generate code",
challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{},
smsProviderFn: func(ctx context.Context, instanceID string) (string, error) {
return "", nil // no external sms provider
},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: defaultExpiry,
},
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
getSessionSucceededExpectation(repo)
return repo
},
userRepo: getUser(otpEnabledAt),
secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeSMS),
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
newPhoneCodeFn: func(g crypto.Generator) (*crypto.CryptoValue, string, error) {
return nil, "", codeErr
},
wantErr: codeErr,
},
{
name: "update successful - external sms provider",
challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{},
smsProviderFn: func(ctx context.Context, instanceID string) (string, error) {
return "external-sms-provider-id", nil // with an external sms provider
},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: defaultExpiry,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
userRepo: getUser(otpEnabledAt),
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
getSessionSucceededExpectation(repo)
challengeOTPSMSChange := repo.SetChallenge(&domain.SessionChallengeOTPSMS{
Code: nil,
Expiry: 0,
CodeReturned: false,
GeneratorID: "external-sms-provider-id",
LastChallengedAt: challengedAt,
})
updateSessionSucceededExpectation(repo, challengeOTPSMSChange)
return repo
},
},
{
name: "update successful - external sms provider with return code true",
challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{
ReturnCode: true,
},
smsProviderFn: func(ctx context.Context, instanceID string) (string, error) {
return "external-sms-provider-id", nil // with an external sms provider
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: defaultExpiry,
},
userRepo: getUser(otpEnabledAt),
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
getSessionSucceededExpectation(repo)
challengeOTPSMSChange := repo.SetChallenge(&domain.SessionChallengeOTPSMS{
Code: nil,
Expiry: 0,
CodeReturned: true,
GeneratorID: "external-sms-provider-id",
LastChallengedAt: challengedAt,
})
updateSessionSucceededExpectation(repo, challengeOTPSMSChange)
return repo
},
wantOTPSMSChallenge: gu.Ptr(""), // no code is generated for external sms provider
},
{
name: "update successful - internal sms provider",
challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{
ReturnCode: true,
},
smsProviderFn: func(ctx context.Context, instanceID string) (string, error) {
return "", nil
},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: defaultExpiry,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeSMS),
newPhoneCodeFn: func(g crypto.Generator) (*crypto.CryptoValue, string, error) {
return &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
}, "code", nil
},
userRepo: getUser(otpEnabledAt),
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
getSessionSucceededExpectation(repo)
challengeOTPSMSChange := repo.SetChallenge(&domain.SessionChallengeOTPSMS{
Code: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
Expiry: expiry,
CodeReturned: true,
GeneratorID: "",
LastChallengedAt: challengedAt,
})
updateSessionSucceededExpectation(repo, challengeOTPSMSChange)
return repo
},
wantOTPSMSChallenge: gu.Ptr("code"),
},
{
name: "update successful - internal sms provider - with return code false",
challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{
ReturnCode: false,
},
smsProviderFn: func(ctx context.Context, instanceID string) (string, error) {
return "", nil
},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: defaultExpiry,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeSMS),
newPhoneCodeFn: func(g crypto.Generator) (*crypto.CryptoValue, string, error) {
return &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
}, "code", nil
},
userRepo: getUser(otpEnabledAt),
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
getSessionSucceededExpectation(repo)
challengeOTPSMSChange := repo.SetChallenge(&domain.SessionChallengeOTPSMS{
Code: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
Expiry: expiry,
CodeReturned: false,
GeneratorID: "",
LastChallengedAt: challengedAt,
})
updateSessionSucceededExpectation(repo, challengeOTPSMSChange)
return repo
},
wantOTPSMSChallenge: nil,
},
{
name: "update successful - internal sms provider - with default config",
challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{
ReturnCode: true,
},
smsProviderFn: func(ctx context.Context, instanceID string) (string, error) {
return "", nil
},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: defaultExpiry,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStatePreview, domain.OTPTypeSMS),
newPhoneCodeFn: func(g crypto.Generator) (*crypto.CryptoValue, string, error) {
return &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
}, "code", nil
},
userRepo: getUser(otpEnabledAt),
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
getSessionSucceededExpectation(repo)
challengeOTPSMSChange := repo.SetChallenge(&domain.SessionChallengeOTPSMS{
Code: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
Expiry: defaultExpiry,
CodeReturned: true,
GeneratorID: "",
LastChallengedAt: challengedAt,
})
updateSessionSucceededExpectation(repo, challengeOTPSMSChange)
return repo
},
wantOTPSMSChallenge: gu.Ptr("code"),
},
{
name: "update successful - internal sms provider - missing SMS OTP config - with default config",
challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{
ReturnCode: true,
},
smsProviderFn: func(ctx context.Context, instanceID string) (string, error) {
return "", nil
},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: defaultExpiry,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, 2),
newPhoneCodeFn: func(g crypto.Generator) (*crypto.CryptoValue, string, error) {
return &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
}, "code", nil
},
userRepo: getUser(otpEnabledAt),
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
getSessionSucceededExpectation(repo)
challengeOTPSMSChange := repo.SetChallenge(&domain.SessionChallengeOTPSMS{
Code: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
Expiry: defaultExpiry,
CodeReturned: true,
GeneratorID: "",
LastChallengedAt: challengedAt,
})
updateSessionSucceededExpectation(repo, challengeOTPSMSChange)
return repo
},
wantOTPSMSChallenge: gu.Ptr("code"),
},
{
name: "failed to update session",
challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{
ReturnCode: true,
},
smsProviderFn: func(ctx context.Context, instanceID string) (string, error) {
return "", nil
},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: defaultExpiry,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeSMS),
newPhoneCodeFn: func(g crypto.Generator) (*crypto.CryptoValue, string, error) {
return &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
}, "code", nil
},
userRepo: getUser(otpEnabledAt),
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
getSessionSucceededExpectation(repo)
challengeOTPSMSChange := repo.SetChallenge(&domain.SessionChallengeOTPSMS{
Code: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
Expiry: expiry,
CodeReturned: true,
GeneratorID: "",
LastChallengedAt: challengedAt,
})
updateSessionFailedExpectation(repo, challengeOTPSMSChange, assert.AnError, 0)
return repo
},
wantErr: zerrors.ThrowInternal(assert.AnError, "DOM-AigB0Z", "failed updating Session"),
},
{
name: "failed to update session - no rows updated",
challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{
ReturnCode: true,
},
smsProviderFn: func(ctx context.Context, instanceID string) (string, error) {
return "", nil
},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: defaultExpiry,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeSMS),
newPhoneCodeFn: func(g crypto.Generator) (*crypto.CryptoValue, string, error) {
return &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
}, "code", nil
},
userRepo: getUser(otpEnabledAt),
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
getSessionSucceededExpectation(repo)
challengeOTPSMSChange := repo.SetChallenge(&domain.SessionChallengeOTPSMS{
Code: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
Expiry: expiry,
CodeReturned: true,
GeneratorID: "",
LastChallengedAt: challengedAt,
})
updateSessionFailedExpectation(repo, challengeOTPSMSChange, nil, 0)
return repo
},
wantErr: zerrors.ThrowNotFound(nil, "DOM-AigB0Z", "Session not found"),
},
{
name: "failed to update session - more than 1 row updated",
challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{
ReturnCode: true,
},
smsProviderFn: func(ctx context.Context, instanceID string) (string, error) {
return "", nil
},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: defaultExpiry,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeSMS),
newPhoneCodeFn: func(g crypto.Generator) (*crypto.CryptoValue, string, error) {
return &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
}, "code", nil
},
userRepo: getUser(otpEnabledAt),
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
getSessionSucceededExpectation(repo)
challengeOTPSMSChange := repo.SetChallenge(&domain.SessionChallengeOTPSMS{
Code: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
Expiry: expiry,
CodeReturned: true,
GeneratorID: "",
LastChallengedAt: challengedAt,
})
updateSessionFailedExpectation(repo, challengeOTPSMSChange, nil, 2)
return repo
},
wantErr: zerrors.ThrowInternal(domain.NewMultipleObjectsUpdatedError(1, 2), "DOM-AigB0Z", "unexpected number of rows updated"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := authz.NewMockContext("instance-1", "", "")
ctrl := gomock.NewController(t)
cmd := domain.NewOTPSMSChallengeCommand(tt.challengeTypeOTPSMS,
"session-1",
"instance-1",
tt.secretGeneratorConfig,
tt.otpAlgorithm,
tt.smsProviderFn,
tt.newPhoneCodeFn,
)
opts := &domain.InvokeOpts{
Invoker: domain.NewTransactionInvoker(nil),
}
if tt.sessionRepo != nil {
domain.WithSessionRepo(tt.sessionRepo(ctrl))(opts)
}
if tt.userRepo != nil {
domain.WithUserRepo(tt.userRepo(ctrl))(opts)
}
if tt.secretGeneratorSettingsRepo != nil {
domain.WithSecretGeneratorSettingsRepo(tt.secretGeneratorSettingsRepo(ctrl))(opts)
}
// to fetch/validate session and user before calling Execute
err := cmd.Validate(ctx, opts)
assert.NoError(t, err)
err = cmd.Execute(ctx, opts)
assert.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.wantOTPSMSChallenge, cmd.GetOTPSMSChallenge())
})
}
}
func TestOTPSMSChallengeCommand_Events(t *testing.T) {
t.Parallel()
challengedAt := time.Now()
otpEnabledAt := time.Now().Add(-30 * time.Minute)
defaultExpiry := 10 * time.Minute
expiry := 30 * time.Minute
code := &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
}
ctx := authz.NewMockContext("instance-1", "", "")
tests := []struct {
name string
challengeTypeOTPSMS *domain.ChallengeTypeOTPSMS
sessionRepo func(ctrl *gomock.Controller) domain.SessionRepository
userRepo func(ctrl *gomock.Controller) domain.UserRepository
secretGeneratorSettingsRepo func(ctrl *gomock.Controller) domain.SecretGeneratorSettingsRepository
secretGeneratorConfig *crypto.GeneratorConfig
otpAlgorithm crypto.EncryptionAlgorithm
smsProviderFn func(ctx context.Context, instanceID string) (string, error)
newPhoneCodeFn func(g crypto.Generator) (*crypto.CryptoValue, string, error)
wantErr error
wantEvent eventstore.Command
}{
{
name: "no otp sms challenge request",
challengeTypeOTPSMS: nil,
},
{
name: "valid otp sms challenge request - internal provider - with return code set to false",
challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{
ReturnCode: false,
},
smsProviderFn: func(ctx context.Context, instanceID string) (string, error) {
return "", nil
},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: defaultExpiry,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeSMS),
newPhoneCodeFn: func(g crypto.Generator) (*crypto.CryptoValue, string, error) {
return &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
}, "code", nil
},
userRepo: getUser(otpEnabledAt),
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
getSessionSucceededExpectation(repo)
challengeOTPSMSChange := repo.SetChallenge(&domain.SessionChallengeOTPSMS{
Code: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
Expiry: expiry,
CodeReturned: false,
GeneratorID: "",
LastChallengedAt: challengedAt,
})
updateSessionSucceededExpectation(repo, challengeOTPSMSChange)
return repo
},
wantEvent: session.NewOTPSMSChallengedEvent(ctx,
&session.NewAggregate("session-1", "instance-1").Aggregate,
code,
expiry,
false,
"",
),
},
{
name: "valid otp sms challenge request - internal provider - with return code set to true",
challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{
ReturnCode: true,
},
smsProviderFn: func(ctx context.Context, instanceID string) (string, error) {
return "", nil
},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: defaultExpiry,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeSMS),
newPhoneCodeFn: func(g crypto.Generator) (*crypto.CryptoValue, string, error) {
return &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
}, "code", nil
},
userRepo: getUser(otpEnabledAt),
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
getSessionSucceededExpectation(repo)
challengeOTPSMSChange := repo.SetChallenge(&domain.SessionChallengeOTPSMS{
Code: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
Expiry: expiry,
CodeReturned: true,
GeneratorID: "",
LastChallengedAt: challengedAt,
})
updateSessionSucceededExpectation(repo, challengeOTPSMSChange)
return repo
},
wantEvent: session.NewOTPSMSChallengedEvent(ctx,
&session.NewAggregate("session-1", "instance-1").Aggregate,
code,
expiry,
true,
"",
),
},
{
name: "update successful - external sms provider - return code false",
challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: defaultExpiry,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
smsProviderFn: func(ctx context.Context, instanceID string) (string, error) {
return "external-sms-provider-id", nil // with an external sms provider
},
userRepo: getUser(otpEnabledAt),
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
getSessionSucceededExpectation(repo)
challengeOTPSMSChange := repo.SetChallenge(&domain.SessionChallengeOTPSMS{
Code: nil,
Expiry: 0,
CodeReturned: false,
GeneratorID: "external-sms-provider-id",
LastChallengedAt: challengedAt,
})
updateSessionSucceededExpectation(repo, challengeOTPSMSChange)
return repo
},
wantEvent: session.NewOTPSMSChallengedEvent(ctx,
&session.NewAggregate("session-1", "instance-1").Aggregate,
nil,
0,
false,
"external-sms-provider-id",
),
},
{
name: "update successful - external sms provider - return code true",
challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{
ReturnCode: true,
},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: defaultExpiry,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
smsProviderFn: func(ctx context.Context, instanceID string) (string, error) {
return "external-sms-provider-id", nil // with an external sms provider
},
userRepo: getUser(otpEnabledAt),
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
getSessionSucceededExpectation(repo)
challengeOTPSMSChange := repo.SetChallenge(&domain.SessionChallengeOTPSMS{
Code: nil,
Expiry: 0,
CodeReturned: true,
GeneratorID: "external-sms-provider-id",
LastChallengedAt: challengedAt,
})
updateSessionSucceededExpectation(repo, challengeOTPSMSChange)
return repo
},
wantEvent: session.NewOTPSMSChallengedEvent(ctx,
&session.NewAggregate("session-1", "instance-1").Aggregate,
nil,
0,
true,
"external-sms-provider-id",
),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := authz.NewMockContext("instance-1", "", "")
ctrl := gomock.NewController(t)
cmd := domain.NewOTPSMSChallengeCommand(tt.challengeTypeOTPSMS,
"session-1",
"instance-1",
tt.secretGeneratorConfig,
tt.otpAlgorithm,
tt.smsProviderFn,
tt.newPhoneCodeFn,
)
opts := &domain.InvokeOpts{
Invoker: domain.NewTransactionInvoker(nil),
}
if tt.sessionRepo != nil {
domain.WithSessionRepo(tt.sessionRepo(ctrl))(opts)
}
if tt.userRepo != nil {
domain.WithUserRepo(tt.userRepo(ctrl))(opts)
}
if tt.secretGeneratorSettingsRepo != nil {
domain.WithSecretGeneratorSettingsRepo(tt.secretGeneratorSettingsRepo(ctrl))(opts)
}
// to fetch/validate session and user before calling Execute
err := cmd.Validate(ctx, opts)
assert.NoError(t, err)
// to set the challengeOTPSMS in the command for event generation
err = cmd.Execute(ctx, opts)
assert.NoError(t, err)
events, err := cmd.Events(ctx, opts)
assert.ErrorIs(t, err, tt.wantErr)
if tt.wantEvent != nil {
require.Len(t, events, 1)
assert.Equal(t, tt.wantEvent, events[0])
}
})
}
}
func secretGeneratorSettingsRepo(state domain.SettingState, otpType domain.OTPType) func(ctrl *gomock.Controller) domain.SecretGeneratorSettingsRepository {
var attrs domain.SecretGeneratorSettingsAttributes
switch otpType {
case domain.OTPTypeEmail:
attrs = domain.SecretGeneratorSettingsAttributes{
OTPEmail: &domain.OTPEmailAttributes{
SecretGeneratorAttrsWithExpiry: domain.SecretGeneratorAttrsWithExpiry{
Expiry: gu.Ptr(30 * time.Minute),
SecretGeneratorAttrs: domain.SecretGeneratorAttrs{
Length: gu.Ptr(uint(8)),
IncludeLowerLetters: gu.Ptr(true),
IncludeUpperLetters: gu.Ptr(false),
IncludeDigits: gu.Ptr(true),
IncludeSymbols: gu.Ptr(false),
},
},
},
}
case domain.OTPTypeSMS:
attrs = domain.SecretGeneratorSettingsAttributes{
OTPSMS: &domain.OTPSMSAttributes{
SecretGeneratorAttrsWithExpiry: domain.SecretGeneratorAttrsWithExpiry{
Expiry: gu.Ptr(30 * time.Minute),
SecretGeneratorAttrs: domain.SecretGeneratorAttrs{
Length: gu.Ptr(uint(6)),
IncludeLowerLetters: gu.Ptr(true),
IncludeUpperLetters: gu.Ptr(false),
IncludeDigits: gu.Ptr(true),
IncludeSymbols: gu.Ptr(false),
},
},
},
}
}
return func(ctrl *gomock.Controller) domain.SecretGeneratorSettingsRepository {
repo := domainmock.NewSecretGeneratorSettingsRepo(ctrl)
repo.EXPECT().
Get(gomock.Any(), gomock.Any(),
dbmock.QueryOptions(
database.WithCondition(
repo.UniqueCondition("instance-1", nil, domain.SettingTypeSecretGenerator, domain.SettingStateActive),
),
),
).Times(1).
Return(&domain.SecretGeneratorSettings{
Settings: domain.Settings{
State: state,
},
SecretGeneratorSettingsAttributes: attrs,
}, nil)
return repo
}
}
func getUser(otpEnabledAt time.Time) func(ctrl *gomock.Controller) domain.UserRepository {
return func(ctrl *gomock.Controller) domain.UserRepository {
repo := domainmock.NewUserRepo(ctrl)
repo.EXPECT().
Get(gomock.Any(), gomock.Any(),
dbmock.QueryOptions(
database.WithCondition(
repo.PrimaryKeyCondition("instance-1", "user-1"),
),
)).
Times(1).
Return(&domain.User{
InstanceID: "instance-1",
OrganizationID: "org-id",
ID: "123",
Username: "username",
Human: &domain.HumanUser{
FirstName: "first",
LastName: "last",
Phone: &domain.HumanPhone{
Number: "09080706050",
OTP: domain.OTP{
EnabledAt: otpEnabledAt,
},
},
Email: domain.HumanEmail{
Address: "testuser@example.com",
OTP: domain.OTP{
EnabledAt: otpEnabledAt,
},
},
},
}, nil)
return repo
}
}