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
+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 () => {