Files
zitadel/backend/v3/domain/org_update_test.go
T
f04d873270 feat(domain): Organization APIs with relational tables (#10704)
# Which Problems Are Solved

This PR implements the endpoints listed in #10445 using relational
tables.

- [x] UpdateOrganization
- [x] ListOrganizations
- [x] DeleteOrganization
- [x] DeactivateOrganization
- [x] ActivateOrganization

# How the Problems Are Solved

- **UpdateOrganization:** Implemented logic for `Validate()`,
`Execute()` and `Events()`. On CQRS side, updating an organization emits
events related to the organization and its domains. Separate commanders
have been made for the domain events so that their logic is run
accordingly and we keep separation of concerns. Implementation of the
domains is left to its own ticket.

Because some domain-related data is changed during the organization
update, its original values are saved as pointers inside
[UpdateOrgCommand](https://github.com/zitadel/zitadel/blob/4f87cd8d0cfd75f290aa13c342e4939cecebef73/backend/v3/domain/org_update.go#L20).

These variables will be then used as input for the domain commanders
(that will run after the update organization one).
- **ListOrganizations:** Implemented logic for `Validate()`,
`Execute()`. Utility methods have been made to parse the input queries.
This commander is used both by the v2 and v2beta endpoint, so a
translation layer has been put in place to translate all v2beta requests
into v2. The commander strictly handles v2 request only.

The translation layer between v2beta and v2 APIs was made so to make it
simple to remove it once the v2beta APIs will be removed (a simple
deletion should suffice). A `converter` package [is
shipped](https://github.com/zitadel/zitadel/blob/4f87cd8d0cfd75f290aa13c342e4939cecebef73/backend/v3/api/org/v2/convert/README.md#L5)
as well with instruction for removal. TODOs have been put in place all
over the code to indicate that the related piece of code needs to be
removed once v2beta -> v2 transition is complete.
- **DeleteOrganization:** Implemented logic for `Validate()`,
`Execute()` and `Events()`. The `Events()` method is mostly incomplete
as it requires a lot of data that is currently not retrievable due to
the lack of their relational tables.
- **DeactivateOrganization** and **ActivateOrganization**: Implemented
logic for `Validate()`, `Execute()` and `Events()`. No notable remarks.

👉 All endpoints return matching errors to the CQRS counterpart: this way
allows re-using the integration tests and

👉 All endpoints have been unit-tested through DB mocking

👉 A bunch of TODOs have been put in place for future reworks that are
needed (e.g. `Update()` method on repository should also return a
timestamp)

# Additional Changes

- A test utility method to allow mocking query options have been made.
This was necessary because `database.QueryOption` is a function and the
only comparison possible with functions is `nil` check. So, to mock
this, `database.QueryOption` is converted first to `database.QueryOpts`.
See 2276742adaef1998ea4844e1f0dca2079e45ccfb

- A `BaseCommand` has been created with the idea to collect methods that
are going to be needed by other commands. For the moment, it only offers
a method to convert query text operations from gRPC to `domain` model.
See bb85456dd101282fa76d2e2ad165eed4c8ef61b9

- SQL operations have been reworked to allow for queries using
`ContainsIgnoreCase`, `EndsWithIgnoreCase` and other `IgnoreCase`
operations. This change is pending approval and might be reworked. See
https://github.com/zitadel/zitadel/pull/10704#discussion_r2392886177

# Additional Context

`UpdateOrganization` is partially working because the logic for handling
domain updates is not done (there is only a draft of the commanders to
showcase the call chain). See
https://github.com/zitadel/zitadel/pull/10704#discussion_r2356143178

Permissions are not implemented as they are missing. See
https://github.com/zitadel/zitadel/pull/10771

- Closes: #10445

---------

Co-authored-by: Silvan <[email protected]>
2025-10-21 15:27:38 +02:00

493 lines
14 KiB
Go

package domain_test
import (
"context"
"errors"
"testing"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"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/zerrors"
)
func TestUpdateOrgCommand_Execute(t *testing.T) {
t.Parallel()
txInitErr := errors.New("tx init error")
getErr := errors.New("get error")
updateErr := errors.New("update error")
tt := []struct {
testName string
queryExecutor func(ctrl *gomock.Controller) database.QueryExecutor
orgRepo func(ctrl *gomock.Controller) domain.OrganizationRepository
inputID string
inputName string
expectedError error
expectedOldDomainName *string
expectedDomainVerified *bool
}{
{
testName: "when EnsureTx fails should return error",
queryExecutor: func(ctrl *gomock.Controller) database.QueryExecutor {
mockDB := dbmock.NewMockPool(ctrl)
mockDB.EXPECT().
Begin(gomock.Any(), gomock.Any()).
Times(1).
Return(nil, txInitErr)
return mockDB
},
expectedError: txInitErr,
},
{
testName: "when retrieving org fails should return error",
orgRepo: func(ctrl *gomock.Controller) domain.OrganizationRepository {
repo := domainmock.NewOrgRepo(ctrl)
repo.EXPECT().
LoadDomains().
Times(1).
Return(repo)
repo.EXPECT().
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(
database.WithCondition(
database.And(
repo.IDCondition("org-1"),
repo.InstanceIDCondition("instance-1"),
),
))).
Times(1).
Return(nil, getErr)
return repo
},
inputID: "org-1",
inputName: "test org update",
expectedError: getErr,
},
{
testName: "when setting domain info fails should return error",
orgRepo: func(ctrl *gomock.Controller) domain.OrganizationRepository {
repo := domainmock.NewOrgRepo(ctrl)
repo.EXPECT().
LoadDomains().
Times(1).
Return(repo)
repo.EXPECT().
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(database.WithCondition(
database.And(
repo.IDCondition("org-1"),
repo.InstanceIDCondition("instance-1"),
),
))).
Times(1).
Return(&domain.Organization{
ID: "org-1",
Name: "",
State: domain.OrgStateActive,
Domains: []*domain.OrganizationDomain{
{IsPrimary: true, IsVerified: true, Domain: "old org name"},
{IsPrimary: false, IsVerified: true, Domain: "old org name"},
{IsPrimary: false, IsVerified: true, Domain: "old primary org name"},
},
}, nil)
return repo
},
inputID: "org-1",
inputName: "test org update",
expectedError: zerrors.ThrowInvalidArgument(nil, "ORG-RrfXY", "Errors.Org.Domain.EmptyString"),
},
{
testName: "when org update fails should return error",
orgRepo: func(ctrl *gomock.Controller) domain.OrganizationRepository {
repo := domainmock.NewOrgRepo(ctrl)
repo.EXPECT().
LoadDomains().
Times(1).
Return(repo)
repo.EXPECT().
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(database.WithCondition(
database.And(
repo.IDCondition("org-1"),
repo.InstanceIDCondition("instance-1"),
),
))).
Times(1).
Return(&domain.Organization{
ID: "org-1",
Name: "old org name",
InstanceID: "instance-1",
State: domain.OrgStateActive,
Domains: []*domain.OrganizationDomain{
{IsPrimary: true, IsVerified: true, Domain: "old-org-name."},
{IsPrimary: false, IsVerified: true, Domain: "old-org-name-2."},
},
}, nil)
repo.EXPECT().
Update(gomock.Any(), gomock.Any(),
database.And(
repo.IDCondition("org-1"),
repo.InstanceIDCondition("instance-1"),
),
repo.SetName("test org update"),
).
Times(1).
Return(int64(0), updateErr)
return repo
},
inputID: "org-1",
inputName: "test org update",
expectedError: updateErr,
},
{
testName: "when org update returns 0 rows updated should return not found error",
inputID: "org-1",
inputName: "test org update",
orgRepo: func(ctrl *gomock.Controller) domain.OrganizationRepository {
repo := domainmock.NewOrgRepo(ctrl)
repo.EXPECT().
LoadDomains().
Times(1).
Return(repo)
repo.EXPECT().
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(database.WithCondition(
database.And(
repo.IDCondition("org-1"),
repo.InstanceIDCondition("instance-1"),
),
))).
Times(1).
Return(&domain.Organization{
ID: "org-1",
Name: "old org name",
InstanceID: "instance-1",
State: domain.OrgStateActive,
Domains: []*domain.OrganizationDomain{
{IsPrimary: true, IsVerified: true, Domain: "old-org-name."},
{IsPrimary: false, IsVerified: true, Domain: "old-org-name."},
{IsPrimary: false, IsVerified: true, Domain: "old-org-name-2."},
},
}, nil)
repo.EXPECT().
Update(gomock.Any(), gomock.Any(),
database.And(
repo.IDCondition("org-1"),
repo.InstanceIDCondition("instance-1"),
),
repo.SetName("test org update")).
Times(1).
Return(int64(0), nil)
return repo
},
expectedError: zerrors.ThrowNotFound(nil, "DOM-7PfSUn", "Errors.Org.NotFound"),
expectedOldDomainName: gu.Ptr("old-org-name."),
expectedDomainVerified: gu.Ptr(true),
},
{
testName: "when org update returns more than 1 row updated should return internal error",
orgRepo: func(ctrl *gomock.Controller) domain.OrganizationRepository {
repo := domainmock.NewOrgRepo(ctrl)
repo.EXPECT().
LoadDomains().
Times(1).
Return(repo)
repo.EXPECT().
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(database.WithCondition(
database.And(
repo.IDCondition("org-1"),
repo.InstanceIDCondition("instance-1"),
),
))).
Times(1).
Return(&domain.Organization{
ID: "org-1",
Name: "old org name",
InstanceID: "instance-1",
State: domain.OrgStateActive,
Domains: []*domain.OrganizationDomain{
{IsPrimary: true, IsVerified: true, Domain: "old-org-name."},
{IsPrimary: false, IsVerified: true, Domain: "old-org-name."},
{IsPrimary: false, IsVerified: true, Domain: "old-org-name-2."},
},
}, nil)
repo.EXPECT().
Update(gomock.Any(), gomock.Any(),
database.And(
repo.IDCondition("org-1"),
repo.InstanceIDCondition("instance-1"),
),
repo.SetName("test org update")).
Times(1).
Return(int64(2), nil)
return repo
},
inputID: "org-1",
inputName: "test org update",
expectedError: zerrors.ThrowInternal(domain.NewMultipleObjectsUpdatedError(1, 2), "DOM-QzITrx", "unexpected number of rows updated"),
expectedOldDomainName: gu.Ptr("old-org-name."),
expectedDomainVerified: gu.Ptr(true),
},
{
testName: "when org update returns 1 row updated should return no error and set non-primary verified domain",
orgRepo: func(ctrl *gomock.Controller) domain.OrganizationRepository {
repo := domainmock.NewOrgRepo(ctrl)
repo.EXPECT().
LoadDomains().
Times(1).
Return(repo)
repo.EXPECT().
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(database.WithCondition(
database.And(
repo.IDCondition("org-1"),
repo.InstanceIDCondition("instance-1"),
),
))).
Times(1).
Return(&domain.Organization{
ID: "org-1",
Name: "old org name",
InstanceID: "instance-1",
State: domain.OrgStateActive,
Domains: []*domain.OrganizationDomain{
{IsPrimary: true, IsVerified: true, Domain: "old-org-name."},
{IsPrimary: false, IsVerified: true, Domain: "old-org-name."},
{IsPrimary: false, IsVerified: true, Domain: "old-org-name-2."},
},
}, nil)
repo.EXPECT().
Update(gomock.Any(), gomock.Any(),
database.And(
repo.IDCondition("org-1"),
repo.InstanceIDCondition("instance-1"),
),
repo.SetName("test org update")).
Times(1).
Return(int64(1), nil)
return repo
},
inputID: "org-1",
inputName: "test org update",
expectedOldDomainName: gu.Ptr("old-org-name."),
expectedDomainVerified: gu.Ptr(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)
cmd := domain.NewUpdateOrgCommand(tc.inputID, tc.inputName)
opts := &domain.InvokeOpts{
DB: new(noopdb.Pool),
}
if tc.orgRepo != nil {
domain.WithOrganizationRepo(tc.orgRepo(ctrl))(opts)
}
if tc.queryExecutor != nil {
opts.DB = tc.queryExecutor(ctrl)
}
// Test
err := cmd.Execute(ctx, opts)
// Verify
assert.Equal(t, tc.expectedError, err)
assert.Equal(t, tc.expectedOldDomainName, cmd.OldDomainName)
assert.Equal(t, tc.expectedDomainVerified, cmd.IsOldDomainVerified)
})
}
}
func TestUpdateOrgCommand_Validate(t *testing.T) {
t.Parallel()
txInitErr := errors.New("tx init error")
getErr := errors.New("get error")
tt := []struct {
testName string
queryExecutor func(ctrl *gomock.Controller) database.QueryExecutor
orgRepo func(ctrl *gomock.Controller) domain.OrganizationRepository
inputOrgID string
inputOrgName string
expectedError error
}{
{
testName: "when no ID should return invalid argument error",
inputOrgID: "",
inputOrgName: "test-name",
expectedError: zerrors.ThrowInvalidArgument(nil, "DOM-lEMhVC", "invalid organization ID"),
},
{
testName: "when no name shuld return invalid argument error",
inputOrgID: "test-id",
inputOrgName: "",
expectedError: zerrors.ThrowInvalidArgument(nil, "DOM-wfUntW", "invalid organization name"),
},
{
testName: "when EnsureTx fails should return error",
queryExecutor: func(ctrl *gomock.Controller) database.QueryExecutor {
mockDB := dbmock.NewMockPool(ctrl)
mockDB.EXPECT().
Begin(gomock.Any(), gomock.Any()).
Times(1).
Return(nil, txInitErr)
return mockDB
},
inputOrgID: "test-id",
inputOrgName: "test-name",
expectedError: txInitErr,
},
{
testName: "when retrieving org fails should return error",
orgRepo: func(ctrl *gomock.Controller) domain.OrganizationRepository {
repo := domainmock.NewOrgRepo(ctrl)
repo.EXPECT().
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(
database.WithCondition(
database.And(
repo.IDCondition("org-1"),
repo.InstanceIDCondition("instance-1"),
),
))).
Times(1).
Return(nil, getErr)
return repo
},
inputOrgID: "org-1",
inputOrgName: "test org update",
expectedError: getErr,
},
{
testName: "when org name is not changed should return name not changed error",
orgRepo: func(ctrl *gomock.Controller) domain.OrganizationRepository {
repo := domainmock.NewOrgRepo(ctrl)
repo.EXPECT().
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(database.WithCondition(
database.And(
repo.IDCondition("org-1"),
repo.InstanceIDCondition("instance-1"),
),
))).
Times(1).
Return(&domain.Organization{
ID: "org-1",
Name: "test org update",
}, nil)
return repo
},
inputOrgID: "org-1",
inputOrgName: "test org update",
expectedError: zerrors.ThrowPreconditionFailed(nil, "DOM-nDzwIu", "Errors.Org.NotChanged"),
},
{
testName: "when org name is changed should validate successfully and return no error",
orgRepo: func(ctrl *gomock.Controller) domain.OrganizationRepository {
repo := domainmock.NewOrgRepo(ctrl)
repo.EXPECT().
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(database.WithCondition(
database.And(
repo.IDCondition("org-1"),
repo.InstanceIDCondition("instance-1"),
),
))).
Times(1).
Return(&domain.Organization{
ID: "org-1",
Name: "old org name",
}, nil)
return repo
},
inputOrgID: "org-1",
inputOrgName: "test org update",
},
}
for _, tc := range tt {
t.Run(tc.testName, func(t *testing.T) {
t.Parallel()
// Given
ctx := authz.NewMockContext("instance-1", "", "")
ctrl := gomock.NewController(t)
cmd := domain.NewUpdateOrgCommand(tc.inputOrgID, tc.inputOrgName)
opts := &domain.InvokeOpts{
DB: new(noopdb.Pool),
}
if tc.orgRepo != nil {
domain.WithOrganizationRepo(tc.orgRepo(ctrl))(opts)
}
if tc.queryExecutor != nil {
opts.DB = tc.queryExecutor(ctrl)
}
err := cmd.Validate(ctx, opts)
assert.Equal(t, tc.expectedError, err)
})
}
}
func TestUpdateOrgCommand_Events(t *testing.T) {
t.Parallel()
tests := []struct {
name string
cmd *domain.UpdateOrgCommand
expectedCount int
}{
{
name: "no old name, no events",
cmd: &domain.UpdateOrgCommand{
ID: "org-1",
Name: "new-name",
OldDomainName: nil,
},
expectedCount: 0,
},
{
name: "old name equals new name, no events",
cmd: &domain.UpdateOrgCommand{
ID: "org-1",
Name: "same-name",
OldDomainName: gu.Ptr("same-name"),
},
expectedCount: 0,
},
{
name: "old name different from new name, returns event",
cmd: &domain.UpdateOrgCommand{
ID: "org-1",
Name: "new-name",
OldDomainName: gu.Ptr("old-name"),
},
expectedCount: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
events, err := tt.cmd.Events(context.Background(), &domain.InvokeOpts{})
require.Nil(t, err)
assert.Len(t, events, tt.expectedCount)
})
}
}