328 lines
12 KiB
JavaScript
328 lines
12 KiB
JavaScript
import assert from 'assert';
|
|
|
|
import { mintAsapToken } from './helpers/jwt.js';
|
|
import { createTestContext } from './helpers/test_context.js';
|
|
import { getRoomState } from './helpers/test_observer.js';
|
|
import { joinWithTranscriber } from './helpers/xmpp_client.js';
|
|
import { isAvailablePresence } from './helpers/xmpp_utils.js';
|
|
|
|
const CONFERENCE = 'conference.localhost';
|
|
|
|
let _roomCounter = 0;
|
|
const room = () => `meeting-id-${++_roomCounter}@${CONFERENCE}`;
|
|
const healthRoom = () => `__jicofo-health-check-mid-${++_roomCounter}@${CONFERENCE}`;
|
|
|
|
/**
|
|
* Asserts that an IQ response is a 'cancel'/'not-allowed' error.
|
|
*
|
|
* @param {object} iq the IQ stanza returned by sendMucAdmin
|
|
* @returns {void}
|
|
*/
|
|
function assertNotAllowed(iq) {
|
|
assert.strictEqual(iq.attrs.type, 'error', 'IQ must be rejected');
|
|
const error = iq.getChild('error');
|
|
|
|
assert.ok(error, 'response must carry an <error>');
|
|
assert.strictEqual(error.attrs.type, 'cancel');
|
|
assert.ok(error.getChild('not-allowed'), 'error condition must be not-allowed');
|
|
}
|
|
|
|
describe('mod_muc_meeting_id', () => {
|
|
|
|
let ctx;
|
|
|
|
beforeEach(() => {
|
|
ctx = createTestContext();
|
|
});
|
|
|
|
afterEach(() => ctx.cleanup());
|
|
|
|
// -------------------------------------------------------------------------
|
|
// jicofo lock
|
|
// -------------------------------------------------------------------------
|
|
describe('jicofo lock', () => {
|
|
|
|
it('focus can join a room', async () => {
|
|
// connectFocus throws (timeout) if the join is blocked.
|
|
await ctx.connectFocus(room());
|
|
});
|
|
|
|
it('regular user can join after focus has joined', async () => {
|
|
const r = room();
|
|
|
|
await ctx.connectFocus(r);
|
|
const c = await ctx.connect();
|
|
const presence = await c.joinRoom(r);
|
|
|
|
assert.ok(isAvailablePresence(presence),
|
|
'regular user should be allowed in after focus unlocks');
|
|
});
|
|
|
|
it('a regular user cannot create a room by joining first', async () => {
|
|
const r = room();
|
|
|
|
// restrict_room_creation = true (core Prosody mod_muc) limits
|
|
// :create-room to admins. A non-focus client's presence to a
|
|
// nonexistent room is rejected at muc-room-pre-create, before the
|
|
// room object exists and before mod_muc_meeting_id's jicofo lock
|
|
// ever comes into play.
|
|
const c = await ctx.connect();
|
|
const presence = await c.joinRoom(r);
|
|
|
|
assert.strictEqual(presence.attrs.type, 'error', 'join must be rejected');
|
|
const error = presence.getChild('error');
|
|
|
|
assert.ok(error, 'response must carry an <error>');
|
|
assert.strictEqual(error.attrs.type, 'cancel');
|
|
assert.ok(error.getChild('not-allowed'), 'error condition must be not-allowed');
|
|
});
|
|
|
|
it('focus can still create the room after a rejected creation attempt', async () => {
|
|
const r = room();
|
|
|
|
const c = await ctx.connect();
|
|
|
|
await c.joinRoom(r);
|
|
|
|
// connectFocus throws (timeout) if the join is blocked.
|
|
await ctx.connectFocus(r);
|
|
});
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// set admin filtering
|
|
// -------------------------------------------------------------------------
|
|
//
|
|
// filter_admin_set in mod_muc_meeting_id intercepts muc#admin set IQs at a
|
|
// high priority (before Prosody's own handler) and rejects any item that
|
|
// targets certain occupants — by nick (role changes) or by real JID
|
|
// (affiliation changes).
|
|
|
|
describe('set admin filtering', () => {
|
|
|
|
it('a kick (role=none by nick) targeting certain participant is rejected', async () => {
|
|
const r = room();
|
|
|
|
await ctx.connectFocus(r);
|
|
const attacker = await ctx.connect();
|
|
|
|
await attacker.joinRoom(r);
|
|
|
|
const resp = await attacker.sendMucAdmin(r, { nick: 'focus',
|
|
role: 'none' });
|
|
|
|
assertNotAllowed(resp);
|
|
|
|
const state = await getRoomState(r);
|
|
|
|
assert.strictEqual(state.occupant_count, 2, 'must remain in the room after a blocked kick');
|
|
});
|
|
|
|
it('a ban (affiliation=outcast by jid) targeting certain participant is rejected', async () => {
|
|
const r = room();
|
|
|
|
const focus = await ctx.connectFocus(r);
|
|
const focusBareJid = focus.jid.split('/')[0];
|
|
const attacker = await ctx.connect();
|
|
|
|
await attacker.joinRoom(r);
|
|
|
|
const resp = await attacker.sendMucAdmin(r, { jid: focusBareJid,
|
|
affiliation: 'outcast' });
|
|
|
|
assertNotAllowed(resp);
|
|
|
|
const state = await getRoomState(r);
|
|
|
|
assert.strictEqual(state.occupant_count, 2, 'must remain in the room after a blocked ban');
|
|
});
|
|
|
|
it('a ban by jid is rejected even when a mismatched nick is also present', async () => {
|
|
const r = room();
|
|
|
|
const focus = await ctx.connectFocus(r);
|
|
const focusBareJid = focus.jid.split('/')[0];
|
|
|
|
// An owner (moderator token) is the only actor Prosody would let
|
|
// change another occupant's affiliation, so the item has to be
|
|
// filtered by this hook rather than by Prosody's permission check.
|
|
const token = mintAsapToken({ room: r.split('@')[0],
|
|
context: { user: { moderator: true } } });
|
|
const attacker = await ctx.connect({ params: { token } });
|
|
|
|
await attacker.joinRoom(r);
|
|
|
|
// The affiliation change acts on the real jid; a nick that resolves
|
|
// to no occupant must not cause the jid to be skipped when deciding
|
|
// whether the item targets focus.
|
|
const resp = await attacker.sendMucAdmin(r, { jid: focusBareJid,
|
|
nick: 'decoy',
|
|
affiliation: 'outcast' });
|
|
|
|
// The real proof is on focus's own connection: a successful ban
|
|
// makes the room send focus an unavailable self-presence. If the
|
|
// item is filtered, none arrives.
|
|
const kicked = await focus.waitForPresenceFrom(`${r}/focus`, { type: 'unavailable',
|
|
timeout: 500 })
|
|
.then(() => true, () => false);
|
|
|
|
assert.strictEqual(kicked, false, 'focus must not be kicked/banned from the room');
|
|
|
|
assertNotAllowed(resp);
|
|
|
|
const state = await getRoomState(r);
|
|
|
|
assert.strictEqual(state.occupant_count, 2, 'must remain in the room after a blocked ban');
|
|
});
|
|
|
|
it('a role demotion (role=participant) targeting certain participant is rejected', async () => {
|
|
const r = room();
|
|
|
|
await ctx.connectFocus(r);
|
|
const attacker = await ctx.connect();
|
|
|
|
await attacker.joinRoom(r);
|
|
|
|
const resp = await attacker.sendMucAdmin(r, { nick: 'focus',
|
|
role: 'participant' });
|
|
|
|
assertNotAllowed(resp);
|
|
});
|
|
|
|
it('admin operations targeting a non-focus occupant are not blocked by this hook', async () => {
|
|
const r = room();
|
|
|
|
// focus is the room owner, so it is allowed to kick a regular user.
|
|
// This verifies the filtering is scoped and does not
|
|
// block legitimate moderation of other participants.
|
|
const focus = await ctx.connectFocus(r);
|
|
const user = await ctx.connect();
|
|
|
|
await user.joinRoom(r);
|
|
const { nick } = user;
|
|
|
|
const resp = await focus.sendMucAdmin(r, { nick,
|
|
role: 'none' });
|
|
|
|
assert.strictEqual(resp.attrs.type, 'result',
|
|
'must be able to kick a regular participant');
|
|
|
|
const state = await getRoomState(r);
|
|
|
|
assert.strictEqual(state.occupant_count, 1,
|
|
'kicked regular user should be gone, only focus remains');
|
|
});
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// transcription filter robustness
|
|
// -------------------------------------------------------------------------
|
|
//
|
|
// filterTranscriptionResult() in mod_muc_meeting_id processes groupchat
|
|
// messages from transcribers. Two nil-deref bugs exist:
|
|
// 1. transcript[1].text crashes when transcript is an empty array.
|
|
// 2. participant.id crashes when the participant field is absent.
|
|
// Both are reached only from transcriber JIDs (is_transcriber() guard).
|
|
// Tests verify the session survives both malformed payloads.
|
|
|
|
describe('transcription filter robustness', () => {
|
|
|
|
it('session survives a transcription-result with an empty transcript array', async () => {
|
|
const r = room();
|
|
|
|
await ctx.connectFocus(r);
|
|
const transcriber = await joinWithTranscriber(r);
|
|
|
|
try {
|
|
// transcript: [] → transcript[1] is nil → .text crashes
|
|
await transcriber.sendJsonGroupchat(r, {
|
|
type: 'transcription-result',
|
|
transcript: [],
|
|
participant: { id: 'p1',
|
|
name: 'Alice' },
|
|
// eslint-disable-next-line camelcase
|
|
is_interim: false
|
|
});
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 400));
|
|
|
|
const disconnected = await transcriber.waitForDisconnect(300)
|
|
.then(() => true, () => false);
|
|
|
|
assert.strictEqual(disconnected, false,
|
|
'transcriber session must survive empty transcript array');
|
|
} finally {
|
|
await transcriber.disconnect();
|
|
}
|
|
});
|
|
|
|
it('session survives a transcription-result with a missing participant field', async () => {
|
|
const r = room();
|
|
|
|
await ctx.connectFocus(r);
|
|
const transcriber = await joinWithTranscriber(r);
|
|
|
|
try {
|
|
// participant absent → participant.id crashes
|
|
await transcriber.sendJsonGroupchat(r, {
|
|
type: 'transcription-result',
|
|
transcript: [ { text: 'hello world' } ],
|
|
// eslint-disable-next-line camelcase
|
|
is_interim: false
|
|
});
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 400));
|
|
|
|
const disconnected = await transcriber.waitForDisconnect(300)
|
|
.then(() => true, () => false);
|
|
|
|
assert.strictEqual(disconnected, false,
|
|
'transcriber session must survive missing participant field');
|
|
} finally {
|
|
await transcriber.disconnect();
|
|
}
|
|
});
|
|
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// health check rooms
|
|
// -------------------------------------------------------------------------
|
|
describe('health check rooms', () => {
|
|
|
|
it('focus can join a health check room', async () => {
|
|
// connectFocus throws on timeout if the join is blocked.
|
|
await ctx.connectFocus(healthRoom());
|
|
});
|
|
|
|
it('non-focus participant is blocked from a health check room', async () => {
|
|
const r = healthRoom();
|
|
const c = await ctx.connect();
|
|
const presence = await c.joinRoom(r);
|
|
|
|
assert.equal(presence.attrs.type, 'error',
|
|
'non-focus should not be allowed into health-check rooms');
|
|
assert.ok(
|
|
presence.getChild('error')?.getChild('service-unavailable'),
|
|
'error condition must be service-unavailable'
|
|
);
|
|
});
|
|
|
|
it('non-focus is blocked even when focus is already in the room', async () => {
|
|
const r = healthRoom();
|
|
|
|
await ctx.connectFocus(r);
|
|
|
|
const intruder = await ctx.connect();
|
|
const presence = await intruder.joinRoom(r);
|
|
|
|
assert.equal(presence.attrs.type, 'error',
|
|
'non-focus must still be blocked after focus has joined');
|
|
assert.ok(
|
|
presence.getChild('error')?.getChild('service-unavailable'),
|
|
'error condition must be service-unavailable'
|
|
);
|
|
});
|
|
});
|
|
});
|