fix(test): a real home is never "the test HOME" to the deletion guards

safeRmHomeTree and isUnderTestHome answered "is this path under process.env.HOME?".
With test/setup.ts skipped (a bare vitest) HOME is the developer's real home, so every
real file passed, which is how the real linked-cases registry was deleted on 2026-10-10.

They now also require HOME to be the throwaway codeman-vitest-* directory setup.ts
creates, and the shared route harness throws when it is not, so every route test refuses
to start against a real ~/.codeman rather than just the one file that checked.
This commit is contained in:
Devvyn committed 2026-10-17 05:07:38 +08:00
1 parent 1802bafc74
commit 710ed73585
5 files changed
+149 -28

No files matched your search

+9 -1
View File
@@ -7,5 +7,13 @@
export { MockSession, createMockSession, terminalOutputs } from './mock-session.js';
export { MockStateStore } from './mock-state-store.js';
export { waitForEvent, createDeferred, safeRmHomeTree, isUnderTestHome } from './test-helpers.js';
export {
waitForEvent,
createDeferred,
safeRmHomeTree,
isUnderTestHome,
isSandboxedHome,
assertSandboxedHome,
TEST_HOME_PREFIX,
} from './test-helpers.js';
export { createMockRouteContext, type MockRouteContext } from './mock-route-context.js';
+55 -24
View File
@@ -3,7 +3,7 @@
*/
import { rmSync } from 'node:fs';
import { resolve } from 'node:path';
import { basename, resolve } from 'node:path';
/** Wait for an EventEmitter to emit a specific event, with timeout */
export function waitForEvent(
@@ -39,37 +39,68 @@ export function createDeferred<T = void>(): {
}
/**
* Delete a directory tree, but ONLY when it lives inside the test HOME.
*
* SAFETY (2026-08-29): `test/setup.ts` redirects `process.env.HOME` to a
* throwaway dir and `os.homedir()` follows it, so a `rmSync(CASES_DIR,
* recursive)` normally lands inside the fixture. This gate is defense in depth
* for the day that stops being true (a test that runs outside setup.ts, an
* env override that anchors a path elsewhere): it refuses to delete anything
* not under the redirected `process.env.HOME`, so the failure mode is a
* leftover temp dir rather than a deleted PRODUCTION `~/codeman-cases`.
* Lexical `resolve()` is used because the leaf often does not exist and
* `realpathSync` would throw.
* Prefix of the throwaway HOME `test/setup.ts` creates (`mkdtempSync(join(tmpdir(), 'codeman-vitest-'))`).
* Pinned by test/test-env-isolation.test.ts, so renaming one without the other fails there.
*/
export function safeRmHomeTree(path: string): void {
export const TEST_HOME_PREFIX = 'codeman-vitest-';
/**
* True only when `process.env.HOME` is the throwaway directory `test/setup.ts` made.
*
* ⚠️ This is what the helpers below hang on, and it exists because "is the path under HOME"
* is a vacuous question when setup never ran: a bare `vitest` invocation has no setupFiles,
* HOME is the developer's REAL home, and every real file is then "under the test HOME". That is
* how a bare run on 2026-10-10 deleted the real linked-cases registry through a guard that was
* written to prevent exactly that. The temp directory's name is the only thing a real home
* cannot have.
*/
export function isSandboxedHome(): boolean {
const home = process.env.HOME;
if (!home) return;
const target = resolve(path);
const root = resolve(home);
if (target === root || target.startsWith(root + '/')) {
rmSync(target, { recursive: true, force: true });
return !!home && basename(resolve(home)).startsWith(TEST_HOME_PREFIX);
}
/**
* Throw unless the suite is running under `test/setup.ts`'s temp HOME. The shared route harness
* calls this, so every route test refuses to start against a real `~/.codeman`.
*/
export function assertSandboxedHome(what = 'this test'): void {
if (!isSandboxedHome()) {
throw new Error(
`${what} reads and writes Codeman's data files and must run under test/setup.ts's temp HOME ` +
`(HOME=${process.env.HOME ?? '(unset)'}). Run it with \`npm test\` or ` +
`\`npx vitest run --config config/vitest.ci.config.ts <file>\`, never with a bare vitest.`
);
}
}
/**
* True when `path` resolves strictly inside `process.env.HOME` (or to it).
* Same rationale as `safeRmHomeTree`; use for guarded non-recursive deletes
* (single files like `linked-cases.json`) so they can never touch prod state.
* Delete a directory tree, but ONLY when it lives inside the test HOME.
*
* SAFETY (2026-08-29, tightened 2026-10-12): `test/setup.ts` redirects `process.env.HOME` to a
* throwaway dir and `os.homedir()` follows it, so a `rmSync(CASES_DIR, recursive)` normally
* lands inside the fixture. This gate is defense in depth for the day that stops being true
* (a test that runs outside setup.ts, an env override that anchors a path elsewhere): it
* refuses to delete anything unless HOME IS the throwaway dir ({@link isSandboxedHome}) and the
* target is under it, so the failure mode is a leftover temp dir rather than a deleted
* PRODUCTION `~/codeman-cases`. The first version only checked "under HOME", which a run
* without setup.ts satisfied for the user's real home.
* Lexical `resolve()` is used because the leaf often does not exist and
* `realpathSync` would throw.
*/
export function safeRmHomeTree(path: string): void {
if (!isUnderTestHome(path)) return;
rmSync(resolve(path), { recursive: true, force: true });
}
/**
* True when `path` resolves strictly inside the throwaway test HOME (or to it), and false
* whenever HOME is anything else. Same rationale as `safeRmHomeTree`; use for guarded
* non-recursive deletes (single files like `linked-cases.json`) so they can never touch
* prod state.
*/
export function isUnderTestHome(path: string): boolean {
const home = process.env.HOME;
if (!home) return false;
if (!isSandboxedHome()) return false;
const root = resolve(process.env.HOME as string);
const target = resolve(path);
const root = resolve(home);
return target === root || target.startsWith(root + '/');
}