snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
This commit is contained in:
25
tests/prosody/helpers/container.js
Normal file
25
tests/prosody/helpers/container.js
Normal file
@@ -0,0 +1,25 @@
|
||||
let _container;
|
||||
|
||||
/**
|
||||
* Stores the testcontainers container reference for use by test helpers.
|
||||
* Called by setup.js after the container starts.
|
||||
*
|
||||
* @param {object} c - The testcontainers container instance.
|
||||
*/
|
||||
export function setContainer(c) {
|
||||
_container = c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the running testcontainers container.
|
||||
* Throws if setup.js has not run yet.
|
||||
*
|
||||
* @returns {object} The testcontainers container instance.
|
||||
*/
|
||||
export function getContainer() {
|
||||
if (!_container) {
|
||||
throw new Error('Container not initialised — is setup.js loaded?');
|
||||
}
|
||||
|
||||
return _container;
|
||||
}
|
||||
145
tests/prosody/helpers/jwt.js
Normal file
145
tests/prosody/helpers/jwt.js
Normal file
@@ -0,0 +1,145 @@
|
||||
import fs from 'fs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
|
||||
const DEFAULT_SECRET = 'testsecret';
|
||||
const DEFAULT_APP_ID = 'jitsi';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Fixed RSA key pair for login ASAP (RS256) tokens.
|
||||
// The public key is copied into the Prosody Docker image and served via
|
||||
// mod_test_observer_http at /test-observer/asap-keys/ so that mod_auth_token
|
||||
// can fetch it when verifying RS256 login tokens.
|
||||
// The kid must be "test-asap-key"; mod_auth_token derives the fetch URL as:
|
||||
// <asap_key_server>/<sha256hex(kid)>.pem
|
||||
// which equals /test-observer/asap-keys/dc6983da...dfc.pem.
|
||||
export const ASAP_KID = 'test-asap-key';
|
||||
export const ASAP_PRIVATE_KEY = fs.readFileSync(
|
||||
path.join(__dirname, '../fixtures/test-asap-private.pem'), 'utf8');
|
||||
|
||||
// Separate RSA key pair for system ASAP tokens used by HTTP API modules such as
|
||||
// mod_muc_end_meeting. The public key is served at /test-observer/system-asap-keys/
|
||||
// (prosody_password_public_key_repo_url) so that system modules can verify tokens
|
||||
// signed with this key. Login tokens (signed with ASAP_PRIVATE_KEY) are rejected
|
||||
// by the system key server, and system tokens are rejected by the login key server.
|
||||
export const SYSTEM_ASAP_KID = 'test-system-asap-key';
|
||||
export const SYSTEM_ASAP_PRIVATE_KEY = fs.readFileSync(
|
||||
path.join(__dirname, '../fixtures/test-system-asap-private.pem'), 'utf8');
|
||||
|
||||
/**
|
||||
* Builds a standard JWT payload with sane defaults.
|
||||
*
|
||||
* @param {object} [overrides] Fields to merge / override.
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.expired] If true, set exp to one hour in the past.
|
||||
* @param {boolean} [opts.notYetValid] If true, set nbf to one hour in the future.
|
||||
*/
|
||||
function buildPayload(overrides = {}, { expired = false, notYetValid = false } = {}) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
return {
|
||||
iss: DEFAULT_APP_ID,
|
||||
aud: DEFAULT_APP_ID,
|
||||
sub: '*',
|
||||
iat: now,
|
||||
exp: expired ? now - 3600 : now + 3600,
|
||||
...notYetValid ? { nbf: now + 3600 } : {},
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints an HS256 JWT compatible with mod_auth_token / luajwtjitsi.
|
||||
*
|
||||
* luajwtjitsi.verify() checks:
|
||||
* - header.typ === "JWT"
|
||||
* - header.alg === signatureAlgorithm (HS256 in test config for "localhost")
|
||||
* - HMAC-SHA256 signature with appSecret
|
||||
* - exp, iss, aud claims
|
||||
*
|
||||
* util.lib.lua additionally checks:
|
||||
* - requireRoomClaim (true; callers must include room claim)
|
||||
* - sets session.jitsi_meet_context_features from claims.context.features
|
||||
*
|
||||
* @param {object} [overrides] Fields to merge / override in the payload.
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.secret] Signing secret (default: testsecret).
|
||||
* @param {boolean} [opts.expired] If true, set exp to one hour in the past.
|
||||
* @returns {string} Signed JWT string.
|
||||
*/
|
||||
export function mintToken(overrides = {}, { secret = DEFAULT_SECRET, expired = false, notYetValid = false } = {}) {
|
||||
return jwt.sign(buildPayload(overrides, { expired,
|
||||
notYetValid }), secret, { algorithm: 'HS256' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints an RS256 login JWT for ASAP tests.
|
||||
*
|
||||
* The token is signed with the test login RSA private key. Prosody fetches the
|
||||
* matching public key from mod_test_observer_http's /asap-keys/ route
|
||||
* (configured via asap_key_server on VirtualHost "localhost").
|
||||
*
|
||||
* These tokens are REJECTED by mod_muc_end_meeting and other system HTTP API
|
||||
* modules, which use a separate key server (prosody_password_public_key_repo_url).
|
||||
*
|
||||
* @param {object} [overrides] Fields to merge / override in the payload.
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.privateKey] PEM private key (default: test-asap-private.pem).
|
||||
* @param {string} [opts.kid] Key ID (default: ASAP_KID).
|
||||
* @param {boolean} [opts.expired] If true, set exp to one hour in the past.
|
||||
* @returns {string} Signed JWT string.
|
||||
*/
|
||||
export function mintAsapToken(overrides = {}, {
|
||||
privateKey = ASAP_PRIVATE_KEY,
|
||||
kid = ASAP_KID,
|
||||
expired = false,
|
||||
notYetValid = false
|
||||
} = {}) {
|
||||
// sub: '*' satisfies the mandatory sub claim check in process_and_verify_token
|
||||
// and the domain verification in verify_room (wildcard allows any MUC domain).
|
||||
return jwt.sign(
|
||||
buildPayload({ sub: '*',
|
||||
...overrides }, { expired,
|
||||
notYetValid }),
|
||||
privateKey,
|
||||
{ algorithm: 'RS256',
|
||||
keyid: kid }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints an RS256 system JWT for HTTP API modules such as mod_muc_end_meeting.
|
||||
*
|
||||
* The token is signed with the test system RSA private key. Prosody fetches
|
||||
* the matching public key from mod_test_observer_http's /system-asap-keys/ route
|
||||
* (configured via prosody_password_public_key_repo_url).
|
||||
*
|
||||
* These tokens are REJECTED by mod_auth_token (login), which uses a different
|
||||
* key server (asap_key_server).
|
||||
*
|
||||
* @param {object} [overrides] Fields to merge / override in the payload.
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.privateKey] PEM private key (default: test-system-asap-private.pem).
|
||||
* @param {string} [opts.kid] Key ID (default: SYSTEM_ASAP_KID).
|
||||
* @param {boolean} [opts.expired] If true, set exp to one hour in the past.
|
||||
* @returns {string} Signed JWT string.
|
||||
*/
|
||||
export function mintSystemToken(overrides = {}, {
|
||||
privateKey = SYSTEM_ASAP_PRIVATE_KEY,
|
||||
kid = SYSTEM_ASAP_KID,
|
||||
expired = false,
|
||||
notYetValid = false
|
||||
} = {}) {
|
||||
return jwt.sign(
|
||||
buildPayload({ sub: 'system.localhost',
|
||||
room: '*',
|
||||
...overrides }, { expired,
|
||||
notYetValid }),
|
||||
privateKey,
|
||||
{ algorithm: 'RS256',
|
||||
keyid: kid }
|
||||
);
|
||||
}
|
||||
36
tests/prosody/helpers/prosody_shell.js
Normal file
36
tests/prosody/helpers/prosody_shell.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { getContainer } from './container.js';
|
||||
|
||||
/**
|
||||
* Runs a Prosody shell command inside the Docker container (Option I).
|
||||
* Uses `prosodyctl shell` via stdin so the command executes against the live instance.
|
||||
*
|
||||
* Example commands:
|
||||
* module:load("muc_hide_all", "conference.localhost")
|
||||
* module:unload("muc_hide_all", "conference.localhost")
|
||||
*
|
||||
* @param {string} command Prosody shell command string
|
||||
* @returns {Promise<string>} stdout from the shell
|
||||
*/
|
||||
export async function prosodyShell(command) {
|
||||
const container = getContainer();
|
||||
|
||||
// Escape single quotes in the command before embedding in the shell string.
|
||||
const escaped = command.replace(/'/g, '\'\\\'\'');
|
||||
|
||||
const { output, exitCode } = await container.exec([
|
||||
'su', '-s', '/bin/sh', 'prosody', '-c',
|
||||
`echo '${escaped}' | prosodyctl shell`
|
||||
]);
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`prosodyctl shell failed (exit ${exitCode}):\n${output}`);
|
||||
}
|
||||
|
||||
// prosodyctl shell exits 0 even when the Lua command throws; detect errors
|
||||
// from the output text. Prosody prefixes error lines with '! '.
|
||||
if (/^!/m.test(output)) {
|
||||
throw new Error(`prosodyctl shell command error:\n${output}`);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
101
tests/prosody/helpers/test_context.js
Normal file
101
tests/prosody/helpers/test_context.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import { createXmppClient, joinWithFocus, joinWithJibri, joinWithTranscriber } from './xmpp_client.js';
|
||||
|
||||
/**
|
||||
* Creates a per-test context that tracks connected clients and provides
|
||||
* convenience helpers for connecting as different participant types.
|
||||
* Call in beforeEach; call cleanup() in afterEach.
|
||||
*
|
||||
* @returns {{ connect: Function, connectWhitelisted: Function, connectFocus: Function, cleanup: Function }}
|
||||
*/
|
||||
export function createTestContext() {
|
||||
const clients = [];
|
||||
|
||||
return {
|
||||
/**
|
||||
* Creates a regular XMPP client and registers it for cleanup.
|
||||
*
|
||||
* @param {object} [opts] Options forwarded to createXmppClient (e.g.
|
||||
* { params: { token } } to authenticate with a JWT).
|
||||
* @returns {Promise<XmppTestClient>}
|
||||
*/
|
||||
async connect(opts) {
|
||||
const c = await createXmppClient(opts);
|
||||
|
||||
clients.push(c);
|
||||
|
||||
return c;
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a whitelisted XMPP client (domain: whitelist.localhost) and
|
||||
* registers it for cleanup. Whitelisted clients bypass the occupant limit.
|
||||
*
|
||||
* @returns {Promise<XmppTestClient>}
|
||||
*/
|
||||
async connectWhitelisted() {
|
||||
const c = await createXmppClient({ domain: 'whitelist.localhost' });
|
||||
|
||||
clients.push(c);
|
||||
|
||||
return c;
|
||||
},
|
||||
|
||||
/**
|
||||
* Joins the room as focus (jicofo), unlocking the mod_muc_meeting_id
|
||||
* jicofo lock. The focus client is whitelisted and does not count
|
||||
* against the occupant limit. Registers the client for cleanup.
|
||||
*
|
||||
* @param {string} roomJid full room JID, e.g. 'room@conference.localhost'
|
||||
* @returns {Promise<XmppTestClient>}
|
||||
*/
|
||||
async connectFocus(roomJid) {
|
||||
const c = await joinWithFocus(roomJid);
|
||||
|
||||
clients.push(c);
|
||||
|
||||
return c;
|
||||
},
|
||||
|
||||
/**
|
||||
* Joins the room as a Jibri recorder (recorder@recorder.localhost).
|
||||
* The client's bare JID starts with 'recorder@recorder.' so is_jibri()
|
||||
* returns true for this client. Registers the client for cleanup.
|
||||
*
|
||||
* @param {string} roomJid full room JID, e.g. 'room@conference.localhost'
|
||||
* @returns {Promise<XmppTestClient>}
|
||||
*/
|
||||
async connectJibri(roomJid) {
|
||||
const c = await joinWithJibri(roomJid);
|
||||
|
||||
clients.push(c);
|
||||
|
||||
return c;
|
||||
},
|
||||
|
||||
/**
|
||||
* Joins the room as a transcriber (transcriber@recorder.localhost).
|
||||
* The client's bare JID starts with 'transcriber@recorder.' so
|
||||
* is_transcriber() returns true for this client. Registers the client
|
||||
* for cleanup.
|
||||
*
|
||||
* @param {string} roomJid full room JID, e.g. 'room@conference.localhost'
|
||||
* @returns {Promise<XmppTestClient>}
|
||||
*/
|
||||
async connectTranscriber(roomJid) {
|
||||
const c = await joinWithTranscriber(roomJid);
|
||||
|
||||
clients.push(c);
|
||||
|
||||
return c;
|
||||
},
|
||||
|
||||
/**
|
||||
* Disconnects all clients created through this context.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async cleanup() {
|
||||
await Promise.all(clients.map(c => c.disconnect()));
|
||||
}
|
||||
};
|
||||
}
|
||||
496
tests/prosody/helpers/test_observer.js
Normal file
496
tests/prosody/helpers/test_observer.js
Normal file
@@ -0,0 +1,496 @@
|
||||
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<Array<{event: string, room?: string, occupant?: string, timestamp: number}>>}
|
||||
*/
|
||||
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<object|null>} 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<string>} 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<Array<object>>}
|
||||
*/
|
||||
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() };
|
||||
}
|
||||
1341
tests/prosody/helpers/xmpp_client.js
Normal file
1341
tests/prosody/helpers/xmpp_client.js
Normal file
File diff suppressed because it is too large
Load Diff
69
tests/prosody/helpers/xmpp_utils.js
Normal file
69
tests/prosody/helpers/xmpp_utils.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import { createXmppClient } from './xmpp_client.js';
|
||||
|
||||
/**
|
||||
* Connects a client to the given domain, sends a XEP-0215 extdisco IQ, and
|
||||
* asserts that the response is a valid result containing service entries that
|
||||
* all use the expected port. The connected client is pushed into `clients` for
|
||||
* afterEach cleanup.
|
||||
*
|
||||
* @param {string} domain - XMPP domain to connect to and send the IQ to.
|
||||
* @param {number} expectedPort - Port number all returned service entries must use.
|
||||
* @param {Array} clients - Cleanup array; the new client is appended.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function assertExtdiscoServices(domain, expectedPort, clients) {
|
||||
const c = await createXmppClient({ domain });
|
||||
|
||||
clients.push(c);
|
||||
|
||||
const iq = await c.sendExtdiscoIq(domain);
|
||||
|
||||
assert.equal(iq.attrs.type, 'result', `expected result IQ, got type="${iq.attrs.type}"`);
|
||||
|
||||
const services = iq.getChild('services', 'urn:xmpp:extdisco:1');
|
||||
|
||||
assert.ok(services, 'result IQ must contain <services xmlns="urn:xmpp:extdisco:1">');
|
||||
|
||||
const entries = services.getChildren('service');
|
||||
|
||||
assert.ok(entries.length > 0, 'services element must contain at least one <service>');
|
||||
|
||||
const ports = entries.map(s => s.attrs.port);
|
||||
|
||||
assert.ok(
|
||||
entries.every(s => Number(s.attrs.port) === expectedPort),
|
||||
`all entries must use port ${expectedPort}, got: ${JSON.stringify(ports)}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when a presence stanza represents a successful join.
|
||||
* Per XEP-0045 / RFC 6121, available presence has no type attribute or
|
||||
* type="available"; error presence has type="error".
|
||||
*
|
||||
* @param {object} presence - Presence stanza.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAvailablePresence(presence) {
|
||||
const t = presence.attrs.type;
|
||||
|
||||
return t === undefined || t === 'available';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the value of a named field from a disco#info IQ result stanza.
|
||||
* Returns the string value, or null if the field is absent or has no value.
|
||||
*
|
||||
* @param {object} iq - IQ stanza returned by sendDiscoInfo().
|
||||
* @param {string} varName - The field var attribute to look for.
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function discoField(iq, varName) {
|
||||
const query = iq.getChild('query', 'http://jabber.org/protocol/disco#info');
|
||||
const form = query?.getChild('x', 'jabber:x:data');
|
||||
const field = form?.getChildren('field').find(f => f.attrs.var === varName);
|
||||
|
||||
return field?.getChild('value')?.text() ?? null;
|
||||
}
|
||||
Reference in New Issue
Block a user