mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
The picker behind Link Existing's "Browse" and the mobile keyboard's Path key refused every path with a dot-prefixed segment, so `.github/workflows/ci.yml` could not be selected and a hidden folder could not be opened at all. It gains the same `.*` toggle as the File Viewer: default OFF, per-device, and applied to both the listing and the preview endpoint, which re-resolves the path independently. That dotfile filter was quietly doing security work. The picker's roots include Home, so with every hidden path unreachable the shared blocklist never had to name the credentials that live in dot-directories. Lifting the filter removes that accident, so `isSensitivePath` now covers them explicitly: SSH keys at any depth rather than only under $HOME, GPG keyrings, AWS/GCloud/Azure/Docker/ Kubernetes credentials, npm, Yarn, git, gh, netrc, PyPI, RubyGems, Cargo and Terraform tokens, .pgpass and .my.cnf, and the Claude and Codeman agent credentials. `~/.codeman/` and `~/.claude/` stay attachable as trees, since the publish skill and the review-card loop read from them; only their secret-bearing members are named. Everything else still applies with the toggle on: blocked trees, sensitive files, root confinement, ownership scoping and symlink-escape checks. A hidden entry whose realpath is a secret is dropped from the listing, and opening it is refused. Follows #221 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
887 lines
33 KiB
TypeScript
887 lines
33 KiB
TypeScript
/**
|
|
* @fileoverview Tests for file-routes route handlers.
|
|
*
|
|
* Uses app.inject() — no real HTTP ports needed.
|
|
* Port: N/A (app.inject doesn't open ports)
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
|
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
|
import { ApiErrorCode } from '../../src/types.js';
|
|
|
|
// Mock fs/promises for file operations
|
|
vi.mock('node:fs/promises', () => ({
|
|
default: {
|
|
readdir: vi.fn(async () => []),
|
|
readFile: vi.fn(async () => 'file content'),
|
|
stat: vi.fn(async () => ({ size: 100, isFile: () => true, isDirectory: () => true })),
|
|
},
|
|
}));
|
|
|
|
// Mock realpathSync for symlink resolution
|
|
vi.mock('node:fs', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('node:fs')>();
|
|
return {
|
|
...actual,
|
|
realpathSync: vi.fn((p: string) => p),
|
|
};
|
|
});
|
|
|
|
// Mock fileStreamManager
|
|
vi.mock('../../src/file-stream-manager.js', () => ({
|
|
fileStreamManager: {
|
|
createStream: vi.fn(async () => ({ success: true, streamId: 'stream-1' })),
|
|
closeStream: vi.fn(() => true),
|
|
},
|
|
}));
|
|
|
|
import fs from 'node:fs/promises';
|
|
import { realpathSync } from 'node:fs';
|
|
import { fileStreamManager } from '../../src/file-stream-manager.js';
|
|
|
|
const mockedReaddir = vi.mocked(fs.readdir);
|
|
const mockedReadFile = vi.mocked(fs.readFile);
|
|
const mockedStat = vi.mocked(fs.stat);
|
|
const mockedRealpathSync = vi.mocked(realpathSync);
|
|
const mockedFileStreamManager = vi.mocked(fileStreamManager);
|
|
|
|
describe('file-routes', () => {
|
|
let harness: RouteTestHarness;
|
|
|
|
beforeEach(async () => {
|
|
harness = await createRouteTestHarness(registerFileRoutes);
|
|
vi.clearAllMocks();
|
|
|
|
// Default: realpathSync returns the path unchanged
|
|
mockedRealpathSync.mockImplementation((p: string) => p as never);
|
|
// Default stat
|
|
mockedStat.mockResolvedValue({ size: 100, isFile: () => true, isDirectory: () => true } as never);
|
|
mockedReadFile.mockImplementation(async (path) =>
|
|
String(path).endsWith('settings.json') ? ('{}' as never) : ('file content' as never)
|
|
);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await harness.app.close();
|
|
});
|
|
|
|
// ========== GET /api/filesystem/browse ==========
|
|
|
|
describe('GET /api/filesystem/browse', () => {
|
|
it('lists the active session folder lazily with directories first', async () => {
|
|
mockedReaddir.mockResolvedValueOnce([
|
|
{
|
|
name: 'notes.txt',
|
|
isDirectory: () => false,
|
|
isFile: () => true,
|
|
isSymbolicLink: () => false,
|
|
},
|
|
{
|
|
name: 'src',
|
|
isDirectory: () => true,
|
|
isFile: () => false,
|
|
isSymbolicLink: () => false,
|
|
},
|
|
] as never);
|
|
|
|
const path = harness.ctx._session.workingDir;
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(true);
|
|
expect(body.data.path).toBe(path);
|
|
expect(body.data.roots[0]).toEqual({ label: 'Current Folder', path });
|
|
expect(
|
|
body.data.entries.map((entry: { name: string; type: string; previewKind?: string }) => [
|
|
entry.name,
|
|
entry.type,
|
|
entry.previewKind,
|
|
])
|
|
).toEqual([
|
|
['src', 'directory', undefined],
|
|
['notes.txt', 'file', 'text'],
|
|
]);
|
|
});
|
|
|
|
it('rejects paths outside the configured roots', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/filesystem/browse?path=${encodeURIComponent('/tmp/not-an-allowed-root')}`,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(403);
|
|
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
|
});
|
|
|
|
it('does not expose hidden entries or symlinks that escape the allowed roots', async () => {
|
|
const root = harness.ctx._session.workingDir;
|
|
mockedReaddir.mockResolvedValueOnce([
|
|
{
|
|
name: '.secret',
|
|
isDirectory: () => false,
|
|
isFile: () => true,
|
|
isSymbolicLink: () => false,
|
|
},
|
|
{
|
|
name: 'outside-link',
|
|
isDirectory: () => false,
|
|
isFile: () => false,
|
|
isSymbolicLink: () => true,
|
|
},
|
|
] as never);
|
|
mockedRealpathSync.mockImplementation((path: string) =>
|
|
path === `${root}/outside-link` ? ('/etc/shadow' as never) : (path as never)
|
|
);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(root)}`,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(JSON.parse(res.body).data.entries).toEqual([]);
|
|
});
|
|
|
|
it('returns 404 for an unknown session scope', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: '/api/filesystem/browse?sessionId=missing-session',
|
|
});
|
|
|
|
expect(res.statusCode).toBe(404);
|
|
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.NOT_FOUND });
|
|
});
|
|
|
|
it('rejects direct navigation into a hidden descendant', async () => {
|
|
const hidden = `${harness.ctx._session.workingDir}/.git`;
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(hidden)}`,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(403);
|
|
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
|
});
|
|
|
|
// ===== showHidden=true (issue #221) =====
|
|
//
|
|
// The dotfile filter used to be doing security work by accident: with every
|
|
// hidden path unreachable, the sensitive-path blocklist never had to cover
|
|
// `~/.config/gh/hosts.yml` and friends. These pin that opting in lifts the
|
|
// hidden filter and NOTHING else — blocked trees, sensitive files and root
|
|
// confinement all still apply.
|
|
describe('showHidden=true', () => {
|
|
it('lists dot-prefixed entries', async () => {
|
|
mockedReaddir.mockResolvedValueOnce([
|
|
{ name: '.github', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
|
|
{ name: '.gitignore', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
|
|
{ name: 'src', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
|
|
] as never);
|
|
|
|
const root = harness.ctx._session.workingDir;
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(root)}&showHidden=true`,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(JSON.parse(res.body).data.entries.map((e: { name: string }) => e.name)).toEqual([
|
|
'.github',
|
|
'src',
|
|
'.gitignore',
|
|
]);
|
|
});
|
|
|
|
it('allows navigating into a hidden descendant', async () => {
|
|
mockedReaddir.mockResolvedValueOnce([
|
|
{ name: 'workflows', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
|
|
] as never);
|
|
|
|
const hidden = `${harness.ctx._session.workingDir}/.github`;
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(hidden)}&showHidden=true`,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(JSON.parse(res.body).data.path).toBe(hidden);
|
|
});
|
|
|
|
it('still hides dot-prefixed entries when the flag is absent or false', async () => {
|
|
const entries = [
|
|
{ name: '.gitignore', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
|
|
{ name: 'src', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
|
|
];
|
|
const root = harness.ctx._session.workingDir;
|
|
|
|
for (const query of ['', '&showHidden=false']) {
|
|
mockedReaddir.mockResolvedValueOnce(entries as never);
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(root)}${query}`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(JSON.parse(res.body).data.entries.map((e: { name: string }) => e.name)).toEqual(['src']);
|
|
}
|
|
});
|
|
|
|
it('rejects a showHidden value that is not a boolean string', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&showHidden=yes`,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
|
});
|
|
|
|
it('still omits blocked and sensitive entries', async () => {
|
|
const root = harness.ctx._session.workingDir;
|
|
mockedReaddir.mockResolvedValueOnce([
|
|
{ name: '.ssh', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
|
|
{ name: '.npmrc', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
|
|
{ name: '.env', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
|
|
{ name: '.gitignore', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
|
|
// A plainly-named symlink whose target is a secret: caught on the
|
|
// resolved path, not the visible name.
|
|
{ name: 'notes', isDirectory: () => false, isFile: () => false, isSymbolicLink: () => true },
|
|
] as never);
|
|
mockedRealpathSync.mockImplementation((p: string) =>
|
|
p === `${root}/notes` ? (`${root}/.aws/credentials` as never) : (p as never)
|
|
);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(root)}&showHidden=true`,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(JSON.parse(res.body).data.entries.map((e: { name: string }) => e.name)).toEqual(['.gitignore']);
|
|
});
|
|
|
|
it('refuses a hidden path that resolves outside every root', async () => {
|
|
const outside = `${harness.ctx._session.workingDir}/.cache`;
|
|
mockedRealpathSync.mockImplementation((p: string) =>
|
|
p === outside ? ('/tmp/somewhere-else' as never) : (p as never)
|
|
);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(outside)}&showHidden=true`,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(403);
|
|
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
|
});
|
|
});
|
|
});
|
|
|
|
// ========== 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', () => {
|
|
it('serves Markdown as inert plain text inside the active session root', async () => {
|
|
const path = `${harness.ctx._session.workingDir}/notes.md`;
|
|
mockedReadFile.mockImplementation(async (candidate) =>
|
|
candidate === path ? ('# Safe heading\n<script>alert(1)</script>' as never) : ('{}' as never)
|
|
);
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.headers['content-type']).toContain('text/plain');
|
|
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
|
expect(res.body).toContain('<script>alert(1)</script>');
|
|
});
|
|
|
|
it('rejects unsupported file types', async () => {
|
|
const path = `${harness.ctx._session.workingDir}/archive.exe`;
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
|
});
|
|
|
|
it('rejects hidden files even when requested directly', async () => {
|
|
const path = `${harness.ctx._session.workingDir}/.env`;
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(403);
|
|
});
|
|
|
|
it('rejects a preview symlink whose real path escapes every allowed root', async () => {
|
|
const path = `${harness.ctx._session.workingDir}/outside.png`;
|
|
mockedRealpathSync.mockImplementation((candidate: string) =>
|
|
candidate === path ? ('/etc/shadow' as never) : (candidate as never)
|
|
);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(403);
|
|
});
|
|
|
|
it('caps text previews at 2MB', async () => {
|
|
const path = `${harness.ctx._session.workingDir}/large.txt`;
|
|
mockedStat.mockImplementation(async (candidate) =>
|
|
candidate === path
|
|
? ({ size: 2 * 1024 * 1024 + 1, isFile: () => true, isDirectory: () => false } as never)
|
|
: ({ size: 100, isFile: () => true, isDirectory: () => true } as never)
|
|
);
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(413);
|
|
});
|
|
});
|
|
|
|
// ========== GET /api/sessions/:id/files ==========
|
|
|
|
describe('GET /api/sessions/:id/files', () => {
|
|
it('returns 404 for unknown session', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: '/api/sessions/nonexistent/files',
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it('returns file tree for valid session', async () => {
|
|
mockedReaddir.mockResolvedValue([
|
|
{ name: 'src', isDirectory: () => true },
|
|
{ name: 'package.json', isDirectory: () => false, name_: 'package.json' },
|
|
] as never);
|
|
// Nested readdir for src/ returns empty
|
|
mockedReaddir.mockResolvedValueOnce([
|
|
{ name: 'src', isDirectory: () => true },
|
|
{ name: 'package.json', isDirectory: () => false },
|
|
] as never);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/files`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(true);
|
|
expect(body.data.root).toBe(harness.ctx._session.workingDir);
|
|
expect(body.data.tree).toBeDefined();
|
|
});
|
|
|
|
it('respects depth parameter', async () => {
|
|
mockedReaddir.mockResolvedValue([] as never);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/files?depth=2`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(true);
|
|
});
|
|
|
|
it('excludes hidden files by default', async () => {
|
|
mockedReaddir.mockResolvedValue([
|
|
{ name: '.hidden', isDirectory: () => false },
|
|
{ name: 'visible.ts', isDirectory: () => false },
|
|
] as never);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/files`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
// Hidden files should be excluded
|
|
expect(body.data.totalFiles).toBe(1);
|
|
});
|
|
|
|
it('includes hidden files when showHidden=true', async () => {
|
|
mockedReaddir.mockResolvedValue([
|
|
{ name: '.hidden', isDirectory: () => false },
|
|
{ name: 'visible.ts', isDirectory: () => false },
|
|
] as never);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/files?showHidden=true`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.data.totalFiles).toBe(2);
|
|
});
|
|
|
|
it('excludes node_modules and .git directories', async () => {
|
|
let callCount = 0;
|
|
mockedReaddir.mockImplementation(async () => {
|
|
callCount++;
|
|
if (callCount === 1) {
|
|
return [
|
|
{ name: 'node_modules', isDirectory: () => true },
|
|
{ name: '.git', isDirectory: () => true },
|
|
{ name: 'src', isDirectory: () => true },
|
|
] as never;
|
|
}
|
|
return [] as never;
|
|
});
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/files?showHidden=true`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
// node_modules and .git are in excludeDirs set — only src should be counted
|
|
expect(body.data.totalDirectories).toBe(1); // only src
|
|
});
|
|
});
|
|
|
|
// ========== GET /api/sessions/:id/file-content ==========
|
|
|
|
describe('GET /api/sessions/:id/file-content', () => {
|
|
it('returns 404 for unknown session', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: '/api/sessions/nonexistent/file-content?path=test.ts',
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it('returns error for missing path parameter', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/file-content`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(false);
|
|
expect(body.error).toContain('Missing path');
|
|
});
|
|
|
|
it('returns text file content', async () => {
|
|
const fileContent = 'const x = 1;\nconst y = 2;\n';
|
|
mockedReadFile.mockResolvedValue(fileContent as never);
|
|
mockedStat.mockResolvedValue({ size: fileContent.length } as never);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=src/test.ts`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(true);
|
|
expect(body.data.content).toBe(fileContent);
|
|
expect(body.data.extension).toBe('ts');
|
|
});
|
|
|
|
it('returns binary metadata for image files', async () => {
|
|
mockedStat.mockResolvedValue({ size: 1024 } as never);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=logo.png`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(true);
|
|
expect(body.data.type).toBe('image');
|
|
expect(body.data.url).toContain('file-raw');
|
|
});
|
|
|
|
it('returns audio metadata for audio files', async () => {
|
|
mockedStat.mockResolvedValue({ size: 2048 } as never);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=clip.mp3`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(true);
|
|
expect(body.data.type).toBe('audio');
|
|
expect(body.data.url).toContain('file-raw');
|
|
});
|
|
|
|
it('flags known-binary extensions (e.g. xlsx) instead of dumping mojibake', async () => {
|
|
mockedStat.mockResolvedValue({ size: 4096 } as never);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=sheet.xlsx`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(true);
|
|
expect(body.data.type).toBe('binary');
|
|
expect(body.data.content).toBeUndefined();
|
|
});
|
|
|
|
it('sniffs NUL bytes and flags binary content for unknown extensions', async () => {
|
|
const binary = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00, 0x01, 0x02]);
|
|
mockedReadFile.mockResolvedValue(binary as never);
|
|
mockedStat.mockResolvedValue({ size: binary.length } as never);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=mystery.dat`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(true);
|
|
expect(body.data.type).toBe('binary');
|
|
expect(body.data.content).toBeUndefined();
|
|
});
|
|
|
|
it('rejects path traversal attempts', async () => {
|
|
// realpathSync resolves the symlink to a path outside workingDir
|
|
mockedRealpathSync.mockReturnValue('/etc/passwd' as never);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=../../etc/passwd`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(false);
|
|
});
|
|
|
|
it('rejects files that are too large', async () => {
|
|
mockedStat.mockResolvedValue({ size: 20 * 1024 * 1024 } as never); // 20MB
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=large-file.txt`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(false);
|
|
expect(body.error).toContain('too large');
|
|
});
|
|
|
|
it('truncates content when exceeding line limit', async () => {
|
|
const lines = Array.from({ length: 600 }, (_, i) => `line ${i + 1}`).join('\n');
|
|
mockedReadFile.mockResolvedValue(lines as never);
|
|
mockedStat.mockResolvedValue({ size: lines.length } as never);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=big.txt&lines=100`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(true);
|
|
expect(body.data.truncated).toBe(true);
|
|
expect(body.data.totalLines).toBe(600);
|
|
});
|
|
|
|
it('returns file not found when realpathSync throws', async () => {
|
|
mockedRealpathSync.mockImplementation(() => {
|
|
throw new Error('ENOENT');
|
|
});
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=nonexistent.ts`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(false);
|
|
expect(body.error).toContain('not found');
|
|
});
|
|
});
|
|
|
|
// ========== GET /api/sessions/:id/file-raw ==========
|
|
|
|
describe('GET /api/sessions/:id/file-raw', () => {
|
|
it('returns 404 for unknown session', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: '/api/sessions/nonexistent/file-raw?path=test.png',
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it('returns 400 for missing path parameter', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/file-raw`,
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it('serves raw file with correct content type', async () => {
|
|
const content = Buffer.from('fake png data');
|
|
mockedReadFile.mockResolvedValue(content as never);
|
|
mockedStat.mockResolvedValue({ size: content.length } as never);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=image.png`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.headers['content-type']).toBe('image/png');
|
|
});
|
|
|
|
it('serves workspace SVG as an untrusted attachment instead of inline image/svg+xml', async () => {
|
|
const content = Buffer.from('<svg><script>alert("xss")</script></svg>');
|
|
mockedReadFile.mockResolvedValue(content as never);
|
|
mockedStat.mockResolvedValue({ size: content.length } as never);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=malicious.svg`,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.headers['content-type']).toBe('application/octet-stream');
|
|
expect(res.headers['content-disposition']).toContain('attachment; filename="malicious.svg"');
|
|
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
|
});
|
|
|
|
it('rejects path traversal in raw file serving', async () => {
|
|
mockedRealpathSync.mockReturnValue('/etc/shadow' as never);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=../../etc/shadow`,
|
|
});
|
|
// Path traversal returns 404 ("File not found") to avoid revealing the target exists.
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it('rejects overly large raw files', async () => {
|
|
mockedStat.mockResolvedValue({ size: 100 * 1024 * 1024 } as never); // 100MB
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=huge.bin`,
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
});
|
|
|
|
// ========== DELETE /api/sessions/:id/tail-file/:streamId ==========
|
|
|
|
describe('DELETE /api/sessions/:id/tail-file/:streamId', () => {
|
|
it('returns 404 for unknown session', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'DELETE',
|
|
url: '/api/sessions/nonexistent/tail-file/stream-1',
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it('closes an existing stream', async () => {
|
|
mockedFileStreamManager.closeStream.mockReturnValue(true);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'DELETE',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/tail-file/stream-1`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.closed).toBe(true);
|
|
expect(mockedFileStreamManager.closeStream).toHaveBeenCalledWith('stream-1');
|
|
});
|
|
|
|
it('returns closed: false for unknown stream', async () => {
|
|
mockedFileStreamManager.closeStream.mockReturnValue(false);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'DELETE',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/tail-file/nonexistent`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.closed).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ========== GET /api/download ==========
|
|
|
|
describe('GET /api/download', () => {
|
|
it('requires a sessionId to scope downloads', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/download?path=${encodeURIComponent('/tmp/test-workdir/report.txt')}`,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it('downloads files scoped to the session working directory', async () => {
|
|
const content = Buffer.from('download content');
|
|
mockedReadFile.mockResolvedValue(content as never);
|
|
mockedStat.mockResolvedValue({ size: content.length, isFile: () => true } as never);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/download?sessionId=${harness.ctx._sessionId}&path=report.txt`,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.headers['content-disposition']).toContain('filename="report.txt"');
|
|
expect(res.body).toBe('download content');
|
|
});
|
|
|
|
it('rejects absolute paths outside the session working directory', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/download?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent('/var/log/app.log')}`,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it('rejects symlink targets that escape the session working directory', async () => {
|
|
mockedRealpathSync.mockReturnValue('/tmp/outside-workdir/link.log' as never);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/download?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(
|
|
'/tmp/test-workdir/link.log'
|
|
)}`,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it('blocks sensitive files even when they are inside the session working directory', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/download?sessionId=${harness.ctx._sessionId}&path=.env`,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(403);
|
|
});
|
|
});
|
|
});
|