Files
zitadel/internal/command/user_human_recovery_codes_test.go
Gayathri Vijayan fb35fdd3c7 feat: add recovery codes to user repository (#11702)
# Which Problems Are Solved

Adds a repository implementation to add/remove recovery codes to the
users relational table.

# How the Problems Are Solved

This is achieved by:
* adding the following columns to the users table: `recovery_codes`,
`recovery_code_last_successful_check`, `recovery_code_failed_attempts`
* adding the `RecoveryCodes` field to the `HumanUser` domain with fields
to set recovery `codes`, `lastSuccessfullyCheckedAt` timestamp, and
`failedAttempts`
* setting `recoveryCodes` in the `Get` user query statement to return a
json object with details related to the recovery codes
* adding the repository-layer implementation to add/remove recovery
codes and set fields related to recovery code checks.
* adding projection reducers to handle the following events:
`HumanRecoveryCodesAddedEvent`, `HumanRecoveryCodesRemovedEvent`,
`HumanRecoveryCodeCheckSucceededEvent`, and
`HumanRecoveryCodeCheckFailedEvent`
* adding unit tests

# Additional Changes
* fix the error message when the recovery is empty in
`internal/command/user_human_recovery_codes.go`
* add a new error message for empty recovery code during checks in
`en.yaml`

# Additional Context
- Closes https://github.com/zitadel/zitadel/issues/11212
- Follow-up: integration tests for the reducers will be added in a
different PR after this
[PR](https://github.com/zitadel/zitadel/pull/11478) is merged
2026-02-26 19:22:37 +01:00

1038 lines
26 KiB
Go

package command
import (
"context"
"io"
"net"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/user"
"github.com/zitadel/zitadel/internal/zerrors"
)
func TestCommands_ImportHumanRecoveryCodes(t *testing.T) {
ctx := authz.NewMockContext("inst1", "org1", "user1")
type fields struct {
eventstore func(*testing.T) *eventstore.Eventstore
}
type args struct {
ctx context.Context
userID string
resourceOwner string
codes []domain.ImportHumanRecoveryCode
}
tests := []struct {
name string
fields fields
args args
wantErr error
}{
{
name: "user not found, error",
fields: fields{
eventstore: expectEventstore(
expectFilter(), // user not found
),
},
args: args{
ctx: ctx,
userID: "user1",
resourceOwner: "org1",
codes: []domain.ImportHumanRecoveryCode{
{RawCode: "code1"},
{RawCode: "code2"},
},
},
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-uXHNj", "Errors.User.NotFound"),
},
{
name: "recovery codes already exist, add to existing",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"email@test.ch",
true,
),
),
),
expectFilter(
eventFromEventPusher(
user.NewHumanRecoveryCodesAddedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
[]string{"hashedcode1", "hashedcode2"},
nil,
),
),
),
expectPush(
user.NewHumanRecoveryCodesAddedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
[]string{"$plain$$code1", "$plain$$code2"},
nil,
),
),
),
},
args: args{
ctx: ctx,
userID: "user1",
resourceOwner: "org1",
codes: []domain.ImportHumanRecoveryCode{
{RawCode: "code1"},
{RawCode: "code2"},
},
},
},
{
name: "empty codes, error",
fields: fields{
eventstore: expectEventstore(),
},
args: args{
ctx: ctx,
userID: "user1",
resourceOwner: "org1",
codes: []domain.ImportHumanRecoveryCode{},
},
wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-vee93", "Errors.User.MFA.RecoveryCodes.CountInvalid"),
},
{
name: "max count exceeded with existing codes, error",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"email@test.ch",
true,
),
),
),
expectFilter(
eventFromEventPusher(
user.NewHumanRecoveryCodesAddedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
[]string{"code1", "code2", "code3", "code4", "code5", "code6", "code7", "code8"},
nil,
),
),
),
),
},
args: args{
ctx: ctx,
userID: "user1",
resourceOwner: "org1",
codes: []domain.ImportHumanRecoveryCode{ // 8 existing + 3 new = 11 > max 10
{RawCode: "code9"},
{RawCode: "code10"},
{RawCode: "code11"},
},
},
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-53cjw", "Errors.User.MFA.RecoveryCodes.MaxCountExceeded"),
},
{
name: "successful import",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"email@test.ch",
true,
),
),
),
expectFilter(), // no existing recovery codes
expectPush(
user.NewHumanRecoveryCodesAddedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
[]string{"$plain$$code1", "$plain$$code2"},
nil,
),
),
),
},
args: args{
ctx: ctx,
userID: "user1",
resourceOwner: "org1",
codes: []domain.ImportHumanRecoveryCode{
{RawCode: "code1"},
{RawCode: "code2"},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore(t),
userPasswordHasher: mockPasswordHasher(""),
multifactors: domain.MultifactorConfigs{
RecoveryCodes: domain.RecoveryCodesConfig{
MaxCount: 10,
Format: domain.RecoveryCodeFormatAlphanumeric,
Length: 8,
WithHyphen: false,
},
},
}
err := c.ImportHumanRecoveryCodes(tt.args.ctx, tt.args.userID, tt.args.resourceOwner, tt.args.codes)
require.ErrorIs(t, err, tt.wantErr)
})
}
}
func TestCommands_GenerateRecoveryCodes(t *testing.T) {
ctx := authz.NewMockContext("inst1", "org1", "user1")
type fields struct {
eventstore func(*testing.T) *eventstore.Eventstore
checkPermission domain.PermissionCheck
}
type args struct {
ctx context.Context
userID string
count int
resourceOwner string
authRequest *domain.AuthRequest
}
tests := []struct {
name string
fields fields
args args
want *RecoveryCodesDetails
wantErr error
}{
{
name: "missing userID, error",
fields: fields{
eventstore: expectEventstore(),
},
args: args{
ctx: ctx,
userID: "",
count: 2,
resourceOwner: "org1",
},
wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-4kje7", "Errors.User.UserIDMissing"),
},
{
name: "user not found, error",
fields: fields{
eventstore: expectEventstore(
expectFilter(), // user not found
),
},
args: args{
ctx: ctx,
userID: "user1",
count: 2,
resourceOwner: "org1",
},
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-uXHNj", "Errors.User.NotFound"),
},
{
name: "permission denied, error",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(ctx,
&user.NewAggregate("user2", "org1").Aggregate,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"email@test.ch",
true,
),
),
),
),
checkPermission: newMockPermissionCheckNotAllowed(),
},
args: args{
ctx: ctx,
userID: "user2",
count: 2,
resourceOwner: "org1",
},
wantErr: zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"),
},
{
name: "invalid count (zero), error",
fields: fields{
eventstore: expectEventstore(),
},
args: args{
ctx: ctx,
userID: "user1",
count: 0,
resourceOwner: "org1",
},
wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-7c0nx", "Errors.User.RecoveryCodes.CountInvalid"),
},
{
name: "invalid count (too high), error",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"email@test.ch",
true,
),
),
),
expectFilter(), // no existing recovery codes
),
},
args: args{
ctx: ctx,
userID: "user1",
count: 15, // exceeds max count of 10
resourceOwner: "org1",
},
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-8f2k9", "Errors.User.MFA.RecoveryCodes.MaxCountExceeded"),
},
{
name: "max count exceeded with existing codes, error",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"email@test.ch",
true,
),
),
),
expectFilter(
eventFromEventPusher(
user.NewHumanRecoveryCodesAddedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
[]string{"code1", "code2", "code3", "code4", "code5", "code6", "code7", "code8"},
nil,
),
),
),
),
},
args: args{
ctx: ctx,
userID: "user1",
count: 5, // 8 existing + 5 new = 13 > max 10
resourceOwner: "org1",
},
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-8f2k9", "Errors.User.MFA.RecoveryCodes.MaxCountExceeded"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore(t),
checkPermission: tt.fields.checkPermission,
userPasswordHasher: mockPasswordHasher(""),
multifactors: domain.MultifactorConfigs{
RecoveryCodes: domain.RecoveryCodesConfig{
MaxCount: 10,
Format: domain.RecoveryCodeFormatAlphanumeric,
Length: 8,
WithHyphen: false,
},
},
}
got, err := c.GenerateRecoveryCodes(tt.args.ctx, tt.args.userID, tt.args.count, tt.args.resourceOwner, tt.args.authRequest)
require.ErrorIs(t, err, tt.wantErr)
if tt.want != nil {
require.NotNil(t, got)
assert.Equal(t, tt.want.ResourceOwner, got.ResourceOwner)
}
})
}
}
func TestCommands_RemoveRecoveryCodes(t *testing.T) {
ctx := authz.NewMockContext("inst1", "org1", "user1")
type fields struct {
eventstore func(*testing.T) *eventstore.Eventstore
checkPermission domain.PermissionCheck
}
type args struct {
ctx context.Context
userID string
resourceOwner string
authRequest *domain.AuthRequest
}
tests := []struct {
name string
fields fields
args args
want *domain.ObjectDetails
wantErr error
}{
{
name: "missing userID, error",
fields: fields{
eventstore: expectEventstore(),
},
args: args{
ctx: ctx,
userID: "",
resourceOwner: "org1",
},
wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-l2n9r", "Errors.User.UserIDMissing"),
},
{
name: "permission denied, error",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanRecoveryCodesAddedEvent(ctx,
&user.NewAggregate("user2", "org1").Aggregate,
[]string{"code1", "code2"},
nil,
),
),
),
),
checkPermission: newMockPermissionCheckNotAllowed(),
},
args: args{
ctx: ctx,
userID: "user2",
resourceOwner: "org1",
},
wantErr: zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"),
},
{
name: "user locked, error",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanRecoveryCodesAddedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
[]string{"code1", "code2"},
nil,
),
),
eventFromEventPusher(
user.NewUserLockedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
),
),
),
),
},
args: args{
ctx: ctx,
userID: "user1",
resourceOwner: "org1",
},
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-d9u8q", "Errors.User.Locked"),
},
{
name: "recovery codes not added, ok",
fields: fields{
eventstore: expectEventstore(
expectFilter(), // no recovery codes
),
},
args: args{
ctx: ctx,
userID: "user1",
resourceOwner: "org1",
},
want: &domain.ObjectDetails{
ResourceOwner: "org1",
ID: "user1",
},
},
{
name: "successful removal",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanRecoveryCodesAddedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
[]string{"code1", "code2"},
nil,
),
),
),
expectPush(
user.NewHumanRecoveryCodeRemovedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
nil,
),
),
),
},
args: args{
ctx: ctx,
userID: "user1",
resourceOwner: "org1",
},
want: &domain.ObjectDetails{
ResourceOwner: "org1",
ID: "user1",
},
},
{
name: "successful removal with auth request",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanRecoveryCodesAddedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
[]string{"code1", "code2"},
nil,
),
),
),
expectPush(
user.NewHumanRecoveryCodeRemovedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
&user.AuthRequestInfo{
ID: "authRequestID",
UserAgentID: "userAgentID",
BrowserInfo: &user.BrowserInfo{
UserAgent: "user-agent",
AcceptLanguage: "en",
RemoteIP: net.IP{192, 0, 2, 1},
},
},
),
),
),
},
args: args{
ctx: ctx,
userID: "user1",
resourceOwner: "org1",
authRequest: &domain.AuthRequest{
ID: "authRequestID",
AgentID: "userAgentID",
BrowserInfo: &domain.BrowserInfo{
UserAgent: "user-agent",
AcceptLanguage: "en",
RemoteIP: net.IP{192, 0, 2, 1},
},
},
},
want: &domain.ObjectDetails{
ResourceOwner: "org1",
ID: "user1",
},
},
{
name: "successful removal, other user",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanRecoveryCodesAddedEvent(ctx,
&user.NewAggregate("user2", "org1").Aggregate,
[]string{"code1", "code2"},
nil,
),
),
),
expectPush(
user.NewHumanRecoveryCodeRemovedEvent(ctx,
&user.NewAggregate("user2", "org1").Aggregate,
nil,
),
),
),
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
ctx: ctx,
userID: "user2",
resourceOwner: "org1",
},
want: &domain.ObjectDetails{
ResourceOwner: "org1",
ID: "user2",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore(t),
checkPermission: tt.fields.checkPermission,
}
got, err := c.RemoveRecoveryCodes(tt.args.ctx, tt.args.userID, tt.args.resourceOwner, tt.args.authRequest)
require.ErrorIs(t, err, tt.wantErr)
if tt.want != nil {
assert.Equal(t, tt.want, got)
}
})
}
}
func TestCommands_HumanCheckRecoveryCode(t *testing.T) {
ctx := authz.NewMockContext("inst1", "org1", "user1")
type fields struct {
eventstore func(*testing.T) *eventstore.Eventstore
}
type args struct {
ctx context.Context
userID string
code string
resourceOwner string
authRequest *domain.AuthRequest
}
tests := []struct {
name string
fields fields
args args
wantErr error
}{
{
name: "missing code, error",
fields: fields{
eventstore: expectEventstore(),
},
args: args{
ctx: ctx,
userID: "user1",
code: "",
resourceOwner: "org1",
},
wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-u0b6c", "Errors.User.MFA.RecoveryCodes.Empty"),
},
{
name: "missing userID, error",
fields: fields{
eventstore: expectEventstore(),
},
args: args{
ctx: ctx,
userID: "",
code: "validcode",
resourceOwner: "org1",
},
wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-4m9s2", "Errors.User.UserIDMissing"),
},
{
name: "user locked, error",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanRecoveryCodesAddedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
[]string{"$plain$$validcode", "$plain$$validcode2"},
nil,
),
),
eventFromEventPusher(
user.NewUserLockedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
),
),
),
),
},
args: args{
ctx: ctx,
userID: "user1",
code: "validcode",
resourceOwner: "org1",
},
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-2w6oa", "Errors.User.Locked"),
},
{
name: "recovery codes not ready, error",
fields: fields{
eventstore: expectEventstore(
expectFilter(), // no recovery codes
),
},
args: args{
ctx: ctx,
userID: "user1",
code: "validcode",
resourceOwner: "org1",
},
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-84rgg", "Errors.User.MFA.RecoveryCodes.NotReady"),
},
{
name: "valid code, successful check",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanRecoveryCodesAddedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
[]string{"$plain$$validcode", "$plain$$validcode2"},
nil,
),
),
),
expectFilter(), // additional lock check
expectPush(
user.NewHumanRecoveryCodeCheckSucceededEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
"$plain$$validcode",
nil,
),
),
),
},
args: args{
ctx: ctx,
userID: "user1",
code: "validcode",
resourceOwner: "org1",
},
},
{
name: "valid code, successful check with auth request",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanRecoveryCodesAddedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
[]string{"$plain$$validcode", "$plain$$validcode2"},
nil,
),
),
),
expectFilter(), // additional lock check
expectPush(
user.NewHumanRecoveryCodeCheckSucceededEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
"$plain$$validcode",
&user.AuthRequestInfo{
ID: "authRequestID",
UserAgentID: "userAgentID",
BrowserInfo: &user.BrowserInfo{
UserAgent: "user-agent",
AcceptLanguage: "en",
RemoteIP: net.IP{192, 0, 2, 1},
},
},
),
),
),
},
args: args{
ctx: ctx,
userID: "user1",
code: "validcode",
resourceOwner: "org1",
authRequest: &domain.AuthRequest{
ID: "authRequestID",
AgentID: "userAgentID",
BrowserInfo: &domain.BrowserInfo{
UserAgent: "user-agent",
AcceptLanguage: "en",
RemoteIP: net.IP{192, 0, 2, 1},
},
},
},
},
{
name: "valid code, locked in the meantime, error",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanRecoveryCodesAddedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
[]string{"$plain$$validcode", "$plain$$validcode2"},
nil,
),
),
),
expectFilter(
eventFromEventPusher(
user.NewUserLockedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
),
),
),
),
},
args: args{
ctx: ctx,
userID: "user1",
code: "validcode",
resourceOwner: "org1",
},
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-ASV12", "Errors.User.Locked"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore(t),
userPasswordHasher: mockPasswordHasher(""),
}
err := c.HumanCheckRecoveryCode(tt.args.ctx, tt.args.userID, tt.args.code, tt.args.resourceOwner, tt.args.authRequest)
require.ErrorIs(t, err, tt.wantErr)
})
}
}
func TestCommands_checkRecoveryCode(t *testing.T) {
ctx := authz.NewMockContext("inst1", "org1", "user1")
hasher := mockPasswordHasher("")
queryReducer := func(ctx context.Context, r eventstore.QueryReducer) error {
// Basic query reducer that doesn't set any specific state
return nil
}
queryReducerError := func(ctx context.Context, r eventstore.QueryReducer) error {
return io.ErrClosedPipe
}
// Query reducer that sets up a write model with valid recovery codes
queryReducerWithCodes := func(ctx context.Context, r eventstore.QueryReducer) error {
switch wm := r.(type) {
case *HumanRecoveryCodeWriteModel:
wm.State = domain.MFAStateReady
wm.codes = []string{"$plain$$validcode", "$plain$$validcode2"}
case *OrgLockoutPolicyWriteModel:
// Set up lockout policy - no lockout for this test
wm.MaxOTPAttempts = 0
wm.State = domain.PolicyStateActive
}
return nil
}
// Query reducer that sets up a locked user
queryReducerUserLocked := func(ctx context.Context, r eventstore.QueryReducer) error {
switch wm := r.(type) {
case *HumanRecoveryCodeWriteModel:
wm.State = domain.MFAStateReady
wm.codes = []string{"$plain$$validcode", "$plain$$validcode2"}
wm.userLocked = true
case *OrgLockoutPolicyWriteModel:
// Default lockout policy
wm.State = domain.PolicyStateActive
}
return nil
}
// Query reducer that sets up recovery codes not ready
queryReducerNotReady := func(ctx context.Context, r eventstore.QueryReducer) error {
switch wm := r.(type) {
case *HumanRecoveryCodeWriteModel:
wm.State = domain.MFAStateNotReady
case *OrgLockoutPolicyWriteModel:
// Default lockout policy
wm.State = domain.PolicyStateActive
}
return nil
}
// Query reducer that sets up a locked user after the first check
queryReducerLockedAfterFirstCheck := func(ctx context.Context, r eventstore.QueryReducer) error {
switch wm := r.(type) {
case *HumanRecoveryCodeWriteModel:
wm.State = domain.MFAStateReady
// simulate lock after first check when the codes are already set by the first reduction
if len(wm.codes) > 0 {
wm.userLocked = true
}
wm.codes = []string{"$plain$$validcode", "$plain$$validcode2"}
case *OrgLockoutPolicyWriteModel:
// Default lockout policy
wm.State = domain.PolicyStateActive
}
return nil
}
type args struct {
ctx context.Context
userID string
code string
resourceOwner string
authRequest *domain.AuthRequest
queryReducer func(ctx context.Context, r eventstore.QueryReducer) error
secretHasher *crypto.Hasher
}
tests := []struct {
name string
args args
wantCommands int
wantErr error
}{
{
name: "missing code, error",
args: args{
ctx: ctx,
userID: "user1",
code: "",
resourceOwner: "org1",
queryReducer: queryReducer,
secretHasher: hasher,
},
wantCommands: 0,
wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-u0b6c", "Errors.User.MFA.RecoveryCodes.Empty"),
},
{
name: "missing userID, error",
args: args{
ctx: ctx,
userID: "",
code: "validcode",
resourceOwner: "org1",
queryReducer: queryReducer,
secretHasher: hasher,
},
wantCommands: 0,
wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-4m9s2", "Errors.User.UserIDMissing"),
},
{
name: "query reducer error",
args: args{
ctx: ctx,
userID: "user1",
code: "validcode",
resourceOwner: "org1",
queryReducer: queryReducerError,
secretHasher: hasher,
},
wantCommands: 0,
wantErr: io.ErrClosedPipe,
},
{
name: "user locked, error",
args: args{
ctx: ctx,
userID: "user1",
code: "validcode",
resourceOwner: "org1",
queryReducer: queryReducerUserLocked,
secretHasher: hasher,
},
wantCommands: 0,
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-2w6oa", "Errors.User.Locked"),
},
{
name: "recovery codes not ready, error",
args: args{
ctx: ctx,
userID: "user1",
code: "validcode",
resourceOwner: "org1",
queryReducer: queryReducerNotReady,
secretHasher: hasher,
},
wantCommands: 0,
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-84rgg", "Errors.User.MFA.RecoveryCodes.NotReady"),
},
{
name: "invalid code, returns failed event and error",
args: args{
ctx: ctx,
userID: "user1",
code: "invalidcode",
resourceOwner: "org1",
queryReducer: queryReducerWithCodes,
secretHasher: hasher,
},
wantCommands: 1, // should return failed event command
wantErr: zerrors.ThrowInvalidArgument(nil, "DOMAIN-6uvh0", "Errors.User.MFA.RecoveryCodes.InvalidCode"),
},
{
name: "valid code, returns success event",
args: args{
ctx: ctx,
userID: "user1",
code: "validcode",
resourceOwner: "org1",
queryReducer: queryReducerWithCodes,
secretHasher: hasher,
},
wantCommands: 1, // should return success event command
wantErr: nil,
},
{
name: "valid code, locked in the meantime, error",
args: args{
ctx: ctx,
userID: "user1",
code: "validcode",
resourceOwner: "org1",
queryReducer: queryReducerLockedAfterFirstCheck,
secretHasher: hasher,
},
wantCommands: 0,
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-ASV12", "Errors.User.Locked"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
commands, err := checkRecoveryCode(tt.args.ctx, tt.args.userID, tt.args.code, tt.args.resourceOwner, tt.args.authRequest, tt.args.queryReducer, tt.args.secretHasher)
require.ErrorIs(t, err, tt.wantErr)
assert.Len(t, commands, tt.wantCommands)
})
}
}