import assert from 'assert'; import { mintAsapToken } from './helpers/jwt.js'; import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js'; const CONFERENCE = 'conference.localhost'; const BREAKOUT_MUC = 'breakout.conference.localhost'; const JITMEET_NS = 'http://jitsi.org/jitmeet'; // JSON message type constants (must match mod_muc_breakout_rooms.lua) const BREAKOUT_ROOMS_TYPE = 'breakout_rooms'; const EVENT_UPDATE = 'features/breakout-rooms/update'; const OP_ADD = 'features/breakout-rooms/add'; const OP_REMOVE = 'features/breakout-rooms/remove'; const OP_RENAME = 'features/breakout-rooms/rename'; let _roomCounter = 0; const nextRoomName = () => `br-test-${++_roomCounter}`; const roomJid = name => `${name}@${CONFERENCE}`; /** * Parses the JSON payload from a stanza. * Returns null if the stanza has no such child or the body is not valid JSON. * * @param {object} stanza * @returns {object|null} */ function parseJsonMessage(stanza) { const jm = stanza.getChild('json-message', JITMEET_NS); if (!jm) { return null; } try { return JSON.parse(jm.getText()); } catch { return null; } } /** * Waits for a breakout_rooms update broadcast on the given client. * Resolves with the parsed JSON payload. * * mod_muc_breakout_rooms broadcasts only to non-admin occupants, so always * wait on a JWT moderator client, never on the focus (admin) client. * * @param {object} client * @param {number} [timeout=6000] ms — must exceed BROADCAST_ROOMS_INTERVAL (300 ms) * @returns {Promise} */ async function waitForBreakoutUpdate(client, timeout = 6000) { const stanza = await client.waitForMessage(s => { const p = parseJsonMessage(s); return p?.type === BREAKOUT_ROOMS_TYPE && p?.event === EVENT_UPDATE; }, timeout); return parseJsonMessage(stanza); } /** * Creates a conference room with focus and an authenticated moderator. * * Focus joins first to satisfy the mod_muc_meeting_id jicofo lock so that * regular clients can join. The moderator connects with ?room=name so that * mod_jitsi_session sets jitsi_web_query_room, which mod_muc_breakout_rooms * uses to look up the main room. The JWT token carries context.user.moderator=true * so mod_token_affiliation promotes the client to moderator on join. * * mod_muc_breakout_rooms excludes Prosody admins (focus) from update broadcasts, * so the moderator — who is NOT a Prosody admin — acts as the broadcast receiver * in tests. * * @param {string} name bare room name, e.g. 'br-test-1' * @returns {Promise<{focus: object, moderator: object}>} */ async function createRoom(name) { const jid = roomJid(name); const focus = await joinWithFocus(jid); const token = mintAsapToken({ room: name, context: { user: { moderator: true } } }); const moderator = await createXmppClient({ params: { room: name, token } }); await moderator.joinRoom(jid); return { focus, moderator }; } describe('mod_muc_breakout_rooms', () => { let clients; beforeEach(() => { clients = []; }); afterEach(async () => { await Promise.all(clients.map(c => c.disconnect())); }); // ------------------------------------------------------------------------- // breakout room creation // ------------------------------------------------------------------------- describe('create breakout room', () => { it('moderator receives an update broadcast after creating a breakout room', async () => { const name = nextRoomName(); const { focus, moderator } = await createRoom(name); clients.push(focus, moderator); await moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_ADD, { subject: 'Group 1' }); const payload = await waitForBreakoutUpdate(moderator); assert.equal(payload.type, BREAKOUT_ROOMS_TYPE); assert.equal(payload.event, EVENT_UPDATE); }); it('update payload contains the main room and the new breakout room', async () => { const name = nextRoomName(); const { focus, moderator } = await createRoom(name); clients.push(focus, moderator); await moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_ADD, { subject: 'Group A' }); const payload = await waitForBreakoutUpdate(moderator); const rooms = Object.values(payload.rooms); assert.equal(rooms.length, 2, 'payload must contain main room + one breakout room'); const mainRoom = rooms.find(r => r.isMainRoom); const breakoutRoom = rooms.find(r => !r.isMainRoom); assert.ok(mainRoom, 'one room must be flagged as the main room'); assert.ok(breakoutRoom, 'one room must be the breakout room'); assert.equal(breakoutRoom.name, 'Group A'); assert.ok( breakoutRoom.jid.endsWith(`@${BREAKOUT_MUC}`), `breakout room JID must be on ${BREAKOUT_MUC}` ); }); it('all non-admin occupants of the main room receive the update broadcast', async () => { const name = nextRoomName(); const { focus, moderator } = await createRoom(name); // A second non-admin participant. const participant = await createXmppClient({ params: { room: name } }); clients.push(focus, moderator, participant); await participant.joinRoom(roomJid(name)); await moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_ADD, { subject: 'Team Room' }); const [ modPayload, partPayload ] = await Promise.all([ waitForBreakoutUpdate(moderator), waitForBreakoutUpdate(participant) ]); assert.equal(modPayload.event, EVENT_UPDATE, 'moderator must receive the update'); assert.equal(partPayload.event, EVENT_UPDATE, 'regular participant must receive the update'); }); it('roomCounter increments with each created breakout room', async () => { const name = nextRoomName(); const { focus, moderator } = await createRoom(name); clients.push(focus, moderator); await moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_ADD, { subject: 'Room 1' }); const first = await waitForBreakoutUpdate(moderator); await moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_ADD, { subject: 'Room 2' }); const second = await waitForBreakoutUpdate(moderator); assert.ok(second.roomCounter > first.roomCounter, 'roomCounter must increase'); }); }); // ------------------------------------------------------------------------- // breakout room removal // ------------------------------------------------------------------------- describe('remove breakout room', () => { it('moderator can remove a breakout room and receives an update', async () => { const name = nextRoomName(); const { focus, moderator } = await createRoom(name); clients.push(focus, moderator); // Create await moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_ADD, { subject: 'Temp Room' }); const createPayload = await waitForBreakoutUpdate(moderator); const breakoutJid = Object.values(createPayload.rooms) .find(r => !r.isMainRoom)?.jid; assert.ok(breakoutJid, 'breakout room JID must be present in create update'); // Remove await moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_REMOVE, { breakoutRoomJid: breakoutJid }); const removePayload = await waitForBreakoutUpdate(moderator); const rooms = Object.values(removePayload.rooms); assert.equal(rooms.length, 1, 'only the main room must remain after removal'); assert.ok(rooms[0].isMainRoom, 'remaining room must be the main room'); }); }); // ------------------------------------------------------------------------- // breakout room rename // ------------------------------------------------------------------------- describe('rename breakout room', () => { it('moderator can rename a breakout room and receives an update with the new name', async () => { const name = nextRoomName(); const { focus, moderator } = await createRoom(name); clients.push(focus, moderator); await moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_ADD, { subject: 'Original Name' }); const createPayload = await waitForBreakoutUpdate(moderator); const breakoutJid = Object.values(createPayload.rooms) .find(r => !r.isMainRoom)?.jid; await moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_RENAME, { breakoutRoomJid: breakoutJid, subject: 'Renamed Room' }); const renamePayload = await waitForBreakoutUpdate(moderator); const renamedRoom = Object.values(renamePayload.rooms).find(r => !r.isMainRoom); assert.ok(renamedRoom, 'breakout room must still exist after rename'); assert.equal(renamedRoom.name, 'Renamed Room'); }); }); // ------------------------------------------------------------------------- // smacks regression: eb43cf601 // ------------------------------------------------------------------------- describe('smacks session resume', () => { it('participant can join breakout room after smacks session resume', async () => { // Regression test for eb43cf601: // mod_auth_token.lua's c2s-session-updated handler used to copy // jitsi_breakout_main_jid from the fresh TCP session (which never // joined a room, so the field is nil) onto the old hibernating // session, silently overwriting the valid value set when the user // originally joined the main room. Without the fix, the subsequent // on_occupant_pre_join_or_change check // origin.jitsi_breakout_main_jid ~= main_room.jid // fails with nil ~= main_room_jid → not-allowed error. const name = nextRoomName(); const { focus, moderator } = await createRoom(name); clients.push(focus, moderator); // Register a breakout room via the OP_ADD message flow and capture its JID. await moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_ADD, { subject: 'Breakout 1' }); const createPayload = await waitForBreakoutUpdate(moderator); const breakoutJid = Object.values(createPayload.rooms).find(r => !r.isMainRoom)?.jid; assert.ok(breakoutJid, 'breakout room JID must be in the create broadcast'); // Focus (jicofo) joins the breakout room first to physically create it, // matching the real-life flow where Jicofo creates the room on the // breakout component (which has restrict_room_creation=true, so only // Prosody admins like focus@auth.localhost can create rooms here). const focusBreakoutPresence = await focus.joinRoom(breakoutJid, 'focus'); assert.notEqual( focusBreakoutPresence.attrs.type, 'error', 'focus must be able to join (create) the breakout room' ); // Arm the reconnect listener BEFORE dropping so we cannot miss the // 'reconnected' event even if the reconnect is very fast. const reconnected = moderator.waitForReconnect(); // Snap the WebSocket. @xmpp/client schedules a reconnect after ~1 s; // on reconnect it sends which triggers Prosody's // c2s-session-updated — the event where the bug cleared jitsi_breakout_main_jid. moderator.dropConnection(); // Wait until SMACKS resume is fully complete (entity.status === 'online'). // waitForReconnect() polls entity.status after the @xmpp/reconnect // 'reconnected' event, because that event fires on stream-open which // is before stream features (including SMACKS) are negotiated. await reconnected; // The moderator now tries to join the breakout room. // With the bug: jitsi_breakout_main_jid is nil → not-allowed error. // With the fix: jitsi_breakout_main_jid is preserved → join succeeds. const presence = await moderator.joinRoom(breakoutJid); assert.notEqual( presence.attrs.type, 'error', 'participant must be able to join breakout room after smacks session resume' ); }); }); // ------------------------------------------------------------------------- // access control // ------------------------------------------------------------------------- describe('access control', () => { it('non-admin cannot create rooms on the breakout component (restrict_room_creation)', async () => { // Any anonymous or JWT-authenticated client that is NOT a Prosody // admin (i.e. not focus@auth.localhost) should receive a // or similar error when trying to create a new room on the breakout // MUC component. restrict_room_creation=true on the component is the // first line of defence; on_breakout_room_pre_create is the second. const name = nextRoomName(); const user = await createXmppClient({ params: { room: name } }); clients.push(user); const fakeBreakoutJid = `00000000-0000-0000-0000-000000000000@${BREAKOUT_MUC}`; const presence = await user.joinRoom(fakeBreakoutJid, undefined, { timeout: 15000 }); assert.equal( presence.attrs.type, 'error', 'non-admin must receive an error when attempting to create a breakout room' ); }); it('ignores create requests from non-moderator participants', async () => { const name = nextRoomName(); const { focus, moderator } = await createRoom(name); // Anonymous participant — no moderator token means participant role. const participant = await createXmppClient({ params: { room: name } }); clients.push(focus, moderator, participant); await participant.joinRoom(roomJid(name)); // Participant sends a create request — the module must ignore it. await participant.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_ADD, { subject: 'Unauthorized Room' }); // A broadcast would only arrive if the room was actually created. await assert.rejects( waitForBreakoutUpdate(participant, 1500), /Timeout/, 'no broadcast must be received when a non-moderator attempts to create a room' ); }); }); });