Files
zitadel/backend/v3/domain/session_challenge_otp_email_test.go
T
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. <[email protected]>
2026-03-31 11:50:53 +00:00

1114 lines
37 KiB
Go

package domain_test
import (
"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 TestOTPEmailChallengeCommand_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
challengeTypeOTPEmail *domain.ChallengeTypeOTPEmail
userRepo func(ctrl *gomock.Controller) domain.UserRepository
sessionRepo func(ctrl *gomock.Controller) domain.SessionRepository
wantErr error
}{
{
name: "no request otp email challenge",
challengeTypeOTPEmail: nil,
wantErr: nil,
},
{
name: "no session id",
sessionID: "",
instanceID: "instance-1",
challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{},
wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-BQ5UgK", "Errors.Missing.SessionID"),
},
{
name: "no instance id",
sessionID: "session-1",
instanceID: "",
challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{},
wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-kDnkDn", "Errors.Missing.InstanceID"),
},
{
name: "no default secret generator config",
sessionID: "session-1",
instanceID: "instance-1",
challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{},
wantErr: zerrors.ThrowInternal(nil, "DOM-nnB9MS", "missing default secret generator config"),
},
{
name: "no otp algorithm",
sessionID: "session-1",
instanceID: "instance-1",
challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: 10 * time.Minute,
},
wantErr: zerrors.ThrowInternal(nil, "DOM-kuG75Q", "missing MFA encryption algorithm"),
},
{
name: "failed to render url template",
sessionID: "session-1",
instanceID: "instance-1",
challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{
DeliveryType: domain.DeliveryType{
SendCode: &domain.SendCode{
URLTemplate: "http://{{.Invalid",
},
},
},
secretGeneratorConfig: &crypto.GeneratorConfig{},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
wantErr: zerrors.ThrowInvalidArgument(nil, "DOM-wkDwQM", "Errors.Invalid.URLTemplate"),
},
{
name: "failed to get session",
sessionID: "session-1",
instanceID: "instance-1",
challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{
DeliveryType: domain.DeliveryType{
SendCode: &domain.SendCode{
URLTemplate: "https://example.com",
},
},
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: 10 * time.Minute,
},
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-JArUai", "failed fetching Session"),
},
{
name: "session not found",
sessionID: "session-1",
instanceID: "instance-1",
challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: 10 * time.Minute,
},
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-JArUai", "Session not found"),
},
{
name: "missing user id in session",
sessionID: "session-1",
instanceID: "instance-1",
challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: 10 * time.Minute,
},
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-wG2XoJ", "Errors.Missing.Session.UserID"),
},
{
name: "failed to get user",
sessionID: "session-1",
instanceID: "instance-1",
challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: 10 * time.Minute,
},
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-56MWkg", "failed fetching User"),
},
{
name: "user not found",
sessionID: "session-1",
instanceID: "instance-1",
challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: 10 * time.Minute,
},
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-56MWkg", "User not found"),
},
{
name: "human user not set",
sessionID: "session-1",
instanceID: "instance-1",
challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: 10 * time.Minute,
},
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-7hG2d", "Errors.NotFound.User.Human.Email"),
},
{
name: "email not set",
sessionID: "session-1",
instanceID: "instance-1",
challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: 10 * time.Minute,
},
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-7hG2d", "Errors.NotFound.User.Human.Email"),
},
{
name: "email address not set",
sessionID: "session-1",
instanceID: "instance-1",
challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: 10 * time.Minute,
},
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",
},
Email: domain.HumanEmail{
Address: "",
},
},
}, nil)
return repo
},
wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-7hG2d", "Errors.NotFound.User.Human.Email"),
},
{
name: "email otp not enabled",
sessionID: "session-1",
instanceID: "instance-1",
challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: 10 * time.Minute,
},
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",
},
Email: domain.HumanEmail{
Address: "[email protected]",
OTP: domain.OTP{},
},
},
}, nil)
return repo
},
wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-9kL4q", "Errors.User.MFA.OTP.NotReady"),
},
{
name: "valid OTP Email challenge request",
sessionID: "session-1",
instanceID: "instance-1",
challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: 10 * time.Minute,
},
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",
Email: domain.HumanEmail{
Address: "[email protected]",
OTP: domain.OTP{
EnabledAt: otpEnabledAt,
},
},
},
}, nil)
return repo
},
},
}
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.NewOTPEmailChallengeCommand(
tt.challengeTypeOTPEmail,
tt.sessionID,
tt.instanceID,
tt.secretGeneratorConfig,
tt.otpAlgorithm,
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 TestOTPEmailChallengeCommand_Execute(t *testing.T) {
t.Parallel()
codeErr := errors.New("failed to create code")
challengedAt := time.Now()
defaultExpiry := 10 * time.Minute
expiry := 30 * time.Minute
otpEnabledAt := time.Now().Add(-30 * time.Minute)
tests := []struct {
name string
challengeTypeOTPEmail *domain.ChallengeTypeOTPEmail
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
newEmailCodeFn func(g crypto.Generator) (*crypto.CryptoValue, string, error)
wantErr error
wantOTPEmailChallenge *string
}{
{
name: "no otp email challenge request",
challengeTypeOTPEmail: nil,
wantErr: nil,
},
{
name: "failed to retrieve secret generator config from settings DB",
challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{},
secretGeneratorConfig: &crypto.GeneratorConfig{},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
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",
challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{},
secretGeneratorConfig: &crypto.GeneratorConfig{},
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
getSessionSucceededExpectation(repo)
return repo
},
userRepo: getUser(otpEnabledAt),
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeEmail),
newEmailCodeFn: func(g crypto.Generator) (*crypto.CryptoValue, string, error) {
return nil, "", codeErr
},
wantErr: codeErr,
},
{
name: "failed to update session",
challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{
DeliveryType: domain.DeliveryType{
ReturnCode: true,
},
},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: expiry,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeEmail),
newEmailCodeFn: func(g crypto.Generator) (*crypto.CryptoValue, string, error) {
return &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
}, "", nil
},
userRepo: getUser(otpEnabledAt),
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
getSessionSucceededExpectation(repo)
challengeOTPEmail := repo.SetChallenge(&domain.SessionChallengeOTPEmail{
Code: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
Expiry: expiry,
CodeReturned: true,
LastChallengedAt: challengedAt,
})
updateSessionFailedExpectation(repo, challengeOTPEmail, assert.AnError, 0)
return repo
},
wantErr: zerrors.ThrowInternal(assert.AnError, "DOM-YfQIA3", "failed updating Session"),
},
{
name: "failed to update session - no rows updated",
challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{
DeliveryType: domain.DeliveryType{
SendCode: &domain.SendCode{
URLTemplate: "http://example.com",
},
},
},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: defaultExpiry,
Length: uint(6),
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeEmail),
newEmailCodeFn: func(g crypto.Generator) (*crypto.CryptoValue, string, error) {
return &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
}, "", nil
},
userRepo: getUser(otpEnabledAt),
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
getSessionSucceededExpectation(repo)
challengeOTPEmail := repo.SetChallenge(&domain.SessionChallengeOTPEmail{
Code: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
Expiry: expiry,
URLTemplate: "http://example.com",
CodeReturned: false,
LastChallengedAt: challengedAt,
})
updateSessionFailedExpectation(repo, challengeOTPEmail, nil, 0)
return repo
},
wantErr: zerrors.ThrowNotFound(nil, "DOM-YfQIA3", "Session not found"),
},
{
name: "failed to update session - more than 1 row updated",
challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{
DeliveryType: domain.DeliveryType{
ReturnCode: true,
},
},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: defaultExpiry,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeEmail),
newEmailCodeFn: func(g crypto.Generator) (*crypto.CryptoValue, string, error) {
return &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
}, "", nil
},
userRepo: getUser(otpEnabledAt),
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
getSessionSucceededExpectation(repo)
challengeOTPEmail := repo.SetChallenge(&domain.SessionChallengeOTPEmail{
Code: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
Expiry: expiry,
CodeReturned: true,
LastChallengedAt: challengedAt,
})
repo.EXPECT().
Update(
gomock.Any(),
gomock.Any(),
repo.PrimaryKeyCondition("instance-1", "session-1"),
challengeOTPEmail,
).
Times(1).
Return(int64(2), nil)
return repo
},
wantErr: zerrors.ThrowInternal(nil, "DOM-YfQIA3", "unexpected number of rows updated"),
},
{
name: "update session succeeded - delivery type return code",
challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{
DeliveryType: domain.DeliveryType{
ReturnCode: true,
},
},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: defaultExpiry,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeEmail),
newEmailCodeFn: 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)
challengeOTPEmail := repo.SetChallenge(&domain.SessionChallengeOTPEmail{
Code: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
Expiry: expiry,
CodeReturned: true,
LastChallengedAt: challengedAt,
})
updateSessionSucceededExpectation(repo, challengeOTPEmail)
return repo
},
wantOTPEmailChallenge: gu.Ptr("code"), // OTPEmailChallenge is only set when the delivery type is return code
},
{
name: "update session succeeded - delivery type send code",
challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{
DeliveryType: domain.DeliveryType{
SendCode: &domain.SendCode{
URLTemplate: "http://example.com",
},
},
},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: defaultExpiry,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeEmail),
newEmailCodeFn: 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)
challengeOTPEmail := repo.SetChallenge(&domain.SessionChallengeOTPEmail{
Code: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
Expiry: expiry,
CodeReturned: false,
URLTemplate: "http://example.com",
LastChallengedAt: challengedAt,
})
updateSessionSucceededExpectation(repo, challengeOTPEmail)
return repo
},
},
{
name: "update session succeeded - delivery type default",
challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: defaultExpiry,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeEmail),
newEmailCodeFn: 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)
challengeOTPEmail := repo.SetChallenge(&domain.SessionChallengeOTPEmail{
Code: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
Expiry: expiry,
CodeReturned: false,
URLTemplate: "",
LastChallengedAt: challengedAt,
})
updateSessionSucceededExpectation(repo, challengeOTPEmail)
return repo
},
},
{
name: "update session succeeded - with default config when setting state is not active",
challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: defaultExpiry,
Length: uint(8),
IncludeLowerLetters: false,
IncludeUpperLetters: true,
IncludeDigits: true,
IncludeSymbols: false,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStatePreview, domain.OTPTypeEmail),
newEmailCodeFn: 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)
challengeOTPEmail := repo.SetChallenge(&domain.SessionChallengeOTPEmail{
Code: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
Expiry: defaultExpiry,
CodeReturned: false,
URLTemplate: "",
LastChallengedAt: challengedAt,
})
updateSessionSucceededExpectation(repo, challengeOTPEmail)
return repo
},
},
{
name: "update session succeeded - with default config when OTP email settings not found",
challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: defaultExpiry,
Length: uint(8),
IncludeLowerLetters: false,
IncludeUpperLetters: true,
IncludeDigits: true,
IncludeSymbols: false,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, 2),
newEmailCodeFn: 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)
challengeOTPEmail := repo.SetChallenge(&domain.SessionChallengeOTPEmail{
Code: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
Expiry: defaultExpiry,
CodeReturned: false,
URLTemplate: "",
LastChallengedAt: challengedAt,
})
updateSessionSucceededExpectation(repo, challengeOTPEmail)
return repo
},
},
}
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.NewOTPEmailChallengeCommand(
tt.challengeTypeOTPEmail,
"session-1",
"instance-1",
tt.secretGeneratorConfig,
tt.otpAlgorithm,
tt.newEmailCodeFn,
)
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)
require.NoError(t, err)
err = cmd.Execute(ctx, opts)
assert.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.wantOTPEmailChallenge, cmd.GetOTPEmailChallenge())
})
}
}
func TestOTPEmailChallengeCommand_Events(t *testing.T) {
t.Parallel()
challengedAt := time.Now()
expiry := 30 * time.Minute
defaultExpiry := 10 * time.Minute
otpEnabledAt := time.Now().Add(-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
challengeTypeOTPEmail *domain.ChallengeTypeOTPEmail
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
newEmailCodeFn func(g crypto.Generator) (*crypto.CryptoValue, string, error)
wantErr error
wantEvent eventstore.Command
}{
{
name: "no request otp email challenge",
challengeTypeOTPEmail: nil,
},
{
name: "valid OTP email challenge request - no delivery type",
challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: defaultExpiry,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeEmail),
newEmailCodeFn: 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)
challengeOTPEmail := repo.SetChallenge(&domain.SessionChallengeOTPEmail{
Code: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
Expiry: expiry,
CodeReturned: false,
URLTemplate: "",
LastChallengedAt: challengedAt,
})
updateSessionSucceededExpectation(repo, challengeOTPEmail)
return repo
},
wantEvent: session.NewOTPEmailChallengedEvent(
ctx,
&session.NewAggregate("session-1", "instance-1").Aggregate,
code,
expiry,
false,
"",
),
},
{
name: "valid OTP email challenge request - delivery type send code",
challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{
DeliveryType: domain.DeliveryType{
SendCode: &domain.SendCode{
URLTemplate: "https://example.com",
},
},
},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: defaultExpiry,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeEmail),
newEmailCodeFn: 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)
challengeOTPEmail := repo.SetChallenge(&domain.SessionChallengeOTPEmail{
Code: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
Expiry: expiry,
CodeReturned: false,
URLTemplate: "https://example.com",
LastChallengedAt: challengedAt,
})
updateSessionSucceededExpectation(repo, challengeOTPEmail)
return repo
},
wantEvent: session.NewOTPEmailChallengedEvent(
ctx,
&session.NewAggregate("session-1", "instance-1").Aggregate,
code,
expiry,
false,
"https://example.com",
),
},
{
name: "valid OTP email challenge request - delivery type return code",
challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{
DeliveryType: domain.DeliveryType{
ReturnCode: true,
},
},
secretGeneratorConfig: &crypto.GeneratorConfig{
Expiry: defaultExpiry,
},
otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeEmail),
newEmailCodeFn: 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)
challengeOTPEmail := repo.SetChallenge(&domain.SessionChallengeOTPEmail{
Code: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
Expiry: expiry,
CodeReturned: true,
URLTemplate: "",
LastChallengedAt: challengedAt,
})
updateSessionSucceededExpectation(repo, challengeOTPEmail)
return repo
},
wantEvent: session.NewOTPEmailChallengedEvent(
ctx,
&session.NewAggregate("session-1", "instance-1").Aggregate,
code,
expiry,
true,
"",
),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cmd := domain.NewOTPEmailChallengeCommand(
tt.challengeTypeOTPEmail,
"session-1",
"instance-1",
tt.secretGeneratorConfig,
tt.otpAlgorithm,
tt.newEmailCodeFn,
)
ctrl := gomock.NewController(t)
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)
require.NoError(t, err)
// to set the challengeOTPSMS in the command for event generation
err = cmd.Execute(ctx, opts)
require.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])
}
})
}
}