const BASE = 'http://localhost:5280/test-observer'; const ACCESS_MANAGER_URL = `${BASE}/access-manager`; const END_MEETING_URL = 'http://localhost:5280/end-meeting'; const KICK_PARTICIPANT_URL = 'http://localhost:5280/kick-participant'; const SYSTEM_CHAT_URL = 'http://localhost:5280/send-system-chat-message'; const JIGASI_INVITE_URL = 'http://localhost:5280/invite-jigasi'; /** * Configures the mock access manager endpoint (served by mod_test_observer_http). * mod_muc_auth_ban calls this endpoint for VPaaS sessions * (jitsi_web_query_prefix starting with "vpaas-magic-cookie-"). * * Call this before connecting the client under test. * Call with { access: true, status: 200 } (the defaults) to reset between tests. * * @param {object} [opts] * @param {boolean} [opts.access=true] true → allow, false → ban * @param {number} [opts.status=200] HTTP status code to return. * Non-200 values simulate HTTP errors; * mod_muc_auth_ban fails open on errors. * @param {boolean} [opts.nonJson=false] When true, return a 200 with a non-JSON * body. Tests the nil-check bug: * json.decode returns nil and the module * must not crash on r['access']. */ export async function setAccessManagerResponse({ access = true, status = 200, nonJson = false } = {}) { const res = await fetch(ACCESS_MANAGER_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ access, status, // eslint-disable-next-line camelcase non_json: nonJson }) }); if (res.status !== 204) { throw new Error(`setAccessManagerResponse failed: ${res.status} ${await res.text()}`); } } /** * Returns all MUC events recorded by mod_test_observer since the last clear. * @returns {Promise>} */ export async function getEvents() { const res = await fetch(`${BASE}/events`); if (!res.ok) { throw new Error(`GET /events failed: ${res.status}`); } return res.json(); } /** * Clears the recorded event list. */ export async function clearEvents() { const res = await fetch(`${BASE}/events`, { method: 'DELETE' }); if (res.status !== 204) { throw new Error(`DELETE /events failed: ${res.status}`); } } /** * Sets room._data.hideDisplayNameForGuests for mod_muc_displayname tests. * The room must already exist (at least one occupant). * * @param {string} roomJid e.g. 'room@conference.localhost' * @param {boolean} hidden true to enable filtering; false to disable */ export async function setHideDisplayNameForGuests(roomJid, hidden) { const res = await fetch(`${BASE}/rooms/hide-display-name`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jid: roomJid, hidden }) }); if (!res.ok) { throw new Error(`setHideDisplayNameForGuests failed: ${res.status} ${await res.text()}`); } } /** * Sets room._data.breakout_rooms_active for mod_muc_cleanup_backend_services tests. * When active is true, the module skips the destroy-timer logic even if only * backend services remain. The room must already exist (at least one occupant). * * @param {string} roomJid e.g. 'room@conference.localhost' * @param {boolean} active true to simulate active breakout rooms; false to clear */ export async function setBreakoutRoomsActive(roomJid, active) { const res = await fetch(`${BASE}/rooms/breakout-rooms-active`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jid: roomJid, active }) }); if (!res.ok) { throw new Error(`setBreakoutRoomsActive failed: ${res.status} ${await res.text()}`); } } /** * Sets the per-room max_occupants limit for an existing room. * The room must already exist (at least one occupant). * This overrides the global muc_max_occupants for this room only. * * @param {string} roomJid e.g. 'room@conference.localhost' * @param {number} max new occupant limit */ export async function setRoomMaxOccupants(roomJid, max) { const res = await fetch(`${BASE}/rooms/max-occupants`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jid: roomJid, // eslint-disable-next-line camelcase max_occupants: max }) }); if (!res.ok) { throw new Error(`setRoomMaxOccupants failed: ${res.status} ${await res.text()}`); } } /** * Returns the live jitsi_meet_context_features for a session. * Unlike session-info (which snapshots at resource-bind), this reads the current * value so it reflects any updates made after join (e.g. by mod_jitsi_permissions). * * @param {string} fullJid e.g. 'abc123@localhost/res1' * @returns {Promise} feature map, or null when none are set */ export async function getSessionFeatures(fullJid) { const res = await fetch(`${BASE}/sessions/features?jid=${encodeURIComponent(fullJid)}`); if (!res.ok) { throw new Error(`getSessionFeatures failed: ${res.status} ${await res.text()}`); } return (await res.json()).features; } /** * Sets jitsi_meet_context_user and jitsi_meet_context_features on an active c2s * session identified by full JID. Allows tests to simulate JWT token context * without running a real token-auth module. * * @param {string} fullJid e.g. 'abc123@localhost/res1' * @param {string} userId value for jitsi_meet_context_user.id * @param {object} features key/value feature flags, e.g. { flip: true } */ export async function setSessionContext(fullJid, userId, features = {}) { const res = await fetch(`${BASE}/sessions/context`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jid: fullJid, // eslint-disable-next-line camelcase user_id: userId, features }) }); if (!res.ok) { throw new Error(`setSessionContext failed: ${res.status} ${await res.text()}`); } } /** * Returns mod_muc_flip's per-room participant tracking state. * * @param {string} roomJid e.g. 'room@conference.localhost' * @returns {Promise<{participants_details: object, kicked_participant_nick?: string, flip_participant_nick?: string}>} */ export async function getRoomParticipants(roomJid) { const res = await fetch(`${BASE}/rooms/participants?jid=${encodeURIComponent(roomJid)}`); if (res.status === 404) { return null; } if (!res.ok) { throw new Error(`getRoomParticipants failed: ${res.status}`); } return res.json(); } /** * Enables or disables the lobby for the given room. * * @param {string} roomJid e.g. 'room@conference.localhost' * @param {boolean} enabled * @returns {Promise<{ok: boolean, lobbyroom: string|null}>} */ async function setLobby(roomJid, enabled) { const res = await fetch(`${BASE}/rooms/lobby`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jid: roomJid, enabled }) }); if (!res.ok) { throw new Error(`setLobby(${enabled}) failed: ${res.status} ${await res.text()}`); } return res.json(); } /** * Enables the lobby for the given room. * Returns the lobby room JID. * * @param {string} roomJid e.g. 'room@conference.localhost' * @returns {Promise} lobby room JID */ export async function enableLobby(roomJid) { const data = await setLobby(roomJid, true); return data.lobbyroom; } /** * Disables the lobby for the given room and unsets members-only. * * @param {string} roomJid e.g. 'room@conference.localhost' */ export async function disableLobby(roomJid) { await setLobby(roomJid, false); } /** * Sets the affiliation of a JID in a room. * * @param {string} roomJid e.g. 'room@conference.localhost' * @param {string} occupantJid bare JID of the user, e.g. 'user@localhost' * @param {string} affiliation e.g. 'member', 'owner', 'none' */ export async function setAffiliation(roomJid, occupantJid, affiliation) { const res = await fetch(`${BASE}/rooms/affiliation`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jid: roomJid, // eslint-disable-next-line camelcase occupant_jid: occupantJid, affiliation }) }); if (!res.ok) { throw new Error(`setAffiliation failed: ${res.status} ${await res.text()}`); } } /** * Returns the jitsiMetadata table for a room as seen by Prosody. * Useful for asserting server-side state after metadata updates. * * Returns null if the room does not exist. * Throws if the server cannot encode the metadata (indicates corrupt state). * * @param {string} roomJid e.g. 'room@conference.localhost' * @returns {Promise<{jid: string, metadata: object}|null>} */ export async function getRoomMetadata(roomJid) { const res = await fetch(`${BASE}/rooms/metadata?jid=${encodeURIComponent(roomJid)}`); if (res.status === 404) { return null; } if (!res.ok) { throw new Error(`GET /rooms/metadata failed: ${res.status} ${await res.text()}`); } return res.json(); } /** * Returns the aggregated live-translation request map stored on room._data by * mod_audio_translation_component. Reads server state directly, so it is robust * against broadcast de-duplication (no broadcast fires when the aggregate is * unchanged). * * Returns null if the room does not exist; the map is undefined when there are * no subscriptions. * * @param {string} roomJid e.g. 'room@conference.localhost' * @returns {Promise<{jid: string, audioTranslationRequests?: object}|null>} */ export async function getAudioTranslationRequests(roomJid) { const res = await fetch( `${BASE}/rooms/audio-translation-requests?jid=${encodeURIComponent(roomJid)}`); if (res.status === 404) { return null; } if (!res.ok) { throw new Error(`GET /rooms/audio-translation-requests failed: ${res.status} ${await res.text()}`); } return res.json(); } /** * Returns every OTLP ExportTraceServiceRequest body received by the mock * receiver (mod_test_observer_http) so far. Used by mod_trace tests. * @returns {Promise>} */ export async function getOtlpTraces() { const res = await fetch(`${BASE}/otlp-traces`); if (!res.ok) { throw new Error(`GET /otlp-traces failed: ${res.status}`); } return res.json(); } /** * Clears the recorded OTLP export list. */ export async function clearOtlpTraces() { const res = await fetch(`${BASE}/otlp-traces`, { method: 'DELETE' }); if (res.status !== 204) { throw new Error(`DELETE /otlp-traces failed: ${res.status}`); } } /** * Returns room state from Prosody's internal MUC state. * @param {string} roomJid e.g. 'room@conference.localhost' * @returns {Promise<{jid: string, hidden: boolean, occupant_count: number}|null>} */ export async function getRoomState(roomJid) { const res = await fetch(`${BASE}/rooms?jid=${encodeURIComponent(roomJid)}`); if (res.status === 404) { return null; } if (!res.ok) { throw new Error(`GET /rooms failed: ${res.status}`); } return res.json(); } /** * Calls the mod_muc_kick_participant HTTP endpoint to kick a participant. * * Returns raw { status, body } so tests can assert on error responses. * * @param {string} roomJid e.g. 'room@conference.localhost' * @param {string} participantId Occupant nick / resource to kick. * @param {string} token Bearer token. * @param {object} [opts] * @param {boolean} [opts.omitAuth] If true, omits the Authorization header. * @returns {Promise<{status: number, body: string}>} */ export async function kickParticipant(roomJid, participantId, token, { omitAuth = false } = {}) { const roomName = roomJid.split('@')[0]; const url = new URL(KICK_PARTICIPANT_URL); url.searchParams.set('room', roomName); const headers = { 'Content-Type': 'application/json' }; if (!omitAuth) { headers.Authorization = `Bearer ${token}`; } const res = await fetch(url.toString(), { method: 'PUT', headers, body: JSON.stringify({ participantId }) }); return { status: res.status, body: await res.text() }; } /** * Calls the mod_muc_jigasi_invite HTTP endpoint to invite Jigasi to dial out. * * Returns raw { status, body } so tests can assert on error responses. * * @param {string} roomJid Conference room JID, e.g. 'room@conference.localhost' * @param {string} phoneNo Dial target, e.g. '+15551234567' * @param {string} token Bearer token. * @param {object} [opts] * @param {boolean} [opts.omitAuth] If true, omits the Authorization header. * @returns {Promise<{status: number, body: string}>} */ export async function inviteJigasi(roomJid, phoneNo, token, { omitAuth = false } = {}) { const headers = { 'Content-Type': 'application/json' }; if (!omitAuth) { headers.Authorization = `Bearer ${token}`; } const res = await fetch(JIGASI_INVITE_URL, { method: 'POST', headers, body: JSON.stringify({ conference: roomJid, phoneNo }) }); return { status: res.status, body: await res.text() }; } /** * Calls the mod_system_chat_message HTTP endpoint to send a system chat message * to one or more participants. * * Returns raw { status, body } so tests can assert on error responses. * * @param {string} roomJid e.g. 'room@conference.localhost' * @param {string[]} connectionJIDs Full JIDs of recipients, e.g. ['user@localhost/res'] * @param {string} message Message text. * @param {string} token Bearer token. * @param {object} [opts] * @param {boolean} [opts.omitAuth] If true, omits the Authorization header. * @param {string} [opts.displayName] Optional display name to include. * @returns {Promise<{status: number, body: string}>} */ export async function sendSystemChatMessage(roomJid, connectionJIDs, message, token, { // eslint-disable-line max-params omitAuth = false, displayName } = {}) { const headers = { 'Content-Type': 'application/json' }; if (!omitAuth) { headers.Authorization = `Bearer ${token}`; } const body = { room: roomJid, connectionJIDs, message }; if (displayName !== undefined) { body.displayName = displayName; } const res = await fetch(SYSTEM_CHAT_URL, { method: 'POST', headers, body: JSON.stringify(body) }); return { status: res.status, body: await res.text() }; } /** * Calls the mod_muc_end_meeting HTTP endpoint to terminate a conference. * * Returns the raw { status, body } rather than throwing on non-2xx so that * tests can assert on error responses (401, 404, etc.) directly. * * @param {string} roomJid e.g. 'room@conference.localhost' * @param {string} token Bearer token (system token for success; login token to test rejection). * @param {object} [opts] * @param {boolean} [opts.silentReconnect] If true, adds silent-reconnect=true to the query. * @param {boolean} [opts.omitAuth] If true, omits the Authorization header entirely. * @returns {Promise<{status: number, body: string}>} */ export async function endMeeting(roomJid, token, { silentReconnect = false, omitAuth = false } = {}) { const url = new URL(END_MEETING_URL); url.searchParams.set('conference', roomJid); if (silentReconnect) { url.searchParams.set('silent-reconnect', 'true'); } const headers = { 'Content-Type': 'application/json' }; if (!omitAuth) { headers.Authorization = `Bearer ${token}`; } const res = await fetch(url.toString(), { method: 'POST', headers }); return { status: res.status, body: await res.text() }; }