Files
zitadel/internal/query/projection/user_relational_test.go
T
f138082b64 feat: add administrator repository (#11803)
Introduces the `administrators` relational table and repository layer
for the v3 backend, unifying instance, organization, project, and
project-grant membership into a single scoped model.

Closes #10220

## Changes

- **Domain**: `Administrator` struct, `AdministratorScope` enum,
`AdministratorRepository` interface with typed CRUD, column, condition,
and change helpers. Generated enumer and GoMock.
- **Migration** (`016`): `zitadel.administrators` table with a generated
`id`, scope-alignment check constraint, cascading foreign keys, and a
child `administrator_roles` table.
- **Repository**: CTE-based role mutations (`AddRole`, `RemoveRole`,
`SetRoles`) for atomicity. Roles aggregated via `ARRAY_AGG`.
- **Projection**: Event reducers for
`member.added/changed/removed/cascade_removed` across all four admin
scopes.
- **Fixes**: `Rows.Close()` error wrapping; `TextArray` nil-safe scan +
full `pgtype` array interface; `authorization` and `projectGrant` scan
helpers fixed for `ARRAY_AGG` under `database/sql`; removed unused `ctx`
parameter from `callReduce`.

---------

Co-authored-by: Marco Ardizzone <[email protected]>
2026-03-18 10:24:09 +01:00

221 lines
7.0 KiB
Go

package projection
import (
"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 := &relationalTablesProjection{}
rawTx, tx := getTransactions(t)
t.Cleanup(func() {
require.NoError(t, rawTx.Rollback())
})
// create instance
instanceRepo := repository.InstanceRepository()
instanceID := gofakeit.UUID()
orgID := gofakeit.UUID()
err := instanceRepo.Create(t.Context(), tx, &domain.Instance{
ID: instanceID,
Name: "test-instance",
DefaultOrgID: orgID,
})
require.NoError(t, err)
// create org
orgRepo := repository.OrganizationRepository()
err = orgRepo.Create(t.Context(), 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, tx, userRepo, instanceID, orgID, existingUserID)
// set `user.human.mfa.recoverycode.added` event
recoveryCodes := []string{"code1", "code2", "code3"}
recoveryCodesAddedEvent := user.NewHumanRecoveryCodesAddedEvent(
t.Context(),
&existingUserAgg.Aggregate,
recoveryCodes,
nil)
// reduce the event
eventReduced := callReduce(t, rawTx, handler, recoveryCodesAddedEvent)
require.True(t, eventReduced)
// assert that the recovery codes are stored
gotUser, err := userRepo.Get(t.Context(), 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, tx, userRepo, instanceID, orgID, existingUserID)
// add recovery codes to the user
_, err = userRepo.Update(t.Context(), 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(
t.Context(),
&existingUserAgg.Aggregate,
nil)
// reduce the event
eventReduced := callReduce(t, rawTx, handler, recoveryCodesRemovedEvent)
require.True(t, eventReduced)
// assert that the recovery codes are removed
gotUser, err := userRepo.Get(t.Context(), 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, tx, userRepo, instanceID, orgID, existingUserID)
// add recovery codes to the user
_, err = userRepo.Update(t.Context(), 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(
t.Context(),
&existingUserAgg.Aggregate,
"code1",
nil)
// reduce the event
eventReduced := callReduce(t, 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(t.Context(), 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, tx, userRepo, instanceID, orgID, existingUserID)
// add recovery codes to the user
_, err = userRepo.Update(t.Context(), 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(
t.Context(),
&existingUserAgg.Aggregate,
nil,
)
// reduce the event
eventReduced := callReduce(t, rawTx, handler, recoveryCodeCheckFailedEvent)
require.True(t, eventReduced)
// assert that the recovery code failed_attempts is incremented
gotUser, err := userRepo.Get(t.Context(), 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,
tx *sql.Transaction,
userRepo domain.UserRepository,
instanceID,
orgID,
userID string,
) *user.Aggregate {
userAgg := user.NewAggregate(userID, orgID)
userAgg.InstanceID = instanceID
err := userRepo.Create(t.Context(), 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
}