403 lines
13 KiB
TypeScript
403 lines
13 KiB
TypeScript
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,
|
|
});
|
|
source.get("parent")!.columns.push({
|
|
name: "child_id", nullable: false, defaultValue: null, generated: false,
|
|
});
|
|
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/);
|
|
});
|