import test from "node:test"; import assert from "node:assert/strict"; import http from "node:http"; import { PreviewProxy } from "../src/preview-proxy.js"; /** A stand-in for a previewed application: answers with the path it was actually asked for. */ async function application(body = "the app") { const server = http.createServer((request, response) => { response.writeHead(200, { "content-type": "text/plain" }); response.end(`${body} at ${request.url}`); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); return { server, port: server.address().port, close: () => new Promise((r) => server.close(r)) }; } async function proxyFor(instances) { const touched = []; const proxy = new PreviewProxy({ port: 0, resolve: (key) => { const found = instances[key]; return found ? { port: found, touch: () => touched.push(key) } : null; } }); await proxy.listen(); return { proxy, touched, port: proxy.server.address().port }; } function fetchNoRedirect(url, headers = {}) { return fetch(url, { headers, redirect: "manual" }); } test("a valid token is exchanged for a cookie, and the token leaves the address bar", async () => { // The token travels in a link a person was given; leaving it in the URL would send it onward in // Referer to whatever the previewed page links to. const app = await application(); const { proxy, port } = await proxyFor({ "exec-a": app.port }); try { const token = proxy.issueToken("exec-a"); const answer = await fetchNoRedirect(`http://127.0.0.1:${port}/exec-a/?t=${token}`); assert.equal(answer.status, 302); // The path the browser is at, not the one this proxy receives. The gateway strips /preview // before forwarding, so redirecting to what arrived would send the browser out of the mount // and into the gateway's own 404 - which is exactly what happened the first time. assert.equal(answer.headers.get("location"), "/preview/exec-a/"); const cookie = answer.headers.get("set-cookie"); assert.match(cookie, /dev_server_preview_exec-a=/); assert.match(cookie, /HttpOnly/); assert.match(cookie, /Path=\/preview\/exec-a\//, "the cookie must not be usable on another preview"); } finally { await proxy.close(); await app.close(); } }); test("the cookie from one preview does not open another", async () => { const app = await application(); const { proxy, port } = await proxyFor({ "exec-a": app.port, "exec-b": app.port }); try { const tokenA = proxy.issueToken("exec-a"); proxy.issueToken("exec-b"); const answer = await fetchNoRedirect(`http://127.0.0.1:${port}/exec-b/`, { cookie: `dev_server_preview_exec-a=${tokenA}` }); assert.equal(answer.status, 403); } finally { await proxy.close(); await app.close(); } }); test("an execution key alone is not enough to open a preview", async () => { // rootExecutionId is on screen in the editor and in every event the flow logs. If knowing it // were sufficient, the preview would effectively be public. const app = await application(); const { proxy, port } = await proxyFor({ "exec-a": app.port }); try { proxy.issueToken("exec-a"); const answer = await fetchNoRedirect(`http://127.0.0.1:${port}/exec-a/`); assert.equal(answer.status, 403); } finally { await proxy.close(); await app.close(); } }); test("a wrong token is refused", async () => { const app = await application(); const { proxy, port } = await proxyFor({ "exec-a": app.port }); try { proxy.issueToken("exec-a"); const answer = await fetchNoRedirect(`http://127.0.0.1:${port}/exec-a/?t=not-the-token`); assert.equal(answer.status, 403); } finally { await proxy.close(); await app.close(); } }); test("a restart invalidates the link the previous start handed out", async () => { const app = await application(); const { proxy, port } = await proxyFor({ "exec-a": app.port }); try { const first = proxy.issueToken("exec-a"); proxy.issueToken("exec-a"); const answer = await fetchNoRedirect(`http://127.0.0.1:${port}/exec-a/?t=${first}`); assert.equal(answer.status, 403); } finally { await proxy.close(); await app.close(); } }); test("an authorised request reaches the application at the path it expects", async () => { // The app was not written to live under a prefix, so what reaches it must be the path it would // see at the root - the key and the /preview mount are ours, not its business. const app = await application("inner"); const { proxy, port } = await proxyFor({ "exec-a": app.port }); try { const token = proxy.issueToken("exec-a"); const answer = await fetch(`http://127.0.0.1:${port}/exec-a/orders?page=2`, { headers: { cookie: `dev_server_preview_exec-a=${token}` } }); assert.equal(answer.status, 200); assert.equal(await answer.text(), "inner at /orders?page=2"); } finally { await proxy.close(); await app.close(); } }); test("reading the preview keeps it alive", async () => { // A person takes hours where a model takes seconds. Without this the instance's own idle clock // would reclaim a preview somebody is still looking at. const app = await application(); const { proxy, touched, port } = await proxyFor({ "exec-a": app.port }); try { const token = proxy.issueToken("exec-a"); await fetch(`http://127.0.0.1:${port}/exec-a/`, { headers: { cookie: `dev_server_preview_exec-a=${token}` } }); assert.deepEqual(touched, ["exec-a"]); } finally { await proxy.close(); await app.close(); } }); test("an execution with nothing running says so, rather than failing obscurely", async () => { const { proxy, port } = await proxyFor({}); try { const answer = await fetchNoRedirect(`http://127.0.0.1:${port}/exec-missing/`); assert.equal(answer.status, 404); assert.match(await answer.text(), /No preview is running/); } finally { await proxy.close(); } }); test("a key that is not one safe segment is refused before anything is looked up", async () => { const { proxy, port } = await proxyFor({}); try { for (const path of ["/", "/..%2fetc/", "/with%20space/"]) { const answer = await fetchNoRedirect(`http://127.0.0.1:${port}${path}`); assert.equal(answer.status, 404, `accepted ${path}`); } } finally { await proxy.close(); } }); test("an application that has died answers as a gateway failure, not a hang", async () => { const app = await application(); const dead = app.port; await app.close(); const { proxy, port } = await proxyFor({ "exec-a": dead }); try { const token = proxy.issueToken("exec-a"); const answer = await fetchNoRedirect(`http://127.0.0.1:${port}/exec-a/`, { cookie: `dev_server_preview_exec-a=${token}` }); assert.equal(answer.status, 502); } finally { await proxy.close(); } }); test("the mount the gateway publishes is one setting, used by both the redirect and the cookie", async () => { // These were written separately once and disagreed: the cookie assumed /preview, the redirect // did not, and the link led out of the mount. One parameter now, so they cannot drift again. const app = await application(); const proxy = new PreviewProxy({ port: 0, mountPath: "/elsewhere", resolve: () => ({ port: app.port }) }); await proxy.listen(); try { const token = proxy.issueToken("exec-a"); const answer = await fetchNoRedirect(`http://127.0.0.1:${proxy.server.address().port}/exec-a/?t=${token}`); assert.equal(answer.headers.get("location"), "/elsewhere/exec-a/"); assert.match(answer.headers.get("set-cookie"), /Path=\/elsewhere\/exec-a\//); } finally { await proxy.close(); await app.close(); } });