/** * 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 } /** Temp git repo on `main` with a `feature` branch that adds one committed file * containing a would-be XSS payload. Returns its absolute path. */ async function makeTwoBranchRepo(): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-base-')) tmpDirs.push(dir) const git = (args: string[]): void => { execFileSync('git', args, { cwd: dir, stdio: 'ignore' }) } git(['init', '-b', 'main']) git(['config', 'user.email', 'test@localhost']) git(['config', 'user.name', 'Test']) git(['config', 'commit.gpgsign', 'false']) await fs.writeFile(path.join(dir, 'base.txt'), 'base\n', 'utf8') git(['add', '.']) git(['commit', '-m', 'base on main']) git(['checkout', '-b', 'feature']) await fs.writeFile(path.join(dir, 'feat.txt'), '\n', 'utf8') git(['add', '.']) git(['commit', '-m', 'feature work']) 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)', { timeout: 30_000 }, () => { 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!) }) }) // ── DELETE /projects/worktree (W4 remove) ───────────────────────────────────────── /** Create a worktree via the POST route; returns its git path. */ async function createWorktreeViaRoute( port: number, origin: string, repo: string, branch: string, ): Promise { 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 }), }) expect(res.status).toBe(200) const body = (await res.json()) as { ok: boolean; path?: string } expect(body.ok).toBe(true) return body.path as string } describe('DELETE /projects/worktree (W4)', { timeout: 30_000 }, () => { 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: 'DELETE', headers: { 'Content-Type': 'application/json', Origin: 'http://evil.example' }, body: JSON.stringify({ path: '/tmp/x', worktreePath: '/tmp/x/wt' }), }) 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: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: '/tmp/x', worktreePath: '/tmp/x/wt' }), }) 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: 'DELETE', headers: { 'Content-Type': 'application/json', Origin: origin }, body: JSON.stringify({ path: '/tmp/x', worktreePath: '/tmp/x/wt' }), }) expect(res.status).toBe(403) }) it('returns 400 when worktreePath is missing', async () => { const { port, origin } = await spawnServer() const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', Origin: origin }, body: JSON.stringify({ path: '/tmp/x' }), }) expect(res.status).toBe(400) }) itGit('refuses to remove the main worktree with 400', async () => { const repo = await makeRealRepo() const { port, origin } = await spawnServer() const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', Origin: origin }, body: JSON.stringify({ path: repo, worktreePath: repo }), }) expect(res.status).toBe(400) }) itGit('removes a clean linked worktree and returns ok (200)', async () => { const repo = await makeRealRepo() const root = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-rmroot-')) tmpDirs.push(root) const { port, origin } = await spawnServer({ WORKTREE_ROOT: root }) const wt = await createWorktreeViaRoute(port, origin, repo, 'to-remove') expect(execFileSync('git', ['worktree', 'list'], { cwd: repo }).toString()).toContain(wt) const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', Origin: origin }, body: JSON.stringify({ path: repo, worktreePath: wt }), }) expect(res.status).toBe(200) const body = (await res.json()) as { ok: boolean; path?: string } expect(body.ok).toBe(true) expect(execFileSync('git', ['worktree', 'list'], { cwd: repo }).toString()).not.toContain(wt) }) itGit('refuses a dirty worktree (409), then removes it with force (200)', async () => { const repo = await makeRealRepo() const root = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-dirtyroot-')) tmpDirs.push(root) const { port, origin } = await spawnServer({ WORKTREE_ROOT: root }) const wt = await createWorktreeViaRoute(port, origin, repo, 'dirty-one') await fs.writeFile(path.join(wt, 'scratch.txt'), 'wip\n', 'utf8') const refused = await fetch(`http://127.0.0.1:${port}/projects/worktree`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', Origin: origin }, body: JSON.stringify({ path: repo, worktreePath: wt }), }) expect(refused.status).toBe(409) const forced = await fetch(`http://127.0.0.1:${port}/projects/worktree`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', Origin: origin }, body: JSON.stringify({ path: repo, worktreePath: wt, force: true }), }) expect(forced.status).toBe(200) expect(execFileSync('git', ['worktree', 'list'], { cwd: repo }).toString()).not.toContain(wt) }) }) // ── POST /projects/worktree/prune (W4) ──────────────────────────────────────────── describe('POST /projects/worktree/prune (W4)', { timeout: 30_000 }, () => { it('rejects a foreign Origin with 403', async () => { const { port } = await spawnServer() const res = await fetch(`http://127.0.0.1:${port}/projects/worktree/prune`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: 'http://evil.example' }, body: JSON.stringify({ path: '/tmp/x' }), }) 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/prune`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin }, body: JSON.stringify({ path: '/tmp/x' }), }) expect(res.status).toBe(403) }) it('returns 400 when path is missing', async () => { const { port, origin } = await spawnServer() const res = await fetch(`http://127.0.0.1:${port}/projects/worktree/prune`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin }, body: JSON.stringify({}), }) expect(res.status).toBe(400) }) itGit('prunes a worktree whose folder was deleted out-of-band (200)', async () => { const repo = await makeRealRepo() const root = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-pruneroot-')) tmpDirs.push(root) const { port, origin } = await spawnServer({ WORKTREE_ROOT: root }) const wt = await createWorktreeViaRoute(port, origin, repo, 'stale-one') await fs.rm(wt, { recursive: true, force: true }) const res = await fetch(`http://127.0.0.1:${port}/projects/worktree/prune`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin }, body: JSON.stringify({ path: repo }), }) expect(res.status).toBe(200) const body = (await res.json()) as { ok: boolean; pruned: string[] } expect(body.ok).toBe(true) expect(Array.isArray(body.pruned)).toBe(true) expect(execFileSync('git', ['worktree', 'list'], { cwd: repo }).toString()).not.toContain(wt) }) }) // ── GET /projects/diff ─────────────────────────────────────────────────────────── describe('GET /projects/diff (B1)', { timeout: 30_000 }, () => { 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('') }) // ── FR-B1.9: ?base= whole-branch diff ───────────────────────────────── itGit('diffs the whole branch against a base (?base=main), echoing base', async () => { const repo = await makeTwoBranchRepo() // HEAD is on `feature` const { port } = await spawnServer() const res = await fetch( `http://127.0.0.1:${port}/projects/diff?path=${encodeURIComponent(repo)}&base=main`, ) expect(res.status).toBe(200) const diff = (await res.json()) as DiffResult expect(diff.base).toBe('main') expect(diff.staged).toBe(false) const feat = diff.files.find((f) => f.newPath === 'feat.txt') expect(feat).toBeDefined() expect(feat?.status).toBe('added') expect(JSON.stringify(diff)).toContain('') }) itGit('rejects a flag-injection base with 400 (?base=-rf)', async () => { const repo = await makeTwoBranchRepo() const { port } = await spawnServer() const res = await fetch( `http://127.0.0.1:${port}/projects/diff?path=${encodeURIComponent(repo)}&base=-rf`, ) expect(res.status).toBe(400) }) itGit('rejects a metacharacter base with 400 (?base=main;rm)', async () => { const repo = await makeTwoBranchRepo() const { port } = await spawnServer() const res = await fetch( `http://127.0.0.1:${port}/projects/diff?path=${encodeURIComponent(repo)}` + `&base=${encodeURIComponent('main; rm -rf /')}`, ) expect(res.status).toBe(400) }) itGit('returns 200 with an empty diff for an unknown base (?base=ghost-branch)', async () => { const repo = await makeTwoBranchRepo() const { port } = await spawnServer() const res = await fetch( `http://127.0.0.1:${port}/projects/diff?path=${encodeURIComponent(repo)}&base=ghost-branch`, ) expect(res.status).toBe(200) const diff = (await res.json()) as DiffResult expect(diff.base).toBe('ghost-branch') expect(diff.files).toEqual([]) }) })