Files
zitadel/internal/command/project_application_test.go
T
0d9169af03 feat(eventstore): remove application constraints by app owner (#12798)
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
# Which Problems Are Solved

Deleting an application loads the SAML config only to learn the entity
id, then deletes the `entity_ids` unique constraint by that field. The
app name constraint is deleted by `name:projectID`. Neither row is
tagged with the app id, so the delete cannot use the owner index.

# How the Problems Are Solved

`appname` and `entity_ids` rows are tagged `app:<appID>`, in addition to
the existing org and project tags.

- Live writes stamp the tag on application add/rename and on SAML
entity-id add/change.
- Setup 79 appends `app:<appID>` on the next version run, including rows
that already have `org` and `project` owners.
- When the owners backfill is finalized, `RemoveApplication` emits
`RemoveByOwner(app, appID)` and does not read the SAML config.
- Before that backfill, removal still deletes by the app name and, for
SAML apps, by the entity id.

Renames stay field deletes. Project names and roles stay tagged with org
and project only.

# Additional Changes

- `UniqueConstraintOwnerApp` is added next to the other owner kinds.

# Additional Context

- Base branch: `v4.x`
- Follow-up for the unique-constraint owners work (setup 78/79)

Verified locally:

- `go test ./cmd/setup -run TestBackfillUniqueConstraintOwnersStmts`
- `go test ./internal/command -run
'TestCommandSide_RemoveApplication|TestCommandSide_RemoveDynamicOIDCClient'`
- `go test ./internal/eventstore ./internal/v2/eventstore`

<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a
href="https://cursor.com/agents/bc-31bfb5c4-5888-43f0-85cf-af1af097ca30?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/background-agent?bcId=bc-31bfb5c4-5888-43f0-85cf-af1af097ca30&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img
alt="Open in Cursor" width="131" height="28"
src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Silvan <adlerhurst@users.noreply.github.com>
2026-09-23 07:17:58 +02:00

708 lines
17 KiB
Go

package command
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/repository/mock"
"github.com/zitadel/zitadel/internal/repository/project"
"github.com/zitadel/zitadel/internal/zerrors"
)
func TestCommandSide_ChangeApplication(t *testing.T) {
t.Parallel()
type fields struct {
eventstore func(*testing.T) *eventstore.Eventstore
}
type args struct {
ctx context.Context
projectID string
app *domain.ChangeApp
resourceOwner string
}
type res struct {
want *domain.ObjectDetails
err func(error) bool
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
name: "invalid app missing projectid, invalid argument error",
fields: fields{
eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}),
},
args: args{
ctx: context.Background(),
projectID: "",
app: &domain.ChangeApp{
AppID: "app1",
AppName: "app",
},
resourceOwner: "org1",
},
res: res{
err: zerrors.IsErrorInvalidArgument,
},
},
{
name: "invalid app missing appid, invalid argument error",
fields: fields{
eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}),
},
args: args{
ctx: context.Background(),
projectID: "project1",
app: &domain.ChangeApp{
AppName: "app",
},
resourceOwner: "org1",
},
res: res{
err: zerrors.IsErrorInvalidArgument,
},
},
{
name: "invalid app missing name, invalid argument error",
fields: fields{
eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}),
},
args: args{
ctx: context.Background(),
projectID: "project1",
app: &domain.ChangeApp{
AppID: "app1",
AppName: "",
},
resourceOwner: "org1",
},
res: res{
err: zerrors.IsErrorInvalidArgument,
},
},
{
name: "app not existing, not found error",
fields: fields{
eventstore: expectEventstore(expectFilter()),
},
args: args{
ctx: context.Background(),
projectID: "project1",
app: &domain.ChangeApp{
AppID: "app1",
AppName: "app",
},
resourceOwner: "org1",
},
res: res{
err: zerrors.IsNotFound,
},
},
{
name: "app name not changed, not found error",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"app",
)),
),
),
},
args: args{
ctx: context.Background(),
projectID: "project1",
app: &domain.ChangeApp{
AppID: "app1",
AppName: "app",
},
resourceOwner: "org1",
},
res: res{
err: zerrors.IsPreconditionFailed,
},
},
{
name: "app changed, ok",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"app",
)),
),
expectFilter(
eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"app",
)),
),
expectPush(
project.NewApplicationChangedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"app",
"app changed",
),
),
),
},
args: args{
ctx: context.Background(),
projectID: "project1",
app: &domain.ChangeApp{
AppID: "app1",
AppName: "app changed",
},
resourceOwner: "org1",
},
res: res{
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
r := &Commands{
eventstore: tt.fields.eventstore(t),
checkPermission: newMockPermissionCheckAllowed(),
}
got, err := r.UpdateApplicationName(tt.args.ctx, tt.args.projectID, tt.args.app, tt.args.resourceOwner)
if tt.res.err == nil {
assert.NoError(t, err)
}
if tt.res.err != nil && !tt.res.err(err) {
t.Errorf("got wrong err: %v ", err)
}
if tt.res.err == nil {
assertObjectDetails(t, tt.res.want, got)
}
})
}
}
func TestCommandSide_DeactivateApplication(t *testing.T) {
t.Parallel()
type fields struct {
eventstore func(*testing.T) *eventstore.Eventstore
}
type args struct {
ctx context.Context
projectID string
appID string
resourceOwner string
}
type res struct {
want *domain.ObjectDetails
err func(error) bool
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
name: "missing projectid, invalid argument error",
fields: fields{
eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}),
},
args: args{
ctx: context.Background(),
projectID: "",
appID: "app1",
resourceOwner: "org1",
},
res: res{
err: zerrors.IsErrorInvalidArgument,
},
},
{
name: "missing appid, invalid argument error",
fields: fields{
eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}),
},
args: args{
ctx: context.Background(),
projectID: "project1",
appID: "",
resourceOwner: "org1",
},
res: res{
err: zerrors.IsErrorInvalidArgument,
},
},
{
name: "app not existing, not found error",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
),
},
args: args{
ctx: context.Background(),
projectID: "project1",
appID: "app1",
resourceOwner: "org1",
},
res: res{
err: zerrors.IsNotFound,
},
},
{
name: "app already inactive, precondition error",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"app",
)),
eventFromEventPusher(project.NewApplicationDeactivatedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
)),
),
),
},
args: args{
ctx: context.Background(),
projectID: "project1",
appID: "app1",
resourceOwner: "org1",
},
res: res{
err: zerrors.IsPreconditionFailed,
},
},
{
name: "app deactivate, ok",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"app",
)),
),
expectFilter(
eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"app",
)),
),
expectPush(
project.NewApplicationDeactivatedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
),
),
),
},
args: args{
ctx: context.Background(),
projectID: "project1",
appID: "app1",
resourceOwner: "org1",
},
res: res{
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
r := &Commands{
eventstore: tt.fields.eventstore(t),
checkPermission: newMockPermissionCheckAllowed(),
}
got, err := r.DeactivateApplication(tt.args.ctx, tt.args.projectID, tt.args.appID, tt.args.resourceOwner)
if tt.res.err == nil {
assert.NoError(t, err)
}
if tt.res.err != nil && !tt.res.err(err) {
t.Errorf("got wrong err: %v ", err)
}
if tt.res.err == nil {
assertObjectDetails(t, tt.res.want, got)
}
})
}
}
func TestCommandSide_ReactivateApplication(t *testing.T) {
t.Parallel()
type fields struct {
eventstore func(*testing.T) *eventstore.Eventstore
}
type args struct {
ctx context.Context
projectID string
appID string
resourceOwner string
}
type res struct {
want *domain.ObjectDetails
err func(error) bool
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
name: "missing projectid, invalid argument error",
fields: fields{
eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}),
},
args: args{
ctx: context.Background(),
projectID: "",
appID: "app1",
resourceOwner: "org1",
},
res: res{
err: zerrors.IsErrorInvalidArgument,
},
},
{
name: "missing appid, invalid argument error",
fields: fields{
eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}),
},
args: args{
ctx: context.Background(),
projectID: "project1",
appID: "",
resourceOwner: "org1",
},
res: res{
err: zerrors.IsErrorInvalidArgument,
},
},
{
name: "app not existing, not found error",
fields: fields{
eventstore: expectEventstore(expectFilter()),
},
args: args{
ctx: context.Background(),
projectID: "project1",
appID: "app1",
resourceOwner: "org1",
},
res: res{
err: zerrors.IsNotFound,
},
},
{
name: "app already active, precondition error",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"app",
)),
),
),
},
args: args{
ctx: context.Background(),
projectID: "project1",
appID: "app1",
resourceOwner: "org1",
},
res: res{
err: zerrors.IsPreconditionFailed,
},
},
{
name: "app reactivate, ok",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"app",
)),
eventFromEventPusher(project.NewApplicationDeactivatedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
)),
),
expectPush(
project.NewApplicationReactivatedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
),
),
),
},
args: args{
ctx: context.Background(),
projectID: "project1",
appID: "app1",
resourceOwner: "org1",
},
res: res{
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
r := &Commands{
eventstore: tt.fields.eventstore(t),
checkPermission: newMockPermissionCheckAllowed(),
}
got, err := r.ReactivateApplication(tt.args.ctx, tt.args.projectID, tt.args.appID, tt.args.resourceOwner)
if tt.res.err == nil {
assert.NoError(t, err)
}
if tt.res.err != nil && !tt.res.err(err) {
t.Errorf("got wrong err: %v ", err)
}
if tt.res.err == nil {
assertObjectDetails(t, tt.res.want, got)
}
})
}
}
func TestCommandSide_RemoveApplication(t *testing.T) {
t.Parallel()
type fields struct {
eventstore func(*testing.T) *eventstore.Eventstore
}
type args struct {
ctx context.Context
projectID string
appID string
resourceOwner string
}
type res struct {
want *domain.ObjectDetails
err func(error) bool
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
name: "missing projectid, invalid argument error",
fields: fields{
eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}),
},
args: args{
ctx: context.Background(),
projectID: "",
appID: "app1",
resourceOwner: "org1",
},
res: res{
err: zerrors.IsErrorInvalidArgument,
},
},
{
name: "missing appid, invalid argument error",
fields: fields{
eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}),
},
args: args{
ctx: context.Background(),
projectID: "project1",
appID: "",
resourceOwner: "org1",
},
res: res{
err: zerrors.IsErrorInvalidArgument,
},
},
{
name: "app not existing, not found error",
fields: fields{
eventstore: expectEventstore(expectFilter()),
},
args: args{
ctx: context.Background(),
projectID: "project1",
appID: "app1",
resourceOwner: "org1",
},
res: res{
err: zerrors.IsNotFound,
},
},
{
name: "app remove, entityID, ok",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"app",
)),
),
expectFilter(),
expectFilter(
eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"app",
)),
eventFromEventPusher(project.NewSAMLConfigAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"https://test.com/saml/metadata",
[]byte("<?xml version=\"1.0\"?>\n<md:EntityDescriptor xmlns:md=\"urn:oasis:names:tc:SAML:2.0:metadata\"\n validUntil=\"2022-08-26T14:08:16Z\"\n cacheDuration=\"PT604800S\"\n entityID=\"https://test.com/saml/metadata\">\n <md:SPSSODescriptor AuthnRequestsSigned=\"false\" WantAssertionsSigned=\"false\" protocolSupportEnumeration=\"urn:oasis:names:tc:SAML:2.0:protocol\">\n <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat>\n <md:AssertionConsumerService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\"\n Location=\"https://test.com/saml/acs\"\n index=\"1\" />\n \n </md:SPSSODescriptor>\n</md:EntityDescriptor>"),
"",
domain.LoginVersionUnspecified,
"",
)),
),
expectPush(
project.NewApplicationRemovedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"app",
"https://test.com/saml/metadata",
),
),
),
},
args: args{
ctx: context.Background(),
projectID: "project1",
appID: "app1",
resourceOwner: "org1",
},
res: res{
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
{
name: "app remove, ok",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"app",
)),
),
// app is not saml, or no saml config available
expectFilter(),
expectFilter(),
expectPush(
project.NewApplicationRemovedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"app",
"",
),
),
),
},
args: args{
ctx: context.Background(),
projectID: "project1",
appID: "app1",
resourceOwner: "org1",
},
res: res{
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
r := &Commands{
eventstore: tt.fields.eventstore(t),
checkPermission: newMockPermissionCheckAllowed(),
}
got, err := r.RemoveApplication(tt.args.ctx, tt.args.projectID, tt.args.appID, tt.args.resourceOwner)
if tt.res.err == nil {
assert.NoError(t, err)
}
if tt.res.err != nil && !tt.res.err(err) {
t.Errorf("got wrong err: %v ", err)
}
if tt.res.err == nil {
assertObjectDetails(t, tt.res.want, got)
}
})
}
}
func TestCommandSide_RemoveApplicationOwnerDeleteReady(t *testing.T) {
r := &Commands{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"app",
)),
),
expectFilter(),
expectPush(
project.NewApplicationRemovedByOwnerEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
),
),
)(t),
checkPermission: newMockPermissionCheckAllowed(),
ownerDeleteReady: func(context.Context) (bool, error) { return true, nil },
}
got, err := r.RemoveApplication(context.Background(), "project1", "app1", "org1")
require.NoError(t, err)
assertObjectDetails(t, &domain.ObjectDetails{ResourceOwner: "org1"}, got)
}