fix(files): the review's fixes for the Files panel delete

The fixes #529's review asked for, each with a test.

- No inline handler. The button quoted the path inside
  onclick="...decodeURIComponent('<path>')", and encodeURIComponent leaves
  ' alone, so a file named x'-alert(1)-'.txt ran script in Codeman's
  origin and it's.txt was a syntax error. It is now bound in the existing
  .file-tree-item loops from the row's escaped data-path.
- Symbolic links are refused, never followed. validateSessionFilePath()
  returns the realpath, so deleting AGENTS.md -> CLAUDE.md deleted
  CLAUDE.md and left the link dangling. The path as named is lstat'ed,
  a link answers 400, and the inode is compared again before the unlink.
- The edit route's confinement whole: validateLocalSessionFilePath()
  behind withGuardedOutsideWorktree(), so an absolute path in a followed
  outside worktree obeys the same rules, and the sensitive, blocked and
  .git checks run on both the realpath and the path as named.
- Adopted sessions are refused like remote ones (the /tmp exception of
  the older file surfaces is not copied to a new one); Docker workspaces
  are allowed, as on the edit route. The workspace is probed bounded
  first (#516). Errors are thrown with their status.
- Deleting from search results re-runs the search, the row buttons stay
  visible on touch screens, the confirm and toasts have zh-CN text, and
  no button is drawn for remote or adopted sessions.
- Docs: CLAUDE.md, architecture-invariants, api-reference and the
  Working With Files wiki page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-09 03:09:46 +01:00
1 parent dfe1af2e5f
commit 078bd74bd1
12 files changed
+776 -37

No files matched your search

+4
View File
@@ -30,6 +30,9 @@ interface FakeRow {
dataset: Record<string, string>;
addEventListener: (type: string, handler: ClickHandler) => void;
click: () => void;
// The row's delete button is looked up per row; the fake rows carry none (file-delete-ui.test.ts
// drives it over a real DOM).
querySelector: (selector: string) => null;
}
interface FakeElement {
@@ -105,6 +108,7 @@ function fakeElement(initialClasses: string[] = []): FakeElement {
click() {
clickHandler?.();
},
querySelector: () => null,
});
}
},
+264
View File
@@ -0,0 +1,264 @@
/**
* @fileoverview The Files panel's delete button (salvaged from #529), in the browser module.
*
* #529 first rendered the button with an inline `onclick` that quoted the path inside
* `decodeURIComponent('...')`. `encodeURIComponent` leaves `'` alone, so a file named
* `x'-alert(1)-'.txt` ran script in Codeman's origin, and `it's.txt` was a syntax error whose
* button did nothing. Workspace names come from agents and cloned repos, so the click is now
* bound from the row's escaped `data-path`.
*
* The DOM is jsdom with `runScripts: 'dangerously'` on purpose: jsdom then compiles inline event
* attributes the way a browser does, with `window.app` and `window.alert` present, so the old
* markup would really call `alert` here and this test would fail on it. The escaper is the real
* one from constants.js, not a stub. panels-ui.js loads via `vm` as in
* test/file-browser-incomplete.test.ts. Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterAll, describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const PANELS = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
const CONSTANTS = readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8');
const STYLES = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
// The shipped escaper: its map, its pattern and the function, cut from constants.js.
const ESCAPE_START = CONSTANTS.indexOf('const _htmlEscapeMap');
const ESCAPE_END = CONSTANTS.indexOf('\n}\n', CONSTANTS.indexOf('function escapeHtml(')) + 3;
const ESCAPE_SRC = CONSTANTS.slice(ESCAPE_START, ESCAPE_END);
const HOSTILE = ["x'-alert(1)-'.txt", "it's.txt", 'a "b" <i>c</i> & d.txt', "');alert(2);('.txt"];
const SESSION = 'sess-1';
const dom = new JSDOM(
`<div id="fileBrowserPanel" class="visible"></div>
<input id="fileBrowserSearch">
<div id="fileBrowserStatus"></div>
<div id="fileBrowserTree"></div>`,
{ runScripts: 'dangerously' }
);
afterAll(() => dom.window.close());
const document = dom.window.document;
const win = dom.window as unknown as Record<string, unknown>;
const treeEl = () => document.getElementById('fileBrowserTree') as HTMLElement;
interface FakeResponse {
ok: boolean;
status: number;
json: () => Promise<unknown>;
}
const reply = (body: unknown, status = 200): FakeResponse => ({
ok: status >= 200 && status < 300,
status,
json: async () => body,
});
function loadPanel(
options: {
confirm?: (message: string) => boolean;
fetch?: (url: string, init?: { method?: string }) => Promise<FakeResponse>;
session?: Record<string, unknown>;
} = {}
) {
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
const context = vm.createContext({
CodemanApp,
console,
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
document: { getElementById: () => null, addEventListener: vi.fn() },
window: { addEventListener: vi.fn() },
// The search debounce, collapsed so the re-run lands on the next turn.
setTimeout: (fn: () => void) => setTimeout(fn, 0),
clearTimeout,
CodemanBase: { url: (p: string) => p },
confirm: vi.fn(options.confirm ?? (() => true)),
fetch: vi.fn(options.fetch ?? (async () => reply({ success: true, data: {} }))),
});
vm.runInContext(ESCAPE_SRC, context, { filename: 'constants.js (escapeHtml)' });
vm.runInContext(PANELS, context, { filename: 'panels-ui.js' });
const app = new CodemanApp();
app.$ = (id: string) => document.getElementById(id);
app.activeSessionId = SESSION;
app.sessions = new Map([[SESSION, options.session ?? { id: SESSION }]]);
app.fileBrowserExpandedDirs = new Set(['src']);
app.fileBrowserShowHidden = false;
app.getFileIcon = () => '';
app.formatFileSize = () => '';
app.openFilePreview = vi.fn();
app.showToast = vi.fn();
// The sentinel the old inline handler reached: `app.deleteFileFromBrowser(...)` ran in the page.
win.app = app;
win.alert = vi.fn();
return { app, context: context as Record<string, any> };
}
const fileNode = (name: string) => ({ name, path: `src/${name}`, type: 'file', size: 1, extension: 'txt' });
const treeEnvelope = (names: string[]) => ({
success: true,
data: {
root: '/repo',
tree: [{ name: 'src', path: 'src', type: 'directory', children: names.map(fileNode) }],
totalFiles: names.length,
totalDirectories: 1,
truncated: false,
},
});
const searchEnvelope = (names: string[]) => ({
success: true,
data: { mode: 'search', matches: names.map(fileNode), matchCount: names.length, truncated: false },
});
const rowFor = (path: string) =>
[...treeEl().querySelectorAll<HTMLElement>('.file-tree-item')].find((row) => row.dataset.path === path);
const deleteButtonFor = (path: string) => rowFor(path)?.querySelector<HTMLButtonElement>('.file-tree-delete');
function renderTree(app: Record<string, any>, names: string[]) {
app.fileBrowserData = app._validateFileBrowserTreeEnvelope(treeEnvelope(names));
app.renderFileBrowserTree(SESSION);
}
function renderSearch(app: Record<string, any>, names: string[]) {
app._renderFileBrowserSearchResults(names.map(fileNode), SESSION, { matchCount: names.length, truncated: false });
}
describe('Files panel delete button: no inline handler, the exact path', () => {
it.each([
['tree', renderTree],
['search results', renderSearch],
])('%s: a click deletes exactly the hostile path and runs nothing', (_label, render) => {
const { app } = loadPanel();
app.deleteFileFromBrowser = vi.fn();
render(app, HOSTILE);
expect(treeEl().innerHTML).not.toContain('decodeURIComponent');
for (const name of HOSTILE) {
const button = deleteButtonFor(`src/${name}`);
expect(button, name).toBeTruthy();
expect(button!.getAttribute('onclick')).toBeNull();
expect(button!.type).toBe('button');
button!.click();
expect(app.deleteFileFromBrowser).toHaveBeenLastCalledWith(SESSION, `src/${name}`);
}
expect(app.deleteFileFromBrowser).toHaveBeenCalledTimes(HOSTILE.length);
expect(win.alert).not.toHaveBeenCalled();
// The click stays on the button: the row behind it does not open the file.
expect(app.openFilePreview).not.toHaveBeenCalled();
// The row itself still opens it.
rowFor(`src/${HOSTILE[0]}`)!.click();
expect(app.openFilePreview).toHaveBeenCalledWith(`src/${HOSTILE[0]}`, SESSION);
});
it('labels the button with the escaped name, as text', () => {
const { app } = loadPanel();
renderTree(app, ['a "b" <i>c</i> & d.txt']);
const button = deleteButtonFor('src/a "b" <i>c</i> & d.txt')!;
expect(button.getAttribute('aria-label')).toBe('Delete a "b" <i>c</i> & d.txt');
expect(rowFor('src/a "b" <i>c</i> & d.txt')!.querySelector('i')).toBeNull();
});
it('offers no delete button on a folder row', () => {
const { app } = loadPanel();
renderTree(app, ['a.txt']);
expect(rowFor('src')!.querySelector('.file-tree-delete')).toBeNull();
expect(deleteButtonFor('src/a.txt')).toBeTruthy();
});
it.each([
['a remote (SSH) session', { id: SESSION, remote: { host: 'h' } }],
['an adopted session', { id: SESSION, adopt: { kind: 'local' } }],
])('offers none for %s, which the route refuses', (_label, session) => {
const { app } = loadPanel({ session });
renderTree(app, ['a.txt']);
expect(rowFor('src/a.txt')).toBeTruthy();
expect(deleteButtonFor('src/a.txt')).toBeNull();
renderSearch(app, ['a.txt']);
expect(rowFor('src/a.txt')).toBeTruthy();
expect(deleteButtonFor('src/a.txt')).toBeNull();
});
});
describe('deleteFileFromBrowser', () => {
it('asks first and sends nothing when the answer is no', async () => {
const { app, context } = loadPanel({ confirm: () => false });
await app.deleteFileFromBrowser(SESSION, "src/it's.txt");
expect(context.confirm).toHaveBeenCalledWith("Delete the file src/it's.txt? This cannot be undone.");
expect(context.fetch).not.toHaveBeenCalled();
});
it('sends one DELETE with the encoded path, then reloads keeping the query', async () => {
const { app, context } = loadPanel({ fetch: async () => reply({ success: true, data: { path: 'x' } }) });
app._reloadFileBrowserKeepingQuery = vi.fn(async () => {});
const path = "src/x'-alert(1)-' & #?.txt";
await app.deleteFileFromBrowser(SESSION, path);
expect(context.fetch).toHaveBeenCalledTimes(1);
expect(context.fetch).toHaveBeenCalledWith(`/api/sessions/${SESSION}/files?path=${encodeURIComponent(path)}`, {
method: 'DELETE',
});
expect(app.showToast).toHaveBeenCalledWith(`File deleted: ${path}`, 'success');
expect(app._reloadFileBrowserKeepingQuery).toHaveBeenCalledTimes(1);
});
it('shows the server reason and reloads nothing when the delete is refused', async () => {
const { app } = loadPanel({
fetch: async () => reply({ success: false, error: 'Symbolic links are not deleted from the Files panel' }, 400),
});
app._reloadFileBrowserKeepingQuery = vi.fn(async () => {});
await app.deleteFileFromBrowser(SESSION, 'AGENTS.md');
expect(app.showToast).toHaveBeenCalledWith(
'Delete failed: Symbolic links are not deleted from the Files panel',
'error'
);
expect(app._reloadFileBrowserKeepingQuery).not.toHaveBeenCalled();
});
it('removes the row from the search results it was deleted from', async () => {
let files = ['delete-me.txt', 'keep-me.txt', 'other.md'];
const { app, context } = loadPanel({
fetch: async (url, init) => {
const query = new URL(url, 'http://localhost');
if (init?.method === 'DELETE') {
const gone = query.searchParams.get('path')!.replace(/^src\//, '');
files = files.filter((name) => name !== gone);
return reply({ success: true, data: { path: query.searchParams.get('path') } });
}
const q = query.searchParams.get('q');
return reply(q ? searchEnvelope(files.filter((name) => name.includes(q))) : treeEnvelope(files));
},
});
await app.loadFileBrowser(SESSION);
(document.getElementById('fileBrowserSearch') as HTMLInputElement).value = 'me';
app.filterFileBrowser('me');
await vi.waitFor(() => expect(rowFor('src/delete-me.txt')).toBeTruthy());
expect(rowFor('src/other.md')).toBeUndefined();
deleteButtonFor('src/delete-me.txt')!.click();
await vi.waitFor(() => expect(rowFor('src/delete-me.txt')).toBeUndefined());
expect(rowFor('src/keep-me.txt')).toBeTruthy();
// Still the search view, not the tree behind it.
expect(rowFor('src/other.md')).toBeUndefined();
expect(document.getElementById('fileBrowserStatus')!.textContent).toBe('1 match');
const methods = context.fetch.mock.calls.map(([url, init]: [string, { method?: string }?]) =>
init?.method === 'DELETE' ? 'DELETE' : url.includes('&q=') ? 'search' : 'tree'
);
expect(methods).toEqual(['tree', 'search', 'DELETE', 'tree', 'search']);
});
});
describe('Files panel delete button: touch screens', () => {
it('keeps the row buttons visible where there is no hover', () => {
const blocks = [...STYLES.matchAll(/@media \(hover: none\) \{([^{}]*\{[^{}]*\})\s*\}/g)].map((m) => m[1]);
expect(
blocks.some((block) => /\.file-tree-download,\s*\.file-tree-delete\s*\{\s*display: inline;/.test(block))
).toBe(true);
});
});
+322
View File
@@ -0,0 +1,322 @@
/**
* @fileoverview `DELETE /api/sessions/:id/files?path=`, the Files panel's delete button (salvaged
* from #529).
*
* Real temp workspace, no fs mock, like test/routes/file-write-routes.test.ts: the thing under
* test is what the route does to real files, and the defect it fixes (a delete of
* `AGENTS.md -> CLAUDE.md` removed CLAUDE.md, because the confinement check hands back the
* realpath) only shows against a real symlink. Each refusal also checks that nothing on disk moved.
*
* Uses app.inject() — no real HTTP ports needed.
* Port: N/A (app.inject doesn't open ports)
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
describe('DELETE /api/sessions/:id/files (real fs)', () => {
let harness: RouteTestHarness;
let base: string;
let workDir: string;
let outsideDir: string;
const del = (path: string, h: RouteTestHarness = harness) =>
h.app.inject({
method: 'DELETE',
url: `/api/sessions/${h.ctx._sessionId}/files?path=${encodeURIComponent(path)}`,
});
beforeEach(async () => {
harness = await createRouteTestHarness(registerFileRoutes);
// realpath: tmpdir() can hold a symlinked component (macOS /tmp), and the fixtures compare paths.
base = realpathSync(mkdtempSync(join(tmpdir(), 'codeman-delete-')));
workDir = join(base, 'ws');
outsideDir = join(base, 'outside');
mkdirSync(workDir);
mkdirSync(outsideDir);
harness.ctx._session.workingDir = workDir;
});
afterEach(async () => {
await harness.app.close();
rmSync(base, { recursive: true, force: true });
rmSync(SETTINGS_PATH, { force: true });
});
it('deletes a regular file and answers the path it was given', async () => {
mkdirSync(join(workDir, 'src'));
writeFileSync(join(workDir, 'src', 'old.ts'), 'x');
writeFileSync(join(workDir, 'keep.ts'), 'y');
const res = await del('src/old.ts');
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ success: true, data: { path: 'src/old.ts' } });
expect(existsSync(join(workDir, 'src', 'old.ts'))).toBe(false);
expect(existsSync(join(workDir, 'src'))).toBe(true);
expect(readFileSync(join(workDir, 'keep.ts'), 'utf8')).toBe('y');
});
it('deletes a file whose name carries quotes and other characters a shell or a URL cares about', async () => {
const name = "x'-alert(1)-' & #?.txt";
writeFileSync(join(workDir, name), 'x');
const res = await del(name);
expect(res.statusCode).toBe(200);
expect(existsSync(join(workDir, name))).toBe(false);
});
describe('symbolic links are refused, never followed', () => {
it('leaves both the link and its target when asked to delete AGENTS.md -> CLAUDE.md', async () => {
writeFileSync(join(workDir, 'CLAUDE.md'), '# rules');
symlinkSync('CLAUDE.md', join(workDir, 'AGENTS.md'));
const res = await del('AGENTS.md');
expect(res.statusCode).toBe(400);
expect(res.json().success).toBe(false);
expect(res.json().error).toMatch(/symbolic link/i);
expect(readFileSync(join(workDir, 'CLAUDE.md'), 'utf8')).toBe('# rules');
expect(lstatSync(join(workDir, 'AGENTS.md')).isSymbolicLink()).toBe(true);
});
it('refuses a link to a folder, which the listing shows as a file row', async () => {
mkdirSync(join(workDir, 'real'));
writeFileSync(join(workDir, 'real', 'a.txt'), 'a');
symlinkSync('real', join(workDir, 'alias'));
const res = await del('alias');
expect(res.statusCode).toBe(400);
expect(readFileSync(join(workDir, 'real', 'a.txt'), 'utf8')).toBe('a');
expect(lstatSync(join(workDir, 'alias')).isSymbolicLink()).toBe(true);
});
it('answers 404 for a link that leads out of the workspace, and leaves its target', async () => {
writeFileSync(join(outsideDir, 'secret.txt'), 'secret');
symlinkSync(join(outsideDir, 'secret.txt'), join(workDir, 'leak.txt'));
const res = await del('leak.txt');
expect(res.statusCode).toBe(404);
expect(readFileSync(join(outsideDir, 'secret.txt'), 'utf8')).toBe('secret');
});
it('deletes a plain file reached through a linked folder inside the workspace', async () => {
mkdirSync(join(workDir, 'real'));
writeFileSync(join(workDir, 'real', 'b.txt'), 'b');
symlinkSync('real', join(workDir, 'alias'));
const res = await del('alias/b.txt');
expect(res.statusCode).toBe(200);
expect(existsSync(join(workDir, 'real', 'b.txt'))).toBe(false);
expect(lstatSync(join(workDir, 'alias')).isSymbolicLink()).toBe(true);
});
});
it('refuses a file under .git with 403, also when .git is reached through a linked folder', async () => {
mkdirSync(join(workDir, '.git'));
writeFileSync(join(workDir, '.git', 'config'), '[core]');
symlinkSync('.git', join(workDir, 'gitdir'));
for (const path of ['.git/config', 'gitdir/config']) {
const res = await del(path);
expect(res.statusCode).toBe(403);
expect(res.json().success).toBe(false);
}
expect(readFileSync(join(workDir, '.git', 'config'), 'utf8')).toBe('[core]');
});
it('refuses a sensitive file with 403', async () => {
writeFileSync(join(workDir, '.env'), 'TOKEN=abc');
const res = await del('.env');
expect(res.statusCode).toBe(403);
expect(readFileSync(join(workDir, '.env'), 'utf8')).toBe('TOKEN=abc');
});
it('answers 404 for a path that climbs out of the workspace', async () => {
writeFileSync(join(outsideDir, 'victim.txt'), 'v');
for (const path of ['../outside/victim.txt', join(outsideDir, 'victim.txt')]) {
const res = await del(path);
expect(res.statusCode).toBe(404);
}
expect(readFileSync(join(outsideDir, 'victim.txt'), 'utf8')).toBe('v');
});
it('answers 404 for a file that does not exist', async () => {
expect((await del('nope.txt')).statusCode).toBe(404);
});
it('refuses a directory with 400', async () => {
mkdirSync(join(workDir, 'src'));
writeFileSync(join(workDir, 'src', 'a.ts'), 'a');
const res = await del('src');
expect(res.statusCode).toBe(400);
expect(readFileSync(join(workDir, 'src', 'a.ts'), 'utf8')).toBe('a');
});
it('refuses a missing or repeated path parameter with 400', async () => {
const missing = await harness.app.inject({
method: 'DELETE',
url: `/api/sessions/${harness.ctx._sessionId}/files`,
});
expect(missing.statusCode).toBe(400);
writeFileSync(join(workDir, 'a.txt'), 'a');
const repeated = await harness.app.inject({
method: 'DELETE',
url: `/api/sessions/${harness.ctx._sessionId}/files?path=a.txt&path=a.txt`,
});
expect(repeated.statusCode).toBe(400);
expect(existsSync(join(workDir, 'a.txt'))).toBe(true);
});
it('refuses a remote (SSH) session before looking at the local twin of its folder', async () => {
// The local folder of the same absolute name is exactly the hazard: validation would resolve
// against it and the delete would land here while the user meant the remote file.
writeFileSync(join(workDir, 'twin.txt'), 'local');
harness.ctx._session.remote = {
hostId: 'host-1',
label: 'testhost',
host: '192.0.2.10',
username: 'j',
remotePath: workDir,
};
const res = await del('twin.txt');
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/remote/i);
expect(readFileSync(join(workDir, 'twin.txt'), 'utf8')).toBe('local');
});
it('refuses an adopted session, whose folder is not a workspace of ours', async () => {
writeFileSync(join(workDir, 'shared.txt'), 'tmp');
harness.ctx._session.isAdopted = true;
const res = await del('shared.txt');
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/adopted/i);
expect(readFileSync(join(workDir, 'shared.txt'), 'utf8')).toBe('tmp');
});
describe('a worktree outside the session folder', () => {
let sibling: string;
beforeEach(() => {
sibling = join(base, 'ws-feature');
mkdirSync(join(sibling, 'src'), { recursive: true });
writeFileSync(join(sibling, 'src', 'feature.ts'), 'feature');
Object.assign(harness.ctx._session, {
liveWorktree: { name: 'ws-feature', path: sibling, outside: true },
});
});
it('deletes an absolute path inside it, as the other file routes serve it', async () => {
const res = await del(join(sibling, 'src', 'feature.ts'));
expect(res.statusCode).toBe(200);
expect(existsSync(join(sibling, 'src', 'feature.ts'))).toBe(false);
});
it('does not reach it with a relative path, which resolves against the session folder only', async () => {
const res = await del('../ws-feature/src/feature.ts');
expect(res.statusCode).toBe(404);
expect(existsSync(join(sibling, 'src', 'feature.ts'))).toBe(true);
});
it('refuses a symlink in it that leads out of it, and leaves the target', async () => {
writeFileSync(join(outsideDir, 'key.txt'), 'secret');
symlinkSync(join(outsideDir, 'key.txt'), join(sibling, 'leak.txt'));
const res = await del(join(sibling, 'leak.txt'));
expect(res.statusCode).toBe(404);
expect(readFileSync(join(outsideDir, 'key.txt'), 'utf8')).toBe('secret');
});
it('reaches nothing there once it is no longer an outside worktree', async () => {
Object.assign(harness.ctx._session, { liveWorktree: { name: 'ws-feature', path: sibling } });
expect((await del(join(sibling, 'src', 'feature.ts'))).statusCode).toBe(404);
expect(existsSync(join(sibling, 'src', 'feature.ts'))).toBe(true);
});
it.each([
['workspace confinement is turned on', () => ({ attachmentConfineToWorkspace: true })],
['its parent folder is blocked', () => ({ attachmentBlockedPaths: [base] })],
])('deletes nothing there once %s', async (_label, settings) => {
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify(settings()));
const res = await del(join(sibling, 'src', 'feature.ts'));
expect(res.statusCode).toBe(404);
expect(readFileSync(join(sibling, 'src', 'feature.ts'), 'utf8')).toBe('feature');
});
});
describe('multi-user mode', () => {
let prevMultiUser: string | undefined;
beforeEach(() => {
prevMultiUser = process.env.CODEMAN_MULTIUSER;
process.env.CODEMAN_MULTIUSER = '1';
});
afterEach(() => {
if (prevMultiUser === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = prevMultiUser;
});
it("answers 404 for another user's session and deletes nothing", async () => {
const bob = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } });
try {
bob.ctx._session.workingDir = workDir;
(bob.ctx._session as unknown as { owner?: string }).owner = 'alice';
writeFileSync(join(workDir, 'alice.txt'), 'mine');
const res = await del('alice.txt', bob);
expect(res.statusCode).toBe(404);
expect(readFileSync(join(workDir, 'alice.txt'), 'utf8')).toBe('mine');
} finally {
await bob.app.close();
}
});
it('lets the owner delete in their own session', async () => {
const bob = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } });
try {
bob.ctx._session.workingDir = workDir;
(bob.ctx._session as unknown as { owner?: string }).owner = 'bob';
writeFileSync(join(workDir, 'bob.txt'), 'mine');
expect((await del('bob.txt', bob)).statusCode).toBe(200);
expect(existsSync(join(workDir, 'bob.txt'))).toBe(false);
} finally {
await bob.app.close();
}
});
});
});