fix: give Doctor the host configuration the runtime receives
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 14s
CI / migration-smoke (pull_request) Successful in 1m24s

`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]>
This commit is contained in:
ekklesia
2026-08-24 19:15:49 -05:00
co-authored by Claude Opus 5
parent a835215eb8
commit 2b4f0afb72
23 changed files with 1411 additions and 209 deletions
@@ -0,0 +1,425 @@
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");
});
});
+29
View File
@@ -3,6 +3,7 @@ import {
type DoctorDependencies,
diagnoseEkklesia,
formatDoctorReport,
RENDERED_DOCTOR_CHECK_IDS,
} from "../src/diagnostics/index.js";
import type { MigrationDatabase } from "../src/migrations/index.js";
import type { ModuleMigrationAggregate, ModuleSchemaAggregate } from "../src/modules/index.js";
@@ -123,9 +124,37 @@ Object storage
✓ No module requires object storage
0 modules declare a logical storage bucket.
Outbound email
○ Outbound email not configured
No EKKLESIA_SMTP_HOST is set. This is a complete deployment: nothing in the platform sends email yet.
Result: READY WITH WARNINGS`);
});
it("renders every check the report contains, so none can silently affect the result", async () => {
const report = await diagnoseEkklesia(
{
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: "/not-rendered/modules",
platformVersion: "0.1.0",
},
runtimeRange: ">=24.11.0 <25",
runtimeVersion: "24.15.0",
},
noConfigurationDependencies(),
);
// `outbound-email.configuration` was computed, counted toward the overall result, and never
// printed — so a rejected SMTP setting turned an operator's report NOT READY with nothing on
// screen saying why. Asserting the two sets against each other is what keeps a future check
// from repeating it.
expect([...RENDERED_DOCTOR_CHECK_IDS].sort()).toEqual(
report.checks.map((entry) => entry.id).sort(),
);
expect(new Set(RENDERED_DOCTOR_CHECK_IDS).size).toBe(RENDERED_DOCTOR_CHECK_IDS.length);
});
it("does not depend on NO_COLOR or terminal interactivity", async () => {
const report = await diagnoseEkklesia(
{
+4 -4
View File
@@ -1378,7 +1378,7 @@ describe("Ekklesia Doctor — object storage", () => {
});
const report = await diagnoseEkklesia(
{ ...options(databaseUrl), storage: storageConfig },
{ ...options(databaseUrl), storageConfiguration: storageConfig },
services,
);
const bindings = check(report, "object-storage.bindings");
@@ -1398,7 +1398,7 @@ describe("Ekklesia Doctor — object storage", () => {
});
const report = await diagnoseEkklesia(
{ ...options(databaseUrl), storage: storageConfig },
{ ...options(databaseUrl), storageConfiguration: storageConfig },
services,
);
const integration = check(report, "object-storage.integration");
@@ -1420,7 +1420,7 @@ describe("Ekklesia Doctor — object storage", () => {
});
const report = await diagnoseEkklesia(
{ ...options(databaseUrl), storage: storageConfig },
{ ...options(databaseUrl), storageConfiguration: storageConfig },
services,
);
const integration = check(report, "object-storage.integration");
@@ -1448,7 +1448,7 @@ describe("Ekklesia Doctor — object storage", () => {
});
const report = await diagnoseEkklesia(
{ ...options(databaseUrl), storage: storageConfig },
{ ...options(databaseUrl), storageConfiguration: storageConfig },
services,
);
@@ -0,0 +1,153 @@
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
/**
* Structural guarantees about where deployment configuration is read, which no runtime assertion can
* make.
*
* The defect this pass fixed was structural: two executables each read their own subset of the same
* environment variables, and the subsets drifted until `pnpm doctor` reported a correctly configured
* installation as having no object storage. Restoring parity once is not enough — the arrangement
* that allowed the drift has to stay gone.
*/
const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
async function readTypeScriptFiles(
directory: string,
): Promise<ReadonlyArray<{ readonly path: string; readonly text: string }>> {
const results: Array<{ path: string; text: string }> = [];
const walk = async (current: string): Promise<void> => {
for (const entry of await readdir(current, { withFileTypes: true })) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
if (entry.name === "node_modules" || entry.name === "dist") {
continue;
}
await walk(full);
continue;
}
if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
results.push({ path: full, text: await readFile(full, "utf8") });
}
}
};
await walk(path.join(repositoryRoot, directory));
return results;
}
/**
* Strips block comments and whole-line `//` comments.
*
* These assertions are about what the *code* does, and prose that explains a rule inevitably quotes
* the very token the rule forbids. Trailing comments are deliberately left alone so a URL inside a
* string literal is never truncated.
*/
function withoutComments(text: string): string {
return text
.replaceAll(/\/\*[\s\S]*?\*\//gu, "")
.split("\n")
.filter((line) => !line.trimStart().startsWith("//"))
.join("\n");
}
/**
* Every variable the shared loader *interprets* — parses, defaults, splits, or classifies.
*
* `DATABASE_URL` is deliberately absent: it is passed through verbatim, and the small migration and
* owner-bootstrap commands legitimately read it directly. Interpretation is what drifts.
*/
const INTERPRETED_VARIABLES = Object.freeze([
"EKKLESIA_CLIENT_ADDRESS_HEADER",
"EKKLESIA_CLIENT_ADDRESS_MODE",
"EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES",
"EKKLESIA_DATABASE_CA",
"EKKLESIA_DATABASE_TLS_MODE",
"EKKLESIA_IDENTITY_BASE_URL",
"EKKLESIA_IDENTITY_SECRET",
"EKKLESIA_IDENTITY_TRUSTED_ORIGINS",
"EKKLESIA_PUBLIC_SIGNUP_ENABLED",
"EKKLESIA_SMTP_FROM_ADDRESS",
"EKKLESIA_SMTP_FROM_NAME",
"EKKLESIA_SMTP_HOST",
"EKKLESIA_SMTP_PASSWORD",
"EKKLESIA_SMTP_PORT",
"EKKLESIA_SMTP_SECURITY",
"EKKLESIA_SMTP_USERNAME",
"NODE_ENV",
"S3_ACCESS_KEY",
"S3_BUCKET_PREFIX",
"S3_DELIVERY_ENDPOINT",
"S3_ENDPOINT",
"S3_FORCE_PATH_STYLE",
"S3_REGION",
"S3_SECRET_KEY",
]);
describe("host configuration is read in exactly one place", () => {
it("never reads the process environment from the shared loader itself", async () => {
for (const file of await readTypeScriptFiles(path.join("core", "src", "host"))) {
// The loader is reusable Core code: it takes the environment as an argument, exactly as
// `createEnvironmentModuleSecretSource` does, so both executables can hand it theirs.
expect(withoutComments(file.text), file.path).not.toContain("process.env");
}
});
it("interprets each host infrastructure variable in exactly one file", async () => {
const sources = [
...(await readTypeScriptFiles(path.join("core", "src"))),
...(await readTypeScriptFiles(path.join("apps", "api", "src"))),
];
for (const file of sources) {
const code = withoutComments(file.text);
for (const variable of INTERPRETED_VARIABLES) {
// Reading one of these anywhere else means a second, independently drifting interpretation
// of the same deployment — which is exactly the defect this pass fixed. Operator-facing
// message text that *names* a variable is untouched: only an actual read is forbidden.
expect(code, `${file.path} reads ${variable}`).not.toContain(`process.env.${variable}`);
expect(code, `${file.path} reads ${variable}`).not.toContain(`process.env["${variable}"]`);
}
}
});
it("leaves the two executables reading nothing but their own listener address", async () => {
const doctorCli = withoutComments(
await readFile(path.join(repositoryRoot, "core", "src", "cli", "doctor.ts"), "utf8"),
);
const server = withoutComments(
await readFile(path.join(repositoryRoot, "apps", "api", "src", "server.ts"), "utf8"),
);
for (const source of [doctorCli, server]) {
expect(source).toContain("loadHostConfiguration");
expect(source).not.toContain("DATABASE_URL");
}
// `EKKLESIA_API_HOST`/`EKKLESIA_API_PORT` describe this listener rather than the host's
// infrastructure, so Doctor has no use for them and they stay with the process that binds.
expect(server).toContain("EKKLESIA_API_HOST");
expect(doctorCli).not.toContain("EKKLESIA_API_");
});
it("reads the environment only from executables, never from reusable Core", async () => {
for (const file of await readTypeScriptFiles(path.join("core", "src"))) {
if (file.path.includes(`${path.sep}cli${path.sep}`)) {
continue;
}
expect(withoutComments(file.text), file.path).not.toContain("process.env");
}
});
it("gives Doctor the same object-storage and secret inputs the runtime receives", async () => {
const doctorCli = await readFile(
path.join(repositoryRoot, "core", "src", "cli", "doctor.ts"),
"utf8",
);
// The two options whose absence was the bug. A future edit that drops either fails here rather
// than in a support conversation about storage that Doctor says is missing.
expect(doctorCli).toContain("storageConfiguration: host.storage");
expect(doctorCli).toContain("moduleSecrets: host.moduleSecrets");
});
});
+192
View File
@@ -0,0 +1,192 @@
import { describe, expect, it } from "vitest";
import { createDatabaseTransportPolicy } from "../src/database/index.js";
import { loadHostConfiguration } from "../src/host/index.js";
import { createIdentityConfig } from "../src/identity/index.js";
import { createMailProviderConfig } from "../src/mail/index.js";
import { createStorageProviderConfig } from "../src/storage/index.js";
/**
* A complete, valid production deployment. Every optional service is present, because the bug this
* suite exists for was a *configured* service being reported absent — an empty environment would
* never have shown it.
*/
const productionEnvironment = Object.freeze({
DATABASE_URL: "postgres://[email protected]:5432/ekklesia",
EKKLESIA_CLIENT_ADDRESS_MODE: "trusted-proxy",
EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: "10.40.0.1, 10.40.0.2 ,",
EKKLESIA_IDENTITY_BASE_URL: "https://ekklesia.example.org",
EKKLESIA_IDENTITY_SECRET: "b7QwZ2rTn4Xs9LmVpKdF6yHc3JgEuNaW",
EKKLESIA_IDENTITY_TRUSTED_ORIGINS: "https://ekklesia.example.org, https://display.example.org",
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL: "module-secret-material",
EKKLESIA_SMTP_FROM_ADDRESS: "[email protected]",
EKKLESIA_SMTP_FROM_NAME: "Ekklesia",
EKKLESIA_SMTP_HOST: "smtp.example.org",
EKKLESIA_SMTP_PASSWORD: "sup3r-secret-relay-password",
EKKLESIA_SMTP_PORT: "587",
EKKLESIA_SMTP_SECURITY: "starttls",
EKKLESIA_SMTP_USERNAME: "[email protected]",
NODE_ENV: "production",
S3_ACCESS_KEY: "AKIAEXAMPLEACCESSID",
S3_BUCKET_PREFIX: "ekklesia",
S3_DELIVERY_ENDPOINT: "https://files.example.org",
S3_ENDPOINT: "https://storage.example.org",
S3_FORCE_PATH_STYLE: "true",
S3_REGION: "us-east-1",
S3_SECRET_KEY: "sk-example-secret-material",
});
describe("host configuration loading", () => {
it("never reads the process environment, only the object it is given", () => {
// Proved by construction: an empty environment produces an unconfigured deployment even though
// this test process's own `process.env` may carry DATABASE_URL, S3_*, or EKKLESIA_SMTP_*.
const host = loadHostConfiguration({});
expect(host.databaseUrl).toBeUndefined();
expect(host.storage.endpoint).toBeUndefined();
expect(host.mail.host).toBeUndefined();
expect(host.identity.secret).toBeUndefined();
});
it("carries every object-storage field the storage passes established", () => {
const host = loadHostConfiguration(productionEnvironment);
expect(host.storage).toEqual({
accessKey: "AKIAEXAMPLEACCESSID",
bucketPrefix: "ekklesia",
deliveryEndpoint: "https://files.example.org",
endpoint: "https://storage.example.org",
forcePathStyle: "true",
region: "us-east-1",
secretKey: "sk-example-secret-material",
});
});
it("carries every SMTP field, leaving credentials optional as a pair", () => {
const host = loadHostConfiguration(productionEnvironment);
expect(host.mail).toEqual({
fromAddress: "[email protected]",
fromName: "Ekklesia",
host: "smtp.example.org",
password: "sup3r-secret-relay-password",
port: "587",
security: "starttls",
username: "[email protected]",
});
const relayByAddress = loadHostConfiguration({
EKKLESIA_SMTP_FROM_ADDRESS: "[email protected]",
EKKLESIA_SMTP_HOST: "smtp-relay.example.org",
});
expect(relayByAddress.mail.username).toBeUndefined();
expect(relayByAddress.mail.password).toBeUndefined();
// A source-IP-authorized relay has no credentials at all, and that must still validate.
expect(createMailProviderConfig(relayByAddress.mail)).toBeDefined();
});
it("resolves module secrets only under names derived from a module's own declared key", () => {
const host = loadHostConfiguration(productionEnvironment);
expect(host.moduleSecrets.get("announcements", "storageCredential")).toBe(
"module-secret-material",
);
expect(host.moduleSecrets.get("announcements", "unknownKey")).toBeUndefined();
// There is no accessor that takes a raw variable name, so a module can never reach these.
expect(host.moduleSecrets.get("announcements", "DATABASE_URL")).toBeUndefined();
});
it("treats only the exact string development as development", () => {
expect(loadHostConfiguration({ NODE_ENV: "development" }).environment).toBe("development");
for (const value of ["Development", "dev", "test", "", "production", undefined]) {
expect(loadHostConfiguration({ NODE_ENV: value }).environment).toBe("production");
}
});
it("omits client-address settings entirely unless a mode is set", () => {
expect(loadHostConfiguration({}).clientAddress).toBeUndefined();
// Half-configuring proxy mode by setting only the header must not silently enable it.
expect(
loadHostConfiguration({ EKKLESIA_CLIENT_ADDRESS_HEADER: "x-forwarded-for" }).clientAddress,
).toBeUndefined();
expect(loadHostConfiguration(productionEnvironment).clientAddress).toEqual({
header: undefined,
mode: "trusted-proxy",
trustedProxies: ["10.40.0.1", "10.40.0.2"],
});
});
it("omits database transport keys that are unset rather than passing undefined values", () => {
expect(loadHostConfiguration({}).databaseTransport).toEqual({});
expect(
loadHostConfiguration({
EKKLESIA_DATABASE_CA: "-----BEGIN CERTIFICATE-----",
EKKLESIA_DATABASE_TLS_MODE: "verify-full",
}).databaseTransport,
).toEqual({
certificateAuthority: "-----BEGIN CERTIFICATE-----",
mode: "verify-full",
});
});
it("splits comma-separated lists identically wherever they appear", () => {
const host = loadHostConfiguration({
EKKLESIA_CLIENT_ADDRESS_MODE: "trusted-proxy",
EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: " 10.0.0.1 ,, 10.0.0.2 ",
EKKLESIA_IDENTITY_TRUSTED_ORIGINS: " https://a.example.org ,, https://b.example.org ",
});
expect(host.identity.trustedOrigins).toEqual([
"https://a.example.org",
"https://b.example.org",
]);
expect(host.clientAddress?.trustedProxies).toEqual(["10.0.0.1", "10.0.0.2"]);
// A variable that is set but empty yields an empty list, not `undefined` — the two mean
// different things to the identity validator (`TRUSTED_ORIGINS_EMPTY` vs
// `TRUSTED_ORIGINS_MISSING`), and both executables must see the same one.
expect(
loadHostConfiguration({ EKKLESIA_IDENTITY_TRUSTED_ORIGINS: "" }).identity.trustedOrigins,
).toEqual([]);
expect(loadHostConfiguration({}).identity.trustedOrigins).toBeUndefined();
});
it("passes a malformed value through unchanged rather than repairing or discarding it", () => {
// The loader deliberately does not validate: `startApi` wants a malformed value to abort
// startup and Doctor wants it reported, and neither can happen if the load already threw or
// silently dropped it.
const host = loadHostConfiguration({
EKKLESIA_PUBLIC_SIGNUP_ENABLED: "",
S3_ENDPOINT: "not-a-url",
});
expect(host.identity.publicSignupEnabled).toBe("");
expect(host.storage.endpoint).toBe("not-a-url");
expect(() => createStorageProviderConfig(host.storage)).toThrowError(/absolute http/u);
expect(() => createIdentityConfig(host.identity)).toThrowError();
});
it("is accepted unchanged by every Core validator that consumes it", () => {
const host = loadHostConfiguration(productionEnvironment);
expect(createIdentityConfig(host.identity, { environment: host.environment })).toMatchObject({
baseUrl: "https://ekklesia.example.org",
publicSignupEnabled: false,
});
expect(
createDatabaseTransportPolicy(host.databaseTransport, { environment: host.environment }),
).toBeDefined();
expect(createStorageProviderConfig(host.storage, { environment: host.environment })).toEqual({
accessKey: "AKIAEXAMPLEACCESSID",
bucketPrefix: "ekklesia",
deliveryEndpoint: "https://files.example.org",
endpoint: "https://storage.example.org",
forcePathStyle: true,
region: "us-east-1",
secretKey: "sk-example-secret-material",
});
expect(createMailProviderConfig(host.mail, { environment: host.environment })).toMatchObject({
host: "smtp.example.org",
port: 587,
security: "starttls",
});
});
});
+16 -6
View File
@@ -118,15 +118,25 @@ describe("outbound email stays server-only", () => {
}
});
it("reads SMTP environment only in the executable that owns configuration", async () => {
// Reusable Core code never touches `process.env`; the composition root turns environment into
// validated configuration and injects it.
it("maps SMTP environment in exactly one place, and never inside the mail provider", async () => {
// The mail provider takes validated configuration and nothing else — it never reads the
// environment, under any name.
for (const file of await readTextFiles("core/src/mail")) {
expect(file.text, file.path).not.toContain("process.env");
expect(file.text, file.path).not.toContain("EKKLESIA_SMTP_");
}
const server = await readFile(path.join(repositoryRoot, "apps/api/src/server.ts"), "utf8");
expect(server).toContain("EKKLESIA_SMTP_HOST");
expect(server).toContain("EKKLESIA_SMTP_PASSWORD");
// The variable names live in the shared host-configuration loader, which both `startApi` and
// `pnpm doctor` call with their own environment. Two executables mapping these names
// independently is what let Doctor and the runtime disagree about object storage.
const loader = await readFile(
path.join(repositoryRoot, "core/src/host/configuration.ts"),
"utf8",
);
expect(loader).toContain("EKKLESIA_SMTP_HOST");
expect(loader).toContain("EKKLESIA_SMTP_PASSWORD");
// That the loader itself reads no environment is asserted in
// `host-configuration-boundaries.test.ts`, which strips comments first — this file's plain
// substring match would trip over the loader's own prose about the rule.
});
it("keeps no SMTP password out of the committed environment example", async () => {