Files
web-terminal/test/integration/worktree.test.ts
Yaojia Wang b119c31019 feat(diff): diff a whole branch vs a base (?base=<rev>) — review before landing (W3)
The git-diff viewer can now diff the current branch against a base commit-ish
(e.g. main) — review an agent's whole branch from your phone before merging, not
just uncommitted changes. Completes the long-deferred FR-B1.9.

- src/http/diff.ts: getDiff() gains an optional base. Three-layer defense so an
  attacker-supplied base never reaches a shell or acts as a git option:
  (1) isPlausibleRev() rejects leading '-', '..'/'...' ranges, metachars, control
  chars, >250 chars; (2) git rev-parse --verify --quiet --end-of-options
  <base>^{commit} — only a resolved 7-64 hex sha is accepted, else empty result;
  (3) git diff --no-color <sha>... -- (three-dot = the branch's changes since
  divergence, PR-style). execFile, no shell, timeout + maxBuffer bound.
- src/types.ts: additive optional base on DiffResult.
- src/server.ts GET /projects/diff reads ?base (400 on !isPlausibleRev); read-only.
- public/diff.ts: a "compare base" <select> (Working tree + one option per base),
  disables Working/Staged tabs in base mode. public/projects.ts derives bases from
  the worktree branches ∪ current branch.

Verified: typecheck + build:web clean, 1763 pass (diff tests 87 green). The 1 red
in the plain full run is the pre-existing real-PTY "ring buffer" flake (times out
at default 5s under sandbox load; 27/27 at --test-timeout=30000) — unrelated.
2026-07-12 20:43:32 +02:00

276 lines
10 KiB
TypeScript

/**
* 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<number> {
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<void> }[] = []
const tmpDirs: string[] = []
async function spawnServer(
overrides: Record<string, string | undefined> = {},
): 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<void>((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<string> {
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<string> {
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'), '<script>x</script>\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<void>((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<script>x</script>\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('<script>x</script>')
})
// ── FR-B1.9: ?base=<rev> 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('<script>x</script>')
})
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([])
})
})