fix(cases): custom-folder create landing fixes (#535)

- Route test hygiene: each test works in its own mkdtemp folder, every
  deletion goes through safeRmHomeTree, and the suite refuses to start
  outside test/setup.ts's temp HOME, so a raw `npx vitest` can no longer
  delete a real ~/projects or the live linked-cases registry.
- Path policy: the symlink-resolved target is also judged against the
  resolved home, data dir and system roots (home reached through a link,
  macOS /etc -> /private/etc); test expectations are realpath-safe.
- Refuse a target equal to or inside the caller's or the shared cases
  directory, pointing at plain Create New (it would list twice, and
  deleting the local copy removes files).
- The registry re-read comment no longer claims to prevent the
  lost-update race; documented as narrowing it, like /api/cases/link.
- UI: the success toast names the folder the server created, the
  "under ~/codeman-cases" blurb and name hint change while a custom
  folder is ticked, a "/" parent previews and sends /<name> instead of
  an empty path, and the new labels have zh-CN entries.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-05 19:51:59 +02:00
parent 566365e127
commit 192a5994e0
9 changed files with 240 additions and 28 deletions
+2 -1
View File
@@ -707,7 +707,8 @@ normal `caseName`/`mode`/etc. body)
The target is judged before anything is written:
- It must be absolute with no `..` and none of the shell metacharacters a session working directory is rejected for (spaces are fine). `400 INVALID_INPUT` otherwise.
- It must not be a system directory (`/etc`, `/usr`, `/proc`, ...), the home folder itself, Codeman's own data folder, or a credential/config tree (`~/.ssh`, `~/.aws`, `~/.claude`, ...). Judged on the path as typed and on its symlink-resolved form. `400`.
- It must not be a system directory (`/etc`, `/usr`, `/proc`, ...), the home folder itself, Codeman's own data folder, or a credential/config tree (`~/.ssh`, `~/.aws`, `~/.claude`, ...). Judged on the path as typed and on its symlink-resolved form, against both the given and the symlink-resolved roots. `400`.
- It must not be, or be inside, the cases directory (the caller's own and the shared one): a case there is a plain create without `path`. `400`.
- Its parent must already exist (one folder is created, never a chain): `404 NOT_FOUND`.
- The folder must not exist, or must be an **empty** directory; a folder with contents is Link Existing's job: `409 ALREADY_EXISTS`. A symlink or a plain file at the target is `400`.
- `409 ALREADY_EXISTS` also for a case name already in use (in the cases dir or the registry) and for a folder that is already a case.
+49 -5
View File
@@ -9,7 +9,11 @@
* a case this accepts is one a session can actually start in;
* - it must not be a system directory, the home directory itself, Codeman's own data directory, or
* a credential/config tree (`~/.ssh`, `~/.aws`, `~/.claude`, ...). Judged on the path as typed AND on
* its symlink-resolved form, so a link into `/etc` is not a way around it;
* its symlink-resolved form, against both the given and the symlink-resolved roots (a home reached
* through a link, macOS's `/etc` -> `/private/etc`), so a link into a blocked tree is not a way
* around it;
* - it must not be, or be inside, a cases directory: a case there is a plain Create New, and the same
* folder listed both as a local case and as a linked one would make deleting it remove files;
* - its parent must already exist (one folder is created, never a whole chain), and the folder
* itself must not exist or must be an EMPTY directory (a folder with contents is Link Existing's
* job, and silently scaffolding into someone's project is the one thing this must never do);
@@ -48,6 +52,11 @@ export interface NewCasePathContext {
home: string;
/** Codeman's own state directory (`getDataDir()`), which must never become a case. */
dataDir: string;
/**
* The cases directories (the caller's own and the shared one). A folder in one of them is already
* listed as a local case, so it must not be registered as a linked one too.
*/
casesDirs?: readonly string[];
}
export type NewCasePathResult =
@@ -64,10 +73,17 @@ export function expandHome(raw: string, home: string): string {
return raw;
}
/** Why a case may not live at this (already absolute and normalised) path, or null. */
export function blockedReason(absPath: string, ctx: NewCasePathContext): string | null {
/**
* Why a case may not live at this (already absolute and normalised) path, or null. `systemRoots`
* defaults to the system trees as spelled; pass their symlink-resolved forms to judge a resolved path.
*/
export function blockedReason(
absPath: string,
ctx: NewCasePathContext,
systemRoots: readonly string[] = BLOCKED_SYSTEM_ROOTS
): string | null {
if (absPath === sep) return 'The filesystem root cannot be a case';
for (const root of BLOCKED_SYSTEM_ROOTS) {
for (const root of systemRoots) {
if (isWithin(absPath, root)) return `${root} is a system directory`;
}
if (absPath === ctx.home) return 'The home folder itself cannot be a case; pick a folder inside it';
@@ -81,9 +97,34 @@ export function blockedReason(absPath: string, ctx: NewCasePathContext): string
const firstSegment = absPath.slice(ctx.home.length + 1).split(sep)[0];
if (/^\.codeman/.test(firstSegment)) return "Codeman's own data folder cannot be a case";
}
for (const dir of ctx.casesDirs ?? []) {
if (isWithin(absPath, dir)) {
return 'That folder is inside the cases folder; create a case there with plain Create New (no custom folder)';
}
}
return null;
}
/** `p` with its symlinks resolved, or `p` itself when it does not exist (or cannot be read). */
async function realpathOr(p: string): Promise<string> {
try {
return await fs.realpath(p);
} catch {
return p;
}
}
/** The context and system roots with their symlinks resolved, for judging a resolved path. */
async function resolvedPolicy(ctx: NewCasePathContext): Promise<[NewCasePathContext, string[]]> {
const [home, dataDir, casesDirs, systemRoots] = await Promise.all([
realpathOr(ctx.home),
realpathOr(ctx.dataDir),
Promise.all((ctx.casesDirs ?? []).map(realpathOr)),
Promise.all(BLOCKED_SYSTEM_ROOTS.map(realpathOr)),
]);
return [{ home, dataDir, casesDirs }, systemRoots];
}
/**
* Judge `raw` as the folder for a new case and, if it is acceptable, say what to create.
* Never creates anything.
@@ -115,7 +156,10 @@ export async function prepareNewCasePath(raw: string, ctx: NewCasePathContext):
return { ok: false, code: 'NOT_FOUND', reason: `The parent folder ${dirname(target)} does not exist` };
}
const real = join(realParent, basename(target));
const realBlock = blockedReason(real, ctx);
// The resolved path against the roots as given AND as resolved: with home reached through a link, a
// link to <real home>/.ssh is only caught by the resolved home; on macOS /etc is /private/etc.
const [resolvedCtx, resolvedSystemRoots] = await resolvedPolicy(ctx);
const realBlock = blockedReason(real, ctx) ?? blockedReason(real, resolvedCtx, resolvedSystemRoots);
if (realBlock) return { ok: false, code: 'BLOCKED', reason: realBlock };
try {
+17
View File
@@ -788,6 +788,22 @@
'现有项目文件夹的绝对路径,例如 /home/you/my-project',
'Letters, numbers, hyphens, underscores only. Created in ~/codeman-cases/':
'仅允许字母、数字、连字符和下划线;将在 ~/codeman-cases/ 中创建。',
'Letters, numbers, hyphens, underscores only. Created inside the parent folder below.':
'仅允许字母、数字、连字符和下划线;将在下方的父文件夹中创建。',
'A fresh workspace under ~/codeman-cases, scaffolded with its own CLAUDE.md.':
'在 ~/codeman-cases 下新建工作区,并生成独立的 CLAUDE.md。',
'A fresh workspace in a folder you choose, scaffolded with its own CLAUDE.md.':
'在你选择的文件夹中新建工作区,并生成独立的 CLAUDE.md。',
'Create in a custom folder': '在自定义文件夹中创建',
'📁 Create in a custom folder': '📁 在自定义文件夹中创建',
'By default a new case is created under ~/codeman-cases. Choose another folder and the case is created there instead; it is listed like any other case.':
'新案例默认创建在 ~/codeman-cases 下。选择其他文件夹后,案例会改为创建在那里,并像其他案例一样列出。',
'Parent Folder': '父文件夹',
'Pick the folder the new case folder should be created inside.': '选择要在其中创建新案例文件夹的文件夹。',
'Choose the folder to create the case in': '选择要在其中创建案例的文件夹',
'Not available for a Docker case': 'Docker 案例不可用',
'Not available with a custom folder': '使用自定义文件夹时不可用',
'Browse…': '浏览…',
'Docker exports': 'Docker 导出',
'No exports yet. Export a docker case from its tab.': '暂无导出;请从 Docker 案例标签页导出。',
'Runs inside an isolated container. Multiple sessions can share the same container.':
@@ -952,6 +968,7 @@
[/^Update available: v(.+)$/, (_m, version) => `有可用更新:v${version}`],
[/^Selected: (.+)$/, (_m, value) => `已选择:${value}`],
[/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`],
[/^Will create: (.+)$/, (_m, path) => `将创建:${path}`],
// Group names are user text: they pass through untranslated.
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
[
+2 -2
View File
@@ -3023,11 +3023,11 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M12 8v8M8 12h8"/></svg>
<h2>Create New</h2>
</div>
<p class="set-section-blurb">A fresh workspace under ~/codeman-cases, scaffolded with its own CLAUDE.md.</p>
<p class="set-section-blurb" id="newCaseBlurb">A fresh workspace under ~/codeman-cases, scaffolded with its own CLAUDE.md.</p>
<div class="form-row">
<label>Case Name</label>
<input type="text" id="newCaseName" placeholder="my-project" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false" oninput="app.updateNewCasePathPreview()">
<span class="form-hint">Letters, numbers, hyphens, underscores only. Created in ~/codeman-cases/</span>
<span class="form-hint" id="newCaseNameHint">Letters, numbers, hyphens, underscores only. Created in ~/codeman-cases/</span>
</div>
<div class="form-row">
<label>Description (optional)</label>
+21 -3
View File
@@ -3356,6 +3356,19 @@ Object.assign(CodemanApp.prototype, {
const row = document.getElementById('newCaseCustomPathRow');
if (!custom || !row) return;
row.style.display = custom.checked ? '' : 'none';
// The "under ~/codeman-cases" wording is wrong while a custom folder is picked.
const blurb = document.getElementById('newCaseBlurb');
if (blurb) {
blurb.textContent = custom.checked
? 'A fresh workspace in a folder you choose, scaffolded with its own CLAUDE.md.'
: 'A fresh workspace under ~/codeman-cases, scaffolded with its own CLAUDE.md.';
}
const nameHint = document.getElementById('newCaseNameHint');
if (nameHint) {
nameHint.textContent = custom.checked
? 'Letters, numbers, hyphens, underscores only. Created inside the parent folder below.'
: 'Letters, numbers, hyphens, underscores only. Created in ~/codeman-cases/';
}
custom.disabled = !!docker?.checked;
custom.title = docker?.checked ? 'Not available for a Docker case' : '';
if (docker) {
@@ -3367,9 +3380,12 @@ Object.assign(CodemanApp.prototype, {
/** The folder the case would be created in: the parent field plus the case name. */
_newCaseTargetPath() {
const parent = (document.getElementById('newCasePath')?.value || '').trim().replace(/\/+$/, '');
const rawParent = (document.getElementById('newCasePath')?.value || '').trim();
const name = (document.getElementById('newCaseName')?.value || '').trim();
return parent && name ? `${parent}/${name}` : '';
if (!rawParent || !name) return '';
// Trailing slashes off, but `/` stays the root rather than becoming an empty path.
const parent = rawParent.replace(/\/+$/, '');
return `${parent}/${name}`;
},
updateNewCasePathPreview() {
@@ -3443,7 +3459,9 @@ Object.assign(CodemanApp.prototype, {
// Start a session INSIDE the container (routes through quick-start).
await this.runClaude();
} else {
this.showToast(customFolder ? `Case "${name}" created in ${payload.path}` : `Case "${name}" created`, 'success');
// The server's path is the folder actually created (~ expanded, symlinks resolved).
const createdIn = data.data?.case?.path || payload.path;
this.showToast(customFolder ? `Case "${name}" created in ${createdIn}` : `Case "${name}" created`, 'success');
}
} else {
this.showToast(data.error || 'Failed to create case', 'error');
+8 -3
View File
@@ -446,7 +446,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
req: FastifyRequest,
reply: { code: (n: number) => unknown }
): Promise<ApiResponse<{ case: { name: string; path: string } }>> {
if (existsSync(join(resolveCasesDir(getAuthUser(req)), name))) {
const ownCasesDir = resolveCasesDir(getAuthUser(req));
if (existsSync(join(ownCasesDir, name))) {
reply.code(409);
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'A case with this name already exists in codeman-cases.');
}
@@ -459,7 +460,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
);
}
const prepared = await prepareNewCasePath(customPath, { home: homedir(), dataDir: getDataDir() });
// The caller's own cases dir and the shared one (the same folder outside multi-user mode).
const casesDirs = [...new Set([ownCasesDir, resolveCasesDir()])];
const prepared = await prepareNewCasePath(customPath, { home: homedir(), dataDir: getDataDir(), casesDirs });
if (!prepared.ok) {
const status = prepared.code === 'NOT_FOUND' ? 404 : prepared.code === 'EXISTS' ? 409 : 400;
reply.code(status);
@@ -494,7 +497,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
const codemanDir = getDataDir();
if (!existsSync(codemanDir)) mkdirSync(codemanDir, { recursive: true });
// Re-read right before writing: another request may have linked a case since the check above.
// Re-read right before writing, so a case linked since the check above is not dropped. This only
// narrows the window: like POST /api/cases/link, the registry write is not serialized, and two
// requests that both read before either writes can still lose one entry.
const fresh = await readLinkedCases();
if (fresh[name]) throw Object.assign(new Error(`Case "${name}" was just linked`), { conflict: true });
fresh[name] = casePath;
+36 -2
View File
@@ -1,7 +1,7 @@
/** @fileoverview Add Case → Create New → "Create in a custom folder", end to end: real server, real Chromium, real folders. */
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { basename, join } from 'node:path';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
@@ -127,6 +127,40 @@ describe('Create a case in a custom folder', () => {
expect(existsSync(join(busy, 'CLAUDE.md'))).toBe(false);
});
it('rewords the "under ~/codeman-cases" hints while a custom folder is picked', async () => {
await open();
expect(await page.textContent('#newCaseNameHint')).toMatch(/Created in ~\/codeman-cases/);
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
expect(await page.textContent('#newCaseNameHint')).toMatch(/parent folder below/);
expect(await page.textContent('#newCaseBlurb')).toMatch(/a folder you choose/);
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
expect(await page.textContent('#newCaseNameHint')).toMatch(/Created in ~\/codeman-cases/);
expect(await page.textContent('#newCaseBlurb')).toMatch(/under ~\/codeman-cases/);
});
it('keeps / as the root parent instead of sending an empty path', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCaseName', 'at-root');
await page.fill('#newCasePath', '/');
expect(await page.textContent('#newCasePathPreview')).toBe('Will create: /at-root');
expect(await page.evaluate(() => (window as any).app._newCaseTargetPath())).toBe('/at-root');
});
it('names the folder the server created in the success toast (~ expanded)', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCaseName', 'via-tilde');
await page.fill('#newCasePath', `~/${basename(parent)}`);
await page.evaluate(() => (window as any).app.submitCaseModal());
const target = join(realpathSync(parent), 'via-tilde');
await page.waitForFunction(
(t) => [...document.querySelectorAll('.toast')].some((el) => el.textContent?.includes(t)),
target
);
expect(await toastText()).not.toMatch(/created in ~\//);
});
it('starts unticked every time the modal opens', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
+47 -2
View File
@@ -1,5 +1,5 @@
// @vitest-environment node
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
@@ -10,7 +10,8 @@ let home: string;
let ctx: NewCasePathContext;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'case-path-'));
// Resolved: prepareNewCasePath answers with symlink-resolved paths (macOS temp is under /private).
root = realpathSync(mkdtempSync(join(tmpdir(), 'case-path-')));
home = join(root, 'home');
mkdirSync(join(home, 'code'), { recursive: true });
ctx = { home, dataDir: join(home, '.codeman') };
@@ -52,6 +53,19 @@ describe('blockedReason', () => {
expect(blockedReason('/etcetera/x', c)).toBeNull();
expect(blockedReason('/usrlocal', c)).toBeNull();
});
it('refuses a cases directory and anything inside it, when given', () => {
const withCases = { ...c, casesDirs: ['/home/u/codeman-cases'] };
expect(blockedReason('/home/u/codeman-cases', withCases)).toMatch(/plain Create New/);
expect(blockedReason('/home/u/codeman-cases/foo', withCases)).toMatch(/plain Create New/);
expect(blockedReason('/home/u/codeman-cases-old/foo', withCases)).toBeNull();
expect(blockedReason('/home/u/codeman-cases/foo', c)).toBeNull();
});
it('judges against the system roots it is given', () => {
expect(blockedReason('/private/etc/x', c)).toBeNull();
expect(blockedReason('/private/etc/x', c, ['/private/etc'])).toMatch(/system directory/);
});
});
describe('prepareNewCasePath', () => {
@@ -111,6 +125,37 @@ describe('prepareNewCasePath', () => {
});
});
it('judges the resolved path against the resolved home too, when home is reached through a symlink', async () => {
const realHome = join(root, 'realhome');
mkdirSync(join(realHome, '.ssh'), { recursive: true });
mkdirSync(join(realHome, 'code'));
const linkHome = join(root, 'linkhome');
symlinkSync(realHome, linkHome);
// Typed, this reads as <link home>/code/innocent/x; resolved, it is <real home>/.ssh/x.
symlinkSync(join(realHome, '.ssh'), join(realHome, 'code', 'innocent'));
const viaLink = { home: linkHome, dataDir: join(linkHome, '.codeman') };
expect(await prepareNewCasePath(join(linkHome, 'code', 'innocent', 'x'), viaLink)).toMatchObject({
ok: false,
code: 'BLOCKED',
});
// The same for Codeman's data dir given through the link.
mkdirSync(join(realHome, '.codeman'));
symlinkSync(join(realHome, '.codeman'), join(realHome, 'code', 'state'));
expect(await prepareNewCasePath(join(linkHome, 'code', 'state', 'x'), viaLink)).toMatchObject({
ok: false,
code: 'BLOCKED',
});
});
it('refuses a link into a cases directory, judged on its resolved form', async () => {
const cases = join(home, 'codeman-cases');
mkdirSync(cases);
symlinkSync(cases, join(home, 'code', 'shortcut'));
const r = await prepareNewCasePath(join(home, 'code', 'shortcut', 'app'), { ...ctx, casesDirs: [cases] });
expect(r).toMatchObject({ ok: false, code: 'BLOCKED' });
if (!r.ok) expect(r.reason).toMatch(/plain Create New/);
});
it('reports a missing parent as NOT_FOUND and never makes a chain of folders', async () => {
const r = await prepareNewCasePath(join(home, 'code', 'nope', 'deeper', 'app'), ctx);
expect(r).toMatchObject({ ok: false, code: 'NOT_FOUND' });
+58 -10
View File
@@ -2,30 +2,54 @@
* @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 } from 'vitest';
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
realpathSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
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 { safeRmHomeTree } from '../mocks/index.js';
const LINKED = () => dataPath('linked-cases.json');
const work = () => join(homedir(), 'projects');
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 = () => basename(homedir()).startsWith('codeman-vitest-');
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(() => {
rmSync(work(), { recursive: true, force: true });
rmSync(LINKED(), { recursive: true, force: true });
mkdirSync(work(), { recursive: true });
if (!sandboxed()) {
throw new Error('case-custom-path-routes.test.ts deletes the linked-cases registry: run it via npm test');
}
// Recursive: the rollback tests turn the registry into a directory.
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;
rmSync(work(), { recursive: true, force: true });
rmSync(LINKED(), { recursive: true, force: true });
if (workDir) safeRmHomeTree(workDir);
workDir = '';
if (sandboxed()) safeRmHomeTree(LINKED());
});
describe('POST /api/cases with a custom path', () => {
@@ -53,7 +77,7 @@ describe('POST /api/cases with a custom path', () => {
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: '~/projects/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);
});
@@ -132,7 +156,31 @@ describe('POST /api/cases with a custom path', () => {
expect(res.statusCode).toBe(200);
expect(existsSync(join(homedir(), 'codeman-cases', 'plain-case', 'CLAUDE.md'))).toBe(true);
expect(linked()).toEqual({});
rmSync(join(homedir(), 'codeman-cases', 'plain-case'), { recursive: true, force: true });
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('multi-user: a non-admin is refused (403) and nothing is created; an admin is allowed', async () => {