184 lines
7.5 KiB
JavaScript
184 lines
7.5 KiB
JavaScript
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(); }
|
|
});
|