2026-06-05 17:29:09 +05:30
// End-to-end test of the backend platform.
2026-06-12 00:40:07 +05:30
// 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.)
2026-06-05 17:29:09 +05:30
const fs = require ( 'fs' );
2026-06-12 00:40:07 +05:30
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 {} }
2026-06-05 17:29:09 +05:30
const PORT = 8099 ;
process . env . PORT = PORT ;
2026-06-12 00:40:07 +05:30
process . env . HTTPS_PORT = 8444 ; // avoid clashing with a running dev server on 8443
2026-06-05 17:29:09 +05:30
const { server } = require ( '../server' );
const A = require ( '../auth' );
const WebSocket = require ( 'ws' );
2026-06-23 16:15:29 +05:30
const http = require ( 'http' );
const crypto = require ( 'crypto' );
2026-06-05 17:29:09 +05:30
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:' );
2026-06-23 16:15:29 +05:30
// 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' ;
2026-06-12 00:40:07 +05:30
// 1. Register (first user becomes admin)
2026-06-05 17:29:09 +05:30
const email = 'tech@example.com' ;
const reg = await call ( '/api/register' , { email , password : 'supersecret' , teamName : 'Acme IT' });
2026-06-12 00:40:07 +05:30
check ( 'register succeeds' , reg . status === 200 && reg . data . ok === true );
2026-06-05 17:29:09 +05:30
2026-06-12 00:40:07 +05:30
// 2. Login -> session cookie (login marks the session MFA-passed)
const login = await call ( '/api/login' , { email , password : 'supersecret' });
2026-06-05 17:29:09 +05:30
check ( 'login sets session cookie' , !! login . cookie );
2026-06-12 00:40:07 +05:30
const cookie = login . cookie ;
2026-06-05 17:29:09 +05:30
2026-06-12 00:40:07 +05:30
// 3. Protected route works right after login, role=admin
2026-06-05 17:29:09 +05:30
const me = await get ( '/api/me' , cookie );
2026-06-12 00:40:07 +05:30
check ( 'me works after login, role=admin' , me . status === 200 && me . data . role === 'admin' );
2026-06-05 17:29:09 +05:30
2026-06-23 16:15:29 +05:30
// 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 ();
2026-06-12 00:40:07 +05:30
// 4. Wrong password rejected
2026-06-05 17:29:09 +05:30
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 );
2026-06-23 16:15:29 +05:30
// 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' ));
2026-06-05 17:29:09 +05:30
// 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 ();
2026-06-23 16:15:29 +05:30
hookSrv . close ();
2026-06-05 17:29:09 +05:30
console . log ( `\n ${ passed } passed, ${ failed } failed.` );
server . close ();
process . exit ( failed ? 1 : 0 );
})(). catch (( e ) => { console . error ( 'E2E ERROR:' , e ); process . exit ( 1 ); });