/** * T-server-wire — integration tests for the B3 worktree-create and B1 diff routes. * * Covers (against a real startServer): * - POST /projects/worktree → Origin guard (403), WORKTREE_ENABLED=0 (403), * invalid branch (400), missing fields (400), * real creation in a temp git repo (git-gated) * - GET /projects/diff → 400 missing path, 404 non-git path, structured * DiffResult for a real repo with verbatim content */ import net from 'node:net' import os from 'node:os' import path from 'node:path' import fs from 'node:fs/promises' import { execFileSync } from 'node:child_process' import { afterEach, describe, expect, it } from 'vitest' import { loadConfig } from '../../src/config.js' import { startServer } from '../../src/server.js' import type { DiffResult } from '../../src/types.js' const GIT_AVAILABLE = (() => { try { execFileSync('git', ['--version'], { stdio: 'ignore' }) return true } catch { return false } })() const itGit = GIT_AVAILABLE ? it : it.skip function getFreePort(): Promise { return new Promise((resolve, reject) => { const srv = net.createServer() srv.listen(0, '127.0.0.1', () => { const addr = srv.address() if (addr === null || typeof addr === 'string') { srv.close() reject(new Error('bad addr')) return } const port = addr.port srv.close(() => resolve(port)) }) srv.on('error', reject) }) } const handles: { close(): Promise }[] = [] const tmpDirs: string[] = [] async function spawnServer( overrides: Record = {}, ): Promise<{ port: number; origin: string }> { const port = await getFreePort() const cfg = loadConfig({ PORT: String(port), BIND_HOST: '127.0.0.1', SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh', ALLOWED_ORIGINS: `http://127.0.0.1:${port}`, USE_TMUX: '0', IDLE_TTL: '86400', ...overrides, }) const handle = startServer(cfg) handles.push(handle) await new Promise((r) => setTimeout(r, 80)) return { port, origin: `http://127.0.0.1:${port}` } } /** Create a temp git repo with one committed file. Returns its absolute path. */ async function makeRealRepo(): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-wt-')) tmpDirs.push(dir) const git = (args: string[]): void => { execFileSync('git', args, { cwd: dir, stdio: 'ignore' }) } git(['init']) git(['config', 'user.email', 'test@localhost']) git(['config', 'user.name', 'Test']) git(['config', 'commit.gpgsign', 'false']) await fs.writeFile(path.join(dir, 'file.txt'), 'line1\nline2\n', 'utf8') git(['add', '.']) git(['commit', '-m', 'init']) return dir } afterEach(async () => { while (handles.length > 0) await handles.pop()?.close() for (const d of tmpDirs.splice(0)) await fs.rm(d, { recursive: true, force: true }).catch(() => undefined) await new Promise((r) => setTimeout(r, 30)) }) // ── POST /projects/worktree ────────────────────────────────────────────────────── describe('POST /projects/worktree (B3)', () => { it('rejects a foreign Origin with 403 (SEC-C3)', async () => { const { port } = await spawnServer() const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: 'http://evil.example' }, body: JSON.stringify({ path: '/tmp/x', branch: 'feature' }), }) expect(res.status).toBe(403) }) it('rejects a missing Origin with 403', async () => { const { port } = await spawnServer() const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: '/tmp/x', branch: 'feature' }), }) expect(res.status).toBe(403) }) it('returns 403 when WORKTREE_ENABLED=0', async () => { const { port, origin } = await spawnServer({ WORKTREE_ENABLED: '0' }) const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin }, body: JSON.stringify({ path: '/tmp/x', branch: 'feature' }), }) expect(res.status).toBe(403) }) it('rejects an invalid branch name with 400 (no git executed, SEC-H2)', async () => { const { port, origin } = await spawnServer() const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin }, body: JSON.stringify({ path: '/tmp/x', branch: '../evil' }), }) expect(res.status).toBe(400) }) it('rejects a missing branch field with 400', async () => { const { port, origin } = await spawnServer() const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin }, body: JSON.stringify({ path: '/tmp/x' }), }) expect(res.status).toBe(400) }) itGit('creates a worktree in a real git repo and returns its path', async () => { const repo = await makeRealRepo() const root = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-wtroot-')) tmpDirs.push(root) const { port, origin } = await spawnServer({ WORKTREE_ROOT: root }) const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin }, body: JSON.stringify({ path: repo, branch: 'feature-x' }), }) expect(res.status).toBe(200) const body = (await res.json()) as { ok: boolean; path?: string; branch?: string } expect(body.ok).toBe(true) expect(body.branch).toBe('feature-x') expect(typeof body.path).toBe('string') // The created dir actually exists and is a worktree. const list = execFileSync('git', ['worktree', 'list'], { cwd: repo }).toString() expect(list).toContain(body.path!) }) }) // ── GET /projects/diff ─────────────────────────────────────────────────────────── describe('GET /projects/diff (B1)', () => { it('returns 400 when the path query parameter is missing', async () => { const { port } = await spawnServer() const res = await fetch(`http://127.0.0.1:${port}/projects/diff`) expect(res.status).toBe(400) }) it('returns 404 for a non-git directory (SEC-H7)', async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-nogit-')) tmpDirs.push(dir) const { port } = await spawnServer() const res = await fetch(`http://127.0.0.1:${port}/projects/diff?path=${encodeURIComponent(dir)}`) expect(res.status).toBe(404) }) itGit('returns a structured DiffResult with verbatim content (AC-B1.4)', async () => { const repo = await makeRealRepo() // Introduce a tracked change containing a would-be XSS payload. await fs.writeFile(path.join(repo, 'file.txt'), 'line1\n\n', 'utf8') const { port } = await spawnServer() const res = await fetch(`http://127.0.0.1:${port}/projects/diff?path=${encodeURIComponent(repo)}`) expect(res.status).toBe(200) const diff = (await res.json()) as DiffResult expect(diff.staged).toBe(false) expect(diff.files.length).toBeGreaterThan(0) const flat = JSON.stringify(diff) // The diff carries the raw text verbatim (the FE renders it inert via textContent). expect(flat).toContain('') }) })