mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-07 07:59:42 +02:00
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:
@@ -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
@@ -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' });
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user