Files
Ekklesia/scripts/lib/deploy-smoke-client.mjs
ekklesia fa27603133
CI / verify (pull_request) Successful in 1m13s
CI / infrastructure-smoke (pull_request) Successful in 40s
CI / migration-smoke (pull_request) Successful in 1m56s
CI / package-release (pull_request) Successful in 37s
CI / image-build (pull_request) Successful in 54s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 2m22s
CI / package-publication (pull_request) Successful in 55s
test(deploy): exercise external module packages
2026-10-09 22:18:04 -05:00

539 lines
21 KiB
JavaScript

// HTTP assertions for scripts/smoke-deploy.sh, run inside a disposable container on the reference
// deployment's `edge` network: every request goes through the proxy by its public hostname, exactly
// as a browser's would. Not a test of the API in isolation, and never run against a real
// installation.
//
// node deploy-smoke-client.mjs edge an ordinary, untrusted peer of the proxy
// node deploy-smoke-client.mjs tunnel the Cloudflare Tunnel connector's fixed address
// node deploy-smoke-client.mjs session the initial owner signing in and out through the proxy
// node deploy-smoke-client.mjs module the test module `example` as the initial owner sees it:
// SMOKE_MODULE_EXPECT is "absent", or a JSON array of the
// exact values its records route must return (ADR 0048)
//
// Either topology (ADR 0045): SMOKE_HOSTNAME is the API's hostname, and SMOKE_DASHBOARD_HOSTNAME,
// when set, the Dashboard's. Unset means the same-origin topology, where both are one hostname. Every
// request goes to the API's origin and carries the Dashboard's origin as `Origin`, as a browser would.
//
// Output is one line per check; any failed check exits non-zero. Nothing printed carries a
// credential, cookie, signed URL, or response body beyond the fixed shapes asserted here.
import { connect as connectHttp2 } from "node:http2";
const mode = process.argv[2];
const apiOrigin = `https://${required("SMOKE_HOSTNAME")}`;
const dashboardOrigin = process.env.SMOKE_DASHBOARD_HOSTNAME
? `https://${process.env.SMOKE_DASHBOARD_HOSTNAME}`
: apiOrigin;
const split = dashboardOrigin !== apiOrigin;
const untrustedOrigin = "https://untrusted.smoke.test";
function required(name) {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required.`);
}
return value;
}
function optional(name) {
return process.env[name] || "";
}
let failures = 0;
function check(condition, description) {
console.log(`${condition ? "ok " : "FAIL"} ${description}`);
if (!condition) {
failures += 1;
}
}
/** A sign-in attempt that can only fail authentication; what matters is whether the API's
* per-client limiter (3 attempts per 10 seconds) counts it against the expected client. */
async function signInAttempt(headers = {}) {
const response = await fetch(`${apiOrigin}/api/auth/sign-in/email`, {
body: JSON.stringify({ email: "[email protected]", password: "definitely-not-the-password" }),
headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers },
method: "POST",
});
await response.arrayBuffer();
return response.status;
}
async function edgeChecks() {
const ready = await fetch(`${apiOrigin}/api/v1/health/ready`);
check(
ready.status === 200 && (await ready.text()) === '{"status":"ready"}',
"GET /api/v1/health/ready through the proxy is 200 ready",
);
const page = await fetch(`${dashboardOrigin}/`);
const html = await page.text();
check(page.status === 200, "GET / through the proxy is 200");
check(
page.headers.get("content-type") === "text/html; charset=utf-8" &&
html.includes('id="ekklesia-host"') &&
html.includes('src="/assets/ekklesia-dashboard/app.js"'),
"GET / is the Dashboard HTML",
);
check(
page.headers.get("x-frame-options") === "DENY" &&
page.headers.get("content-security-policy") === "frame-ancestors 'none'" &&
page.headers.get("referrer-policy") === "no-referrer",
"the Dashboard page keeps its security headers through the proxy",
);
check(
html.includes(`<meta content="${apiOrigin}" name="ekklesia-api-origin" />`),
"the Dashboard page names the API's origin, rendered at run time (ADR 0045)",
);
const script = await fetch(`${dashboardOrigin}/assets/ekklesia-dashboard/app.js`);
await script.arrayBuffer();
check(script.status === 200, "GET /assets/ekklesia-dashboard/app.js is 200");
const outside = await fetch(`${dashboardOrigin}/favicon.ico`);
check(
outside.status === 404 && !(outside.headers.get("content-type") ?? "").includes("json"),
"a path the API does not serve is refused by the proxy, never forwarded",
);
const unknownApi = await fetch(`${apiOrigin}/api/v1/does-not-exist`);
check(
unknownApi.status === 404 && (await unknownApi.text()) === '{"code":"NOT_FOUND"}',
"an unknown /api/* path reaches the API and gets its sanitized 404",
);
if (split) {
await splitRoutingChecks();
await splitCorsChecks();
}
await storageDeliveryCheck();
// Every caller-supplied forwarding header rotates on every attempt. If any of them reached the
// API's client-address resolution, each attempt would land in a fresh limiter bucket and the
// fourth would not be limited.
const statuses = [];
for (let attempt = 1; attempt <= 4; attempt += 1) {
statuses.push(
await signInAttempt({
"cf-connecting-ip": `198.51.100.${attempt}`,
forwarded: `for=198.51.100.${attempt + 10}`,
"true-client-ip": `198.51.100.${attempt + 20}`,
"x-ekklesia-client-address": `198.51.100.${attempt + 50}`,
"x-forwarded-for": `198.51.100.${attempt + 30}`,
"x-real-ip": `198.51.100.${attempt + 40}`,
}),
);
}
check(
statuses.slice(0, 3).every((status) => status !== 429 && status < 500) && statuses[3] === 429,
`spoofed forwarding headers do not choose the client address (statuses ${statuses.join(",")})`,
);
}
/** A proxy refusal: Caddy's own empty `404`, never the API's JSON one, so the request was not
* forwarded at all. */
async function refusedByProxy(url) {
const response = await fetch(url);
const body = await response.text();
return response.status === 404 && body === "";
}
/** Each hostname serves exactly its own paths (ADR 0045, "Routing per hostname"). */
async function splitRoutingChecks() {
check(
await refusedByProxy(`${dashboardOrigin}/api/v1/health/ready`),
"the Dashboard hostname refuses /api/*, so the session cookie has one home",
);
check(
await refusedByProxy(`${dashboardOrigin}/display/example`),
"the Dashboard hostname refuses /display/*",
);
check(await refusedByProxy(`${apiOrigin}/`), "the API hostname serves nothing at /");
check(
await refusedByProxy(`${apiOrigin}/assets/ekklesia-dashboard/app.js`),
"the API hostname refuses the Dashboard's script",
);
const display = await fetch(`${apiOrigin}/display/does-not-exist`);
check(
display.status === 404 && (await display.text()) === '{"code":"NOT_FOUND"}',
"the API hostname forwards /display/* to the API (the display host's origin)",
);
}
/** Credentialed CORS for exactly the Dashboard's origin, and for nothing else. */
async function splitCorsChecks() {
const preflight = await fetch(`${apiOrigin}/api/auth/sign-in/email`, {
headers: {
"access-control-request-headers": "content-type",
"access-control-request-method": "POST",
origin: dashboardOrigin,
},
method: "OPTIONS",
});
await preflight.arrayBuffer();
check(
preflight.status === 204 &&
preflight.headers.get("access-control-allow-origin") === dashboardOrigin &&
preflight.headers.get("access-control-allow-credentials") === "true",
"a preflight from the Dashboard's origin is allowed, with credentials",
);
const foreignPreflight = await fetch(`${apiOrigin}/api/auth/sign-in/email`, {
headers: { "access-control-request-method": "POST", origin: untrustedOrigin },
method: "OPTIONS",
});
await foreignPreflight.arrayBuffer();
check(
foreignPreflight.headers.get("access-control-allow-origin") === null,
"a preflight from any other origin gets no CORS permission",
);
const principal = await fetch(`${apiOrigin}/api/v1/principal`, {
headers: { origin: dashboardOrigin },
});
await principal.arrayBuffer();
check(
principal.headers.get("access-control-allow-origin") === dashboardOrigin &&
principal.headers.get("access-control-allow-credentials") === "true" &&
(principal.headers.get("vary") ?? "").includes("Origin"),
"an API response to the Dashboard's origin reflects exactly that origin, with credentials",
);
}
/**
* One GET over a real HTTP/2 connection. Node's fetch speaks HTTP/1.1, and the failure this guards
* against exists only on HTTP/2: a reset stream, which a browser reports as ERR_HTTP2_PROTOCOL_ERROR.
*/
function http2Get(url, headers = {}) {
return new Promise((resolve) => {
const session = connectHttp2(url.origin);
const finish = (result) => {
clearTimeout(timer);
session.close();
resolve(result);
};
const timer = setTimeout(() => finish({ error: "timeout" }), 10_000);
session.on("error", () => finish({ error: "session" }));
const stream = session.request({
":method": "GET",
":path": `${url.pathname}${url.search}`,
...headers,
});
let status;
let responseHeaders = {};
let bytes = 0;
stream.on("response", (received) => {
status = received[":status"];
responseHeaders = received;
});
stream.on("data", (chunk) => {
bytes += chunk.length;
});
stream.on("error", (error) => finish({ error: error.code ?? "stream" }));
stream.on("end", () => finish({ bytes, headers: responseHeaders, status }));
stream.end();
});
}
/** The CORS headers of a response, by name: what a browser would act on. */
function corsOf(response) {
return [...response.headers.keys()].filter((name) => name.startsWith("access-control-"));
}
async function storageDeliveryCheck() {
// Core's own provider, from the image, exactly as the API composes it: it reaches object-storage
// over HTTPS on the private network and signs read URLs for the storage hostname.
const { createS3StorageProvider, createStorageProviderConfig } = await import(
"/opt/ekklesia/core/dist/storage/index.js"
);
const config = createStorageProviderConfig(
{
accessKey: required("S3_ACCESS_KEY"),
bucketPrefix: "ekklesia-smoke",
deliveryEndpoint: `https://${required("SMOKE_STORAGE_HOSTNAME")}`,
endpoint: "https://object-storage:8443",
forcePathStyle: true,
region: "us-east-1",
secretKey: required("S3_SECRET_KEY"),
},
{ environment: "production" },
);
const provider = createS3StorageProvider(config);
try {
const bucket = "ekklesia-smoke-delivery";
const body = new TextEncoder().encode("delivered through the proxy\n");
await provider.ensureBucket(bucket);
await provider.put(bucket, "smoke/object.txt", body, {
contentLength: body.byteLength,
contentType: "text/plain",
});
const readUrl = await provider.createReadUrl(bucket, "smoke/object.txt", {
expiresInSeconds: 300,
});
const url = new URL(readUrl.url);
check(
url.origin === `https://${required("SMOKE_STORAGE_HOSTNAME")}`,
"a signed read URL names the storage hostname",
);
const delivered = await fetch(url);
const text = await delivered.text();
check(
delivered.status === 200 && text === "delivered through the proxy\n",
"a signed read URL verifies through the proxy (original Host preserved)",
);
check(
(delivered.headers.get("cache-control") ?? "").startsWith("private"),
"a delivered object is Cache-Control: private",
);
await provider.delete(bucket, "smoke/object.txt");
// The same image, twice, over HTTP/2: a browser's second load of a signed URL it has cached is a
// conditional request, and must come back as a clean 304, not a reset stream.
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"base64",
);
await provider.put(bucket, "smoke/image.png", png, {
contentLength: png.byteLength,
contentType: "image/png",
});
const image = new URL(
(await provider.createReadUrl(bucket, "smoke/image.png", { expiresInSeconds: 300 })).url,
);
const first = await http2Get(image);
check(
first.status === 200 && first.bytes === png.byteLength && Boolean(first.headers?.etag),
"an image loads over HTTP/2 through the proxy, with a validator (200, ETag)",
);
const revalidated = await http2Get(image, { "if-none-match": first.headers?.etag ?? "" });
check(
revalidated.status === 304 && revalidated.error === undefined,
`the same image loaded again over HTTP/2 revalidates cleanly (${revalidated.status ?? revalidated.error})`,
);
const sinceModified = await http2Get(image, {
"if-modified-since": first.headers?.["last-modified"] ?? "",
});
check(
sinceModified.status === 304 && sinceModified.error === undefined,
`a revalidation by date over HTTP/2 is a clean 304 too (${sinceModified.status ?? sinceModified.error})`,
);
// Delivery-layer CORS is the deployment's own, set by the proxy: exactly the origins whose pages
// read objects with fetch(), GET and HEAD only, and nothing of the object store's own answer.
const fromApi = await fetch(image, { headers: { origin: apiOrigin } });
check(
fromApi.headers.get("access-control-allow-origin") === apiOrigin &&
(fromApi.headers.get("vary") ?? "").toLowerCase().includes("origin") &&
corsOf(fromApi).length === 1,
"a read from the API origin is allowed for exactly that origin, with Vary: Origin and nothing else",
);
if (split) {
const fromDashboard = await fetch(image, { headers: { origin: dashboardOrigin } });
check(
fromDashboard.headers.get("access-control-allow-origin") === dashboardOrigin,
"a read from the Dashboard origin is allowed for exactly that origin",
);
}
const fromElsewhere = await fetch(image, { headers: { origin: untrustedOrigin } });
check(
fromElsewhere.status === 200 && corsOf(fromElsewhere).length === 0,
"a read from any other origin gets no CORS header at all",
);
const preflight = await fetch(image, {
headers: { "access-control-request-method": "GET", origin: apiOrigin },
method: "OPTIONS",
});
check(
preflight.status === 204 &&
preflight.headers.get("access-control-allow-origin") === apiOrigin &&
preflight.headers.get("access-control-allow-methods") === "GET, HEAD" &&
preflight.headers.get("x-amz-request-id") === null,
"the proxy answers a preflight itself (204, GET and HEAD only), never the object store",
);
const foreignPreflight = await fetch(image, {
headers: { "access-control-request-method": "GET", origin: untrustedOrigin },
method: "OPTIONS",
});
check(
foreignPreflight.status === 204 && corsOf(foreignPreflight).length === 0,
"a preflight from any other origin is answered without permission",
);
await provider.delete(bucket, "smoke/image.png");
} finally {
await provider.close();
}
}
async function tunnelChecks() {
// From the connector's fixed address, CF-Connecting-IP is the client: one visitor is limited on
// its fourth attempt, and a different visitor is not limited by the first one's attempts.
const first = [];
for (let attempt = 1; attempt <= 4; attempt += 1) {
first.push(await signInAttempt({ "cf-connecting-ip": "203.0.113.10" }));
}
check(
first.slice(0, 3).every((status) => status !== 429 && status < 500) && first[3] === 429,
`the connector's CF-Connecting-IP identifies the visitor (statuses ${first.join(",")})`,
);
const second = await signInAttempt({ "cf-connecting-ip": "203.0.113.11" });
check(
second !== 429 && second < 500,
`a second visitor behind the connector has its own limiter bucket (status ${second})`,
);
}
/** The deployed browser authentication contract (launch checklist item 9), as the initial owner
* created by the image's own CLI (item 10). */
async function sessionChecks() {
for (const path of [
"/api/auth/get-session",
"/api/auth/list-sessions",
"/api/auth/update-session",
"/api/auth/change-password",
]) {
const response = await fetch(`${apiOrigin}${path}`, {
method: path.endsWith("get-session") || path.endsWith("list-sessions") ? "GET" : "POST",
headers: { origin: dashboardOrigin },
});
check(
response.status === 404 && (await response.text()) === '{"code":"NOT_FOUND"}',
`${path} is unavailable through the proxy`,
);
}
const signIn = await fetch(`${apiOrigin}/api/auth/sign-in/email`, {
body: JSON.stringify({
email: required("SMOKE_OWNER_EMAIL"),
password: required("SMOKE_OWNER_PASSWORD"),
}),
headers: { "content-type": "application/json", origin: dashboardOrigin },
method: "POST",
});
const signInBody = await signIn.text();
const setCookie = signIn.headers.get("set-cookie") ?? "";
check(
signIn.status === 200 && signInBody === '{"ok":true}',
'sign-in returns the fixed {"ok":true}',
);
const attributes = setCookie.toLowerCase();
check(
attributes.includes("httponly") &&
attributes.includes("secure") &&
attributes.includes("samesite=lax") &&
!attributes.includes("domain="),
"the session cookie is host-only, HttpOnly, Secure, and SameSite=Lax",
);
const cookie = setCookie.split(";")[0] ?? "";
const principal = await fetch(`${apiOrigin}/api/v1/principal`, {
headers: { cookie, origin: dashboardOrigin },
});
const principalBody = await principal.json();
check(
principal.status === 200 && principalBody.kind === "authenticated",
"the session resolves an authenticated principal through the proxy",
);
const signOut = await fetch(`${apiOrigin}/api/auth/sign-out`, {
body: "{}",
headers: { "content-type": "application/json", cookie, origin: dashboardOrigin },
method: "POST",
});
await signOut.arrayBuffer();
const after = await fetch(`${apiOrigin}/api/v1/principal`, {
headers: { cookie, origin: dashboardOrigin },
});
check(
signOut.status === 200 && (await after.json()).kind === "anonymous",
"sign-out ends the session",
);
}
/** Signs the initial owner in, through the proxy, and returns the session cookie. */
async function ownerCookie() {
const signIn = await fetch(`${apiOrigin}/api/auth/sign-in/email`, {
body: JSON.stringify({
email: required("SMOKE_OWNER_EMAIL"),
password: required("SMOKE_OWNER_PASSWORD"),
}),
headers: { "content-type": "application/json", origin: dashboardOrigin },
method: "POST",
});
await signIn.arrayBuffer();
check(signIn.status === 200, "the initial owner signs in");
return (signIn.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
}
/**
* The installed-module checks (ADR 0048): whether the selected module is in the UI inventory and,
* when one is supplied, whether its module-owned probe route responds. Core's own `example` fixture
* retains its stronger records assertion, which also proves its database facade.
*/
async function moduleChecks() {
const expectation = required("SMOKE_MODULE_EXPECT");
const moduleId = optional("SMOKE_MODULE_ID") || "example";
const probePath = optional("SMOKE_MODULE_PROBE_PATH");
const cookie = await ownerCookie();
const headers = { cookie, origin: dashboardOrigin };
const inventory = await fetch(`${apiOrigin}/api/v1/module-ui`, { headers });
const inventoryBody = await inventory.json();
const installed =
inventory.status === 200 && inventoryBody.modules.some((module) => module.id === moduleId);
if (expectation === "absent") {
check(
inventory.status === 200 && !installed,
`the module inventory has no \`${moduleId}\` module`,
);
} else {
check(installed, `the module inventory lists the \`${moduleId}\` module`);
}
if (moduleId === "example") {
const records = await fetch(`${apiOrigin}/api/v1/modules/example/records`, { headers });
if (expectation === "absent") {
check(
records.status === 404 && (await records.text()) === '{"code":"NOT_FOUND"}',
"the absent module's routes are not mounted",
);
} else {
const expected = JSON.parse(expectation);
const values = records.status === 200 ? (await records.json()).map((row) => row.value) : null;
check(
JSON.stringify(values) === JSON.stringify(expected),
`the module's records route returns ${JSON.stringify(expected)}`,
);
}
} else if (expectation !== "absent" && probePath) {
const probe = await fetch(`${apiOrigin}${probePath}`, { headers });
await probe.arrayBuffer();
check(probe.status === 200, `the module probe ${probePath} responds`);
}
const signOut = await fetch(`${apiOrigin}/api/auth/sign-out`, {
body: "{}",
headers: { ...headers, "content-type": "application/json" },
method: "POST",
});
await signOut.arrayBuffer();
}
if (mode === "edge") {
await edgeChecks();
} else if (mode === "tunnel") {
await tunnelChecks();
} else if (mode === "session") {
await sessionChecks();
} else if (mode === "module") {
await moduleChecks();
} else {
throw new Error('Mode must be "edge", "tunnel", "session", or "module".');
}
if (failures > 0) {
console.log(`${failures} check(s) failed.`);
process.exit(1);
}