mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
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]>
221 lines
7.0 KiB
Go
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
|
|
}
|