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

426 lines
15 KiB
JavaScript

import assert from 'assert';
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
// mod_polls_component is loaded as the "polls.localhost" component (see
// docker/prosody.cfg.lua). It hooks message/host for new-poll / answer-poll
// json-messages, keeps per-room poll state (installed on conference.localhost
// through process_host_module) and re-broadcasts poll updates to every
// occupant. The sender must be an occupant of the room, located from
// jitsi_web_query_room (set by mod_jitsi_session from the ?room= param).
const CONFERENCE = 'conference.localhost';
const POLLS_COMPONENT = 'polls.localhost';
const JITMEET_NS = 'http://jitsi.org/jitmeet';
let _roomCounter = 0;
const nextRoom = () => `polls-${++_roomCounter}@${CONFERENCE}`;
let _pollCounter = 0;
const nextPollId = () => `poll-${++_pollCounter}`;
/**
* True when msg is a polls broadcast from the polls component.
*
* @param {object} msg Message stanza.
* @returns {boolean}
*/
function isPollsBroadcast(msg) {
return msg.name === 'message'
&& msg.attrs.from === POLLS_COMPONENT
&& msg.getChild('json-message', JITMEET_NS) !== undefined;
}
/**
* Parses the JSON payload carried by a polls broadcast stanza, or null.
*
* @param {object} msg Message stanza.
* @returns {object|null}
*/
function parsePoll(msg) {
const text = msg.getChild('json-message', JITMEET_NS)?.getText();
try {
return text ? JSON.parse(text) : null;
} catch {
return null;
}
}
/**
* Waits for a polls broadcast carrying the given command.
*
* @param {object} client Connected client.
* @param {string} command 'new-poll' | 'answer-poll' | 'old-polls'
* @param {number} [timeout=3000]
* @returns {Promise<object>} The parsed poll payload.
*/
async function waitForPollCommand(client, command, timeout = 3000) {
const msg = await client.waitForMessage(
s => isPollsBroadcast(s) && parsePoll(s)?.command === command, timeout);
return parsePoll(msg);
}
/**
* Asserts that NO polls broadcast arrives within the given window.
*
* @param {object} client
* @param {number} [timeout=1000]
*/
async function assertNoPollBroadcast(client, timeout = 1000) {
try {
await client.waitForMessage(isPollsBroadcast, timeout);
throw new Error('received unexpected polls broadcast');
} catch (err) {
if (err.message.includes('Timeout')) {
return; // expected — no broadcast arrived
}
throw err;
}
}
describe('mod_polls_component', () => {
let clients;
beforeEach(() => {
clients = [];
});
afterEach(async () => {
await Promise.all(clients.map(c => c.disconnect()));
});
/**
* Focus-joins the room (creating and unlocking it) and registers the client
* for cleanup. conference.localhost has mod_muc_meeting_id, so a room must be
* created by focus before regular clients can join.
*
* @param {string} roomJid Full room JID.
* @returns {Promise<object>}
*/
async function focusJoin(roomJid) {
const c = await joinWithFocus(roomJid);
clients.push(c);
return c;
}
/**
* Connects a client with ?room=<roomName> (so mod_jitsi_session sets
* jitsi_web_query_room), joins the room, and registers it for cleanup.
*
* @param {string} roomJid Full room JID.
* @returns {Promise<object>}
*/
async function joinParticipant(roomJid) {
const roomName = roomJid.split('@')[0];
const c = await createXmppClient({ params: { room: roomName } });
clients.push(c);
await c.joinRoom(roomJid);
return c;
}
// ── Poll creation ───────────────────────────────────────────────────────
describe('poll creation', () => {
it('broadcasts a new poll to all occupants', async () => {
const r = nextRoom();
await focusJoin(r);
const creator = await joinParticipant(r);
const other = await joinParticipant(r);
const pollId = nextPollId();
creator.createPoll(POLLS_COMPONENT, pollId, 'Pick one', [
{ name: 'Option A' },
{ name: 'Option B' }
]);
const [ atCreator, atOther ] = await Promise.all([
waitForPollCommand(creator, 'new-poll'),
waitForPollCommand(other, 'new-poll')
]);
for (const poll of [ atCreator, atOther ]) {
assert.equal(poll.pollId, pollId);
assert.equal(poll.question, 'Pick one');
assert.deepEqual(poll.answers.map(a => a.name), [ 'Option A', 'Option B' ]);
assert.ok(poll.senderId, 'broadcast must carry the server-stamped senderId');
}
});
it('relayed answers contain only names — extra fields are stripped', async () => {
// Regression test: the component must re-broadcast only the sanitized
// answers ({ name }). A type-confused voters field (or any other extra
// field) on an answer must not survive to participants, otherwise it
// reaches the client reducer / results render which assume voters is
// an array.
const r = nextRoom();
await focusJoin(r);
const creator = await joinParticipant(r);
const pollId = nextPollId();
creator.createPoll(POLLS_COMPONENT, pollId, 'Pick one', [
{ name: 'Option A',
voters: 1,
evil: 'payload' },
{ name: 'Option B',
voters: [ { id: 'spoofed',
name: 'spoofed' } ] }
]);
const poll = await waitForPollCommand(creator, 'new-poll');
assert.equal(poll.answers.length, 2);
assert.deepEqual(poll.answers.map(a => a.name), [ 'Option A', 'Option B' ]);
for (const answer of poll.answers) {
assert.strictEqual(answer.voters, undefined, 'voters must be stripped from the broadcast');
assert.strictEqual(answer.evil, undefined, 'extra fields must be stripped from the broadcast');
assert.deepEqual(Object.keys(answer), [ 'name' ], 'answers must expose only the name field');
}
});
it('a duplicate pollId is rejected with an error', async () => {
const r = nextRoom();
await focusJoin(r);
const creator = await joinParticipant(r);
const pollId = nextPollId();
creator.createPoll(POLLS_COMPONENT, pollId, 'First', [ { name: 'A' } ]);
await waitForPollCommand(creator, 'new-poll');
// Same pollId — the module replies with an error and does not
// broadcast a second new-poll.
creator.createPoll(POLLS_COMPONENT, pollId, 'Second', [ { name: 'A' } ]);
const errorReply = await creator.waitForMessage(s => s.attrs.type === 'error');
assert.ok(errorReply.getChild('error'), 'duplicate pollId must be rejected with an error stanza');
});
});
// ── Poll answering ──────────────────────────────────────────────────────
describe('poll answering', () => {
it('broadcasts an answer to all occupants', async () => {
const r = nextRoom();
await focusJoin(r);
const creator = await joinParticipant(r);
const voter = await joinParticipant(r);
const pollId = nextPollId();
creator.createPoll(POLLS_COMPONENT, pollId, 'Pick one', [
{ name: 'Option A' },
{ name: 'Option B' }
]);
await Promise.all([
waitForPollCommand(creator, 'new-poll'),
waitForPollCommand(voter, 'new-poll')
]);
voter.answerPoll(POLLS_COMPONENT, pollId, [ true, false ]);
const [ atCreator, atVoter ] = await Promise.all([
waitForPollCommand(creator, 'answer-poll'),
waitForPollCommand(voter, 'answer-poll')
]);
for (const answer of [ atCreator, atVoter ]) {
assert.equal(answer.pollId, pollId);
assert.deepEqual(answer.answers, [ true, false ]);
assert.ok(answer.senderId, 'answer broadcast must carry the server-stamped senderId');
}
});
it('an answer for an unknown poll is ignored', async () => {
const r = nextRoom();
await focusJoin(r);
const voter = await joinParticipant(r);
voter.answerPoll(POLLS_COMPONENT, nextPollId(), [ true ]);
await assertNoPollBroadcast(voter);
});
});
// ── History delivery ──────────────────────────────────────────────────────
describe('history delivery', () => {
it('a client joining after a poll was created receives it via old-polls', async () => {
const r = nextRoom();
await focusJoin(r);
const creator = await joinParticipant(r);
const pollId = nextPollId();
creator.createPoll(POLLS_COMPONENT, pollId, 'History question', [
{ name: 'A' },
{ name: 'B' }
]);
await waitForPollCommand(creator, 'new-poll');
// A late joiner receives the current poll state as an old-polls
// message right after joining.
const late = await joinParticipant(r);
const history = await waitForPollCommand(late, 'old-polls');
assert.ok(Array.isArray(history.polls), 'old-polls must carry a polls array');
const stored = history.polls.find(p => p.pollId === pollId);
assert.ok(stored, 'the previously created poll must be present in history');
assert.equal(stored.question, 'History question');
assert.deepEqual(stored.answers.map(a => a.name), [ 'A', 'B' ]);
});
});
// ── Validation ──────────────────────────────────────────────────────────
describe('validation', () => {
/**
* Sends a raw new-poll-shaped payload, then asserts no broadcast arrives
* and the module is still functional afterwards.
*
* @param {object} rawPayload The payload object to serialize and send.
*/
async function assertRejected(rawPayload) {
const r = nextRoom();
await focusJoin(r);
const creator = await joinParticipant(r);
creator.sendPollsMessage(POLLS_COMPONENT, JSON.stringify(rawPayload));
await assertNoPollBroadcast(creator);
// The module must still process a well-formed poll afterwards.
const goodId = nextPollId();
creator.createPoll(POLLS_COMPONENT, goodId, 'Still working', [ { name: 'A' } ]);
const poll = await waitForPollCommand(creator, 'new-poll');
assert.equal(poll.pollId, goodId);
}
it('new-poll with a non-string answer name is ignored', () => assertRejected({
answers: [ { name: 42 } ],
command: 'new-poll',
pollId: nextPollId(),
question: 'Q',
type: 'polls'
}));
it('new-poll with an empty answers array is ignored', () => assertRejected({
answers: [],
command: 'new-poll',
pollId: nextPollId(),
question: 'Q',
type: 'polls'
}));
it('new-poll with a missing question is ignored', () => assertRejected({
answers: [ { name: 'A' } ],
command: 'new-poll',
pollId: nextPollId(),
type: 'polls'
}));
it('answer-poll with non-boolean answers is ignored', async () => {
const r = nextRoom();
await focusJoin(r);
const creator = await joinParticipant(r);
const pollId = nextPollId();
creator.createPoll(POLLS_COMPONENT, pollId, 'Pick one', [ { name: 'A' }, { name: 'B' } ]);
await waitForPollCommand(creator, 'new-poll');
// answers must be booleans; a numeric answer must be rejected.
creator.sendPollsMessage(POLLS_COMPONENT, JSON.stringify({
answers: [ 1, 0 ],
command: 'answer-poll',
pollId,
type: 'polls'
}));
await assertNoPollBroadcast(creator);
});
it('invalid JSON is ignored cleanly', async () => {
const r = nextRoom();
await focusJoin(r);
const creator = await joinParticipant(r);
creator.sendPollsMessage(POLLS_COMPONENT, '{not valid json{{');
await assertNoPollBroadcast(creator);
// Module must still be functional after the bad message.
const goodId = nextPollId();
creator.createPoll(POLLS_COMPONENT, goodId, 'After bad JSON', [ { name: 'A' } ]);
const poll = await waitForPollCommand(creator, 'new-poll');
assert.equal(poll.pollId, goodId);
});
it('an oversized payload is rejected', async () => {
const r = nextRoom();
await focusJoin(r);
const creator = await joinParticipant(r);
// The payload size is checked (>= 1024 bytes) before decoding.
creator.createPoll(POLLS_COMPONENT, nextPollId(), 'x'.repeat(1100), [ { name: 'A' } ]);
await assertNoPollBroadcast(creator);
// A normal poll must still go through afterwards.
const goodId = nextPollId();
creator.createPoll(POLLS_COMPONENT, goodId, 'Normal size', [ { name: 'A' } ]);
const poll = await waitForPollCommand(creator, 'new-poll');
assert.equal(poll.pollId, goodId);
});
});
// ── Sender must be an occupant ────────────────────────────────────────────
describe('sender authorization', () => {
it('a connected non-occupant cannot create a poll', async () => {
const r = nextRoom();
await focusJoin(r);
const roomName = r.split('@')[0];
const nonOccupant = await createXmppClient({ params: { room: roomName } });
clients.push(nonOccupant);
// Deliberately do NOT join the room — the sender is not an occupant.
nonOccupant.createPoll(POLLS_COMPONENT, nextPollId(), 'Q', [ { name: 'A' } ]);
await assertNoPollBroadcast(nonOccupant);
});
});
});