import { createHash } from "node:crypto"; import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { formatMigrationValidationReport } from "../src/cli/migration-report.js"; import { discoverModuleMigrations, ModuleDiscoveryError, ModuleMigrationDiscoveryError, type ModuleMigrationDiscoveryIssueCode, } from "../src/modules/index.js"; import { createMigrationModule, manifestFor, originSnapshotId, snapshotId, } from "./support/migration-module-fixture.js"; const temporaryDirectories: string[] = []; afterEach(async () => { await Promise.all( temporaryDirectories .splice(0) .map((directory) => rm(directory, { force: true, recursive: true })), ); }); async function createModulesDirectory(): Promise { const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-migrations-")); temporaryDirectories.push(directory); return directory; } function discoveryOptions(modulesDirectory: string) { return { moduleSdkVersion: "0.1.0", modulesDirectory, platformVersion: "0.1.0", }; } async function captureMigrationError( modulesDirectory: string, ): Promise { try { await discoverModuleMigrations(discoveryOptions(modulesDirectory)); } catch (error) { if (error instanceof ModuleMigrationDiscoveryError) { return error; } throw error; } throw new Error("Expected module migration discovery to fail."); } function issueCodes(error: ModuleMigrationDiscoveryError): ModuleMigrationDiscoveryIssueCode[] { return error.issues.map((issue) => issue.code); } function sha256(contents: string | Uint8Array): string { return createHash("sha256").update(contents).digest("hex"); } describe("module migration discovery", () => { it("aggregates dependency-first modules and journal-ordered migrations with an exact report", async () => { const modulesDirectory = await createModulesDirectory(); const peopleInitial = 'CREATE TABLE "people_records" ("id" integer);\n'; const peopleArchive = new TextEncoder().encode( 'ALTER TABLE "people_records" ADD COLUMN "archived" boolean;\r\n', ); const calendarEvents = 'CREATE TABLE "calendar_events" ("id" integer);\n'; await createMigrationModule(modulesDirectory, "calendar", { dependencies: { modules: { required: { people: "^0.1.0" } } }, migrations: [{ sql: calendarEvents, tag: "0000_events" }], }); await createMigrationModule(modulesDirectory, "people", { migrations: [ { sql: peopleInitial, tag: "0000_initial" }, { destructive: { mode: "approval-required", reason: "Existing deployments must approve the archive-column rollout.", }, sql: peopleArchive, tag: "0001_archive", }, ], }); const aggregate = await discoverModuleMigrations(discoveryOptions(modulesDirectory)); expect(aggregate.modules.map((module) => module.moduleId)).toEqual(["people", "calendar"]); expect(aggregate.migrations.map((migration) => migration.identity)).toEqual([ "people:0000_initial", "people:0001_archive", "calendar:0000_events", ]); expect(aggregate.migrations.map((migration) => migration.checksum)).toEqual([ sha256(peopleInitial), sha256(peopleArchive), sha256(calendarEvents), ]); expect(aggregate.migrations[1]).toMatchObject({ approvalRequired: true, breakpoints: true, destructive: { mode: "approval-required", reason: "Existing deployments must approve the archive-column rollout.", }, file: "0001_archive.sql", index: 1, timestamp: 1_700_000_000_001, }); expect(formatMigrationValidationReport(aggregate)).toEqual([ "Migration validation passed (2 modules, 3 migrations):", `- people:0000_initial sha256=${sha256(peopleInitial)} destructive=none`, `- people:0001_archive sha256=${sha256(peopleArchive)} destructive=approval-required`, `- calendar:0000_events sha256=${sha256(calendarEvents)} destructive=none`, ]); expect(Object.isFrozen(aggregate)).toBe(true); expect(Object.isFrozen(aggregate.modules)).toBe(true); expect(Object.isFrozen(aggregate.modules[0])).toBe(true); expect(Object.isFrozen(aggregate.modules[0]?.migrations)).toBe(true); expect(Object.isFrozen(aggregate.migrations)).toBe(true); expect(Object.isFrozen(aggregate.migrations[0])).toBe(true); expect(Object.isFrozen(aggregate.migrations[1]?.destructive)).toBe(true); expect(Object.isFrozen(formatMigrationValidationReport(aggregate))).toBe(true); }); it("hashes exact SQL bytes and changes only the checksum when content changes", async () => { const modulesDirectory = await createModulesDirectory(); const original = new TextEncoder().encode("SELECT 1;\n"); const changed = new TextEncoder().encode("SELECT 1;\r\n"); const fixture = await createMigrationModule(modulesDirectory, "exact", { migrations: [{ sql: original, tag: "0000_initial" }], }); const first = await discoverModuleMigrations(discoveryOptions(modulesDirectory)); expect(first.migrations[0]).toMatchObject({ checksum: sha256(original), identity: "exact:0000_initial", sql: "SELECT 1;\n", }); await writeFile(path.join(fixture.migrationDirectory ?? "", "0000_initial.sql"), changed); const second = await discoverModuleMigrations(discoveryOptions(modulesDirectory)); expect(second.migrations[0]?.identity).toBe(first.migrations[0]?.identity); expect(second.migrations[0]?.checksum).toBe(sha256(changed)); expect(second.migrations[0]?.checksum).not.toBe(first.migrations[0]?.checksum); }); it("supports modules without migrations without importing server or schema code", async () => { const modulesDirectory = await createModulesDirectory(); await createMigrationModule(modulesDirectory, "schema-only", { migrations: false, schemaSource: 'throw new Error("schema entrypoint must remain unloaded");', serverSource: 'throw new Error("server entrypoint must remain unloaded");', }); await createMigrationModule(modulesDirectory, "server-only", { migrations: false, serverSource: 'throw new Error("server entrypoint must remain unloaded");', }); const aggregate = await discoverModuleMigrations(discoveryOptions(modulesDirectory)); expect(aggregate.discoveredModules).toHaveLength(2); expect(aggregate.modules).toEqual([ { migrations: [], moduleId: "schema-only" }, { migrations: [], moduleId: "server-only" }, ]); expect(aggregate.migrations).toEqual([]); expect(formatMigrationValidationReport(aggregate)).toEqual([ "Migration validation passed (2 modules, 0 migrations):", "- schema-only (no migrations)", "- server-only (no migrations)", ]); expect(Object.isFrozen(aggregate.modules[0]?.migrations)).toBe(true); }); it("completes static discovery before inspecting migration contents", async () => { const modulesDirectory = await createModulesDirectory(); const valid = await createMigrationModule(modulesDirectory, "migration-invalid"); await writeFile( path.join(valid.migrationDirectory ?? "", "meta/_journal.json"), '{"secret":"postgres://admin:password@database/private"', ); await createMigrationModule(modulesDirectory, "static-invalid", { manifest: { ...manifestFor("static-invalid", { migrations: false }), entrypoints: { server: "./dist/server/missing.js" }, }, migrations: false, }); let discoveryError: ModuleDiscoveryError | undefined; try { await discoverModuleMigrations(discoveryOptions(modulesDirectory)); } catch (error) { if (error instanceof ModuleDiscoveryError) { discoveryError = error; } else { throw error; } } expect(discoveryError?.issues).toContainEqual( expect.objectContaining({ code: "MODULE_ENTRYPOINT_MISSING", moduleId: "static-invalid", path: "entrypoints.server", }), ); expect(JSON.stringify(discoveryError)).not.toContain("postgres://"); }); it("rejects malformed journal and policy metadata without leaking their contents", async () => { const modulesDirectory = await createModulesDirectory(); const journalInvalid = await createMigrationModule(modulesDirectory, "journal-invalid"); const policyInvalid = await createMigrationModule(modulesDirectory, "policy-invalid"); await Promise.all([ writeFile( path.join(journalInvalid.migrationDirectory ?? "", "meta/_journal.json"), '{"url":"postgres://admin:password@database/private","path":"/machine/journal"', ), writeFile( path.join(policyInvalid.migrationDirectory ?? "", "meta/_ekklesia.json"), JSON.stringify({ version: 1, moduleId: "policy-invalid", entries: [ { tag: "0000_initial", destructive: { mode: "none" }, approved: "token-at-/machine/policy", }, ], }), ), ]); const error = await captureMigrationError(modulesDirectory); expect(issueCodes(error)).toEqual([ "MODULE_MIGRATION_JOURNAL_INVALID", "MODULE_MIGRATION_METADATA_INVALID", ]); const report = `${error.message}\n${error.stack}\n${JSON.stringify(error.issues)}`; expect(report).not.toContain("postgres://"); expect(report).not.toContain("password"); expect(report).not.toContain("/machine/journal"); expect(report).not.toContain("token-at-/machine/policy"); expect(report).not.toContain(modulesDirectory); }); it("rejects duplicate identities, invalid ordering, and inconsistent destructive policy", async () => { const modulesDirectory = await createModulesDirectory(); const fixture = await createMigrationModule(modulesDirectory, "inconsistent", { migrations: [ { sql: "SELECT 1;\n", tag: "0000_first" }, { sql: "SELECT 2;\n", tag: "0001_second" }, ], }); const root = fixture.migrationDirectory ?? ""; await Promise.all([ writeFile( path.join(root, "meta/_journal.json"), JSON.stringify({ version: "7", dialect: "postgresql", entries: [ { idx: 1, version: "7", when: 200, tag: "0001_second", breakpoints: true, }, { idx: 1, version: "7", when: 100, tag: "0001_second", breakpoints: true, }, ], }), ), writeFile( path.join(root, "meta/_ekklesia.json"), JSON.stringify({ version: 1, moduleId: "wrong-module", entries: [ { tag: "0001_second", destructive: { mode: "none" } }, { tag: "0001_second", destructive: { mode: "none" } }, ], }), ), ]); const error = await captureMigrationError(modulesDirectory); expect(issueCodes(error)).toEqual( expect.arrayContaining([ "MODULE_MIGRATION_ID_DUPLICATE", "MODULE_MIGRATION_METADATA_INCONSISTENT", "MODULE_MIGRATION_ORDER_INVALID", ]), ); expect( issueCodes(error).filter((code) => code === "MODULE_MIGRATION_METADATA_INCONSISTENT"), ).toHaveLength(2); expect( issueCodes(error).filter((code) => code === "MODULE_MIGRATION_ORDER_INVALID"), ).toHaveLength(2); }); it("rejects unsafe journal tags before deriving or reading a migration path", async () => { const modulesDirectory = await createModulesDirectory(); const fixture = await createMigrationModule(modulesDirectory, "unsafe-tag"); const root = fixture.migrationDirectory ?? ""; await Promise.all([ writeFile( path.join(root, "meta/_journal.json"), JSON.stringify({ version: "7", dialect: "postgresql", entries: [ { idx: 0, version: "7", when: 1_700_000_000_000, tag: "../outside", breakpoints: true, }, ], }), ), writeFile( path.join(fixture.moduleDirectory, "outside.sql"), "SELECT 'database-password at /machine/outside';\n", ), ]); const error = await captureMigrationError(modulesDirectory); expect(issueCodes(error)).toContain("MODULE_MIGRATION_ID_INVALID"); expect(error.issues).not.toContainEqual(expect.objectContaining({ path: "migrations.0.sql" })); const report = `${error.stack}\n${JSON.stringify(error.issues)}`; expect(report).not.toContain("../outside"); expect(report).not.toContain("database-password"); expect(report).not.toContain("/machine/outside"); }); it("rejects missing, undeclared, unsupported, empty, and invalid UTF-8 migration files", async () => { const modulesDirectory = await createModulesDirectory(); const missing = await createMigrationModule(modulesDirectory, "missing"); const inventory = await createMigrationModule(modulesDirectory, "inventory"); await createMigrationModule(modulesDirectory, "empty", { migrations: [{ sql: " \n\t", tag: "0000_initial" }], }); await createMigrationModule(modulesDirectory, "invalid-utf8", { migrations: [{ sql: Uint8Array.from([0xff, 0xfe, 0xfd]), tag: "0000_initial" }], }); await Promise.all([ rm(path.join(missing.migrationDirectory ?? "", "0000_initial.sql")), writeFile(path.join(inventory.migrationDirectory ?? "", "0001_orphan.sql"), "SELECT 2;\n"), writeFile(path.join(inventory.migrationDirectory ?? "", "run.ts"), "throw new Error();\n"), ]); const error = await captureMigrationError(modulesDirectory); expect(issueCodes(error)).toEqual( expect.arrayContaining([ "MODULE_MIGRATION_EMPTY", "MODULE_MIGRATION_FILE_INVALID", "MODULE_MIGRATION_FILE_MISSING", "MODULE_MIGRATION_FILE_UNDECLARED", "MODULE_MIGRATION_UNSUPPORTED_FILE", ]), ); expect(error.issues.some((issue) => issue.moduleId === "missing")).toBe(true); expect(error.issues.some((issue) => issue.moduleId === "inventory")).toBe(true); expect(error.issues.some((issue) => issue.moduleId === "empty")).toBe(true); expect(error.issues.some((issue) => issue.moduleId === "invalid-utf8")).toBe(true); }); it("rejects missing, broken, and undeclared Drizzle snapshots", async () => { const modulesDirectory = await createModulesDirectory(); const fixture = await createMigrationModule(modulesDirectory, "snapshots", { migrations: [ { sql: "SELECT 1;\n", tag: "0000_first" }, { sql: "SELECT 2;\n", tag: "0001_second" }, ], }); const missing = await createMigrationModule(modulesDirectory, "snapshot-missing"); const root = fixture.migrationDirectory ?? ""; await Promise.all([ rm(path.join(missing.migrationDirectory ?? "", "meta/0000_snapshot.json")), writeFile( path.join(root, "meta/0001_snapshot.json"), JSON.stringify({ version: "7", dialect: "postgresql", id: snapshotId(1), prevId: originSnapshotId, }), ), writeFile( path.join(root, "meta/9999_snapshot.json"), JSON.stringify({ version: "7", dialect: "postgresql", id: "99999999-9999-4999-8999-999999999999", prevId: snapshotId(1), }), ), ]); const error = await captureMigrationError(modulesDirectory); expect(issueCodes(error)).toEqual( expect.arrayContaining([ "MODULE_MIGRATION_FILE_MISSING", "MODULE_MIGRATION_FILE_UNDECLARED", "MODULE_MIGRATION_SNAPSHOT_INVALID", ]), ); expect(error.issues).toContainEqual( expect.objectContaining({ code: "MODULE_MIGRATION_FILE_MISSING", moduleId: "snapshot-missing", path: "snapshots.0", }), ); }); it("rejects unsafe migration symlinks and sanitizes the target path", async () => { const modulesDirectory = await createModulesDirectory(); const fixture = await createMigrationModule(modulesDirectory, "linked"); const outsideDirectory = await createModulesDirectory(); const outsideSql = path.join(outsideDirectory, "database-password.sql"); const migrationFile = path.join(fixture.migrationDirectory ?? "", "0000_initial.sql"); await writeFile(outsideSql, "SELECT 'secret-at-/machine/private';\n"); await rm(migrationFile); await symlink(outsideSql, migrationFile); const error = await captureMigrationError(modulesDirectory); expect(issueCodes(error)).toContain("MODULE_MIGRATION_PATH_UNSAFE"); const report = `${error.stack}\n${JSON.stringify(error.issues)}`; expect(report).not.toContain("database-password"); expect(report).not.toContain("secret-at-/machine/private"); expect(report).not.toContain(outsideDirectory); }); it("produces deterministic sanitized errors and no partial result when any module is invalid", async () => { const firstModulesDirectory = await createModulesDirectory(); const secondModulesDirectory = await createModulesDirectory(); for (const modulesDirectory of [firstModulesDirectory, secondModulesDirectory]) { await createMigrationModule(modulesDirectory, "valid"); const broken = await createMigrationModule(modulesDirectory, "broken"); await writeFile( path.join(broken.migrationDirectory ?? "", "meta/_journal.json"), '{"secret":"https://user:password@example.test/private","sql":"DROP TABLE private"', ); } const [firstError, secondError] = await Promise.all([ captureMigrationError(firstModulesDirectory), captureMigrationError(secondModulesDirectory), ]); expect(firstError.issues).toEqual(secondError.issues); expect(firstError.stack).toBe(secondError.stack); expect(firstError.issues).toEqual([ expect.objectContaining({ code: "MODULE_MIGRATION_JOURNAL_INVALID", moduleId: "broken", path: "journal", }), ]); const report = `${firstError.message}\n${firstError.stack}\n${JSON.stringify(firstError.issues)}`; expect(report).not.toContain("https://"); expect(report).not.toContain("password"); expect(report).not.toContain("DROP TABLE"); expect(report).not.toContain(firstModulesDirectory); expect(Object.isFrozen(firstError.issues)).toBe(true); expect(Object.isFrozen(firstError.issues[0])).toBe(true); }); });