import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import test from "node:test"; import { types as pgTypes } from "pg"; import { SafeProductionMigrationError, assertActiveAdminRoles, assertActiveAdminUsers, assertTargetAdminState, assertTargetEmpty, copiedDeferredForeignKeys, normalizeAuthUser, normalizeAuthUsers, parseMode, prepareColumnValues, readActiveAdminUserIds, readConfiguration, readSchema, rowsSha256, transferPlan, } from "../scripts/migrate-supabase-production.mjs"; const scriptPath = fileURLToPath( new URL("../scripts/migrate-supabase-production.mjs", import.meta.url), ); function table( columns: Array<{ name: string; nullable?: boolean; defaultValue?: string | null; generated?: boolean; identity?: boolean; identityGeneration?: string | null; dataType?: string; udtName?: string; }>, primaryKey = ["id"], foreignKeys: Array<{ columns: string[]; refSchema: string; refTable: string; refColumns: string[]; }> = [], ) { return { columns: columns.map((column) => ({ nullable: false, defaultValue: null, generated: false, identity: false, identityGeneration: null, dataType: "text", udtName: "text", ...column, })), primaryKey, foreignKeys, }; } test("CLI requires one explicit migration mode", () => { assert.equal(parseMode(["--preflight"]), "preflight"); assert.equal(parseMode(["--apply"]), "apply"); assert.equal(parseMode(["--verify"]), "verify"); assert.throws(() => parseMode([]), SafeProductionMigrationError); assert.throws(() => parseMode(["--apply", "--verify"]), SafeProductionMigrationError); }); test("configuration requires an explicit Owner and ciphertext decision", () => { const base = { SUPABASE_SOURCE_DATABASE_URL: "postgresql://source.invalid/jyotisha", PRODUCTION_TARGET_DATABASE_URL: "postgresql://target.invalid/jyotisha", PRODUCTION_OWNER_USER_ID: "b8907d0c-6ed0-4270-b866-7e83bb4a1b26", PRODUCTION_OWNER_EMAIL: "luna@copse.life", }; assert.equal( readConfiguration({ ...base, PRODUCTION_CIPHERTEXT_MODE: "exclude" }).ciphertextMode, "exclude", ); assert.throws( () => readConfiguration({ ...base, PRODUCTION_OWNER_USER_ID: "018f4e6d-7a11-7000-8000-000000000001", PRODUCTION_CIPHERTEXT_MODE: "exclude", }), /approved production Owner/, ); assert.throws( () => readConfiguration({ ...base, PRODUCTION_OWNER_EMAIL: "other@example.com", PRODUCTION_CIPHERTEXT_MODE: "exclude", }), /approved production Owner/, ); assert.throws( () => readConfiguration({ ...base, PRODUCTION_CIPHERTEXT_MODE: "preserve" }), /confirmed production encryption keys/, ); assert.equal( readConfiguration({ ...base, PRODUCTION_CIPHERTEXT_MODE: "preserve", PRODUCTION_CIPHERTEXT_KEYS_CONFIRMED: "true", }).ciphertextMode, "preserve", ); }); test("PostgreSQL timestamp parsers preserve microseconds", () => { const value = "2026-08-09 00:00:00.123456+00"; assert.equal(pgTypes.getTypeParser(1184)(value), value); assert.equal(pgTypes.getTypeParser(1114)(value), value); }); test("Supabase identity transform preserves UUID and ban state without credentials", () => { const source = { id: "018F4E6D-7A11-7000-8000-000000000001", email: " Person@Example.com ", raw_user_meta_data: { full_name: "Person One", avatar_url: "https://example.invalid/a.png" }, email_confirmed_at: new Date("2026-07-01T00:00:00Z"), banned_until: new Date("2027-01-01T00:00:00Z"), created_at: new Date("2026-06-01T00:00:00Z"), updated_at: new Date("2026-07-02T00:00:00Z"), encrypted_password: "must-not-migrate", refresh_token: "must-not-migrate", mfa_secret: "must-not-migrate", }; const user = normalizeAuthUser(source, new Date("2026-08-09T00:00:00Z")); assert.equal(user.id, source.id.toLowerCase()); assert.equal(user.email, "person@example.com"); assert.equal(user.banned, true); assert.deepEqual(user.ban_expires, source.banned_until); assert.equal(user.two_factor_enabled, false); assert.deepEqual(user.created_at, source.created_at); assert.deepEqual(user.updated_at, source.updated_at); assert.doesNotMatch(JSON.stringify(user), /must-not-migrate|password|refresh_token|mfa_secret/); }); test("production identity preflight rejects duplicate canonical emails", () => { const base = { id: "018f4e6d-7a11-7000-8000-000000000001", email: "person@example.com", raw_user_meta_data: {}, email_confirmed_at: null, created_at: new Date("2026-06-01T00:00:00Z"), updated_at: new Date("2026-06-01T00:00:00Z"), }; assert.throws( () => normalizeAuthUsers([ base, { ...base, id: "018f4e6d-7a11-7000-8000-000000000002", email: " PERSON@example.com " }, ]), /invalid or duplicate auth identities/, ); }); test("legacy production without admin tables promotes only the designated Owner", async () => { const ownerId = "018f4e6d-7a11-7000-8000-000000000001"; const source = { async query() { throw new Error("legacy fallback must not query a missing admin_users table"); }, }; assert.deepEqual( [...await readActiveAdminUserIds(source, new Map(), ownerId)], [ownerId], ); }); test("source and target reject a second active Owner", async () => { const sourceTables = new Map([ ["admin_users", table([{ name: "user_id" }])], ["admin_user_roles", table([{ name: "admin_user_id" }, { name: "role_id" }])], ["admin_roles", table([{ name: "id" }, { name: "code" }])], ]); let query = 0; const source = { async query() { query += 1; return { rows: [{ count: query === 1 ? "0" : "1" }] }; }, }; await assert.rejects( () => assertActiveAdminRoles(source, sourceTables, "018f4e6d-7a11-7000-8000-000000000001"), /more than one active Owner/, ); const target = { async query() { return { rows: [{ owner_count: 1, total_owner_count: 2, admins_without_roles: 0, unusable_identity_admins: 0 }] }; }, }; await assert.rejects( () => assertTargetAdminState(target, "018f4e6d-7a11-7000-8000-000000000001"), /administrator reconciliation failed/, ); }); test("active administrators become usable identity admins", () => { const ownerId = "018f4e6d-7a11-7000-8000-000000000001"; const adminId = "018f4e6d-7a11-7000-8000-000000000002"; const sourceUsers = [ownerId, adminId].map((id) => ({ id, email: `${id}@example.com`, raw_user_meta_data: {}, email_confirmed_at: null, banned_until: null, created_at: new Date("2026-06-01T00:00:00Z"), updated_at: new Date("2026-06-01T00:00:00Z"), })); const activeAdminUserIds = new Set([ownerId, adminId]); const users = normalizeAuthUsers( sourceUsers, new Date("2026-08-09T00:00:00Z"), activeAdminUserIds, ); assert.deepEqual(users.map((user) => user.role), ["admin", "admin"]); assert.doesNotThrow(() => assertActiveAdminUsers(users, activeAdminUserIds, ownerId, `${ownerId}@example.com`)); assert.throws( () => assertActiveAdminUsers(users, new Set([adminId]), ownerId, `${ownerId}@example.com`), /Owner is not an active source administrator/, ); assert.throws( () => assertActiveAdminUsers([{ ...users[0], banned: true }, users[1]], activeAdminUserIds, ownerId, `${ownerId}@example.com`), /Owner is blocked/, ); assert.throws( () => assertActiveAdminUsers(users, activeAdminUserIds, ownerId, "wrong@example.com"), /UUID does not match/, ); }); test("schema reader preserves PostgreSQL identity metadata", async () => { const client = { async query(text: string) { if (text.includes("information_schema.columns")) { return { rows: [{ table_name: "redemption_attempts", column_name: "id", nullable: false, column_default: null, generated: false, identity: true, identity_generation: "ALWAYS", data_type: "bigint", udt_name: "int8", }], }; } if (text.includes("PRIMARY KEY")) { return { rows: [{ table_name: "redemption_attempts", columns: ["id"] }] }; } return { rows: [] }; }, }; const schema = await readSchema(client, "public"); assert.deepEqual(schema.get("redemption_attempts")?.columns[0], { name: "id", nullable: false, defaultValue: null, generated: false, identity: true, identityGeneration: "ALWAYS", dataType: "bigint", udtName: "int8", }); }); test("JSON columns are serialized without changing PostgreSQL arrays", () => { const target = table([ { name: "payload", dataType: "jsonb", udtName: "jsonb" }, { name: "tags", dataType: "ARRAY", udtName: "_text" }, ]); const tags = ["a", "b"]; assert.deepEqual( prepareColumnValues({ payload: [], tags }, ["payload", "tags"], target), ["[]", tags], ); }); test("target-only nullable foreign keys are not restored from the legacy source", () => { const target = table( [{ name: "id" }, { name: "new_session_id", nullable: true }], ["id"], [{ columns: ["new_session_id"], refSchema: "public", refTable: "chat_sessions", refColumns: ["id"], }], ); assert.deepEqual( copiedDeferredForeignKeys("requests", target, new Set(["chat_sessions"]), ["id"]), [], ); }); test("transfer plan rejects source-only public columns", () => { const source = new Map([ ["profiles", table([{ name: "id" }, { name: "legacy_value" }])], ]); const target = new Map([ ["profiles", table([{ name: "id" }])], ]); assert.throws(() => transferPlan(source, target), /target schema is missing a source column/); }); test("transfer plan uses non-nullable dependencies and rejects unsafe cycles", () => { const source = new Map([ ["parent", table([{ name: "id" }])], ["child", table([{ name: "id" }, { name: "parent_id" }])], ]); const target = new Map([ ["parent", table([{ name: "id" }])], ["child", table( [{ name: "id" }, { name: "parent_id" }], ["id"], [{ columns: ["parent_id"], refSchema: "public", refTable: "parent", refColumns: ["id"] }], )], ]); assert.deepEqual(transferPlan(source, target).ordered, ["parent", "child"]); target.get("parent")!.columns.push({ name: "child_id", nullable: false, defaultValue: null, generated: false, identity: false, identityGeneration: null, dataType: "text", udtName: "text", }); source.get("parent")!.columns.push({ name: "child_id", nullable: false, defaultValue: null, generated: false, identity: false, identityGeneration: null, dataType: "text", udtName: "text", }); target.get("parent")!.foreignKeys.push({ columns: ["child_id"], refSchema: "public", refTable: "child", refColumns: ["id"], }); assert.throws(() => transferPlan(source, target), /foreign-key cycle/); }); test("target preflight rejects existing business rows but permits migration seeds", async () => { const counts = new Map([ ["identity.users", 0], ["identity.accounts", 0], ["identity.sessions", 0], ["identity.verifications", 0], ["identity.otp_rate_limits", 0], ["identity.two_factors", 0], ["auth.users", 0], ["public.admin_roles", 6], ["public.profiles", 1], ]); const client = { async query(text: string) { const match = text.match(/from\s+"(identity|auth|public)"\."([a-z_]+)"/i); assert.ok(match, text); return { rows: [{ count: String(counts.get(`${match[1]}.${match[2]}`) ?? 0) }] }; }, }; const targetTables = new Map([ ["admin_roles", table([{ name: "id" }])], ["profiles", table([{ name: "id" }])], ]); await assert.rejects(() => assertTargetEmpty(client, targetTables), /not empty/); counts.set("public.profiles", 0); counts.set("identity.verifications", 1); await assert.rejects(() => assertTargetEmpty(client, targetTables), /identity database is not empty/); counts.set("identity.verifications", 0); await assert.doesNotReject(() => assertTargetEmpty(client, targetTables)); }); test("reconciliation hashes are stable and the script has no wildcard data reads", () => { const rows = [ { id: "b", payload: { z: 2, a: 1 } }, { id: "a", payload: { a: 1, z: 2 } }, ]; assert.equal(rowsSha256(rows, ["id", "payload"]), rowsSha256([...rows].reverse(), ["id", "payload"])); const source = readFileSync(scriptPath, "utf8"); assert.doesNotMatch(source, /select\s+\*/i); assert.match(source, /begin isolation level repeatable read read only/i); assert.match(source, /pg_advisory_xact_lock/); assert.match(source, /target business database is not empty/); assert.match(source, /rollback/); assert.match(source, /--preflight/); assert.match(source, /--apply/); assert.match(source, /--verify/); assert.match(source, /overriding system value/i); assert.match(source, /setval\(pg_get_serial_sequence/i); assert.match(source, /target_primary_key_sha256/); assert.match(source, /sourceKeyHash === targetKeyHash/); assert.match(source, /users\.role = 'admin' and users\.banned = false/); });