A real deployment could not send mail through an IP-authorized relay. Every attempt surfaced as MAIL_PROVIDER_UNAVAILABLE while the relay was healthy and swaks sent from the same machine. The cause was the greeting, and it is generic rather than tied to any provider. An SMTP client identifies itself in EHLO, and with nothing configured Nodemailer derives that from the machine hostname — substituting the address literal [127.0.0.1] whenever the hostname is not fully qualified, which is the normal state of a container. The relay closed the connection on it with a 421 before any message was offered, which is indistinguishable from the relay being down. EKKLESIA_SMTP_HELO_NAME now lets the deployment say what to greet as. It is validated as a fully qualified domain name — no scheme, port, brackets, whitespace, or control characters, and at least one dot, since a single unqualified label is exactly the input Nodemailer already discards. It is not a credential: it travels in the clear on every connection, so Doctor prints it where an operator can compare it against what the relay expects, and Doctor's probe performs the same greeting the runtime does. Absent preserves Nodemailer's behaviour exactly. It is deliberately not defaulted to the sender's domain: a machine whose hostname is already a valid FQDN may be greeting correctly, and some relays check that against reverse DNS, so replacing it silently would trade one deployment's bug for another's. The regression guard is a raw SMTP server that refuses any greeting but the expected one, because smtp-server exposes no EHLO hook and its earliest hook is never reached by a send-nothing probe. Co-Authored-By: Claude Opus 5 <[email protected]>
198 lines
8.5 KiB
TypeScript
198 lines
8.5 KiB
TypeScript
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_HELO_NAME: "mail.example.org",
|
|
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",
|
|
heloName: "mail.example.org",
|
|
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();
|
|
// The SMTP client identity is optional too, and unset is the ordinary case: it matters only
|
|
// where the machine's own hostname is not a fully qualified name the relay accepts.
|
|
expect(relayByAddress.mail.heloName).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",
|
|
});
|
|
});
|
|
});
|