841 lines
32 KiB
JavaScript
841 lines
32 KiB
JavaScript
import { xml } from '@xmpp/client';
|
|
import assert from 'assert';
|
|
|
|
import { mintAsapToken } from './helpers/jwt.js';
|
|
import { getRoomMetadata } from './helpers/test_observer.js';
|
|
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
|
|
|
|
const CONFERENCE = 'conference.localhost';
|
|
const METADATA_COMPONENT = 'metadata.localhost';
|
|
const JITMEET_NS = 'http://jitsi.org/jitmeet';
|
|
const START_MUTED_NS = 'http://jitsi.org/jitmeet/start-muted';
|
|
|
|
let _counter = 0;
|
|
const nextRoom = () => `metadata-${++_counter}@${CONFERENCE}`;
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Connects a client on the main VirtualHost. Passes ?room=<roomName> so that
|
|
* mod_jitsi_session sets jitsi_web_query_room, which the metadata component
|
|
* requires before processing any message.
|
|
*
|
|
* @param {string} roomJid full room JID, e.g. 'room@conference.localhost'
|
|
* @param {object} [contextUser] JWT context.user claims; omit for anonymous
|
|
*/
|
|
function connect(roomJid, contextUser) {
|
|
const roomName = roomJid.split('@')[0];
|
|
const params = { room: roomName };
|
|
|
|
if (contextUser !== undefined) {
|
|
params.token = mintAsapToken({ room: roomName,
|
|
context: { user: contextUser } });
|
|
}
|
|
|
|
return createXmppClient({ params });
|
|
}
|
|
|
|
const connectModerator = roomJid => connect(roomJid, { moderator: true });
|
|
const connectMember = roomJid => connect(roomJid, { id: 'member1' });
|
|
|
|
/**
|
|
* True when msg is a metadata broadcast from the metadata component.
|
|
* getChild returns undefined (not null) when the child is absent, so check
|
|
* against undefined rather than null.
|
|
*/
|
|
function isMetadataBroadcast(msg) {
|
|
return msg.name === 'message'
|
|
&& msg.getChild('json-message', JITMEET_NS) !== undefined;
|
|
}
|
|
|
|
/** Parses the outer {type, metadata} envelope from a broadcast stanza. */
|
|
function parseBroadcast(msg) {
|
|
const text = msg.getChild('json-message', JITMEET_NS)?.getText();
|
|
|
|
return text ? JSON.parse(text) : null;
|
|
}
|
|
|
|
/**
|
|
* Waits for a metadata broadcast stanza.
|
|
*
|
|
* @param {object} client
|
|
* @param {number} [timeout=3000]
|
|
*/
|
|
function waitForMetadata(client, timeout = 3000) {
|
|
return client.waitForMessage(isMetadataBroadcast, timeout);
|
|
}
|
|
|
|
/**
|
|
* Drains the initial metadata delivery that filter_stanza sends to every
|
|
* occupant before their self-presence. Call this after joinRoom() in tests
|
|
* that need to assert on subsequent broadcasts rather than the initial push.
|
|
*
|
|
* @param {object} client
|
|
*/
|
|
async function drainInitialMetadata(client) {
|
|
await waitForMetadata(client, 2000);
|
|
}
|
|
|
|
/**
|
|
* Asserts that NO metadata broadcast arrives within the given window.
|
|
* Throws if one does arrive.
|
|
*
|
|
* @param {object} client
|
|
* @param {number} [timeout=1000]
|
|
*/
|
|
async function assertNoMetadata(client, timeout = 1000) {
|
|
try {
|
|
await client.waitForMessage(isMetadataBroadcast, timeout);
|
|
throw new Error('received unexpected metadata broadcast');
|
|
} catch (err) {
|
|
if (err.message.includes('Timeout')) {
|
|
return; // expected — no broadcast arrived
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Asserts that no <message type='error'> arrives within the given window.
|
|
* An error stanza indicates a crash or protocol failure rather than a clean
|
|
* rejection.
|
|
*
|
|
* @param {object} client
|
|
* @param {number} [timeout=1000]
|
|
*/
|
|
async function assertNoErrorStanza(client, timeout = 1000) {
|
|
try {
|
|
await client.waitForMessage(s => s.attrs.type === 'error', timeout);
|
|
throw new Error('received unexpected error stanza — module may have crashed');
|
|
} catch (err) {
|
|
if (err.message.includes('Timeout')) {
|
|
return; // expected — no error, clean handling
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
|
|
describe('mod_room_metadata_component', () => {
|
|
|
|
const clients = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(clients.map(c => c.disconnect()));
|
|
clients.length = 0;
|
|
});
|
|
|
|
// ── Authorization ─────────────────────────────────────────────────────────
|
|
|
|
describe('authorization', () => {
|
|
|
|
it('moderator can update metadata', async () => {
|
|
const r = nextRoom();
|
|
const foc = await joinWithFocus(r);
|
|
const mod = await connectModerator(r);
|
|
|
|
clients.push(foc, mod);
|
|
await mod.joinRoom(r);
|
|
await drainInitialMetadata(mod);
|
|
|
|
mod.sendMetadataUpdate(METADATA_COMPONENT, r, 'myKey', 'myValue');
|
|
|
|
const broadcast = await waitForMetadata(mod);
|
|
|
|
assert.equal(parseBroadcast(broadcast).metadata.myKey, 'myValue');
|
|
});
|
|
|
|
it('non-moderator cannot update metadata', async () => {
|
|
const r = nextRoom();
|
|
const foc = await joinWithFocus(r);
|
|
const member = await connectMember(r);
|
|
|
|
clients.push(foc, member);
|
|
await member.joinRoom(r);
|
|
await drainInitialMetadata(member);
|
|
|
|
member.sendMetadataUpdate(METADATA_COMPONENT, r, 'myKey', 'myValue');
|
|
|
|
await assertNoMetadata(member);
|
|
});
|
|
|
|
it('non-occupant (connected but not joined) cannot update metadata', async () => {
|
|
const r = nextRoom();
|
|
const foc = await joinWithFocus(r);
|
|
const nonOcc = await connectModerator(r);
|
|
|
|
clients.push(foc, nonOcc);
|
|
|
|
// deliberately do NOT call joinRoom — sender is not an occupant
|
|
nonOcc.sendMetadataUpdate(METADATA_COMPONENT, r, 'myKey', 'myValue');
|
|
|
|
await assertNoMetadata(nonOcc);
|
|
});
|
|
|
|
});
|
|
|
|
// ── Blocked keys ──────────────────────────────────────────────────────────
|
|
|
|
describe('blocked keys', () => {
|
|
|
|
/**
|
|
* Asserts that a moderator cannot set the given metadata key.
|
|
* @param {string} key Metadata key to test.
|
|
*/
|
|
async function assertKeyBlocked(key) {
|
|
const r = nextRoom();
|
|
const foc = await joinWithFocus(r);
|
|
const mod = await connectModerator(r);
|
|
|
|
clients.push(foc, mod);
|
|
await mod.joinRoom(r);
|
|
await drainInitialMetadata(mod);
|
|
|
|
mod.sendMetadataUpdate(METADATA_COMPONENT, r, key, 'someValue');
|
|
await assertNoMetadata(mod);
|
|
}
|
|
|
|
for (const key of [
|
|
'allownersEnabled', 'asyncTranscription', 'conferencePresetsServiceEnabled',
|
|
'dialinEnabled', 'moderators', 'participants', 'participantsSoftLimit',
|
|
'services', 'transcriberType', 'transcription', 'visitorsEnabled'
|
|
]) {
|
|
it(`moderator cannot set blocked key "${key}"`, () => assertKeyBlocked(key));
|
|
}
|
|
|
|
it('moderator can set a non-blocked key', async () => {
|
|
const r = nextRoom();
|
|
const foc = await joinWithFocus(r);
|
|
const mod = await connectModerator(r);
|
|
|
|
clients.push(foc, mod);
|
|
await mod.joinRoom(r);
|
|
await drainInitialMetadata(mod);
|
|
|
|
mod.sendMetadataUpdate(METADATA_COMPONENT, r, 'permissions', { groupChatRestricted: true });
|
|
|
|
const broadcast = await waitForMetadata(mod);
|
|
|
|
assert.deepEqual(parseBroadcast(broadcast).metadata.permissions,
|
|
{ groupChatRestricted: true });
|
|
});
|
|
|
|
});
|
|
|
|
// ── Value deduplication ───────────────────────────────────────────────────
|
|
|
|
describe('value deduplication', () => {
|
|
|
|
it('does not broadcast when value is unchanged', async () => {
|
|
const r = nextRoom();
|
|
const foc = await joinWithFocus(r);
|
|
const mod = await connectModerator(r);
|
|
|
|
clients.push(foc, mod);
|
|
await mod.joinRoom(r);
|
|
await drainInitialMetadata(mod);
|
|
|
|
// First update establishes the value.
|
|
mod.sendMetadataUpdate(METADATA_COMPONENT, r, 'dedupKey', 42);
|
|
await waitForMetadata(mod);
|
|
|
|
// Second update with the same value must not trigger a broadcast.
|
|
mod.sendMetadataUpdate(METADATA_COMPONENT, r, 'dedupKey', 42);
|
|
await assertNoMetadata(mod);
|
|
});
|
|
|
|
it('broadcasts when value changes', async () => {
|
|
const r = nextRoom();
|
|
const foc = await joinWithFocus(r);
|
|
const mod = await connectModerator(r);
|
|
|
|
clients.push(foc, mod);
|
|
await mod.joinRoom(r);
|
|
await drainInitialMetadata(mod);
|
|
|
|
mod.sendMetadataUpdate(METADATA_COMPONENT, r, 'changing', 'first');
|
|
await waitForMetadata(mod);
|
|
|
|
mod.sendMetadataUpdate(METADATA_COMPONENT, r, 'changing', 'second');
|
|
const broadcast = await waitForMetadata(mod);
|
|
|
|
assert.equal(parseBroadcast(broadcast).metadata.changing, 'second');
|
|
});
|
|
|
|
});
|
|
|
|
// ── Broadcast delivery ────────────────────────────────────────────────────
|
|
|
|
describe('broadcast delivery', () => {
|
|
|
|
it('all occupants receive the broadcast', async () => {
|
|
const r = nextRoom();
|
|
const foc = await joinWithFocus(r);
|
|
const mod = await connectModerator(r);
|
|
const member = await connectMember(r);
|
|
|
|
clients.push(foc, mod, member);
|
|
await mod.joinRoom(r);
|
|
await member.joinRoom(r);
|
|
await drainInitialMetadata(mod);
|
|
await drainInitialMetadata(member);
|
|
|
|
mod.sendMetadataUpdate(METADATA_COMPONENT, r, 'broadcastKey', 'hello');
|
|
|
|
const [ modBroadcast, memberBroadcast ] = await Promise.all([
|
|
waitForMetadata(mod),
|
|
waitForMetadata(member)
|
|
]);
|
|
|
|
assert.equal(parseBroadcast(modBroadcast).metadata.broadcastKey, 'hello');
|
|
assert.equal(parseBroadcast(memberBroadcast).metadata.broadcastKey, 'hello');
|
|
});
|
|
|
|
it('broadcast preserves previously set keys', async () => {
|
|
const r = nextRoom();
|
|
const foc = await joinWithFocus(r);
|
|
const mod = await connectModerator(r);
|
|
|
|
clients.push(foc, mod);
|
|
await mod.joinRoom(r);
|
|
await drainInitialMetadata(mod);
|
|
|
|
mod.sendMetadataUpdate(METADATA_COMPONENT, r, 'key1', 'val1');
|
|
await waitForMetadata(mod);
|
|
|
|
mod.sendMetadataUpdate(METADATA_COMPONENT, r, 'key2', 'val2');
|
|
const broadcast = await waitForMetadata(mod);
|
|
const { metadata } = parseBroadcast(broadcast);
|
|
|
|
assert.equal(metadata.key1, 'val1', 'previous key must be preserved');
|
|
assert.equal(metadata.key2, 'val2', 'new key must be present');
|
|
});
|
|
|
|
});
|
|
|
|
// ── Initial metadata delivery ─────────────────────────────────────────────
|
|
|
|
describe('initial metadata delivery', () => {
|
|
|
|
it('joining client receives current metadata', async () => {
|
|
const r = nextRoom();
|
|
const foc = await joinWithFocus(r);
|
|
const mod = await connectModerator(r);
|
|
|
|
clients.push(foc, mod);
|
|
await mod.joinRoom(r);
|
|
await drainInitialMetadata(mod);
|
|
|
|
// Establish metadata before the second client joins.
|
|
mod.sendMetadataUpdate(METADATA_COMPONENT, r, 'preExisting', 'yes');
|
|
await waitForMetadata(mod);
|
|
|
|
// Joining client should receive the current metadata automatically.
|
|
const late = await connectMember(r);
|
|
|
|
clients.push(late);
|
|
await late.joinRoom(r);
|
|
|
|
// The initial delivery arrives before the self-presence so it is
|
|
// already in the stanza queue when joinRoom resolves.
|
|
const broadcast = await waitForMetadata(late, 2000);
|
|
|
|
assert.equal(parseBroadcast(broadcast).metadata.preExisting, 'yes');
|
|
});
|
|
|
|
it('rejoining client receives fresh metadata', async () => {
|
|
const r = nextRoom();
|
|
const foc = await joinWithFocus(r);
|
|
const mod = await connectModerator(r);
|
|
|
|
clients.push(foc, mod);
|
|
await mod.joinRoom(r);
|
|
await drainInitialMetadata(mod);
|
|
|
|
mod.sendMetadataUpdate(METADATA_COMPONENT, r, 'freshKey', 'freshVal');
|
|
await waitForMetadata(mod);
|
|
|
|
// Connect a new client (different JID), join, then leave.
|
|
const first = await connectMember(r);
|
|
|
|
clients.push(first);
|
|
await first.joinRoom(r);
|
|
await drainInitialMetadata(first);
|
|
await first.disconnect();
|
|
|
|
// Second distinct client simulates a rejoin from a different session.
|
|
const rejoin = await connectMember(r);
|
|
|
|
clients.push(rejoin);
|
|
await rejoin.joinRoom(r);
|
|
|
|
const broadcast = await waitForMetadata(rejoin, 2000);
|
|
|
|
assert.equal(parseBroadcast(broadcast).metadata.freshKey, 'freshVal');
|
|
});
|
|
|
|
});
|
|
|
|
// ── startMuted presence shim ──────────────────────────────────────────────
|
|
|
|
describe('startMuted presence shim', () => {
|
|
|
|
it('moderator startmuted presence updates metadata and broadcasts', async () => {
|
|
const r = nextRoom();
|
|
const foc = await joinWithFocus(r);
|
|
const mod = await connectModerator(r);
|
|
|
|
clients.push(foc, mod);
|
|
await mod.joinRoom(r);
|
|
await drainInitialMetadata(mod);
|
|
|
|
mod.sendPresence(`${r}/${mod.nick}`, [
|
|
xml('startmuted', { xmlns: START_MUTED_NS,
|
|
audio: 'true',
|
|
video: 'false' })
|
|
]);
|
|
|
|
const broadcast = await waitForMetadata(mod);
|
|
const { startMuted } = parseBroadcast(broadcast).metadata;
|
|
|
|
assert.equal(startMuted.audio, true);
|
|
assert.equal(startMuted.video, false);
|
|
});
|
|
|
|
it('identical startmuted values do not trigger a second broadcast', async () => {
|
|
const r = nextRoom();
|
|
const foc = await joinWithFocus(r);
|
|
const mod = await connectModerator(r);
|
|
|
|
clients.push(foc, mod);
|
|
await mod.joinRoom(r);
|
|
await drainInitialMetadata(mod);
|
|
|
|
const startMutedEl = xml('startmuted', { xmlns: START_MUTED_NS,
|
|
audio: 'true',
|
|
video: 'true' });
|
|
|
|
mod.sendPresence(`${r}/${mod.nick}`, [ startMutedEl ]);
|
|
await waitForMetadata(mod);
|
|
|
|
// Same values — no second broadcast.
|
|
mod.sendPresence(`${r}/${mod.nick}`, [ startMutedEl ]);
|
|
await assertNoMetadata(mod);
|
|
});
|
|
|
|
it('non-moderator startmuted presence is ignored', async () => {
|
|
const r = nextRoom();
|
|
const foc = await joinWithFocus(r);
|
|
const member = await connectMember(r);
|
|
|
|
clients.push(foc, member);
|
|
await member.joinRoom(r);
|
|
await drainInitialMetadata(member);
|
|
|
|
member.sendPresence(`${r}/${member.nick}`, [
|
|
xml('startmuted', { xmlns: START_MUTED_NS,
|
|
audio: 'true',
|
|
video: 'true' })
|
|
]);
|
|
|
|
await assertNoMetadata(member);
|
|
});
|
|
|
|
});
|
|
|
|
// ── allow-moderation hook (mod_filter_iq_rayo interop) ───────────────────
|
|
//
|
|
// mod_filter_iq_rayo hooks 'jitsi-metadata-allow-moderation' on the main
|
|
// VirtualHost and controls access to the 'recording' metadata key:
|
|
//
|
|
// • has features + transcription=true → allow; sanitize to only
|
|
// isTranscribingEnabled / isRecordingRequested
|
|
// • no features + is moderator → allow; full data passed through
|
|
// • has features + transcription=false → deny (return false), even for moderators
|
|
// • no features + not moderator → deny (hook returns nil → default moderator check)
|
|
//
|
|
// For any other key the hook returns nil, falling through to the default
|
|
// moderator-only access check.
|
|
|
|
describe('allow-moderation hook (mod_filter_iq_rayo interop)', () => {
|
|
|
|
/**
|
|
* Non-moderator client whose JWT carries the given context features.
|
|
* No user.moderator flag — mod_token_affiliation will not grant owner/moderator role.
|
|
*
|
|
* @param {string} roomJid
|
|
* @param {object} features e.g. { transcription: true }
|
|
*/
|
|
function connectWithFeatures(roomJid, features) {
|
|
const roomName = roomJid.split('@')[0];
|
|
|
|
return createXmppClient({
|
|
params: {
|
|
room: roomName,
|
|
token: mintAsapToken({ room: roomName,
|
|
context: { features } })
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Moderator client (user.moderator=true) whose JWT also carries context features.
|
|
* mod_token_affiliation grants owner/moderator role in the MUC, but the hook
|
|
* checks features first, so the moderator role cannot override a feature-level deny.
|
|
*
|
|
* @param {string} roomJid
|
|
* @param {object} features e.g. { transcription: false }
|
|
*/
|
|
function connectModeratorWithFeatures(roomJid, features) {
|
|
const roomName = roomJid.split('@')[0];
|
|
|
|
return createXmppClient({
|
|
params: {
|
|
room: roomName,
|
|
token: mintAsapToken({ room: roomName,
|
|
context: { user: { moderator: true },
|
|
features } })
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Allow cases ───────────────────────────────────────────────────────
|
|
|
|
it('non-moderator with transcription feature can set recording.isTranscribingEnabled',
|
|
async () => {
|
|
const r = nextRoom();
|
|
const foc = await joinWithFocus(r);
|
|
const c = await connectWithFeatures(r, { transcription: true });
|
|
|
|
clients.push(foc, c);
|
|
await c.joinRoom(r);
|
|
await drainInitialMetadata(c);
|
|
|
|
c.sendMetadataUpdate(METADATA_COMPONENT, r, 'recording', { isTranscribingEnabled: true });
|
|
|
|
const broadcast = await waitForMetadata(c);
|
|
|
|
assert.deepEqual(parseBroadcast(broadcast).metadata.recording, { isTranscribingEnabled: true });
|
|
});
|
|
|
|
it('non-moderator with transcription feature can set recording.isRecordingRequested',
|
|
async () => {
|
|
const r = nextRoom();
|
|
const foc = await joinWithFocus(r);
|
|
const c = await connectWithFeatures(r, { transcription: true });
|
|
|
|
clients.push(foc, c);
|
|
await c.joinRoom(r);
|
|
await drainInitialMetadata(c);
|
|
|
|
c.sendMetadataUpdate(METADATA_COMPONENT, r, 'recording', { isRecordingRequested: true });
|
|
|
|
const broadcast = await waitForMetadata(c);
|
|
|
|
assert.deepEqual(parseBroadcast(broadcast).metadata.recording, { isRecordingRequested: true });
|
|
});
|
|
|
|
it('non-moderator with transcription feature can set both recording fields simultaneously',
|
|
async () => {
|
|
const r = nextRoom();
|
|
const foc = await joinWithFocus(r);
|
|
const c = await connectWithFeatures(r, { transcription: true });
|
|
|
|
clients.push(foc, c);
|
|
await c.joinRoom(r);
|
|
await drainInitialMetadata(c);
|
|
|
|
c.sendMetadataUpdate(METADATA_COMPONENT, r, 'recording',
|
|
{ isTranscribingEnabled: true,
|
|
isRecordingRequested: true });
|
|
|
|
const broadcast = await waitForMetadata(c);
|
|
|
|
assert.deepEqual(parseBroadcast(broadcast).metadata.recording,
|
|
{ isTranscribingEnabled: true,
|
|
isRecordingRequested: true });
|
|
});
|
|
|
|
it('moderator without features can set recording.isTranscribingEnabled', async () => {
|
|
// No context.features in JWT → session.jitsi_meet_context_features is nil.
|
|
// Hook falls to: not features AND is moderator → allow full data.
|
|
const r = nextRoom();
|
|
const foc = await joinWithFocus(r);
|
|
const mod = await connectModerator(r);
|
|
|
|
clients.push(foc, mod);
|
|
await mod.joinRoom(r);
|
|
await drainInitialMetadata(mod);
|
|
|
|
mod.sendMetadataUpdate(METADATA_COMPONENT, r, 'recording', { isTranscribingEnabled: true });
|
|
|
|
const broadcast = await waitForMetadata(mod);
|
|
|
|
assert.deepEqual(parseBroadcast(broadcast).metadata.recording, { isTranscribingEnabled: true });
|
|
});
|
|
|
|
// ── Deny cases ────────────────────────────────────────────────────────
|
|
|
|
it('non-moderator without features cannot set recording.isTranscribingEnabled', async () => {
|
|
// No features → hook falls through to default moderator check → non-moderator denied.
|
|
const r = nextRoom();
|
|
const foc = await joinWithFocus(r);
|
|
const member = await connectMember(r);
|
|
|
|
clients.push(foc, member);
|
|
await member.joinRoom(r);
|
|
await drainInitialMetadata(member);
|
|
|
|
member.sendMetadataUpdate(METADATA_COMPONENT, r, 'recording', { isTranscribingEnabled: true });
|
|
|
|
await assertNoMetadata(member);
|
|
});
|
|
|
|
it('non-moderator with transcription=false cannot set recording.isTranscribingEnabled',
|
|
async () => {
|
|
// Features present but transcription is false → hook returns false → hard deny.
|
|
const r = nextRoom();
|
|
const foc = await joinWithFocus(r);
|
|
const c = await connectWithFeatures(r, { transcription: false });
|
|
|
|
clients.push(foc, c);
|
|
await c.joinRoom(r);
|
|
await drainInitialMetadata(c);
|
|
|
|
c.sendMetadataUpdate(METADATA_COMPONENT, r, 'recording', { isTranscribingEnabled: true });
|
|
|
|
await assertNoMetadata(c);
|
|
});
|
|
|
|
it('moderator with transcription=false is denied despite moderator role', async () => {
|
|
// Features take precedence: having features but transcription=false → hook returns
|
|
// false, which short-circuits before the moderator role check.
|
|
const r = nextRoom();
|
|
const foc = await joinWithFocus(r);
|
|
const mod = await connectModeratorWithFeatures(r, { transcription: false });
|
|
|
|
clients.push(foc, mod);
|
|
await mod.joinRoom(r);
|
|
await drainInitialMetadata(mod);
|
|
|
|
mod.sendMetadataUpdate(METADATA_COMPONENT, r, 'recording', { isTranscribingEnabled: true });
|
|
|
|
await assertNoMetadata(mod);
|
|
});
|
|
|
|
it('non-moderator with transcription feature cannot set unrelated metadata keys', async () => {
|
|
// Hook returns nil for non-recording keys; default moderator check then applies.
|
|
const r = nextRoom();
|
|
const foc = await joinWithFocus(r);
|
|
const c = await connectWithFeatures(r, { transcription: true });
|
|
|
|
clients.push(foc, c);
|
|
await c.joinRoom(r);
|
|
await drainInitialMetadata(c);
|
|
|
|
c.sendMetadataUpdate(METADATA_COMPONENT, r, 'someOtherKey', 'value');
|
|
|
|
await assertNoMetadata(c);
|
|
});
|
|
|
|
// ── Sanitization ──────────────────────────────────────────────────────
|
|
|
|
it('extra fields in recording payload are stripped when set via transcription feature',
|
|
async () => {
|
|
// The hook builds the stored value as { isTranscribingEnabled, isRecordingRequested }
|
|
// only; any other fields in the client payload are discarded.
|
|
const r = nextRoom();
|
|
const foc = await joinWithFocus(r);
|
|
const c = await connectWithFeatures(r, { transcription: true });
|
|
|
|
clients.push(foc, c);
|
|
await c.joinRoom(r);
|
|
await drainInitialMetadata(c);
|
|
|
|
c.sendMetadataUpdate(METADATA_COMPONENT, r, 'recording', {
|
|
isTranscribingEnabled: true,
|
|
isRecordingRequested: false,
|
|
evil: 'payload'
|
|
});
|
|
|
|
const broadcast = await waitForMetadata(c);
|
|
const { recording } = parseBroadcast(broadcast).metadata;
|
|
|
|
assert.strictEqual(recording.isTranscribingEnabled, true, 'isTranscribingEnabled must be stored');
|
|
assert.strictEqual(recording.isRecordingRequested, false, 'isRecordingRequested must be stored');
|
|
assert.strictEqual(recording.evil, undefined, 'extra fields must be stripped');
|
|
});
|
|
|
|
// ── Edge case: recording with only unrecognized fields ─────────────────
|
|
|
|
it('recording with only unrecognized fields falls through to the default moderator check',
|
|
async () => {
|
|
// When data has neither isTranscribingEnabled nor isRecordingRequested the
|
|
// outer `if` in the hook is false → returns nil → default moderator-only check.
|
|
const r = nextRoom();
|
|
const foc = await joinWithFocus(r);
|
|
const mod = await connectModerator(r);
|
|
const member = await connectMember(r);
|
|
|
|
clients.push(foc, mod, member);
|
|
await mod.joinRoom(r);
|
|
await member.joinRoom(r);
|
|
await drainInitialMetadata(mod);
|
|
await drainInitialMetadata(member);
|
|
|
|
// Non-moderator must be denied.
|
|
member.sendMetadataUpdate(METADATA_COMPONENT, r, 'recording', { someUnrelatedField: 'x' });
|
|
await assertNoMetadata(member);
|
|
|
|
// Moderator must be allowed.
|
|
mod.sendMetadataUpdate(METADATA_COMPONENT, r, 'recording', { someUnrelatedField: 'y' });
|
|
const broadcast = await waitForMetadata(mod);
|
|
|
|
assert.strictEqual(parseBroadcast(broadcast).metadata.recording.someUnrelatedField, 'y');
|
|
});
|
|
|
|
});
|
|
|
|
// ── Invalid payloads ─────────────────────────────────────────────────────
|
|
|
|
describe('invalid payloads', () => {
|
|
|
|
it('message with no <room_metadata> child is ignored cleanly', async () => {
|
|
const r = nextRoom();
|
|
const foc = await joinWithFocus(r);
|
|
const mod = await connectModerator(r);
|
|
|
|
clients.push(foc, mod);
|
|
await mod.joinRoom(r);
|
|
await drainInitialMetadata(mod);
|
|
|
|
mod.sendPlainMessage(METADATA_COMPONENT);
|
|
|
|
// Must not receive an error stanza (indicates a crash, not a clean skip).
|
|
await assertNoErrorStanza(mod);
|
|
|
|
// Module must still be functional after the bad message.
|
|
mod.sendMetadataUpdate(METADATA_COMPONENT, r, 'afterNoChild', 'ok');
|
|
const broadcast = await waitForMetadata(mod);
|
|
|
|
assert.equal(parseBroadcast(broadcast).metadata.afterNoChild, 'ok');
|
|
});
|
|
|
|
it('message with missing room attribute is ignored', async () => {
|
|
const r = nextRoom();
|
|
const foc = await joinWithFocus(r);
|
|
const mod = await connectModerator(r);
|
|
|
|
clients.push(foc, mod);
|
|
await mod.joinRoom(r);
|
|
await drainInitialMetadata(mod);
|
|
|
|
// Omit the room attribute — without a nil-guard this crashes
|
|
// room_jid_match_rewrite with a nil argument.
|
|
mod.sendMetadataMessage(METADATA_COMPONENT, null,
|
|
JSON.stringify({ key: 'k',
|
|
data: 'v' }));
|
|
await assertNoMetadata(mod);
|
|
await assertNoErrorStanza(mod);
|
|
});
|
|
|
|
it('message with empty <room_metadata> body is ignored', async () => {
|
|
const r = nextRoom();
|
|
const foc = await joinWithFocus(r);
|
|
const mod = await connectModerator(r);
|
|
|
|
clients.push(foc, mod);
|
|
await mod.joinRoom(r);
|
|
await drainInitialMetadata(mod);
|
|
|
|
mod.sendMetadataMessage(METADATA_COMPONENT, r, '');
|
|
await assertNoMetadata(mod);
|
|
await assertNoErrorStanza(mod);
|
|
});
|
|
|
|
it('message with invalid JSON is ignored', async () => {
|
|
const r = nextRoom();
|
|
const foc = await joinWithFocus(r);
|
|
const mod = await connectModerator(r);
|
|
|
|
clients.push(foc, mod);
|
|
await mod.joinRoom(r);
|
|
await drainInitialMetadata(mod);
|
|
|
|
mod.sendMetadataMessage(METADATA_COMPONENT, r, '{not valid json{{');
|
|
await assertNoMetadata(mod);
|
|
});
|
|
|
|
it('message with missing "key" field is ignored', async () => {
|
|
const r = nextRoom();
|
|
const foc = await joinWithFocus(r);
|
|
const mod = await connectModerator(r);
|
|
|
|
clients.push(foc, mod);
|
|
await mod.joinRoom(r);
|
|
await drainInitialMetadata(mod);
|
|
|
|
mod.sendMetadataMessage(METADATA_COMPONENT, r, JSON.stringify({ data: 'value' }));
|
|
await assertNoMetadata(mod);
|
|
});
|
|
|
|
it('message with missing "data" field is ignored', async () => {
|
|
const r = nextRoom();
|
|
const foc = await joinWithFocus(r);
|
|
const mod = await connectModerator(r);
|
|
|
|
clients.push(foc, mod);
|
|
await mod.joinRoom(r);
|
|
await drainInitialMetadata(mod);
|
|
|
|
mod.sendMetadataMessage(METADATA_COMPONENT, r, JSON.stringify({ key: 'someKey' }));
|
|
await assertNoMetadata(mod);
|
|
});
|
|
|
|
it('message with null "data" is ignored', async () => {
|
|
const r = nextRoom();
|
|
const foc = await joinWithFocus(r);
|
|
const mod = await connectModerator(r);
|
|
|
|
clients.push(foc, mod);
|
|
await mod.joinRoom(r);
|
|
await drainInitialMetadata(mod);
|
|
|
|
mod.sendMetadataMessage(METADATA_COMPONENT, r,
|
|
JSON.stringify({ key: 'someKey',
|
|
data: null }));
|
|
await assertNoMetadata(mod);
|
|
});
|
|
|
|
it('message with non-string key does not corrupt room state', async () => {
|
|
const r = nextRoom();
|
|
const foc = await joinWithFocus(r);
|
|
const mod = await connectModerator(r);
|
|
|
|
clients.push(foc, mod);
|
|
await mod.joinRoom(r);
|
|
await drainInitialMetadata(mod);
|
|
|
|
// A JSON array as key must be rejected; if stored as a Lua table key
|
|
// it poisons json.encode for all subsequent broadcasts in the room.
|
|
mod.sendMetadataMessage(METADATA_COMPONENT, r,
|
|
JSON.stringify({ key: [],
|
|
data: 'someValue' }));
|
|
|
|
// Server metadata table must still be encodeable (not corrupted).
|
|
const state = await getRoomMetadata(r);
|
|
|
|
assert.ok(state, 'metadata HTTP endpoint must return valid JSON');
|
|
|
|
// A valid subsequent update must still produce a broadcast.
|
|
mod.sendMetadataUpdate(METADATA_COMPONENT, r, 'afterNonStringKey', 'ok');
|
|
const broadcast = await waitForMetadata(mod);
|
|
|
|
assert.equal(parseBroadcast(broadcast).metadata.afterNonStringKey, 'ok');
|
|
});
|
|
|
|
});
|
|
|
|
});
|