snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
This commit is contained in:
446
tests/prosody/mod_muc_lobby_rooms_spec.js
Normal file
446
tests/prosody/mod_muc_lobby_rooms_spec.js
Normal file
@@ -0,0 +1,446 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import { mintAsapToken } from './helpers/jwt.js';
|
||||
import { disableLobby, enableLobby, setAffiliation } from './helpers/test_observer.js';
|
||||
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
|
||||
|
||||
const CONFERENCE = 'conference.localhost';
|
||||
const LOBBY = 'lobby.conference.localhost';
|
||||
const LOBBY_NS = 'http://jitsi.org/jitmeet';
|
||||
const MUC_NS = 'http://jabber.org/protocol/muc#user';
|
||||
|
||||
/**
|
||||
* Extracts role and affiliation from a self-presence stanza.
|
||||
* @param {object} presence
|
||||
* @returns {{ role: string|null, affiliation: string|null }}
|
||||
*/
|
||||
function getRoleAndAffiliation(presence) {
|
||||
const item = presence.getChild('x', MUC_NS)?.getChild('item');
|
||||
|
||||
return {
|
||||
role: item?.attrs.role ?? null,
|
||||
affiliation: item?.attrs.affiliation ?? null
|
||||
};
|
||||
}
|
||||
|
||||
let _roomCounter = 0;
|
||||
const room = () => `lobby-${++_roomCounter}@${CONFERENCE}`;
|
||||
|
||||
describe('mod_muc_lobby_rooms', () => {
|
||||
|
||||
let clients;
|
||||
|
||||
beforeEach(() => {
|
||||
clients = [];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(clients.map(c => c.disconnect()));
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates an XMPP client and pushes it to the cleanup list.
|
||||
* @param {object} opts Options forwarded to createXmppClient.
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
async function connect(opts) {
|
||||
const c = await createXmppClient(opts);
|
||||
|
||||
clients.push(c);
|
||||
|
||||
return c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins a room as focus and pushes the client to the cleanup list.
|
||||
* @param {string} roomJid Full room JID.
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
async function focusJoin(roomJid) {
|
||||
const c = await joinWithFocus(roomJid);
|
||||
|
||||
clients.push(c);
|
||||
|
||||
return c;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// join gate — non-member
|
||||
// -------------------------------------------------------------------------
|
||||
describe('non-member join gate', () => {
|
||||
|
||||
it('blocks a non-member with 407 registration-required', async () => {
|
||||
const r = room();
|
||||
|
||||
await focusJoin(r);
|
||||
await enableLobby(r);
|
||||
|
||||
const c = await connect();
|
||||
const presence = await c.joinRoom(r, undefined, { displayName: 'Alice' });
|
||||
|
||||
assert.equal(presence.attrs.type, 'error', 'join must be rejected');
|
||||
|
||||
const error = presence.getChild('error');
|
||||
|
||||
assert.ok(error, 'error element must be present');
|
||||
assert.equal(error.attrs.type, 'auth');
|
||||
assert.ok(
|
||||
error.getChild('registration-required'),
|
||||
'error condition must be registration-required'
|
||||
);
|
||||
});
|
||||
|
||||
it('includes the lobby room JID in the 407 error', async () => {
|
||||
const r = room();
|
||||
|
||||
await focusJoin(r);
|
||||
const lobbyJid = await enableLobby(r);
|
||||
|
||||
const c = await connect();
|
||||
const presence = await c.joinRoom(r, undefined, { displayName: 'Alice' });
|
||||
|
||||
assert.equal(presence.attrs.type, 'error');
|
||||
|
||||
// Prosody builds the error reply with cursor at <error>, so
|
||||
// Jitsi-specific child tags land inside <error>, not as siblings.
|
||||
const error = presence.getChild('error');
|
||||
|
||||
assert.ok(error, 'error element must be present');
|
||||
|
||||
const lobbyroom = error.getChild('lobbyroom', LOBBY_NS);
|
||||
|
||||
assert.ok(lobbyroom, 'expected <lobbyroom xmlns="http://jitsi.org/jitmeet"> in error');
|
||||
assert.equal(
|
||||
lobbyroom.getText(),
|
||||
lobbyJid,
|
||||
'lobbyroom JID in error must match the created lobby room'
|
||||
);
|
||||
assert.ok(
|
||||
lobbyJid.endsWith(`@${LOBBY}`),
|
||||
'lobby room JID must be on the lobby component'
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks with 406 not-acceptable when display name is missing', async () => {
|
||||
const r = room();
|
||||
|
||||
await focusJoin(r);
|
||||
await enableLobby(r);
|
||||
|
||||
const c = await connect();
|
||||
const presence = await c.joinRoom(r); // no displayName
|
||||
|
||||
assert.equal(presence.attrs.type, 'error', 'join must be rejected');
|
||||
|
||||
const error = presence.getChild('error');
|
||||
|
||||
assert.ok(error, 'error element must be present');
|
||||
assert.equal(error.attrs.type, 'modify');
|
||||
assert.ok(error.getChild('not-acceptable'), 'error condition must be not-acceptable');
|
||||
|
||||
// Same as lobbyroom: cursor is at <error> when tag is appended.
|
||||
const dnRequired = error.getChild('displayname-required', LOBBY_NS);
|
||||
|
||||
assert.ok(
|
||||
dnRequired,
|
||||
'expected <displayname-required xmlns="http://jitsi.org/jitmeet"> in error'
|
||||
);
|
||||
assert.equal(dnRequired.attrs.lobby, 'true');
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// bypass paths
|
||||
// -------------------------------------------------------------------------
|
||||
describe('bypass paths', () => {
|
||||
|
||||
it('pre-approved member with display name can join', async () => {
|
||||
const r = room();
|
||||
|
||||
await focusJoin(r);
|
||||
await enableLobby(r);
|
||||
|
||||
// Connect first to learn the server-assigned JID, then pre-approve the
|
||||
// user by setting member affiliation before they attempt to join.
|
||||
const c = await connect();
|
||||
const bareJid = c.jid.split('/')[0];
|
||||
|
||||
await setAffiliation(r, bareJid, 'member');
|
||||
|
||||
const presence = await c.joinRoom(r, undefined, { displayName: 'Bob' });
|
||||
|
||||
assert.notEqual(presence.attrs.type, 'error', 'pre-approved member must be allowed in');
|
||||
});
|
||||
|
||||
it('whitelisted domain bypasses lobby without affiliation', async () => {
|
||||
const r = room();
|
||||
|
||||
await focusJoin(r);
|
||||
await enableLobby(r);
|
||||
|
||||
// whitelist.localhost is in muc_lobby_whitelist in prosody.cfg.lua
|
||||
const c = await connect({ domain: 'whitelist.localhost' });
|
||||
const presence = await c.joinRoom(r); // no displayName needed for whitelisted
|
||||
|
||||
assert.notEqual(presence.attrs.type, 'error', 'whitelisted domain must bypass lobby');
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// lobby room lifecycle
|
||||
// -------------------------------------------------------------------------
|
||||
describe('lifecycle', () => {
|
||||
|
||||
it('lobby room JID is advertised in room disco info after enable', async () => {
|
||||
const r = room();
|
||||
const focus = await focusJoin(r);
|
||||
const lobbyJid = await enableLobby(r);
|
||||
|
||||
// Query disco info on the main room from the focus client (already joined).
|
||||
const discoResponse = await focus.sendDiscoInfo(r);
|
||||
const query = discoResponse.getChild('query', 'http://jabber.org/protocol/disco#info');
|
||||
|
||||
assert.ok(query, 'disco#info query element must be present in response');
|
||||
|
||||
const dataForm = query.getChild('x', 'jabber:x:data');
|
||||
|
||||
assert.ok(dataForm, 'data form must be present in disco#info');
|
||||
|
||||
const lobbyField = dataForm
|
||||
.getChildren('field')
|
||||
.find(f => f.attrs.var === 'muc#roominfo_lobbyroom');
|
||||
|
||||
assert.ok(lobbyField, 'muc#roominfo_lobbyroom field must be present');
|
||||
assert.equal(
|
||||
lobbyField.getChildText('value'),
|
||||
lobbyJid,
|
||||
'muc#roominfo_lobbyroom value must equal the lobby room JID'
|
||||
);
|
||||
});
|
||||
|
||||
it('lobby room JID is absent from disco info after disable', async () => {
|
||||
const r = room();
|
||||
const focus = await focusJoin(r);
|
||||
|
||||
await enableLobby(r);
|
||||
await disableLobby(r);
|
||||
|
||||
const discoResponse = await focus.sendDiscoInfo(r);
|
||||
const query = discoResponse.getChild('query', 'http://jabber.org/protocol/disco#info');
|
||||
const dataForm = query?.getChild('x', 'jabber:x:data');
|
||||
|
||||
const lobbyField = dataForm
|
||||
?.getChildren('field')
|
||||
.find(f => f.attrs.var === 'muc#roominfo_lobbyroom');
|
||||
|
||||
assert.ok(!lobbyField, 'muc#roominfo_lobbyroom must not appear after lobby is disabled');
|
||||
});
|
||||
|
||||
it('non-member can join freely after lobby is disabled', async () => {
|
||||
const r = room();
|
||||
|
||||
await focusJoin(r);
|
||||
await enableLobby(r);
|
||||
await disableLobby(r);
|
||||
|
||||
const c = await connect();
|
||||
const presence = await c.joinRoom(r);
|
||||
|
||||
assert.notEqual(presence.attrs.type, 'error',
|
||||
'non-member must be allowed after lobby is disabled');
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// token_affiliation interaction
|
||||
// -------------------------------------------------------------------------
|
||||
describe('token_affiliation interaction', () => {
|
||||
|
||||
it('moderator token holder is blocked by lobby, not bypassed by affiliation grant', async () => {
|
||||
const r = room();
|
||||
const roomName = r.split('@')[0];
|
||||
|
||||
await focusJoin(r);
|
||||
await enableLobby(r);
|
||||
|
||||
const token = mintAsapToken({ room: roomName,
|
||||
context: { user: { moderator: true } } });
|
||||
const c = await connect({ params: { token } });
|
||||
const presence = await c.joinRoom(r, undefined, { displayName: 'TokenMod' });
|
||||
|
||||
assert.equal(presence.attrs.type, 'error',
|
||||
'moderator token user must be blocked by lobby');
|
||||
|
||||
const error = presence.getChild('error');
|
||||
|
||||
assert.ok(error, 'error element must be present');
|
||||
assert.ok(
|
||||
error.getChild('registration-required'),
|
||||
'error condition must be registration-required'
|
||||
);
|
||||
});
|
||||
|
||||
it('non-moderator token holder is blocked by lobby', async () => {
|
||||
const r = room();
|
||||
const roomName = r.split('@')[0];
|
||||
|
||||
await focusJoin(r);
|
||||
await enableLobby(r);
|
||||
|
||||
const token = mintAsapToken({ room: roomName,
|
||||
context: { user: { id: 'user1' } } });
|
||||
const c = await connect({ params: { token } });
|
||||
const presence = await c.joinRoom(r, undefined, { displayName: 'TokenUser' });
|
||||
|
||||
assert.equal(presence.attrs.type, 'error',
|
||||
'non-moderator token user must be blocked by lobby');
|
||||
|
||||
const error = presence.getChild('error');
|
||||
|
||||
assert.ok(error, 'error element must be present');
|
||||
assert.ok(
|
||||
error.getChild('registration-required'),
|
||||
'error condition must be registration-required'
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// lobby + room password
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
it('moderator token user admitted from lobby joins directly without password prompt', async () => {
|
||||
const r = room();
|
||||
const roomName = r.split('@')[0];
|
||||
const focus = await focusJoin(r);
|
||||
|
||||
await focus.setRoomPassword(r, 'testpass');
|
||||
await enableLobby(r);
|
||||
|
||||
const token = mintAsapToken({ room: roomName,
|
||||
context: { user: { moderator: true } } });
|
||||
const c = await connect({ params: { token } });
|
||||
|
||||
// First attempt — blocked by lobby
|
||||
const blocked = await c.joinRoom(r, undefined, { displayName: 'TokenMod' });
|
||||
|
||||
assert.equal(blocked.attrs.type, 'error', 'first join must be blocked by lobby');
|
||||
assert.ok(
|
||||
blocked.getChild('error')?.getChild('registration-required'),
|
||||
'must be blocked with registration-required'
|
||||
);
|
||||
|
||||
// Simulate moderator approval
|
||||
const userBareJid = c.jid.split('/')[0];
|
||||
|
||||
await setAffiliation(r, userBareJid, 'member');
|
||||
|
||||
// Second attempt — admitted user must join directly as moderator, no password required
|
||||
const presence = await c.joinRoom(r, undefined, { displayName: 'TokenMod' });
|
||||
|
||||
assert.notEqual(presence.attrs.type, 'error',
|
||||
'admitted user must not be asked for password');
|
||||
|
||||
const { role, affiliation } = getRoleAndAffiliation(presence);
|
||||
|
||||
assert.equal(affiliation, 'owner',
|
||||
'admitted moderator token user must get owner affiliation');
|
||||
assert.equal(role, 'moderator',
|
||||
'admitted moderator token user must get moderator role');
|
||||
});
|
||||
|
||||
it('moderator token user who bypasses lobby with room password gets moderator role', async () => {
|
||||
const r = room();
|
||||
const roomName = r.split('@')[0];
|
||||
const focus = await focusJoin(r);
|
||||
|
||||
await focus.setRoomPassword(r, 'testpass');
|
||||
await enableLobby(r);
|
||||
|
||||
const token = mintAsapToken({ room: roomName,
|
||||
context: { user: { moderator: true } } });
|
||||
const c = await connect({ params: { token } });
|
||||
|
||||
// Join with password — must bypass lobby and get moderator role
|
||||
const presence = await c.joinRoom(r, undefined, {
|
||||
password: 'testpass',
|
||||
displayName: 'TokenMod'
|
||||
});
|
||||
|
||||
assert.notEqual(presence.attrs.type, 'error',
|
||||
'password-bypass join must succeed');
|
||||
|
||||
const { role, affiliation } = getRoleAndAffiliation(presence);
|
||||
|
||||
assert.equal(affiliation, 'owner',
|
||||
'moderator token user who bypassed lobby via password must get owner affiliation');
|
||||
assert.equal(role, 'moderator',
|
||||
'moderator token user who bypassed lobby via password must get moderator role');
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// muc#admin set — nick reservation must not act before authorization
|
||||
// -------------------------------------------------------------------------
|
||||
//
|
||||
// handle_admin_query_set_command_item's "nickname is reserved" branch kicks
|
||||
// whoever currently holds the requested nick when the item's jid doesn't
|
||||
// match. That kick must only happen once set_affiliation has confirmed the
|
||||
// sender is allowed to act — otherwise an unauthorized sender can remove an
|
||||
// occupant just by naming their nick, before the affiliation check ever
|
||||
// gets a chance to reject the request.
|
||||
describe('admin-set nick reservation', () => {
|
||||
|
||||
it('does not remove an occupant before authorizing the affiliation change', async () => {
|
||||
const r = room();
|
||||
|
||||
await focusJoin(r);
|
||||
|
||||
const occupant = await connect();
|
||||
|
||||
await occupant.joinRoom(r, 'participant', { displayName: 'Participant' });
|
||||
|
||||
// Never joins the room — permission is checked from the sender's own
|
||||
// bare jid, which has no affiliation record here regardless.
|
||||
const sender = await connect();
|
||||
|
||||
const resp = await sender.sendMucAdmin(r, {
|
||||
nick: 'participant',
|
||||
jid: 'someone-else@example.com',
|
||||
affiliation: 'member'
|
||||
});
|
||||
|
||||
// The real proof: a successful kick sends the occupant an unavailable
|
||||
// self-presence. Its absence means the kick never happened.
|
||||
const kicked = await occupant.waitForPresenceFrom(`${r}/participant`, { type: 'unavailable',
|
||||
timeout: 500 })
|
||||
.then(() => true, () => false);
|
||||
|
||||
assert.strictEqual(kicked, false, 'occupant must not be removed by an unauthorized sender');
|
||||
assert.equal(resp.attrs.type, 'error', 'IQ must be rejected');
|
||||
});
|
||||
|
||||
it('does not remove focus before authorizing the affiliation change', async () => {
|
||||
const r = room();
|
||||
|
||||
const focus = await focusJoin(r);
|
||||
|
||||
// Also rejected outright by mod_muc_meeting_id's filter_admin_set,
|
||||
// which runs at a higher priority for any item naming focus's nick.
|
||||
// Kept here so a regression in either guard is still caught.
|
||||
const sender = await connect();
|
||||
|
||||
const resp = await sender.sendMucAdmin(r, {
|
||||
nick: 'focus',
|
||||
jid: 'someone-else@example.com',
|
||||
affiliation: 'member'
|
||||
});
|
||||
|
||||
const kicked = await focus.waitForPresenceFrom(`${r}/focus`, { type: 'unavailable',
|
||||
timeout: 500 })
|
||||
.then(() => true, () => false);
|
||||
|
||||
assert.strictEqual(kicked, false, 'focus must not be removed by an unauthorized sender');
|
||||
assert.equal(resp.attrs.type, 'error', 'IQ must be rejected');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user