Require explicit grandfather or migrate dispositions for outgoing defaults. Treat any live plan attachment as blocking baseline restoration.
371 lines
17 KiB
Go
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)
|
|
}
|
|
}
|