Files
msd-core/tests/helpers-cleanup.test.cjs
sim 2e81516f1f test(#3090): accept the temp roots the suite actually uses, and say which ones
A fourth failure of the same guard, found by review before it reached CI: the
config-schema property suite builds fixtures through a getWritableTmp() helper
that returns the first writable of /private/tmp, /tmp, os.tmpdir(). On macOS
that is /private/tmp while os.tmpdir() is /var/folders/.../T, so five cleanup()
calls in that file were refused outright.

The three earlier breakages were all spellings of one root. This one is not: the
suite legitimately uses more than one temp root, so the premise was wrong rather
than the encoding. tmpRootCandidates() now probes the conventional system temp
dirs alongside os.tmpdir(), each included only if it exists on the host, so the
accepted set stays a bounded explicit list instead of growing a patch per
platform.

Two corrections that follow from the same review:

A root that is itself a filesystem root already ends in a separator, and
appending another built `//`, which only the literal `/` satisfies — TMPDIR=/
would have refused every descendant. The separator is only appended when it is
not already there.

The refusal messages named os.tmpdir(), which stopped being the boundary. They
now name the roots actually compared against. Every failure of this guard so far
was diagnosed from that message in a CI log, so it should show what was checked
rather than a stale approximation of it.

The symlink-escape check keeps its refusal but drops its claim. fs.rmSync does
not follow a top-level symlink — it unlinks the link and leaves the target
alone — so that check was never closing a live escape, and the test asserting
the victim survived would have passed with the guard removed. Both now say what
is true: a defense-in-depth boundary against a future change to the deletion
mechanism, with only the refusal itself load-bearing.

The QA path helpers were evaluated for reuse rather than keeping a third copy of
path containment. They resolve against one project directory and have no
multi-root or Windows short-name handling, so they are not a drop-in; noted
rather than forced.

Refs #3057

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 20:05:45 -04:00

321 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* GSD Tools Test Helpers – cleanup() behavioral tests
*
* Three deterministic, cross-platform tests that verify cleanup()'s
* observable contract at the seam rather than probing its internals.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { cleanup, createTempDir, tmpRootCandidates } = require('./helpers.cjs');
// ─── Test 1: Real-FS happy path ──────────────────────────────────────────────
test('cleanup removes a real temp dir with nested subdirs and files', () => {
const dir = createTempDir('gsd-cleanup-test-');
const nested = path.join(dir, 'a', 'b', 'c');
fs.mkdirSync(nested, { recursive: true });
fs.writeFileSync(path.join(nested, 'file.txt'), 'hello');
fs.writeFileSync(path.join(dir, 'root.txt'), 'world');
cleanup(dir);
assert.strictEqual(fs.existsSync(dir), false, 'temp dir should not exist after cleanup');
});
// ─── Test 2: Retry-budget contract ───────────────────────────────────────────
test('cleanup passes recursive/force/maxRetries/retryDelay options to fs.rmSync', () => {
// Use a real temp dir as the target so cleanup() has a valid path argument.
// We chdir AWAY from it first so cleanup() does not try to chdir either.
const dir = createTempDir('gsd-cleanup-opts-test-');
// Capture original cwd and shift away from the target.
const originalCwd = process.cwd();
// Chdir to the parent of the target so cleanup's cwd-guard is a no-op.
process.chdir(path.dirname(dir));
let capturedOptions = null;
const realRmSync = fs.rmSync;
try {
// Replace fs.rmSync with a probe that captures options then does nothing.
// This is an assignment expression (not a CallExpression) so it satisfies
// the ESLint rule that bans raw fs.rmSync(...) call expressions in tests.
fs.rmSync = (targetPath, opts) => {
capturedOptions = opts;
// Do NOT call through — we don't want the dir actually removed here;
// we're only testing the options shape.
};
cleanup(dir);
} finally {
fs.rmSync = realRmSync;
process.chdir(originalCwd);
// Remove the dir with the real rmSync now that we restored it.
cleanup(dir);
}
assert.ok(capturedOptions !== null, 'fs.rmSync should have been called');
assert.strictEqual(capturedOptions.recursive, true, 'recursive must be true');
assert.strictEqual(capturedOptions.force, true, 'force must be true');
assert.ok(
typeof capturedOptions.maxRetries === 'number' && capturedOptions.maxRetries > 0,
'maxRetries must be a positive number'
);
assert.ok(
typeof capturedOptions.retryDelay === 'number' && capturedOptions.retryDelay > 0,
'retryDelay must be a positive number'
);
});
// ─── Test 3: cwd-guard ───────────────────────────────────────────────────────
test('cleanup does not throw when cwd is inside the target dir, and removes the dir', () => {
const dir = createTempDir('gsd-cleanup-cwd-test-');
const nested = path.join(dir, 'deep', 'nested');
fs.mkdirSync(nested, { recursive: true });
const originalCwd = process.cwd();
try {
// Step INTO the nested subdir so cwd is inside the cleanup target.
process.chdir(nested);
assert.doesNotThrow(() => {
cleanup(dir);
}, 'cleanup should not throw even when cwd is inside the target');
} finally {
// Restore original cwd. cleanup() will have chdir'd to dirname(dir),
// so we always restore explicitly regardless.
if (process.cwd() !== originalCwd) {
process.chdir(originalCwd);
}
}
assert.strictEqual(fs.existsSync(dir), false, 'temp dir should not exist after cleanup');
});
// ─── Test 4: out-of-tmpdir refusal ───────────────────────────────────────────
test('cleanup throws and does not chdir or delete when target is outside os.tmpdir()', () => {
// __dirname (this repo's tests/ directory) must never be deleted. Whether
// it is actually outside os.tmpdir() is environment-dependent: on Linux
// os.tmpdir() is /tmp, and a CI container that checks the repo out under
// /tmp would put __dirname INSIDE tmpdir, in which case a correctly-working
// cleanup() would not refuse it -- it would delete this directory. The
// precondition assertion below verifies the "outside tmpdir" assumption
// before cleanup() is ever called, so that situation fails loudly and
// safely instead of destructively. No scratch directory is created, so
// there is nothing to tear down.
const outsideDir = __dirname;
const knownFile = path.join(outsideDir, 'helpers-cleanup.test.cjs');
// Use cleanup()'s own tmpRootCandidates() as the single source of truth
// for "is this path inside tmpdir" instead of a hand-mirrored predicate.
// The old copy here checked only path.resolve(os.tmpdir()), one of
// SEVERAL root spellings cleanup() actually accepts (it also accepts the
// realpath'd and native-realpath'd forms) — a repo checked out under a
// root this precondition didn't know about would compute "outside, safe"
// while cleanup() computed "inside, delete", turning this refusal test
// into the very destructive scenario it exists to prevent. Importing the
// real function removes that drift class entirely.
const resolvedOutsideDir = path.resolve(outsideDir);
const roots = tmpRootCandidates();
const isWindows = process.platform === 'win32';
const outsideDirForCompare = isWindows ? resolvedOutsideDir.toLowerCase() : resolvedOutsideDir;
const isInsideTmpdir = roots.some((root) => {
const rootForCompare = isWindows ? root.toLowerCase() : root;
return (
outsideDirForCompare === rootForCompare ||
outsideDirForCompare.startsWith(`${rootForCompare}${path.sep}`)
);
});
assert.strictEqual(
isInsideTmpdir,
false,
`this test cannot run safely when the repo lives under any tmpdir root ` +
`cleanup() accepts: outsideDir (${resolvedOutsideDir}) is inside one of ${JSON.stringify(roots)}`
);
const cwdBefore = process.cwd();
assert.throws(
() => cleanup(outsideDir),
(err) => err instanceof Error && err.message.includes(outsideDir),
'cleanup must throw an Error whose message names the offending path'
);
assert.strictEqual(
process.cwd(),
cwdBefore,
'cleanup must refuse before chdir, so cwd is unchanged'
);
assert.strictEqual(fs.existsSync(outsideDir), true, 'target directory must still exist after refusal');
assert.strictEqual(
fs.existsSync(knownFile),
true,
'a known file inside the target must still exist, proving the dir was not emptied'
);
});
// ─── Test 5: control — a real os.tmpdir()-rooted path still cleans up ───────
test('cleanup still removes a real os.tmpdir()-rooted directory (control)', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cleanup-control-'));
cleanup(dir);
assert.strictEqual(fs.existsSync(dir), false, 'os.tmpdir()-rooted directory should be removed');
});
// ─── Test 6b: accepted-roots set always accepts a freshly-minted tmpdir ─────
test('cleanup accepts a freshly-created os.tmpdir()-rooted dir on any platform (accepted-roots invariant)', (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cleanup-roots-'));
t.after(() => {
if (fs.existsSync(dir)) cleanup(dir);
});
assert.doesNotThrow(
() => cleanup(dir),
'cleanup must accept a directory created directly under os.tmpdir(), regardless of platform-specific canonical spelling (8.3 short/long names, /var symlink, drive-letter case)'
);
assert.strictEqual(fs.existsSync(dir), false, 'temp dir should be removed, not refused');
});
// ─── Test 6: realpath'd os.tmpdir() form is not refused (regression) ────────
test('cleanup accepts a realpath()d temp dir even when it differs from the raw path (macOS /var -> /private/var)', (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cleanup-realpath-'));
const realPath = fs.realpathSync(dir);
if (realPath !== dir) {
// macOS (and any other symlinked-tmpdir platform): the realpath'd form
// diverges from the raw mkdtempSync() path. This is exactly the shape a
// caller gets from fs.realpathSync() or from process.cwd() after
// chdir-ing into a realpath'd dir — assert the guard does NOT refuse it.
cleanup(realPath);
assert.strictEqual(fs.existsSync(realPath), false, 'realpath()d form should be removed, not refused');
assert.strictEqual(fs.existsSync(dir), false, 'raw path should also be gone (same directory)');
} else {
// Linux and any platform with no tmpdir symlink indirection: realpath()
// equals the raw path, so this branch exercises the ordinary path and
// keeps the test meaningful (non-vacuous) on both platforms.
t.after(() => {
if (fs.existsSync(dir)) cleanup(dir);
});
cleanup(dir);
assert.strictEqual(fs.existsSync(dir), false, 'temp dir should be removed');
}
});
// ─── Test 7: tmpRootCandidates() shape invariants (platform-independent) ────
//
// Test 6 above is coverage-identical to Test 5's control on any platform
// where fs.realpathSync(tmpdir) does not diverge from the raw path (Linux,
// notably — the platform the remote matrix actually runs on), so it cannot
// verify the macOS-specific fix there. These assertions hold the exported
// tmpRootCandidates() to a contract that is meaningful on every platform.
test('tmpRootCandidates() returns a well-formed, deduplicated list of absolute paths', () => {
const roots = tmpRootCandidates();
assert.ok(Array.isArray(roots) && roots.length > 0, 'must return a non-empty array');
for (const root of roots) {
assert.strictEqual(path.isAbsolute(root), true, `root must be absolute: ${root}`);
}
const isWindows = process.platform === 'win32';
const keys = roots.map((r) => (isWindows ? r.toLowerCase() : r));
const uniqueKeys = new Set(keys);
assert.strictEqual(uniqueKeys.size, keys.length, `roots must be deduplicated: ${JSON.stringify(roots)}`);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cleanup-roots-contract-'));
try {
const resolvedDir = path.resolve(dir);
const dirForCompare = isWindows ? resolvedDir.toLowerCase() : resolvedDir;
const isUnderSomeRoot = roots.some((root) => {
const rootForCompare = isWindows ? root.toLowerCase() : root;
return (
dirForCompare === rootForCompare ||
dirForCompare.startsWith(`${rootForCompare}${path.sep}`)
);
});
assert.strictEqual(
isUnderSomeRoot,
true,
`a freshly mkdtempSync'd dir under os.tmpdir() must be under at least one returned root: ` +
`${resolvedDir} vs ${JSON.stringify(roots)}`
);
} finally {
cleanup(dir);
}
});
// ─── Test 8: symlink-escape refusal, all platforms ───────────────────────────
test('cleanup refuses a symlink under tmpdir that resolves outside tmpdir', (t) => {
const symlinkParent = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cleanup-symlink-'));
t.after(() => {
if (fs.existsSync(symlinkParent)) cleanup(symlinkParent);
});
// The victim must live OUTSIDE tmpdir (that's the whole point of the
// scenario) — put it under this worktree's gitignored .gsd/ directory
// rather than os.homedir() so the test has no dependency on the runner's
// home layout. cleanup() will (correctly, by design) refuse a path there,
// so teardown cannot route through cleanup() either; the raw rmSync below
// is narrowly scoped and justified inline.
const victimParent = path.join(__dirname, '..', '.gsd');
fs.mkdirSync(victimParent, { recursive: true });
const victim = fs.mkdtempSync(path.join(victimParent, 'cleanup-symlink-victim-'));
t.after(() => {
if (fs.existsSync(victim)) {
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- victim is deliberately outside os.tmpdir() (that's the scenario under test), so cleanup() refuses it by design and cannot be used for its own teardown.
fs.rmSync(victim, { recursive: true, force: true });
}
});
fs.writeFileSync(path.join(victim, 'marker.txt'), 'victim');
const symlinkPath = path.join(symlinkParent, 'escape-link');
let symlinkCreated = true;
try {
fs.symlinkSync(victim, symlinkPath, 'dir');
} catch (error) {
symlinkCreated = false;
t.skip(`symlink creation not permitted in this environment (${error.code || error.message}) — likely Windows without developer mode`);
}
if (symlinkCreated) {
// The assert.throws below is the load-bearing assertion for this test:
// it proves the guard refuses a symlink whose target resolves outside
// every accepted tmp root.
assert.throws(
() => cleanup(symlinkPath),
(err) => err instanceof Error && err.message.includes(symlinkPath),
'cleanup must throw and name the symlink path when it resolves outside os.tmpdir()'
);
// These two assertions document the end state only — they are NOT proof
// the guard prevented a deletion that would otherwise have happened. A
// probe on node v26.5.1 showed fs.rmSync({recursive:true, force:true})
// does not follow a top-level symlink target regardless: it unlinks the
// symlink itself and leaves whatever it points at (the victim, here)
// untouched either way. So the victim would survive with or without this
// guard; the guard's own correctness is established solely by the
// assert.throws above.
assert.strictEqual(fs.existsSync(victim), true, 'victim directory still exists (rmSync would not have followed the symlink either way)');
assert.strictEqual(
fs.existsSync(path.join(victim, 'marker.txt')),
true,
'victim contents still exist (rmSync would not have followed the symlink either way)'
);
}
});