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

205 lines
8.4 KiB
JavaScript

// Tests for mod_muc_rate_limit (resources/prosody-plugins/mod_muc_rate_limit.lua).
//
// The module rate-limits MUC joins and leaves per room. When the configured
// rate is exceeded (muc_rate_joins / muc_rate_leaves, default 3/s and 5/s),
// excess presence events are held in a per-room FIFO queue and replayed one
// at a time by a 1-second recurring timer. This prevents presence storms in
// large meetings.
//
// Joins: muc-occupant-pre-join events beyond the rate are queued; when the
// timer fires it replays them through room:handle_normal_presence, setting
// a delayed_join_skip flag to avoid re-queuing on the second pass.
//
// Leaves: muc-occupant-pre-leave events beyond the rate are queued; the
// occupant's role is set to nil immediately (removing them from the room
// roster) but publicise_occupant_status (the unavailable presence broadcast
// to peers) is deferred until the timer processes the event.
//
// Room destruction: when muc-room-destroyed fires, the queue is marked empty
// so the in-flight timer skips further processing cleanly.
import assert from 'assert';
import { prosodyShell } from './helpers/prosody_shell.js';
import { createTestContext } from './helpers/test_context.js';
const RATE_MUC = 'rate-limited.localhost';
const NUM_CLIENTS = 5;
// 5 clients at rate=1/s means the last client waits ~4 s; allow ample margin.
const JOIN_TIMEOUT = 12000;
let _roomCounter = 0;
const room = () => `rl-${++_roomCounter}@${RATE_MUC}`;
describe('mod_muc_rate_limit', () => {
before(async () => {
const out = await prosodyShell(`module:reload("muc_rate_limit", "${RATE_MUC}")`);
console.log('[prosody] module:reload muc_rate_limit:', out);
});
let ctx;
beforeEach(() => {
ctx = createTestContext();
});
afterEach(() => ctx.cleanup());
// -------------------------------------------------------------------------
// Join rate limiting
// -------------------------------------------------------------------------
describe('join rate limiting', () => {
it('all 5 simultaneous joins complete despite rate=1/s', async () => {
const r = room();
const clients = await Promise.all(
Array.from({ length: NUM_CLIENTS }, () => ctx.connectWhitelisted())
);
// All 5 fire simultaneously; the throttle (1/s) will queue 4 of them.
const presences = await Promise.all(
clients.map(c => c.joinRoom(r, undefined, { timeout: JOIN_TIMEOUT }))
);
assert.equal(presences.length, NUM_CLIENTS, 'all 5 joins must resolve');
for (const p of presences) {
assert.notEqual(p.attrs.type, 'error', 'join must not be rejected with an error');
const x = p.getChild('x', 'http://jabber.org/protocol/muc#user');
const isSelf = x?.getChildren('status').some(s => s.attrs.code === '110');
assert.ok(isSelf, 'each self-presence must carry MUC status 110');
}
});
it('queued joins arrive in order (each after the previous)', async () => {
const r = room();
const clients = await Promise.all(
Array.from({ length: NUM_CLIENTS }, () => ctx.connectWhitelisted())
);
// Record the wall-clock time at which each joinRoom resolves.
const resolvedAt = new Array(NUM_CLIENTS);
const joinPromises = clients.map((c, i) =>
c.joinRoom(r, undefined, { timeout: JOIN_TIMEOUT }).then(p => {
resolvedAt[i] = Date.now();
return p;
})
);
await Promise.all(joinPromises);
// With rate=1/s the resolutions must be spread ≥ 800 ms apart in total.
// (We avoid asserting strict per-join gaps to tolerate scheduling jitter.)
const earliest = Math.min(...resolvedAt);
const latest = Math.max(...resolvedAt);
const span = latest - earliest;
assert.ok(span >= 800,
`joins should be spread over ≥ 800 ms, actual span: ${span} ms`);
});
});
// -------------------------------------------------------------------------
// Leave rate limiting
// -------------------------------------------------------------------------
describe('leave rate limiting', () => {
it('all 5 simultaneous leaves complete and observer receives all unavailable presences', async () => {
const r = room();
// Observer joins first so it can witness the leaves.
const observer = await ctx.connectWhitelisted();
await observer.joinRoom(r, 'observer', { timeout: JOIN_TIMEOUT });
// 5 clients join (rate-limited; each waits its turn).
const leavers = await Promise.all(
Array.from({ length: NUM_CLIENTS }, () => ctx.connectWhitelisted())
);
const joinResults = await Promise.all(
leavers.map(c => c.joinRoom(r, undefined, { timeout: JOIN_TIMEOUT }))
);
// Derive the nick each leaver was assigned from its self-presence.
const nicks = joinResults.map(p => p.attrs.from.split('/')[1]);
// Set up watchers BEFORE disconnecting so no unavailable presence is missed.
const unavailablePromises = nicks.map(nick =>
observer.waitForPresenceFrom(`${r}/${nick}`, {
type: 'unavailable',
timeout: JOIN_TIMEOUT
})
);
// All 5 disconnect simultaneously; the leave throttle (1/s) will queue 4.
await Promise.all(leavers.map(c => c.disconnect()));
const unavailables = await Promise.all(unavailablePromises);
assert.equal(unavailables.length, NUM_CLIENTS,
'observer must receive unavailable presences for all 5 leavers');
for (const p of unavailables) {
assert.equal(p.attrs.type, 'unavailable',
'each received presence must be of type unavailable');
}
});
});
// -------------------------------------------------------------------------
// Room destruction during queued joins
// -------------------------------------------------------------------------
describe('room destruction', () => {
it('room destroyed while joins are queued does not crash the module', async () => {
const r = room();
const clients = await Promise.all(
Array.from({ length: NUM_CLIENTS }, () => ctx.connectWhitelisted())
);
// The first client joins separately so it becomes the room owner and
// can destroy the room via XMPP. This also consumes the throttle
// token so all remaining joins land immediately in the queue.
const [ owner, ...rest ] = clients;
await owner.joinRoom(r, 'owner', { timeout: JOIN_TIMEOUT });
// Fire the remaining 4 joins simultaneously. They are all queued
// (throttle token was just used) and won't be replayed until the
// 1-second timer fires. Use a short timeout so the test doesn't hang
// if the queued joins are never replayed (expected after destroy).
const pendingJoins = rest.map(c =>
c.joinRoom(r, undefined, { timeout: 3000 }).catch(() => null)
);
// Destroy the room as owner before the 1-second timer fires.
await owner.destroyRoom(r);
// Wait for all queued join attempts to settle (they should time out
// because the room was destroyed and the queue was marked empty).
await Promise.allSettled(pendingJoins);
// Verify the module still works: a fresh join on the same component succeeds.
const freshRoom = room();
const freshClient = await ctx.connectWhitelisted();
const p = await freshClient.joinRoom(freshRoom, undefined, { timeout: JOIN_TIMEOUT });
assert.notEqual(p.attrs.type, 'error',
'a fresh join on the same component must succeed after room destruction');
const x = p.getChild('x', 'http://jabber.org/protocol/muc#user');
assert.ok(
x?.getChildren('status').some(s => s.attrs.code === '110'),
'fresh join self-presence must carry status 110'
);
});
});
});