import assert from "node:assert/strict"; import { EventEmitter, once } from "node:events"; import { readFileSync } from "node:fs"; import http from "node:http"; import net from "node:net"; import test from "node:test"; import { observeChatWebhookProxyRejection } from "../lib/chat-webhook-proxy-diagnostics.mjs"; const allowedHost = "dottas-macbook-pro.tail29c1aa.ts.net:8443"; const allowedPath = "/api/chat-webhooks/PRIVATE_ENDPOINT_SENTINEL/slack"; const closedKeys = [ "bytes", "connectionId", "durationMs", "event", "reason", "statusCode", ]; function assertPrivate(rows) { assert.doesNotMatch( JSON.stringify(rows), /private|sentinel|authorization|forwarded|cookie|https?:\/\//i, ); } function fixtureHandler(rows, connections, upstreamPort) { return (req, res) => { const observation = observeChatWebhookProxyRejection(req, res, { connectionId: connections.get(req.socket), emit: (row) => rows.push(row), }); const reject = (reason, status) => { observation.reject(reason); req.on("data", (chunk) => observation.countBytes(chunk.length)); res.writeHead(status).end(); req.resume(); }; let url; try { url = new URL(req.url ?? "/", "http://localhost"); } catch { reject("malformed_target", 400); return; } if (req.method !== "POST") return reject("method", 404); if (req.headers.host?.toLowerCase() !== allowedHost) return reject("host", 404); if ( !/^\/api\/chat-webhooks\/[A-Za-z0-9_-]+\/(slack|github|telegram|microsoft-teams)\/?$/.test( url.pathname, ) ) return reject("path", 404); const upstream = http.request( { hostname: "127.0.0.1", port: upstreamPort, method: req.method, path: req.url, headers: req.headers, }, (reply) => { res.writeHead(reply.statusCode, reply.headers); reply.pipe(res); }, ); upstream.on("error", () => { if (!res.headersSent) res.writeHead(502); res.end(); }); req.pipe(upstream); }; } async function harness(t, options = {}) { const rows = []; const connections = new WeakMap(); const upstreamRequests = []; const upstream = http.createServer((req, res) => { upstreamRequests.push({ path: req.url, host: req.headers.host }); req.on("error", () => {}); req.on("end", () => res.writeHead(202).end()); req.resume(); }); upstream.listen(0, "127.0.0.1"); await once(upstream, "listening"); let server; if (process.env.PAPERCLIP_WEBHOOK_PROXY_UNDER_TEST) { // Optional integration against the ignored local proxy; the portable suite // needs no live deployment/configuration or ignored workspace artifacts. const source = readFileSync( process.env.PAPERCLIP_WEBHOOK_PROXY_UNDER_TEST, "utf8", ).replace(/^import .*;\r?\n/gm, ""); const wrappedHttp = { createServer(handler) { server = http.createServer(handler); return { on: (...args) => server.on(...args), listen() {} }; }, request(options, callback) { assert.equal(options.hostname, "127.0.0.1"); assert.equal(options.port, 3137); assert.equal(options.timeout, 30_000); return http.request( { ...options, port: upstream.address().port }, callback, ); }, }; new Function( "http", "console", "observeChatWebhookProxyRejection", "process", source, )( wrappedHttp, { log: (line) => { try { rows.push(JSON.parse(line)); } catch {} }, }, observeChatWebhookProxyRejection, { env: { PAPERCLIP_QA_FAIL_ONCE_GITHUB_PATH: options.faultPath } }, ); } else { server = http.createServer( fixtureHandler(rows, connections, upstream.address().port), ); let nextId = 1; server.on("connection", (socket) => connections.set(socket, nextId++)); } const baseline = http.createServer(); assert.equal(server.listenerCount("clientError"), 0); for (const key of [ "keepAliveTimeout", "keepAliveTimeoutBuffer", "headersTimeout", "requestTimeout", "timeout", ]) assert.equal(server[key], baseline[key]); server.listen(0, "127.0.0.1"); await once(server, "listening"); const agent = new http.Agent({ keepAlive: true, maxSockets: 1 }); t.after(async () => { agent.destroy(); server.closeAllConnections(); upstream.closeAllConnections(); await Promise.all([ new Promise((resolve) => server.close(resolve)), new Promise((resolve) => upstream.close(resolve)), ]); }); const request = ({ method = "POST", path = allowedPath, host = allowedHost, body = "PRIVATE_BODY_SENTINEL", headers = {}, } = {}) => new Promise((resolve, reject) => { const req = http.request( { agent, host: "127.0.0.1", port: server.address().port, method, path, headers: { host, authorization: "PRIVATE_AUTH_SENTINEL", cookie: "PRIVATE_COOKIE_SENTINEL", "content-length": Buffer.byteLength(body), ...headers, }, }, (res) => { res.resume(); res.on("end", () => resolve({ status: res.statusCode, socket: req.socket }), ); }, ); req.on("error", reject); req.end(body); }); return { rows, server, upstreamRequests, request }; } test( "real keep-alive HTTP observes each rejection exactly once alongside accepted traffic", { timeout: 5_000 }, async (t) => { const { rows, request, upstreamRequests } = await harness(t); const first = await request(); assert.equal(first.status, 202); for (const [input, reason, status] of [ [{ method: "PUT" }, "method", 404], [{ host: "PRIVATE_HOST_SENTINEL.invalid" }, "host", 404], [ { path: "/PRIVATE_PATH_SENTINEL?secret=PRIVATE_QUERY_SENTINEL" }, "path", 404, ], [{ path: "http://[PRIVATE_TARGET_SENTINEL" }, "malformed_target", 400], ]) { const prior = rows.filter( (row) => row.event === "chat_webhook_proxy_rejection", ).length; const response = await request(input); assert.equal(response.status, status); assert.equal(response.socket, first.socket); const observations = rows.filter( (row) => row.event === "chat_webhook_proxy_rejection", ); assert.equal(observations.length, prior + 1); assert.equal(observations.at(-1).reason, reason); assert.equal(observations.at(-1).statusCode, status); assert.equal(observations.at(-1).connectionId, 1); assert.deepEqual(Object.keys(observations.at(-1)).sort(), closedKeys); } const last = await request({ path: `${allowedPath}?token=PRIVATE_QUERY_SENTINEL`, }); assert.equal(last.status, 202); assert.equal(last.socket, first.socket); assert.equal(upstreamRequests.length, 2); assertPrivate(rows); }, ); test( "real Node parser retains native 400/431 and never reaches rejection observer", { timeout: 5_000 }, async (t) => { const { server, rows, upstreamRequests } = await harness(t); const exchange = (raw) => new Promise((resolve, reject) => { const socket = net.createConnection({ host: "127.0.0.1", port: server.address().port, }); t.after(() => socket.destroy()); let response = ""; socket.setTimeout(2_000, () => socket.destroy(new Error("fixture timeout")), ); socket.on("connect", () => socket.write(raw)); socket.on("data", (chunk) => { response += chunk.toString(); }); socket.on("error", reject); socket.on("close", () => resolve(response)); }); assert.match( await exchange( "INVALID_PRIVATE_METHOD_SENTINEL / HTTP/1.1\r\nHost: private.invalid\r\n\r\n", ), /^HTTP\/1\.1 400 /, ); assert.match( await exchange( `GET / HTTP/1.1\r\nHost: private.invalid\r\nX-Private: ${"x".repeat(http.maxHeaderSize + 1024)}\r\n\r\n`, ), /^HTTP\/1\.1 431 /, ); assert.equal(server.listenerCount("clientError"), 0); assert.equal(upstreamRequests.length, 0); assert.equal( rows.filter((row) => row.event === "chat_webhook_proxy_rejection").length, 0, ); assertPrivate(rows); }, ); test("observer does not read/resume bodies and emits one closed event for abort then close", () => { const req = new EventEmitter(); req.resume = () => assert.fail("observer must not resume request"); const res = Object.assign(new EventEmitter(), { headersSent: false }); const rows = []; const observation = observeChatWebhookProxyRejection(req, res, { connectionId: 4, emit: (row) => rows.push(row), }); assert.equal(req.listenerCount("data"), 0); observation.reject("path"); observation.countBytes(17); req.emit("aborted"); res.emit("close"); res.emit("finish"); assert.equal(rows.length, 1); assert.equal(rows[0].bytes, 17); assert.equal(rows[0].statusCode, null); assert.equal(req.listenerCount("aborted"), 0); assertPrivate(rows); }); for (const failure of ["client_abort", "response_error"]) { test( `real HTTP ${failure} records one rejection without private error prose`, { timeout: 5_000 }, async (t) => { const rows = []; let signalBytes; const bytesRead = new Promise((resolve) => { signalBytes = resolve; }); let response; let signalObservation; const observed = new Promise((resolve) => { signalObservation = resolve; }); const server = http.createServer((req, res) => { response = res; const observation = observeChatWebhookProxyRejection(req, res, { connectionId: 1, emit: (row) => { rows.push(row); signalObservation(); }, }); assert.equal(req.listenerCount("data"), 0); observation.reject("path"); req.on("data", (chunk) => { observation.countBytes(chunk.length); signalBytes(); }); // The fixture deliberately holds its response so it can exercise the // observer's abort/close path, not change the deployed rejection policy. req.on("error", () => {}); res.on("error", () => {}); }); server.listen(0, "127.0.0.1"); await once(server, "listening"); const socket = net.createConnection({ host: "127.0.0.1", port: server.address().port, }); socket.on("error", () => {}); const closed = once(socket, "close"); t.after(async () => { socket.destroy(); server.closeAllConnections(); await new Promise((resolve) => server.close(resolve)); }); await once(socket, "connect"); socket.write( "POST /PRIVATE_PATH_SENTINEL HTTP/1.1\r\nHost: PRIVATE_HOST_SENTINEL.invalid\r\nContent-Length: 100\r\n\r\nabc", ); await bytesRead; if (failure === "client_abort") socket.destroy(); else response.destroy(new Error("PRIVATE_ERROR_SENTINEL")); await Promise.all([observed, closed]); await new Promise((resolve) => setImmediate(resolve)); assert.equal(server.listenerCount("clientError"), 0); assert.equal(rows.length, 1); assert.equal(rows[0].bytes, 3); assert.equal(rows[0].statusCode, null); assert.deepEqual(Object.keys(rows[0]).sort(), closedKeys); assertPrivate(rows); }, ); } if (process.env.PAPERCLIP_WEBHOOK_PROXY_UNDER_TEST) { test( "wired proxy preserves its 1 MiB streaming limit and accepted timing", { timeout: 5_000 }, async (t) => { const { rows, request } = await harness(t); assert.equal( (await request({ body: Buffer.alloc(1_048_576) })).status, 202, ); assert.equal( (await request({ body: Buffer.alloc(1_048_577) })).status, 413, ); const timings = rows.filter( (row) => row.event === "chat_webhook_proxy_timing", ); assert.deepEqual( timings.map((row) => [row.statusCode, row.outcome, row.bytes]), [ [202, "completed", 1_048_576], [413, "body_too_large", 1_048_577], ], ); assert.equal( rows.filter((row) => row.event === "chat_webhook_proxy_rejection") .length, 0, ); assertPrivate(rows); }, ); test( "wired proxy preserves exact one-shot GitHub fault without rejected-route consumption", { timeout: 5_000 }, async (t) => { const faultPath = "/api/chat-webhooks/PRIVATE_ENDPOINT_SENTINEL/github"; const { rows, request, upstreamRequests } = await harness(t, { faultPath, }); assert.equal( ( await request({ method: "PUT", path: faultPath, headers: { "x-github-event": "issue_comment" }, }) ).status, 404, ); assert.equal( ( await request({ path: faultPath, headers: { "x-github-event": "ping" }, }) ).status, 202, ); assert.equal( ( await request({ path: faultPath, headers: { "x-github-event": "issue_comment" }, }) ).status, 503, ); assert.equal( ( await request({ path: faultPath, headers: { "x-github-event": "issue_comment" }, }) ).status, 202, ); assert.equal(upstreamRequests.length, 2); assert.equal( rows.filter((row) => row.outcome === "qualification_injected_503") .length, 1, ); assert.equal( rows.filter((row) => row.event === "chat_webhook_proxy_rejection") .length, 1, ); assertPrivate(rows); }, ); } test("closed labels, bounds and failing diagnostic sinks cannot change response handling", async () => { for (const value of ["PRIVATE_REASON_SENTINEL", "method"]) { const req = new EventEmitter(); const res = Object.assign(new EventEmitter(), { headersSent: true, statusCode: 404, }); const rows = []; let clock = 20; const observation = observeChatWebhookProxyRejection(req, res, { connectionId: "PRIVATE_ID_SENTINEL", now: () => clock, emit: (row) => rows.push(row), }); observation.reject(value); observation.countBytes(Infinity); observation.countBytes(-1); observation.countBytes("PRIVATE_BYTES_SENTINEL"); observation.countBytes(Number.MAX_SAFE_INTEGER); observation.countBytes(1); clock = -100; res.emit("finish"); assert.equal(rows.length, value === "method" ? 1 : 0); if (rows.length) assert.deepEqual(rows[0], { event: "chat_webhook_proxy_rejection", reason: "method", connectionId: null, statusCode: 404, durationMs: 0, bytes: Number.MAX_SAFE_INTEGER, }); assertPrivate(rows); } for (const emit of [ () => { throw new Error("PRIVATE_ERROR_SENTINEL"); }, async () => { throw new Error("PRIVATE_ERROR_SENTINEL"); }, ]) { const req = new EventEmitter(); const res = Object.assign(new EventEmitter(), { headersSent: true, statusCode: 404, }); observeChatWebhookProxyRejection(req, res, { emit }).reject("method"); assert.doesNotThrow(() => res.emit("finish")); } await new Promise((resolve) => setImmediate(resolve)); });