Files
upstream-jitsi-meet/tests/prosody/mod_muc_breakout_rooms_spec.js
upstream-mirror 1d2bfcac0c
Some checks failed
Prosody Plugin Tests / test (push) Failing after 10m11s
Close stale issues and PRs / stale (push) Failing after 1s
snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
2026-09-15 15:56:32 +05:00

374 lines
15 KiB
JavaScript

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 <json-message xmlns="http://jitsi.org/jitmeet"> 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<object>}
*/
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 <resume> 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 <forbidden/>
// 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'
);
});
});
});