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'); }); });