import { xml } from '@xmpp/client'; import assert from 'assert'; import { mintAsapToken } from './helpers/jwt.js'; import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js'; const CONFERENCE = 'conference.localhost'; const METADATA_COMPONENT = 'metadata.localhost'; const JITMEET_NS = 'http://jitsi.org/jitmeet'; const LIMIT = 3; const ERROR_TEXT = 'The message limit for the room has been reached. Messaging is now disabled.'; let _counter = 0; const nextRoom = () => `limit-${++_counter}@${CONFERENCE}`; // ─── Helpers ────────────────────────────────────────────────────────────────── /** * Connects an anonymous client to the main VirtualHost with ?room= set so that * session.jitsi_web_query_room is populated — required by mod_muc_limit_messages * to locate the room via get_room_by_name_and_subdomain. * * @param {string} roomJid Full room JID, e.g. 'name@conference.localhost' */ function connectAnon(roomJid) { const roomName = roomJid.split('@')[0]; return createXmppClient({ params: { room: roomName } }); } /** * Connects a token-authenticated client. session.auth_token is set by Prosody's * auth_token module on the VirtualHost, and read by mod_muc_limit_messages when * muc_limit_messages_check_token = true to permanently lift the room cap. * * @param {string} roomJid * @param {object} [contextUser] JWT context.user claims; moderator: true → owner */ function connectWithToken(roomJid, { contextUser } = {}) { const roomName = roomJid.split('@')[0]; const token = mintAsapToken({ room: roomName, context: contextUser ? { user: contextUser } : {} }); return createXmppClient({ params: { room: roomName, token } }); } const connectModerator = roomJid => connectWithToken(roomJid, { contextUser: { moderator: true } }); /** * Sends LIMIT messages (all within the cap) then one more that triggers the * block. Returns the reply stanza for the triggering message. */ async function exhaustAndTrigger(sender, roomJid) { for (let i = 0; i < LIMIT; i++) { await sender.sendGroupchat(roomJid, `msg ${i + 1}`); } return sender.sendGroupchat(roomJid, 'trigger'); } /** * Sets groupChatRestricted on the room and waits for the metadata broadcast * to confirm Prosody has processed it before returning. */ async function restrictChat(mod, roomJid) { mod.sendMetadataUpdate(METADATA_COMPONENT, roomJid, 'permissions', { groupChatRestricted: true }); await mod.waitForMessage(s => { const j = s.getChild('json-message', JITMEET_NS); if (!j) { return false; } try { return JSON.parse(j.getText())?.metadata?.permissions?.groupChatRestricted === true; } catch { return false; } }, 3000); } /** * Clears groupChatRestricted and waits for the metadata broadcast to confirm. */ async function unrestrictChat(mod, roomJid) { mod.sendMetadataUpdate(METADATA_COMPONENT, roomJid, 'permissions', { groupChatRestricted: false }); await mod.waitForMessage(s => { const j = s.getChild('json-message', JITMEET_NS); if (!j) { return false; } try { return JSON.parse(j.getText())?.metadata?.permissions?.groupChatRestricted === false; } catch { return false; } }, 3000); } // ─── Tests ──────────────────────────────────────────────────────────────────── describe('mod_muc_limit_messages', () => { const clients = []; afterEach(async () => { await Promise.all(clients.map(c => c.disconnect())); clients.length = 0; }); // ── within limit ────────────────────────────────────────────────────────── it('messages within the cap are delivered', async () => { const r = nextRoom(); const focus = await joinWithFocus(r); const sender = await connectAnon(r); clients.push(focus, sender); await sender.joinRoom(r); for (let i = 0; i < LIMIT; i++) { const reply = await sender.sendGroupchat(r, `message ${i + 1}`); assert.notEqual(reply.attrs.type, 'error', `message ${i + 1} must not be blocked (within cap)`); } }); // ── at cap boundary ─────────────────────────────────────────────────────── it('first message over cap is rejected with ', async () => { const r = nextRoom(); const focus = await joinWithFocus(r); const sender = await connectAnon(r); clients.push(focus, sender); await sender.joinRoom(r); const reply = await exhaustAndTrigger(sender, r); assert.strictEqual(reply.attrs.type, 'error', 'triggering message must be rejected'); assert.ok( reply.getChild('error')?.getChild('not-allowed'), 'error condition must be ' ); }); it('room broadcast is sent when cap is first hit', async () => { const r = nextRoom(); const focus = await joinWithFocus(r); const sender = await connectAnon(r); // observer must join before the limit fires so it is an occupant // when the broadcast is sent; muc_max_occupants=2 so focus (exempt) + // sender + observer = 2 non-whitelisted users, within the limit. const observer = await connectAnon(r); clients.push(focus, sender, observer); await sender.joinRoom(r); await observer.joinRoom(r); for (let i = 0; i < LIMIT; i++) { await sender.sendGroupchat(r, `msg ${i + 1}`); } const [ senderReply, announcement ] = await Promise.all([ sender.sendGroupchat(r, 'trigger'), observer.waitForMessage( s => s.attrs.type === 'groupchat' && s.getChildText('body') === ERROR_TEXT, 5000 ) ]); assert.strictEqual(senderReply.attrs.type, 'error'); assert.ok(announcement, 'observer must receive the room announcement'); }); // ── after cap is set ────────────────────────────────────────────────────── it('messages after cap remain blocked', async () => { const r = nextRoom(); const focus = await joinWithFocus(r); const sender = await connectAnon(r); clients.push(focus, sender); await sender.joinRoom(r); await exhaustAndTrigger(sender, r); for (let i = 0; i < 2; i++) { const reply = await sender.sendGroupchat(r, `post-cap ${i + 1}`); assert.strictEqual(reply.attrs.type, 'error', `message ${i + 1} after cap must remain blocked`); } }); // ── message type exemptions ─────────────────────────────────────────────── it('body-less groupchat (no json) counts toward the cap and is blocked', async () => { const r = nextRoom(); const focus = await joinWithFocus(r); const sender = await connectAnon(r); clients.push(focus, sender); await sender.joinRoom(r); // Fill LIMIT-1 slots with regular messages. for (let i = 0; i < LIMIT - 1; i++) { await sender.sendGroupchat(r, `msg ${i + 1}`); } // Body-less groupchat with no json: not exempt, consumes the last slot. const bodyless = await sender.sendGroupchat(r, undefined); assert.notEqual(bodyless.attrs.type, 'error', 'body-less groupchat within cap must pass through'); // Next message triggers the block. const over = await sender.sendGroupchat(r, 'now blocked'); assert.strictEqual(over.attrs.type, 'error', 'message after body-less consumed last slot must be blocked'); }); it('answer-poll json-message passes through even after cap is reached', async () => { const r = nextRoom(); const focus = await joinWithFocus(r); const sender = await connectAnon(r); clients.push(focus, sender); await sender.joinRoom(r); await exhaustAndTrigger(sender, r); const pollEl = xml('json-message', { xmlns: JITMEET_NS }, JSON.stringify({ type: 'answer-poll', pollId: '1', answers: [] })); const reply = await sender.sendGroupchat(r, undefined, [ pollEl ]); assert.notEqual(reply.attrs.type, 'error', 'answer-poll body-less message must pass through regardless of cap'); }); // ── check_token × token_verification interaction ────────────────────────── // // muc_limit_messages_check_token = true on conference.localhost. // A sender whose session.auth_token is set (set by mod_auth_token when the // client connects with a valid JWT) permanently disables the cap for that room // by setting room._muc_messages_limit = false, overriding even a cap already // set to true. it('authenticated sender lifts the cap permanently', async () => { const r = nextRoom(); const focus = await joinWithFocus(r); const anon = await connectAnon(r); const authed = await connectWithToken(r); clients.push(focus, anon, authed); await anon.joinRoom(r); await authed.joinRoom(r); // Anonymous sender exhausts and triggers the cap. await exhaustAndTrigger(anon, r); // Authenticated sender (session.auth_token set by token_verification) // sets room._muc_messages_limit = false. const authedReply = await authed.sendGroupchat(r, 'auth unlocks'); assert.notEqual(authedReply.attrs.type, 'error', 'authenticated sender must pass through'); // Cap is now lifted: anonymous sender can send freely again. const anonReply = await anon.sendGroupchat(r, 'now unlimited'); assert.notEqual(anonReply.attrs.type, 'error', 'cap must be permanently lifted after authenticated sender'); }); it('unauthenticated sender is still capped when check_token is enabled', async () => { const r = nextRoom(); const focus = await joinWithFocus(r); const anon = await connectAnon(r); clients.push(focus, anon); await anon.joinRoom(r); const reply = await exhaustAndTrigger(anon, r); assert.strictEqual(reply.attrs.type, 'error', 'anonymous sender must still hit the cap with check_token=true'); }); // ── filter_messages fires before muc_limit_messages (priority guard) ──────── // // mod_filter_messages hooks message/bare at the default priority (0). // mod_muc_limit_messages hooks message/bare at priority -1 (explicit). // Higher priority fires first in Prosody, so filter_messages always runs // before muc_limit_messages — the order is deterministic and does not depend // on module load order or Prosody's internal sort stability. // // Regression guard: if muc_limit_messages ever reverts to the default // priority (0) the ordering becomes non-deterministic and blocked messages // could silently exhaust the cap. it('muc_limit_messages must not count messages already blocked by filter_messages', async () => { const r = nextRoom(); const focus = await joinWithFocus(r); const mod = await connectModerator(r); const anon = await connectAnon(r); clients.push(focus, mod, anon); await mod.joinRoom(r); await anon.joinRoom(r); // Restrict chat; filter_messages (priority 0) swallows every anon message // before muc_limit_messages (priority -1) can increment the counter. await restrictChat(mod, r); // Send exactly LIMIT messages — all blocked by filter_messages; the room // counter must stay at zero. for (let i = 0; i < LIMIT; i++) { await anon.sendGroupchat(r, `blocked ${i + 1}`); } await unrestrictChat(mod, r); // Counter is still 0, so this message must pass through. const reply = await anon.sendGroupchat(r, 'should pass'); assert.notEqual(reply.attrs.type, 'error', 'muc_limit_messages must not count messages blocked by filter_messages'); }); // ── filter_messages × muc_limit_messages hook-ordering interaction ──────── // // mod_muc_limit_messages registers its message/bare hook at priority -1 so // that modules at the default priority (0) — including filter_messages — run // first. When filter_messages blocks a message (returns true) the hook chain // ends; muc_limit_messages never runs and the room counter is not incremented. it('messages blocked by filter_messages do not count toward the cap', async () => { const r = nextRoom(); const focus = await joinWithFocus(r); const mod = await connectModerator(r); const anon = await connectAnon(r); clients.push(focus, mod, anon); await mod.joinRoom(r); await anon.joinRoom(r); // Restrict chat so that filter_messages blocks anon messages. await restrictChat(mod, r); // Send more than LIMIT messages — all blocked by filter_messages before // muc_limit_messages runs, so the room counter stays at zero. for (let i = 0; i < LIMIT + 1; i++) { const reply = await anon.sendGroupchat(r, `restricted ${i + 1}`); assert.strictEqual(reply.attrs.type, 'error', `restricted message ${i + 1} must be blocked`); } // Lift the restriction. await unrestrictChat(mod, r); // Counter is still 0, so the next LIMIT messages must all pass. for (let i = 0; i < LIMIT; i++) { const reply = await anon.sendGroupchat(r, `after unrestrict ${i + 1}`); assert.notEqual(reply.attrs.type, 'error', `message ${i + 1} after unrestrict must pass (cap not consumed by blocked messages)`); } }); });