snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
This commit is contained in:
373
tests/prosody/mod_muc_breakout_rooms_spec.js
Normal file
373
tests/prosody/mod_muc_breakout_rooms_spec.js
Normal file
@@ -0,0 +1,373 @@
|
||||
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'
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
Reference in New Issue
Block a user