npm test now preloads tests/helpers/no-session-id.mjs, which deletes CLAUDE_CODE_SESSION_ID before any test file loads, and the 18 files that spread process.env into a child use childEnv() instead (23 sites). A live session's stop guard is no longer pointed at a temp-repo lock (S133). engines stays >=18: --import exists from Node 18.18/20.6, and the quoted test glob already needs Node 21 (criteria-runner NODE_GLOB_MAJOR), so the real floor does not move. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
217 lines
10 KiB
JavaScript
217 lines
10 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 { 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/;
|
|
|
|
test('(d) the lock-driving test files run green and leave no pointer for a made-up id', { timeout: TIMEOUT }, () => {
|
|
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(', ')}`);
|
|
|
|
const flags = preloadFlags();
|
|
const problems = [];
|
|
for (const rel of drivers) {
|
|
for (const [mode, pre] of [['test command', flags], ['direct', []]]) {
|
|
const id = madeUpId();
|
|
const tmp = privateTmp();
|
|
try {
|
|
const r = spawnSync(process.execPath, [...pre, '--test', '--test-reporter=tap', rel],
|
|
{ cwd: ROOT, encoding: 'utf8', env: isolatedEnv(id, tmp), timeout: TIMEOUT });
|
|
const out = `${r.stdout || ''}\n${r.stderr || ''}`;
|
|
const m = out.match(/^# tests (\d+)$/m);
|
|
if (r.status !== 0) problems.push(`${rel} [${mode}]: exit ${r.status}${r.signal ? ` (${r.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`);
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
}
|
|
}
|
|
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 });
|
|
}
|
|
});
|