// End-to-end test of the backend platform. // Exercises the full flow: register -> login -> enroll machine -> agent online -> // technician requests session -> consent -> signaling relay -> audit trail. // No browser/Electron needed: the "agent" and "viewer" are raw WebSocket clients. // (Login currently marks the session MFA-passed directly, so there is no separate // TOTP step in the product flow; the MFA endpoints still exist but aren't exercised here.) const fs = require('fs'); const os = require('os'); const path = require('path'); const DB = path.join(os.tmpdir(), 'ra-e2e.db'); process.env.DB_PATH = DB; for (const f of [DB, DB + '-wal', DB + '-shm']) { try { fs.unlinkSync(f); } catch {} } const PORT = 8099; process.env.PORT = PORT; process.env.HTTPS_PORT = 8444; // avoid clashing with a running dev server on 8443 const { server } = require('../server'); const A = require('../auth'); const WebSocket = require('ws'); const http = require('http'); const crypto = require('crypto'); const BASE = `http://localhost:${PORT}`; let passed = 0, failed = 0; function check(name, cond) { if (cond) { console.log(' ok -', name); passed++; } else { console.log(' FAIL-', name); failed++; } } // minimal cookie-aware fetch async function call(path, body, cookie) { const r = await fetch(BASE + path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(cookie ? { Cookie: cookie } : {}) }, body: body ? JSON.stringify(body) : undefined, }); const setCookie = r.headers.get('set-cookie'); const data = await r.json().catch(() => ({})); return { status: r.status, data, cookie: setCookie ? setCookie.split(';')[0] : cookie }; } async function get(path, cookie) { const r = await fetch(BASE + path, { headers: cookie ? { Cookie: cookie } : {} }); return { status: r.status, data: await r.json().catch(() => ({})) }; } const wait = (ms) => new Promise((r) => setTimeout(r, ms)); function wsClient() { const ws = new WebSocket(`ws://localhost:${PORT}/ws`); ws.q = []; ws.on('message', (d) => ws.q.push(JSON.parse(d))); return ws; } function nextMsg(ws, type, timeout = 3000) { return new Promise((resolve, reject) => { const start = Date.now(); (function poll() { const i = ws.q.findIndex((m) => m.type === type); if (i >= 0) return resolve(ws.q.splice(i, 1)[0]); if (Date.now() - start > timeout) return reject(new Error('timeout waiting for ' + type)); setTimeout(poll, 20); })(); }); } (async () => { await wait(300); // let server bind console.log('E2E backend tests:'); // Local receiver to capture outbound webhook deliveries. const webhookHits = []; const hookSrv = http.createServer((rq, rs) => { let b = ''; rq.on('data', (c) => (b += c)); rq.on('end', () => { webhookHits.push({ sig: rq.headers['x-bizgaze-signature'], body: b }); rs.writeHead(200); rs.end('ok'); }); }); await new Promise((r) => hookSrv.listen(8077, r)); const HOOK_URL = 'http://localhost:8077/hook'; // 1. Register (first user becomes admin) const email = 'tech@example.com'; const reg = await call('/api/register', { email, password: 'supersecret', teamName: 'Acme IT' }); check('register succeeds', reg.status === 200 && reg.data.ok === true); // 2. Login -> session cookie (login marks the session MFA-passed) const login = await call('/api/login', { email, password: 'supersecret' }); check('login sets session cookie', !!login.cookie); const cookie = login.cookie; // 3. Protected route works right after login, role=admin const me = await get('/api/me', cookie); check('me works after login, role=admin', me.status === 200 && me.data.role === 'admin'); // 3b. Native client path: access+refresh tokens, refresh exchange (rotated), Bearer auth check('login returns access + refresh tokens', !!login.data.token && !!login.data.refreshToken); const refreshed = await call('/api/v1/auth/refresh', { refreshToken: login.data.refreshToken }); check('refresh issues a new access token', refreshed.status === 200 && !!refreshed.data.token && !!refreshed.data.refreshToken); const meBearer = await fetch(BASE + '/api/v1/me', { headers: { Authorization: 'Bearer ' + refreshed.data.token } }); check('new access token authorizes /api/v1/me (Bearer)', meBearer.status === 200); const reuse = await call('/api/v1/auth/refresh', { refreshToken: login.data.refreshToken }); check('rotated (old) refresh token is rejected', reuse.status === 401); // 3c. API keys (machine-to-machine integration), scoped + revocable const mkKey = await call('/api/v1/keys', { name: 'ci', scopes: ['report:read'] }, cookie); check('admin creates API key (bzc_ prefix)', mkKey.status === 200 && /^bzc_/.test(mkKey.data.key || '')); const apiKey = mkKey.data.key; const repKey = await fetch(BASE + '/api/v1/report', { headers: { 'X-API-Key': apiKey } }); check('API key with report:read reads /api/v1/report', repKey.status === 200); const repNone = await fetch(BASE + '/api/v1/report'); check('no credential -> /api/v1/report 401', repNone.status === 401); const audKey = await fetch(BASE + '/api/v1/audit', { headers: { 'X-API-Key': apiKey } }); check('report-only key cannot read audit (scope enforced)', audKey.status === 401); const revKey = await call('/api/v1/keys/revoke', { id: mkKey.data.id }, cookie); check('admin revokes API key', revKey.status === 200); const repRevoked = await fetch(BASE + '/api/v1/report', { headers: { 'X-API-Key': apiKey } }); check('revoked API key -> 401', repRevoked.status === 401); // 3d. Register a webhook (delivery is asserted after the session flow below) const mkHook = await call('/api/v1/webhooks', { url: HOOK_URL, events: ['*'] }, cookie); check('admin registers a webhook', mkHook.status === 200 && !!mkHook.data.secret); const hookSecret = mkHook.data.secret; // 3e. Chat (persistent 1:1 messaging) — two users in the same team const adminId = me.data.id; await call('/api/v1/users', { email: 'bob@example.com', password: 'supersecret', name: 'Bob' }, cookie); const bobLogin = await call('/api/v1/login', { email: 'bob@example.com', password: 'supersecret' }); const bobCookie = bobLogin.cookie; const contacts = await get('/api/v1/messages/contacts', cookie); check('contacts list includes the other user', contacts.status === 200 && contacts.data.some((c) => c.email === 'bob@example.com')); const bobId = (contacts.data.find((c) => c.email === 'bob@example.com') || {}).id; const bobWs = new WebSocket(`ws://localhost:${PORT}/ws`, { headers: { Cookie: bobCookie } }); bobWs.q = []; bobWs.on('message', (d) => bobWs.q.push(JSON.parse(d))); await new Promise((r) => bobWs.on('open', r)); bobWs.send(JSON.stringify({ type: 'chat-hello' })); await nextMsg(bobWs, 'chat-ready'); check('chat-hello -> chat-ready', true); const sent = await call('/api/v1/messages', { to: bobId, body: 'hello bob' }, cookie); check('message sent', sent.status === 200 && !!sent.data.id); const pushed = await nextMsg(bobWs, 'chat-message'); check('recipient receives the message live over WS', pushed.message && pushed.message.body === 'hello bob'); const bobConvos = await get('/api/v1/messages/conversations', bobCookie); check('recipient conversation shows unread', bobConvos.data.some((c) => c.contactId === adminId && c.unread >= 1)); const bobThread = await get('/api/v1/messages/thread?with=' + adminId, bobCookie); check('thread returns the message', bobThread.status === 200 && bobThread.data.some((m) => m.body === 'hello bob')); const bobConvos2 = await get('/api/v1/messages/conversations', bobCookie); check('reading the thread clears unread', !bobConvos2.data.some((c) => c.contactId === adminId && c.unread >= 1)); // read receipt: after bob read the thread, admin's sent message shows read_at const adminTh = await get('/api/v1/messages/thread?with=' + bobId, cookie); const seenMsg = adminTh.data.find((x) => x.body === 'hello bob'); check('read receipt: sent message marked read after recipient reads', !!seenMsg && !!seenMsg.read_at); // reply / quote const reply = await call('/api/v1/messages', { to: bobId, body: 'replying to you', replyTo: sent.data.id }, cookie); check('reply accepts replyTo', reply.status === 200 && reply.data.reply_to === sent.data.id); const adminThread = await get('/api/v1/messages/thread?with=' + bobId, cookie); check('thread carries the quoted preview', adminThread.data.some((x) => x.reply && x.reply.body === 'hello bob')); // reactions const react1 = await call('/api/v1/messages/react', { messageId: sent.data.id, emoji: '👍' }, cookie); check('reaction toggles on', react1.status === 200 && react1.data.added === true); const rpush = await nextMsg(bobWs, 'chat-reaction'); check('reaction pushed live (full set, with who)', rpush.messageId === sent.data.id && Array.isArray(rpush.reactions) && rpush.reactions.some((r) => r.emoji === '👍' && r.count === 1 && Array.isArray(r.who) && r.who.length === 1)); const th2 = await get('/api/v1/messages/thread?with=' + bobId, cookie); const rmsg = th2.data.find((x) => x.id === sent.data.id); check('thread aggregates the reaction', !!rmsg && rmsg.reactions.some((r) => r.emoji === '👍' && r.count === 1 && r.mine === true && r.who.length === 1)); // one reaction per user: a different emoji REPLACES the previous one (no stacking) const react1b = await call('/api/v1/messages/react', { messageId: sent.data.id, emoji: '❤️' }, cookie); check('switching emoji replaces (one reaction per user)', react1b.data.added === true && react1b.data.reactions.length === 1 && react1b.data.reactions[0].emoji === '❤️'); const react2 = await call('/api/v1/messages/react', { messageId: sent.data.id, emoji: '❤️' }, cookie); check('reaction toggles off', react2.data.added === false && react2.data.reactions.length === 0); // file sharing const up = await fetch(BASE + '/api/v1/messages/upload', { method: 'POST', headers: { Cookie: cookie, 'Content-Type': 'text/plain', 'X-Filename': encodeURIComponent('note.txt') }, body: 'hello file' }); const upd = await up.json(); check('file upload returns an id', up.status === 200 && !!upd.id); const fmsg = await call('/api/v1/messages', { to: bobId, attachmentId: upd.id }, cookie); check('message with attachment (no text) accepted', fmsg.status === 200 && fmsg.data.attachment && fmsg.data.attachment.id === upd.id); const dl = await fetch(BASE + '/files/' + upd.id, { headers: { Cookie: cookie } }); const dlBody = await dl.text(); check('attachment downloads for a participant', dl.status === 200 && dlBody === 'hello file'); const dlNo = await fetch(BASE + '/files/' + upd.id); check('attachment download denied without auth', dlNo.status === 401); // 3g. Group chat const mkGroup = await call('/api/v1/groups', { name: 'Team Huddle', memberIds: [bobId] }, cookie); check('group created with members', mkGroup.status === 200 && !!mkGroup.data.id && mkGroup.data.members === 2); const gid = mkGroup.data.id; bobWs.q.length = 0; // drain prior pushes so we catch the group message const gsend = await call('/api/v1/messages', { group: gid, body: 'hi team' }, cookie); check('group message sent', gsend.status === 200 && gsend.data.conversation_id === gid); const gpush = await nextMsg(bobWs, 'chat-message'); check('group message delivered to a member', gpush.message && gpush.message.conversation_id === gid && gpush.message.body === 'hi team'); const gconv = await get('/api/v1/messages/conversations', bobCookie); const gItem = gconv.data.find((c) => c.kind === 'group' && c.id === gid); check('group appears in member conversations with unread', !!gItem && gItem.unread >= 1); const gthread = await get('/api/v1/messages/thread?group=' + gid, bobCookie); check('group thread returns messages with sender name', gthread.status === 200 && gthread.data.some((m) => m.body === 'hi team' && m.fromName)); const gconv2 = await get('/api/v1/messages/conversations', bobCookie); const gItem2 = gconv2.data.find((c) => c.kind === 'group' && c.id === gid); check('reading group thread clears unread', !!gItem2 && gItem2.unread === 0); // group "seen by": bob read the thread above, so admin's message lists him as a reader const gSeen = await get('/api/v1/messages/thread?group=' + gid, cookie); const hiTeam = gSeen.data.find((x) => x.body === 'hi team'); check('group message shows seen-by readers', !!hiTeam && Array.isArray(hiTeam.seenBy) && hiTeam.seenBy.length >= 1); const gmembers = await get('/api/v1/groups/members?group=' + gid, cookie); check('group members listed', gmembers.status === 200 && gmembers.data.length === 2); // @mentions: members + @everyone are stored; non-members are filtered out const ment = await call('/api/v1/messages', { group: gid, body: 'ping @Bob and @everyone', mentions: [bobId, 'everyone', 'not-a-member'] }, cookie); check('group message accepts mentions', ment.status === 200); check('mentions filtered to members + everyone', Array.isArray(ment.data.mentions) && ment.data.mentions.includes(bobId) && ment.data.mentions.includes('everyone') && !ment.data.mentions.includes('not-a-member')); const mthread = await get('/api/v1/messages/thread?group=' + gid, bobCookie); const mEntry = mthread.data.find((x) => x.id === ment.data.id); check('thread carries mentions', !!mEntry && mEntry.mentions.includes(bobId) && mEntry.mentions.includes('everyone')); // 3j. Polls (single + multi, vote/switch, close, inline in thread) const mkPoll = await call('/api/v1/polls', { group: gid, question: 'Lunch?', options: ['Pizza', 'Sushi'], multi: false }, cookie); check('poll created', mkPoll.status === 200 && mkPoll.data.options.length === 2 && mkPoll.data.totalVotes === 0); const pid = mkPoll.data.id; const needTwo = await call('/api/v1/polls', { group: gid, question: 'X?', options: ['only one'] }, cookie); check('poll requires >=2 options', needTwo.status === 400); const v1 = await call('/api/v1/polls/vote', { pollId: pid, optionIdx: 0 }, bobCookie); check('vote recorded', v1.status === 200 && v1.data.options[0].votes === 1 && v1.data.options[0].mine === true); const v2 = await call('/api/v1/polls/vote', { pollId: pid, optionIdx: 1 }, bobCookie); // single-choice -> switch check('single-choice switches the vote', v2.data.options[0].votes === 0 && v2.data.options[1].votes === 1); const av = await call('/api/v1/polls/vote', { pollId: pid, optionIdx: 0 }, cookie); check('second voter counted', av.data.totalVotes === 2 && av.data.voters === 2); const mkPoll2 = await call('/api/v1/polls', { group: gid, question: 'Toppings?', options: ['Cheese', 'Olives', 'Mushroom'], multi: true }, cookie); const pid2 = mkPoll2.data.id; await call('/api/v1/polls/vote', { pollId: pid2, optionIdx: 0 }, bobCookie); const mv = await call('/api/v1/polls/vote', { pollId: pid2, optionIdx: 1 }, bobCookie); check('multi-choice keeps multiple votes', mv.data.options[0].votes === 1 && mv.data.options[1].votes === 1 && mv.data.voters === 1); const badClose = await call('/api/v1/polls/close', { pollId: pid }, bobCookie); check('non-creator cannot close a poll', badClose.status === 403); const closed = await call('/api/v1/polls/close', { pollId: pid }, cookie); check('creator closes the poll', closed.status === 200 && closed.data.closed === true); const voteClosed = await call('/api/v1/polls/vote', { pollId: pid, optionIdx: 0 }, bobCookie); check('cannot vote on a closed poll', voteClosed.status === 400); const pthread = await get('/api/v1/messages/thread?group=' + gid, bobCookie); const pmsg = pthread.data.find((x) => x.poll && x.poll.id === pid); check('poll renders inline in the thread', !!pmsg && pmsg.poll.options.length === 2 && pmsg.poll.closed === true); // group management: rename, info, remove(leave) const ren = await call('/api/v1/groups/rename', { group: gid, name: 'Renamed Huddle' }, cookie); check('group renamed', ren.status === 200 && ren.data.name === 'Renamed Huddle'); const gthrRen = await get('/api/v1/messages/thread?group=' + gid, cookie); check('rename posts an activity message', gthrRen.data.some((x) => x.system && /renamed/.test(x.body))); // admin-only toggle: only the creator can change it / manage members when on const ao1 = await call('/api/v1/groups/admin-only', { group: gid, value: true }, cookie); check('admin enables admin-only', ao1.status === 200 && ao1.data.adminOnly === true); const aoBobToggle = await call('/api/v1/groups/admin-only', { group: gid, value: false }, bobCookie); check('non-admin cannot change admin-only', aoBobToggle.status === 403); const aoBobRemove = await call('/api/v1/groups/remove', { group: gid, userId: adminId }, bobCookie); check('admin-only blocks non-admin removing others', aoBobRemove.status === 403); const ao0 = await call('/api/v1/groups/admin-only', { group: gid, value: false }, cookie); check('admin disables admin-only', ao0.status === 200 && ao0.data.adminOnly === false); // shared group call: start returns a room; a second start joins the same live call const call1 = await call('/api/v1/groups/call/start', { group: gid }, cookie); check('group call starts with a room', call1.status === 200 && /^\d{6}$/.test(call1.data.room || '') && call1.data.active === true); const call2 = await call('/api/v1/groups/call/start', { group: gid }, bobCookie); check('second start joins the same call (no code)', call2.status === 200 && call2.data.room === call1.data.room && call2.data.already === true); const convCall = await get('/api/v1/messages/conversations', bobCookie); const gcRow = convCall.data.find((c) => c.kind === 'group' && c.id === gid); check('conversations expose the active call', !!gcRow && gcRow.callActive === true && gcRow.callRoom === call1.data.room); // 1:1 call + add-participant invite const dmCall1 = await call('/api/v1/calls/dm/start', { to: bobId }, cookie); check('DM call starts with a room', dmCall1.status === 200 && /^\d{6}$/.test(dmCall1.data.room || '') && dmCall1.data.active === true); const dmCall2 = await call('/api/v1/calls/dm/start', { to: adminId }, bobCookie); check('DM call join returns the same room', dmCall2.status === 200 && dmCall2.data.room === dmCall1.data.room && dmCall2.data.already === true); const inv = await call('/api/v1/calls/invite', { room: dmCall1.data.room, userIds: [bobId] }, cookie); check('invite to a live call accepted', inv.status === 200 && inv.data.invited === 1); const invBad = await call('/api/v1/calls/invite', { room: '000000', userIds: [bobId] }, cookie); check('invite to a non-existent call rejected', invBad.status === 404); const ginfo = await get('/api/v1/groups/info?group=' + gid, cookie); check('group info returns name + members + isCreator', ginfo.status === 200 && ginfo.data.name === 'Renamed Huddle' && ginfo.data.isCreator === true && ginfo.data.members.length === 2); // 3h. Scheduled meetings (schedule -> announce -> list buckets -> lazy join -> cancel) const future = Date.now() + 24 * 60 * 60 * 1000; bobWs.q.length = 0; const sched = await call('/api/v1/meetings/schedule', { group: gid, title: 'Sprint Review', description: 'Demo + retro', scheduledAt: future, whenText: 'Tomorrow' }, cookie); check('meeting scheduled, returns 6-digit room code', sched.status === 200 && /^\d{6}$/.test(sched.data.roomCode || '')); const schP = await call('/api/v1/meetings/schedule', { title: 'Synced', scheduledAt: Date.now() + 3600000, participants: [bobId] }, cookie); check('meeting scheduled with participants', schP.status === 200 && Array.isArray(schP.data.participants) && schP.data.participants.includes(bobId)); const bobMeetings = await get('/api/v1/meetings', bobCookie); const bm = bobMeetings.data.find((m) => m.id === schP.data.id); check('invited participant sees the meeting', !!bm && bm.isHost === false); const schPush = await nextMsg(bobWs, 'chat-message'); check('schedule announced in the group chat', schPush.message && /Scheduled a call/.test(schPush.message.body)); const pastM = await call('/api/v1/meetings/schedule', { group: gid, title: 'Old Standup', scheduledAt: Date.now() - 2 * 60 * 60 * 1000 }, cookie); check('past meeting scheduling is rejected', pastM.status === 400); // can't schedule in the past (#1) const mlist = await get('/api/v1/meetings', cookie); const schUp = mlist.data.find((m) => m.id === sched.data.id); check('meetings list buckets upcoming', mlist.status === 200 && schUp && schUp.status === 'upcoming'); const sm = wsClient(); await new Promise((r) => sm.on('open', r)); sm.send(JSON.stringify({ type: 'meeting-join', room: sched.data.roomCode, name: 'Admin' })); const smJoined = await nextMsg(sm, 'meeting-joined'); check('scheduled meeting joinable by code (room created lazily)', !!smJoined.peerId && smJoined.room === sched.data.roomCode); const mlist2 = await get('/api/v1/meetings', cookie); const upRun = mlist2.data.find((m) => m.id === sched.data.id); check('scheduled meeting shows running while a peer is connected', !!upRun && upRun.status === 'running' && upRun.inCall >= 1); sm.close(); const cancelBob = await call('/api/v1/meetings/cancel', { id: sched.data.id }, bobCookie); check('non-organizer cannot cancel a meeting', cancelBob.status === 403); const cancelOk = await call('/api/v1/meetings/cancel', { id: sched.data.id }, cookie); check('organizer cancels the meeting', cancelOk.status === 200); // 3i. Group image (upload -> set as group avatar -> visible to members) const imgUp = await fetch(BASE + '/api/v1/messages/upload', { method: 'POST', headers: { Cookie: cookie, 'Content-Type': 'image/png', 'X-Filename': encodeURIComponent('logo.png') }, body: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) }); const imgUpd = await imgUp.json(); check('group image uploaded', imgUp.status === 200 && !!imgUpd.id); const badAv = await call('/api/v1/groups/avatar', { group: gid, attachmentId: upd.id }, cookie); // upd.id is text/plain check('non-image rejected as group photo', badAv.status === 400); const setAv = await call('/api/v1/groups/avatar', { group: gid, attachmentId: imgUpd.id }, cookie); check('group photo set', setAv.status === 200 && setAv.data.avatar === '/files/' + imgUpd.id); const ginfoAv = await get('/api/v1/groups/info?group=' + gid, cookie); check('group info exposes the photo', ginfoAv.status === 200 && ginfoAv.data.avatar === '/files/' + imgUpd.id); const memberFetch = await fetch(BASE + '/files/' + imgUpd.id, { headers: { Cookie: bobCookie } }); check('group member can fetch the group photo', memberFetch.status === 200); const bobLeave = await call('/api/v1/groups/remove', { group: gid }, bobCookie); // bob leaves check('member can leave the group', bobLeave.status === 200 && bobLeave.data.left === true); const gthrLeft = await get('/api/v1/messages/thread?group=' + gid, cookie); check('leaving posts an activity message', gthrLeft.data.some((x) => x.system && /left/.test(x.body))); const ginfo2 = await get('/api/v1/groups/info?group=' + gid, cookie); check('member count drops after leave', ginfo2.status === 200 && ginfo2.data.members.length === 1); bobWs.close(); // 3f. Meetings (mesh) signaling: create room, two peers join, relay signal, leave const alice = wsClient(); await new Promise((r) => alice.on('open', r)); alice.send(JSON.stringify({ type: 'meeting-create' })); const created = await nextMsg(alice, 'meeting-created'); check('meeting-create returns a 6-digit room code', /^\d{6}$/.test(created.room || '')); alice.send(JSON.stringify({ type: 'meeting-join', room: created.room, name: 'Alice' })); const aJoined = await nextMsg(alice, 'meeting-joined'); check('first peer joins (room empty)', !!aJoined.peerId && aJoined.peers.length === 0); const carol = wsClient(); await new Promise((r) => carol.on('open', r)); carol.send(JSON.stringify({ type: 'meeting-join', room: created.room, name: 'Carol' })); const cJoined = await nextMsg(carol, 'meeting-joined'); check('second peer sees the first in the room', cJoined.peers.some((p) => p.peerId === aJoined.peerId)); const aPeerJoined = await nextMsg(alice, 'meeting-peer-joined'); check('existing peer notified of newcomer', aPeerJoined.peerId === cJoined.peerId); alice.send(JSON.stringify({ type: 'meeting-signal', to: cJoined.peerId, data: { fake: 'offer' } })); const relayed = await nextMsg(carol, 'meeting-signal'); check('meeting signal relayed peer->peer', relayed.from === aJoined.peerId && relayed.data.fake === 'offer'); carol.close(); const aPeerLeft = await nextMsg(alice, 'meeting-peer-left'); check('peer-left delivered on disconnect', aPeerLeft.peerId === cJoined.peerId); alice.close(); // 4. Wrong password rejected const badLogin = await call('/api/login', { email, password: 'wrong' }); check('wrong password rejected', badLogin.status === 401); // 8. Enroll a machine (consent-required) const mach = await call('/api/machines', { name: 'Dana-Laptop', unattended: false }, cookie); check('machine enrolled, returns token', mach.status === 200 && mach.data.enrollToken); const enrollToken = mach.data.enrollToken; // 9. Agent comes online const agent = wsClient(); await new Promise((r) => agent.on('open', r)); agent.send(JSON.stringify({ type: 'agent-hello', enrollToken })); const reg2 = await nextMsg(agent, 'agent-registered'); check('agent registers via enroll token', reg2.name === 'Dana-Laptop'); // machine shows online in API const machines = await get('/api/machines', cookie); check('machine reports online', machines.data[0].online === true); // 10. Technician (viewer) requests a session — needs cookie on the WS upgrade const viewer = new WebSocket(`ws://localhost:${PORT}/ws`, { headers: { Cookie: cookie } }); viewer.q = []; viewer.on('message', (d) => viewer.q.push(JSON.parse(d))); await new Promise((r) => viewer.on('open', r)); viewer.send(JSON.stringify({ type: 'viewer-connect', machineId: machines.data[0].id })); const pending = await nextMsg(viewer, 'session-pending'); check('viewer gets session-pending', !!pending.sessionId); // 11. Agent receives the consent request const reqMsg = await nextMsg(agent, 'session-request'); check('agent receives session-request with technician email', reqMsg.technician === email); // 12. Agent grants consent -> both sides proceed agent.send(JSON.stringify({ type: 'consent', sessionId: reqMsg.sessionId, granted: true })); const ready = await nextMsg(viewer, 'session-ready'); const startStream = await nextMsg(agent, 'start-stream'); check('consent grant -> viewer session-ready', !!ready); check('consent grant -> agent start-stream', !!startStream); // 13. Signaling relay: agent offer reaches viewer; viewer answer reaches agent agent.send(JSON.stringify({ type: 'offer', sessionId: reqMsg.sessionId, sdp: { fake: 'offer' } })); const relayedOffer = await nextMsg(viewer, 'offer'); check('offer relayed agent->viewer', relayedOffer.sdp.fake === 'offer'); viewer.send(JSON.stringify({ type: 'answer', sessionId: reqMsg.sessionId, sdp: { fake: 'answer' } })); const relayedAnswer = await nextMsg(agent, 'answer'); check('answer relayed viewer->agent', relayedAnswer.sdp.fake === 'answer'); // 14. End session viewer.send(JSON.stringify({ type: 'end-session', sessionId: reqMsg.sessionId })); await nextMsg(agent, 'session-ended'); check('session-ended delivered to agent', true); // 14b. Outbound webhook delivery (session.started + session.ended) with valid signatures await wait(900); const parse = (h) => { try { return JSON.parse(h.body); } catch { return {}; } }; const hookStarted = webhookHits.find((h) => parse(h).event === 'session.started'); const hookEnded = webhookHits.find((h) => parse(h).event === 'session.ended'); check('webhook received session.started', !!hookStarted); check('webhook received session.ended', !!hookEnded); check('webhook signature is valid (HMAC-SHA256)', !!hookEnded && hookEnded.sig === crypto.createHmac('sha256', hookSecret).update(hookEnded.body).digest('base64url')); // 15. Audit log captured the full flow const audit = await get('/api/audit', cookie); const actions = audit.data.map((a) => a.action); for (const a of ['user_registered', 'login', 'machine_enrolled', 'session_requested', 'consent_granted', 'session_ended']) { check(`audit contains "${a}"`, actions.includes(a)); } // 16. Denial path viewer.send(JSON.stringify({ type: 'viewer-connect', machineId: machines.data[0].id })); const pending2 = await nextMsg(viewer, 'session-pending'); const req2 = await nextMsg(agent, 'session-request'); agent.send(JSON.stringify({ type: 'consent', sessionId: req2.sessionId, granted: false })); const denied = await nextMsg(viewer, 'session-denied'); check('consent denial -> viewer session-denied', !!denied); agent.close(); viewer.close(); hookSrv.close(); console.log(`\n${passed} passed, ${failed} failed.`); server.close(); process.exit(failed ? 1 : 0); })().catch((e) => { console.error('E2E ERROR:', e); process.exit(1); });