From 3f258953ceccf38b17a89c54e2dbe747e809ced3 Mon Sep 17 00:00:00 2001 From: Gayathri Vijayan <66356931+grvijayan@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:40:02 +0100 Subject: [PATCH] test: add tests for recovery codes event reducers (#11773) # Which Problems Are Solved Add integration tests for recovery codes event reducers in the RT model. # How the Problems Are Solved Added integration tests for recovery codes event reducers # Additional Changes N/A # Additional Context - Closes https://github.com/zitadel/zitadel/issues/11707 - Todo: add tests for other reducers in the `users_relational` projection. --------- Co-authored-by: Livio Spring --- internal/query/projection/user_relational.go | 1 + .../query/projection/user_relational_test.go | 224 ++++++++++++++++++ 2 files changed, 225 insertions(+) create mode 100644 internal/query/projection/user_relational_test.go diff --git a/internal/query/projection/user_relational.go b/internal/query/projection/user_relational.go index 0a8209d92..214277c1c 100644 --- a/internal/query/projection/user_relational.go +++ b/internal/query/projection/user_relational.go @@ -424,6 +424,7 @@ func (p *userRelationalProjection) Reducers() []handler.AggregateReducer { Event: user.HumanInviteCheckFailedType, Reduce: p.reduceInviteCheckFailed, }, + // recovery code reducers { Event: user.HumanRecoveryCodesAddedType, Reduce: p.reduceRecoveryCodesAdded, diff --git a/internal/query/projection/user_relational_test.go b/internal/query/projection/user_relational_test.go new file mode 100644 index 000000000..a33d1fd8a --- /dev/null +++ b/internal/query/projection/user_relational_test.go @@ -0,0 +1,224 @@ +package projection + +import ( + "context" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/backend/v3/domain" + "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/backend/v3/storage/database/dialect/sql" + "github.com/zitadel/zitadel/backend/v3/storage/database/repository" + "github.com/zitadel/zitadel/internal/repository/user" +) + +func TestUserRelationalProjection_Reducers(t *testing.T) { + handler := &userRelationalProjection{} + rawTx, tx := getTransactions(t) + t.Cleanup(func() { + require.NoError(t, rawTx.Rollback()) + }) + + ctx := t.Context() + + // create instance + instanceRepo := repository.InstanceRepository() + instanceID := gofakeit.UUID() + orgID := gofakeit.UUID() + err := instanceRepo.Create(ctx, tx, &domain.Instance{ + ID: instanceID, + Name: "test-instance", + DefaultOrgID: orgID, + }) + require.NoError(t, err) + + // create org + orgRepo := repository.OrganizationRepository() + err = orgRepo.Create(ctx, tx, &domain.Organization{ + InstanceID: instanceID, + ID: orgID, + Name: "test-org", + State: domain.OrgStateActive, + }) + require.NoError(t, err) + + userRepo := repository.UserRepository() + + // TODO: add tests for other reducers in user_relational.go + + // recovery code reducers + t.Run("reduce user.human.mfa.recoverycode.added event", func(t *testing.T) { + // create user + existingUserID := gofakeit.UUID() + existingUserAgg := createUser(t, ctx, tx, userRepo, instanceID, orgID, existingUserID) + + // set `user.human.mfa.recoverycode.added` event + recoveryCodes := []string{"code1", "code2", "code3"} + recoveryCodesAddedEvent := user.NewHumanRecoveryCodesAddedEvent( + ctx, + &existingUserAgg.Aggregate, + recoveryCodes, + nil) + + // reduce the event + eventReduced := callReduce(t, ctx, rawTx, handler, recoveryCodesAddedEvent) + require.True(t, eventReduced) + + // assert that the recovery codes are stored + gotUser, err := userRepo.Get(ctx, tx, database.WithCondition( + database.And( + userRepo.IDCondition(existingUserID), + userRepo.InstanceIDCondition(instanceID), + ), + )) + require.NoError(t, err) + assert.ElementsMatch(t, recoveryCodes, gotUser.Human.RecoveryCodes.Codes) + assert.Zero(t, gotUser.Human.RecoveryCodes.LastSuccessfullyCheckedAt) + assert.Zero(t, gotUser.Human.RecoveryCodes.FailedAttempts) + }) + + t.Run("reduce user.human.mfa.recoverycode.removed event", func(t *testing.T) { + // create user + existingUserID := gofakeit.UUID() + existingUserAgg := createUser(t, ctx, tx, userRepo, instanceID, orgID, existingUserID) + + // add recovery codes to the user + _, err = userRepo.Update(ctx, tx, + userRepo.PrimaryKeyCondition(instanceID, existingUserID), + userRepo.Human().AddRecoveryCodes([]string{"code1", "code2", "code3"})) + require.NoError(t, err) + + // set `user.human.mfa.recoverycode.removed` event + recoveryCodesRemovedEvent := user.NewHumanRecoveryCodeRemovedEvent( + ctx, + &existingUserAgg.Aggregate, + nil) + + // reduce the event + eventReduced := callReduce(t, ctx, rawTx, handler, recoveryCodesRemovedEvent) + require.True(t, eventReduced) + + // assert that the recovery codes are removed + gotUser, err := userRepo.Get(ctx, tx, database.WithCondition( + database.And( + userRepo.IDCondition(existingUserID), + userRepo.InstanceIDCondition(instanceID), + ), + )) + require.NoError(t, err) + assert.Empty(t, gotUser.Human.RecoveryCodes.Codes) + assert.Zero(t, gotUser.Human.RecoveryCodes.LastSuccessfullyCheckedAt) + assert.Zero(t, gotUser.Human.RecoveryCodes.FailedAttempts) + }) + + t.Run("reduce user.human.mfa.recoverycode.check.succeeded event", func(t *testing.T) { + // create user + existingUserID := gofakeit.UUID() + existingUserAgg := createUser(t, ctx, tx, userRepo, instanceID, orgID, existingUserID) + + // add recovery codes to the user + _, err = userRepo.Update(ctx, tx, + userRepo.PrimaryKeyCondition(instanceID, existingUserID), + userRepo.Human().AddRecoveryCodes([]string{"code1", "code2", "code3"})) + require.NoError(t, err) + + // set `user.human.mfa.recoverycode.check.succeeded` event + recoveryCodeCheckSucceededEvent := user.NewHumanRecoveryCodeCheckSucceededEvent( + ctx, + &existingUserAgg.Aggregate, + "code1", + nil) + + // reduce the event + eventReduced := callReduce(t, ctx, rawTx, handler, recoveryCodeCheckSucceededEvent) + require.True(t, eventReduced) + + // assert that the recovery code is removed and the last_successfully_checked_at timestamp is set + gotUser, err := userRepo.Get(ctx, tx, database.WithCondition( + database.And( + userRepo.IDCondition(existingUserID), + userRepo.InstanceIDCondition(instanceID), + ), + )) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"code2", "code3"}, gotUser.Human.RecoveryCodes.Codes) + assert.NotZero(t, gotUser.Human.RecoveryCodes.LastSuccessfullyCheckedAt) + assert.Zero(t, gotUser.Human.RecoveryCodes.FailedAttempts) + }) + + t.Run("reduce user.human.mfa.recoverycode.check.failed event", func(t *testing.T) { + // create user + existingUserID := gofakeit.UUID() + existingUserAgg := createUser(t, ctx, tx, userRepo, instanceID, orgID, existingUserID) + + // add recovery codes to the user + _, err = userRepo.Update(ctx, tx, + userRepo.PrimaryKeyCondition(instanceID, existingUserID), + userRepo.Human().AddRecoveryCodes([]string{"code1", "code2", "code3"})) + require.NoError(t, err) + + // set `user.human.mfa.recoverycode.check.failed` event + recoveryCodeCheckFailedEvent := user.NewHumanRecoveryCodeCheckFailedEvent( + ctx, + &existingUserAgg.Aggregate, + nil, + ) + + // reduce the event + eventReduced := callReduce(t, ctx, rawTx, handler, recoveryCodeCheckFailedEvent) + require.True(t, eventReduced) + + // assert that the recovery code failed_attempts is incremented + gotUser, err := userRepo.Get(ctx, tx, database.WithCondition( + database.And( + userRepo.IDCondition(existingUserID), + userRepo.InstanceIDCondition(instanceID), + ), + )) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"code1", "code2", "code3"}, gotUser.Human.RecoveryCodes.Codes) + assert.Zero(t, gotUser.Human.RecoveryCodes.LastSuccessfullyCheckedAt) + assert.Equal(t, uint8(1), gotUser.Human.RecoveryCodes.FailedAttempts) + }) +} + +func createUser(t *testing.T, + ctx context.Context, + tx *sql.Transaction, + userRepo domain.UserRepository, + instanceID, + orgID, + userID string, +) *user.Aggregate { + userAgg := user.NewAggregate(userID, orgID) + userAgg.InstanceID = instanceID + + err := userRepo.Create(ctx, tx, &domain.User{ + InstanceID: instanceID, + OrganizationID: orgID, + ID: userID, + Username: userID + gofakeit.Username(), + State: domain.UserStateActive, + Human: &domain.HumanUser{ + FirstName: gofakeit.FirstName(), + LastName: gofakeit.LastName(), + Email: domain.HumanEmail{ + Address: gofakeit.Email(), + VerifiedAt: time.Now(), + }, + }, + Metadata: []*domain.Metadata{ + { + Key: "key1", + Value: []byte("value1"), + }, + }, + }) + require.NoError(t, err) + + return userAgg +}