diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6f912f..11263b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -251,7 +251,24 @@ jobs: - name: Audit dependencies working-directory: web - run: npm run audit + # shell: bash is load-bearing: the default bash -e {0} shell has no + # pipefail, so tee would report its own exit status and a failing audit + # would pass this gate. + shell: bash + run: npm run audit 2>&1 | tee "${RUNNER_TEMP}/web-audit-transcript.txt" + + - name: Upload audit evidence + if: always() && !cancelled() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: web-audit-transcript-${{ github.sha }} + path: ${{ runner.temp }}/web-audit-transcript.txt + if-no-files-found: error + retention-days: 14 + + - name: Run unit tests + working-directory: web + run: npm test - name: Lint working-directory: web @@ -351,6 +368,80 @@ jobs: node --check platforms.js npm pack --dry-run --ignore-scripts + windows-audit-evidence: + name: Windows audit evidence + # The batch helper exists to prove audit-retry.mjs works on a real cmd.exe, + # where only `npm run` supplies npm_execpath. Its regression test replays the + # batch source against a stub npm.cmd, so it proves the mechanics but never + # the execution. This job is the execution, and it is a job rather than an + # `if: runner.os == 'Windows'` step inside npm-wrapper-compatibility so the + # check name itself documents the Windows dependency. + runs-on: windows-2025 + timeout-minutes: 20 + + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: "24" + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install dependencies + working-directory: web + run: npm ci + + - name: Test Windows audit evidence helper + run: node --test scripts/test_win_audit_verify.mjs + + - name: Run Windows audit evidence helper against the real registry + working-directory: web + # shell: bash is load-bearing: with the default shell there is no + # pipefail, so tee's exit status would replace cmd.exe's and the + # helper's nonzero-audit assertion would become unfalsifiable. + shell: bash + run: | + # //d //c, not /d /c: git-bash rewrites a leading /d and /c into D:/ + # and C:/, which leaves cmd.exe with no option flags. It then prints + # its interactive banner and exits 0, so the helper never runs and the + # job goes green on an empty transcript. + cmd.exe //d //c "..\scripts\win-audit-verify.bat" 2>&1 \ + | tee "${RUNNER_TEMP}/win-audit-transcript.txt" + + - name: Verify the transcript is real evidence + shell: bash + # A successful cmd.exe proves nothing on its own; the transcript has to + # show the helper actually reached its report and gated on a passing + # audit, or this job would silently certify nothing. + run: | + transcript="${RUNNER_TEMP}/win-audit-transcript.txt" + for needle in \ + "\[win-audit-verify\] starting npm run audit" \ + "\[win-audit-verify\] finished at" \ + "\[win-audit-verify\] npm run audit exit code: 0" \ + "found 0 vulnerabilities" + do + if ! grep -q -- "$needle" "$transcript"; then + echo "::error::win-audit-verify.bat did not produce '$needle'; the Windows audit evidence is not real." + sed -n '1,40p' "$transcript" + exit 1 + fi + done + + - name: Upload Windows audit evidence + if: always() && !cancelled() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: win-audit-transcript-${{ github.sha }} + path: ${{ runner.temp }}/win-audit-transcript.txt + if-no-files-found: error + retention-days: 14 + deployment: name: Deployment profiles runs-on: ubuntu-24.04 diff --git a/CHANGELOG.md b/CHANGELOG.md index fa583ff..9a60f1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,19 @@ The project publishes 0.x prerelease versions; a stable release line is not yet differs. `deploy/compose/compose.yaml` previously carried the same broken reference, so the documented self-hosted path would have failed on a cold host even though no workflow exercises it. +- CI Web job now runs unit tests (`npm test`), including audit retry regression + tests. `npm run audit` retries recognized transient registry failures up to + three attempts per threshold (two retries), with a 60-second limit per attempt + and portable backoff. It starts npm through Node on Windows, fails immediately + for vulnerabilities, unknown errors or incomplete runs, and echoes the output + of every attempt it retries so a self-healing failure still leaves a trace. + That transcript is uploaded as a downloadable artifact + (`web-audit-transcript-`, 14-day retention) on the success and failure + paths alike, and because the step merges stderr into stdout the artifact also + carries the per-attempt diagnostics. A dedicated `Windows audit evidence` job + runs the audit through `scripts/win-audit-verify.bat` on a real Windows runner + and uploads `win-audit-transcript-`, so the helper is executed rather + than only replayed against a stub by its fixture test. - A configured URL that carries credentials in a shape `url.Parse` does not report as userinfo no longer reaches output. `admin:pw@host` parses as `Scheme="admin"` with the credential in `Opaque` and `User` unset, so an diff --git a/scripts/test_win_audit_verify.mjs b/scripts/test_win_audit_verify.mjs new file mode 100644 index 0000000..0b7b5ae --- /dev/null +++ b/scripts/test_win_audit_verify.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +assert.equal(process.platform, "win32", "Run this process regression in the Windows CI job"); +const source = readFileSync(new URL("./win-audit-verify.bat", import.meta.url), "utf8"); + +function invoke(script, status) { + const directory = mkdtempSync(join(tmpdir(), "mem audit evidence ")); + try { + writeFileSync(join(directory, "verify.bat"), script); + // cmd.exe resolves the real .cmd fixture from its working directory. + // No registry, installed package, or user npm configuration is involved. + writeFileSync(join(directory, "npm.cmd"), + `@echo off\r\necho fixture npm audit status: ${status}\r\nexit /b ${status}\r\n`); + const result = spawnSync(process.env.ComSpec || "cmd.exe", ["/d", "/c", "verify.bat"], { + cwd: directory, + encoding: "utf8", + timeout: 15_000, + env: { ...process.env, AUDIT_RC: "" }, + }); + assert.ifError(result.error); + assert.match(result.stdout, new RegExp(`fixture npm audit status: ${status}`)); + return result; + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +for (const status of [0, 7]) { + test(`prints completed evidence and preserves audit exit ${status}`, () => { + const result = invoke(source, status); + assert.equal(result.status, status); + assert.match(result.stdout, /\[win-audit-verify\] finished at/); + assert.match(result.stdout, new RegExp(`npm run audit exit code: ${status}`)); + }); +} + +test("negative control: omitting CALL loses the post-audit evidence", () => { + const broken = source.replace("call npm run audit", "npm run audit"); + assert.notEqual(broken, source); + const result = invoke(broken, 7); + assert.doesNotMatch(result.stdout, /\[win-audit-verify\] finished at/); +}); + +test("negative control: separate ENDLOCAL loses a nonzero saved exit status", () => { + const broken = source.replace("endlocal & exit /b %AUDIT_RC%", "endlocal\r\nexit /b %AUDIT_RC%"); + assert.notEqual(broken, source); + const result = invoke(broken, 7); + assert.match(result.stdout, /npm run audit exit code: 7/); + assert.equal(result.status, 0); +}); diff --git a/scripts/win-audit-verify.bat b/scripts/win-audit-verify.bat new file mode 100644 index 0000000..e899083 --- /dev/null +++ b/scripts/win-audit-verify.bat @@ -0,0 +1,41 @@ +@echo off +REM win-audit-verify.bat — Verify audit-retry.mjs works on real Windows. +REM Must be run from a real Windows terminal (cmd.exe), NOT WSL. +REM Captures: commit, npm_execpath, full audit output, exit code. + +setlocal enabledelayedexpansion + +echo === win-audit-verify === +echo. + +REM 1. Show which commit is being tested +echo --- git head --- +git rev-parse HEAD +git log -1 --format=^"%%h %%s^" +echo. + +REM 2. Show Node version +echo --- node --- +node --version +echo. + +REM 3. Prove npm_execpath is set by npm run, and that a direct node.exe +REM invocation does NOT have it (the failure mode from the review). +echo --- npm_execpath probe via node -e (should be EMPTY) --- +node -e "console.log('npm_execpath=' + (process.env.npm_execpath || '(unset)'))" +echo. + +REM 4. Run the actual audit via npm run — this is the path that supplies npm_execpath. +echo --- npm run audit (full output) --- +echo [win-audit-verify] starting npm run audit at %DATE% %TIME% +call npm run audit +set AUDIT_RC=!ERRORLEVEL! +echo [win-audit-verify] finished at %DATE% %TIME% +echo. + +REM 5. Report exit code +echo --- result --- +echo [win-audit-verify] npm run audit exit code: !AUDIT_RC! +echo. + +endlocal & exit /b %AUDIT_RC% diff --git a/web/audit-retry.mjs b/web/audit-retry.mjs new file mode 100644 index 0000000..923acfc --- /dev/null +++ b/web/audit-retry.mjs @@ -0,0 +1,138 @@ +import { spawnSync } from "node:child_process"; +import { setTimeout as sleep } from "node:timers/promises"; +import { pathToFileURL } from "node:url"; + +const MAX_ATTEMPTS = 3; +const BACKOFF_MS = 10_000; +// At most six 60-second attempts and four 10-second backoffs across both thresholds. +const ATTEMPT_TIMEOUT_MS = 60_000; + +const NETWORK_PATTERNS = [ + "network timeout", + "503 Service Unavailable", + "ECONNRESET", + "ETIMEDOUT", +]; + +const VULNERABILITY_PATTERNS = [ + "found \\d+ vulnerabilit(?:y|ies)", + "npm audit report", + "vulnerabilities found", +]; + +const COMMANDS = [ + { + label: "production dependencies (moderate threshold)", + args: ["audit", "--omit=dev", "--audit-level=moderate", "--fetch-timeout=45000"], + }, + { + label: "all dependencies (high threshold)", + args: ["audit", "--audit-level=high", "--fetch-timeout=45000"], + }, +]; + +function isNetworkError(stderr) { + return NETWORK_PATTERNS.some((p) => stderr.toLowerCase().includes(p.toLowerCase())); +} + +function isVulnerabilityReport(stdout) { + return VULNERABILITY_PATTERNS.some((p) => new RegExp(p, "i").test(stdout)); +} + +async function runWithRetry(label, args, { spawn, wait, npmExecPath, stdout, stderr }) { + let result; + const finish = () => { + stdout.write(result.stdout ?? ""); + stderr.write(result.stderr ?? ""); + if (result.error) { + stderr.write(`[audit-retry] ${result.error.code ?? "spawn error"}: ${result.error.message}\n`); + } + if (result.signal) { + stderr.write(`[audit-retry] terminated by ${result.signal}.\n`); + } + return Number.isInteger(result.status) && result.status > 0 && result.status <= 255 ? result.status : 1; + }; + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + const ts = new Date().toISOString(); + stderr.write( + `[audit-retry] ${ts} — ${label} (attempt ${attempt}/${MAX_ATTEMPTS})\n` + ); + + // npm run supplies the CLI path, including on Windows where npm is a .cmd + // shim that cannot be launched directly with shell-free spawnSync. + result = spawn(npmExecPath ? process.execPath : "npm", npmExecPath ? [npmExecPath, ...args] : args, { + encoding: "utf8", + stdio: ["inherit", "pipe", "pipe"], + timeout: ATTEMPT_TIMEOUT_MS, + killSignal: "SIGKILL", + }); + + // An interrupted or unstarted audit is never a valid audit result, even + // when its partial output happens to mention a transient network error. + if (result.error || result.signal || !Number.isInteger(result.status) || result.status < 0 || result.status > 255) { + stderr.write(`[audit-retry] ${label} did not complete — not retrying.\n`); + return finish(); + } + + if (result.status === 0) { + stdout.write(result.stdout ?? ""); + stderr.write(result.stderr ?? ""); + stderr.write(`[audit-retry] ${label} passed.\n`); + return 0; + } + + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; + + if (isVulnerabilityReport(output)) { + stderr.write( + `[audit-retry] ${label} found real vulnerabilities — not retrying.\n` + ); + return finish(); + } + + if (isNetworkError(output)) { + if (attempt < MAX_ATTEMPTS) { + stderr.write( + `[audit-retry] ${label} hit a network error — will retry.\n` + + // An attempt that self-heals would otherwise leave no trace, and + // stdout stays reserved for the final audit report. + `[audit-retry] ${label} attempt ${attempt}/${MAX_ATTEMPTS} output:\n${output.trimEnd()}\n` + ); + await wait(BACKOFF_MS); + continue; + } + stderr.write(`[audit-retry] ${label} exhausted ${MAX_ATTEMPTS} attempts.\n`); + return finish(); + } + + stderr.write( + `[audit-retry] ${label} failed with unrecognized error — not retrying.\n` + ); + return finish(); + } +} + +export async function runAudits({ + spawn = spawnSync, + wait = sleep, + npmExecPath = process.env.npm_execpath, + platform = process.platform, + stdout = process.stdout, + stderr = process.stderr, +} = {}) { + if (platform === "win32" && !npmExecPath) { + stderr.write("[audit-retry] Run npm run audit so npm supplies its CLI path on Windows.\n"); + return 1; + } + + for (const cmd of COMMANDS) { + const code = await runWithRetry(cmd.label, cmd.args, { spawn, wait, npmExecPath, stdout, stderr }); + if (code !== 0) return code; + } + return 0; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exitCode = await runAudits(); +} diff --git a/web/audit-retry.test.mjs b/web/audit-retry.test.mjs new file mode 100644 index 0000000..1a4c560 --- /dev/null +++ b/web/audit-retry.test.mjs @@ -0,0 +1,309 @@ +// @vitest-environment node +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { runAudits } from "./audit-retry.mjs"; + +const PASS = { status: 0, stdout: "", stderr: "" }; +const PROD_ARGS = ["audit", "--omit=dev", "--audit-level=moderate", "--fetch-timeout=45000"]; +const ALL_ARGS = ["audit", "--audit-level=high", "--fetch-timeout=45000"]; + +function harness(results, overrides = {}) { + let stdout = ""; + let stderr = ""; + const spawn = vi.fn(() => { + const result = results.shift(); + if (!result) throw new Error("Unexpected audit attempt"); + return result; + }); + const wait = vi.fn(async () => {}); + return { + spawn, + wait, + output: () => ({ stdout, stderr }), + run: () => runAudits({ + spawn, + wait, + npmExecPath: "/npm with spaces/npm-cli.js", + stdout: { write: (text) => { stdout += text; } }, + stderr: { write: (text) => { stderr += text; } }, + ...overrides, + }), + }; +} + +describe("audit retry policy", () => { + it("preserves successful audit reports, including findings below the threshold", async () => { + const production = { status: 0, stdout: "found 0 vulnerabilities\n", stderr: "production notice\n" }; + const development = { status: 0, stdout: "# npm audit report\n1 moderate severity vulnerability\n", stderr: "development notice\n" }; + const test = harness([production, development]); + expect(await test.run()).toBe(0); + expect(test.output().stdout).toBe(production.stdout + development.stdout); + expect(test.output().stderr).toContain(production.stderr); + expect(test.output().stderr).toContain(development.stderr); + expect(test.spawn).toHaveBeenCalledTimes(2); + expect(test.wait).not.toHaveBeenCalled(); + }); + + it("requires both unchanged thresholds to pass, using Node and the npm CLI path", async () => { + const test = harness([PASS, PASS], { platform: "win32" }); + expect(await test.run()).toBe(0); + expect(test.spawn.mock.calls.map(([command, args]) => [command, args])).toEqual([ + [process.execPath, ["/npm with spaces/npm-cli.js", ...PROD_ARGS]], + [process.execPath, ["/npm with spaces/npm-cli.js", ...ALL_ARGS]], + ]); + for (const [, args, options] of test.spawn.mock.calls) { + expect(options).toMatchObject({ timeout: 60_000, killSignal: "SIGKILL" }); + expect(options.shell).toBeUndefined(); + const fetchTimeout = args.filter(a => a.startsWith("--fetch-timeout=")).map(a => Number(a.split("=")[1])); + for (const ms of fetchTimeout) expect(ms).toBeLessThan(options.timeout); + } + expect(test.wait).not.toHaveBeenCalled(); + }); + + it("keeps direct Node invocation available on Unix", async () => { + const test = harness([PASS, PASS], { npmExecPath: "", platform: "linux" }); + expect(await test.run()).toBe(0); + expect(test.spawn.mock.calls[0].slice(0, 2)).toEqual(["npm", PROD_ARGS]); + }); + + it("fails with an actionable message when direct invocation lacks npm on Windows", async () => { + const test = harness([], { npmExecPath: "", platform: "win32" }); + expect(await test.run()).toBe(1); + expect(test.output().stderr).toContain("Run npm run audit"); + expect(test.spawn).not.toHaveBeenCalled(); + }); + + it.each(["network timeout", "503 Service Unavailable", "econnreset", "ETIMEDOUT"])( + "retries a recognized transient failure: %s", async (message) => { + const test = harness([{ status: 1, stderr: message }, PASS, PASS]); + expect(await test.run()).toBe(0); + expect(test.spawn).toHaveBeenCalledTimes(3); + expect(test.wait.mock.calls).toEqual([[10_000]]); + }, + ); + + it("recognizes transient failures on stdout", async () => { + const test = harness([{ status: 1, stdout: "ECONNRESET" }, PASS, PASS]); + expect(await test.run()).toBe(0); + expect(test.wait).toHaveBeenCalledTimes(1); + }); + + it("echoes the transcript of every retried attempt, on stderr only", async () => { + const transient = { status: 1, stdout: "registry notice\n", stderr: "network timeout at .../security/advisories/bulk\n" }; + const test = harness([transient, PASS, PASS]); + expect(await test.run()).toBe(0); + expect(test.output().stderr).toContain("attempt 1/3 output:"); + expect(test.output().stderr).toContain(transient.stdout); + expect(test.output().stderr).toContain(transient.stderr); + expect(test.output().stdout).not.toContain("registry notice"); + }); + + it("caps retries at three, skips the final backoff, and retains final diagnostics", async () => { + const failure = { status: 7, stdout: "final stdout\n", stderr: "503 Service Unavailable: final detail\n" }; + const test = harness([failure, failure, failure]); + expect(await test.run()).toBe(7); + expect(test.spawn).toHaveBeenCalledTimes(3); + expect(test.wait.mock.calls).toEqual([[10_000], [10_000]]); + expect(test.output().stdout).toBe(failure.stdout); + expect(test.output().stderr).toContain(failure.stderr); + expect(test.output().stderr).toContain("exhausted 3 attempts"); + expect(test.output().stderr.match(/will retry/g)).toHaveLength(2); + }); + + it("gives the second threshold its own bounded retry budget", async () => { + const failure = { status: 1, stderr: "ETIMEDOUT" }; + const test = harness([failure, failure, PASS, failure, failure, PASS]); + expect(await test.run()).toBe(0); + expect(test.spawn).toHaveBeenCalledTimes(6); + expect(test.wait.mock.calls).toEqual(Array(4).fill([10_000])); + expect(test.spawn.mock.calls[3][1].slice(1)).toEqual(ALL_ARGS); + }); + + it.each(["stdout", "stderr"])("never retries vulnerabilities on %s, even with network text", async (stream) => { + const test = harness([{ status: 1, [stream]: "# npm audit report\nETIMEDOUT\n" }]); + expect(await test.run()).toBe(1); + expect(test.spawn).toHaveBeenCalledTimes(1); + expect(test.wait).not.toHaveBeenCalled(); + expect(test.output()[stream]).toContain("# npm audit report"); + }); + + it.each(["found 1 vulnerability", "found 2 vulnerabilities", "vulnerabilities found"])( + "prioritizes vulnerability summaries over network text: %s", async (message) => { + const test = harness([{ status: 1, stdout: message, stderr: "ECONNRESET" }]); + expect(await test.run()).toBe(1); + expect(test.spawn).toHaveBeenCalledTimes(1); + expect(test.wait).not.toHaveBeenCalled(); + }, + ); + + it.each(["E401 unauthorized", "invalid config", "fetch failed", "audit endpoint returned an error"])( + "fails unknown or non-transient errors without retry: %s", async (message) => { + const test = harness([{ status: 2, stdout: "diagnostic\n", stderr: message }]); + expect(await test.run()).toBe(2); + expect(test.wait).not.toHaveBeenCalled(); + expect(test.output().stdout).toBe("diagnostic\n"); + expect(test.output().stderr).toContain(message); + }, + ); + + it.each([ + ["missing executable", { status: null, error: Object.assign(new Error("npm missing"), { code: "ENOENT" }) }, "ENOENT"], + ["timeout", { status: null, error: Object.assign(new Error("timed out"), { code: "ETIMEDOUT" }) }, "timed out"], + ["signal", { status: null, signal: "SIGTERM" }, "SIGTERM"], + ["null status", { status: null }, "did not complete"], + ["missing status", {}, "did not complete"], + ["negative status", { status: -1 }, "did not complete"], + ["out of range status", { status: 256 }, "did not complete"], + ["buffer overflow", { status: null, error: Object.assign(new Error("output limit"), { code: "ENOBUFS" }) }, "ENOBUFS"], + ["error with zero status", { status: 0, error: new Error("incomplete") }, "incomplete"], + ["signal with zero status", { status: 0, signal: "SIGKILL" }, "SIGKILL"], + ])("fails closed for %s regardless of transient-looking output", async (_label, result, diagnostic) => { + const test = harness([{ ...result, stderr: "503 Service Unavailable" }]); + expect(await test.run()).toBe(1); + expect(test.spawn).toHaveBeenCalledTimes(1); + expect(test.wait).not.toHaveBeenCalled(); + expect(test.output().stderr).toContain(diagnostic); + expect(test.output().stderr).toContain("503 Service Unavailable"); + }); + + it("fails overall if the second threshold finds vulnerabilities", async () => { + const test = harness([PASS, { status: 1, stdout: "found 2 vulnerabilities" }]); + expect(await test.run()).toBe(1); + expect(test.spawn).toHaveBeenCalledTimes(2); + expect(test.wait).not.toHaveBeenCalled(); + }); + + it("vite.config.ts includes audit-retry.test.mjs in test collection", async () => { + const configPath = join(__dirname, "vite.config.ts"); + const config = readFileSync(configPath, "utf8"); + expect(config).toContain("audit-retry.test.mjs"); + }); + + it("ci.yml keeps the audit transcript pipe fail-closed and uploads it", async () => { + const ci = readFileSync(join(__dirname, "../.github/workflows/ci.yml"), "utf8"); + const steps = ci.split(/\n(?= - name: )/); + + // Without `shell: bash` the default `bash -e {0}` has no pipefail, so tee's + // exit status would replace the audit's and a failing gate would pass. + const transcriptSteps = steps.filter((step) => step.includes('tee "${RUNNER_TEMP}/')); + expect(transcriptSteps.map((step) => step.match(/- name: (.+)/)[1])).toEqual([ + "Audit dependencies", + "Run Windows audit evidence helper against the real registry", + ]); + for (const step of transcriptSteps) { + expect(step).toMatch(/^[ ]+shell: bash$/m); + } + + // Evidence is only worth collecting if it also survives a red step. + for (const artifact of ["web-audit-transcript", "win-audit-transcript"]) { + const upload = steps.find((step) => step.includes(`name: ${artifact}-\${{ github.sha }}`)); + expect(upload).toBeDefined(); + expect(upload).toContain("if: always() && !cancelled()"); + expect(upload).toContain(`path: \${{ runner.temp }}/${artifact}.txt`); + expect(upload).toContain("if-no-files-found: error"); + expect(upload).toContain("retention-days: 14"); + } + + expect(ci).not.toMatch(/continue-on-error|\|\| true/); + }); + + it("ci.yml executes win-audit-verify.bat on a Windows runner", async () => { + const ci = readFileSync(join(__dirname, "../.github/workflows/ci.yml"), "utf8"); + // The helper's own test only replays its source text against a stub + // npm.cmd; a real invocation has to exist somewhere or the batch file is + // dead code that the PR description advertises as evidence. + expect(ci).toMatch(/cmd\.exe \/\/d \/\/c "\.\.\\scripts\\win-audit-verify\.bat"/); + // Matching the invocation text alone was not enough: the first version of + // this step passed CI on a cmd.exe that never ran the helper. + expect(ci).toMatch(/\[win-audit-verify\\\] finished at/); + expect(ci).toMatch(/npm run audit exit code: 0/); + const job = ci + .split(/\n(?= [a-z][a-z0-9-]*:\n)/) + .find((entry) => entry.includes("win-audit-verify.bat")) + // Judge configuration, not prose: the job's comments explain this very + // pattern, so quoting them would make the assertions self-defeating. + .split("\n") + .filter((line) => !/^\s*#/.test(line)) + .join("\n"); + expect(job).toMatch(/^ runs-on: windows-/m); + // The check must not be reachable only via a matrix entry that can vanish. + expect(job).not.toMatch(/if: runner\.os == 'Windows'/); + }); +}); + +describe("audit CLI process behavior", () => { + const directories = []; + afterEach(() => { + for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); + }); + + function fixture(results) { + const directory = mkdtempSync(join(tmpdir(), "audit npm fixture ")); + directories.push(directory); + const cli = join(directory, "npm cli.cjs"); + const log = join(directory, "calls.json"); + writeFileSync(cli, ` + const fs = require('node:fs'); + const log = ${JSON.stringify(log)}; + const calls = fs.existsSync(log) ? JSON.parse(fs.readFileSync(log, 'utf8')) : []; + const result = ${JSON.stringify(results)}[calls.length]; + calls.push(process.argv.slice(2)); + fs.writeFileSync(log, JSON.stringify(calls)); + process.stdout.write(result.stdout || ''); + process.stderr.write(result.stderr || ''); + process.exitCode = result.status; + `); + return { cli, log }; + } + + function invoke(cli) { + return spawnSync(process.execPath, [fileURLToPath(new URL("./audit-retry.mjs", import.meta.url))], { + encoding: "utf8", + timeout: 5_000, + killSignal: "SIGKILL", + env: { ...process.env, npm_execpath: cli }, + }); + } + + it("executes a CLI path with spaces and exits zero only after both audits", () => { + const { cli, log } = fixture([PASS, PASS]); + const result = invoke(cli); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(JSON.parse(readFileSync(log, "utf8"))).toEqual([PROD_ARGS, ALL_ARGS]); + }); + + it.each(["# npm audit report\n", "unknown audit error\n"])("returns failure and output for %s", (message) => { + const { cli, log } = fixture([{ status: 2, stdout: message, stderr: "detail\n" }]); + const result = invoke(cli); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(2); + expect(result.stdout).toBe(message); + expect(result.stderr).toContain("detail\n"); + expect(JSON.parse(readFileSync(log, "utf8"))).toEqual([PROD_ARGS]); + }); + + it("exits nonzero when the npm CLI cannot be started", () => { + const { cli } = fixture([]); + rmSync(cli); + const result = invoke(cli); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("MODULE_NOT_FOUND"); + }); + + it("terminates a stalled process and fails closed at the attempt timeout", async () => { + const test = harness([], { + spawn: (_command, _args, options) => spawnSync(process.execPath, [ + "-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);", + ], { ...options, timeout: 200 }), + }); + expect(await test.run()).toBe(1); + expect(test.wait).not.toHaveBeenCalled(); + expect(test.output().stderr).toContain("ETIMEDOUT"); + expect(test.output().stderr).toContain("SIGKILL"); + }); +}); diff --git a/web/package.json b/web/package.json index a005d33..e8a3535 100644 --- a/web/package.json +++ b/web/package.json @@ -8,7 +8,7 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", - "audit": "npm audit --omit=dev --audit-level=moderate && npm audit --audit-level=high", + "audit": "node audit-retry.mjs", "lint": "eslint . --ext .ts,.tsx --max-warnings 0", "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", "test:enrichment": "node enrichment-acceptance.mjs", diff --git a/web/vite.config.ts b/web/vite.config.ts index 7f64a36..f5b8a78 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -25,7 +25,7 @@ export default defineConfig({ globals: true, environment: 'jsdom', setupFiles: ['./src/test-setup.ts'], - include: ['src/**/*.{test,spec}.{ts,tsx}'], + include: ['src/**/*.{test,spec}.{ts,tsx}', 'audit-retry.test.mjs'], coverage: { provider: 'v8', reporter: ['text', 'json', 'lcov'],