mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
- 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>
197 lines
9.6 KiB
TypeScript
197 lines
9.6 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 } from 'vitest';
|
|
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
realpathSync,
|
|
symlinkSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
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 { 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 = () => 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(() => {
|
|
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;
|
|
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);
|
|
mkdirSync(LINKED(), { recursive: true }); // writeFile onto a directory fails
|
|
const res = await create(app, { name: 'doomed', path: join(work(), 'doomed') });
|
|
expect(res.statusCode).toBe(500);
|
|
expect(existsSync(join(work(), 'doomed'))).toBe(false);
|
|
});
|
|
|
|
it('undoes only the scaffold inside an empty folder the user picked, leaving the folder', async () => {
|
|
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
|
mkdirSync(join(work(), 'picked'));
|
|
mkdirSync(LINKED(), { recursive: true });
|
|
const res = await create(app, { name: 'picked', path: join(work(), 'picked') });
|
|
expect(res.statusCode).toBe(500);
|
|
expect(existsSync(join(work(), 'picked'))).toBe(true);
|
|
expect(readdirSync(join(work(), 'picked'))).toEqual([]);
|
|
});
|
|
|
|
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('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);
|
|
});
|
|
});
|