Files
web-terminal/test/integration/git-ops.test.ts
Yaojia Wang cc811dd18d test: stop the suite flaking on real-git and real-PTY fixtures
`npm test` was failing 5-11 tests per run with the set shifting between runs,
which made it useless as a gate. Two independent causes, neither of them a
product defect.

1. Fixtures had outgrown vitest's 5 s default. The git ones spawn 6-12
   sequential `git` processes (init/config/commit/clone/push); the real-server
   ones boot a server and a shell. Alone they finish easily; under a full
   parallel run they do not, and the failure reads `Test timed out in 5000ms`.
   Gave those describes an explicit 30 s ceiling, and the real-PTY waits a
   named PTY_WAIT_MS. The deliberate short bounds in the "must be rejected"
   handshake tests are left alone — there a timeout IS the assertion.
   (The same rebudgeting also touched the test files in the preceding commit.)

2. test/integration/server.test.ts cannot share the machine with the rest of
   the suite. It asserts on real prompt output within seconds, which is not
   achievable while ~8 workers saturate the box: it passed alone (27/27,
   repeatedly) and flaked in the full run. Raising the numbers further only
   moved the flake around, so this is fixed as a scheduling problem — `npm
   test` now runs two passes, `test:unit` (everything else, parallel) then
   `test:e2e` (that file on its own). `vitest run` still runs everything at
   once for anyone who wants that.

Also bounded the srv.close() in H1's finally. Unbounded, it swallowed whatever
really went wrong in the body — the inner waits threw, control jumped to the
finally, close() blocked, and the case reported a bare timeout instead of its
own diagnosis. The root-causing above only became possible after making that
failure legible.

Note on the `[needs real PTY (sandbox-off)]` labels: those cases were NOT
skipping here. PTY_AVAILABLE is true on this machine, so they were running and
failing on timing, not being gated out by a sandbox.

Verified: npm test green end to end across repeated runs (unit 78 files /
2147 tests, e2e 27).
2026-07-29 17:12:16 +02:00

238 lines
9.7 KiB
TypeScript

/**
* 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<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}` }
}
/** Temp git repo on `main` with one committed file. Returns its absolute path. */
async function makeRealRepo(): Promise<string> {
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<string> {
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<string, string>,
): Promise<Response> {
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<void>((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)', { timeout: 30_000 }, () => {
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', { timeout: 30_000 }, () => {
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', { timeout: 30_000 }, () => {
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', { timeout: 30_000 }, () => {
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)
})
})