205 lines
8.4 KiB
JavaScript
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'
|
|
);
|
|
});
|
|
|
|
});
|
|
|
|
});
|