// P5 adapter smoke: signed webhook → (async) normalized into a thread; bad // signature rejected; sandboxed outbound recorded. Requires service running // (relay timer on). No real network is ever contacted. import 'dotenv/config'; import crypto from 'node:crypto'; import jwt from 'jsonwebtoken'; const SERVICE = process.env.SMOKE_URL ?? 'http://localhost:3200'; const APP_ID = 'portal-demo'; const APP_SECRET = JSON.parse(process.env.APP_SECRETS ?? '{"portal-demo":"dev-secret"}')[APP_ID]; const ADAPTER_SECRET = (() => { try { return JSON.parse(process.env.ADAPTER_SECRETS ?? '{}').WEBHOOK ?? 'dev-adapter-secret'; } catch { return 'dev-adapter-secret'; } })(); const sessionToken = jwt.sign({ sub: 'inspector', appId: APP_ID, orgId: `org_${APP_ID}` }, APP_SECRET, { algorithm: 'HS256', expiresIn: '1h' }); const sign = (body) => 'sha256=' + crypto.createHmac('sha256', ADAPTER_SECRET).update(body).digest('hex'); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const assert = (c, m) => { if (!c) { console.error('✗', m); process.exit(1); } console.log('✓', m); }; async function get(path) { const r = await fetch(`${SERVICE}${path}`, { headers: { authorization: `Bearer ${sessionToken}` } }); if (!r.ok) throw new Error(`GET ${path} ${r.status}`); return r.json(); } async function pollUntil(fn, ms = 8000) { const end = Date.now() + ms; while (Date.now() < end) { const v = await fn(); if (v) return v; await sleep(300); } return null; } // 1) signed webhook → 202 → async normalize const payload = { eventId: `sm-${Date.now()}`, from: 'sim@customer', text: 'hi from a provider', threadRef: 'sm-thread' }; const body = JSON.stringify(payload); const wh = await fetch(`${SERVICE}/v1/adapters/WEBHOOK/webhook`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-iios-signature': sign(body) }, body, }); assert(wh.status === 202, `signed webhook accepted (202)`); const normalized = await pollUntil(async () => { const list = await get('/v1/adapters/inbound'); const e = list.find((x) => x.externalEventId === payload.eventId); return e && e.status === 'NORMALIZED' ? e : null; }); assert(normalized, 'raw event NORMALIZED (async projector)'); assert(!!normalized.interactionId, 'ingested into a kernel interaction'); // 2) bad signature → rejected, nothing ingested const bad = await fetch(`${SERVICE}/v1/adapters/WEBHOOK/webhook`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-iios-signature': 'sha256=bad' }, body: JSON.stringify({ eventId: `bad-${Date.now()}`, from: 'x', text: 'x' }), }); assert(bad.status === 401, 'bad signature rejected (401)'); // 3) outbound → sandbox sink (no network) const sendRes = await fetch(`${SERVICE}/v1/adapters/WEBHOOK/send`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` }, body: JSON.stringify({ target: 'user@x', payload: { text: 'agent reply' } }), }); const cmd = await sendRes.json(); assert(cmd.status === 'SENT' && String(cmd.providerRef).includes('sim-'), 'outbound SENT via sandbox (no network)'); console.log('\nP5 adapter smoke: PASS'); process.exit(0);