Files
web-terminal/test/integration/push.test.ts
Yaojia Wang d6809c65c4 feat(v0.7): Walk-away Workbench (Band A + B) — multi-agent parallel build
Implements docs/PLAN_WALKAWAY_WORKBENCH.md (27 tasks, waves R0→W0→W1×14→W2→W3→W4)
via module-builder agents. 23 tasks built, 0 blocked.

Band A (finish the walk-away loop): A1 Web Push + lock-screen approve/deny
(web-push dep), A2 voice dictation, A3 quick-reply chips + saved-prompt palette,
A4 activity timeline, A5 stuck/idle alert.
Band B (workbench above the terminal): B1 read-only git diff viewer, B2 statusLine
telemetry → per-tab cost/context/PR gauges, B3 create git worktrees from the UI,
B4 plan-mode / permission-mode relay.

New: src/push/* (subscription store + VAPID push), src/http/{diff,statusline}.ts,
src/session/timeline.ts, public/{diff,timeline,quickreply,push-ui,...}.ts, sw-push,
statusLine script; extends hook intake, manager, server routes (Origin/CSRF guards
+ per-IP rate limits on state-changing ones; loopback-only ingest), terminal-session,
tabs, projects detail, service worker, setup-hooks (statusLine + ntfy bridge).

Orchestrator reconciled a W0 contract gap: added the 21 v0.7 Config fields to the
Config interface in types.ts (T-types had left them only in config.ts's return).

Verified: both tsc clean, full vitest + coverage 91.4/84.1/92.2/93.4 (≥80×4),
build:web OK. W4 review: no CRITICAL/HIGH; all security checks pass. Follow-ups
(non-blocking): move approve.mode validation into parseClientMessage, drop CSP
ws:/wss: wildcard, validate worktree base ref, +2 targeted tests.
2026-06-30 17:42:18 +02:00

423 lines
16 KiB
TypeScript

/**
* 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<void> => {
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<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 tmpFiles: string[] = []
async function spawnServer(
overrides: Record<string, string | undefined> = {},
): 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<void>((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<void> {
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<string, unknown>) => boolean): Promise<Record<string, unknown>> {
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<string, unknown>
try {
m = JSON.parse(raw.toString('utf8')) as Record<string, unknown>
} catch {
return
}
if (pred(m)) {
clearTimeout(t)
ws.off('message', onMsg)
resolve(m)
}
}
ws.on('message', onMsg)
})
}
const isAttached = (m: Record<string, unknown>): 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<void>((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<void>((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<void>((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')
})
})