CI / verify (pull_request) Failing after 47s
CI / infrastructure-smoke (pull_request) Skipped
CI / migration-smoke (pull_request) Skipped
CI / image-build (pull_request) Skipped
CI / deploy-smoke (same-origin) (pull_request) Skipped
CI / deploy-smoke (split) (pull_request) Skipped
The production image now contains zero feature modules and is the same for every installation. Installed modules live in module storage that one Core resolver selects from EKKLESIA_MODULES_DIR: the checkout's modules/ when unset, an absolute path that must exist when set (/var/lib/ekklesia/modules in the image). A relative, blank, missing, or unreadable configured directory fails closed with a sanitized error that never contains the value. - Host configuration reads EKKLESIA_MODULES_DIR. The API, migration plan, apply and validate, module validation, module enablement, and Doctor all resolve it the same way; the test-only API override still takes precedence. - Doctor receives Core's migrations directory explicitly instead of deriving it from the modules directory's parent. - The migration runner retains and reports the applied history of a module that is not installed, and plans and applies Core's and present modules' migrations around it. Core's history and every installed module's history stay strict: a missing or changed applied migration, or a downgrade, still fails. No down migrations, no data deletion. - deploy/Dockerfile copies no module and installs nothing for the module template; .dockerignore excludes modules/ and the build refuses a context that contains it; the runtime has an empty root-owned /var/lib/ekklesia/modules and sets EKKLESIA_MODULES_DIR. - deploy/compose.yaml bind-mounts EKKLESIA_MODULES_HOST_DIR read-only into api and migrate, with create_host_path disabled. - scripts/check-image.sh proves the image holds no modules and that a populated modules/ directory leaves its runtime file system identical; CI runs it. - The split deploy smoke now uses the same generic image: it boots with no modules, installs a self-contained test module packaged from the template (test-only), migrates and restarts, removes it (Core runs, planning reports the retained history, data survives), and restores it with its data. Docs: deploy README (module storage, install/update/remove/restore, the runtime modules target), .env.example, API configuration, module development guide, testing, workflow, security, and the architecture overview. Co-Authored-By: Claude Opus 5.5 <[email protected]>
211 lines
9.2 KiB
TypeScript
211 lines
9.2 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 the configured modules directory verbatim, and nothing when it is unset", () => {
|
|
expect(loadHostConfiguration({}).modulesDirectory).toBeUndefined();
|
|
expect(
|
|
loadHostConfiguration({ EKKLESIA_MODULES_DIR: "/var/lib/ekklesia/modules" }).modulesDirectory,
|
|
).toBe("/var/lib/ekklesia/modules");
|
|
// Validation is the resolver's job (ADR 0048). The loader neither repairs a relative or blank
|
|
// value nor treats a set-but-empty variable as unset, so the resolver can refuse it.
|
|
expect(
|
|
loadHostConfiguration({ EKKLESIA_MODULES_DIR: "relative/modules" }).modulesDirectory,
|
|
).toBe("relative/modules");
|
|
expect(loadHostConfiguration({ EKKLESIA_MODULES_DIR: "" }).modulesDirectory).toBe("");
|
|
});
|
|
|
|
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",
|
|
});
|
|
});
|
|
});
|