test: extend CASES_DIR containment guard to the rest of the suite

#356 introduced safeRmHomeTree/isUnderTestHome to stop tests from deleting
the PRODUCTION ~/codeman-cases tree on platforms where os.homedir() ignores
the $HOME override -- but only applied it to the one file caught doing it
live. CASES_DIR has no CODEMAN_DATA_DIR-style env override at all, so every
other test file's raw rmSync(join(CASES_DIR, ...)) was the same unguarded
pattern, just not yet triggered.

Routes every CASES_DIR delete in these 10 files through safeRmHomeTree:
cli-skill-target, edge-cases, integration-flows, operation-lightspeed,
ralph-integration, routes/case-clone-routes, routes/voice-routes,
session-cleanup, sse-events, sse-subscription-filter.

Also fixes one instance in case-clone-routes.test.ts that mkdirSync'd then
rmSync'd a CASES_DIR path directly with no guard at all -- the exact
clobbering pattern #356 exists to prevent, found by extending the sweep.

Held as a separate commit (and intended as a separate PR once #356 merges)
rather than folding into #356 -- keeps the already-checked skinny fix
reviewable on its own; this is the same bug class applied broadly, not new
functionality.

Verified: all 10 files pass (180 tests), npm run typecheck clean.
This commit is contained in:
timkjr
2026-09-02 20:41:24 -05:00
parent 4068c02b9e
commit 4a63ab1604
10 changed files with 75 additions and 92 deletions
+9 -4
View File
@@ -18,6 +18,7 @@ import { mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { dataPath } from '../src/config/instance.js';
import { safeRmHomeTree } from './mocks/index.js';
import { program, resolveCliCasePath, resolveSkillTargetPath } from '../src/cli.js';
import { getCasesDir } from '../src/config/cases-dir.js';
@@ -37,14 +38,18 @@ function writeLinkedCases(content: string): void {
beforeEach(() => {
rmSync(LINKED_CASES_FILE, { force: true });
rmSync(CASES_DIR, { recursive: true, force: true });
rmSync(LINKED_ROOT, { recursive: true, force: true });
safeRmHomeTree(CASES_DIR);
safeRmHomeTree(LINKED_ROOT);
});
afterEach(() => {
// LINKED_CASES_FILE is dataPath('linked-cases.json') → CODEMAN_DATA_DIR,
// which test/setup.ts points at a throwaway /tmp dir, so a plain delete is
// safe here. Only homedir()-derived paths (CASES_DIR/LINKED_ROOT) need the
// containment gate.
rmSync(LINKED_CASES_FILE, { force: true });
rmSync(CASES_DIR, { recursive: true, force: true });
rmSync(LINKED_ROOT, { recursive: true, force: true });
safeRmHomeTree(CASES_DIR);
safeRmHomeTree(LINKED_ROOT);
});
describe('resolveSkillTargetPath (global)', () => {
+7 -14
View File
@@ -1,8 +1,8 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { existsSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { safeRmHomeTree } from './mocks/index.js';
const TEST_PORT = 3110;
const CASES_DIR = join(homedir(), 'codeman-cases');
@@ -19,13 +19,12 @@ describe('Edge Cases and Error Handling', () => {
});
afterEach(() => {
// Clean up cases created during this test
// Clean up cases created during this test. SAFETY: CASES_DIR is
// homedir()-derived, which on some platforms ignores the test HOME — the
// containment gate refuses to delete anything not under the temp HOME.
while (createdCases.length > 0) {
const caseName = createdCases.pop()!;
const casePath = join(CASES_DIR, caseName);
if (existsSync(casePath)) {
rmSync(casePath, { recursive: true, force: true });
}
safeRmHomeTree(join(CASES_DIR, caseName));
}
});
@@ -349,15 +348,9 @@ describe('Concurrent Session Handling', () => {
}
}
// Cleanup
const { rmSync, existsSync } = await import('node:fs');
const { join } = await import('node:path');
const { homedir } = await import('node:os');
// Cleanup (containment-gated: never touch prod ~/codeman-cases)
for (const name of createdCases) {
const path = join(homedir(), 'codeman-cases', name);
if (existsSync(path)) {
rmSync(path, { recursive: true, force: true });
}
safeRmHomeTree(join(homedir(), 'codeman-cases', name));
}
});
});
+6 -11
View File
@@ -1,8 +1,8 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { existsSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { safeRmHomeTree } from './mocks/index.js';
const TEST_PORT = 3115;
const CASES_DIR = join(homedir(), 'codeman-cases');
@@ -24,13 +24,11 @@ describe('Integration Flows', () => {
});
afterEach(() => {
// Clean up cases created during this test
// Clean up cases created during this test (containment-gated: never
// delete a case dir outside the temp HOME, e.g. prod ~/codeman-cases on
// platforms where os.homedir() ignores $HOME).
while (createdCases.length > 0) {
const caseName = createdCases.pop()!;
const casePath = join(CASES_DIR, caseName);
if (existsSync(casePath)) {
rmSync(casePath, { recursive: true, force: true });
}
safeRmHomeTree(join(CASES_DIR, createdCases.pop()!));
}
});
@@ -296,10 +294,7 @@ describe('SSE Event Flow', () => {
} catch {}
}
for (const caseName of createdCases) {
const casePath = join(CASES_DIR, caseName);
if (existsSync(casePath)) {
rmSync(casePath, { recursive: true, force: true });
}
safeRmHomeTree(join(CASES_DIR, caseName));
}
await server.stop();
}, 60000);
+5 -10
View File
@@ -12,7 +12,9 @@
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { safeRmHomeTree } from './mocks/index.js';
import { homedir } from 'node:os';
import { join } from 'node:path';
const TEST_PORT = 3215;
// Helper to parse SSE events from raw text
@@ -1043,15 +1045,8 @@ describe('Operation Lightspeed', () => {
const caseEvent = events.find((e) => e.event === 'case:created');
expect(caseEvent).toBeDefined();
// Cleanup
const { rmSync } = await import('node:fs');
const { join } = await import('node:path');
const { homedir } = await import('node:os');
try {
rmSync(join(homedir(), 'codeman-cases', caseName), { recursive: true });
} catch {
/* may not exist */
}
// Cleanup (containment-gated: never touch prod ~/codeman-cases)
safeRmHomeTree(join(homedir(), 'codeman-cases', caseName));
});
it('should deliver session:created to every client, even those with a mismatched filter', async () => {
+4 -7
View File
@@ -13,9 +13,9 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { existsSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { safeRmHomeTree } from './mocks/index.js';
const TEST_PORT = 3125;
const CASES_DIR = join(homedir(), 'codeman-cases');
@@ -33,13 +33,10 @@ describe('Ralph Integration Tests', () => {
});
afterEach(() => {
// Clean up cases created during this test
// Clean up cases created during this test (containment-gated: never
// delete a case dir outside the temp HOME).
while (createdCases.length > 0) {
const caseName = createdCases.pop()!;
const casePath = join(CASES_DIR, caseName);
if (existsSync(casePath)) {
rmSync(casePath, { recursive: true, force: true });
}
safeRmHomeTree(join(CASES_DIR, createdCases.pop()!));
}
});
+7 -5
View File
@@ -9,8 +9,10 @@
* working tree in the case directory, that scaffolding does not overwrite the
* repository's own files, and that a rejected URL never reaches git.
*
* `test/setup.ts` points HOME at a per-file temp dir, so CASES_DIR resolves
* inside the fixture and nothing touches the developer's real ~/codeman-cases.
* `test/setup.ts` points HOME at a per-file temp dir, but CASES_DIR is
* `join(homedir(), 'codeman-cases')` and `os.homedir()` ignores the HOME
* override on some platforms/Node builds — so cleanup below goes through
* `safeRmHomeTree`, which refuses to delete anything outside the temp HOME.
*
* Port: N/A (app.inject).
*/
@@ -31,7 +33,7 @@ import {
} from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { createMockRouteContext, safeRmHomeTree, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
@@ -147,7 +149,7 @@ describe('POST /api/cases/clone — input rejection', () => {
expect(res.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.ALREADY_EXISTS));
expect(JSON.parse(res.body).error).toMatch(/already exists/i);
} finally {
rmSync(join(CASES_DIR, 'taken'), { recursive: true, force: true });
safeRmHomeTree(join(CASES_DIR, 'taken'));
}
});
});
@@ -217,7 +219,7 @@ describe.skipIf(!gitPresent)('POST /api/cases/clone — real clone', () => {
afterAll(() => {
rmSync(root, { recursive: true, force: true });
for (const name of created) rmSync(join(CASES_DIR, name), { recursive: true, force: true });
for (const name of created) safeRmHomeTree(join(CASES_DIR, name));
});
beforeEach(buildApp);
+22 -14
View File
@@ -19,12 +19,33 @@ import Fastify, { type FastifyInstance } from 'fastify';
import fastifyWebsocket from '@fastify/websocket';
import WebSocket, { WebSocketServer } from 'ws';
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { registerVoiceRoutes, _resetVoiceStreamCountForTesting } from '../../src/web/routes/voice-routes.js';
import { MAX_CONCURRENT_STREAMS } from '../../src/config/voice.js';
// SAFETY (2026-08-29): anchor on the REDIRECTED test HOME (process.env.HOME,
// which test/setup.ts points at a throwaway dir) instead of os.homedir().
// On some Linux builds os.homedir() reads /etc/passwd and would resolve to the
// REAL home, clobbering the user's ~/.claude/.credentials.json.
function testHome(): string {
if (!process.env.HOME) throw new Error('process.env.HOME unset — test/setup.ts must run first');
return process.env.HOME;
}
function writeCredentials(expiresAt: number | undefined): void {
const dir = join(testHome(), '.claude');
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, '.credentials.json'),
JSON.stringify({ claudeAiOauth: { accessToken: TOKEN, expiresAt, subscriptionType: 'max' } })
);
}
function removeCredentials(): void {
rmSync(join(testHome(), '.claude', '.credentials.json'), { force: true });
}
const PORT = 3230;
const UPSTREAM_PORT = 3231;
const TOKEN = 'sk-ant-oat01-voice-route-test';
@@ -38,19 +59,6 @@ interface UpstreamCapture {
socket: WebSocket | null;
}
function writeCredentials(expiresAt: number | undefined): void {
const dir = join(homedir(), '.claude');
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, '.credentials.json'),
JSON.stringify({ claudeAiOauth: { accessToken: TOKEN, expiresAt, subscriptionType: 'max' } })
);
}
function removeCredentials(): void {
rmSync(join(homedir(), '.claude', '.credentials.json'), { force: true });
}
function waitForClose(ws: WebSocket, timeoutMs = 3000): Promise<{ code: number; reason: string }> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('WS close timeout')), timeoutMs);
+5 -11
View File
@@ -1,8 +1,9 @@
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { homedir, tmpdir } from 'node:os';
import { safeRmHomeTree } from './mocks/index.js';
const TEST_PORT = 3120;
const CASES_DIR = join(homedir(), 'codeman-cases');
@@ -27,13 +28,9 @@ describe('Session Cleanup', () => {
});
afterEach(() => {
// Clean up cases created during this test
// Clean up cases created during this test (containment-gated).
while (createdCases.length > 0) {
const caseName = createdCases.pop()!;
const casePath = join(CASES_DIR, caseName);
if (existsSync(casePath)) {
rmSync(casePath, { recursive: true, force: true });
}
safeRmHomeTree(join(CASES_DIR, createdCases.pop()!));
}
});
@@ -228,10 +225,7 @@ describe('Resource Management', () => {
afterAll(async () => {
for (const caseName of createdCases) {
const casePath = join(CASES_DIR, caseName);
if (existsSync(casePath)) {
rmSync(casePath, { recursive: true, force: true });
}
safeRmHomeTree(join(CASES_DIR, caseName));
}
await server.stop();
}, 60000);
+5 -7
View File
@@ -1,6 +1,9 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { EventEmitter } from 'node:events';
import { safeRmHomeTree } from './mocks/index.js';
import { homedir } from 'node:os';
import { join } from 'node:path';
const TEST_PORT = 3107;
@@ -295,13 +298,8 @@ describe('SSE Event Types', () => {
expect(caseCreated).toBeDefined();
expect((caseCreated?.data as any).name).toBe(caseName);
// Cleanup
const { rmSync } = await import('node:fs');
const { join } = await import('node:path');
const { homedir } = await import('node:os');
try {
rmSync(join(homedir(), 'codeman-cases', caseName), { recursive: true });
} catch {}
// Cleanup (containment-gated)
safeRmHomeTree(join(homedir(), 'codeman-cases', caseName));
});
});
});
+5 -9
View File
@@ -1,5 +1,8 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { safeRmHomeTree } from './mocks/index.js';
import { homedir } from 'node:os';
import { join } from 'node:path';
const TEST_PORT = 3212;
@@ -437,14 +440,7 @@ describe('SSE Subscription Filtering', () => {
expect(caseCreated).toBeDefined();
expect((caseCreated?.data as any).name).toBe(caseName);
// Cleanup
const { rmSync } = await import('node:fs');
const { join } = await import('node:path');
const { homedir } = await import('node:os');
try {
rmSync(join(homedir(), 'codeman-cases', caseName), { recursive: true });
} catch {
/* may not exist */
}
// Cleanup (containment-gated)
safeRmHomeTree(join(homedir(), 'codeman-cases', caseName));
});
});