/** * test/integration/git-ops.test.ts (w4-commit-push) — the three git-WRITE routes * against a real startServer: * POST /projects/git/stage — Origin guard, kill-switch, missing field, non-git, * real stage in a temp repo * POST /projects/git/commit — same guards + real commit + safe 409/400 bodies * POST /projects/git/push — same guards + real push to a temp BARE remote * * Mirrors test/integration/worktree.test.ts (startServer + fetch + Origin + real * temp repos). No network — a local bare repo is the push remote. */ 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' 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}` } } /** Temp git repo on `main` with one committed file. Returns its absolute path. */ async function makeRealRepo(): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-gitops-')) 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, 'file.txt'), 'line1\nline2\n', 'utf8') git(['add', '.']) git(['commit', '-m', 'init']) return dir } /** A bare repo to serve as a push `origin`. Returns its absolute path. */ async function makeBareRemote(): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-gitops-bare-')) tmpDirs.push(dir) execFileSync('git', ['init', '--bare', '-b', 'main', dir], { stdio: 'ignore' }) return dir } async function postJson( port: number, route: string, body: unknown, headers: Record, ): Promise { return fetch(`http://127.0.0.1:${port}${route}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body), }) } 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)) }) // ── shared guard matrix ───────────────────────────────────────────────────────── const ROUTES = ['/projects/git/stage', '/projects/git/commit', '/projects/git/push'] as const describe('git-write routes — shared guards (CSRF / kill-switch)', () => { for (const route of ROUTES) { it(`${route}: rejects a foreign Origin with 403`, async () => { const { port } = await spawnServer() const res = await postJson(port, route, { path: '/tmp/x' }, { Origin: 'http://evil.example' }) expect(res.status).toBe(403) }) it(`${route}: rejects a missing Origin with 403 (default-deny)`, async () => { const { port } = await spawnServer() const res = await postJson(port, route, { path: '/tmp/x' }, {}) expect(res.status).toBe(403) }) it(`${route}: returns 403 when GIT_OPS_ENABLED=0`, async () => { const { port, origin } = await spawnServer({ GIT_OPS_ENABLED: '0' }) const res = await postJson(port, route, { path: '/tmp/x' }, { Origin: origin }) expect(res.status).toBe(403) }) } }) // ── POST /projects/git/stage ────────────────────────────────────────────────────── describe('POST /projects/git/stage', () => { it('returns 400 when files are missing', async () => { const { port, origin } = await spawnServer() const res = await postJson(port, '/projects/git/stage', { path: '/tmp/x' }, { Origin: origin }) expect(res.status).toBe(400) }) it('returns 404 for a non-git directory', async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-nogit-')) tmpDirs.push(dir) const { port, origin } = await spawnServer() const res = await postJson(port, '/projects/git/stage', { path: dir, files: ['a'] }, { Origin: origin }) expect(res.status).toBe(404) }) itGit('stages a modified file in a real repo (200) and git records it', async () => { const repo = await makeRealRepo() await fs.writeFile(path.join(repo, 'file.txt'), 'line1\nCHANGED\n', 'utf8') const { port, origin } = await spawnServer() const res = await postJson(port, '/projects/git/stage', { path: repo, files: ['file.txt'] }, { Origin: origin }) expect(res.status).toBe(200) const body = (await res.json()) as { ok: boolean; staged: boolean; count: number } expect(body).toMatchObject({ ok: true, staged: true, count: 1 }) const cached = execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: repo }).toString() expect(cached).toContain('file.txt') }) }) // ── POST /projects/git/commit ───────────────────────────────────────────────────── describe('POST /projects/git/commit', () => { itGit('returns 400 for an empty message', async () => { const repo = await makeRealRepo() const { port, origin } = await spawnServer() const res = await postJson(port, '/projects/git/commit', { path: repo, message: ' ' }, { Origin: origin }) expect(res.status).toBe(400) }) itGit('returns a safe 409 (no "fatal:") when nothing is staged', async () => { const repo = await makeRealRepo() const { port, origin } = await spawnServer() const res = await postJson(port, '/projects/git/commit', { path: repo, message: 'noop' }, { Origin: origin }) expect(res.status).toBe(409) const body = (await res.json()) as { ok: boolean; error: string } // Failure body carries ok:false + a SAFE message so the FE guard surfaces it. expect(body.ok).toBe(false) expect(body.error).not.toContain('fatal:') expect(body.error).toBe('Nothing staged to commit.') }) itGit('commits staged changes and returns a short SHA (200)', async () => { const repo = await makeRealRepo() await fs.writeFile(path.join(repo, 'file.txt'), 'line1\nCHANGED\n', 'utf8') execFileSync('git', ['add', 'file.txt'], { cwd: repo }) const { port, origin } = await spawnServer() const res = await postJson(port, '/projects/git/commit', { path: repo, message: 'phone commit' }, { Origin: origin }) expect(res.status).toBe(200) const body = (await res.json()) as { ok: boolean; commit: string } expect(body.ok).toBe(true) const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo }).toString().trim() expect(head.startsWith(body.commit)).toBe(true) }) }) // ── POST /projects/git/push ─────────────────────────────────────────────────────── describe('POST /projects/git/push', () => { itGit('returns a safe 400 when the repo has no remote', async () => { const repo = await makeRealRepo() const { port, origin } = await spawnServer() const res = await postJson(port, '/projects/git/push', { path: repo }, { Origin: origin }) expect(res.status).toBe(400) const body = (await res.json()) as { error: string } expect(body.error).not.toContain('fatal:') }) itGit('pushes the current branch to a bare remote and sets upstream (200)', async () => { const repo = await makeRealRepo() const bare = await makeBareRemote() execFileSync('git', ['remote', 'add', 'origin', bare], { cwd: repo }) const { port, origin } = await spawnServer() const res = await postJson(port, '/projects/git/push', { path: repo }, { Origin: origin }) expect(res.status).toBe(200) const body = (await res.json()) as { ok: boolean; branch: string; remote: string } expect(body).toMatchObject({ ok: true, branch: 'main', remote: 'origin' }) const remoteHead = execFileSync('git', ['--git-dir', bare, 'rev-parse', 'main']).toString().trim() const localHead = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo }).toString().trim() expect(remoteHead).toBe(localHead) }) })