/** * Integration test for the orphan-session routes (A): * GET /orphan-sessions * GET /orphan-sessions/:id/preview * DELETE /orphan-sessions/:id * * SAFETY: this file NEVER issues `DELETE /orphan-sessions` (the bulk cleanup), * because these tests run against the host's real tmux server and would end the * developer's own long-running sessions. Bulk cleanup is covered with mocks in * test/manager-orphans.test.ts. The only session touched here is one this file * creates under a fresh UUID and tears down itself. */ import net from 'node:net' import { execFileSync } from 'node:child_process' import { randomUUID } from 'node:crypto' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { loadConfig } from '../../src/config.js' import { startServer } from '../../src/server.js' import type { OrphanSessionInfo } from '../../src/types.js' const TMUX_AVAILABLE = (() => { try { execFileSync('tmux', ['-V'], { stdio: 'ignore' }) return true } catch { return false } })() const itTmux = TMUX_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('unexpected address type')) return } const port = addr.port srv.close(() => resolve(port)) }) srv.on('error', reject) }) } /* ── tmux off: the whole feature must be inert ──────────────────────────────── */ describe('orphan sessions — tmux disabled', () => { let port: number let handle: { close(): Promise } const origin = (): string => `http://127.0.0.1:${port}` beforeAll(async () => { port = await getFreePort() handle = startServer( 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', }), ) await new Promise((r) => setTimeout(r, 100)) }) afterAll(async () => { await handle.close() }) it('lists nothing, and does not require an Origin header (read-only)', async () => { const res = await fetch(`${origin()}/orphan-sessions`) expect(res.status).toBe(200) expect(await res.json()).toEqual([]) }) it('404s a preview instead of shelling out to tmux', async () => { const res = await fetch(`${origin()}/orphan-sessions/${randomUUID()}/preview`) expect(res.status).toBe(404) }) it('404s a kill', async () => { const res = await fetch(`${origin()}/orphan-sessions/${randomUUID()}`, { method: 'DELETE', headers: { Origin: origin() }, }) expect(res.status).toBe(404) }) }) /* ── tmux on: real session, real capture, real kill ─────────────────────────── */ describe('orphan sessions — tmux enabled', () => { let port: number let handle: { close(): Promise } const origin = (): string => `http://127.0.0.1:${port}` /** Our throwaway session. Never any other. */ const id = randomUUID() const name = `web_${id}` let created = false beforeAll(async () => { port = await getFreePort() handle = startServer( 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: '1', IDLE_TTL: '86400', }), ) await new Promise((r) => setTimeout(r, 100)) if (TMUX_AVAILABLE) { // Detached, printing a known marker so capture-pane has something to find. execFileSync( 'tmux', ['new-session', '-d', '-s', name, `printf 'ORPHAN_MARKER_%s' OK; sleep 300`], { stdio: 'ignore' }, ) created = true await new Promise((r) => setTimeout(r, 400)) } }) afterAll(async () => { if (created) { try { execFileSync('tmux', ['kill-session', '-t', name], { stdio: 'ignore' }) } catch { // the kill test already removed it — fine } } await handle.close() }) itTmux('lists our untracked tmux session as an orphan', async () => { const res = await fetch(`${origin()}/orphan-sessions`) expect(res.status).toBe(200) const body = (await res.json()) as OrphanSessionInfo[] const mine = body.find((o) => o.id === id) expect(mine).toBeDefined() expect(mine!.attached).toBe(false) // created with -d, nobody attached expect(mine!.cols).toBeGreaterThan(0) expect(mine!.lastActivityAt).toBeGreaterThan(0) }) itTmux('previews the live screen without attaching to it', async () => { const res = await fetch(`${origin()}/orphan-sessions/${id}/preview`) expect(res.status).toBe(200) const body = (await res.json()) as { id: string; data: string } expect(body.id).toBe(id) expect(body.data).toContain('ORPHAN_MARKER_OK') // The capture must not have left a client behind: still unattached. const after = execFileSync('tmux', ['ls', '-F', '#{session_name} #{session_attached}'], { encoding: 'utf8', }) expect(after).toContain(`${name} 0`) }) itTmux('rejects a non-UUID id with 400, never reaching tmux', async () => { for (const bad of ['-t', 'not-a-uuid', '../../etc/passwd']) { const res = await fetch(`${origin()}/orphan-sessions/${encodeURIComponent(bad)}/preview`) expect(res.status).toBe(400) } }) itTmux('refuses a kill with a foreign Origin (CSRF guard)', async () => { const res = await fetch(`${origin()}/orphan-sessions/${id}`, { method: 'DELETE', headers: { Origin: 'http://evil.example' }, }) expect(res.status).toBe(403) // and it is still alive expect( execFileSync('tmux', ['ls', '-F', '#{session_name}'], { encoding: 'utf8' }), ).toContain(name) }) itTmux('cannot reach a look-alike session by tmux prefix matching', async () => { // tmux -t resolves exact name, then NAME PREFIX, then fnmatch. `web__mine` // is refused by parseSessionList (suffix is not a UUID) and so is never listed — // but a bare `-t web_` prefix-matches it, which would let a DELETE aimed at // a non-existent id end a session the user created for their own work. const decoyId = randomUUID() const decoy = `web_${decoyId}_mine` execFileSync('tmux', ['new-session', '-d', '-s', decoy, 'sleep 300'], { stdio: 'ignore' }) try { // It must not appear in the listing… const list = (await (await fetch(`${origin()}/orphan-sessions`)).json()) as OrphanSessionInfo[] expect(list.some((o) => o.id === decoyId)).toBe(false) // …and the exact id must be reported as absent, not silently matched to it. const preview = await fetch(`${origin()}/orphan-sessions/${decoyId}/preview`) expect(preview.status).toBe(404) const del = await fetch(`${origin()}/orphan-sessions/${decoyId}`, { method: 'DELETE', headers: { Origin: origin() }, }) expect(del.status).toBe(404) // The decoy is still alive — that is the whole point. expect( execFileSync('tmux', ['ls', '-F', '#{session_name}'], { encoding: 'utf8' }), ).toContain(decoy) } finally { try { execFileSync('tmux', ['kill-session', '-t', `=${decoy}`], { stdio: 'ignore' }) } catch { // already gone } } }) itTmux('kills it, and 404s the second time', async () => { const first = await fetch(`${origin()}/orphan-sessions/${id}`, { method: 'DELETE', headers: { Origin: origin() }, }) expect(first.status).toBe(204) const listing = execFileSync('tmux', ['ls', '-F', '#{session_name}'], { encoding: 'utf8', }).split('\n') expect(listing).not.toContain(name) const second = await fetch(`${origin()}/orphan-sessions/${id}`, { method: 'DELETE', headers: { Origin: origin() }, }) expect(second.status).toBe(404) }) })