#!/usr/bin/env node // Acceptation P4 (sans navigateur, sans quota Claude) : Web Push + commande WS `answer`. // Vrai daemon. Couvre : garde auth + Origin sur les routes push, clé VAPID exposée, subscribe // idempotent / malformé / unsubscribe, et la commande `answer` (rejets INVALID_ANSWER / // NOT_CONTROLLING — la validation fine `select` vit dans les tests vitest sur fixtures). import { spawn } from 'node:child_process'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const WebSocket = require('ws'); const PORT = 7544; const ORIGIN = `http://127.0.0.1:${PORT}`; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..'); const results = []; const check = (name, ok, detail = '') => { results.push({ name, ok, detail }); console.log(`${ok ? '✅' : '❌'} ${name}${detail ? ` — ${detail}` : ''}`); }; const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-p4-')); const srv = spawn( 'node', [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--claude-home', join(tmp, 'claude'), '--no-discover'], { env: { ...process.env, ARBORETUM_LOG: 'warn' }, stdio: ['ignore', 'pipe', 'pipe'] }, ); let srvOut = ''; srv.stdout.on('data', (d) => (srvOut += d)); srv.stderr.on('data', (d) => (srvOut += d)); const j = (path, method, cookie, body) => fetch(`${ORIGIN}${path}`, { method, headers: { Origin: ORIGIN, ...(cookie ? { Cookie: cookie } : {}), ...(body ? { 'Content-Type': 'application/json' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}), }); function wsClient(cookie) { const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws`, { headers: { Origin: ORIGIN, Cookie: cookie } }); const state = { msgs: [] }; ws.on('message', (data, isBinary) => { if (!isBinary) state.msgs.push(JSON.parse(String(data))); }); const waitMsg = async (pred, timeout = 8000) => { const t0 = Date.now(); while (Date.now() - t0 < timeout) { const m = state.msgs.find(pred); if (m) return m; await sleep(50); } return null; }; return { ws, state, waitMsg, send: (m) => ws.send(JSON.stringify(m)) }; } try { await sleep(1500); const token = /arb_[0-9a-f]+/.exec(srvOut)?.[0]; check('boot + token bootstrap', !!token); // Garde d'auth globale : route push sans cookie → 401. const noAuth = await j('/api/v1/push/vapid-public-key', 'GET', ''); check('GET vapid-public-key sans cookie → 401', noAuth.status === 401); const login = await fetch(`${ORIGIN}/api/v1/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: ORIGIN }, body: JSON.stringify({ token }), }); const cookie = login.headers.get('set-cookie')?.split(';')[0] ?? ''; check('login → cookie', login.status === 200); // Check Origin strict : origine non autorisée (même avec cookie) → 403. const badOrigin = await fetch(`${ORIGIN}/api/v1/push/vapid-public-key`, { headers: { Origin: 'http://evil.example', Cookie: cookie } }); check('Origin invalide → 403', badOrigin.status === 403); // Clé VAPID publique exposée (sûre). const vapid = await j('/api/v1/push/vapid-public-key', 'GET', cookie); const vapidBody = await vapid.json(); check('GET vapid-public-key → 200 + clé non triviale', vapid.status === 200 && typeof vapidBody.key === 'string' && vapidBody.key.length > 20); // Abonnement : 201, UPSERT idempotent, rejet du malformé, désabonnement. const sub = { endpoint: 'https://push.example/endpoint-1', keys: { p256dh: 'BPp256dhKeyDummy', auth: 'authDummy' } }; const s1 = await j('/api/v1/push/subscribe', 'POST', cookie, sub); check('POST subscribe → 201', s1.status === 201); const s2 = await j('/api/v1/push/subscribe', 'POST', cookie, sub); check('subscribe idempotent (même endpoint) → 201', s2.status === 201); const badSub = await j('/api/v1/push/subscribe', 'POST', cookie, { endpoint: 'x' }); check('subscribe sans keys → 400', badSub.status === 400); const uns = await j('/api/v1/push/unsubscribe', 'POST', cookie, { endpoint: sub.endpoint }); check('POST unsubscribe → 200', uns.status === 200); // Commande WS `answer` : rejets sur une session bash managée (pas d'état waiting) et en observer. const created = await j('/api/v1/sessions', 'POST', cookie, { cwd: tmp, command: 'bash' }); const sess = (await created.json()).session; check('POST /sessions bash → 201', created.status === 201 && sess?.command === 'bash'); const c = wsClient(cookie); await new Promise((res, rej) => (c.ws.on('open', res), c.ws.on('error', rej))); c.send({ type: 'hello', protocol: 1 }); await c.waitMsg((m) => m.type === 'hello_ok'); c.send({ type: 'attach', sessionId: sess.id, mode: 'interactive', cols: 80, rows: 24 }); const att = await c.waitMsg((m) => m.type === 'attached'); check('attach interactive (contrôleur)', !!att && att.controlling === true); c.send({ type: 'answer', channel: att.channel, action: 'deny' }); const invalid = await c.waitMsg((m) => m.type === 'error' && m.code === 'INVALID_ANSWER'); check('answer sur session non-waiting → INVALID_ANSWER', !!invalid); c.send({ type: 'attach', sessionId: sess.id, mode: 'observer', cols: 80, rows: 24 }); const obs = await c.waitMsg((m) => m.type === 'attached' && m.mode === 'observer'); check('attach observer (read-only)', !!obs && obs.controlling === false); c.send({ type: 'answer', channel: obs.channel, action: 'deny' }); const notCtrl = await c.waitMsg((m) => m.type === 'error' && m.code === 'NOT_CONTROLLING' && m.channel === obs.channel); check('answer en observer → NOT_CONTROLLING', !!notCtrl); c.ws.close(); } catch (err) { check('exception', false, String(err)); } finally { srv.kill('SIGTERM'); await sleep(1500); check('arrêt propre du daemon (SIGTERM)', srv.exitCode === 0 || srv.signalCode === null || srv.exitCode === null); rmSync(tmp, { recursive: true, force: true }); const failed = results.filter((r) => !r.ok); console.log(failed.length === 0 ? '\nACCEPTANCE P4: ALL GREEN' : `\nACCEPTANCE P4: ${failed.length} FAILURE(S)`); process.exit(failed.length === 0 ? 0 : 1); }