Files
zitadel/internal/eventstore/eventstore_pusher_owners_test.go
T
0a08e7269a fix(eventstore): omit owners on empty unique constraint inserts (#12785)
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
# Which Problems Are Solved

After #12740, setup on existing deployments (v4.17.x → v4.18.0) fails
before migration 78 can add `eventstore.unique_constraints.owners`.
Starting any setup step pushes `system.migration.started`, which inserts
a unique constraint that names the `owners` column. On upgraded
databases that column does not exist yet, so setup dies on
`76_users14_login_equality_indexes` with `SQLSTATE 42703`.

# How the Problems Are Solved

Empty-owner unique-constraint inserts no longer name the `owners`
column:

- `len(Owners) == 0` → `INSERT (instance_id, unique_type, unique_field)`
- tagged `WithOwners(...)` → unchanged `INSERT (..., owners)`

Setup started/done events never stamp owners, so 76–78 can start.
Migration 78 still adds the column; after that, omitted `owners` uses
`DEFAULT '{}'`. Both v2 and v3 pushers apply the same rule. Mixed pushes
split into two INSERT batches so tagged and empty rows do not share a
column list.

# Additional Changes

- v2 SQL mocks now expect the three-column INSERT for untagged adds,
plus tagged and mixed cases
- Pusher tests cover mixed tagged+empty adds and adds against a table
that does not have `owners` yet
- The missing-column pusher test drops `owners` and pushes in one
transaction (`PushWithClient`), then rolls back so the shared test
schema is not mutated. Other tests may stall on `AccessExclusiveLock`
until rollback.

# Additional Context

- Closes #12780
- Follow-up for PR #12740
- Operators do not need to re-run init or apply DDL outside setup

Verified locally:

- `go test ./internal/eventstore ./internal/v2/eventstore/postgres
./internal/eventstore/v3` (including `-race` on those packages)
- Empty-owner insert succeeds when the `owners` column is missing;
tagged insert still requires the column

<!-- 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 <[email protected]>
Co-authored-by: Silvan <[email protected]>
2026-09-22 15:36:44 +00:00

373 lines
13 KiB
Go

package eventstore_test
import (
"context"
"database/sql"
"errors"
"testing"
"github.com/jackc/pgx/v5/pgconn"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/zerrors"
)
func TestEventstore_Push_UniqueConstraintOwners(t *testing.T) {
for pusherName, pusher := range pushers {
t.Run(pusherName, func(t *testing.T) {
runUniqueConstraintOwnerCases(t, pusher, clients[pusherName])
})
}
}
func runUniqueConstraintOwnerCases(t *testing.T, pusher eventstore.Pusher, client *database.DB) {
t.Helper()
db := eventstore.NewEventstore(&eventstore.Config{
Querier: queriers["v2(inmemory)"],
Pusher: pusher,
})
const instanceID = "owners-instance"
for _, tc := range uniqueConstraintOwnerCases {
t.Run(tc.name, func(t *testing.T) {
t.Cleanup(cleanupEventstore(client))
tc.run(t, db, client, instanceID)
})
}
}
type uniqueConstraintOwnerCase struct {
name string
run func(t *testing.T, db *eventstore.Eventstore, client *database.DB, instanceID string)
}
var uniqueConstraintOwnerCases = []uniqueConstraintOwnerCase{
{
name: "add stores owners",
run: func(t *testing.T, db *eventstore.Eventstore, client *database.DB, instanceID string) {
_, err := db.Push(context.Background(), generateCommand("owners-add", "1",
withInstanceID(instanceID),
generateAddUniqueConstraint("usernames", "alice", "org:org-1", "user:user-1"),
))
if err != nil {
t.Fatal(err)
}
assertOwners(t, client, instanceID, "usernames", "alice", []string{"org:org-1", "user:user-1"})
},
},
{
name: "omit owners stores empty",
run: func(t *testing.T, db *eventstore.Eventstore, client *database.DB, instanceID string) {
_, err := db.Push(context.Background(), generateCommand("owners-empty", "1",
withInstanceID(instanceID),
generateAddUniqueConstraint("usernames", "bob"),
))
if err != nil {
t.Fatal(err)
}
assertOwners(t, client, instanceID, "usernames", "bob", nil)
},
},
{
name: "add duplicate already exists",
run: func(t *testing.T, db *eventstore.Eventstore, client *database.DB, instanceID string) {
cmd := func() eventstore.Command {
return generateCommand("owners-dup", "1",
withInstanceID(instanceID),
generateAddUniqueConstraint("usernames", "alice", "org:org-1"),
)
}
if _, err := db.Push(context.Background(), cmd()); err != nil {
t.Fatal(err)
}
_, err := db.Push(context.Background(), cmd())
if !zerrors.IsErrorAlreadyExists(err) {
t.Fatalf("expected already exists, got %v", err)
}
},
},
{
name: "remove by field case insensitive",
run: func(t *testing.T, db *eventstore.Eventstore, client *database.DB, instanceID string) {
if err := insertUniqueConstraint(client, instanceID, "usernames", "mixedcase", "org:org-1"); err != nil {
t.Fatal(err)
}
_, err := db.Push(context.Background(), generateCommand("owners-field-remove", "1",
withInstanceID(instanceID),
generateRemoveUniqueConstraint("usernames", "MixedCase"),
))
if err != nil {
t.Fatal(err)
}
assertUniqueCount(t, client, instanceID, 0)
},
},
{
name: "remove by owner org",
run: func(t *testing.T, db *eventstore.Eventstore, client *database.DB, instanceID string) {
if err := insertUniqueConstraint(client, instanceID, "usernames", "alice", "org:org-x", "user:u1"); err != nil {
t.Fatal(err)
}
if err := insertUniqueConstraint(client, instanceID, "usernames", "bob", "org:org-y", "user:u2"); err != nil {
t.Fatal(err)
}
_, err := db.Push(context.Background(), generateCommand("owners-org-remove", "1",
withInstanceID(instanceID),
generateRemoveUniqueConstraintsByOwner(eventstore.UniqueConstraintOwnerOrg, "org-x"),
))
if err != nil {
t.Fatal(err)
}
assertUniqueCount(t, client, instanceID, 1)
assertOwners(t, client, instanceID, "usernames", "bob", []string{"org:org-y", "user:u2"})
},
},
{
name: "remove by owner user",
run: func(t *testing.T, db *eventstore.Eventstore, client *database.DB, instanceID string) {
if err := insertUniqueConstraint(client, instanceID, "usernames", "alice", "org:org-1", "user:u1"); err != nil {
t.Fatal(err)
}
if err := insertUniqueConstraint(client, instanceID, "external_idps", "idp1ext1", "org:org-1", "idp:idp1", "user:u1"); err != nil {
t.Fatal(err)
}
if err := insertUniqueConstraint(client, instanceID, "usernames", "bob", "org:org-1", "user:u2"); err != nil {
t.Fatal(err)
}
_, err := db.Push(context.Background(), generateCommand("owners-user-remove", "1",
withInstanceID(instanceID),
generateRemoveUniqueConstraintsByOwner(eventstore.UniqueConstraintOwnerUser, "u1"),
))
if err != nil {
t.Fatal(err)
}
assertUniqueCount(t, client, instanceID, 1)
assertOwners(t, client, instanceID, "usernames", "bob", []string{"org:org-1", "user:u2"})
},
},
{
name: "remove by owner idp",
run: func(t *testing.T, db *eventstore.Eventstore, client *database.DB, instanceID string) {
if err := insertUniqueConstraint(client, instanceID, "external_idps", "idp1ext1", "org:org-1", "idp:idp-1", "user:u1"); err != nil {
t.Fatal(err)
}
if err := insertUniqueConstraint(client, instanceID, "external_idps", "idp2ext2", "org:org-1", "idp:idp-2", "user:u2"); err != nil {
t.Fatal(err)
}
_, err := db.Push(context.Background(), generateCommand("owners-idp-remove", "1",
withInstanceID(instanceID),
generateRemoveUniqueConstraintsByOwner(eventstore.UniqueConstraintOwnerIDP, "idp-1"),
))
if err != nil {
t.Fatal(err)
}
assertUniqueCount(t, client, instanceID, 1)
assertOwners(t, client, instanceID, "external_idps", "idp2ext2", []string{"org:org-1", "idp:idp-2", "user:u2"})
},
},
{
name: "two org tags either org deletes grant",
run: func(t *testing.T, db *eventstore.Eventstore, client *database.DB, instanceID string) {
if err := insertUniqueConstraint(client, instanceID, "project_grant", "granted:project", "org:granting", "org:granted", "project:project"); err != nil {
t.Fatal(err)
}
_, err := db.Push(context.Background(), generateCommand("owners-grant-org", "1",
withInstanceID(instanceID),
generateRemoveUniqueConstraintsByOwner(eventstore.UniqueConstraintOwnerOrg, "granted"),
))
if err != nil {
t.Fatal(err)
}
assertUniqueCount(t, client, instanceID, 0)
},
},
{
name: "instance remove deletes tagged rows",
run: func(t *testing.T, db *eventstore.Eventstore, client *database.DB, instanceID string) {
if err := insertUniqueConstraint(client, instanceID, "usernames", "alice", "org:org-1"); err != nil {
t.Fatal(err)
}
if err := insertUniqueConstraint(client, "other", "usernames", "alice", "org:org-1"); err != nil {
t.Fatal(err)
}
_, err := db.Push(context.Background(), generateCommand("owners-instance-remove", "1",
withInstanceID(instanceID),
generateRemoveInstanceUniqueConstraints(),
))
if err != nil {
t.Fatal(err)
}
assertUniqueCount(t, client, instanceID, 0)
assertUniqueCount(t, client, "other", 1)
},
},
{
name: "global and empty owners not deleted by owner",
run: func(t *testing.T, db *eventstore.Eventstore, client *database.DB, instanceID string) {
if err := insertUniqueConstraint(client, "", "instance_domain", "example.com"); err != nil {
t.Fatal(err)
}
if err := insertUniqueConstraint(client, instanceID, "usernames", "untagged"); err != nil {
t.Fatal(err)
}
if err := insertUniqueConstraint(client, instanceID, "usernames", "tagged", "org:org-1"); err != nil {
t.Fatal(err)
}
_, err := db.Push(context.Background(), generateCommand("owners-skip-empty", "1",
withInstanceID(instanceID),
generateRemoveUniqueConstraintsByOwner(eventstore.UniqueConstraintOwnerOrg, "org-1"),
))
if err != nil {
t.Fatal(err)
}
assertUniqueCount(t, client, "", 1)
assertUniqueCount(t, client, instanceID, 1)
assertOwners(t, client, instanceID, "usernames", "untagged", nil)
},
},
{
name: "mixed add tagged and empty owners",
run: func(t *testing.T, db *eventstore.Eventstore, client *database.DB, instanceID string) {
_, err := db.Push(context.Background(), generateCommand("owners-mixed-add", "1",
withInstanceID(instanceID),
generateAddUniqueConstraint("usernames", "tagged", "org:org-1", "user:u1"),
generateAddUniqueConstraint("usernames", "untagged"),
))
if err != nil {
t.Fatal(err)
}
assertUniqueCount(t, client, instanceID, 2)
assertOwners(t, client, instanceID, "usernames", "tagged", []string{"org:org-1", "user:u1"})
assertOwners(t, client, instanceID, "usernames", "untagged", nil)
},
},
{
name: "mixed batch deletes then inserts",
run: func(t *testing.T, db *eventstore.Eventstore, client *database.DB, instanceID string) {
if err := insertUniqueConstraint(client, instanceID, "usernames", "old", "org:org-1"); err != nil {
t.Fatal(err)
}
if err := insertUniqueConstraint(client, instanceID, "org_name", "acme", "org:org-1"); err != nil {
t.Fatal(err)
}
_, err := db.Push(context.Background(), generateCommand("owners-mixed", "1",
withInstanceID(instanceID),
generateAddUniqueConstraint("usernames", "new", "org:org-1", "user:u1"),
generateRemoveUniqueConstraint("usernames", "old"),
generateRemoveUniqueConstraintsByOwner(eventstore.UniqueConstraintOwnerOrg, "org-1"),
))
if err != nil {
t.Fatal(err)
}
assertUniqueCount(t, client, instanceID, 1)
assertOwners(t, client, instanceID, "usernames", "new", []string{"org:org-1", "user:u1"})
},
},
}
func TestEventstore_Push_UniqueConstraintOwners_MissingColumn(t *testing.T) {
for pusherName, pusher := range pushers {
t.Run(pusherName, func(t *testing.T) {
client := clients[pusherName]
tx, err := client.Begin()
if err != nil {
t.Fatal(err)
}
// DROP COLUMN takes AccessExclusiveLock until rollback. Other tests
// against the same database may stall; they still complete afterwards.
t.Cleanup(func() {
if err := tx.Rollback(); err != nil && !errors.Is(err, sql.ErrTxDone) {
t.Errorf("rollback missing-column tx: %v", err)
}
})
if _, err := tx.Exec("ALTER TABLE eventstore.unique_constraints DROP COLUMN IF EXISTS owners"); err != nil {
t.Fatal(err)
}
db := eventstore.NewEventstore(&eventstore.Config{
Querier: queriers["v2(inmemory)"],
Pusher: pusher,
})
const instanceID = "owners-missing-col"
t.Run("empty owners add succeeds", func(t *testing.T) {
_, err := db.PushWithClient(context.Background(), tx, generateCommand("owners-missing-empty", "1",
withInstanceID(instanceID),
generateAddUniqueConstraint("usernames", "bob"),
))
if err != nil {
t.Fatal(err)
}
assertUniqueCountTx(t, tx, instanceID, 1)
})
t.Run("tagged owners add fails", func(t *testing.T) {
_, err := db.PushWithClient(context.Background(), tx, generateCommand("owners-missing-tagged", "1",
withInstanceID(instanceID),
generateAddUniqueConstraint("usernames", "alice", "org:org-1", "user:u1"),
))
if err == nil {
t.Fatal("expected missing owners column error")
}
if !isUndefinedColumn(err) {
t.Fatalf("expected undefined column, got %v", err)
}
})
})
}
}
func isUndefinedColumn(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "42703"
}
func assertUniqueCountTx(t *testing.T, tx *sql.Tx, instanceID string, want int) {
t.Helper()
var count int
if err := tx.QueryRow("SELECT COUNT(*) FROM eventstore.unique_constraints WHERE instance_id = $1", instanceID).Scan(&count); err != nil {
t.Fatal(err)
}
if count != want {
t.Errorf("unique count = %d, want %d", count, want)
}
}
func assertUniqueCount(t *testing.T, db *database.DB, instanceID string, want int) {
t.Helper()
var count int
err := db.QueryRow(func(row *sql.Row) error {
return row.Scan(&count)
}, "SELECT COUNT(*) FROM eventstore.unique_constraints WHERE instance_id = $1", instanceID)
if err != nil {
t.Fatal(err)
}
if count != want {
t.Errorf("unique count = %d, want %d", count, want)
}
}
func assertOwners(t *testing.T, db *database.DB, instanceID, uniqueType, uniqueField string, want []string) {
t.Helper()
var owners database.TextArray[string]
err := db.QueryRow(func(row *sql.Row) error {
return row.Scan(&owners)
}, "SELECT owners FROM eventstore.unique_constraints WHERE instance_id = $1 AND unique_type = $2 AND unique_field = $3", instanceID, uniqueType, uniqueField)
if err != nil {
t.Fatal(err)
}
if len(owners) != len(want) {
t.Fatalf("owners = %v, want %v", []string(owners), want)
}
got := map[string]int{}
for _, owner := range owners {
got[owner]++
}
for _, owner := range want {
if got[owner] == 0 {
t.Fatalf("owners = %v, want %v", []string(owners), want)
}
got[owner]--
}
}