Files
MikrotikManager/backend/src/db/migration-schema-inventory.sql.test.ts
T

120 lines
8.5 KiB
TypeScript

import assert from "node:assert/strict"
import { randomUUID } from "node:crypto"
import { basename, dirname, resolve } from "node:path"
import { fileURLToPath } from "node:url"
assert.equal(process.env.MMAPP_OWN_MIGRATION_SCHEMA_INVENTORY, "true")
const databaseUrl = new URL(process.env.DATABASE_URL ?? "")
assert.equal(databaseUrl.hostname, "127.0.0.1"); assert.equal(databaseUrl.pathname, "/postgres")
assert(["postgres:", "postgresql:"].includes(databaseUrl.protocol))
assert(process.env.MCP_STATE_DIR && basename(dirname(resolve(process.env.MCP_STATE_DIR))).startsWith("mm-mcp-test-"))
const { Pool } = await import("pg")
const { pool, closePool } = await import("./index.js")
const { applySqlMigrations } = await import("./migrate.js")
const { inventoryMigrationDirectory, migrationManifestDigest, MIGRATION_MANIFEST } = await import("./migration-manifest.js")
const { captureMigrationSchema, compareMigrationSchemas, MigrationSchemaInventoryError } = await import("./migration-schema-inventory.js")
const suffix = randomUUID().replaceAll("-", "").slice(0, 12), names: string[] = []
const identifier = (value: string) => { assert(/^mm_schema_[a-z0-9_]+$/.test(value)); return '"' + value + '"' }
const database = async (label: string, template?: string) => {
const name = `mm_schema_${suffix}_${label}`; identifier(name)
await pool.query(`CREATE DATABASE ${identifier(name)} TEMPLATE ${template ? identifier(template) : "template0"}`)
names.push(name)
const url = new URL(databaseUrl); url.pathname = "/" + name
return { name, connection: new Pool({ connectionString: url.toString(), max: 2 }) }
}
async function capture(connection: InstanceType<typeof Pool>, options?: Parameters<typeof captureMigrationSchema>[1]) {
const client = await connection.connect()
try {
await client.query("BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY")
await client.query("SET LOCAL search_path=pg_catalog")
await client.query("SET LOCAL TimeZone='UTC'")
await client.query("SET LOCAL DateStyle='ISO, YMD'")
await client.query("SET LOCAL statement_timeout='5s'")
const result = await captureMigrationSchema(client, options)
await client.query("ROLLBACK")
return result
} catch (error) { await client.query("ROLLBACK").catch(() => {}); throw error }
finally { client.release() }
}
const cases: string[] = []
try {
await applySqlMigrations(pool)
const inventory = await inventoryMigrationDirectory(fileURLToPath(new URL("../../drizzle/", import.meta.url)))
assert.equal(inventory.length, MIGRATION_MANIFEST.length)
const reference = await database("reference")
let baseline: Awaited<ReturnType<typeof capture>>
try {
await applySqlMigrations(reference.connection)
baseline = await capture(reference.connection)
assert.equal(baseline.format, "pg18-application-schema-v3")
assert(baseline.sections.relations.length >= 89)
assert(baseline.sections.columns.length > 500)
assert(baseline.sections.constraints.length > 100)
assert(baseline.sections.functions.length > 0)
assert.deepEqual(compareMigrationSchemas(baseline, await capture(reference.connection)), { equal: true, evidenceOnly: true, enrollmentAuthorized: false, sha256: baseline.sha256 })
cases.push("all36 pinned migrations produce repeatable complete PG18 catalog evidence on an actual checked-out read-only backend")
} finally { await reference.connection.end() }
const independent = await database("independent")
try {
await applySqlMigrations(independent.connection)
const observed = await capture(independent.connection)
try { compareMigrationSchemas(baseline!, observed) } catch (error) {
// This reference is built exclusively from the pinned repository SQL;
// report field names, not function bodies/default values or user data.
const differences = Object.keys(baseline!.sections).flatMap(key => {
const section = key as keyof typeof baseline.sections, before = baseline!.sections[section], after = observed.sections[section]
if (JSON.stringify(before) === JSON.stringify(after)) return []
return [{ section, beforeRows: before.length, afterRows: after.length, fields: [...new Set(before.flatMap((row, i) => Object.keys(row).filter(field => JSON.stringify(row[field]) !== JSON.stringify(after[i]?.[field]))))] }]
})
console.error("Independent schema differences:", JSON.stringify(differences)); throw error
}
cases.push("independently migrated database with different physical OIDs matches the same exact logical schema")
} finally { await independent.connection.end() }
const mismatches: [string, string][] = [
["table", "CREATE TABLE public.mm_schema_extra(id integer)"],
["column-default", "ALTER TABLE public.servers ALTER COLUMN name SET DEFAULT 'owned schema drift'"],
["column-type", "ALTER TABLE public.servers ALTER COLUMN name TYPE varchar(999)"],
["index", "CREATE INDEX mm_schema_index ON public.servers(host)"],
["constraint", "ALTER TABLE public.servers ADD CONSTRAINT mm_schema_port CHECK(port>0) NOT VALID"],
["foreign-key", "ALTER TABLE public.servers ADD CONSTRAINT mm_schema_self FOREIGN KEY(id) REFERENCES public.servers(id) DEFERRABLE INITIALLY DEFERRED"],
["sequence", "ALTER SEQUENCE public.servers_id_seq INCREMENT BY 2"],
["comment", "COMMENT ON TABLE public.servers IS 'owned schema drift'"],
["function", "CREATE FUNCTION public.mm_schema_function() RETURNS integer LANGUAGE sql IMMUTABLE AS 'SELECT 7'"],
["view", "CREATE VIEW public.mm_schema_view AS SELECT id FROM public.servers"],
["partition", "CREATE TABLE public.mm_schema_partitioned(id integer) PARTITION BY RANGE(id); CREATE TABLE public.mm_schema_leaf PARTITION OF public.mm_schema_partitioned FOR VALUES FROM(0) TO(10)"],
["trigger", "CREATE FUNCTION public.mm_schema_trigger_fn() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RETURN NEW; END'; CREATE TRIGGER mm_schema_trigger BEFORE UPDATE ON public.servers FOR EACH ROW EXECUTE FUNCTION public.mm_schema_trigger_fn()"],
]
for (const [label, sql] of mismatches) {
const target = await database(label.replaceAll("-", "_"), reference.name)
try {
await target.connection.query(sql)
const observed = await capture(target.connection)
assert.throws(() => compareMigrationSchemas(baseline!, observed), MigrationSchemaInventoryError, label)
cases.push("actual " + label + " drift differs from the complete captured baseline")
} finally { await target.connection.end() }
}
const unsupported = await database("unsupported", reference.name)
try {
await unsupported.connection.query("ALTER TABLE public.servers ENABLE ROW LEVEL SECURITY; CREATE POLICY mm_schema_policy ON public.servers USING(true)")
await assert.rejects(capture(unsupported.connection), MigrationSchemaInventoryError)
cases.push("actual unsupported RLS policy refuses capture rather than disappearing from equality")
} finally { await unsupported.connection.end() }
const control = await database("control", reference.name)
try {
await control.connection.query("CREATE SCHEMA mm_control; CREATE TABLE mm_control.mm_schema_private(id integer)")
await assert.rejects(capture(control.connection), MigrationSchemaInventoryError)
const scoped = await capture(control.connection, { scope: { applicationNamespaces: ["public"], excludedControlNamespaces: ["mm_control"] } })
assert(scoped.sections.namespaces.some(row => row.namespace === "mm_control"))
assert(!scoped.sections.relations.some(row => row.namespace === "mm_control"))
await control.connection.query("CREATE VIEW public.mm_schema_external_view AS SELECT id FROM mm_control.mm_schema_private")
await assert.rejects(capture(control.connection, { scope: { applicationNamespaces: ["public"], excludedControlNamespaces: ["mm_control"] } }), MigrationSchemaInventoryError)
cases.push("extra namespace refuses by default; explicit control exclusion is inventoried and application cross-dependency still refuses")
} finally { await control.connection.end() }
const client = await pool.connect()
try { await assert.rejects(captureMigrationSchema(client), MigrationSchemaInventoryError); cases.push("autocommit/read-write connection cannot claim a stable read-only catalog snapshot") }
finally { client.release() }
console.log("Owned migration schema inventory SQL:", JSON.stringify({ cases: cases.length, names: cases, databases: names.length, actualSql: true, pinnedFiles: inventory.length, manifestDigest: migrationManifestDigest(), logicalSchemaDigest: baseline!.sha256, dispatcherChanged: false, enrollmentAuthorized: false, productionDatabaseTouched: false }))
} finally { await closePool() }