Files
Ekklesia/tests/deploy.test.ts
T
ekklesiaandClaude Opus 5.5 2a64383af5 test(deploy): Doctor must check the client-address configuration the API runs
Behind the reference proxy the API runs in trusted-proxy mode, but
compose.proxy.yaml gives those variables to `api` only. The documented
Doctor command runs as `migrate`, so it validated an empty
configuration and reported "Direct client-address mode" for an
installation that trusts its proxy. That is what the first production
installation saw.

Fails today:
- deploy.test.ts: the proxy overlay gives `migrate` no client-address
  environment;
- doctor.test.ts: the check does not say whose configuration it read;
- smoke-deploy.sh: Doctor through the proxy overlay does not report
  trusted-proxy mode (Docker).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 18:38:29 -05:00

237 lines
9.7 KiB
TypeScript

import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
/**
* Structural guarantees of the reference deployment (ADR 0044), checked on the files themselves so
* an edit cannot quietly weaken them. `scripts/smoke-deploy.sh` proves the same configuration
* actually runs; this proves it still says what the documentation promises.
*/
interface ComposeService {
build?: unknown;
cap_add?: string[];
cap_drop?: string[];
command?: string[];
env_file?: string[];
environment?: Record<string, string>;
healthcheck?: { disable?: boolean };
image?: string;
init?: boolean;
networks?: Record<string, { ipv4_address?: string } | null>;
ports?: string[];
profiles?: string[];
read_only?: boolean;
security_opt?: string[];
tmpfs?: string[];
}
interface ComposeDocument {
networks?: Record<
string,
{ internal?: boolean; ipam?: { config?: { subnet?: string; ip_range?: string }[] } }
>;
services?: Record<string, ComposeService>;
}
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const deployDirectory = path.join(repositoryRoot, "deploy");
async function compose(file: string): Promise<ComposeDocument> {
// `merge: true` resolves the base file's `<<:` hardening anchors exactly as Compose does.
return parse(await readFile(path.join(deployDirectory, file), "utf8"), {
merge: true,
}) as ComposeDocument;
}
async function text(file: string): Promise<string> {
return readFile(path.join(deployDirectory, file), "utf8");
}
function envAssignments(source: string): Map<string, string> {
const assignments = new Map<string, string>();
for (const line of source.split("\n")) {
const match = /^([A-Z][A-Z0-9_]*)=(.*)$/u.exec(line);
if (match?.[1]) {
assignments.set(match[1], match[2] ?? "");
}
}
return assignments;
}
const DIGEST_PINNED = /^[^\s:@]+(?::[^\s@]+)?@sha256:[0-9a-f]{64}$/u;
describe("reference deployment compose files", () => {
it("keeps PostgreSQL on its internal network only, and publishes no port from the base", async () => {
const base = await compose("compose.yaml");
const services = base.services ?? {};
expect(Object.keys(services).sort()).toEqual(["api", "migrate", "object-storage", "postgres"]);
expect(Object.keys(services.postgres?.networks ?? {})).toEqual(["data"]);
expect(base.networks?.data?.internal).toBe(true);
expect(base.networks?.storage?.internal).toBe(true);
for (const [name, service] of Object.entries(services)) {
expect(service.ports, name).toBeUndefined();
}
for (const overlay of ["compose.proxy.yaml", "compose.tunnel.yaml"]) {
const document = await compose(overlay);
expect(document.services?.postgres, overlay).toBeUndefined();
}
});
it("pins every third-party image by digest and runs the API only from EKKLESIA_IMAGE", async () => {
const documents = await Promise.all(
["compose.yaml", "compose.proxy.yaml", "compose.tunnel.yaml"].map(compose),
);
for (const document of documents) {
for (const [name, service] of Object.entries(document.services ?? {})) {
expect(service.build, name).toBeUndefined();
if (service.image === undefined) {
continue;
}
if (name === "api" || name === "migrate") {
expect(service.image, name).toMatch(/^\$\{EKKLESIA_IMAGE:\?/u);
} else {
expect(service.image, name).toMatch(DIGEST_PINNED);
}
}
}
});
it("hardens api and migrate, keeps migrate operator-only, and never lets it apply by default", async () => {
const services = (await compose("compose.yaml")).services ?? {};
for (const name of ["api", "migrate"]) {
const service = services[name];
expect(service?.init, name).toBe(true);
expect(service?.read_only, name).toBe(true);
expect(service?.tmpfs, name).toEqual(["/tmp"]);
expect(service?.cap_drop, name).toEqual(["ALL"]);
expect(service?.security_opt, name).toContain("no-new-privileges:true");
expect(service?.environment?.NODE_EXTRA_CA_CERTS, name).toBe("/run/ekklesia/tls/ca.crt");
}
const migrate = services.migrate;
expect(migrate?.profiles).toEqual(["ops"]);
expect(migrate?.healthcheck?.disable).toBe(true);
expect(migrate?.command).toEqual(["node", "core/dist/cli/plan-migrations.js"]);
expect(JSON.stringify(services)).not.toContain("apply-migrations");
});
it("serves PostgreSQL and object storage over TLS from the installation-private CA", async () => {
const services = (await compose("compose.yaml")).services ?? {};
expect(services.postgres?.command).toEqual(
expect.arrayContaining([
"ssl=on",
"ssl_min_protocol_version=TLSv1.3",
"hba_file=/etc/ekklesia/pg_hba.conf",
]),
);
expect(services["object-storage"]?.command).toEqual(
expect.arrayContaining(["-s3.port.https=8443"]),
);
const hba = (await text("postgres/pg_hba.conf"))
.split("\n")
.filter((line) => line.trim() !== "" && !line.trim().startsWith("#"))
.map((line) => line.trim().split(/\s+/u));
expect(hba).toEqual([
["local", "all", "all", "trust"],
["hostssl", "all", "all", "all", "scram-sha-256"],
["hostnossl", "all", "all", "all", "reject"],
]);
});
it("makes the proxy the API's only trusted peer, at a fixed address outside the dynamic range", async () => {
const proxyOverlay = await compose("compose.proxy.yaml");
const proxy = proxyOverlay.services?.proxy;
const proxyAddress = proxy?.networks?.edge?.ipv4_address;
expect(proxyAddress).toBe("${EKKLESIA_PROXY_ADDRESS:-10.249.250.254}");
expect(proxyOverlay.services?.api?.environment).toEqual({
EKKLESIA_CLIENT_ADDRESS_HEADER: "x-forwarded-for",
EKKLESIA_CLIENT_ADDRESS_MODE: "trusted-proxy",
EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: `${proxyAddress}/32`,
});
// Doctor runs as `migrate`. Given anything else, it validates a configuration the API is not
// running, and reports "Direct" for an installation that trusts its proxy.
expect(proxyOverlay.services?.migrate?.environment).toEqual(
proxyOverlay.services?.api?.environment,
);
expect(proxyOverlay.networks?.edge?.ipam?.config).toEqual([
{
ip_range: "${EKKLESIA_EDGE_DYNAMIC_RANGE:-10.249.250.0/25}",
subnet: "${EKKLESIA_EDGE_SUBNET:-10.249.250.0/24}",
},
]);
expect(proxy?.cap_drop).toEqual(["ALL"]);
expect(proxy?.cap_add).toEqual(["NET_BIND_SERVICE"]);
const tunnel = (await compose("compose.tunnel.yaml")).services?.tunnel;
expect(tunnel?.networks?.edge?.ipv4_address).toBe("${EKKLESIA_TUNNEL_ADDRESS:-10.249.250.253}");
expect(tunnel?.env_file).toEqual(["${EKKLESIA_ENV_DIR:-/etc/ekklesia}/tunnel.env"]);
});
it("routes only the API's own paths, overwrites the forwarding header, and never ships local_certs", async () => {
const caddyfile = await text("Caddyfile.example");
const directives = caddyfile
.split("\n")
.filter((line) => !line.trim().startsWith("#"))
.join("\n");
expect(directives).toContain("@ekklesia path / /api/* /display/* /assets/*");
expect(directives).toContain("header_up X-Forwarded-For {client_ip}");
expect(directives).toContain("header_up -CF-Connecting-IP");
expect(directives).toContain("trusted_proxies static {$EKKLESIA_TUNNEL_ADDRESS}/32");
expect(directives).toContain("header_up Host {host}");
expect(directives).toContain("tls_trust_pool file /etc/caddy/ekklesia-ca.crt");
// Caddy's internal CA is a test-only substitution made by scripts/smoke-deploy.sh.
expect(directives).not.toContain("local_certs");
expect(directives).not.toMatch(/tls\s+internal/u);
});
});
describe("reference deployment env examples", () => {
it("contain no secret values", async () => {
const secretKeys = new Map([
["env/ekklesia.env.example", ["EKKLESIA_IDENTITY_SECRET", "S3_ACCESS_KEY", "S3_SECRET_KEY"]],
["env/postgres.env.example", ["POSTGRES_PASSWORD"]],
["env/object-storage.env.example", ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"]],
["env/tunnel.env.example", ["TUNNEL_TOKEN"]],
]);
for (const [file, keys] of secretKeys) {
const assignments = envAssignments(await text(file));
for (const key of keys) {
expect(assignments.get(key), `${file} ${key}`).toBe("");
}
}
const ekklesia = envAssignments(await text("env/ekklesia.env.example"));
expect(ekklesia.get("DATABASE_URL")).toBe(
"postgresql://<user>:<password>@postgres:5432/<database>",
);
expect(ekklesia.get("EKKLESIA_DATABASE_TLS_MODE")).toBe("verify-full");
expect(ekklesia.get("S3_ENDPOINT")).toBe("https://object-storage:8443");
});
it("names every variable Core's host configuration reads", async () => {
// The single place executables read the environment (core/src/host/configuration.ts). A new
// variable there without a line here would leave an operator guessing.
const hostConfiguration = await readFile(
path.join(repositoryRoot, "core", "src", "host", "configuration.ts"),
"utf8",
);
const read = new Set(
[...hostConfiguration.matchAll(/environment\.([A-Z][A-Z0-9_]+)/gu)].map((match) => match[1]),
);
const example = await text("env/ekklesia.env.example");
// Set by the image, not by an operator.
read.delete("NODE_ENV");
// Guards against a pattern that silently stops matching and checks nothing.
expect(read.size).toBeGreaterThan(20);
for (const name of read) {
expect(example, String(name)).toMatch(new RegExp(`^#? ?${name}=`, "mu"));
}
expect(example).toContain("EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>=");
});
});