Files
BizGaze_Remote/server/test/e2e.js
T

423 lines
29 KiB
JavaScript

// 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); });