import assert from "node:assert/strict"; import { spawn, spawnSync } from "node:child_process"; import { randomUUID } from "node:crypto"; import http from "node:http"; import { createRequire } from "node:module"; import { mkdtemp, mkdir, writeFile, symlink, rm } from "node:fs/promises"; import net from "node:net"; import { tmpdir } from "node:os"; import { resolve, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { test } from "node:test"; test("embedded dashboard starts real Vite and authenticates shutdown", { timeout: 180000 }, async (t) => { const root = resolve(fileURLToPath(new URL("..", import.meta.url))); const reservation = net.createServer(); await new Promise(resolve => reservation.listen(0, "127.0.0.1", resolve)); const port = reservation.address().port; await new Promise(resolve => reservation.close(resolve)); const session = randomUUID(); const dependencies = randomUUID(); const child = spawn(process.execPath, [resolve(root, "firecrab-cli/src/micromanager/dev/dashboard.mjs"), root, String(port)], { cwd: tmpdir(), env: { ...process.env, FIRECRAB_DEV_SESSION: session, FIRECRAB_DEV_DEPENDENCIES: dependencies }, stdio: ["ignore", "pipe", "pipe"], }); const exited = new Promise(resolve => child.once("exit", (code) => resolve(code))); let logs = ""; child.stdout.on("data", value => logs += value); child.stderr.on("data", value => logs += value); t.after(() => { if (child.exitCode === null) child.kill(); }); const url = `http://127.0.0.1:${port}`; const headers = { "X-Firecrab-Dev-Session": session }; let ready = false; for (let attempt = 0; attempt < 1500; attempt++) { if (child.exitCode !== null) throw new Error(logs); try { const response = await fetch(`${url}/__firecrab_dev/`, { headers }); if (response.headers.get("content-type")?.includes("application/json")) { assert.deepEqual(await response.json(), { source: root, session, dependencies, port, api_port: 5523, resource_port: 5524, isolated: false }); ready = true; break; } } catch {} await new Promise(resolve => setTimeout(resolve, 100)); } assert.ok(ready, `Vite did not answer the ownership endpoint: ${logs}`); const page = await fetch(url); assert.equal(page.status, 200); const html = await page.text(); assert.match(html, /name="firecrab-resource-endpoint" content="\/__firecrab_resources\/api\/service\/resources"/); assert.ok(!html.includes(session), "public bootstrap must not expose the ownership credential"); assert.equal(page.headers.get("content-security-policy"), null); assert.equal((await fetch(`${url}/@vite/client`)).status, 200); const terminal = await fetch(`${url}/src/components/Console.tsx`); assert.equal(terminal.status, 200, await terminal.text()); const main = await fetch(`${url}/src/main.tsx`); assert.equal(main.status, 200, await main.text()); assert.equal((await fetch(`${url}/__firecrab_dev/stop`, { method: "POST" })).status, 403); assert.equal((await fetch(`${url}/__firecrab_dev/stop`, { method: "POST", headers: { "X-Firecrab-Dev-Session": randomUUID() } })).status, 403); assert.equal((await fetch(`${url}/__firecrab_dev/stop`, { method: "POST", headers })).status, 202); assert.equal(await exited, 0, logs); }); test("isolated dashboards reject missing private backend ports before listening", () => { const repository = fileURLToPath(new URL("..", import.meta.url)); for (const [api, resource] of [["", "45122"], ["45121", ""], ["5523x", "45122"]]) { const child = spawnSync(process.execPath, [join(repository, "firecrab-cli/src/micromanager/dev/dashboard.mjs"), repository, "8098"], { env: { ...process.env, FIRECRAB_DEV_SESSION: randomUUID(), FIRECRAB_DEV_ISOLATED: "1", FIRECRAB_DEV_API_PORT: api, FIRECRAB_DEV_RESOURCE_PORT: resource, }, encoding: "utf8", timeout: 10000, }); assert.notEqual(child.status, 0); assert.match(child.stderr, /Invalid development dashboard\/backend port/); } }); test("isolated dashboards keep private API/resource routes and stop independently", { timeout: 180000 }, async (t) => { const repository = fileURLToPath(new URL("..", import.meta.url)); const temporary = await mkdtemp(join(tmpdir(), "firecrab-isolated-dashboards-")); t.after(() => rm(temporary, { recursive: true, force: true })); const backend = async (identity) => { const listener = http.createServer((request, response) => { const { host, origin } = request.headers; const allowed = !identity.endsWith("-api") || !origin || origin === `http://${host}`; response.writeHead(allowed ? 200 : 403, { "Content-Type": "application/json", "Connection": "close" }); response.end(JSON.stringify({ identity, host, origin, path: request.url })); }); await new Promise(resolve => listener.listen(0, "127.0.0.1", resolve)); t.after(() => new Promise(resolve => listener.close(resolve))); return listener.address().port; }; const launch = async (name) => { const root = join(temporary, name); const frontend = join(root, "firecrab-frontend"); await mkdir(frontend, { recursive: true }); await writeFile(join(frontend, "package.json"), '{"type":"module"}'); await writeFile(join(frontend, "index.html"), "Legacy checkoutfixture"); await symlink(join(repository, "firecrab-frontend/node_modules"), join(frontend, "node_modules"), "junction"); // An older source checkout still points to the primary API and permits any // Host. The embedded launcher must override it for a private instance. await writeFile(join(frontend, "vite.config.js"), `export default {server:{allowedHosts:true,proxy:{'/api':'http://127.0.0.1:5523','/auth/callback':'http://127.0.0.1:5523','/ws':{target:'ws://127.0.0.1:5523',ws:true}}}};`); const api_port = await backend(`${name}-api`); const resource_port = await backend(`${name}-resources`); const reservation = net.createServer(); await new Promise(resolve => reservation.listen(0, "127.0.0.1", resolve)); const port = reservation.address().port; await new Promise(resolve => reservation.close(resolve)); const session = randomUUID(); const dependencies = randomUUID(); const child = spawn(process.execPath, [join(repository, "firecrab-cli/src/micromanager/dev/dashboard.mjs"), root, String(port)], { cwd: tmpdir(), env: { ...process.env, FIRECRAB_DEV_SESSION: session, FIRECRAB_DEV_DEPENDENCIES: dependencies, FIRECRAB_DEV_API_PORT: String(api_port), FIRECRAB_DEV_RESOURCE_PORT: String(resource_port), FIRECRAB_DEV_ISOLATED: "1", }, stdio: ["ignore", "pipe", "pipe"], }); const exited = new Promise(resolve => child.once("exit", resolve)); let logs = ""; child.stdout.on("data", value => logs += value); child.stderr.on("data", value => logs += value); t.after(() => { if (child.exitCode === null) child.kill(); }); const url = `http://127.0.0.1:${port}`; const headers = { "X-Firecrab-Dev-Session": session }; let ready = false; for (let attempt = 0; attempt < 300; attempt++) { if (child.exitCode !== null) throw new Error(logs); try { const response = await fetch(`${url}/__firecrab_dev/`, { headers }); if (response.ok) { assert.deepEqual(await response.json(), { source: root, session, dependencies, port, api_port, resource_port, isolated: true }); ready = true; break; } } catch {} await new Promise(resolve => setTimeout(resolve, 100)); } assert.ok(ready, logs); const stop = async () => { assert.equal((await fetch(`${url}/__firecrab_dev/stop`, { method: "POST", headers })).status, 202); assert.equal(await exited, 0, logs); }; return { name, url, session, resource_port, stop }; }; const first = await launch("first"); const second = await launch("second"); for (const instance of [first, second]) { const headers = { Origin: instance.url }; const api = await (await fetch(`${instance.url}/api/host`, { headers })).json(); assert.equal(api.identity, `${instance.name}-api`); assert.equal(api.host, instance.url.slice("http://".length)); assert.equal(api.origin, instance.url); const callbackPath = "/auth/callback?state=fixture-state&code=fixture-code"; const callback = await (await fetch(`${instance.url}${callbackPath}`)).json(); assert.equal(callback.identity, `${instance.name}-api`); assert.equal(callback.host, instance.url.slice("http://".length)); assert.equal(callback.path, callbackPath); assert.equal((await fetch(`${instance.url}/api/host`, { headers: { Origin: "https://evil.example" } })).status, 403); const resources = await (await fetch(`${instance.url}/__firecrab_resources/api/service/resources`, { headers })).json(); assert.equal(resources.identity, `${instance.name}-resources`); assert.equal(resources.host, `127.0.0.1:${instance.resource_port}`); assert.equal(resources.origin, instance.url); assert.equal(resources.path, "/api/service/resources"); const page = await fetch(instance.url); const html = await page.text(); assert.match(html, /name="firecrab-resource-endpoint"/); assert.ok(!html.includes(instance.session)); const policy = page.headers.get("content-security-policy"); assert.equal(policy, `connect-src 'self' ws://localhost:${new URL(instance.url).port} ws://127.0.0.1:${new URL(instance.url).port}`); assert.ok(!policy.includes(":5524"), "legacy resource requests must not reach the primary controller"); const badHost = await new Promise((resolve, reject) => { const request = http.request(`${instance.url}/api/host`, { headers: { Host: "evil.example", Origin: "http://evil.example" } }, response => { response.resume(); resolve(response.statusCode); }); request.on("error", reject); request.end(); }); assert.equal(badHost, 403); } await first.stop(); assert.equal((await (await fetch(`${second.url}/api/host`)).json()).identity, "second-api"); assert.equal((await fetch(`${second.url}/__firecrab_resources/api/service/resources`)).status, 200); await second.stop(); }); test("development API proxy preserves the dashboard origin on a custom port", { timeout: 180000 }, async (t) => { const frontend = fileURLToPath(new URL("../firecrab-frontend/", import.meta.url)); const require = createRequire(resolve(frontend, "package.json")); const { createServer, loadConfigFromFile } = await import(pathToFileURL(require.resolve("vite")).href); // Match the API's same-origin policy without a VM or a listener on port 5523. const callbacks = []; const upstream = http.createServer((request, response) => { const { host, origin } = request.headers; if (request.url.startsWith("/auth/callback")) callbacks.push(request.url); response.writeHead(!origin || origin === `http://${host}` ? 200 : 403, { "Content-Type": "application/json", "Connection": "close", }); response.end(JSON.stringify({ host, origin, method: request.method })); }); await new Promise(resolve => upstream.listen(0, "127.0.0.1", resolve)); t.after(() => new Promise(resolve => upstream.close(resolve))); const target = `http://127.0.0.1:${upstream.address().port}`; const previousPort = process.env.FIRECRAB_DEV_API_PORT; process.env.FIRECRAB_DEV_API_PORT = String(upstream.address().port); let config; try { ({ config } = await loadConfigFromFile({ command: "serve", mode: "test" }, resolve(frontend, "vite.config.ts"))); } finally { if (previousPort === undefined) delete process.env.FIRECRAB_DEV_API_PORT; else process.env.FIRECRAB_DEV_API_PORT = previousPort; } assert.equal(config.server.proxy["/api"].target, target); assert.equal(config.server.proxy["/auth/callback"].target, target); const server = await createServer({ ...config, configFile: false, root: frontend, server: { ...config.server, host: "127.0.0.1", port: 0, strictPort: true, }, }); t.after(() => server.close()); await server.listen(); const port = server.httpServer.address().port; const request = async (method, host, origin, path = "/api/proxy-origin") => { const response = await new Promise((resolve, reject) => { const pending = http.request({ hostname: "127.0.0.1", port, path, method, headers: { Host: host, ...(origin ? { Origin: origin } : {}) }, }, resolve); pending.on("error", reject); pending.end(); }); let body = ""; for await (const chunk of response) body += chunk; return { status: response.statusCode, body }; }; for (const hostname of ["localhost", "127.0.0.1"]) { const host = `${hostname}:${port}`; for (const method of ["GET", "POST"]) { const response = await request(method, host, `http://${host}`); assert.equal(response.status, 200, `${method} from http://${host}`); assert.deepEqual(JSON.parse(response.body), { host, origin: `http://${host}`, method }); } const callback = await request("GET", host, undefined, "/auth/callback?state=fixture-state&code=fixture-code"); assert.equal(callback.status, 200); assert.deepEqual(JSON.parse(callback.body), { host, method: "GET" }); for (const origin of ["https://evil.example", `http://${hostname}:${port + 1}`]) { assert.equal((await request("POST", host, origin)).status, 403, origin); } } assert.deepEqual(callbacks, Array(2).fill("/auth/callback?state=fixture-state&code=fixture-code")); const untrustedHost = `evil.example:${port}`; assert.equal((await request("POST", untrustedHost, `http://${untrustedHost}`)).status, 403); });