diff --git a/internal/db/migrations/00026_outgoing_default_rule.sql b/internal/db/migrations/00026_outgoing_default_rule.sql new file mode 100644 index 00000000..6781713f --- /dev/null +++ b/internal/db/migrations/00026_outgoing_default_rule.sql @@ -0,0 +1,231 @@ +-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial +-- SPDX-FileCopyrightText: 2025-2026 Christian Galo + +-- +goose Up +-- --------------------------------------------------------------------------- +-- One rule for an outgoing default and for a pool's class. The default +-- change's preview, its disposition gate and each move decide whether a +-- default-sourced position is outgoing, and which class a pool falls in, from +-- the two functions below, so the three cannot disagree. The gate answers in +-- a time that does not grow with the org type, because it reads two entries +-- of a partial index per type; the index needs the position to carry the +-- type of its pool's organization and whether its grant is a default grant, +-- so both are copied onto the position row by trigger. +-- +-- The copies rest on four facts: a pool's organization, an organization's +-- type, a provision's grant and a grant's reason. No runtime statement +-- changes any of them, and the five guard triggers at the end refuse any +-- update that does. A feature that must change one decides what the copies do +-- and replaces the guard in its own migration. +-- --------------------------------------------------------------------------- + +-- Nullable until the backfill below has filled the existing rows. +ALTER TABLE core.pool_provision_ladders + ADD COLUMN org_type VARCHAR(20), + ADD COLUMN default_sourced BOOLEAN; + +-- Whether the grant is a default grant: the reason the org type's default +-- plan is conferred under. False for a provision with no grant (a +-- subscription's or a purchase's) and for a grant of any other reason. +-- +goose StatementBegin +CREATE FUNCTION core.provision_is_default_sourced(p_grant_id UUID) +RETURNS BOOLEAN +LANGUAGE sql +STABLE +SET search_path = core, pg_temp +AS $$ +SELECT COALESCE((SELECT g.grant_reason = 'default' FROM core.grants g WHERE g.grant_id = p_grant_id), false); +$$; +-- +goose StatementEnd + +-- Fills both copies from the position's pool and provision. The update +-- trigger fires only when the position moves to another pool or provision: +-- core.sync_pool_provision_ladders assigns pool_id on every status, +-- activation or end change of a provision, and the guard triggers below keep +-- any such assignment from changing the copies. +-- +goose StatementBegin +CREATE FUNCTION core.copy_position_sources() +RETURNS TRIGGER +LANGUAGE plpgsql +SET search_path = core, pg_temp +AS $$ +BEGIN + NEW.org_type := (SELECT o.org_type + FROM core.resource_pools rp + JOIN core.organizations o ON o.org_id = rp.org_id + WHERE rp.pool_id = NEW.pool_id); + NEW.default_sourced := core.provision_is_default_sourced( + (SELECT pp.grant_id FROM core.pool_provisions pp WHERE pp.provision_id = NEW.provision_id)); + RETURN NEW; +END; +$$; +-- +goose StatementEnd + +CREATE TRIGGER trigger_pool_provision_ladders_copy_sources_insert + BEFORE INSERT ON core.pool_provision_ladders + FOR EACH ROW + EXECUTE FUNCTION core.copy_position_sources(); + +CREATE TRIGGER trigger_pool_provision_ladders_copy_sources_update + BEFORE UPDATE OF pool_id, provision_id ON core.pool_provision_ladders + FOR EACH ROW + WHEN (OLD.pool_id IS DISTINCT FROM NEW.pool_id OR OLD.provision_id IS DISTINCT FROM NEW.provision_id) + EXECUTE FUNCTION core.copy_position_sources(); + +-- The rows written before this migration. updated_at keeps its value: the +-- backfill describes the rows, it does not change them. +ALTER TABLE core.pool_provision_ladders DISABLE TRIGGER trigger_pool_provision_ladders_updated_at; +UPDATE core.pool_provision_ladders l +SET org_type = o.org_type, + default_sourced = core.provision_is_default_sourced(pp.grant_id) +FROM core.resource_pools rp +JOIN core.organizations o ON o.org_id = rp.org_id, + core.pool_provisions pp +WHERE rp.pool_id = l.pool_id + AND pp.provision_id = l.provision_id; +ALTER TABLE core.pool_provision_ladders ENABLE TRIGGER trigger_pool_provision_ladders_updated_at; + +ALTER TABLE core.pool_provision_ladders + ALTER COLUMN org_type SET NOT NULL, + ALTER COLUMN default_sourced SET NOT NULL; + +-- The gate's index: the live default-sourced positions of a type, ordered by +-- product, so the lowest and the highest product of a type are two entries. +CREATE INDEX idx_pool_provision_ladders_live_default + ON core.pool_provision_ladders (org_type, product_id) + WHERE default_sourced AND status IN ('active', 'suspended'); + +-- The only statement of "outgoing" in the code: a default-sourced position +-- is outgoing when the target is None (NULL) or a different product. +-- IMMUTABLE SQL with no SET clause, as core.transition_change_kind is, so +-- PostgreSQL inlines it into the statement that calls it. +-- +goose StatementBegin +CREATE FUNCTION core.plan_position_is_outgoing(p_default_sourced BOOLEAN, p_product_id UUID, p_target UUID) +RETURNS BOOLEAN +LANGUAGE sql +IMMUTABLE +PARALLEL SAFE +AS $$ +SELECT p_default_sourced AND (p_target IS NULL OR p_product_id <> p_target); +$$; +-- +goose StatementEnd + +-- The class of one pool, from what its live positions show: whether the +-- organization has an active default pool, whether any position is outgoing, +-- whether any is default-sourced and whether any exists. Inlined as above. +-- +goose StatementBegin +CREATE FUNCTION core.plan_pool_bucket(p_has_pool BOOLEAN, p_any_outgoing BOOLEAN, p_any_default_sourced BOOLEAN, p_any_position BOOLEAN) +RETURNS VARCHAR +LANGUAGE sql +IMMUTABLE +PARALLEL SAFE +AS $$ +SELECT CASE + WHEN NOT p_has_pool THEN 'would_fail' + WHEN p_any_outgoing THEN 'outgoing_default' + WHEN p_any_default_sourced THEN 'already_at_default' + WHEN p_any_position THEN 'other_source' + ELSE 'would_initiate' +END::VARCHAR; +$$; +-- +goose StatementEnd + +-- Whether any organization of the given types holds a live default-sourced +-- position the rule marks outgoing. Per type it reads the lowest and the +-- highest product through the partial index: when every product equals the +-- target both equal it and neither is outgoing, and when any differs one +-- extreme does; with a None target any position is outgoing. +-- +goose StatementBegin +CREATE FUNCTION core.outgoing_default_exists(p_org_types VARCHAR[], p_target UUID) +RETURNS BOOLEAN +LANGUAGE sql +STABLE +AS $$ +SELECT EXISTS ( + SELECT 1 + FROM unnest(p_org_types) AS t(org_type) + CROSS JOIN LATERAL ( + (SELECT l.product_id FROM core.pool_provision_ladders l + WHERE l.org_type = t.org_type AND l.default_sourced AND l.status IN ('active', 'suspended') + ORDER BY l.product_id LIMIT 1) + UNION ALL + (SELECT l.product_id FROM core.pool_provision_ladders l + WHERE l.org_type = t.org_type AND l.default_sourced AND l.status IN ('active', 'suspended') + ORDER BY l.product_id DESC LIMIT 1) + ) e + WHERE core.plan_position_is_outgoing(true, e.product_id, p_target) +); +$$; +-- +goose StatementEnd + +-- Refuses an update that changes a fact the copies rest on. One function, +-- attached once per watched column; the trigger's argument names the column. +-- UPDATE OF fires on any statement that assigns the column, so each trigger's +-- WHEN clause lets an assignment of the same value through. +-- +goose StatementBegin +CREATE FUNCTION core.refuse_position_source_change() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION '% cannot change: plan positions copy it', TG_ARGV[0] + USING ERRCODE = 'restrict_violation', TABLE = TG_TABLE_NAME, COLUMN = TG_ARGV[0]; +END; +$$; +-- +goose StatementEnd + +CREATE TRIGGER trigger_organizations_refuse_org_type_change + BEFORE UPDATE OF org_type ON core.organizations + FOR EACH ROW + WHEN (OLD.org_type IS DISTINCT FROM NEW.org_type) + EXECUTE FUNCTION core.refuse_position_source_change('org_type'); + +CREATE TRIGGER trigger_resource_pools_refuse_org_id_change + BEFORE UPDATE OF org_id ON core.resource_pools + FOR EACH ROW + WHEN (OLD.org_id IS DISTINCT FROM NEW.org_id) + EXECUTE FUNCTION core.refuse_position_source_change('org_id'); + +CREATE TRIGGER trigger_pool_provisions_refuse_grant_id_change + BEFORE UPDATE OF grant_id ON core.pool_provisions + FOR EACH ROW + WHEN (OLD.grant_id IS DISTINCT FROM NEW.grant_id) + EXECUTE FUNCTION core.refuse_position_source_change('grant_id'); + +CREATE TRIGGER trigger_pool_provisions_refuse_pool_id_change + BEFORE UPDATE OF pool_id ON core.pool_provisions + FOR EACH ROW + WHEN (OLD.pool_id IS DISTINCT FROM NEW.pool_id) + EXECUTE FUNCTION core.refuse_position_source_change('pool_id'); + +CREATE TRIGGER trigger_grants_refuse_grant_reason_change + BEFORE UPDATE OF grant_reason ON core.grants + FOR EACH ROW + WHEN (OLD.grant_reason IS DISTINCT FROM NEW.grant_reason) + EXECUTE FUNCTION core.refuse_position_source_change('grant_reason'); + +GRANT EXECUTE ON FUNCTION core.provision_is_default_sourced(UUID) TO core_reader, core_writer, core_owner; +GRANT EXECUTE ON FUNCTION core.copy_position_sources() TO core_reader, core_writer, core_owner; +GRANT EXECUTE ON FUNCTION core.plan_position_is_outgoing(BOOLEAN, UUID, UUID) TO core_reader, core_writer, core_owner; +GRANT EXECUTE ON FUNCTION core.plan_pool_bucket(BOOLEAN, BOOLEAN, BOOLEAN, BOOLEAN) TO core_reader, core_writer, core_owner; +GRANT EXECUTE ON FUNCTION core.outgoing_default_exists(VARCHAR[], UUID) TO core_reader, core_writer, core_owner; +GRANT EXECUTE ON FUNCTION core.refuse_position_source_change() TO core_reader, core_writer, core_owner; + +-- +goose Down +DROP TRIGGER trigger_grants_refuse_grant_reason_change ON core.grants; +DROP TRIGGER trigger_pool_provisions_refuse_pool_id_change ON core.pool_provisions; +DROP TRIGGER trigger_pool_provisions_refuse_grant_id_change ON core.pool_provisions; +DROP TRIGGER trigger_resource_pools_refuse_org_id_change ON core.resource_pools; +DROP TRIGGER trigger_organizations_refuse_org_type_change ON core.organizations; +DROP FUNCTION core.refuse_position_source_change(); +DROP FUNCTION core.outgoing_default_exists(VARCHAR[], UUID); +DROP FUNCTION core.plan_pool_bucket(BOOLEAN, BOOLEAN, BOOLEAN, BOOLEAN); +DROP FUNCTION core.plan_position_is_outgoing(BOOLEAN, UUID, UUID); +DROP INDEX core.idx_pool_provision_ladders_live_default; +DROP TRIGGER trigger_pool_provision_ladders_copy_sources_update ON core.pool_provision_ladders; +DROP TRIGGER trigger_pool_provision_ladders_copy_sources_insert ON core.pool_provision_ladders; +DROP FUNCTION core.copy_position_sources(); +DROP FUNCTION core.provision_is_default_sourced(UUID); +ALTER TABLE core.pool_provision_ladders + DROP COLUMN default_sourced, + DROP COLUMN org_type; diff --git a/internal/db/outgoing_default_rule_test.go b/internal/db/outgoing_default_rule_test.go new file mode 100644 index 00000000..e808eb26 --- /dev/null +++ b/internal/db/outgoing_default_rule_test.go @@ -0,0 +1,339 @@ +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial +// SPDX-FileCopyrightText: 2025-2026 Christian Galo + +package db_test + +// Migration 00026 (openspec change plan-act-obligations, tasks 3.1 and 3.5): +// the copies a position carries of its pool's organization's type and of +// whether its grant is a default grant, and the guards that keep the facts +// those copies rest on from changing. The fixture writes its rows by SQL, +// because the triggers and the backfill are the subject and a builder that +// went through the console's functions would not exercise them. + +import ( + "errors" + "slices" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" + + "git.coopcloud.tech/wiki-cafe/member-console/internal/db" + "git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest" +) + +const ( + outgoingRuleVersion = 26 + outgoingRulePrevious = 25 + restrictViolation = "23001" +) + +// positionKind is one position the fixture writes for each of its two org +// types: what sources it, the status it is in and which of the two products +// it delivers. +type positionKind struct { + source string // "default", "legacy", "manual" (grant reasons) or "subscription" + status string + product int +} + +var positionKinds = []positionKind{ + {"default", "active", 0}, + {"legacy", "active", 0}, + {"manual", "active", 1}, + {"subscription", "active", 0}, + {"default", "suspended", 1}, + {"default", "ended", 0}, +} + +// positionFixture names the rows of one seedPositions call. +type positionFixture struct { + types [2]string + orgs []string // the organization of each position, in write order + pools []string + grants []string // "" for a subscription's position + provisions []string +} + +// seedPositions writes, for each of two new org types, one organization with +// a default pool for each of positionKinds, and the provision and position +// each kind describes. It writes no column the migration adds, so it runs +// before and after 00026. +func seedPositions(t *testing.T, q *dbtest.Q) positionFixture { + t.Helper() + tag := uuid.NewString()[:8] + var f positionFixture + f.types = [2]string{"odr1-" + tag, "odr2-" + tag} + person := q.ID(`WITH u AS (INSERT INTO core.users (oidc_subject) VALUES ($1) RETURNING user_id) + INSERT INTO core.persons (user_id, display_name, primary_email) + SELECT user_id, 'Owner', $2 FROM u RETURNING person_id`, "odr-"+tag, "odr-"+tag+"@example.test") + set := q.ID(`INSERT INTO core.entitlement_sets (name) VALUES ($1) RETURNING set_id`, "odr set "+tag) + var products, ladders [2]string + for i := range products { + products[i] = q.ID(`INSERT INTO core.products (name, entitlement_set_id) VALUES ($1, $2) RETURNING product_id`, + "odr product "+tag+string(rune('a'+i)), set) + ladders[i] = q.ID(`INSERT INTO core.plan_ladders (name) VALUES ($1) RETURNING plan_ladder_id`, + "odr ladder "+tag+string(rune('a'+i))) + } + for _, orgType := range f.types { + q.Exec(`INSERT INTO core.org_types (org_type, display_name) VALUES ($1, $1)`, orgType) + for _, k := range positionKinds { + org := q.ID(`INSERT INTO core.organizations (name, org_type, owner_person_id) VALUES ($1, $2, $3) RETURNING org_id`, + "odr org "+tag, orgType, person) + pool := q.ID(`INSERT INTO core.resource_pools (org_id, name, pool_type) VALUES ($1, 'Default', 'default') RETURNING pool_id`, org) + var provision, grant string + if k.source == "subscription" { + provision = q.ID(`INSERT INTO core.pool_provisions (pool_id, entitlement_set_id, product_id, subscription_id, status, ended_at) + VALUES ($1, $2, $3, $4, $5::varchar, CASE WHEN $5::varchar = 'ended' THEN now() END) RETURNING provision_id`, + pool, set, products[k.product], uuid.NewString(), k.status) + } else { + // A default grant has no author; every other grant has one. + grant = q.ID(`INSERT INTO core.grants (product_id, grant_reason, granted_to_org_id, granted_by_person_id) + VALUES ($1, $2::varchar, $3, CASE WHEN $2::varchar = 'default' THEN NULL ELSE $4::uuid END) RETURNING grant_id`, + products[k.product], k.source, org, person) + provision = q.ID(`INSERT INTO core.pool_provisions (pool_id, entitlement_set_id, product_id, grant_id, status, ended_at) + VALUES ($1, $2, $3, $4, $5::varchar, CASE WHEN $5::varchar = 'ended' THEN now() END) RETURNING provision_id`, + pool, set, products[k.product], grant, k.status) + } + q.Exec(`INSERT INTO core.pool_provision_ladders (provision_id, plan_ladder_id, pool_id, status, activated_at, ended_at, product_id) + SELECT provision_id, $2, pool_id, status, activated_at, ended_at, product_id FROM core.pool_provisions WHERE provision_id = $1`, + provision, ladders[k.product]) + f.orgs = append(f.orgs, org) + f.pools = append(f.pools, pool) + f.grants = append(f.grants, grant) + f.provisions = append(f.provisions, provision) + } + } + return f +} + +// positionCopyMismatches counts the fixture's positions whose copies differ +// from a recomputation from their pools, organizations, provisions and +// grants. +func positionCopyMismatches(q *dbtest.Q, f positionFixture) int64 { + return q.Int(`SELECT count(*) + FROM core.pool_provision_ladders l + JOIN core.resource_pools rp ON rp.pool_id = l.pool_id + JOIN core.organizations o ON o.org_id = rp.org_id + JOIN core.pool_provisions p ON p.provision_id = l.provision_id + LEFT JOIN core.grants g ON g.grant_id = p.grant_id + WHERE o.org_type = ANY($1) + AND (l.org_type IS DISTINCT FROM o.org_type + OR l.default_sourced IS DISTINCT FROM COALESCE(g.grant_reason = 'default', false))`, + f.types[:]) +} + +// requirePositionCopies requires the fixture's positions to carry copies that +// match their sources, and to cover both org types and both values of +// default_sourced, so that a copy that is always the same value cannot pass. +func requirePositionCopies(t *testing.T, q *dbtest.Q, f positionFixture, positions int) { + t.Helper() + if n := positionCopyMismatches(q, f); n != 0 { + t.Errorf("%d positions carry a copy that differs from its source", n) + } + var rows, types, defaults int64 + q.Scan([]any{&rows, &types, &defaults}, `SELECT count(*), count(DISTINCT l.org_type), count(*) FILTER (WHERE l.default_sourced) + FROM core.pool_provision_ladders l WHERE l.org_type = ANY($1)`, f.types[:]) + if int(rows) != positions || types != 2 || int(defaults) != positions/2 { + t.Errorf("positions = %d across %d org types with %d default-sourced, want %d across 2 with %d", rows, types, defaults, positions, positions/2) + } +} + +// updatedAts reads every fixture position's updated_at, by provision. +func updatedAts(q *dbtest.Q, f positionFixture) map[string]time.Time { + out := map[string]time.Time{} + for _, provision := range f.provisions { + var at time.Time + q.Scan([]any{&at}, `SELECT updated_at FROM core.pool_provision_ladders WHERE provision_id = $1`, provision) + out[provision] = at + } + return out +} + +func TestOutgoingDefaultRuleMigrationUpDownUp(t *testing.T) { + scratch := dbtest.Connect(t, dbtest.Scratch(t)) + ctx := t.Context() + core := db.BaseSources()[0] + + dbtest.UpTo(t, scratch, core, outgoingRulePrevious) + before := schemaSnapshot(t, ctx, scratch) + + dbtest.ApplyVersion(t, scratch, core, outgoingRuleVersion, true) + afterUp := schemaSnapshot(t, ctx, scratch) + if slices.Equal(before, afterUp) { + t.Fatalf("applying %d changed nothing in the snapshot", outgoingRuleVersion) + } + + dbtest.ApplyVersion(t, scratch, core, outgoingRuleVersion, false) + if afterDown := schemaSnapshot(t, ctx, scratch); !slices.Equal(before, afterDown) { + t.Errorf("the schema after Down differs from the schema before Up:\n%s", snapshotDiff(before, afterDown)) + } + + dbtest.ApplyVersion(t, scratch, core, outgoingRuleVersion, true) + if again := schemaSnapshot(t, ctx, scratch); !slices.Equal(afterUp, again) { + t.Errorf("the schema after a second Up differs from the first:\n%s", snapshotDiff(afterUp, again)) + } +} + +// TestPositionCopiesMatchTheirSources writes default, legacy, operator-grant +// and subscription positions on two org types before 00026, so the backfill +// fills them; then more through the insert trigger, and then changes a +// provision's status, which core.sync_pool_provision_ladders copies onto its +// positions. After each step every position's two copies equal a +// recomputation from the rows they rest on. The backfill leaves updated_at +// alone, and a Down followed by an Up fills the copies again. +func TestPositionCopiesMatchTheirSources(t *testing.T) { + scratch := dbtest.Connect(t, dbtest.Scratch(t)) + core := db.BaseSources()[0] + q := dbtest.On(t, scratch) + + dbtest.UpTo(t, scratch, core, outgoingRulePrevious) + old := seedPositions(t, q) + oldUpdated := updatedAts(q, old) + + dbtest.ApplyVersion(t, scratch, core, outgoingRuleVersion, true) + perFixture := 2 * len(positionKinds) + requirePositionCopies(t, q, old, perFixture) + for provision, at := range updatedAts(q, old) { + if !at.Equal(oldUpdated[provision]) { + t.Errorf("the backfill moved updated_at of the position of provision %s from %s to %s", provision, oldUpdated[provision], at) + } + } + + fresh := seedPositions(t, q) + requirePositionCopies(t, q, fresh, perFixture) + + q.Exec(`UPDATE core.pool_provisions SET status = 'ended', ended_at = now() WHERE provision_id = ANY($1) AND status = 'suspended'`, fresh.provisions) + q.Exec(`UPDATE core.pool_provisions SET status = 'suspended' WHERE provision_id = ANY($1) AND status = 'active'`, fresh.provisions) + requirePositionCopies(t, q, fresh, perFixture) + if n := q.Int(`SELECT count(*) FROM core.pool_provision_ladders WHERE provision_id = ANY($1) AND status = 'active'`, fresh.provisions); n != 0 { + t.Errorf("%d positions are still active after their provisions were suspended or ended", n) + } + + dbtest.ApplyVersion(t, scratch, core, outgoingRuleVersion, false) + dbtest.ApplyVersion(t, scratch, core, outgoingRuleVersion, true) + requirePositionCopies(t, q, old, perFixture) + requirePositionCopies(t, q, fresh, perFixture) +} + +// sourceChange is an update that changes one fact the copies rest on, and +// the same column assigned its own value. +type sourceChange struct { + column string + change string // $1 is the row's id, $2 the value to change to + same string // $1 is the row's id + row func(f positionFixture) string + to func(f positionFixture) string +} + +var sourceChanges = []sourceChange{ + { + column: "org_type", + change: `UPDATE core.organizations SET org_type = $2 WHERE org_id = $1`, + same: `UPDATE core.organizations SET org_type = org_type WHERE org_id = $1`, + row: func(f positionFixture) string { return f.orgs[0] }, + to: func(f positionFixture) string { return f.types[1] }, + }, + { + column: "org_id", + change: `UPDATE core.resource_pools SET org_id = $2 WHERE pool_id = $1`, + same: `UPDATE core.resource_pools SET org_id = org_id WHERE pool_id = $1`, + row: func(f positionFixture) string { return f.pools[0] }, + to: func(f positionFixture) string { return f.orgs[1] }, + }, + { + column: "grant_id", + change: `UPDATE core.pool_provisions SET grant_id = $2 WHERE provision_id = $1`, + same: `UPDATE core.pool_provisions SET grant_id = grant_id WHERE provision_id = $1`, + row: func(f positionFixture) string { return f.provisions[0] }, + to: func(f positionFixture) string { return f.grants[1] }, + }, + { + column: "pool_id", + change: `UPDATE core.pool_provisions SET pool_id = $2 WHERE provision_id = $1`, + same: `UPDATE core.pool_provisions SET pool_id = pool_id WHERE provision_id = $1`, + row: func(f positionFixture) string { return f.provisions[0] }, + to: func(f positionFixture) string { return f.pools[1] }, + }, + { + column: "grant_reason", + change: `UPDATE core.grants SET grant_reason = $2 WHERE grant_id = $1`, + same: `UPDATE core.grants SET grant_reason = grant_reason WHERE grant_id = $1`, + row: func(f positionFixture) string { return f.grants[0] }, + to: func(f positionFixture) string { return "legacy" }, + }, +} + +// TestPositionSourcesCannotChange requires an update that changes any of the +// five columns to be refused with restrict_violation naming that column, an +// update that assigns the column its own value to pass, and an insert to be +// unaffected (the fixture's own rows are inserts). +func TestPositionSourcesCannotChange(t *testing.T) { + scratch := dbtest.Connect(t, dbtest.Scratch(t)) + dbtest.UpTo(t, scratch, db.BaseSources()[0], outgoingRuleVersion) + q := dbtest.Tx(t, scratch) + f := seedPositions(t, q) + for _, c := range sourceChanges { + t.Run(c.column, func(t *testing.T) { + q := dbtest.On(t, q.Tx()) + err := q.Attempt(c.change, c.row(f), c.to(f)) + dbtest.WantPgError(t, err, restrictViolation, "") + var pgErr *pgconn.PgError + if !errors.As(err, &pgErr) { + return + } + if pgErr.ColumnName != c.column || !strings.Contains(pgErr.Message, c.column) { + t.Errorf("the refusal names column %q in %q, want %q", pgErr.ColumnName, pgErr.Message, c.column) + } + if err := q.Attempt(c.same, c.row(f)); err != nil { + t.Errorf("assigning %s its own value was refused: %v", c.column, err) + } + }) + } +} + +// TestPositionCopiesAreWrittenOnlyWhenTheirSourceMoves makes a position's +// copies wrong on purpose and watches when a statement writes them again. +// core.sync_pool_provision_ladders assigns pool_id on every status change of +// a provision, so the update trigger's WHEN clause is what keeps each of them +// from reading the pool, the organization and the grant: a status change and +// an assignment of the same pool leave the wrong copies as they are, and a +// move to another pool writes them from that pool. +func TestPositionCopiesAreWrittenOnlyWhenTheirSourceMoves(t *testing.T) { + scratch := dbtest.Connect(t, dbtest.Scratch(t)) + dbtest.UpTo(t, scratch, db.BaseSources()[0], outgoingRuleVersion) + q := dbtest.Tx(t, scratch) + f := seedPositions(t, q) + + provision := f.provisions[0] // a default-sourced position in the first type + copies := func() string { + return q.String(`SELECT org_type || ' ' || default_sourced FROM core.pool_provision_ladders WHERE provision_id = $1`, provision) + } + want := f.types[0] + " true" + if got := copies(); got != want { + t.Fatalf("copies = %q, want %q", got, want) + } + q.Exec(`UPDATE core.pool_provision_ladders SET org_type = 'stale', default_sourced = false WHERE provision_id = $1`, provision) + stale := copies() + if stale != "stale false" { + t.Fatalf("the copies could not be made wrong: %q", stale) + } + + q.Exec(`UPDATE core.pool_provisions SET status = 'suspended' WHERE provision_id = $1`, provision) + if got := copies(); got != stale { + t.Errorf("a status change wrote the copies again: %q", got) + } + q.Exec(`UPDATE core.pool_provision_ladders SET pool_id = pool_id WHERE provision_id = $1`, provision) + if got := copies(); got != stale { + t.Errorf("assigning the pool its own value wrote the copies again: %q", got) + } + + // The pool of the first organization of the second type. + other := f.pools[len(positionKinds)] + q.Exec(`UPDATE core.pool_provision_ladders SET pool_id = $2 WHERE provision_id = $1`, provision, other) + if got, want := copies(), f.types[1]+" true"; got != want { + t.Errorf("a move to another pool left the copies as %q, want %q", got, want) + } +} diff --git a/internal/embeds/templates/partials/operator_org_type_preview.html b/internal/embeds/templates/partials/operator_org_type_preview.html index 3896be5d..7a66cc34 100644 --- a/internal/embeds/templates/partials/operator_org_type_preview.html +++ b/internal/embeds/templates/partials/operator_org_type_preview.html @@ -22,22 +22,23 @@
No existing organizations are affected.
{{ else }}No existing organizations are affected.
{{ else }}