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:
100
apps/server/src/routes/__tests__/projects.test.ts
Normal file
100
apps/server/src/routes/__tests__/projects.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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 }> {
|
||||
|
||||
237
apps/server/src/services/__tests__/inference.test.ts
Normal file
237
apps/server/src/services/__tests__/inference.test.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildMessagesPayload } from '../inference.js';
|
||||
import type {
|
||||
Message,
|
||||
MessageRole,
|
||||
Project,
|
||||
Session,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
} from '../../types/api.js';
|
||||
|
||||
// ---- fixtures ---------------------------------------------------------------
|
||||
|
||||
function makeSession(overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: 'sess',
|
||||
project_id: 'proj',
|
||||
name: 'test session',
|
||||
model: 'test-model',
|
||||
system_prompt: '',
|
||||
status: 'open',
|
||||
created_at: new Date(0).toISOString(),
|
||||
updated_at: new Date(0).toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeProject(overrides: Partial<Project> = {}): Project {
|
||||
return {
|
||||
id: 'proj',
|
||||
name: 'test project',
|
||||
path: '/tmp/proj',
|
||||
added_at: new Date(0).toISOString(),
|
||||
last_session_id: null,
|
||||
status: 'open',
|
||||
gitea_remote: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
let counter = 0;
|
||||
function makeMessage(
|
||||
role: MessageRole,
|
||||
content: string,
|
||||
overrides: Partial<Message> = {}
|
||||
): Message {
|
||||
counter += 1;
|
||||
return {
|
||||
id: `m${counter}`,
|
||||
session_id: 'sess',
|
||||
chat_id: 'chat',
|
||||
role,
|
||||
content,
|
||||
kind: 'message',
|
||||
tool_calls: null,
|
||||
tool_results: null,
|
||||
status: 'complete',
|
||||
last_seq: 0,
|
||||
tokens_used: null,
|
||||
ctx_used: null,
|
||||
ctx_max: null,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
created_at: new Date(counter * 1000).toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- tests ------------------------------------------------------------------
|
||||
|
||||
describe('buildMessagesPayload', () => {
|
||||
it('prepends a system prompt containing the project path', () => {
|
||||
const session = makeSession();
|
||||
const project = makeProject({ path: '/tmp/my-proj' });
|
||||
const result = buildMessagesPayload(session, project, []);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.role).toBe('system');
|
||||
expect(result[0]!.content).toContain('/tmp/my-proj');
|
||||
});
|
||||
|
||||
it('appends session.system_prompt to the system message when set', () => {
|
||||
const session = makeSession({ system_prompt: 'Be terse.' });
|
||||
const project = makeProject();
|
||||
const result = buildMessagesPayload(session, project, []);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.role).toBe('system');
|
||||
expect(result[0]!.content).toContain('Be terse.');
|
||||
});
|
||||
|
||||
it('returns user/assistant messages in order when no compact marker is present', () => {
|
||||
const session = makeSession();
|
||||
const project = makeProject();
|
||||
const history: Message[] = [
|
||||
makeMessage('user', 'hi'),
|
||||
makeMessage('assistant', 'hello'),
|
||||
makeMessage('user', 'how are you'),
|
||||
makeMessage('assistant', 'great'),
|
||||
];
|
||||
const result = buildMessagesPayload(session, project, history);
|
||||
// 1 system + 4 history messages
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result[0]!.role).toBe('system');
|
||||
expect(result[1]).toMatchObject({ role: 'user', content: 'hi' });
|
||||
expect(result[2]).toMatchObject({ role: 'assistant', content: 'hello' });
|
||||
expect(result[3]).toMatchObject({ role: 'user', content: 'how are you' });
|
||||
expect(result[4]).toMatchObject({ role: 'assistant', content: 'great' });
|
||||
});
|
||||
|
||||
it('starts from the latest compact marker, emitting it as a system message', () => {
|
||||
const session = makeSession();
|
||||
const project = makeProject();
|
||||
const history: Message[] = [
|
||||
makeMessage('user', 'old1'),
|
||||
makeMessage('assistant', 'oldreply1'),
|
||||
makeMessage('user', 'old2'),
|
||||
makeMessage('assistant', 'compacted summary text', { kind: 'compact' }),
|
||||
makeMessage('user', 'new1'),
|
||||
makeMessage('assistant', 'newreply1'),
|
||||
];
|
||||
const result = buildMessagesPayload(session, project, history);
|
||||
// Expect: leading base-system prompt, then the compact as system, then
|
||||
// the user/assistant pair following it.
|
||||
expect(result).toHaveLength(4);
|
||||
expect(result[0]!.role).toBe('system');
|
||||
expect(result[1]).toMatchObject({
|
||||
role: 'system',
|
||||
content: 'compacted summary text',
|
||||
});
|
||||
expect(result[2]).toMatchObject({ role: 'user', content: 'new1' });
|
||||
expect(result[3]).toMatchObject({ role: 'assistant', content: 'newreply1' });
|
||||
});
|
||||
|
||||
it('uses only the most recent compact when multiple are present', () => {
|
||||
const session = makeSession();
|
||||
const project = makeProject();
|
||||
const history: Message[] = [
|
||||
makeMessage('user', 'u1'),
|
||||
makeMessage('assistant', 'first compact summary', { kind: 'compact' }),
|
||||
makeMessage('user', 'u2'),
|
||||
makeMessage('assistant', 'second compact summary', { kind: 'compact' }),
|
||||
makeMessage('user', 'u3'),
|
||||
makeMessage('assistant', 'final reply'),
|
||||
];
|
||||
const result = buildMessagesPayload(session, project, history);
|
||||
// Expect: base system + latest compact as system + the two messages
|
||||
// following it. The earlier compact and pre-compact history are dropped.
|
||||
expect(result).toHaveLength(4);
|
||||
expect(result[0]!.role).toBe('system');
|
||||
expect(result[1]).toMatchObject({
|
||||
role: 'system',
|
||||
content: 'second compact summary',
|
||||
});
|
||||
expect(result[2]).toMatchObject({ role: 'user', content: 'u3' });
|
||||
expect(result[3]).toMatchObject({ role: 'assistant', content: 'final reply' });
|
||||
// None of the earlier content should leak through
|
||||
const concatenated = result.map((m) => m.content ?? '').join(' ');
|
||||
expect(concatenated).not.toContain('first compact summary');
|
||||
expect(concatenated).not.toContain('u1');
|
||||
expect(concatenated).not.toContain('u2');
|
||||
});
|
||||
|
||||
it('skips streaming and cancelled assistant rows', () => {
|
||||
const session = makeSession();
|
||||
const project = makeProject();
|
||||
const history: Message[] = [
|
||||
makeMessage('user', 'hi'),
|
||||
makeMessage('assistant', 'partial...', { status: 'streaming' }),
|
||||
makeMessage('assistant', 'cancelled fragment', { status: 'cancelled' }),
|
||||
makeMessage('assistant', 'final answer'),
|
||||
];
|
||||
const result = buildMessagesPayload(session, project, history);
|
||||
// 1 system + 1 user + 1 assistant (only the complete one)
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[1]).toMatchObject({ role: 'user', content: 'hi' });
|
||||
expect(result[2]).toMatchObject({ role: 'assistant', content: 'final answer' });
|
||||
});
|
||||
|
||||
it('round-trips an assistant-with-tool_calls followed by its tool result', () => {
|
||||
const session = makeSession();
|
||||
const project = makeProject();
|
||||
const toolCall: ToolCall = {
|
||||
id: 'call_abc',
|
||||
name: 'view_file',
|
||||
args: { path: 'src/index.ts' },
|
||||
};
|
||||
const toolResult: ToolResult = {
|
||||
tool_call_id: 'call_abc',
|
||||
output: { contents: 'console.log(1)' },
|
||||
truncated: false,
|
||||
};
|
||||
const history: Message[] = [
|
||||
makeMessage('user', 'show me the file'),
|
||||
makeMessage('assistant', '', { tool_calls: [toolCall] }),
|
||||
makeMessage('tool', '', { tool_results: toolResult }),
|
||||
makeMessage('assistant', 'here it is'),
|
||||
];
|
||||
const result = buildMessagesPayload(session, project, history);
|
||||
// 1 system + 1 user + 1 assistant(tool_calls) + 1 tool + 1 assistant
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result[1]).toMatchObject({ role: 'user', content: 'show me the file' });
|
||||
expect(result[2]!.role).toBe('assistant');
|
||||
expect(result[2]!.tool_calls).toBeDefined();
|
||||
expect(result[2]!.tool_calls).toHaveLength(1);
|
||||
expect(result[2]!.tool_calls![0]).toMatchObject({
|
||||
id: 'call_abc',
|
||||
type: 'function',
|
||||
function: { name: 'view_file' },
|
||||
});
|
||||
// The OpenAI shape stringifies args.
|
||||
expect(result[2]!.tool_calls![0]!.function.arguments).toBe(
|
||||
JSON.stringify({ path: 'src/index.ts' })
|
||||
);
|
||||
// assistant with empty content should be serialized as content: null
|
||||
expect(result[2]!.content).toBeNull();
|
||||
expect(result[3]).toMatchObject({
|
||||
role: 'tool',
|
||||
tool_call_id: 'call_abc',
|
||||
});
|
||||
// Non-string tool output is JSON-stringified.
|
||||
expect(result[3]!.content).toBe(JSON.stringify({ contents: 'console.log(1)' }));
|
||||
expect(result[4]).toMatchObject({ role: 'assistant', content: 'here it is' });
|
||||
});
|
||||
|
||||
it('skips tool rows with no tool_results', () => {
|
||||
const session = makeSession();
|
||||
const project = makeProject();
|
||||
const history: Message[] = [
|
||||
makeMessage('user', 'do it'),
|
||||
makeMessage('tool', '', { tool_results: null }),
|
||||
makeMessage('assistant', 'done'),
|
||||
];
|
||||
const result = buildMessagesPayload(session, project, history);
|
||||
// 1 system + 1 user + 1 assistant; the empty tool row is dropped.
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.find((m) => m.role === 'tool')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
47
apps/server/src/services/__tests__/project_bootstrap.test.ts
Normal file
47
apps/server/src/services/__tests__/project_bootstrap.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { sanitizeFolderName } from '../project_bootstrap.js';
|
||||
|
||||
describe('sanitizeFolderName', () => {
|
||||
it('passes through a normal slug-like name', () => {
|
||||
expect(sanitizeFolderName('my-project')).toBe('my-project');
|
||||
});
|
||||
|
||||
it('lowercases and replaces whitespace with hyphens', () => {
|
||||
expect(sanitizeFolderName('Hello World')).toBe('hello-world');
|
||||
});
|
||||
|
||||
it('strips path-traversal characters', () => {
|
||||
// dots and slashes fall outside [a-z0-9-] and are removed entirely.
|
||||
expect(sanitizeFolderName('../etc/passwd')).toBe('etcpasswd');
|
||||
});
|
||||
|
||||
it('strips trailing and leading dots and slashes', () => {
|
||||
expect(sanitizeFolderName('./foo/')).toBe('foo');
|
||||
});
|
||||
|
||||
it('collapses runs of hyphens and strips leading/trailing ones', () => {
|
||||
expect(sanitizeFolderName('---foo---')).toBe('foo');
|
||||
});
|
||||
|
||||
it('returns empty string when nothing survives sanitization', () => {
|
||||
// NOTE: sanitizeFolderName itself does NOT throw — it returns ''. The
|
||||
// BootstrapNameError is raised by the caller (bootstrapProject) when the
|
||||
// sanitized result fails the SAFE_NAME regex. The spec's "throws" phrasing
|
||||
// refers to that caller-level validation, not this pure function.
|
||||
expect(sanitizeFolderName('...')).toBe('');
|
||||
expect(sanitizeFolderName(' ')).toBe('');
|
||||
});
|
||||
|
||||
it('strips control characters and null bytes', () => {
|
||||
// Null bytes and control characters are not in [a-z0-9-] so they're
|
||||
// filtered out (effectively rejected as folder-name content).
|
||||
expect(sanitizeFolderName('my\x00proj\x01')).toBe('myproj');
|
||||
expect(sanitizeFolderName('foo\x00bar')).toBe('foobar');
|
||||
});
|
||||
|
||||
it('truncates names longer than 64 characters', () => {
|
||||
const long = 'a'.repeat(100);
|
||||
expect(sanitizeFolderName(long)).toBe('a'.repeat(64));
|
||||
expect(sanitizeFolderName(long)).toHaveLength(64);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user