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

477 lines
18 KiB
JavaScript

/**
* Integration tests for mod_filesharing_component.
*
* The module runs as a Prosody component (filesharing.localhost). Clients send
* <file-sharing xmlns='http://jitsi.org/jitmeet' type='add|remove'> messages
* to it; the component looks up the room from jitsi_web_query_room (set by
* mod_jitsi_session from the ?room= WebSocket URL parameter), validates that
* the sender is a room occupant, applies a feature gate, and then:
*
* add — stores the file in room.jitsi_shared_files and broadcasts a
* json-message with event='add' to all non-admin, non-sender occupants.
* remove — deletes the file from room.jitsi_shared_files and broadcasts
* event='remove' to all non-admin, non-sender occupants.
*
* When a new occupant joins a room that already has shared files, the
* muc-occupant-joined hook sends them a json-message with event='list'
* containing the current file set.
*
* Author fields (authorParticipantId, authorParticipantJid, authorParticipantName)
* in add payloads are overwritten by Prosody from the real session state,
* preventing clients from spoofing each other's identity.
*
* Feature gate: the sender must have jitsi_meet_context_features containing
* file-upload=true. The is_owner fallback in is_feature_allowed is effectively
* unreachable in this environment because mod_jitsi_permissions (auto-loaded by
* muc_meeting_id) always sets at least default_permissions on moderator sessions,
* making jitsi_meet_context_features truthy before the fallback is evaluated.
*/
import assert from 'assert';
import { setRoomMaxOccupants, setSessionContext } from './helpers/test_observer.js';
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
const CONFERENCE = 'conference.localhost';
const FILESHARING_COMPONENT = 'filesharing.localhost';
const JITMEET_NS = 'http://jitsi.org/jitmeet';
let _counter = 0;
const nextRoom = () => `fs-${++_counter}@${CONFERENCE}`;
// ── Helpers ───────────────────────────────────────────────────────────────────
/**
* Connects an anonymous client on the main VirtualHost with ?room=<roomName>
* so that mod_jitsi_session sets jitsi_web_query_room. The filesharing
* component requires this field before processing any message.
*
* @param {string} roomJid full room JID, e.g. 'room@conference.localhost'
* @returns {Promise<XmppTestClient>}
*/
function connect(roomJid) {
const roomName = roomJid.split('@')[0];
return createXmppClient({ params: { room: roomName } });
}
/**
* Returns true when stanza is a filesharing broadcast from the component
* (a <message> carrying a <json-message xmlns='http://jitsi.org/jitmeet'> child).
*
* @param {object} stanza
*/
function isFilesharingBroadcast(stanza) {
return stanza.name === 'message'
&& stanza.attrs.from === FILESHARING_COMPONENT
&& stanza.getChild('json-message', JITMEET_NS) !== undefined;
}
/**
* Parses the JSON payload from a filesharing broadcast stanza.
* Returns the decoded object: { type, event, file?, files?, fileId? }
*
* @param {object} stanza
* @returns {object|null}
*/
function parseBroadcast(stanza) {
const text = stanza.getChild('json-message', JITMEET_NS)?.getText();
return text ? JSON.parse(text) : null;
}
/**
* Waits for a filesharing broadcast stanza on the given client.
*
* @param {object} client
* @param {number} [timeout=3000]
* @returns {Promise<object>}
*/
function waitForFilesharing(client, timeout = 3000) {
return client.waitForMessage(isFilesharingBroadcast, timeout);
}
/**
* Asserts that no filesharing broadcast arrives within the given window.
* Throws if one does arrive.
*
* @param {object} client
* @param {number} [timeout=1000]
*/
async function assertNoFilesharing(client, timeout = 1000) {
try {
await client.waitForMessage(isFilesharingBroadcast, timeout);
throw new Error('received unexpected filesharing broadcast');
} catch (err) {
if (err.message.includes('Timeout')) {
return; // expected — no broadcast
}
throw err;
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('mod_filesharing_component', () => {
const clients = [];
afterEach(async () => {
await Promise.all(clients.map(c => c.disconnect()));
clients.length = 0;
});
// ── add file ──────────────────────────────────────────────────────────────
describe('add file', () => {
it('broadcasts add event to other occupants', async () => {
const r = nextRoom();
const foc = await joinWithFocus(r);
const sender = await connect(r);
const receiver = await connect(r);
clients.push(foc, sender, receiver);
await sender.joinRoom(r);
await receiver.joinRoom(r);
await setSessionContext(sender.jid, 'user-a', { 'file-upload': true });
sender.sendFileSharingAdd(FILESHARING_COMPONENT, { fileId: 'file1',
name: 'test.pdf' });
const broadcast = await waitForFilesharing(receiver);
const payload = parseBroadcast(broadcast);
assert.equal(payload.type, 'file-sharing');
assert.equal(payload.event, 'add');
assert.equal(payload.file.fileId, 'file1');
});
it('does not send broadcast back to sender', async () => {
const r = nextRoom();
const foc = await joinWithFocus(r);
const sender = await connect(r);
const receiver = await connect(r);
clients.push(foc, sender, receiver);
await sender.joinRoom(r);
await receiver.joinRoom(r);
await setSessionContext(sender.jid, 'user-a', { 'file-upload': true });
sender.sendFileSharingAdd(FILESHARING_COMPONENT, { fileId: 'file1',
name: 'test.pdf' });
// Wait for receiver to confirm Prosody processed the add, then
// assert sender received nothing.
await waitForFilesharing(receiver);
await assertNoFilesharing(sender);
});
it('does not send broadcast to admin (focus)', async () => {
const r = nextRoom();
const foc = await joinWithFocus(r);
const sender = await connect(r);
clients.push(foc, sender);
await sender.joinRoom(r);
await setSessionContext(sender.jid, 'user-a', { 'file-upload': true });
sender.sendFileSharingAdd(FILESHARING_COMPONENT, { fileId: 'file1',
name: 'test.pdf' });
await assertNoFilesharing(foc);
});
it('overwrites author fields to prevent spoofing', async () => {
const r = nextRoom();
const foc = await joinWithFocus(r);
const sender = await connect(r);
const receiver = await connect(r);
clients.push(foc, sender, receiver);
await sender.joinRoom(r);
await receiver.joinRoom(r);
await setSessionContext(sender.jid, 'user-a', { 'file-upload': true });
sender.sendFileSharingAdd(FILESHARING_COMPONENT, {
fileId: 'file1',
authorParticipantId: 'FAKE_ID',
authorParticipantJid: 'fake@evil.com'
});
const { file } = parseBroadcast(await waitForFilesharing(receiver));
assert.notEqual(file.authorParticipantId, 'FAKE_ID',
'authorParticipantId must be overwritten by Prosody');
assert.notEqual(file.authorParticipantJid, 'fake@evil.com',
'authorParticipantJid must be overwritten by Prosody');
assert.ok(file.authorParticipantId, 'authorParticipantId must be set');
assert.ok(file.authorParticipantJid, 'authorParticipantJid must be set');
});
});
// ── remove file ───────────────────────────────────────────────────────────
describe('remove file', () => {
it('broadcasts remove event to other occupants', async () => {
const r = nextRoom();
const foc = await joinWithFocus(r);
const sender = await connect(r);
const receiver = await connect(r);
clients.push(foc, sender, receiver);
await sender.joinRoom(r);
await receiver.joinRoom(r);
await setSessionContext(sender.jid, 'user-a', { 'file-upload': true });
// Add first, wait for add broadcast to confirm it landed.
sender.sendFileSharingAdd(FILESHARING_COMPONENT, { fileId: 'file1',
name: 'test.pdf' });
await waitForFilesharing(receiver);
// Now remove.
sender.sendFileSharingRemove(FILESHARING_COMPONENT, 'file1');
const broadcast = await waitForFilesharing(receiver);
const payload = parseBroadcast(broadcast);
assert.equal(payload.type, 'file-sharing');
assert.equal(payload.event, 'remove');
assert.equal(payload.fileId, 'file1');
});
it('removed file is not delivered to subsequent joiners', async () => {
const r = nextRoom();
const foc = await joinWithFocus(r);
// Raise the limit: need sender + watcher + late joiner (3 regular occupants).
await setRoomMaxOccupants(r, 10);
const sender = await connect(r);
const watcher = await connect(r);
clients.push(foc, sender, watcher);
await sender.joinRoom(r);
await watcher.joinRoom(r);
await setSessionContext(sender.jid, 'user-a', { 'file-upload': true });
// Add then remove — observe both broadcasts on watcher so we know
// Prosody has fully processed them before the late joiner connects.
sender.sendFileSharingAdd(FILESHARING_COMPONENT, { fileId: 'file1',
name: 'test.pdf' });
await waitForFilesharing(watcher);
sender.sendFileSharingRemove(FILESHARING_COMPONENT, 'file1');
await waitForFilesharing(watcher);
const late = await connect(r);
clients.push(late);
await late.joinRoom(r);
await assertNoFilesharing(late);
});
});
// ── new joiner receives file list ─────────────────────────────────────────
describe('new joiner receives file list', () => {
it('joiner receives list of existing files', async () => {
const r = nextRoom();
const foc = await joinWithFocus(r);
// Raise the limit: need sender + watcher + late joiner (3 regular occupants).
await setRoomMaxOccupants(r, 10);
const sender = await connect(r);
const watcher = await connect(r);
clients.push(foc, sender, watcher);
await sender.joinRoom(r);
await watcher.joinRoom(r);
await setSessionContext(sender.jid, 'user-a', { 'file-upload': true });
// Add a file and wait for it to land before the late joiner connects.
sender.sendFileSharingAdd(FILESHARING_COMPONENT, { fileId: 'file1',
name: 'test.pdf' });
await waitForFilesharing(watcher);
const late = await connect(r);
clients.push(late);
await late.joinRoom(r);
const broadcast = await waitForFilesharing(late);
const payload = parseBroadcast(broadcast);
assert.equal(payload.type, 'file-sharing');
assert.equal(payload.event, 'list');
assert.ok(payload.files?.file1, 'file list must include the added file');
});
it('joiner receives nothing when no files have been shared', async () => {
const r = nextRoom();
const foc = await joinWithFocus(r);
const first = await connect(r);
const late = await connect(r);
clients.push(foc, first, late);
await first.joinRoom(r);
await late.joinRoom(r);
await assertNoFilesharing(late);
});
});
// ── authorization ─────────────────────────────────────────────────────────
describe('authorization', () => {
it('non-occupant message is silently ignored', async () => {
const r = nextRoom();
const foc = await joinWithFocus(r);
const nonOccupant = await connect(r); // connected but NOT joined
const receiver = await connect(r);
clients.push(foc, nonOccupant, receiver);
await receiver.joinRoom(r);
await setSessionContext(nonOccupant.jid, 'user-a', { 'file-upload': true });
nonOccupant.sendFileSharingAdd(FILESHARING_COMPONENT, { fileId: 'file1' });
await assertNoFilesharing(receiver);
});
it('message without jitsi_web_query_room is silently ignored', async () => {
const r = nextRoom();
const foc = await joinWithFocus(r);
const noRoom = await createXmppClient(); // no ?room= param
const receiver = await connect(r);
clients.push(foc, noRoom, receiver);
await receiver.joinRoom(r);
noRoom.sendFileSharingAdd(FILESHARING_COMPONENT, { fileId: 'file1' });
await assertNoFilesharing(receiver);
});
it('non-owner with no token features receives forbidden error', async () => {
const r = nextRoom();
const foc = await joinWithFocus(r);
const member = await connect(r);
clients.push(foc, member);
await member.joinRoom(r);
// No setSessionContext — no features, member affiliation.
member.sendFileSharingAdd(FILESHARING_COMPONENT, { fileId: 'file1' });
const err = await member.waitForMessage(s => s.attrs.type === 'error', 3000);
assert.equal(err.attrs.type, 'error');
assert.ok(
err.getChild('error')?.getChild('forbidden'),
'expected <forbidden/> in error stanza'
);
});
it('client with file-upload feature can add file', async () => {
const r = nextRoom();
const foc = await joinWithFocus(r);
const sender = await connect(r);
const receiver = await connect(r);
clients.push(foc, sender, receiver);
await sender.joinRoom(r);
await receiver.joinRoom(r);
await setSessionContext(sender.jid, 'user-a', { 'file-upload': true });
sender.sendFileSharingAdd(FILESHARING_COMPONENT, { fileId: 'file1',
name: 'test.pdf' });
const broadcast = await waitForFilesharing(receiver);
assert.equal(parseBroadcast(broadcast).event, 'add');
});
});
// ── invalid payloads ──────────────────────────────────────────────────────
describe('invalid payloads', () => {
it('add without fileId in JSON body is dropped silently', async () => {
const r = nextRoom();
const foc = await joinWithFocus(r);
const sender = await connect(r);
const receiver = await connect(r);
clients.push(foc, sender, receiver);
await sender.joinRoom(r);
await receiver.joinRoom(r);
await setSessionContext(sender.jid, 'user-a', { 'file-upload': true });
sender.sendFileSharingAdd(FILESHARING_COMPONENT, { name: 'no-id.pdf' }); // no fileId
await assertNoFilesharing(receiver);
});
it('remove without fileId attribute is dropped silently', async () => {
const r = nextRoom();
const foc = await joinWithFocus(r);
const sender = await connect(r);
const receiver = await connect(r);
clients.push(foc, sender, receiver);
await sender.joinRoom(r);
await receiver.joinRoom(r);
await setSessionContext(sender.jid, 'user-a', { 'file-upload': true });
// Remove stanza with no fileId attribute — the module logs an error
// and returns true without firing any event.
sender.sendFileSharingRaw(FILESHARING_COMPONENT, {}, { type: 'remove' });
await assertNoFilesharing(receiver);
});
it('error-type message is ignored without triggering a reply', async () => {
const r = nextRoom();
const foc = await joinWithFocus(r);
const sender = await connect(r);
clients.push(foc, sender);
await sender.joinRoom(r);
// type='error' stanzas must be silently dropped (early return in on_message).
sender.sendFileSharingRaw(
FILESHARING_COMPONENT,
{ type: 'error' },
{ type: 'add' },
JSON.stringify({ fileId: 'f1' })
);
// Module must not reply with another error (would cause a loop).
try {
await sender.waitForMessage(s => s.attrs.type === 'error', 1000);
assert.fail('received unexpected error reply to error stanza');
} catch (err) {
assert.ok(err.message.includes('Timeout'),
`unexpected error: ${err.message}`);
}
});
});
});