Files
upstream-jitsi-meet/tests/prosody/mod_muc_lobby_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

447 lines
17 KiB
JavaScript

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');
});
});
});