CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 45s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m34s
CI / image-build (pull_request) Successful in 1m8s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 3m52s
Displays are platform-owned, but a screen could only start pairing on a
module's display page: every pairing request named a module, so an
installation with no module that uses displays could not set one up.
- /display/setup is the platform's own display page, served whenever
the deployment has device support, whatever is installed. It pairs a
platform display with no module. Once paired, it opens the module it
is set to show; with none set, it shows its name and waits, rechecking
so it stays alive and notices revocation. "setup" is now a reserved
module id.
- Migration core:0009_platform_display_pairing lets a pairing request
name no module. It relaxes module_id and permission_id and adds a
both-or-neither check. It is additive and destroys nothing.
- POST /api/v1/device-pairing/requests accepts {} for a platform
display. Approval accepts an optional moduleId, so the administrator
can choose an installed display module and the device gets that
module's declared permission. A module display's own request can't be
redirected.
- GET /api/v1/device-pairing/self lets a paired device describe itself:
its name and the module displays it may show. It renews the device's
cookie.
- GET /api/v1/devices/setup is replaced by
GET /api/v1/devices/display-modules: the modules a display can be set
to show.
- Settings -> Displays always offers the /display/setup address, Copy
setup link and Add display. Add display has an optional "Show on this
screen" choice, and each paired display says what it shows.
- /display/<module-id> is unchanged, so released Signage 0.1.5 works as
before.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
660 lines
26 KiB
JavaScript
660 lines
26 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"') &&
|
|
html.includes('<script src="/assets/ekklesia-dashboard/appearance.js"></script>'),
|
|
"GET / is the Dashboard HTML, with its pre-paint appearance script (ADR 0062)",
|
|
);
|
|
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)",
|
|
);
|
|
|
|
// Every Dashboard page answers with that same page, so a refresh stays put (ADR 0057). No module
|
|
// is installed yet at this point; a module's own address is reloaded in a real browser later.
|
|
for (const deepLink of [
|
|
"/settings",
|
|
"/settings/displays/add",
|
|
"/account",
|
|
"/account/appearance",
|
|
]) {
|
|
const deepPage = await fetch(`${dashboardOrigin}${deepLink}`);
|
|
check(
|
|
deepPage.status === 200 &&
|
|
deepPage.headers.get("etag") === page.headers.get("etag") &&
|
|
(await deepPage.text()) === html,
|
|
`GET ${deepLink} through the proxy is the same Dashboard page`,
|
|
);
|
|
}
|
|
// A top-level name shaped like a module id reaches the API, which knows the installed modules and
|
|
// answers anything that is not a Dashboard page with its sanitized 404 — never the page.
|
|
for (const unknown of ["/definitely-not-a-real-module", "/settings-old", "/modules/example"]) {
|
|
const response = await fetch(`${dashboardOrigin}${unknown}`);
|
|
check(
|
|
response.status === 404 && (await response.text()) === '{"code":"NOT_FOUND"}',
|
|
`GET ${unknown} through the proxy is the API's sanitized 404, not a Dashboard page`,
|
|
);
|
|
}
|
|
|
|
// ADR 0065: a new screen can start pairing at the platform's own setup page whatever is
|
|
// installed — in the same-origin smoke, with no module at all — and asks to be paired with no
|
|
// module, as a platform display.
|
|
const setupPage = await fetch(`${apiOrigin}/display/setup`);
|
|
const setupHtml = await setupPage.text();
|
|
check(
|
|
setupPage.status === 200 &&
|
|
setupPage.headers.get("cache-control") === "no-store" &&
|
|
setupHtml.includes('id="ekklesia-display"'),
|
|
"GET /display/setup through the proxy is the platform display setup page",
|
|
);
|
|
const platformPairing = await fetch(`${apiOrigin}/api/v1/device-pairing/requests`, {
|
|
body: "{}",
|
|
headers: { "content-type": "application/json", origin: apiOrigin },
|
|
method: "POST",
|
|
});
|
|
const platformPairingBody = await platformPairing.json().catch(() => ({}));
|
|
check(
|
|
platformPairing.status === 201 &&
|
|
/^[0-9]{6}$/u.test(String(platformPairingBody.code)) &&
|
|
/HttpOnly/iu.test(platformPairing.headers.get("set-cookie") ?? ""),
|
|
"a screen at /display/setup can ask to be paired as a platform display, with no module",
|
|
);
|
|
|
|
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 appearanceScript = await fetch(
|
|
`${dashboardOrigin}/assets/ekklesia-dashboard/appearance.js`,
|
|
);
|
|
const appearanceSource = await appearanceScript.text();
|
|
check(
|
|
appearanceScript.status === 200 &&
|
|
appearanceScript.headers.get("content-type") === "text/javascript; charset=utf-8" &&
|
|
appearanceSource.includes("ekklesia.dashboard.appearance"),
|
|
"GET /assets/ekklesia-dashboard/appearance.js is the pre-paint script",
|
|
);
|
|
|
|
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}/settings/displays`),
|
|
"the API hostname serves no Dashboard page either",
|
|
);
|
|
check(
|
|
await refusedByProxy(`${apiOrigin}/assets/ekklesia-dashboard/app.js`),
|
|
"the API hostname refuses the Dashboard's script",
|
|
);
|
|
check(
|
|
await refusedByProxy(`${apiOrigin}/assets/ekklesia-dashboard/appearance.js`),
|
|
"the API hostname refuses the Dashboard's pre-paint 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",
|
|
);
|
|
|
|
// The person's own account (ADR 0062): who they are, and a preference that round-trips.
|
|
const account = await fetch(`${apiOrigin}/api/v1/account`, {
|
|
headers: { cookie, origin: dashboardOrigin },
|
|
});
|
|
const accountBody = await account.json();
|
|
check(
|
|
account.status === 200 &&
|
|
accountBody.email === required("SMOKE_OWNER_EMAIL") &&
|
|
Object.keys(accountBody).sort().join(",") === "email,name,preferences",
|
|
"GET /api/v1/account returns the signed-in person's own name, email, and preferences only",
|
|
);
|
|
const changePreferences = async (body) => {
|
|
const response = await fetch(`${apiOrigin}/api/v1/account/preferences`, {
|
|
body: JSON.stringify(body),
|
|
headers: { "content-type": "application/json", cookie, origin: dashboardOrigin },
|
|
method: "PATCH",
|
|
});
|
|
return { body: await response.json(), status: response.status };
|
|
};
|
|
const saved = await changePreferences({ motion: "reduce" });
|
|
check(
|
|
saved.status === 200 && saved.body.motion === "reduce",
|
|
"PATCH /api/v1/account/preferences saves a change to the person's own account",
|
|
);
|
|
const restored = await changePreferences(accountBody.preferences);
|
|
check(restored.status === 200, "the account's previous preferences are put back");
|
|
const anonymousAccount = await fetch(`${apiOrigin}/api/v1/account`, {
|
|
headers: { origin: dashboardOrigin },
|
|
});
|
|
await anonymousAccount.arrayBuffer();
|
|
check(anonymousAccount.status === 403, "an anonymous caller is refused the account");
|
|
|
|
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`);
|
|
}
|
|
|
|
// The module's own Dashboard address (ADR 0057) exists exactly while the module is installed with a
|
|
// Dashboard UI — disabled or not: the Dashboard page when it is, the API's sanitized 404 when it is
|
|
// not. A disabled module is not running, but its address still opens the Dashboard.
|
|
const pageExpectation =
|
|
optional("SMOKE_MODULE_PAGE") || (expectation === "absent" ? "absent" : "served");
|
|
const modulePage = await fetch(`${dashboardOrigin}/${moduleId}`);
|
|
const modulePageBody = await modulePage.text();
|
|
if (pageExpectation === "absent") {
|
|
check(
|
|
modulePage.status === 404 && modulePageBody === '{"code":"NOT_FOUND"}',
|
|
`GET /${moduleId} is the sanitized 404 while the module is not installed`,
|
|
);
|
|
} else {
|
|
check(
|
|
modulePage.status === 200 && modulePageBody.includes('id="ekklesia-host"'),
|
|
`GET /${moduleId} through the proxy is the Dashboard page while the module is installed`,
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|