test: vitest harness + unit tests for security-critical pure functions

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>
This commit is contained in:
2026-05-16 04:35:31 +00:00
parent 9436a81b5f
commit 1ecb79476e
8 changed files with 722 additions and 4 deletions

View File

@@ -0,0 +1,100 @@
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');
});
});

View File

@@ -41,7 +41,7 @@ async function isDir(path: string): Promise<boolean> {
}
}
async function resolveProjectPath(
export async function resolveProjectPath(
raw: string,
whitelist: string
): Promise<{ real: string; name: string } | { error: string }> {