paperclip/packages/paperclip-runner/scripts/local-runner-browser-server...

245 lines
7.2 KiB
JavaScript

import { createServer, request as httpRequest } from "node:http";
import { afterEach, describe, expect, it } from "vitest";
import { createLocalRunnerBrowserMiddleware } from "./local-runner-browser-server.mjs";
const openServers = new Set();
function deferred() {
let resolve;
let reject;
const promise = new Promise((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, reject, resolve };
}
function fakeRunner() {
const completions = [];
let startCalls = 0;
return {
completions,
get startCalls() {
return startCalls;
},
async load() {
return {
async startLocalRunnerScenario({ scenario }) {
startCalls += 1;
const completion = deferred();
completions.push(completion);
return {
metadata: { schema: "test", scenario },
completion: completion.promise,
async interrupt() {
return { status: "accepted" };
},
async resolveRequest() {
return { status: "accepted" };
},
};
},
};
},
};
}
async function startServer(options = {}) {
const middleware = createLocalRunnerBrowserMiddleware(options);
const server = createServer((request, response) => {
void middleware(request, response, () => {
response.statusCode = 404;
response.end();
});
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
openServers.add(server);
const address = server.address();
if (typeof address !== "object" || address === null) {
throw new Error("test server did not expose a TCP address");
}
return { server, port: address.port };
}
function sendRequest(port, options = {}) {
const body = options.body ?? "";
const host = options.host ?? `127.0.0.1:${port}`;
const origin = options.origin === undefined ? `http://${host}` : options.origin;
const headers = {
host,
...(origin === null ? {} : { origin }),
...(body.length === 0 ? {} : { "content-type": "application/json" }),
...options.headers,
};
return new Promise((resolve, reject) => {
const request = httpRequest(
{
hostname: "127.0.0.1",
port,
path: options.path ?? "/api/localRunner/runs",
method: options.method ?? "POST",
headers,
},
(response) => {
const chunks = [];
response.on("data", (chunk) => chunks.push(chunk));
response.on("end", () => {
const source = Buffer.concat(chunks).toString("utf8");
resolve({
status: response.statusCode,
body: source.length === 0 ? null : JSON.parse(source),
});
});
},
);
request.on("error", reject);
if (options.chunks !== undefined) {
for (const chunk of options.chunks) {
request.write(chunk);
}
request.end();
} else {
request.end(body);
}
});
}
async function flushPromises() {
await new Promise((resolve) => setImmediate(resolve));
}
afterEach(async () => {
const servers = [...openServers];
openServers.clear();
await Promise.all(
servers.map(
(server) =>
new Promise((resolve) => {
server.close(resolve);
server.closeAllConnections?.();
}),
),
);
});
describe("Local runner browser transport hardening", () => {
it("denies cross-origin starts and applies the guard before route lookup", async () => {
const runner = fakeRunner();
const { port } = await startServer({ loadRunner: () => runner.load() });
const start = await sendRequest(port, {
origin: "https://attacker.example",
body: JSON.stringify({ scenario: "happy-path" }),
});
const unknown = await sendRequest(port, {
method: "GET",
path: "/api/localRunner/runs/missing/events",
origin: "https://attacker.example",
});
expect(start.status).toBe(403);
expect(unknown.status).toBe(403);
expect(runner.startCalls).toBe(0);
});
it("rejects non-loopback Host values even when Origin matches", async () => {
const runner = fakeRunner();
const { port } = await startServer({ loadRunner: () => runner.load() });
const response = await sendRequest(port, {
host: "attacker.example",
origin: "http://attacker.example",
body: JSON.stringify({ scenario: "happy-path" }),
});
expect(response.status).toBe(403);
expect(runner.startCalls).toBe(0);
});
it("rejects a loopback Host that does not name the listening port", async () => {
const runner = fakeRunner();
const { port } = await startServer({ loadRunner: () => runner.load() });
const response = await sendRequest(port, {
host: "127.0.0.1:9",
origin: "http://127.0.0.1:9",
body: JSON.stringify({ scenario: "happy-path" }),
});
expect(response.status).toBe(403);
expect(runner.startCalls).toBe(0);
});
it("caps active runs and evicts the oldest completed run at the retention bound", async () => {
const runner = fakeRunner();
const { port } = await startServer({
loadRunner: () => runner.load(),
limits: { maxActiveRuns: 1, maxRetainedRuns: 2 },
});
const body = JSON.stringify({ scenario: "happy-path" });
const first = await sendRequest(port, { body });
const rejected = await sendRequest(port, { body });
expect(first.status).toBe(201);
expect(rejected.status).toBe(429);
runner.completions[0].resolve({ run: 1 });
await flushPromises();
const second = await sendRequest(port, { body });
expect(second.status).toBe(201);
runner.completions[1].resolve({ run: 2 });
await flushPromises();
const third = await sendRequest(port, { body });
expect(third.status).toBe(201);
const evicted = await sendRequest(port, {
method: "GET",
path: `/api/localRunner/runs/${first.body.id}/events`,
});
expect(evicted.status).toBe(404);
runner.completions[2].resolve({ run: 3 });
await flushPromises();
});
it("rejects declared and streamed oversized JSON before starting a runner", async () => {
const runner = fakeRunner();
const { port } = await startServer({
loadRunner: () => runner.load(),
limits: { maxRequestBodyBytes: 32 },
});
const oversized = JSON.stringify({ scenario: "x".repeat(64) });
const declared = await sendRequest(port, { body: oversized });
const streamed = await sendRequest(port, {
chunks: ["{\"scenario\":\"", "y".repeat(64), "\"}"],
headers: {
"content-type": "application/json",
"transfer-encoding": "chunked",
},
});
expect(declared.status).toBe(413);
expect(streamed.status).toBe(413);
expect(runner.startCalls).toBe(0);
});
it("requires JSON content so a simple text/plain browser POST cannot start work", async () => {
const runner = fakeRunner();
const { port } = await startServer({ loadRunner: () => runner.load() });
const response = await sendRequest(port, {
body: JSON.stringify({ scenario: "happy-path" }),
headers: { "content-type": "text/plain" },
});
expect(response.status).toBe(415);
expect(runner.startCalls).toBe(0);
});
});