Files
Ekklesia/core/tests/doctor-host-configuration.test.ts
ekklesia 49b4d4165d
CI / verify (pull_request) Successful in 1m0s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 23s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
feat: deliver module database ABI by manifest
2026-10-07 17:29:32 -05:00

488 lines
19 KiB
TypeScript

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://[email protected]: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<Record<string, string | undefined>> = {},
): Readonly<Record<string, string | undefined>> {
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: "[email protected]",
EKKLESIA_SMTP_HOST: "smtp.example.org",
EKKLESIA_SMTP_PASSWORD: smtpPassword,
EKKLESIA_SMTP_SECURITY: "starttls",
EKKLESIA_SMTP_USERNAME: "[email protected]",
});
function discoveredModule(id: string, overrides: Record<string, unknown> = {}): 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: 8,
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> = {}): 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<Record<string, string | undefined>>,
services: DoctorDependencies = dependencies(),
): Promise<{ readonly report: Awaited<ReturnType<typeof diagnoseEkklesia>> }> {
return { report: await diagnoseEkklesia(createDoctorOptions(env, metadata), services) };
}
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;
}
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");
});
});