Ekklesia had no notion of a module being on or off: the accepted module set was exactly "whatever discovery found on disk", and `ModuleLifecycleStage` named nine states nothing produced or consumed. This replaces that vocabulary with two axes that real code produces — persistent, administrator-owned configuration (`enabled`/`disabled`) and per-boot runtime observation (`running`/`disabled`/`blocked`) — and wires them through startup. - One Core table (`ekklesia_core_module_state`, migration 0004) holding only `module_id` and `enabled`. A module with no record is enabled by default, so deploying a module is still sufficient to run it and discovery never needs an initialization write. A record for an undeployed module is retained and ignored, so redeploying restores the decision. - `resolveModuleEligibility` is pure: a disabled module transitively blocks every module that *requires* it (module and capability edges alike) with a structured reason naming the provider; optional dependencies never block; and a missing, incompatible, or cyclic dependency stays discovery's concern. - `startApi` starts only the eligible set and builds the UI publication registry from that same list, so routes, artifacts, browser inventory, and Dashboard navigation all follow one authoritative set. A disabled module is never imported, so there is no per-request enablement check anywhere. - Startup stays all-or-nothing, deliberately; there is consequently no `failed` runtime state. `pnpm modules:enable`/`modules:disable` is the out-of-band recovery path for a module that breaks startup. - Changes take effect at the next start, and the read model reports configuration, runtime, and `restartRequired` separately rather than claiming a module stopped when it has not. - Migration participation and permission composition still span the deployed and discovered inventories respectively, unchanged. - New restricted platform permission `core.modules.lifecycle.manage` gates both reading and changing enablement, at a sibling prefix (`/api/v1/module-lifecycle`) that cannot collide with the module route namespace. - `pnpm doctor` gains a `modules.lifecycle` check so "why is this module unavailable?" is answerable without reading logs. See ADR 0028. Co-Authored-By: Claude Opus 5 <[email protected]>
1019 lines
37 KiB
TypeScript
1019 lines
37 KiB
TypeScript
import { parsePermissionId, type ModuleManifest } from "@ekklesia/contracts";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { createPermissionRegistry, PermissionRegistryError } from "../src/authorization/index.js";
|
|
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";
|
|
import { ModulePermissionCompositionError } from "../src/modules/permissions.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: 4,
|
|
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 {
|
|
composePermissions: vi.fn((modules: readonly DiscoveredModule[]) =>
|
|
createPermissionRegistry(
|
|
modules.flatMap((discovered) => [...discovered.manifest.permissions]),
|
|
),
|
|
),
|
|
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()),
|
|
readModuleLifecycle: vi.fn(async () => Object.freeze({ modules: Object.freeze([]) })),
|
|
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",
|
|
"modules.permissions",
|
|
"modules.lifecycle",
|
|
"schemas.discovery",
|
|
"migrations.discovery",
|
|
"database.transport",
|
|
"postgresql.connection",
|
|
"postgresql.plan",
|
|
"identity.configuration",
|
|
"identity.client-address",
|
|
"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, "modules.permissions")).toMatchObject({
|
|
details: ["0 permissions registered"],
|
|
status: "pass",
|
|
});
|
|
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 }),
|
|
);
|
|
|
|
// Doctor's own read-only connection carries the same validated transport policy every other
|
|
// executable uses — it defaults to certificate-verified TLS, exactly like production.
|
|
expect(createMigrationDatabase).toHaveBeenCalledWith(
|
|
"postgresql://not-rendered.invalid/ekklesia",
|
|
{
|
|
connectionTimeoutMs: 10_000,
|
|
operationTimeoutMs: 10_000,
|
|
transport: { mode: "verify-full" },
|
|
},
|
|
);
|
|
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",
|
|
"modules.permissions",
|
|
"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("passes permission composition with a safe declaration count when modules are installed", async () => {
|
|
const calendar = discoveredModule("calendar");
|
|
const report = await diagnoseEkklesia(
|
|
options(),
|
|
dependencies({
|
|
composePermissions: vi.fn(() =>
|
|
createPermissionRegistry([
|
|
{
|
|
baseline: "restricted",
|
|
description: "Read calendar events.",
|
|
id: parsePermissionId("calendar.events.read"),
|
|
},
|
|
]),
|
|
),
|
|
discoverModules: vi.fn(async () => Object.freeze([calendar])),
|
|
}),
|
|
);
|
|
|
|
expect(check(report, "modules.permissions")).toMatchObject({
|
|
details: ["1 permission registered"],
|
|
status: "pass",
|
|
});
|
|
});
|
|
|
|
it("reuses the already-discovered module list rather than rediscovering modules for permission composition", async () => {
|
|
const calendar = discoveredModule("calendar");
|
|
const composePermissions = vi.fn(() => createPermissionRegistry([]));
|
|
const discoverModules = vi.fn(async () => Object.freeze([calendar]));
|
|
await diagnoseEkklesia(options(), dependencies({ composePermissions, discoverModules }));
|
|
|
|
expect(discoverModules).toHaveBeenCalledOnce();
|
|
expect(composePermissions).toHaveBeenCalledOnce();
|
|
expect(composePermissions).toHaveBeenCalledWith([calendar]);
|
|
});
|
|
|
|
it("fails permission composition when a module claims the reserved core.* namespace, distinctly from a generic duplicate", async () => {
|
|
const report = await diagnoseEkklesia(
|
|
options(),
|
|
dependencies({
|
|
composePermissions: vi.fn(() => {
|
|
throw new ModulePermissionCompositionError("core", "core.rogue.permission");
|
|
}),
|
|
}),
|
|
);
|
|
|
|
expect(check(report, "modules.permissions")).toMatchObject({
|
|
details: [
|
|
'Module "core" declared permission "core.rogue.permission" in the reserved core.* namespace.',
|
|
],
|
|
status: "failure",
|
|
});
|
|
expect(report.result).toBe("not-ready");
|
|
});
|
|
|
|
it("fails permission composition on a generic duplicate declaration, distinctly from a reserved-namespace violation", async () => {
|
|
const report = await diagnoseEkklesia(
|
|
options(),
|
|
dependencies({
|
|
composePermissions: vi.fn(() => {
|
|
throw new PermissionRegistryError("calendar.events.read");
|
|
}),
|
|
}),
|
|
);
|
|
|
|
expect(check(report, "modules.permissions")).toMatchObject({
|
|
details: ['Permission "calendar.events.read" is declared more than once.'],
|
|
status: "failure",
|
|
});
|
|
expect(report.result).toBe("not-ready");
|
|
});
|
|
|
|
it("sanitizes an unexpected permission-composition exception to a fixed generic detail", async () => {
|
|
const hostile = "postgresql://admin:[email protected]/db /home/operator/.env";
|
|
const report = await diagnoseEkklesia(
|
|
options(),
|
|
dependencies({
|
|
composePermissions: vi.fn(() => {
|
|
throw new Error(hostile);
|
|
}),
|
|
}),
|
|
);
|
|
|
|
expect(check(report, "modules.permissions")).toMatchObject({
|
|
details: ["The installed permission declarations could not be composed safely."],
|
|
status: "failure",
|
|
});
|
|
expect(JSON.stringify(report)).not.toContain(hostile);
|
|
});
|
|
|
|
it("continues independent schema, migration, and PostgreSQL checks after permission composition fails", async () => {
|
|
const planMigrations = vi.fn(async () => plan());
|
|
const report = await diagnoseEkklesia(
|
|
options("postgresql://not-rendered.invalid/ekklesia"),
|
|
dependencies({
|
|
composePermissions: vi.fn(() => {
|
|
throw new PermissionRegistryError("calendar.events.read");
|
|
}),
|
|
planMigrations,
|
|
}),
|
|
);
|
|
|
|
expect(check(report, "modules.permissions").status).toBe("failure");
|
|
expect(check(report, "schemas.discovery").status).toBe("pass");
|
|
expect(check(report, "migrations.discovery").status).toBe("pass");
|
|
expect(check(report, "postgresql.connection").status).toBe("pass");
|
|
expect(planMigrations).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
it("skips identity configuration (not a failure) when none of it is set", async () => {
|
|
const report = await diagnoseEkklesia(options(), dependencies());
|
|
|
|
const identity = check(report, "identity.configuration");
|
|
expect(identity.status).toBe("skipped");
|
|
expect(identity.details[0]).toContain("Core identity schema defines 4 tables");
|
|
expect(report.result).toBe("ready-with-warnings");
|
|
expect(doctorExitCode(report)).toBe(0);
|
|
});
|
|
|
|
it("passes identity configuration when a valid secret, base URL, and trusted origins are provided", async () => {
|
|
const report = await diagnoseEkklesia(
|
|
{
|
|
...options(),
|
|
identityConfiguration: {
|
|
baseUrl: "https://identity.example.test",
|
|
environment: "production",
|
|
secret: "correct-horse-battery-staple-0123456789",
|
|
trustedOrigins: ["https://identity.example.test"],
|
|
},
|
|
},
|
|
dependencies(),
|
|
);
|
|
|
|
const identity = check(report, "identity.configuration");
|
|
expect(identity.status).toBe("pass");
|
|
expect(report.result).toBe("ready-with-warnings"); // PostgreSQL is still skipped in this fixture
|
|
});
|
|
|
|
it("fails identity configuration for an invalid secret without leaking it", async () => {
|
|
const hostileSecret = "s3cr3t-do-not-log";
|
|
const report = await diagnoseEkklesia(
|
|
{
|
|
...options(),
|
|
identityConfiguration: {
|
|
baseUrl: "https://identity.example.test",
|
|
environment: "production",
|
|
secret: hostileSecret,
|
|
trustedOrigins: ["https://identity.example.test"],
|
|
},
|
|
},
|
|
dependencies(),
|
|
);
|
|
|
|
const identity = check(report, "identity.configuration");
|
|
expect(identity.status).toBe("failure");
|
|
expect(identity.details.some((detail) => detail.includes("SECRET_TOO_SHORT"))).toBe(true);
|
|
expect(report.result).toBe("not-ready");
|
|
expect(doctorExitCode(report)).toBe(1);
|
|
expect(JSON.stringify(report)).not.toContain(hostileSecret);
|
|
});
|
|
|
|
it("passes identity configuration and reports public signup disabled by default", async () => {
|
|
const report = await diagnoseEkklesia(
|
|
{
|
|
...options(),
|
|
identityConfiguration: {
|
|
baseUrl: "https://identity.example.test",
|
|
environment: "production",
|
|
secret: "correct-horse-battery-staple-0123456789",
|
|
trustedOrigins: ["https://identity.example.test"],
|
|
},
|
|
},
|
|
dependencies(),
|
|
);
|
|
|
|
const identity = check(report, "identity.configuration");
|
|
expect(identity.status).toBe("pass");
|
|
expect(identity.details.some((detail) => detail.includes("disabled (safe default)"))).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("reports public signup as explicitly enabled when configured in development", async () => {
|
|
const report = await diagnoseEkklesia(
|
|
{
|
|
...options(),
|
|
identityConfiguration: {
|
|
baseUrl: "http://localhost:3000",
|
|
environment: "development",
|
|
publicSignupEnabled: "true",
|
|
secret: "correct-horse-battery-staple-0123456789",
|
|
trustedOrigins: ["http://localhost:3000"],
|
|
},
|
|
},
|
|
dependencies(),
|
|
);
|
|
|
|
const identity = check(report, "identity.configuration");
|
|
expect(identity.status).toBe("pass");
|
|
expect(identity.details.some((detail) => detail.includes("explicitly ENABLED"))).toBe(true);
|
|
});
|
|
|
|
it("blocks readiness — not merely warns — when production configuration enables public signup", async () => {
|
|
const report = await diagnoseEkklesia(
|
|
{
|
|
...options(),
|
|
identityConfiguration: {
|
|
baseUrl: "https://identity.example.test",
|
|
environment: "production",
|
|
publicSignupEnabled: "true",
|
|
secret: "correct-horse-battery-staple-0123456789",
|
|
trustedOrigins: ["https://identity.example.test"],
|
|
},
|
|
},
|
|
dependencies(),
|
|
);
|
|
|
|
const identity = check(report, "identity.configuration");
|
|
expect(identity.status).toBe("failure");
|
|
expect(
|
|
identity.details.some((detail) => detail.includes("PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION")),
|
|
).toBe(true);
|
|
expect(report.result).toBe("not-ready");
|
|
expect(doctorExitCode(report)).toBe(1);
|
|
});
|
|
|
|
it("fails identity configuration for a misspelled public-signup value, never silently disabling or enabling it", async () => {
|
|
const report = await diagnoseEkklesia(
|
|
{
|
|
...options(),
|
|
identityConfiguration: {
|
|
baseUrl: "https://identity.example.test",
|
|
environment: "production",
|
|
publicSignupEnabled: "yes",
|
|
secret: "correct-horse-battery-staple-0123456789",
|
|
trustedOrigins: ["https://identity.example.test"],
|
|
},
|
|
},
|
|
dependencies(),
|
|
);
|
|
|
|
const identity = check(report, "identity.configuration");
|
|
expect(identity.status).toBe("failure");
|
|
expect(
|
|
identity.details.some((detail) => detail.includes("PUBLIC_SIGNUP_ENABLED_INVALID")),
|
|
).toBe(true);
|
|
expect(report.result).toBe("not-ready");
|
|
});
|
|
|
|
it("passes database transport by default, reporting verified TLS without revealing any connection detail", async () => {
|
|
const report = await diagnoseEkklesia(
|
|
{ ...options("postgresql://admin:[email protected]:5432/ekklesia") },
|
|
dependencies(),
|
|
);
|
|
|
|
const transport = check(report, "database.transport");
|
|
expect(transport.status).toBe("pass");
|
|
expect(transport.summary).toBe("Database TLS verified");
|
|
const rendered = JSON.stringify(transport);
|
|
expect(rendered).not.toContain("hunter2");
|
|
expect(rendered).not.toContain("db.internal.example");
|
|
expect(rendered).not.toContain("postgresql://");
|
|
});
|
|
|
|
it("blocks readiness when a production deployment would reach PostgreSQL without verified TLS", async () => {
|
|
for (const [label, extra] of [
|
|
["explicit production plaintext", { databaseTransport: { mode: "disable" } }],
|
|
[
|
|
"connection-string downgrade",
|
|
{ databaseUrl: "postgresql://db.example.test/ekklesia?sslmode=disable" },
|
|
],
|
|
["malformed mode", { databaseTransport: { mode: "require" } }],
|
|
] as const) {
|
|
const report = await diagnoseEkklesia(
|
|
{ ...options("postgresql://db.example.test/ekklesia"), ...extra },
|
|
dependencies(),
|
|
);
|
|
|
|
const transport = check(report, "database.transport");
|
|
expect(transport.status, `expected ${label} to block readiness`).toBe("failure");
|
|
expect(transport.summary).toBe("Database transport unsafe");
|
|
expect(report.result).toBe("not-ready");
|
|
expect(doctorExitCode(report)).toBe(1);
|
|
}
|
|
});
|
|
|
|
it("passes development plaintext only when development is explicitly selected", async () => {
|
|
const report = await diagnoseEkklesia(
|
|
{
|
|
...options("postgresql://127.0.0.1:5432/ekklesia"),
|
|
databaseEnvironment: "development",
|
|
},
|
|
dependencies(),
|
|
);
|
|
|
|
const transport = check(report, "database.transport");
|
|
expect(transport.status).toBe("pass");
|
|
expect(transport.summary).toContain("disabled");
|
|
});
|
|
|
|
it("passes client-address configuration with direct mode when nothing is configured", async () => {
|
|
const report = await diagnoseEkklesia(options(), dependencies());
|
|
|
|
const clientAddress = check(report, "identity.client-address");
|
|
expect(clientAddress.status).toBe("pass");
|
|
expect(clientAddress.summary).toContain("Direct");
|
|
expect(report.result).toBe("ready-with-warnings");
|
|
});
|
|
|
|
it("passes client-address configuration with an explicitly configured trusted-proxy mode", async () => {
|
|
const report = await diagnoseEkklesia(
|
|
{
|
|
...options(),
|
|
clientAddress: {
|
|
header: "x-forwarded-for",
|
|
mode: "trusted-proxy",
|
|
trustedProxies: ["10.0.0.1", "10.0.0.2"],
|
|
},
|
|
},
|
|
dependencies(),
|
|
);
|
|
|
|
const clientAddress = check(report, "identity.client-address");
|
|
expect(clientAddress.status).toBe("pass");
|
|
expect(clientAddress.summary).toContain("Trusted-proxy");
|
|
expect(clientAddress.details.some((detail) => detail.includes("x-forwarded-for"))).toBe(true);
|
|
});
|
|
|
|
it("fails client-address configuration for an invalid trusted-proxy entry without leaking it", async () => {
|
|
const hostileEntry = "not-an-ip-secret-value";
|
|
const report = await diagnoseEkklesia(
|
|
{
|
|
...options(),
|
|
clientAddress: {
|
|
header: "x-forwarded-for",
|
|
mode: "trusted-proxy",
|
|
trustedProxies: [hostileEntry],
|
|
},
|
|
},
|
|
dependencies(),
|
|
);
|
|
|
|
const clientAddress = check(report, "identity.client-address");
|
|
expect(clientAddress.status).toBe("failure");
|
|
expect(
|
|
clientAddress.details.some((detail) =>
|
|
detail.includes("CLIENT_ADDRESS_TRUSTED_PROXY_INVALID"),
|
|
),
|
|
).toBe(true);
|
|
expect(JSON.stringify(report)).not.toContain(hostileEntry);
|
|
expect(report.result).toBe("not-ready");
|
|
});
|
|
|
|
it("fails client-address configuration for an incomplete trusted-proxy mode (missing header)", async () => {
|
|
const report = await diagnoseEkklesia(
|
|
{
|
|
...options(),
|
|
clientAddress: { mode: "trusted-proxy", trustedProxies: ["10.0.0.1"] },
|
|
},
|
|
dependencies(),
|
|
);
|
|
|
|
const clientAddress = check(report, "identity.client-address");
|
|
expect(clientAddress.status).toBe("failure");
|
|
expect(report.result).toBe("not-ready");
|
|
});
|
|
|
|
it("never constructs a migration database or Better Auth merely to check identity configuration", async () => {
|
|
const services = dependencies();
|
|
await diagnoseEkklesia(
|
|
{
|
|
...options(),
|
|
identityConfiguration: {
|
|
baseUrl: "https://identity.example.test",
|
|
secret: "correct-horse-battery-staple-0123456789",
|
|
trustedOrigins: ["https://identity.example.test"],
|
|
},
|
|
},
|
|
services,
|
|
);
|
|
|
|
expect(services.createMigrationDatabase).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
const databaseUrl = "postgresql://not-rendered.invalid/ekklesia";
|
|
|
|
describe("Ekklesia Doctor — module lifecycle", () => {
|
|
/** A discovered module double with a real required-dependency edge, so blocking is exercised
|
|
* through the same resolver production uses rather than a hand-written eligibility record. */
|
|
function dependentModule(id: string, requires: string): DiscoveredModule {
|
|
const base = discoveredModule(id);
|
|
return {
|
|
...base,
|
|
manifest: {
|
|
...base.manifest,
|
|
dependencies: {
|
|
capabilities: { optional: {}, required: {} },
|
|
modules: { optional: {}, required: { [requires]: "^0.1.0" } },
|
|
},
|
|
} as unknown as ModuleManifest,
|
|
};
|
|
}
|
|
|
|
it("skips the check, rather than guessing a default, when DATABASE_URL is not configured", async () => {
|
|
const services = dependencies({
|
|
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
|
|
});
|
|
|
|
const report = await diagnoseEkklesia(options(), services);
|
|
|
|
expect(check(report, "modules.lifecycle").status).toBe("skipped");
|
|
expect(check(report, "modules.lifecycle").details).toEqual(["DATABASE_URL is not configured."]);
|
|
// Configuration lives in PostgreSQL; reporting "everything is enabled" without reading it would
|
|
// be a guess an operator could act on.
|
|
expect(services.readModuleLifecycle).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips the check when discovery itself failed", async () => {
|
|
const services = dependencies({
|
|
discoverModules: vi.fn(async () => {
|
|
throw new Error("broken inventory");
|
|
}),
|
|
});
|
|
|
|
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
|
|
|
expect(check(report, "modules.lifecycle").status).toBe("skipped");
|
|
expect(check(report, "modules.lifecycle").details).toEqual([
|
|
"Complete module discovery is required.",
|
|
]);
|
|
});
|
|
|
|
it("passes and lists each module when everything enabled can run", async () => {
|
|
const services = dependencies({
|
|
discoverModules: vi.fn(async () =>
|
|
Object.freeze([discoveredModule("storage"), discoveredModule("calendar", "0.2.0")]),
|
|
),
|
|
});
|
|
|
|
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
|
|
|
expect(check(report, "modules.lifecycle").status).toBe("pass");
|
|
expect(check(report, "modules.lifecycle").details).toEqual([
|
|
"2 eligible · 0 disabled · 0 blocked",
|
|
"storage 0.1.0 · enabled",
|
|
"calendar 0.2.0 · enabled",
|
|
]);
|
|
});
|
|
|
|
it("still passes when a module is deliberately disabled — a decision is not a problem", async () => {
|
|
const services = dependencies({
|
|
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
|
|
readModuleLifecycle: vi.fn(async () =>
|
|
Object.freeze({ modules: Object.freeze([{ enabled: false, moduleId: "calendar" }]) }),
|
|
),
|
|
});
|
|
|
|
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
|
|
|
expect(check(report, "modules.lifecycle").status).toBe("pass");
|
|
expect(check(report, "modules.lifecycle").details).toEqual([
|
|
"0 eligible · 1 disabled · 0 blocked",
|
|
"calendar 0.1.0 · disabled",
|
|
]);
|
|
});
|
|
|
|
it("distinguishes the platform default from a decision somebody made", async () => {
|
|
const services = dependencies({
|
|
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
|
|
readModuleLifecycle: vi.fn(async () => Object.freeze({ modules: Object.freeze([]) })),
|
|
});
|
|
|
|
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
|
|
|
// Nothing stored: the module is enabled by the platform default, and the detail says so.
|
|
expect(check(report, "modules.lifecycle").details).toContain("calendar 0.1.0 · enabled");
|
|
});
|
|
|
|
it("warns when an enabled module cannot run, naming the dependency an operator must act on", async () => {
|
|
const services = dependencies({
|
|
discoverModules: vi.fn(async () =>
|
|
Object.freeze([discoveredModule("storage"), dependentModule("announcements", "storage")]),
|
|
),
|
|
readModuleLifecycle: vi.fn(async () =>
|
|
Object.freeze({ modules: Object.freeze([{ enabled: false, moduleId: "storage" }]) }),
|
|
),
|
|
});
|
|
|
|
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
|
const lifecycle = check(report, "modules.lifecycle");
|
|
|
|
// An enabled module that cannot run is the anomaly worth surfacing — and it is exactly the
|
|
// question an operator would otherwise take to the logs.
|
|
expect(lifecycle.status).toBe("warning");
|
|
expect(lifecycle.summary).toBe("1 enabled module cannot run");
|
|
expect(lifecycle.details).toEqual([
|
|
"0 eligible · 1 disabled · 1 blocked",
|
|
"storage 0.1.0 · disabled",
|
|
"announcements 0.1.0 · blocked · required_dependency_disabled · storage",
|
|
]);
|
|
expect(report.result).toBe("ready-with-warnings");
|
|
});
|
|
|
|
it("fails safely, without a driver detail, when the configuration cannot be read", async () => {
|
|
const services = dependencies({
|
|
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
|
|
readModuleLifecycle: vi.fn(async () => {
|
|
throw new Error("connect ECONNREFUSED 10.40.0.2:5432 password=hunter2");
|
|
}),
|
|
});
|
|
|
|
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
|
const lifecycle = check(report, "modules.lifecycle");
|
|
|
|
expect(lifecycle.status).toBe("failure");
|
|
expect(lifecycle.details).toEqual([
|
|
"The stored module enablement configuration could not be read safely.",
|
|
]);
|
|
expect(JSON.stringify(lifecycle)).not.toContain("hunter2");
|
|
expect(JSON.stringify(lifecycle)).not.toContain("10.40.0.2");
|
|
});
|
|
|
|
it("reads with finite timeouts and the same validated transport policy every executable uses", async () => {
|
|
const services = dependencies({
|
|
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
|
|
});
|
|
|
|
await diagnoseEkklesia(options(databaseUrl), services);
|
|
|
|
expect(services.readModuleLifecycle).toHaveBeenCalledWith(
|
|
databaseUrl,
|
|
expect.objectContaining({
|
|
connectionTimeoutMs: 10_000,
|
|
operationTimeoutMs: 10_000,
|
|
transport: expect.objectContaining({ mode: "verify-full" }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("still reports lifecycle when an unrelated check fails, and never blocks on one", async () => {
|
|
const services = dependencies({
|
|
composePermissions: vi.fn(() => {
|
|
throw new Error("composition exploded");
|
|
}),
|
|
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
|
|
});
|
|
|
|
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
|
|
|
expect(check(report, "modules.permissions").status).toBe("failure");
|
|
expect(check(report, "modules.lifecycle").status).toBe("pass");
|
|
});
|
|
});
|