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]>
178 lines
7.0 KiB
TypeScript
178 lines
7.0 KiB
TypeScript
import { loadHostConfiguration } from "@ekklesia/core/host";
|
|
import { createMailProviderConfig } from "@ekklesia/core/mail";
|
|
import { createStorageProviderConfig } from "@ekklesia/core/storage";
|
|
import { describe, expect, it } from "vitest";
|
|
import { createApiRuntimeConfig } from "../src/config.js";
|
|
import { readConfigFromEnvironment } from "../src/server.js";
|
|
|
|
/**
|
|
* `pnpm doctor` and `startApi` must reach the same conclusions from the same environment. They
|
|
* previously could not: each executable read its own subset of the variables, and Doctor's subset
|
|
* omitted `S3_*` and the module secrets entirely.
|
|
*
|
|
* These tests assert the property directly — the API's startup inputs and the inputs Doctor
|
|
* validates come from one `loadHostConfiguration` call and produce identical validated
|
|
* configuration, including every default.
|
|
*/
|
|
|
|
const secretKey = "sk-example-secret-material";
|
|
const smtpPassword = "sup3r-secret-relay-password";
|
|
|
|
function environment(
|
|
overrides: Readonly<Record<string, string | undefined>> = {},
|
|
): Readonly<Record<string, string | undefined>> {
|
|
return {
|
|
DATABASE_URL: "postgres://[email protected]:5432/ekklesia",
|
|
EKKLESIA_IDENTITY_BASE_URL: "https://ekklesia.example.org",
|
|
EKKLESIA_IDENTITY_SECRET: "b7QwZ2rTn4Xs9LmVpKdF6yHc3JgEuNaW",
|
|
EKKLESIA_IDENTITY_TRUSTED_ORIGINS: "https://ekklesia.example.org",
|
|
NODE_ENV: "production",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const configuredInfrastructure = Object.freeze({
|
|
EKKLESIA_SMTP_FROM_ADDRESS: "[email protected]",
|
|
EKKLESIA_SMTP_HELO_NAME: "mail.example.org",
|
|
EKKLESIA_SMTP_HOST: "smtp.example.org",
|
|
EKKLESIA_SMTP_PASSWORD: smtpPassword,
|
|
EKKLESIA_SMTP_USERNAME: "[email protected]",
|
|
S3_ACCESS_KEY: "AKIAEXAMPLEACCESSID",
|
|
S3_ENDPOINT: "https://storage.example.org",
|
|
S3_SECRET_KEY: secretKey,
|
|
});
|
|
|
|
/** Exactly what Doctor validates, from exactly the same load the API performs. */
|
|
function doctorView(env: Readonly<Record<string, string | undefined>>) {
|
|
const host = loadHostConfiguration(env);
|
|
return {
|
|
mail: createMailProviderConfig(host.mail, { environment: host.environment }),
|
|
storage: createStorageProviderConfig(host.storage, { environment: host.environment }),
|
|
};
|
|
}
|
|
|
|
function apiView(env: Readonly<Record<string, string | undefined>>) {
|
|
const { environment: resolved, input } = readConfigFromEnvironment(env);
|
|
return createApiRuntimeConfig(input, { environment: resolved });
|
|
}
|
|
|
|
describe("host configuration parity between startApi and Doctor", () => {
|
|
it("derives identical object-storage configuration, defaults included", () => {
|
|
const env = environment(configuredInfrastructure);
|
|
|
|
expect(apiView(env).storage).toEqual(doctorView(env).storage);
|
|
// Named explicitly rather than only compared, so a change to either default fails here loudly.
|
|
expect(apiView(env).storage).toEqual({
|
|
accessKey: "AKIAEXAMPLEACCESSID",
|
|
bucketPrefix: "ekklesia",
|
|
deliveryEndpoint: "https://storage.example.org",
|
|
endpoint: "https://storage.example.org",
|
|
forcePathStyle: true,
|
|
region: "us-east-1",
|
|
secretKey,
|
|
});
|
|
});
|
|
|
|
it("derives identical outbound-email configuration, defaults included", () => {
|
|
const env = environment(configuredInfrastructure);
|
|
|
|
expect(apiView(env).mail).toEqual(doctorView(env).mail);
|
|
expect(apiView(env).mail).toMatchObject({
|
|
// The SMTP client identity is part of that equivalence: a relay that refuses the greeting
|
|
// must fail in Doctor for the same reason it fails at runtime.
|
|
heloName: "mail.example.org",
|
|
host: "smtp.example.org",
|
|
port: 587,
|
|
security: "starttls",
|
|
});
|
|
});
|
|
|
|
it("agrees that an installation with neither service is complete", () => {
|
|
const env = environment();
|
|
|
|
expect(apiView(env).storage).toBeUndefined();
|
|
expect(doctorView(env).storage).toBeUndefined();
|
|
expect(apiView(env).mail).toBeUndefined();
|
|
expect(doctorView(env).mail).toBeUndefined();
|
|
});
|
|
|
|
it("carries every storage field the direct-delivery pass established", () => {
|
|
const env = environment({
|
|
...configuredInfrastructure,
|
|
S3_BUCKET_PREFIX: "rogc",
|
|
S3_DELIVERY_ENDPOINT: "https://files.example.org",
|
|
S3_FORCE_PATH_STYLE: "false",
|
|
S3_REGION: "eu-west-1",
|
|
});
|
|
|
|
expect(apiView(env).storage).toEqual(doctorView(env).storage);
|
|
expect(apiView(env).storage).toMatchObject({
|
|
bucketPrefix: "rogc",
|
|
deliveryEndpoint: "https://files.example.org",
|
|
forcePathStyle: false,
|
|
region: "eu-west-1",
|
|
});
|
|
});
|
|
|
|
it("rejects the same malformed environment with the same sanitized code", () => {
|
|
for (const [overrides, code] of [
|
|
[{ S3_ENDPOINT: "https://storage.example.org" }, "STORAGE_ACCESS_KEY_MISSING"],
|
|
[{ ...configuredInfrastructure, S3_ENDPOINT: "not-a-url" }, "STORAGE_ENDPOINT_INVALID"],
|
|
[
|
|
{ ...configuredInfrastructure, S3_ENDPOINT: "http://storage.example.org" },
|
|
"STORAGE_ENDPOINT_INSECURE_IN_PRODUCTION",
|
|
],
|
|
[
|
|
{ ...configuredInfrastructure, EKKLESIA_SMTP_PASSWORD: undefined },
|
|
"MAIL_USERNAME_WITHOUT_PASSWORD",
|
|
],
|
|
[{ ...configuredInfrastructure, EKKLESIA_SMTP_PORT: "70000" }, "MAIL_PORT_INVALID"],
|
|
[
|
|
{ ...configuredInfrastructure, EKKLESIA_SMTP_HELO_NAME: "localhost" },
|
|
"MAIL_HELO_NAME_INVALID",
|
|
],
|
|
] as const) {
|
|
const env = environment(overrides);
|
|
expect(() => apiView(env)).toThrowError(expect.objectContaining({ code }));
|
|
expect(() => doctorView(env)).toThrowError(expect.objectContaining({ code }));
|
|
}
|
|
});
|
|
|
|
it("resolves module secrets from the same source the runtime is given", () => {
|
|
const env = environment({
|
|
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL: "module-secret-material",
|
|
});
|
|
|
|
expect(
|
|
readConfigFromEnvironment(env).moduleSecrets.get("announcements", "storageCredential"),
|
|
).toBe("module-secret-material");
|
|
expect(loadHostConfiguration(env).moduleSecrets.get("announcements", "storageCredential")).toBe(
|
|
"module-secret-material",
|
|
);
|
|
});
|
|
|
|
it("reads only this listener's own address and port outside the shared loader", () => {
|
|
const config = apiView(
|
|
environment({ EKKLESIA_API_HOST: "0.0.0.0", EKKLESIA_API_PORT: "8080" }),
|
|
);
|
|
|
|
expect(config.host).toBe("0.0.0.0");
|
|
expect(config.port).toBe(8080);
|
|
// Defaults, when unset, stay where they always were.
|
|
const defaults = apiView(environment());
|
|
expect(defaults.host).toBe("127.0.0.1");
|
|
expect(defaults.port).toBe(3000);
|
|
});
|
|
|
|
it("interprets a set-but-empty flag identically instead of one side ignoring it", () => {
|
|
// Doctor used to drop any falsy identity value before validating, so `startApi` refused to
|
|
// start on `EKKLESIA_PUBLIC_SIGNUP_ENABLED=` while Doctor reported the deployment ready.
|
|
const env = environment({ EKKLESIA_PUBLIC_SIGNUP_ENABLED: "" });
|
|
|
|
expect(loadHostConfiguration(env).identity.publicSignupEnabled).toBe("");
|
|
expect(() => apiView(env)).toThrowError(
|
|
expect.objectContaining({ code: "PUBLIC_SIGNUP_ENABLED_INVALID" }),
|
|
);
|
|
});
|
|
});
|