snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
Some checks failed
Prosody Plugin Tests / test (push) Failing after 10m11s
Close stale issues and PRs / stale (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-15 15:56:32 +05:00
commit 1d2bfcac0c
3159 changed files with 464524 additions and 0 deletions

18
tests/.eslintrc.js Normal file
View File

@@ -0,0 +1,18 @@
module.exports = {
'extends': [
'../.eslintrc.js'
],
'overrides': [
{
'files': [ '*.ts', '*.tsx' ],
extends: [ '@jitsi/eslint-config/typescript' ],
parserOptions: {
sourceType: 'module',
project: [ './tests/tsconfig.json' ]
},
rules: {
'@typescript-eslint/no-require-imports': 0
}
}
]
};

93
tests/env.example Normal file
View File

@@ -0,0 +1,93 @@
# Note: any setting below that takes a file path (EXPECTATIONS, JAAS_PRIVATE_KEY_PATH,
# JWT_PRIVATE_KEY_PATH, VIDEO_CAPTURE_FILE) is resolved relative to the current working directory
# at run time, not relative to this file. A relative path only works for whichever way you launch
# the tests: `npm test` from the repo root resolves it against the repo root, while
# `cd tests && npm install && npm run test-grid` resolves it against tests/ instead - use an
# absolute path if you want it to work the same way from both.
# Ignore certificate errors (self-signed certificates)
#ALLOW_INSECURE_CERTS=true
# The base url that will be used for the test (default will be using "https://alpha.jitsi.net")
# If there is a tenant in the URL it must end with a slash (e.g. "https://alpha.jitsi.net/sometenant/")
#BASE_URL=
# Whether to use beta for the first participants
#BROWSER_CHROME_BETA=false
#BROWSER_FF_BETA=false
# A rest URL to be used by dial-in tests to invite jigasi to the conference
#DIAL_IN_REST_URL=
# A destination number to dialout, that auto answers and sends media
#DIAL_OUT_URL=
# Path to a JSON file with test expectation overrides for the environment (see tests/helpers/expectations.ts
# for the supported options and defaults). Silently ignored (falls back to the defaults, logging an error) if
# the path doesn't resolve - see the file-path note at the top of this file.
#EXPECTATIONS=tests/expectations.json
# The grid host url (https://mygrid.com/wd/hub)
#GRID_HOST_URL=
# Whether to run the browser in headless mode
#HEADLESS=false
# The tenant used when executing the iframeAPI tests, will override any tenant from BASE_URL if any
#IFRAME_TENANT=
# Configure properties for jaas-specific tests (specs/jaas). Note that some of the iFrame tests can also
# be used to test JaaS, they are configured separately via IFRAME_TENANT, JWT_KID, JWT_PRIVATE_KEY_PATH.
# Make sure this tenant is configured to not allow guests.
# The key ID
#JAAS_KID=
# The path to the private key used for generating JWT token (.pk) for jaas-specific tests (see the
# file-path note at the top of this file)
#JAAS_PRIVATE_KEY_PATH=
# The JaaS tenant, e.g. vpaas-magic-cookie-abcdabcd1234567890
#JAAS_TENANT=
# Enable debug logging. Note this includes private information from .env.
#JITSI_DEBUG=true
# An access token to use to create meetings (used for the first participant)
#JWT_ACCESS_TOKEN=
# The kid to use in the token for non-jaas-specific tests (though it could be a jaas key).
#JWT_KID=
# The path to the private key used for generating JWT token (.pk) for non-jaas-specific tests (though it could be a
# jaas key). See the file-path note at the top of this file.
#JWT_PRIVATE_KEY_PATH=
# The count of workers that execute the tests in parallel
# MAX_INSTANCES=1
# To be able to match a domain to a specific address
# The format is "MAP example.com 1.2.3.4"
#RESOLVER_RULES=
# Fixed room name to use, overrides generated name (including prefix/suffix)
#ROOM_NAME=
# Room name prefix to use when creating new room names
#ROOM_NAME_PREFIX=
# Room name suffix to use when creating new room names
#ROOM_NAME_SUFFIX=
# A destination number to dialout, that auto answer and sends media audio and video
#SIP_JIBRI_DIAL_OUT_URL=
# The path to the browser video capture file (see the file-path note at the top of this file)
#VIDEO_CAPTURE_FILE=tests/resources/FourPeople_1280x720_30.y4m
# A shared secret to authenticate the webhook proxy connection
#WEBHOOKS_PROXY_SHARED_SECRET=
# The address of the webhooks proxy used to test the webhooks feature (e.g. wss://your.service)
#WEBHOOKS_PROXY_URL=
# A stream key abd broadcast ID that can be used by the tests to stream to YouTube
#YTUBE_TEST_STREAM_KEY=
#YTUBE_TEST_BROADCAST_ID=

5
tests/globals.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
import { IContext } from './helpers/types';
declare global {
const ctx: IContext;
}

1193
tests/helpers/Participant.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,128 @@
/**
* An interface that tests can export (as a TEST_PROPERTIES property) to define what they require.
*/
export type ITestProperties = {
/**
* A more detailed description of what the test does, to be included in the Allure report.
*/
description?: string;
/** The test requires the webhook proxy to be available. */
requireWebhookProxy: boolean;
/** Whether the test should be retried. */
retry: boolean;
/** The test requires jaas, it should be skipped when the jaas configuration is not enabled. */
useJaas: boolean;
/** The test uses the webhook proxy if available. */
useWebhookProxy: boolean;
usesBrowsers: string[];
};
const defaultProperties: ITestProperties = {
useWebhookProxy: false,
requireWebhookProxy: false,
retry: false,
useJaas: false,
usesBrowsers: [ 'p1' ]
};
/**
* Maps a test filename to its registered properties.
*/
export const testProperties: Record<string, ITestProperties> = {};
/**
* Set properties for a test file. This was needed because I couldn't find a hook that executes with describe() before
* the code in wdio.conf.ts's before() hook. The intention is for tests to execute this directly. The properties don't
* change dynamically.
*
* @param filename the absolute path to the test file
* @param properties the properties to set for the test file, defaults will be applied for missing properties
*/
export function setTestProperties(filename: string, properties: Partial<ITestProperties>): void {
if (testProperties[filename]) {
console.warn(`Test properties for ${filename} are already set. Overwriting.`);
}
testProperties[filename] = { ...defaultProperties, ...properties };
}
let testFilesLoaded = false;
/**
* Loads test files to populate the testProperties registry. This function:
* 1. Mocks test framework globals to prevent test registration
* 2. require()s each file to trigger setTestProperties calls
* 3. Restores original test framework functions
*
* @param files - Array of file names to load
*/
export function loadTestFiles(files: string[]): void {
if (testFilesLoaded) {
return;
}
// Temporarily override test functions to prevent tests registering at this stage. We only want TestProperties to be
// loaded.
const originalTestFunctions: Record<string, any> = {};
const testGlobals = [ 'describe', 'it', 'test', 'expect', 'beforeEach', 'afterEach', 'before', 'after', 'beforeAll',
'afterAll', 'suite', 'setup', 'teardown' ];
testGlobals.forEach(fn => {
originalTestFunctions[fn] = (global as any)[fn];
const stub: any = () => {
// do nothing
};
// Tolerate any expect.extend({...}) calls a spec or helper might do at module load.
if (fn === 'expect') {
stub.extend = () => { /* no-op */ };
}
(global as any)[fn] = stub;
});
try {
// Load all spec files to trigger setTestProperties calls
files.forEach(file => {
try {
require(file);
if (!testProperties[file]) {
// If no properties were set, apply defaults
setTestProperties(file, { ...defaultProperties });
}
} catch (error) {
console.warn(`Warning: Could not analyze ${file}:`, (error as Error).message);
}
});
testFilesLoaded = true;
} finally {
// Restore original functions
testGlobals.forEach(fn => {
if (originalTestFunctions[fn] !== undefined) {
(global as any)[fn] = originalTestFunctions[fn];
} else {
delete (global as any)[fn];
}
});
// Clear require cache for analyzed files so they can be loaded fresh by WebDriverIO
files.forEach(file => {
delete require.cache[file];
});
}
}
/**
* @param testFilePath - The absolute path to the test file
* @returns Promise<ITestProperties> - The test properties with defaults applied
*/
export async function getTestProperties(testFilePath: string): Promise<ITestProperties> {
const properties = testProperties[testFilePath] || { ...defaultProperties };
if (properties.requireWebhookProxy && !properties.useWebhookProxy) {
properties.useWebhookProxy = true;
}
return properties;
}

View File

@@ -0,0 +1,76 @@
/**
* Global configuration that the tests are run with. Loaded from environment variables.
*/
export const config = {
/** Enable debug logging. Note this includes private information from .env */
debug: Boolean(process.env.JITSI_DEBUG?.trim()),
expectationsFile: process.env.EXPECTATIONS?.trim(),
jaas: {
customerId: (() => {
if (typeof process.env.JAAS_TENANT !== 'undefined') {
return process.env.JAAS_TENANT?.trim()?.replace('vpaas-magic-cookie-', '');
}
return process.env.IFRAME_TENANT?.trim()?.replace('vpaas-magic-cookie-', '');
})(),
/** Whether the configuration for JaaS specific tests is enabled. */
enabled: Boolean(
(process.env.JAAS_TENANT || process.env.IFRAME_TENANT)
&& (process.env.JAAS_PRIVATE_KEY_PATH || process.env.JWT_PRIVATE_KEY_PATH)
&& (process.env.JAAS_KID || process.env.JWT_KID)),
/** The JaaS key ID, used to sign the tokens. */
kid: (() => {
if (typeof process.env.JAAS_KID !== 'undefined') {
return process.env.JAAS_KID?.trim();
}
return process.env.JWT_KID?.trim();
})(),
/** The path to the JaaS private key, used to sign JaaS tokens. */
privateKeyPath: (() => {
if (typeof process.env.JAAS_PRIVATE_KEY_PATH != 'undefined') {
return process.env.JAAS_PRIVATE_KEY_PATH?.trim();
}
return process.env.JWT_PRIVATE_KEY_PATH?.trim();
})(),
/** The JaaS tenant (vpaas-magic-cookie-<ID>) . */
tenant: (() => {
if (typeof process.env.JAAS_TENANT !== 'undefined') {
return process.env.JAAS_TENANT?.trim();
}
return process.env.IFRAME_TENANT?.trim();
})()
},
jwt: {
kid: process.env.JWT_KID?.trim(),
/** A pre-configured token used by some tests. */
preconfiguredJwt: process.env.JWT_ACCESS_TOKEN?.trim(),
preconfiguredToken: (() => {
if (process.env.JWT_ACCESS_TOKEN) {
return { jwt: process.env.JWT_ACCESS_TOKEN?.trim() };
}
return undefined;
})(),
privateKeyPath: process.env.JWT_PRIVATE_KEY_PATH?.trim()
},
roomName: {
/** Optional fixed room name that overrides the generated one entirely. */
name: process.env.ROOM_NAME?.trim(),
/** Optional prefix for room names used for tests. */
prefix: process.env.ROOM_NAME_PREFIX?.trim(),
/** Optional suffix for room names used for tests. */
suffix: process.env.ROOM_NAME_SUFFIX?.trim()
},
webhooksProxy: {
enabled: Boolean(process.env.WEBHOOKS_PROXY_URL && process.env.WEBHOOKS_PROXY_SHARED_SECRET),
sharedSecret: process.env.WEBHOOKS_PROXY_SHARED_SECRET?.trim(),
url: process.env.WEBHOOKS_PROXY_URL?.trim(),
}
};
if (config.debug) {
console.log('TestsConfig:', JSON.stringify(config, null, 2));
}

View File

@@ -0,0 +1,241 @@
import fs from 'node:fs';
import WebSocket from 'ws';
/**
* Uses the webhook proxy service to proxy events to the testing clients.
*/
export default class WebhookProxy {
private readonly url;
private readonly secret;
private logFile;
private ws: WebSocket | undefined;
private cache = new Map<string, any[]>();
private listeners = new Map();
private consumers = new Map<string, Array<{
callback: (event: any) => void;
predicate?: (event: any) => boolean;
}>>();
private _defaultMeetingSettings: object | undefined;
/**
* Initializes the webhook proxy.
* @param url
* @param secret
* @param logFile
*/
constructor(url: string, secret: string, logFile: string) {
this.url = url;
this.secret = secret;
this.logFile = logFile;
}
/**
* Connects.
*/
connect() {
this.ws = new WebSocket(this.url, {
headers: {
Authorization: this.secret
}
});
this.ws.on('error', console.error);
this.ws.on('open', () => {
console.log('WebhookProxy connected');
this.logInfo('connected');
});
this.ws.on('message', (data: any) => {
const msg = JSON.parse(data.toString());
this.logInfo(`${msg.eventType} event: ${JSON.stringify(msg)}`);
if (msg.eventType) {
let processed = false;
if (this.consumers.has(msg.eventType)) {
const list = this.consumers.get(msg.eventType)!;
const idx = list.findIndex(c => !c.predicate || c.predicate(msg));
if (idx !== -1) {
const consumer = list[idx];
list.splice(idx, 1);
if (list.length === 0) {
this.consumers.delete(msg.eventType);
}
consumer.callback(msg);
processed = true;
}
}
if (!processed) {
if (!this.cache.has(msg.eventType)) {
this.cache.set(msg.eventType, []);
}
this.cache.get(msg.eventType)!.push(msg);
}
if (this.listeners.has(msg.eventType)) {
this.listeners.get(msg.eventType)(msg);
processed = true;
}
if (!processed && msg.eventType === 'SETTINGS_PROVISIONING') {
// just in case to not be empty
let response: any = { someField: 'someValue' };
if (this._defaultMeetingSettings) {
response = this._defaultMeetingSettings;
}
this.ws?.send(JSON.stringify(response));
}
}
});
}
/**
* Adds event consumer. Consumers receive the event single time and we remove them from the list of consumers.
* @param eventType
* @param callback
* @param predicate Optional filter; only events for which predicate returns true are consumed.
*/
addConsumer(eventType: string, callback: (event: any) => void, predicate?: (event: any) => boolean) {
if (this.cache.has(eventType)) {
const list = this.cache.get(eventType)!;
const idx = list.findIndex(e => !predicate || predicate(e));
if (idx !== -1) {
const event = list[idx];
list.splice(idx, 1);
if (list.length === 0) {
this.cache.delete(eventType);
}
callback(event);
return;
}
}
if (!this.consumers.has(eventType)) {
this.consumers.set(eventType, []);
}
this.consumers.get(eventType)!.push({ callback, predicate });
}
/**
* Clear any stored event.
*/
clearCache() {
this.logInfo('cache cleared');
this.cache.clear();
}
/**
* Waits for the event to be received.
* @param eventType
* @param predicateOrTimeout Optional predicate to filter events, or a timeout in ms.
* @param timeout
*/
async waitForEvent(
eventType: string,
predicateOrTimeout?: ((event: any) => boolean) | number,
timeout = 120000): Promise<any> {
let predicate: ((event: any) => boolean) | undefined;
let actualTimeout = timeout;
if (typeof predicateOrTimeout === 'function') {
predicate = predicateOrTimeout;
} else if (typeof predicateOrTimeout === 'number') {
actualTimeout = predicateOrTimeout;
}
// we create the error here so we have a meaningful stack trace
const error = new Error(`Timeout waiting for event:${eventType}`);
return new Promise((resolve, reject) => {
const waiter = setTimeout(() => {
this.logInfo(error.message);
return reject(error);
}, actualTimeout);
this.addConsumer(eventType, event => {
clearTimeout(waiter);
resolve(event);
}, predicate);
});
}
/**
* Adds a listener for the event type.
* @param eventType
* @param callback
*/
addListener(eventType: string, callback: (data: any) => void) {
this.listeners.set(eventType, callback);
}
/**
* Adds a listener for the event type.
* @param eventType
*/
removeListener(eventType: string) {
this.listeners.delete(eventType);
}
/**
* Disconnects the webhook proxy.
*/
disconnect() {
if (this.ws) {
this.ws.close();
console.log('WebhookProxy disconnected');
this.ws = undefined;
this.logInfo('disconnected');
}
}
/**
* Logs a message in the logfile.
*
* @param {string} message - The message to add.
* @returns {void}
*/
logInfo(message: string) {
try {
// @ts-ignore
fs.appendFileSync(this.logFile, `${new Date().toISOString()} ${message}\n`);
} catch (err) {
console.error(err);
}
}
/**
* Sets the settings provider.
* @param value
*/
set defaultMeetingSettings(value: {
asyncTranscriptions?: boolean;
autoAudioRecording?: boolean;
autoTranscriptions?: boolean;
autoVideoRecording?: boolean;
lobbyEnabled?: boolean;
lobbyType?: 'WAIT_FOR_APPROVAL' | 'WAIT_FOR_MODERATOR';
maxOccupants?: number;
outboundPhoneNo?: string;
participantsSoftLimit?: number;
passcode?: string;
transcriberType?: 'GOOGLE' | 'ORACLE_CLOUD_AI_SPEECH' | 'EGHT_WHISPER';
visitorsEnabled?: boolean;
visitorsLive?: boolean;
}) {
this._defaultMeetingSettings = value;
}
}

View File

@@ -0,0 +1,83 @@
import fs from 'node:fs';
/**
* A prefix to use for all messages we add to the console log.
*/
export const LOG_PREFIX = '[MeetTest] ';
/**
* Initialize logger for a driver.
*
* @param {WebdriverIO.Browser} driver - The driver.
* @param {string} fileName - The name of the file.
* @param {string} folder - The folder to save the file.
* @returns {void}
*/
export function initLogger(driver: WebdriverIO.Browser, fileName: string, folder: string) {
// @ts-ignore
driver.logFile = `${folder}/${fileName}.log`;
driver.sessionSubscribe({ events: [ 'log.entryAdded' ] });
driver.on('log.entryAdded', (entry: any) => {
try {
// @ts-ignore
fs.appendFileSync(driver.logFile, `${entry.text}\n`);
} catch (err) {
console.error(err);
}
});
}
/**
* Returns the content of the log file.
*
* @param {WebdriverIO.Browser} driver - The driver which log file is requested.
* @returns {string} The content of the log file.
*/
export function getLogs(driver: WebdriverIO.Browser) {
// @ts-ignore
if (!driver.logFile) {
return;
}
// @ts-ignore
return fs.readFileSync(driver.logFile, 'utf8');
}
/**
* Appends value to the log file.
* @param {WebdriverIO.Browser} driver - The driver which log file is requested.
* @param {string} value - The content to add to the file.
*/
export function saveLogs(driver: WebdriverIO.Browser, value: string) {
// @ts-ignore
if (!driver.logFile) {
return;
}
try {
// @ts-ignore
fs.appendFileSync(driver.logFile, value);
} catch (err) {
console.error(err);
}
}
/**
* Logs a message in the logfile.
*
* @param {WebdriverIO.Browser} driver - The participant in which log file to write.
* @param {string} message - The message to add.
* @returns {void}
*/
export async function logInfo(driver: WebdriverIO.Browser, message: string) {
// @ts-ignore
if (!driver.logFile) {
return;
}
return driver.execute((prefix, msg) =>
console.log(`${new Date().toISOString()} ${prefix} ${msg}\n`),
LOG_PREFIX, message);
}

View File

@@ -0,0 +1,88 @@
import fs from 'fs';
import { merge } from 'lodash-es';
import { config } from './TestsConfig';
const defaultExpectations = {
dialIn: {
/*
* The dial-in functionality is enabled.
* true -> assert the config is enabled, the UI elements are displayed, and the feature works.
* false -> assert the config is disabled and the UI elements are not displayed.
* null -> if the config is enabled, assert the UI elements are displayed and the feature works.
*/
enabled: null,
minPinLength: 8,
/* Whether to verify that the "more numbers" page is displayed. Note that only the positive verification is
performed: when set to 'false' we do not assert that the "more numbers" page is not displayed. */
moreNumbersPage: true
},
iframe: {
// Whether the iframe integration is enabled (the inverse of `disableIframeAPI` from config.js)
enabled: true
},
jaas: {
fileSharingEnabled: true,
liveStreamingEnabled: true,
recordingEnabled: true,
transcriptionEnabled: true,
transcription: {
// Expect "async" transcription to be enabled.
asyncTranscription: true,
// Expect the JaaS transcription webhooks to fire in "async" mode.
asyncTranscriptionWebhook: true,
// Expect (deprecated) jigasi-based transcription to be enabled.
jigasiTranscription: true
},
/**
* Whether the jaas account is configured with the account-level setting to allow unauthenticated users to join.
*/
unauthenticatedJoins: false,
visitors: true
},
moderation: {
// Everyone is a moderator.
allModerators: false,
// When a moderator leaves, another one is elected.
autoModerator: true,
// The first to join is a moderator.
firstModerator: true,
// The grantOwner function is available.
grantModerator: true,
// Whether the ability to set a password is available (there's a backend options which makes moderators unable
// to set a room password unless they also happen to have a token (any valid token?))
setPasswordAvailable: true
},
connectivity: {
jvb: {
direct: true,
turn: true
},
p2p: {
direct: false,
turn: true
}
},
// We can create conferences under any tenant.
useTenant: true
};
let overrides: any = {};
if (config.expectationsFile) {
try {
const str = fs.readFileSync(config.expectationsFile, 'utf8');
// Remove comments and multiline comments.
overrides = JSON.parse(str.replace(/\/\/.*|\/\*[\s\S]*?\*\//g, ''));
} catch (e) {
console.error('Error reading expectations file', e);
}
console.log('Loaded expectations from', config.expectationsFile);
}
export const expectations = merge(defaultExpectations, overrides);
if (!process.env.WDIO_WORKER_ID) {
console.log('Expectations:', expectations);
}

41
tests/helpers/jaas.ts Normal file
View File

@@ -0,0 +1,41 @@
import { Participant } from './Participant';
import { config } from './TestsConfig';
import { joinMuc } from './joinMuc';
import { IToken, ITokenOptions, generateToken } from './token';
import { IParticipantJoinOptions, IParticipantOptions } from './types';
export function generateJaasToken(options: ITokenOptions): IToken {
if (!config.jaas.enabled) {
throw new Error('JaaS is not configured.');
}
// Don't override the keyId and keyPath if they are already set in options, allow tests to set them.
return generateToken({
...options,
keyId: options.keyId || config.jaas.kid,
keyPath: options.keyPath || config.jaas.privateKeyPath
});
}
/**
* Creates a new Participant and joins the MUC with the given options. The jaas-specific properties must be set as
* environment variables (see env.example and TestsConfig.ts). If no room name is specified, the default room name
* from the context is used.
*
* @param participantOptions
* @param joinOptions options to use when joining the MUC.
* @returns {Promise<Participant>} The Participant that has joined the MUC.
*/
export async function joinJaasMuc(
participantOptions?: Partial<IParticipantOptions>,
joinOptions?: Partial<IParticipantJoinOptions>): Promise<Participant> {
if (!config.jaas.enabled) {
throw new Error('JaaS is not configured.');
}
return await joinMuc(participantOptions, {
...joinOptions,
tenant: joinOptions?.tenant || config.jaas.tenant
});
}

50
tests/helpers/joinMuc.ts Normal file
View File

@@ -0,0 +1,50 @@
import { Participant } from './Participant';
import { IParticipantJoinOptions, IParticipantOptions } from './types';
/**
* Creates a new Participant and joins the MUC with the given options. If no room name is specified, the default room
* name from the context is used.
*
* @param participantOptions
* @param joinOptions options to use when joining the MUC.
* @returns {Promise<Participant>} The Participant that has joined the MUC.
*/
export async function joinMuc(
participantOptions?: Partial<IParticipantOptions>,
joinOptions?: Partial<IParticipantJoinOptions>): Promise<Participant> {
const name = participantOptions?.name || 'p1';
// @ts-ignore
const p = ctx[name] as Participant;
if (p) {
// Load a blank page to make sure the page is reloaded (in case the new participant uses the same URL). Using
// 'about:blank' was causing problems in the past, if we notice any issues we can change to "base.html".
await p.driver.url('about:blank');
}
const newParticipant = new Participant({
iFrameApi: participantOptions?.iFrameApi || false,
name,
token: participantOptions?.token
});
// @ts-ignore
ctx[name] = newParticipant;
return await newParticipant.joinConference({
...joinOptions,
roomName: joinOptions?.roomName || ctx.roomName,
});
}
/**
* Wait until all participants have ICE connected and have sent and received data (their PC stats are ready).
* @param participants
*/
export async function waitForMedia(participants: Participant[]) {
await Promise.all(participants.map(p =>
p.waitForIceConnected().then(() => p.waitForSendReceiveData())
));
}

48
tests/helpers/matchers.ts Normal file
View File

@@ -0,0 +1,48 @@
// Register custom matchers on the global expect set up by @wdio/runner during _initSession.
// Must be called from a wdio config hook that runs after _initSession (e.g. `before`); calling
// at module load extends a stale `globalThis.expect` that gets replaced before tests run.
export function registerCustomMatchers(): void {
expect.extend({
toPartiallyMatch(received: string, other: string, receivedLabel = 'received', otherLabel = 'other') {
const pass = received.includes(other) || other.includes(received);
return {
pass,
message: () => pass
? `Expected ${receivedLabel} NOT to partially match ${otherLabel}.\n ${receivedLabel}: "${received}"\n ${otherLabel}: "${other}"`
: `Expected ${receivedLabel} to partially match ${otherLabel} (one should contain the other).\n ${receivedLabel}: "${received}"\n ${otherLabel}: "${other}"`
};
},
toStartWith(received: string, prefix: string, label = 'value') {
const pass = received.startsWith(prefix);
return {
pass,
message: () => pass
? `Expected ${label} NOT to start with "${prefix}".\n ${label}: "${received}"`
: `Expected ${label} to start with "${prefix}".\n ${label}: "${received}"`
};
},
toBeInteger(received: unknown, label = 'value') {
const pass = Number.isInteger(received);
return {
pass,
message: () => pass
? `Expected ${label} NOT to be an integer.\n ${label}: ${JSON.stringify(received)}`
: `Expected ${label} to be an integer.\n ${label}: ${JSON.stringify(received)}`
};
}
});
}
declare module 'expect' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface Matchers<R> {
toBeInteger: (label?: string) => R;
toPartiallyMatch: (other: string, receivedLabel?: string, otherLabel?: string) => R;
toStartWith: (prefix: string, label?: string) => R;
}
}

View File

@@ -0,0 +1,330 @@
import { P1, P2, P3, P4, P5, P6, Participant } from './Participant';
import { config } from './TestsConfig';
import { IJoinOptions, IParticipantOptions } from './types';
const SUBJECT_XPATH = '//div[starts-with(@class, "subject-text")]';
/**
* Ensure that there is on participant.
* Ensure that the first participant is moderator if there is such an option.
*
* @param {IJoinOptions} options - The options to use when joining the participant.
* @param participantOptions
* @returns {Promise<void>}
*/
export async function ensureOneParticipant(
options?: IJoinOptions, participantOptions?: IParticipantOptions): Promise<void> {
if (!participantOptions) {
participantOptions = { name: P1 };
}
participantOptions.name = P1;
if (!participantOptions.token) {
participantOptions.token = config.jwt.preconfiguredToken;
}
// make sure the first participant is moderator, if supported by deployment
await joinParticipant(participantOptions, options);
}
/**
* Ensure that there are three participants.
*
* @param {IJoinOptions} options - The options to use when joining the participant.
* @returns {Promise<void>}
*/
export async function ensureThreeParticipants(options?: IJoinOptions): Promise<void> {
await ensureOneParticipant(options);
// these need to be all, so we get the error when one fails
await Promise.all([
joinParticipant({ name: P2 }, options),
joinParticipant({ name: P3 }, options)
]);
if (options?.skipInMeetingChecks) {
return Promise.resolve();
}
await Promise.all([
ctx.p1.waitForIceConnected(),
ctx.p2.waitForIceConnected(),
ctx.p3.waitForIceConnected()
]);
await Promise.all([
ctx.p1.waitForSendReceiveData().then(() => ctx.p1.waitForRemoteStreams(1)),
ctx.p2.waitForSendReceiveData().then(() => ctx.p2.waitForRemoteStreams(1)),
ctx.p3.waitForSendReceiveData().then(() => ctx.p3.waitForRemoteStreams(1)),
]);
}
/**
* Creates the first participant instance or prepares one for re-joining.
*
* @param {IJoinOptions} options - The options to use when joining the participant.
* @returns {Promise<void>}
*/
export function joinFirstParticipant(options: IJoinOptions = { }): Promise<void> {
return ensureOneParticipant(options);
}
/**
* Creates the second participant instance or prepares one for re-joining.
*
* @param {IJoinOptions} options - The options to use when joining the participant.
* @returns {Promise<Participant>}
*/
export function joinSecondParticipant(options?: IJoinOptions): Promise<Participant> {
return joinParticipant({ name: P2 }, options);
}
/**
* Creates the third participant instance or prepares one for re-joining.
*
* @param {IJoinOptions} options - The options to use when joining the participant.
* @returns {Promise<Participant>}
*/
export function joinThirdParticipant(options?: IJoinOptions): Promise<Participant> {
return joinParticipant({ name: P3 }, options);
}
/**
* Ensure that there are four participants.
*
* @param {IJoinOptions} options - The options to use when joining the participant.
* @returns {Promise<void>}
*/
export async function ensureFourParticipants(options?: IJoinOptions): Promise<void> {
await ensureOneParticipant(options);
// these need to be all, so we get the error when one fails
await Promise.all([
joinParticipant({ name: P2 }, options),
joinParticipant({ name: P3 }, options),
joinParticipant({ name: P4 }, options)
]);
if (options?.skipInMeetingChecks) {
return Promise.resolve();
}
await Promise.all([
ctx.p1.waitForIceConnected(),
ctx.p2.waitForIceConnected(),
ctx.p3.waitForIceConnected(),
ctx.p4.waitForIceConnected()
]);
await Promise.all([
ctx.p1.waitForSendReceiveData().then(() => ctx.p1.waitForRemoteStreams(1)),
ctx.p2.waitForSendReceiveData().then(() => ctx.p2.waitForRemoteStreams(1)),
ctx.p3.waitForSendReceiveData().then(() => ctx.p3.waitForRemoteStreams(1)),
ctx.p4.waitForSendReceiveData().then(() => ctx.p4.waitForRemoteStreams(1)),
]);
}
/**
* Ensure that there are six participants.
*
* @param {IJoinOptions} options - The options to use when joining the participant.
* @returns {Promise<void>}
*/
export async function ensureSixParticipants(options?: IJoinOptions): Promise<void> {
await ensureOneParticipant(options);
// Join participants in batches
await Promise.all([
joinParticipant({ name: P2 }, options),
joinParticipant({ name: P3 }, options),
joinParticipant({ name: P4 }, options)
]);
await Promise.all([
joinParticipant({ name: P5 }, options),
joinParticipant({ name: P6 }, options)
]);
if (options?.skipInMeetingChecks) {
return Promise.resolve();
}
await Promise.all([
ctx.p1.waitForIceConnected(),
ctx.p2.waitForIceConnected(),
ctx.p3.waitForIceConnected(),
ctx.p4.waitForIceConnected(),
ctx.p5.waitForIceConnected(),
ctx.p6.waitForIceConnected()
]);
await Promise.all([
ctx.p1.waitForSendReceiveData().then(() => ctx.p1.waitForRemoteStreams(1)),
ctx.p2.waitForSendReceiveData().then(() => ctx.p2.waitForRemoteStreams(1)),
ctx.p3.waitForSendReceiveData().then(() => ctx.p3.waitForRemoteStreams(1)),
ctx.p4.waitForSendReceiveData().then(() => ctx.p4.waitForRemoteStreams(1)),
ctx.p5.waitForSendReceiveData().then(() => ctx.p5.waitForRemoteStreams(1)),
ctx.p6.waitForSendReceiveData().then(() => ctx.p6.waitForRemoteStreams(1))
]);
}
/**
* Ensure that there are two participants.
*
* @param {IJoinOptions} options - The options to join.
* @param participantOptions
*/
export async function ensureTwoParticipants(
options?: IJoinOptions, participantOptions?: IParticipantOptions): Promise<void> {
await ensureOneParticipant(options, participantOptions);
if (!participantOptions) {
participantOptions = { name: P2 };
}
participantOptions.name = P2;
await joinParticipant(participantOptions, options);
if (options?.skipInMeetingChecks) {
return Promise.resolve();
}
await Promise.all([
ctx.p1.waitForIceConnected(),
ctx.p2.waitForIceConnected()
]);
await Promise.all([
ctx.p1.waitForSendReceiveData().then(() => ctx.p1.waitForRemoteStreams(1)),
ctx.p2.waitForSendReceiveData().then(() => ctx.p2.waitForRemoteStreams(1))
]);
}
/**
* Creates a new participant instance, or returns an existing one if it is already joined.
* @param participantOptions - The participant options, with required name set.
* @param options - Join options.
* @returns {Promise<Participant>} - The participant instance.
*/
async function joinParticipant( // eslint-disable-line max-params
participantOptions: IParticipantOptions,
options?: IJoinOptions
): Promise<Participant> {
// @ts-ignore
const p = ctx[participantOptions.name] as Participant;
if (p) {
// Skip the isInMuc check (and the iframe context switch it requires) when the participant
// is already on base.html — they cannot be in the MUC and there is no iframe to switch into.
const alreadyOnBasePage = (await p.driver.getUrl()).endsWith('/base.html');
if (!alreadyOnBasePage) {
if (participantOptions.iFrameApi) {
await p.switchToIFrame();
}
if (await p.isInMuc()) {
return p;
}
if (participantOptions.iFrameApi) {
// when loading url make sure we are on the top page context or strange errors may occur
await p.switchToMainFrame();
}
// Change the page so we can reload same url if we need to, base.html is supposed to be empty or close to empty
await p.driver.url('/base.html');
}
}
const newParticipant = new Participant(participantOptions);
// set the new participant instance
// @ts-ignore
ctx[participantOptions.name] = newParticipant;
return await newParticipant.joinConference({
...options,
roomName: options?.roomName || ctx.roomName,
});
}
/**
* Parse a JID string.
* @param str the string to parse.
*/
export function parseJid(str: string): {
domain: string;
node: string;
resource: string | undefined;
} {
const parts = str.split('@');
const domainParts = parts[1].split('/');
return {
node: parts[0],
domain: domainParts[0],
resource: domainParts.length > 0 ? domainParts[1] : undefined
};
}
/**
* Check the subject of the participant.
* @param participant
* @param subject
*/
export async function checkSubject(participant: Participant, subject: string) {
const localTile = participant.driver.$(SUBJECT_XPATH);
await localTile.waitForExist();
await localTile.moveTo();
const txt = await localTile.getText();
expect(txt.startsWith(subject)).toBe(true);
}
/**
* Check if a screensharing tile is displayed on the observer.
* Expects there was already a video by this participant and screen sharing will be the second video `-v1`.
*/
export async function checkForScreensharingTile(sharer: Participant, observer: Participant, reverse = false) {
const selector = `//span[@id='participant_${await sharer.getEndpointId()}-v1']`;
// Re-runs the selector on every poll, unlike waitForDisplayed(), which keeps checking the element it
// matched first. Thumbnails get replaced by React while the wait is running, and losing the matched one
// is not something wdio recovers from here: over WebDriver BiDi the node handle is passed to the
// visibility check as a script argument, so Chrome rejects it with `invalid argument - Invalid input in
// "arguments"/0`, which wdio's refetch-on-stale handling does not recognize as staleness. Every
// remaining poll then reuses the dead handle, failing the wait while a tile is on screen.
await observer.driver.waitUntil(
async () => {
try {
const displayed = await observer.driver.$(selector).isDisplayed();
return displayed !== reverse;
} catch (e) {
// Look the element up again on the next poll.
return false;
}
},
{
timeout: 3_000,
timeoutMsg: `Screensharing tile of ${sharer.name} is ${
reverse ? 'still displayed' : 'not displayed'} on ${observer.name}`
}
);
}
/**
* Hangs up all participants (p1, p2, p3, p4, p5, and p6)
* @returns {Promise<void>}
*/
export function hangupAllParticipants() {
return Promise.all([
ctx.p1?.hangup(),
ctx.p2?.hangup(),
ctx.p3?.hangup(),
ctx.p4?.hangup(),
ctx.p5?.hangup(),
ctx.p6?.hangup()
].map(p => p ?? Promise.resolve()));
}

140
tests/helpers/token.ts Normal file
View File

@@ -0,0 +1,140 @@
import fs from 'fs';
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
import { config } from './TestsConfig';
export type ITokenOptions = {
displayName?: string;
/**
* The duration for which the token is valid, e.g. "1h" for one hour.
*/
exp?: string;
/**
* Additional JWT features to include (merged with the defaults).
* Keys are feature names (e.g. 'file-upload'), values are boolean or string.
*/
features?: Record<string, boolean | string>;
/**
* The key ID to use for the token.
* If not provided, the kid configured with environment variables will be used (see env.example).
*/
keyId?: string;
/**
* The path to the private key file used to sign the token.
* If not provided, the path configured with environment variables will be used (see env.example).
*/
keyPath?: string;
/**
* Whether to set the 'moderator' flag.
*/
moderator?: boolean;
/**
* The room for which the token is valid, or '*'. Defaults to '*'.
*/
room?: string;
sub?: string;
/**
* Whether to set the 'visitor' flag.
*/
visitor?: boolean;
};
export type IToken = {
/**
* The JWT headers, for easy reference.
*/
headers?: any;
/**
* The signed JWT.
*/
jwt: string;
/**
* The options used to generate the token.
*/
options?: ITokenOptions;
/**
* The token's payload, for easy reference.
*/
payload?: any;
};
export function generatePayload(options: ITokenOptions): any {
const payload = {
'aud': 'jitsi',
'iss': 'chat',
'sub': options?.sub || '',
'context': {
'user': {
'name': options.displayName,
'id': uuidv4(),
'avatar': 'https://avatars0.githubusercontent.com/u/3671647',
'email': 'john.doe@jitsi.org'
},
'group': uuidv4(),
'features': {
'outbound-call': 'true',
'transcription': 'true',
'recording': 'true',
'sip-outbound-call': true,
'livestreaming': true,
...options.features
},
},
'room': options.room || '*'
};
if (options.moderator) {
// @ts-ignore
payload.context.user.moderator = true;
} else if (options.visitor) {
// @ts-ignore
payload.context.user.role = 'visitor';
}
return payload;
}
/**
* Generate a signed token.
*/
export function generateToken(options: ITokenOptions): IToken {
const keyId = options.keyId || config.jwt.kid;
const keyPath = options.keyPath || config.jwt.privateKeyPath;
const headers = {
algorithm: 'RS256',
noTimestamp: true,
expiresIn: options.exp || '24h',
keyid: keyId,
};
if (!keyId) {
throw new Error('No keyId provided (JWT_KID is not set?)');
}
if (!keyPath) {
throw new Error('No keyPath provided (JWT_PRIVATE_KEY_PATH is not set?)');
}
const key = fs.readFileSync(keyPath);
const payload = generatePayload({
...options,
displayName: options?.displayName || '',
sub: keyId.substring(0, keyId.indexOf('/'))
});
return {
headers,
// @ts-ignore
jwt: jwt.sign(payload, key, headers),
options,
payload
};
}
/**
* Generated a signed token and return just the JWT string.
*/
export function generateJwt(options: ITokenOptions): string {
return generateToken(options).jwt;
}

142
tests/helpers/types.ts Normal file
View File

@@ -0,0 +1,142 @@
import { IConfig } from '../../react/features/base/config/configType';
import type { Participant } from './Participant';
import { ITestProperties } from './TestProperties';
import type WebhookProxy from './WebhookProxy';
import { IToken, ITokenOptions } from './token';
export type IContext = {
/**
* The name (describe block title) of the suite in which a test failed. Used to skip the remaining tests in that
* suite while still running tests in other describe blocks in the same file.
*/
failedSuite?: string;
/**
* The up-to-six browser instances provided by the framework. These can be initialized using
* ensureOneParticipant, ensureTwoParticipants, etc. from participants.ts.
**/
p1: Participant;
p2: Participant;
p3: Participant;
p4: Participant;
p5: Participant;
p6: Participant;
/** A room name automatically generated by the framework for convenience. */
roomName: string;
/**
* A flag that tests can set, which signals to the framework that the (rest of the) test suite should be skipped.
* A string value indicates the reason for the skipped (to be included in the Allure report).
*/
skipSuiteTests: boolean | string;
/**
* Test properties provided by the test file via TestProperties.setTestProperties. Used by the framework to
* set up the context appropriately.
**/
testProperties: ITestProperties;
times: any;
/**
* A WebhooksProxy instance generated by the framework and available for tests to use, if configured.
* Note that this is only configured for roomName, if a test wishes to use a different room name it can set up
* a WebhooksProxy instance itself.
*/
webhooksProxy: WebhookProxy;
};
export type IParticipantOptions = {
/** Whether it should use the iFrame API. */
iFrameApi?: boolean;
/** Determines the browser instance to use. */
name: 'p1' | 'p2' | 'p3' | 'p4' | 'p5' | 'p6';
/** An optional token to use. */
token?: IToken;
};
/**
* Options for joinConference.
*/
export type IParticipantJoinOptions = {
/**
* Config overwrites to use.
*/
configOverwrite?: IConfig;
/** The name of the room to join */
roomName: string;
/**
* Whether to skip setting display name.
*/
skipDisplayName?: boolean;
/**
* Whether to skip the prejoin button 'Join' click when joining.
*/
skipPrejoinButtonClick?: boolean;
/**
* Whether to skip waiting for the participant to join the room. Cases like lobby where we do not succeed to join
* based on the logic of the test.
*/
skipWaitToJoin?: boolean;
/**
* An optional tenant to use. If provided it overrides the default.
*/
tenant?: string;
/**
* An optional string to append to the URL when joining the room.
*/
urlAppendString?: string;
};
export type IJoinOptions = {
/**
* Config overwrites to pass to IParticipantJoinOptions.
*/
configOverwrite?: IConfig;
/**
* To be able to override the ctx generated room name. If missing the one from the context will be used.
*/
roomName?: string;
/**
*The skip display name setting to pass to IParticipantJoinOptions.
*/
skipDisplayName?: boolean;
/**
* Whether to skip in meeting checks like ice connected and send receive data. For single in meeting participant.
*/
skipInMeetingChecks?: boolean;
/**
* Whether to skip the prejoin button 'Join' click when joining.
*/
skipPrejoinButtonClick?: boolean;
/**
* The skip waiting for the participant to join the room setting to pass to IParticipantJoinOptions.
*/
skipWaitToJoin?: boolean;
/**
* An optional tenant to use. If provided the default tenant is changed to it.
*/
tenant?: string;
/**
* Options used when generating a token.
*/
tokenOptions?: ITokenOptions;
/**
* An optional string to append to the URL when joining the room.
*/
urlAppendString?: string;
};

57
tests/helpers/utils.ts Normal file
View File

@@ -0,0 +1,57 @@
import { config as testsConfig } from './TestsConfig';
const https = require('https');
export function generateRoomName(testName: string) {
if (testsConfig.roomName.name) {
return testsConfig.roomName.name;
}
const rand = (Math.floor(Math.random() * 400) + 1).toString();
let roomName = `${testName}-${rand}`;
if (testsConfig.roomName.prefix) {
roomName = `${testsConfig.roomName.prefix}_${roomName}`;
}
if (testsConfig.roomName.suffix) {
roomName += `_${testsConfig.roomName.suffix}`;
}
return roomName.toLowerCase();
}
/**
* Fetches JSON data from a given URL.
* @param {string} url - The URL to fetch data from.
* @returns {Promise<Object>} - A promise that resolves to the parsed JSON object.
*/
export async function fetchJson(url) {
return new Promise((resolve, reject) => {
https.get(url, res => {
let data = '';
// Handle HTTP errors
if (res.statusCode < 200 || res.statusCode >= 300) {
return reject(new Error(`HTTP Status Code: ${res.statusCode}`));
}
// Collect data chunks
res.on('data', chunk => {
data += chunk;
});
// Parse JSON when the response ends
res.on('end', () => {
try {
const json = JSON.parse(data);
resolve(json);
} catch (err) {
reject(new Error('Invalid JSON response'));
}
});
}).on('error', err => {
reject(err);
});
});
}

7424
tests/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
tests/package.json Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "jitsi-meet-tests",
"version": "1.0.0",
"private": true,
"description": "Standalone WebdriverIO runner for the jitsi-meet e2e tests. Installing here (npm install, from this directory) only pulls in the wdio-side runtime dependencies, not the rest of the monorepo's (webpack/react-native/eslint/...) dependencies.",
"scripts": {
"test": "DOTENV_CONFIG_PATH=.env NODE_PATH=\"$(pwd)/node_modules\" wdio run wdio.conf.ts",
"test-single": "DOTENV_CONFIG_PATH=.env NODE_PATH=\"$(pwd)/node_modules\" wdio run wdio.conf.ts --spec",
"test-ff": "DOTENV_CONFIG_PATH=.env NODE_PATH=\"$(pwd)/node_modules\" wdio run wdio.firefox.conf.ts",
"test-ff-single": "DOTENV_CONFIG_PATH=.env NODE_PATH=\"$(pwd)/node_modules\" wdio run wdio.firefox.conf.ts --spec",
"test-dev": "DOTENV_CONFIG_PATH=.env NODE_PATH=\"$(pwd)/node_modules\" wdio run wdio.dev.conf.ts",
"test-dev-single": "DOTENV_CONFIG_PATH=.env NODE_PATH=\"$(pwd)/node_modules\" wdio run wdio.dev.conf.ts --spec",
"test-grid": "DOTENV_CONFIG_PATH=.env NODE_PATH=\"$(pwd)/node_modules\" wdio run wdio.grid.conf.ts",
"test-grid-single": "DOTENV_CONFIG_PATH=.env NODE_PATH=\"$(pwd)/node_modules\" wdio run wdio.grid.conf.ts --spec",
"test-grid-ff": "DOTENV_CONFIG_PATH=.env NODE_PATH=\"$(pwd)/node_modules\" wdio run wdio.grid.firefox.conf.ts",
"test-grid-ff-single": "DOTENV_CONFIG_PATH=.env NODE_PATH=\"$(pwd)/node_modules\" wdio run wdio.grid.firefox.conf.ts --spec"
},
"dependencies": {
"@braintree/sanitize-url": "7.0.0",
"@jitsi/js-utils": "6.3.2",
"@wdio/allure-reporter": "9.27.0",
"@wdio/cli": "9.27.0",
"@wdio/globals": "9.27.0",
"@wdio/junit-reporter": "9.27.0",
"@wdio/local-runner": "9.27.0",
"@wdio/mocha-framework": "9.27.0",
"allure-commandline": "2.32.0",
"dotenv": "17.2.3",
"glob": "12.0.0",
"jsonwebtoken": "9.0.2",
"junit-report-builder": "5.1.1",
"lodash-es": "4.18.1",
"pretty": "2.0.0",
"uuid": "14.0.0",
"webdriverio": "9.27.0",
"ws": "6.2.3"
},
"devDependencies": {
"@types/jsonwebtoken": "9.0.7",
"@types/mocha": "10.0.10",
"@types/pretty": "2.0.3"
},
"engines": {
"node": ">=24.0.0",
"npm": ">=11.0.0"
}
}

View File

@@ -0,0 +1,56 @@
import BasePageObject from './BasePageObject';
const START_AUDIO_MODERATION = 'participants-pane-context-menu-start-audio-moderation';
const STOP_AUDIO_MODERATION = 'participants-pane-context-menu-stop-audio-moderation';
const START_VIDEO_MODERATION = 'participants-pane-context-menu-start-video-moderation';
const STOP_VIDEO_MODERATION = 'participants-pane-context-menu-stop-video-moderation';
/**
* Represents the Audio Video Moderation menu in the participants pane.
*/
export default class AVModerationMenu extends BasePageObject {
/**
* Clicks the start audio moderation menu item.
*/
clickStartAudioModeration() {
return this.clickButton(START_AUDIO_MODERATION);
}
/**
* Clicks the stop audio moderation menu item.
*/
clickStopAudioModeration() {
return this.clickButton(STOP_AUDIO_MODERATION);
}
/**
* Clicks the start video moderation menu item.
*/
clickStartVideoModeration() {
return this.clickButton(START_VIDEO_MODERATION);
}
/**
* Clicks the stop audio moderation menu item.
*/
clickStopVideoModeration() {
return this.clickButton(STOP_VIDEO_MODERATION);
}
/**
* Clicks a context menu button.
* @param id
* @private
*/
private async clickButton(id: string) {
const button = this.participant.driver.$(`#${id}`);
await button.waitForDisplayed();
await button.click();
await button.moveTo({
xOffset: -40,
yOffset: -40
});
}
}

View File

@@ -0,0 +1,23 @@
import BasePageObject from './BasePageObject';
const CLOSE_BUTTON = 'modal-header-close-button';
const OK_BUTTON = 'modal-dialog-ok-button';
/**
* Base class for all dialogs.
*/
export default class BaseDialog extends BasePageObject {
/**
* Clicks on the X (close) button.
*/
clickCloseButton(): Promise<void> {
return this.participant.driver.$(`#${CLOSE_BUTTON}`).click();
}
/**
* Clicks on the ok button.
*/
clickOkButton(): Promise<void> {
return this.participant.driver.$(`#${OK_BUTTON}`).click();
}
}

View File

@@ -0,0 +1,16 @@
import { Participant } from '../helpers/Participant';
/**
* Represents the base page object.
* All page object has the current participant (holding the driver/browser session).
*/
export default class BasePageObject {
participant: Participant;
/**
* Represents the base page object.
*/
constructor(participant: Participant) {
this.participant = participant;
}
}

View File

@@ -0,0 +1,213 @@
import { Participant } from '../helpers/Participant';
import BaseDialog from './BaseDialog';
import BasePageObject from './BasePageObject';
const BREAKOUT_ROOMS_CLASS = 'breakout-room-container';
const ADD_BREAKOUT_ROOM = 'Add breakout room';
const MORE_LABEL = 'More';
const LEAVE_ROOM_LABEL = 'Leave breakout room';
const AUTO_ASSIGN_LABEL = 'Auto assign to breakout rooms';
/**
* Represents a single breakout room and the operations for it.
*/
class BreakoutRoom extends BasePageObject {
title: string;
id: string;
count: number;
/**
* Constructs a breakout room.
*/
constructor(participant: Participant, title: string, id: string) {
super(participant);
this.title = title;
this.id = id;
const tMatch = title.match(/.*\((.*)\)/);
if (tMatch) {
this.count = parseInt(tMatch[1], 10);
}
}
/**
* Returns room name.
*/
get name() {
return this.title.split('(')[0].trim();
}
/**
* Returns the number of participants in the room.
*/
get participantCount() {
return this.count;
}
/**
* Collapses the breakout room.
*/
collapse() {
const collapseElem = this.participant.driver.$(
`div[data-testid="${this.id}"]`);
return collapseElem.click();
}
/**
* Joins the breakout room.
*/
async joinRoom() {
const joinButton = this.participant.driver
.$(`button[data-testid="join-room-${this.id}"]`);
await joinButton.waitForClickable();
await joinButton.click();
}
/**
* Removes the breakout room.
*/
async removeRoom() {
await this.openContextMenu();
const removeButton = this.participant.driver.$(`#remove-room-${this.id}`);
await removeButton.waitForClickable();
await removeButton.click();
}
/**
* Renames the breakout room.
*/
async renameRoom(newName: string) {
await this.openContextMenu();
const renameButton = this.participant.driver.$(`#rename-room-${this.id}`);
await renameButton.click();
const newNameInput = this.participant.driver.$('input[name="breakoutRoomName"]');
await newNameInput.waitForStable();
await newNameInput.setValue(newName);
await new BaseDialog(this.participant).clickOkButton();
}
/**
* Closes the breakout room.
*/
async closeRoom() {
await this.openContextMenu();
const closeButton = this.participant.driver.$(`#close-room-${this.id}`);
await closeButton.waitForClickable();
await closeButton.click();
}
/**
* Opens the context menu.
* @private
*/
private async openContextMenu() {
const listItem = this.participant.driver.$(`div[data-testid="${this.id}"]`);
await listItem.click();
const button = listItem.$(`button[title="${MORE_LABEL}"]`);
await button.waitForClickable();
await button.click();
}
}
/**
* All breakout rooms objects and operations.
*/
export default class BreakoutRooms extends BasePageObject {
/**
* Returns the number of breakout rooms.
*/
async getRoomsCount() {
const participantsPane = this.participant.getParticipantsPane();
if (!await participantsPane.isOpen()) {
await participantsPane.open();
}
return await this.participant.driver.$$(`.${BREAKOUT_ROOMS_CLASS}`).length;
}
/**
* Adds a breakout room.
*/
async addBreakoutRoom() {
const participantsPane = this.participant.getParticipantsPane();
if (!await participantsPane.isOpen()) {
await participantsPane.open();
}
const addBreakoutButton = this.participant.driver.$(`button=${ADD_BREAKOUT_ROOM}`);
await addBreakoutButton.waitForDisplayed();
await addBreakoutButton.click();
}
/**
* Returns all breakout rooms.
*/
async getRooms(): Promise<BreakoutRoom[]> {
const rooms = this.participant.driver.$$(`.${BREAKOUT_ROOMS_CLASS}`);
return rooms.map(async room => new BreakoutRoom(
this.participant, await room.$('span').getText(), await room.getAttribute('data-testid')));
}
/**
* Leave by clicking the leave button in participant pane.
*/
async leaveBreakoutRoom() {
const participantsPane = this.participant.getParticipantsPane();
if (!await participantsPane.isOpen()) {
await participantsPane.open();
}
const leaveButton = this.participant.driver.$(`button=${LEAVE_ROOM_LABEL}`);
await leaveButton.isClickable();
await leaveButton.click();
}
/**
* Auto assign participants to breakout rooms.
*/
async autoAssignToBreakoutRooms() {
const button = this.participant.driver.$(`button=${AUTO_ASSIGN_LABEL}`);
await button.waitForClickable();
await button.click();
}
/**
* Tries to send a participant to a breakout room.
*/
async sendParticipantToBreakoutRoom(participant: Participant, roomName: string) {
const participantsPane = this.participant.getParticipantsPane();
await participantsPane.selectParticipant(participant);
await participantsPane.openParticipantContextMenu(participant);
const sendButton = this.participant.driver.$(`div=${roomName}`);
await sendButton.scrollIntoView();
await sendButton.waitForClickable();
await sendButton.click();
}
}

View File

@@ -0,0 +1,207 @@
import BasePageObject from './BasePageObject';
/**
* Chat panel elements.
*/
export default class ChatPanel extends BasePageObject {
/**
* Is chat panel open.
*/
isOpen() {
return this.participant.driver.$('#sideToolbarContainer').isExisting();
}
/**
* Presses the "chat" keyboard shortcut which opens or closes the chat
* panel.
*/
async pressShortcut() {
await this.participant.driver.$('body').click();
await this.participant.driver.keys([ 'c' ]);
}
async sendMessage(message: string) {
if (!await this.isOpen()) {
await this.pressShortcut();
}
if (!await this.isOpen()) {
throw new Error('Chat panel failed to open');
}
const inputField = this.participant.driver.$('#chat-input');
await inputField.click();
await this.participant.driver.keys(`${message}\n`);
}
/**
* Opens the polls tab in the chat panel.
*/
async openPollsTab() {
await this.participant.driver.$('#polls-tab').click();
}
/**
* Checks whether the polls tab is visible.
*/
async isPollsTabVisible() {
return this.participant.driver.$('#polls-tab-panel').isDisplayed();
}
async clickCreatePollButton() {
await this.participant.driver.$('button=Create a poll').click();
}
/**
* Waits for the new poll input to be visible.
*/
async waitForNewPollInput() {
await this.participant.driver.$(
'#polls-create-input')
.waitForExist({
timeout: 2000,
timeoutMsg: 'New poll not created'
});
}
/**
* Waits for the option input to be visible.
* @param index
*/
async waitForOptionInput(index: number) {
await this.participant.driver.$(
`#polls-answer-input-${index}`)
.waitForExist({
timeout: 1000,
timeoutMsg: `Answer input ${index} not created`
});
}
/**
* Waits for the option input to be non-existing.
* @param index
*/
async waitForOptionInputNonExisting(index: number) {
await this.participant.driver.$(
`#polls-answer-input-${index}`)
.waitForExist({
reverse: true,
timeout: 2000,
timeoutMsg: `Answer input ${index} still exists`
});
}
/**
* Clicks the "Add option" button.
*/
async clickAddOptionButton() {
await this.participant.driver.$('button=Add option').click();
}
/**
* Clicks the "Remove option" button.
* @param index
*/
async clickRemoveOptionButton(index: number) {
await this.participant.driver.$(`[data-testid="remove-polls-answer-input-${index}"]`).click();
}
/**
* Fills in the poll question.
* @param question
*/
async fillPollQuestion(question: string) {
const input = await this.participant.driver.$('#polls-create-input');
await input.click();
await this.participant.driver.keys(question);
}
/**
* Fills in the poll option.
* @param index
* @param option
*/
async fillPollOption(index: number, option: string) {
const input = await this.participant.driver.$(`#polls-answer-input-${index}`);
await input.click();
await this.participant.driver.keys(option);
}
/**
* Gets the poll option.
* @param index
*/
async getOption(index: number) {
return this.participant.driver.$(`#polls-answer-input-${index}`).getValue();
}
/**
* Clicks the "Save" button.
*/
async clickSavePollButton() {
await this.participant.driver.$('button=Save').click();
}
/**
* Clicks the "Edit" button.
*/
async clickEditPollButton() {
await this.participant.driver.$('button=Edit').click();
}
/**
* Clicks the "Skip" button.
*/
async clickSkipPollButton() {
await this.participant.driver.$('button=Skip').click();
}
/**
* Clicks the "Send" button.
*/
async clickSendPollButton() {
await this.participant.driver.$('button=Send').click();
}
/**
* Waits for the "Send" button to be visible.
*/
async waitForSendButton() {
await this.participant.driver.$('button=Send').waitForExist({
timeout: 1000,
timeoutMsg: 'Send button not visible'
});
}
/**
* Votes for the given option in the given poll.
* @param pollId
* @param index
*/
async voteForOption(pollId: string, index: number) {
await this.participant.driver.execute(
(id, ix) => document.getElementById(`poll-answer-checkbox-${id}-${ix}`)?.click(),
pollId, index);
await this.participant.driver.$('button=Submit').click();
}
/**
* Checks whether the given poll is visible.
* @param pollId
*/
async isPollVisible(pollId: string) {
return this.participant.driver.$(`#poll-${pollId}`).isDisplayed();
}
/**
* Gets the result text for the given option in the given poll.
* @param pollId
* @param optionIndex
*/
async getResult(pollId: string, optionIndex: number) {
return await this.participant.driver.$(`#poll-result-${pollId}-${optionIndex}`).getText();
}
}

View File

@@ -0,0 +1,172 @@
import BasePageObject from './BasePageObject';
const FILE_SHARING_TAB_ID = 'file_sharing-tab';
const FILE_SHARING_PANEL_ID = 'file_sharing-tab-panel';
const UPLOAD_BUTTON_LABEL = 'Share file';
const DOWNLOAD_BUTTON_LABEL = 'Download';
const REMOVE_BUTTON_LABEL = 'Remove';
/**
* Page object for the file sharing panel.
*/
export default class FileSharingPanel extends BasePageObject {
/**
* Opens the chat sidebar and navigates to the file sharing tab.
*/
async open(): Promise<void> {
if (!await this.participant.driver.$('#sideToolbarContainer').isExisting()) {
await this.participant.getToolbar().clickChatButton();
}
await this.participant.driver.$(`#${FILE_SHARING_TAB_ID}`).click();
}
/**
* Returns whether the file sharing panel is the currently active tab.
*/
async isActive(): Promise<boolean> {
return this.participant.execute(() => {
// @ts-ignore
const state = APP?.store?.getState?.();
return state?.['features/chat']?.focusedTab === 'file_sharing-tab';
});
}
/**
* Returns whether the upload button is enabled (not disabled).
*/
async isUploadButtonEnabled(): Promise<boolean> {
const btn = this.participant.driver.$(`[aria-label="${UPLOAD_BUTTON_LABEL}"]`);
return !await btn.getAttribute('disabled');
}
/**
* Uploads a file via the hidden file input.
*
* @param {string} localFilePath - Local path to the file to upload.
*/
async uploadFile(localFilePath: string): Promise<void> {
const remotePath = await this.participant.driver.uploadFile(localFilePath);
const input = this.participant.driver.$(`#${FILE_SHARING_PANEL_ID} input[type="file"]`);
await input.addValue(remotePath);
}
/**
* Waits until a file with the given name appears in the file list.
*
* @param {string} fileName - The file name to wait for.
* @param {number} timeout - Timeout in milliseconds.
*/
async waitForFile(fileName: string, timeout = 15_000): Promise<void> {
await this.participant.driver.$(`#${FILE_SHARING_PANEL_ID} [title="${fileName}"]`)
.waitForExist({ timeout, timeoutMsg: `File "${fileName}" did not appear within ${timeout}ms` });
}
/**
* Waits until a file with the given name disappears from the file list.
*
* @param {string} fileName - The file name to wait for.
* @param {number} timeout - Timeout in milliseconds.
*/
async waitForFileGone(fileName: string, timeout = 15_000): Promise<void> {
await this.participant.driver.$(`#${FILE_SHARING_PANEL_ID} [title="${fileName}"]`)
.waitForExist({ timeout, reverse: true, timeoutMsg: `File "${fileName}" still present after ${timeout}ms` });
}
/**
* Returns whether a file with the given name exists in the file list.
*
* @param {string} fileName - The file name to check.
*/
async hasFile(fileName: string): Promise<boolean> {
return this.participant.driver.$(`#${FILE_SHARING_PANEL_ID} [title="${fileName}"]`).isExisting();
}
/**
* Clicks the download button for the given file. Hovers over the file item first to make action buttons visible.
*
* @param {string} fileName - The file name to download.
*/
async downloadFile(fileName: string): Promise<void> {
await this.hoverOverFileItem(fileName);
await this.participant.driver
.$(`button[aria-label="${DOWNLOAD_BUTTON_LABEL} ${fileName}"]`)
.click();
}
/**
* Clicks the remove button for the given file. Hovers over the file item first to make action buttons visible.
*
* @param {string} fileName - The file name to remove.
*/
async removeFile(fileName: string): Promise<void> {
await this.hoverOverFileItem(fileName);
await this.participant.driver
.$(`button[aria-label="${REMOVE_BUTTON_LABEL} ${fileName}"]`)
.click();
}
/**
* Returns whether the remove button exists in the DOM for the given file.
* The remove button is only rendered when the participant has the 'file-upload' JWT feature.
*
* @param {string} fileName - The file name.
*/
canRemoveFile(fileName: string) {
return this.participant.driver
.$(`button[aria-label="${REMOVE_BUTTON_LABEL} ${fileName}"]`)
.isExisting();
}
/**
* Returns whether the download button exists in the DOM for the given file.
*
* @param {string} fileName - The file name.
*/
canDownloadFile(fileName: string) {
return this.participant.driver
.$(`button[aria-label="${DOWNLOAD_BUTTON_LABEL} ${fileName}"]`)
.isExisting();
}
/**
* Simulates dragging a file into the conference area and dispatches drag events.
* A dragenter event sets the React isDragging state, then after a short delay a
* dragover event triggers the file sharing tab to open.
*/
async simulateDragIntoConference(): Promise<void> {
await this.participant.driver.executeAsync((done: () => void) => {
const el = document.querySelector('[data-testid="conference-drag-zone"]') ?? document.body;
el.dispatchEvent(new DragEvent('dragenter', {
bubbles: true,
cancelable: true
}));
setTimeout(() => {
el.dispatchEvent(new DragEvent('dragover', {
bubbles: true,
cancelable: true
}));
done();
}, 200);
});
}
/**
* Returns whether the chat sidebar is currently open.
*/
isChatOpen() {
return this.participant.driver.$('#sideToolbarContainer').isExisting();
}
/**
* Hovers over the file item element to make its action buttons visible.
*
* @param {string} fileName - The file name whose item should be hovered.
*/
private async hoverOverFileItem(fileName: string): Promise<void> {
await this.participant.driver.$(`#${FILE_SHARING_PANEL_ID} [title="${fileName}"]`).moveTo();
}
}

View File

@@ -0,0 +1,390 @@
import { Participant } from '../helpers/Participant';
import BaseDialog from './BaseDialog';
import BasePageObject from './BasePageObject';
const LOCAL_VIDEO_XPATH = '//span[@id="localVideoContainer"]';
const LOCAL_VIDEO_MENU_TRIGGER = '#local-video-menu-trigger';
const LOCAL_USER_CONTROLS = 'button[title="Local user controls"]';
const HIDE_SELF_VIEW_BUTTON_XPATH = '//div[contains(@class, "popover")]//div[@id="hideselfviewButton"]';
/**
* Filmstrip elements.
*/
export default class Filmstrip extends BasePageObject {
/**
* Asserts that {@code participant} shows or doesn't show the audio
* mute icon for the conference participant identified by
* {@code testee}.
*
* @param {Participant} testee - The {@code Participant} for whom we're checking the status of audio muted icon.
* @param {boolean} reverse - If {@code true}, the method will assert the absence of the "mute" icon;
* otherwise, it will assert its presence.
* @returns {Promise<void>}
*/
async assertAudioMuteIconIsDisplayed(testee: Participant, reverse = false): Promise<void> {
let id;
if (testee === this.participant) {
id = 'localVideoContainer';
} else {
id = `participant_${await testee.getEndpointId()}`;
}
const mutedIconXPath
= `//span[@id='${id}']//span[contains(@id, 'audioMuted')]//*[local-name()='svg' and @id='mic-disabled']`;
await this.participant.driver.$(mutedIconXPath).waitForDisplayed({
reverse,
timeout: 5_000,
timeoutMsg: `Audio mute icon is${reverse ? '' : ' not'} displayed for ${testee.name}`
});
}
/**
* Returns the remote display name for an endpoint.
* @param endpointId The endpoint id.
*/
async getRemoteDisplayName(endpointId: string) {
const remoteDisplayName = this.participant.driver.$(`span[id="participant_${endpointId}_name"]`);
await remoteDisplayName.moveTo();
return await remoteDisplayName.getText();
}
/**
* Returns the remote video id of a participant with endpointID.
* @param endpointId
*/
async getRemoteVideoId(endpointId: string) {
await this.participant.driver.$(`span[id="participant_${endpointId}"]`).waitForExist();
return await this.participant.execute(eId =>
document.evaluate(`//span[@id="participant_${eId}"]//video`,
document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue?.srcObject?.id, endpointId);
}
/**
* Returns the local video id.
*/
getLocalVideoId() {
return this.participant.execute(
'return document.getElementById("localVideo_container").srcObject.id');
}
/**
* Pins a participant by clicking on their thumbnail.
* @param participant The participant.
*/
async pinParticipant(participant: Participant) {
if (participant === this.participant) {
// when looking up the element and clicking it, it doesn't work if we do it twice in a row (oneOnOne.spec)
await this.participant.execute(() => document?.getElementById('localVideoContainer')?.click());
} else {
const epId = await participant.getEndpointId();
await this.participant.driver.$(`//span[@id="participant_${epId}"]`).click();
}
const endpointID = await participant.getEndpointId();
await this.participant.waitForParticipantOnLargeVideo(endpointID);
}
/**
* Unpins a participant by clicking on their thumbnail.
* @param participant
*/
async unpinParticipant(participant: Participant) {
const epId = await participant.getEndpointId();
if (participant === this.participant) {
await this.participant.execute(() => document?.getElementById('localVideoContainer')?.click());
} else {
await this.participant.driver.$(`//span[@id="participant_${epId}"]`).click();
}
await this.participant.driver.$(`//div[ @id="pin-indicator-${epId}" ]`).waitForDisplayed({
timeout: 2_000,
timeoutMsg: `${this.participant.name} did not unpin ${participant.name}`,
reverse: true
});
}
/**
* Gets avatar SRC attribute for the one displayed on small video thumbnail.
* @param endpointId
*/
async getAvatar(endpointId: string) {
const elem = this.participant.driver.$(
`//span[@id='participant_${endpointId}']//img[contains(@class,'userAvatar')]`);
return await elem.isExisting() ? await elem.getAttribute('src') : null;
}
/**
* Grants moderator rights to a participant.
* @param participant
*/
async grantModerator(participant: Participant) {
await this.clickOnRemoteMenuLink(await participant.getEndpointId(), 'grantmoderatorlink', true);
}
/**
* Clicks on the link in the remote participant actions menu.
* @param participantId
* @param linkClassname
* @param dialogConfirm
* @private
*/
private async clickOnRemoteMenuLink(participantId: string, linkClassname: string, dialogConfirm: boolean) {
await this.participant.driver.$(`//span[@id='participant_${participantId}']`).moveTo();
await this.participant.driver.$(
`//span[@id='participant_${participantId
}']//span[@id='remotevideomenu']//div[@id='remote-video-menu-trigger']`).moveTo();
const popoverElement = this.participant.driver.$(
`//div[contains(@class, 'popover')]//div[contains(@class, '${linkClassname}')]`);
await popoverElement.waitForExist();
await popoverElement.waitForDisplayed();
await popoverElement.click();
if (dialogConfirm) {
await new BaseDialog(this.participant).clickOkButton();
}
}
/**
* Mutes the audio of a participant.
* @param participant
*/
async muteAudio(participant: Participant) {
await this.clickOnRemoteMenuLink(await participant.getEndpointId(), 'mutelink', false);
}
/**
* Mutes the video of a participant.
* @param participant
*/
async muteVideo(participant: Participant) {
await this.clickOnRemoteMenuLink(await participant.getEndpointId(), 'mutevideolink', true);
}
/**
* Kicks a participant.
* @param participantId
*/
kickParticipant(participantId: string) {
return this.clickOnRemoteMenuLink(participantId, 'kicklink', true);
}
/**
* Hover over local video.
*/
hoverOverLocalVideo() {
return this.participant.driver.$(LOCAL_VIDEO_MENU_TRIGGER).moveTo();
}
/**
* Clicks on the hide self view button from local video.
*/
async hideSelfView() {
// open local video menu
await this.hoverOverLocalVideo();
await this.participant.driver.$(LOCAL_USER_CONTROLS).moveTo();
// click Hide self view button
const hideSelfViewButton = this.participant.driver.$(HIDE_SELF_VIEW_BUTTON_XPATH);
await hideSelfViewButton.waitForExist();
await hideSelfViewButton.waitForClickable();
await hideSelfViewButton.click();
}
/**
* Checks whether the local self view is displayed or not.
*/
assertSelfViewIsHidden(hidden: boolean) {
return this.participant.driver.$(LOCAL_VIDEO_XPATH).waitForDisplayed({
reverse: hidden,
timeout: 5000,
timeoutMsg: `Local video thumbnail is${hidden ? '' : ' not'} displayed for ${this.participant.name}`
});
}
/**
* Toggles the filmstrip.
*/
async toggle() {
const toggleButton = this.participant.driver.$('#toggleFilmstripButton');
await toggleButton.moveTo();
await toggleButton.waitForDisplayed();
await toggleButton.click();
}
/**
* Asserts that the filmstrip itself (the whole container, including the self view) is visible or not
* (it is hidden for example when collapsed via the toggle button).
*
* @param visible Whether the filmstrip is expected to be visible.
*/
assertVisible(visible = true) {
return this.participant.driver.waitUntil(
() => this.participant.execute(
expected => {
const filmstrip = document.querySelector('.filmstrip');
return Boolean(filmstrip) && filmstrip.classList.contains('hidden') !== expected;
}, visible),
{
timeout: 5_000,
timeoutMsg: `The filmstrip is${visible ? ' not' : ''} visible for ${this.participant.name}`
}
);
}
/**
* Asserts that the remote videos are hidden or not.
* @param reverse
*/
assertRemoteVideosHidden(reverse = false) {
return this.participant.driver.waitUntil(
async () =>
await this.participant.driver.$$('//div[@id="remoteVideos" and contains(@class, "hidden")]').length > 0,
{
timeout: 10_000, // 10 seconds
timeoutMsg: `Timeout waiting fore remote videos to be hidden: ${!reverse}.`
}
);
}
/**
* Counts the displayed remote video thumbnails.
*/
async countVisibleThumbnails() {
return (await this.participant.driver.$$('//div[@id="remoteVideos"]//span[contains(@class,"videocontainer")]')
.filter(thumbnail => thumbnail.isDisplayed())).length;
}
/**
* Check if remote videos in filmstrip are visible.
*
* @param isDisplayed whether or not filmstrip remote videos should be visible
*/
verifyRemoteVideosDisplay(isDisplayed: boolean) {
return this.participant.driver.$('//div[contains(@class, "remote-videos")]/div').waitForDisplayed({
timeout: 5_000,
reverse: !isDisplayed,
});
}
/**
* Checks for visible gaps in the filmstrip thumbnails.
* This detects if there are any missing thumbnails or excessive spacing between consecutive visible thumbnails.
*
* @returns Returns true if gaps are detected, false otherwise.
*/
async hasGapsInFilmstrip(): Promise<boolean> {
return await this.participant.execute(() => {
// Get all visible thumbnail containers in the filmstrip
const thumbnails = Array.from(
document.querySelectorAll('#remoteVideos span.videocontainer')
).filter((thumb: any) => {
const style = window.getComputedStyle(thumb);
const rect = thumb.getBoundingClientRect();
// Check if element is visible and has dimensions
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& rect.width > 0
&& rect.height > 0;
});
if (thumbnails.length < 2) {
// Can't have gaps with less than 2 thumbnails
return false;
}
// Get positions and calculated margins of all visible thumbnails
const positions = thumbnails.map((thumb: any) => {
const rect = thumb.getBoundingClientRect();
const style = window.getComputedStyle(thumb);
return {
left: rect.left,
right: rect.right,
top: rect.top,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
marginTop: parseFloat(style.marginTop) || 0,
marginBottom: parseFloat(style.marginBottom) || 0,
marginLeft: parseFloat(style.marginLeft) || 0,
marginRight: parseFloat(style.marginRight) || 0
};
});
// Calculate expected spacing between thumbnails based on first two
const firstGap = positions.length >= 2
? Math.abs(positions[1].top - positions[0].top) !== 0
? positions[1].top - positions[0].bottom // vertical
: positions[1].left - positions[0].right // horizontal
: 0;
// Check if filmstrip is vertical or horizontal
const isVertical = Math.abs(positions[1].top - positions[0].top) > Math.abs(positions[1].left - positions[0].left);
if (isVertical) {
// For vertical filmstrip, check vertical spacing consistency
for (let i = 0; i < positions.length - 1; i++) {
const current = positions[i];
const next = positions[i + 1];
const gap = next.top - current.bottom;
// Compare against the first gap with some tolerance
// Flag if gap is more than 2x the expected spacing
if (gap > Math.max(firstGap * 2, current.height * 0.3)) {
return true;
}
}
} else {
// For horizontal filmstrip, check horizontal spacing consistency
for (let i = 0; i < positions.length - 1; i++) {
const current = positions[i];
const next = positions[i + 1];
const gap = next.left - current.right;
// Compare against the first gap with some tolerance
// Flag if gap is more than 2x the expected spacing
if (gap > Math.max(firstGap * 2, current.width * 0.3)) {
return true;
}
}
}
return false;
});
}
/**
* Asserts that there are no gaps in the filmstrip.
* This is useful for detecting layout issues where thumbnails might be missing or mispositioned.
*
* @param reverse - If true, asserts that gaps should exist. Default false.
*/
async assertNoGapsInFilmstrip(reverse = false): Promise<void> {
const hasGaps = await this.hasGapsInFilmstrip();
const expectedResult = reverse ? true : false;
if (hasGaps !== expectedResult) {
throw new Error(
`Expected filmstrip to ${reverse ? 'have' : 'not have'} gaps, but ${
hasGaps ? 'gaps were detected' : 'no gaps were found'
}`
);
}
}
}

View File

@@ -0,0 +1,139 @@
import { LOG_PREFIX } from '../helpers/browserLogger';
import BasePageObject from './BasePageObject';
/**
* The Iframe API and helpers from iframeAPITest.html
*/
export default class IframeAPI extends BasePageObject {
/**
* Returns the json object from the iframeAPI helper.
* @param event
*/
getEventResult(event: string): Promise<any> {
return this.participant.execute(
eventName => {
const result = window.jitsiAPI.test[eventName];
if (!result) {
return false;
}
return result;
}, event);
}
/**
* Clears the history of an event.
* @param event
*/
clearEventResults(event: string) {
return this.participant.execute(
eventName => {
window.jitsiAPI.test[eventName] = undefined;
}, event);
}
/**
* Adds an event listener to the iframeAPI.
* @param eventName The event name.
*/
addEventListener(eventName: string) {
return this.participant.execute(
(event, prefix) => {
// we want to add it once as we use static .test[event] to store the last event
if (window.jitsiAPI.listenerCount(event) > 0) {
return;
}
console.log(`${new Date().toISOString()} ${prefix}iframeAPI - Adding listener for event: ${event}`);
window.jitsiAPI.addListener(event, evt => {
console.log(
`${new Date().toISOString()} ${prefix}iframeAPI - Received ${event} event: ${JSON.stringify(evt)}`);
window.jitsiAPI.test[event] = evt;
});
}, eventName, LOG_PREFIX);
}
/**
* Returns an array of available rooms and details of it.
*/
getRoomsInfo() {
return this.participant.execute(() => window.jitsiAPI.getRoomsInfo());
}
/**
* Returns the number of participants in the conference.
*/
getNumberOfParticipants() {
return this.participant.execute(() => window.jitsiAPI.getNumberOfParticipants());
}
/**
* Executes command using iframeAPI.
* @param command The command.
* @param args The arguments.
*/
executeCommand(command: string, ...args: any[]) {
return this.participant.execute(
(commandName, commandArgs) =>
window.jitsiAPI.executeCommand(commandName, ...commandArgs)
, command, args);
}
/**
* Returns the current state of the participant's pane.
*/
isParticipantsPaneOpen() {
return this.participant.execute(() => window.jitsiAPI.isParticipantsPaneOpen());
}
/**
* Removes the embedded Jitsi Meet conference.
*/
dispose() {
return this.participant.execute(() => window.jitsiAPI.dispose());
}
/**
* Invite the given participant to the meeting via PSTN.
*/
invitePhone(value: string) {
return this.participant.execute(v => window.jitsiAPI.invite([ {
type: 'phone',
number: v
} ]), value);
}
/**
* Invite the given participant to the meeting via sip (sip jibri).
*/
inviteSIP(value: string) {
return this.participant.execute(v => window.jitsiAPI.invite([ {
type: 'sip',
address: v
} ]), value);
}
/**
* Starts a file recording or streaming session.
* @param options
*/
startRecording(options: any) {
return this.participant.execute(o => window.jitsiAPI.startRecording(o), options);
}
/**
* Stop a file recording or streaming session.
* @param mode
*/
stopRecording(mode: string) {
return this.participant.execute(m => window.jitsiAPI.stopRecording(m), mode);
}
/**
* Returns the live-streaming url.
*/
async getLivestreamUrl() {
return this.participant.execute(() => window.jitsiAPI.getLivestreamUrl());
}
}

View File

@@ -0,0 +1,104 @@
import BaseDialog from './BaseDialog';
const CONFERENCE_ID = 'conference-id';
const CONFERENCE_URL = 'invite-more-dialog-conference-url';
const DIALOG_CONTAINER = 'invite-more-dialog';
const MORE_NUMBERS = 'more-numbers';
const PHONE_NUMBER = 'phone-number';
/**
* Represents the invite dialog in a particular participant.
*/
export default class InviteDialog extends BaseDialog {
/**
* Checks if the dialog is open.
*/
isOpen() {
return this.participant.driver.$(`.${DIALOG_CONTAINER}`).isExisting();
}
/**
* Open the invite dialog, if the info dialog is closed.
*/
async open() {
if (await this.isOpen()) {
return;
}
await this.participant.getParticipantsPane().clickInvite();
}
/**
* Returns the PIN for the conference.
*/
async getPinNumber() {
await this.open();
return (await this.getValueAfterColon(CONFERENCE_ID)).replace(/[# ]/g, '');
}
/**
* Private helper to get values after colons. The invite dialog lists conference specific information
* after a label, followed by a colon.
*
* @param className
* @private
*/
private async getValueAfterColon(className: string) {
const elem = this.participant.driver.$(`.${className}`);
await elem.waitForExist({ timeout: 5000 });
const fullText = await elem.getText();
await this.participant.log(`Extracted text in invite dialog: ${fullText}`);
return fullText.split(':')[1].trim();
}
/**
* Returns the meeting url displayed in the dialog.
*/
async getMeetingURL() {
const elem = this.participant.driver.$(`.${CONFERENCE_URL}`);
await elem.waitForExist({
timeout: 5000
});
return (await elem.getText())?.trim();
}
/**
* Waits for the dialog to be open or closed.
* @param reverse
*/
async waitTillOpen(reverse = false) {
await this.participant.driver.waitUntil(
/* eslint-disable no-extra-parens */
async () => (reverse ? !await this.isOpen() : await this.isOpen()),
{
timeout: 2_000,
timeoutMsg: `invite dialog did not ${reverse ? 'close' : 'open'}`
}
);
}
/**
* Gets the string that contains the dial in number for the current conference.
*/
getDialInNumber() {
return this.getValueAfterColon(PHONE_NUMBER);
}
/**
* Clicks the link to open a page to show all available dial in numbers.
*/
async openDialInNumbersPage() {
const moreNumbers = this.participant.driver.$(`.${MORE_NUMBERS}`);
await moreNumbers.waitForExist();
await moreNumbers.waitForClickable();
await moreNumbers.click();
}
}

View File

@@ -0,0 +1,80 @@
import BasePageObject from './BasePageObject';
/**
* The large video.
*/
export default class LargeVideo extends BasePageObject {
/**
* Returns the elapsed time at which video has been playing.
*
* @return {number} - The current play time of the video element.
*/
async getPlaytime() {
return this.participant.driver.$('#largeVideo').getProperty('currentTime');
}
/**
* Waits 5s for the large video to switch to passed endpoint id.
*
* @param {string} endpointId - The endpoint.
* @returns {Promise<void>}
*/
waitForSwitchTo(endpointId: string): Promise<void> {
return this.participant.driver.waitUntil(async () => endpointId === await this.getResource(), {
timeout: 5_000,
timeoutMsg: `expected large video to switch to ${endpointId} for 5s`
});
}
/**
* Gets avatar SRC attribute for the one displayed on large video.
*/
async getAvatar() {
const avatar = this.participant.driver.$('//img[@id="dominantSpeakerAvatar"]');
return await avatar.isExisting() ? await avatar.getAttribute('src') : null;
}
/**
* Returns resource part of the JID of the user who is currently displayed in the large video area.
*/
getResource() {
return this.participant.execute(() => APP?.UI?.getLargeVideoID());
}
/**
* Returns the source of the large video currently shown.
*/
getId() {
return this.participant.execute(() => document.getElementById('largeVideo')?.srcObject?.id);
}
/**
* Checks if the large video is playing or not.
*
* @returns {Promise<void>}
*/
assertPlaying() {
let lastTime: number;
return this.participant.driver.waitUntil(async () => {
const currentTime = parseFloat(await this.getPlaytime());
if (typeof lastTime === 'undefined') {
lastTime = currentTime;
}
if (currentTime > lastTime) {
return true;
}
lastTime = currentTime;
return false;
}, {
timeout: 5_500,
interval: 500,
timeoutMsg:
`Expected large video for participant ${this.participant.name} to play but it didn't for more than 5s`
});
}
}

View File

@@ -0,0 +1,30 @@
import PreMeetingScreen from './PreMeetingScreen';
const DISPLAY_NAME_TEST_ID = 'lobby.nameField';
const JOIN_BUTTON_TEST_ID = 'lobby.knockButton';
/**
* Page object for the Lobby screen.
*/
export default class LobbyScreen extends PreMeetingScreen {
/**
* Returns the join button element.
*/
getJoinButton(): ChainablePromiseElement {
return this.participant.driver.$(`[data-testid="${JOIN_BUTTON_TEST_ID}"]`);
}
/**
* Returns the display name input element.
*/
getDisplayNameInput(): ChainablePromiseElement {
return this.participant.driver.$(`[data-testid="${DISPLAY_NAME_TEST_ID}"]`);
}
/**
* Waits for lobby screen to load.
*/
waitForLoading(): Promise<void> {
return this.participant.driver.$('.lobby-screen').waitForDisplayed({ timeout: 6000 });
}
}

View File

@@ -0,0 +1,296 @@
import BasePageObject from './BasePageObject';
const ASK_TO_UNMUTE_NOTIFICATION_ID = 'notify.hostAskedUnmute';
const AV_MODERATION_MUTED_NOTIFICATION_ID = 'notify.moderationInEffectTitle';
const AV_MODERATION_VIDEO_MUTED_NOTIFICATION_ID = 'notify.moderationInEffectVideoTitle';
const JOIN_MULTIPLE_TEST_ID = 'notify.connectedThreePlusMembers';
const JOIN_ONE_TEST_ID = 'notify.connectedOneMember';
const JOIN_TWO_TEST_ID = 'notify.connectedTwoMembers';
const LOBBY_ACCESS_DENIED_TEST_ID = 'lobby.joinRejectedMessage';
const LOBBY_ENABLED_TEST_ID = 'lobby.notificationLobbyEnabled';
const LOBBY_KNOCKING_PARTICIPANT_NOTIFICATION_XPATH
= '//div[@data-testid="notify.participantWantsToJoin"]/div/div/span';
const LOBBY_NOTIFICATIONS_TITLE_TEST_ID = 'lobby.notificationTitle';
const LOBBY_PARTICIPANT_ACCESS_DENIED_TEST_ID = 'lobby.notificationLobbyAccessDenied';
const LOBBY_PARTICIPANT_ACCESS_GRANTED_TEST_ID = 'lobby.notificationLobbyAccessGranted';
const LOBBY_PARTICIPANT_ADMIT_TEST_ID = 'participantsPane.actions.admit';
const LOBBY_PARTICIPANT_REJECT_TEST_ID = 'participantsPane.actions.reject';
const RAISE_HAND_NOTIFICATION_ID = 'notify.raisedHand';
const REENABLE_SELF_VIEW_CLOSE_NOTIFICATION = 'notify.selfViewTitle-dismiss';
const REENABLE_SELF_VIEW_NOTIFICATION_ID = 'notify.selfViewTitle';
const YOU_ARE_MUTED_TEST_ID = 'notify.mutedTitle';
export const MAX_USERS_TEST_ID = 'dialog.maxUsersLimitReached';
export const TOKEN_AUTH_FAILED_TEST_ID = 'dialog.tokenAuthFailed';
export const TOKEN_AUTH_FAILED_TITLE_TEST_ID = 'dialog.tokenAuthFailedTitle';
/**
* Gathers all notifications logic in the UI and obtaining those.
*/
export default class Notifications extends BasePageObject {
/**
* Waits for the raised hand notification to be displayed.
* The notification on moderators page when the participant tries to unmute.
*/
async waitForRaisedHandNotification() {
const displayNameEl
= this.participant.driver.$(`div[data-testid="${RAISE_HAND_NOTIFICATION_ID}"]`);
await displayNameEl.waitForExist({ timeout: 2000 });
await displayNameEl.waitForDisplayed();
}
/**
* The notification on participants page when the moderator asks to unmute.
*/
async waitForAskToUnmuteNotification() {
const displayNameEl
= this.participant.driver.$(`div[data-testid="${ASK_TO_UNMUTE_NOTIFICATION_ID}"]`);
await displayNameEl.waitForExist({ timeout: 2000 });
await displayNameEl.waitForDisplayed();
}
/**
* Closes the muted notification.
*/
async closeAVModerationMutedNotification(skipNonExisting = false) {
return this.closeNotification(AV_MODERATION_MUTED_NOTIFICATION_ID, skipNonExisting);
}
/**
* Closes the video muted notification.
*/
async closeAVModerationVideoMutedNotification(skipNonExisting = false) {
return this.closeNotification(AV_MODERATION_VIDEO_MUTED_NOTIFICATION_ID, skipNonExisting);
}
/**
* Closes the ask to unmute notification.
*/
async closeAskToUnmuteNotification(skipNonExisting = false) {
return this.closeNotification(ASK_TO_UNMUTE_NOTIFICATION_ID, skipNonExisting);
}
/**
* Dismisses any join notifications.
*/
dismissAnyJoinNotification() {
return Promise.allSettled(
[ `${JOIN_ONE_TEST_ID}-dismiss`, `${JOIN_TWO_TEST_ID}-dismiss`, `${JOIN_MULTIPLE_TEST_ID}-dismiss` ]
.map(id => this.participant.driver.$(`#${id}"]`).click()));
}
/**
* Waits for the self view notification to be displayed.
*/
async waitForReEnableSelfViewNotification() {
const el
= this.participant.driver.$(`div[data-testid="${REENABLE_SELF_VIEW_NOTIFICATION_ID}"]`);
await el.waitForExist({ timeout: 2000 });
await el.waitForDisplayed();
}
/**
* Closes the self view notification.
*/
closeReEnableSelfViewNotification() {
return this.participant.driver.$(`div[data-testid="${REENABLE_SELF_VIEW_CLOSE_NOTIFICATION}"]`).click();
}
/**
* The notification on participants page when Lobby is being enabled or disabled.
*/
getLobbyEnabledText() {
return this.waitForNotificationText(LOBBY_ENABLED_TEST_ID);
}
/**
* Closes a specific lobby notification.
* @param testId
* @param skipNonExisting
* @private
*/
private async closeNotification(testId: string, skipNonExisting = false) {
const closeButton = this.participant.driver.$(`[data-testid="${testId}"] #close-notification`);
try {
if (skipNonExisting && !await closeButton.isExisting()) {
return Promise.resolve();
}
await closeButton.moveTo();
await closeButton.click();
} catch (e) {
console.error(`Error closing notification ${testId}`, e);
if (!skipNonExisting) {
throw e;
}
}
}
/**
* Closes a specific lobby notification.
* @param testId
* @private
*/
private async closeLobbyNotification(testId: string) {
const notification = this.participant.driver.$(`[data-testid="${testId}"]`);
await notification.waitForExist();
await notification.waitForStable();
const closeButton
= this.participant.driver.$(`[data-testid="${LOBBY_NOTIFICATIONS_TITLE_TEST_ID}"]`)
.$('#close-notification');
await closeButton.moveTo();
await closeButton.click();
}
/**
* Closes the notification.
*/
closeLobbyEnabled() {
return this.closeLobbyNotification(LOBBY_ENABLED_TEST_ID);
}
/**
* Returns the name of the knocking participant (the only one) that is displayed on the notification.
*/
async getKnockingParticipantName() {
const knockingParticipantNotification
= this.participant.driver.$('//div[@data-testid="notify.participantWantsToJoin"]/div/div/span');
await knockingParticipantNotification.waitForDisplayed({
timeout: 15000,
timeoutMsg: 'Knocking participant notification not displayed'
});
return await knockingParticipantNotification.getText();
}
/**
* Admits the last knocking participant (it is the only one).
*/
async allowLobbyParticipant() {
const admitButton
= this.participant.driver.$(`[data-testid="${LOBBY_PARTICIPANT_ADMIT_TEST_ID}"]`);
await admitButton.waitForExist();
await admitButton.waitForClickable();
await admitButton.click();
}
/**
* The notification that someone's access was approved.
*/
getLobbyParticipantAccessGranted() {
return this.waitForNotificationText(LOBBY_PARTICIPANT_ACCESS_GRANTED_TEST_ID);
}
/**
* Closes the notification.
*/
closeLobbyParticipantAccessGranted() {
return this.closeLobbyNotification(LOBBY_PARTICIPANT_ACCESS_GRANTED_TEST_ID);
}
/**
* Waits until a notifications with the given testId is found and returns its text.
* @return the notification text.
*/
private async waitForNotificationText(testId: string) {
const notificationElement = this.participant.driver.$(`[data-testid="${testId}"]`);
await notificationElement.waitForExist({ timeout: 2_000 });
return await notificationElement.getText();
}
/**
* Gets the text from the notification with the given testId, if the notification exists (otherwise returns
* undefined).
* @param testId
* @return the notification text.
*/
async getNotificationText(testId: string) {
const notificationElement = this.participant.driver.$(`[data-testid="${testId}"]`);
if (await notificationElement.isExisting()) {
return await notificationElement.getText();
}
}
/**
* Rejects the last knocking participant (it is the only one).
*/
async rejectLobbyParticipant() {
const admitButton
= this.participant.driver.$(`[data-testid="${LOBBY_PARTICIPANT_REJECT_TEST_ID}"]`);
await admitButton.waitForExist();
await admitButton.waitForClickable();
await admitButton.click();
}
/**
* The notification test that someone's access was denied.
*/
getLobbyParticipantAccessDenied() {
return this.waitForNotificationText(LOBBY_PARTICIPANT_ACCESS_DENIED_TEST_ID);
}
/**
* Closes the notification.
*/
closeLobbyParticipantAccessDenied() {
return this.closeLobbyNotification(LOBBY_PARTICIPANT_ACCESS_DENIED_TEST_ID);
}
/**
* Waits for the notification for access denied for entering the lobby is shown.
*/
async waitForLobbyAccessDeniedNotification() {
const displayNameEl
= this.participant.driver.$(`div[data-testid="${LOBBY_ACCESS_DENIED_TEST_ID}"]`);
await displayNameEl.waitForExist({ timeout: 2000 });
await displayNameEl.waitForDisplayed();
}
/**
* Will wait 3 seconds for the knocking participants to disappear and return true or will return false.
* @return <tt>true</tt> if the knocking participants list was not displayed.
*/
waitForHideOfKnockingParticipants() {
return this.participant.driver.$(LOBBY_KNOCKING_PARTICIPANT_NOTIFICATION_XPATH)
.waitForDisplayed({
timeout: 3000,
reverse: true
});
}
/**
* Closes local notification, for the participant that was denied.
*/
async closeLocalLobbyAccessDenied() {
await this.participant.driver.$('[data-testid="lobby.joinRejectedMessage"').waitForExist();
const dismissButton
= this.participant.driver.$('[data-testid="lobby.joinRejectedTitle-dismiss"]');
await dismissButton.moveTo();
await dismissButton.click();
}
/**
* Closes the `you are muted` notification.
*/
async closeYouAreMutedNotification() {
return this.closeNotification(YOU_ARE_MUTED_TEST_ID, true);
}
}

View File

@@ -0,0 +1,308 @@
import { ChainablePromiseElement } from 'webdriverio';
import { Participant } from '../helpers/Participant';
import AVModerationMenu from './AVModerationMenu';
import BasePageObject from './BasePageObject';
/**
* ID of the closed/hidden participants pane
*/
const PARTICIPANTS_PANE = 'participants-pane';
const INVITE = 'Invite someone';
/**
* Represents the participants pane from the UI.
*/
export default class ParticipantsPane extends BasePageObject {
/**
* Gets the audio video moderation menu.
*/
getAVModerationMenu() {
return new AVModerationMenu(this.participant);
}
/**
* Checks if the pane is open.
*/
isOpen() {
return this.participant.driver.$(`#${PARTICIPANTS_PANE}`).isExisting();
}
/**
* Clicks the "participants" toolbar button to open the participants pane.
*/
async open() {
await this.participant.getToolbar().clickParticipantsPaneButton();
const pane = this.participant.driver.$(`#${PARTICIPANTS_PANE}`);
await pane.waitForExist();
await pane.waitForStable();
await pane.waitForDisplayed();
}
/**
* Clicks the "participants" toolbar button to close the participants pane.
*/
async close() {
await this.participant.getToolbar().clickCloseParticipantsPaneButton();
await this.participant.driver.$(`#${PARTICIPANTS_PANE}`).waitForDisplayed({ reverse: true });
}
/**
* Asserts that {@code participant} shows or doesn't show the video mute icon for the conference participant
* identified by {@code testee}.
*
* @param {Participant} testee - The {@code Participant} for whom we're checking the status of audio muted icon.
* @param {boolean} reverse - If {@code true}, the method will assert the absence of the "mute" icon;
* otherwise, it will assert its presence.
* @returns {Promise<void>}
*/
async assertVideoMuteIconIsDisplayed(testee: Participant, reverse = false): Promise<void> {
const isOpen = await this.isOpen();
if (!isOpen) {
await this.open();
}
const id = `participant-item-${await testee.getEndpointId()}`;
const mutedIconXPath
= `//div[@id='${id}']//div[contains(@class, 'indicators')]//*[local-name()='svg' and @id='videoMuted']`;
await this.participant.driver.$(mutedIconXPath).waitForDisplayed({
reverse,
timeout: 2000,
timeoutMsg: `Video mute icon is${reverse ? '' : ' not'} displayed for ${testee.name} at ${
this.participant.name} side.`
});
if (!isOpen) {
await this.close();
}
}
/**
* Asserts that {@code participant} shows or doesn't show the video mute icon for the conference participant
* identified by {@code testee}.
*
* @param {Participant} testee - The {@code Participant} for whom we're checking the status of audio muted icon.
* @param {boolean} reverse - If {@code true}, the method will assert the absence of the "mute" icon;
* otherwise, it will assert its presence.
* @returns {Promise<void>}
*/
async assertAudioMuteIconIsDisplayed(testee: Participant, reverse = false): Promise<void> {
const isOpen = await this.isOpen();
if (!isOpen) {
await this.open();
}
const id = `participant-item-${await testee.getEndpointId()}`;
const mutedIconXPath
= `//div[@id='${id}']//div[contains(@class, 'indicators')]//*[local-name()='svg' and @id='audioMuted']`;
await this.participant.driver.$(mutedIconXPath).waitForDisplayed({
reverse,
timeout: 2000,
timeoutMsg: `Audio mute icon is${reverse ? '' : ' not'} displayed for ${testee.name} at ${
this.participant.name} side.`
});
if (!isOpen) {
await this.close();
}
}
/**
* Clicks the context menu button in the participants pane.
*/
async clickContextMenuButton() {
if (!await this.isOpen()) {
await this.open();
}
const menu = this.participant.driver.$('#participants-pane-context-menu');
await menu.waitForDisplayed();
await menu.click();
}
/**
* Trys to click allow video button.
* @param participantToUnmute
*/
async allowVideo(participantToUnmute: Participant) {
if (!await this.isOpen()) {
await this.open();
}
const participantId = await participantToUnmute.getEndpointId();
await this.selectParticipant(participantToUnmute);
await this.openParticipantContextMenu(participantToUnmute);
const unmuteButton = this.participant.driver
.$(`[data-testid="unmute-video-${participantId}"]`);
await unmuteButton.waitForExist();
await unmuteButton.click();
}
/**
* Trys to click ask to unmute button.
* @param participantToUnmute
* @param fromContextMenu
*/
async askToUnmute(participantToUnmute: Participant, fromContextMenu: boolean) {
if (!await this.isOpen()) {
await this.open();
}
await this.participant.getNotifications().dismissAnyJoinNotification();
const participantId = await participantToUnmute.getEndpointId();
await this.selectParticipant(participantToUnmute);
if (fromContextMenu) {
await this.openParticipantContextMenu(participantToUnmute);
}
const unmuteButton = this.participant.driver
.$(`[data-testid="unmute-audio-${participantId}"]`);
await unmuteButton.waitForExist();
await unmuteButton.click();
}
/**
* Open context menu for given participant.
*/
async selectParticipant(participant: Participant) {
const participantId = await participant.getEndpointId();
const participantItem = this.participant.driver.$(`#participant-item-${participantId}`);
await participantItem.waitForExist();
await participantItem.waitForStable();
await participantItem.waitForDisplayed();
await participantItem.moveTo();
}
/**
* Open context menu for given participant.
*/
async openParticipantContextMenu(participant: Participant) {
const participantId = await participant.getEndpointId();
const meetingParticipantMoreOptions = this.participant.driver
.$(`[data-testid="participant-more-options-${participantId}"]`);
await meetingParticipantMoreOptions.waitForExist();
await meetingParticipantMoreOptions.waitForDisplayed();
await meetingParticipantMoreOptions.waitForStable();
await meetingParticipantMoreOptions.moveTo();
// The button is only revealed (moved into view) while its participant row is actually hovered.
// waitForDisplayed/waitForStable above do not guarantee that, so wait for real interactability
// right before clicking, retrying the hover if a re-render dropped it in the meantime.
await meetingParticipantMoreOptions.waitForClickable();
await meetingParticipantMoreOptions.click();
}
/**
* Clicks the invite button.
*/
async clickInvite() {
if (!await this.isOpen()) {
await this.open();
}
const inviteButton = this.participant.driver.$(`button=${INVITE}`);
await inviteButton.waitForDisplayed();
await inviteButton.click();
}
/**
* Find the participant by name.
* @param name - The name to look for.
* @private
*/
private async findLobbyParticipantByName(name: string): Promise<ChainablePromiseElement> {
return this.participant.driver.$$('//div[@id="lobby-list"]//div[starts-with(@id, "participant-item-")]')
.find(async participant => (await participant.getText()).includes(name));
}
/**
* Tries to click on the approve button and fails if it cannot be clicked.
* @param participantNameToAdmit - the name of the participant to admit.
*/
async admitLobbyParticipant(participantNameToAdmit: string) {
const participantToAdmit = await this.findLobbyParticipantByName(participantNameToAdmit);
await participantToAdmit.moveTo();
const participantIdToAdmit = (await participantToAdmit.getAttribute('id'))
.substring('participant-item-'.length);
const admitButton = this.participant.driver
.$(`[data-testid="admit-${participantIdToAdmit}"]`);
await admitButton.waitForExist();
await admitButton.click();
}
/**
* Tries to click on the reject button and fails if it cannot be clicked.
* @param participantNameToReject - the name of the participant for this {@link ParticipantsPane} to reject.
*/
async rejectLobbyParticipant(participantNameToReject: string) {
const participantToReject
= await this.findLobbyParticipantByName(participantNameToReject);
await participantToReject.moveTo();
const participantIdToReject = (await participantToReject.getAttribute('id'))
.substring('participant-item-'.length);
const moreOptionsButton
= this.participant.driver.$(`button[title="More moderation options ${participantNameToReject}"]`);
await moreOptionsButton.click();
const rejectButton = this.participant.driver
.$(`[data-testid="reject-${participantIdToReject}"]`);
await rejectButton.waitForExist();
await rejectButton.click();
}
/**
* Waits for the mute audio action of a participant to be available.
* @param participant
*/
async waitForMuteAudioAction(participant: Participant) {
if (!await this.isOpen()) {
await this.open();
}
const participantId = await participant.getEndpointId();
await this.participant.driver.$(`#participant-item-${participantId}`).moveTo();
await this.participant.driver.$(`button[data-testid="mute-audio-${participantId}"]`).waitForDisplayed();
}
/**
* Mutes the audio of a participant.
* @param participant
*/
async muteAudio(participant: Participant) {
const participantId = await participant.getEndpointId();
await this.participant.driver.$(`#participant-item-${participantId}`).moveTo();
await this.participant.driver.$(`button[data-testid="mute-audio-${participantId}"]`).click();
}
}

View File

@@ -0,0 +1,51 @@
import BaseDialog from './BaseDialog';
const INPUT_KEY_XPATH = '//input[@name="lockKey"]';
/**
* Represents the password dialog in a particular participant.
*/
export default class PasswordDialog extends BaseDialog {
/**
* Waiting for the dialog to appear.
*/
async waitForDialog() {
const input = this.participant.driver.$(INPUT_KEY_XPATH);
await input.waitForExist({
timeout: 5000,
timeoutMsg: 'Password dialog not found'
});
await input.waitForDisplayed();
await input.waitForStable();
}
async isOpen() {
const input = this.participant.driver.$(INPUT_KEY_XPATH);
try {
await input.isExisting();
return await input.isDisplayed();
} catch (e) {
return false;
}
}
/**
* Sets a password and submits the dialog.
* @param password
*/
async submitPassword(password: string) {
const passwordInput = this.participant.driver.$(INPUT_KEY_XPATH);
await passwordInput.waitForExist();
await passwordInput.waitForStable();
await passwordInput.click();
await passwordInput.clearValue();
await this.participant.driver.keys(password);
await this.clickOkButton();
}
}

View File

@@ -0,0 +1,55 @@
import PreMeetingScreen from './PreMeetingScreen';
const DISPLAY_NAME_ID = 'premeeting-name-input';
const ERROR_ON_JOIN = 'prejoin.errorMessage';
const JOIN_BUTTON_TEST_ID = 'prejoin.joinMeeting';
const JOIN_WITHOUT_AUDIO = 'prejoin.joinWithoutAudio';
const OPTIONS_BUTTON = 'prejoin.joinOptions';
/**
* Page object for the PreJoin screen.
*/
export default class PreJoinScreen extends PreMeetingScreen {
/**
* Returns the join button element.
*/
getJoinButton(): ChainablePromiseElement {
return this.participant.driver.$(`[data-testid="${JOIN_BUTTON_TEST_ID}"]`);
}
/**
* Returns the display name input element.
*/
getDisplayNameInput(): ChainablePromiseElement {
return this.participant.driver.$(`#${DISPLAY_NAME_ID}`);
}
/**
* Waits for pre join screen to load.
*/
waitForLoading(): Promise<void> {
return this.participant.driver.$('[data-testid="prejoin.screen"]')
.waitForDisplayed({ timeout: 3000 });
}
/**
* Returns the error message displayed on the prejoin screen.
*/
getErrorOnJoin() {
return this.participant.driver.$(`[data-testid="${ERROR_ON_JOIN}"]`);
}
/**
* Returns the join without audio button element.
*/
getJoinWithoutAudioButton() {
return this.participant.driver.$(`[data-testid="${JOIN_WITHOUT_AUDIO}"]`);
}
/**
* Returns the join options button element.
*/
getJoinOptions() {
return this.participant.driver.$(`[data-testid="${OPTIONS_BUTTON}"]`);
}
}

View File

@@ -0,0 +1,90 @@
import BasePageObject from './BasePageObject';
const PASSWORD_BUTTON_TEST_ID = 'lobby.enterPasswordButton';
/**
* Page object for the PreMeeting screen, common stuff between pre-join and lobby screens.
*/
export default abstract class PreMeetingScreen extends BasePageObject {
/**
* Waits for pre join or lobby screen to load.
*/
abstract waitForLoading(): Promise<void>;
/**
* Returns the display name input element.
*/
abstract getDisplayNameInput(): WebdriverIO.Element;
/**
* Returns the join button element.
*/
abstract getJoinButton(): WebdriverIO.Element;
/**
* Interacts with the view to enter a display name.
*/
async enterDisplayName(displayName: string) {
const displayNameInput = this.getDisplayNameInput();
await displayNameInput.click();
// element.clear does not always work, make sure we delete the content
await displayNameInput.clearValue();
await this.participant.driver.keys(displayName);
}
/**
* Checks internally whether lobby room is joined.
*
* @returns {Promise<boolean>}
*/
waitToJoinLobby(): Promise<boolean> {
return this.participant.driver.waitUntil(
() => this.isLobbyRoomJoined(),
{
timeout: 15_000, // 15 seconds
timeoutMsg: `Timeout waiting to join lobby for ${this.participant.name}`
}
);
}
/**
* Checks internally whether lobby room is joined.
*/
isLobbyRoomJoined() {
return this.participant.execute(
() => APP?.conference?._room?.room?.getLobby()?.lobbyRoom?.joined === true);
}
/**
* Returns the password button element.
*/
getPasswordButton() {
return this.participant.driver.$(`[data-testid="${PASSWORD_BUTTON_TEST_ID}"]`);
}
/**
* Interacts with the view to enter a password.
*/
async enterPassword(password: string) {
const passwordButton = this.getPasswordButton();
await passwordButton.moveTo();
await passwordButton.click();
const passwordInput = this.participant.driver.$('[data-testid="lobby.password"]');
await passwordInput.waitForDisplayed();
await passwordInput.click();
await passwordInput.clearValue();
await this.participant.driver.keys(password);
const joinButton = this.participant.driver.$('[data-testid="lobby.passwordJoinButton"]');
await joinButton.waitForDisplayed();
await joinButton.click();
}
}

View File

@@ -0,0 +1,342 @@
import BasePageObject from './BasePageObject';
const DIALOG_TITLE = '#dialog-title';
const CLOSE_BUTTON = '#modal-header-close-button';
const START_RECORDING = '[data-testid="recordingDialog.startRecording"]';
const STOP_RECORDING = '[data-testid="recordingDialog.stopRecording"]';
const START_TRANSCRIPTION = '[data-testid="recordingDialog.startTranscription"]';
const STOP_TRANSCRIPTION = '[data-testid="recordingDialog.stopTranscription"]';
const START_BOTH = '[data-testid="recordingDialog.startBoth"]';
const STOP_BOTH = '[data-testid="recordingDialog.stopBoth"]';
const RECORDING_OPTIONS_HEADER = '#recording-options';
const RECORDING_OPTIONS_BODY = '#recording-options-content';
const TRANSCRIPTION_OPTIONS_HEADER = '#transcription-options';
const SERVICE_SELECT = '#recording-service-select';
const SERVICE_SELECT_MENU = '#recording-service-select-menu';
const LANGUAGE_SELECT = '#transcription-language-select';
const LANGUAGE_SELECT_MENU = '#transcription-language-select-menu';
const FOLLOW_ME_RECORDER_SWITCH = '#recording-switch-follow-me';
const FILE_SHARING_SWITCH = '#recording-switch-share';
const LOCAL_RECORDING_ONLY_SELF_SWITCH = '#recording-switch-myself';
/**
* Page object for the unified Recording & Transcription dialog: two sections
* (audio & video recording, transcription), each with its own immediate
* start/stop button and a collapsible options accordion, plus footer buttons
* acting on both services at once.
*/
export default class RecordingTranscriptionDialog extends BasePageObject {
/**
* Waits for the dialog to be displayed.
*/
async waitForDisplay(): Promise<void> {
await this.participant.driver.$(DIALOG_TITLE).waitForExist({
timeout: 5000,
timeoutMsg: 'Recording & Transcription dialog did not appear'
});
}
/**
* Returns whether the dialog is currently open.
*/
async isDisplayed(): Promise<boolean> {
return this.participant.driver.$(DIALOG_TITLE).isExisting();
}
/**
* Returns the dialog title text.
*/
getTitle(): Promise<string> {
return this.participant.driver.$(DIALOG_TITLE).getText();
}
/**
* Whether the recording section currently offers a Start button, i.e.
* recording is not running.
*/
hasStartRecordingButton(): Promise<boolean> {
return this.participant.driver.$(START_RECORDING).isExisting();
}
/**
* Whether the recording section currently offers a Stop button, i.e.
* recording is running.
*/
hasStopRecordingButton(): Promise<boolean> {
return this.participant.driver.$(STOP_RECORDING).isExisting();
}
/**
* Whether the transcription section currently offers a Start button.
*/
hasStartTranscriptionButton(): Promise<boolean> {
return this.participant.driver.$(START_TRANSCRIPTION).isExisting();
}
/**
* Whether the transcription section currently offers a Stop button.
*/
hasStopTranscriptionButton(): Promise<boolean> {
return this.participant.driver.$(STOP_TRANSCRIPTION).isExisting();
}
/**
* Whether the footer shows the "Start both" button.
*/
hasStartBothButton(): Promise<boolean> {
return this.participant.driver.$(START_BOTH).isExisting();
}
/**
* Whether the footer shows the "Stop both" button.
*/
hasStopBothButton(): Promise<boolean> {
return this.participant.driver.$(STOP_BOTH).isExisting();
}
/**
* Whether the recording section Start button is enabled (a usable storage
* service is selected).
*/
isStartRecordingEnabled(): Promise<boolean> {
return this.participant.driver.$(START_RECORDING).isEnabled();
}
/**
* Whether the footer "Start both" button is enabled.
*/
isStartBothEnabled(): Promise<boolean> {
return this.participant.driver.$(START_BOTH).isEnabled();
}
/**
* Starts the audio & video recording. The action applies immediately and
* closes the dialog.
*/
async startRecording(): Promise<void> {
await this.participant.log('RecordingTranscriptionDialog: clicking start recording');
return this.participant.driver.$(START_RECORDING).click();
}
/**
* Stops the audio & video recording. The action applies immediately and
* closes the dialog.
*/
async stopRecording(): Promise<void> {
await this.participant.log('RecordingTranscriptionDialog: clicking stop recording');
return this.participant.driver.$(STOP_RECORDING).click();
}
/**
* Starts the transcription. The action applies immediately and closes the
* dialog.
*/
async startTranscription(): Promise<void> {
await this.participant.log('RecordingTranscriptionDialog: clicking start transcription');
return this.participant.driver.$(START_TRANSCRIPTION).click();
}
/**
* Stops the transcription. The action applies immediately and closes the
* dialog.
*/
async stopTranscription(): Promise<void> {
await this.participant.log('RecordingTranscriptionDialog: clicking stop transcription');
return this.participant.driver.$(STOP_TRANSCRIPTION).click();
}
/**
* Starts every service which is not running yet.
*/
async startBoth(): Promise<void> {
await this.participant.log('RecordingTranscriptionDialog: clicking start both');
return this.participant.driver.$(START_BOTH).click();
}
/**
* Stops every running service.
*/
async stopBoth(): Promise<void> {
await this.participant.log('RecordingTranscriptionDialog: clicking stop both');
return this.participant.driver.$(STOP_BOTH).click();
}
/**
* Whether the recording options accordion exists.
*/
hasRecordingOptions(): Promise<boolean> {
return this.participant.driver.$(RECORDING_OPTIONS_HEADER).isExisting();
}
/**
* Whether the transcription options accordion exists.
*/
hasTranscriptionOptions(): Promise<boolean> {
return this.participant.driver.$(TRANSCRIPTION_OPTIONS_HEADER).isExisting();
}
/**
* Whether the recording options accordion is expanded.
*/
async isRecordingOptionsExpanded(): Promise<boolean> {
return await this.participant.driver.$(RECORDING_OPTIONS_HEADER)
.getAttribute('aria-expanded') === 'true';
}
/**
* Whether the transcription options accordion is expanded.
*/
async isTranscriptionOptionsExpanded(): Promise<boolean> {
return await this.participant.driver.$(TRANSCRIPTION_OPTIONS_HEADER)
.getAttribute('aria-expanded') === 'true';
}
/**
* Expands/collapses the recording options accordion.
*/
toggleRecordingOptions(): Promise<void> {
return this.participant.driver.$(RECORDING_OPTIONS_HEADER).click();
}
/**
* Expands/collapses the transcription options accordion.
*/
toggleTranscriptionOptions(): Promise<void> {
return this.participant.driver.$(TRANSCRIPTION_OPTIONS_HEADER).click();
}
/**
* Whether the storage service dropdown is visible (recording options
* expanded).
*/
hasServiceSelect(): Promise<boolean> {
return this.participant.driver.$(SERVICE_SELECT).isExisting();
}
/**
* Whether the transcription language dropdown is visible (transcription
* options expanded).
*/
hasLanguageSelect(): Promise<boolean> {
return this.participant.driver.$(LANGUAGE_SELECT).isExisting();
}
/**
* The currently selected storage service, as displayed on the dropdown
* trigger.
*/
getSelectedService(): Promise<string> {
return this.participant.driver.$(SERVICE_SELECT).getText();
}
/**
* The currently selected transcription language, as displayed on the
* dropdown trigger.
*/
getSelectedLanguage(): Promise<string> {
return this.participant.driver.$(LANGUAGE_SELECT).getText();
}
/**
* Picks a storage service by its visible label (e.g. "Recording service",
* "Dropbox", "Local recording"). The recording options accordion must be
* expanded.
*/
selectService(label: string): Promise<void> {
return this._selectDropdownOption(SERVICE_SELECT, SERVICE_SELECT_MENU, label);
}
/**
* Picks a transcription language by its visible label (e.g. "English").
* The transcription options accordion must be expanded.
*/
selectLanguage(label: string): Promise<void> {
return this._selectDropdownOption(LANGUAGE_SELECT, LANGUAGE_SELECT_MENU, label);
}
/**
* Whether the "Recorder follows me" switch is shown (moderator with a
* cloud based service selected).
*/
hasFollowMeRecorderSwitch(): Promise<boolean> {
return this.participant.driver.$(FOLLOW_ME_RECORDER_SWITCH).isExisting();
}
/**
* Whether the "Recorder follows me" switch is enabled (it locks while a
* recording is in progress).
*/
isFollowMeRecorderEnabled(): Promise<boolean> {
return this.participant.driver.$(FOLLOW_ME_RECORDER_SWITCH).isEnabled();
}
/**
* Whether the "Share the recording link" switch is shown (Jitsi
* recording service selected and sharing is enabled server-side).
*/
hasFileSharingSwitch(): Promise<boolean> {
return this.participant.driver.$(FILE_SHARING_SWITCH).isExisting();
}
/**
* Whether the "Record only my audio and video streams" switch is shown
* (local recording selected).
*/
hasLocalRecordingOnlySelfSwitch(): Promise<boolean> {
return this.participant.driver.$(LOCAL_RECORDING_ONLY_SELF_SWITCH).isExisting();
}
/**
* Toggles the "Record only my audio and video streams" switch.
*/
toggleLocalRecordingOnlySelfSwitch(): Promise<void> {
return this.participant.driver.$(LOCAL_RECORDING_ONLY_SELF_SWITCH).click();
}
/**
* The text content of the (expanded) recording options accordion body,
* e.g. to check for the local-recording warning texts.
*/
getRecordingOptionsBodyText(): Promise<string> {
return this.participant.driver.$(RECORDING_OPTIONS_BODY).getText();
}
/**
* Clicks the close (X) button to dismiss the dialog.
*/
cancel(): Promise<void> {
return this.participant.driver.$(CLOSE_BUTTON).click();
}
/**
* Opens the given dropdown and picks the option with the given visible
* label.
*/
private async _selectDropdownOption(
triggerSelector: string, menuSelector: string, optionLabel: string): Promise<void> {
const driver = this.participant.driver;
await driver.$(triggerSelector).click();
await driver.$(menuSelector).waitForExist({
timeout: 3000,
timeoutMsg: 'Dropdown menu did not open'
});
const option = driver.$(menuSelector).$(`[role="menuitem"][aria-label="${optionLabel}"]`);
await option.waitForExist({
timeout: 3000,
timeoutMsg: `Dropdown option "${optionLabel}" not found`
});
await option.click();
}
}

View File

@@ -0,0 +1,134 @@
import BaseDialog from './BaseDialog';
const ADD_PASSWORD_LINK = 'add-password';
const ADD_PASSWORD_FIELD = 'info-password-input';
const DIALOG_CONTAINER = 'security-dialog';
const LOCAL_LOCK = 'info-password-local';
const REMOTE_LOCK = 'info-password-remote';
const REMOVE_PASSWORD = 'remove-password';
/**
* Page object for the security dialog.
*/
export default class SecurityDialog extends BaseDialog {
/**
* Waits for the settings dialog to be visible.
*/
waitForDisplay() {
return this.participant.driver.$(`.${DIALOG_CONTAINER}`).waitForDisplayed();
}
/**
* Returns the switch that can be used to detect lobby state or change lobby state.
* @private
*/
private getLobbySwitch() {
return this.participant.driver.$('#lobby-section-switch');
}
/**
* Returns is the lobby enabled.
*/
isLobbyEnabled() {
return this.getLobbySwitch().isSelected();
}
/**
* Toggles the lobby option from the security dialog.
*/
async toggleLobby() {
const lobbySwitch = this.getLobbySwitch();
await lobbySwitch.moveTo();
await lobbySwitch.click();
}
/**
* Checks whether lobby section is present in the UI.
*/
isLobbySectionPresent() {
return this.getLobbySwitch().isExisting();
}
/**
* Waits for the lobby to be enabled or disabled.
* @param reverse
*/
waitForLobbyEnabled(reverse = false) {
const lobbySwitch = this.getLobbySwitch();
return this.participant.driver.waitUntil(
async () => await lobbySwitch.isSelected() !== reverse,
{
timeout: 5_000, // 30 seconds
timeoutMsg: `Timeout waiting for lobby being ${reverse ? 'disabled' : 'enabled'} for ${
this.participant.name}.`
}
);
}
/**
* Checks if the current conference is locked with a locally set password.
*
* @return {@code true} if the conference is displayed as locked locally in
* the security dialog, {@code false} otherwise.
*/
private isLockedLocally() {
return this.participant.driver.$(`.${LOCAL_LOCK}`).isExisting();
}
/**
* Checks if the current conference is locked with a locally set password.
*
* @return {@code true} if the conference is displayed as locked remotely
* in the security dialog, {@code false} otherwise.
*/
private isLockedRemotely() {
return this.participant.driver.$(`.${REMOTE_LOCK}`).isExisting();
}
/**
* Checks if the current conference is locked based on the security dialog's
* display state.
*
* @return {@code true} if the conference is displayed as locked in the
* security dialog, {@code false} otherwise.
*/
async isLocked() {
return await this.isLockedLocally() || await this.isLockedRemotely();
}
/**
* Sets a password on the current conference to lock it.
*
* @param password - The password to use to lock the conference.
*/
async addPassword(password: string) {
const addPasswordLink = this.participant.driver.$(`.${ADD_PASSWORD_LINK}`);
await addPasswordLink.waitForClickable();
await addPasswordLink.click();
const passwordEntry = this.participant.driver.$(`#${ADD_PASSWORD_FIELD}`);
await passwordEntry.waitForDisplayed();
await passwordEntry.click();
await this.participant.driver.keys(password);
await this.participant.driver.$('button=Add').click();
}
/**
* Removes the password from the current conference through the security dialog, if a password is set.
*/
async removePassword() {
if (!await this.isLocked()) {
return;
}
const removePassword = this.participant.driver.$(`.${REMOVE_PASSWORD}`);
await removePassword.waitForClickable();
await removePassword.click();
}
}

View File

@@ -0,0 +1,197 @@
import BaseDialog from './BaseDialog';
const EMAIL_FIELD = '#setEmail';
const CTRL_ALT_REACTION_SHORTCUTS_CHECKBOX = '//input[@name="enable-ctrl-alt-reaction-shortcuts"]';
const FOLLOW_ME_CHECKBOX = '//input[@name="follow-me"]';
const HIDE_SELF_VIEW_CHECKBOX = '//input[@name="hide-self-view"]';
const SETTINGS_DIALOG_CONTENT = '.settings-pane';
const START_AUDIO_MUTED_CHECKBOX = '//input[@name="start-audio-muted"]';
const START_VIDEO_MUTED_CHECKBOX = '//input[@name="start-video-muted"]';
const X_PATH_MODERATOR_TAB = '//div[contains(@class, "settings-dialog")]//*[text()="Moderator"]';
const X_PATH_MORE_TAB = '//div[contains(@class, "settings-dialog")]//*[text()="General"]';
const X_PATH_PROFILE_TAB = '//div[contains(@class, "settings-dialog")]//*[text()="Profile"]';
const X_PATH_SHORTCUTS_TAB = '//div[contains(@class, "settings-dialog")]//*[text()="Shortcuts"]';
/**
* The settings dialog.
*/
export default class SettingsDialog extends BaseDialog {
/**
* Waits for the settings dialog to be visible.
*/
waitForDisplay() {
return this.participant.driver.$(SETTINGS_DIALOG_CONTENT).waitForDisplayed();
}
/**
* Displays a specific tab in the settings dialog.
* @param xpath
* @private
*/
private async openTab(xpath: string) {
const elem = this.participant.driver.$(xpath);
await elem.waitForClickable();
await elem.click();
}
/**
* Selects the Profile tab to be displayed.
*/
openProfileTab() {
return this.openTab(X_PATH_PROFILE_TAB);
}
/**
* Selects the More tab to be displayed.
*/
openMoreTab() {
return this.openTab(X_PATH_MORE_TAB);
}
/**
* Selects the moderator tab to be displayed.
*/
openModeratorTab() {
return this.openTab(X_PATH_MODERATOR_TAB);
}
/**
* Selects the Shortcuts tab to be displayed.
*/
openShortcutsTab() {
return this.openTab(X_PATH_SHORTCUTS_TAB);
}
/**
* Returns whether the Firefox-only Ctrl+Alt reaction shortcut setting exists.
*/
hasCtrlAltReactionShortcutsSetting() {
return this.participant.driver.$(CTRL_ALT_REACTION_SHORTCUTS_CHECKBOX).isExisting();
}
/**
* Returns whether Ctrl+Alt reaction shortcut aliases are enabled.
*/
isCtrlAltReactionShortcutsEnabled() {
return this.participant.driver.$(CTRL_ALT_REACTION_SHORTCUTS_CHECKBOX).isSelected();
}
/**
* Enables or disables Ctrl+Alt reaction shortcut aliases.
*
* @param enable - Whether the aliases should be enabled.
*/
setCtrlAltReactionShortcutsEnabled(enable: boolean) {
return this.setCheckbox(CTRL_ALT_REACTION_SHORTCUTS_CHECKBOX, enable);
}
/**
* Returns the shortcut key labels currently displayed on the Shortcuts tab.
*/
async getShortcutKeyLabels() {
const elements = await this.participant.driver.$$('//ul/li/span[last()]/span');
return elements.map(element => element.getText());
}
/**
* Enters the passed in email into the email field.
* @param email
*/
async setEmail(email: string) {
await this.openProfileTab();
await this.participant.driver.$(EMAIL_FIELD).setValue(email);
}
/**
* Returns the participant's email displayed in the settings dialog.
*/
async getEmail() {
await this.openProfileTab();
return await this.participant.driver.$(EMAIL_FIELD).getValue();
}
/**
* Clicks the OK button on the settings dialog to close the dialog and save any changes made.
*/
submit() {
return this.clickOkButton();
}
/**
* Sets the start audio muted feature to enabled/disabled.
* @param {boolean} enable - true for enabled and false for disabled.
* @returns {Promise<void>}
*/
async setStartAudioMuted(enable: boolean) {
await this.openModeratorTab();
await this.setCheckbox(START_AUDIO_MUTED_CHECKBOX, enable);
}
/**
* Sets the start video muted feature to enabled/disabled.
* @param {boolean} enable - true for enabled and false for disabled.
* @returns {Promise<void>}
*/
async setStartVideoMuted(enable: boolean) {
await this.openModeratorTab();
await this.setCheckbox(START_VIDEO_MUTED_CHECKBOX, enable);
}
/**
* Sets the state checked/selected of a checkbox in the settings dialog.
*/
async setHideSelfView(hideSelfView: boolean) {
await this.openMoreTab();
await this.setCheckbox(HIDE_SELF_VIEW_CHECKBOX, hideSelfView);
}
/**
* Sets the follow me feature to enabled/disabled.
* @param enable
*/
async setFollowMe(enable: boolean) {
await this.openModeratorTab();
await this.setCheckbox(FOLLOW_ME_CHECKBOX, enable);
}
/**
* Returns true if the follow me checkbox is displayed in the settings dialog.
*/
async isFollowMeDisplayed() {
const elem = this.participant.driver.$(X_PATH_MODERATOR_TAB);
if (!await elem.isExisting()) {
return false;
}
await this.openModeratorTab();
return await this.participant.driver.$$(FOLLOW_ME_CHECKBOX).length > 0;
}
/**
* Sets the state of a checkbox.
* @param selector
* @param enable
* @private
*/
private async setCheckbox(selector: string, enable: boolean) {
const checkbox = this.participant.driver.$(selector);
await checkbox.waitForExist();
if (enable !== await checkbox.isSelected()) {
// we show a div with svg and text after the input and those elements grab the click
// so we need to click on the parent element
await this.participant.driver.$(`${selector}//ancestor::div[1]`).click();
}
}
}

View File

@@ -0,0 +1,465 @@
import BasePageObject from './BasePageObject';
const AUDIO_MUTE = 'Mute microphone';
const RECORDING = 'Record & Transcribe';
// Non-moderators (who can only do local recording) get a "Record" button instead of the
// moderator's "Record & Transcribe" — see AbstractRecordButton._getAccessibilityLabel().
const RECORDING_NON_MODERATOR = 'Record';
const LIVE_STREAMING = 'Start live stream';
const AUDIO_UNMUTE = 'Unmute microphone';
const CHAT = 'Open chat';
const CLOSE_CHAT = 'Close chat';
const CLOSE_PARTICIPANTS_PANE = 'Close participants pane';
const DESKTOP = 'Start sharing your screen';
const HANGUP = 'Leave the meeting';
const OVERFLOW_MENU = 'More actions menu';
const OVERFLOW = 'More actions';
const CLOSE_OVERFLOW = 'Close more actions menu';
const PARTICIPANTS = 'Open participants pane';
const PROFILE = 'Edit your profile';
const RAISE_HAND = 'Raise your hand';
const SECURITY = 'Security options';
const SETTINGS = 'Open settings';
const STOP_DESKTOP = 'Stop sharing your screen';
const ENTER_TILE_VIEW_BUTTON = 'Enter tile view';
const EXIT_TILE_VIEW_BUTTON = 'Exit tile view';
const VIDEO_QUALITY = 'Manage video quality';
const VIRTUAL_BACKGROUND = 'Select Background';
const VIDEO_MUTE = 'Stop camera';
const VIDEO_UNMUTE = 'Start camera';
/**
* The toolbar elements.
*/
export default class Toolbar extends BasePageObject {
/**
* Returns the button.
*
* @param {string} accessibilityCSSSelector - The selector to find the button.
* @returns {WebdriverIO.Element} The button.
* @private
*/
private getButton(accessibilityCSSSelector: string) {
return this.participant.driver.$(`[aria-label="${accessibilityCSSSelector}"]`);
}
/**
* The audio mute button.
*/
get audioMuteBtn() {
return this.getButton(AUDIO_MUTE);
}
/**
* The audio unmute button.
*/
get audioUnMuteBtn() {
return this.getButton(AUDIO_UNMUTE);
}
/**
* Returns whether the recording button exists in the DOM for this participant.
*
* @returns {Promise<boolean>}
*/
async hasRecordingButton(): Promise<boolean> {
// The recording button lives in the overflow ("More actions") menu, so it is only in the
// DOM while that menu is open. isExisting() searches the whole DOM, so opening the menu
// first also covers the case where the button is promoted to the main toolbar.
await this.openOverflowMenu();
// Match either label since moderators get "Record & Transcribe" while non-moderators
// (local recording only) get "Record".
const exists = await this.getButton(RECORDING).isExisting()
|| await this.getButton(RECORDING_NON_MODERATOR).isExisting();
await this.closeOverflowMenu();
return exists;
}
/**
* Clicks the recording button to open the recording/transcription dialog.
*
* @returns {Promise<void>}
*/
async clickRecordingButton(): Promise<void> {
// The recording button lives in the overflow ("More actions") menu; open it, click, close
// (matches clickSettings/clickSecurity — closeOverflowMenu is a no-op once the dialog opens).
// Match either label, same as hasRecordingButton: moderators who can't transcribe (e.g.
// transcription.enabled=false) get "Record" too, not just non-moderators — see
// AbstractRecordButton._getAccessibilityLabel()/canAddTranscriber().
return this.clickButtonInOverflowMenu([ RECORDING, RECORDING_NON_MODERATOR ]);
}
/**
* Returns whether the live streaming button exists in the DOM for this participant.
*
* @returns {Promise<boolean>}
*/
async hasLiveStreamingButton(): Promise<boolean> {
// Same reasoning as hasRecordingButton: the button lives in the overflow menu, so open it
// first to make sure it is in the DOM regardless of whether it got promoted to the main bar.
await this.openOverflowMenu();
const exists = await this.getButton(LIVE_STREAMING).isExisting();
await this.closeOverflowMenu();
return exists;
}
/**
* Clicks audio mute button.
*
* @returns {Promise<void>}
*/
async clickAudioMuteButton(): Promise<void> {
await this.participant.log('Clicking on: Audio Mute Button');
await this.audioMuteBtn.waitForExist({
timeout: 2000, timeoutMsg: 'Audio mute button not found'
});
// not directly clicking the button to avoid issues of UI notifications preventing it
return this.participant.execute(() => JitsiMeetJS.app.testing.audioMute());
}
/**
* Clicks audio unmute button.
*
* @returns {Promise<void>}
*/
async clickAudioUnmuteButton(): Promise<void> {
await this.participant.log('Clicking on: Audio Unmute Button');
await this.audioUnMuteBtn.waitForExist({
timeout: 2000, timeoutMsg: 'Audio unmute button not found'
});
// not directly clicking the button to avoid issues of UI notifications preventing it
return this.participant.execute(() => JitsiMeetJS.app.testing.audioUnmute());
}
/**
* The video mute button.
*/
get videoMuteBtn() {
return this.getButton(VIDEO_MUTE);
}
/**
* The video unmute button.
*/
get videoUnMuteBtn() {
return this.getButton(VIDEO_UNMUTE);
}
/**
* Clicks video mute button.
*
* @returns {Promise<void>}
*/
async clickVideoMuteButton(): Promise<void> {
await this.participant.log('Clicking on: Video Mute Button');
await this.videoMuteBtn.waitForExist({
timeout: 2000, timeoutMsg: 'Video mute button not found'
});
// not directly clicking the button to avoid issues of UI notifications preventing it
return this.participant.execute(() => JitsiMeetJS.app.testing.videoMute());
}
/**
* Clicks video unmute button.
*
* @returns {Promise<void>}
*/
async clickVideoUnmuteButton(): Promise<void> {
await this.participant.log('Clicking on: Video Unmute Button');
await this.videoUnMuteBtn.waitForExist({
timeout: 2000, timeoutMsg: 'Video unmute button not found'
});
// not directly clicking the button to avoid issues of UI notifications preventing it
return this.participant.execute(() => JitsiMeetJS.app.testing.videoUnmute());
}
/**
* Clicks Participants pane button.
*
* @returns {Promise<void>}
*/
async clickCloseParticipantsPaneButton(): Promise<void> {
await this.participant.log('Clicking on: Close Participants pane Button');
return this.getButton(CLOSE_PARTICIPANTS_PANE).click();
}
/**
* Clicks Participants pane button.
*
* @returns {Promise<void>}
*/
async clickParticipantsPaneButton(): Promise<void> {
await this.participant.log('Clicking on: Participants pane Button');
// Special case for participants pane button, as it contains the number of participants and its label
// is changing
return this.participant.driver.$(`[aria-label^="${PARTICIPANTS}"]`).click();
}
/**
* Clicks on the video quality toolbar button which opens the
* dialog for adjusting max-received video quality.
*/
clickVideoQualityButton(): Promise<void> {
return this.clickButtonInOverflowMenu(VIDEO_QUALITY);
}
/**
* Clicks on the profile toolbar button which opens or closes the profile panel.
*/
clickProfileButton(): Promise<void> {
return this.clickButtonInOverflowMenu(PROFILE);
}
/**
* Clicks on the raise hand button that enables participants will to speak.
*/
async clickRaiseHandButton(): Promise<void> {
await this.participant.log('Clicking on: Raise hand Button');
return this.getButton(RAISE_HAND).click();
}
/**
* Clicks on the chat button that opens chat panel.
*/
async clickChatButton(): Promise<void> {
await this.participant.log('Clicking on: Chat Button');
return this.getButton(CHAT).click();
}
/**
* Clicks on the chat button that closes chat panel.
*/
async clickCloseChatButton(): Promise<void> {
await this.participant.log('Clicking on: Close Chat Button');
return this.getButton(CLOSE_CHAT).click();
}
/**
* Clicks on the desktop sharing button that starts desktop sharing. Waits until the toggle takes effect
* (button flips to STOP_DESKTOP) so a subsequent call doesn't race a still-in-flight stop.
*/
clickDesktopSharingButton() {
return this._toggleDesktopSharing(DESKTOP, STOP_DESKTOP, 'start');
}
/**
* Clicks on the desktop sharing button to stop it. Waits until the toggle takes effect (button flips to
* DESKTOP). On a busy main thread the click can land on a DOM node React has already detached during a
* track-replace / participantLeft cascade and the React onClick never fires — re-locate and re-click once
* before failing.
*/
clickStopDesktopSharingButton() {
return this._toggleDesktopSharing(STOP_DESKTOP, DESKTOP, 'stop');
}
/**
* Clicks the desktop-sharing toggle and waits for the button to flip to the opposite aria-label. If the
* flip doesn't happen within a short window, re-locates the source button (it may have been re-rendered
* out from under the original element handle) and clicks once more before failing.
*
* @private
*/
private async _toggleDesktopSharing(fromLabel: string, toLabel: string, action: 'start' | 'stop') {
await this.getButton(fromLabel).click();
try {
await this.getButton(toLabel).waitForExist({ timeout: 2_000 });
return;
} catch {
// Fall through to retry.
}
// If the source button is still present, the first click was dropped — re-locate and click again. If
// it isn't present, the toggle is in flight; just keep waiting.
if (await this.getButton(fromLabel).isExisting()) {
await this.participant.log(`Retrying desktop-sharing ${action} click for ${this.participant.name}`);
await this.getButton(fromLabel).click();
}
await this.getButton(toLabel).waitForExist({
timeout: 3_000,
timeoutMsg: `Desktop sharing did not ${action} for ${this.participant.name}`
});
}
/**
* Clicks on the tile view button which enables tile layout.
*/
clickEnterTileViewButton() {
return this.getButton(ENTER_TILE_VIEW_BUTTON).click();
}
/**
* Clicks on the tile view button which exits tile layout.
*/
clickExitTileViewButton() {
return this.getButton(EXIT_TILE_VIEW_BUTTON).click();
}
/**
* Clicks on the hangup button that ends the conference.
*/
async clickHangupButton(): Promise<void> {
await this.participant.log('Clicking on: Hangup Button');
return this.getButton(HANGUP).click();
}
/**
* Clicks on the security toolbar button which opens the security panel.
*/
clickSecurityButton() {
return this.clickButtonInOverflowMenu(SECURITY);
}
/**
* Clicks on the settings toolbar button which opens or closes the settings panel.
*/
clickSettingsButton() {
return this.clickButtonInOverflowMenu(SETTINGS);
}
/**
* Clicks on the virtual background toolbar button which opens the virtual background settings.
*/
clickVirtualBackgroundButton() {
return this.clickButtonInOverflowMenu(VIRTUAL_BACKGROUND);
}
/**
* Ensure the overflow menu is open and clicks on a specified button.
* @param accessibilityLabel The accessibility label of the button to be clicked, or a list of
* candidate labels (e.g. a moderator vs. non-moderator variant) — the first one found in the
* DOM is clicked.
* @private
*/
private async clickButtonInOverflowMenu(accessibilityLabel: string | string[]) {
await this.openOverflowMenu();
// sometimes the overflow button tooltip is over the last entry in the menu,
// so let's move focus away before clicking the button
await this.participant.driver.$('#overflow-context-menu').moveTo();
const labels = Array.isArray(accessibilityLabel) ? accessibilityLabel : [ accessibilityLabel ];
let label = labels[0];
for (const candidate of labels) {
if (await this.getButton(candidate).isExisting()) {
label = candidate;
break;
}
}
await this.participant.log(`Clicking on: ${label}`);
await this.getButton(label).click();
await this.closeOverflowMenu();
}
/**
* Checks if the overflow menu is open and visible.
* @private
*/
private async isOverflowMenuOpen() {
return await this.participant.driver.$$(`[aria-label="${OVERFLOW_MENU}"]`).length > 0;
}
/**
* Clicks on the overflow toolbar button which opens or closes the overflow menu.
* @private
*/
private clickOverflowButton(): Promise<void> {
return this.getButton(OVERFLOW).click();
}
/**
* Ensure the overflow menu is displayed.
* @private
*/
private async openOverflowMenu() {
if (await this.isOverflowMenuOpen()) {
return;
}
// Wake the auto-hidden toolbar by hovering the local video tile; otherwise the overflow
// button click can fire on an invisible element and the menu never appears. When the
// self-view is hidden (e.g. via settings), localVideoContainer is not in the DOM, so fall
// back to the large video container which is always present while in conference.
const localVideoContainer = this.participant.driver.$('span[id="localVideoContainer"]');
if (await localVideoContainer.isExisting()) {
await localVideoContainer.moveTo();
} else {
await this.participant.driver.$('#largeVideoContainer').moveTo();
}
await this.clickOverflowButton();
await this.waitForOverFlowMenu(true);
}
/**
* Ensures the overflow menu is not displayed.
* @private
*/
async closeOverflowMenu() {
if (!await this.isOverflowMenuOpen()) {
return;
}
// When the overflow menu is open the toggle button's aria-label changes from
// "More actions" to "Close more actions menu", so we cannot reuse clickOverflowButton here.
await this.getButton(CLOSE_OVERFLOW).click();
await this.waitForOverFlowMenu(false);
}
/**
* Waits for the overflow menu to be visible or hidden.
* @param visible
* @private
*/
private waitForOverFlowMenu(visible: boolean) {
return this.getButton(OVERFLOW_MENU).waitForDisplayed({
reverse: !visible,
// The menu opens with an enter animation and its render can be delayed by several
// seconds when the main thread is busy (e.g. right after a rejoin or while a virtual
// background effect is loading), so allow a generous timeout before giving up.
timeout: 10000,
timeoutMsg: `Overflow menu is not ${visible ? 'visible' : 'hidden'}`
});
}
/**
* Gets the participant's avatar image element located in the toolbar.
*/
async getProfileImage() {
await this.openOverflowMenu();
const elem = this.participant.driver.$(`[aria-label^="${PROFILE}"] img`);
return await elem.isExisting() ? await elem.getAttribute('src') : null;
}
}

View File

@@ -0,0 +1,42 @@
import { Key } from 'webdriverio';
import BaseDialog from './BaseDialog';
const VIDEO_QUALITY_SLIDER_CLASS = 'custom-slider';
/**
* The video quality dialog.
*/
export default class VideoQualityDialog extends BaseDialog {
/**
* Opens the video quality dialog and sets the video quality to the minimum or maximum definition.
* @param audioOnly - Whether to set the video quality to audio only (minimum).
* @private
*/
async setVideoQuality(audioOnly: boolean) {
await this.participant.getToolbar().clickVideoQualityButton();
const videoQualitySlider = this.participant.driver.$(`.${VIDEO_QUALITY_SLIDER_CLASS}`);
const audioOnlySliderValue = parseInt(await videoQualitySlider.getAttribute('min'), 10);
const maxDefinitionSliderValue = parseInt(await videoQualitySlider.getAttribute('max'), 10);
const activeValue = parseInt(await videoQualitySlider.getAttribute('value'), 10);
const targetValue = audioOnly ? audioOnlySliderValue : maxDefinitionSliderValue;
const distanceToTargetValue = targetValue - activeValue;
const keyDirection = distanceToTargetValue > 0 ? Key.ArrowRight : Key.ArrowLeft;
// we need to click the element to activate it so it will receive the keys
await videoQualitySlider.click();
// Move the slider to the target value.
for (let i = 0; i < Math.abs(distanceToTargetValue); i++) {
await this.participant.driver.keys(keyDirection);
}
// Close the video quality dialog.
await this.clickCloseButton();
}
}

View File

@@ -0,0 +1,139 @@
import BaseDialog from './BaseDialog';
const VB_DIALOG_CONTAINER = '#virtual-background-dialog';
const NONE_THUMBNAIL = '[aria-label="Remove background"]';
const SLIGHT_BLUR_THUMBNAIL = '[aria-label="Half Blur"]';
const BLUR_THUMBNAIL = '[aria-label="Blur"]';
/**
* Page object for the Virtual Background tab inside the settings dialog.
*/
export default class VirtualBackgroundDialog extends BaseDialog {
/**
* Waits for the virtual background dialog container to be displayed.
*/
async waitForDisplay(): Promise<void> {
await this.participant.driver.$(VB_DIALOG_CONTAINER).waitForDisplayed({
timeout: 5000,
timeoutMsg: 'Virtual background dialog not displayed'
});
}
/**
* Returns true if the dialog container is currently displayed.
*/
isDisplayed(): Promise<boolean> {
return this.participant.driver.$(VB_DIALOG_CONTAINER).isDisplayed();
}
/**
* Clicks a thumbnail and waits for it to become aria-checked.
* Ensures React has finished re-rendering before the next click, preventing
* rapid clicks from being swallowed during reconciliation.
*
* @param {string} selector - CSS selector for the thumbnail.
* @param {string} label - Human-readable label for logging.
*/
private async clickAndWaitChecked(selector: string, label: string): Promise<void> {
await this.participant.log(`VirtualBackgroundDialog: clicking ${label} thumbnail`);
const el = this.participant.driver.$(selector);
await el.waitForClickable({ timeout: 3000, timeoutMsg: `${label} thumbnail not clickable` });
// Selecting an effect makes the preview (re)load the segmentation model/wasm, which
// saturates the main thread. Two flaky failure modes follow from that:
// 1. A click issued while React is mid-reconciliation (e.g. during rapid switching) is
// dropped — the onClick handler never fires and the thumbnail never becomes checked.
// 2. The first selection of a blur effect triggers a cold model/worker init that blocks
// the main thread for several seconds, delaying the aria-checked re-render (and the
// attribute reads themselves) well past a short timeout.
// Re-issue the click on every poll until aria-checked confirms it landed (covers 1), and
// allow a generous timeout for the cold-start re-render under load (covers 2). The poll
// interval is kept large so a still-pending selection is not needlessly re-triggered.
await this.participant.driver.waitUntil(
async () => {
if (await el.getAttribute('aria-checked') === 'true') {
return true;
}
await el.click();
return false;
},
{
timeout: 15000,
interval: 1500,
timeoutMsg: `${label} thumbnail did not become checked after click`
}
);
}
/**
* Clicks the "Remove background" (None) thumbnail.
*/
clickNone(): Promise<void> {
return this.clickAndWaitChecked(NONE_THUMBNAIL, 'None');
}
/**
* Clicks the "Half Blur" (slight blur) thumbnail.
*/
clickSlightBlur(): Promise<void> {
return this.clickAndWaitChecked(SLIGHT_BLUR_THUMBNAIL, 'Slight Blur');
}
/**
* Clicks the "Blur" (full blur) thumbnail.
*/
clickBlur(): Promise<void> {
return this.clickAndWaitChecked(BLUR_THUMBNAIL, 'Blur');
}
/**
* Returns true if the given thumbnail element has aria-checked="true" (selected in the dialog).
*
* @param {string} selector - CSS selector for the thumbnail element.
*/
async isThumbnailChecked(selector: string): Promise<boolean> {
const el = this.participant.driver.$(selector);
await el.waitForExist({ timeout: 2000 });
return (await el.getAttribute('aria-checked')) === 'true';
}
/**
* Returns true if the None thumbnail is currently selected in the dialog.
*/
isNoneChecked(): Promise<boolean> {
return this.isThumbnailChecked(NONE_THUMBNAIL);
}
/**
* Returns true if the Slight Blur thumbnail is currently selected in the dialog.
*/
isSlightBlurChecked(): Promise<boolean> {
return this.isThumbnailChecked(SLIGHT_BLUR_THUMBNAIL);
}
/**
* Returns true if the Blur thumbnail is currently selected in the dialog.
*/
isBlurChecked(): Promise<boolean> {
return this.isThumbnailChecked(BLUR_THUMBNAIL);
}
/**
* Clicks OK to apply the selected background and close the dialog.
*/
confirm(): Promise<void> {
return this.clickOkButton();
}
/**
* Clicks the X button to cancel without applying any changes.
*/
cancel(): Promise<void> {
return this.clickCloseButton();
}
}

View File

@@ -0,0 +1,63 @@
import BasePageObject from './BasePageObject';
/**
* Page object for the visitors elements in moderator and visitor page.
*/
export default class Visitors extends BasePageObject {
/**
* Returns the visitors dialog element if any.
*/
hasVisitorsDialog() {
return this.participant.driver.$('div=Joining meeting');
}
/**
* Returns the visitors count shown in the conference info (subject) area.
*/
getVisitorsCount() {
return this.participant.driver.$('#visitorsCountLabel').getText();
}
/**
* Returns the visitors count shown in the Participants pane.
*/
async getVisitorsHeaderFromParticipantsPane() {
const participantsPane = this.participant.getParticipantsPane();
const isOpen = await participantsPane.isOpen();
if (!isOpen) {
await participantsPane.open();
}
return this.participant.driver.$('#visitor-list-header').getText();
}
/**
* Whether the visitors queue UI is shown.
*/
isVisitorsQueueUIShown() {
return this.participant.driver.$('#visitors-waiting-queue').isDisplayed();
}
/**
* Returns the name of the knocking participant (the only one) that is displayed on the notification.
*/
async getWaitingVisitorsInQueue() {
const goLiveNotification
= this.participant.driver.$('//div[@data-testid="notify.waitingVisitors"]');
await goLiveNotification.waitForDisplayed({
timeout: 3000,
timeoutMsg: 'Go live notification not displayed'
});
return await goLiveNotification.getText();
}
/**
* Clicks the go live button in the visitors notification.
*/
async goLive() {
return this.participant.driver.$('//button[@data-testid="participantsPane.actions.goLive"]').click();
}
}

View File

@@ -0,0 +1,7 @@
// ESLint overrides for the Prosody test suite.
// Enables mocha globals (before/after/describe/it) on top of the root config.
module.exports = {
env: {
mocha: true
}
};

4
tests/prosody/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
allure-results/
allure-report/
xunit.xml

View File

@@ -0,0 +1,8 @@
module.exports = {
require: ['./setup.js'],
spec: './**/*_spec.js',
timeout: 120000,
exit: true, // force-exit after tests: @xmpp/reconnect leaves open handles
reporter: 'allure-mocha',
reporterOptions: 'resultsDir=./allure-results,extraReporters=spec',
};

189
tests/prosody/README.md Normal file
View File

@@ -0,0 +1,189 @@
# Prosody Plugin Integration Tests
Integration tests for the Jitsi Prosody plugins. Tests run against a real Prosody 13 instance
inside Docker and connect to it over XMPP WebSocket and HTTP.
## Architecture and Lifecycle
One Docker container runs for the entire test suite (not per-test). The container is started
by the Mocha global setup hook (`setup.js`) using [testcontainers](https://testcontainers.com/),
and torn down after all tests complete.
```
npm test
└─ Mocha (setup.js beforeAll)
└─ Docker container: Prosody 13
└─ HTTP :5280 ← test clients connect via WebSocket (/xmpp-websocket)
← test assertions query HTTP endpoints here
```
Test clients are anonymous XMPP connections (`@xmpp/client`). Rooms are created by joining
them; they are automatically destroyed by Prosody when all occupants disconnect.
Because rooms live only as long as their occupants, tests are effectively isolated as long as
each test disconnects its clients in a `finally` block. Room names are unique per test (counter
suffix) so there is no cross-test interference.
## Assertion Strategies
Two complementary mechanisms are available for asserting internal Prosody state.
### Option I — `prosodyctl shell` (black-box runtime control)
`helpers/prosody_shell.js` runs Lua expressions inside the live Prosody process via
`prosodyctl shell` (executed through `docker exec`). Use this when you need to:
- Load or unload a module at runtime to test behaviour with/without it.
- Execute arbitrary Lua to inspect or mutate server state.
```js
import { prosodyShell } from './helpers/prosody_shell.js';
await prosodyShell('module:unload("muc_hide_all", "conference.localhost")');
// ... run test ...
await prosodyShell('module:load("muc_hide_all", "conference.localhost")');
```
### Option II — `mod_test_observer` HTTP endpoints (event log + room state)
`mod_test_observer.lua` (loaded on the MUC component) hooks MUC events and stores them in a
Lua shared table. `mod_test_observer_http.lua` (loaded on the main VirtualHost) serves that
shared state over plain HTTP on port 5280.
> **Why two modules?** Prosody routes HTTP by `Host` header. A module on the MUC component
> (`conference.localhost`) would only be reachable at `Host: conference.localhost`, which is
> not resolvable from the host machine. Loading the HTTP server on the main VirtualHost
> (`localhost`) makes port 5280 directly reachable. The two modules share data through
> `module:shared()`.
`helpers/test_observer.js` wraps the three endpoints:
| Endpoint | Purpose |
|---|---|
| `GET /test-observer/events` | Return all MUC events fired since the last clear |
| `DELETE /test-observer/events` | Clear the event log (call in `beforeEach`) |
| `GET /test-observer/rooms?jid=<room-jid>` | Return live room state: `{ jid, hidden, occupant_count }` |
```js
import { clearEvents, getEvents, getRoomState } from './helpers/test_observer.js';
beforeEach(() => clearEvents());
// Check fired events:
const events = await getEvents();
assert.ok(events.map(e => e.event).includes('muc-room-pre-create'));
// Check internal room state:
const state = await getRoomState('room@conference.localhost');
assert.equal(state.hidden, true);
```
## Test Placement Guidelines
**Pure Lua code (no Prosody API needed)** — test in `lua/` using
[busted](https://lunarmodules.github.io/busted/). These run without Docker and
are fast. A good fit for library files (e.g. `token/jwk.lib.lua`) and any
module logic that can be extracted into plain functions.
**Code that requires Prosody stubs or live server behaviour** — test via the
Docker/JS path (Mocha + `*_spec.js`). This covers anything that calls
`module:hook`, `module:shared`, `prosody.hosts`, MUC APIs, etc.
**Prefer integration over isolation.** When adding a new module under test,
load it on the main VirtualHost or MUC component in
`docker/prosody.cfg.lua` rather than testing it in isolation with stubs.
This exercises real module interactions (hooks fire in the correct order,
shared state is visible across modules) and catches interoperability bugs that
unit tests miss.
## Directory Structure
```
tests/prosody/
├── README.md this file
├── setup.js Mocha root hooks: start/stop Docker container
├── .mocharc.cjs Mocha configuration (spec glob, timeout, reporters)
├── mocha-reporter-config.json Multi-reporter config: spec + allure-mocha
├── package.json Node dependencies (mocha, testcontainers, @xmpp/client, allure)
│
├── docker/
│ ├── Dockerfile Prosody 13 image with lua-cjson + Jitsi plugins
│ ├── docker-compose.yml Exposes :5222 (XMPP) and :5280 (HTTP)
│ ├── prosody.cfg.lua Top-level config: Includes the cfg/ fragments below
│ └── cfg/ Config fragments (see prosody.cfg.lua header)
│ ├── global.cfg.lua Global settings: no TLS, anonymous auth, HTTP on :5280
│ ├── virtualhosts.cfg.lua All VirtualHost blocks
│ ├── components.cfg.lua All Component blocks except conference.localhost
│ └── conference.cfg.lua conference.localhost (separate so extending images can re-declare it)
│
├── helpers/
│ ├── container.js Holds the testcontainers container reference (set in setup.js)
│ ├── xmpp_client.js Creates anonymous XMPP clients with joinRoom / sendDiscoInfo
│ ├── prosody_shell.js Runs prosodyctl shell commands inside the container (Option I)
│ └── test_observer.js HTTP client for mod_test_observer endpoints (Option II)
│
├── lua/
│ └── jwk_spec.lua busted unit tests for token/jwk.lib.lua (no Prosody needed)
│
├── mod_conference_duration_spec.js Tests for mod_conference_duration
├── mod_muc_filter_access_spec.js Tests for mod_muc_filter_access
├── mod_muc_hide_all_spec.js Tests for mod_muc_hide_all
├── mod_muc_max_occupants_spec.js Tests for mod_muc_max_occupants
├── mod_muc_meeting_id_spec.js Tests for mod_muc_meeting_id
└── mod_muc_size_spec.js Tests for mod_muc_size
```
The plugin sources under test (`mod_muc_hide_all.lua`, `mod_muc_max_occupants.lua`, etc.) live
in `resources/prosody-plugins/` and are copied into the Docker image at build time. The test
observer modules (`mod_test_observer.lua`, `mod_test_observer_http.lua`) are test-only and
should never be loaded in production.
## Running
```bash
cd tests/prosody
npm install # first time only
npm test # runs Lua unit tests, integration tests, and generates Allure report
```
`npm test` runs three steps in sequence:
1. `test:lua` — busted unit tests for pure Lua code (skipped gracefully if busted is not installed)
2. `test:integration` — Mocha integration tests against a Docker Prosody instance
3. `test:report` — generates `allure-report/` from both test suites
To run a single spec file with container debug output:
```bash
DEBUG=testcontainers,testcontainers:containers npm run test:one -- <spec-file>
```
To open the report after a run:
```bash
npx allure open allure-report
```
On macOS with Colima the Docker socket is not at the default path. Export it before running:
```bash
export DOCKER_HOST=unix:///Users/$(whoami)/.colima/default/docker.sock
export TESTCONTAINERS_RYUK_DISABLED=true # Ryuk reaper fails under Colima
npm test
```
## Extending from another repo
Repos with private Prosody plugins (e.g. jitsi-meet-branding) can reuse this
setup by building on top of the test image:
```bash
npm run build:test-image # builds jitsi-meet-prosody-test:latest
```
The extending image starts `FROM jitsi-meet-prosody-test:latest`, copies its
plugins into an additional plugin path, and replaces
`/etc/prosody/prosody.cfg.lua` with a config that Includes the baked-in
`/etc/prosody/cfg/global.cfg.lua`, `virtualhosts.cfg.lua` and
`components.cfg.lua` fragments, then re-declares `conference.localhost`
(based on `cfg/conference.cfg.lua`) with the extra modules appended.
Prosody Component blocks cannot be extended in place, which is why
`conference.localhost` lives in its own fragment that extending configs
replace wholesale.

View File

@@ -0,0 +1,182 @@
-- Custom busted output handler that writes Allure-native JSON result files.
-- Produces {uuid}-result.json files in spec/allure-results/ with proper
-- parentSuite/suite/subSuite labels derived from the describe() nesting.
--
-- Usage (run from tests/prosody/):
-- busted --output busted_allure lua/
local io = io
local os = os
local math = math
local string = string
local table = table
return function(options)
local busted = require 'busted'
local handler = require 'busted.outputHandlers.base'()
-- Output directory. When run via npm (from tests/prosody/) the ALLURE_RESULTS_DIR
-- env var is set to an absolute path so results land in tests/prosody/allure-results/
-- regardless of busted's CWD (resources/prosody-plugins/).
local results_dir = os.getenv('ALLURE_RESULTS_DIR') or 'allure-results'
local test_start_ms = 0
local current_status = 'passed'
local current_message = nil
local current_trace = nil
local counter = 0
local passed = 0
local failed = 0
local skipped = 0
math.randomseed(os.time())
local function now_ms()
return math.floor(os.time() * 1000)
end
local function uuid()
counter = counter + 1
return string.format('%08x-%04x-4%03x-%04x-%08x%04x',
os.time() % 0xffffffff,
math.random(0, 0xffff),
math.random(0, 0xfff),
math.random(0x8000, 0xbfff),
counter,
math.random(0, 0xffff)
)
end
local function escape_json(s)
s = tostring(s or '')
s = s:gsub('\\', '\\\\')
s = s:gsub('"', '\\"')
s = s:gsub('\n', '\\n')
s = s:gsub('\r', '\\r')
s = s:gsub('\t', '\\t')
return s
end
-- Traverse element's parent chain to collect describe() names.
-- Returns an array like {'luajwtjitsi', 'verify', 'HS256'}.
local function get_describe_stack(element)
local stack = {}
local parent = busted.parent(element)
while parent and parent.descriptor ~= 'file' do
table.insert(stack, 1, parent.name)
parent = busted.parent(parent)
end
return stack
end
local function write_result(element, allure_status, start_ms, stop_ms, message, trace)
local stack = get_describe_stack(element)
local labels = {}
if stack[1] then
table.insert(labels, string.format(
'{"name":"parentSuite","value":"%s"}', escape_json(stack[1])))
end
if stack[2] then
table.insert(labels, string.format(
'{"name":"suite","value":"%s"}', escape_json(stack[2])))
end
if #stack >= 3 then
local parts = {}
for i = 3, #stack do parts[#parts + 1] = stack[i] end
table.insert(labels, string.format(
'{"name":"subSuite","value":"%s"}', escape_json(table.concat(parts, ' \xe2\x80\xba '))))
end
table.insert(labels, '{"name":"framework","value":"busted"}')
table.insert(labels, '{"name":"language","value":"lua"}')
local status_block = ''
if message or trace then
status_block = string.format(
',"statusDetails":{"message":"%s","trace":"%s"}',
escape_json(message), escape_json(trace))
end
local id = uuid()
local json = string.format(
'{"uuid":"%s","name":"%s","status":"%s","start":%d,"stop":%d,"labels":[%s]%s}',
id,
escape_json(element.name),
allure_status,
start_ms,
stop_ms,
table.concat(labels, ','),
status_block
)
local f = io.open(results_dir .. '/' .. id .. '-result.json', 'w')
if f then
f:write(json)
f:write('\n')
f:close()
end
end
handler.testStart = function(element, parent)
test_start_ms = now_ms()
current_status = 'passed'
current_message = nil
current_trace = nil
return nil, true
end
handler.testEnd = function(element, parent, status)
local stop_ms = now_ms()
local allure_status
if status == 'success' then
allure_status = 'passed'
passed = passed + 1
io.write('.')
elseif status == 'pending' then
allure_status = 'skipped'
skipped = skipped + 1
local p = handler.pendings[#handler.pendings]
if p then current_message = p.message end
io.write('S')
else
-- failure or error — current_status set by failureTest/errorTest
allure_status = current_status
failed = failed + 1
io.write('F')
end
io.flush()
write_result(element, allure_status, test_start_ms, stop_ms,
current_message, current_trace)
return nil, true
end
handler.failureTest = function(element, parent, message, trace)
current_status = 'failed'
current_message = message
current_trace = trace and trace.traceback
return nil, true
end
handler.errorTest = function(element, parent, message, trace)
current_status = 'broken'
current_message = message
current_trace = trace and trace.traceback
return nil, true
end
handler.exit = function()
io.write(string.format('\nLua tests: %d passed, %d failed, %d skipped\n',
passed, failed, skipped))
io.flush()
return nil, true
end
busted.subscribe({'test', 'start'}, handler.testStart, {predicate = handler.cancelOnPending})
busted.subscribe({'test', 'end'}, handler.testEnd, {predicate = handler.cancelOnPending})
busted.subscribe({'error', 'it'}, handler.errorTest)
busted.subscribe({'failure', 'it'}, handler.failureTest)
busted.subscribe({'exit'}, handler.exit)
return handler
end

View File

@@ -0,0 +1,61 @@
# Prosody 0.13+ with Jitsi plugins for integration testing.
# Builder stage: compile/install Lua libraries that aren't available as apt
# packages for Lua 5.4 in the prosodyim base image.
FROM prosodyim/prosody:13.0 AS builder
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
liblua5.4-dev \
&& luarocks --lua-version 5.4 install basexx \
&& rm -rf /var/lib/apt/lists/*
# Final image
FROM prosodyim/prosody:13.0
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
lua-cjson \
lua-inspect \
lua-luaossl \
&& mv /usr/share/lua/5.3/inspect.lua /usr/share/lua/5.4/ \
&& rm -rf /var/lib/apt/lists/*
# Copy luarocks-installed pure-Lua libs from builder.
COPY --from=builder /usr/local/share/lua/5.4 /usr/local/share/lua/5.4
# Copy all plugins into a dedicated path (added to plugin_paths in the config).
# The tests/ directory is also copied but Prosody only loads explicitly named modules.
COPY --chown=prosody:prosody resources/prosody-plugins/ /opt/prosody-jitsi-plugins/
# inspect.lua is required by several Jitsi plugins for debug logging.
# Provide a minimal stub so those modules load without needing luarocks.
COPY --chown=prosody:prosody tests/prosody/docker/inspect.lua /opt/prosody-jitsi-plugins/inspect.lua
COPY --chown=prosody:prosody tests/prosody/docker/prosody.cfg.lua /etc/prosody/prosody.cfg.lua
COPY --chown=prosody:prosody tests/prosody/docker/cfg/ /etc/prosody/cfg/
# Test ASAP public keys — served by mod_test_observer_http for RS256 token verification tests.
# Two separate key pairs are used: one for login tokens (mod_auth_token) and one for system
# tokens (mod_muc_end_meeting and similar HTTP API modules).
COPY --chown=prosody:prosody tests/prosody/fixtures/test-asap-public.pem /opt/prosody-jitsi-plugins/test-asap-public.pem
COPY --chown=prosody:prosody tests/prosody/fixtures/test-system-asap-public.pem /opt/prosody-jitsi-plugins/test-system-asap-public.pem
# Pre-create the focus admin account (focus@auth.localhost, password "focussecret").
# prosodyctl register writes properly hashed credentials (SCRAM-SHA-*) directly
# to the data directory without requiring a running Prosody instance — it just
# reads the config for data_path and the VirtualHost definition.
RUN prosodyctl register focus auth.localhost focussecret \
&& chown -R prosody:prosody /var/lib/prosody
# Pre-create Jibri and transcriber accounts used by mod_muc_cleanup_backend_services tests.
# recorder@recorder.localhost — is_jibri() matches (starts with 'recorder@recorder.')
# transcriber@recorder.localhost — is_transcriber() matches (starts with 'transcriber@recorder.')
RUN prosodyctl register recorder recorder.localhost recordersecret \
&& prosodyctl register transcriber recorder.localhost transcribersecret \
&& chown -R prosody:prosody /var/lib/prosody

View File

@@ -0,0 +1,136 @@
-- Component blocks fragment (all Components except conference.localhost).
-- See prosody.cfg.lua for the fragment layout.
Component "lobby.conference.localhost" "muc"
storage = "memory"
muc_room_cache_size = 1000
restrict_room_creation = true
muc_room_locking = false
muc_room_default_public_jids = true
-- Breakout MUC component for mod_muc_breakout_rooms tests.
-- Only Prosody admins (focus@auth.localhost) may create rooms here, matching
-- real-life behaviour where Jicofo creates breakout rooms on behalf of the
-- moderator. The on_breakout_room_pre_create hook then enforces that the UUID
-- is registered in the main room before admitting non-admin occupants.
Component "breakout.conference.localhost" "muc"
storage = "memory"
restrict_room_creation = true
muc_room_locking = false
muc_room_default_public_jids = true
-- Internal MUC used by mod_muc_jigasi_invite: the module resolves the Jigasi
-- brewery room from this component via process_host_module. Without this
-- component main_muc_service would remain nil and requests that reach the
-- invite_jigasi() path would crash instead of returning 404.
Component "internal.auth.localhost" "muc"
-- Minimal MUC component used to test mod_muc_filter_access in isolation.
-- Only clients from whitelist.localhost are permitted to join rooms here.
Component "conference-internal.localhost" "muc"
modules_enabled = {
"muc_filter_access";
}
muc_filter_whitelist = { "whitelist.localhost" }
-- Component for mod_end_conference tests. Clients send a message stanza with
-- an <end_conference/> child to this component; the module looks up the room
-- from the sender's jitsi_web_query_room session field (set by mod_jitsi_session
-- from the ?room= WebSocket URL param) and destroys it if the sender is a
-- moderator occupant.
Component "endconference.localhost" "end_conference"
muc_component = "conference.localhost"
muc_mapper_domain_base = "localhost"
muc_mapper_domain_prefix = "conference"
-- MUC component for mod_muc_rate_limit integration tests.
-- Intentionally low join/leave rates (1/s) so that 5 simultaneous clients
-- exercise the queuing and timer logic without long waits.
Component "rate-limited.localhost" "muc"
modules_enabled = { "muc_rate_limit" }
muc_rate_joins = 1
muc_rate_leaves = 1
-- Metadata component for mod_room_metadata_component tests.
Component "metadata.localhost" "room_metadata_component"
muc_component = "conference.localhost"
muc_mapper_domain_base = "localhost"
muc_mapper_domain_prefix = "conference"
-- Component for mod_filesharing_component tests. Clients send file-sharing
-- messages here; the component looks up the room from jitsi_web_query_room
-- (set by mod_jitsi_session) and broadcasts add/remove notifications.
Component "filesharing.localhost" "filesharing_component"
muc_component = "conference.localhost"
muc_mapper_domain_base = "localhost"
muc_mapper_domain_prefix = "conference"
-- Component for mod_audio_translation_component tests. Receivers send
-- <audio-translation> messages here; the component aggregates per-receiver
-- subscriptions and exposes the map to jicofo via room._data.audioTranslationRequests
-- (forwarded by mod_room_metadata_component on the metadata.localhost component).
Component "audiotranslation.localhost" "audio_translation_component"
muc_component = "conference.localhost"
muc_mapper_domain_base = "localhost"
muc_mapper_domain_prefix = "conference"
-- Short debounce so tests don't wait long for the aggregate broadcast.
audio_translation_debounce_interval = 0.1
-- Low limit so the per-receiver cap is exercised without many subscriptions.
audio_translation_max_subscriptions = 3
-- Component for mod_polls_component tests. The module hooks message/host for
-- new-poll / answer-poll json-messages, keeps per-room poll state (installed on
-- conference.localhost via process_host_module) and re-broadcasts poll updates
-- to all occupants. The sender must be an occupant of the room, located from
-- jitsi_web_query_room (set by mod_jitsi_session from the ?room= param).
Component "polls.localhost" "polls_component"
muc_mapper_domain_base = "localhost"
muc_mapper_domain_prefix = "conference"
-- Plain MUC for mod_presence_identity tests. No token verification and no
-- muc_meeting_id lock so any client can join freely without focus.
Component "conference-identity.localhost" "muc"
-- Isolated MUC component for mod_muc_wait_for_host tests.
-- No muc_meeting_id, so no jicofo lock — a JWT-authenticated client can join
-- directly as host without focus unlocking the room first.
-- muc_mapper_domain_base is set to "conference.localhost" so that:
-- lobby_muc_component_config = "lobby." .. "conference.localhost"
-- = "lobby.conference.localhost" (already configured above)
-- This means the existing lobby component is reused for the lobby rooms created
-- by this component, and no additional lobby MUC component is needed.
Component "conference-waitforhost.localhost" "muc"
storage = "memory"
modules_enabled = {
"muc_wait_for_host";
}
muc_mapper_domain_base = "conference.localhost"
muc_mapper_domain_prefix = "conference"
-- Isolated MUC component for mod_muc_allowners tests.
-- No muc_meeting_id (no jicofo lock) and no token_verification, so anonymous
-- clients can join freely. In non-moderated rooms every occupant is promoted
-- to owner on join and muc#admin set stanzas revoking affiliations are
-- rejected. Room names listed in allowners_moderated_rooms are moderated:
-- only JWT-authenticated users with a matching room claim are promoted and
-- the revoke filtering is skipped.
Component "conference-allowners.localhost" "muc"
storage = "memory"
modules_enabled = {
"muc_allowners";
}
muc_mapper_domain_base = "localhost"
muc_mapper_domain_prefix = "conference"
allowners_moderated_rooms = { "moderated-room-1"; "moderated-room-2" }
-- MUC component paired with VirtualHost "noregex.test". Its parent host
-- (derived from the component name by mod_token_verification) sets
-- token_regex_enabled = false, so a join whose token carries a room-claim
-- pattern is refused with <invalid-regex/>.
Component "conference.noregex.test" "muc"
storage = "memory"
modules_enabled = {
"token_verification";
}
muc_mapper_domain_base = "noregex.test"
muc_mapper_domain_prefix = "conference"

View File

@@ -0,0 +1,79 @@
-- conference.localhost fragment. See prosody.cfg.lua for the fragment layout.
--
-- Kept in its own fragment so that extending environments (e.g. the
-- jitsi-meet-branding private plugins) can skip this Include and re-declare
-- the component with extra modules appended. Keep this block self-contained:
-- everything conference.localhost needs must live in this file.
Component "conference.localhost" "muc"
-- Module order follows prod (conference.8x8.vc) so that same-priority hook
-- execution order matches production. Test-only modules are inserted at
-- sensible positions relative to the shared modules they interact with.
modules_enabled = {
"muc_hide_all";
"token_verification"; -- prod pos 2: gate check runs before meeting-id / password logic
"muc_max_occupants";
"muc_resource_validate"; -- test-only: occupant gate, alongside muc_max_occupants
"muc_password_whitelist";
"token_affiliation"; -- test-only: reads auth_token set by token_verification above
"muc_meeting_id"; -- prod pos 9: after gate checks
"muc_flip";
"test_observer"; -- test-only
"filter_messages";
"muc_displayname"; -- prod pos 21: after filter_messages
-- mod_muc_limit_messages registers its message/bare hook at priority -1
-- (below the default 0 used by filter_messages), so filter_messages
-- always fires first. Messages it blocks (return true) never reach
-- muc_limit_messages and do not count toward the per-room cap.
"muc_limit_messages";
-- Destroys a room after a short timeout when only Jibri/transcriber remain.
"muc_cleanup_backend_services";
}
anonymous_strict = true
-- Same as prod: only Prosody admins (jicofo, i.e. focus@auth.localhost) may
-- create a room. Regular clients (anonymous or JWT) can only join a room
-- that focus has already created.
restrict_room_creation = true
-- Used by mod_muc_max_occupants tests (2 occupants max).
muc_max_occupants = 2
-- Clients on whitelist.localhost bypass the occupant limit.
-- Clients on auth.localhost are the focus (jicofo) admin; they also bypass
-- the limit and are not counted against it.
-- Clients on recorder.localhost are Jibri/transcriber accounts; they bypass
-- token_verification (is_jibri / is_transcriber JID prefix checks still apply).
muc_access_whitelist = { "whitelist.localhost", "auth.localhost", "recorder.localhost" }
-- Used by mod_muc_password_whitelist tests: clients from whitelist.localhost
-- are injected with the room password and bypass the password check.
muc_password_whitelist = { "whitelist.localhost" }
-- Required by util.lib.lua domain-mapping helpers and mod_jitsi_permissions.
muc_mapper_domain_base = "localhost"
muc_mapper_domain_prefix = "conference"
-- focus@auth.localhost is a Prosody admin and is therefore exempt from
-- token_verification on both muc-room-pre-create and muc-occupant-pre-join,
-- mirroring production where jicofo is a Prosody admin.
-- mod_muc_cleanup_backend_services: short timeout so tests don't wait 20 s.
services_empty_meeting_timeout = 1
-- mod_time_restricted (loaded on demand by mod_time_restricted_spec, not in
-- modules_enabled, so it does not destroy other tests' rooms): a fractional
-- limit keeps the test fast — 0.125 min = 7.5 s to the limit, plus a 3 s
-- wrap-up grace before the room is actually destroyed.
conference_max_minutes = 0.125
conference_grace_seconds = 3
-- mod_muc_limit_messages: cap per room and honour auth tokens.
muc_limit_messages_count = 3
muc_limit_messages_check_token = true
-- Blocks unauthenticated users from sending room-owner config IQs
-- (muc#owner queries), which is how moderator status is granted to other
-- participants and how room configuration is changed.
token_verification_require_token_for_moderation = true

View File

@@ -0,0 +1,58 @@
-- Global settings fragment. See prosody.cfg.lua for the fragment layout.
--
-- Minimal Prosody config for integration testing.
-- No TLS: tests connect plaintext over loopback.
data_path = "/var/lib/prosody"
pid_file = "/var/run/prosody/prosody.pid"
plugin_paths = { "/usr/lib/prosody/modules", "/opt/prosody-jitsi-plugins" }
log = {
{ levels = { min = "debug" }, to = "console" };
}
-- No TLS module means no STARTTLS offered, so clients connect plaintext.
modules_enabled = {
"saslauth";
"disco";
"ping";
"admin_shell";
"http";
"websocket";
"smacks";
-- Global HTTP API modules.
"muc_end_meeting";
-- Sets jitsi_web_query_room on sessions from the ?room= WebSocket URL param;
-- required by mod_end_conference to locate the target MUC room.
"jitsi_session";
}
-- Required by mod_muc_end_meeting (global module) to locate the MUC component
-- and to attach its HTTP handler to the correct VirtualHost.
muc_mapper_domain_base = "localhost"
muc_mapper_domain_prefix = "conference"
-- System token key server for mod_muc_end_meeting and similar HTTP API modules.
-- These tokens are signed with a separate key pair from login tokens, providing
-- key-level separation between user-facing and system-facing authentication.
-- The endpoint is served by mod_test_observer_http at /test-observer/system-asap-keys/.
prosody_password_public_key_repo_url = "http://localhost:5280/test-observer/system-asap-keys"
-- Allow WebSocket connections without TLS (tests run over loopback).
consider_websocket_secure = true
c2s_require_encryption = false
s2s_require_encryption = false
allow_unencrypted_plain_auth = true
-- HTTP server for mod_test_observer_http endpoints.
-- Disable HTTPS so all HTTP routes land on port 5280 (not 5281).
http_ports = { 5280 }
http_interfaces = { "*" }
https_ports = {}
-- The focus (jicofo) test helper authenticates as focus@auth.localhost.
-- Prosody admins are exempt from token_verification checks and are used as
-- room owners, mirroring production where jicofo is a Prosody admin.
admins = { "focus@auth.localhost" }

View File

@@ -0,0 +1,161 @@
-- VirtualHost blocks fragment. See prosody.cfg.lua for the fragment layout.
VirtualHost "localhost"
authentication = "token"
app_id = "jitsi"
asap_key_server = "http://localhost:5280/test-observer/asap-keys"
signature_algorithm = "RS256"
allow_empty_token = true
-- Serve test_observer HTTP endpoints here so plain HTTP on port 5280 is
-- reachable. Component HTTP routes end up on HTTPS 5281 due to Prosody's
-- virtual-host routing, which does not match Host: localhost on port 5280.
modules_enabled = {
-- test-only: HTTP endpoints and census helpers
"test_observer_http";
"muc_size";
"muc_census";
-- Module order below follows prod (conference.8x8.vc VirtualHost) so that
-- same-priority hook execution order matches production.
-- Handles 'jitsi-add-identity' so components (room_metadata,
-- audio_translation, ...) can advertise a disco identity on this host.
"features_identity";
"conference_duration";
"filter_iq_rayo"; -- priority 1 on pre-iq/full; listed before filter_iq_jibri (matches prod)
"filter_iq_jibri";
"muc_lobby_rooms";
"jiconop";
"muc_kick_participant";
"muc_jigasi_invite";
-- Loaded here so that the global jitsi-access-ban-check event handler
-- is registered and mod_auth_token can fire it for token-authenticated
-- sessions. muc_prosody_jitsi_access_manager_url points at the mock
-- access manager served by mod_test_observer_http on the same host.
"muc_auth_ban";
-- test-only
"turncredentials_http";
-- Rewrites MUC JIDs between external subdomain form
-- (room@conference.sub.localhost) and internal bracket form
-- ([sub]room@conference.localhost) for all sessions on all hosts.
"muc_domain_mapper";
"persistent_lobby";
"system_chat_message";
-- test-only
"muc_password_check";
"muc_breakout_rooms";
}
-- mod_muc_password_check: verify Bearer tokens with the login ASAP key server.
enable_password_token_verification = true
shard_name = "test-shard"
region_name = "test-region"
release_number = "test-release"
-- mod_turncredentials_http (HTTP via external_services): port 3479.
external_services = {
{ type = "stun", host = "127.0.0.1", port = 3479 };
{ type = "turn", host = "127.0.0.1", port = 3479, secret = "http-turn-secret" };
}
-- Required by mod_muc_auth_ban: URL of the access manager to call.
-- Points at the mock endpoint served by mod_test_observer_http.
muc_prosody_jitsi_access_manager_url = "http://localhost:5280/test-observer/access-manager"
-- Required by mod_test_observer_http to locate the shared MUC data.
muc_mapper_domain_base = "localhost"
muc_mapper_domain_prefix = "conference"
-- Required by mod_conference_duration to find the MUC component.
-- Required by mod_muc_lobby_rooms.
-- Required by mod_muc_breakout_rooms.
main_muc = "conference.localhost"
lobby_muc = "lobby.conference.localhost"
breakout_rooms_muc = "breakout.conference.localhost"
-- Clients on whitelist.localhost bypass the lobby.
muc_lobby_whitelist = { "whitelist.localhost" }
-- VirtualHost for the focus (jicofo) test helper.
-- Clients authenticate with username "focus" and password "focussecret".
-- The account is pre-created in the Docker image (see Dockerfile).
-- focus@auth.localhost is listed in the global admins table, so it is exempt
-- from token_verification and can act as room owner on all MUC components.
VirtualHost "auth.localhost"
authentication = "internal_hashed"
-- @xmpp/sasl (v0.13) does not await async SASL responses; the SCRAM-SHA-1
-- mechanism in sasl-scram-sha-1 1.4+ is async, so the client sends an
-- empty client-final message which Prosody rejects as malformed-request.
-- Disable SCRAM and force PLAIN (safe on loopback in the test environment).
disable_sasl_mechanisms = { "SCRAM-SHA-1", "SCRAM-SHA-1-PLUS", "SCRAM-SHA-256", "SCRAM-SHA-256-PLUS" }
-- VirtualHost for Jibri (recorder) and transcriber accounts used by
-- mod_muc_cleanup_backend_services tests. Accounts on this domain get JIDs whose
-- bare form starts with 'recorder@recorder.' or 'transcriber@recorder.', satisfying
-- is_jibri() and is_transcriber() respectively.
-- recorder.localhost is added to muc_access_whitelist on the MUC component so
-- these clients bypass token_verification without needing JWT tokens.
VirtualHost "recorder.localhost"
authentication = "internal_hashed"
disable_sasl_mechanisms = { "SCRAM-SHA-1", "SCRAM-SHA-1-PLUS", "SCRAM-SHA-256", "SCRAM-SHA-256-PLUS" }
-- VirtualHost for HS256 (shared-secret) token auth tests and mod_presence_identity tests.
VirtualHost "hs256.localhost"
authentication = "token"
app_id = "jitsi"
app_secret = "testsecret"
signature_algorithm = "HS256"
allow_empty_token = false
modules_enabled = { "presence_identity" }
-- Second VirtualHost whose domain is listed in muc_access_whitelist on the
-- MUC component below. Clients connecting here get JIDs like
-- <random>@whitelist.localhost and are treated as whitelisted.
-- VirtualHost with room-claim patterns turned off (token_regex_enabled), used
-- to test that a token flagged with context.room.regex is refused rather than
-- matched. mod_token_verification reads token options from the PARENT host of
-- the MUC component, so the option must live here and not on the component.
-- muc_mapper_domain_base is set so that verify_room builds the MUC domain of
-- the paired component below, and not the global conference.localhost.
-- The host deliberately sits outside .localhost: mod_muc_domain_mapper is
-- enabled for every host and would read "conference.noregex.localhost" as the
-- multidomain form of "[noregex]room@conference.localhost", moving the room
-- off this component entirely.
VirtualHost "noregex.test"
authentication = "token"
app_id = "jitsi"
asap_key_server = "http://localhost:5280/test-observer/asap-keys"
signature_algorithm = "RS256"
allow_empty_token = true
token_regex_enabled = false
muc_mapper_domain_base = "noregex.test"
-- VirtualHost used by mod_auth_jitsi-anonymous tests.
VirtualHost "jitsi-anonymous.localhost"
authentication = "jitsi-anonymous"
-- VirtualHost used by mod_auth_jitsi-shared-secret tests.
VirtualHost "shared-secret.localhost"
authentication = "jitsi-shared-secret"
shared_secret = "topsecret"
shared_secret_prev = "oldsecret"
-- Disable SCRAM so Prosody only offers PLAIN. SCRAM requires per-user key
-- derivation which is incompatible with a shared-secret auth provider.
disable_sasl_mechanisms = { "SCRAM-SHA-1", "SCRAM-SHA-1-PLUS" }
VirtualHost "whitelist.localhost"
authentication = "anonymous"
-- VirtualHost for mod_trace tests, with otlp_endpoint configured so the
-- module is active. Points at the mock OTLP receiver served by
-- mod_test_observer_http on the main VirtualHost.
VirtualHost "trace.localhost"
authentication = "anonymous"
modules_enabled = { "trace" }
otlp_endpoint = "http://localhost:5280/test-observer/otlp-traces"
-- VirtualHost for mod_trace tests with otlp_endpoint left unset: the module
-- must log a warning and no-op rather than hook anything.
VirtualHost "trace-disabled.localhost"
authentication = "anonymous"
modules_enabled = { "trace" }

View File

@@ -0,0 +1,14 @@
services:
prosody:
build:
context: ../../..
dockerfile: tests/prosody/docker/Dockerfile
ports:
- "5222:5222"
- "5280:5280"
healthcheck:
test: ["CMD-SHELL", "prosodyctl status | grep -q running"]
interval: 2s
timeout: 5s
retries: 20
start_period: 5s

View File

@@ -0,0 +1,11 @@
-- Minimal inspect stub for test Docker image.
-- Several Jitsi Prosody plugins require('inspect') for debug logging only.
-- This stub satisfies the require without pulling in the full library.
return function(val, _opts)
if type(val) ~= 'table' then return tostring(val) end
local parts = {}
for k, v in pairs(val) do
parts[#parts + 1] = tostring(k) .. '=' .. tostring(v)
end
return '{' .. table.concat(parts, ', ') .. '}'
end

View File

@@ -0,0 +1,20 @@
-- Prosody config for integration testing.
-- Assembled from fragment files so that other environments (e.g. private
-- plugin repos) can extend specific sections without duplicating the entire
-- config.
--
-- Fragment layout:
-- cfg/global.cfg.lua — global settings (data_path, plugin_paths, modules_enabled, …)
-- cfg/virtualhosts.cfg.lua — all VirtualHost blocks
-- cfg/components.cfg.lua — all Component blocks except conference.localhost
-- cfg/conference.cfg.lua — conference.localhost (separate so it can be re-declared)
--
-- An extending image keeps the fragments and replaces this file with one that
-- Includes cfg/global + cfg/virtualhosts + cfg/components, then declares its
-- own conference.localhost block (based on cfg/conference.cfg.lua) with extra
-- modules appended.
Include "/etc/prosody/cfg/global.cfg.lua"
Include "/etc/prosody/cfg/virtualhosts.cfg.lua"
Include "/etc/prosody/cfg/components.cfg.lua"
Include "/etc/prosody/cfg/conference.cfg.lua"

View File

@@ -0,0 +1,20 @@
// Post-process the generated Allure report to set default sort order to
// "order" (the order tests ran) instead of alphabetical. Mirrors the same
// hack used in tests/wdio.conf.ts.
'use strict';
const fs = require('fs');
const path = require('path');
const indexFile = path.join(__dirname, 'allure-report', 'index.html');
if (!fs.existsSync(indexFile)) {
console.warn('fix-allure-report: index.html not found, skipping');
process.exit(0);
}
const setting = '{"treeSorting":{"sorter":"sorter.order","ascending":true}}';
const script = `<script>localStorage.setItem("ALLURE_REPORT_SETTINGS_SUITES", '${setting}');</script>`;
const content = fs.readFileSync(indexFile, 'utf8');
fs.writeFileSync(indexFile, content.replace('<body>', `<body>${script}`));

View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC+OojmOlyLskdV
sDnBHbCdShlarY1kyA9U2oKxe4oNUMKN31YlvgeRfht30Z/fmRGI0JPPU0Lfe5a+
YELVFXz7eDtZlEFQo8qyux7hPd5KAELSOOjOJI/fVI3ZPS4mOVrH5mOHSh8SvORL
ukKxRA8AMt8YRD7jr9AjapIdn9c+/8vs84TKMQznlbmDsVcp5Bt+VY0ohmREOfR5
pX8uPAgDrui8vfbtuSX5KvdPuVcgR3r9RfeHfLG5bQ6oWhtAxiGdhdUQYbLdPkR/
b/UVY7sxEgY8k88gvYDIOg4nWMMk7v7AcWfkj9Eb3ke0OO+5FfJ6wKAhDMsXe/P2
9N788XJTAgMBAAECggEAEExVlFwlt7ZTlEzf9eLEUgWaPIGoHp1hauS509kAz/k+
YgdjiyJH5bhHRDHKn86uiOlN8LJyhVFCbhczQqxvo9/+PcONAQq3gC62C5hQZki1
cOt9TsQlK35EFPu/63h4ha4AkwPPu7xBVxejRSrOmjbBlIOsW7ehfpdP44fhWj1M
csYiI6hNvehN84bfbGqonZlD1vRwwBQTpjBthKl01hk6dHvzYs6EYSqRi9tHLzBg
ZqbicT0hw/V2dL5sE3VEl0Z9c837PZjBpLJuP5wyUJH+WpUQNOLVxuqDsyiFRYlo
vbbLdiDpU4HV+DXNz1ouHvVFJKct9vB7EQBYvA4KSQKBgQDmiZdLafQYkj331s1U
WqbtfyTSzwnMNmVdmnurt8OiOOS63KUYSrqVkZ/JLFDUfT7uwWDQnDy6LaCqrUH6
FyiiN15MlVivSbeTl5ime26apHgSBpUMUAdZJWbmbiGDlvFrw8DMEhePL9o9KQET
TAA/RpdM1OkZ5NRCj/nZe3FOJwKBgQDTPTf7eKfCN8wF45BOV7FNK46VC46SGnej
q8cKiLVU5dKuAKGm7yNB3RUnvGxt6q2/NGetl7Lnla52krGZ2bjZyJWPNv3u2d/+
waGlxZGmAhQNoAYFECDbGR+KIFXSLSrVox9YE2BBWxInLHFCulF+z+LtLKqGkFOe
6lu8CeCB9QKBgQCd2+Fplme624jrSH7ZICnlvoYsg/ClkSnf6oR8lRy03FhjS+sQ
szsIZ+sOCfZfSlPpkSkGL7W1lsDJnlHrOihsy5Uaw7kybjvyKIAyn5qg8bX2QeOV
xscBWAcaCpeQT6+Ip0ZBdrIDLjU2Y/mEiSoyKdg4mCH1xSdDXOss7MYtSQKBgEd8
yUxWWde1kFtR1w1cSgmGuxsfrSEuydxfDt42w782g+UVG5/mADWS/0zSTJOqPyCd
OJUb6JTNKBzCqk4Zy6AQbOTpxGgn3dFWcEEsqozW2Th/NmpSOfxL9UuGg+S8Gmnw
aXQiIoobqvoM5UuiyF+1NOP1IMSnZVU7lM3/PbZdAoGBAJlGJxAN6hb7CTTw3jHi
4Ikw7h7bAUQOPRebV4ExVgRpvLKMTmRN+1cNDI5XA+Hv+Ln9bxu6h6TVEj1lThOW
1yBUTacr2iWic2JcgT1A8zoO2YhLPAP3ambPpeVAY92dfU3aBhOj2cJJ5HLct1xx
nQOPlio964MhT6udIC7dtGt1
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvjqI5jpci7JHVbA5wR2w
nUoZWq2NZMgPVNqCsXuKDVDCjd9WJb4HkX4bd9Gf35kRiNCTz1NC33uWvmBC1RV8
+3g7WZRBUKPKsrse4T3eSgBC0jjoziSP31SN2T0uJjlax+Zjh0ofErzkS7pCsUQP
ADLfGEQ+46/QI2qSHZ/XPv/L7POEyjEM55W5g7FXKeQbflWNKIZkRDn0eaV/LjwI
A67ovL327bkl+Sr3T7lXIEd6/UX3h3yxuW0OqFobQMYhnYXVEGGy3T5Ef2/1FWO7
MRIGPJPPIL2AyDoOJ1jDJO7+wHFn5I/RG95HtDjvuRXyesCgIQzLF3vz9vTe/PFy
UwIDAQAB
-----END PUBLIC KEY-----

View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDKjbOGjK6U2SxE
M2EW86joXHg67aj8vYaukGGnf9XQG7Fp7YcDV53fX7m+gVG7zBge9yf3lbTtF32X
uQ2tJTzw0xaZK+y81nAdGk+LdWZiua00qawizUXV2pFZAButLkoo2kI2ve4m948G
NUuzrfvWU6P6iTY+UKAX15R9eiWjci3nUAhUZoqBlxGF+DJulxX+Q/BMhsCoYZvz
vH3bXU9LrR31LIBVNASy1BlrWuDdvHQcmbWFVEsqr2v09qaoA7/xzSukEgGET9Co
4g8Bt2c8OwVEB9HJ3tIMaJPbwmXlbXyDdj/resyzUZ4liPgjBE/ZPduQDf3ZzvXb
tJhsZnUHAgMBAAECggEAU/eqFHz9YnclAqDJ/tnDi4/Jx6P+CcgHrRXtZaJ44Gya
f28YKSqJJ7BnL7IsT82rsiqDRv+ooSC7z8nHAaAOQ0c+dwDeguniUC44C3f/ma2f
P9WWplayPJT+7AY/cutdktHn4QmbUEwP3mL5nuLhI1hJAfMfqXWC6F9WDy4zrC1M
QXsLOtqi8q5b4As+oPuYRVxehqA5AHTpie/2Tn6wLMCLe1vBP2zSGX7Bq/QucQIv
ow5ZEfJ6bYUS6cB3tItZ0x2pI+m7Lq2LFx6Vl6VcXMzdVir0J9lH/8MgNkyhnc/0
ZSx282redRJjBNxoOUPJa33vsZpZLTOSTyLQ+1ghAQKBgQDo76lu+8J42yRsCDvI
O/k+i1whszoCJxlH1mzaYxEHuEPBLSN3vEBWUeypA+oHEfKPjJSRkMFE0OnKBiwV
MpYyrwETQXrK/P+mdXqjoPUrZo8qcf+AmiueuMPsHhwI3qOfhU0MOrtu6PRwcJDn
zD1FFtuqeqptwLIajfp507W/CwKBgQDem+qgEzvChgVa4JI2hUphiogvXLQsQYdB
IP2eqPK8w3+/uOP6K10NTSVMa8ID9iXakAoAXzUhyj9yc+KtTZjWPvUjhuXr0gK6
5E/HvRMoNvqcEsiYxgmXMvkVj2UB3Z1d2V20cgMK3rWaCgGW61G4mU5WBoTuZ/5o
F9r8PT2PdQKBgDFybq04lFfDbT/hn48p7Aby3mPo/+9lDWDKi+DwFrVk0D05r8XD
GIU6btqSEiPeE3eViBQ+fkh1cKuKE+GME4Y+0COeSsq8Wiij15zUljbYVpvUB0Dt
eUUAQ9bjrV/UozdBvNFTxmYM3ZbgzmHmYTtBVvAhifwyY5xvdzRVVMdxAoGBAJ4V
4aguIHlDDdh8tLjtLWZZp97imbz4CCJTWGjtF/y/ZSB1H8lQNDO2/m7n8482paky
MzgSZLwLUcVo0Kg7+/biHNpO+UbgDDpG2vVAq7MaYByoJjaAJN1wQH10KMoLZK76
J1Z2xPxaLmMnCfvwP0e173CeDpbz2TJ5BnWs0+PlAoGATsVxvhghfHFYT8wJMyx5
ArmD0qF/4AckJfKRnDXXKsRAh1UF/2fbIaP5Y1T7czK+ATJW44NnorR/RG/kl8OR
81qoONPhL2A+qhtPD7maaPFvXJ8lBkd+kTy4EsYCyB6Gy18lkqQaGvHK6Agnt5Ts
izKr6gzVOVg1TAf/2ibw0XM=
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyo2zhoyulNksRDNhFvOo
6Fx4Ou2o/L2GrpBhp3/V0Buxae2HA1ed31+5voFRu8wYHvcn95W07Rd9l7kNrSU8
8NMWmSvsvNZwHRpPi3VmYrmtNKmsIs1F1dqRWQAbrS5KKNpCNr3uJvePBjVLs637
1lOj+ok2PlCgF9eUfXolo3It51AIVGaKgZcRhfgybpcV/kPwTIbAqGGb87x9211P
S60d9SyAVTQEstQZa1rg3bx0HJm1hVRLKq9r9PamqAO/8c0rpBIBhE/QqOIPAbdn
PDsFRAfRyd7SDGiT28Jl5W18g3Y/63rMs1GeJYj4IwRP2T3bkA392c7127SYbGZ1
BwIDAQAB
-----END PUBLIC KEY-----

View 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;
}

View 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 }
);
}

View 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;
}

View 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()));
}
};
}

View 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() };
}

File diff suppressed because it is too large Load Diff

View 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;
}

View File

@@ -0,0 +1,154 @@
-- Unit tests for token/jwk.lib.lua
-- Run with busted from resources/prosody-plugins/:
-- busted spec/lua/
-- Requires: basexx (luarocks install basexx)
-- dofile resolves relative to CWD, so run from resources/prosody-plugins/.
-- The module sets ASN1, base64_encode, and base64url_decode as globals.
local M = dofile("token/jwk.lib.lua")
local ASN1 = M.ASN1
describe("jwk.lib", function()
describe("ASN1", function()
describe("encode_length", function()
it("encodes single-byte length for 0", function()
assert.equal(string.char(0x00), ASN1.encode_length(0))
end)
it("encodes single-byte length for 127", function()
assert.equal(string.char(0x7F), ASN1.encode_length(127))
end)
it("encodes two-byte form for 128", function()
assert.equal(string.char(0x81, 0x80), ASN1.encode_length(128))
end)
it("encodes two-byte form for 255", function()
assert.equal(string.char(0x81, 0xFF), ASN1.encode_length(255))
end)
it("encodes three-byte form for 256", function()
assert.equal(string.char(0x82, 0x01, 0x00), ASN1.encode_length(256))
end)
it("encodes three-byte form for 65535", function()
assert.equal(string.char(0x82, 0xFF, 0xFF), ASN1.encode_length(65535))
end)
end)
describe("encode_null", function()
it("returns NULL tag (0x05) with zero length", function()
assert.equal(string.char(0x05, 0x00), ASN1.encode_null())
end)
end)
describe("encode_integer", function()
it("tags with 0x02 and does not pad when high bit is clear", function()
assert.equal(string.char(0x02, 0x01, 0x7F), ASN1.encode_integer(string.char(0x7F)))
end)
it("prepends 0x00 when high bit is set to indicate positive number", function()
assert.equal(string.char(0x02, 0x02, 0x00, 0x80), ASN1.encode_integer(string.char(0x80)))
end)
it("prepends 0x00 when first byte is 0xFF", function()
assert.equal(string.char(0x02, 0x02, 0x00, 0xFF), ASN1.encode_integer(string.char(0xFF)))
end)
it("handles multi-byte integer without high bit", function()
assert.equal(string.char(0x02, 0x02, 0x01, 0x00), ASN1.encode_integer(string.char(0x01, 0x00)))
end)
end)
describe("encode_sequence", function()
it("wraps content with SEQUENCE tag 0x30", function()
assert.equal(string.char(0x30, 0x02, 0xAB, 0xCD), ASN1.encode_sequence(string.char(0xAB, 0xCD)))
end)
it("handles empty content", function()
assert.equal(string.char(0x30, 0x00), ASN1.encode_sequence(""))
end)
end)
describe("encode_bit_string", function()
it("wraps with BIT STRING tag (0x03) and prepends unused-bits byte (0x00)", function()
-- tag=0x03, length=2 (0x00 + one content byte), 0x00 unused bits, 0xAB
assert.equal(string.char(0x03, 0x02, 0x00, 0xAB), ASN1.encode_bit_string(string.char(0xAB)))
end)
end)
describe("encode_oid", function()
it("wraps bytes with OID tag 0x06", function()
local oid_bytes = string.char(0x2A, 0x86, 0x48)
assert.equal(string.char(0x06, 0x03) .. oid_bytes, ASN1.encode_oid(oid_bytes))
end)
end)
describe("der_to_pem", function()
it("wraps DER bytes in PEM format with default PUBLIC KEY label", function()
local der = string.char(0x30, 0x00)
local pem = ASN1.der_to_pem(der)
assert.truthy(pem:match("^-----BEGIN PUBLIC KEY-----\n"))
assert.truthy(pem:match("-----END PUBLIC KEY-----\n$"))
end)
it("uses custom label when provided", function()
local der = string.char(0x30, 0x00)
local pem = ASN1.der_to_pem(der, "RSA PUBLIC KEY")
assert.truthy(pem:match("^-----BEGIN RSA PUBLIC KEY-----\n"))
assert.truthy(pem:match("-----END RSA PUBLIC KEY-----\n$"))
end)
it("breaks base64 into 64-character lines", function()
-- 48 bytes of DER = 64 chars of base64 (exactly one line)
local der = string.rep(string.char(0x00), 48)
local pem = ASN1.der_to_pem(der)
-- Should have exactly one data line between the markers
local data = pem:match("-----BEGIN PUBLIC KEY-----\n(.-)\n-----END PUBLIC KEY-----")
assert.falsy(data:match("\n")) -- no newline = single line
assert.equal(64, #data)
end)
end)
end) -- ASN1
describe("jwk_to_pem", function()
-- Test vectors computed independently with Python's cryptography primitives.
-- n=0x0F (15), e=0x03 (3) — minimal key with no high-bit padding needed.
it("converts a minimal RSA JWK (no 0x00 padding) to PEM", function()
local pem = M.jwk_to_pem({ n = "Dw", e = "Aw" })
local expected = "-----BEGIN PUBLIC KEY-----\n"
.. "MBowDQYJKoZIhvcNAQEBBQADCQAwBgIBDwIBAw==\n"
.. "-----END PUBLIC KEY-----\n"
assert.equal(expected, pem)
end)
-- n=0x8001, e=65537 — modulus has high bit set, so ASN.1 must prepend 0x00.
it("converts a JWK with high-bit modulus (requires 0x00 padding) to PEM", function()
local pem = M.jwk_to_pem({ n = "gAE", e = "AQAB" })
local expected = "-----BEGIN PUBLIC KEY-----\n"
.. "MB4wDQYJKoZIhvcNAQEBBQADDQAwCgIDAIABAgMBAAE=\n"
.. "-----END PUBLIC KEY-----\n"
assert.equal(expected, pem)
end)
it("output begins and ends with correct PEM markers", function()
local pem = M.jwk_to_pem({ n = "Dw", e = "Aw" })
assert.truthy(pem:match("^-----BEGIN PUBLIC KEY-----\n"))
assert.truthy(pem:match("-----END PUBLIC KEY-----\n$"))
end)
it("returns nil when n is missing", function()
local pem = M.jwk_to_pem({ e = "Aw" })
assert.is_nil(pem)
end)
it("returns nil when e is missing", function()
local pem = M.jwk_to_pem({ n = "Dw" })
assert.is_nil(pem)
end)
end) -- jwk_to_pem
end) -- jwk.lib

View File

@@ -0,0 +1,443 @@
-- Unit tests for luajwtjitsi.lib.lua
-- Run with busted from resources/prosody-plugins/:
-- busted spec/lua/
-- Requires: basexx, lua-cjson, openssl (luarocks package 'openssl')
-- Compatibility shim: the luarocks 'openssl' package (v0.11+) has a different
-- API from the old 'lua-openssl' that the production code was written for.
-- Differences handled here:
-- 1. require style: monolithic (require 'openssl') vs submodule (require 'openssl.digest')
-- 2. hmac.new: old=(key,algo), new=(algo,key)
-- 3. digest:update(): old returns self (chainable), new returns bool
-- 4. pkey.new(pem): not supported in new API; use pkey.read(pem, private, 'pem')
-- 5. pkey:sign(digest_obj): old takes digest obj, new takes (data, algo)
-- 6. pkey:verify(sig, digest_obj): old order; new is verify(data, sig, algo)
local _ok, _ssl = pcall(require, 'openssl')
if _ok and type(_ssl) == 'table' then
-- 1+2. hmac: swap args and register as submodule
if not package.preload['openssl.hmac'] then
local _hmac_raw = _ssl.hmac
local _hmac_compat = setmetatable({}, { __index = _hmac_raw })
_hmac_compat.new = function(key, algo) return _hmac_raw.new(algo, key) end
package.preload['openssl.hmac'] = function() return _hmac_compat end
end
-- 1+3. digest: make :update() return self, store data+algo for pkey compat
if not package.preload['openssl.digest'] then
local _digest_raw = _ssl.digest
local _digest_compat = {}
_digest_compat.new = function(algo)
local inner = _digest_raw.new(algo)
local w = { _inner = inner, _algo = algo, _data = '' }
return setmetatable(w, {
__index = function(self, k)
if k == 'update' then
return function(s, d)
s._inner:update(d)
s._data = s._data .. d
return s -- return self for chaining (old API)
end
elseif k == 'final' then
return function(s) return s._inner:final() end
end
end
})
end
package.preload['openssl.digest'] = function() return _digest_compat end
end
-- 1+4+5+6. pkey: map new() to read(), adapt sign/verify signatures
if not package.preload['openssl.pkey'] then
local _pkey_raw = _ssl.pkey
local function wrap_key(raw)
return setmetatable({}, {
__index = function(_, k)
if k == 'sign' then
-- old: sign(digest_obj) → new: sign(data, algo)
return function(_, dobj)
return raw:sign(dobj._data, dobj._algo)
end
elseif k == 'verify' then
-- old: verify(sig, digest_obj) → new: verify(data, sig, algo)
return function(_, sig, dobj)
return raw:verify(dobj._data, sig, dobj._algo)
end
else
return raw[k]
end
end
})
end
local _pkey_compat = setmetatable({}, { __index = _pkey_raw })
_pkey_compat.new = function(pem)
local ok1, k1 = pcall(_pkey_raw.read, pem, true, 'pem') -- private
if ok1 and k1 then return wrap_key(k1) end
local ok2, k2 = pcall(_pkey_raw.read, pem, false, 'pem') -- public
if ok2 and k2 then return wrap_key(k2) end
return nil -- signal invalid key (matches old API behaviour)
end
package.preload['openssl.pkey'] = function() return _pkey_compat end
end
end
-- Guard: skip the whole suite if the module's hard dependencies aren't installed.
local ok, M = pcall(dofile, "luajwtjitsi.lib.lua")
if not ok then
describe("luajwtjitsi", function()
it("skipped — missing dependencies (lua-cjson and/or lua-openssl not installed)", function()
pending(tostring(M):match("([^\n]+)"))
end)
end)
return
end
local I = M._internals
-- Test RSA key pair (1024-bit PKCS#1, test use only — not for production).
-- Generated with: openssl genrsa -traditional 1024
local PRIVATE_PEM = [[
-----BEGIN RSA PRIVATE KEY-----
MIICXQIBAAKBgQDElXxE6H6t622jPZbwgXi+WuJpLDIPr2znPUqtzTJCAVnK1+Iw
CoTjKYmtkiaszVCn/QL41Hr1JXHA/zzo/dLedvKQhs6dvadUVt4Ch2D7hcqGzn7T
oCTCzD1CyaIb9jGR0KOh8gMpL7JE6Zk9f1ku+91QPTe55FlyHPJ4fZfh/wIDAQAB
AoGBAK6uUlV87owmxi6m3MfrlFo5sjRYhEdxz2iPCxvzWTRTtPFM8f1Ua47bay6j
zuAANSqA5bIsRPIvCqy/YOV8HnHnNVl1ARCEAWMl2it0BsqOxuJKhqUsbp1gPAtM
mJE51yPwfN8fcvMNw+6dwV0h3jp+Y57RR0JAhMRWZNrBSmPhAkEA4smEZX8tjQzB
W3FoPIz5a8hgTDfyHYZftC6HqTdEx/NK1z2+EkirS/4WAcMkIb63gWtxSs/9H+bE
tEKcIGzJsQJBAN3n/iLa+2SIseykvzD5bJrdqdmpFYTMzzs8mua5ngkVGd5hkhJ1
MhhfRe3B/ytBLsd74A889k6vFUxoIYMYoq8CQDinhv+kCckWyqcBabzWquiWIhho
UX+2tenZReKr/+7DhIrIzdbbeI9/ZhgCm+AiOM2H/cFmvCFgEPvHOCGRi8ECQQC3
qIe0j2sbZOtlgWDgBr+1WRp3lDLEK8KkM1+88Al1WOqFHFoKPscMVuYIhtRLF3LB
Fax20CzHKK9HArQhzWTvAkBezAcZyo/TBNq+gHKAQw66mFbr7QSd7BCMIQAZg6Cn
VfvTq/eTsOOIgqKYyQzlSY1Jwu27pVQtRzGPOitHbO3E
-----END RSA PRIVATE KEY-----
]]
local PUBLIC_PEM = [[
-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDElXxE6H6t622jPZbwgXi+WuJp
LDIPr2znPUqtzTJCAVnK1+IwCoTjKYmtkiaszVCn/QL41Hr1JXHA/zzo/dLedvKQ
hs6dvadUVt4Ch2D7hcqGzn7ToCTCzD1CyaIb9jGR0KOh8gMpL7JE6Zk9f1ku+91Q
PTe55FlyHPJ4fZfh/wIDAQAB
-----END PUBLIC KEY-----
]]
-- Returns a payload with exp set one hour in the future (so verify's exp check passes).
-- Extra fields can be merged in via the `extra` table.
local function payload(extra)
local p = { sub = 'test', exp = os.time() + 3600 }
if extra then
for k, v in pairs(extra) do p[k] = v end
end
return p
end
describe("luajwtjitsi", function()
describe("internals", function()
describe("verify_claim", function()
it("exact match returns true", function()
assert.is_true(I.verify_claim('foo', { 'foo', 'bar' }))
end)
it("non-matching value returns false", function()
assert.is_false(I.verify_claim('baz', { 'foo', 'bar' }))
end)
it("wildcard '*' matches any value", function()
assert.is_true(I.verify_claim('anything', { '*' }))
end)
it("empty list returns false", function()
assert.is_false(I.verify_claim('foo', {}))
end)
it("wildcard in mixed list still matches", function()
assert.is_true(I.verify_claim('x', { 'a', '*', 'b' }))
end)
end)
describe("split_token", function()
it("splits three segments correctly", function()
local parts = I.split_token("aaa.bbb.ccc")
assert.equal(3, #parts)
assert.equal('aaa', parts[1])
assert.equal('bbb', parts[2])
assert.equal('ccc', parts[3])
end)
it("returns one element when there are no dots", function()
local parts = I.split_token("nodots")
assert.equal(1, #parts)
assert.equal('nodots', parts[1])
end)
it("returns two elements for two segments", function()
local parts = I.split_token("hdr.body")
assert.equal(2, #parts)
end)
end)
describe("strip_signature", function()
it("removes the signature segment", function()
local result = I.strip_signature("hdr.body.sig")
assert.equal("hdr.body", result)
end)
it("returns nil and error for two-segment input", function()
local result, _, _, err = I.strip_signature("hdr.body")
assert.is_nil(result)
assert.is_string(err)
end)
it("returns nil and error for single-segment input", function()
local result, _, _, err = I.strip_signature("nodots")
assert.is_nil(result)
assert.is_string(err)
end)
end)
describe("parse_token", function()
it("parses a valid HS256 token into header, body, and signature", function()
local tok = assert(M.encode({ sub = 'alice', exp = os.time() + 3600 }, 'secret', 'HS256'))
local header, body, sig = I.parse_token(tok)
assert.is_table(header)
assert.is_table(body)
assert.is_string(sig)
assert.equal('JWT', header.typ)
assert.equal('HS256', header.alg)
assert.equal('alice', body.sub)
end)
it("returns error for token with only two segments", function()
local h, b, s, err = I.parse_token("hdr.body")
assert.is_nil(h)
assert.is_string(err)
end)
it("returns error or throws for token with invalid base64 header", function()
-- Older basexx returns garbage bytes (decode attempt fails gracefully);
-- newer basexx returns nil causing cjson.decode to throw. Both are
-- acceptable outcomes — just verify we never get a valid header back.
local ok, h = pcall(I.parse_token, "!!!!.body.sig")
if ok then
assert.is_nil(h) -- graceful nil-return path
end
-- if not ok: threw an error, which is also acceptable
end)
it("returns error for token with invalid JSON body", function()
local basexx = require 'basexx'
local good_hdr = basexx.to_url64('{"typ":"JWT","alg":"HS256"}')
local bad_body = basexx.to_url64('not-valid-json{{')
local h, b, s, err = I.parse_token(good_hdr .. '.' .. bad_body .. '.sig')
assert.is_nil(h)
assert.is_string(err)
end)
end)
end) -- internals
describe("encode", function()
describe("argument validation", function()
it("non-table data returns error", function()
local tok, err = M.encode("not-a-table", "key", "HS256")
assert.is_nil(tok)
assert.is_string(err)
end)
it("non-string key returns error", function()
local tok, err = M.encode({}, 123, "HS256")
assert.is_nil(tok)
assert.is_string(err)
end)
it("unsupported algorithm returns error", function()
local tok, err = M.encode({}, "key", "XX999")
assert.is_nil(tok)
assert.is_string(err)
end)
end)
end) -- encode
describe("verify", function()
describe("argument validation", function()
it("non-string token returns error", function()
local body, err = M.verify(123, "HS256", "key")
assert.is_nil(body)
assert.is_string(err)
end)
it("non-string algorithm returns error", function()
local body, err = M.verify("a.b.c", 42, "key")
assert.is_nil(body)
assert.is_string(err)
end)
it("unsupported algorithm returns error", function()
local body, err = M.verify("a.b.c", "XX999", "key")
assert.is_nil(body)
assert.is_string(err)
end)
it("malformed token (two segments) returns error", function()
local body, err = M.verify("hdr.body", "HS256", "key")
assert.is_nil(body)
assert.is_string(err)
end)
end)
describe("HS256", function()
it("encode then verify returns original payload", function()
local tok = assert(M.encode(payload({ iss = 'me' }), 'my-secret', 'HS256'))
local body = assert(M.verify(tok, 'HS256', 'my-secret'))
assert.equal('test', body.sub)
assert.equal('me', body.iss)
end)
it("wrong key fails verification", function()
local tok = assert(M.encode(payload(), 'correct', 'HS256'))
local body, err = M.verify(tok, 'HS256', 'wrong')
assert.is_nil(body)
assert.is_string(err)
end)
it("tampered payload fails verification", function()
local tok = assert(M.encode(payload(), 'secret', 'HS256'))
local basexx = require 'basexx'
local parts = I.split_token(tok)
parts[2] = basexx.to_url64('{"sub":"hacker","exp":9999999999}')
local body, err = M.verify(table.concat(parts, '.'), 'HS256', 'secret')
assert.is_nil(body)
assert.is_string(err)
end)
end)
describe("HS384", function()
it("encode then verify returns original payload", function()
local tok = assert(M.encode(payload(), 'secret', 'HS384'))
local body = assert(M.verify(tok, 'HS384', 'secret'))
assert.equal('test', body.sub)
end)
end)
describe("HS512", function()
it("encode then verify returns original payload", function()
local tok = assert(M.encode(payload(), 'secret', 'HS512'))
local body = assert(M.verify(tok, 'HS512', 'secret'))
assert.equal('test', body.sub)
end)
end)
describe("RS256", function()
it("encode with private key, verify with public key", function()
local tok = assert(M.encode(payload(), PRIVATE_PEM, 'RS256'))
local body = assert(M.verify(tok, 'RS256', PUBLIC_PEM))
assert.equal('test', body.sub)
end)
it("tampered signature fails verification", function()
local tok = assert(M.encode(payload(), PRIVATE_PEM, 'RS256'))
-- Corrupt the middle of the signature segment so the decoded bytes change.
-- (Avoid the last 1-2 chars: they may carry padding bits that decode identically.)
local mid = math.floor(#tok / 2)
local c = tok:sub(mid, mid)
local replacement = (c == 'A') and 'B' or 'A'
local tampered = tok:sub(1, mid - 1) .. replacement .. tok:sub(mid + 1)
local body, err = M.verify(tampered, 'RS256', PUBLIC_PEM)
assert.is_nil(body)
assert.is_string(err)
end)
end)
describe("claims", function()
it("expired token is rejected", function()
local tok = assert(M.encode({ sub = 'test', exp = os.time() - 1 }, 'secret', 'HS256'))
local body, err = M.verify(tok, 'HS256', 'secret')
assert.is_nil(body)
assert.truthy(err:match('exp'))
end)
it("nbf in the future is rejected", function()
local tok = assert(M.encode(
{ sub = 'test', exp = os.time() + 3600, nbf = os.time() + 9999 },
'secret', 'HS256'
))
local body, err = M.verify(tok, 'HS256', 'secret')
assert.is_nil(body)
assert.truthy(err:match('nbf'))
end)
it("alg mismatch between header and expectedAlgo is rejected", function()
local tok = assert(M.encode(payload(), 'secret', 'HS256'))
local body, err = M.verify(tok, 'HS512', 'secret')
assert.is_nil(body)
assert.is_string(err)
end)
end)
describe("issuer", function()
it("accepted issuer passes", function()
local tok = assert(M.encode(payload({ iss = 'jitsi' }), 'secret', 'HS256'))
local body = assert(M.verify(tok, 'HS256', 'secret', { 'jitsi' }))
assert.equal('jitsi', body.iss)
end)
it("wrong issuer is rejected", function()
local tok = assert(M.encode(payload({ iss = 'evil' }), 'secret', 'HS256'))
local body, err = M.verify(tok, 'HS256', 'secret', { 'jitsi' })
assert.is_nil(body)
assert.is_string(err)
end)
it("missing iss claim when acceptedIssuers provided is rejected", function()
local tok = assert(M.encode(payload(), 'secret', 'HS256'))
local body, err = M.verify(tok, 'HS256', 'secret', { 'jitsi' })
assert.is_nil(body)
assert.is_string(err)
end)
it("wildcard matches any value", function()
local tok = assert(M.encode(payload({ iss = 'anyone' }), 'secret', 'HS256'))
local body = assert(M.verify(tok, 'HS256', 'secret', { '*' }))
assert.equal('anyone', body.iss)
end)
end)
describe("audience", function()
it("accepted audience passes", function()
local tok = assert(M.encode(payload({ aud = 'jitsi' }), 'secret', 'HS256'))
local body = assert(M.verify(tok, 'HS256', 'secret', nil, { 'jitsi' }))
assert.equal('jitsi', body.aud)
end)
it("wrong audience is rejected", function()
local tok = assert(M.encode(payload({ aud = 'wrong' }), 'secret', 'HS256'))
local body, err = M.verify(tok, 'HS256', 'secret', nil, { 'jitsi' })
assert.is_nil(body)
assert.is_string(err)
end)
it("missing aud claim when acceptedAudiences provided is rejected", function()
local tok = assert(M.encode(payload(), 'secret', 'HS256'))
local body, err = M.verify(tok, 'HS256', 'secret', nil, { 'jitsi' })
assert.is_nil(body)
assert.is_string(err)
end)
it("wildcard matches any value", function()
local tok = assert(M.encode(payload({ aud = 'anything' }), 'secret', 'HS256'))
local body = assert(M.verify(tok, 'HS256', 'secret', nil, { '*' }))
assert.equal('anything', body.aud)
end)
end)
end) -- verify
end) -- luajwtjitsi

View File

@@ -0,0 +1,160 @@
-- Unit tests for mod_certs_s2soutinjection.lua
-- Run with busted from resources/prosody-plugins/:
-- busted spec/lua/
--
-- No Prosody installation required — all dependencies are stubbed.
-- The module exposes a global `attach` function that is called directly.
-- ---------------------------------------------------------------------------
-- Helper: load the module with given option values
--
-- Returns attach, wrapped_events table so callers can inspect both.
-- ---------------------------------------------------------------------------
local function load_module(options)
options = options or {}
local wrapped_events = {}
local mod = {
set_global = function() end,
get_option = function(_, key) return options[key] end,
wrap_event = function(_, event_name, handler)
wrapped_events[event_name] = handler
end,
log = function() end,
}
_G.module = mod
_G.attach = nil -- reset from any previous load
local ok, err = pcall(dofile, "mod_certs_s2soutinjection.lua")
assert(ok, "module load failed: " .. tostring(err))
return _G.attach, wrapped_events
end
-- ---------------------------------------------------------------------------
-- Tests
-- ---------------------------------------------------------------------------
describe("mod_certs_s2soutinjection", function()
-- -----------------------------------------------------------------------
describe("attach()", function()
it("marks cert valid when host is in s2s_connect_overrides", function()
local attach = load_module({
s2s_connect_overrides = { ["bridge.example.com"] = "10.0.0.1:5269" },
})
local session = {}
local result = attach({ session = session, host = "bridge.example.com" })
assert.equal("valid", session.cert_chain_status)
assert.equal("valid", session.cert_identity_status)
assert.is_true(result)
end)
it("does not touch cert status for host not in overrides", function()
local attach = load_module({
s2s_connect_overrides = { ["bridge.example.com"] = "10.0.0.1:5269" },
})
local session = {}
local result = attach({ session = session, host = "other.example.com" })
assert.is_nil(session.cert_chain_status)
assert.is_nil(session.cert_identity_status)
assert.is_falsy(result)
end)
it("returns falsy and does not error when no overrides configured", function()
local attach = load_module({})
local session = {}
local result = attach({ session = session, host = "bridge.example.com" })
assert.is_nil(session.cert_chain_status)
assert.is_falsy(result)
end)
it("falls back to s2sout_override when s2s_connect_overrides is absent", function()
local attach = load_module({
s2sout_override = { ["bridge.example.com"] = "10.0.0.1:5269" },
})
local session = {}
local result = attach({ session = session, host = "bridge.example.com" })
assert.equal("valid", session.cert_chain_status)
assert.equal("valid", session.cert_identity_status)
assert.is_true(result)
end)
it("prefers s2s_connect_overrides — s2sout_override hosts are ignored", function()
local attach = load_module({
s2s_connect_overrides = { ["bridge.example.com"] = "10.0.0.1:5269" },
s2sout_override = { ["legacy.example.com"] = "10.0.0.2:5269" },
})
-- host only in s2s_connect_overrides → valid
local s1 = {}
attach({ session = s1, host = "bridge.example.com" })
assert.equal("valid", s1.cert_chain_status)
-- host only in s2sout_override (legacy, ignored when primary present) → not set
local s2 = {}
attach({ session = s2, host = "legacy.example.com" })
assert.is_nil(s2.cert_chain_status)
end)
it("handles multiple hosts in override map independently", function()
local attach = load_module({
s2s_connect_overrides = {
["bridge1.example.com"] = "10.0.0.1:5269",
["bridge2.example.com"] = "10.0.0.2:5269",
},
})
local s1 = {}
attach({ session = s1, host = "bridge1.example.com" })
assert.equal("valid", s1.cert_chain_status)
local s2 = {}
attach({ session = s2, host = "bridge2.example.com" })
assert.equal("valid", s2.cert_chain_status)
local s3 = {}
attach({ session = s3, host = "unlisted.example.com" })
assert.is_nil(s3.cert_chain_status)
end)
end) -- attach()
-- -----------------------------------------------------------------------
describe("event wrapping", function()
it("registers handler on s2s-check-certificate event", function()
local _, wrapped = load_module({})
assert.is_function(wrapped["s2s-check-certificate"])
end)
it("wrapped handler delegates to attach and returns its result", function()
local _, wrapped = load_module({
s2s_connect_overrides = { ["bridge.example.com"] = "10.0.0.1" },
})
local session = {}
local handler = wrapped["s2s-check-certificate"]
-- handler signature: (handlers, event_name, event_data)
local result = handler(nil, "s2s-check-certificate",
{ session = session, host = "bridge.example.com" })
assert.equal("valid", session.cert_chain_status)
assert.is_true(result)
end)
end) -- event wrapping
end)

View File

@@ -0,0 +1,251 @@
-- Unit tests for mod_debug_traceback.lua
-- Run with busted from resources/prosody-plugins/:
-- busted spec/lua/
--
-- No Prosody installation required — all dependencies are stubbed.
-- The module exposes a global `dump_traceback` function that is called directly.
-- ---------------------------------------------------------------------------
-- Package preloads (must be registered before any dofile() call)
-- ---------------------------------------------------------------------------
package.preload['util.debug'] = function()
return { traceback = function() return "fake traceback\nframe 1\nframe 2" end }
end
package.preload['util.pposix'] = function()
return { getpid = function() return 99 end }
end
-- Minimal interpolation stub: substitutes {key} and {key.subkey} tokens.
-- Filters (yyyymmdd, hhmmss) are invoked when the key matches.
package.preload['util.interpolation'] = function()
return {
new = function(_, _, filters)
return function(template, vars)
return (template:gsub('{([^}]+)}', function(key)
-- Try filters first (yyyymmdd, hhmmss, ...)
if filters and filters[key] and vars.time then
return tostring(filters[key](vars.time))
end
-- Nested key lookup (e.g. paths.data)
local val = vars
for part in key:gmatch('[^%.]+') do
if type(val) == 'table' then val = val[part]
else val = nil; break end
end
return val ~= nil and tostring(val) or ('{' .. key .. '}')
end))
end
end,
}
end
-- util.signal is required when mod_posix lacks signal_events support
package.preload['util.signal'] = function()
return { signal = function() end }
end
-- ---------------------------------------------------------------------------
-- Prosody global required by get_filename (prosody.paths.data)
-- ---------------------------------------------------------------------------
_G.prosody = { paths = { data = "/tmp/prosody-test" } }
-- ---------------------------------------------------------------------------
-- Helper: load module with given option overrides
--
-- Returns: dump_traceback fn, log_calls list, fired_events list
-- ---------------------------------------------------------------------------
local function load_module(options)
options = options or {}
local log_calls = {}
local fired_events = {}
local mod = {
set_global = function() end,
get_option_string = function(_, key, default)
local v = options[key]
return v ~= nil and v or default
end,
log = function(_, level, fmt, ...)
local ok, msg = pcall(string.format, fmt, ...)
table.insert(log_calls, { level = level, msg = ok and msg or fmt })
end,
fire_event = function(_, name, data)
table.insert(fired_events, { name = name, data = data })
end,
depends = function() return {} end, -- no features.signal_events → util.signal path
hook = function() end,
wrap_event = function() end,
}
_G.module = mod
_G.dump_traceback = nil
local ok, err = pcall(dofile, "mod_debug_traceback.lua")
assert(ok, "module load failed: " .. tostring(err))
return _G.dump_traceback, log_calls, fired_events
end
-- ---------------------------------------------------------------------------
-- io.open helpers
-- ---------------------------------------------------------------------------
-- Replace io.open for the duration of fn(); collect written content per path.
local function with_mock_io(files_written, fn)
local orig = io.open
io.open = function(path, _mode)
local buf = {}
files_written[path] = buf
return {
write = function(_, s) buf[#buf + 1] = s end,
close = function() end,
}
end
fn()
io.open = orig
end
-- Replace io.open with one that always fails.
local function with_failing_io(fn)
local orig = io.open
io.open = function() return nil, "permission denied" end
fn()
io.open = orig
end
-- ---------------------------------------------------------------------------
-- Tests
-- ---------------------------------------------------------------------------
describe("mod_debug_traceback", function()
-- -----------------------------------------------------------------------
describe("dump_traceback()", function()
it("writes traceback content to file", function()
local dump = load_module()
local files = {}
with_mock_io(files, function() dump() end)
local path = next(files)
local content = table.concat(files[path])
assert.is_truthy(content:find("fake traceback", 1, true))
end)
it("surrounds traceback with header and footer lines", function()
local dump = load_module()
local files = {}
with_mock_io(files, function() dump() end)
local content = table.concat(files[next(files)])
assert.is_truthy(content:find("-- Traceback generated at", 1, true))
assert.is_truthy(content:find("-- End of traceback --", 1, true))
end)
it("fires debug_traceback/triggered event with a traceback string", function()
local dump, _, events = load_module()
local files = {}
with_mock_io(files, function() dump() end)
assert.equal(1, #events)
assert.equal("debug_traceback/triggered", events[1].name)
assert.is_string(events[1].data.traceback)
end)
it("fires event before attempting file write (even on io failure)", function()
local dump, _, events = load_module()
with_failing_io(function() dump() end)
assert.equal(1, #events)
assert.equal("debug_traceback/triggered", events[1].name)
end)
it("logs an error when the file cannot be opened", function()
local dump, log_calls = load_module()
with_failing_io(function() dump() end)
local found = false
for _, e in ipairs(log_calls) do
if e.level == "error" and e.msg:find("permission denied", 1, true) then
found = true
end
end
assert.is_true(found, "expected error log mentioning 'permission denied'")
end)
it("does not write a file when io.open fails", function()
local dump = load_module()
local files = {}
with_failing_io(function() dump() end)
-- files table should remain empty (no successful open)
assert.is_nil(next(files))
end)
it("default filename contains pid and starts with data path", function()
local dump = load_module()
local files = {}
with_mock_io(files, function() dump() end)
local path = next(files)
assert.is_truthy(path:find("/tmp/prosody-test", 1, true))
assert.is_truthy(path:find("99", 1, true)) -- pid = 99
end)
it("default filename embeds count (starting at 0)", function()
local dump = load_module()
local files = {}
with_mock_io(files, function() dump() end)
local path = next(files)
-- default template: traceback-{pid}-{count}.log → count=0 on first call
assert.is_truthy(path:find("%-0%.log$"))
end)
it("increments count between calls producing distinct filenames", function()
local dump = load_module()
local paths = {}
local orig = io.open
io.open = function(path, _)
paths[#paths + 1] = path
return { write = function() end, close = function() end }
end
dump()
dump()
io.open = orig
assert.equal(2, #paths)
assert.not_equal(paths[1], paths[2])
end)
it("uses custom signal name in info log message", function()
local dump, log_calls = load_module({ debug_traceback_signal = "SIGUSR2" })
local files = {}
with_mock_io(files, function() dump() end)
local found = false
for _, e in ipairs(log_calls) do
if e.level == "info" and e.msg:find("SIGUSR2", 1, true) then
found = true
end
end
assert.is_true(found, "expected info log mentioning 'SIGUSR2'")
end)
it("uses custom filename template from config", function()
local dump = load_module({
debug_traceback_filename = "/custom/tb-{pid}.log",
})
local files = {}
with_mock_io(files, function() dump() end)
local path = next(files)
assert.equal("/custom/tb-99.log", path)
end)
end) -- dump_traceback()
end)

View File

@@ -0,0 +1,273 @@
-- Unit tests for mod_jibri_session.lua
-- Run with busted from resources/prosody-plugins/:
-- busted spec/lua/
--
-- Stubs every Prosody dependency so no Prosody installation is needed.
-- The module is loaded once; the hooked attachJibriSessionId function is
-- extracted and called directly in each test.
-- cjson is required by the module under test — skip gracefully if absent.
local ok_json, json = pcall(require, 'cjson')
if not ok_json then
describe("mod_jibri_session", function()
it("skipped — lua-cjson not installed", function()
pending("lua-cjson is required: luarocks install lua-cjson")
end)
end)
return
end
-- ---------------------------------------------------------------------------
-- Shared mutable mock state — reset in before_each
-- ---------------------------------------------------------------------------
local mock = {
room_from_jid = nil, -- returned by get_room_from_jid; nil = "not found"
rewrite_jid = nil, -- if set, room_jid_match_rewrite returns this; else identity
last_room_jid = nil, -- records the jid passed to get_room_from_jid
}
local util_stub = {
room_jid_match_rewrite = function(jid)
return mock.rewrite_jid or jid
end,
get_room_from_jid = function(jid)
mock.last_room_jid = jid
return mock.room_from_jid
end,
}
-- Capture the hook function installed by the module at load time.
local hooked_fn = nil
local mod_stub = { host = 'test.example.com' }
function mod_stub:require(name) -- luacheck: ignore 212
if name == 'util' then return util_stub end
return {}
end
function mod_stub:hook(event, fn) -- luacheck: ignore 212
if event == 'pre-iq/full' then hooked_fn = fn end
end
function mod_stub:log() end -- luacheck: ignore 212
_G.module = mod_stub
-- ---------------------------------------------------------------------------
-- Load the module under test
-- ---------------------------------------------------------------------------
local ok_mod, load_err = pcall(dofile, 'mod_jibri_session.lua')
if not ok_mod then
describe("mod_jibri_session", function()
it("skipped — failed to load module", function()
pending(tostring(load_err):match("([^\n]+)") or tostring(load_err))
end)
end)
return
end
assert(hooked_fn, "mod_jibri_session did not hook 'pre-iq/full'")
-- ---------------------------------------------------------------------------
-- Test helpers
-- ---------------------------------------------------------------------------
local JIBRI_NS = 'http://jitsi.org/protocol/jibri'
local function make_jibri_elem(attrs)
local e = { name = 'jibri', attr = attrs or {} }
function e:up() return self end
return e
end
local function make_iq(jibri_elem)
local s = { name = 'iq', attr = {} }
function s:get_child(tag, ns)
if tag == 'jibri' and ns == JIBRI_NS then return jibri_elem end
return nil
end
function s:up() return self end
return s
end
local function make_event(stanza, origin)
return { stanza = stanza, origin = origin }
end
local function decoded_app_data(jibri_elem)
return json.decode(jibri_elem.attr.app_data)
end
-- ---------------------------------------------------------------------------
-- Tests
-- ---------------------------------------------------------------------------
describe("mod_jibri_session", function()
before_each(function()
mock.room_from_jid = nil
mock.rewrite_jid = nil
mock.last_room_jid = nil
end)
-- -----------------------------------------------------------------------
describe("non-matching stanzas pass through unchanged", function()
it("ignores a non-IQ stanza", function()
local s = { name = 'message', attr = {} }
function s:get_child() return nil end
assert.has_no.errors(function() hooked_fn(make_event(s)) end)
end)
it("ignores an IQ with no jibri child", function()
local s = { name = 'iq', attr = {} }
function s:get_child() return nil end
assert.has_no.errors(function() hooked_fn(make_event(s)) end)
end)
it("ignores a jibri IQ with action 'stop'", function()
local jibri = make_jibri_elem({ action = 'stop' })
hooked_fn(make_event(make_iq(jibri)))
assert.is_nil(jibri.attr.app_data)
end)
it("ignores a jibri IQ with no action attribute", function()
local jibri = make_jibri_elem({})
hooked_fn(make_event(make_iq(jibri)))
assert.is_nil(jibri.attr.app_data)
end)
end)
-- -----------------------------------------------------------------------
-- Client → jicofo pass: no `room` attribute on the jibri element.
-- The module reads initiator identity from the XMPP session (event.origin).
-- -----------------------------------------------------------------------
describe("client-to-jicofo pass (no room attr on jibri element)", function()
it("sets initiator.id from jitsi_meet_context_user.id", function()
local jibri = make_jibri_elem({ action = 'start' })
local session = { jitsi_meet_context_user = { id = 'user-abc' } }
hooked_fn(make_event(make_iq(jibri), session))
assert.equal('user-abc', decoded_app_data(jibri).file_recording_metadata.initiator.id)
end)
it("falls back to granted_jitsi_meet_context_user_id when context_user is nil", function()
local jibri = make_jibri_elem({ action = 'start' })
local session = { granted_jitsi_meet_context_user_id = 'granted-id-xyz' }
hooked_fn(make_event(make_iq(jibri), session))
assert.equal('granted-id-xyz', decoded_app_data(jibri).file_recording_metadata.initiator.id)
end)
it("sets initiator.group from jitsi_meet_context_group", function()
local jibri = make_jibri_elem({ action = 'start' })
local session = {
jitsi_meet_context_user = { id = 'user-1' },
jitsi_meet_context_group = 'group-A',
}
hooked_fn(make_event(make_iq(jibri), session))
assert.equal('group-A', decoded_app_data(jibri).file_recording_metadata.initiator.group)
end)
it("falls back to granted_jitsi_meet_context_group_id when context_group is nil", function()
local jibri = make_jibri_elem({ action = 'start' })
local session = {
jitsi_meet_context_user = { id = 'user-1' },
granted_jitsi_meet_context_group_id = 'granted-group-B',
}
hooked_fn(make_event(make_iq(jibri), session))
assert.equal('granted-group-B', decoded_app_data(jibri).file_recording_metadata.initiator.group)
end)
it("does not modify app_data when origin (session) is nil", function()
local jibri = make_jibri_elem({ action = 'start' })
hooked_fn(make_event(make_iq(jibri), nil))
assert.is_nil(jibri.attr.app_data)
end)
it("preserves pre-existing top-level app_data keys", function()
local existing = json.encode({ custom_key = 'custom_value' })
local jibri = make_jibri_elem({ action = 'start', app_data = existing })
local session = { jitsi_meet_context_user = { id = 'user-1' } }
hooked_fn(make_event(make_iq(jibri), session))
local data = decoded_app_data(jibri)
assert.equal('custom_value', data.custom_key)
assert.equal('user-1', data.file_recording_metadata.initiator.id)
end)
it("preserves pre-existing file_recording_metadata fields", function()
local existing = json.encode({
file_recording_metadata = { existing_field = 'existing_value' }
})
local jibri = make_jibri_elem({ action = 'start', app_data = existing })
local session = { jitsi_meet_context_user = { id = 'user-1' } }
hooked_fn(make_event(make_iq(jibri), session))
local data = decoded_app_data(jibri)
assert.equal('existing_value', data.file_recording_metadata.existing_field)
assert.equal('user-1', data.file_recording_metadata.initiator.id)
end)
end)
-- -----------------------------------------------------------------------
-- Jicofo → jibri pass: `room` attribute present on the jibri element.
-- The module looks up the room and attaches its meetingId as session_id.
-- -----------------------------------------------------------------------
describe("jicofo-to-jibri pass (room attr present on jibri element)", function()
it("sets conference_details.session_id from room meetingId", function()
mock.room_from_jid = { _data = { meetingId = 'meeting-123' } }
local jibri = make_jibri_elem({ action = 'start', room = 'room@conference.example.com' })
hooked_fn(make_event(make_iq(jibri)))
assert.equal('meeting-123',
decoded_app_data(jibri).file_recording_metadata.conference_details.session_id)
end)
it("does not modify app_data when room is not found", function()
mock.room_from_jid = nil
local jibri = make_jibri_elem({ action = 'start', room = 'missing@conference.example.com' })
hooked_fn(make_event(make_iq(jibri)))
assert.is_nil(jibri.attr.app_data)
end)
it("preserves pre-existing top-level app_data keys", function()
mock.room_from_jid = { _data = { meetingId = 'meeting-456' } }
local existing = json.encode({ custom_key = 'custom_value' })
local jibri = make_jibri_elem({
action = 'start',
room = 'room@conference.example.com',
app_data = existing,
})
hooked_fn(make_event(make_iq(jibri)))
local data = decoded_app_data(jibri)
assert.equal('custom_value', data.custom_key)
assert.equal('meeting-456', data.file_recording_metadata.conference_details.session_id)
end)
it("passes the room JID through room_jid_match_rewrite before lookup", function()
mock.rewrite_jid = 'rewritten@conference.example.com'
mock.room_from_jid = { _data = { meetingId = 'meeting-789' } }
local jibri = make_jibri_elem({
action = 'start',
room = 'original@conference.sub.example.com',
})
hooked_fn(make_event(make_iq(jibri)))
assert.equal('rewritten@conference.example.com', mock.last_room_jid)
end)
end)
-- -----------------------------------------------------------------------
describe("malformed app_data", function()
it("silently ignores invalid JSON in app_data (cjson.safe: returns nil, no throw)", function()
local raw = '{not valid json{{'
local jibri = make_jibri_elem({ action = 'start', app_data = raw })
local session = { jitsi_meet_context_user = { id = 'user-1' } }
assert.has_no.errors(function() hooked_fn(make_event(make_iq(jibri), session)) end)
assert.equal(raw, jibri.attr.app_data) -- unchanged: module returned early
end)
end)
end) -- mod_jibri_session

View File

@@ -0,0 +1,308 @@
-- Unit tests for mod_muc_domain_mapper.lua
-- Run with busted from resources/prosody-plugins/:
-- busted spec/lua/
--
-- Stubs every Prosody dependency so no Prosody installation is needed.
-- ---------------------------------------------------------------------------
-- Stanza / session helpers
-- ---------------------------------------------------------------------------
local function make_stanza(name, attrs, children)
local s = { name = name, attr = attrs or {}, _children = children or {} }
function s:get_child(cname, _ns)
for _, c in ipairs(self._children) do
if c.name == cname then return c end
end
return nil
end
return s
end
local function make_session(session_type)
local logs = {}
return {
type = session_type or "c2s",
log_calls = logs,
-- session.log is called as session.log(level, msg, ...) (not method syntax)
log = function(level, _msg, ...)
table.insert(logs, level)
end,
}
end
-- ---------------------------------------------------------------------------
-- Call-tracking rewrite stubs (reset before each test via clear helpers)
-- ---------------------------------------------------------------------------
local rewrite_to_calls = {}
local rewrite_from_calls = {}
local function clear_calls()
for i = #rewrite_to_calls, 1, -1 do rewrite_to_calls[i] = nil end
for i = #rewrite_from_calls, 1, -1 do rewrite_from_calls[i] = nil end
end
local mock_util = {
room_jid_match_rewrite = function(jid, _stanza)
table.insert(rewrite_to_calls, jid)
return "MAPPED:" .. (jid or "nil")
end,
internal_room_jid_match_rewrite = function(jid, _stanza)
table.insert(rewrite_from_calls, jid)
return "UNMAPPED:" .. (jid or "nil")
end,
}
-- ---------------------------------------------------------------------------
-- Package preloads — must be registered before dofile()
-- ---------------------------------------------------------------------------
package.preload['util.filters'] = function()
return {
add_filter_hook = function(_fn) end,
remove_filter_hook = function(_fn) end,
add_filter = function(_session, _type, _fn) end,
}
end
-- ---------------------------------------------------------------------------
-- Global stubs required by the module at load time
-- ---------------------------------------------------------------------------
_G.prosody = {
events = { add_handler = function() end },
hosts = {},
}
_G.module = {
host = "localhost",
log = function() end,
get_option_string = function(_, key, default)
if key == "muc_mapper_domain_base" then return "localhost" end
return default
end,
get_option_boolean = function(_, key, default)
if key == "muc_mapper_log_not_allowed_errors" then return true end
return default
end,
require = function(_, name)
if name == "util" then return mock_util end
return nil
end,
context = function(_, _host)
return { hook = function() end }
end,
}
-- ---------------------------------------------------------------------------
-- Load the module under test
-- ---------------------------------------------------------------------------
local ok, err = pcall(dofile, "mod_muc_domain_mapper.lua")
if not ok then
describe("mod_muc_domain_mapper", function()
it("skipped — failed to load", function()
pending(tostring(err):match("([^\n]+)") or tostring(err))
end)
end)
return
end
local filter_stanza = _G.filter_stanza
assert(filter_stanza, "filter_stanza must be a global after dofile()")
-- ---------------------------------------------------------------------------
-- Tests
-- ---------------------------------------------------------------------------
describe("mod_muc_domain_mapper", function()
before_each(clear_calls)
-- -----------------------------------------------------------------------
describe("filter_stanza", function()
-- -------------------------------------------------------------------
describe("message stanzas", function()
it("rewrites 'to' via room_jid_match_rewrite", function()
local stanza = make_stanza("message", { to = "room@conference.sub.example.com" })
local session = make_session("c2s")
local result = filter_stanza(stanza, session)
assert.equal(1, #rewrite_to_calls)
assert.equal("room@conference.sub.example.com", rewrite_to_calls[1])
assert.equal("MAPPED:room@conference.sub.example.com", result.attr.to)
end)
it("rewrites 'from' via internal_room_jid_match_rewrite", function()
local stanza = make_stanza("message", { from = "[sub]room@conference.example.com/nick" })
local session = make_session("c2s")
local result = filter_stanza(stanza, session)
assert.equal(1, #rewrite_from_calls)
assert.equal("[sub]room@conference.example.com/nick", rewrite_from_calls[1])
assert.equal("UNMAPPED:[sub]room@conference.example.com/nick", result.attr.from)
end)
it("rewrites both 'to' and 'from' when both are set", function()
local stanza = make_stanza("message", {
to = "room@conference.sub.example.com",
from = "[sub]room@conference.example.com/nick",
})
filter_stanza(stanza, make_session("c2s"))
assert.equal(1, #rewrite_to_calls)
assert.equal(1, #rewrite_from_calls)
end)
it("does not call rewrites when 'to' and 'from' are absent", function()
local stanza = make_stanza("message", {})
filter_stanza(stanza, make_session("c2s"))
assert.equal(0, #rewrite_to_calls)
assert.equal(0, #rewrite_from_calls)
end)
end)
-- -------------------------------------------------------------------
describe("presence stanzas", function()
it("rewrites 'to' and 'from'", function()
local stanza = make_stanza("presence", {
to = "room@conference.sub.example.com/nick",
from = "[sub]room@conference.example.com/nick",
})
filter_stanza(stanza, make_session("c2s"))
assert.equal(1, #rewrite_to_calls)
assert.equal(1, #rewrite_from_calls)
end)
end)
-- -------------------------------------------------------------------
describe("iq stanzas", function()
it("rewrites 'to' and 'from'", function()
local stanza = make_stanza("iq", {
to = "room@conference.sub.example.com",
from = "[sub]room@conference.example.com",
})
filter_stanza(stanza, make_session("c2s"))
assert.equal(1, #rewrite_to_calls)
assert.equal(1, #rewrite_from_calls)
end)
it("rewrites <conference room> attribute when child is present", function()
local conf = make_stanza("conference", { room = "room@conference.sub.example.com" })
local stanza = make_stanza("iq", { to = "focus@example.com" }, { conf })
filter_stanza(stanza, make_session("c2s"))
-- room attr on the child gets rewritten
assert.equal("MAPPED:room@conference.sub.example.com", conf.attr.room)
-- 'to' also gets rewritten (focus@example.com passes through the mock)
assert.equal(2, #rewrite_to_calls)
end)
it("does not touch stanzas with no <conference> child", function()
local stanza = make_stanza("iq", { to = "focus@example.com" })
filter_stanza(stanza, make_session("c2s"))
assert.equal(1, #rewrite_to_calls)
assert.equal(0, #rewrite_from_calls)
end)
end)
-- -------------------------------------------------------------------
describe("skipped stanzas", function()
it("passes stanza through unchanged when skipMapping is true", function()
local stanza = make_stanza("message", { to = "room@conference.sub.example.com" })
stanza.skipMapping = true
filter_stanza(stanza, make_session("c2s"))
assert.equal(0, #rewrite_to_calls)
assert.equal("room@conference.sub.example.com", stanza.attr.to)
end)
it("passes stanza through unchanged for s2sout sessions", function()
local stanza = make_stanza("message", { to = "room@conference.sub.example.com" })
filter_stanza(stanza, make_session("s2sout"))
assert.equal(0, #rewrite_to_calls)
end)
it("passes non-message/iq/presence stanzas through unchanged", function()
local stanza = make_stanza("stream:features", { to = "example.com" })
filter_stanza(stanza, make_session("c2s"))
assert.equal(0, #rewrite_to_calls)
assert.equal(0, #rewrite_from_calls)
end)
end)
-- -------------------------------------------------------------------
describe("log_not_allowed_errors", function()
local function make_not_allowed_presence()
local not_allowed = make_stanza("not-allowed",
{ xmlns = "urn:ietf:params:xml:ns:xmpp-stanzas" })
local error_el = make_stanza("error", { type = "cancel" }, { not_allowed })
return make_stanza("presence", { type = "error" }, { error_el })
end
it("logs a not-allowed presence error once per session", function()
local stanza = make_not_allowed_presence()
local session = make_session("c2s")
filter_stanza(stanza, session)
assert.equal(1, #session.log_calls)
assert.equal("error", session.log_calls[1])
assert.is_true(session.jitsi_not_allowed_logged)
end)
it("does not log a second time on the same session", function()
local stanza = make_not_allowed_presence()
local session = make_session("c2s")
filter_stanza(stanza, session)
filter_stanza(stanza, session)
assert.equal(1, #session.log_calls)
end)
it("does not log for non-error presence", function()
local stanza = make_stanza("presence", { to = "room@conference.sub.example.com" })
local session = make_session("c2s")
filter_stanza(stanza, session)
assert.equal(0, #session.log_calls)
end)
it("does not log when error type is not 'cancel'", function()
local not_allowed = make_stanza("not-allowed",
{ xmlns = "urn:ietf:params:xml:ns:xmpp-stanzas" })
local error_el = make_stanza("error", { type = "auth" }, { not_allowed })
local stanza = make_stanza("presence", { type = "error" }, { error_el })
local session = make_session("c2s")
filter_stanza(stanza, session)
assert.equal(0, #session.log_calls)
end)
end)
end)
end)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,669 @@
-- Unit tests for mod_short_lived_token.lua
-- Run with busted from resources/prosody-plugins/:
-- busted ../../tests/prosody/lua/mod_short_lived_token_spec.lua
--
-- Stubs every Prosody dependency so no Prosody installation is needed.
-- Uses a controllable jwt stub to capture payload fields without requiring
-- the openssl library.
-- ---------------------------------------------------------------------------
-- Package preloads — Prosody dependencies
-- ---------------------------------------------------------------------------
package.preload['util.jid'] = function()
local function split(j)
if not j then return nil, nil, nil end
local node, host, resource
local at = j:find('@')
if at then
local slash = j:find('/', at + 1)
node = j:sub(1, at - 1)
if slash then
host = j:sub(at + 1, slash - 1)
resource = j:sub(slash + 1)
else
host = j:sub(at + 1)
end
else
local slash = j:find('/')
if slash then
host = j:sub(1, slash - 1)
resource = j:sub(slash + 1)
else
host = j
end
end
node = (node and #node > 0) and node or nil
host = (host and #host > 0) and host or nil
resource = (resource and #resource > 0) and resource or nil
return node, host, resource
end
return {
split = split,
node = function(j) local n = split(j); return n end,
resource = function(j) local _, _, r = split(j); return r end,
bare = function(j)
local n, h = split(j)
if n then return n .. '@' .. (h or '') end
return h
end,
}
end
package.preload['util.stanza'] = function()
return {
error_reply = function(stanza, etype, condition)
return { is_error_reply = true, type = etype, condition = condition, original = stanza }
end,
}
end
package.preload['util.throttle'] = function()
return { create = function() return {} end }
end
-- ---------------------------------------------------------------------------
-- Helpers
-- ---------------------------------------------------------------------------
-- Single-pass iterator over a list — mimics Prosody's requested_credentials.
local function iter(t)
local i = 0
return function()
i = i + 1
return t[i]
end
end
-- Minimal presence stanza stub.
local function make_presence(nick, email)
return {
get_child_text = function(_, tag, _ns)
if tag == 'nick' then return nick end
if tag == 'email' then return email end
return nil
end,
}
end
-- Minimal occupant stub.
local function make_occupant(full_jid, nick, presence_nick, presence_email)
local p = make_presence(presence_nick, presence_email)
return {
nick = nick or ('conference.example.com/' .. (presence_nick or 'Alice')),
get_presence = function(_, _jid) return p end,
}
end
-- Minimal room stub.
local function make_room(jid, meeting_id, occupant_map)
return {
jid = jid,
_data = { meetingId = meeting_id },
get_occupant_by_real_jid = function(_, real_jid)
if occupant_map then return occupant_map[real_jid] end
return nil
end,
}
end
-- ---------------------------------------------------------------------------
-- Controllable JWT stub.
-- Exposes captured_payload and captured_key so tests can inspect them.
-- Set .fail = true to simulate an encode error.
-- ---------------------------------------------------------------------------
local mock_jwt = {
fail = false,
captured_payload = nil,
captured_key = nil,
encode = function(self, payload, key, _alg, _headers)
self.captured_payload = payload
self.captured_key = key
if self.fail then
return nil, 'mock-encode-error'
end
return 'mock.jwt.token', nil
end,
}
-- Make encode callable without explicit self (module calls jwt.encode(...))
setmetatable(mock_jwt, {
__index = mock_jwt,
})
-- ---------------------------------------------------------------------------
-- Module stub factory.
-- Returns a fresh (stub, hooks, log_calls) triple.
-- ---------------------------------------------------------------------------
local function make_module_stub(options_override)
local hooks = {}
local log_calls = {}
local opts = options_override or {
issuer = 'test-issuer',
accepted_audiences = { 'file-sharing' },
key_path = '/fake/key.pem',
key_id = 'kid-1',
ttl_seconds = 30,
}
local stub = {
host = 'test.example.com',
set_global = function() end,
log = function(_, level, fmt, ...)
-- fmt may contain no format specifiers; guard with pcall
local ok, msg = pcall(string.format, fmt, ...)
log_calls[#log_calls + 1] = {
level = level,
msg = ok and msg or fmt,
}
end,
hook = function(_, name, handler)
hooks[name] = handler
end,
get_option = function(_, key)
if key == 'short_lived_token' then return opts end
return nil
end,
get_option_string = function(_, key, default)
if key == 'region_name' then return 'us-east-1' end
if key == 'main_muc' then return 'conference.test.example.com' end
return default
end,
require = function(_, name)
if name == 'luajwtjitsi' then
-- Return a table whose encode method delegates to mock_jwt
return {
encode = function(payload, key, alg, headers)
return mock_jwt:encode(payload, key, alg, headers)
end,
}
end
if name == 'util' then
return {
is_vpaas = function() return false end,
process_host_module = function() end,
table_find = function(t, v)
for _, x in ipairs(t) do if x == v then return true end end
return nil
end,
}
end
error('unexpected module:require("' .. name .. '")')
end,
}
return stub, hooks, log_calls
end
-- Patch io.open so key-file read at load-time returns a fake key string.
local real_io_open = io.open
local function patch_io()
io.open = function(path, mode)
if path == '/fake/key.pem' and mode == 'r' then
return {
read = function(_, _fmt) return 'FAKE-PRIVATE-KEY' end,
close = function() end,
}
end
return real_io_open(path, mode)
end
end
local function restore_io() io.open = real_io_open end
-- Load the module under test into _G.
local function load_module(module_stub)
mock_jwt.fail = false
mock_jwt.captured_payload = nil
mock_jwt.captured_key = nil
_G.module = module_stub
_G.prosody = { hosts = {} }
_G.extract_subdomain = function(node) return nil, node, nil end
_G.get_room_by_name_and_subdomain = function() return nil end
patch_io()
local ok, err = pcall(dofile, 'mod_short_lived_token.lua')
restore_io()
return ok, err
end
-- Convenience: load + return hooks (asserts successful load).
local function load_and_get_hooks(opts_override)
local stub, hooks, log_calls = make_module_stub(opts_override)
local ok, err = load_module(stub)
assert.is_true(ok, tostring(err))
return hooks, log_calls, stub
end
-- ---------------------------------------------------------------------------
-- Tests
-- ---------------------------------------------------------------------------
describe('mod_short_lived_token', function()
-- -----------------------------------------------------------------------
-- Config validation
-- -----------------------------------------------------------------------
describe('config validation', function()
it('loads cleanly with all required options present', function()
local _, log_calls = load_and_get_hooks()
local errors = {}
for _, e in ipairs(log_calls) do
if e.level == 'error' then errors[#errors + 1] = e.msg end
end
assert.equal(0, #errors, 'unexpected error: ' .. table.concat(errors, '; '))
end)
for _, missing in ipairs({ 'issuer', 'accepted_audiences', 'key_path', 'key_id', 'ttl_seconds' }) do
it('logs error and skips hook when ' .. missing .. ' is absent', function()
local opts = {
issuer = 'test-issuer',
accepted_audiences = { 'file-sharing' },
key_path = '/fake/key.pem',
key_id = 'kid-1',
ttl_seconds = 30,
}
opts[missing] = nil
local stub, hooks, log_calls = make_module_stub(opts)
local ok, err = load_module(stub)
assert.is_true(ok, tostring(err))
assert.is_nil(hooks['external_service/credentials'],
'hook must not register when config is incomplete')
local found_error = false
for _, e in ipairs(log_calls) do
if e.level == 'error' then found_error = true end
end
assert.is_true(found_error, 'expected an error log entry')
end)
end
it('logs error and does not register hook when main_muc is absent', function()
local stub, hooks, log_calls = make_module_stub()
stub.get_option_string = function(_, key, default)
if key == 'region_name' then return 'us-east-1' end
if key == 'main_muc' then return nil end
return default
end
local ok = load_module(stub)
assert.is_true(ok)
assert.is_nil(hooks['external_service/credentials'])
local found_error = false
for _, e in ipairs(log_calls) do
if e.level == 'error' then found_error = true end
end
assert.is_true(found_error)
end)
end) -- config validation
-- -----------------------------------------------------------------------
-- external_service/credentials handler — error cases
-- -----------------------------------------------------------------------
describe('external_service/credentials handler', function()
local hooks
before_each(function()
hooks = load_and_get_hooks()
end)
it('sends not-allowed when room is not found', function()
_G.get_room_by_name_and_subdomain = function() return nil end
local error_sent
local session = {
full_jid = 'user@example.com/res',
jitsi_web_query_room = 'missing',
jitsi_web_query_prefix = '',
send = function(reply) error_sent = reply end,
}
hooks['external_service/credentials']({
requested_credentials = iter({ 'short-lived-token:file-sharing:0' }),
services = { push = function() end },
origin = session,
stanza = { id = 'iq1' },
})
assert.is_not_nil(error_sent)
assert.equal('not-allowed', error_sent.condition)
end)
it('sends not-allowed when session is not an occupant', function()
local room = make_room('r@conference.example.com', 'mid', {})
_G.get_room_by_name_and_subdomain = function() return room end
local error_sent
local session = {
full_jid = 'outsider@example.com/res',
jitsi_web_query_room = 'r',
jitsi_web_query_prefix = '',
send = function(reply) error_sent = reply end,
}
hooks['external_service/credentials']({
requested_credentials = iter({ 'short-lived-token:file-sharing:0' }),
services = { push = function() end },
origin = session,
stanza = { id = 'iq2' },
})
assert.is_not_nil(error_sent)
assert.equal('not-allowed', error_sent.condition)
end)
it('does not push service when credential key is not in accepted map', function()
local occupant = make_occupant('user@example.com/res', nil, 'Alice', nil)
local room = make_room('r@conference.example.com', 'mid',
{ ['user@example.com/res'] = occupant })
_G.get_room_by_name_and_subdomain = function() return room end
local pushed = {}
local services = { push = function(_, item) pushed[#pushed + 1] = item end }
hooks['external_service/credentials']({
requested_credentials = iter({ 'short-lived-token:unknown-audience:0' }),
services = services,
origin = { full_jid = 'user@example.com/res', jitsi_web_query_room = 'r',
jitsi_web_query_prefix = '' },
stanza = {},
})
assert.equal(0, #pushed)
end)
-- -------------------------------------------------------------------
-- Service entry shape
-- -------------------------------------------------------------------
describe('valid request service entry', function()
local pushed_item
before_each(function()
local occupant = make_occupant('user@example.com/res', nil, 'Alice', nil)
local room = make_room('r@conference.example.com', 'meet-123',
{ ['user@example.com/res'] = occupant })
_G.get_room_by_name_and_subdomain = function() return room end
local items = {}
local services = { push = function(_, item) items[#items + 1] = item end }
hooks['external_service/credentials']({
requested_credentials = iter({ 'short-lived-token:file-sharing:0' }),
services = services,
origin = { full_jid = 'user@example.com/res', jitsi_web_query_room = 'r',
jitsi_web_query_prefix = '' },
stanza = {},
})
pushed_item = items[1]
end)
it('type is short-lived-token', function() assert.equal('short-lived-token', pushed_item.type) end)
it('host is audience value', function() assert.equal('file-sharing', pushed_item.host) end)
it('username is token', function() assert.equal('token', pushed_item.username) end)
it('transport is https', function() assert.equal('https', pushed_item.transport) end)
it('port is 443', function() assert.equal(443, pushed_item.port) end)
it('restricted is true', function() assert.is_true(pushed_item.restricted) end)
it('expires = now + ttl', function()
local now = os.time()
assert.is_true(pushed_item.expires >= now + 29 and pushed_item.expires <= now + 31)
end)
it('password is non-empty string', function()
assert.is_string(pushed_item.password)
assert.is_true(#pushed_item.password > 0)
end)
end)
it('multiple audiences produce one service entry each', function()
local hooks2 = load_and_get_hooks({
issuer = 'iss',
accepted_audiences = { 'aud-a', 'aud-b' },
key_path = '/fake/key.pem',
key_id = 'kid',
ttl_seconds = 30,
})
local occupant = make_occupant('user@example.com/res', nil, 'Bob', nil)
local room = make_room('r@conference.example.com', 'mid',
{ ['user@example.com/res'] = occupant })
_G.get_room_by_name_and_subdomain = function() return room end
local items = {}
local services = { push = function(_, item) items[#items + 1] = item end }
hooks2['external_service/credentials']({
requested_credentials = iter({
'short-lived-token:aud-a:0',
'short-lived-token:aud-b:0',
}),
services = services,
origin = { full_jid = 'user@example.com/res', jitsi_web_query_room = 'r',
jitsi_web_query_prefix = '' },
stanza = {},
})
assert.equal(2, #items)
assert.equal('aud-a', items[1].host)
assert.equal('aud-b', items[2].host)
end)
end) -- handler
-- -----------------------------------------------------------------------
-- generateToken — JWT payload claims
-- Tested by capturing mock_jwt.captured_payload after invoking the handler.
-- -----------------------------------------------------------------------
describe('generateToken payload', function()
-- Invoke handler with given session fields; returns captured payload.
-- Optional extract_subdomain_fn overrides the global after module load.
local function get_payload(session_fields, room_override, extract_subdomain_fn)
hooks = load_and_get_hooks()
mock_jwt.captured_payload = nil
-- Apply extract_subdomain override AFTER load_module (which resets it).
if extract_subdomain_fn then
_G.extract_subdomain = extract_subdomain_fn
end
local full_jid = session_fields.full_jid or 'user@example.com/res'
local pres_nick = session_fields._presence_nick or 'Alice'
local pres_email = session_fields._presence_email or nil
local occupant = make_occupant(full_jid, nil, pres_nick, pres_email)
local room = room_override or make_room(
'myroom@conference.example.com', 'meeting-xyz',
{ [full_jid] = occupant }
)
_G.get_room_by_name_and_subdomain = function() return room end
local session = {}
for k, v in pairs(session_fields) do
if k:sub(1, 1) ~= '_' then session[k] = v end
end
local items = {}
local services = { push = function(_, item) items[#items + 1] = item end }
hooks['external_service/credentials']({
requested_credentials = iter({ 'short-lived-token:file-sharing:0' }),
services = services,
origin = session,
stanza = {},
})
return mock_jwt.captured_payload
end
local base_session = {
full_jid = 'user@example.com/res',
jitsi_web_query_room = 'myroom',
jitsi_web_query_prefix = '',
}
it('iss is set to configured issuer', function()
local p = get_payload(base_session)
assert.equal('test-issuer', p.iss)
end)
it('aud is set to requested audience', function()
local p = get_payload(base_session)
assert.equal('file-sharing', p.aud)
end)
it('exp is approximately now + ttl_seconds', function()
local before = os.time()
local p = get_payload(base_session)
local after = os.time()
assert.is_true(p.exp >= before + 30 and p.exp <= after + 30)
end)
it('nbf is approximately now', function()
local before = os.time()
local p = get_payload(base_session)
local after = os.time()
assert.is_true(p.nbf >= before - 1 and p.nbf <= after)
end)
it('sub defaults to module.host when jitsi_web_query_prefix is nil', function()
local p = get_payload({
full_jid = 'user@example.com/res',
jitsi_web_query_room = 'myroom',
jitsi_web_query_prefix = nil,
})
assert.equal('test.example.com', p.sub)
end)
it('sub defaults to module.host when jitsi_web_query_prefix is empty string', function()
local p = get_payload(base_session)
assert.equal('test.example.com', p.sub)
end)
it('sub is jitsi_web_query_prefix when non-empty', function()
local p = get_payload({
full_jid = 'user@example.com/res',
jitsi_web_query_room = 'myroom',
jitsi_web_query_prefix = 'mysubdomain',
})
assert.equal('mysubdomain', p.sub)
end)
it('context.user taken from session when jitsi_meet_context_user present', function()
local cu = { id = 'uid-99', name = 'Session User', email = 'su@example.com' }
local p = get_payload({
full_jid = 'user@example.com/res',
jitsi_web_query_room = 'myroom',
jitsi_web_query_prefix = '',
jitsi_meet_context_user = cu,
})
assert.same(cu, p.context.user)
end)
it('context.user built from presence when jitsi_meet_context_user absent', function()
local p = get_payload({
full_jid = 'user@example.com/res',
jitsi_web_query_room = 'myroom',
jitsi_web_query_prefix = '',
_presence_nick = 'PresenceNick',
_presence_email = 'presence@example.com',
})
assert.equal('PresenceNick', p.context.user.name)
assert.equal('presence@example.com', p.context.user.email)
assert.equal('user@example.com/res', p.context.user.id)
end)
it('context.group uses jitsi_meet_context_group when present', function()
local p = get_payload({
full_jid = 'user@example.com/res',
jitsi_web_query_room = 'myroom',
jitsi_web_query_prefix = '',
jitsi_meet_context_group = 'group-primary',
granted_jitsi_meet_context_group_id = 'group-fallback',
})
assert.equal('group-primary', p.context.group)
end)
it('context.group falls back to granted_jitsi_meet_context_group_id', function()
local p = get_payload({
full_jid = 'user@example.com/res',
jitsi_web_query_room = 'myroom',
jitsi_web_query_prefix = '',
jitsi_meet_context_group = nil,
granted_jitsi_meet_context_group_id = 'group-fallback',
})
assert.equal('group-fallback', p.context.group)
end)
it('meeting_id taken from room._data.meetingId', function()
local p = get_payload(base_session)
assert.equal('meeting-xyz', p.meeting_id)
end)
it('backend_region taken from region_name config', function()
local p = get_payload(base_session)
assert.equal('us-east-1', p.backend_region)
end)
it('user_region propagated from session', function()
local p = get_payload({
full_jid = 'user@example.com/res',
jitsi_web_query_room = 'myroom',
jitsi_web_query_prefix = '',
user_region = 'eu-west-1',
})
assert.equal('eu-west-1', p.user_region)
end)
it('customer_id uses extracted subdomain customer_id when present', function()
local p = get_payload(base_session, nil, function(_node)
return 'vpaas-magic-cookie-abc123', 'myroom', 'abc123'
end)
assert.equal('abc123', p.customer_id)
end)
it('customer_id falls back to group when extract_subdomain returns no customer_id', function()
local p = get_payload({
full_jid = 'user@example.com/res',
jitsi_web_query_room = 'myroom',
jitsi_web_query_prefix = '',
jitsi_meet_context_group = 'my-group',
}, nil, function(node) return nil, node, nil end)
assert.equal('my-group', p.customer_id)
end)
it('granted_from set from session.granted_jitsi_meet_context_user_id', function()
local p = get_payload({
full_jid = 'user@example.com/res',
jitsi_web_query_room = 'myroom',
jitsi_web_query_prefix = '',
granted_jitsi_meet_context_user_id = 'granter-uid',
})
assert.equal('granter-uid', p.granted_from)
end)
it('key passed to jwt.encode is the key read from key_path', function()
get_payload(base_session)
assert.equal('FAKE-PRIVATE-KEY', mock_jwt.captured_key)
end)
-- -------------------------------------------------------------------
-- encode error path
-- -------------------------------------------------------------------
it('password is empty string when jwt.encode fails', function()
local stub, hooks2, log_calls = make_module_stub()
load_module(stub)
mock_jwt.fail = true
local occupant = make_occupant('user@example.com/res', nil, 'Alice', nil)
local room = make_room('r@conference.example.com', 'mid',
{ ['user@example.com/res'] = occupant })
_G.get_room_by_name_and_subdomain = function() return room end
local items = {}
local services = { push = function(_, item) items[#items + 1] = item end }
hooks2['external_service/credentials']({
requested_credentials = iter({ 'short-lived-token:file-sharing:0' }),
services = services,
origin = { full_jid = 'user@example.com/res', jitsi_web_query_room = 'r',
jitsi_web_query_prefix = '' },
stanza = {},
})
mock_jwt.fail = false
assert.equal(1, #items, 'service entry still pushed')
assert.equal('', items[1].password, 'password empty on encode error')
local found_error = false
for _, e in ipairs(log_calls) do
if e.level == 'error' then found_error = true end
end
assert.is_true(found_error, 'error logged on encode failure')
end)
end) -- generateToken payload
end) -- mod_short_lived_token

View File

@@ -0,0 +1,221 @@
-- Unit tests for mod_token_affiliation.lua
-- Run with busted from resources/prosody-plugins/:
-- busted ../../tests/prosody/lua/
--
-- Stubs every Prosody dependency so no Prosody installation is needed.
--
-- JWT claim correctness and role/affiliation values are covered by the
-- integration tests in mod_token_affiliation_spec.js. These unit tests
-- focus on skip conditions (healthcheck rooms, admin occupants, missing tokens)
-- and the correct affiliation derived from various token claims.
-- ---------------------------------------------------------------------------
-- Captured hooks
-- ---------------------------------------------------------------------------
local pre_join_fn = nil
-- ---------------------------------------------------------------------------
-- Stubs
-- ---------------------------------------------------------------------------
local is_healthcheck_stub = false
local is_admin_stub = false
local util_stub = {
is_admin = function(_jid) return is_admin_stub end,
is_healthcheck_room = function(_jid) return is_healthcheck_stub end,
}
_G.module = {
host = 'conference.example.com',
log = function() end,
get_option_string = function(_, _key, default) return default end,
require = function(_, name)
if name == 'util' then return util_stub end
return {}
end,
hook = function(_, event, fn)
if event == 'muc-occupant-pre-join' then pre_join_fn = fn end
end,
}
-- ---------------------------------------------------------------------------
-- Load the module under test
-- ---------------------------------------------------------------------------
local ok, load_err = pcall(dofile, 'mod_token_affiliation.lua')
if not ok then
describe("mod_token_affiliation", function()
it("skipped — failed to load module", function()
pending(tostring(load_err):match("([^\n]+)") or tostring(load_err))
end)
end)
return
end
assert(pre_join_fn, "mod_token_affiliation did not hook 'muc-occupant-pre-join'")
-- ---------------------------------------------------------------------------
-- Test helpers
-- ---------------------------------------------------------------------------
local jid_counter = 0
local function fresh_jid()
jid_counter = jid_counter + 1
return string.format("user%d@example.com", jid_counter)
end
local function make_room(opts)
opts = opts or {}
local room = {
jid = 'testroom@conference.example.com',
affiliation_log = {},
_members_only = opts.members_only or false,
_affiliations = opts.affiliations or {},
}
function room:set_affiliation(actor, bare_jid, affiliation)
table.insert(self.affiliation_log, { actor = actor, jid = bare_jid, affiliation = affiliation })
end
function room:get_members_only()
return self._members_only
end
function room:get_affiliation(bare_jid)
return self._affiliations[bare_jid]
end
return room
end
local function make_occupant(bare_jid)
return { jid = bare_jid .. '/res', bare_jid = bare_jid, role = 'participant' }
end
local function make_session(opts)
opts = opts or {}
local auth_token
if not opts.no_token then auth_token = 'test.token.value' end
return {
auth_token = auth_token,
jitsi_meet_context_user = opts.context_user,
}
end
local function run_pre_join(room, occupant, session)
pre_join_fn({ room = room, occupant = occupant, origin = session })
end
-- ---------------------------------------------------------------------------
-- Tests
-- ---------------------------------------------------------------------------
describe("mod_token_affiliation", function()
before_each(function()
is_healthcheck_stub = false
is_admin_stub = false
end)
-- -----------------------------------------------------------------------
describe("skip conditions in muc-occupant-pre-join", function()
it("healthcheck room — no affiliation set", function()
is_healthcheck_stub = true
local room = make_room()
local occupant = make_occupant(fresh_jid())
local session = make_session({ context_user = { moderator = true } })
run_pre_join(room, occupant, session)
assert.equal(0, #room.affiliation_log)
end)
it("admin occupant — no affiliation set", function()
is_admin_stub = true
local room = make_room()
local occupant = make_occupant(fresh_jid())
local session = make_session({ context_user = { moderator = true } })
run_pre_join(room, occupant, session)
assert.equal(0, #room.affiliation_log)
end)
it("no token — no affiliation set", function()
local room = make_room()
local occupant = make_occupant(fresh_jid())
local session = make_session({ no_token = true, context_user = { moderator = true } })
run_pre_join(room, occupant, session)
assert.equal(0, #room.affiliation_log)
end)
it("nil session — no affiliation set", function()
local room = make_room()
local occupant = make_occupant(fresh_jid())
run_pre_join(room, occupant, nil)
assert.equal(0, #room.affiliation_log)
end)
end)
-- -----------------------------------------------------------------------
describe("affiliation assignment", function()
it("moderator=true sets owner affiliation and moderator role", function()
local room = make_room()
local occupant = make_occupant(fresh_jid())
local session = make_session({ context_user = { moderator = true } })
run_pre_join(room, occupant, session)
assert.equal(1, #room.affiliation_log)
assert.equal("owner", room.affiliation_log[1].affiliation)
assert.equal("moderator", occupant.role)
end)
it("moderator='true' sets owner affiliation", function()
local room = make_room()
local occupant = make_occupant(fresh_jid())
local session = make_session({ context_user = { moderator = 'true' } })
run_pre_join(room, occupant, session)
assert.equal("owner", room.affiliation_log[1].affiliation)
end)
it("affiliation='owner' sets owner", function()
local room = make_room()
local occupant = make_occupant(fresh_jid())
local session = make_session({ context_user = { affiliation = 'owner' } })
run_pre_join(room, occupant, session)
assert.equal("owner", room.affiliation_log[1].affiliation)
end)
it("affiliation='moderator' sets owner", function()
local room = make_room()
local occupant = make_occupant(fresh_jid())
local session = make_session({ context_user = { affiliation = 'moderator' } })
run_pre_join(room, occupant, session)
assert.equal("owner", room.affiliation_log[1].affiliation)
end)
it("affiliation='teacher' sets owner", function()
local room = make_room()
local occupant = make_occupant(fresh_jid())
local session = make_session({ context_user = { affiliation = 'teacher' } })
run_pre_join(room, occupant, session)
assert.equal("owner", room.affiliation_log[1].affiliation)
end)
it("authenticated non-moderator sets member affiliation, role unchanged", function()
local room = make_room()
local occupant = make_occupant(fresh_jid())
local session = make_session({ context_user = { id = 'plainuser' } })
run_pre_join(room, occupant, session)
assert.equal(1, #room.affiliation_log)
assert.equal("member", room.affiliation_log[1].affiliation)
assert.equal("participant", occupant.role)
end)
it("moderator=false sets member affiliation", function()
local room = make_room()
local occupant = make_occupant(fresh_jid())
local session = make_session({ context_user = { moderator = false } })
run_pre_join(room, occupant, session)
assert.equal("member", room.affiliation_log[1].affiliation)
end)
end)
end) -- mod_token_affiliation

View File

@@ -0,0 +1,113 @@
-- Unit tests for otel.lib.lua
-- Run with busted from resources/prosody-plugins/:
-- busted spec/lua/
-- Requires: lua-cjson
--
-- Stubs every Prosody dependency so no Prosody installation is needed.
local os = require "os"
local cjson = require "cjson"
package.preload['util.json'] = function()
return {
encode = function(x)
return cjson.encode(x)
end
}
end
package.preload['util.array'] = function()
return function(t) return t end
end
package.preload['util.time'] = function()
return {
now = function()
return os.time();
end
}
end
package.preload['util.random'] = function()
return {
bytes = function(n)
local out = {}
for i = 1, n do
out[i] = string.char(math.random(0, 255))
end
return table.concat(out)
end
}
end
package.preload['util.hex'] = function()
return {
to = function(bytes)
local out = {}
for i = 1, #bytes do
out[i] = string.format("%02x", bytes:byte(i))
end
return table.concat(out)
end
}
end
-- Guard: skip the whole suite if the module's hard dependencies aren't installed.
local ok, M = pcall(dofile, "otel.lib.lua")
if not ok then
describe("otel", function()
it("skipped", function()
pending(tostring(M):match("([^\n]+)"))
end)
end)
return
end
describe("otel", function()
describe("span", function()
it("create and export span", function()
local request
local request_fn = function(url, options, callback) request = options end
local exporter = M.Exporter.new(request_fn, "endpoint")
local processor = M.Processor.new(exporter)
local tracer = M.Tracer.new(processor, "service", "scope")
local span = tracer:start_span("name", {
trace_id = "deadbeefdeadbeefdeadbeefdeadbeef",
span_id = "deadbeefdeadbeef",
})
span:set_attribute("key", M.Attribute.string("value"))
span:end_span()
local body = cjson.decode(request.body)
assert.are.same(
{
attributes = {
{ key = "service.name", value = { string_value = "service" } }
}
},
body.resource_spans[1].resource)
assert.are.same(
{
name = "scope"
},
body.resource_spans[1].scope_spans[1].scope
)
local span = body.resource_spans[1].scope_spans[1].spans[1]
assert.are.same(
{ { key = "key", value = { string_value = "value" } } },
span.attributes
)
assert.are.same(
"deadbeefdeadbeefdeadbeefdeadbeef",
span.trace_id
)
assert.are.same(
"deadbeefdeadbeef",
span.parent_span_id
)
assert.are.same(1, span.kind)
assert.are.same("name", span.name)
assert.are.same({ code = 1 }, span.status)
end)
end)
end) -- otel

View File

@@ -0,0 +1,165 @@
-- Unit test for Rayo dial IQ header serialization.
-- Run with busted from resources/prosody-plugins/:
-- busted spec/lua/
--
-- Purpose: a Rayo "dial" IQ (urn:xmpp:rayo:1) carries client-influenced header
-- values (e.g. JvbRoomName / JvbRoomPassword). If a header value contains a raw
-- carriage-return / line-feed, an unescaped serialization would let a client
-- inject extra lines into the XMPP stream — and, downstream, into the SIP
-- headers jigasi builds from these values (CRLF / header injection).
--
-- Prosody's util.stanza serializer neutralizes this by XML-escaping control
-- characters: "\r" -> "&#13;" and "\n" -> "&#10;". This spec produces a dial IQ
-- with such a header and asserts the serialized stanza contains "&#13;&#10;" and
-- leaks no raw CR/LF bytes.
--
-- It uses Prosody's real util.stanza when it resolves on LUA_PATH; otherwise it
-- falls back to a small in-file serializer that mirrors Prosody's escape table,
-- so the contract is still asserted in CI (where Prosody is not installed).
local RAYO_NS = 'urn:xmpp:rayo:1';
-- ---------------------------------------------------------------------------
-- Obtain a stanza builder: prefer the real util.stanza, else a faithful stub.
-- ---------------------------------------------------------------------------
local st, source;
local ok_real, real = pcall(require, 'util.stanza');
if ok_real and type(real) == 'table' and real.iq then
st = real;
source = 'util.stanza (real Prosody)';
else
source = 'in-file reference serializer';
-- Mirrors Prosody's util.stanza escape table: control characters are
-- escaped to numeric entities so they cannot break out of an attribute.
local escape_table = {
["'"] = "&apos;",
['"'] = "&quot;",
["<"] = "&lt;",
[">"] = "&gt;",
["&"] = "&amp;",
["\t"] = "&#9;",
["\n"] = "&#10;",
["\r"] = "&#13;",
};
local function xml_escape(str)
return (str:gsub("['&<>\"\t\n\r]", escape_table));
end
local stanza_mt = {};
stanza_mt.__index = stanza_mt;
local function new_stanza(name, attr)
return setmetatable({
name = name,
attr = attr or {},
tags = {},
last_add = {},
}, stanza_mt);
end
function stanza_mt:tag(name, attr)
local child = new_stanza(name, attr);
local parent = self.last_add[#self.last_add] or self;
table.insert(parent.tags, child);
table.insert(self.last_add, child);
return self;
end
function stanza_mt:up()
table.remove(self.last_add);
return self;
end
local function serialize(node, buf)
table.insert(buf, "<" .. node.name);
for k, v in pairs(node.attr) do
table.insert(buf, " " .. k .. '="' .. xml_escape(tostring(v)) .. '"');
end
if #node.tags == 0 then
table.insert(buf, "/>");
else
table.insert(buf, ">");
for _, child in ipairs(node.tags) do
serialize(child, buf);
end
table.insert(buf, "</" .. node.name .. ">");
end
return buf;
end
stanza_mt.__tostring = function(self)
return table.concat(serialize(self, {}));
end
st = {
iq = function(attr)
return new_stanza('iq', attr);
end,
};
end
-- ---------------------------------------------------------------------------
-- Test
-- ---------------------------------------------------------------------------
describe("Rayo dial IQ header CRLF escaping [" .. source .. "]", function()
-- Builds a dial IQ the same shape mod_muc_jigasi_invite.lua produces:
-- <iq><dial xmlns=rayo><header name=... value=.../></dial></iq>
-- with a JvbRoomName header whose value is spiked with a raw CRLF.
local function build_dial_iq(room_value)
return st.iq({
xmlns = "jabber:client",
type = "set",
to = "jvb@example.com",
from = "focus@example.com",
id = "dial-1",
})
:tag("dial", { xmlns = RAYO_NS, from = "fromnumber", to = "sip:test@example.com" })
:tag("header", { xmlns = RAYO_NS, name = "JvbRoomName", value = room_value })
:up()
:up();
end
it("escapes a CRLF-injected header value to &#13;&#10;", function()
-- A client attempting to smuggle an extra SIP header via CRLF injection.
local malicious = "room@conference.example.com\r\nInjected-Header: evil";
local iq = build_dial_iq(malicious);
local xml = tostring(iq);
assert.is_truthy(xml:find("&#13;&#10;", 1, true),
"serialized dial IQ must escape the raw CRLF to &#13;&#10;");
end)
it("leaks no raw CR or LF bytes in the serialized stanza", function()
local malicious = "room@conference.example.com\r\nInjected-Header: evil";
local iq = build_dial_iq(malicious);
local xml = tostring(iq);
assert.is_nil(xml:find("[\r\n]"),
"serialized dial IQ must not contain any raw CR/LF byte");
end)
it("preserves the room name text around the escaped CRLF", function()
local malicious = "room@conference.example.com\r\nInjected-Header: evil";
local iq = build_dial_iq(malicious);
local xml = tostring(iq);
-- The header name and the surrounding value text survive; only the
-- control characters are entity-encoded.
assert.is_truthy(xml:find("JvbRoomName", 1, true));
assert.is_truthy(xml:find("room@conference.example.com&#13;&#10;Injected%-Header: evil"));
end)
end);

View File

@@ -0,0 +1,329 @@
-- Unit tests for SpeakerStats in mod_speakerstats_component.lua
-- Run with busted from resources/prosody-plugins/:
-- busted spec/lua/
--
-- Stubs every Prosody dependency so no Prosody installation is needed.
-- Only the pure SpeakerStats class (new_SpeakerStats / setDominantSpeaker /
-- isDominantSpeaker / isSilent) is exercised here. Functions that depend on
-- live rooms, stanzas, or XMPP routing are covered by the integration tests.
-- ---------------------------------------------------------------------------
-- Controllable clock
-- socket.gettime() returns seconds (float); the module multiplies by 1000.
-- Call set_clock(ms) to advance time before each action.
-- ---------------------------------------------------------------------------
local _clock_ms = 0
local function set_clock(ms)
_clock_ms = ms
end
-- ---------------------------------------------------------------------------
-- Package preloads — registered before dofile() so the module sees them
-- ---------------------------------------------------------------------------
package.preload['socket'] = function()
return {
gettime = function() return _clock_ms / 1000 end,
}
end
package.preload['util.jid'] = function()
local function split(jid)
if not jid then return nil, nil, nil end
local node = jid:match('^([^@]+)@')
local host = jid:match('@([^/]+)')
local res = jid:match('/(.+)$')
return node, host, res
end
return {
resource = function(jid)
if not jid then return nil end
return jid:match('/(.+)$')
end,
split = split,
}
end
package.preload['util.stanza'] = function()
-- Only used by occupant_joined (route_stanza) — not exercised here.
return {
message = function()
local s = {}
s.tag = function(self, _, _) return self end
s.text = function(self, _) return self end
s.up = function(self) return self end
return s
end,
}
end
package.preload['cjson.safe'] = function()
return {
encode = function(_) return '{}', nil end,
decode = function(_) return {}, nil end,
}
end
package.preload['util.queue'] = function()
return {
new = function(max_size)
local items = {}
local q = {}
function q:push(v)
table.insert(items, v)
while #items > max_size do
table.remove(items, 1)
end
end
function q:items()
local i = 0
return function()
i = i + 1
return items[i]
end
end
function q:count() return #items end
return q
end,
}
end
package.preload['util.array'] = function()
-- Prosody's util.array module is itself callable as a constructor.
local arr_mt = {}
arr_mt.__index = arr_mt
return setmetatable({}, {
__call = function(_, iter)
local t = setmetatable({}, arr_mt)
if iter then
for v in iter do t[#t + 1] = v end
end
return t
end,
})
end
package.preload['util.async'] = function() return {} end
-- ---------------------------------------------------------------------------
-- Global stubs required by mod_speakerstats_component.lua at load time
-- ---------------------------------------------------------------------------
_G.prosody = { hosts = {}, version = 'test' }
-- Stub for the util library loaded via module:require "util"
local util_stub = {
is_admin = function() return false end,
get_room_from_jid = function() return nil end,
room_jid_match_rewrite = function(jid) return jid end,
is_jibri = function() return false end,
is_healthcheck_room = function() return false end,
is_transcriber = function() return false end,
-- noop: prevents hook registration against live muc components
process_host_module = function() end,
}
local mod = { host = 'speakerstats.test.example.com' }
mod.log = function() end
mod.get_option_string = function(_, key, default)
if key == 'muc_component' then return 'conference.test.example.com' end
if key == 'muc_mapper_domain_base' then return 'test.example.com' end
return default
end
mod.get_option_number = function(_, _, default) return default end
mod.hook = function() end
mod.fire_event = function() end
mod.context = function() return { fire_event = function() end } end
mod.require = function(_, name)
if name == 'util' then return util_stub end
error('unexpected module:require("' .. tostring(name) .. '")')
end
_G.module = mod
-- ---------------------------------------------------------------------------
-- Load the module under test
-- ---------------------------------------------------------------------------
local ok, err = pcall(dofile, 'mod_speakerstats_component.lua')
if not ok then
describe('mod_speakerstats_component', function()
it('skipped — failed to load', function()
pending(tostring(err):match('([^\n]+)') or tostring(err))
end)
end)
return
end
-- new_SpeakerStats is a module-level global (no `local` prefix in the source).
assert(type(new_SpeakerStats) == 'function',
'new_SpeakerStats must be a global function after loading the module')
-- ---------------------------------------------------------------------------
-- Helpers
-- ---------------------------------------------------------------------------
local function new_stats(nick)
return new_SpeakerStats(nick or 'testnick', nil)
end
-- ---------------------------------------------------------------------------
-- Tests
-- ---------------------------------------------------------------------------
describe('SpeakerStats', function()
-- Reset clock before every test so time never leaks between cases.
before_each(function() set_clock(0) end)
-- -----------------------------------------------------------------------
describe('initial state', function()
it('is not dominant speaker', function()
assert.is_false(new_stats():isDominantSpeaker())
end)
it('is not silent', function()
assert.is_false(new_stats():isSilent())
end)
it('has zero total speaking time', function()
assert.equal(0, new_stats().totalDominantSpeakerTime)
end)
end)
-- -----------------------------------------------------------------------
describe('setDominantSpeaker', function()
it('accumulates time when dominant speaker stops', function()
local s = new_stats()
set_clock(0)
s:setDominantSpeaker(true, false)
set_clock(1000)
s:setDominantSpeaker(false, false)
assert.equal(1000, s.totalDominantSpeakerTime)
end)
it('does not count time when starting dominant+silent', function()
local s = new_stats()
set_clock(0)
s:setDominantSpeaker(true, true) -- dominant but muted from the start
set_clock(5000)
s:setDominantSpeaker(false, false)
assert.equal(0, s.totalDominantSpeakerTime)
end)
it('accumulates time up to the moment of going silent', function()
local s = new_stats()
set_clock(0)
s:setDominantSpeaker(true, false) -- start speaking
set_clock(500)
s:setDominantSpeaker(true, true) -- go silent while still dominant
assert.equal(500, s.totalDominantSpeakerTime)
end)
it('resumes accumulating after returning from silence', function()
local s = new_stats()
set_clock(0)
s:setDominantSpeaker(true, false) -- speak: timer starts at 0
set_clock(500)
s:setDominantSpeaker(true, true) -- mute: +500 ms accumulated
set_clock(700)
s:setDominantSpeaker(true, false) -- unmute: timer restarts at 700
set_clock(900)
s:setDominantSpeaker(false, false) -- stop: +200 ms
assert.equal(700, s.totalDominantSpeakerTime) -- 500 + 200
end)
it('does not double-count when stopped twice in a row', function()
local s = new_stats()
set_clock(0)
s:setDominantSpeaker(true, false)
set_clock(300)
s:setDominantSpeaker(false, false) -- first stop: +300 ms
set_clock(9999)
s:setDominantSpeaker(false, false) -- second stop — must be a no-op
assert.equal(300, s.totalDominantSpeakerTime)
end)
it('accumulates across multiple dominant speaker episodes', function()
local s = new_stats()
set_clock(0)
s:setDominantSpeaker(true, false)
set_clock(400)
s:setDominantSpeaker(false, false) -- first episode: +400 ms
set_clock(1000)
s:setDominantSpeaker(true, false)
set_clock(1600)
s:setDominantSpeaker(false, false) -- second episode: +600 ms
assert.equal(1000, s.totalDominantSpeakerTime)
end)
it('updates isDominantSpeaker state', function()
local s = new_stats()
s:setDominantSpeaker(true, false)
assert.is_true(s:isDominantSpeaker())
s:setDominantSpeaker(false, false)
assert.is_false(s:isDominantSpeaker())
end)
it('updates isSilent state', function()
local s = new_stats()
s:setDominantSpeaker(true, false)
assert.is_false(s:isSilent())
s:setDominantSpeaker(true, true)
assert.is_true(s:isSilent())
s:setDominantSpeaker(true, false)
assert.is_false(s:isSilent())
end)
it('does not start timer when becoming dominant while silent', function()
-- Silence=true on the transition to dominant → no timer started.
-- Stopping later must not add spurious time.
local s = new_stats()
set_clock(0)
s:setDominantSpeaker(true, true) -- dominant+silent, no timer
set_clock(2000)
s:setDominantSpeaker(true, false) -- unmute: timer starts NOW at t=2000
set_clock(2500)
s:setDominantSpeaker(false, false) -- stop: +500 ms
assert.equal(500, s.totalDominantSpeakerTime)
end)
end) -- setDominantSpeaker
end) -- SpeakerStats

View File

@@ -0,0 +1,919 @@
-- Unit tests for util.lib.lua
-- Run with busted from resources/prosody-plugins/:
-- busted spec/lua/
--
-- Stubs every Prosody dependency so no Prosody installation is needed.
-- Functions that rely on live Prosody objects (async HTTP, room lookup,
-- stanza mutation, etc.) are not covered here — they are exercised by the
-- integration tests.
--
-- Module is loaded with:
-- muc_domain_prefix = "conference" (default)
-- muc_domain_base = "test.example.com" (module.host default)
-- muc_domain = "conference.test.example.com"
-- moderated_subdomains = {"moderated-subdomain"}
-- moderated_rooms = {"moderatedroom"}
-- ---------------------------------------------------------------------------
-- Minimal Set implementation (mirrors Prosody's util.set behaviour)
-- ---------------------------------------------------------------------------
local Set_mt = {}
Set_mt.__index = Set_mt
-- set / fn → new set with fn mapped over every element
Set_mt.__div = function(self, fn)
local new_s = setmetatable({ _data = {}, _is_set = true }, Set_mt)
for k in pairs(self._data) do
local mapped = fn(k)
if mapped ~= nil then new_s._data[mapped] = true end
end
return new_s
end
function Set_mt:contains(v) return self._data[v] == true end
function Set_mt:empty() return next(self._data) == nil end
function Set_mt:items() return pairs(self._data) end
local function new_set(list)
local s = setmetatable({ _data = {}, _is_set = true }, Set_mt)
for _, v in ipairs(list or {}) do s._data[v] = true end
return s
end
-- ---------------------------------------------------------------------------
-- Package preloads — must be registered before dofile()
-- ---------------------------------------------------------------------------
package.preload['util.set'] = function()
return {
is_set = function(v) return type(v) == 'table' and v._is_set == true end,
new = new_set,
}
end
package.preload['util.jid'] = function()
local function jid_split(j)
if not j then return nil, nil, nil end
local node, host, resource
local at = j:find('@')
local slash
if at then
slash = j:find('/', at + 1)
node = j:sub(1, at - 1)
if slash then
host = j:sub(at + 1, slash - 1)
resource = j:sub(slash + 1)
else
host = j:sub(at + 1)
end
else
slash = j:find('/')
if slash then
host = j:sub(1, slash - 1)
resource = j:sub(slash + 1)
else
host = j
end
end
node = (node and #node > 0) and node or nil
host = (host and #host > 0) and host or nil
resource = (resource and #resource > 0) and resource or nil
return node, host, resource
end
return {
split = jid_split,
join = function(node, host, resource)
local j = ''
if node then j = node .. '@' end
j = j .. (host or '')
if resource then j = j .. '/' .. resource end
return j
end,
prep = function(j) return j end,
bare = function(j)
if not j then return nil end
local node, host = jid_split(j)
if node then return node .. '@' .. (host or '') end
return host
end,
node = function(j)
if not j then return nil end
local node = jid_split(j)
return node
end,
}
end
package.preload['util.cache'] = function()
return {
new = function(_size)
local t = {}
return {
get = function(_, k) return t[k] end,
set = function(_, k, v) t[k] = v end,
}
end,
}
end
package.preload['util.array'] = function()
local Array_mt = {}
Array_mt.__index = Array_mt
function Array_mt:push(v)
self[#self + 1] = v
return self
end
return function()
return setmetatable({}, Array_mt)
end
end
-- Modules used only by functions we do not test — empty stubs suffice.
package.preload['util.stanza'] = function() return {} end
package.preload['util.timer'] = function() return { add_task = function() end } end
package.preload['util.async'] = function() return {} end
package.preload['net.http'] = function() return {} end
package.preload['net.http.server'] = function() return {} end
package.preload['core.usermanager'] = function() return {} end
-- ---------------------------------------------------------------------------
-- Global stubs required by util.lib.lua at module load time
--
-- Busted sandboxes spec files via setfenv: bare assignments like
-- module = {...}
-- go into the busted context env, not _G. But dofile() always runs chunks
-- in _G. So any global that util.lib.lua reads at load time must be written
-- directly into _G via _G.key = value.
-- package.preload is exempt — setting a key there mutates the real table.
-- ---------------------------------------------------------------------------
_G.prosody = { version = "test", platform = "posix" }
_G.hosts = {}
local mod = { host = "test.example.com" }
mod.log = function() end
mod.get_option_string = function(_, _key, default) return default end
mod.get_option_set = function(_, key, default)
-- Populate the sets inspected by is_moderated() with test fixtures.
if key == "allowners_moderated_subdomains" then
return new_set({ "moderated-subdomain" })
elseif key == "allowners_moderated_rooms" then
return new_set({ "moderatedroom" })
end
return new_set(type(default) == 'table' and default or {})
end
mod.get_option_inherited_set = function(_, _key, default)
return new_set(type(default) == 'table' and default or {})
end
mod.context = function(_, _host)
return {
get_option_set = function(_, _key, default)
return new_set(type(default) == 'table' and default or {})
end,
}
end
_G.module = mod
-- ---------------------------------------------------------------------------
-- Load the module under test
-- ---------------------------------------------------------------------------
local ok, M = pcall(dofile, "util.lib.lua")
if not ok then
describe("util.lib", function()
it("skipped — failed to load", function()
pending(tostring(M):match("([^\n]+)") or tostring(M))
end)
end)
return
end
-- Convenience: muc_domain derived from the defaults above
local MUC_DOMAIN = "conference.test.example.com"
local MUC_BASE = "test.example.com"
local MUC_PREFIX = "conference"
-- ---------------------------------------------------------------------------
-- Tests
-- ---------------------------------------------------------------------------
describe("util.lib", function()
-- -----------------------------------------------------------------------
describe("starts_with", function()
it("returns false for nil string", function()
assert.is_false(M.starts_with(nil, "x"))
end)
it("returns true when string starts with prefix", function()
assert.is_true(M.starts_with("hello world", "hello"))
end)
it("returns false when string does not start with prefix", function()
assert.is_false(M.starts_with("hello", "world"))
end)
it("returns true for empty prefix", function()
assert.is_true(M.starts_with("hello", ""))
end)
it("returns true when string equals prefix exactly", function()
assert.is_true(M.starts_with("abc", "abc"))
end)
it("returns false when prefix is longer than string", function()
assert.is_false(M.starts_with("hi", "hello"))
end)
end)
-- -----------------------------------------------------------------------
describe("ends_with", function()
it("returns false for nil string", function()
assert.is_false(M.ends_with(nil, "x"))
end)
it("returns true when string ends with suffix", function()
assert.is_true(M.ends_with("hello", "llo"))
end)
it("returns false when string does not end with suffix", function()
assert.is_false(M.ends_with("hello", "world"))
end)
it("returns true for empty suffix", function()
assert.is_true(M.ends_with("hello", ""))
end)
it("returns true when string equals suffix exactly", function()
assert.is_true(M.ends_with("abc", "abc"))
end)
end)
-- -----------------------------------------------------------------------
describe("starts_with_one_of", function()
it("returns false for nil string", function()
assert.is_false(M.starts_with_one_of(nil, { "a" }))
end)
it("returns false for nil prefixes", function()
assert.is_false(M.starts_with_one_of("hello", nil))
end)
it("matches against a plain table of prefixes", function()
assert.is_truthy(M.starts_with_one_of("recorder@example.com", { "recorder@", "jibri@" }))
end)
it("returns false when no prefix in table matches", function()
assert.is_false(M.starts_with_one_of("user@example.com", { "recorder@", "jibri@" }))
end)
it("matches against a Set of prefixes", function()
local prefixes = new_set({ "recorder@", "jibri@" })
assert.is_truthy(M.starts_with_one_of("recorder@example.com", prefixes))
end)
it("returns false when no prefix in Set matches", function()
local prefixes = new_set({ "recorder@", "jibri@" })
assert.is_false(M.starts_with_one_of("user@example.com", prefixes))
end)
it("returns the matched prefix value (table)", function()
assert.equal("rec@", M.starts_with_one_of("rec@host", { "rec@", "other@" }))
end)
end)
-- -----------------------------------------------------------------------
describe("is_healthcheck_room", function()
it("recognises a jicofo health-check room", function()
assert.is_true(M.is_healthcheck_room("__jicofo-health-check-abc@conference.example.com"))
end)
it("recognises the bare prefix itself", function()
assert.is_true(M.is_healthcheck_room("__jicofo-health-check"))
end)
it("returns false for a regular room", function()
assert.is_false(M.is_healthcheck_room("myroom@conference.example.com"))
end)
it("returns false for nil", function()
assert.is_false(M.is_healthcheck_room(nil))
end)
end)
-- -----------------------------------------------------------------------
describe("is_feature_allowed", function()
it("returns is_moderator when features table is absent", function()
assert.is_true(M.is_feature_allowed("feature", nil, true))
assert.is_false(M.is_feature_allowed("feature", nil, false))
end)
it("returns true when feature value is the string 'true'", function()
assert.is_true(M.is_feature_allowed("f", { f = "true" }, false))
end)
it("returns true when feature value is boolean true", function()
assert.is_true(M.is_feature_allowed("f", { f = true }, false))
end)
it("returns false when feature is present but set to false", function()
assert.is_false(M.is_feature_allowed("f", { f = false }, true))
end)
it("returns false when feature is absent from table", function()
assert.is_false(M.is_feature_allowed("f", { g = "true" }, false))
end)
end)
-- -----------------------------------------------------------------------
describe("extract_subdomain", function()
it("returns nil subdomain and the node itself when no brackets", function()
local subdomain, room, customer_id = M.extract_subdomain("myroom")
assert.is_nil(subdomain)
assert.equal("myroom", room)
assert.is_nil(customer_id)
end)
it("extracts subdomain and room name from [sub]room format", function()
local subdomain, room = M.extract_subdomain("[foo]myroom")
assert.equal("foo", subdomain)
assert.equal("myroom", room)
end)
it("extracts subdomain from vpaas magic cookie node", function()
local subdomain, room, customer_id =
M.extract_subdomain("[vpaas-magic-cookie-abc123]myroom")
assert.equal("vpaas-magic-cookie-abc123", subdomain)
assert.equal("myroom", room)
assert.equal("abc123", customer_id)
end)
it("returns no customer_id for non-vpaas subdomain", function()
local subdomain, room, customer_id = M.extract_subdomain("[tenant]myroom")
assert.equal("tenant", subdomain)
assert.equal("myroom", room)
assert.is_nil(customer_id)
end)
it("returns nil subdomain for empty brackets (pattern requires >=1 char)", function()
-- "[]room" does not match ^%[([^%]]+)%](.+)$
local subdomain, room = M.extract_subdomain("[]room")
assert.is_nil(subdomain)
assert.equal("[]room", room)
end)
end)
-- -----------------------------------------------------------------------
-- room_jid_split_subdomain / room_jid_match_rewrite /
-- internal_room_jid_match_rewrite
--
-- With the default stubs:
-- muc_domain_prefix = "conference"
-- muc_domain_base = "test.example.com"
-- target_subdomain_pattern matches conference.<sub>.test.example.com
-- -----------------------------------------------------------------------
describe("room_jid_split_subdomain", function()
it("extracts node, host, resource and subdomain from a multidomain JID", function()
local node, host, resource, subdomain =
M.room_jid_split_subdomain("room1@conference.foo.test.example.com/res")
assert.equal("room1", node)
assert.equal("conference.foo.test.example.com", host)
assert.equal("res", resource)
assert.equal("foo", subdomain)
end)
it("returns nil subdomain for a non-multidomain MUC JID", function()
local node, host, _, subdomain =
M.room_jid_split_subdomain("room1@conference.test.example.com")
assert.equal("room1", node)
assert.equal("conference.test.example.com", host)
assert.is_nil(subdomain)
end)
it("handles JID with no node (bare host)", function()
local node, host, _, subdomain =
M.room_jid_split_subdomain("conference.foo.test.example.com")
assert.is_nil(node)
assert.equal("conference.foo.test.example.com", host)
assert.equal("foo", subdomain)
end)
-- Regression: target_subdomain_pattern used an unescaped '.' between the
-- prefix and the subdomain capture group. In Lua patterns '.' matches any
-- character, so "conference-foo.test.example.com" (hyphen) was incorrectly
-- treated as a subdomain JID with subdomain "foo". The correct pattern
-- requires a literal dot: "conference%.foo.test.example.com".
it("returns nil subdomain for a hyphenated-prefix host (regression: unescaped dot)", function()
-- 'conference-foo' shares the same base domain but is NOT a subdomain
-- MUC component; the separator is a hyphen, not a dot.
local _, _, _, subdomain =
M.room_jid_split_subdomain("room@conference-foo.test.example.com")
assert.is_nil(subdomain)
end)
it("returns nil subdomain when only the suffix differs by a non-dot character", function()
-- e.g. conference.foo-test.example.com — the subdomain capture is
-- [^%.]+, so a hyphen inside 'foo-test' is fine, but this test
-- confirms the prefix boundary is strict.
local _, _, _, subdomain =
M.room_jid_split_subdomain("room@conferenceXfoo.test.example.com")
assert.is_nil(subdomain)
end)
end)
-- -----------------------------------------------------------------------
describe("room_jid_match_rewrite", function()
it("rewrites a multidomain JID to internal [sub]room@muc_domain form", function()
local result = M.room_jid_match_rewrite("room1@conference.foo.test.example.com")
assert.equal("[foo]room1@" .. MUC_DOMAIN, result)
end)
it("preserves the resource in the rewritten JID", function()
local result = M.room_jid_match_rewrite("room1@conference.foo.test.example.com/r")
assert.equal("[foo]room1@" .. MUC_DOMAIN .. "/r", result)
end)
it("returns the JID unchanged when there is no subdomain", function()
local jid = "room1@" .. MUC_DOMAIN
assert.equal(jid, M.room_jid_match_rewrite(jid))
end)
it("handles host-only JID (no node) — stores id in roomless_iqs and rewrites host", function()
local stanza = { attr = { id = "iq-roomless-1", to = "conference.foo.test.example.com" } }
local result = M.room_jid_match_rewrite("conference.foo.test.example.com", stanza)
-- No node → only host is rewritten
assert.equal(MUC_DOMAIN, result)
end)
-- Regression: unescaped '.' in target_subdomain_pattern caused
-- "conference-internal.test.example.com" to be treated as a subdomain
-- MUC host and rewritten to "[internal]room@conference.test.example.com".
it("does not rewrite a JID whose host uses a hyphen after the prefix (regression)", function()
local jid = "room@conference-internal.test.example.com"
assert.equal(jid, M.room_jid_match_rewrite(jid))
end)
it("does not rewrite a JID whose host uses an arbitrary non-dot separator after the prefix", function()
local jid = "room@conferenceXinternal.test.example.com"
assert.equal(jid, M.room_jid_match_rewrite(jid))
end)
end)
-- -----------------------------------------------------------------------
describe("internal_room_jid_match_rewrite", function()
it("rewrites an internal [sub]room JID back to pretty conference.sub.base/room form", function()
local result = M.internal_room_jid_match_rewrite("[foo]room1@" .. MUC_DOMAIN)
assert.equal("room1@" .. MUC_PREFIX .. ".foo." .. MUC_BASE, result)
end)
it("preserves resource in the rewritten JID", function()
local result = M.internal_room_jid_match_rewrite("[foo]room1@" .. MUC_DOMAIN .. "/res")
assert.equal("room1@" .. MUC_PREFIX .. ".foo." .. MUC_BASE .. "/res", result)
end)
it("returns JID unchanged when host is not the MUC domain", function()
local jid = "room1@other.domain.com"
assert.equal(jid, M.internal_room_jid_match_rewrite(jid))
end)
it("returns JID unchanged when node lacks [sub] prefix", function()
local jid = "room1@" .. MUC_DOMAIN
assert.equal(jid, M.internal_room_jid_match_rewrite(jid))
end)
it("resolves a roomless IQ stored by room_jid_match_rewrite", function()
local stanza = { attr = { id = "iq-roomless-2", to = "conference.bar.test.example.com" } }
-- Trigger storage of the roomless IQ
M.room_jid_match_rewrite("conference.bar.test.example.com", stanza)
-- internal_room_jid_match_rewrite should return the stored value
local result = M.internal_room_jid_match_rewrite("anything@" .. MUC_DOMAIN, stanza)
assert.equal("conference.bar.test.example.com", result)
end)
end)
-- -----------------------------------------------------------------------
describe("table helpers", function()
describe("table_shallow_copy", function()
it("copies all key-value pairs", function()
local orig = { a = 1, b = "x", c = true }
local copy = M.table_shallow_copy(orig)
assert.equal(1, copy.a)
assert.equal("x", copy.b)
assert.equal(true, copy.c)
end)
it("returns a distinct table (not the same reference)", function()
local orig = { x = 1 }
local copy = M.table_shallow_copy(orig)
assert.not_equal(orig, copy)
end)
it("does not deep-copy nested tables", function()
local inner = { z = 9 }
local orig = { a = inner }
local copy = M.table_shallow_copy(orig)
assert.equal(inner, copy.a) -- same reference
end)
end)
-- -------------------------------------------------------------------
describe("table_find", function()
it("returns the index of a found value", function()
assert.equal(2, M.table_find({ "a", "b", "c" }, "b"))
end)
it("returns nil when value is not present", function()
assert.is_nil(M.table_find({ "a", "b" }, "z"))
end)
it("returns nil for nil table", function()
assert.is_nil(M.table_find(nil, "x"))
end)
it("returns nil when searching for nil", function()
assert.is_nil(M.table_find({ "a", "b" }, nil))
end)
end)
-- -------------------------------------------------------------------
describe("table_add", function()
it("appends all values from t2 onto t1", function()
local t1 = { "a", "b" }
local t2 = { "c", "d" }
M.table_add(t1, t2)
assert.same({ "a", "b", "c", "d" }, t1)
end)
it("leaves t1 unchanged when t2 is empty", function()
local t1 = { "a" }
M.table_add(t1, {})
assert.same({ "a" }, t1)
end)
end)
-- -------------------------------------------------------------------
describe("table_compare", function()
it("reports removed keys", function()
local removed, added, modified = M.table_compare({ a = 1, b = 2 }, { a = 1 })
assert.equal(1, #removed)
assert.equal("b", removed[1])
assert.equal(0, #added)
assert.equal(0, #modified)
end)
it("reports added keys", function()
local removed, added, modified = M.table_compare({ a = 1 }, { a = 1, b = 2 })
assert.equal(0, #removed)
assert.equal(1, #added)
assert.equal("b", added[1])
assert.equal(0, #modified)
end)
it("reports modified values", function()
local removed, added, modified = M.table_compare({ a = 1 }, { a = 99 })
assert.equal(0, #removed)
assert.equal(0, #added)
assert.equal(1, #modified)
assert.equal("a", modified[1])
end)
it("returns three empty lists for identical tables", function()
local removed, added, modified = M.table_compare({ x = 1 }, { x = 1 })
assert.equal(0, #removed)
assert.equal(0, #added)
assert.equal(0, #modified)
end)
end)
-- -------------------------------------------------------------------
describe("table_equals", function()
it("returns true for two identical tables", function()
assert.is_true(M.table_equals({ a = 1, b = 2 }, { a = 1, b = 2 }))
end)
it("returns false when values differ", function()
assert.is_false(M.table_equals({ a = 1 }, { a = 2 }))
end)
it("returns false when keys differ", function()
assert.is_false(M.table_equals({ a = 1 }, { b = 1 }))
end)
it("returns true when both arguments are nil", function()
assert.is_true(M.table_equals(nil, nil))
end)
it("returns false when only one argument is nil", function()
assert.is_false(M.table_equals(nil, {}))
assert.is_false(M.table_equals({}, nil))
end)
end)
end) -- table helpers
-- -----------------------------------------------------------------------
describe("split_string", function()
it("splits a simple comma-delimited string", function()
local result = M.split_string("a,b,c", ",")
assert.equal("a", result[1])
assert.equal("b", result[2])
assert.equal("c", result[3])
end)
it("produces a single element when delimiter is absent", function()
local result = M.split_string("hello", ",")
assert.equal("hello", result[1])
assert.is_nil(result[2])
end)
it("produces empty strings at boundaries", function()
local result = M.split_string(",a,", ",")
assert.equal("", result[1])
assert.equal("a", result[2])
assert.equal("", result[3])
end)
it("handles multi-character delimiter", function()
local result = M.split_string("x::y::z", "::")
assert.equal("x", result[1])
assert.equal("y", result[2])
assert.equal("z", result[3])
end)
end)
-- -----------------------------------------------------------------------
describe("get_sip_jibri_email_prefix", function()
it("returns nil for nil email", function()
assert.is_nil(M.get_sip_jibri_email_prefix(nil))
end)
it("returns nil for unrecognised email", function()
assert.is_nil(M.get_sip_jibri_email_prefix("user@example.com"))
end)
it("recognises outbound-sip-jibri@ prefix", function()
assert.equal("outbound-sip-jibri@",
M.get_sip_jibri_email_prefix("outbound-sip-jibri@example.com"))
end)
it("recognises sipjibriouta@ prefix", function()
assert.equal("sipjibriouta@",
M.get_sip_jibri_email_prefix("sipjibriouta@example.com"))
end)
it("recognises sipjibrioutb@ prefix", function()
assert.equal("sipjibrioutb@",
M.get_sip_jibri_email_prefix("sipjibrioutb@example.com"))
end)
it("recognises inbound-sip-jibri@ prefix", function()
assert.equal("inbound-sip-jibri@",
M.get_sip_jibri_email_prefix("inbound-sip-jibri@example.com"))
end)
it("recognises sipjibriina@ prefix", function()
assert.equal("sipjibriina@",
M.get_sip_jibri_email_prefix("sipjibriina@example.com"))
end)
end)
-- -----------------------------------------------------------------------
describe("is_jibri", function()
it("returns truthy for recorder@ prefixed JID string", function()
assert.is_truthy(M.is_jibri("recorder@recorder.example.com"))
end)
it("returns truthy for jibria@ prefixed JID string", function()
assert.is_truthy(M.is_jibri("jibria@recorder.example.com"))
end)
it("returns truthy for jibrib@ prefixed JID string", function()
assert.is_truthy(M.is_jibri("jibrib@recorder.example.com"))
end)
it("returns false for a non-recorder JID string", function()
assert.is_false(M.is_jibri("user@example.com"))
end)
it("accepts an occupant object and inspects its .jid field", function()
local occupant = { jid = "recorder@recorder.example.com" }
assert.is_truthy(M.is_jibri(occupant))
end)
it("returns false for occupant object with non-recorder JID", function()
local occupant = { jid = "user@example.com" }
assert.is_false(M.is_jibri(occupant))
end)
end)
-- -----------------------------------------------------------------------
describe("is_transcriber", function()
it("returns truthy for transcriber@ JID", function()
assert.is_truthy(M.is_transcriber("transcriber@recorder.example.com"))
end)
it("returns truthy for transcribera@ JID", function()
assert.is_truthy(M.is_transcriber("transcribera@recorder.example.com"))
end)
it("returns truthy for transcriberb@ JID", function()
assert.is_truthy(M.is_transcriber("transcriberb@recorder.example.com"))
end)
it("returns false for a regular user JID", function()
assert.is_false(M.is_transcriber("user@example.com"))
end)
it("returns false for nil", function()
assert.is_false(M.is_transcriber(nil))
end)
end)
-- -----------------------------------------------------------------------
describe("is_vpaas", function()
-- muc_domain = "conference.test.example.com" (derived from defaults)
it("returns false for nil room", function()
assert.is_false(M.is_vpaas(nil))
end)
it("returns true for a room whose tenant starts with vpaas-magic-cookie-", function()
local room = { jid = "[vpaas-magic-cookie-xyz]myroom@" .. MUC_DOMAIN }
assert.is_true(M.is_vpaas(room))
end)
it("caches the result in room.is_vpaas", function()
local room = { jid = "[vpaas-magic-cookie-xyz]myroom@" .. MUC_DOMAIN }
M.is_vpaas(room)
assert.is_true(room.is_vpaas)
end)
it("returns false for a room on the wrong MUC domain", function()
local room = { jid = "[vpaas-magic-cookie-xyz]myroom@other.domain.com" }
assert.is_false(M.is_vpaas(room))
end)
it("returns false for a room without a tenant bracket prefix", function()
local room = { jid = "myroom@" .. MUC_DOMAIN }
assert.is_false(M.is_vpaas(room))
end)
it("returns false for a room whose tenant does not start with vpaas-magic-cookie-", function()
local room = { jid = "[othertenant]myroom@" .. MUC_DOMAIN }
assert.is_false(M.is_vpaas(room))
end)
it("returns cached is_vpaas value when present", function()
local room = { jid = "[vpaas-magic-cookie-xyz]myroom@" .. MUC_DOMAIN, is_vpaas = false }
-- Cached false should be returned without re-evaluating the JID
assert.is_false(M.is_vpaas(room))
end)
end)
-- -----------------------------------------------------------------------
describe("is_moderated", function()
-- moderated_subdomains = {"moderated-subdomain"} (configured in stub)
-- moderated_rooms = {"moderatedroom"}
it("returns true and room/subdomain for a moderated subdomain", function()
local result, room_name, subdomain =
M.is_moderated("[moderated-subdomain]someroom@" .. MUC_DOMAIN)
assert.is_true(result)
assert.equal("someroom", room_name)
assert.equal("moderated-subdomain", subdomain)
end)
it("returns true and room name for a moderated room (no subdomain)", function()
local result, room_name, subdomain =
M.is_moderated("moderatedroom@" .. MUC_DOMAIN)
assert.is_true(result)
assert.equal("moderatedroom", room_name)
assert.is_nil(subdomain)
end)
it("returns false for an unmoderated room with a subdomain", function()
assert.is_false(M.is_moderated("[other-subdomain]room@" .. MUC_DOMAIN))
end)
it("returns false for an unmoderated room without a subdomain", function()
assert.is_false(M.is_moderated("normalroom@" .. MUC_DOMAIN))
end)
end)
describe("is_focus", function()
it("nick ending in /focus returns true", function()
assert.is_true(M.is_focus("room@conference.example.com/focus"))
end)
it("plain /focus string returns true", function()
assert.is_true(M.is_focus("/focus"))
end)
it("regular nick returns false", function()
assert.is_false(M.is_focus("room@conference.example.com/user1"))
end)
it("nick containing focus but not ending in /focus returns false", function()
assert.is_false(M.is_focus("room@conference.example.com/focususer"))
end)
it("bare word focus without slash returns false", function()
assert.is_false(M.is_focus("focus"))
end)
it("empty string returns false", function()
assert.is_false(M.is_focus(""))
end)
end)
describe("is_focus_nick", function()
it("returns true for the bare string 'focus'", function()
assert.is_true(M.is_focus_nick("focus"))
end)
it("returns false for a regular nick", function()
assert.is_false(M.is_focus_nick("user1"))
end)
it("returns false for a nick that contains focus but is not exactly focus", function()
assert.is_false(M.is_focus_nick("focususer"))
end)
it("returns false for a full MUC JID (slash-qualified)", function()
assert.is_false(M.is_focus_nick("room@conference.example.com/focus"))
end)
it("returns false for empty string", function()
assert.is_false(M.is_focus_nick(""))
end)
end)
describe("is_focus_jid", function()
it("returns true for focus@auth.example.com", function()
assert.is_true(M.is_focus_jid("focus@auth.example.com"))
end)
it("returns true for focus@auth.example.com/resource", function()
assert.is_true(M.is_focus_jid("focus@auth.example.com/res123"))
end)
it("returns false for a regular user JID", function()
assert.is_false(M.is_focus_jid("user@auth.example.com"))
end)
it("returns false for JID whose node contains but is not exactly focus", function()
assert.is_false(M.is_focus_jid("focususer@auth.example.com"))
end)
it("returns false for a host-only JID (no node)", function()
assert.is_false(M.is_focus_jid("auth.example.com"))
end)
it("returns false for nil", function()
assert.is_false(M.is_focus_jid(nil))
end)
end)
describe("build_room_address", function()
-- muc_domain_prefix = "conference" (set in module stub above)
it("builds room JID with no subdomain", function()
assert.equal("myroom@conference.example.com",
M.build_room_address("myroom", "example.com", nil))
end)
it("prepends [subdomain] when subdomain is given", function()
assert.equal("[tenant]myroom@conference.example.com",
M.build_room_address("myroom", "example.com", "tenant"))
end)
it("treats empty-string subdomain as no subdomain", function()
assert.equal("myroom@conference.example.com",
M.build_room_address("myroom", "example.com", ""))
end)
it("uses the configured muc_domain_prefix (conference)", function()
assert.equal("room@conference.meet.jit.si",
M.build_room_address("room", "meet.jit.si", nil))
end)
it("combines subdomain with multi-part base domain", function()
assert.equal("[vpaas-magic-cookie-abc]r@conference.meet.jit.si",
M.build_room_address("r", "meet.jit.si", "vpaas-magic-cookie-abc"))
end)
end)
end) -- util.lib

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,80 @@
import assert from 'assert';
import { createXmppClient } from './helpers/xmpp_client.js';
describe('mod_auth_jitsi-anonymous', () => {
const clients = [];
afterEach(async () => {
await Promise.all(clients.map(c => c.disconnect()));
clients.length = 0;
});
/**
* Creates and connects an XMPP client.
*
* @param {object} opts - Options passed to createXmppClient.
* @returns {Promise<object>}
*/
async function connect(opts = {}) {
const c = await createXmppClient({ domain: 'jitsi-anonymous.localhost',
...opts });
clients.push(c);
return c;
}
it('assigns a UUID-based JID on anonymous connect', async () => {
const c = await connect();
assert.ok(c.jid, 'client must have a JID after connect');
const username = c.jid.split('@')[0];
// Prosody anonymous auth assigns a UUID (32 hex chars + 4 dashes = 36 chars).
assert.match(username,
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
`JID username must be a UUID, got: ${username}`);
});
it('assigns a different JID to each client', async () => {
const c1 = await connect();
const c2 = await connect();
assert.notEqual(c1.jid.split('@')[0], c2.jid.split('@')[0],
'each anonymous client must get a unique username');
});
it('resumes session with same username when previd matches SM token', async () => {
const c1 = await connect();
const username = c1.jid.split('@')[0];
// Capture the SM resumption token before disconnecting — it is cleared on offline.
const smId = c1.smId;
assert.ok(smId, 'stream management id must be set after connect');
// Drop the connection abruptly (no stream close) so Prosody puts the
// session into smacks hibernation — it stays in full_sessions where
// mod_auth_jitsi-anonymous can find it by resumption_token.
c1.dropConnection();
// Reconnect with previd — mod_auth_jitsi-anonymous should reuse the username.
const c2 = await connect({ params: { previd: smId } });
assert.equal(c2.jid.split('@')[0], username,
'reconnecting with previd must yield the same username');
});
it('assigns a new username when previd does not match any session', async () => {
const c = await connect({ params: { previd: 'nonexistent-token' } });
const username = c.jid.split('@')[0];
assert.match(username,
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
'unmatched previd must fall back to a fresh UUID username');
});
});

View File

@@ -0,0 +1,107 @@
import assert from 'assert';
import { getContainer } from './helpers/container.js';
import { prosodyShell } from './helpers/prosody_shell.js';
import { createXmppClient } from './helpers/xmpp_client.js';
const DOMAIN = 'shared-secret.localhost';
const PROSODY_CFG = '/etc/prosody/cfg/virtualhosts.cfg.lua';
const SECRET = 'topsecret';
const PREV_SECRET = 'oldsecret';
/**
* Enable or disable shared_secret_prev in the live Prosody config by
* commenting/uncommenting the line, reloading the config, and reloading the
* auth module so it re-reads the options.
*
* @param {boolean} enabled
*/
async function setSharedSecretPrev(enabled) { // eslint-disable-line no-unused-vars
const container = getContainer();
const from = enabled
? `-- shared_secret_prev = "${PREV_SECRET}"`
: `shared_secret_prev = "${PREV_SECRET}"`;
const to = enabled
? `shared_secret_prev = "${PREV_SECRET}"`
: `-- shared_secret_prev = "${PREV_SECRET}"`;
await container.exec([ 'sed', '-i', `s/${from}/${to}/`, PROSODY_CFG ]);
await container.exec([ 'prosodyctl', 'reload' ]);
await prosodyShell(`module:reload("auth_jitsi-shared-secret", "${DOMAIN}")`);
await new Promise(resolve => setTimeout(resolve, 300));
}
/**
* Attempt to connect with the given credentials.
* Returns the connected client on success, or throws on auth failure.
*
* @param {string} username
* @param {string} password
* @returns {Promise<object>}
*/
function connect(username, password) {
return createXmppClient({ domain: DOMAIN,
username,
password });
}
describe('mod_auth_jitsi-shared-secret', () => {
const clients = [];
afterEach(async () => {
await Promise.all(clients.map(c => c.disconnect()));
clients.length = 0;
});
it('allows connection with correct shared secret', async () => {
const c = await connect('alice', SECRET);
clients.push(c);
assert.ok(c.jid, 'client must have a JID after successful auth');
});
it('allows any username with the correct secret', async () => {
const c1 = await connect('alice', SECRET);
const c2 = await connect('bob', SECRET);
const c3 = await connect('anyusernamehere', SECRET);
clients.push(c1, c2, c3);
assert.ok(c1.jid, 'alice must be allowed');
assert.ok(c2.jid, 'bob must be allowed');
assert.ok(c3.jid, 'arbitrary username must be allowed');
});
it('rejects connection with wrong secret', async () => {
await assert.rejects(
() => connect('alice', 'wrongsecret'),
'connection with wrong secret must be rejected'
);
});
// TODO: shared_secret_prev is broken for PLAIN SASL. get_sasl_handler() always
// returns shared_secret from its plain callback, so Prosody rejects any password
// other than the current secret. provider.test_password() handles shared_secret_prev
// correctly but is not called in the PLAIN SASL flow.
// it('allows connection with shared_secret_prev', async () => {
// const c = await connect('alice', PREV_SECRET);
// clients.push(c);
// assert.ok(c.jid, 'client must be allowed with prev secret');
// });
// it('rejects shared_secret_prev after it is removed from config', async () => {
// await setSharedSecretPrev(false);
// try {
// await assert.rejects(
// () => connect('alice', PREV_SECRET),
// 'prev secret must be rejected after removal from config'
// );
// const c = await connect('alice', SECRET);
// clients.push(c);
// assert.ok(c.jid, 'current secret must still be accepted');
// } finally {
// await setSharedSecretPrev(true);
// }
// });
});

View File

@@ -0,0 +1,423 @@
import assert from 'assert';
import http from 'http';
import { mintAsapToken } from './helpers/jwt.js';
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
/**
* Fetches session-info for the given full JID.
*
* @param {string} jid
* @returns {Promise<object>}
*/
function getSessionInfo(jid) {
return new Promise((resolve, reject) => {
const url = `http://localhost:5280/test-observer/session-info?jid=${encodeURIComponent(jid)}`;
http.get(url, res => {
let body = '';
res.on('data', chunk => {
body += chunk;
});
res.on('end', () => {
if (res.statusCode !== 200) {
reject(new Error(`session-info returned ${res.statusCode}: ${body}`));
return;
}
try {
resolve(JSON.parse(body));
} catch (e) {
reject(new Error(`session-info bad JSON: ${body}`));
}
});
}).on('error', reject);
});
}
/**
* Connects to the main VirtualHost (RS256 / ASAP key-server auth).
* Tokens must be signed with the test RSA private key; Prosody fetches the
* matching public key from mod_test_observer_http's /asap-keys/ route.
*/
function asapClient(params) {
return createXmppClient({ params });
}
describe('mod_auth_token (ASAP / RS256)', () => {
const clients = [];
afterEach(async () => {
await Promise.all(clients.map(c => c.disconnect()));
clients.length = 0;
});
it('connects successfully with a valid RS256 token', async () => {
const token = mintAsapToken({ room: '*' });
const c = await asapClient({ token });
clients.push(c);
assert.ok(c.jid, 'client should have a JID after connecting');
});
it('rejects connection with wrong private key', async () => {
// Sign with a freshly generated key that the server does not know.
const { generateKeyPairSync } = await import('crypto');
const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const wrongPem = privateKey.export({ type: 'pkcs8',
format: 'pem' });
const token = mintAsapToken({}, { privateKey: wrongPem });
await assert.rejects(
() => asapClient({ token }),
/not-allowed/
);
});
it('rejects connection with expired token', async () => {
const token = mintAsapToken({}, { expired: true });
await assert.rejects(
() => asapClient({ token }),
/not-allowed/
);
});
it('rejects connection with not-yet-valid token (nbf in the future)', async () => {
const token = mintAsapToken({}, { notYetValid: true });
await assert.rejects(
() => asapClient({ token }),
/not-allowed/
);
});
it('rejects connection with wrong issuer', async () => {
const token = mintAsapToken({ iss: 'other-app' });
await assert.rejects(
() => asapClient({ token }),
/not-allowed/
);
});
it('sets session.jitsi_meet_context_features from token context', async () => {
const token = mintAsapToken({
room: '*',
context: {
features: {
'screen-sharing': true,
'recording': false
}
}
});
const c = await asapClient({ token });
clients.push(c);
const info = await getSessionInfo(c.jid);
assert.strictEqual(info.jitsi_meet_context_features['screen-sharing'], true);
assert.strictEqual(info.jitsi_meet_context_features.recording, false);
});
it('sets session.jitsi_meet_room from room claim', async () => {
const token = mintAsapToken({ room: 'testroom' });
const c = await asapClient({ token });
clients.push(c);
const info = await getSessionInfo(c.jid);
assert.strictEqual(info.jitsi_meet_room, 'testroom');
});
it('sets session.jitsi_meet_context_user from token context', async () => {
const token = mintAsapToken({
room: '*',
context: {
user: { id: 'user-123',
name: 'Alice',
email: 'alice@example.com' }
}
});
const c = await asapClient({ token });
clients.push(c);
const info = await getSessionInfo(c.jid);
assert.strictEqual(info.jitsi_meet_context_user.id, 'user-123');
assert.strictEqual(info.jitsi_meet_context_user.name, 'Alice');
assert.strictEqual(info.jitsi_meet_context_user.email, 'alice@example.com');
});
it('sets session.jitsi_meet_context_group from token context', async () => {
const token = mintAsapToken({
room: '*',
context: { group: 'test-group' }
});
const c = await asapClient({ token });
clients.push(c);
const info = await getSessionInfo(c.jid);
assert.strictEqual(info.jitsi_meet_context_group, 'test-group');
});
it('sets session.jitsi_meet_context_user.id from top-level user_id when context is absent', async () => {
const token = mintAsapToken({ room: '*',
// eslint-disable-next-line camelcase
user_id: 'legacy-user-456' });
const c = await asapClient({ token });
clients.push(c);
const info = await getSessionInfo(c.jid);
assert.strictEqual(info.jitsi_meet_context_user.id, 'legacy-user-456');
});
it('allows connection without token when allow_empty_token is true', async () => {
const c = await asapClient({});
clients.push(c);
assert.ok(c.jid, 'client should have a JID');
});
// ── Room regex claim: invalid Lua pattern ─────────────────────────────────
//
// When context.room.regex == true, util.lib.lua uses the JWT room claim as
// a Lua pattern via string:match() inside verify_room() at MUC join time.
// An invalid Lua pattern (e.g. unbalanced parenthesis) must be rejected
// cleanly rather than crashing Prosody with a Lua error.
it('does not crash on connect with invalid Lua regex room claim', async () => {
// process_and_verify_token only stores the claim; it does not evaluate
// the pattern. The crash path is verify_room(), called at MUC join.
const token = mintAsapToken({
room: '(invalid',
context: { room: { regex: true } }
});
const c = await asapClient({ token });
clients.push(c);
assert.ok(c.jid, 'connection must succeed: pattern not evaluated at auth time');
});
it('rejects MUC join with invalid Lua regex room claim', async () => {
const CONFERENCE = 'conference.localhost';
const roomJid = `regex-invalid-${Date.now()}@${CONFERENCE}`;
// '(invalid' is an unbalanced Lua pattern capture — string:match() would
// raise "unfinished capture" without the pcall guard in verify_room().
const token = mintAsapToken({
room: '(invalid',
context: { room: { regex: true } }
});
const focus = await joinWithFocus(roomJid);
const c = await asapClient({ token });
clients.push(focus, c);
const presence = await c.joinRoom(roomJid);
assert.equal(presence.attrs.type, 'error', 'join must be rejected');
assert.ok(
presence.getChild('error')?.getChild('invalid-regex'),
'error must contain <invalid-regex/>'
);
});
// ── Room regex claim: pattern is used as written ──────────────────────────
//
// Lua pattern classes are case-sensitive: %d is a digit, %D is anything
// but a digit. verify_room() must therefore not change the case of a room
// claim that is flagged as a pattern.
// Letters only, so that the room name matches %D+ ("not a digit").
const lettersOnly = () => Date.now()
.toString(36)
.replace(/[0-9]/g, 'x');
it('accepts MUC join when the regex room claim uses an uppercase class', async () => {
const CONFERENCE = 'conference.localhost';
const roomJid = `rxcase${lettersOnly()}@${CONFERENCE}`;
const token = mintAsapToken({
room: 'rxcase%D+',
context: { room: { regex: true } }
});
const focus = await joinWithFocus(roomJid);
const c = await asapClient({ token });
clients.push(focus, c);
const presence = await c.joinRoom(roomJid);
assert.notEqual(presence.attrs.type, 'error',
`join must succeed: ${presence.toString()}`);
});
it('rejects MUC join when the room does not match the regex room claim', async () => {
const CONFERENCE = 'conference.localhost';
const roomJid = `rxcase${Date.now()}@${CONFERENCE}`;
// 'rxcase%D+' does not match a room name that ends in digits.
const token = mintAsapToken({
room: 'rxcase%D+',
context: { room: { regex: true } }
});
const focus = await joinWithFocus(roomJid);
const c = await asapClient({ token });
clients.push(focus, c);
const presence = await c.joinRoom(roomJid);
assert.equal(presence.attrs.type, 'error', 'join must be rejected');
assert.ok(
presence.getChild('error')?.getChild('room-name-does-not-match'),
'error must contain <room-name-does-not-match/>'
);
});
// ── Room regex claim: matching limits ─────────────────────────────────────
//
// Lua patterns backtrack, and one match blocks Prosody. verify_room()
// therefore refuses to match a room name that is longer than
// token_regex_room_max_length (128), or a pattern that has more than
// token_regex_max_quantifiers (3) quantifiers. Both refusals report
// <invalid-regex/>, which distinguishes them from a pattern that was
// evaluated and did not match (<room-name-does-not-match/>).
it('accepts MUC join when the regex room claim is at the quantifier limit', async () => {
const CONFERENCE = 'conference.localhost';
const roomJid = `rxlimit${lettersOnly()}@${CONFERENCE}`;
// Three quantifiers is the default limit, and must still be allowed.
const token = mintAsapToken({
room: 'rxlimit%D*%D*%D+',
context: { room: { regex: true } }
});
const focus = await joinWithFocus(roomJid);
const c = await asapClient({ token });
clients.push(focus, c);
const presence = await c.joinRoom(roomJid);
assert.notEqual(presence.attrs.type, 'error',
`join must succeed: ${presence.toString()}`);
});
it('rejects MUC join when the regex room claim has too many quantifiers', async () => {
const CONFERENCE = 'conference.localhost';
// Under the 128-char room-name limit, so the quantifier limit is what
// must reject this join.
const roomJid = `${'a'.repeat(90)}${lettersOnly()}@${CONFERENCE}`;
// The pathological case: four quantifiers over a long run of 'a's, and
// a trailing '%d' that a letters-only room name can never satisfy, so
// every combination is tried before the match fails.
const token = mintAsapToken({
room: 'a*a*a*a*%d',
context: { room: { regex: true } }
});
const focus = await joinWithFocus(roomJid);
const c = await asapClient({ token });
clients.push(focus, c);
const presence = await c.joinRoom(roomJid);
assert.equal(presence.attrs.type, 'error', 'join must be rejected');
assert.ok(
presence.getChild('error')?.getChild('invalid-regex'),
`pattern must be refused unevaluated: ${presence.toString()}`
);
});
it('rejects MUC join when the room name is too long to match a regex claim', async () => {
const CONFERENCE = 'conference.localhost';
// Over the 128-char room-name limit.
const roomJid = `${'x'.repeat(150)}${lettersOnly()}@${CONFERENCE}`;
// '.*' matches any room name, so without the length limit this join
// would be allowed.
const token = mintAsapToken({
room: '.*',
context: { room: { regex: true } }
});
const focus = await joinWithFocus(roomJid);
const c = await asapClient({ token });
clients.push(focus, c);
const presence = await c.joinRoom(roomJid);
assert.equal(presence.attrs.type, 'error', 'join must be rejected');
assert.ok(
presence.getChild('error')?.getChild('invalid-regex'),
`long room name must not be matched: ${presence.toString()}`
);
});
// ── Room regex claim: token_regex_enabled = false ─────────────────────────
//
// conference.noregex.test has a parent host that sets
// token_regex_enabled = false, so a room claim flagged as a pattern is
// refused instead of matched. The same claim is accepted on
// conference.localhost, where the option keeps its default.
it('rejects MUC join with a regex room claim when patterns are disabled', async () => {
const roomName = `rxoff${lettersOnly()}`;
const roomJid = `${roomName}@conference.noregex.test`;
// Matches the room name, so only the disabled option can reject it.
const token = mintAsapToken({
room: 'rxoff%D+',
context: { room: { regex: true } }
});
const focus = await joinWithFocus(roomJid);
const c = await createXmppClient({ domain: 'noregex.test',
params: { token } });
clients.push(focus, c);
const presence = await c.joinRoom(roomJid);
assert.equal(presence.attrs.type, 'error', 'join must be rejected');
assert.ok(
presence.getChild('error')?.getChild('invalid-regex'),
`pattern must be refused: ${presence.toString()}`
);
});
it('accepts the same regex room claim on a host where patterns are enabled', async () => {
const roomName = `rxoff${lettersOnly()}`;
const roomJid = `${roomName}@conference.localhost`;
const token = mintAsapToken({
room: 'rxoff%D+',
context: { room: { regex: true } }
});
const focus = await joinWithFocus(roomJid);
const c = await asapClient({ token });
clients.push(focus, c);
const presence = await c.joinRoom(roomJid);
assert.notEqual(presence.attrs.type, 'error',
`join must succeed: ${presence.toString()}`);
});
});

View File

@@ -0,0 +1,448 @@
import assert from 'assert';
import http from 'http';
import { mintAsapToken, mintToken } from './helpers/jwt.js';
import { prosodyShell } from './helpers/prosody_shell.js';
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
/**
* GETs a test-observer endpoint and parses the JSON response.
*
* @param {string} route e.g. 'session-info'
* @param {string} jid full JID to look up
* @returns {Promise<object>}
*/
function getObserverJson(route, jid) {
return new Promise((resolve, reject) => {
const url = `http://localhost:5280/test-observer/${route}?jid=${encodeURIComponent(jid)}`;
http.get(url, res => {
let body = '';
res.on('data', chunk => {
body += chunk;
});
res.on('end', () => {
if (res.statusCode !== 200) {
reject(new Error(`${route} returned ${res.statusCode}: ${body}`));
return;
}
try {
resolve(JSON.parse(body));
} catch (e) {
reject(new Error(`${route} bad JSON: ${body}`));
}
});
}).on('error', reject);
});
}
/**
* Fetches the session fields snapshotted at resource-bind time.
*
* @param {string} jid
* @returns {Promise<object>}
*/
function getSessionInfo(jid) {
return getObserverJson('session-info', jid);
}
/**
* Fetches the CURRENT JWT-derived fields of a live session. Needed after a
* XEP-0198 resume, which refreshes the claims without binding a new resource
* (so the /session-info snapshot is not updated).
*
* @param {string} jid
* @returns {Promise<object>}
*/
function getSessionLive(jid) {
return getObserverJson('session-live', jid);
}
/** Connects to the HS256 VirtualHost. */
function hs256Client(params) {
return createXmppClient({ domain: 'hs256.localhost',
params });
}
describe('mod_auth_token (HS256 shared secret)', () => {
const clients = [];
afterEach(async () => {
await Promise.all(clients.map(c => c.disconnect()));
clients.length = 0;
});
it('connects successfully with a valid token', async () => {
const token = mintToken({ room: '*' });
const c = await hs256Client({ token });
clients.push(c);
assert.ok(c.jid, 'client should have a JID after connecting');
});
it('rejects connection with wrong secret', async () => {
const token = mintToken({}, { secret: 'wrongsecret' });
await assert.rejects(
() => hs256Client({ token }),
/not-allowed/
);
});
it('rejects connection with expired token', async () => {
const token = mintToken({}, { expired: true });
await assert.rejects(
() => hs256Client({ token }),
/not-allowed/
);
});
it('rejects connection with not-yet-valid token (nbf in the future)', async () => {
const token = mintToken({}, { notYetValid: true });
await assert.rejects(
() => hs256Client({ token }),
/not-allowed/
);
});
it('rejects connection with wrong issuer', async () => {
const token = mintToken({ iss: 'other-app' });
await assert.rejects(
() => hs256Client({ token }),
/not-allowed/
);
});
it('sets session.jitsi_meet_context_features from token context', async () => {
const token = mintToken({
room: '*',
context: {
features: {
'screen-sharing': true,
'recording': false
}
}
});
const c = await hs256Client({ token });
clients.push(c);
const info = await getSessionInfo(c.jid);
assert.strictEqual(info.jitsi_meet_context_features['screen-sharing'], true);
assert.strictEqual(info.jitsi_meet_context_features.recording, false);
});
it('sets session.jitsi_meet_room from room claim', async () => {
const token = mintToken({ room: 'testroom' });
const c = await hs256Client({ token });
clients.push(c);
const info = await getSessionInfo(c.jid);
assert.strictEqual(info.jitsi_meet_room, 'testroom');
});
});
/**
* XEP-0198 stream resumption re-runs SASL on the new connection, so a client
* may present a different JWT than the one it joined with. mod_auth_token
* refreshes the hibernating session's claims from that token — that is how a
* client rotates an expiring token across a reconnect — and the claims it
* adopts have to stay scoped to the conference the session is an occupant of,
* the same way the room claim is scoped at join time.
*
* The room claim of the refreshed token is therefore re-checked against the
* rooms the session already occupies (mod_token_verification answers the
* 'jitsi-verify-session-rooms' event), and the claims verified on join are kept
* when it does not cover them. The file-sharing component is used here as the
* observable effect of context.features on a live session.
*/
describe('mod_auth_token (claims on XEP-0198 resumption)', () => {
const CONFERENCE = 'conference.localhost';
const BREAKOUT_MUC = 'breakout.conference.localhost';
const FILESHARING_COMPONENT = 'filesharing.localhost';
const JITMEET_NS = 'http://jitsi.org/jitmeet';
const clients = [];
let roomCounter = 0;
afterEach(async () => {
await Promise.all(clients.map(c => c.disconnect()));
clients.length = 0;
});
/** Connects a client carrying ?room= and ?token= on the token VirtualHost. */
function tokenClient(roomName, token) {
return createXmppClient({ params: { room: roomName,
token } });
}
/** Mints an RS256 login token scoped to `room`, optionally with features. */
function tokenFor(room, features) {
return mintAsapToken({
room,
context: {
user: { id: 'user-a',
name: 'user-a' },
...features ? { features } : {}
}
});
}
/**
* Sends a file-sharing add with a caller-chosen stanza id, so the reply can
* be matched by id. After a resume the server flushes the unacked stanzas
* queued during hibernation, so an earlier error can be redelivered.
*/
function addFile(client, id, fileId) {
return client.sendFileSharingRaw(
FILESHARING_COMPONENT,
{ id },
{ type: 'add' },
JSON.stringify({ fileId,
name: 'shared.txt',
size: 0,
type: 'text/plain' })
);
}
/** Waits for the reply to the stanza with the given id. */
function waitForReply(client, id, timeout = 3000) {
return client.waitForMessage(s => s.attrs.id === id, timeout);
}
/** Asserts a reply is an auth/forbidden error. */
function assertForbidden(stanza, message) {
assert.equal(stanza.attrs.type, 'error', message);
assert.ok(stanza.getChild('error')?.getChild('forbidden'),
`${message} (expected <forbidden/>, got ${stanza.toString()})`);
}
/** Resolves with the filesharing broadcast payload received by `client`. */
async function waitForBroadcast(client, timeout = 3000) {
const stanza = await client.waitForMessage(
s => s.attrs.from === FILESHARING_COMPONENT && s.getChild('json-message', JITMEET_NS) !== undefined,
timeout);
return JSON.parse(stanza.getChild('json-message', JITMEET_NS).getText());
}
/** Asserts that no filesharing broadcast reaches `client` within the window. */
async function assertNoBroadcast(client, timeout = 1000) {
try {
await waitForBroadcast(client, timeout);
} catch (err) {
if (err.message.includes('Timeout')) {
return;
}
throw err;
}
throw new Error('unexpected file-sharing broadcast');
}
/**
* Sets up a meeting with a participant holding a token that grants no
* features, plus a second occupant that receives the room's broadcasts.
*/
async function setupMeeting() {
const roomName = `resume-${++roomCounter}`;
const roomJid = `${roomName}@${CONFERENCE}`;
const focus = await joinWithFocus(roomJid);
const participant = await tokenClient(roomName, tokenFor(roomName));
const other = await tokenClient(roomName, tokenFor(roomName));
clients.push(focus, participant, other);
await participant.joinRoom(roomJid);
await other.joinRoom(roomJid);
// Baseline: without a file-upload feature the operation is refused.
addFile(participant, 'baseline-1', 'file-before-resume');
assertForbidden(await waitForReply(participant, 'baseline-1'),
'file-upload must be refused before the token swap');
await assertNoBroadcast(other);
return { roomName,
roomJid,
participant,
other };
}
it('ignores features from a token issued for a different room', async () => {
const { roomName, participant, other } = await setupMeeting();
// Same tenant, scoped to another room, and granting file-upload there.
const otherRoomToken = tokenFor(`${roomName}-other`, { 'file-upload': true });
const reconnected = participant.waitForReconnect();
participant.dropConnection({ token: otherRoomToken });
await reconnected;
const session = await getSessionLive(participant.jid);
assert.equal(session.jitsi_meet_room, roomName,
'room claim of a token that does not cover the joined conference must not be adopted');
assert.ok(!session.jitsi_meet_context_features?.['file-upload'],
'features of a token that does not cover the joined conference must not be adopted');
addFile(participant, 'after-1', 'file-after-resume');
assertForbidden(await waitForReply(participant, 'after-1'),
'file-upload must still be refused after resuming with a token for another room');
await assertNoBroadcast(other);
});
it('adopts the claims of a rotated token issued for the same room', async () => {
const { roomName, participant, other } = await setupMeeting();
// Same conference, new token: this is the token rotation the
// c2s-session-updated handler exists for, and it must keep working.
const rotatedToken = tokenFor(roomName, { 'file-upload': true });
const reconnected = participant.waitForReconnect();
participant.dropConnection({ token: rotatedToken });
await reconnected;
const session = await getSessionLive(participant.jid);
assert.equal(session.jitsi_meet_context_features['file-upload'], true,
'features of a token covering the joined conference must be adopted');
addFile(participant, 'after-2', 'file-after-resume');
const payload = await waitForBroadcast(other);
assert.equal(payload.event, 'add');
assert.equal(payload.file.fileId, 'file-after-resume');
});
/**
* Sets up a meeting whose moderator has moved into a breakout room: it
* joins the main room, registers a breakout room, joins it and then leaves
* the main room, which is what the client does when switching rooms.
*
* The session is left occupying only the breakout room, while the
* conference its claims have to cover is still the main room.
*/
async function setupBreakout() {
const roomName = `resume-br-${++roomCounter}`;
const mainJid = `${roomName}@${CONFERENCE}`;
const focus = await joinWithFocus(mainJid);
const moderator = await tokenClient(
roomName,
mintAsapToken({ room: roomName,
context: { user: { moderator: true } } }));
clients.push(focus, moderator);
await moderator.joinRoom(mainJid);
// Register a breakout room, then let focus create it on the breakout
// component (restrict_room_creation allows only Prosody admins there).
moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, 'features/breakout-rooms/add',
{ subject: 'Breakout 1' });
const update = await moderator.waitForMessage(s2 => {
const jm = s2.getChild('json-message', JITMEET_NS);
return jm !== undefined && JSON.parse(jm.getText()).type === 'breakout_rooms';
}, 6000);
const rooms = JSON.parse(update.getChild('json-message', JITMEET_NS).getText()).rooms;
const breakoutJid = Object.values(rooms).find(r => !r.isMainRoom)?.jid;
assert.ok(breakoutJid, 'breakout room JID must be in the update broadcast');
await focus.joinRoom(breakoutJid, 'focus');
const presence = await moderator.joinRoom(breakoutJid);
assert.notEqual(presence.attrs.type, 'error', 'moderator must be able to join the breakout room');
// Moving into a breakout room means leaving the main room.
await moderator.leaveRoom(mainJid);
return { roomName,
mainJid,
breakoutJid,
moderator };
}
it('ignores a token for another room while the session sits in a breakout room', async () => {
// A participant that moved into a breakout room is no longer an
// occupant of the main room, but the conference it belongs to — and so
// the room its token has to cover — is still the main room.
const { roomName, moderator } = await setupBreakout();
const reconnected = moderator.waitForReconnect();
moderator.dropConnection({ token: tokenFor(`${roomName}-other`, { 'file-upload': true }) });
await reconnected;
const session = await getSessionLive(moderator.jid);
assert.equal(session.jitsi_meet_room, roomName,
'claims of a token that does not cover the main room must not be adopted in a breakout room');
assert.ok(!session.jitsi_meet_context_features?.['file-upload'],
'features of a token that does not cover the main room must not be adopted in a breakout room');
});
it('adopts the claims of a rotated token for the main room while the session sits in a breakout room',
async () => {
// The counterpart of the check above: the room the claims are
// verified against is the main room the session joined, not the
// breakout room it currently occupies, so a token that does cover
// that main room has to be adopted. Without this, a rotation that
// named the breakout room instead would fail closed and token
// rotation would silently stop working inside breakout rooms.
const { roomName, moderator } = await setupBreakout();
const rotatedToken = mintAsapToken({
room: roomName,
context: {
user: { moderator: true },
features: { 'file-upload': true }
}
});
const reconnected = moderator.waitForReconnect();
moderator.dropConnection({ token: rotatedToken });
await reconnected;
const session = await getSessionLive(moderator.jid);
assert.equal(session.jitsi_meet_room, roomName,
'the room claim of a token covering the main room must be adopted in a breakout room');
assert.equal(session.jitsi_meet_context_features['file-upload'], true,
'features of a token covering the main room must be adopted in a breakout room');
});
it('refreshes the claims unverified when no module answers the verification event', async () => {
// mod_token_verification owns room verification: without it a join is
// not room-checked either, so there is nothing to answer the
// 'jitsi-verify-session-rooms' event with and the refreshed claims are
// taken as they come.
const { roomName, participant } = await setupMeeting();
await prosodyShell(`module:unload("token_verification", "${CONFERENCE}")`);
try {
const reconnected = participant.waitForReconnect();
participant.dropConnection({ token: tokenFor(`${roomName}-other`, { 'file-upload': true }) });
await reconnected;
const session = await getSessionLive(participant.jid);
assert.equal(session.jitsi_meet_room, `${roomName}-other`,
'with no verifier loaded the refreshed claims are adopted as-is');
} finally {
await prosodyShell(`module:load("token_verification", "${CONFERENCE}")`);
}
});
});

View File

@@ -0,0 +1,85 @@
import assert from 'assert';
import { createTestContext } from './helpers/test_context.js';
import { setRoomMaxOccupants } from './helpers/test_observer.js';
import { discoField } from './helpers/xmpp_utils.js';
const CONFERENCE = 'conference.localhost';
let _roomCounter = 0;
const room = () => `conf-duration-${++_roomCounter}@${CONFERENCE}`;
describe('mod_conference_duration', () => {
let ctx;
beforeEach(() => {
ctx = createTestContext();
});
afterEach(() => ctx.cleanup());
it('created_timestamp is absent before a second occupant joins', async () => {
const r = room();
const focus = await ctx.connectFocus(r);
// Only focus is in the room — timestamp should not be set yet.
const iq = await focus.sendDiscoInfo(r);
assert.equal(iq.attrs.type, 'result');
const ts = discoField(iq, 'muc#roominfo_created_timestamp');
assert.ok(!ts, `expected empty/absent created_timestamp before second occupant, got: ${ts}`);
});
it('created_timestamp is set once a second occupant joins', async () => {
const r = room();
const focus = await ctx.connectFocus(r);
const c = await ctx.connect();
await c.joinRoom(r);
const iq = await focus.sendDiscoInfo(r);
assert.equal(iq.attrs.type, 'result');
const ts = discoField(iq, 'muc#roominfo_created_timestamp');
assert.ok(ts, 'created_timestamp must be present after second occupant joins');
assert.match(ts, /^\d+$/, 'created_timestamp must be a numeric string (ms since epoch)');
const tsNum = parseInt(ts, 10);
const now = Date.now();
assert.ok(tsNum > 0, 'timestamp must be positive');
assert.ok(tsNum <= now, 'timestamp must not be in the future');
assert.ok(tsNum > now - 30_000, 'timestamp must be recent (within last 30s)');
});
it('created_timestamp does not change when further occupants join', async () => {
const r = room();
const focus = await ctx.connectFocus(r);
const c1 = await ctx.connect();
await c1.joinRoom(r);
const iq1 = await focus.sendDiscoInfo(r);
const ts1 = discoField(iq1, 'muc#roominfo_created_timestamp');
assert.ok(ts1, 'timestamp must be set after second occupant');
// Raise per-room limit so the third occupant is not blocked by muc_max_occupants = 2.
await setRoomMaxOccupants(r, 5);
// Third occupant joins — timestamp must remain unchanged.
const c2 = await ctx.connect();
await c2.joinRoom(r);
const iq2 = await focus.sendDiscoInfo(r);
const ts2 = discoField(iq2, 'muc#roominfo_created_timestamp');
assert.equal(ts2, ts1, 'created_timestamp must not change when more occupants join');
});
});

View File

@@ -0,0 +1,173 @@
import assert from 'assert';
import { mintAsapToken } from './helpers/jwt.js';
import { getRoomState } from './helpers/test_observer.js';
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
const CONFERENCE = 'conference.localhost';
const COMPONENT = 'endconference.localhost';
let _roomCounter = 0;
const room = () => `end-conf-${++_roomCounter}@${CONFERENCE}`;
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Creates a room with:
* - focus (joins first to satisfy the mod_muc_meeting_id jicofo lock)
* - moderator: a client whose token carries context.user.moderator=true,
* which mod_token_affiliation promotes to owner/moderator on join.
* The ?room= URL param sets jitsi_web_query_room so that
* mod_end_conference can locate the room.
*
* @returns {{ roomJid, roomName, focus, moderator }}
*/
async function createRoom() {
const roomJid = room();
const roomName = roomJid.split('@')[0];
const focus = await joinWithFocus(roomJid);
const token = mintAsapToken({ room: roomName,
context: { user: { moderator: true } } });
const moderator = await createXmppClient({ params: { room: roomName,
token } });
await moderator.joinRoom(roomJid);
return { roomJid,
roomName,
focus,
moderator };
}
/**
* Disconnects all provided clients.
*
* @param {...object} clients - Clients to disconnect.
* @returns {Promise<void>}
*/
async function disconnectAll(...clients) {
await Promise.all(clients.map(c => c.disconnect()));
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('mod_end_conference', () => {
// ── Successful destruction ───────────────────────────────────────────────
it('moderator can end the conference — room is destroyed', async () => {
const { roomJid, focus, moderator } = await createRoom();
try {
const before = await getRoomState(roomJid);
assert.ok(before, 'room must exist before end_conference');
await moderator.sendEndConference(COMPONENT);
// Room destruction triggers an unavailable presence to all occupants.
await moderator.waitForPresence(p => p.attrs.type === 'unavailable');
const after = await getRoomState(roomJid);
assert.strictEqual(after, null, 'room must be gone after end_conference');
} finally {
await disconnectAll(focus, moderator);
}
});
it('all occupants receive an unavailable presence with a destroy reason', async () => {
const { roomJid, roomName, focus, moderator } = await createRoom();
// A second participant so we can verify the unavailable broadcast.
const participant = await createXmppClient({ params: { room: roomName } });
await participant.joinRoom(roomJid);
try {
await moderator.sendEndConference(COMPONENT);
// Both the moderator and the participant must receive the kick.
const [ modPresence, partPresence ] = await Promise.all([
moderator.waitForPresence(p => p.attrs.type === 'unavailable'),
participant.waitForPresence(p => p.attrs.type === 'unavailable')
]);
for (const [ label, presence ] of [ [ 'moderator', modPresence ], [ 'participant', partPresence ] ]) {
const mucUser = presence.getChild('x', 'http://jabber.org/protocol/muc#user');
assert.ok(mucUser, `${label} unavailable presence must contain muc#user <x>`);
assert.ok(
mucUser.getChild('destroy'),
`${label} unavailable presence must contain a <destroy> element`
);
}
} finally {
await disconnectAll(focus, moderator, participant);
}
});
// ── Non-moderator / non-occupant ─────────────────────────────────────────
it('non-moderator participant cannot end the conference', async () => {
const { roomJid, roomName, focus, moderator } = await createRoom();
// Participant joins with ?room= but is NOT granted moderator.
const participant = await createXmppClient({ params: { room: roomName } });
await participant.joinRoom(roomJid);
try {
await participant.sendEndConference(COMPONENT);
// No reply is sent; poll briefly to confirm the room is untouched.
await new Promise(resolve => setTimeout(resolve, 300));
const state = await getRoomState(roomJid);
assert.ok(state, 'room must still exist after non-moderator end_conference attempt');
} finally {
await disconnectAll(focus, moderator, participant);
}
});
it('non-occupant cannot end the conference', async () => {
const { roomJid, roomName, focus, moderator } = await createRoom();
// Client has the correct ?room= param but has never joined the MUC.
const outsider = await createXmppClient({ params: { room: roomName } });
try {
await outsider.sendEndConference(COMPONENT);
await new Promise(resolve => setTimeout(resolve, 300));
const state = await getRoomState(roomJid);
assert.ok(state, 'room must still exist after non-occupant end_conference attempt');
} finally {
await disconnectAll(focus, moderator, outsider);
}
});
// ── Message filtering ────────────────────────────────────────────────────
it('plain message without <end_conference/> element is ignored', async () => {
const { roomJid, focus, moderator } = await createRoom();
try {
await moderator.sendPlainMessage(COMPONENT);
await new Promise(resolve => setTimeout(resolve, 300));
const state = await getRoomState(roomJid);
assert.ok(state, 'room must still exist after message without end_conference element');
} finally {
await disconnectAll(focus, moderator);
}
});
});

View File

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

View File

@@ -0,0 +1,289 @@
import assert from 'assert';
import http from 'http';
import { mintAsapToken } from './helpers/jwt.js';
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
let _roomCounter = 0;
const nextRoom = () => `jibri-test-${++_roomCounter}@conference.localhost`;
// ─── HTTP helpers ────────────────────────────────────────────────────────────
/**
* Fetches pending Jibri IQ messages from the test observer.
*
* @returns {Promise<Array>}
*/
function getJibriIqs() {
return new Promise((resolve, reject) => {
http.get('http://localhost:5280/test-observer/jibri-iqs', res => {
let body = '';
res.on('data', c => {
body += c;
});
res.on('end', () => resolve(JSON.parse(body)));
}).on('error', reject);
});
}
/**
* Clears all Jibri IQ messages from the test observer.
*
* @returns {Promise<void>}
*/
function clearJibriIqs() {
return new Promise((resolve, reject) => {
const req = http.request(
'http://localhost:5280/test-observer/jibri-iqs',
{ method: 'DELETE' },
res => res.resume().on('end', resolve)
);
req.on('error', reject);
req.end();
});
}
/**
* Sends a Jibri IQ and waits briefly for Prosody to route it (or block it),
* then returns the list of Jibri IQs that reached the MUC component.
*/
async function sendAndCollect(client, roomJid, action, recordingMode) {
await clearJibriIqs();
await client.sendJibriIq(roomJid, action, recordingMode);
// Give Prosody time to route (or block) the IQ before we poll.
await new Promise(r => setTimeout(r, 300));
return getJibriIqs();
}
// ─── Test setup ──────────────────────────────────────────────────────────────
describe('mod_filter_iq_jibri (feature-based authorization)', () => {
const clients = [];
afterEach(async () => {
await Promise.all(clients.map(c => c.disconnect()));
clients.length = 0;
});
/**
* Creates a test client with a token scoped to a fresh room, joins that room
* (focus joins first to unlock the jicofo lock), and returns { client, room }.
*
* @param {object} [overrides] JWT payload overrides merged into the token.
* @returns {Promise<{client: object, room: string}>}
*/
async function setup(overrides = {}) {
const room = nextRoom();
const roomName = room.split('@')[0];
const token = mintAsapToken({ room: roomName,
...overrides });
const focus = await joinWithFocus(room);
clients.push(focus);
const c = await createXmppClient({ params: { token } });
clients.push(c);
await c.joinRoom(room);
return { client: c,
room };
}
// ─── recording (recording_mode="file") ───────────────────────────────────
describe('recording (recording_mode=file)', () => {
for (const action of [ 'start', 'stop' ]) {
it(`passes ${action} IQ when features.recording = true`, async () => {
const { client: c, room } = await setup({ context: { features: { recording: true } } });
const iqs = await sendAndCollect(c, room, action, 'file');
assert.strictEqual(iqs.length, 1, 'IQ should reach the MUC');
assert.strictEqual(iqs[0].action, action);
assert.strictEqual(iqs[0].recording_mode, 'file');
});
it(`blocks ${action} IQ when features.recording = false`, async () => {
const { client: c, room } = await setup({ context: { features: { recording: false } } });
const iqs = await sendAndCollect(c, room, action, 'file');
assert.strictEqual(iqs.length, 0);
});
it(`blocks ${action} IQ when context.features present but recording key absent`, async () => {
// features object present but no 'recording' key → treated as false
const { client: c, room } = await setup({ context: { features: { livestreaming: true } } });
const iqs = await sendAndCollect(c, room, action, 'file');
assert.strictEqual(iqs.length, 0);
});
it(`blocks ${action} IQ when token has no context.features (non-moderator fallback)`, async () => {
// No features in token → is_feature_allowed falls back to is_moderator.
// Client joined after focus so it is a non-moderator participant → blocked.
const { client: c, room } = await setup();
const iqs = await sendAndCollect(c, room, action, 'file');
assert.strictEqual(iqs.length, 0);
});
}
it('passes status IQ regardless of features (only start/stop are gated)', async () => {
const { client: c, room } = await setup({ context: { features: { recording: false } } });
const iqs = await sendAndCollect(c, room, 'status', 'file');
assert.strictEqual(iqs.length, 1);
});
});
// ─── livestreaming (recording_mode="stream") ─────────────────────────────
describe('livestreaming (recording_mode=stream)', () => {
for (const action of [ 'start', 'stop' ]) {
it(`passes ${action} IQ when features.livestreaming = true`, async () => {
const { client: c, room } = await setup({ context: { features: { livestreaming: true } } });
const iqs = await sendAndCollect(c, room, action, 'stream');
assert.strictEqual(iqs.length, 1);
assert.strictEqual(iqs[0].action, action);
assert.strictEqual(iqs[0].recording_mode, 'stream');
});
it(`blocks ${action} IQ when features.livestreaming = false`, async () => {
const { client: c, room } = await setup({ context: { features: { livestreaming: false } } });
const iqs = await sendAndCollect(c, room, action, 'stream');
assert.strictEqual(iqs.length, 0);
});
it(`blocks ${action} IQ when context.features present but livestreaming key absent`, async () => {
const { client: c, room } = await setup({ context: { features: { recording: true } } });
const iqs = await sendAndCollect(c, room, action, 'stream');
assert.strictEqual(iqs.length, 0);
});
it(`blocks ${action} IQ when token has no context.features (non-moderator fallback)`, async () => {
const { client: c, room } = await setup();
const iqs = await sendAndCollect(c, room, action, 'stream');
assert.strictEqual(iqs.length, 0);
});
}
it('passes status IQ regardless of features (only start/stop are gated)', async () => {
const { client: c, room } = await setup({ context: { features: { livestreaming: false } } });
const iqs = await sendAndCollect(c, room, 'status', 'stream');
assert.strictEqual(iqs.length, 1);
});
});
// ─── robustness: nil room ─────────────────────────────────────────────────
//
// mod_filter_iq_jibri calls get_room_from_jid() on the IQ target and then
// immediately dereferences the result with room:get_occupant_by_real_jid().
// If the room does not exist, get_room_from_jid returns nil and the next
// line crashes. This test verifies the session survives such an IQ.
describe('robustness', () => {
it('session survives a Jibri start IQ targeting a non-existent room', async () => {
// We need a connected client but the IQ target room must never have
// been created. Use a wildcard token so the connection is accepted.
const token = mintAsapToken({ room: '*' });
const c = await createXmppClient({ params: { token } });
clients.push(c);
// Target a room that has never been created — get_room_from_jid
// returns nil, which would crash room:get_occupant_by_real_jid.
const ghost = 'ghost-room-nil-test@conference.localhost';
await clearJibriIqs();
await c.sendJibriIq(ghost, 'start', 'file');
// Give Prosody time to process the stanza.
await new Promise(r => setTimeout(r, 400));
// If the hook crashed and killed the session this resolves immediately;
// we expect it to time out (session still alive).
const disconnected = await c.waitForDisconnect(400).then(() => true, () => false);
assert.strictEqual(disconnected, false,
'client session must remain alive after IQ to non-existent room');
});
it('returns item-not-found error reply for Jibri IQ targeting a non-existent room', async () => {
// When the room does not exist, get_room_from_jid should return <item-not-found/> (as opposed to nil which
// may be returned if an internal error is triggered).
const token = mintAsapToken({ room: '*' });
const c = await createXmppClient({ params: { token } });
clients.push(c);
const ghost = 'ghost-room-error-reply-test@conference.localhost';
await c.sendJibriIq(ghost, 'start', 'file');
const reply = await c.waitForIq(s => s.attrs.type === 'error', 2000);
assert.strictEqual(reply.attrs.type, 'error');
const error = reply.getChild('error');
assert.ok(error, 'expected <error/> child in IQ reply');
assert.ok(
error.getChild('item-not-found'),
'expected <item-not-found/> error condition — room does not exist'
);
});
});
// ─── attribute case normalisation ────────────────────────────────────────
describe('attribute case normalisation', () => {
for (const action of [ 'START', 'STOP', 'Start', 'Stop' ]) {
it(`blocks ${action} (recording_mode=file) when features.recording = false`, async () => {
const { client: c, room } = await setup({ context: { features: { recording: false } } });
const iqs = await sendAndCollect(c, room, action, 'file');
assert.strictEqual(iqs.length, 0);
});
}
it('passes START (recording_mode=file) when features.recording = true', async () => {
const { client: c, room } = await setup({ context: { features: { recording: true } } });
const iqs = await sendAndCollect(c, room, 'START', 'file');
assert.strictEqual(iqs.length, 1);
});
it('blocks START (recording_mode=FILE) when features.recording = false', async () => {
const { client: c, room } = await setup({ context: { features: { recording: false } } });
const iqs = await sendAndCollect(c, room, 'START', 'FILE');
assert.strictEqual(iqs.length, 0);
});
it('blocks START (recording_mode=FILE) when only features.livestreaming = true (cross-feature)', async () => {
// recording_mode='FILE' must still route to the recording gate, not livestreaming
const { client: c, room } = await setup({ context: { features: { livestreaming: true,
recording: false } } });
const iqs = await sendAndCollect(c, room, 'START', 'FILE');
assert.strictEqual(iqs.length, 0);
});
});
});

View File

@@ -0,0 +1,302 @@
import assert from 'assert';
import http from 'http';
import { mintAsapToken } from './helpers/jwt.js';
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
let _roomCounter = 0;
const nextRoom = () => `rayo-test-${++_roomCounter}@conference.localhost`;
// ─── HTTP helpers ────────────────────────────────────────────────────────────
/**
* Fetches pending dial IQ messages from the test observer.
*
* @returns {Promise<Array>}
*/
function getDialIqs() {
return new Promise((resolve, reject) => {
http.get('http://localhost:5280/test-observer/dial-iqs', res => {
let body = '';
res.on('data', c => {
body += c;
});
res.on('end', () => resolve(JSON.parse(body)));
}).on('error', reject);
});
}
/**
* Clears all dial IQ messages from the test observer.
*
* @returns {Promise<void>}
*/
function clearDialIqs() {
return new Promise((resolve, reject) => {
const req = http.request(
'http://localhost:5280/test-observer/dial-iqs',
{ method: 'DELETE' },
res => res.resume().on('end', resolve)
);
req.on('error', reject);
req.end();
});
}
/**
* Sends a Rayo dial IQ and waits briefly for Prosody to route or block it,
* then returns the list of dial IQs that reached the MUC component.
*
* @param {object} client XmppTestClient with sendRayoIq
* @param {string} roomJid full room JID
* @param {object} [opts] options forwarded to sendRayoIq
*/
async function sendAndCollect(client, roomJid, opts) {
await clearDialIqs();
await client.sendRayoIq(roomJid, opts);
// Give Prosody time to route (or block) the IQ before we poll.
await new Promise(r => setTimeout(r, 300));
return getDialIqs();
}
// ─── Test setup ──────────────────────────────────────────────────────────────
describe('mod_filter_iq_rayo (feature-based authorization)', () => {
const clients = [];
afterEach(async () => {
await Promise.all(clients.map(c => c.disconnect()));
clients.length = 0;
});
/**
* Creates a test client with a token scoped to a fresh room, joins that room
* (focus joins first so the jicofo lock is lifted), and returns { client, room }.
*
* @param {object} [overrides] JWT payload overrides merged into the token.
* @returns {Promise<{client: object, room: string}>}
*/
async function setup(overrides = {}) {
const room = nextRoom();
const roomName = room.split('@')[0];
const token = mintAsapToken({ room: roomName,
...overrides });
const focus = await joinWithFocus(room);
clients.push(focus);
const c = await createXmppClient({ params: { token } });
clients.push(c);
await c.joinRoom(room);
return { client: c,
room };
}
// ─── outbound-call (dial to a SIP/telephony address) ────────────────────
describe('outbound-call (dial to non-transcribe address)', () => {
it('passes IQ when features.outbound-call = true', async () => {
const { client: c, room } = await setup({ context: { features: { 'outbound-call': true } } });
const iqs = await sendAndCollect(c, room);
assert.strictEqual(iqs.length, 1, 'IQ should reach the MUC');
assert.strictEqual(iqs[0].dial_to, 'sip:test@example.com');
});
it('blocks IQ when features.outbound-call = false', async () => {
const { client: c, room } = await setup({ context: { features: { 'outbound-call': false } } });
const iqs = await sendAndCollect(c, room);
assert.strictEqual(iqs.length, 0);
});
it('blocks IQ when context.features present but outbound-call key absent', async () => {
const { client: c, room } = await setup({ context: { features: { recording: true } } });
const iqs = await sendAndCollect(c, room);
assert.strictEqual(iqs.length, 0);
});
it('blocks IQ when token has no context.features (non-owner fallback)', async () => {
// No features → fallback to is_moderator = affiliation == 'owner'.
// Regular client joined after focus so has 'member' affiliation → blocked.
const { client: c, room } = await setup();
const iqs = await sendAndCollect(c, room);
assert.strictEqual(iqs.length, 0);
});
});
// ─── transcription (dial to 'jitsi_meet_transcribe') ────────────────────
describe('transcription (dial to jitsi_meet_transcribe)', () => {
it('passes IQ when features.transcription = true', async () => {
const { client: c, room } = await setup({ context: { features: { transcription: true } } });
const iqs = await sendAndCollect(c, room, { dialTo: 'jitsi_meet_transcribe' });
assert.strictEqual(iqs.length, 1, 'IQ should reach the MUC');
assert.strictEqual(iqs[0].dial_to, 'jitsi_meet_transcribe');
});
it('blocks IQ when features.transcription = false', async () => {
const { client: c, room } = await setup({ context: { features: { transcription: false } } });
const iqs = await sendAndCollect(c, room, { dialTo: 'jitsi_meet_transcribe' });
assert.strictEqual(iqs.length, 0);
});
it('blocks IQ when context.features present but transcription key absent', async () => {
const { client: c, room } = await setup({ context: { features: { 'outbound-call': true } } });
const iqs = await sendAndCollect(c, room, { dialTo: 'jitsi_meet_transcribe' });
assert.strictEqual(iqs.length, 0);
});
it('blocks IQ when token has no context.features (non-owner fallback)', async () => {
const { client: c, room } = await setup();
const iqs = await sendAndCollect(c, room, { dialTo: 'jitsi_meet_transcribe' });
assert.strictEqual(iqs.length, 0);
});
});
// ─── JvbRoomName header validation ──────────────────────────────────────
describe('JvbRoomName header validation', () => {
it('blocks IQ when JvbRoomName header is missing', async () => {
const { client: c, room } = await setup({ context: { features: { 'outbound-call': true } } });
// null → header omitted entirely
const iqs = await sendAndCollect(c, room, { roomNameHeader: null });
assert.strictEqual(iqs.length, 0);
});
it('blocks IQ when JvbRoomName header does not match room JID', async () => {
const { client: c, room } = await setup({ context: { features: { 'outbound-call': true } } });
const iqs = await sendAndCollect(c, room, { roomNameHeader: 'wrong-room@conference.localhost' });
assert.strictEqual(iqs.length, 0);
});
});
// ─── Per-room transcription throttle ────────────────────────────────────
describe('transcription per-room throttle', () => {
const transcribeOpts = { dialTo: 'jitsi_meet_transcribe' };
it('allows the first transcription dial in a room', async () => {
const { client: c, room } = await setup({ context: { features: { transcription: true } } });
const iqs = await sendAndCollect(c, room, transcribeOpts);
assert.strictEqual(iqs.length, 1, 'first dial should reach the MUC');
assert.strictEqual(iqs[0].dial_to, 'jitsi_meet_transcribe');
});
it('drops a second transcription dial to the same room within 10 s', async () => {
const { client: c, room } = await setup({ context: { features: { transcription: true } } });
const first = await sendAndCollect(c, room, transcribeOpts);
assert.strictEqual(first.length, 1, 'first dial should reach the MUC');
// Second dial: throttle budget exhausted, should be blocked
const second = await sendAndCollect(c, room, transcribeOpts);
assert.strictEqual(second.length, 0, 'second dial within 10 s should be throttled');
});
it('allows only one transcription dial when multiple are sent concurrently', async () => {
const { client: c, room } = await setup({ context: { features: { transcription: true } } });
await clearDialIqs();
// Fire three dials before Prosody has processed any of them
await Promise.all([
c.sendRayoIq(room, transcribeOpts),
c.sendRayoIq(room, transcribeOpts),
c.sendRayoIq(room, transcribeOpts)
]);
await new Promise(r => setTimeout(r, 500));
const iqs = await getDialIqs();
assert.strictEqual(iqs.length, 1, 'exactly one concurrent transcription dial should pass');
});
it('does not apply the throttle across different rooms', async () => {
const { client: c1, room: room1 } = await setup({ context: { features: { transcription: true } } });
const { client: c2, room: room2 } = await setup({ context: { features: { transcription: true } } });
// First dial to room1 consumes room1's throttle budget
const iqs1 = await sendAndCollect(c1, room1, transcribeOpts);
assert.strictEqual(iqs1.length, 1, 'first dial to room1 should pass');
// First dial to room2 has its own independent throttle bucket
const iqs2 = await sendAndCollect(c2, room2, transcribeOpts);
assert.strictEqual(iqs2.length, 1, 'first dial to room2 should pass independently');
});
it('does not throttle outbound-call dials', async () => {
const { client: c, room } = await setup({ context: { features: { 'outbound-call': true } } });
const first = await sendAndCollect(c, room);
assert.strictEqual(first.length, 1, 'first outbound-call should pass');
// A second outbound-call to the same room must not be caught by the
// transcription throttle (which only applies to jitsi_meet_transcribe dials)
const second = await sendAndCollect(c, room);
assert.strictEqual(second.length, 1, 'second outbound-call should also pass');
});
});
// ─── Header stripping ───────────────────────────────────────────────────
describe('header stripping', () => {
it('strips arbitrary client-supplied headers', async () => {
const { client: c, room } = await setup({ context: { features: { 'outbound-call': true } } });
const iqs = await sendAndCollect(c, room, { extraHeaders: { SomeHeader: 'spoofed-value' } });
assert.strictEqual(iqs.length, 1, 'IQ should reach the MUC');
assert.strictEqual(iqs[0].headers?.SomeHeader, undefined, 'custom header must be stripped');
});
});
// ─── JvbRoomPassword header ──────────────────────────────────────────────
describe('JvbRoomPassword header', () => {
it('passes JvbRoomPassword through when provided', async () => {
const { client: c, room } = await setup({ context: { features: { 'outbound-call': true } } });
const iqs = await sendAndCollect(c, room, { roomPassHeader: 'secret123' });
assert.strictEqual(iqs.length, 1);
assert.strictEqual(iqs[0].room_pass_header, 'secret123');
});
it('omits JvbRoomPassword in forwarded IQ when not provided by client', async () => {
const { client: c, room } = await setup({ context: { features: { 'outbound-call': true } } });
const iqs = await sendAndCollect(c, room);
assert.strictEqual(iqs.length, 1);
assert.strictEqual(iqs[0].room_pass_header, undefined);
});
});
});

View File

@@ -0,0 +1,283 @@
import assert from 'assert';
import { mintAsapToken } from './helpers/jwt.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';
let _counter = 0;
const nextRoom = () => `filter-msg-${++_counter}@${CONFERENCE}`;
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Connects an authenticated client to the main VirtualHost.
*
* @param {string} roomJid Full room JID — used as the room claim in the token
* and as the ?room= WebSocket param.
* @param {object} [contextUser] JWT context.user claims; moderator: true → owner.
* @param {object} [features] JWT context.features claims.
*/
function connect(roomJid, { contextUser, features } = {}) {
const roomName = roomJid.split('@')[0];
const context = {};
if (contextUser !== undefined) {
context.user = contextUser;
}
if (features !== undefined) {
context.features = features;
}
const token = mintAsapToken({ room: roomName,
context });
const params = { room: roomName,
token };
return createXmppClient({ params });
}
const connectModerator = roomJid => connect(roomJid, { contextUser: { moderator: true } });
/**
* Sets groupChatRestricted on the room and waits for the metadata broadcast
* to confirm Prosody has processed the update before returning.
*
* @param {object} mod Moderator XMPP client (occupant of the room).
* @param {string} roomJid Full room JID.
*/
async function restrictChat(mod, roomJid) {
mod.sendMetadataUpdate(METADATA_COMPONENT, roomJid, 'permissions', { groupChatRestricted: true });
await mod.waitForMessage(s => {
const jsonMsg = s.getChild('json-message', JITMEET_NS);
if (!jsonMsg) {
return false;
}
try {
const parsed = JSON.parse(jsonMsg.getText());
return parsed?.metadata?.permissions?.groupChatRestricted === true;
} catch {
return false;
}
}, 3000);
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('mod_filter_messages', () => {
const clients = [];
afterEach(async () => {
await Promise.all(clients.map(c => c.disconnect()));
clients.length = 0;
});
// ── unrestricted ──────────────────────────────────────────────────────────
it('message is delivered when groupChatRestricted is not set', async () => {
const r = nextRoom();
const focus = await joinWithFocus(r);
clients.push(focus);
const sender = await createXmppClient();
clients.push(sender);
await sender.joinRoom(r);
const reply = await sender.sendGroupchat(r, 'hello');
assert.notEqual(reply.attrs.type, 'error', 'message must not be blocked when chat is unrestricted');
});
// ── restricted — blocked ──────────────────────────────────────────────────
it('anonymous sender is blocked when groupChatRestricted is true', async () => {
const r = nextRoom();
const focus = await joinWithFocus(r);
const mod = await connectModerator(r);
clients.push(focus, mod);
await mod.joinRoom(r);
// Anonymous sender joins.
const sender = await createXmppClient();
clients.push(sender);
await sender.joinRoom(r);
await restrictChat(mod, r);
const reply = await sender.sendGroupchat(r, 'blocked?');
assert.strictEqual(reply.attrs.type, 'error',
'anonymous sender must be blocked when groupChatRestricted');
assert.ok(
reply.getChild('error')?.getChild('not-allowed'),
'expected <not-allowed/> error condition'
);
});
it('sender with send-groupchat=false is blocked when groupChatRestricted is true', async () => {
const r = nextRoom();
const focus = await joinWithFocus(r);
const mod = await connectModerator(r);
clients.push(focus, mod);
await mod.joinRoom(r);
const sender = await connect(r, { features: { 'send-groupchat': false } });
clients.push(sender);
await sender.joinRoom(r);
await restrictChat(mod, r);
const reply = await sender.sendGroupchat(r, 'blocked?');
assert.strictEqual(reply.attrs.type, 'error',
'sender with send-groupchat=false must be blocked');
assert.ok(
reply.getChild('error')?.getChild('not-allowed'),
'expected <not-allowed/> error condition'
);
});
it('sender with no send-groupchat key in features is blocked when groupChatRestricted is true', async () => {
const r = nextRoom();
const focus = await joinWithFocus(r);
const mod = await connectModerator(r);
clients.push(focus, mod);
await mod.joinRoom(r);
// Token has a features object but no send-groupchat key.
const sender = await connect(r, { features: { 'screen-sharing': true } });
clients.push(sender);
await sender.joinRoom(r);
await restrictChat(mod, r);
const reply = await sender.sendGroupchat(r, 'blocked?');
assert.strictEqual(reply.attrs.type, 'error',
'sender without send-groupchat key must be blocked');
assert.ok(
reply.getChild('error')?.getChild('not-allowed'),
'expected <not-allowed/> error condition'
);
});
it('moderator with no features in token is allowed when groupChatRestricted is true', async () => {
// mod_jitsi_permissions (auto-loaded by muc_meeting_id) injects
// default_permissions (which includes send-groupchat=true) into
// session.jitsi_meet_context_features for moderators whose token
// has no explicit context.features.
const r = nextRoom();
const focus = await joinWithFocus(r);
const mod = await connectModerator(r);
clients.push(focus, mod);
await mod.joinRoom(r);
const sender = await connect(r, { contextUser: { moderator: true } });
clients.push(sender);
await sender.joinRoom(r);
await restrictChat(mod, r);
const reply = await sender.sendGroupchat(r, 'allowed?');
assert.notEqual(reply.attrs.type, 'error',
'moderator with no explicit features gets default permissions (send-groupchat=true)');
});
it('moderator with empty features in token is blocked when groupChatRestricted is true', async () => {
const r = nextRoom();
const focus = await joinWithFocus(r);
const mod = await connectModerator(r);
clients.push(focus, mod);
await mod.joinRoom(r);
// Moderator token with an empty features object.
const sender = await connect(r, { contextUser: { moderator: true },
features: {} });
clients.push(sender);
await sender.joinRoom(r);
await restrictChat(mod, r);
const reply = await sender.sendGroupchat(r, 'blocked?');
assert.strictEqual(reply.attrs.type, 'error',
'moderator with empty features must be blocked');
assert.ok(
reply.getChild('error')?.getChild('not-allowed'),
'expected <not-allowed/> error condition'
);
});
// ── restricted — allowed ──────────────────────────────────────────────────
it('sender with send-groupchat=true is allowed when groupChatRestricted is true', async () => {
const r = nextRoom();
const focus = await joinWithFocus(r);
const mod = await connectModerator(r);
clients.push(focus, mod);
await mod.joinRoom(r);
const sender = await connect(r, { features: { 'send-groupchat': true } });
clients.push(sender);
await sender.joinRoom(r);
await restrictChat(mod, r);
const reply = await sender.sendGroupchat(r, 'allowed!');
assert.notEqual(reply.attrs.type, 'error',
'sender with send-groupchat=true must be allowed');
});
// ── body-less message ─────────────────────────────────────────────────────
it('body-less message is always delivered even when groupChatRestricted is true', async () => {
const r = nextRoom();
const focus = await joinWithFocus(r);
const mod = await connectModerator(r);
clients.push(focus, mod);
await mod.joinRoom(r);
// Anonymous sender — would normally be blocked for body messages.
const sender = await createXmppClient();
clients.push(sender);
await sender.joinRoom(r);
await restrictChat(mod, r);
// Send a groupchat message with no <body> child.
const reply = await sender.sendGroupchat(r);
assert.notEqual(reply.attrs.type, 'error',
'body-less message must pass through even when chat is restricted');
});
});

View File

@@ -0,0 +1,76 @@
import assert from 'assert';
import { createXmppClient } from './helpers/xmpp_client.js';
const DISCO_INFO_NS = 'http://jabber.org/protocol/disco#info';
describe('mod_jiconop', () => {
const clients = [];
afterEach(async () => {
await Promise.all(clients.map(c => c.disconnect()));
clients.length = 0;
});
/**
* Connects a client and waits for the unsolicited jiconop message.
*
* @returns {Promise<{client: object, msg: object}>}
*/
async function connectAndGetJiconop() {
const c = await createXmppClient();
clients.push(c);
const msg = await c.waitForMessage(m => m.getChild('query', DISCO_INFO_NS) !== null);
return { client: c,
msg };
}
it('sends an unsolicited message with a disco#info query on connect', async () => {
const { msg } = await connectAndGetJiconop();
assert.ok(msg, 'must receive a message stanza after bind');
assert.ok(msg.getChild('query', DISCO_INFO_NS),
'message must contain <query xmlns="http://jabber.org/protocol/disco#info">');
});
it('message is addressed to the client full JID', async () => {
const { client,
msg } = await connectAndGetJiconop();
assert.equal(msg.attrs.to, client.jid, 'message must be addressed to the client full JID');
});
it('query contains shard identity', async () => {
const { msg } = await connectAndGetJiconop();
const query = msg.getChild('query', DISCO_INFO_NS);
const identities = query.getChildren('identity');
const shard = identities.find(i => i.attrs.category === 'server' && i.attrs.type === 'shard');
assert.ok(shard, 'query must contain a server/shard identity');
assert.equal(shard.attrs.name, 'test-shard');
});
it('query contains region identity', async () => {
const { msg } = await connectAndGetJiconop();
const query = msg.getChild('query', DISCO_INFO_NS);
const identities = query.getChildren('identity');
const region = identities.find(i => i.attrs.category === 'server' && i.attrs.type === 'region');
assert.ok(region, 'query must contain a server/region identity');
assert.equal(region.attrs.name, 'test-region');
});
it('query contains release identity', async () => {
const { msg } = await connectAndGetJiconop();
const query = msg.getChild('query', DISCO_INFO_NS);
const identities = query.getChildren('identity');
const release = identities.find(i => i.attrs.category === 'server' && i.attrs.type === 'release');
assert.ok(release, 'query must contain a server/release identity');
assert.equal(release.attrs.name, 'test-release');
});
});

View File

@@ -0,0 +1,752 @@
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);
});
});
});

View File

@@ -0,0 +1,106 @@
import assert from 'assert';
import http from 'http';
import { createXmppClient } from './helpers/xmpp_client.js';
/**
* Fetches the mod_jitsi_session field snapshot for the given full JID from
* the /test-observer/session-info endpoint (served by mod_test_observer_http).
*
* @param {string} jid full JID, e.g. "user@localhost/resource"
* @returns {Promise<object>}
*/
function getSessionInfo(jid) {
return new Promise((resolve, reject) => {
const url = `http://localhost:5280/test-observer/session-info?jid=${encodeURIComponent(jid)}`;
http.get(url, res => {
let body = '';
res.on('data', chunk => {
body += chunk;
});
res.on('end', () => {
if (res.statusCode !== 200) {
reject(new Error(`session-info returned ${res.statusCode}: ${body}`));
return;
}
try {
resolve(JSON.parse(body));
} catch (e) {
reject(new Error(`session-info bad JSON: ${body}`));
}
});
}).on('error', reject);
});
}
describe('mod_jitsi_session', () => {
const clients = [];
afterEach(async () => {
await Promise.all(clients.map(c => c.disconnect()));
clients.length = 0;
});
it('sets session.previd from ?previd query param', async () => {
const c = await createXmppClient({ params: { previd: 'testprevid' } });
clients.push(c);
const info = await getSessionInfo(c.jid);
assert.equal(info.previd, 'testprevid');
});
it('sets session.jitsi_web_query_room from ?room query param', async () => {
const c = await createXmppClient({ params: { room: 'myroom' } });
clients.push(c);
const info = await getSessionInfo(c.jid);
assert.equal(info.jitsi_web_query_room, 'myroom');
});
it('sets session.jitsi_web_query_prefix from ?prefix query param', async () => {
const c = await createXmppClient({ params: { prefix: 'tenant1' } });
clients.push(c);
const info = await getSessionInfo(c.jid);
assert.equal(info.jitsi_web_query_prefix, 'tenant1');
});
it('sets session.jitsi_web_query_prefix to empty string when query string present but ?prefix absent', async () => {
// A query string must be present so the if-block in mod_jitsi_session runs;
// without any query string the field is never assigned and stays nil.
const c = await createXmppClient({ params: { room: 'myroom' } });
clients.push(c);
const info = await getSessionInfo(c.jid);
assert.equal(info.jitsi_web_query_prefix, '');
});
it('rejects connection when ?token is present but invalid', async () => {
// mod_jitsi_session sets session.auth_token from ?token, but with
// authentication="token" (RS256/ASAP on localhost) the auth module
// immediately tries to verify it. An invalid token string fails JWT
// parsing before resource-bind, so the session fields are never
// accessible via getSessionInfo.
await assert.rejects(
() => createXmppClient({ params: { token: 'notavalidjwt' } }),
/not-allowed/
);
});
it('sets session.customusername from ?customusername query param', async () => {
const c = await createXmppClient({ params: { customusername: 'alice' } });
clients.push(c);
const info = await getSessionInfo(c.jid);
assert.equal(info.customusername, 'alice');
});
});

View File

@@ -0,0 +1,238 @@
import assert from 'assert';
import { mintAsapToken } from './helpers/jwt.js';
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
// Uses a dedicated MUC component that has muc_allowners loaded and neither
// muc_meeting_id (no jicofo lock) nor token_verification, so anonymous clients
// can join freely. Room names listed in allowners_moderated_rooms in
// docker/prosody.cfg.lua ('moderated-room-1', 'moderated-room-2') are treated
// as moderated by util.is_moderated().
const CONFERENCE = 'conference-allowners.localhost';
const MUC_USER_NS = 'http://jabber.org/protocol/muc#user';
let _roomCounter = 0;
const room = () => `allowners-${++_roomCounter}@${CONFERENCE}`;
/**
* Returns the affiliation carried by a MUC presence stanza, or undefined.
* @param {object} presence Presence stanza.
* @returns {string|undefined}
*/
function affiliationOf(presence) {
return presence.getChild('x', MUC_USER_NS)?.getChild('item')?.attrs.affiliation;
}
/**
* Returns true when an IQ response is the auth/forbidden error reply sent by
* filter_admin_set_query in mod_muc_allowners.
* @param {object} iq IQ response stanza.
* @returns {boolean}
*/
function isForbidden(iq) {
return iq.attrs.type === 'error'
&& Boolean(iq.getChild('error')?.getChild('forbidden'));
}
describe('mod_muc_allowners', () => {
let clients;
beforeEach(() => {
clients = [];
});
afterEach(async () => {
await Promise.all(clients.map(c => c.disconnect()));
});
/**
* Connects an anonymous client and registers it for cleanup.
* @param {object} [opts] Options forwarded to createXmppClient.
* @returns {Promise<XmppTestClient>}
*/
async function connect(opts) {
const c = await createXmppClient(opts);
clients.push(c);
return c;
}
/**
* Joins as focus (jicofo admin) and registers the client for cleanup.
* Focus is a Prosody admin, so mod_muc_allowners skips it and it creates
* and unlocks the room as in production. Rooms must be created by focus:
* a regular creator's initial owner self-presence would be filtered by
* filter_stanza and the room would never be unlocked.
*
* @param {string} roomJid Full room JID.
* @returns {Promise<XmppTestClient>}
*/
async function focusJoin(roomJid) {
const c = await joinWithFocus(roomJid);
clients.push(c);
return c;
}
/**
* Joins a room and resolves once the occupant holds the owner affiliation.
* mod_muc_allowners filters out the initial 'participant' self-presence of
* a joining occupant, so joinRoom usually resolves with the promotion
* presence directly; handle both event orders to be safe.
*
* @param {XmppTestClient} c Connected client.
* @param {string} roomJid Full room JID.
* @returns {Promise<object>} The self-presence carrying the owner affiliation.
*/
async function joinAsOwner(c, roomJid) {
const presence = await c.joinRoom(roomJid);
if (affiliationOf(presence) === 'owner') {
return presence;
}
return c.waitForPresence(p =>
p.getChild('x', MUC_USER_NS)?.getChildren('status')
.some(s => s.attrs.code === '110')
&& affiliationOf(p) === 'owner');
}
/**
* Returns the bare JID of a connected client.
* @param {XmppTestClient} c Connected client.
* @returns {string}
*/
const bareJid = c => c.jid.split('/')[0];
// -------------------------------------------------------------------------
// Non-moderated rooms
// -------------------------------------------------------------------------
describe('non-moderated room', () => {
it('anonymous participants are promoted to owner on join', async () => {
const r = room();
await focusJoin(r);
const p1 = await connect();
const p2 = await connect();
const presence1 = await joinAsOwner(p1, r);
const presence2 = await joinAsOwner(p2, r);
assert.equal(affiliationOf(presence1), 'owner', 'first participant must be promoted');
assert.equal(affiliationOf(presence2), 'owner', 'second participant must be promoted');
});
it('revoking an affiliation is rejected with forbidden', async () => {
const r = room();
await focusJoin(r);
const p1 = await connect();
const p2 = await connect();
await joinAsOwner(p1, r);
await joinAsOwner(p2, r);
for (const affiliation of [ 'none', 'outcast', 'member' ]) {
const response = await p1.sendMucAdmin(r, { jid: bareJid(p2),
affiliation });
assert.ok(isForbidden(response),
`affiliation='${affiliation}' must be rejected with forbidden`);
}
});
it('a revoke item after a benign first item is also rejected', async () => {
const r = room();
await focusJoin(r);
const p1 = await connect();
const p2 = await connect();
const p3 = await connect();
await joinAsOwner(p1, r);
await joinAsOwner(p2, r);
await joinAsOwner(p3, r);
// The first item is an allowed grant; the second revokes p3. The
// filter must inspect every item, not just the first one.
const response = await p1.sendMucAdminItems(r, [
{ jid: bareJid(p2),
affiliation: 'owner' },
{ jid: bareJid(p3),
affiliation: 'none' }
]);
assert.ok(isForbidden(response),
'a batched revoke hidden behind a benign first item must be rejected');
});
it('granting owner is allowed', async () => {
const r = room();
await focusJoin(r);
const p1 = await connect();
const p2 = await connect();
await joinAsOwner(p1, r);
await joinAsOwner(p2, r);
const response = await p1.sendMucAdmin(r, { jid: bareJid(p2),
affiliation: 'owner' });
assert.equal(response.attrs.type, 'result', 'granting owner must not be filtered');
});
});
// -------------------------------------------------------------------------
// Moderated rooms (listed in allowners_moderated_rooms)
// -------------------------------------------------------------------------
describe('moderated room', () => {
it('anonymous participants are not promoted', async () => {
const r = `moderated-room-1@${CONFERENCE}`;
await focusJoin(r);
const guest = await connect();
const presence = await guest.joinRoom(r);
assert.equal(affiliationOf(presence), 'none',
'anonymous participant must not be promoted in a moderated room');
});
it('JWT host is promoted and may revoke affiliations', async () => {
const roomName = 'moderated-room-2';
const r = `${roomName}@${CONFERENCE}`;
await focusJoin(r);
const token = mintAsapToken({ room: roomName });
const host = await connect({ params: { token } });
const presence = await joinAsOwner(host, r);
assert.equal(affiliationOf(presence), 'owner',
'JWT host must be promoted in a moderated room');
const guest = await connect();
await guest.joinRoom(r);
// Revoking is filtered only in non-moderated rooms; in a moderated
// room the owner may ban an occupant.
const response = await host.sendMucAdmin(r, { jid: bareJid(guest),
affiliation: 'outcast' });
assert.equal(response.attrs.type, 'result',
'revoking must be allowed in a moderated room');
});
});
});

View File

@@ -0,0 +1,262 @@
import assert from 'assert';
import { getContainer } from './helpers/container.js';
import { mintAsapToken } from './helpers/jwt.js';
import { setAccessManagerResponse } from './helpers/test_observer.js';
import { createXmppClient } from './helpers/xmpp_client.js';
// VPaaS tenant prefix — mod_muc_auth_ban only calls the access manager for
// sessions whose jitsi_web_query_prefix starts with this string.
// The prefix is set from the ?prefix= WebSocket URL param by mod_jitsi_session.
const VPAAS_PREFIX = 'vpaas-magic-cookie-test';
// Tokens are RS256 (deterministic): same payload in the same second → same
// token string. Use a per-test jti so each test gets a unique token and cannot
// accidentally match a token that was cached as banned by an earlier test.
let _tokenCounter = 0;
const freshToken = () => mintAsapToken({ room: '*',
jti: `ban-test-${++_tokenCounter}` });
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Creates an XMPP client that identifies as a VPaaS session.
* Passes ?prefix=<VPAAS_PREFIX>&token=<token> in the WebSocket URL so that
* mod_jitsi_session populates jitsi_web_query_prefix, which mod_muc_auth_ban
* uses to decide whether to call the external access manager.
*
* @param {string} token A valid login JWT (RS256 / HS256).
* @returns {Promise<XmppTestClient>}
*/
function createVpaasClient(token) {
return createXmppClient({
params: { prefix: VPAAS_PREFIX,
token }
});
}
/**
* Asserts that a VPaaS session is banned, whichever way the ban surfaces.
*
* mod_muc_auth_ban's HTTP callback calls session:close(), and where that lands
* depends on when the access manager answers. Answering within the same event
* loop tick as the SASL auth refuses the connection outright; answering a tick
* later lets the session establish and then closes it. Both are the same ban,
* and which one happens is timing, so accept either rather than only the first.
*
* @param {string} token A login JWT the access manager will reject.
* @param {string} message Assertion message.
* @returns {Promise<void>}
*/
async function assertBanned(token, message) {
let client;
try {
client = await createVpaasClient(token);
} catch (e) {
// refused during SASL
assert.match(String(e), /not-allowed/, message);
return;
}
// established, so the close must follow
const disconnected = await client.waitForDisconnect(5000).then(() => true, () => false);
if (!disconnected) {
await client.disconnect();
}
assert.ok(disconnected, message);
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('mod_muc_auth_ban', () => {
afterEach(async () => {
// Reset the mock access manager to the default (allow, HTTP 200)
// so that each test starts from a clean state.
await setAccessManagerResponse({ access: true,
status: 200 });
});
// ── No token ─────────────────────────────────────────────────────────────
//
// mod_muc_auth_ban checks session.auth_token first. When nil it returns
// nil (not false), so the ban-check event has no effect and auth proceeds.
it('user without a token is allowed (ban check is a no-op)', async () => {
// No token param → session.auth_token is nil → shouldAllow returns nil.
// VirtualHost "localhost" has allow_empty_token = true so auth still succeeds.
const c = await createXmppClient();
await c.disconnect();
});
// ── Non-VPaaS tenant ─────────────────────────────────────────────────────
//
// mod_muc_auth_ban returns true immediately for tenants whose prefix does
// NOT start with "vpaas-magic-cookie-". The access manager is never called.
it('non-VPaaS user with a valid token is allowed without an HTTP check', async () => {
// Configure the mock to return an error — if the module calls it for
// non-VPaaS sessions this test will catch that regression.
await setAccessManagerResponse({ status: 500 });
const token = freshToken();
// No ?prefix= param → jitsi_web_query_prefix is "" → bypasses VPaaS check.
const c = await createXmppClient({ params: { token } });
await c.disconnect();
});
// ── VPaaS — access granted ────────────────────────────────────────────────
it('VPaaS user is allowed when access manager returns access=true', async () => {
await setAccessManagerResponse({ access: true });
const token = freshToken();
const c = await createVpaasClient(token);
// Stay connected for a moment to confirm the async HTTP response does
// not trigger a session close.
await new Promise(resolve => setTimeout(resolve, 500));
await c.disconnect();
});
// ── VPaaS — access denied ─────────────────────────────────────────────────
//
// When access=false, mod_muc_auth_ban's HTTP callback calls session:close()
// and caches the token. The access manager runs on the same Prosody process
// (loopback), so the callback usually resolves within the same event loop
// tick as the SASL auth and the ban surfaces as a SASL failure, but under
// load it can resolve a tick later and close an established session instead.
// assertBanned accepts either.
it('VPaaS user is rejected when access manager returns access=false', async () => {
await setAccessManagerResponse({ access: false });
await assertBanned(freshToken(),
'VPaaS user must be rejected when access manager returns access=false');
});
// ── Cached ban ────────────────────────────────────────────────────────────
//
// When a token is rejected (access=false), mod_muc_auth_ban caches it with a
// 5-minute TTL. Subsequent connections with the same token hit the Lua LRU
// cache (no HTTP request) and are rejected immediately — even after the mock
// access manager is reset to allow. This verifies the cache is active.
it('cached banned token is rejected even when access manager is reset to allow', async () => {
// Step 1: Ban the token — cb fires, token enters the LRU cache.
await setAccessManagerResponse({ access: false });
const token = freshToken();
await assertBanned(token, 'initial ban must be rejected');
// Step 2: Reset the mock to allow. A fresh token must now succeed,
// proving the cache — not the mock response — drives the next rejection.
await setAccessManagerResponse({ access: true });
const fresh = await createVpaasClient(freshToken());
await fresh.disconnect();
// Step 3: Same banned token must still be rejected (cache wins over mock).
await assertBanned(token,
'cached banned token must be rejected even when mock is reset to allow');
});
// ── HTTP error — fail open ────────────────────────────────────────────────
//
// If the access manager returns a non-200 response, mod_muc_auth_ban logs
// a warning and does nothing — the session is NOT closed (fail open).
it('HTTP error from access manager does not ban the user (fail open)', async () => {
await setAccessManagerResponse({ status: 500 });
const token = freshToken();
const c = await createVpaasClient(token);
// Wait long enough for the async HTTP callback to have fired.
await new Promise(resolve => setTimeout(resolve, 500));
// The client must still be connected. waitForDisconnect would reject
// after the timeout — instead, assert the connection is alive by
// verifying disconnect() completes cleanly.
await c.disconnect();
});
// ── Non-JSON 200 — should fail open without crashing ─────────────────────
//
// When the access manager returns HTTP 200 but with a non-JSON body,
// json.decode() returns nil. mod_muc_auth_ban then calls r['access'] on
// nil, which crashes the callback. The crash must not affect the session
// (fail open: user stays connected).
it('non-JSON 200 from access manager does not crash or ban the user (fail open)', async () => {
await setAccessManagerResponse({ nonJson: true });
const token = freshToken();
const c = await createVpaasClient(token);
// Wait long enough for the async HTTP callback to have fired and crashed.
await new Promise(resolve => setTimeout(resolve, 500));
// Session must still be alive despite the callback crash.
const disconnected = await c.waitForDisconnect(300).then(() => true, () => false);
assert.strictEqual(disconnected, false,
'session must remain alive when access manager returns non-JSON 200');
await c.disconnect();
});
// ── Non-JSON 200 — Lua error in callback ─────────────────────────────────
//
// cjson.safe returns nil (not an error) for invalid JSON, so json.decode()
// returns nil when the access manager body is not JSON. Without a nil guard,
// r['access'] on the nil value crashes the callback with "attempt to index
// a nil value". This test catches that crash by asserting that no such error
// appears in the Prosody log during the test window.
it('non-JSON 200 does not produce a Lua error in the HTTP callback', async () => {
const since = Math.floor(Date.now() / 1000);
await setAccessManagerResponse({ nonJson: true });
const token = freshToken();
const c = await createVpaasClient(token);
// Give the async HTTP callback time to fire (and crash if the bug is present).
await new Promise(resolve => setTimeout(resolve, 600));
await c.disconnect();
// Collect Prosody log output from this test's window.
const until = Math.floor(Date.now() / 1000) + 1;
const container = getContainer();
const stream = await container.logs({ since,
until });
const logs = await new Promise((resolve, reject) => {
const chunks = [];
const timer = setTimeout(() => resolve(chunks.join('')), 2000);
stream.on('data', chunk => chunks.push(chunk.toString()));
stream.on('end', () => {
clearTimeout(timer);
resolve(chunks.join(''));
});
stream.on('error', reject);
});
assert.ok(
!logs.includes('attempt to index a nil value'),
'mod_muc_auth_ban HTTP callback must not crash on non-JSON 200 response '
+ '(missing nil guard on json.decode result)'
);
});
});

View File

@@ -0,0 +1,373 @@
import assert from 'assert';
import { mintAsapToken } from './helpers/jwt.js';
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
const CONFERENCE = 'conference.localhost';
const BREAKOUT_MUC = 'breakout.conference.localhost';
const JITMEET_NS = 'http://jitsi.org/jitmeet';
// JSON message type constants (must match mod_muc_breakout_rooms.lua)
const BREAKOUT_ROOMS_TYPE = 'breakout_rooms';
const EVENT_UPDATE = 'features/breakout-rooms/update';
const OP_ADD = 'features/breakout-rooms/add';
const OP_REMOVE = 'features/breakout-rooms/remove';
const OP_RENAME = 'features/breakout-rooms/rename';
let _roomCounter = 0;
const nextRoomName = () => `br-test-${++_roomCounter}`;
const roomJid = name => `${name}@${CONFERENCE}`;
/**
* Parses the JSON payload from a <json-message xmlns="http://jitsi.org/jitmeet"> stanza.
* Returns null if the stanza has no such child or the body is not valid JSON.
*
* @param {object} stanza
* @returns {object|null}
*/
function parseJsonMessage(stanza) {
const jm = stanza.getChild('json-message', JITMEET_NS);
if (!jm) {
return null;
}
try {
return JSON.parse(jm.getText());
} catch {
return null;
}
}
/**
* Waits for a breakout_rooms update broadcast on the given client.
* Resolves with the parsed JSON payload.
*
* mod_muc_breakout_rooms broadcasts only to non-admin occupants, so always
* wait on a JWT moderator client, never on the focus (admin) client.
*
* @param {object} client
* @param {number} [timeout=6000] ms — must exceed BROADCAST_ROOMS_INTERVAL (300 ms)
* @returns {Promise<object>}
*/
async function waitForBreakoutUpdate(client, timeout = 6000) {
const stanza = await client.waitForMessage(s => {
const p = parseJsonMessage(s);
return p?.type === BREAKOUT_ROOMS_TYPE && p?.event === EVENT_UPDATE;
}, timeout);
return parseJsonMessage(stanza);
}
/**
* Creates a conference room with focus and an authenticated moderator.
*
* Focus joins first to satisfy the mod_muc_meeting_id jicofo lock so that
* regular clients can join. The moderator connects with ?room=name so that
* mod_jitsi_session sets jitsi_web_query_room, which mod_muc_breakout_rooms
* uses to look up the main room. The JWT token carries context.user.moderator=true
* so mod_token_affiliation promotes the client to moderator on join.
*
* mod_muc_breakout_rooms excludes Prosody admins (focus) from update broadcasts,
* so the moderator — who is NOT a Prosody admin — acts as the broadcast receiver
* in tests.
*
* @param {string} name bare room name, e.g. 'br-test-1'
* @returns {Promise<{focus: object, moderator: object}>}
*/
async function createRoom(name) {
const jid = roomJid(name);
const focus = await joinWithFocus(jid);
const token = mintAsapToken({
room: name,
context: { user: { moderator: true } }
});
const moderator = await createXmppClient({ params: { room: name,
token } });
await moderator.joinRoom(jid);
return { focus,
moderator };
}
describe('mod_muc_breakout_rooms', () => {
let clients;
beforeEach(() => {
clients = [];
});
afterEach(async () => {
await Promise.all(clients.map(c => c.disconnect()));
});
// -------------------------------------------------------------------------
// breakout room creation
// -------------------------------------------------------------------------
describe('create breakout room', () => {
it('moderator receives an update broadcast after creating a breakout room', async () => {
const name = nextRoomName();
const { focus, moderator } = await createRoom(name);
clients.push(focus, moderator);
await moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_ADD, { subject: 'Group 1' });
const payload = await waitForBreakoutUpdate(moderator);
assert.equal(payload.type, BREAKOUT_ROOMS_TYPE);
assert.equal(payload.event, EVENT_UPDATE);
});
it('update payload contains the main room and the new breakout room', async () => {
const name = nextRoomName();
const { focus, moderator } = await createRoom(name);
clients.push(focus, moderator);
await moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_ADD, { subject: 'Group A' });
const payload = await waitForBreakoutUpdate(moderator);
const rooms = Object.values(payload.rooms);
assert.equal(rooms.length, 2, 'payload must contain main room + one breakout room');
const mainRoom = rooms.find(r => r.isMainRoom);
const breakoutRoom = rooms.find(r => !r.isMainRoom);
assert.ok(mainRoom, 'one room must be flagged as the main room');
assert.ok(breakoutRoom, 'one room must be the breakout room');
assert.equal(breakoutRoom.name, 'Group A');
assert.ok(
breakoutRoom.jid.endsWith(`@${BREAKOUT_MUC}`),
`breakout room JID must be on ${BREAKOUT_MUC}`
);
});
it('all non-admin occupants of the main room receive the update broadcast', async () => {
const name = nextRoomName();
const { focus, moderator } = await createRoom(name);
// A second non-admin participant.
const participant = await createXmppClient({ params: { room: name } });
clients.push(focus, moderator, participant);
await participant.joinRoom(roomJid(name));
await moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_ADD, { subject: 'Team Room' });
const [ modPayload, partPayload ] = await Promise.all([
waitForBreakoutUpdate(moderator),
waitForBreakoutUpdate(participant)
]);
assert.equal(modPayload.event, EVENT_UPDATE, 'moderator must receive the update');
assert.equal(partPayload.event, EVENT_UPDATE, 'regular participant must receive the update');
});
it('roomCounter increments with each created breakout room', async () => {
const name = nextRoomName();
const { focus, moderator } = await createRoom(name);
clients.push(focus, moderator);
await moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_ADD, { subject: 'Room 1' });
const first = await waitForBreakoutUpdate(moderator);
await moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_ADD, { subject: 'Room 2' });
const second = await waitForBreakoutUpdate(moderator);
assert.ok(second.roomCounter > first.roomCounter, 'roomCounter must increase');
});
});
// -------------------------------------------------------------------------
// breakout room removal
// -------------------------------------------------------------------------
describe('remove breakout room', () => {
it('moderator can remove a breakout room and receives an update', async () => {
const name = nextRoomName();
const { focus, moderator } = await createRoom(name);
clients.push(focus, moderator);
// Create
await moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_ADD, { subject: 'Temp Room' });
const createPayload = await waitForBreakoutUpdate(moderator);
const breakoutJid = Object.values(createPayload.rooms)
.find(r => !r.isMainRoom)?.jid;
assert.ok(breakoutJid, 'breakout room JID must be present in create update');
// Remove
await moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_REMOVE, {
breakoutRoomJid: breakoutJid
});
const removePayload = await waitForBreakoutUpdate(moderator);
const rooms = Object.values(removePayload.rooms);
assert.equal(rooms.length, 1, 'only the main room must remain after removal');
assert.ok(rooms[0].isMainRoom, 'remaining room must be the main room');
});
});
// -------------------------------------------------------------------------
// breakout room rename
// -------------------------------------------------------------------------
describe('rename breakout room', () => {
it('moderator can rename a breakout room and receives an update with the new name', async () => {
const name = nextRoomName();
const { focus, moderator } = await createRoom(name);
clients.push(focus, moderator);
await moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_ADD, { subject: 'Original Name' });
const createPayload = await waitForBreakoutUpdate(moderator);
const breakoutJid = Object.values(createPayload.rooms)
.find(r => !r.isMainRoom)?.jid;
await moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_RENAME, {
breakoutRoomJid: breakoutJid,
subject: 'Renamed Room'
});
const renamePayload = await waitForBreakoutUpdate(moderator);
const renamedRoom = Object.values(renamePayload.rooms).find(r => !r.isMainRoom);
assert.ok(renamedRoom, 'breakout room must still exist after rename');
assert.equal(renamedRoom.name, 'Renamed Room');
});
});
// -------------------------------------------------------------------------
// smacks regression: eb43cf601
// -------------------------------------------------------------------------
describe('smacks session resume', () => {
it('participant can join breakout room after smacks session resume', async () => {
// Regression test for eb43cf601:
// mod_auth_token.lua's c2s-session-updated handler used to copy
// jitsi_breakout_main_jid from the fresh TCP session (which never
// joined a room, so the field is nil) onto the old hibernating
// session, silently overwriting the valid value set when the user
// originally joined the main room. Without the fix, the subsequent
// on_occupant_pre_join_or_change check
// origin.jitsi_breakout_main_jid ~= main_room.jid
// fails with nil ~= main_room_jid → not-allowed error.
const name = nextRoomName();
const { focus, moderator } = await createRoom(name);
clients.push(focus, moderator);
// Register a breakout room via the OP_ADD message flow and capture its JID.
await moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_ADD, { subject: 'Breakout 1' });
const createPayload = await waitForBreakoutUpdate(moderator);
const breakoutJid = Object.values(createPayload.rooms).find(r => !r.isMainRoom)?.jid;
assert.ok(breakoutJid, 'breakout room JID must be in the create broadcast');
// Focus (jicofo) joins the breakout room first to physically create it,
// matching the real-life flow where Jicofo creates the room on the
// breakout component (which has restrict_room_creation=true, so only
// Prosody admins like focus@auth.localhost can create rooms here).
const focusBreakoutPresence = await focus.joinRoom(breakoutJid, 'focus');
assert.notEqual(
focusBreakoutPresence.attrs.type,
'error',
'focus must be able to join (create) the breakout room'
);
// Arm the reconnect listener BEFORE dropping so we cannot miss the
// 'reconnected' event even if the reconnect is very fast.
const reconnected = moderator.waitForReconnect();
// Snap the WebSocket. @xmpp/client schedules a reconnect after ~1 s;
// on reconnect it sends <resume> which triggers Prosody's
// c2s-session-updated — the event where the bug cleared jitsi_breakout_main_jid.
moderator.dropConnection();
// Wait until SMACKS resume is fully complete (entity.status === 'online').
// waitForReconnect() polls entity.status after the @xmpp/reconnect
// 'reconnected' event, because that event fires on stream-open which
// is before stream features (including SMACKS) are negotiated.
await reconnected;
// The moderator now tries to join the breakout room.
// With the bug: jitsi_breakout_main_jid is nil → not-allowed error.
// With the fix: jitsi_breakout_main_jid is preserved → join succeeds.
const presence = await moderator.joinRoom(breakoutJid);
assert.notEqual(
presence.attrs.type,
'error',
'participant must be able to join breakout room after smacks session resume'
);
});
});
// -------------------------------------------------------------------------
// access control
// -------------------------------------------------------------------------
describe('access control', () => {
it('non-admin cannot create rooms on the breakout component (restrict_room_creation)', async () => {
// Any anonymous or JWT-authenticated client that is NOT a Prosody
// admin (i.e. not focus@auth.localhost) should receive a <forbidden/>
// or similar error when trying to create a new room on the breakout
// MUC component. restrict_room_creation=true on the component is the
// first line of defence; on_breakout_room_pre_create is the second.
const name = nextRoomName();
const user = await createXmppClient({ params: { room: name } });
clients.push(user);
const fakeBreakoutJid = `00000000-0000-0000-0000-000000000000@${BREAKOUT_MUC}`;
const presence = await user.joinRoom(fakeBreakoutJid, undefined, { timeout: 15000 });
assert.equal(
presence.attrs.type,
'error',
'non-admin must receive an error when attempting to create a breakout room'
);
});
it('ignores create requests from non-moderator participants', async () => {
const name = nextRoomName();
const { focus, moderator } = await createRoom(name);
// Anonymous participant — no moderator token means participant role.
const participant = await createXmppClient({ params: { room: name } });
clients.push(focus, moderator, participant);
await participant.joinRoom(roomJid(name));
// Participant sends a create request — the module must ignore it.
await participant.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_ADD, {
subject: 'Unauthorized Room'
});
// A broadcast would only arrive if the room was actually created.
await assert.rejects(
waitForBreakoutUpdate(participant, 1500),
/Timeout/,
'no broadcast must be received when a non-moderator attempts to create a room'
);
});
});
});

View File

@@ -0,0 +1,119 @@
import assert from 'assert';
import { createTestContext } from './helpers/test_context.js';
import { setRoomMaxOccupants } from './helpers/test_observer.js';
const CONFERENCE = 'conference.localhost';
const BASE = 'http://localhost:5280';
let _roomCounter = 0;
const room = () => `census-${++_roomCounter}@${CONFERENCE}`;
/**
* Fetch GET /room-census and return the parsed body.
*
* @returns {Promise<{room_census: Array}>}
*/
async function getRoomCensus() {
const res = await fetch(`${BASE}/room-census`);
const text = await res.text();
assert.equal(res.status, 200, `GET /room-census must return 200, got ${res.status}: ${text}`);
let body;
try {
body = JSON.parse(text);
} catch (e) {
assert.fail(`GET /room-census returned non-JSON: ${text}`);
}
return body;
}
describe('mod_muc_census', () => {
let ctx;
beforeEach(() => {
ctx = createTestContext();
});
afterEach(() => ctx.cleanup());
it('returns 200 with a room_census array', async () => {
const body = await getRoomCensus();
assert.ok(Array.isArray(body.room_census),
`response must have a room_census array, got: ${JSON.stringify(body)}`);
});
it('only active rooms appear in census after a room is destroyed', async () => {
const r1 = room();
const r2 = room();
const r1Name = r1.split('@')[0];
const r2Name = r2.split('@')[0];
// Bring up both rooms.
const focus1 = await ctx.connectFocus(r1);
await ctx.connectFocus(r2);
const c1 = await ctx.connect();
const c2 = await ctx.connect();
await c1.joinRoom(r1);
await c2.joinRoom(r2);
const { room_census: before } = await getRoomCensus();
assert.ok(before.find(e => e.room_name && e.room_name.startsWith(r1Name)),
'room1 must appear in census while active');
assert.ok(before.find(e => e.room_name && e.room_name.startsWith(r2Name)),
'room2 must appear in census while active');
// Destroy room1 by disconnecting all its occupants.
await c1.disconnect();
await focus1.disconnect();
await new Promise(resolve => setTimeout(resolve, 200));
const { room_census: after } = await getRoomCensus();
assert.ok(!after.find(e => e.room_name && e.room_name.startsWith(r1Name)),
'room1 must not appear in census after all occupants leave');
assert.ok(after.find(e => e.room_name && e.room_name.startsWith(r2Name)),
'room2 must still appear in census');
});
it('tracks room occupancy and updates when clients leave', async () => {
const r = room();
const roomName = r.split('@')[0];
await ctx.connectFocus(r);
await setRoomMaxOccupants(r, 5);
const c1 = await ctx.connect();
const c2 = await ctx.connect();
await c1.joinRoom(r);
await c2.joinRoom(r);
// Room must appear with correct participant count and fields.
const { room_census: census1 } = await getRoomCensus();
const entry1 = census1.find(e => e.room_name && e.room_name.startsWith(roomName));
assert.ok(entry1, `room ${roomName} must appear in census after join`);
assert.ok(typeof entry1.created_time !== 'undefined', 'entry must have created_time');
assert.equal(entry1.participants, 2, 'participants must equal number of non-focus clients');
// After one client leaves the count must drop.
await c2.disconnect();
await new Promise(resolve => setTimeout(resolve, 200));
const { room_census: census2 } = await getRoomCensus();
const entry2 = census2.find(e => e.room_name && e.room_name.startsWith(roomName));
assert.ok(entry2, 'room must still appear after one client leaves');
assert.equal(entry2.participants, 1,
`participant count must be 1 after disconnect (got ${entry2.participants})`);
});
});

View File

@@ -0,0 +1,165 @@
import assert from 'assert';
import { createTestContext } from './helpers/test_context.js';
import { getRoomState, setBreakoutRoomsActive } from './helpers/test_observer.js';
const CONFERENCE = 'conference.localhost';
let _roomCounter = 0;
const room = () => `cleanup-svc-${++_roomCounter}@${CONFERENCE}`;
/** Resolves after ms milliseconds. */
const sleep = ms => new Promise(r => setTimeout(r, ms));
// services_empty_meeting_timeout is set to 1 s in prosody.cfg.lua.
// We wait TIMER_MS + BUFFER_MS to be sure the timer has either fired or not.
const TIMER_MS = 1000;
const BUFFER_MS = 1000;
describe('mod_muc_cleanup_backend_services', () => {
let ctx;
beforeEach(() => {
ctx = createTestContext();
});
afterEach(() => ctx.cleanup());
it('destroys room when last regular user leaves and only jibri remains', async () => {
const r = room();
await ctx.connectFocus(r);
await ctx.connectJibri(r);
const user = await ctx.connect();
await user.joinRoom(r);
await user.disconnect();
await sleep(TIMER_MS + BUFFER_MS);
const state = await getRoomState(r);
assert.equal(state, null, 'room should be destroyed after timeout');
});
it('destroys room when last regular user leaves and only transcriber remains', async () => {
const r = room();
await ctx.connectFocus(r);
await ctx.connectTranscriber(r);
const user = await ctx.connect();
await user.joinRoom(r);
await user.disconnect();
await sleep(TIMER_MS + BUFFER_MS);
const state = await getRoomState(r);
assert.equal(state, null, 'room should be destroyed after timeout');
});
it('does not destroy room when a second regular user is still present', async () => {
const r = room();
await ctx.connectFocus(r);
await ctx.connectJibri(r);
const user1 = await ctx.connect();
const user2 = await ctx.connect();
await user1.joinRoom(r);
await user2.joinRoom(r);
await user1.disconnect();
await sleep(TIMER_MS + BUFFER_MS);
const state = await getRoomState(r);
assert.ok(state, 'room should still exist — user2 is still a regular participant');
});
it('cancels the destroy timer when a regular user joins after timer starts', async () => {
const r = room();
await ctx.connectFocus(r);
await ctx.connectJibri(r);
const user = await ctx.connect();
await user.joinRoom(r);
// user leaves — timer starts counting down
await user.disconnect();
// a new regular user joins immediately, which must cancel the timer
const user2 = await ctx.connect();
await user2.joinRoom(r);
await sleep(TIMER_MS + BUFFER_MS);
const state = await getRoomState(r);
assert.ok(state, 'room should survive — destroy timer was cancelled by user2 joining');
});
it('does not start timer when jibri leaves and a regular user is still present', async () => {
const r = room();
await ctx.connectFocus(r);
const jibri = await ctx.connectJibri(r);
const user = await ctx.connect();
await user.joinRoom(r);
// jibri leaves — muc-occupant-left hook returns early (leaver is jibri)
await jibri.disconnect();
await sleep(TIMER_MS + BUFFER_MS);
const state = await getRoomState(r);
assert.ok(state, 'room should survive — timer is not started when jibri is the leaver');
});
it('does not start timer when focus (admin) leaves', async () => {
const r = room();
const focus = await ctx.connectFocus(r);
await ctx.connectJibri(r);
const user = await ctx.connect();
await user.joinRoom(r);
// admin leaves — muc-occupant-left hook returns early (leaver is admin)
await focus.disconnect();
await sleep(TIMER_MS + BUFFER_MS);
const state = await getRoomState(r);
assert.ok(state, 'room should survive — timer is not started when an admin leaves');
});
it('does not start timer when breakout rooms are active', async () => {
const r = room();
await ctx.connectFocus(r);
await ctx.connectJibri(r);
const user = await ctx.connect();
await user.joinRoom(r);
// Simulate active breakout rooms — hook returns early when this flag is set.
await setBreakoutRoomsActive(r, true);
// user leaves — would normally start the timer, but breakout_rooms_active prevents it
await user.disconnect();
await sleep(TIMER_MS + BUFFER_MS);
const state = await getRoomState(r);
assert.ok(state, 'room should survive — breakout_rooms_active prevents the destroy timer');
});
});

View File

@@ -0,0 +1,346 @@
import assert from 'assert';
import { mintAsapToken } from './helpers/jwt.js';
import { setHideDisplayNameForGuests, setRoomMaxOccupants } from './helpers/test_observer.js';
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
const CONFERENCE = 'conference.localhost';
const NICK_NS = 'http://jabber.org/protocol/nick';
const DISPLAY_NAME_NS = 'http://jitsi.org/protocol/display-name';
let _counter = 0;
const nextRoom = () => `displayname-${++_counter}@${CONFERENCE}`;
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Connects a client on the main VirtualHost with a given token context.
* Always passes ?room= so that session.jitsi_web_query_room is set —
* required by the hideDisplayNameForGuests outbound filter.
*
* @param {string} roomJid
* @param {object} [contextUser] JWT context.user claims (e.g. { name: 'Alice' })
* @param {object} [features] JWT context.features claims
*/
function connect(roomJid, { contextUser, features } = {}) {
const roomName = roomJid.split('@')[0];
const context = {};
if (contextUser !== undefined) {
context.user = contextUser;
}
if (features !== undefined) {
context.features = features;
}
const token = mintAsapToken({ room: roomName,
context });
return createXmppClient({ params: { room: roomName,
token } });
}
const connectModerator = roomJid => connect(roomJid, { contextUser: { moderator: true } });
/**
* Waits for a non-error, non-unavailable presence from a specific occupant nick.
* Skips any presences already queued from earlier occupants.
*
* @param {object} client observer client
* @param {string} roomJid
* @param {string} nick MUC nick of the occupant to wait for
*/
function waitForOtherPresence(client, roomJid, nick) {
return client.waitForPresence(
s => s.attrs.from === `${roomJid}/${nick}` && !s.attrs.type,
5000
);
}
// ─── name-readonly ────────────────────────────────────────────────────────────
describe('mod_muc_displayname — name-readonly', () => {
const clients = [];
afterEach(async () => {
await Promise.all(clients.map(c => c.disconnect()));
clients.length = 0;
});
it('enforces token name: observer sees token name even when sender sent a different nick', async () => {
const r = nextRoom();
const focus = await joinWithFocus(r);
const observer = await createXmppClient({ params: { room: r.split('@')[0] } });
clients.push(focus, observer);
await observer.joinRoom(r);
// Alice connects with name-readonly=true and name="Alice" in the token.
const alice = await connect(r, {
contextUser: { name: 'Alice' },
features: { 'name-readonly': true }
});
clients.push(alice);
// Alice joins with a different display name — the inbound filter must replace it.
await alice.joinRoom(r, undefined, { displayName: 'WrongName' });
// The observer sees Alice's join presence; nick must be "Alice" (from token).
const presence = await waitForOtherPresence(observer, r, alice.nick);
const nick = presence.getChild('nick', NICK_NS);
assert.ok(nick, 'presence must contain a <nick> element');
assert.strictEqual(nick.getText(), 'Alice',
'nick must be the token name, not the client-supplied name');
});
it('strips nick when name-readonly=true and token has no name', async () => {
const r = nextRoom();
const focus = await joinWithFocus(r);
const observer = await createXmppClient({ params: { room: r.split('@')[0] } });
clients.push(focus, observer);
await observer.joinRoom(r);
// No context.user.name — name-readonly with no name means nick is stripped.
const alice = await connect(r, { features: { 'name-readonly': true } });
clients.push(alice);
await alice.joinRoom(r, undefined, { displayName: 'SomeName' });
const presence = await waitForOtherPresence(observer, r, alice.nick);
assert.ok(
!presence.getChild('nick', NICK_NS),
'nick element must be absent when name-readonly and token has no name'
);
});
it('passes nick unchanged when name-readonly is not set', async () => {
const r = nextRoom();
const focus = await joinWithFocus(r);
const observer = await createXmppClient({ params: { room: r.split('@')[0] } });
clients.push(focus, observer);
await observer.joinRoom(r);
// No name-readonly feature — client's own nick must be preserved.
const alice = await connect(r, { contextUser: { name: 'Alice' } });
clients.push(alice);
await alice.joinRoom(r, undefined, { displayName: 'ChosenName' });
const presence = await waitForOtherPresence(observer, r, alice.nick);
const nick = presence.getChild('nick', NICK_NS);
assert.ok(nick, 'presence must contain a <nick> element');
assert.strictEqual(nick.getText(), 'ChosenName',
'nick must be the client-supplied name when name-readonly is not set');
});
});
// ─── hideDisplayNameForGuests ─────────────────────────────────────────────────
describe('mod_muc_displayname — hideDisplayNameForGuests', () => {
const clients = [];
afterEach(async () => {
await Promise.all(clients.map(c => c.disconnect()));
clients.length = 0;
});
it('non-moderator recipient receives identity-stripped presence', async () => {
const r = nextRoom();
const roomName = r.split('@')[0];
const focus = await joinWithFocus(r);
await setRoomMaxOccupants(r, 10);
// Non-moderator guest — must receive filtered presences.
const guest = await createXmppClient({ params: { room: roomName } });
// Moderator — must receive full presences.
const mod = await connectModerator(r);
clients.push(focus, guest, mod);
await guest.joinRoom(r);
await mod.joinRoom(r);
await setHideDisplayNameForGuests(r, true);
// Alice joins with a display name after the flag is set.
const alice = await connect(r, { contextUser: { name: 'Alice' } });
clients.push(alice);
await alice.joinRoom(r, undefined, { displayName: 'Alice' });
// Guest sees Alice's join presence — identity must be stripped.
const guestPresence = await waitForOtherPresence(guest, r, alice.nick);
assert.ok(
!guestPresence.getChild('nick', NICK_NS),
'non-moderator must not see <nick> when hideDisplayNameForGuests is true'
);
});
it('moderator recipient receives full presence with identity intact', async () => {
const r = nextRoom();
const roomName = r.split('@')[0];
const focus = await joinWithFocus(r);
await setRoomMaxOccupants(r, 10);
const guest = await createXmppClient({ params: { room: roomName } });
const mod = await connectModerator(r);
clients.push(focus, guest, mod);
await guest.joinRoom(r);
await mod.joinRoom(r);
await setHideDisplayNameForGuests(r, true);
const alice = await connect(r, { contextUser: { name: 'Alice' } });
clients.push(alice);
await alice.joinRoom(r, undefined, { displayName: 'Alice' });
// Drain guest's copy; moderator's copy is what we assert on.
await waitForOtherPresence(guest, r, alice.nick);
// Mod sees Alice's join presence — identity must be intact.
const modPresence = await waitForOtherPresence(mod, r, alice.nick);
const nick = modPresence.getChild('nick', NICK_NS);
assert.ok(nick, 'moderator must see <nick> when hideDisplayNameForGuests is true');
assert.strictEqual(nick.getText(), 'Alice');
});
it('non-moderator receives full presence when hideDisplayNameForGuests is false', async () => {
const r = nextRoom();
const roomName = r.split('@')[0];
const focus = await joinWithFocus(r);
const guest = await createXmppClient({ params: { room: roomName } });
clients.push(focus, guest);
await guest.joinRoom(r);
// Flag is not set — guest must receive full presence.
const alice = await connect(r, { contextUser: { name: 'Alice' } });
clients.push(alice);
await alice.joinRoom(r, undefined, { displayName: 'Alice' });
const presence = await waitForOtherPresence(guest, r, alice.nick);
const nick = presence.getChild('nick', NICK_NS);
assert.ok(nick, 'guest must see <nick> when hideDisplayNameForGuests is false');
assert.strictEqual(nick.getText(), 'Alice');
});
});
// ─── message display-name handling ───────────────────────────────────────────
describe('mod_muc_displayname — message display-name handling', () => {
const clients = [];
afterEach(async () => {
await Promise.all(clients.map(c => c.disconnect()));
clients.length = 0;
});
it('live broadcast strips <nick> and <display-name> from groupchat messages', async () => {
const r = nextRoom();
const roomName = r.split('@')[0];
const focus = await joinWithFocus(r);
// Alice is the sender; Bob observes live delivery.
const alice = await connect(r, { contextUser: { name: 'Alice' } });
const bob = await createXmppClient({ params: { room: roomName } });
clients.push(focus, alice, bob);
await alice.joinRoom(r, undefined, { displayName: 'Alice' });
await bob.joinRoom(r);
await alice.sendGroupchat(r, 'hello');
// Bob receives the live broadcast; it must not contain nick or display-name.
const msg = await bob.waitForMessage(
s => s.attrs.type === 'groupchat' && s.getChildText('body') === 'hello'
);
assert.ok(!msg.getChild('nick', NICK_NS),
'live message must not contain <nick>');
assert.ok(!msg.getChild('display-name', DISPLAY_NAME_NS),
'live message must not contain <display-name>');
});
it('history message contains <display-name> injected from token name', async () => {
const r = nextRoom();
const focus = await joinWithFocus(r);
// Alice has a token name; her message must be stored in history with display-name.
const alice = await connect(r, { contextUser: { name: 'Alice',
id: 'alice-id' } });
clients.push(focus, alice);
await alice.joinRoom(r);
await alice.sendGroupchat(r, 'history-msg');
// Bob joins after the message — he receives it as history (with <delay>).
const bob = await createXmppClient({ params: { room: r.split('@')[0] } });
clients.push(bob);
await bob.joinRoom(r);
const historyMsg = await bob.waitForMessage(
s => s.attrs.type === 'groupchat'
&& s.getChildText('body') === 'history-msg'
&& s.getChild('delay') !== undefined
);
const displayName = historyMsg.getChild('display-name', DISPLAY_NAME_NS);
assert.ok(displayName, 'history message must contain <display-name>');
assert.strictEqual(displayName.getText(), 'Alice');
assert.strictEqual(displayName.attrs.source, 'token');
});
it('history message contains <display-name> from nick for anonymous sender', async () => {
const r = nextRoom();
const focus = await joinWithFocus(r);
// Anonymous sender — display name comes from their <nick> element, not token.
const anon = await createXmppClient({ params: { room: r.split('@')[0] } });
clients.push(focus, anon);
await anon.joinRoom(r, undefined, { displayName: 'GuestUser' });
await anon.sendGroupchat(r, 'anon-msg');
const bob = await createXmppClient({ params: { room: r.split('@')[0] } });
clients.push(bob);
await bob.joinRoom(r);
const historyMsg = await bob.waitForMessage(
s => s.attrs.type === 'groupchat'
&& s.getChildText('body') === 'anon-msg'
&& s.getChild('delay') !== undefined
);
const displayName = historyMsg.getChild('display-name', DISPLAY_NAME_NS);
assert.ok(displayName, 'history message must contain <display-name>');
assert.strictEqual(displayName.getText(), 'GuestUser');
assert.strictEqual(displayName.attrs.source, 'guest');
});
});

View File

@@ -0,0 +1,137 @@
import assert from 'assert';
import { createTestContext } from './helpers/test_context.js';
import { isAvailablePresence } from './helpers/xmpp_utils.js';
// mod_muc_domain_mapper rewrites JIDs between:
// external: room@conference.subdomain.localhost (what clients send/receive)
// internal: [subdomain]room@conference.localhost (what Prosody routes)
//
// Both focus and regular clients use the external subdomain JID. The mapper
// rewrites 'to' on the way in (before routing) and 'from' on the way out
// (before the client sees it), so the subdomain format is transparent.
const CONFERENCE_SUB = 'conference.subdomain.localhost';
const CONFERENCE = 'conference.localhost';
let _roomCounter = 0;
const subRoom = () => `mapper-${++_roomCounter}@${CONFERENCE_SUB}`;
const baseRoom = () => `mapper-base-${++_roomCounter}@${CONFERENCE}`;
describe('mod_muc_domain_mapper', () => {
let ctx;
beforeEach(() => {
ctx = createTestContext();
});
afterEach(() => ctx.cleanup());
// -------------------------------------------------------------------------
// Subdomain room join
// -------------------------------------------------------------------------
describe('subdomain room join', () => {
it('focus can join a subdomain room', async () => {
// connectFocus throws on timeout if the join fails.
await ctx.connectFocus(subRoom());
});
it('regular user receives available self-presence with subdomain from-JID', async () => {
const r = subRoom();
await ctx.connectFocus(r);
const c = await ctx.connect();
const presence = await c.joinRoom(r);
assert.ok(isAvailablePresence(presence),
'join must succeed');
assert.ok(
presence.attrs.from?.startsWith(`${r}/`),
`from must use the subdomain JID (got ${presence.attrs.from})`
);
});
it('non-subdomain room still works after mapper is loaded', async () => {
const r = baseRoom();
await ctx.connectFocus(r);
const c = await ctx.connect();
const presence = await c.joinRoom(r);
assert.ok(isAvailablePresence(presence),
'plain conference.localhost join must still succeed');
});
});
// -------------------------------------------------------------------------
// Presence exchange between occupants
// -------------------------------------------------------------------------
describe('occupant presence exchange', () => {
it('two clients in the same subdomain room receive each other\'s presence', async () => {
const r = subRoom();
await ctx.connectFocus(r);
const c1 = await ctx.connect();
const c2 = await ctx.connect();
await c1.joinRoom(r);
await c2.joinRoom(r);
// c1 must receive c2's join presence with the subdomain from-JID
const p = await c1.waitForPresenceFrom(`${r}/${c2.nick}`);
assert.ok(isAvailablePresence(p),
'c1 must see c2 as available');
assert.equal(p.attrs.from, `${r}/${c2.nick}`,
'from must be the subdomain JID of c2');
});
it('two clients in different subdomain rooms do not see each other', async () => {
const r1 = subRoom();
const r2 = subRoom();
await ctx.connectFocus(r1);
await ctx.connectFocus(r2);
const c1 = await ctx.connect();
const c2 = await ctx.connect();
await c1.joinRoom(r1);
await c2.joinRoom(r2);
await assert.rejects(
() => c1.waitForPresenceFrom(`${r1}/${c2.nick}`, { timeout: 1000 }),
/Timeout/,
'c1 must not see c2 who joined a different room'
);
});
});
// -------------------------------------------------------------------------
// IQ routing through subdomain
// -------------------------------------------------------------------------
describe('IQ routing', () => {
it('disco#info to a subdomain room JID returns a result', async () => {
const r = subRoom();
await ctx.connectFocus(r);
const c = await ctx.connect();
await c.joinRoom(r);
const iq = await c.sendDiscoInfo(r);
assert.equal(iq.attrs.type, 'result',
'disco#info to subdomain room must return a result IQ');
});
});
});

Some files were not shown because too many files have changed in this diff Show More