diff --git a/public/diff.ts b/public/diff.ts index afdcc49..5a0636c 100644 --- a/public/diff.ts +++ b/public/diff.ts @@ -137,6 +137,70 @@ export async function fetchDiff(repoPath: string, opts: FetchDiffOpts = {}): Pro } } +/* ── W4 git write: stage / commit / push helpers ─────────────────────────────── */ + +/** Server response shape for the three W4 git-write routes (mirrors GitOpResult). + * Only `ok` is guaranteed; the rest are route-specific success/failure fields. */ +export interface GitOpResponse { + ok: boolean + status?: number + error?: string + staged?: boolean + count?: number + commit?: string + branch?: string + remote?: string +} + +/** Narrow an untrusted JSON body to a GitOpResponse (mirrors isWorktreeResult). */ +export function isGitOpResult(v: unknown): v is GitOpResponse { + return v !== null && typeof v === 'object' && typeof (v as Record)['ok'] === 'boolean' +} + +/** POST JSON to a same-origin git-write route; returns the parsed GitOpResponse + * or null on any transport/parse error. Same-origin, so the browser attaches the + * Origin header the server's CSRF guard checks — no manual header needed. */ +async function postGitOp(url: string, body: unknown): Promise { + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + let data: unknown + try { + data = await res.json() + } catch { + data = null + } + if (isGitOpResult(data)) return data + // Non-JSON / unexpected body (e.g. a 403/429 with no body): synthesize a + // failure carrying the HTTP status so callers can show a message. + return { ok: false, status: res.status } + } catch { + return null + } +} + +/** Stage (`stage=true`) or unstage the given repo-relative files. */ +export function postStage( + repoPath: string, + files: string[], + stage: boolean, +): Promise { + return postGitOp('/projects/git/stage', { path: repoPath, files, stage }) +} + +/** Commit the staged changes with `message`. */ +export function postCommit(repoPath: string, message: string): Promise { + return postGitOp('/projects/git/commit', { path: repoPath, message }) +} + +/** Push the current branch to its upstream (or `-u `). */ +export function postPush(repoPath: string): Promise { + return postGitOp('/projects/git/push', { path: repoPath }) +} + /* ── renderDiffFile ──────────────────────────────────────────────────────────── */ /** CSS class prefix for diff line kinds. */ @@ -148,13 +212,21 @@ const LINE_KIND_CLASS: Record = { meta: 'df-meta', } +/** Optional per-file stage/unstage control (W4). `staged` picks the button label + * (Unstage in the staged view, Stage otherwise); `onToggle` fires on click. */ +export interface StageControl { + staged: boolean + onToggle: (file: DiffFile) => void +} + /** - * Render a single DiffFile into an HTMLElement. + * Render a single DiffFile into an HTMLElement. When `stageCtl` is provided (W4, + * working/staged views only) a Stage/Unstage button is added to the file header. * * Security: ALL text content is set via textContent — zero innerHTML. * ' + const mockFetch = routingFetch({ '/projects/git/commit': { ok: false, status: 409, error: evil } }) + vi.stubGlobal('fetch', mockFetch) + const container = makeContainer() + const handle = mountDiffViewer(container, '/repo', {}) + await new Promise((r) => setTimeout(r, 0)) + + const msg = container.querySelector('.df-commit-msg') as HTMLTextAreaElement + msg.value = 'x' + msg.dispatchEvent(new Event('input')) + ;(container.querySelector('.df-commit-btn') as HTMLButtonElement).click() + await new Promise((r) => setTimeout(r, 0)) + + const status = container.querySelector('.df-op-status') as HTMLElement + expect(status.textContent).toContain(evil) + expect(container.querySelectorAll('script').length).toBe(0) + handle.destroy() + }) + + it('Push POSTs /projects/git/push and reflects busy/disabled while in flight', async () => { + let resolvePush: (v: unknown) => void = () => undefined + const pending = new Promise((r) => { + resolvePush = r + }) + const mockFetch = vi.fn(async (url: string, init?: RequestInit) => { + if (init?.method === 'POST' && url === '/projects/git/push') { + await pending + return { ok: true, status: 200, json: async () => ({ ok: true, branch: 'main', remote: 'origin' }) } + } + return { ok: true, status: 200, json: async () => makeResult() } + }) + vi.stubGlobal('fetch', mockFetch) + + const container = makeContainer() + const handle = mountDiffViewer(container, '/repo', {}) + await new Promise((r) => setTimeout(r, 0)) + + const pushBtn = container.querySelector('.df-push-btn') as HTMLButtonElement + pushBtn.click() + await new Promise((r) => setTimeout(r, 0)) + // In flight → disabled. + expect(pushBtn.disabled).toBe(true) + + resolvePush(undefined) + await new Promise((r) => setTimeout(r, 0)) + expect(pushBtn.disabled).toBe(false) + expect((container.querySelector('.df-op-status') as HTMLElement).textContent).toContain('main') + handle.destroy() + }) + + it('hides the commit bar in base-compare mode', async () => { + vi.stubGlobal('fetch', routingFetch()) + const container = makeContainer() + const handle = mountDiffViewer(container, '/repo', { bases: ['main'] }) + await new Promise((r) => setTimeout(r, 0)) + + const bar = container.querySelector('.df-commitbar') as HTMLElement + expect(bar.style.display).toBe('') // visible in working mode + + const select = container.querySelector('.df-base-select') as HTMLSelectElement + select.value = 'main' + select.dispatchEvent(new Event('change')) + await new Promise((r) => setTimeout(r, 0)) + expect(bar.style.display).toBe('none') // hidden in base mode + // No stage buttons in base mode either. + expect(container.querySelector('.df-file-stage')).toBeNull() + handle.destroy() + }) +}) diff --git a/test/http/git-ops.test.ts b/test/http/git-ops.test.ts new file mode 100644 index 0000000..0368a62 --- /dev/null +++ b/test/http/git-ops.test.ts @@ -0,0 +1,322 @@ +/** + * test/http/git-ops.test.ts (w4-commit-push) — the git WRITE engine. + * + * Covers src/http/git-ops.ts: + * - classifyGitError (pure: stderr → safe {status,error}, SEC-M10) + * - validateRepoFiles (realpath containment: rejects escapes/absolute/flags) + * - stageFiles (git add / restore --staged, real temp repos) + * - commit (staged commit, empty/identity/length guards) + * - push (upstream vs no-upstream argv, bare-remote real push) + * + * Pure functions are unit-tested; the git verbs run against real throwaway git + * repos in os.tmpdir() (no network — a local bare repo is the push remote), + * mirroring test/http/worktrees-create.test.ts. + */ + +import { describe, it, expect, beforeAll } from 'vitest' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { + classifyGitError, + validateRepoFiles, + stageFiles, + commit, + push, +} from '../../src/http/git-ops.js' + +const execFileP = promisify(execFile) +const TIMEOUT_MS = 15000 +const OPTS = { timeoutMs: TIMEOUT_MS, maxFiles: 300 } + +let gitAvailable = false +beforeAll(async () => { + try { + await execFileP('git', ['--version']) + gitAvailable = true + } catch { + gitAvailable = false + } +}) + +/** Init a temp git repo with one commit; returns its REALPATH (macOS /var fix). */ +async function makeRepo(): Promise { + const dir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-'))) + const git = async (args: string[]): Promise => { + await execFileP('git', args, { cwd: dir }) + } + await git(['init', '-q', '-b', 'main']) + await git(['config', 'user.email', 't@t.local']) + await git(['config', 'user.name', 'tester']) + await git(['config', 'commit.gpgsign', 'false']) + await fs.writeFile(path.join(dir, 'tracked.txt'), 'v1\n', 'utf8') + await git(['add', '.']) + await git(['commit', '-q', '-m', 'init']) + return dir +} + +/** A bare repo to serve as a push `origin`. Returns its REALPATH. */ +async function makeBareRemote(): Promise { + const dir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-bare-'))) + await execFileP('git', ['init', '-q', '--bare', '-b', 'main', dir]) + return dir +} + +// ── classifyGitError (pure, SEC-M10) ──────────────────────────────────────────── + +describe('classifyGitError', () => { + const cases: Array<[string, number]> = [ + ['nothing to commit, working tree clean', 409], + ['no changes added to commit (use "git add")', 409], + ['Please tell me who you are', 400], + [' ! [rejected] main -> main (non-fast-forward)', 409], + ['Updates were rejected because the tip of your current branch is behind\nfetch first', 409], + ['fatal: Authentication failed for \'https://github.com/x/y.git/\'', 401], + ["fatal: could not read Username for 'https://github.com': terminal prompts disabled", 401], + ['git@github.com: Permission denied (publickey).', 401], + ["fatal: Unable to create '/repo/.git/index.lock': File exists.", 409], + ['some totally unexpected failure', 500], + ] + + it('maps each stderr signature to the expected status', () => { + for (const [stderr, status] of cases) { + expect(classifyGitError(stderr).status).toBe(status) + } + }) + + it('never leaks raw git stderr (no "fatal:" in any safe message)', () => { + for (const [stderr] of cases) { + expect(classifyGitError(stderr).error.toLowerCase()).not.toContain('fatal:') + } + }) + + it('is case-insensitive and tolerant of a non-string input', () => { + expect(classifyGitError('NOTHING TO COMMIT').status).toBe(409) + // @ts-expect-error — defensive: non-string must not throw + expect(classifyGitError(undefined).status).toBe(500) + }) +}) + +// ── validateRepoFiles (realpath containment) ──────────────────────────────────── + +describe('validateRepoFiles', () => { + it('rejects an empty list', async () => { + const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-'))) + expect(await validateRepoFiles(repo, [], 300)).toBeNull() + }) + + it('rejects more than maxFiles entries', async () => { + const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-'))) + expect(await validateRepoFiles(repo, ['a', 'b', 'c'], 2)).toBeNull() + }) + + it('rejects non-string, absolute, and leading-dash entries', async () => { + const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-'))) + expect(await validateRepoFiles(repo, [123], 300)).toBeNull() + expect(await validateRepoFiles(repo, [''], 300)).toBeNull() + expect(await validateRepoFiles(repo, ['/etc/passwd'], 300)).toBeNull() + expect(await validateRepoFiles(repo, ['-rf'], 300)).toBeNull() + }) + + it('rejects a ../ traversal escape', async () => { + const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-'))) + expect(await validateRepoFiles(repo, ['../escape'], 300)).toBeNull() + }) + + it('rejects the repo root itself (".")', async () => { + const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-'))) + expect(await validateRepoFiles(repo, ['.'], 300)).toBeNull() + }) + + it('accepts in-repo relative paths (existing AND not-yet-on-disk / deleted)', async () => { + const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-'))) + await fs.writeFile(path.join(repo, 'exists.txt'), 'x', 'utf8') + const ok = await validateRepoFiles(repo, ['exists.txt', 'sub/deleted.txt'], 300) + expect(ok).toEqual(['exists.txt', 'sub/deleted.txt']) + }) + + it('rejects a pre-planted symlink that escapes the repo after realpath (M2)', async () => { + const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-'))) + const outside = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-out-'))) + await fs.writeFile(path.join(outside, 'secret.txt'), 'top', 'utf8') + await fs.symlink(path.join(outside, 'secret.txt'), path.join(repo, 'link.txt')) + expect(await validateRepoFiles(repo, ['link.txt'], 300)).toBeNull() + }) +}) + +// ── stageFiles (real temp repos) ──────────────────────────────────────────────── + +describe('stageFiles', () => { + it('stages the given files (git add) and unstages them (restore --staged)', async () => { + if (!gitAvailable) return + const repo = await makeRepo() + await fs.writeFile(path.join(repo, 'tracked.txt'), 'v2\n', 'utf8') // modify tracked + await fs.writeFile(path.join(repo, 'new.txt'), 'new\n', 'utf8') // add untracked + + const staged = await stageFiles(repo, ['tracked.txt', 'new.txt'], true, OPTS) + expect(staged).toMatchObject({ ok: true, staged: true, count: 2 }) + const cached = (await execFileP('git', ['diff', '--cached', '--name-only'], { cwd: repo })).stdout + expect(cached).toContain('tracked.txt') + expect(cached).toContain('new.txt') + + const unstaged = await stageFiles(repo, ['tracked.txt'], false, OPTS) + expect(unstaged).toMatchObject({ ok: true, staged: false, count: 1 }) + const cached2 = (await execFileP('git', ['diff', '--cached', '--name-only'], { cwd: repo })).stdout + expect(cached2).not.toContain('tracked.txt') + }) + + it('returns 404 for a non-git directory', async () => { + const plain = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-plain-'))) + const res = await stageFiles(plain, ['x.txt'], true, OPTS) + expect(res).toMatchObject({ ok: false, status: 404 }) + }) + + it('returns 400 for an escaping file path (never runs git)', async () => { + if (!gitAvailable) return + const repo = await makeRepo() + const res = await stageFiles(repo, ['../../etc/passwd'], true, OPTS) + expect(res).toMatchObject({ ok: false, status: 400 }) + }) +}) + +// ── commit (real temp repos) ──────────────────────────────────────────────────── + +describe('commit', () => { + it('commits staged changes and returns a short SHA', async () => { + if (!gitAvailable) return + const repo = await makeRepo() + await fs.writeFile(path.join(repo, 'tracked.txt'), 'v2\n', 'utf8') + await execFileP('git', ['add', 'tracked.txt'], { cwd: repo }) + + const res = await commit(repo, 'second commit', { timeoutMs: TIMEOUT_MS, maxLen: 5000 }) + expect(res.ok).toBe(true) + expect(typeof res.commit).toBe('string') + const head = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim() + expect(head.startsWith(res.commit as string)).toBe(true) + }) + + it('returns 409 when nothing is staged', async () => { + if (!gitAvailable) return + const repo = await makeRepo() + const res = await commit(repo, 'noop', { timeoutMs: TIMEOUT_MS, maxLen: 5000 }) + expect(res).toMatchObject({ ok: false, status: 409 }) + expect(res.error).not.toContain('fatal:') + }) + + it('rejects an empty message with 400 (never runs git)', async () => { + if (!gitAvailable) return + const repo = await makeRepo() + expect(await commit(repo, '', { timeoutMs: TIMEOUT_MS, maxLen: 5000 })).toMatchObject({ + ok: false, + status: 400, + }) + expect(await commit(repo, ' \n ', { timeoutMs: TIMEOUT_MS, maxLen: 5000 })).toMatchObject({ + ok: false, + status: 400, + }) + }) + + it('rejects an over-long message with 400', async () => { + if (!gitAvailable) return + const repo = await makeRepo() + const res = await commit(repo, 'x'.repeat(51), { timeoutMs: TIMEOUT_MS, maxLen: 50 }) + expect(res).toMatchObject({ ok: false, status: 400 }) + }) + + it('returns 400 when the author identity is unset', async () => { + if (!gitAvailable) return + // Init WITHOUT any local identity, then hide the global/system config so no + // identity resolves — git must fail with "Please tell me who you are" → 400. + const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-noid-'))) + await execFileP('git', ['init', '-q', '-b', 'main'], { cwd: repo }) + // useConfigOnly=true stops git auto-detecting an identity from the OS user, so + // with no configured identity it hard-fails "Please tell me who you are". + await execFileP('git', ['config', '--local', 'user.useConfigOnly', 'true'], { cwd: repo }) + await fs.writeFile(path.join(repo, 'f.txt'), 'x\n', 'utf8') + await execFileP('git', ['add', 'f.txt'], { cwd: repo }) // stage on unborn HEAD + + const prevG = process.env['GIT_CONFIG_GLOBAL'] + const prevS = process.env['GIT_CONFIG_SYSTEM'] + process.env['GIT_CONFIG_GLOBAL'] = '/dev/null' + process.env['GIT_CONFIG_SYSTEM'] = '/dev/null' + try { + const res = await commit(repo, 'msg', { timeoutMs: TIMEOUT_MS, maxLen: 5000 }) + expect(res).toMatchObject({ ok: false, status: 400 }) + expect(res.error).not.toContain('fatal:') + } finally { + if (prevG === undefined) delete process.env['GIT_CONFIG_GLOBAL'] + else process.env['GIT_CONFIG_GLOBAL'] = prevG + if (prevS === undefined) delete process.env['GIT_CONFIG_SYSTEM'] + else process.env['GIT_CONFIG_SYSTEM'] = prevS + } + }) +}) + +// ── push (real bare-repo remote, no network) ──────────────────────────────────── + +describe('push', () => { + it('first push sets upstream (-u) and the bare remote receives the ref', async () => { + if (!gitAvailable) return + const repo = await makeRepo() + const bare = await makeBareRemote() + await execFileP('git', ['remote', 'add', 'origin', bare], { cwd: repo }) + + const res = await push(repo, { timeoutMs: TIMEOUT_MS }) + expect(res).toMatchObject({ ok: true, branch: 'main', remote: 'origin' }) + const remoteHead = (await execFileP('git', ['--git-dir', bare, 'rev-parse', 'main'], {})).stdout.trim() + const localHead = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim() + expect(remoteHead).toBe(localHead) + }) + + it('a second push (upstream now set) succeeds via a plain push', async () => { + if (!gitAvailable) return + const repo = await makeRepo() + const bare = await makeBareRemote() + await execFileP('git', ['remote', 'add', 'origin', bare], { cwd: repo }) + expect((await push(repo, { timeoutMs: TIMEOUT_MS })).ok).toBe(true) + + await fs.writeFile(path.join(repo, 'tracked.txt'), 'v2\n', 'utf8') + await execFileP('git', ['add', 'tracked.txt'], { cwd: repo }) + await execFileP('git', ['commit', '-q', '-m', 'second'], { cwd: repo }) + + const res = await push(repo, { timeoutMs: TIMEOUT_MS }) + expect(res).toMatchObject({ ok: true, branch: 'main', remote: 'origin' }) + const remoteHead = (await execFileP('git', ['--git-dir', bare, 'rev-parse', 'main'], {})).stdout.trim() + const localHead = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim() + expect(remoteHead).toBe(localHead) + }) + + it('returns 400 for a detached HEAD', async () => { + if (!gitAvailable) return + const repo = await makeRepo() + const sha = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim() + await execFileP('git', ['checkout', '-q', sha], { cwd: repo }) // detach + const res = await push(repo, { timeoutMs: TIMEOUT_MS }) + expect(res).toMatchObject({ ok: false, status: 400 }) + }) + + it('returns 400 when the repo has zero remotes', async () => { + if (!gitAvailable) return + const repo = await makeRepo() + const res = await push(repo, { timeoutMs: TIMEOUT_MS }) + expect(res).toMatchObject({ ok: false, status: 400 }) + }) + + it('returns 409 with two remotes and no upstream (ambiguous)', async () => { + if (!gitAvailable) return + const repo = await makeRepo() + const bare1 = await makeBareRemote() + const bare2 = await makeBareRemote() + await execFileP('git', ['remote', 'add', 'origin', bare1], { cwd: repo }) + await execFileP('git', ['remote', 'add', 'backup', bare2], { cwd: repo }) + const res = await push(repo, { timeoutMs: TIMEOUT_MS }) + expect(res).toMatchObject({ ok: false, status: 409 }) + }) + + it('returns 404 for a non-git directory', async () => { + const plain = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-nrepo-'))) + expect(await push(plain, { timeoutMs: TIMEOUT_MS })).toMatchObject({ ok: false, status: 404 }) + }) +}) diff --git a/test/integration/git-ops.test.ts b/test/integration/git-ops.test.ts new file mode 100644 index 0000000..74a02e7 --- /dev/null +++ b/test/integration/git-ops.test.ts @@ -0,0 +1,237 @@ +/** + * 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) + }) +})