CI / verify (pull_request) Successful in 50s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Failing after 25s
CI / image-build (pull_request) Successful in 18s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m17s
An installation can now serve the Dashboard and the API on sibling hostnames (dash./api.) beside the default same-origin topology, from one image and with configuration alone. - The Dashboard learns its API origin at run time: the built page carries one empty <meta name="ekklesia-api-origin">, which the API fills with its identity base URL's origin at startup, before computing the page's ETag. The shell accepts only a canonical http(s) origin and otherwise fails closed without making a request. The build-time EKKLESIA_API_BASE_URL is gone from the bundle; the development server injects the same element. - The display host stays on the API's origin, so its device cookies, requests, and offline cache are unchanged. - The API trusts its own origin (trusted origins plus the identity base URL), as Better Auth already does, so the display page's mutations pass the origin check without operators listing it. - Doctor validates EKKLESIA_DASHBOARD_ORIGIN exactly as the API's startup does (it previously skipped it), reports the topology without printing hostnames, and warns when split hostnames are not siblings. - The module UI contract states the CORS header limit (safelisted headers plus Content-Type, safelisted response headers only). - Reference deployment: Caddyfile.split.example (Dashboard hostname serves / and its script; API hostname serves /api/*, /display/*, and the display script; each refuses the other's paths) and EKKLESIA_DASHBOARD_HOSTNAME in the proxy overlay and env examples. - The deploy smoke test runs either topology (CI runs both). The split run installs the module template as a test-only module, checks per-hostname routing and exact CORS, then drives a digest-pinned headless Chromium over the DevTools protocol, with no added dependency: sign-in on the Dashboard hostname, the session cookie host-only on the API hostname, the module UI imported cross-origin and its credentialed request succeeding, the display and its service worker on the API hostname, and sign-out. ADR 0045 is marked Accepted. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1645 lines
61 KiB
TypeScript
1645 lines
61 KiB
TypeScript
import { type ModuleManifest, parsePermissionId } 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 {
|
|
contributions: [],
|
|
directory: `/unpublished/${id}`,
|
|
entrypoints: { server: `/unpublished/${id}/server.js` },
|
|
manifest: {
|
|
capabilities: [],
|
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
|
configuration: [],
|
|
dependencies: {
|
|
capabilities: { optional: {}, required: {} },
|
|
modules: { optional: {}, required: {} },
|
|
},
|
|
entrypoints: { server: "./dist/server.js" },
|
|
id,
|
|
manifestVersion: 7,
|
|
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()),
|
|
probeMail: vi.fn(async () => undefined),
|
|
probeStorage: vi.fn(async () => undefined),
|
|
readModuleConfiguration: vi.fn(async () => Object.freeze({ records: Object.freeze([]) })),
|
|
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 outbound email reporting", () => {
|
|
const mailConfiguration = {
|
|
fromAddress: "[email protected]",
|
|
fromName: "Ekklesia",
|
|
host: "smtp.example.org",
|
|
password: "sup3r-secret-relay-password",
|
|
security: "starttls",
|
|
username: "[email protected]",
|
|
};
|
|
|
|
it("reports an installation with no SMTP host as complete rather than degraded", async () => {
|
|
const services = dependencies();
|
|
|
|
const report = await diagnoseEkklesia(options(), services);
|
|
const mailCheck = check(report, "outbound-email.configuration");
|
|
|
|
expect(mailCheck.status).toBe("skipped");
|
|
expect(mailCheck.summary).toBe("Outbound email not configured");
|
|
// Nothing sends email yet, so an absent provider must not make a deployment read as broken.
|
|
expect(report.result).not.toBe("not-ready");
|
|
expect(services.probeMail).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reports a reachable provider without ever naming a credential", async () => {
|
|
const services = dependencies();
|
|
|
|
const report = await diagnoseEkklesia({ ...options(), mailConfiguration }, services);
|
|
const mailCheck = check(report, "outbound-email.configuration");
|
|
|
|
expect(mailCheck.status).toBe("pass");
|
|
const rendered = mailCheck.details.join(" ");
|
|
expect(rendered).toContain("smtp.example.org:587");
|
|
expect(rendered).toContain("starttls");
|
|
expect(rendered).toContain("authenticated");
|
|
expect(rendered).not.toContain("sup3r-secret-relay-password");
|
|
expect(rendered).not.toContain("[email protected]");
|
|
// Honest about what a connection proves: it did not send anything, and deliverability is not
|
|
// something this check can establish.
|
|
expect(rendered).toMatch(/did not|without sending/iu);
|
|
});
|
|
|
|
it("warns rather than failing when a configured relay does not answer", async () => {
|
|
const services = dependencies({
|
|
probeMail: vi.fn(async () => {
|
|
throw new Error("connection refused to smtp.example.org with sup3r-secret-relay-password");
|
|
}),
|
|
});
|
|
|
|
const report = await diagnoseEkklesia({ ...options(), mailConfiguration }, services);
|
|
const mailCheck = check(report, "outbound-email.configuration");
|
|
|
|
// A warning, never a failure: the configuration is valid and nothing depends on mail yet.
|
|
expect(mailCheck.status).toBe("warning");
|
|
expect(report.result).not.toBe("not-ready");
|
|
// The thrown value carried a credential; none of it reaches the report.
|
|
expect(mailCheck.details.join(" ")).not.toContain("sup3r-secret-relay-password");
|
|
});
|
|
|
|
it("fails a malformed configuration with a sanitized code and probes nothing", async () => {
|
|
const services = dependencies();
|
|
|
|
const report = await diagnoseEkklesia(
|
|
{ ...options(), mailConfiguration: { ...mailConfiguration, port: "not-a-port" } },
|
|
services,
|
|
);
|
|
const mailCheck = check(report, "outbound-email.configuration");
|
|
|
|
expect(mailCheck.status).toBe("failure");
|
|
const rendered = mailCheck.details.join(" ");
|
|
expect(rendered).toContain("MAIL_PORT_INVALID");
|
|
expect(rendered).not.toContain("sup3r-secret-relay-password");
|
|
// A configuration this broken is never connected to.
|
|
expect(services.probeMail).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("refuses plaintext SMTP when the deployment validates as production", async () => {
|
|
const services = dependencies();
|
|
|
|
const report = await diagnoseEkklesia(
|
|
{
|
|
...options(),
|
|
databaseEnvironment: "production",
|
|
mailConfiguration: { ...mailConfiguration, security: "plaintext" },
|
|
},
|
|
services,
|
|
);
|
|
|
|
expect(check(report, "outbound-email.configuration").details.join(" ")).toContain(
|
|
"MAIL_SECURITY_INSECURE_IN_PRODUCTION",
|
|
);
|
|
});
|
|
});
|
|
|
|
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",
|
|
"modules.configuration",
|
|
"schemas.discovery",
|
|
"migrations.discovery",
|
|
"database.transport",
|
|
"postgresql.connection",
|
|
"postgresql.plan",
|
|
"identity.configuration",
|
|
"identity.client-address",
|
|
"object-storage.integration",
|
|
"object-storage.bindings",
|
|
"outbound-email.configuration",
|
|
]);
|
|
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("skips the storage check, without probing, when no provider is configured", async () => {
|
|
const services = dependencies();
|
|
const report = await diagnoseEkklesia(options(), services);
|
|
|
|
expect(check(report, "object-storage.integration")).toEqual({
|
|
details: [
|
|
"No S3_ENDPOINT is set. This is a complete deployment unless a module requires storage.",
|
|
],
|
|
id: "object-storage.integration",
|
|
status: "skipped",
|
|
summary: "Object storage not configured",
|
|
});
|
|
expect(services.probeStorage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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
|
|
});
|
|
|
|
describe("Dashboard and API topology (ADR 0045)", () => {
|
|
const secret = "correct-horse-battery-staple-0123456789";
|
|
async function identityCheck(identity: {
|
|
readonly baseUrl: string;
|
|
readonly dashboardOrigin?: string;
|
|
readonly environment?: "development" | "production";
|
|
readonly trustedOrigins: readonly string[];
|
|
}) {
|
|
const report = await diagnoseEkklesia(
|
|
{
|
|
...options(),
|
|
identityConfiguration: { environment: "production", secret, ...identity },
|
|
},
|
|
dependencies(),
|
|
);
|
|
return { check: check(report, "identity.configuration"), report };
|
|
}
|
|
|
|
it("reports the same-origin topology when no Dashboard origin is set", async () => {
|
|
const { check: identity } = await identityCheck({
|
|
baseUrl: "https://ekklesia.example.test",
|
|
trustedOrigins: ["https://ekklesia.example.test"],
|
|
});
|
|
expect(identity.status).toBe("pass");
|
|
expect(identity.details).toContain(
|
|
"Topology: same-origin (the Dashboard is served from the API's origin).",
|
|
);
|
|
});
|
|
|
|
it("reports the split topology for sibling hostnames without printing either", async () => {
|
|
const { check: identity, report } = await identityCheck({
|
|
baseUrl: "https://api.example.test",
|
|
dashboardOrigin: "https://dash.example.test",
|
|
trustedOrigins: ["https://dash.example.test"],
|
|
});
|
|
expect(identity.status).toBe("pass");
|
|
expect(identity.details).toContain(
|
|
"Topology: split (the Dashboard and the API are on separate origins).",
|
|
);
|
|
expect(JSON.stringify(report)).not.toContain("dash.example.test");
|
|
expect(JSON.stringify(report)).not.toContain("api.example.test");
|
|
});
|
|
|
|
it.each([
|
|
["different registrable domains", "https://dash.example.test", "https://api.example.org"],
|
|
["two IP addresses", "https://10.0.0.2", "https://10.0.0.1"],
|
|
["a parent that is only a top-level domain", "https://dash.test", "https://api.test"],
|
|
])("warns, without failing, when split hostnames may not share a site: %s", async (_case, dashboardOrigin, baseUrl) => {
|
|
const { check: identity, report } = await identityCheck({
|
|
baseUrl,
|
|
dashboardOrigin,
|
|
trustedOrigins: [dashboardOrigin],
|
|
});
|
|
expect(identity.status).toBe("warning");
|
|
expect(identity.details.join("\n")).toContain("not siblings under one parent domain");
|
|
expect(doctorExitCode(report)).toBe(0);
|
|
});
|
|
|
|
it("treats another port on the same host as the same site (split development)", async () => {
|
|
const { check: identity } = await identityCheck({
|
|
baseUrl: "http://localhost:3000",
|
|
dashboardOrigin: "http://localhost:5173",
|
|
environment: "development",
|
|
trustedOrigins: ["http://localhost:5173"],
|
|
});
|
|
expect(identity.status).toBe("pass");
|
|
});
|
|
|
|
it("fails, as the API's startup would, for a Dashboard origin the API does not trust", async () => {
|
|
const { check: identity, report } = await identityCheck({
|
|
baseUrl: "https://api.example.test",
|
|
dashboardOrigin: "https://dash.example.test",
|
|
trustedOrigins: ["https://other.example.test"],
|
|
});
|
|
expect(identity.status).toBe("failure");
|
|
expect(identity.details.some((detail) => detail.includes("DASHBOARD_ORIGIN_UNTRUSTED"))).toBe(
|
|
true,
|
|
);
|
|
expect(doctorExitCode(report)).toBe(1);
|
|
});
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|
|
|
|
describe("Ekklesia Doctor — module configuration", () => {
|
|
function configuringModule(
|
|
id: string,
|
|
configuration: readonly Record<string, unknown>[],
|
|
): DiscoveredModule {
|
|
const base = discoveredModule(id);
|
|
return {
|
|
...base,
|
|
manifest: { ...base.manifest, configuration } as unknown as ModuleManifest,
|
|
};
|
|
}
|
|
|
|
it("skips the check, rather than guessing, when DATABASE_URL is not configured", async () => {
|
|
const services = dependencies({
|
|
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("announcements")])),
|
|
});
|
|
|
|
const report = await diagnoseEkklesia(options(), services);
|
|
|
|
expect(check(report, "modules.configuration").status).toBe("skipped");
|
|
expect(services.readModuleConfiguration).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("passes and says so plainly when a module declares no configuration", async () => {
|
|
const services = dependencies({
|
|
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
|
|
});
|
|
|
|
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
|
|
|
expect(check(report, "modules.configuration").status).toBe("pass");
|
|
expect(check(report, "modules.configuration").details).toEqual([
|
|
"0 modules declaring configuration · 0 blocked",
|
|
"calendar 0.1.0 · no configuration declared",
|
|
]);
|
|
});
|
|
|
|
it("passes with a key count when configuration is satisfied, and prints no value", async () => {
|
|
const services = dependencies({
|
|
discoverModules: vi.fn(async () =>
|
|
Object.freeze([
|
|
configuringModule("announcements", [
|
|
{ key: "slideSeconds", type: "integer", default: 7, required: false },
|
|
{ key: "bucket", type: "string", required: true },
|
|
]),
|
|
]),
|
|
),
|
|
readModuleConfiguration: vi.fn(async () =>
|
|
Object.freeze({
|
|
records: Object.freeze([
|
|
{ key: "bucket", moduleId: "announcements", value: "very-specific-bucket-name" },
|
|
]),
|
|
}),
|
|
),
|
|
});
|
|
|
|
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
|
const configuration = check(report, "modules.configuration");
|
|
|
|
expect(configuration.status).toBe("pass");
|
|
expect(configuration.details).toEqual([
|
|
"1 module declaring configuration · 0 blocked",
|
|
"announcements 0.1.0 · 2 keys declared · valid",
|
|
]);
|
|
// Counts and keys, never values: `pnpm doctor` output gets pasted into chat.
|
|
expect(JSON.stringify(configuration)).not.toContain("very-specific-bucket-name");
|
|
});
|
|
|
|
it("warns and names the offending key when required configuration is missing", async () => {
|
|
const services = dependencies({
|
|
discoverModules: vi.fn(async () =>
|
|
Object.freeze([
|
|
configuringModule("announcements", [{ key: "bucket", type: "string", required: true }]),
|
|
]),
|
|
),
|
|
});
|
|
|
|
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
|
const configuration = check(report, "modules.configuration");
|
|
|
|
expect(configuration.status).toBe("warning");
|
|
expect(configuration.summary).toBe("1 module cannot run with its current configuration");
|
|
expect(configuration.details).toEqual([
|
|
"1 module declaring configuration · 1 blocked",
|
|
"announcements 0.1.0 · 1 key declared · configuration_missing · bucket",
|
|
]);
|
|
});
|
|
|
|
it("names a missing secret's key and never its value", async () => {
|
|
const services = dependencies({
|
|
discoverModules: vi.fn(async () =>
|
|
Object.freeze([
|
|
configuringModule("announcements", [
|
|
{ key: "storageCredential", type: "secret", required: true },
|
|
]),
|
|
]),
|
|
),
|
|
});
|
|
|
|
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
|
|
|
expect(check(report, "modules.configuration").details).toContain(
|
|
"announcements 0.1.0 · 1 key declared · secret_missing · storageCredential",
|
|
);
|
|
});
|
|
|
|
it("reports a supplied secret as satisfied, without printing it", async () => {
|
|
const services = dependencies({
|
|
discoverModules: vi.fn(async () =>
|
|
Object.freeze([
|
|
configuringModule("announcements", [
|
|
{ key: "storageCredential", type: "secret", required: true },
|
|
]),
|
|
]),
|
|
),
|
|
});
|
|
|
|
const report = await diagnoseEkklesia(
|
|
{
|
|
...options(databaseUrl),
|
|
moduleSecrets: { get: () => "super-secret-value" },
|
|
},
|
|
services,
|
|
);
|
|
const configuration = check(report, "modules.configuration");
|
|
|
|
expect(configuration.status).toBe("pass");
|
|
expect(JSON.stringify(configuration)).not.toContain("super-secret-value");
|
|
});
|
|
|
|
it("does not evaluate a disabled module's configuration, and says so", async () => {
|
|
const services = dependencies({
|
|
discoverModules: vi.fn(async () =>
|
|
Object.freeze([
|
|
configuringModule("announcements", [{ key: "bucket", type: "string", required: true }]),
|
|
]),
|
|
),
|
|
readModuleLifecycle: vi.fn(async () =>
|
|
Object.freeze({ modules: Object.freeze([{ enabled: false, moduleId: "announcements" }]) }),
|
|
),
|
|
});
|
|
|
|
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
|
const configuration = check(report, "modules.configuration");
|
|
|
|
// Claiming it is valid would assert something nothing checked; claiming it is broken would be
|
|
// noise about a module nobody asked to run.
|
|
expect(configuration.status).toBe("pass");
|
|
expect(configuration.details).toContain(
|
|
"announcements 0.1.0 · 1 key declared · not evaluated (disabled)",
|
|
);
|
|
});
|
|
|
|
it("fails safely, without a driver detail, when the configuration cannot be read", async () => {
|
|
const services = dependencies({
|
|
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("announcements")])),
|
|
readModuleConfiguration: vi.fn(async () => {
|
|
throw new Error("connect ECONNREFUSED 10.40.0.2:5432 password=hunter2");
|
|
}),
|
|
});
|
|
|
|
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
|
const configuration = check(report, "modules.configuration");
|
|
|
|
expect(configuration.status).toBe("failure");
|
|
expect(JSON.stringify(configuration)).not.toContain("hunter2");
|
|
expect(JSON.stringify(configuration)).not.toContain("10.40.0.2");
|
|
});
|
|
|
|
it("agrees with the lifecycle check, because both come from one resolution", async () => {
|
|
const services = dependencies({
|
|
discoverModules: vi.fn(async () =>
|
|
Object.freeze([
|
|
configuringModule("announcements", [{ key: "bucket", type: "string", required: true }]),
|
|
]),
|
|
),
|
|
});
|
|
|
|
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
|
|
|
// Doctor can never report a module as runnable that startup would block, or the reverse.
|
|
expect(check(report, "modules.lifecycle").status).toBe("warning");
|
|
expect(check(report, "modules.configuration").status).toBe("warning");
|
|
expect(check(report, "modules.lifecycle").details).toContain(
|
|
"announcements 0.1.0 · blocked · configuration_missing · bucket",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("Ekklesia Doctor — object storage", () => {
|
|
const storageConfig = {
|
|
accessKey: "AKIAEXAMPLEACCESSID",
|
|
bucketPrefix: "ekklesia",
|
|
deliveryEndpoint: "https://storage.example.test",
|
|
endpoint: "https://storage.example.test",
|
|
forcePathStyle: true,
|
|
region: "us-east-1",
|
|
secretKey: "sk-example-secret-material",
|
|
} as const;
|
|
|
|
function storingModule(id: string, buckets: readonly string[]): DiscoveredModule {
|
|
const base = discoveredModule(id);
|
|
return {
|
|
...base,
|
|
manifest: {
|
|
...base.manifest,
|
|
storage: { buckets: buckets.map((bucketId) => ({ id: bucketId })) },
|
|
} as unknown as ModuleManifest,
|
|
};
|
|
}
|
|
|
|
it("does not probe, and stays healthy, when no provider is configured and nothing needs one", async () => {
|
|
const services = dependencies({
|
|
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
|
|
});
|
|
|
|
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
|
|
|
expect(services.probeStorage).not.toHaveBeenCalled();
|
|
expect(check(report, "object-storage.integration").status).toBe("skipped");
|
|
// The load-bearing assertion: an installation whose modules need no object storage is not
|
|
// unhealthy for lacking a provider.
|
|
expect(check(report, "object-storage.bindings").status).toBe("pass");
|
|
expect(check(report, "object-storage.bindings").details).toEqual([
|
|
"0 modules declare a logical storage bucket.",
|
|
]);
|
|
});
|
|
|
|
it("fails the bindings check when a module requires storage and none is configured", async () => {
|
|
const services = dependencies({
|
|
discoverModules: vi.fn(async () =>
|
|
Object.freeze([storingModule("announcements", ["announcement-assets"])]),
|
|
),
|
|
});
|
|
|
|
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
|
const bindings = check(report, "object-storage.bindings");
|
|
|
|
expect(bindings.status).toBe("failure");
|
|
expect(bindings.details[0]).toContain("Configure an S3-compatible Ekklesia storage provider");
|
|
expect(bindings.details).toContain(
|
|
"announcements · announcement-assets · unbound (no storage provider configured)",
|
|
);
|
|
// And the lifecycle check agrees, because both come from one resolution.
|
|
expect(check(report, "modules.lifecycle").details).toContain(
|
|
"announcements 0.1.0 · blocked · storage_unavailable · announcement-assets",
|
|
);
|
|
});
|
|
|
|
it("resolves and lists every binding when a provider is configured", async () => {
|
|
const services = dependencies({
|
|
discoverModules: vi.fn(async () =>
|
|
Object.freeze([storingModule("announcements", ["announcement-assets"])]),
|
|
),
|
|
});
|
|
|
|
const report = await diagnoseEkklesia(
|
|
{ ...options(databaseUrl), storageConfiguration: storageConfig },
|
|
services,
|
|
);
|
|
const bindings = check(report, "object-storage.bindings");
|
|
|
|
expect(bindings.status).toBe("pass");
|
|
// The physical bucket is named here deliberately: Doctor is an operator tool, and someone
|
|
// debugging a permissions problem needs to know which bucket to look at. It is still never
|
|
// reachable from module-facing code.
|
|
expect(bindings.details[0]).toMatch(
|
|
/^announcements · announcement-assets → ekklesia-announcements--announcement-assets-[0-9a-f]{12}$/u,
|
|
);
|
|
});
|
|
|
|
it("probes a configured provider and reports it reachable, without printing a credential", async () => {
|
|
const services = dependencies({
|
|
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
|
|
});
|
|
|
|
const report = await diagnoseEkklesia(
|
|
{ ...options(databaseUrl), storageConfiguration: storageConfig },
|
|
services,
|
|
);
|
|
const integration = check(report, "object-storage.integration");
|
|
|
|
expect(services.probeStorage).toHaveBeenCalledWith(storageConfig);
|
|
expect(integration.status).toBe("pass");
|
|
expect(integration.details[0]).toContain("https://storage.example.test");
|
|
const serialized = JSON.stringify(integration);
|
|
expect(serialized).not.toContain("AKIAEXAMPLEACCESSID");
|
|
expect(serialized).not.toContain("sk-example-secret-material");
|
|
});
|
|
|
|
it("warns rather than fails when a configured provider is unreachable", async () => {
|
|
const services = dependencies({
|
|
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
|
|
probeStorage: vi.fn(async () => {
|
|
throw new Error("connect ECONNREFUSED 10.40.0.2:8333 accessKey=AKIAEXAMPLEACCESSID");
|
|
}),
|
|
});
|
|
|
|
const report = await diagnoseEkklesia(
|
|
{ ...options(databaseUrl), storageConfiguration: storageConfig },
|
|
services,
|
|
);
|
|
const integration = check(report, "object-storage.integration");
|
|
|
|
// A warning, never a failure. A momentary network blip must not make the whole platform read as
|
|
// NOT READY, and it must never look like a module problem (ADR 0030).
|
|
expect(integration.status).toBe("warning");
|
|
expect(report.result).not.toBe("not-ready");
|
|
expect(integration.details).toContain(
|
|
"The configured storage service did not respond. Modules already running are unaffected.",
|
|
);
|
|
const serialized = JSON.stringify(integration);
|
|
expect(serialized).not.toContain("AKIAEXAMPLEACCESSID");
|
|
expect(serialized).not.toContain("10.40.0.2");
|
|
});
|
|
|
|
it("still resolves bindings when the provider is unreachable, because binding is not health", async () => {
|
|
const services = dependencies({
|
|
discoverModules: vi.fn(async () =>
|
|
Object.freeze([storingModule("announcements", ["announcement-assets"])]),
|
|
),
|
|
probeStorage: vi.fn(async () => {
|
|
throw new Error("unreachable");
|
|
}),
|
|
});
|
|
|
|
const report = await diagnoseEkklesia(
|
|
{ ...options(databaseUrl), storageConfiguration: storageConfig },
|
|
services,
|
|
);
|
|
|
|
expect(check(report, "object-storage.integration").status).toBe("warning");
|
|
// The module is still eligible: a configured-but-briefly-unreachable provider is an operational
|
|
// condition, not a lifecycle state.
|
|
expect(check(report, "object-storage.bindings").status).toBe("pass");
|
|
expect(check(report, "modules.lifecycle").details).toContain("announcements 0.1.0 · enabled");
|
|
});
|
|
|
|
it("skips bindings 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, "object-storage.bindings").status).toBe("skipped");
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A browser contribution whose target capability nobody provides is otherwise silent: the manifest
|
|
* is valid, the artifact exists, and the screen renders nothing with no error anywhere (ADR 0041).
|
|
* Doctor names it.
|
|
*/
|
|
describe("Doctor reports declared browser contributions", () => {
|
|
function contributing(
|
|
id: string,
|
|
contribution: { capability: string; capabilityRange: string },
|
|
): DiscoveredModule {
|
|
const base = discoveredModule(id);
|
|
return {
|
|
...base,
|
|
contributions: [
|
|
{
|
|
artifactPath: `/unpublished/${id}/contributions/slide.js`,
|
|
capability: contribution.capability,
|
|
capabilityRange: contribution.capabilityRange,
|
|
id: `${id}.slide`,
|
|
version: "1.0.0",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function providing(id: string, capability: { id: string; version: string }): DiscoveredModule {
|
|
const base = discoveredModule(id);
|
|
return {
|
|
...base,
|
|
manifest: { ...base.manifest, capabilities: [capability] } as DiscoveredModule["manifest"],
|
|
};
|
|
}
|
|
|
|
async function detailsFor(modules: readonly DiscoveredModule[]): Promise<readonly string[]> {
|
|
const report = await diagnoseEkklesia(
|
|
options(),
|
|
dependencies({
|
|
discoverModules: vi.fn(async () => Object.freeze([...modules])),
|
|
discoverSchemas: vi.fn(async () => schemaAggregate(modules)),
|
|
}),
|
|
);
|
|
return check(report, "modules.discovery").details;
|
|
}
|
|
|
|
it("says nothing extra for a module that contributes nothing", async () => {
|
|
expect(await detailsFor([discoveredModule("people")])).toEqual(["people 0.1.0"]);
|
|
});
|
|
|
|
it("reports a contribution whose provider satisfies its declared range", async () => {
|
|
const details = await detailsFor([
|
|
providing("signage", { id: "signage.slide-renderer", version: "1.2.0" }),
|
|
contributing("announcements", {
|
|
capability: "signage.slide-renderer",
|
|
capabilityRange: "^1.0.0",
|
|
}),
|
|
]);
|
|
|
|
expect(details).toEqual([
|
|
"signage 0.1.0",
|
|
"announcements 0.1.0 · 1 browser contribution (1 with a compatible provider)",
|
|
]);
|
|
});
|
|
|
|
it("reports a contribution nobody provides, and one outside its range, as unmatched", async () => {
|
|
expect(
|
|
await detailsFor([
|
|
contributing("announcements", {
|
|
capability: "signage.slide-renderer",
|
|
capabilityRange: "^1.0.0",
|
|
}),
|
|
]),
|
|
).toEqual(["announcements 0.1.0 · 1 browser contribution (0 with a compatible provider)"]);
|
|
|
|
expect(
|
|
await detailsFor([
|
|
providing("signage", { id: "signage.slide-renderer", version: "3.0.0" }),
|
|
contributing("announcements", {
|
|
capability: "signage.slide-renderer",
|
|
capabilityRange: "^1.0.0",
|
|
}),
|
|
]),
|
|
).toEqual([
|
|
"signage 0.1.0",
|
|
"announcements 0.1.0 · 1 browser contribution (0 with a compatible provider)",
|
|
]);
|
|
});
|
|
});
|