diff --git a/docs/api-reference.md b/docs/api-reference.md index b52b577c..e0d79f9b 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -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. diff --git a/src/web/case-path.ts b/src/web/case-path.ts index 7c91e8f6..27b68386 100644 --- a/src/web/case-path.ts +++ b/src/web/case-path.ts @@ -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 { + 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 /.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 { diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index cee646c8..538534b2 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -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}”`], [ diff --git a/src/web/public/index.html b/src/web/public/index.html index 31872f16..958371f6 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3023,11 +3023,11 @@

Create New

-

A fresh workspace under ~/codeman-cases, scaffolded with its own CLAUDE.md.

+

A fresh workspace under ~/codeman-cases, scaffolded with its own CLAUDE.md.

- Letters, numbers, hyphens, underscores only. Created in ~/codeman-cases/ + Letters, numbers, hyphens, underscores only. Created in ~/codeman-cases/
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index dfd6870e..9b2bf1b4 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -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'); diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 0777dcc2..066fc121 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -446,7 +446,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config req: FastifyRequest, reply: { code: (n: number) => unknown } ): Promise> { - 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; diff --git a/test/case-custom-path.browser.test.ts b/test/case-custom-path.browser.test.ts index 91fdb8c7..dcaf0a3d 100644 --- a/test/case-custom-path.browser.test.ts +++ b/test/case-custom-path.browser.test.ts @@ -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)'); diff --git a/test/case-path.test.ts b/test/case-path.test.ts index ce3364c0..8984a14c 100644 --- a/test/case-path.test.ts +++ b/test/case-path.test.ts @@ -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 /code/innocent/x; resolved, it is /.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' }); diff --git a/test/routes/case-custom-path-routes.test.ts b/test/routes/case-custom-path-routes.test.ts index f6a6a656..9db507d1 100644 --- a/test/routes/case-custom-path-routes.test.ts +++ b/test/routes/case-custom-path-routes.test.ts @@ -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 => (existsSync(LINKED()) ? JSON.parse(readFileSync(LINKED(), 'utf8')) : {}); const create = (app: Awaited>['app'], payload: Record) => 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 () => {