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

381 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <not-allowed/>', 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 <not-allowed/>'
);
});
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)`);
}
});
});