Files
Jyotisha/frontend/tests/production-data-migration.test.ts
T
Jesse_Chen 0e5575a8a9
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled
test: close local release gate regressions
2026-08-15 09:32:28 +08:00

405 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,
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/);
});