Files
msd-core/tests/git-fixture.test.cjs
Tom Boucher 2afe17bbdb test(#3143): add the no-unbounded-spawn guard and throw-preserving git fixture (#3150)
* test(#3143): add no-unbounded-spawn guard and throw-preserving git fixture

Adds the ESLint rule local/no-unbounded-spawn, wired into the tests/**/*.cjs
block, plus an allowlist that only ratchets down: a listed file with zero
violations reports its own entry as stale.

The rule resolves renamed destructures and chained requires rather than
matching literal callee names -- both forms exist in the suite today and a
name-only matcher leaves them permanently invisible. It resolves an options
object held in a single-write const, which is what keeps process-seam.cjs,
the bounded reference implementation, from flagging itself.

timeout: 0 and anything above the 600000ms ceiling are rejected as only
nominally bounded.

Adds tests/helpers/git-fixture.cjs so a migrated execSync call site keeps
its throw-on-non-zero contract; process-seam.cjs is unchanged.

* test(#3143): prove the allowlist guards can actually fail

Extracts the D4/D6/D7/D8 checks into pure helpers and drives each against a
synthetic fixture carrying an injected violation. Without this the suite only
proved that today's clean data passes, which a deleted check would also
satisfy.

* fix(#3143): close two ceiling and alias escapes found in review

Nested arithmetic bypassed the ceiling entirely: the numeric evaluator only
resolved a flat literal, so `timeout: 60 * 60 * 1000` (3600000ms, six times
the ceiling) fell through to trusted and reported nothing. The evaluator now
recurses through arithmetic and unary signs with a depth cap.

Alias resolution was traversal-order dependent, not scope dependent: a call
textually above its own require destructure saw an empty alias map and
reported clean. The map is now built in a Program pre-pass.

Also: an explicit timeoutMs:undefined no longer overwrites the git fixture
default via spread, adds the missing seam-routed rule test, and de-duplicates
the repeated try/catch in the fixture tests.

---------

Co-authored-by: sim <sim@local>
2026-08-07 09:43:36 -04:00

214 lines
7.9 KiB
JavaScript

'use strict';
/**
* git-fixture.test.cjs
*
* Behavioral tests for tests/helpers/git-fixture.cjs's `gitOrThrow`, driving
* the real seam against real `git` in a temp fixture repo. Covers matrix
* section E of .gsd/phase/chore-3143-no-unbounded-spawn-guard/50-test-matrix.md.
*
* E10 needs to observe the exact `timeoutMs` value `gitOrThrow` forwards to
* the seam without any wall-clock measurement (both the documented default
* and the seam's own bare default would let a normal git command succeed,
* so a black-box timing test cannot distinguish them). This file installs a
* pass-through call-recording spy on `process-seam.cjs`'s `runGit` *before*
* `helpers/git-fixture.cjs` is required for the first time in this process,
* so `gitOrThrow`'s own `const { runGit } = require('./process-seam.cjs')`
* destructures the spy. With no custom implementation, `mock.method()`
* calls straight through to the real `runGit` — every test below still
* exercises real git — while additionally recording each call's arguments.
*/
const { describe, test, mock, after, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const processSeam = require('./helpers/process-seam.cjs');
const { OUTCOME } = processSeam;
const runGitSpy = mock.method(processSeam, 'runGit');
after(() => mock.restoreAll());
const { gitOrThrow, DEFAULT_GIT_TIMEOUT_MS } = require('./helpers/git-fixture.cjs');
const { createTempDir, cleanup } = require('./helpers.cjs');
/**
* Runs `fn`, returning the error it throws. Fails the calling test with a
* clear assertion if `fn` does not throw. A standalone helper (not inline
* in a test body) is the CONTRIBUTING.md-compliant place for a try/catch of
* this shape — "try/finally is only permitted inside standalone utility or
* helper functions".
*/
function captureThrown(fn) {
let caught;
try {
fn();
} catch (e) {
caught = e;
}
assert.ok(caught, 'expected fn to throw');
return caught;
}
/** Initialize a fresh repo with a known branch name and one commit. */
function initRepo(prefix = 'git-fixture-test-') {
const dir = createTempDir(prefix);
gitOrThrow(['init', '--quiet', '-b', 'mainline'], { cwd: dir });
gitOrThrow(['config', 'user.email', 'git-fixture-test@example.com'], { cwd: dir });
gitOrThrow(['config', 'user.name', 'git-fixture-test'], { cwd: dir });
gitOrThrow(['commit', '--allow-empty', '-m', 'initial commit'], { cwd: dir });
return dir;
}
describe('git-fixture: E — gitOrThrow', () => {
let dir;
beforeEach(() => {
dir = initRepo();
});
afterEach(() => {
cleanup(dir);
});
test('E1: returns stdout as a string on success', () => {
const r = gitOrThrow(['--version'], { cwd: dir });
assert.equal(typeof r, 'string');
});
test('E2: returns real command output', () => {
const r = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir });
assert.equal(r.trim(), 'mainline');
});
test('E3: throws on non-zero exit', () => {
assert.throws(() => gitOrThrow(['rev-parse', '--verify', 'refs/heads/does-not-exist'], { cwd: dir }));
});
test('E4: thrown error exposes .status (legacy execSync idiom)', () => {
const raw = processSeam.runGit(['rev-parse', '--verify', 'refs/heads/does-not-exist'], { cwd: dir });
assert.notEqual(raw.exitCode, 0);
const caught = captureThrown(() =>
gitOrThrow(['rev-parse', '--verify', 'refs/heads/does-not-exist'], { cwd: dir })
);
assert.equal(caught.status, raw.exitCode);
});
test('E5: thrown error exposes .exitCode (seam idiom), aliasing .status', () => {
const caught = captureThrown(() =>
gitOrThrow(['rev-parse', '--verify', 'refs/heads/does-not-exist'], { cwd: dir })
);
assert.equal(caught.exitCode, caught.status);
});
test('E6: thrown error carries both streams as strings', () => {
const caught = captureThrown(() =>
gitOrThrow(['rev-parse', '--verify', 'refs/heads/does-not-exist'], { cwd: dir })
);
assert.equal(typeof caught.stdout, 'string');
assert.equal(typeof caught.stderr, 'string');
assert.ok(caught.stderr.length > 0, 'expected git to write a fatal message to stderr');
});
test('E7: non-zero exit is EXITED, not a failure outcome', () => {
const caught = captureThrown(() =>
gitOrThrow(['rev-parse', '--verify', 'refs/heads/does-not-exist'], { cwd: dir })
);
assert.equal(caught.outcome, OUTCOME.EXITED);
assert.equal(caught.timedOut, false);
});
test('E8: spawn failure throws with SPAWN_FAILED', () => {
const caught = captureThrown(() =>
gitOrThrow(['--version'], { cwd: path.join(dir, 'no-such-subdirectory') })
);
assert.equal(caught.outcome, OUTCOME.SPAWN_FAILED);
});
test('E9: timeout throws and reports timedOut', () => {
const caught = captureThrown(() => gitOrThrow(['rev-parse', 'HEAD'], { cwd: dir, timeoutMs: 1 }));
assert.equal(caught.timedOut, true);
assert.equal(caught.outcome, OUTCOME.TIMED_OUT);
});
test('E10: omitted timeout uses the documented default, not silence', () => {
runGitSpy.mock.resetCalls();
gitOrThrow(['--version'], { cwd: dir });
assert.equal(runGitSpy.mock.calls.length, 1);
assert.equal(runGitSpy.mock.calls[0].arguments[1].timeoutMs, DEFAULT_GIT_TIMEOUT_MS);
assert.equal(DEFAULT_GIT_TIMEOUT_MS, 15000);
});
test('E11: explicit timeoutMs overrides the default', () => {
runGitSpy.mock.resetCalls();
gitOrThrow(['--version'], { cwd: dir, timeoutMs: 12345 });
assert.equal(runGitSpy.mock.calls.length, 1);
assert.equal(runGitSpy.mock.calls[0].arguments[1].timeoutMs, 12345);
assert.notEqual(12345, DEFAULT_GIT_TIMEOUT_MS);
});
test('E12: shell-string args are rejected', () => {
assert.throws(() => gitOrThrow('status', { cwd: dir }), TypeError);
});
test('E13: argv is never shell-interpreted', () => {
const marker = path.join(dir, 'PWNED_MARKER');
// A ref name containing shell metacharacters. spawnSync never invokes a
// shell, so this whole string reaches git as ONE literal argv element
// (the candidate ref name) — never tokenized or command-substituted.
const hostileRef = ';touch ' + marker + ';`id`;$(id)';
let threw = false;
try {
gitOrThrow(['rev-parse', '--verify', hostileRef], { cwd: dir });
} catch (_e) {
threw = true;
}
assert.ok(threw, 'expected the hostile string to fail resolution as a literal (bad) ref');
assert.equal(fs.existsSync(marker), false, 'a shell-interpreted argv would have created this file');
});
test('E14: string return is toString-compatible', () => {
const r = gitOrThrow(['--version'], { cwd: dir });
assert.equal(r.toString(), r);
});
test('E15: string return is trim-compatible', () => {
const r = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir });
assert.equal(typeof r.trim(), 'string');
assert.equal(r.trim(), 'mainline');
});
test('E16: cwd is forwarded to the seam', () => {
const otherDir = initRepo('git-fixture-test-other-');
gitOrThrow(['checkout', '-b', 'other-branch'], { cwd: otherDir });
const branchInDir = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir }).trim();
const branchInOtherDir = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: otherDir }).trim();
assert.equal(branchInDir, 'mainline');
assert.equal(branchInOtherDir, 'other-branch');
cleanup(otherDir);
});
test('E17: env is forwarded to the seam', () => {
gitOrThrow(
['commit', '--allow-empty', '-m', 'env-authored commit'],
{
cwd: dir,
env: {
...process.env,
GIT_AUTHOR_NAME: 'Env Author',
GIT_AUTHOR_EMAIL: 'env-author@example.com',
GIT_COMMITTER_NAME: 'Env Author',
GIT_COMMITTER_EMAIL: 'env-author@example.com',
},
}
);
const author = gitOrThrow(['log', '-1', '--format=%an'], { cwd: dir }).trim();
assert.equal(author, 'Env Author');
});
});