Files
msd-core/tests/npm-integrity-gate.test.cjs
Tom Boucher 30468f16fe test(#4515): migrate installer-runs batch to named timeout constants (#4575)
* test(#4515): migrate installer-runs batch to named timeout constants

Batch 4 of the ad hoc timeout literal migration (epic #4445). Replaces
every bare numeric timeout/timeoutMs object-literal property across
tests/install.test.cjs, tests/install-minimal-hooks.test.cjs,
tests/fragment-single-edit-propagation.install.test.cjs,
tests/install-regressions.test.cjs, tests/install-runtime-artifacts.test.cjs,
tests/npm-integrity-gate.test.cjs, tests/faulty-deps.test.cjs,
tests/release-tarball-smoke.install.test.cjs,
tests/install-write-confinement.test.cjs, tests/plugin-manifest.test.cjs,
and tests/packaging-shipped-scripts-require-only-shipped.test.cjs with a
named constant, per eslint-rules/no-adhoc-timeout-literal.cjs. Removes
these 11 files from the rule's allowlist.

Adds one new shared constant to tests/helpers/timeouts.cjs for a
fixture-JSON value (in seconds, not ms) mimicking a Claude Code
settings.json hook-entry's own timeout field, shared across two files in
this batch. Every other new constant is file-local, each carrying a
comment explaining why its call site is a distinct operational class from
the existing shared norms even where its digits numerically coincide with
one. No src/bin file touched, no numeric timeout value changed anywhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix: combine gsd-worktree-path-guard's git rev-parse calls to cut subprocess count under CI load

Discovered while verifying PR #4575 (a full test (windows-latest) failure in an unrelated test, tests/kilo-upgrades.test.cjs's worktree-path-guard rejection test). Root cause: this hook's up-to-4 sequential git spawns (git-dir, branch, worktree-toplevel, file-toplevel), invoked inside a native-plugin's own 8000ms-capped subprocess wrapper, left zero margin for node/git startup overhead — the guard is deliberately fail-open on any timeout, so CI-load-induced latency in this chain silently downgrades a security block into an allow. Combines the first three git rev-parse calls (git-dir, branch via --abbrev-ref, worktree-toplevel) into ONE spawn instead of three, using git's documented multi-flag rev-parse output (one line per flag, in order) — verified empirically including the detached-HEAD edge case. No timeout value changed; this reduces subprocess COUNT, not any tolerance.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs: add changeset fragment for the worktree-path-guard fix

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 11:45:44 -04:00

359 lines
15 KiB
JavaScript

'use strict';
/**
* Regression test for #114 — npm dependency integrity gate.
*
* Verifies that scripts/check-npm-integrity.cjs correctly detects:
* 1. Clean install — exits 0, no stderr findings
* 2. Version drift — exits 1, stderr names the offending package + both versions
* (reproduces the ws 8.20.1 declared vs 8.20.0 installed incident)
* 3. Extraneous — exits 1 without --ignore-extraneous; exits 0 with it
* 4. Missing — exits 1 regardless of flags
*
* Each fixture lives under tests/fixtures/npm-integrity/<name>/.
* The test spawns the script as a subprocess — no require/import of internals.
*
* Sources:
* - npm CLI docs: https://docs.npmjs.com/cli/v10/commands/npm-ls
* - NIST SSDF PW.4.1: https://csrc.nist.gov/publications/detail/sp/800-218/final
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
const path = require('node:path');
const { runNode } = require('./helpers/process-seam.cjs');
const { QUICK_SPAWN_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const ROOT = path.resolve(__dirname, '..');
const SCRIPT = path.join(ROOT, 'scripts', 'check-npm-integrity.cjs');
const FIXTURES = path.join(__dirname, 'fixtures', 'npm-integrity');
// Bounds a real check-npm-integrity.cjs run via the process seam; digits
// coincide with the shared BUILD_TIMEOUT_MS but this is a different
// operation class (an integrity check, not a hooks build), so kept as its
// own constant rather than aliased.
const NPM_INTEGRITY_CHECK_TIMEOUT_MS = 30_000;
/**
* Run the integrity gate script against a fixture directory.
*
* @param {string} fixtureName - subdirectory under tests/fixtures/npm-integrity/
* @param {string[]} [extraArgs] - additional CLI args passed to the script
* @returns {{ status: number, stdout: string, stderr: string }}
*/
function runGate(fixtureName, extraArgs = []) {
const fixtureDir = path.join(FIXTURES, fixtureName);
const r = runNode([SCRIPT, ...extraArgs], { cwd: fixtureDir, timeoutMs: NPM_INTEGRITY_CHECK_TIMEOUT_MS });
return {
status: r.exitCode ?? 1,
stdout: r.stdout,
stderr: r.stderr,
};
}
// ─── Scenario 1: Clean ───────────────────────────────────────────────────────
describe('#114: npm integrity gate — clean fixture', () => {
test('exits 0 when install matches lockfile', () => {
const { status } = runGate('clean');
assert.strictEqual(status, 0, 'expected exit 0 for clean install');
});
test('emits no integrity findings to stderr on clean install', () => {
const { stderr } = runGate('clean');
// No "FAIL:" lines expected
assert.ok(
!stderr.includes('FAIL:'),
`expected no FAIL: lines in stderr; got:\n${stderr}`
);
});
});
// ─── Scenario 2: Drift (declared vs installed mismatch) ─────────────────────
// Reproduces: ws 8.20.1 declared in lockfile, 8.20.0 installed in node_modules
// Fixture uses: stable-dep@8.20.1 (declared) vs stable-dep@8.20.0 (installed)
describe('#114: npm integrity gate — drift fixture (declared vs installed mismatch)', () => {
test('exits 1 on version drift', () => {
const { status } = runGate('drift');
assert.strictEqual(status, 1, 'expected exit 1 for version drift');
});
test('stderr names the offending package', () => {
const { stderr } = runGate('drift');
assert.ok(
stderr.includes('stable-dep'),
`expected stderr to name "stable-dep"; got:\n${stderr}`
);
});
test('stderr includes both the declared and installed versions', () => {
const { stderr } = runGate('drift');
assert.ok(
stderr.includes('8.20.0'),
`expected stderr to include installed version "8.20.0"; got:\n${stderr}`
);
assert.ok(
stderr.includes('8.20.1'),
`expected stderr to include declared version "8.20.1"; got:\n${stderr}`
);
});
});
// ─── Scenario 3: Extraneous ──────────────────────────────────────────────────
describe('#114: npm integrity gate — extraneous fixture', () => {
test('exits 1 when extraneous package present (default behavior)', () => {
const { status } = runGate('extraneous');
assert.strictEqual(status, 1, 'expected exit 1 for extraneous package without --ignore-extraneous');
});
test('stderr names the extraneous package', () => {
const { stderr } = runGate('extraneous');
assert.ok(
stderr.includes('ghost-pkg'),
`expected stderr to name "ghost-pkg"; got:\n${stderr}`
);
});
test('exits 0 with --ignore-extraneous flag', () => {
const { status } = runGate('extraneous', ['--ignore-extraneous']);
assert.strictEqual(status, 0, 'expected exit 0 for extraneous package with --ignore-extraneous');
});
});
// ─── Scenario 4: Missing ─────────────────────────────────────────────────────
describe('#114: npm integrity gate — missing fixture', () => {
test('exits 1 when required package is missing from node_modules', () => {
const { status } = runGate('missing');
assert.strictEqual(status, 1, 'expected exit 1 for missing package');
});
test('stderr names the missing package', () => {
const { stderr } = runGate('missing');
assert.ok(
stderr.includes('absent-dep'),
`expected stderr to name "absent-dep"; got:\n${stderr}`
);
});
test('exits 1 even with --ignore-extraneous (missing is not extraneous)', () => {
const { status } = runGate('missing', ['--ignore-extraneous']);
assert.strictEqual(status, 1, 'expected exit 1 for missing package even with --ignore-extraneous');
});
});
// ─── Smoke test: --help ───────────────────────────────────────────────────────
describe('#114: npm integrity gate — --help output', () => {
test('exits 0 with --help flag', () => {
const result = spawnSync(process.execPath, [SCRIPT, '--help'], {
cwd: ROOT,
encoding: 'utf-8',
timeout: QUICK_SPAWN_TIMEOUT_MS,
});
assert.strictEqual(result.status, 0, '--help should exit 0');
});
test('--help output mentions --ignore-extraneous', () => {
const result = spawnSync(process.execPath, [SCRIPT, '--help'], {
cwd: ROOT,
encoding: 'utf-8',
timeout: QUICK_SPAWN_TIMEOUT_MS,
});
// The .cjs script writes --help to stdout.
const helpText = (result.stdout ?? '') + (result.stderr ?? '');
assert.ok(
helpText.includes('--ignore-extraneous'),
`expected --help output to document --ignore-extraneous; got:\n${helpText}`
);
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3588-npm-audit-clean.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3588-npm-audit-clean (consolidation epic #1969 B6 #1975)", () => {
'use strict';
/**
* Regression test for #3588 — production dependency tree must not carry
* high or moderate npm-audit advisories.
*
* Strategy: run `npm audit --omit=dev --json` against both the root
* workspace and the embedded SDK package, then diff the resulting
* vulnerable-package set against a baseline tree (see #4196 and
* scripts/npm-audit-baseline.cjs) so the gate only fails on advisories
* this PR/push actually introduces — not on pre-existing advisories in
* an untouched transitive dependency. When no baseline can be resolved,
* falls back to the original zero-tolerance check across
* info/low/moderate/high/critical.
*
* If a future advisory lands without an upstream patch on a package this
* PR touches, either bump the patched transitive (preferred), or annotate
* the acceptance below with a justification AND a link to the upstream
* tracker.
*
* Skips automatically when `node_modules/` is absent (a fresh checkout
* before `npm install`) so the test does not falsely report on developer
* machines mid-setup.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
const {
evaluateAuditDiff,
runPackageLockAudit,
runInstalledTreeAudit,
extractBaselineTree,
resolveBaselineRef,
AUDIT_ATTEMPT_TIMEOUT_MS,
AUDIT_MAX_ATTEMPTS,
AUDIT_BACKOFF_BASE_MS,
} = require('../scripts/npm-audit-baseline.cjs');
const { cleanup, createTempDir } = require('./helpers.cjs');
const ROOT = path.resolve(__dirname, '..');
const SDK = path.join(ROOT, 'sdk');
// Worst case for ONE retry-audit call: every attempt times out, with
// exponential backoff between each (AUDIT_MAX_ATTEMPTS - 1 gaps) -- computed
// with the SAME formula scripts/npm-audit-baseline.cjs uses internally, so
// this can't independently drift from the real backoff schedule.
let totalBackoffMs = 0;
for (let attempt = 1; attempt < AUDIT_MAX_ATTEMPTS; attempt += 1) {
totalBackoffMs += AUDIT_BACKOFF_BASE_MS * (2 ** (attempt - 1));
}
const SINGLE_AUDIT_WORST_CASE_MS = (AUDIT_ATTEMPT_TIMEOUT_MS * AUDIT_MAX_ATTEMPTS) + totalBackoffMs;
// checkTreeAgainstBaseline makes up to TWO sequential retry-audit calls
// (auditProductionVulns for HEAD, runPackageLockAudit for the baseline) --
// budget for both exhausting retries simultaneously, or node:test's own
// timeout fires first and masks buildTimeoutKillError's clear message.
const TEST_TIMEOUT_MS = (SINGLE_AUDIT_WORST_CASE_MS * 2) + 30_000;
function auditProductionVulns(cwd, opts = {}) {
return runInstalledTreeAudit(cwd, opts);
}
describe('#3588: npm audit --omit=dev introduces no NEW advisories vs baseline (#4196)', () => {
// #4196: a pre-existing advisory in an untouched transitive dependency
// must not block this PR/push -- only an advisory THIS change actually
// introduces should fail the gate. When no baseline can be resolved
// (e.g. a bare local run with no git history), fall back to the
// original #3588 zero-tolerance behavior rather than silently skipping.
function checkTreeAgainstBaseline(t, cwd, subdir, skipMessage) {
const audit = auditProductionVulns(cwd);
if (audit === null) {
t.skip(skipMessage);
return;
}
const baselineRef = resolveBaselineRef(ROOT);
const baselineDir = baselineRef ? extractBaselineTree(baselineRef, ROOT, subdir) : null;
if (baselineDir === null) {
const vulns = audit.metadata.vulnerabilities;
assert.strictEqual(vulns.critical, 0, `no baseline available; falling back to zero-tolerance -- expected 0 critical; got ${vulns.critical}`);
assert.strictEqual(vulns.high, 0, `no baseline available; falling back to zero-tolerance -- expected 0 high; got ${vulns.high}`);
assert.strictEqual(vulns.moderate, 0, `no baseline available; falling back to zero-tolerance -- expected 0 moderate; got ${vulns.moderate}`);
assert.strictEqual(vulns.low, 0, `no baseline available; falling back to zero-tolerance -- expected 0 low; got ${vulns.low}`);
return;
}
t.after(() => cleanup(baselineDir));
const baselineAudit = runPackageLockAudit(baselineDir);
const baselineVulns = (baselineAudit && baselineAudit.vulnerabilities) || {};
const result = evaluateAuditDiff({
baselineVulnerabilities: baselineVulns,
headVulnerabilities: audit.vulnerabilities || {},
});
assert.strictEqual(
result.ok,
true,
result.ok
? ''
: `new advisory introduced vs baseline (${baselineRef}): ${result.newlyIntroduced.join(', ')}. Pre-existing advisories are tracked separately (see #4196) and do not block this change.`,
);
}
test('root workspace production tree introduces no new advisories', { timeout: TEST_TIMEOUT_MS }, (t) => {
checkTreeAgainstBaseline(t, ROOT, '', 'auditable npm package not present or node_modules/ missing');
});
test('sdk/ production tree introduces no new advisories', { timeout: TEST_TIMEOUT_MS }, (t) => {
checkTreeAgainstBaseline(t, SDK, 'sdk', 'sdk/ is not an auditable npm package or sdk/node_modules/ is missing');
});
});
describe('auditProductionVulns — timeout-kill retry classification (#4250, #4260)', () => {
function makeFixtureDir(t) {
const dir = createTempDir('gsd-audit-baseline-timeout-');
t.after(() => cleanup(dir));
fs.mkdirSync(path.join(dir, 'node_modules'), { recursive: true });
fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"fixture"}');
return dir;
}
function makeKilledError() {
return Object.assign(new Error('command timed out'), {
killed: true,
signal: 'SIGTERM',
stdout: '{"auditReportVersion":2,"vulnerabi', // deliberately truncated, non-empty
stderr: 'npm http fetch GET 200 https://registry.npmjs.org/-/npm/v1/security/advisories/bulk (attempt 1) 178234ms',
});
}
test('a timeout-killed execFileSync call on every attempt throws a clear timeout error after exhausting retries, not a JSON parse error', (t) => {
const dir = makeFixtureDir(t);
const execFileSyncImpl = () => { throw makeKilledError(); };
const sleepImpl = () => {};
assert.throws(
() => auditProductionVulns(dir, { execFileSyncImpl, sleepImpl }),
(err) => {
assert.match(err.message, /npm audit timed out after \d+ attempts/);
assert.match(err.message, /status\.npmjs\.org/);
assert.doesNotMatch(err.message, /Unexpected end of JSON input/);
assert.match(err.message, /Captured stderr before the last kill/);
assert.match(err.message, /npm http fetch GET/);
return true;
},
);
});
test('retry recovers: timeouts on the first attempts followed by a successful final attempt succeeds', (t) => {
const dir = makeFixtureDir(t);
const completeJson = JSON.stringify({ metadata: { vulnerabilities: { high: 0 } }, vulnerabilities: {} });
let calls = 0;
const execFileSyncImpl = () => {
calls += 1;
if (calls < 3) throw makeKilledError();
return completeJson;
};
const sleepImpl = () => {};
const result = auditProductionVulns(dir, { execFileSyncImpl, sleepImpl });
assert.deepStrictEqual(result.metadata.vulnerabilities, { high: 0 });
assert.strictEqual(calls, 3);
});
test('a normal non-zero exit with complete stdout JSON still recovers correctly (no regression)', (t) => {
const dir = makeFixtureDir(t);
const completeJson = JSON.stringify({ metadata: { vulnerabilities: { high: 1 } }, vulnerabilities: { foo: {} } });
const nonZeroExitError = Object.assign(new Error('npm audit found vulnerabilities'), {
status: 1,
stdout: completeJson,
});
const execFileSyncImpl = () => { throw nonZeroExitError; };
const result = auditProductionVulns(dir, { execFileSyncImpl });
assert.deepStrictEqual(result.metadata.vulnerabilities, { high: 1 });
});
});
});
}