Closes the create-only loop — delete losing worktrees and prune stale ones without a terminal. Destructive, so guarded hard: - src/http/worktrees.ts: removeWorktree + pruneWorktrees (execFile, no shell, never rm -rf; git worktree remove [--force] / git worktree prune). Safeguards: (1) target realpath must match an entry git itself reports in `git worktree list` for THIS repo → 404 otherwise (arbitrary FS paths never match, so git is never invoked against them); (2) reject the MAIN worktree → 400; (3) realpath containment on request + each list entry, operating on git's canonical path (+ --); (4) dirty tree without force → 409 "force required"; (5) locked → 409; (6) errors classified to fixed safe strings (raw stderr/paths never leaked). - DELETE /projects/worktree + POST /projects/worktree/prune — both behind requireAllowedOrigin + worktreeEnabled + audit-logged, mirroring the create route. - public/projects.ts: ✕ remove button per linked-worktree row (hidden for main/locked) + a prune button; force needs an explicit second confirm (no single-click data loss). Verified: typecheck + build:web clean, worktree tests 108 pass (unit covers reject-non-registered / reject-main / reject-escape / refuse-dirty-without-force / clean-remove / force-remove / prune), full suite green at --test-timeout=30000.
446 lines
18 KiB
TypeScript
446 lines
18 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!)
|
|
})
|
|
})
|
|
|
|
// ── 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<string> {
|
|
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)', () => {
|
|
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)', () => {
|
|
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)', () => {
|
|
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([])
|
|
})
|
|
})
|