voyage/tests/lib/session-id-isolation.test.mjs
Kjell Tore Guttormsen ed6699da58
refactor(simplify): one source per rule, shared test helpers, a faster isolation test [skip-docs]
/simplify over a97efb6..HEAD (the plan_version 1.9 / trial-4 delivery).

- coordinator-contract: engine names come from engine-findings ENGINES;
  one markedMajorCodeReview predicate under both lowering checks;
  isEngineFinding everywhere; the unreachable branch (c) of
  illegalBriefLowering folded into (e).
- armB workflow: RULE_KEYS = Object.keys(RULE_TIERS); the engine map is a
  literal pinned equal to ENGINES by the workflow test.
- plan-schema: usesFilesGrammar() is the one 1.9 check (validator, sweep,
  testFirstFiles, stepTestsOnlyFromFields); opensEntry built on opensChunk.
- plan-validator: one issue builder for the Files and Test first grammar.
- plan-scope-sweep: the 1.9 and the 6.2.0 reader are one if/else.
- artifact-commit: TRAILER_RE/trailerProblem no longer exported (no
  importer); the order comment says the CLI finds the repo root first.
- tests: the lock drivers in session-id-isolation (d) run side by side per
  mode (~100 s -> ~30 s); a shared artifact-repo helper; severityRank and
  extractFindingsBlock reused; childEnv(env) without a spread; one copy of
  the PLAN_VERSION_CURRENT check.

Plan readers: validator errors/warnings, plannedPaths, fieldPaths,
testFirstFiles and stepTestsOnlyFromFields compared byte for byte against
HEAD over 510 plans / 1692 steps plus 210 synthetic 1.7-1.9 cases: same.
Suite 2384 (2382/0/2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:06:47 +02:00

228 lines
11 KiB
JavaScript

// tests/lib/session-id-isolation.test.mjs
// The test suite must not run under the live session's id. The lock CLI
// (lib/util/test-lock.mjs) writes a session pointer keyed by
// CLAUDE_CODE_SESSION_ID into <tmpdir>/voyage-test-locks-<uid>/, and the stop
// guard reads it; a test child that inherits the id of the session running
// `npm test` writes a pointer for that live session (S133). Two layers keep
// the id out:
// - `npm test` preloads tests/helpers/no-session-id.mjs, which deletes the
// id from the runner's environment before any test file starts;
// - every test that starts a child builds the child's environment with
// childEnv() (tests/helpers/child-env.mjs), which drops the id, so a
// single file run directly (`node --test <file>`) does not leak it either.
//
// Parts:
// (a) static scan: no file under tests/ spreads the process environment
// into a child, except the helper itself;
// (b) childEnv() drops an inherited id and keeps an id the caller sets;
// (c) the test command holds the preload, and the preload clears the id;
// (d) every test file that drives the lock CLI or the stop guard runs green,
// through the test command's preload and directly, and leaves no
// pointer for a made-up id;
// (e) known-positive control: the lock CLI run with that id does write the
// pointer at the computed path, so (d) cannot pass on a wrong path.
//
// Every child environment here is built without a spread (Object.entries +
// filter), so this file does not depend on the helper and does not trip (a).
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawn, spawnSync } from 'node:child_process';
import { readFileSync, readdirSync, existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync } from 'node:fs';
import { join, dirname, relative, sep } from 'node:path';
import { tmpdir } from 'node:os';
import { createHash, randomUUID } from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { makeRepo } from '../hooks/guard-helpers.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const SELF = 'tests/lib/session-id-isolation.test.mjs';
const HELPER = 'tests/helpers/child-env.mjs';
const PRELOAD = 'tests/helpers/no-session-id.mjs';
const PRELOAD_FLAG = `--import ./${PRELOAD}`;
const LOCK_CLI = join(ROOT, 'lib', 'util', 'test-lock.mjs');
const ID_VAR = 'CLAUDE_CODE_SESSION_ID';
const TIMEOUT = 300_000;
// Built from parts so this file's own text never matches them.
const PENV = 'process' + '\\.env(?![\\w$.])';
const SPREAD_PATTERNS = [
['spread', new RegExp('\\.\\.\\.\\s*' + PENV)],
['env-property', new RegExp('\\benv\\s*:\\s*' + PENV)],
['object-assign', new RegExp('Object\\.assign\\([^)]*' + PENV)],
];
function walk(dir, keep) {
const out = [];
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) out.push(...walk(p, keep));
else if (keep(e.name)) out.push(relative(ROOT, p).split(sep).join('/'));
}
return out.sort();
}
const madeUpId = () => `isolation-${randomUUID()}`;
// The pointer path, as lib/util/test-lock.mjs:76-78 computes it.
function pointerPath(tmp, id) {
const uid = process.getuid ? process.getuid() : 'u';
const name = `${createHash('sha256').update(String(id)).digest('hex').slice(0, 32)}.json`;
return join(tmp, `voyage-test-locks-${uid}`, name);
}
// The child's environment: ours without NODE_TEST_CONTEXT (so a nested
// `node --test` reports its own exit code) and without the live id, plus a
// made-up id and a private TMPDIR.
function isolatedEnv(id, tmp) {
const env = Object.fromEntries(Object.entries(process.env)
.filter(([k]) => k !== 'NODE_TEST_CONTEXT' && k !== ID_VAR));
env[ID_VAR] = id;
env.TMPDIR = tmp;
return env;
}
const privateTmp = () => realpathSync(mkdtempSync(join(tmpdir(), 'voyage-sid-')));
function testScript() {
return JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).scripts?.test ?? '';
}
// The node flags the test command puts before --test (the preload).
function preloadFlags() {
const words = testScript().split(/\s+/).filter(Boolean);
const start = words.indexOf('node');
const end = words.indexOf('--test');
if (start < 0 || end < 0 || end < start) return [];
return words.slice(start + 1, end);
}
test('(a) no test file spreads the process environment into a child, except the helper', () => {
const files = walk(join(ROOT, 'tests'), (n) => n.endsWith('.mjs'));
assert.ok(files.length >= 140, `the scan saw only ${files.length} .mjs files under tests/; an empty scan proves nothing`);
const hits = [];
for (const rel of files) {
if (rel === HELPER) continue;
const text = readFileSync(join(ROOT, rel), 'utf8');
for (const [kind, re] of SPREAD_PATTERNS) {
if (re.test(text)) hits.push(`${rel} (${kind})`);
}
}
assert.deepEqual(hits, [], `use childEnv() from ${HELPER} instead:\n${hits.join('\n')}`);
});
test('(b) childEnv() drops an inherited id and keeps an id the caller sets', async () => {
let mod;
try {
mod = await import(pathToFileURL(join(ROOT, HELPER)).href);
} catch (e) {
// Only the error's name: its message would read as a load crash to red-proof.
assert.fail(`child-env helper missing: ${HELPER} could not be loaded (${e.name})`);
}
assert.equal(typeof mod.childEnv, 'function', 'child-env helper missing: no childEnv export');
const had = Object.prototype.hasOwnProperty.call(process.env, ID_VAR);
const saved = process.env[ID_VAR];
const inherited = madeUpId();
process.env[ID_VAR] = inherited;
try {
const plain = mod.childEnv();
assert.equal(Object.prototype.hasOwnProperty.call(plain, ID_VAR), false, 'an inherited id must not reach the child');
assert.equal(plain.PATH, process.env.PATH, 'the rest of the environment is kept');
plain.VOYAGE_SID_PROBE = 'x';
assert.equal(process.env.VOYAGE_SID_PROBE, undefined, 'childEnv() returns a copy, not process.env');
const own = mod.childEnv({ [ID_VAR]: 'sess-own', VOYAGE_SID_EXTRA: 'y' });
assert.equal(own[ID_VAR], 'sess-own', 'an id the test sets itself is kept');
assert.equal(own.VOYAGE_SID_EXTRA, 'y', 'overrides are applied');
assert.equal(mod.childEnv({ PATH: '/usr/bin:/bin' }).PATH, '/usr/bin:/bin', 'overrides win over the inherited value');
} finally {
if (had) process.env[ID_VAR] = saved;
else delete process.env[ID_VAR];
}
});
test('(c) the test command preloads the helper, and the preload clears the id', () => {
assert.ok(testScript().includes(PRELOAD_FLAG),
`package.json scripts.test must hold "${PRELOAD_FLAG}"; it is: ${testScript()}`);
const tmp = privateTmp();
try {
const r = spawnSync(process.execPath,
['--import', `./${PRELOAD}`, '-e', `process.stdout.write(JSON.stringify(process.env.${ID_VAR} ?? null))`],
{ cwd: ROOT, encoding: 'utf8', env: isolatedEnv(madeUpId(), tmp) });
assert.equal(r.status, 0, `the preload child did not run: ${r.stderr}`);
assert.equal(r.stdout.trim(), 'null', 'the preload must delete the id');
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
// Files that name the lock CLI or the stop guard but do not drive either.
// Each must still exist and still match, so the list stays honest.
const NOT_DRIVERS = new Map([
['tests/lib/review-630.test.mjs', 'names the lock CLI only in source-reading assertions (:502, :952-964)'],
['tests/lib/skills-workshop-prose-pins.test.mjs', 'prose pin: checks the skill names the CLI (:85)'],
]);
const DRIVER_RE = /test-lock\.mjs|stop-test-lock/;
// One nested `node --test` run of `rel`, with its own made-up id and private
// TMPDIR. Resolves to the problems it shows.
function runDriver(rel, mode, pre) {
const id = madeUpId();
const tmp = privateTmp();
return new Promise((resolve) => {
const child = spawn(process.execPath, [...pre, '--test', '--test-reporter=tap', rel],
{ cwd: ROOT, env: isolatedEnv(id, tmp), timeout: TIMEOUT });
let out = '';
child.stdout.on('data', (d) => { out += d; });
child.stderr.on('data', (d) => { out += d; });
child.on('close', (status, signal) => {
const problems = [];
const m = out.match(/^# tests (\d+)$/m);
if (status !== 0) problems.push(`${rel} [${mode}]: exit ${status}${signal ? ` (${signal})` : ''}`);
if (!m || Number(m[1]) === 0) problems.push(`${rel} [${mode}]: reported no tests`);
if (existsSync(pointerPath(tmp, id))) problems.push(`${rel} [${mode}]: left a session pointer for the made-up id`);
rmSync(tmp, { recursive: true, force: true });
resolve(problems);
});
});
}
test('(d) the lock-driving test files run green and leave no pointer for a made-up id', { timeout: TIMEOUT }, async () => {
const all = walk(join(ROOT, 'tests'), (n) => n.endsWith('.test.mjs')).filter((f) => f !== SELF);
const matching = all.filter((rel) => DRIVER_RE.test(readFileSync(join(ROOT, rel), 'utf8')));
for (const [rel, why] of NOT_DRIVERS) {
assert.ok(existsSync(join(ROOT, rel)), `excluded file is gone, drop it from the list: ${rel} (${why})`);
assert.ok(matching.includes(rel), `excluded file no longer names the lock CLI, drop it from the list: ${rel}`);
}
const drivers = matching.filter((rel) => !NOT_DRIVERS.has(rel));
assert.ok(drivers.length >= 5, `only ${drivers.length} lock-driving test files found: ${drivers.join(', ')}`);
// The files of one mode run side by side (as `node --test` runs files); the
// two modes run one after the other.
const problems = [];
for (const [mode, pre] of [['test command', preloadFlags()], ['direct', []]]) {
const runs = await Promise.all(drivers.map((rel) => runDriver(rel, mode, pre)));
problems.push(...runs.flat());
}
assert.deepEqual(problems, []);
});
test('(e) known-positive control: the lock CLI writes the pointer at the computed path', () => {
const id = madeUpId();
const tmp = privateTmp();
const repo = makeRepo('main');
try {
mkdirSync(join(repo, 'tests'), { recursive: true });
writeFileSync(join(repo, 'tests', 'a.test.js'), 'a\n');
const r = spawnSync(process.execPath, [LOCK_CLI, 'lock', '--files', 'tests/a.test.js', '--step', '1'],
{ cwd: repo, encoding: 'utf8', env: isolatedEnv(id, tmp) });
assert.equal(r.status, 0, r.stderr);
const p = pointerPath(tmp, id);
assert.ok(existsSync(p), `the lock CLI wrote no pointer at ${p}; the path in (d) would be wrong`);
assert.ok(existsSync(JSON.parse(readFileSync(p, 'utf8')).lock_path), 'the pointer names the lock file');
} finally {
rmSync(tmp, { recursive: true, force: true });
rmSync(repo, { recursive: true, force: true });
}
});