// 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 // // 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. const mode = process.argv[2]; const hostname = required("SMOKE_HOSTNAME"); const origin = `https://${hostname}`; function required(name) { const value = process.env[name]; if (!value) { throw new Error(`${name} is required.`); } return value; } 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(`${origin}/api/auth/sign-in/email`, { body: JSON.stringify({ email: "nobody@example.test", password: "definitely-not-the-password" }), headers: { "content-type": "application/json", origin, ...headers }, method: "POST", }); await response.arrayBuffer(); return response.status; } async function edgeChecks() { const ready = await fetch(`${origin}/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(`${origin}/`); 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", ); const script = await fetch(`${origin}/assets/ekklesia-dashboard/app.js`); await script.arrayBuffer(); check(script.status === 200, "GET /assets/ekklesia-dashboard/app.js is 200"); const outside = await fetch(`${origin}/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(`${origin}/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", ); 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(",")})`, ); } 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"); } 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(`${origin}${path}`, { method: path.endsWith("get-session") || path.endsWith("list-sessions") ? "GET" : "POST", headers: { origin }, }); check( response.status === 404 && (await response.text()) === '{"code":"NOT_FOUND"}', `${path} is unavailable through the proxy`, ); } const signIn = await fetch(`${origin}/api/auth/sign-in/email`, { body: JSON.stringify({ email: required("SMOKE_OWNER_EMAIL"), password: required("SMOKE_OWNER_PASSWORD"), }), headers: { "content-type": "application/json", origin }, 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(`${origin}/api/v1/principal`, { headers: { cookie, origin } }); 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(`${origin}/api/auth/sign-out`, { body: "{}", headers: { "content-type": "application/json", cookie, origin }, method: "POST", }); await signOut.arrayBuffer(); const after = await fetch(`${origin}/api/v1/principal`, { headers: { cookie, origin } }); check( signOut.status === 200 && (await after.json()).kind === "anonymous", "sign-out ends the session", ); } if (mode === "edge") { await edgeChecks(); } else if (mode === "tunnel") { await tunnelChecks(); } else if (mode === "session") { await sessionChecks(); } else { throw new Error('Mode must be "edge", "tunnel", or "session".'); } if (failures > 0) { console.log(`${failures} check(s) failed.`); process.exit(1); }