mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
fix(security): scope the filesystem path picker per user in multi-user mode
Both picker endpoints are a second file-serving surface, and they inherited neither the attachment guard's confinement nor its ownership scoping. Two separate holes: 1. `sessionId` contributes that session's workingDir as a browse root, but it was resolved straight off ctx.sessions/ctx.store with no owner check, unlike the nine other session-scoped handlers in this file. A non-admin could pin ANOTHER user's working directory as a root just by passing their session id, then list and preview underneath it. Now runs canAccessOwned and reports 404, which also avoids confirming that a session id exists. 2. `Home` and `CASES_DIR` were unconditional roots for every caller. Per-user spaces live at <USER_SPACES_DIR>/<username>, which is INSIDE homedir(), so the Home root alone exposed every other user's workspace. A multi-user non-admin now gets only their own userSpacePath plus anything explicitly listed in CODEMAN_FILE_PICKER_ROOTS. /mnt/d is dropped as well: a broad host mount should be an explicit operator decision in a multi-user deployment, and operators who want it can name it in that env var. Admins and single-user mode keep the host-wide roots, so behavior is unchanged unless CODEMAN_MULTIUSER is on (opt-in, off by default). All three discriminating tests were verified to fail against the previous code: browse and preview both returned 200 instead of 404, and the roots came back as [Home, Codeman Cases, ...] instead of [My Space]. Full suite green, 3784 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
---
|
||||
'aicodeman': patch
|
||||
---
|
||||
|
||||
Fix two multi-user scoping holes in the new filesystem path picker. `GET /api/filesystem/browse` and `GET /api/filesystem/preview` accept an optional `sessionId` that contributes the session's working directory as a browse root, but they resolved it straight off the session map without an ownership check, unlike the nine other session-scoped handlers in the same route file. A non-admin could therefore pin another user's working directory as a root simply by passing their session id, then list and preview files under it. Both endpoints now run `canAccessOwned` and report 404, which also avoids confirming that a session id exists.
|
||||
|
||||
Separately, `Home` and `CASES_DIR` were unconditional browse roots for every caller. Per-user spaces live at `<USER_SPACES_DIR>/<username>`, which is inside `homedir()`, so the `Home` root alone exposed every other user's workspace to any authenticated user. In multi-user mode a non-admin now gets only their own space plus anything explicitly listed in `CODEMAN_FILE_PICKER_ROOTS`; `/mnt/d` is no longer offered by default, since a broad host mount should be an explicit operator decision in a multi-user deployment. Admins keep the host-wide roots, and single-user mode is unchanged.
|
||||
|
||||
Both holes are regression-guarded in `test/routes/file-routes.test.ts`, verified to fail against the previous code. Multi-user mode is opt-in and off by default, so single-user installs were never affected.
|
||||
@@ -211,7 +211,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments)
|
||||
|
||||
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and does not inherit the attachment confinement** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
|
||||
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
|
||||
|
||||
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode--workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
|
||||
|
||||
|
||||
@@ -80,6 +80,13 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
|
||||
|
||||
⚠️ **This is a second file-serving surface, so it carries the same confinement burden as [Attachments](#attachments) and does not inherit it automatically.** Traversal is allowlisted to Home, `CASES_DIR`, `/mnt/d`, or extra roots explicitly configured via `CODEMAN_FILE_PICKER_ROOTS`; sensitive trees are blocked and symlink escapes are rejected after `realpath` resolution rather than before. Without the realpath step a symlink inside an allowed root would walk straight out of it. `preview` reuses the shared conversion cache and the **global** `document-conversion-limiter`, which is what stops N concurrent large-document previews from forking N multi-minute converter processes. Content types are pinned: images and PDF inline, DOCX/PPTX through the converters, and Markdown/TXT/JSON as inert `text/plain` (never `text/html`, which would be stored XSS on our own origin). Size caps are 2MB for text and 50MB for binary/document previews.
|
||||
|
||||
⚠️ **Ownership scoping is also not inherited, and both endpoints must do it themselves.** Two separate holes shipped in the original version and are now regression-guarded in `test/routes/file-routes.test.ts`:
|
||||
|
||||
1. The optional `sessionId` param adds that session's `workingDir` as a "Current Folder" root. It is looked up directly off `ctx.sessions`/`ctx.store` rather than through `findSessionOrFail`, so the `canAccessOwned` check has to be written out by hand. Without it a multi-user caller pins **another user's** working directory as a browse root just by passing their session id. It reports 404 rather than 403 so the endpoint does not confirm that a session id exists.
|
||||
2. `Home` and `CASES_DIR` were unconditional roots. Per-user spaces live at `<USER_SPACES_DIR>/<username>`, which is **inside `homedir()`**, so a `Home` root alone let any authenticated user browse and preview every other user's workspace. In multi-user mode a non-admin now gets only `My Space` (their own `userSpacePath`) plus anything in `CODEMAN_FILE_PICKER_ROOTS`; `/mnt/d` is dropped too, since a broad host mount should be an explicit operator decision in a multi-user deployment. Admins and single-user mode keep the host-wide set unchanged.
|
||||
|
||||
The general rule: **any new endpoint that turns a caller-supplied `sessionId` into a filesystem path is an ownership boundary**, whether or not it goes through `findSessionOrFail`.
|
||||
|
||||
### Ultracode and workflow-run visualization
|
||||
|
||||
**Ultracode / Workflow-run visualization** (opt-in `showUltracodeAgents`, default OFF; released 1.1.2): the Workflow tool ("ultracode") writes a COMPLETION artifact per run at `~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json` (written only at run end); LIVE in-flight runs exist only as transcript dirs at `…/subagents/workflows/wf_<id>/` (journal.jsonl + `agent-*.jsonl`). `workflow-run-watcher.ts` (STANDALONE — deliberately never imports/touches `subagent-watcher.ts`; separate singleton, though it independently reads the same `subagents/workflows/` tree) scans BOTH sources via periodic poll + per-directory chokidar watchers with per-source mtime skip (LRU agentStatCache + journalCache), synthesizing ACTIVE runs (live per-agent tokens/tools/state from transcripts, title/phases from the workflow script) until the completion `wf_*.json` appears and supersedes, and broadcasts SSE `workflow:run_discovered`/`run_updated`/`run_removed`. The watcher is started when **either** `showUltracodeAgents` **or** `ultracodeFloatingWindows` is on (`server.ts` `isWorkflowAgentTrackingEnabled()` returns `(showUltracodeAgents ?? false) || (ultracodeFloatingWindows ?? false)`). Served via `GET /api/workflows` (optional `?minutes=` filter) and `GET /api/workflows/:runId`. Frontend `ultracode-panel.js` renders a docked master-detail view (LEFT: runs + phases; RIGHT: per-agent tokens + tool-calls; click an agent card → its live transcript via client-side `agentId` join). **Additionally**, `ultracode-windows.js` auto-pops a draggable **floating window per active run** (gated on a **DEDICATED** `ultracodeFloatingWindows` toggle, default OFF — independent of the dock panel's `showUltracodeAgents`; see `_ultracodeFloatingEnabled()`), connected by a glowing line to the originating session tab (resolved by `session.claudeSessionId === run.sessionUuid`) — same line idiom as subagent windows, drawn into the shared `#connectionLines` SVG from the tail of `_updateConnectionLinesImmediate`. The window auto-closes ~8s after its run finishes; explicit dismissals are remembered. Clicking an agent card opens an **in-page** connected transcript window (not a browser popup); both run and transcript windows minimize **into** the originating session tab as a merged `ULTRA` badge (🧬 runs / 📄 transcripts) with a restore/dismiss dropdown — minimized runs are skipped by auto-pop. Gesture beta: floating subagent/ultracode windows are pinch-draggable (a `window` grab kind in `entry.ts`). Types: `src/types/workflow-run.ts`. Config: `src/config/workflow-config.ts`.
|
||||
|
||||
@@ -30,6 +30,7 @@ import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
|
||||
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
|
||||
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
|
||||
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
||||
import { isMultiUserMode, userSpacePath } from '../../config/multiuser.js';
|
||||
import {
|
||||
CASES_DIR,
|
||||
canAccessOwned,
|
||||
@@ -323,33 +324,55 @@ function isBlockedPickerPath(path: string, blockedTrees: readonly string[], dire
|
||||
return directory && isBlockedAttachmentPath(join(path, '__codeman_path_picker_probe__'), blockedTrees);
|
||||
}
|
||||
|
||||
function configuredFilesystemPickerRoots(): Array<{ label: string; path: string }> {
|
||||
const candidates: Array<{ label: string; path: string }> = [
|
||||
function extraConfiguredPickerRoots(): Array<{ label: string; path: string }> {
|
||||
const extraRoots = process.env.CODEMAN_FILE_PICKER_ROOTS;
|
||||
if (!extraRoots) return [];
|
||||
return extraRoots
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
.map((path, index) => ({ label: `Configured ${index + 1}`, path }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse roots for the requesting identity.
|
||||
*
|
||||
* Single-user mode (and multi-user admins) get the host-wide set. ⚠️ A regular
|
||||
* multi-user user must NOT: per-user spaces live at `<USER_SPACES_DIR>/<name>`,
|
||||
* which is *inside* `homedir()`, so handing out a `Home` root would let any
|
||||
* authenticated user browse and preview every other user's workspace. The
|
||||
* shared `CASES_DIR` leaks the same way, and `/mnt/d` is a broad host mount
|
||||
* that a multi-user deployment should not expose by default. Operators who
|
||||
* genuinely want a shared area can still name it in `CODEMAN_FILE_PICKER_ROOTS`,
|
||||
* which stays an explicit opt-in in both modes.
|
||||
*/
|
||||
function configuredFilesystemPickerRoots(req: FastifyRequest): Array<{ label: string; path: string }> {
|
||||
const user = getAuthUser(req);
|
||||
if (isMultiUserMode() && user.role !== 'admin') {
|
||||
return [{ label: 'My Space', path: userSpacePath(user.username) }, ...extraConfiguredPickerRoots()];
|
||||
}
|
||||
return [
|
||||
{ label: 'Home', path: homedir() },
|
||||
{ label: 'Codeman Cases', path: CASES_DIR },
|
||||
{ label: 'WSL D:', path: '/mnt/d' },
|
||||
...extraConfiguredPickerRoots(),
|
||||
];
|
||||
const extraRoots = process.env.CODEMAN_FILE_PICKER_ROOTS;
|
||||
if (extraRoots) {
|
||||
for (const [index, path] of extraRoots
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
.entries()) {
|
||||
candidates.push({ label: `Configured ${index + 1}`, path });
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async function resolveFilesystemPickerRoots(
|
||||
ctx: SessionPort & ConfigPort,
|
||||
req: FastifyRequest,
|
||||
sessionId?: string
|
||||
): Promise<FilesystemBrowseRoot[]> {
|
||||
const candidates = configuredFilesystemPickerRoots();
|
||||
const candidates = configuredFilesystemPickerRoots(req);
|
||||
if (sessionId) {
|
||||
const session = ctx.sessions.get(sessionId) ?? ctx.store.getSession(sessionId);
|
||||
if (!session) {
|
||||
// ⚠️ Ownership must be checked here, exactly as `findSessionOrFail` does for
|
||||
// the other session-scoped handlers in this file. Without it a multi-user
|
||||
// caller could pin ANOTHER user's `workingDir` as a browse root just by
|
||||
// passing their sessionId. Report not-found rather than forbidden so the
|
||||
// endpoint does not confirm that a session id exists.
|
||||
if (!session || !canAccessOwned(getAuthUser(req), (session as { owner?: string }).owner)) {
|
||||
throw Object.assign(new Error(`Session ${sessionId} not found`), {
|
||||
statusCode: 404,
|
||||
body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`),
|
||||
@@ -394,10 +417,11 @@ function throwFilesystemPickerError(statusCode: number, code: ApiErrorCode, mess
|
||||
|
||||
async function resolveFilesystemPickerPath(
|
||||
ctx: SessionPort & ConfigPort,
|
||||
req: FastifyRequest,
|
||||
requestedPath: string | undefined,
|
||||
sessionId?: string
|
||||
): Promise<ResolvedFilesystemPickerPath> {
|
||||
const roots = await resolveFilesystemPickerRoots(ctx, sessionId);
|
||||
const roots = await resolveFilesystemPickerRoots(ctx, req, sessionId);
|
||||
if (roots.length === 0) {
|
||||
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'No filesystem browse roots are available');
|
||||
}
|
||||
@@ -545,6 +569,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
const { path: requestedPath, sessionId } = parseBody(FilesystemBrowseQuerySchema, req.query);
|
||||
const { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees } = await resolveFilesystemPickerPath(
|
||||
ctx,
|
||||
req,
|
||||
requestedPath,
|
||||
sessionId
|
||||
);
|
||||
@@ -665,6 +690,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
const { path: requestedPath, sessionId } = parseBody(FilesystemPreviewQuerySchema, req.query);
|
||||
const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath(
|
||||
ctx,
|
||||
req,
|
||||
requestedPath,
|
||||
sessionId
|
||||
);
|
||||
|
||||
@@ -20,18 +20,28 @@ export interface RouteTestHarness {
|
||||
* @param registerFn - The route registration function (e.g., registerSessionRoutes).
|
||||
* Uses `any` for ctx parameter because route functions expect typed port intersections
|
||||
* that MockRouteContext satisfies structurally but not nominally.
|
||||
* @param ctxOptions - Optional overrides for the mock context
|
||||
* @param ctxOptions - Optional overrides for the mock context. `authUser` stands
|
||||
* in for what the auth middleware would attach in multi-user mode; without it
|
||||
* `getAuthUser()` falls back to a synthetic admin, which passes every
|
||||
* ownership check and would make a scoping test pass vacuously.
|
||||
*/
|
||||
export async function createRouteTestHarness(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
registerFn: (app: FastifyInstance, ctx: any) => void,
|
||||
ctxOptions?: { sessionId?: string }
|
||||
ctxOptions?: { sessionId?: string; authUser?: { username: string; role: 'admin' | 'user' } }
|
||||
): Promise<RouteTestHarness> {
|
||||
const app = Fastify({ logger: false });
|
||||
|
||||
// Register cookie plugin — some routes access req.cookies
|
||||
await app.register(fastifyCookie);
|
||||
|
||||
if (ctxOptions?.authUser) {
|
||||
const authUser = ctxOptions.authUser;
|
||||
app.addHook('onRequest', async (req) => {
|
||||
(req as unknown as { authUser: typeof authUser }).authUser = authUser;
|
||||
});
|
||||
}
|
||||
|
||||
const ctx = createMockRouteContext(ctxOptions);
|
||||
|
||||
registerFn(app, ctx);
|
||||
|
||||
@@ -169,6 +169,118 @@ describe('file-routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Multi-user scoping for the filesystem picker ==========
|
||||
//
|
||||
// The picker is a SECOND file-serving surface and does not inherit the
|
||||
// attachment guard's ownership scoping, so both of its endpoints have to do
|
||||
// it themselves. Two distinct holes are covered here:
|
||||
// 1. `sessionId` was used without an owner check, so any user could pin
|
||||
// another user's workingDir as a browse root.
|
||||
// 2. `Home` and `CASES_DIR` were unconditional roots, and per-user spaces
|
||||
// live INSIDE homedir(), so Home alone exposed every other user's files.
|
||||
describe('filesystem picker multi-user scoping', () => {
|
||||
const SPACES = '/tmp/codeman-test-user-spaces';
|
||||
let prevMultiUser: string | undefined;
|
||||
let prevSpaces: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
prevMultiUser = process.env.CODEMAN_MULTIUSER;
|
||||
prevSpaces = process.env.CODEMAN_USER_SPACES_DIR;
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
process.env.CODEMAN_USER_SPACES_DIR = SPACES;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (prevMultiUser === undefined) delete process.env.CODEMAN_MULTIUSER;
|
||||
else process.env.CODEMAN_MULTIUSER = prevMultiUser;
|
||||
if (prevSpaces === undefined) delete process.env.CODEMAN_USER_SPACES_DIR;
|
||||
else process.env.CODEMAN_USER_SPACES_DIR = prevSpaces;
|
||||
});
|
||||
|
||||
const harnessAs = (role: 'admin' | 'user', username: string) =>
|
||||
createRouteTestHarness(registerFileRoutes, { authUser: { username, role } });
|
||||
|
||||
it('404s a browse scoped to another user session instead of adopting its folder', async () => {
|
||||
const scoped = await harnessAs('user', 'bob');
|
||||
scoped.ctx._session.owner = 'alice';
|
||||
try {
|
||||
const res = await scoped.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/browse?sessionId=${scoped.ctx._sessionId}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.NOT_FOUND });
|
||||
// The decisive part: alice's folder must not have leaked in as a root.
|
||||
expect(res.body).not.toContain(scoped.ctx._session.workingDir);
|
||||
} finally {
|
||||
await scoped.app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('404s a preview scoped to another user session', async () => {
|
||||
const scoped = await harnessAs('user', 'bob');
|
||||
scoped.ctx._session.owner = 'alice';
|
||||
try {
|
||||
const res = await scoped.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/preview?sessionId=${scoped.ctx._sessionId}&path=${encodeURIComponent(
|
||||
`${scoped.ctx._session.workingDir}/notes.md`
|
||||
)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
} finally {
|
||||
await scoped.app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('confines a regular user to their own space, never Home or the shared cases dir', async () => {
|
||||
const scoped = await harnessAs('user', 'bob');
|
||||
try {
|
||||
mockedReaddir.mockResolvedValueOnce([] as never);
|
||||
const res = await scoped.app.inject({ method: 'GET', url: '/api/filesystem/browse' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.roots).toEqual([{ label: 'My Space', path: `${SPACES}/bob` }]);
|
||||
expect(body.data.path).toBe(`${SPACES}/bob`);
|
||||
} finally {
|
||||
await scoped.app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses to browse another user's space by absolute path", async () => {
|
||||
const scoped = await harnessAs('user', 'bob');
|
||||
try {
|
||||
const res = await scoped.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/browse?path=${encodeURIComponent(`${SPACES}/alice/cases`)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
} finally {
|
||||
await scoped.app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the host-wide roots for a multi-user admin', async () => {
|
||||
const scoped = await harnessAs('admin', 'root');
|
||||
try {
|
||||
mockedReaddir.mockResolvedValueOnce([] as never);
|
||||
const res = await scoped.app.inject({ method: 'GET', url: '/api/filesystem/browse' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const labels = JSON.parse(res.body).data.roots.map((root: { label: string }) => root.label);
|
||||
expect(labels).toContain('Home');
|
||||
expect(labels).not.toContain('My Space');
|
||||
} finally {
|
||||
await scoped.app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/filesystem/preview ==========
|
||||
|
||||
describe('GET /api/filesystem/preview', () => {
|
||||
|
||||
Reference in New Issue
Block a user