feat: add Ekklesia doctor command
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const discoverModules = vi.fn();
|
||||
const discoverModuleSchemas = vi.fn();
|
||||
const discoverModuleMigrations = vi.fn();
|
||||
const startModuleRuntime = vi.fn();
|
||||
const createPostgresMigrationDatabase = vi.fn();
|
||||
const planModuleMigrations = vi.fn();
|
||||
|
||||
vi.mock("../src/modules/index.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../src/modules/index.js")>()),
|
||||
discoverModuleMigrations,
|
||||
discoverModules,
|
||||
discoverModuleSchemas,
|
||||
startModuleRuntime,
|
||||
}));
|
||||
|
||||
vi.mock("../src/migrations/index.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../src/migrations/index.js")>()),
|
||||
createPostgresMigrationDatabase,
|
||||
planModuleMigrations,
|
||||
}));
|
||||
|
||||
describe("Doctor import safety", () => {
|
||||
it("does not discover, connect, plan, or print when imported", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
|
||||
await import("../src/diagnostics/index.js");
|
||||
await import("../src/cli/doctor.js");
|
||||
await import("../src/index.js");
|
||||
|
||||
expect(discoverModules).not.toHaveBeenCalled();
|
||||
expect(discoverModuleSchemas).not.toHaveBeenCalled();
|
||||
expect(discoverModuleMigrations).not.toHaveBeenCalled();
|
||||
expect(startModuleRuntime).not.toHaveBeenCalled();
|
||||
expect(createPostgresMigrationDatabase).not.toHaveBeenCalled();
|
||||
expect(planModuleMigrations).not.toHaveBeenCalled();
|
||||
expect(log).not.toHaveBeenCalled();
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
|
||||
log.mockRestore();
|
||||
error.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
type DoctorDependencies,
|
||||
diagnoseEkklesia,
|
||||
formatDoctorReport,
|
||||
} from "../src/diagnostics/index.js";
|
||||
import type { MigrationDatabase } from "../src/migrations/index.js";
|
||||
import type { ModuleMigrationAggregate, ModuleSchemaAggregate } from "../src/modules/index.js";
|
||||
|
||||
const originalNoColor = process.env.NO_COLOR;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalNoColor === undefined) {
|
||||
delete process.env.NO_COLOR;
|
||||
} else {
|
||||
process.env.NO_COLOR = originalNoColor;
|
||||
}
|
||||
});
|
||||
|
||||
function zeroSchemaAggregate(): ModuleSchemaAggregate {
|
||||
return Object.freeze({
|
||||
discoveredModules: Object.freeze([]),
|
||||
drizzleSchema: Object.freeze({}),
|
||||
modules: Object.freeze([]),
|
||||
tables: Object.freeze([]),
|
||||
});
|
||||
}
|
||||
|
||||
function zeroMigrationAggregate(): ModuleMigrationAggregate {
|
||||
return Object.freeze({
|
||||
discoveredModules: Object.freeze([]),
|
||||
migrations: Object.freeze([]),
|
||||
modules: Object.freeze([]),
|
||||
});
|
||||
}
|
||||
|
||||
function noConfigurationDependencies(): DoctorDependencies {
|
||||
return {
|
||||
createMigrationDatabase: () => {
|
||||
throw new Error("database must not be constructed");
|
||||
},
|
||||
discoverMigrations: async () => zeroMigrationAggregate(),
|
||||
discoverModules: async () => Object.freeze([]),
|
||||
discoverSchemas: async () => zeroSchemaAggregate(),
|
||||
planMigrations: async () => {
|
||||
throw new Error("database must not be planned");
|
||||
},
|
||||
runtimeSatisfies: () => true,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Ekklesia Doctor terminal report", () => {
|
||||
it("renders the real zero-module and no-database shape exactly", async () => {
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: "/not-rendered/modules",
|
||||
platformVersion: "0.1.0",
|
||||
},
|
||||
runtimeRange: ">=24.11.0 <25",
|
||||
runtimeVersion: "24.15.0",
|
||||
},
|
||||
noConfigurationDependencies(),
|
||||
);
|
||||
|
||||
expect(formatDoctorReport(report).join("\n")).toBe(`Ekklesia Doctor
|
||||
|
||||
Platform
|
||||
✓ Runtime version supported
|
||||
Active Node.js satisfies the declared supported range.
|
||||
|
||||
Modules
|
||||
✓ Module discovery complete
|
||||
0 modules installed
|
||||
✓ Dependency graph valid
|
||||
Dependency order is empty.
|
||||
|
||||
Data definitions
|
||||
✓ Schemas validated
|
||||
0 modules · 0 tables
|
||||
✓ Migrations validated
|
||||
0 migrations · checksums valid
|
||||
|
||||
PostgreSQL
|
||||
○ Connection check skipped
|
||||
DATABASE_URL is not configured.
|
||||
○ Migration plan skipped
|
||||
A validated migration inventory and configured PostgreSQL connection are required.
|
||||
|
||||
Object storage
|
||||
○ Check unavailable
|
||||
Core storage integration has not been implemented yet.
|
||||
|
||||
Result: READY WITH WARNINGS`);
|
||||
});
|
||||
|
||||
it("does not depend on NO_COLOR or terminal interactivity", async () => {
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: "/not-rendered/modules",
|
||||
platformVersion: "0.1.0",
|
||||
},
|
||||
runtimeRange: ">=24.11.0 <25",
|
||||
runtimeVersion: "24.15.0",
|
||||
},
|
||||
noConfigurationDependencies(),
|
||||
);
|
||||
const baseline = formatDoctorReport(report);
|
||||
const originalIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
|
||||
|
||||
try {
|
||||
process.env.NO_COLOR = "1";
|
||||
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
|
||||
const interactiveWithoutColor = formatDoctorReport(report);
|
||||
process.env.NO_COLOR = "";
|
||||
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: false });
|
||||
const nonInteractive = formatDoctorReport(report);
|
||||
|
||||
expect(interactiveWithoutColor).toEqual(baseline);
|
||||
expect(nonInteractive).toEqual(baseline);
|
||||
expect(interactiveWithoutColor.some((line) => line.includes("\u001B"))).toBe(false);
|
||||
expect(Object.isFrozen(interactiveWithoutColor)).toBe(true);
|
||||
} finally {
|
||||
if (originalIsTtyDescriptor) {
|
||||
Object.defineProperty(process.stdout, "isTTY", originalIsTtyDescriptor);
|
||||
} else {
|
||||
delete (process.stdout as { isTTY?: boolean }).isTTY;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("never renders secrets from a hostile database exception", async () => {
|
||||
const hostile =
|
||||
"postgresql://admin:[email protected]/db SELECT password FROM users /home/me/.env";
|
||||
const database = Object.freeze({}) as MigrationDatabase;
|
||||
const dependencies: DoctorDependencies = {
|
||||
...noConfigurationDependencies(),
|
||||
createMigrationDatabase: () => database,
|
||||
planMigrations: async () => {
|
||||
throw new Error(hostile);
|
||||
},
|
||||
};
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
databaseUrl: "postgresql://another:[email protected]/db",
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: "/not-rendered/modules",
|
||||
platformVersion: "0.1.0",
|
||||
},
|
||||
runtimeRange: ">=24.11.0 <25",
|
||||
runtimeVersion: "24.15.0",
|
||||
},
|
||||
dependencies,
|
||||
);
|
||||
const output = formatDoctorReport(report).join("\n");
|
||||
|
||||
for (const forbidden of [
|
||||
"admin",
|
||||
"secret",
|
||||
"host.invalid",
|
||||
"SELECT",
|
||||
"password",
|
||||
"/home/me",
|
||||
"postgresql://",
|
||||
]) {
|
||||
expect(output).not.toContain(forbidden);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,441 @@
|
||||
import type { ModuleManifest } from "@ekklesia/contracts";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type DoctorCheck,
|
||||
type DoctorDependencies,
|
||||
type DoctorOptions,
|
||||
deriveDoctorResult,
|
||||
diagnoseEkklesia,
|
||||
doctorExitCode,
|
||||
} from "../src/diagnostics/index.js";
|
||||
import {
|
||||
type MigrationDatabase,
|
||||
type MigrationDatabaseSession,
|
||||
type MigrationPlanResult,
|
||||
MigrationRunnerError,
|
||||
planModuleMigrations,
|
||||
} from "../src/migrations/index.js";
|
||||
import type {
|
||||
DiscoveredModule,
|
||||
DiscoveredModuleMigration,
|
||||
ModuleMigrationAggregate,
|
||||
ModuleSchemaAggregate,
|
||||
} from "../src/modules/index.js";
|
||||
|
||||
const checksumA = "a".repeat(64);
|
||||
const checksumB = "b".repeat(64);
|
||||
const checksumC = "c".repeat(64);
|
||||
|
||||
function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
|
||||
return {
|
||||
directory: `/unpublished/${id}`,
|
||||
entrypoints: { server: `/unpublished/${id}/server.js` },
|
||||
manifest: {
|
||||
capabilities: [],
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
dependencies: {
|
||||
capabilities: { optional: {}, required: {} },
|
||||
modules: { optional: {}, required: {} },
|
||||
},
|
||||
entrypoints: { server: "./dist/server.js" },
|
||||
id,
|
||||
manifestVersion: 2,
|
||||
name: `${id} module`,
|
||||
permissions: [],
|
||||
storage: { buckets: [] },
|
||||
version,
|
||||
} as unknown as ModuleManifest,
|
||||
manifestPath: `/unpublished/${id}/ekklesia.module.json`,
|
||||
packageName: `@test/${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
function migration(
|
||||
moduleId: string,
|
||||
tag: string,
|
||||
checksum: string,
|
||||
approvalRequired = false,
|
||||
): DiscoveredModuleMigration {
|
||||
const index = Number(tag.slice(0, tag.indexOf("_")));
|
||||
return Object.freeze({
|
||||
approvalRequired,
|
||||
breakpoints: true,
|
||||
checksum,
|
||||
destructive: Object.freeze(
|
||||
approvalRequired
|
||||
? { mode: "approval-required" as const, reason: "Operator review is required." }
|
||||
: { mode: "none" as const },
|
||||
),
|
||||
file: `${tag}.sql`,
|
||||
identity: `${moduleId}:${tag}`,
|
||||
index,
|
||||
moduleId,
|
||||
sql: "SELECT 1;",
|
||||
tag,
|
||||
timestamp: index + 1,
|
||||
});
|
||||
}
|
||||
|
||||
function migrationAggregate(
|
||||
migrations: readonly DiscoveredModuleMigration[] = [],
|
||||
modules: readonly DiscoveredModule[] = [],
|
||||
): ModuleMigrationAggregate {
|
||||
const moduleIds = [...new Set(migrations.map((entry) => entry.moduleId))];
|
||||
return Object.freeze({
|
||||
discoveredModules: Object.freeze([...modules]),
|
||||
migrations: Object.freeze([...migrations]),
|
||||
modules: Object.freeze(
|
||||
moduleIds.map((moduleId) =>
|
||||
Object.freeze({
|
||||
migrations: Object.freeze(migrations.filter((entry) => entry.moduleId === moduleId)),
|
||||
moduleId,
|
||||
}),
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function schemaAggregate(modules: readonly DiscoveredModule[] = []): ModuleSchemaAggregate {
|
||||
return Object.freeze({
|
||||
discoveredModules: Object.freeze([...modules]),
|
||||
drizzleSchema: Object.freeze({}),
|
||||
modules: Object.freeze([]),
|
||||
tables: Object.freeze([]),
|
||||
});
|
||||
}
|
||||
|
||||
function emptyDatabase(): MigrationDatabase {
|
||||
return Object.freeze({
|
||||
openSession: vi.fn().mockRejectedValue(new Error("unused database")),
|
||||
});
|
||||
}
|
||||
|
||||
function plan(entries: MigrationPlanResult["entries"] = []): MigrationPlanResult {
|
||||
const appliedCount = entries.filter((entry) => entry.status === "applied").length;
|
||||
const approvalRequiredCount = entries.filter(
|
||||
(entry) => entry.status === "approval-blocked",
|
||||
).length;
|
||||
return Object.freeze({
|
||||
appliedCount,
|
||||
approvalRequiredCount,
|
||||
entries: Object.freeze([...entries]),
|
||||
pendingCount: entries.length - appliedCount,
|
||||
});
|
||||
}
|
||||
|
||||
function dependencies(overrides: Partial<DoctorDependencies> = {}): DoctorDependencies {
|
||||
return {
|
||||
createMigrationDatabase: vi.fn(() => emptyDatabase()),
|
||||
discoverMigrations: vi.fn(async () => migrationAggregate()),
|
||||
discoverModules: vi.fn(async () => Object.freeze([])),
|
||||
discoverSchemas: vi.fn(async () => schemaAggregate()),
|
||||
planMigrations: vi.fn(async () => plan()),
|
||||
runtimeSatisfies: vi.fn(() => true),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function options(databaseUrl?: string): DoctorOptions {
|
||||
return {
|
||||
...(databaseUrl === undefined ? {} : { databaseUrl }),
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: "/not-rendered/modules",
|
||||
platformVersion: "0.1.0",
|
||||
},
|
||||
runtimeRange: ">=24.11.0 <25",
|
||||
runtimeVersion: "24.15.0",
|
||||
};
|
||||
}
|
||||
|
||||
function check(
|
||||
report: Awaited<ReturnType<typeof diagnoseEkklesia>>,
|
||||
id: DoctorCheck["id"],
|
||||
): DoctorCheck {
|
||||
const found = report.checks.find((candidate) => candidate.id === id);
|
||||
if (!found) {
|
||||
throw new Error(`Missing test check ${id}.`);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function planEntry(
|
||||
identity: string,
|
||||
checksum: string,
|
||||
status: "applied" | "approval-blocked" | "pending",
|
||||
): MigrationPlanResult["entries"][number] {
|
||||
const [moduleId = "", tag = ""] = identity.split(":");
|
||||
return Object.freeze({
|
||||
approvalRequired: status === "approval-blocked",
|
||||
checksum,
|
||||
identity,
|
||||
index: Number(tag.slice(0, tag.indexOf("_"))),
|
||||
moduleId,
|
||||
status,
|
||||
tag,
|
||||
});
|
||||
}
|
||||
|
||||
describe("Ekklesia Doctor diagnostics", () => {
|
||||
it("publishes a healthy deterministic static report without database configuration", async () => {
|
||||
const people = discoveredModule("people");
|
||||
const calendar = discoveredModule("calendar", "0.2.0");
|
||||
const inventory = migrationAggregate(
|
||||
[migration("people", "0000_initial", checksumA)],
|
||||
[people, calendar],
|
||||
);
|
||||
const services = dependencies({
|
||||
discoverMigrations: vi.fn(async () => inventory),
|
||||
discoverModules: vi.fn(async () => Object.freeze([people, calendar])),
|
||||
discoverSchemas: vi.fn(async () => schemaAggregate([people, calendar])),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(), services);
|
||||
|
||||
expect(report.checks.map((entry) => entry.id)).toEqual([
|
||||
"platform.runtime",
|
||||
"modules.discovery",
|
||||
"modules.dependencies",
|
||||
"schemas.discovery",
|
||||
"migrations.discovery",
|
||||
"postgresql.connection",
|
||||
"postgresql.plan",
|
||||
"object-storage.integration",
|
||||
]);
|
||||
expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]);
|
||||
expect(check(report, "modules.dependencies").details).toEqual(["Order: people → calendar"]);
|
||||
expect(check(report, "migrations.discovery").details).toEqual([
|
||||
"1 migration · checksums valid",
|
||||
"people:0000_initial · aaaaaaaaaaaa",
|
||||
]);
|
||||
expect(JSON.stringify(report)).not.toContain(checksumA);
|
||||
expect(report.result).toBe("ready-with-warnings");
|
||||
expect(doctorExitCode(report)).toBe(0);
|
||||
expect(services.createMigrationDatabase).not.toHaveBeenCalled();
|
||||
expect(Object.isFrozen(report)).toBe(true);
|
||||
expect(Object.isFrozen(report.checks)).toBe(true);
|
||||
expect(report.checks.every(Object.isFrozen)).toBe(true);
|
||||
expect(report.checks.every((entry) => Object.isFrozen(entry.details))).toBe(true);
|
||||
});
|
||||
|
||||
it("derives aggregate results and exit codes with failure precedence", () => {
|
||||
const pass = { details: [], id: "platform.runtime", status: "pass", summary: "pass" } as const;
|
||||
const warning = { ...pass, status: "warning" } as const;
|
||||
const skipped = { ...pass, status: "skipped" } as const;
|
||||
const failure = { ...pass, status: "failure" } as const;
|
||||
|
||||
expect(deriveDoctorResult([pass])).toBe("ready");
|
||||
expect(deriveDoctorResult([pass, warning])).toBe("ready-with-warnings");
|
||||
expect(deriveDoctorResult([pass, skipped])).toBe("ready-with-warnings");
|
||||
expect(deriveDoctorResult([warning, failure])).toBe("not-ready");
|
||||
expect(doctorExitCode({ checks: [pass], result: "ready" })).toBe(0);
|
||||
expect(doctorExitCode({ checks: [warning], result: "ready-with-warnings" })).toBe(0);
|
||||
expect(doctorExitCode({ checks: [failure], result: "not-ready" })).toBe(1);
|
||||
});
|
||||
|
||||
it("uses the configured production planner seam with finite timeouts", async () => {
|
||||
const createMigrationDatabase = vi.fn(() => emptyDatabase());
|
||||
const planMigrations = vi.fn(async () => plan());
|
||||
const report = await diagnoseEkklesia(
|
||||
options("postgresql://not-rendered.invalid/ekklesia"),
|
||||
dependencies({ createMigrationDatabase, planMigrations }),
|
||||
);
|
||||
|
||||
expect(createMigrationDatabase).toHaveBeenCalledWith(
|
||||
"postgresql://not-rendered.invalid/ekklesia",
|
||||
{ connectionTimeoutMs: 10_000, operationTimeoutMs: 10_000 },
|
||||
);
|
||||
expect(planMigrations).toHaveBeenCalledOnce();
|
||||
expect(check(report, "postgresql.connection")).toMatchObject({
|
||||
status: "pass",
|
||||
summary: "Connection established",
|
||||
});
|
||||
expect(check(report, "postgresql.plan")).toMatchObject({
|
||||
details: ["0 applied · 0 pending · 0 approval-blocked"],
|
||||
status: "pass",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the read-only planner for a fresh database and closes without mutation", async () => {
|
||||
const pending = migration("people", "0000_initial", checksumA);
|
||||
const aggregate = migrationAggregate([pending], [discoveredModule("people")]);
|
||||
const session: MigrationDatabaseSession = {
|
||||
acquireMigrationLock: vi.fn(),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
readAppliedMigrations: vi.fn().mockResolvedValue(Object.freeze([])),
|
||||
releaseMigrationLock: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
};
|
||||
const database: MigrationDatabase = Object.freeze({
|
||||
openSession: vi.fn().mockResolvedValue(session),
|
||||
});
|
||||
const report = await diagnoseEkklesia(
|
||||
options("postgresql://not-rendered.invalid/ekklesia"),
|
||||
dependencies({
|
||||
createMigrationDatabase: vi.fn(() => database),
|
||||
discoverMigrations: vi.fn(async () => aggregate),
|
||||
planMigrations: planModuleMigrations,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(session.readAppliedMigrations).toHaveBeenCalledOnce();
|
||||
expect(session.close).toHaveBeenCalledOnce();
|
||||
expect(session.acquireMigrationLock).not.toHaveBeenCalled();
|
||||
expect(session.releaseMigrationLock).not.toHaveBeenCalled();
|
||||
expect(session.transaction).not.toHaveBeenCalled();
|
||||
expect(check(report, "postgresql.plan")).toMatchObject({
|
||||
status: "warning",
|
||||
summary: "1 migration requires attention",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports applied, pending, and approval-blocked entries in plan order", async () => {
|
||||
const entries = Object.freeze([
|
||||
planEntry("people:0000_initial", checksumA, "applied"),
|
||||
planEntry("people:0001_add_email", checksumB, "pending"),
|
||||
planEntry("people:0002_remove_old", checksumC, "approval-blocked"),
|
||||
]);
|
||||
const report = await diagnoseEkklesia(
|
||||
options("postgresql://not-rendered.invalid/ekklesia"),
|
||||
dependencies({ planMigrations: vi.fn(async () => plan(entries)) }),
|
||||
);
|
||||
|
||||
expect(check(report, "postgresql.plan")).toEqual({
|
||||
details: [
|
||||
"1 applied · 1 pending · 1 approval-blocked",
|
||||
"applied · people:0000_initial · aaaaaaaaaaaa",
|
||||
"pending · people:0001_add_email · bbbbbbbbbbbb",
|
||||
"approval-blocked · people:0002_remove_old · cccccccccccc",
|
||||
],
|
||||
id: "postgresql.plan",
|
||||
status: "warning",
|
||||
summary: "2 migrations require attention",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails a configured connection without attempting a plan result", async () => {
|
||||
const report = await diagnoseEkklesia(
|
||||
options("postgresql://not-rendered.invalid/ekklesia"),
|
||||
dependencies({
|
||||
planMigrations: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new MigrationRunnerError("MIGRATION_CONNECTION_FAILED")),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(check(report, "postgresql.connection").status).toBe("failure");
|
||||
expect(check(report, "postgresql.plan").status).toBe("skipped");
|
||||
expect(report.result).toBe("not-ready");
|
||||
expect(doctorExitCode(report)).toBe(1);
|
||||
});
|
||||
|
||||
it("reports invalid history as a planning failure after connectivity succeeds", async () => {
|
||||
const report = await diagnoseEkklesia(
|
||||
options("postgresql://not-rendered.invalid/ekklesia"),
|
||||
dependencies({
|
||||
planMigrations: vi
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new MigrationRunnerError("MIGRATION_HISTORY_INVALID", "never-render-this"),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(check(report, "postgresql.connection").status).toBe("pass");
|
||||
expect(check(report, "postgresql.plan")).toMatchObject({
|
||||
details: ["Applied migration history is invalid."],
|
||||
status: "failure",
|
||||
});
|
||||
expect(JSON.stringify(report)).not.toContain("never-render-this");
|
||||
});
|
||||
|
||||
it("skips every dependent check after atomic module discovery fails", async () => {
|
||||
const hostile =
|
||||
"postgresql://admin:[email protected]/db SELECT secret FROM x /home/operator/.env";
|
||||
const discoverSchemas = vi.fn();
|
||||
const discoverMigrations = vi.fn();
|
||||
const createMigrationDatabase = vi.fn();
|
||||
const report = await diagnoseEkklesia(
|
||||
options("postgresql://not-rendered.invalid/ekklesia"),
|
||||
dependencies({
|
||||
createMigrationDatabase,
|
||||
discoverMigrations,
|
||||
discoverModules: vi.fn().mockRejectedValue(new Error(hostile)),
|
||||
discoverSchemas,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(check(report, "modules.discovery").status).toBe("failure");
|
||||
for (const id of [
|
||||
"modules.dependencies",
|
||||
"schemas.discovery",
|
||||
"migrations.discovery",
|
||||
"postgresql.connection",
|
||||
"postgresql.plan",
|
||||
] as const) {
|
||||
expect(check(report, id).status).toBe("skipped");
|
||||
}
|
||||
expect(check(report, "platform.runtime").status).toBe("pass");
|
||||
expect(check(report, "object-storage.integration").status).toBe("skipped");
|
||||
expect(discoverSchemas).not.toHaveBeenCalled();
|
||||
expect(discoverMigrations).not.toHaveBeenCalled();
|
||||
expect(createMigrationDatabase).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(report)).not.toContain(hostile);
|
||||
expect(JSON.stringify(report)).not.toContain("password");
|
||||
});
|
||||
|
||||
it("continues independent migration and PostgreSQL checks after schema failure", async () => {
|
||||
const planMigrations = vi.fn(async () => plan());
|
||||
const report = await diagnoseEkklesia(
|
||||
options("postgresql://not-rendered.invalid/ekklesia"),
|
||||
dependencies({
|
||||
discoverSchemas: vi.fn().mockRejectedValue(new Error("hostile schema detail")),
|
||||
planMigrations,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(check(report, "schemas.discovery").status).toBe("failure");
|
||||
expect(check(report, "migrations.discovery").status).toBe("pass");
|
||||
expect(check(report, "postgresql.connection").status).toBe("pass");
|
||||
expect(planMigrations).toHaveBeenCalledOnce();
|
||||
expect(JSON.stringify(report)).not.toContain("hostile schema detail");
|
||||
});
|
||||
|
||||
it("sanitizes an unexpected configured-database exception", async () => {
|
||||
const hostile =
|
||||
"postgresql://root:[email protected]:5432/db -- DROP TABLE x; /srv/secret/.env";
|
||||
const report = await diagnoseEkklesia(
|
||||
options("postgresql://also-secret.invalid/ekklesia"),
|
||||
dependencies({ planMigrations: vi.fn().mockRejectedValue(new Error(hostile)) }),
|
||||
);
|
||||
const serialized = JSON.stringify(report);
|
||||
|
||||
expect(check(report, "postgresql.connection").status).toBe("failure");
|
||||
expect(serialized).not.toContain("hunter2");
|
||||
expect(serialized).not.toContain("private.invalid");
|
||||
expect(serialized).not.toContain("DROP TABLE");
|
||||
expect(serialized).not.toContain("/srv/secret");
|
||||
expect(serialized).not.toContain("also-secret.invalid");
|
||||
});
|
||||
|
||||
it("always reports object storage as unavailable without probing configuration", async () => {
|
||||
const report = await diagnoseEkklesia(options(), dependencies());
|
||||
|
||||
expect(check(report, "object-storage.integration")).toEqual({
|
||||
details: ["Core storage integration has not been implemented yet."],
|
||||
id: "object-storage.integration",
|
||||
status: "skipped",
|
||||
summary: "Check unavailable",
|
||||
});
|
||||
});
|
||||
|
||||
it("continues static discovery when runtime validation fails", async () => {
|
||||
const services = dependencies({ runtimeSatisfies: vi.fn(() => false) });
|
||||
const report = await diagnoseEkklesia(options(), services);
|
||||
|
||||
expect(check(report, "platform.runtime").status).toBe("failure");
|
||||
expect(check(report, "modules.discovery").status).toBe("pass");
|
||||
expect(services.discoverSchemas).toHaveBeenCalledOnce();
|
||||
expect(services.discoverMigrations).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -49,4 +49,64 @@ describe("PostgreSQL migration database composition", () => {
|
||||
expect(JSON.stringify(database)).not.toContain("do-not-log");
|
||||
expect(JSON.stringify(database)).not.toContain("example.test");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["connectionTimeoutMs", 0],
|
||||
["connectionTimeoutMs", -1],
|
||||
["connectionTimeoutMs", 1.5],
|
||||
["connectionTimeoutMs", Number.NaN],
|
||||
["connectionTimeoutMs", Number.POSITIVE_INFINITY],
|
||||
["connectionTimeoutMs", 2_147_483_648],
|
||||
["operationTimeoutMs", 0],
|
||||
["operationTimeoutMs", -1],
|
||||
["operationTimeoutMs", 1.5],
|
||||
["operationTimeoutMs", Number.NaN],
|
||||
["operationTimeoutMs", Number.POSITIVE_INFINITY],
|
||||
["operationTimeoutMs", 2_147_483_648],
|
||||
] as const)("rejects an invalid %s value without constructing a client", (name, value) => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createPostgresMigrationDatabase("postgresql://example.test/ekklesia", {
|
||||
[name]: value,
|
||||
});
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(PostgresMigrationDatabaseError);
|
||||
expect(captured).toMatchObject({ code: "POSTGRES_MIGRATION_CONFIGURATION_INVALID" });
|
||||
expect(clientConstructor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes bounded connection and operation timeouts to the PostgreSQL client", async () => {
|
||||
const client = {
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
end: vi.fn().mockResolvedValue(undefined),
|
||||
on: vi.fn(),
|
||||
};
|
||||
const MockClient = class {
|
||||
readonly connect = client.connect;
|
||||
readonly end = client.end;
|
||||
readonly on = client.on;
|
||||
};
|
||||
clientConstructor.mockImplementation(MockClient as unknown as () => typeof client);
|
||||
|
||||
const database = createPostgresMigrationDatabase("postgresql://example.test/ekklesia", {
|
||||
connectionTimeoutMs: 10_000,
|
||||
operationTimeoutMs: 12_000,
|
||||
});
|
||||
const session = await database.openSession();
|
||||
|
||||
expect(clientConstructor).toHaveBeenCalledWith({
|
||||
connectionString: "postgresql://example.test/ekklesia",
|
||||
connectionTimeoutMillis: 10_000,
|
||||
query_timeout: 12_000,
|
||||
statement_timeout: 12_000,
|
||||
});
|
||||
expect(client.on).toHaveBeenCalledWith("error", expect.any(Function));
|
||||
expect(client.connect).toHaveBeenCalledOnce();
|
||||
|
||||
await session.close();
|
||||
expect(client.end).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user