// 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: "nobody@example.test", 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(``), "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); }