vps-health-api/__tests__/ci.test.js
le king fu fef9f69fc0 fix(ci): keep every curl of the boot check off environment proxies (#35)
Only the port pre-check of scripts/boot-from-copy.sh carried --noproxy.
The wait loop and the two assertions still honoured http_proxy, so on a
workstation with a proxy configured the script talked to the proxy
instead of the server it had just started, and failed loudly.

Export NO_PROXY='*' and no_proxy='*' at the top of the script, which
covers every curl call, and drop the now-redundant --noproxy.

The new test boots the script for real behind a fake proxy that answers
everything and records what reaches it. It first proves the proxy is
live (a bare curl with the same environment reaches it), then requires
that nothing of the boot check reaches it and that the script passes.
Red on the previous script (the wait loop and the first assertion went
through the proxy), red again when the export is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:51:57 -04:00

205 lines
8.3 KiB
JavaScript

const fs = require("node:fs");
const path = require("node:path");
const http = require("node:http");
const { spawn } = require("node:child_process");
// Pins on the CI workflow and on what the boot check assumes of the Dockerfile.
// The runtime stays 0-dependency and vitest ships no YAML parser, so these read
// the files with narrow regexes: each one looks at a single key, never at the
// YAML structure as a whole.
const ROOT = path.join(__dirname, "..");
const CI_PATH = path.join(ROOT, ".forgejo", "workflows", "ci.yml");
const ci = fs.readFileSync(CI_PATH, "utf-8");
const dockerfile = fs.readFileSync(path.join(ROOT, "Dockerfile"), "utf-8");
// Comment lines stripped, so an explanation that quotes a key cannot satisfy
// (or break) a pin.
const ciCode = ci
.split("\n")
.filter((line) => !/^\s*#/.test(line))
.join("\n");
// Lines of a top-level-indented block (` pull_request:`), up to the next key at
// the same or a lower indentation.
function block(text, key) {
const lines = text.split("\n");
const start = lines.findIndex((l) => new RegExp(`^(\\s*)${key}:`).test(l));
if (start === -1) return null;
const indent = lines[start].match(/^(\s*)/)[1].length;
const out = [lines[start]];
for (const line of lines.slice(start + 1)) {
if (line.trim() === "") continue;
if (line.match(/^(\s*)/)[1].length <= indent) break;
out.push(line);
}
return out.join("\n");
}
describe("CI workflow", () => {
test("runs in the Dockerfile's own base image", () => {
const from = dockerfile.match(/^\s*FROM\s+(\S+)/m);
const container = ciCode.match(/^\s*container:\s*["']?([^"'\s]+)["']?\s*$/m);
expect(from).not.toBeNull();
expect(container).not.toBeNull();
expect(container[1]).toBe(from[1]);
});
test("runs the unit tests, the socket pass and the boot check", () => {
expect(ciCode).toMatch(/^\s*run:\s*npm ci\s*$/m);
expect(ciCode).toMatch(/^\s*run:\s*npm test\s*$/m);
expect(ciCode).toMatch(/^\s*run:\s*bash test-curl\.sh\s*$/m);
expect(ciCode).toMatch(/^\s*run:\s*sh scripts\/boot-from-copy\.sh\s*$/m);
});
test("builds every pull request, whatever branch it targets", () => {
// A `branches: [main]` here would leave the intermediate links of a stack
// without any status.
const pr = block(ciCode, "pull_request");
expect(pr).not.toBeNull();
expect(pr).not.toMatch(/branches(-ignore)?:/);
});
test("builds pushes to main", () => {
const push = block(ciCode, "push");
expect(push).not.toBeNull();
expect(push).toMatch(/branches:\s*\[\s*main\s*\]/);
});
test("avoids the two forms the runner rejects before the first command", () => {
// `defaults:` anywhere, and `working-directory` on a `uses:` step.
expect(ciCode).not.toMatch(/^\s*defaults:/m);
const steps = ciCode.split(/^\s*- (?=name:|uses:|run:)/m);
for (const step of steps) {
if (/^\s*uses:|\n\s*uses:/.test(step)) expect(step).not.toMatch(/working-directory:/);
}
});
test("never lets a failing step pass", () => {
// `continue-on-error: true` turns a red step green without a trace in the
// commit status. Anchored on the key position, so only a real key counts.
expect(ciCode).not.toMatch(/^\s*(- )?continue-on-error\s*:/m);
});
});
describe("what the boot check assumes of the Dockerfile", () => {
test("the image still drops to the node user, as the script does", () => {
expect(dockerfile).toMatch(/^\s*USER\s+node\s*$/m);
});
test("CMD is in exec form, the only one the script parses", () => {
const cmd = dockerfile.match(/^\s*CMD\s+(.+)$/m);
expect(cmd).not.toBeNull();
const argv = JSON.parse(cmd[1]);
expect(Array.isArray(argv)).toBe(true);
expect(argv.length).toBeGreaterThan(0);
});
});
// 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(command, args, { cwd: ROOT, env: { ...process.env, ...env } });
let stderr = "";
child.stderr.on("data", (d) => (stderr += d));
child.on("error", reject);
child.on("close", (code) => resolve({ code, stderr }));
});
}
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));
try {
const { port } = neighbour.address();
const { code, stderr } = await runBootScript({ BOOT_PORT: String(port) });
expect(code).toBe(1);
expect(stderr).toMatch(new RegExp(`port ${port} is already taken`));
} finally {
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
// loaded at startup, nothing more. A require inside a route handler would load
// only when that route runs and escape it. Heuristic stand-in for "loaded at
// startup": every require starts at column 0 (a top-level statement), and there
// is no dynamic import. Comment lines are skipped, so an explanation quoting
// require( cannot break the pin.
describe("runtime modules are all loaded at startup", () => {
const copyLine = dockerfile.match(/^\s*COPY\s+(.+)$/m);
const sources = copyLine ? copyLine[1].trim().split(/\s+/).slice(0, -1) : [];
const runtimeJs = sources.filter((f) => f.endsWith(".js"));
test("the COPY line lists runtime modules", () => {
expect(runtimeJs.length).toBeGreaterThan(0);
});
test.each(runtimeJs)("%s requires only at module level", (file) => {
const lines = fs.readFileSync(path.join(ROOT, file), "utf-8").split("\n");
const offenders = [];
lines.forEach((line, i) => {
if (/^\s*(\/\/|\/?\*)/.test(line)) return;
if (/\brequire\(/.test(line) && /^\s/.test(line)) offenders.push(`${file}:${i + 1}: ${line.trim()}`);
if (/\bimport\(/.test(line)) offenders.push(`${file}:${i + 1}: ${line.trim()}`);
});
expect(offenders).toEqual([]);
});
});