mirror of
https://github.com/zitadel/zitadel.git
synced 2026-11-02 09:49:11 -06:00
<!-- 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> <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> </div> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Silvan <adlerhurst@users.noreply.github.com>
213 lines
8.9 KiB
Go
213 lines
8.9 KiB
Go
package setup
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/zitadel/zitadel/internal/eventstore"
|
|
"github.com/zitadel/zitadel/internal/migration"
|
|
"github.com/zitadel/zitadel/internal/query/projection"
|
|
)
|
|
|
|
var _ migration.RepeatableMigration = (*BackfillUniqueConstraintOwners)(nil)
|
|
|
|
func TestBackfillUniqueConstraintOwnersStmts(t *testing.T) {
|
|
statements, err := backfillUniqueConstraintOwnersStatements()
|
|
require.NoError(t, err)
|
|
|
|
want := []struct {
|
|
file string
|
|
uniqueType string
|
|
field string
|
|
table string
|
|
extra []string
|
|
}{
|
|
{"01_usernames_org_scoped.sql", "usernames", "u.username || u.resource_owner", projection.UserTable, []string{"'org:' || u.resource_owner"}},
|
|
{"02_usernames.sql", "usernames", "u.username, lower(u.username)", projection.UserTable, []string{"'org:' || u.resource_owner", "user_login_must_be_domain"}},
|
|
{"03_external_idps.sql", "external_idps", "l.idp_id || l.external_user_id", projection.IDPUserLinkTable, []string{"'idp:' || l.idp_id"}},
|
|
{"04_org_name.sql", "org_name", "o.name", projection.OrgProjectionTable, nil},
|
|
{"05_org_domain.sql", "org_domain", "d.domain", projection.OrgDomainTable, []string{"d.is_verified"}},
|
|
{"06_project_names.sql", "project_names", "p.name || p.resource_owner", projection.ProjectProjectionTable, nil},
|
|
{"07_appname.sql", "appname", "a.name || ':' || a.project_id", projection.AppProjectionTable, []string{"'app:' || a.id"}},
|
|
{"08_project_role.sql", "project_role", "r.role_key || ':' || r.project_id", projection.ProjectRoleProjectionTable, nil},
|
|
{"09_entity_ids.sql", "entity_ids", "s.entity_id", projection.AppProjectionTable, []string{projection.AppSAMLTable, "'app:' || a.id"}},
|
|
{"10_project_grant.sql", "project_grant", "g.granted_org_id || ':' || g.project_id", projection.ProjectGrantProjectionTable, []string{"'org:' || g.granted_org_id", "'grant:' || g.grant_id"}},
|
|
{"11_project_grant_member.sql", "project_grant_member", "m.project_id || ':' || m.user_id || ':' || m.grant_id", projection.ProjectGrantMemberProjectionTable, []string{"'grant:' || m.grant_id", "m.user_resource_owner", "m.granted_org"}},
|
|
{"12_user_grant.sql", "user_grant", "g.resource_owner || ':' || g.user_id || ':' || g.project_id || ':' || g.grant_id", projection.UserGrantProjectionTable, []string{"'grant:' || g.grant_id", "g.resource_owner_user", "g.granted_org"}},
|
|
{"13_member_org.sql", "member", "m.org_id || ':' || m.user_id", projection.OrgMemberProjectionTable, []string{"m.user_resource_owner"}},
|
|
{"14_member_project.sql", "member", "m.project_id || ':' || m.user_id", projection.ProjectMemberProjectionTable, []string{"m.user_resource_owner"}},
|
|
{"15_member_instance.sql", "member", "m.id || ':' || m.user_id", projection.InstanceMemberProjectionTable, []string{"m.user_resource_owner"}},
|
|
{"17_action_names.sql", "action_names", "a.name || ':' || a.resource_owner", projection.ActionTable, nil},
|
|
{"18_idp_config_names.sql", "idp_config_names", "i.name || i.resource_owner", projection.IDPTable, []string{"'idp:' || i.id"}},
|
|
{"19_idp_config_names_templates.sql", "idp_config_names", "t.name || t.resource_owner", projection.IDPTemplateTable, []string{"'idp:' || t.id"}},
|
|
{"20_mail_text.sql", "mail_text", "t.aggregate_id || ':' || t.type || ':' || t.language", projection.MessageTextTable, []string{"t.aggregate_id <> t.instance_id"}},
|
|
}
|
|
|
|
require.Len(t, statements, len(want))
|
|
for i, stmt := range statements {
|
|
assert.Equal(t, want[i].file, stmt.file)
|
|
assert.Contains(t, stmt.query, "unique_type = '"+want[i].uniqueType+"'")
|
|
assert.Contains(t, stmt.query, want[i].field)
|
|
assert.Contains(t, stmt.query, want[i].table)
|
|
assert.Contains(t, stmt.query, "uc.unique_field IN (")
|
|
assert.NotContains(t, stmt.query, "lower(uc.unique_field)")
|
|
assert.NotContains(t, stmt.query, "{{.")
|
|
for _, extra := range want[i].extra {
|
|
assert.Contains(t, stmt.query, extra)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBackfillUniqueConstraintOwners_CheckAndFinalize(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
forceFinalize bool
|
|
lastRun map[string]interface{}
|
|
wantRun bool
|
|
wantFinalized bool
|
|
}{
|
|
{
|
|
name: "missing lastRun runs without finalizing",
|
|
lastRun: nil,
|
|
wantRun: true,
|
|
wantFinalized: false,
|
|
},
|
|
{
|
|
name: "empty lastRun runs without finalizing",
|
|
lastRun: map[string]interface{}{},
|
|
wantRun: true,
|
|
wantFinalized: false,
|
|
},
|
|
{
|
|
name: "missing lastRun with ForceFinalize finalizes",
|
|
forceFinalize: true,
|
|
lastRun: nil,
|
|
wantRun: true,
|
|
wantFinalized: true,
|
|
},
|
|
{
|
|
name: "same version not finalized skips",
|
|
lastRun: map[string]interface{}{"version": "v2.0.0", "finalized": false},
|
|
wantRun: false,
|
|
wantFinalized: false,
|
|
},
|
|
{
|
|
name: "same version missing finalized skips",
|
|
lastRun: map[string]interface{}{"version": "v2.0.0"},
|
|
wantRun: false,
|
|
wantFinalized: false,
|
|
},
|
|
{
|
|
name: "same version not finalized with ForceFinalize runs and finalizes",
|
|
forceFinalize: true,
|
|
lastRun: map[string]interface{}{"version": "v2.0.0", "finalized": false},
|
|
wantRun: true,
|
|
wantFinalized: true,
|
|
},
|
|
{
|
|
name: "same version already finalized skips",
|
|
lastRun: map[string]interface{}{"version": "v2.0.0", "finalized": true},
|
|
wantRun: false,
|
|
wantFinalized: true,
|
|
},
|
|
{
|
|
name: "same version already finalized with ForceFinalize skips",
|
|
forceFinalize: true,
|
|
lastRun: map[string]interface{}{"version": "v2.0.0", "finalized": true},
|
|
wantRun: false,
|
|
wantFinalized: true,
|
|
},
|
|
{
|
|
name: "version changed runs and finalizes",
|
|
lastRun: map[string]interface{}{"version": "v1.0.0", "finalized": false},
|
|
wantRun: true,
|
|
wantFinalized: true,
|
|
},
|
|
{
|
|
name: "version changed keeps finalized true",
|
|
lastRun: map[string]interface{}{"version": "v1.0.0", "finalized": true},
|
|
wantRun: true,
|
|
wantFinalized: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
mig := &BackfillUniqueConstraintOwners{Version: "v2.0.0", ForceFinalize: tt.forceFinalize}
|
|
assert.Equal(t, tt.wantRun, mig.Check(tt.lastRun))
|
|
assert.False(t, mig.Finalized, "Check must not set finalized before Execute")
|
|
mig.finalizeAfterSuccess()
|
|
assert.Equal(t, tt.wantFinalized, mig.Finalized)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBackfillUniqueConstraintOwners_SequentialVersionChanges(t *testing.T) {
|
|
mig := &BackfillUniqueConstraintOwners{Version: "v1.0.0"}
|
|
require.True(t, mig.Check(nil))
|
|
mig.finalizeAfterSuccess()
|
|
assert.False(t, mig.Finalized)
|
|
|
|
lastRun := map[string]interface{}{"version": mig.Version, "finalized": mig.Finalized}
|
|
mig = &BackfillUniqueConstraintOwners{Version: "v2.0.0"}
|
|
require.True(t, mig.Check(lastRun))
|
|
mig.finalizeAfterSuccess()
|
|
assert.True(t, mig.Finalized)
|
|
|
|
lastRun = map[string]interface{}{"version": mig.Version, "finalized": mig.Finalized}
|
|
mig = &BackfillUniqueConstraintOwners{Version: "v3.0.0"}
|
|
require.True(t, mig.Check(lastRun))
|
|
mig.finalizeAfterSuccess()
|
|
assert.True(t, mig.Finalized)
|
|
}
|
|
|
|
func TestBackfillUniqueConstraintOwners_ForceFinalizeRestart(t *testing.T) {
|
|
mig := &BackfillUniqueConstraintOwners{Version: "v1.0.0"}
|
|
require.True(t, mig.Check(nil))
|
|
mig.finalizeAfterSuccess()
|
|
require.False(t, mig.Finalized)
|
|
|
|
lastRun := map[string]interface{}{"version": "v1.0.0", "finalized": false}
|
|
mig = &BackfillUniqueConstraintOwners{Version: "v1.0.0", ForceFinalize: true}
|
|
require.True(t, mig.Check(lastRun), "restart with ForceFinalize should re-run")
|
|
assert.False(t, mig.Finalized, "Check must not set finalized before Execute")
|
|
mig.finalizeAfterSuccess()
|
|
assert.True(t, mig.Finalized)
|
|
|
|
lastRun = map[string]interface{}{"version": "v1.0.0", "finalized": true}
|
|
mig = &BackfillUniqueConstraintOwners{Version: "v1.0.0", ForceFinalize: true}
|
|
assert.False(t, mig.Check(lastRun), "later restart with ForceFinalize should skip")
|
|
}
|
|
|
|
func TestUniqueTypesWithOwnersOmitsMailText(t *testing.T) {
|
|
assert.NotContains(t, eventstore.UniqueTypesWithOwners, "mail_text")
|
|
assert.Contains(t, eventstore.UniqueTypesWithOwners, "usernames")
|
|
assert.Contains(t, eventstore.UniqueTypesWithOwners, "idp_config_names")
|
|
}
|
|
|
|
func TestBackfillUniqueConstraintOwnersJSONOmitsForceFinalize(t *testing.T) {
|
|
mig := &BackfillUniqueConstraintOwners{
|
|
Version: "v2.0.0",
|
|
Finalized: true,
|
|
ForceFinalize: true,
|
|
lastVersion: "v1.0.0",
|
|
lastFinalized: true,
|
|
}
|
|
data, err := json.Marshal(mig)
|
|
require.NoError(t, err)
|
|
|
|
var payload map[string]any
|
|
require.NoError(t, json.Unmarshal(data, &payload))
|
|
assert.Equal(t, "v2.0.0", payload["version"])
|
|
assert.Equal(t, true, payload["finalized"])
|
|
assert.NotContains(t, payload, "ForceFinalize")
|
|
assert.NotContains(t, payload, "forceFinalize")
|
|
assert.NotContains(t, payload, "lastVersion")
|
|
assert.NotContains(t, payload, "lastFinalized")
|
|
assert.False(t, strings.Contains(string(data), "v1.0.0"))
|
|
}
|