Files
Codeman/test/routes/case-custom-path-routes.test.ts
T
Codeman maintainer 5929584ea4 fix(cases): serialize every write to linked-cases.json
Five routes changed the linked-cases registry: link, unlink, rename, and a
create or clone into a custom folder. Each read the file, checked it and wrote
it back on its own, so two requests that both read before either wrote lost
an entry. Two clones that claimed one name and finished together both passed
the name check, both answered success, and the later write replaced the
earlier entry. The same race made "a name linked while the clone ran is a
409" in test/routes/case-clone-custom-folder.test.ts fail about half the time.

- New src/web/linked-cases-writer.ts, mutateLinkedCases(), shaped like
  mutateSettingsFile(): mutations run one at a time, each write is a temp
  file renamed over the registry (through a symlink, keeping its mode), and
  a file that is not a JSON object is refused (409 CONFLICT) instead of being
  read as {} and overwritten.
- All five writers go through it, with their name checks inside the
  callback, so exactly one of two requests for one name wins. Rename also
  re-checks that the old name still points at the old folder, and drops its
  manual registry undo, since a failed write no longer changes the file.
- Tests: test/linked-cases-writer.test.ts (writer behaviour, and concurrent
  link/unlink through the routes with slowed writes, which fail on the old
  code). Tests that forced a failed registry write with chmod 444 or a
  directory now inject the failure at the rename, or expect the refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:11:27 +01:00

229 lines
11 KiB
TypeScript

/**
* @fileoverview POST /api/cases with a `path`: create a new case in a custom folder. Real
* filesystem under test/setup.ts's temp HOME (the path policy itself is in test/case-path.test.ts).
* Port: N/A (app.inject()).
*
* This suite deletes and rewrites the linked-cases registry. Under test/setup.ts that file lives in a
* throwaway HOME; a raw `npx vitest` (no setup) would reach the real ~/.codeman, so every test refuses
* to start there. Each test's folders sit in a fresh mkdtemp dir, and everything is removed through
* safeRmHomeTree, so a run can only ever delete what it created.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
realpathSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import fs from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
import { dataPath } from '../../src/config/instance.js';
import { isSandboxedHome, safeRmHomeTree } from '../mocks/index.js';
const LINKED = () => dataPath('linked-cases.json');
const CASES_DIR = () => join(homedir(), 'codeman-cases');
/** test/setup.ts's temp HOME (its prefix is pinned by test/test-env-isolation.test.ts). */
const sandboxed = () => isSandboxedHome();
let workDir = '';
const work = () => workDir;
const linked = (): Record<string, string> => (existsSync(LINKED()) ? JSON.parse(readFileSync(LINKED(), 'utf8')) : {});
const create = (app: Awaited<ReturnType<typeof createRouteTestHarness>>['app'], payload: Record<string, unknown>) =>
app.inject({ method: 'POST', url: '/api/cases', payload });
beforeEach(() => {
if (!sandboxed()) {
throw new Error('case-custom-path-routes.test.ts deletes the linked-cases registry: run it via npm test');
}
// Recursive, so a test that left anything at the registry's path cannot leak into the next one.
safeRmHomeTree(LINKED());
// Resolved, because the server answers with the symlink-resolved folder (macOS temp is under /private).
workDir = realpathSync(mkdtempSync(join(homedir(), 'case-custom-path-')));
});
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
if (workDir) safeRmHomeTree(workDir);
workDir = '';
if (sandboxed()) safeRmHomeTree(LINKED());
});
describe('POST /api/cases with a custom path', () => {
it('creates the folder, scaffolds it like a normal case, and registers it as a linked case', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const target = join(work(), 'my-app');
const res = await create(app, { name: 'my-app', description: 'A thing', path: target });
expect(res.statusCode).toBe(200);
expect(res.json().data.case).toEqual({ name: 'my-app', path: target });
expect(readFileSync(join(target, 'CLAUDE.md'), 'utf8')).toContain('my-app');
expect(existsSync(join(target, 'src'))).toBe(true);
expect(existsSync(join(target, '.claude', 'settings.local.json'))).toBe(true);
expect(linked()).toEqual({ 'my-app': target });
});
it('appears in GET /api/cases at its custom path', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const target = join(work(), 'listed');
await create(app, { name: 'listed', path: target });
const list = (await app.inject({ method: 'GET', url: '/api/cases' })).json();
const cases = Array.isArray(list) ? list : list.data;
expect(cases.find((c: { name: string }) => c.name === 'listed')).toMatchObject({ path: target });
});
it('expands ~ and fills an existing EMPTY folder', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(work(), 'empty-one'));
const res = await create(app, { name: 'empty-one', path: `~/${basename(work())}/empty-one` });
expect(res.statusCode).toBe(200);
expect(existsSync(join(work(), 'empty-one', 'CLAUDE.md'))).toBe(true);
});
it('leaves the cases directory alone: nothing is created under codeman-cases', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
await create(app, { name: 'elsewhere', path: join(work(), 'elsewhere') });
expect(existsSync(join(homedir(), 'codeman-cases', 'elsewhere'))).toBe(false);
});
it('refuses a folder that already has files (409) and touches nothing', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(work(), 'existing'));
writeFileSync(join(work(), 'existing', 'keep.txt'), 'mine');
const res = await create(app, { name: 'existing', path: join(work(), 'existing') });
expect(res.statusCode).toBe(409);
expect(res.json().error).toMatch(/Link Existing/);
expect(readdirSync(join(work(), 'existing'))).toEqual(['keep.txt']);
expect(linked()).toEqual({});
});
it.each([
['a system folder', () => '/etc/my-case', 400],
['a credential folder', () => join(homedir(), '.ssh', 'x'), 400],
['the home folder itself', () => homedir(), 400],
['a relative path', () => 'projects/x', 400],
['a path with traversal', () => `${work()}/../x`, 400],
['a missing parent', () => join(work(), 'nope', 'deep', 'app'), 404],
] as const)('refuses %s', async (_label, path, status) => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const res = await create(app, { name: 'x', path: path() });
expect(res.statusCode).toBe(status);
expect(linked()).toEqual({});
});
it('refuses a duplicate case name, and a folder that is already a case, without creating anything', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
await create(app, { name: 'one', path: join(work(), 'one') });
const dupName = await create(app, { name: 'one', path: join(work(), 'two') });
expect(dupName.statusCode).toBe(409);
expect(existsSync(join(work(), 'two'))).toBe(false);
// Same folder under another name: the first case's folder now has files, which is refused earlier.
const dupPath = await create(app, { name: 'other', path: join(work(), 'one') });
expect(dupPath.statusCode).toBe(409);
expect(linked()).toEqual({ one: join(work(), 'one') });
});
it('validates the case name like a normal create', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const res = await create(app, { name: '../evil', path: join(work(), 'x') });
expect(res.statusCode).toBe(400);
expect(existsSync(join(work(), 'x'))).toBe(false);
});
it('undoes what it created when registering fails (a new folder is removed entirely)', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
// The registry is replaced by renaming a temp file over it: fail that rename.
const realRename = fs.rename;
const spy = vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => {
if (to === LINKED()) throw Object.assign(new Error('permission denied'), { code: 'EACCES' });
return realRename(from, to);
});
let res;
try {
res = await create(app, { name: 'doomed', path: join(work(), 'doomed') });
} finally {
spy.mockRestore();
}
expect(res.statusCode).toBe(500);
expect(existsSync(join(work(), 'doomed'))).toBe(false);
expect(linked()).toEqual({});
});
it('undoes only the scaffold inside an empty folder the user picked when the registry is refused', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(work(), 'picked'));
// A registry that does not parse is never written over (that would unlink every other case).
const torn = '{\n "kept": "/somewhere"\n}tail"\n}';
writeFileSync(LINKED(), torn);
const res = await create(app, { name: 'picked', path: join(work(), 'picked') });
expect(res.statusCode).toBe(409);
expect(res.json().errorCode).toBe('CONFLICT');
expect(res.json().error).toContain('is not a valid JSON object');
expect(existsSync(join(work(), 'picked'))).toBe(true);
expect(readdirSync(join(work(), 'picked'))).toEqual([]);
expect(readFileSync(LINKED(), 'utf8')).toBe(torn);
});
it('a request without a path still creates under the cases directory, as before', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const res = await create(app, { name: 'plain-case' });
expect(res.statusCode).toBe(200);
expect(existsSync(join(homedir(), 'codeman-cases', 'plain-case', 'CLAUDE.md'))).toBe(true);
expect(linked()).toEqual({});
safeRmHomeTree(join(CASES_DIR(), 'plain-case'));
});
it('refuses a target in the cases directory (400): a case there is a plain create, never a linked one', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(CASES_DIR(), 'existing-empty'), { recursive: true });
// A link from the custom parent into the cases dir is judged on its resolved form too.
symlinkSync(CASES_DIR(), join(work(), 'into-cases'));
try {
for (const path of [
join(CASES_DIR(), 'bar'),
join(CASES_DIR(), 'existing-empty'),
join(work(), 'into-cases', 'via-link'),
]) {
const res = await create(app, { name: 'bar', path });
expect(res.statusCode, path).toBe(400);
expect(res.json().error, path).toMatch(/plain Create New/);
}
expect(existsSync(join(CASES_DIR(), 'bar'))).toBe(false);
expect(existsSync(join(CASES_DIR(), 'via-link'))).toBe(false);
expect(readdirSync(join(CASES_DIR(), 'existing-empty'))).toEqual([]);
expect(linked()).toEqual({});
} finally {
safeRmHomeTree(join(CASES_DIR(), 'existing-empty'));
}
});
it('creates and links a folder two levels under the cases directory (not listed as a local case)', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(CASES_DIR(), 'parent-dir'), { recursive: true });
try {
const target = join(realpathSync(join(CASES_DIR(), 'parent-dir')), 'nested');
const res = await create(app, { name: 'nested', path: target });
expect(res.statusCode).toBe(200);
expect(res.json().data.case).toEqual({ name: 'nested', path: target });
expect(linked()).toEqual({ nested: target });
expect(existsSync(join(target, 'CLAUDE.md'))).toBe(true);
} finally {
safeRmHomeTree(join(CASES_DIR(), 'parent-dir'));
}
});
it('multi-user: a non-admin is refused (403) and nothing is created; an admin is allowed', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const user = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'bob', role: 'user' } });
const denied = await create(user.app, { name: 'bobs', path: join(work(), 'bobs') });
expect(denied.statusCode).toBe(403);
expect(existsSync(join(work(), 'bobs'))).toBe(false);
expect(linked()).toEqual({});
const admin = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'root', role: 'admin' } });
expect((await create(admin.app, { name: 'roots', path: join(work(), 'roots') })).statusCode).toBe(200);
});
});