diff --git a/__tests__/ci.test.js b/__tests__/ci.test.js index b9d80a4..fcde6b5 100644 --- a/__tests__/ci.test.js +++ b/__tests__/ci.test.js @@ -99,15 +99,12 @@ describe("what the boot check assumes of the Dockerfile", () => { }); }); -// Runs the script with a listener already on its port: without the pre-check, -// the wait loop could reach this neighbour and assert on a server it never -// started. Async spawn, so the listener keeps answering while the script runs. -function runBootScript(env) { +// Spawns a command from the repository root with `env` over the test's own +// environment; a key set to undefined is dropped (spawn ignores undefined +// values). Async, so a listener in this process keeps answering meanwhile. +function run(command, args, env) { return new Promise((resolve, reject) => { - const child = spawn("sh", ["scripts/boot-from-copy.sh"], { - cwd: ROOT, - env: { ...process.env, ...env }, - }); + const child = spawn(command, args, { cwd: ROOT, env: { ...process.env, ...env } }); let stderr = ""; child.stderr.on("data", (d) => (stderr += d)); child.on("error", reject); @@ -115,7 +112,22 @@ function runBootScript(env) { }); } +const runBootScript = (env) => run("sh", ["scripts/boot-from-copy.sh"], env); + +// A port nothing listens on. The probe closes before the script starts: should +// something take the port in between, the script's own check refuses it and +// the test fails loudly. +async function freePort() { + const probe = http.createServer(); + await new Promise((resolve) => probe.listen(0, "127.0.0.1", resolve)); + const { port } = probe.address(); + await new Promise((resolve) => probe.close(resolve)); + return port; +} + describe("boot-from-copy.sh", () => { + // A listener already on the port: without the pre-check, the wait loop could + // reach this neighbour and assert on a server it never started. test("refuses a port something else already holds", async () => { const neighbour = http.createServer((req, res) => res.end("not the API")); await new Promise((resolve) => neighbour.listen(0, "127.0.0.1", resolve)); @@ -129,6 +141,39 @@ describe("boot-from-copy.sh", () => { await new Promise((resolve) => neighbour.close(resolve)); } }, 15000); + + // Every curl of the script targets the loopback, and a proxy from the + // environment would answer in the server's place. Before #35 only the port + // check had --noproxy: the wait loop and the assertions reached the proxy. + test("never sends a request through a proxy from the environment", async () => { + // Answers everything, as a real proxy would, and records what reaches it. + const proxied = []; + const proxy = http.createServer((req, res) => { + proxied.push(`${req.method} ${req.url}`); + res.end("answered by the proxy"); + }); + await new Promise((resolve) => proxy.listen(0, "127.0.0.1", resolve)); + try { + const proxyUrl = `http://127.0.0.1:${proxy.address().port}`; + // ALL_PROXY as well, so that unsetting http_proxy alone would not pass. + // An inherited NO_PROXY would hide the defect, hence dropped. + const env = { http_proxy: proxyUrl, ALL_PROXY: proxyUrl, NO_PROXY: undefined, no_proxy: undefined }; + const port = await freePort(); + + // The proxy is live: a bare curl with the same environment reaches it. + // Without this, "nothing reached the proxy" could pass for nothing. + await run("curl", ["-s", "-o", "/dev/null", "--max-time", "5", `http://127.0.0.1:${port}/`], env); + expect(proxied).toEqual([`GET http://127.0.0.1:${port}/`]); + proxied.length = 0; + + const { code, stderr } = await runBootScript({ ...env, BOOT_PORT: String(port) }); + + expect(proxied).toEqual([]); + expect(code, stderr).toBe(0); + } finally { + await new Promise((resolve) => proxy.close(resolve)); + } + }, 20000); }); // The boot check only calls /health, so it proves the existence of the modules diff --git a/scripts/boot-from-copy.sh b/scripts/boot-from-copy.sh index 19b6c6d..fa6edae 100644 --- a/scripts/boot-from-copy.sh +++ b/scripts/boot-from-copy.sh @@ -20,6 +20,13 @@ set -eu +# Every curl below targets this machine's loopback, and a proxy from the +# environment (http_proxy, ALL_PROXY) would answer in its place: the port check +# would probe the proxy instead of the port, the wait loop and the assertions +# would talk to the proxy instead of the server started here. Both spellings, +# since curl reads the lowercase one first and an inherited no_proxy would win. +export NO_PROXY='*' no_proxy='*' + PORT="${BOOT_PORT:-3097}" TOKEN="boot-from-copy-token" DOCKERFILE="Dockerfile" @@ -65,11 +72,11 @@ CMD_SH=$(printf '%s' "$CMD_LINE" | node -e ' # check a server we never started. So refuse up front: the port counts as free # only when curl gets "connection refused" (exit 7). Any other exit — an HTTP # answer (0), a listener that accepts and stalls (28), a non-HTTP service -# (52, 56) — means it is taken. --noproxy keeps an http_proxy from answering -# in the port's place. +# (52, 56) — means it is taken. No proxy answers in the port's place: NO_PROXY +# is exported at the top. command -v curl >/dev/null 2>&1 || fail "curl is required" RC=0 -curl -s -o /dev/null --noproxy '*' --max-time 2 "http://127.0.0.1:$PORT/" || RC=$? +curl -s -o /dev/null --max-time 2 "http://127.0.0.1:$PORT/" || RC=$? [ "$RC" = "7" ] || fail "port $PORT is already taken (curl exit $RC); set BOOT_PORT to a free port" # --- Build the image's file set ---------------------------------------------