Files
member-console/internal/db/taxonomy_migration_test.go
T
cgalo5758 75d350f88b Replace org type backfill with previewed changes
Require explicit grandfather or migrate dispositions for outgoing
defaults.
Treat any live plan attachment as blocking baseline restoration.
2026-07-11 21:02:17 -05:00

371 lines
17 KiB
Go

package db_test
// Doc 41 migration bucket test (tasks 6.1): seed the pre-00004 world shape —
// a bare-set grant sharing a plan tier's entitlement set, a 'trial'-reason
// grant, a free-text-reason operator grant, and a live provision + junction +
// old-style transition — against a database migrated only through 00003, then
// apply 00004-00006 and assert the §9 bucket outcomes, including the
// halt-and-report on an ambiguous set-direct grant.
//
// The goose harness always migrates fully, so this test applies the raw Up
// sections of the migration files in order (like the smoke script), each file
// as ONE multi-statement simple-protocol Exec — an implicit transaction, so a
// halting migration rolls back atomically exactly as goose would.
//
// Migration 00001 creates cluster-wide roles (core_owner/writer/reader),
// which collide on any cluster that already ran it — including the shared
// TEST_DATABASE_URL database. The test therefore spins its own disposable
// postgres container and is skipped when docker (or TEST_DATABASE_URL, the
// repo's integration-test gate) is unavailable.
import (
"context"
"database/sql"
"fmt"
"os"
"os/exec"
"strings"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
const taxonomyMigImage = "postgres:18.1"
// taxonomyMigPrestate is the old-world pre-state, valid against the pre-00004
// schema. Hex id scheme (last octets): person 0101 | orgs 0201/0202 | pools
// 0301/0302 | sets 0401/0402 | products plan0 0501, plan1 0502, addonA 0503 |
// ladders 0601/0602 | grants 0701..0704 | provisions 0801/0803/0804.
const taxonomyMigPrestate = `
INSERT INTO core.users (user_id, oidc_subject)
VALUES ('00000000-0000-0000-0000-000000000100', 'mig-oidc-subject');
INSERT INTO core.persons (person_id, user_id, display_name, primary_email)
VALUES ('00000000-0000-0000-0000-000000000101', '00000000-0000-0000-0000-000000000100',
'Mig Person', 'mig@example.test');
INSERT INTO core.org_types (org_type, display_name)
VALUES ('mig', 'Mig Type');
INSERT INTO core.organizations (org_id, name, slug, org_type, owner_person_id) VALUES
('00000000-0000-0000-0000-000000000201', 'Mig Org 1', 'mig-org-1', 'mig', '00000000-0000-0000-0000-000000000101'),
('00000000-0000-0000-0000-000000000202', 'Mig Org 2', 'mig-org-2', 'mig', '00000000-0000-0000-0000-000000000101');
INSERT INTO core.resource_pools (pool_id, org_id, name, slug, pool_type) VALUES
('00000000-0000-0000-0000-000000000301', '00000000-0000-0000-0000-000000000201', 'Pool 1', 'pool-1', 'default'),
('00000000-0000-0000-0000-000000000302', '00000000-0000-0000-0000-000000000202', 'Pool 2', 'pool-2', 'default');
INSERT INTO core.entitlement_sets (set_id, name) VALUES
('00000000-0000-0000-0000-000000000401', 'mig-baseline'),
('00000000-0000-0000-0000-000000000402', 'mig-addon-set');
INSERT INTO core.products (product_id, name, product_type, entitlement_set_id, lifecycle_status) VALUES
('00000000-0000-0000-0000-000000000501', 'Plan Zero', NULL, '00000000-0000-0000-0000-000000000401', 'published'),
('00000000-0000-0000-0000-000000000502', 'Plan One', NULL, '00000000-0000-0000-0000-000000000401', 'published'),
('00000000-0000-0000-0000-000000000503', 'Addon A', 'addon', '00000000-0000-0000-0000-000000000402', 'published');
INSERT INTO core.plan_ladders (plan_ladder_id, ladder_key, name)
VALUES ('00000000-0000-0000-0000-000000000601', 'mig-ladder', 'Mig Ladder');
INSERT INTO core.plan_ladder_tiers (plan_ladder_id, product_id, rank) VALUES
('00000000-0000-0000-0000-000000000601', '00000000-0000-0000-0000-000000000501', 0),
('00000000-0000-0000-0000-000000000601', '00000000-0000-0000-0000-000000000502', 1);
-- G1: system default of plan0 to org2 - provision + junction + old-style transition.
INSERT INTO core.grants (grant_id, product_id, granted_to_org_id, granted_by_person_id, grant_reason)
VALUES ('00000000-0000-0000-0000-000000000701', '00000000-0000-0000-0000-000000000501',
'00000000-0000-0000-0000-000000000202', NULL, 'default');
INSERT INTO core.pool_provisions (provision_id, pool_id, grant_id, status, activated_at, entitlement_set_id)
VALUES ('00000000-0000-0000-0000-000000000801', '00000000-0000-0000-0000-000000000302',
'00000000-0000-0000-0000-000000000701', 'active', NOW() - INTERVAL '30 days',
'00000000-0000-0000-0000-000000000401');
INSERT INTO core.pool_provision_ladders (provision_id, plan_ladder_id, pool_id, status, activated_at, product_id)
VALUES ('00000000-0000-0000-0000-000000000801', '00000000-0000-0000-0000-000000000601',
'00000000-0000-0000-0000-000000000302', 'active', NOW() - INTERVAL '30 days',
'00000000-0000-0000-0000-000000000501');
INSERT INTO core.pool_provision_transitions (pool_id, provision_id, plan_ladder_id, from_rank, to_rank, transition_type, actor_type, reason, effective_at)
VALUES ('00000000-0000-0000-0000-000000000302', '00000000-0000-0000-0000-000000000801',
'00000000-0000-0000-0000-000000000601', NULL, 0, 'initiate', 'system',
'mig pre-state', NOW() - INTERVAL '30 days');
-- G2: operator trial of plan1 (reason rename target), no provision.
INSERT INTO core.grants (grant_id, product_id, granted_to_org_id, granted_by_person_id, grant_reason, valid_until)
VALUES ('00000000-0000-0000-0000-000000000702', '00000000-0000-0000-0000-000000000502',
'00000000-0000-0000-0000-000000000201', '00000000-0000-0000-0000-000000000101',
'trial', NOW() + INTERVAL '7 days');
-- G3: the set-direct escape - bare-set system grant, provision with NO junction.
INSERT INTO core.grants (grant_id, product_id, entitlement_set_id, granted_to_org_id, granted_by_person_id, grant_reason)
VALUES ('00000000-0000-0000-0000-000000000703', NULL, '00000000-0000-0000-0000-000000000401',
'00000000-0000-0000-0000-000000000201', NULL, 'default');
INSERT INTO core.pool_provisions (provision_id, pool_id, grant_id, status, activated_at, entitlement_set_id)
VALUES ('00000000-0000-0000-0000-000000000803', '00000000-0000-0000-0000-000000000301',
'00000000-0000-0000-0000-000000000703', 'active', NOW() - INTERVAL '20 days',
'00000000-0000-0000-0000-000000000401');
-- G4: operator grant of addonA with a free-text reason (finding #48 shape).
INSERT INTO core.grants (grant_id, product_id, granted_to_org_id, granted_by_person_id, grant_reason)
VALUES ('00000000-0000-0000-0000-000000000704', '00000000-0000-0000-0000-000000000503',
'00000000-0000-0000-0000-000000000201', '00000000-0000-0000-0000-000000000101',
'special favor');
INSERT INTO core.pool_provisions (provision_id, pool_id, grant_id, status, activated_at, entitlement_set_id)
VALUES ('00000000-0000-0000-0000-000000000804', '00000000-0000-0000-0000-000000000301',
'00000000-0000-0000-0000-000000000704', 'active', NOW() - INTERVAL '10 days',
'00000000-0000-0000-0000-000000000402');
`
// taxonomyMigAmbiguity tiers plan1 (which shares entitlement set 0401 with
// plan0) into a SECOND ladder, so the set-direct grant G3 resolves to two
// ladders and §9 step 4 must halt naming it.
const taxonomyMigAmbiguity = `
INSERT INTO core.plan_ladders (plan_ladder_id, ladder_key, name)
VALUES ('00000000-0000-0000-0000-000000000602', 'mig-ladder-2', 'Mig Ladder Two');
INSERT INTO core.plan_ladder_tiers (plan_ladder_id, product_id, rank) VALUES
('00000000-0000-0000-0000-000000000602', '00000000-0000-0000-0000-000000000502', 0);
`
// startDisposablePostgres runs a throwaway postgres container publishing 5432
// on an ephemeral localhost port, waits for the final server (TCP only comes
// up after the image's init restart), and returns a base DSN.
func startDisposablePostgres(t *testing.T) string {
t.Helper()
name := fmt.Sprintf("taxonomy-migtest-%d", time.Now().UnixNano())
out, err := exec.Command("docker", "run", "-d", "--name", name,
"-p", "127.0.0.1::5432",
"-e", "POSTGRES_USER=member_console",
"-e", "POSTGRES_PASSWORD=member_console",
"-e", "POSTGRES_DB=member_console",
taxonomyMigImage).CombinedOutput()
if err != nil {
t.Skipf("cannot start disposable postgres (%v): %s", err, out)
}
t.Cleanup(func() { _ = exec.Command("docker", "rm", "-f", name).Run() })
portOut, err := exec.Command("docker", "port", name, "5432/tcp").Output()
if err != nil {
t.Fatalf("docker port: %v", err)
}
hostPort := ""
for _, line := range strings.Split(strings.TrimSpace(string(portOut)), "\n") {
if strings.HasPrefix(line, "127.0.0.1:") {
hostPort = line
break
}
}
if hostPort == "" {
t.Fatalf("no 127.0.0.1 mapping in docker port output: %q", portOut)
}
dsn := fmt.Sprintf("postgres://member_console:member_console@%s/member_console?sslmode=disable", hostPort)
probe, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open probe: %v", err)
}
defer probe.Close()
deadline := time.Now().Add(60 * time.Second)
for {
if err := probe.Ping(); err == nil {
break
}
if time.Now().After(deadline) {
t.Fatalf("postgres container did not become ready")
}
time.Sleep(500 * time.Millisecond)
}
return dsn
}
// migrationUpSQL reads a migration file (relative to this package directory)
// and returns its Up section — everything before the "-- +goose Down" marker.
func migrationUpSQL(t *testing.T, filename string) string {
t.Helper()
raw, err := os.ReadFile("migrations/" + filename)
if err != nil {
t.Fatalf("read migration %s: %v", filename, err)
}
up, _, _ := strings.Cut(string(raw), "-- +goose Down")
return up
}
func TestTaxonomyMigrationBuckets(t *testing.T) {
if os.Getenv("TEST_DATABASE_URL") == "" {
t.Skip("TEST_DATABASE_URL not set, skipping integration test")
}
if _, err := exec.LookPath("docker"); err != nil {
t.Skip("docker not available, skipping migration bucket test")
}
ctx := context.Background()
dsn := startDisposablePostgres(t)
// The apply connection uses the simple query protocol so a whole
// migration file executes as one multi-statement string — an implicit
// transaction with goose-equivalent atomicity.
applyDB, err := sql.Open("pgx", dsn+"&default_query_exec_mode=simple_protocol")
if err != nil {
t.Fatalf("open apply conn: %v", err)
}
defer applyDB.Close()
assertDB, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open assert conn: %v", err)
}
defer assertDB.Close()
apply := func(filename string) error {
_, err := applyDB.ExecContext(ctx, migrationUpSQL(t, filename))
return err
}
mustApply := func(filename string) {
t.Helper()
if err := apply(filename); err != nil {
t.Fatalf("apply %s: %v", filename, err)
}
}
count := func(query string, args ...any) int {
t.Helper()
var n int
if err := assertDB.QueryRowContext(ctx, query, args...).Scan(&n); err != nil {
t.Fatalf("count %q: %v", query, err)
}
return n
}
str := func(query string, args ...any) string {
t.Helper()
var s string
if err := assertDB.QueryRowContext(ctx, query, args...).Scan(&s); err != nil {
t.Fatalf("scan %q: %v", query, err)
}
return s
}
// Baseline: the pre-Doc-41 schema.
mustApply("00001_init.sql")
mustApply("00002_seed_resource_keys.sql")
mustApply("00003_seed_system_roles_org_types.sql")
// Old-world pre-state plus the ambiguity: set 0401's tier products span
// two ladders, so the set-direct grant G3 has no single candidate.
if _, err := applyDB.ExecContext(ctx, taxonomyMigPrestate+taxonomyMigAmbiguity); err != nil {
t.Fatalf("seed pre-state: %v", err)
}
// Halt-and-report: 00004 must fail naming the ambiguous grant, and the
// failed run must roll back atomically.
err = apply("00004_doc41_schema_and_data.sql")
if err == nil {
t.Fatal("expected 00004 to halt on the ambiguous set-direct grant, got nil")
}
if !strings.Contains(err.Error(), "00000000-0000-0000-0000-000000000703") {
t.Errorf("halt message must name the grant, got: %v", err)
}
if !strings.Contains(err.Error(), "2 ladders") {
t.Errorf("halt message must report the ladder count, got: %v", err)
}
if n := count(`SELECT count(*) FROM information_schema.columns
WHERE table_schema='core' AND table_name='grants' AND column_name='entitlement_set_id'`); n != 1 {
t.Fatalf("halted 00004 did not roll back: grants.entitlement_set_id column count = %d, want 1", n)
}
if s := str(`SELECT grant_reason FROM core.grants WHERE grant_id='00000000-0000-0000-0000-000000000702'`); s != "trial" {
t.Fatalf("halted 00004 did not roll back: G2 reason = %q, want trial", s)
}
// Manual resolution: remove the second ladder; the set resolves uniquely.
if _, err := applyDB.ExecContext(ctx, `
DELETE FROM core.plan_ladder_tiers WHERE plan_ladder_id='00000000-0000-0000-0000-000000000602';
DELETE FROM core.plan_ladders WHERE plan_ladder_id='00000000-0000-0000-0000-000000000602';`); err != nil {
t.Fatalf("de-ambiguate: %v", err)
}
mustApply("00004_doc41_schema_and_data.sql")
// --- §9 bucket outcomes -------------------------------------------------
// Step 4: the set-direct grant is repointed to the LOWEST-rank tier
// product sharing its set (plan0, rank 0) and the bare-set column is gone.
if s := str(`SELECT product_id::text FROM core.grants WHERE grant_id='00000000-0000-0000-0000-000000000703'`); s != "00000000-0000-0000-0000-000000000501" {
t.Errorf("set-direct grant repointed to %s, want plan0 ...0501 (lowest rank)", s)
}
if n := count(`SELECT count(*) FROM information_schema.columns
WHERE table_schema='core' AND table_name='grants' AND column_name='entitlement_set_id'`); n != 0 {
t.Errorf("grants.entitlement_set_id still exists after 00004")
}
// Its live provision got an honest retroactive attachment plus an
// initiate transition at cutover (Appendix scenario 3).
if n := count(`SELECT count(*) FROM core.pool_provision_ladders
WHERE provision_id='00000000-0000-0000-0000-000000000803'
AND plan_ladder_id='00000000-0000-0000-0000-000000000601'
AND status='active'
AND product_id='00000000-0000-0000-0000-000000000501'`); n != 1 {
t.Errorf("retroactive attachment rows = %d, want 1", n)
}
if n := count(`SELECT count(*) FROM core.pool_provision_transitions
WHERE provision_id='00000000-0000-0000-0000-000000000803'
AND transition_type='initiate'
AND provision_ladder_id IS NOT NULL`); n != 1 {
t.Errorf("retroactive initiate transitions = %d, want 1", n)
}
// Step 3: 'trial' renamed to 'evaluation' (semantic identity preserved).
if s := str(`SELECT grant_reason FROM core.grants WHERE grant_id='00000000-0000-0000-0000-000000000702'`); s != "evaluation" {
t.Errorf("trial grant reason = %q, want evaluation", s)
}
// Free-text reason bucketed to 'manual' with the original preserved in
// the description audit note.
if s := str(`SELECT grant_reason FROM core.grants WHERE grant_id='00000000-0000-0000-0000-000000000704'`); s != "manual" {
t.Errorf("free-text grant reason = %q, want manual", s)
}
if n := count(`SELECT count(*) FROM core.grants
WHERE grant_id='00000000-0000-0000-0000-000000000704' AND description LIKE '%special favor%'`); n != 1 {
t.Errorf("original free-text reason not preserved in description")
}
// Step 5: provision product identity is total and NOT NULL — the grant
// arc backfilled every provision.
if s := str(`SELECT is_nullable FROM information_schema.columns
WHERE table_schema='core' AND table_name='pool_provisions' AND column_name='product_id'`); s != "NO" {
t.Errorf("pool_provisions.product_id is_nullable = %q, want NO", s)
}
if s := str(`SELECT product_id::text FROM core.pool_provisions WHERE provision_id='00000000-0000-0000-0000-000000000804'`); s != "00000000-0000-0000-0000-000000000503" {
t.Errorf("addon provision product = %s, want addonA ...0503 (grant arc)", s)
}
// Step 6: every transition names its occupancy episode after backfill.
if n := count(`SELECT count(*) FROM core.pool_provision_transitions WHERE provision_ladder_id IS NULL`); n != 0 {
t.Errorf("transitions without provision_ladder_id = %d, want 0", n)
}
// --- 00005 + 00006 apply cleanly on the migrated data --------------------
mustApply("00005_doc41_conferral_functions.sql")
mustApply("00006_doc41_sweep_and_catalog.sql")
if n := count(`SELECT count(*) FROM information_schema.columns
WHERE table_schema='core' AND table_name='products' AND column_name='display_category'`); n != 1 {
t.Errorf("products.display_category missing after 00006")
}
if n := count(`SELECT count(*) FROM information_schema.columns
WHERE table_schema='core' AND table_name='products' AND column_name='product_type'`); n != 0 {
t.Errorf("products.product_type still exists after 00006")
}
if n := count(`SELECT count(*) FROM information_schema.views
WHERE table_schema='core' AND table_name IN ('product_shape','product_conferral_shapes')`); n != 2 {
t.Errorf("per-consumer views = %d, want 2 (product_shape, product_conferral_shapes)", n)
}
if n := count(`SELECT count(*) FROM information_schema.views
WHERE table_schema='core' AND table_name='product_kinds'`); n != 0 {
t.Errorf("core.product_kinds still exists after 00006")
}
if n := count(`SELECT count(*) FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname='core' AND p.proname IN
('confer','end_conferral','sync_source_status','align_conferral_shape','update_conferral_bounds')`); n != 5 {
t.Errorf("conferral functions = %d, want 5", n)
}
}