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

753 lines
27 KiB
JavaScript

import assert from 'assert';
import { mintAsapToken } from './helpers/jwt.js';
import { getSessionFeatures } from './helpers/test_observer.js';
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
const CONFERENCE = 'conference.localhost';
const PERMISSIONS_NS = 'http://jitsi.org/jitmeet';
let _roomCounter = 0;
const nextRoom = () => `permissions-${++_roomCounter}@${CONFERENCE}`;
// Keys present in the default_permissions table in mod_jitsi_permissions.lua.
const DEFAULT_PERMISSION_KEYS = [
'livestreaming', 'recording', 'transcription',
'outbound-call', 'create-polls', 'send-groupchat', 'flip'
];
/**
* Extracts the <permissions xmlns='http://jitsi.org/jitmeet'> element from a
* presence stanza and returns its children as a plain { name: val } map.
* Returns null when the element is absent.
*
* @param {object} presence
* @returns {object|null}
*/
function getPermissions(presence) {
const el = presence.getChild('permissions', PERMISSIONS_NS);
if (!el) {
return null;
}
const result = {};
for (const p of el.getChildren('p')) {
result[p.attrs.name] = p.attrs.val;
}
return result;
}
describe('mod_jitsi_permissions', () => {
const clients = [];
afterEach(async () => {
await Promise.all(clients.map(c => c.disconnect()));
clients.length = 0;
});
/**
* Creates a connected XMPP client and tracks it for afterEach cleanup.
*
* @param {object} [opts] Options forwarded to createXmppClient.
* @returns {Promise<object>}
*/
async function connect(opts) {
const c = await createXmppClient(opts);
clients.push(c);
return c;
}
// ── moderator token, no features ──────────────────────────────────────────
//
// mod_token_affiliation grants owner/moderator on join.
// mod_jitsi_permissions sees: is_moderator=true, send_default_permissions_to
// flag set (by muc-pre-set-affiliation from set_affiliation call inside
// token_affiliation), no jitsi_meet_context_features on session → injects
// all defaults.
describe('moderator token without features', () => {
it('injects all default permissions into self-presence on join', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const token = mintAsapToken({ room: r.split('@')[0],
context: { user: { moderator: true } } });
const c = await connect({ params: { token } });
const presence = await c.joinRoom(r);
const perms = getPermissions(presence);
assert.ok(perms !== null, 'permissions element should be present');
for (const key of DEFAULT_PERMISSION_KEYS) {
assert.strictEqual(perms[key], 'true', `expected ${key}=true`);
}
});
});
// ── moderator token with features ─────────────────────────────────────────
//
// mod_token_affiliation grants owner/moderator.
// filter_stanza sees auth_token + jitsi_meet_context_features both set →
// skips injection entirely so the client uses its own token-provided features.
describe('moderator token with features', () => {
it('does not inject permissions when token already contains features', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const token = mintAsapToken({
room: r.split('@')[0],
context: {
user: { moderator: true },
features: {
recording: false,
'screen-sharing': true
}
}
});
const c = await connect({ params: { token } });
const presence = await c.joinRoom(r);
assert.strictEqual(
getPermissions(presence),
null,
'permissions should not be injected when token already provides features'
);
});
});
// ── non-moderator token ───────────────────────────────────────────────────
//
// mod_token_affiliation grants member affiliation (participant role).
// filter_stanza checks is_moderator=false → returns stanza unchanged.
describe('non-moderator token', () => {
it('does not inject permissions for member participant', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const token = mintAsapToken({ room: r.split('@')[0],
context: { user: { id: 'user1' } } });
const c = await connect({ params: { token } });
const presence = await c.joinRoom(r);
assert.strictEqual(
getPermissions(presence),
null,
'permissions should not be injected for non-moderator participant'
);
});
it('does not inject permissions for member with features', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const token = mintAsapToken({
room: r.split('@')[0],
context: {
user: { moderator: false },
features: { recording: true }
}
});
const c = await connect({ params: { token } });
const presence = await c.joinRoom(r);
assert.strictEqual(
getPermissions(presence),
null,
'permissions should not be injected for non-moderator even with features'
);
});
});
// ── anonymous user (no token) ─────────────────────────────────────────────
//
// token_affiliation is a no-op → affiliation=none, role=participant.
// muc-pre-set-affiliation never fires → send_default_permissions_to not set.
// filter_stanza: is_moderator=false → no injection.
describe('anonymous user (no token)', () => {
it('does not inject permissions for anonymous participant', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const c = await connect();
const presence = await c.joinRoom(r);
assert.strictEqual(
getPermissions(presence),
null,
'permissions should not be injected for anonymous user'
);
});
});
// ── affiliation grant and revoke ──────────────────────────────────────────
//
// When a moderator grants owner affiliation to another participant,
// process_set_affiliation copies the granting session's feature table onto
// the recipient's session. When the affiliation is revoked, the recipient
// must go back to the features that its own token supplied, or to no
// features at all when the token supplied none. A session that keeps the
// granted table stays allowed by is_feature_allowed(), which does not look
// at the current role once a feature table is present.
describe('affiliation grant and revoke', () => {
/**
* Joins a room as a moderator that holds every default feature.
* Use it as the actor that grants and revokes owner affiliation.
*
* @param {Function} connectFn the spec-local connect helper.
* @param {string} roomJid e.g. 'room@conference.localhost'
* @returns {Promise<object>}
*/
async function joinAsGranter(connectFn, roomJid, extraParams = {}) {
const features = {};
for (const key of DEFAULT_PERMISSION_KEYS) {
features[key] = true;
}
const token = mintAsapToken({
room: roomJid.split('@')[0],
context: {
user: { id: 'granter',
moderator: true },
features
}
});
const c = await connectFn({ params: { token,
...extraParams } });
await c.joinRoom(roomJid);
return c;
}
/**
* Sends a Jibri start IQ and returns the name of the error condition
* that comes back. mod_filter_iq_jibri answers 'forbidden' when it
* blocks the request. Anything else means the IQ was routed on to the
* focus occupant, which has no Jibri handler in these tests.
*
* @param {object} client the sender.
* @param {string} roomJid e.g. 'room@conference.localhost'
* @returns {Promise<string|null>}
*/
async function jibriStartErrorCondition(client, roomJid) {
const wait = client.waitForIq(iq => iq.attrs.type === 'error', 5000);
client.sendJibriIq(roomJid, 'start', 'file');
const error = (await wait).getChild('error');
return error ? error.children[0].name : null;
}
/**
* Sends a Rayo dial IQ and returns the name of the error condition that
* comes back. mod_filter_iq_rayo answers 'forbidden' when it blocks the
* request. Anything else means the IQ was routed on to the focus
* occupant, which has no Rayo handler in these tests.
*
* @param {object} client the sender.
* @param {string} roomJid e.g. 'room@conference.localhost'
* @returns {Promise<string|null>}
*/
async function rayoDialErrorCondition(client, roomJid) {
const reply = await client.sendRayoIqAndWait(roomJid);
const error = reply.getChild('error');
return error ? error.children[0].name : null;
}
it('restores the token features of the recipient when owner is revoked', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const granter = await joinAsGranter(connect, r);
const token = mintAsapToken({
room: r.split('@')[0],
context: {
user: { id: 'user1' },
features: {
recording: false,
livestreaming: false,
transcription: false,
'outbound-call': false
}
}
});
const c = await connect({ params: { token } });
await c.joinRoom(r);
const bare = c.jid.split('/')[0];
await granter.sendMucAdmin(r, { jid: bare,
affiliation: 'owner' });
const granted = await getSessionFeatures(c.jid);
assert.strictEqual(granted.recording, true, 'grant should hand over the actor features');
await granter.sendMucAdmin(r, { jid: bare,
affiliation: 'member' });
const restored = await getSessionFeatures(c.jid);
assert.ok(restored !== null, 'the token features should come back');
assert.strictEqual(restored.recording, false);
assert.strictEqual(restored.livestreaming, false);
assert.strictEqual(restored.transcription, false);
assert.strictEqual(restored['outbound-call'], false);
});
it('clears granted features when the token of the recipient has none', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const granter = await joinAsGranter(connect, r);
const token = mintAsapToken({ room: r.split('@')[0],
context: { user: { id: 'user2' } } });
const c = await connect({ params: { token } });
await c.joinRoom(r);
const bare = c.jid.split('/')[0];
await granter.sendMucAdmin(r, { jid: bare,
affiliation: 'owner' });
assert.ok(await getSessionFeatures(c.jid) !== null, 'grant should set features');
await granter.sendMucAdmin(r, { jid: bare,
affiliation: 'member' });
assert.strictEqual(await getSessionFeatures(c.jid), null,
'the session must hold no features again');
});
it('clears granted features for a participant without a token', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const granter = await joinAsGranter(connect, r);
const c = await connect();
await c.joinRoom(r);
const bare = c.jid.split('/')[0];
await granter.sendMucAdmin(r, { jid: bare,
affiliation: 'owner' });
assert.ok(await getSessionFeatures(c.jid) !== null, 'grant should set features');
await granter.sendMucAdmin(r, { jid: bare,
affiliation: 'member' });
assert.strictEqual(await getSessionFeatures(c.jid), null,
'the session must hold no features again');
});
it('restores the token features when owner is revoked twice in a row', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const granter = await joinAsGranter(connect, r);
const token = mintAsapToken({
room: r.split('@')[0],
context: {
user: { id: 'user3' },
features: { recording: false }
}
});
const c = await connect({ params: { token } });
await c.joinRoom(r);
const bare = c.jid.split('/')[0];
for (let i = 0; i < 2; i++) {
await granter.sendMucAdmin(r, { jid: bare,
affiliation: 'owner' });
await granter.sendMucAdmin(r, { jid: bare,
affiliation: 'member' });
}
const restored = await getSessionFeatures(c.jid);
assert.ok(restored !== null, 'the token features should come back');
assert.strictEqual(restored.recording, false);
});
it('blocks Jibri and Rayo requests from a recipient whose owner affiliation was revoked', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const granter = await joinAsGranter(connect, r);
const token = mintAsapToken({
room: r.split('@')[0],
context: {
user: { id: 'user4' },
features: { recording: false,
'outbound-call': false }
}
});
const c = await connect({ params: { token } });
await c.joinRoom(r);
assert.strictEqual(
await jibriStartErrorCondition(c, r),
'forbidden',
'a member with recording=false must not reach Jibri');
assert.strictEqual(
await rayoDialErrorCondition(c, r),
'forbidden',
'a member with outbound-call=false must not reach Jigasi');
const bare = c.jid.split('/')[0];
await granter.sendMucAdmin(r, { jid: bare,
affiliation: 'owner' });
assert.notStrictEqual(
await jibriStartErrorCondition(c, r),
'forbidden',
'an owner with a granted recording feature must reach Jibri');
await granter.sendMucAdmin(r, { jid: bare,
affiliation: 'member' });
assert.strictEqual(
await jibriStartErrorCondition(c, r),
'forbidden',
'the recipient must not keep the granted recording feature');
assert.strictEqual(
await rayoDialErrorCondition(c, r),
'forbidden',
'the recipient must not keep the granted outbound-call feature');
});
it('restores the token features when the recipient revokes its own owner affiliation', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const granter = await joinAsGranter(connect, r);
const token = mintAsapToken({
room: r.split('@')[0],
context: {
user: { id: 'user5' },
features: { recording: false }
}
});
const c = await connect({ params: { token } });
await c.joinRoom(r);
const bare = c.jid.split('/')[0];
await granter.sendMucAdmin(r, { jid: bare,
affiliation: 'owner' });
// As an owner, the recipient can set its own affiliation.
const reply = await c.sendMucAdmin(r, { jid: bare,
affiliation: 'member' });
assert.strictEqual(reply.attrs.type, 'result');
const restored = await getSessionFeatures(c.jid);
assert.ok(restored !== null, 'the token features should come back');
assert.strictEqual(restored.recording, false);
assert.strictEqual(
await jibriStartErrorCondition(c, r),
'forbidden',
'the recipient must not keep the granted recording feature');
});
for (const target of [ 'admin', 'outcast' ]) {
it(`restores the token features when owner changes to ${target}`, async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const granter = await joinAsGranter(connect, r);
const token = mintAsapToken({
room: r.split('@')[0],
context: {
user: { id: `user-${target}` },
features: { recording: false }
}
});
const c = await connect({ params: { token } });
await c.joinRoom(r);
const bare = c.jid.split('/')[0];
await granter.sendMucAdmin(r, { jid: bare,
affiliation: 'owner' });
assert.strictEqual((await getSessionFeatures(c.jid)).recording, true);
const reply = await granter.sendMucAdmin(r, { jid: bare,
affiliation: target });
assert.strictEqual(reply.attrs.type, 'result');
const restored = await getSessionFeatures(c.jid);
assert.ok(restored !== null, 'the token features should come back');
assert.strictEqual(restored.recording, false);
});
}
it('restores the token features of a recipient that is in a breakout room', async () => {
const BREAKOUT_MUC = 'breakout.conference.localhost';
const r = nextRoom();
const name = r.split('@')[0];
const focus = await joinWithFocus(r);
clients.push(focus);
// mod_muc_breakout_rooms finds the main room of the moderator through
// the ?room= query parameter.
const granter = await joinAsGranter(connect, r, { room: name });
const token = mintAsapToken({
room: name,
context: {
user: { id: 'user-breakout' },
features: { recording: false }
}
});
const c = await connect({ params: { token } });
await c.joinRoom(r);
const bare = c.jid.split('/')[0];
await granter.sendMucAdmin(r, { jid: bare,
affiliation: 'owner' });
assert.strictEqual((await getSessionFeatures(c.jid)).recording, true);
// Create a breakout room and move the recipient into it.
const parseJsonMessage = s => {
try {
return JSON.parse(s.getChild('json-message', PERMISSIONS_NS)?.getText());
} catch {
return null;
}
};
const update = granter.waitForMessage(s => {
const payload = parseJsonMessage(s);
return payload?.type === 'breakout_rooms'
&& payload?.event === 'features/breakout-rooms/update';
}, 6000);
await granter.sendBreakoutRoomsMessage(BREAKOUT_MUC, 'features/breakout-rooms/add', { subject: 'Group 1' });
const payload = parseJsonMessage(await update);
const breakoutJid = Object.values(payload.rooms).find(room => !room.isMainRoom)?.jid;
assert.ok(breakoutJid, 'the update must list the new breakout room');
// focus creates the breakout room, then the recipient moves into it.
await focus.joinRoom(breakoutJid, 'focus');
const breakoutPresence = await c.joinRoom(breakoutJid);
assert.notStrictEqual(breakoutPresence.attrs.type, 'error');
await c.leaveRoom(r);
// The recipient is no longer an occupant of the main room, but its
// affiliation there still decides its role in the breakout room.
const reply = await granter.sendMucAdmin(r, { jid: bare,
affiliation: 'member' });
assert.strictEqual(reply.attrs.type, 'result');
const restored = await getSessionFeatures(c.jid);
assert.ok(restored !== null, 'the token features should come back');
assert.strictEqual(restored.recording, false);
});
it('does not touch the features of a session that is not in this room', async () => {
const roomA = nextRoom();
const roomB = nextRoom();
clients.push(await joinWithFocus(roomA));
clients.push(await joinWithFocus(roomB));
const granter = await joinAsGranter(connect, roomA);
const token = mintAsapToken({
room: roomB.split('@')[0],
context: {
user: { id: 'user-other-room' },
features: { recording: false }
}
});
const other = await connect({ params: { token } });
await other.joinRoom(roomB);
const bare = other.jid.split('/')[0];
// The granter is only a moderator of room A, and the recipient is only
// ever an occupant of room B. An affiliation change on room A must not
// reach a session that shares the same bare JID in an unrelated room.
const reply = await granter.sendMucAdmin(roomA, { jid: bare,
affiliation: 'owner' });
assert.strictEqual(reply.attrs.type, 'result');
const features = await getSessionFeatures(other.jid);
assert.ok(features !== null, 'the token features must be unchanged');
assert.strictEqual(features.recording, false);
assert.strictEqual(
await jibriStartErrorCondition(other, roomB),
'forbidden',
'a cross-room grant must not authorize this session in its own room');
});
});
// ── session features via HTTP ─────────────────────────────────────────────
//
// Verify that jitsi_meet_context_features is set correctly on the Prosody
// session after joining. These assertions read the live session state (not
// the resource-bind snapshot) so they also capture side-effects from
// filter_stanza, which writes default_permissions onto the session for
// moderators who joined without token features.
describe('session features (via HTTP)', () => {
it('sets token features on session for moderator with features', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const token = mintAsapToken({
room: r.split('@')[0],
context: {
user: { moderator: true },
features: {
recording: false,
livestreaming: false
}
}
});
const c = await connect({ params: { token } });
await c.joinRoom(r);
const features = await getSessionFeatures(c.jid);
assert.strictEqual(features.recording, false);
assert.strictEqual(features.livestreaming, false);
});
it('sets token features on session for non-moderator with features', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const token = mintAsapToken({
room: r.split('@')[0],
context: {
user: { id: 'user1' },
features: {
recording: false,
transcription: true
}
}
});
const c = await connect({ params: { token } });
await c.joinRoom(r);
const features = await getSessionFeatures(c.jid);
assert.strictEqual(features.recording, false);
assert.strictEqual(features.transcription, true);
});
it('sets default permissions on session for moderator without token features', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const token = mintAsapToken({ room: r.split('@')[0],
context: { user: { moderator: true } } });
const c = await connect({ params: { token } });
await c.joinRoom(r);
const features = await getSessionFeatures(c.jid);
assert.ok(features !== null, 'features should be set by filter_stanza');
for (const key of DEFAULT_PERMISSION_KEYS) {
assert.strictEqual(features[key], true, `expected ${key}=true`);
}
});
it('sets no features on session for non-moderator without token features', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const token = mintAsapToken({ room: r.split('@')[0],
context: { user: { id: 'user1' } } });
const c = await connect({ params: { token } });
await c.joinRoom(r);
const features = await getSessionFeatures(c.jid);
assert.strictEqual(features, null);
});
});
});