`pnpm doctor` and `startApi` each read their own subset of the same environment variables, and the subsets had drifted. Doctor never read `S3_*` or module secrets at all, so a correctly configured installation was told its object storage was "not configured" and every module requiring a bucket or a secret was reported blocked -- while the runtime, from the identical environment, bound those buckets and ran the modules. Both executables now map the environment through one `loadHostConfiguration(env)` in Core, which takes the environment as an argument and never reads `process.env` itself. Validation stays with the existing per-domain validators, so each caller keeps its own failure behaviour: startup refuses a rejected value, Doctor reports it as a sanitized code. Doctor's storage option becomes raw input like its mail option, so a half-configured provider is reported as a blocking configuration failure instead of throwing before a report exists. The outbound-email check -- computed, counted toward the result, but absent from every rendered section -- is now printed, and the rendered set is asserted against a real report so a future check cannot go missing the same way. Co-Authored-By: Claude Opus 5 <[email protected]>
426 lines
16 KiB
TypeScript
426 lines
16 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,
|
|
ModuleMigrationAggregate,
|
|
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",
|
|
modulesDirectory: "/not-rendered/modules",
|
|
platformVersion: "0.1.0",
|
|
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 {
|
|
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: 6,
|
|
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("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");
|
|
});
|
|
});
|