import type { ModuleManifest } from "@ekklesia/contracts"; import { describe, expect, it, vi } from "vitest"; import { createPermissionRegistry } from "../src/authorization/index.js"; import { createDoctorOptions } from "../src/cli/doctor.js"; import { type DoctorCheck, type DoctorDependencies, diagnoseEkklesia, formatDoctorReport, } from "../src/diagnostics/index.js"; import { type DiscoveredModule, discoverModules, type ModuleMigrationAggregate, type ModuleSchemaAggregate, } from "../src/modules/index.js"; /** * The bug this suite exists for was operational, not theoretical: `pnpm doctor` built its own * `DoctorOptions` and simply never included object storage or module secrets, so a correctly * configured installation was told its storage was "not configured" and every module requiring a * bucket was reported blocked — while `startApi`, from the identical environment, ran them. * * These tests therefore go through `createDoctorOptions`, the exact function the CLI calls, with an * environment fixture rather than a hand-built options object. A helper-only test would have passed * happily on the broken code. */ const databaseUrl = "postgres://ekklesia@db.example.org:5432/ekklesia"; const accessKey = "AKIAEXAMPLEACCESSID"; const secretKey = "sk-example-secret-material"; const smtpPassword = "sup3r-secret-relay-password"; const identitySecret = "b7QwZ2rTn4Xs9LmVpKdF6yHc3JgEuNaW"; const moduleSecret = "module-secret-material"; const metadata = Object.freeze({ moduleSdkVersion: "0.1.0", platformVersion: "0.1.0", repositoryRoot: "/not-rendered", runtimeRange: ">=24.11.0 <25", runtimeVersion: "24.15.0", }); function environment( overrides: Readonly> = {}, ): Readonly> { return { DATABASE_URL: databaseUrl, EKKLESIA_IDENTITY_BASE_URL: "https://ekklesia.example.org", EKKLESIA_IDENTITY_SECRET: identitySecret, EKKLESIA_IDENTITY_TRUSTED_ORIGINS: "https://ekklesia.example.org", NODE_ENV: "production", ...overrides, }; } const storageEnvironment = Object.freeze({ S3_ACCESS_KEY: accessKey, S3_ENDPOINT: "https://storage.example.org", S3_SECRET_KEY: secretKey, }); const mailEnvironment = Object.freeze({ EKKLESIA_SMTP_FROM_ADDRESS: "noreply@example.org", EKKLESIA_SMTP_HOST: "smtp.example.org", EKKLESIA_SMTP_PASSWORD: smtpPassword, EKKLESIA_SMTP_SECURITY: "starttls", EKKLESIA_SMTP_USERNAME: "relay@example.org", }); function discoveredModule(id: string, overrides: Record = {}): 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: "0.1.0", ...overrides, } as unknown as ModuleManifest, manifestPath: `/unpublished/${id}/ekklesia.module.json`, packageName: `@test/${id}`, }; } const storingModule = discoveredModule("announcements", { storage: { buckets: [{ id: "announcement-assets" }] }, }); const secretRequiringModule = discoveredModule("announcements", { configuration: [{ key: "storageCredential", required: true, type: "secret" }], }); function schemaAggregate(): ModuleSchemaAggregate { return Object.freeze({ discoveredModules: Object.freeze([]), drizzleSchema: Object.freeze({}), modules: Object.freeze([]), tables: Object.freeze([]), }); } function migrationAggregate(): ModuleMigrationAggregate { return Object.freeze({ discoveredModules: Object.freeze([]), migrations: Object.freeze([]), modules: Object.freeze([]), }); } function dependencies(overrides: Partial = {}): DoctorDependencies { return { composePermissions: vi.fn((modules: readonly DiscoveredModule[]) => createPermissionRegistry( modules.flatMap((discovered) => [...discovered.manifest.permissions]), ), ), createMigrationDatabase: vi.fn(() => { throw new Error("not used"); }), discoverMigrations: vi.fn(async () => migrationAggregate()), discoverModules: vi.fn(async () => Object.freeze([])), discoverSchemas: vi.fn(async () => schemaAggregate()), planMigrations: vi.fn(async () => { throw new Error("not used"); }), 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, }; } async function diagnose( env: Readonly>, services: DoctorDependencies = dependencies(), ): Promise<{ readonly report: Awaited> }> { return { report: await diagnoseEkklesia(createDoctorOptions(env, metadata), services) }; } function check( report: Awaited>, id: DoctorCheck["id"], ): DoctorCheck { const found = report.checks.find((candidate) => candidate.id === id); if (!found) { throw new Error(`Missing test check ${id}.`); } return found; } describe("Doctor host-configuration parity — object storage", () => { it("recognizes configured object storage from the deployment environment", async () => { const services = dependencies(); const { report } = await diagnose(environment(storageEnvironment), services); const integration = check(report, "object-storage.integration"); // The regression itself: before this pass the CLI passed no storage at all and this read // "skipped · Object storage not configured" from exactly this environment. expect(integration.status).toBe("pass"); expect(integration.summary).toBe("Object storage reachable"); expect(services.probeStorage).toHaveBeenCalledWith({ accessKey, bucketPrefix: "ekklesia", deliveryEndpoint: "https://storage.example.org", endpoint: "https://storage.example.org", forcePathStyle: true, region: "us-east-1", secretKey, }); }); it("still reports genuinely absent storage as absent", async () => { const services = dependencies(); const { report } = await diagnose(environment(), services); const integration = check(report, "object-storage.integration"); expect(integration.status).toBe("skipped"); expect(integration.summary).toBe("Object storage not configured"); expect(services.probeStorage).not.toHaveBeenCalled(); }); it("reports a half-configured provider as a configuration failure, naming no value", async () => { const services = dependencies(); const { report } = await diagnose( environment({ S3_ENDPOINT: "https://storage.example.org" }), services, ); const integration = check(report, "object-storage.integration"); // `startApi` refuses to start on this input, so the deployment is genuinely not ready — this is // a failure rather than the warning an unreachable-but-valid provider gets. expect(integration.status).toBe("failure"); expect(integration.details[0]).toBe( "The object-storage settings were rejected: STORAGE_ACCESS_KEY_MISSING.", ); expect(report.result).toBe("not-ready"); // Nothing was connected to: a malformed configuration must not produce network traffic. expect(services.probeStorage).not.toHaveBeenCalled(); }); it("reports a malformed endpoint safely rather than crashing the command", async () => { const { report } = await diagnose( environment({ ...storageEnvironment, S3_ENDPOINT: "not-a-url" }), ); const integration = check(report, "object-storage.integration"); expect(integration.status).toBe("failure"); expect(integration.details[0]).toBe( "The object-storage settings were rejected: STORAGE_ENDPOINT_INVALID.", ); expect(JSON.stringify(integration)).not.toContain("not-a-url"); }); it("refuses a plaintext production endpoint exactly as startup would", async () => { const { report } = await diagnose( environment({ ...storageEnvironment, S3_ENDPOINT: "http://storage.example.org" }), ); expect(check(report, "object-storage.integration").details[0]).toBe( "The object-storage settings were rejected: STORAGE_ENDPOINT_INSECURE_IN_PRODUCTION.", ); }); it("applies the same region and bucket-prefix defaults the runtime applies", async () => { const services = dependencies(); await diagnose(environment(storageEnvironment), services); expect(services.probeStorage).toHaveBeenCalledWith( expect.objectContaining({ bucketPrefix: "ekklesia", forcePathStyle: true, region: "us-east-1", }), ); }); it("defaults the browser delivery origin to the endpoint, and flags a separate one", async () => { const { report: shared } = await diagnose(environment(storageEnvironment)); expect(check(shared, "object-storage.integration").details).toHaveLength(1); const { report: separate } = await diagnose( environment({ ...storageEnvironment, S3_DELIVERY_ENDPOINT: "https://files.example.org" }), ); const integration = check(separate, "object-storage.integration"); expect(integration.details[0]).toContain("browser delivery https://files.example.org"); expect(integration.details[1]).toContain("was not probed"); }); it("binds a module's declared bucket instead of reporting it blocked", async () => { const services = dependencies({ discoverModules: vi.fn(async () => Object.freeze([storingModule])), }); const { report } = await diagnose(environment(storageEnvironment), services); // The operator-visible consequence of the bug: this module was reported blocked by a Doctor run // against the very environment that ran it. expect(check(report, "object-storage.bindings").status).toBe("pass"); expect(check(report, "modules.lifecycle").details).toContain("announcements 0.1.0 · enabled"); }); it("still reports a bucket as unbound when no provider is configured", async () => { const services = dependencies({ discoverModules: vi.fn(async () => Object.freeze([storingModule])), }); const { report } = await diagnose(environment(), services); expect(check(report, "object-storage.bindings").status).toBe("failure"); expect(check(report, "modules.lifecycle").details).toContain( "announcements 0.1.0 · blocked · storage_unavailable · announcement-assets", ); }); it("treats a malformed provider as unusable when deciding module eligibility", async () => { const services = dependencies({ discoverModules: vi.fn(async () => Object.freeze([storingModule])), }); const { report } = await diagnose( environment({ S3_ENDPOINT: "https://storage.example.org" }), services, ); // A provider that cannot be constructed cannot bind a bucket, and saying otherwise would report // a module as runnable that startup would refuse to start at all. expect(check(report, "object-storage.bindings").status).toBe("failure"); }); }); describe("Doctor host-configuration parity — module secrets", () => { it("sees a secret the deployment actually set", async () => { const services = dependencies({ discoverModules: vi.fn(async () => Object.freeze([secretRequiringModule])), }); const { report } = await diagnose( environment({ EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL: moduleSecret, }), services, ); const configuration = check(report, "modules.configuration"); // Same class of bug as storage: the CLI never passed a secret source, so a configured secret // was always reported missing. expect(configuration.status).toBe("pass"); expect(check(report, "modules.lifecycle").details).toContain("announcements 0.1.0 · enabled"); expect(JSON.stringify(report)).not.toContain(moduleSecret); }); it("still reports a genuinely unset secret as missing", async () => { const services = dependencies({ discoverModules: vi.fn(async () => Object.freeze([secretRequiringModule])), }); const { report } = await diagnose(environment(), services); expect(check(report, "modules.configuration").status).toBe("warning"); expect(check(report, "modules.lifecycle").details).toContain( "announcements 0.1.0 · blocked · secret_missing · storageCredential", ); }); }); describe("Doctor host-configuration parity — outbound email regression", () => { it("leaves an installation with no SMTP host reported as complete", async () => { const services = dependencies(); const { report } = await diagnose(environment(), services); const mailCheck = check(report, "outbound-email.configuration"); expect(mailCheck.status).toBe("skipped"); expect(mailCheck.summary).toBe("Outbound email not configured"); expect(services.probeMail).not.toHaveBeenCalled(); }); it("leaves a configured relay reported as reachable, without its password", async () => { const services = dependencies(); const { report } = await diagnose(environment(mailEnvironment), services); const mailCheck = check(report, "outbound-email.configuration"); expect(mailCheck.status).toBe("pass"); expect(services.probeMail).toHaveBeenCalledTimes(1); expect(JSON.stringify(mailCheck)).not.toContain(smtpPassword); }); it("probes with the SMTP client identity the runtime would use, and reports it", async () => { const services = dependencies(); const { report } = await diagnose( environment({ ...mailEnvironment, EKKLESIA_SMTP_HELO_NAME: "mail.example.org" }), services, ); const mailCheck = check(report, "outbound-email.configuration"); // Doctor builds its provider from the same validated configuration `startApi` does, so a relay // that refuses the greeting fails here rather than only when a reset email silently never // arrives. The name is announced in the clear on every connection, so showing it leaks nothing. expect(services.probeMail).toHaveBeenCalledWith( expect.objectContaining({ heloName: "mail.example.org" }), ); expect(mailCheck.details[0]).toContain("HELO mail.example.org"); expect(JSON.stringify(mailCheck)).not.toContain(smtpPassword); }); it("reports a malformed SMTP client identity as a typed failure, naming no value", async () => { const { report } = await diagnose( environment({ ...mailEnvironment, EKKLESIA_SMTP_HELO_NAME: "smtp://internal-host:2525" }), ); const mailCheck = check(report, "outbound-email.configuration"); expect(mailCheck.status).toBe("failure"); expect(mailCheck.details[0]).toBe("The SMTP settings were rejected: MAIL_HELO_NAME_INVALID."); expect(JSON.stringify(mailCheck)).not.toContain("internal-host"); }); it("leaves a malformed SMTP setting reported as a typed configuration failure", async () => { const { report } = await diagnose( environment({ ...mailEnvironment, EKKLESIA_SMTP_PORT: "70000" }), ); const mailCheck = check(report, "outbound-email.configuration"); expect(mailCheck.status).toBe("failure"); expect(mailCheck.details[0]).toBe("The SMTP settings were rejected: MAIL_PORT_INVALID."); expect(JSON.stringify(mailCheck)).not.toContain("70000"); }); it("still refuses a plaintext production relay", async () => { const { report } = await diagnose( environment({ ...mailEnvironment, EKKLESIA_SMTP_SECURITY: "plaintext" }), ); expect(check(report, "outbound-email.configuration").details[0]).toBe( "The SMTP settings were rejected: MAIL_SECURITY_INSECURE_IN_PRODUCTION.", ); }); }); describe("Doctor host-configuration parity — sanitization", () => { it("prints no secret material anywhere in a fully configured report", async () => { const services = dependencies({ discoverModules: vi.fn(async () => Object.freeze([storingModule])), }); const { report } = await diagnose( environment({ ...mailEnvironment, ...storageEnvironment, EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL: moduleSecret, }), services, ); const rendered = formatDoctorReport(report).join("\n"); for (const secret of [accessKey, secretKey, smtpPassword, identitySecret, moduleSecret]) { expect(rendered).not.toContain(secret); expect(JSON.stringify(report)).not.toContain(secret); } // The infrastructure host names are deliberately still shown: they carry no credential and are // what an operator needs in order to act on the report. expect(rendered).toContain("https://storage.example.org"); expect(rendered).toContain("smtp.example.org"); }); it("never serializes the environment object wholesale", async () => { const services = dependencies(); const { report } = await diagnose( environment({ ...storageEnvironment, TOTALLY_UNRELATED_SECRET: "unrelated-value" }), services, ); expect(JSON.stringify(report)).not.toContain("unrelated-value"); expect(JSON.stringify(report)).not.toContain("S3_ACCESS_KEY"); }); }); describe("Doctor reads the modules directory from the same host configuration (ADR 0048)", () => { it("uses the repository's own modules directory when EKKLESIA_MODULES_DIR is unset", () => { const doctorOptions = createDoctorOptions(environment(), metadata); expect(doctorOptions.moduleDiscovery.modulesDirectory).toBe("/not-rendered/modules"); expect(doctorOptions.moduleDiscovery.modulesDirectoryRequired).toBe(false); expect(doctorOptions.coreMigrationsDirectory).toBe("/not-rendered/core/drizzle"); }); it("uses a configured directory, and still finds Core's migrations under the platform", () => { const doctorOptions = createDoctorOptions( environment({ EKKLESIA_MODULES_DIR: "/var/lib/ekklesia/modules" }), metadata, ); expect(doctorOptions.moduleDiscovery.modulesDirectory).toBe("/var/lib/ekklesia/modules"); expect(doctorOptions.moduleDiscovery.modulesDirectoryRequired).toBe(true); expect(doctorOptions.coreMigrationsDirectory).toBe("/not-rendered/core/drizzle"); }); it("hands an unusable value to discovery to report, rather than throwing before a report exists", async () => { const doctorOptions = createDoctorOptions( environment({ EKKLESIA_MODULES_DIR: "relative/private-mount-91aa" }), metadata, ); expect(doctorOptions.moduleDiscovery.modulesDirectoryRequired).toBe(true); const report = await diagnoseEkklesia(doctorOptions, dependencies({ discoverModules })); expect(report.checks.find((entry) => entry.id === "modules.discovery")?.status).toBe("failure"); expect(JSON.stringify(report)).not.toContain("private-mount-91aa"); }); });