Files
zitadel/backend/v3/domain/session_check_password_test.go
T
Marco A.andGitHub 8d1a099deb fix(domain): Fix session checks failing tests (#11881)
# Which Problems Are Solved

2 tests were failing on the new session checks:

  - Password check (events) fails due to a race condition
- IDP intent check (events) (also password test) fails due to a flaky
time comparison

# How the Problems Are Solved

- Race condition: remove setting the password hasher in the test. It's
already globally set in `domain_test.go`
- Time comparison: use `assert.WithinRange` instead of `assert.InDelta`
and use 2 second range to make sure you don't get resolution issues

# Additional Context

- Closes #11878
2026-03-20 06:57:36 +01:00

1213 lines
42 KiB
Go

package domain_test
import (
"errors"
"testing"
"time"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/passwap"
"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"
noopdb "github.com/zitadel/zitadel/backend/v3/storage/database/dialect/noop"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/session"
"github.com/zitadel/zitadel/internal/repository/user"
"github.com/zitadel/zitadel/internal/zerrors"
)
func TestPasswordCheckCommand_Validate(t *testing.T) {
t.Parallel()
sessionGetErr := errors.New("session get error")
userGetErr := errors.New("user get error")
notFoundErr := database.NewNoRowFoundError(nil)
tt := []struct {
testName string
sessionRepo func(ctrl *gomock.Controller) domain.SessionRepository
userRepo func(ctrl *gomock.Controller) domain.UserRepository
cmd *domain.PasswordCheckCommand
expectedError error
expectedUser domain.User
}{
{
testName: "when checkPassword is nil should return no error",
cmd: &domain.PasswordCheckCommand{},
expectedError: nil,
},
{
testName: "when session ID is not set should return error",
cmd: &domain.PasswordCheckCommand{CheckPassword: &domain.CheckPasswordType{Password: "test-password"}},
expectedError: zerrors.ThrowPreconditionFailed(nil, "DOM-cRKWNx", "Errors.Missing.SessionID"),
},
{
testName: "when instance ID is not set should return error",
cmd: domain.NewPasswordCheckCommand("session-1", "", nil, nil, &domain.CheckPasswordType{Password: "test-password"}),
expectedError: zerrors.ThrowPreconditionFailed(nil, "DOM-JnEtcJ", "Errors.Missing.InstanceID"),
},
{
testName: "when retrieving session fails should return error",
cmd: domain.NewPasswordCheckCommand("session-1", "instance-1", nil, nil, &domain.CheckPasswordType{Password: "test-password"}),
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
idCondition := repo.IDCondition("session-1")
repo.EXPECT().
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(
database.WithCondition(
idCondition,
),
)).
Times(1).
Return(nil, sessionGetErr)
return repo
},
expectedError: zerrors.ThrowInternal(sessionGetErr, "DOM-qAoQrg", "failed fetching session"),
},
{
testName: "when session not found should return not found error",
cmd: domain.NewPasswordCheckCommand("session-1", "instance-1", nil, nil, &domain.CheckPasswordType{Password: "test-password"}),
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
idCondition := repo.IDCondition("session-1")
repo.EXPECT().
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(
database.WithCondition(
idCondition,
),
)).
Times(1).
Return(nil, notFoundErr)
return repo
},
expectedError: zerrors.ThrowNotFound(notFoundErr, "DOM-0XRmp8", "session not found"),
},
{
testName: "when session userID is empty should return precondition failed error",
cmd: domain.NewPasswordCheckCommand("session-1", "instance-1", nil, nil, &domain.CheckPasswordType{Password: "test-password"}),
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
idCondition := repo.IDCondition("session-1")
repo.EXPECT().
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(
database.WithCondition(
idCondition,
),
)).
Times(1).
Return(&domain.Session{
ID: "session-1",
UserID: "",
}, nil)
return repo
},
expectedError: zerrors.ThrowPreconditionFailed(nil, "DOM-hord0Z", "Errors.User.UserIDMissing"),
},
{
testName: "when retrieving user fails should return error",
cmd: domain.NewPasswordCheckCommand("session-1", "instance-1", nil, nil, &domain.CheckPasswordType{Password: "test-password"}),
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
idCondition := repo.IDCondition("session-1")
repo.EXPECT().
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(
database.WithCondition(
idCondition,
),
)).
Times(1).
Return(&domain.Session{
ID: "session-1",
UserID: "user-1",
}, nil)
return repo
},
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
repo := domainmock.NewUserRepo(ctrl)
idCondition := repo.IDCondition("user-1")
repo.EXPECT().
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(
database.WithCondition(
idCondition,
),
)).
Times(1).
Return(nil, userGetErr)
return repo
},
expectedError: zerrors.ThrowInternal(userGetErr, "DOM-nKD4Gq", "failed fetching user"),
},
{
testName: "when user not found should return not found error",
cmd: domain.NewPasswordCheckCommand("session-1", "instance-1", nil, nil, &domain.CheckPasswordType{Password: "test-password"}),
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
idCondition := repo.IDCondition("session-1")
repo.EXPECT().
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(
database.WithCondition(
idCondition,
),
)).
Times(1).
Return(&domain.Session{
ID: "session-1",
UserID: "user-1",
}, nil)
return repo
},
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
repo := domainmock.NewUserRepo(ctrl)
idCondition := repo.IDCondition("user-1")
repo.EXPECT().
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(
database.WithCondition(
idCondition,
),
)).
Times(1).
Return(nil, notFoundErr)
return repo
},
expectedError: zerrors.ThrowNotFound(notFoundErr, "DOM-zxKosn", "Errors.User.NotFound"),
},
{
testName: "when user is not human should return precondition failed error",
cmd: domain.NewPasswordCheckCommand("session-1", "instance-1", nil, nil, &domain.CheckPasswordType{Password: "test-password"}),
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
idCondition := repo.IDCondition("session-1")
repo.EXPECT().
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(
database.WithCondition(
idCondition,
),
)).
Times(1).
Return(&domain.Session{
ID: "session-1",
UserID: "user-1",
}, nil)
return repo
},
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
repo := domainmock.NewUserRepo(ctrl)
idCondition := repo.IDCondition("user-1")
repo.EXPECT().
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(
database.WithCondition(
idCondition,
),
)).
Times(1).
Return(&domain.User{
ID: "user-1",
Human: nil,
}, nil)
return repo
},
expectedError: zerrors.ThrowPreconditionFailed(nil, "DOM-ADhxAx", "user not human"),
},
{
testName: "when user is locked should return precondition failed error",
cmd: domain.NewPasswordCheckCommand("session-1", "instance-1", nil, nil, &domain.CheckPasswordType{Password: "test-password"}),
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
idCondition := repo.IDCondition("session-1")
repo.EXPECT().
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(
database.WithCondition(
idCondition,
),
)).
Times(1).
Return(&domain.Session{
ID: "session-1",
UserID: "user-1",
}, nil)
return repo
},
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
repo := domainmock.NewUserRepo(ctrl)
idCondition := repo.IDCondition("user-1")
repo.EXPECT().
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(
database.WithCondition(
idCondition,
),
)).
Times(1).
Return(&domain.User{
ID: "user-1",
State: domain.UserStateLocked,
Human: &domain.HumanUser{
Password: domain.HumanPassword{
FailedAttempts: 5,
},
},
}, nil)
return repo
},
expectedError: zerrors.ThrowPreconditionFailed(domain.NewPasswordVerificationError(5), "DOM-D804Sj", "Errors.User.Locked"),
},
{
testName: "when user password is not set should return precondition failed error",
cmd: domain.NewPasswordCheckCommand("session-1", "instance-1", nil, nil, &domain.CheckPasswordType{Password: "test-password"}),
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
idCondition := repo.IDCondition("session-1")
repo.EXPECT().
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(
database.WithCondition(
idCondition,
),
)).
Times(1).
Return(&domain.Session{
ID: "session-1",
UserID: "user-1",
}, nil)
return repo
},
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
repo := domainmock.NewUserRepo(ctrl)
idCondition := repo.IDCondition("user-1")
repo.EXPECT().
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(
database.WithCondition(
idCondition,
),
)).
Times(1).
Return(&domain.User{
ID: "user-1",
State: domain.UserStateActive,
Human: &domain.HumanUser{
Password: domain.HumanPassword{
Hash: "",
},
},
}, nil)
return repo
},
expectedError: zerrors.ThrowPreconditionFailed(nil, "DOM-gklgos", "Errors.User.Password.NotSet"),
},
{
testName: "when all validations pass should return no error and set user",
cmd: domain.NewPasswordCheckCommand("session-1", "instance-1", nil, nil, &domain.CheckPasswordType{Password: "test-password"}),
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
idCondition := repo.IDCondition("session-1")
repo.EXPECT().
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(
database.WithCondition(
idCondition,
),
)).
Times(1).
Return(&domain.Session{
ID: "session-1",
UserID: "user-1",
}, nil)
return repo
},
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
repo := domainmock.NewUserRepo(ctrl)
idCondition := repo.IDCondition("user-1")
repo.EXPECT().
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(
database.WithCondition(
idCondition,
),
)).
Times(1).
Return(&domain.User{
ID: "user-1",
State: domain.UserStateActive,
Human: &domain.HumanUser{
Password: domain.HumanPassword{
Hash: "hashed-password",
FailedAttempts: 0,
},
},
}, nil)
return repo
},
expectedUser: domain.User{
ID: "user-1",
State: domain.UserStateActive,
Human: &domain.HumanUser{
Password: domain.HumanPassword{
Hash: "hashed-password",
FailedAttempts: 0,
},
},
},
},
}
for _, tc := range tt {
t.Run(tc.testName, func(t *testing.T) {
t.Parallel()
// Given
ctx := authz.NewMockContext(tc.cmd.InstanceID, "", "")
ctrl := gomock.NewController(t)
opts := &domain.InvokeOpts{
Invoker: domain.NewTransactionInvoker(nil),
}
domain.WithQueryExecutor(new(noopdb.Pool))(opts)
if tc.sessionRepo != nil {
domain.WithSessionRepo(tc.sessionRepo(ctrl))(opts)
}
if tc.userRepo != nil {
domain.WithUserRepo(tc.userRepo(ctrl))(opts)
}
// Test
err := tc.cmd.Validate(ctx, opts)
// Verify
assert.ErrorIs(t, err, tc.expectedError)
assert.Equal(t, tc.expectedUser, tc.cmd.FetchedUser)
})
}
}
func TestPasswordCheckCommand_GetPasswordCheckAndError(t *testing.T) {
t.Parallel()
otherErr := errors.New("some other error")
tt := []struct {
testName string
inputErr error
fetchedUser domain.User
expectedCheckType domain.VerificationType
expectedError error
}{
{
testName: "when error is nil should return CheckTypeSucceeded with no error",
inputErr: nil,
fetchedUser: domain.User{},
expectedCheckType: &domain.VerificationTypeSucceeded{},
expectedError: nil,
},
{
testName: "when error is password mismatch should return CheckTypeFailed with invalid argument error",
inputErr: passwap.ErrPasswordMismatch,
fetchedUser: domain.User{
Human: &domain.HumanUser{
Password: domain.HumanPassword{
FailedAttempts: 2,
},
},
},
expectedCheckType: &domain.VerificationTypeFailed{},
expectedError: zerrors.ThrowInvalidArgument(domain.NewPasswordVerificationError(3), "DOM-3gcfDV", "Errors.User.Password.Invalid"),
},
{
testName: "when error is other error should return CheckTypeFailed with internal error",
inputErr: otherErr,
fetchedUser: domain.User{
Human: &domain.HumanUser{
Password: domain.HumanPassword{
FailedAttempts: 0,
},
},
},
expectedCheckType: &domain.VerificationTypeFailed{},
expectedError: zerrors.ThrowInternal(otherErr, "DOM-xceNzI", "Errors.Internal"),
},
}
for _, tc := range tt {
t.Run(tc.testName, func(t *testing.T) {
t.Parallel()
// Given
cmd := domain.NewPasswordCheckCommand("session-1", "instance-1", nil, nil, &domain.CheckPasswordType{Password: "test"})
cmd.FetchedUser = tc.fetchedUser
// Test
checkType, err := cmd.GetPasswordCheckAndError(tc.inputErr)
// Verify
assert.IsType(t, tc.expectedCheckType, checkType)
assert.ErrorIs(t, err, tc.expectedError)
if tc.expectedError != nil {
checkFailed := checkType.(*domain.VerificationTypeFailed)
assert.NotZero(t, checkFailed.FailedAt)
} else {
checkSuccess := checkType.(*domain.VerificationTypeSucceeded)
assert.NotZero(t, checkSuccess.VerifiedAt)
}
})
}
}
func TestPasswordCheckCommand_GetPasswordCheckChanges(t *testing.T) {
t.Parallel()
listErr := errors.New("list error")
tt := []struct {
testName string
humanRepo func(ctrl *gomock.Controller, checkTime time.Time) domain.HumanUserRepository
lockoutRepo func(ctrl *gomock.Controller) domain.LockoutSettingsRepository
updatedHash string
checkType domain.VerificationType
cmd *domain.PasswordCheckCommand
expectedChanges int
expectedError error
expectedHashedPsw string
expectedUserLocked bool
}{
{
testName: "when check type succeeded with empty hash should return only check password change",
humanRepo: func(ctrl *gomock.Controller, checkTime time.Time) domain.HumanUserRepository {
repo := domainmock.NewHumanRepo(ctrl)
return repo
},
cmd: &domain.PasswordCheckCommand{
CheckTime: time.Now(),
SessionID: "session-1",
InstanceID: "instance-1",
VerifierFn: func(_, _ string) (_ string, _ error) { return "", nil },
CheckPassword: &domain.CheckPasswordType{Password: "test"},
},
updatedHash: "",
checkType: &domain.VerificationTypeSucceeded{},
expectedChanges: 1,
},
{
testName: "when check type succeeded with hash should return check password and set password changes",
humanRepo: func(ctrl *gomock.Controller, checkTime time.Time) domain.HumanUserRepository {
repo := domainmock.NewHumanRepo(ctrl)
return repo
},
cmd: &domain.PasswordCheckCommand{
CheckTime: time.Now(),
SessionID: "session-1",
InstanceID: "instance-1",
VerifierFn: func(_, _ string) (_ string, _ error) { return "", nil },
CheckPassword: &domain.CheckPasswordType{Password: "test"},
},
updatedHash: "new-hash",
checkType: &domain.VerificationTypeSucceeded{},
expectedChanges: 2,
expectedHashedPsw: "new-hash",
},
{
testName: "when check type failed and no lockout policy should return internal error",
humanRepo: func(ctrl *gomock.Controller, checkTime time.Time) domain.HumanUserRepository {
repo := domainmock.NewHumanRepo(ctrl)
return repo
},
lockoutRepo: func(ctrl *gomock.Controller) domain.LockoutSettingsRepository {
repo := domainmock.NewLockoutSettingsRepo(ctrl)
instanceAndOrg := database.And(repo.InstanceIDCondition("instance-1"), repo.OrganizationIDCondition(gu.Ptr("org-1")))
orgNullOrEmpty := database.Or(repo.OrganizationIDCondition(nil), repo.OrganizationIDCondition(gu.Ptr("")))
onlyInstance := database.And(repo.InstanceIDCondition("instance-1"), orgNullOrEmpty)
orCondition := database.Or(instanceAndOrg, onlyInstance)
repo.EXPECT().
List(gomock.Any(), gomock.Any(),
dbmock.QueryOptions(database.WithCondition(orCondition)),
dbmock.QueryOptions(database.WithOrderByAscending(repo.OrganizationIDColumn(), repo.InstanceIDColumn())),
dbmock.QueryOptions(database.WithLimit(1)),
).Times(1).
Return(nil, nil)
return repo
},
updatedHash: "",
checkType: &domain.VerificationTypeFailed{},
cmd: &domain.PasswordCheckCommand{
CheckTime: time.Now(),
SessionID: "session-1",
InstanceID: "instance-1",
VerifierFn: func(_, _ string) (_ string, _ error) { return "", nil },
CheckPassword: &domain.CheckPasswordType{Password: "test"},
FetchedUser: domain.User{OrganizationID: "org-1"},
},
expectedError: zerrors.ThrowInternal(domain.NewRowsReturnedMismatchError(1, 0), "DOM-mmsrCt", "unexpected number of rows returned"),
},
{
testName: "when check type failed and get lockout policy returns error should return error",
humanRepo: func(ctrl *gomock.Controller, checkTime time.Time) domain.HumanUserRepository {
repo := domainmock.NewHumanRepo(ctrl)
return repo
},
lockoutRepo: func(ctrl *gomock.Controller) domain.LockoutSettingsRepository {
repo := domainmock.NewLockoutSettingsRepo(ctrl)
instanceAndOrg := database.And(repo.InstanceIDCondition("instance-1"), repo.OrganizationIDCondition(gu.Ptr("org-1")))
orgNullOrEmpty := database.Or(repo.OrganizationIDCondition(nil), repo.OrganizationIDCondition(gu.Ptr("")))
onlyInstance := database.And(repo.InstanceIDCondition("instance-1"), orgNullOrEmpty)
orCondition := database.Or(instanceAndOrg, onlyInstance)
repo.EXPECT().
List(gomock.Any(), gomock.Any(),
dbmock.QueryOptions(database.WithCondition(orCondition)),
dbmock.QueryOptions(database.WithOrderByAscending(repo.OrganizationIDColumn(), repo.InstanceIDColumn())),
dbmock.QueryOptions(database.WithLimit(1)),
).Times(1).
Return(nil, listErr)
return repo
},
cmd: &domain.PasswordCheckCommand{
CheckTime: time.Now(),
SessionID: "session-1",
InstanceID: "instance-1",
VerifierFn: func(_, _ string) (_ string, _ error) { return "", nil },
CheckPassword: &domain.CheckPasswordType{Password: "test"},
FetchedUser: domain.User{OrganizationID: "org-1"},
},
updatedHash: "",
checkType: &domain.VerificationTypeFailed{},
expectedError: zerrors.ThrowInternal(listErr, "DOM-3B8Z6s", "failed fetching lockout settings"),
},
{
testName: "when check type failed with nil max attempts should return check type failed change",
humanRepo: func(ctrl *gomock.Controller, checkTime time.Time) domain.HumanUserRepository {
repo := domainmock.NewHumanRepo(ctrl)
return repo
},
lockoutRepo: func(ctrl *gomock.Controller) domain.LockoutSettingsRepository {
repo := domainmock.NewLockoutSettingsRepo(ctrl)
instanceAndOrg := database.And(repo.InstanceIDCondition("instance-1"), repo.OrganizationIDCondition(gu.Ptr("org-1")))
orgNullOrEmpty := database.Or(repo.OrganizationIDCondition(nil), repo.OrganizationIDCondition(gu.Ptr("")))
onlyInstance := database.And(repo.InstanceIDCondition("instance-1"), orgNullOrEmpty)
orCondition := database.Or(instanceAndOrg, onlyInstance)
setting := &domain.LockoutSettings{
Settings: domain.Settings{},
LockoutSettingsAttributes: domain.LockoutSettingsAttributes{},
}
repo.EXPECT().
List(gomock.Any(), gomock.Any(),
dbmock.QueryOptions(database.WithCondition(orCondition)),
dbmock.QueryOptions(database.WithOrderByAscending(repo.OrganizationIDColumn(), repo.InstanceIDColumn())),
dbmock.QueryOptions(database.WithLimit(1)),
).Times(1).
Return([]*domain.LockoutSettings{
setting,
}, nil)
return repo
},
updatedHash: "",
checkType: &domain.VerificationTypeFailed{},
cmd: &domain.PasswordCheckCommand{
CheckTime: time.Now(),
SessionID: "session-1",
InstanceID: "instance-1",
VerifierFn: func(_, _ string) (_ string, _ error) { return "", nil },
CheckPassword: &domain.CheckPasswordType{Password: "test"},
FetchedUser: domain.User{OrganizationID: "org-1"},
},
expectedChanges: 1,
},
{
testName: "when check type failed and user max attempts >= lockout policy max attempts should return check type and user locked changes",
humanRepo: func(ctrl *gomock.Controller, checkTime time.Time) domain.HumanUserRepository {
repo := domainmock.NewHumanRepo(ctrl)
return repo
},
lockoutRepo: func(ctrl *gomock.Controller) domain.LockoutSettingsRepository {
repo := domainmock.NewLockoutSettingsRepo(ctrl)
instanceAndOrg := database.And(repo.InstanceIDCondition("instance-1"), repo.OrganizationIDCondition(gu.Ptr("org-1")))
orgNullOrEmpty := database.Or(repo.OrganizationIDCondition(nil), repo.OrganizationIDCondition(gu.Ptr("")))
onlyInstance := database.And(repo.InstanceIDCondition("instance-1"), orgNullOrEmpty)
orCondition := database.Or(instanceAndOrg, onlyInstance)
setting := &domain.LockoutSettings{
Settings: domain.Settings{},
LockoutSettingsAttributes: domain.LockoutSettingsAttributes{
MaxPasswordAttempts: gu.Ptr(uint64(1)),
},
}
repo.EXPECT().
List(gomock.Any(), gomock.Any(),
dbmock.QueryOptions(database.WithCondition(orCondition)),
dbmock.QueryOptions(database.WithOrderByAscending(repo.OrganizationIDColumn(), repo.InstanceIDColumn())),
dbmock.QueryOptions(database.WithLimit(1)),
).Times(1).
Return([]*domain.LockoutSettings{
setting,
}, nil)
return repo
},
updatedHash: "",
checkType: &domain.VerificationTypeFailed{},
cmd: &domain.PasswordCheckCommand{
CheckTime: time.Now(),
SessionID: "session-1",
InstanceID: "instance-1",
VerifierFn: func(_, _ string) (_ string, _ error) { return "", nil },
CheckPassword: &domain.CheckPasswordType{Password: "test"},
FetchedUser: domain.User{
OrganizationID: "org-1",
Human: &domain.HumanUser{Password: domain.HumanPassword{FailedAttempts: 2}},
},
},
expectedChanges: 2,
expectedUserLocked: true,
},
}
for _, tc := range tt {
t.Run(tc.testName, func(t *testing.T) {
t.Parallel()
// Given
ctx := authz.NewMockContext("instance-1", "", "")
ctrl := gomock.NewController(t)
opts := &domain.InvokeOpts{
Invoker: domain.NewTransactionInvoker(nil),
}
domain.WithQueryExecutor(new(noopdb.Pool))(opts)
if tc.lockoutRepo != nil {
domain.WithLockoutSettingsRepo(tc.lockoutRepo(ctrl))(opts)
}
// Test
changes, err := tc.cmd.GetPasswordCheckChanges(ctx, opts, tc.humanRepo(ctrl, tc.cmd.CheckTime), tc.updatedHash, tc.checkType)
// Verify
assert.ErrorIs(t, err, tc.expectedError)
assert.Equal(t, tc.expectedHashedPsw, tc.cmd.UpdatedHashedPsw)
assert.Equal(t, tc.expectedUserLocked, tc.cmd.IsUserLocked)
assert.Len(t, changes, tc.expectedChanges)
})
}
}
func TestPasswordCheckCommand_Execute(t *testing.T) {
t.Parallel()
userUpdateErr := errors.New("user update error")
sessionUpdateErr := errors.New("session update error")
tt := []struct {
testName string
humanRepo func(ctrl *gomock.Controller) domain.HumanUserRepository
lockoutRepo func(ctrl *gomock.Controller) domain.LockoutSettingsRepository
sessionRepo func(ctrl *gomock.Controller) domain.SessionRepository
cmd *domain.PasswordCheckCommand
expectTarpitCalled bool
expectedError error
expectedValidated bool
expectedValidationSuccess bool
expectedUserLocked bool
}{
{
testName: "when checkPassword is nil should return no error",
cmd: &domain.PasswordCheckCommand{},
expectedError: nil,
},
{
testName: "when user update fails should return error",
cmd: &domain.PasswordCheckCommand{
CheckPassword: &domain.CheckPasswordType{Password: "test-password"},
SessionID: "session-1",
InstanceID: "instance-1",
VerifierFn: func(password string, hash string) (string, error) { return "", nil },
FetchedUser: domain.User{
ID: "user-1",
OrganizationID: "org-1",
Human: &domain.HumanUser{Password: domain.HumanPassword{
Hash: "hashed-password",
FailedAttempts: 0,
}},
},
},
humanRepo: func(ctrl *gomock.Controller) domain.HumanUserRepository {
repo := domainmock.NewHumanRepo(ctrl)
idCondition := repo.IDCondition("user-1")
repo.EXPECT().
Update(gomock.Any(), gomock.Any(), idCondition, gomock.Any()).
Times(1).
Return(int64(0), userUpdateErr)
return repo
},
expectedError: zerrors.ThrowInternal(userUpdateErr, "DOM-netNam", "failed updating user"),
},
{
testName: "when user not found should return not found error",
cmd: &domain.PasswordCheckCommand{
CheckPassword: &domain.CheckPasswordType{Password: "test-password"},
SessionID: "session-1",
InstanceID: "instance-1",
VerifierFn: func(password string, hash string) (string, error) { return "", nil },
FetchedUser: domain.User{
ID: "user-1",
OrganizationID: "org-1",
Human: &domain.HumanUser{Password: domain.HumanPassword{
Hash: "hashed-password",
FailedAttempts: 0,
}},
},
},
humanRepo: func(ctrl *gomock.Controller) domain.HumanUserRepository {
repo := domainmock.NewHumanRepo(ctrl)
idCondition := repo.IDCondition("user-1")
repo.EXPECT().
Update(gomock.Any(), gomock.Any(), idCondition, gomock.Any()).
Times(1).
Return(int64(0), nil)
return repo
},
expectedError: zerrors.ThrowNotFound(nil, "DOM-8wVrNc", "user not found"),
},
{
testName: "when user update returns multiple rows should return internal error",
cmd: &domain.PasswordCheckCommand{
CheckPassword: &domain.CheckPasswordType{Password: "test-password"},
SessionID: "session-1",
InstanceID: "instance-1",
VerifierFn: func(password string, hash string) (string, error) { return "", nil },
FetchedUser: domain.User{
ID: "user-1",
OrganizationID: "org-1",
Human: &domain.HumanUser{Password: domain.HumanPassword{
Hash: "hashed-password",
FailedAttempts: 0,
}},
},
},
humanRepo: func(ctrl *gomock.Controller) domain.HumanUserRepository {
repo := domainmock.NewHumanRepo(ctrl)
idCondition := repo.IDCondition("user-1")
repo.EXPECT().
Update(gomock.Any(), gomock.Any(), idCondition, gomock.Any()).
Times(1).
Return(int64(2), nil)
return repo
},
expectedError: zerrors.ThrowInternal(domain.NewMultipleObjectsUpdatedError(1, 2), "DOM-D4hy9C", "unexpected number of rows updated"),
},
{
testName: "when session update fails should return error",
cmd: &domain.PasswordCheckCommand{
CheckPassword: &domain.CheckPasswordType{Password: "test-password"},
SessionID: "session-1",
InstanceID: "instance-1",
VerifierFn: func(password string, hash string) (string, error) { return "", nil },
FetchedUser: domain.User{
ID: "user-1",
OrganizationID: "org-1",
Human: &domain.HumanUser{Password: domain.HumanPassword{
Hash: "hashed-password",
FailedAttempts: 0,
}},
},
},
humanRepo: func(ctrl *gomock.Controller) domain.HumanUserRepository {
repo := domainmock.NewHumanRepo(ctrl)
idCondition := repo.IDCondition("user-1")
repo.EXPECT().
Update(gomock.Any(), gomock.Any(), idCondition, gomock.Any()).
Times(1).
Return(int64(1), nil)
return repo
},
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
idCondition := repo.IDCondition("session-1")
sessionFactorChange := repo.SetFactor(&domain.SessionFactorPassword{LastVerifiedAt: time.Now()})
repo.EXPECT().
Update(gomock.Any(), gomock.Any(), idCondition, sessionFactorChange).
Times(1).
Return(int64(0), sessionUpdateErr)
return repo
},
expectedError: zerrors.ThrowInternal(sessionUpdateErr, "DOM-IZagay", "failed updating session"),
},
{
testName: "when session not found should return not found error",
cmd: &domain.PasswordCheckCommand{
CheckPassword: &domain.CheckPasswordType{Password: "test-password"},
SessionID: "session-1",
InstanceID: "instance-1",
VerifierFn: func(password string, hash string) (string, error) { return "", nil },
FetchedUser: domain.User{
ID: "user-1",
OrganizationID: "org-1",
Human: &domain.HumanUser{Password: domain.HumanPassword{
Hash: "hashed-password",
FailedAttempts: 0,
}},
},
},
humanRepo: func(ctrl *gomock.Controller) domain.HumanUserRepository {
repo := domainmock.NewHumanRepo(ctrl)
idCondition := repo.IDCondition("user-1")
repo.EXPECT().
Update(gomock.Any(), gomock.Any(), idCondition, gomock.Any()).
Times(1).
Return(int64(1), nil)
return repo
},
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
idCondition := repo.IDCondition("session-1")
sessionFactorChange := repo.SetFactor(&domain.SessionFactorPassword{LastVerifiedAt: time.Now()})
repo.EXPECT().
Update(gomock.Any(), gomock.Any(), idCondition, sessionFactorChange).
Times(1).
Return(int64(0), nil)
return repo
},
expectedError: zerrors.ThrowNotFound(nil, "DOM-H9Q59c", "session not found"),
},
{
testName: "when session update returns multiple rows should return internal error",
cmd: &domain.PasswordCheckCommand{
CheckPassword: &domain.CheckPasswordType{Password: "test-password"},
SessionID: "session-1",
InstanceID: "instance-1",
VerifierFn: func(password string, hash string) (string, error) { return "", nil },
FetchedUser: domain.User{
ID: "user-1",
OrganizationID: "org-1",
Human: &domain.HumanUser{Password: domain.HumanPassword{
Hash: "hashed-password",
FailedAttempts: 0,
}},
},
},
humanRepo: func(ctrl *gomock.Controller) domain.HumanUserRepository {
repo := domainmock.NewHumanRepo(ctrl)
idCondition := repo.IDCondition("user-1")
repo.EXPECT().
Update(gomock.Any(), gomock.Any(), idCondition, gomock.Any()).
Times(1).
Return(int64(1), nil)
return repo
},
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
idCondition := repo.IDCondition("session-1")
sessionFactorChange := repo.SetFactor(&domain.SessionFactorPassword{LastVerifiedAt: time.Now()})
repo.EXPECT().
Update(gomock.Any(), gomock.Any(), idCondition, sessionFactorChange).
Times(1).
Return(int64(2), nil)
return repo
},
expectedError: zerrors.ThrowInternal(domain.NewMultipleObjectsUpdatedError(1, 2), "DOM-Tbvpy8", "unexpected number of rows updated"),
},
{
testName: "when password check succeeds should execute successfully",
cmd: &domain.PasswordCheckCommand{
CheckPassword: &domain.CheckPasswordType{Password: "test-password"},
SessionID: "session-1",
InstanceID: "instance-1",
VerifierFn: func(password string, hash string) (string, error) { return "", nil },
FetchedUser: domain.User{
ID: "user-1",
OrganizationID: "org-1",
Human: &domain.HumanUser{Password: domain.HumanPassword{
Hash: "hashed-password",
FailedAttempts: 0,
}},
},
},
humanRepo: func(ctrl *gomock.Controller) domain.HumanUserRepository {
repo := domainmock.NewHumanRepo(ctrl)
idCondition := repo.IDCondition("user-1")
repo.EXPECT().
Update(gomock.Any(), gomock.Any(), idCondition, gomock.Any()).
Times(1).
Return(int64(1), nil)
return repo
},
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
idCondition := repo.IDCondition("session-1")
sessionFactorChange := repo.SetFactor(&domain.SessionFactorPassword{LastVerifiedAt: time.Now()})
repo.EXPECT().
Update(gomock.Any(), gomock.Any(), idCondition, sessionFactorChange).
Times(1).
Return(int64(1), nil)
return repo
},
expectedError: nil,
expectedValidated: true,
expectedValidationSuccess: true,
},
{
testName: "when password check fails should execute successfully with failed state",
cmd: &domain.PasswordCheckCommand{
CheckPassword: &domain.CheckPasswordType{Password: "wrong-password"},
SessionID: "session-1",
InstanceID: "instance-1",
VerifierFn: func(password string, hash string) (string, error) {
return "", passwap.ErrPasswordMismatch
},
FetchedUser: domain.User{
ID: "user-1",
OrganizationID: "org-1",
Human: &domain.HumanUser{Password: domain.HumanPassword{
Hash: "hashed-password",
FailedAttempts: 2,
}},
},
},
lockoutRepo: func(ctrl *gomock.Controller) domain.LockoutSettingsRepository {
repo := domainmock.NewLockoutSettingsRepo(ctrl)
instanceAndOrg := database.And(repo.InstanceIDCondition("instance-1"), repo.OrganizationIDCondition(gu.Ptr("org-1")))
orgNullOrEmpty := database.Or(repo.OrganizationIDCondition(nil), repo.OrganizationIDCondition(gu.Ptr("")))
onlyInstance := database.And(repo.InstanceIDCondition("instance-1"), orgNullOrEmpty)
orCondition := database.Or(instanceAndOrg, onlyInstance)
setting := &domain.LockoutSettings{
Settings: domain.Settings{},
LockoutSettingsAttributes: domain.LockoutSettingsAttributes{
MaxPasswordAttempts: gu.Ptr(uint64(1)),
},
}
repo.EXPECT().
List(gomock.Any(), gomock.Any(),
dbmock.QueryOptions(database.WithCondition(orCondition)),
dbmock.QueryOptions(database.WithOrderByAscending(repo.OrganizationIDColumn(), repo.InstanceIDColumn())),
dbmock.QueryOptions(database.WithLimit(1)),
).Times(1).
Return([]*domain.LockoutSettings{
setting,
}, nil)
return repo
},
humanRepo: func(ctrl *gomock.Controller) domain.HumanUserRepository {
repo := domainmock.NewHumanRepo(ctrl)
idCondition := repo.IDCondition("user-1")
repo.EXPECT().
Update(gomock.Any(), gomock.Any(), idCondition, gomock.Any()).
Times(1).
Return(int64(1), nil)
return repo
},
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
repo := domainmock.NewSessionRepo(ctrl)
idCondition := repo.IDCondition("session-1")
sessionFactorChange := repo.SetFactor(&domain.SessionFactorPassword{LastFailedAt: time.Now()})
repo.EXPECT().
Update(gomock.Any(), gomock.Any(), idCondition, sessionFactorChange).
Times(1).
Return(int64(1), nil)
return repo
},
expectTarpitCalled: true,
expectedError: zerrors.ThrowInvalidArgument(domain.NewPasswordVerificationError(3), "DOM-3gcfDV", "Errors.User.Password.Invalid"),
expectedValidated: true,
expectedValidationSuccess: false,
expectedUserLocked: true,
},
}
for _, tc := range tt {
t.Run(tc.testName, func(t *testing.T) {
t.Parallel()
// Given
ctx := authz.NewMockContext("instance-1", "", "")
ctrl := gomock.NewController(t)
tarpitCalled := false
tc.cmd.TarpitFunc = func(_ uint64) {
tarpitCalled = true
}
opts := &domain.InvokeOpts{
Invoker: domain.NewTransactionInvoker(nil),
}
domain.WithQueryExecutor(new(noopdb.Pool))(opts)
if tc.sessionRepo != nil {
domain.WithSessionRepo(tc.sessionRepo(ctrl))(opts)
}
if tc.lockoutRepo != nil {
domain.WithLockoutSettingsRepo(tc.lockoutRepo(ctrl))(opts)
}
if tc.humanRepo != nil {
humanRepo := tc.humanRepo(ctrl)
userRepo := domainmock.NewUserRepo(ctrl)
userRepo.EXPECT().Human().Times(1).Return(humanRepo)
domain.WithUserRepo(userRepo)(opts)
}
// Test
err := tc.cmd.Execute(ctx, opts)
// Verify
assert.ErrorIs(t, err, tc.expectedError)
if tc.cmd.CheckPassword != nil {
assert.Equal(t, tc.expectedValidated, tc.cmd.IsValidated)
assert.Equal(t, tc.expectedValidationSuccess, tc.cmd.IsValidationSuccessful)
assert.Equal(t, tc.expectedUserLocked, tc.cmd.IsUserLocked)
assert.Equal(t, tc.expectTarpitCalled, tarpitCalled)
}
})
}
}
func TestPasswordCheckCommand_Events(t *testing.T) {
t.Parallel()
userAgg := user.NewAggregate("user-1", "org-1").Aggregate
sessAgg := session.NewAggregate("session-1", "instance-1").Aggregate
tt := []struct {
testName string
cmd *domain.PasswordCheckCommand
expectedEventTypes []eventstore.Command
}{
{
testName: "when checkPassword is nil should return nil",
cmd: &domain.PasswordCheckCommand{IsValidated: true},
},
{
testName: "when not validated should return nil",
cmd: &domain.PasswordCheckCommand{
CheckPassword: &domain.CheckPasswordType{Password: "test"},
IsValidated: false,
},
},
{
testName: "when validation successful with no hash update should return check succeeded and password checked events",
cmd: &domain.PasswordCheckCommand{
CheckPassword: &domain.CheckPasswordType{Password: "test"},
IsValidated: true,
IsValidationSuccessful: true,
UpdatedHashedPsw: "",
FetchedUser: domain.User{
ID: "user-1",
OrganizationID: "org-1",
},
SessionID: "session-1",
InstanceID: "instance-1",
CheckTime: time.Now(),
},
expectedEventTypes: []eventstore.Command{
user.NewHumanPasswordCheckSucceededEvent(t.Context(), &userAgg, nil),
session.NewPasswordCheckedEvent(t.Context(), &sessAgg, time.Now()),
},
},
{
testName: "when validation successful with hash update should return check succeeded, hash updated, and password checked events",
cmd: &domain.PasswordCheckCommand{
CheckPassword: &domain.CheckPasswordType{Password: "test"},
IsValidated: true,
IsValidationSuccessful: true,
UpdatedHashedPsw: "new-hash",
FetchedUser: domain.User{
ID: "user-1",
OrganizationID: "org-1",
},
SessionID: "session-1",
InstanceID: "instance-1",
CheckTime: time.Now(),
},
expectedEventTypes: []eventstore.Command{
user.NewHumanPasswordCheckSucceededEvent(t.Context(), &userAgg, nil),
user.NewHumanPasswordHashUpdatedEvent(t.Context(), &userAgg, "new-hash"),
session.NewPasswordCheckedEvent(t.Context(), &sessAgg, time.Now()),
},
},
{
testName: "when validation failed without user locked should return check failed and password checked events",
cmd: &domain.PasswordCheckCommand{
CheckPassword: &domain.CheckPasswordType{Password: "wrong"},
IsValidated: true,
IsValidationSuccessful: false,
IsUserLocked: false,
FetchedUser: domain.User{
ID: "user-1",
OrganizationID: "org-1",
},
SessionID: "session-1",
InstanceID: "instance-1",
CheckTime: time.Now(),
},
expectedEventTypes: []eventstore.Command{
user.NewHumanPasswordCheckFailedEvent(t.Context(), &userAgg, nil),
session.NewPasswordCheckedEvent(t.Context(), &sessAgg, time.Now()),
},
},
{
testName: "when validation failed with user locked should return check failed, user locked, and password checked events",
cmd: &domain.PasswordCheckCommand{
CheckPassword: &domain.CheckPasswordType{Password: "wrong"},
IsValidated: true,
IsValidationSuccessful: false,
IsUserLocked: true,
FetchedUser: domain.User{
ID: "user-1",
OrganizationID: "org-1",
},
SessionID: "session-1",
InstanceID: "instance-1",
CheckTime: time.Now(),
},
expectedEventTypes: []eventstore.Command{
user.NewHumanPasswordCheckFailedEvent(t.Context(), &userAgg, nil),
user.NewUserLockedEvent(t.Context(), &userAgg),
session.NewPasswordCheckedEvent(t.Context(), &sessAgg, time.Now())},
},
}
for _, tc := range tt {
t.Run(tc.testName, func(t *testing.T) {
t.Parallel()
// Given
ctx := authz.NewMockContext(tc.cmd.InstanceID, "", "")
// Test
events, err := tc.cmd.Events(ctx, &domain.InvokeOpts{})
// Verify
assert.NoError(t, err)
require.Len(t, events, len(tc.expectedEventTypes))
for i, expectedType := range tc.expectedEventTypes {
assert.IsType(t, expectedType, events[i])
switch expectedAssertedType := expectedType.(type) {
case *user.HumanPasswordCheckSucceededEvent:
actualAssertedType, ok := events[i].(*user.HumanPasswordCheckSucceededEvent)
require.True(t, ok)
assert.Equal(t, expectedAssertedType.AuthRequestInfo, actualAssertedType.AuthRequestInfo)
case *user.HumanPasswordCheckFailedEvent:
actualAssertedType, ok := events[i].(*user.HumanPasswordCheckFailedEvent)
require.True(t, ok)
assert.Equal(t, expectedAssertedType.AuthRequestInfo, actualAssertedType.AuthRequestInfo)
case *user.UserLockedEvent:
continue
case *user.HumanPasswordHashUpdatedEvent:
actualAssertedType, ok := events[i].(*user.HumanPasswordHashUpdatedEvent)
require.True(t, ok)
assert.Equal(t, expectedAssertedType.EncodedHash, actualAssertedType.EncodedHash)
case *session.PasswordCheckedEvent:
actualAssertedType, ok := events[i].(*session.PasswordCheckedEvent)
require.True(t, ok)
assert.Equal(t, tc.cmd.CheckTime, actualAssertedType.CheckedAt)
}
}
})
}
}