Files
BizGaze_Remote/server/test/e2e.js
T
Sravan b2c4b10e41 fix(db): guest_emails/lobby ALTERs ran before scheduled_meetings existed + e2e drift
Migration prep. Two real fixes surfaced while building a regression harness:

1. db.js: the `guest_emails` and `lobby` ALTER TABLEs sat at lines 241/244, BEFORE
   scheduled_meetings is CREATEd (line 299). On a FRESH database the ALTER fails
   (no table yet), is swallowed by the try/catch, and the columns are never added —
   so a brand-new deploy is missing them and scheduling with guests crashes. Prod
   escaped it only by incremental deploy history. Moved both ALTERs to after the
   CREATE. (The upcoming Postgres schema defines every column up front, so this
   whole class of ordering bug goes away there.)

2. test/e2e.js: /api/meetings returns paginated `{list, pastTotal, page, pageSize}`
   now, not a bare array — updated three `.data.find` → `.data.list.find`.

No prod behaviour change (prod already has the columns; ALTERs are idempotent).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 19:43:46 +05:30

439 lines
30 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);
// 3b'. Native device push tokens (FCM/APNs registration; delivery no-ops until creds set)
const devReg = await call('/api/v1/devices', { platform: 'android', token: 'fcm-tok-123' }, cookie);
check('device token registered', devReg.status === 200 && devReg.data.ok === true);
const devBad = await call('/api/v1/devices', { platform: 'windows', token: 'x' }, cookie);
check('device register rejects invalid platform', devBad.status === 400);
const devNoAuth = await call('/api/v1/devices', { platform: 'ios', token: 'y' });
check('device register requires auth', devNoAuth.status === 401);
const devRm = await call('/api/v1/devices/remove', { token: 'fcm-tok-123' }, cookie);
check('device token removed', devRm.status === 200);
// 3b''. App install telemetry (track who installed) + admin listing
const tel = await call('/api/v1/telemetry/install', { installId: 'inst-e2e-1', platform: 'desktop', appVersion: '0.1.1', os: 'win32' }, cookie);
check('install telemetry recorded', tel.status === 200 && tel.data.ok === true);
const insts = await get('/api/v1/admin/installs', cookie);
check('admin sees the install tied to the user', insts.status === 200 && Array.isArray(insts.data) && insts.data.some((i) => i.install_id === 'inst-e2e-1' && i.app_version === '0.1.1' && i.user_email));
// 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.list.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.list.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.list.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); });