/** * T-server-wire — integration tests for the A1 push / lock-screen-decision wiring. * * Covers (against a real startServer): * - GET /push/vapid-key → 503 when VAPID unset, {publicKey} when set * - POST /push/subscribe → Origin guard (403), 204 on success, 400 bad body, * per-IP rate limit (429) * - DELETE /push/subscribe → Origin guard (403), 204 on success * - POST /hook/decision → Origin guard (403), bad/stale token (403), * missing fields (400), rate limit (429), and the * positive token path resolving a held approval * - C1 hold gate (SEC-C2) → zero clients but ≥1 subscription → /hook/permission HELD * - WS approve mode (SEC-M5) → ALLOW_AUTO_MODE gate downgrades mode:'auto' → 'default' * * web-push is mocked so the push fan-out "succeeds" and we can read the signed * payload to recover the per-decision capability token for the positive path. */ import net from 'node:net' import os from 'node:os' import path from 'node:path' import fs from 'node:fs/promises' import { afterEach, describe, expect, it, vi } from 'vitest' import WebSocket from 'ws' import * as nodePty from 'node-pty' import { loadConfig } from '../../src/config.js' import { startServer } from '../../src/server.js' // ── web-push mock (captures every signed payload string) ─────────────────────── const { sentPayloads } = vi.hoisted(() => ({ sentPayloads: [] as string[] })) vi.mock('web-push', () => ({ default: { setVapidDetails: (): void => {}, sendNotification: async (_sub: unknown, payload: string): Promise => { sentPayloads.push(payload) }, }, })) const PTY_AVAILABLE = (() => { try { const p = nodePty.spawn(process.env['SHELL'] ?? '/bin/sh', [], { cols: 80, rows: 24 }) p.kill() return true } catch { return false } })() const itPty = PTY_AVAILABLE ? it : it.skip // ── helpers ──────────────────────────────────────────────────────────────────── 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 tmpFiles: string[] = [] async function spawnServer( overrides: Record = {}, ): Promise<{ port: number; origin: string }> { const port = await getFreePort() const storePath = path.join(os.tmpdir(), `webterm-push-${port}-${Date.now()}.json`) tmpFiles.push(storePath) 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', PUSH_STORE_PATH: storePath, ...overrides, }) const handle = startServer(cfg) handles.push(handle) await new Promise((r) => setTimeout(r, 80)) return { port, origin: `http://127.0.0.1:${port}` } } const VAPID_ON = { VAPID_PUBLIC_KEY: 'test-public-key', VAPID_PRIVATE_KEY: 'test-private-key', VAPID_SUBJECT: 'mailto:test@localhost', } function validSubscriptionBody(endpoint = 'https://push.example/ep-1'): string { return JSON.stringify({ endpoint, keys: { p256dh: 'p256dh-value', auth: 'auth-value' } }) } function waitForOpen(ws: WebSocket): Promise { return new Promise((resolve, reject) => { const t = setTimeout(() => reject(new Error('open timeout')), 3000) ws.once('open', () => { clearTimeout(t) resolve() }) ws.once('error', (e) => { clearTimeout(t) reject(e) }) }) } function waitForMessage(ws: WebSocket, pred: (m: Record) => boolean): Promise> { return new Promise((resolve, reject) => { const t = setTimeout(() => { ws.off('message', onMsg) reject(new Error('message timeout')) }, 5000) const onMsg = (raw: WebSocket.RawData): void => { let m: Record try { m = JSON.parse(raw.toString('utf8')) as Record } catch { return } if (pred(m)) { clearTimeout(t) ws.off('message', onMsg) resolve(m) } } ws.on('message', onMsg) }) } const isAttached = (m: Record): boolean => m['type'] === 'attached' afterEach(async () => { while (handles.length > 0) await handles.pop()?.close() for (const f of tmpFiles.splice(0)) await fs.rm(f, { force: true }).catch(() => undefined) sentPayloads.length = 0 await new Promise((r) => setTimeout(r, 30)) }) // ── GET /push/vapid-key ───────────────────────────────────────────────────────── describe('GET /push/vapid-key', () => { it('returns 503 when VAPID is not configured', async () => { const { port } = await spawnServer() const res = await fetch(`http://127.0.0.1:${port}/push/vapid-key`) expect(res.status).toBe(503) }) it('returns the public key when VAPID is configured', async () => { const { port } = await spawnServer(VAPID_ON) const res = await fetch(`http://127.0.0.1:${port}/push/vapid-key`) expect(res.status).toBe(200) const body = (await res.json()) as { publicKey?: string } expect(body.publicKey).toBe('test-public-key') }) }) // ── POST/DELETE /push/subscribe ────────────────────────────────────────────────── describe('POST /push/subscribe', () => { it('rejects a foreign Origin with 403 (SEC-C4)', async () => { const { port } = await spawnServer(VAPID_ON) const res = await fetch(`http://127.0.0.1:${port}/push/subscribe`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: 'http://evil.example' }, body: validSubscriptionBody(), }) expect(res.status).toBe(403) }) it('rejects a missing Origin with 403 (default-deny)', async () => { const { port } = await spawnServer(VAPID_ON) const res = await fetch(`http://127.0.0.1:${port}/push/subscribe`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: validSubscriptionBody(), }) expect(res.status).toBe(403) }) it('stores a valid subscription with 204 for an allowed Origin', async () => { const { port, origin } = await spawnServer(VAPID_ON) const res = await fetch(`http://127.0.0.1:${port}/push/subscribe`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin }, body: validSubscriptionBody(), }) expect(res.status).toBe(204) }) it('rejects an invalid subscription body with 400', async () => { const { port, origin } = await spawnServer(VAPID_ON) const res = await fetch(`http://127.0.0.1:${port}/push/subscribe`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin }, body: JSON.stringify({ endpoint: '' }), }) expect(res.status).toBe(400) }) it('rate-limits more than 5 subscribe calls/min from one IP (429, SEC-H9)', async () => { const { port, origin } = await spawnServer(VAPID_ON) const codes: number[] = [] for (let i = 0; i < 6; i++) { const res = await fetch(`http://127.0.0.1:${port}/push/subscribe`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin }, body: validSubscriptionBody(`https://push.example/ep-${i}`), }) codes.push(res.status) } expect(codes.slice(0, 5).every((c) => c === 204)).toBe(true) expect(codes[5]).toBe(429) }) }) describe('DELETE /push/subscribe', () => { it('rejects a foreign Origin with 403', async () => { const { port } = await spawnServer(VAPID_ON) const res = await fetch(`http://127.0.0.1:${port}/push/subscribe`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', Origin: 'http://evil.example' }, body: JSON.stringify({ endpoint: 'https://push.example/ep-1' }), }) expect(res.status).toBe(403) }) it('removes a subscription with 204 for an allowed Origin', async () => { const { port, origin } = await spawnServer(VAPID_ON) await fetch(`http://127.0.0.1:${port}/push/subscribe`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin }, body: validSubscriptionBody(), }) const res = await fetch(`http://127.0.0.1:${port}/push/subscribe`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', Origin: origin }, body: JSON.stringify({ endpoint: 'https://push.example/ep-1' }), }) expect(res.status).toBe(204) }) }) // ── POST /hook/decision (security paths) ───────────────────────────────────────── describe('POST /hook/decision — guards (SEC-C1/M1/H9)', () => { const sid = '00000000-0000-4000-8000-000000000000' it('rejects a foreign Origin with 403', async () => { const { port } = await spawnServer(VAPID_ON) const res = await fetch(`http://127.0.0.1:${port}/hook/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: 'http://evil.example' }, body: JSON.stringify({ sessionId: sid, decision: 'allow', token: 'x' }), }) expect(res.status).toBe(403) }) it('rejects a missing Origin with 403', async () => { const { port } = await spawnServer(VAPID_ON) const res = await fetch(`http://127.0.0.1:${port}/hook/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: sid, decision: 'allow', token: 'x' }), }) expect(res.status).toBe(403) }) it('rejects an unknown/stale token with 403', async () => { const { port, origin } = await spawnServer(VAPID_ON) const res = await fetch(`http://127.0.0.1:${port}/hook/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin }, body: JSON.stringify({ sessionId: sid, decision: 'allow', token: 'not-a-real-token' }), }) expect(res.status).toBe(403) }) it('rejects a malformed body with 400', async () => { const { port, origin } = await spawnServer(VAPID_ON) const res = await fetch(`http://127.0.0.1:${port}/hook/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin }, body: JSON.stringify({ sessionId: sid }), }) expect(res.status).toBe(400) }) it('rate-limits more than 10 decision calls/min from one IP (429)', async () => { const { port, origin } = await spawnServer(VAPID_ON) const codes: number[] = [] for (let i = 0; i < 11; i++) { const res = await fetch(`http://127.0.0.1:${port}/hook/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin }, body: JSON.stringify({ sessionId: sid, decision: 'allow', token: 'x' }), }) codes.push(res.status) } // First 10 hit the handler (403 stale token); the 11th is rate-limited. expect(codes.slice(0, 10).every((c) => c === 403)).toBe(true) expect(codes[10]).toBe(429) }) }) // ── C1 hold gate + decision positive path (needs real PTY) ────────────────────── describe('C1 hold gate + /hook/decision positive path', () => { itPty( 'holds a permission with zero clients but ≥1 subscription, then resolves via /hook/decision', async () => { const { port, origin } = await spawnServer({ ...VAPID_ON, PERM_TIMEOUT_MS: '4000' }) // 1. Register a push subscription (so push is "enabled and has a target"). await fetch(`http://127.0.0.1:${port}/push/subscribe`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin }, body: validSubscriptionBody(), }) // 2. Create a session via WS, then detach (zero clients, PTY alive). const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { headers: { Origin: origin } }) await waitForOpen(ws) ws.send(JSON.stringify({ type: 'attach', sessionId: null })) const attached = await waitForMessage(ws, isAttached) const sessionId = attached['sessionId'] as string ws.close() await new Promise((r) => setTimeout(r, 150)) // 3. Fire the held permission hook (loopback). Do NOT await it. sentPayloads.length = 0 let resolved = false const permPromise = fetch(`http://127.0.0.1:${port}/hook/permission`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId }, body: JSON.stringify({ tool_name: 'Bash' }), }).then(async (r) => { resolved = true return (await r.json()) as { hookSpecificOutput?: { decision?: { behavior?: string } } } }) // 4. The push must have fired (carrying the capability token) and the hook // must still be HELD (C1: it did not immediately fall through to {}). await new Promise((r) => setTimeout(r, 250)) expect(sentPayloads.length).toBe(1) expect(resolved).toBe(false) const token = (JSON.parse(sentPayloads[0]!) as { token?: string }).token expect(typeof token).toBe('string') // 5. Resolve it from a "remote" device via /hook/decision with the token. const decRes = await fetch(`http://127.0.0.1:${port}/hook/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin }, body: JSON.stringify({ sessionId, decision: 'allow', token }), }) expect(decRes.status).toBe(204) const decision = await permPromise expect(decision.hookSpecificOutput?.decision?.behavior).toBe('allow') }, ) }) // ── WS approve mode + ALLOW_AUTO_MODE gate (needs real PTY) ────────────────────── describe('WS approve with permission mode (SEC-M5)', () => { async function approveWithMode( autoAllowed: boolean, mode: string, ): Promise<{ behavior?: string; mode?: string }> { const { port, origin } = await spawnServer({ ALLOW_AUTO_MODE: autoAllowed ? '1' : '0' }) const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { headers: { Origin: origin } }) await waitForOpen(ws) ws.send(JSON.stringify({ type: 'attach', sessionId: null })) const attached = await waitForMessage(ws, isAttached) const sessionId = attached['sessionId'] as string const permPromise = fetch(`http://127.0.0.1:${port}/hook/permission`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId }, body: JSON.stringify({ tool_name: 'ExitPlanMode' }), }) await waitForMessage(ws, (m) => m['pending'] === true) ws.send(JSON.stringify({ type: 'approve', mode })) const decision = (await (await permPromise).json()) as { hookSpecificOutput?: { decision?: { behavior?: string; mode?: string } } } ws.close() return decision.hookSpecificOutput?.decision ?? {} } itPty('keeps mode:auto when ALLOW_AUTO_MODE=1', async () => { const d = await approveWithMode(true, 'auto') expect(d.behavior).toBe('allow') expect(d.mode).toBe('auto') }) itPty('downgrades mode:auto → default when ALLOW_AUTO_MODE=0', async () => { const d = await approveWithMode(false, 'auto') expect(d.behavior).toBe('allow') expect(d.mode).toBe('default') }) itPty('passes through mode:acceptEdits unchanged', async () => { const d = await approveWithMode(false, 'acceptEdits') expect(d.behavior).toBe('allow') expect(d.mode).toBe('acceptEdits') }) })