Adds vitest 3.x (pinned to ^3 because vitest 4 requires Vite 6, while the web app pins Vite 5). Tests live under src/**/__tests__/**. Three target functions: - sanitizeFolderName (project_bootstrap.ts): 8 cases covering happy path, path-traversal stripping, empty-after-sanitize, control chars, truncation at 64, null bytes, leading/trailing dot/slash stripping. - resolveProjectPath (projects.ts): 7 cases including symlink-escape via realpath, outside-whitelist rejection, nonexistent path, AND a flagged BEHAVIOR GAP: passing the whitelist path itself currently returns success rather than erroring out (function early-exits the scope check when real === whitelistReal). Test asserts current behavior with explicit comment flagging the spec violation — function NOT silently patched. Function made exportable for testing (single keyword change). - buildMessagesPayload (inference.ts): 8 cases for compact-marker logic (no marker, marker present, multiple compacts, tool-message position). tsconfig.json excludes __tests__ + *.test.ts from emit so dist/ stays clean. pnpm -C apps/server test => 23 passed in ~340ms. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
101 lines
4.2 KiB
TypeScript
101 lines
4.2 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import { mkdtemp, mkdir, rm, realpath, symlink, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { resolveProjectPath } from '../projects.js';
|
|
|
|
describe('resolveProjectPath', () => {
|
|
let scratch: string;
|
|
let whitelist: string;
|
|
let outside: string;
|
|
|
|
beforeAll(async () => {
|
|
// mkdtemp returns a real path on most platforms, but symlink-resolve it
|
|
// anyway to defeat /tmp -> /private/tmp style indirection on macOS, and
|
|
// keep this stable for the comparisons below.
|
|
scratch = await realpath(await mkdtemp(join(tmpdir(), 'boocode-projects-test-')));
|
|
whitelist = join(scratch, 'wl');
|
|
outside = join(scratch, 'other');
|
|
await mkdir(whitelist, { recursive: true });
|
|
await mkdir(outside, { recursive: true });
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (scratch) {
|
|
await rm(scratch, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('returns real path and basename for a valid subdirectory under the whitelist', async () => {
|
|
const projectDir = join(whitelist, 'my-project');
|
|
await mkdir(projectDir);
|
|
const result = await resolveProjectPath(projectDir, whitelist);
|
|
expect('error' in result).toBe(false);
|
|
if ('error' in result) return; // narrow
|
|
expect(result.real).toBe(projectDir);
|
|
expect(result.name).toBe('my-project');
|
|
});
|
|
|
|
it('rejects a path outside the whitelist', async () => {
|
|
const projectDir = join(outside, 'foo');
|
|
await mkdir(projectDir);
|
|
const result = await resolveProjectPath(projectDir, whitelist);
|
|
expect('error' in result).toBe(true);
|
|
if (!('error' in result)) return;
|
|
expect(result.error.toLowerCase()).toContain('path must be under');
|
|
});
|
|
|
|
it('rejects relative paths', async () => {
|
|
const result = await resolveProjectPath('relative/path', whitelist);
|
|
expect('error' in result).toBe(true);
|
|
if (!('error' in result)) return;
|
|
expect(result.error.toLowerCase()).toContain('absolute');
|
|
});
|
|
|
|
it('rejects nonexistent paths', async () => {
|
|
const result = await resolveProjectPath('/nonexistent/foo-boocode-test', whitelist);
|
|
expect('error' in result).toBe(true);
|
|
if (!('error' in result)) return;
|
|
expect(result.error.toLowerCase()).toContain('does not exist');
|
|
});
|
|
|
|
it('rejects symlink escapes (realpath resolution catches traversal)', async () => {
|
|
// Create a symlink INSIDE the whitelist that points OUTSIDE. realpath
|
|
// should resolve through it and the resulting real path should fail the
|
|
// whitelist scope check.
|
|
const escapeLink = join(whitelist, 'escape-link');
|
|
await symlink(outside, escapeLink);
|
|
const result = await resolveProjectPath(escapeLink, whitelist);
|
|
expect('error' in result).toBe(true);
|
|
if (!('error' in result)) return;
|
|
expect(result.error.toLowerCase()).toContain('path must be under');
|
|
});
|
|
|
|
it('BEHAVIOR GAP: currently accepts the whitelist itself as a project root', async () => {
|
|
// SPEC says: the whitelist directory itself should be rejected — a
|
|
// project's parent can't be the project. The current implementation does
|
|
// NOT enforce this: the scope check is
|
|
// if (real !== whitelistReal && !real.startsWith(whitelistReal + sep))
|
|
// which evaluates to false when real === whitelistReal, so the whitelist
|
|
// path falls through and is accepted as a valid project root.
|
|
//
|
|
// This test documents the ACTUAL current behavior. Reported as a bug in
|
|
// the harness report; not silently fixed here. To tighten the check,
|
|
// change the condition to:
|
|
// if (!real.startsWith(whitelistReal + sep))
|
|
const result = await resolveProjectPath(whitelist, whitelist);
|
|
expect('error' in result).toBe(false);
|
|
if ('error' in result) return;
|
|
expect(result.real).toBe(whitelist);
|
|
});
|
|
|
|
it('rejects non-directory targets (file under whitelist)', async () => {
|
|
const filePath = join(whitelist, 'a-file.txt');
|
|
await writeFile(filePath, 'content', 'utf8');
|
|
const result = await resolveProjectPath(filePath, whitelist);
|
|
expect('error' in result).toBe(true);
|
|
if (!('error' in result)) return;
|
|
expect(result.error.toLowerCase()).toContain('not a directory');
|
|
});
|
|
});
|