snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
This commit is contained in:
18
tests/.eslintrc.js
Normal file
18
tests/.eslintrc.js
Normal 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
93
tests/env.example
Normal 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
5
tests/globals.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
import { IContext } from './helpers/types';
|
||||
|
||||
declare global {
|
||||
const ctx: IContext;
|
||||
}
|
||||
1193
tests/helpers/Participant.ts
Normal file
1193
tests/helpers/Participant.ts
Normal file
File diff suppressed because it is too large
Load Diff
128
tests/helpers/TestProperties.ts
Normal file
128
tests/helpers/TestProperties.ts
Normal 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;
|
||||
}
|
||||
76
tests/helpers/TestsConfig.ts
Normal file
76
tests/helpers/TestsConfig.ts
Normal 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));
|
||||
}
|
||||
241
tests/helpers/WebhookProxy.ts
Normal file
241
tests/helpers/WebhookProxy.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
83
tests/helpers/browserLogger.ts
Normal file
83
tests/helpers/browserLogger.ts
Normal 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);
|
||||
}
|
||||
|
||||
88
tests/helpers/expectations.ts
Normal file
88
tests/helpers/expectations.ts
Normal 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
41
tests/helpers/jaas.ts
Normal 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
50
tests/helpers/joinMuc.ts
Normal 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
48
tests/helpers/matchers.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
330
tests/helpers/participants.ts
Normal file
330
tests/helpers/participants.ts
Normal 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
140
tests/helpers/token.ts
Normal 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
142
tests/helpers/types.ts
Normal 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
57
tests/helpers/utils.ts
Normal 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
7424
tests/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
tests/package.json
Normal file
47
tests/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
56
tests/pageobjects/AVModerationMenu.ts
Normal file
56
tests/pageobjects/AVModerationMenu.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
23
tests/pageobjects/BaseDialog.ts
Normal file
23
tests/pageobjects/BaseDialog.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
16
tests/pageobjects/BasePageObject.ts
Normal file
16
tests/pageobjects/BasePageObject.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
213
tests/pageobjects/BreakoutRooms.ts
Normal file
213
tests/pageobjects/BreakoutRooms.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
207
tests/pageobjects/ChatPanel.ts
Normal file
207
tests/pageobjects/ChatPanel.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
172
tests/pageobjects/FileSharingPanel.ts
Normal file
172
tests/pageobjects/FileSharingPanel.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
390
tests/pageobjects/Filmstrip.ts
Normal file
390
tests/pageobjects/Filmstrip.ts
Normal 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'
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
139
tests/pageobjects/IframeAPI.ts
Normal file
139
tests/pageobjects/IframeAPI.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
104
tests/pageobjects/InviteDialog.ts
Normal file
104
tests/pageobjects/InviteDialog.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
80
tests/pageobjects/LargeVideo.ts
Normal file
80
tests/pageobjects/LargeVideo.ts
Normal 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`
|
||||
});
|
||||
}
|
||||
}
|
||||
30
tests/pageobjects/LobbyScreen.ts
Normal file
30
tests/pageobjects/LobbyScreen.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
296
tests/pageobjects/Notifications.ts
Normal file
296
tests/pageobjects/Notifications.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
308
tests/pageobjects/ParticipantsPane.ts
Normal file
308
tests/pageobjects/ParticipantsPane.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
51
tests/pageobjects/PasswordDialog.ts
Normal file
51
tests/pageobjects/PasswordDialog.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
55
tests/pageobjects/PreJoinScreen.ts
Normal file
55
tests/pageobjects/PreJoinScreen.ts
Normal 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}"]`);
|
||||
}
|
||||
}
|
||||
90
tests/pageobjects/PreMeetingScreen.ts
Normal file
90
tests/pageobjects/PreMeetingScreen.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
342
tests/pageobjects/RecordingTranscriptionDialog.ts
Normal file
342
tests/pageobjects/RecordingTranscriptionDialog.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
134
tests/pageobjects/SecurityDialog.ts
Normal file
134
tests/pageobjects/SecurityDialog.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
197
tests/pageobjects/SettingsDialog.ts
Normal file
197
tests/pageobjects/SettingsDialog.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
465
tests/pageobjects/Toolbar.ts
Normal file
465
tests/pageobjects/Toolbar.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
42
tests/pageobjects/VideoQualityDialog.ts
Normal file
42
tests/pageobjects/VideoQualityDialog.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
139
tests/pageobjects/VirtualBackgroundDialog.ts
Normal file
139
tests/pageobjects/VirtualBackgroundDialog.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
63
tests/pageobjects/Visitors.ts
Normal file
63
tests/pageobjects/Visitors.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
7
tests/prosody/.eslintrc.cjs
Normal file
7
tests/prosody/.eslintrc.cjs
Normal 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
4
tests/prosody/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
allure-results/
|
||||
allure-report/
|
||||
xunit.xml
|
||||
8
tests/prosody/.mocharc.cjs
Normal file
8
tests/prosody/.mocharc.cjs
Normal 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
189
tests/prosody/README.md
Normal 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.
|
||||
182
tests/prosody/busted_allure.lua
Normal file
182
tests/prosody/busted_allure.lua
Normal 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
|
||||
61
tests/prosody/docker/Dockerfile
Normal file
61
tests/prosody/docker/Dockerfile
Normal 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
|
||||
136
tests/prosody/docker/cfg/components.cfg.lua
Normal file
136
tests/prosody/docker/cfg/components.cfg.lua
Normal 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"
|
||||
79
tests/prosody/docker/cfg/conference.cfg.lua
Normal file
79
tests/prosody/docker/cfg/conference.cfg.lua
Normal 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
|
||||
58
tests/prosody/docker/cfg/global.cfg.lua
Normal file
58
tests/prosody/docker/cfg/global.cfg.lua
Normal 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" }
|
||||
161
tests/prosody/docker/cfg/virtualhosts.cfg.lua
Normal file
161
tests/prosody/docker/cfg/virtualhosts.cfg.lua
Normal 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" }
|
||||
14
tests/prosody/docker/docker-compose.yml
Normal file
14
tests/prosody/docker/docker-compose.yml
Normal 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
|
||||
11
tests/prosody/docker/inspect.lua
Normal file
11
tests/prosody/docker/inspect.lua
Normal 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
|
||||
20
tests/prosody/docker/prosody.cfg.lua
Normal file
20
tests/prosody/docker/prosody.cfg.lua
Normal 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"
|
||||
20
tests/prosody/fix-allure-report.cjs
Normal file
20
tests/prosody/fix-allure-report.cjs
Normal 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}`));
|
||||
28
tests/prosody/fixtures/test-asap-private.pem
Normal file
28
tests/prosody/fixtures/test-asap-private.pem
Normal 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-----
|
||||
9
tests/prosody/fixtures/test-asap-public.pem
Normal file
9
tests/prosody/fixtures/test-asap-public.pem
Normal 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-----
|
||||
28
tests/prosody/fixtures/test-system-asap-private.pem
Normal file
28
tests/prosody/fixtures/test-system-asap-private.pem
Normal 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-----
|
||||
9
tests/prosody/fixtures/test-system-asap-public.pem
Normal file
9
tests/prosody/fixtures/test-system-asap-public.pem
Normal 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-----
|
||||
25
tests/prosody/helpers/container.js
Normal file
25
tests/prosody/helpers/container.js
Normal file
@@ -0,0 +1,25 @@
|
||||
let _container;
|
||||
|
||||
/**
|
||||
* Stores the testcontainers container reference for use by test helpers.
|
||||
* Called by setup.js after the container starts.
|
||||
*
|
||||
* @param {object} c - The testcontainers container instance.
|
||||
*/
|
||||
export function setContainer(c) {
|
||||
_container = c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the running testcontainers container.
|
||||
* Throws if setup.js has not run yet.
|
||||
*
|
||||
* @returns {object} The testcontainers container instance.
|
||||
*/
|
||||
export function getContainer() {
|
||||
if (!_container) {
|
||||
throw new Error('Container not initialised — is setup.js loaded?');
|
||||
}
|
||||
|
||||
return _container;
|
||||
}
|
||||
145
tests/prosody/helpers/jwt.js
Normal file
145
tests/prosody/helpers/jwt.js
Normal file
@@ -0,0 +1,145 @@
|
||||
import fs from 'fs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
|
||||
const DEFAULT_SECRET = 'testsecret';
|
||||
const DEFAULT_APP_ID = 'jitsi';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Fixed RSA key pair for login ASAP (RS256) tokens.
|
||||
// The public key is copied into the Prosody Docker image and served via
|
||||
// mod_test_observer_http at /test-observer/asap-keys/ so that mod_auth_token
|
||||
// can fetch it when verifying RS256 login tokens.
|
||||
// The kid must be "test-asap-key"; mod_auth_token derives the fetch URL as:
|
||||
// <asap_key_server>/<sha256hex(kid)>.pem
|
||||
// which equals /test-observer/asap-keys/dc6983da...dfc.pem.
|
||||
export const ASAP_KID = 'test-asap-key';
|
||||
export const ASAP_PRIVATE_KEY = fs.readFileSync(
|
||||
path.join(__dirname, '../fixtures/test-asap-private.pem'), 'utf8');
|
||||
|
||||
// Separate RSA key pair for system ASAP tokens used by HTTP API modules such as
|
||||
// mod_muc_end_meeting. The public key is served at /test-observer/system-asap-keys/
|
||||
// (prosody_password_public_key_repo_url) so that system modules can verify tokens
|
||||
// signed with this key. Login tokens (signed with ASAP_PRIVATE_KEY) are rejected
|
||||
// by the system key server, and system tokens are rejected by the login key server.
|
||||
export const SYSTEM_ASAP_KID = 'test-system-asap-key';
|
||||
export const SYSTEM_ASAP_PRIVATE_KEY = fs.readFileSync(
|
||||
path.join(__dirname, '../fixtures/test-system-asap-private.pem'), 'utf8');
|
||||
|
||||
/**
|
||||
* Builds a standard JWT payload with sane defaults.
|
||||
*
|
||||
* @param {object} [overrides] Fields to merge / override.
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.expired] If true, set exp to one hour in the past.
|
||||
* @param {boolean} [opts.notYetValid] If true, set nbf to one hour in the future.
|
||||
*/
|
||||
function buildPayload(overrides = {}, { expired = false, notYetValid = false } = {}) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
return {
|
||||
iss: DEFAULT_APP_ID,
|
||||
aud: DEFAULT_APP_ID,
|
||||
sub: '*',
|
||||
iat: now,
|
||||
exp: expired ? now - 3600 : now + 3600,
|
||||
...notYetValid ? { nbf: now + 3600 } : {},
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints an HS256 JWT compatible with mod_auth_token / luajwtjitsi.
|
||||
*
|
||||
* luajwtjitsi.verify() checks:
|
||||
* - header.typ === "JWT"
|
||||
* - header.alg === signatureAlgorithm (HS256 in test config for "localhost")
|
||||
* - HMAC-SHA256 signature with appSecret
|
||||
* - exp, iss, aud claims
|
||||
*
|
||||
* util.lib.lua additionally checks:
|
||||
* - requireRoomClaim (true; callers must include room claim)
|
||||
* - sets session.jitsi_meet_context_features from claims.context.features
|
||||
*
|
||||
* @param {object} [overrides] Fields to merge / override in the payload.
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.secret] Signing secret (default: testsecret).
|
||||
* @param {boolean} [opts.expired] If true, set exp to one hour in the past.
|
||||
* @returns {string} Signed JWT string.
|
||||
*/
|
||||
export function mintToken(overrides = {}, { secret = DEFAULT_SECRET, expired = false, notYetValid = false } = {}) {
|
||||
return jwt.sign(buildPayload(overrides, { expired,
|
||||
notYetValid }), secret, { algorithm: 'HS256' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints an RS256 login JWT for ASAP tests.
|
||||
*
|
||||
* The token is signed with the test login RSA private key. Prosody fetches the
|
||||
* matching public key from mod_test_observer_http's /asap-keys/ route
|
||||
* (configured via asap_key_server on VirtualHost "localhost").
|
||||
*
|
||||
* These tokens are REJECTED by mod_muc_end_meeting and other system HTTP API
|
||||
* modules, which use a separate key server (prosody_password_public_key_repo_url).
|
||||
*
|
||||
* @param {object} [overrides] Fields to merge / override in the payload.
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.privateKey] PEM private key (default: test-asap-private.pem).
|
||||
* @param {string} [opts.kid] Key ID (default: ASAP_KID).
|
||||
* @param {boolean} [opts.expired] If true, set exp to one hour in the past.
|
||||
* @returns {string} Signed JWT string.
|
||||
*/
|
||||
export function mintAsapToken(overrides = {}, {
|
||||
privateKey = ASAP_PRIVATE_KEY,
|
||||
kid = ASAP_KID,
|
||||
expired = false,
|
||||
notYetValid = false
|
||||
} = {}) {
|
||||
// sub: '*' satisfies the mandatory sub claim check in process_and_verify_token
|
||||
// and the domain verification in verify_room (wildcard allows any MUC domain).
|
||||
return jwt.sign(
|
||||
buildPayload({ sub: '*',
|
||||
...overrides }, { expired,
|
||||
notYetValid }),
|
||||
privateKey,
|
||||
{ algorithm: 'RS256',
|
||||
keyid: kid }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints an RS256 system JWT for HTTP API modules such as mod_muc_end_meeting.
|
||||
*
|
||||
* The token is signed with the test system RSA private key. Prosody fetches
|
||||
* the matching public key from mod_test_observer_http's /system-asap-keys/ route
|
||||
* (configured via prosody_password_public_key_repo_url).
|
||||
*
|
||||
* These tokens are REJECTED by mod_auth_token (login), which uses a different
|
||||
* key server (asap_key_server).
|
||||
*
|
||||
* @param {object} [overrides] Fields to merge / override in the payload.
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.privateKey] PEM private key (default: test-system-asap-private.pem).
|
||||
* @param {string} [opts.kid] Key ID (default: SYSTEM_ASAP_KID).
|
||||
* @param {boolean} [opts.expired] If true, set exp to one hour in the past.
|
||||
* @returns {string} Signed JWT string.
|
||||
*/
|
||||
export function mintSystemToken(overrides = {}, {
|
||||
privateKey = SYSTEM_ASAP_PRIVATE_KEY,
|
||||
kid = SYSTEM_ASAP_KID,
|
||||
expired = false,
|
||||
notYetValid = false
|
||||
} = {}) {
|
||||
return jwt.sign(
|
||||
buildPayload({ sub: 'system.localhost',
|
||||
room: '*',
|
||||
...overrides }, { expired,
|
||||
notYetValid }),
|
||||
privateKey,
|
||||
{ algorithm: 'RS256',
|
||||
keyid: kid }
|
||||
);
|
||||
}
|
||||
36
tests/prosody/helpers/prosody_shell.js
Normal file
36
tests/prosody/helpers/prosody_shell.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { getContainer } from './container.js';
|
||||
|
||||
/**
|
||||
* Runs a Prosody shell command inside the Docker container (Option I).
|
||||
* Uses `prosodyctl shell` via stdin so the command executes against the live instance.
|
||||
*
|
||||
* Example commands:
|
||||
* module:load("muc_hide_all", "conference.localhost")
|
||||
* module:unload("muc_hide_all", "conference.localhost")
|
||||
*
|
||||
* @param {string} command Prosody shell command string
|
||||
* @returns {Promise<string>} stdout from the shell
|
||||
*/
|
||||
export async function prosodyShell(command) {
|
||||
const container = getContainer();
|
||||
|
||||
// Escape single quotes in the command before embedding in the shell string.
|
||||
const escaped = command.replace(/'/g, '\'\\\'\'');
|
||||
|
||||
const { output, exitCode } = await container.exec([
|
||||
'su', '-s', '/bin/sh', 'prosody', '-c',
|
||||
`echo '${escaped}' | prosodyctl shell`
|
||||
]);
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`prosodyctl shell failed (exit ${exitCode}):\n${output}`);
|
||||
}
|
||||
|
||||
// prosodyctl shell exits 0 even when the Lua command throws; detect errors
|
||||
// from the output text. Prosody prefixes error lines with '! '.
|
||||
if (/^!/m.test(output)) {
|
||||
throw new Error(`prosodyctl shell command error:\n${output}`);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
101
tests/prosody/helpers/test_context.js
Normal file
101
tests/prosody/helpers/test_context.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import { createXmppClient, joinWithFocus, joinWithJibri, joinWithTranscriber } from './xmpp_client.js';
|
||||
|
||||
/**
|
||||
* Creates a per-test context that tracks connected clients and provides
|
||||
* convenience helpers for connecting as different participant types.
|
||||
* Call in beforeEach; call cleanup() in afterEach.
|
||||
*
|
||||
* @returns {{ connect: Function, connectWhitelisted: Function, connectFocus: Function, cleanup: Function }}
|
||||
*/
|
||||
export function createTestContext() {
|
||||
const clients = [];
|
||||
|
||||
return {
|
||||
/**
|
||||
* Creates a regular XMPP client and registers it for cleanup.
|
||||
*
|
||||
* @param {object} [opts] Options forwarded to createXmppClient (e.g.
|
||||
* { params: { token } } to authenticate with a JWT).
|
||||
* @returns {Promise<XmppTestClient>}
|
||||
*/
|
||||
async connect(opts) {
|
||||
const c = await createXmppClient(opts);
|
||||
|
||||
clients.push(c);
|
||||
|
||||
return c;
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a whitelisted XMPP client (domain: whitelist.localhost) and
|
||||
* registers it for cleanup. Whitelisted clients bypass the occupant limit.
|
||||
*
|
||||
* @returns {Promise<XmppTestClient>}
|
||||
*/
|
||||
async connectWhitelisted() {
|
||||
const c = await createXmppClient({ domain: 'whitelist.localhost' });
|
||||
|
||||
clients.push(c);
|
||||
|
||||
return c;
|
||||
},
|
||||
|
||||
/**
|
||||
* Joins the room as focus (jicofo), unlocking the mod_muc_meeting_id
|
||||
* jicofo lock. The focus client is whitelisted and does not count
|
||||
* against the occupant limit. Registers the client for cleanup.
|
||||
*
|
||||
* @param {string} roomJid full room JID, e.g. 'room@conference.localhost'
|
||||
* @returns {Promise<XmppTestClient>}
|
||||
*/
|
||||
async connectFocus(roomJid) {
|
||||
const c = await joinWithFocus(roomJid);
|
||||
|
||||
clients.push(c);
|
||||
|
||||
return c;
|
||||
},
|
||||
|
||||
/**
|
||||
* Joins the room as a Jibri recorder (recorder@recorder.localhost).
|
||||
* The client's bare JID starts with 'recorder@recorder.' so is_jibri()
|
||||
* returns true for this client. Registers the client for cleanup.
|
||||
*
|
||||
* @param {string} roomJid full room JID, e.g. 'room@conference.localhost'
|
||||
* @returns {Promise<XmppTestClient>}
|
||||
*/
|
||||
async connectJibri(roomJid) {
|
||||
const c = await joinWithJibri(roomJid);
|
||||
|
||||
clients.push(c);
|
||||
|
||||
return c;
|
||||
},
|
||||
|
||||
/**
|
||||
* Joins the room as a transcriber (transcriber@recorder.localhost).
|
||||
* The client's bare JID starts with 'transcriber@recorder.' so
|
||||
* is_transcriber() returns true for this client. Registers the client
|
||||
* for cleanup.
|
||||
*
|
||||
* @param {string} roomJid full room JID, e.g. 'room@conference.localhost'
|
||||
* @returns {Promise<XmppTestClient>}
|
||||
*/
|
||||
async connectTranscriber(roomJid) {
|
||||
const c = await joinWithTranscriber(roomJid);
|
||||
|
||||
clients.push(c);
|
||||
|
||||
return c;
|
||||
},
|
||||
|
||||
/**
|
||||
* Disconnects all clients created through this context.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async cleanup() {
|
||||
await Promise.all(clients.map(c => c.disconnect()));
|
||||
}
|
||||
};
|
||||
}
|
||||
496
tests/prosody/helpers/test_observer.js
Normal file
496
tests/prosody/helpers/test_observer.js
Normal file
@@ -0,0 +1,496 @@
|
||||
const BASE = 'http://localhost:5280/test-observer';
|
||||
const ACCESS_MANAGER_URL = `${BASE}/access-manager`;
|
||||
const END_MEETING_URL = 'http://localhost:5280/end-meeting';
|
||||
const KICK_PARTICIPANT_URL = 'http://localhost:5280/kick-participant';
|
||||
const SYSTEM_CHAT_URL = 'http://localhost:5280/send-system-chat-message';
|
||||
const JIGASI_INVITE_URL = 'http://localhost:5280/invite-jigasi';
|
||||
|
||||
/**
|
||||
* Configures the mock access manager endpoint (served by mod_test_observer_http).
|
||||
* mod_muc_auth_ban calls this endpoint for VPaaS sessions
|
||||
* (jitsi_web_query_prefix starting with "vpaas-magic-cookie-").
|
||||
*
|
||||
* Call this before connecting the client under test.
|
||||
* Call with { access: true, status: 200 } (the defaults) to reset between tests.
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.access=true] true → allow, false → ban
|
||||
* @param {number} [opts.status=200] HTTP status code to return.
|
||||
* Non-200 values simulate HTTP errors;
|
||||
* mod_muc_auth_ban fails open on errors.
|
||||
* @param {boolean} [opts.nonJson=false] When true, return a 200 with a non-JSON
|
||||
* body. Tests the nil-check bug:
|
||||
* json.decode returns nil and the module
|
||||
* must not crash on r['access'].
|
||||
*/
|
||||
export async function setAccessManagerResponse({ access = true, status = 200, nonJson = false } = {}) {
|
||||
const res = await fetch(ACCESS_MANAGER_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
access,
|
||||
status,
|
||||
// eslint-disable-next-line camelcase
|
||||
non_json: nonJson
|
||||
})
|
||||
});
|
||||
|
||||
if (res.status !== 204) {
|
||||
throw new Error(`setAccessManagerResponse failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all MUC events recorded by mod_test_observer since the last clear.
|
||||
* @returns {Promise<Array<{event: string, room?: string, occupant?: string, timestamp: number}>>}
|
||||
*/
|
||||
export async function getEvents() {
|
||||
const res = await fetch(`${BASE}/events`);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`GET /events failed: ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the recorded event list.
|
||||
*/
|
||||
export async function clearEvents() {
|
||||
const res = await fetch(`${BASE}/events`, { method: 'DELETE' });
|
||||
|
||||
if (res.status !== 204) {
|
||||
throw new Error(`DELETE /events failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets room._data.hideDisplayNameForGuests for mod_muc_displayname tests.
|
||||
* The room must already exist (at least one occupant).
|
||||
*
|
||||
* @param {string} roomJid e.g. 'room@conference.localhost'
|
||||
* @param {boolean} hidden true to enable filtering; false to disable
|
||||
*/
|
||||
export async function setHideDisplayNameForGuests(roomJid, hidden) {
|
||||
const res = await fetch(`${BASE}/rooms/hide-display-name`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jid: roomJid,
|
||||
hidden })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`setHideDisplayNameForGuests failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets room._data.breakout_rooms_active for mod_muc_cleanup_backend_services tests.
|
||||
* When active is true, the module skips the destroy-timer logic even if only
|
||||
* backend services remain. The room must already exist (at least one occupant).
|
||||
*
|
||||
* @param {string} roomJid e.g. 'room@conference.localhost'
|
||||
* @param {boolean} active true to simulate active breakout rooms; false to clear
|
||||
*/
|
||||
export async function setBreakoutRoomsActive(roomJid, active) {
|
||||
const res = await fetch(`${BASE}/rooms/breakout-rooms-active`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jid: roomJid,
|
||||
active })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`setBreakoutRoomsActive failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the per-room max_occupants limit for an existing room.
|
||||
* The room must already exist (at least one occupant).
|
||||
* This overrides the global muc_max_occupants for this room only.
|
||||
*
|
||||
* @param {string} roomJid e.g. 'room@conference.localhost'
|
||||
* @param {number} max new occupant limit
|
||||
*/
|
||||
export async function setRoomMaxOccupants(roomJid, max) {
|
||||
const res = await fetch(`${BASE}/rooms/max-occupants`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jid: roomJid,
|
||||
// eslint-disable-next-line camelcase
|
||||
max_occupants: max
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`setRoomMaxOccupants failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the live jitsi_meet_context_features for a session.
|
||||
* Unlike session-info (which snapshots at resource-bind), this reads the current
|
||||
* value so it reflects any updates made after join (e.g. by mod_jitsi_permissions).
|
||||
*
|
||||
* @param {string} fullJid e.g. 'abc123@localhost/res1'
|
||||
* @returns {Promise<object|null>} feature map, or null when none are set
|
||||
*/
|
||||
export async function getSessionFeatures(fullJid) {
|
||||
const res = await fetch(`${BASE}/sessions/features?jid=${encodeURIComponent(fullJid)}`);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`getSessionFeatures failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
return (await res.json()).features;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets jitsi_meet_context_user and jitsi_meet_context_features on an active c2s
|
||||
* session identified by full JID. Allows tests to simulate JWT token context
|
||||
* without running a real token-auth module.
|
||||
*
|
||||
* @param {string} fullJid e.g. 'abc123@localhost/res1'
|
||||
* @param {string} userId value for jitsi_meet_context_user.id
|
||||
* @param {object} features key/value feature flags, e.g. { flip: true }
|
||||
*/
|
||||
export async function setSessionContext(fullJid, userId, features = {}) {
|
||||
const res = await fetch(`${BASE}/sessions/context`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jid: fullJid,
|
||||
// eslint-disable-next-line camelcase
|
||||
user_id: userId,
|
||||
features })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`setSessionContext failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns mod_muc_flip's per-room participant tracking state.
|
||||
*
|
||||
* @param {string} roomJid e.g. 'room@conference.localhost'
|
||||
* @returns {Promise<{participants_details: object, kicked_participant_nick?: string, flip_participant_nick?: string}>}
|
||||
*/
|
||||
export async function getRoomParticipants(roomJid) {
|
||||
const res = await fetch(`${BASE}/rooms/participants?jid=${encodeURIComponent(roomJid)}`);
|
||||
|
||||
if (res.status === 404) {
|
||||
return null;
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`getRoomParticipants failed: ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables or disables the lobby for the given room.
|
||||
*
|
||||
* @param {string} roomJid e.g. 'room@conference.localhost'
|
||||
* @param {boolean} enabled
|
||||
* @returns {Promise<{ok: boolean, lobbyroom: string|null}>}
|
||||
*/
|
||||
async function setLobby(roomJid, enabled) {
|
||||
const res = await fetch(`${BASE}/rooms/lobby`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jid: roomJid,
|
||||
enabled
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`setLobby(${enabled}) failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables the lobby for the given room.
|
||||
* Returns the lobby room JID.
|
||||
*
|
||||
* @param {string} roomJid e.g. 'room@conference.localhost'
|
||||
* @returns {Promise<string>} lobby room JID
|
||||
*/
|
||||
export async function enableLobby(roomJid) {
|
||||
const data = await setLobby(roomJid, true);
|
||||
|
||||
return data.lobbyroom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables the lobby for the given room and unsets members-only.
|
||||
*
|
||||
* @param {string} roomJid e.g. 'room@conference.localhost'
|
||||
*/
|
||||
export async function disableLobby(roomJid) {
|
||||
await setLobby(roomJid, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the affiliation of a JID in a room.
|
||||
*
|
||||
* @param {string} roomJid e.g. 'room@conference.localhost'
|
||||
* @param {string} occupantJid bare JID of the user, e.g. 'user@localhost'
|
||||
* @param {string} affiliation e.g. 'member', 'owner', 'none'
|
||||
*/
|
||||
export async function setAffiliation(roomJid, occupantJid, affiliation) {
|
||||
const res = await fetch(`${BASE}/rooms/affiliation`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jid: roomJid,
|
||||
// eslint-disable-next-line camelcase
|
||||
occupant_jid: occupantJid,
|
||||
affiliation
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`setAffiliation failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the jitsiMetadata table for a room as seen by Prosody.
|
||||
* Useful for asserting server-side state after metadata updates.
|
||||
*
|
||||
* Returns null if the room does not exist.
|
||||
* Throws if the server cannot encode the metadata (indicates corrupt state).
|
||||
*
|
||||
* @param {string} roomJid e.g. 'room@conference.localhost'
|
||||
* @returns {Promise<{jid: string, metadata: object}|null>}
|
||||
*/
|
||||
export async function getRoomMetadata(roomJid) {
|
||||
const res = await fetch(`${BASE}/rooms/metadata?jid=${encodeURIComponent(roomJid)}`);
|
||||
|
||||
if (res.status === 404) {
|
||||
return null;
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`GET /rooms/metadata failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the aggregated live-translation request map stored on room._data by
|
||||
* mod_audio_translation_component. Reads server state directly, so it is robust
|
||||
* against broadcast de-duplication (no broadcast fires when the aggregate is
|
||||
* unchanged).
|
||||
*
|
||||
* Returns null if the room does not exist; the map is undefined when there are
|
||||
* no subscriptions.
|
||||
*
|
||||
* @param {string} roomJid e.g. 'room@conference.localhost'
|
||||
* @returns {Promise<{jid: string, audioTranslationRequests?: object}|null>}
|
||||
*/
|
||||
export async function getAudioTranslationRequests(roomJid) {
|
||||
const res = await fetch(
|
||||
`${BASE}/rooms/audio-translation-requests?jid=${encodeURIComponent(roomJid)}`);
|
||||
|
||||
if (res.status === 404) {
|
||||
return null;
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`GET /rooms/audio-translation-requests failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns every OTLP ExportTraceServiceRequest body received by the mock
|
||||
* receiver (mod_test_observer_http) so far. Used by mod_trace tests.
|
||||
* @returns {Promise<Array<object>>}
|
||||
*/
|
||||
export async function getOtlpTraces() {
|
||||
const res = await fetch(`${BASE}/otlp-traces`);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`GET /otlp-traces failed: ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the recorded OTLP export list.
|
||||
*/
|
||||
export async function clearOtlpTraces() {
|
||||
const res = await fetch(`${BASE}/otlp-traces`, { method: 'DELETE' });
|
||||
|
||||
if (res.status !== 204) {
|
||||
throw new Error(`DELETE /otlp-traces failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns room state from Prosody's internal MUC state.
|
||||
* @param {string} roomJid e.g. 'room@conference.localhost'
|
||||
* @returns {Promise<{jid: string, hidden: boolean, occupant_count: number}|null>}
|
||||
*/
|
||||
export async function getRoomState(roomJid) {
|
||||
const res = await fetch(`${BASE}/rooms?jid=${encodeURIComponent(roomJid)}`);
|
||||
|
||||
if (res.status === 404) {
|
||||
return null;
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`GET /rooms failed: ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the mod_muc_kick_participant HTTP endpoint to kick a participant.
|
||||
*
|
||||
* Returns raw { status, body } so tests can assert on error responses.
|
||||
*
|
||||
* @param {string} roomJid e.g. 'room@conference.localhost'
|
||||
* @param {string} participantId Occupant nick / resource to kick.
|
||||
* @param {string} token Bearer token.
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.omitAuth] If true, omits the Authorization header.
|
||||
* @returns {Promise<{status: number, body: string}>}
|
||||
*/
|
||||
export async function kickParticipant(roomJid, participantId, token, { omitAuth = false } = {}) {
|
||||
const roomName = roomJid.split('@')[0];
|
||||
const url = new URL(KICK_PARTICIPANT_URL);
|
||||
|
||||
url.searchParams.set('room', roomName);
|
||||
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
|
||||
if (!omitAuth) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify({ participantId })
|
||||
});
|
||||
|
||||
return { status: res.status,
|
||||
body: await res.text() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the mod_muc_jigasi_invite HTTP endpoint to invite Jigasi to dial out.
|
||||
*
|
||||
* Returns raw { status, body } so tests can assert on error responses.
|
||||
*
|
||||
* @param {string} roomJid Conference room JID, e.g. 'room@conference.localhost'
|
||||
* @param {string} phoneNo Dial target, e.g. '+15551234567'
|
||||
* @param {string} token Bearer token.
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.omitAuth] If true, omits the Authorization header.
|
||||
* @returns {Promise<{status: number, body: string}>}
|
||||
*/
|
||||
export async function inviteJigasi(roomJid, phoneNo, token, { omitAuth = false } = {}) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
|
||||
if (!omitAuth) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(JIGASI_INVITE_URL, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ conference: roomJid,
|
||||
phoneNo })
|
||||
});
|
||||
|
||||
return { status: res.status,
|
||||
body: await res.text() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the mod_system_chat_message HTTP endpoint to send a system chat message
|
||||
* to one or more participants.
|
||||
*
|
||||
* Returns raw { status, body } so tests can assert on error responses.
|
||||
*
|
||||
* @param {string} roomJid e.g. 'room@conference.localhost'
|
||||
* @param {string[]} connectionJIDs Full JIDs of recipients, e.g. ['user@localhost/res']
|
||||
* @param {string} message Message text.
|
||||
* @param {string} token Bearer token.
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.omitAuth] If true, omits the Authorization header.
|
||||
* @param {string} [opts.displayName] Optional display name to include.
|
||||
* @returns {Promise<{status: number, body: string}>}
|
||||
*/
|
||||
export async function sendSystemChatMessage(roomJid, connectionJIDs, message, token, { // eslint-disable-line max-params
|
||||
omitAuth = false,
|
||||
displayName
|
||||
} = {}) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
|
||||
if (!omitAuth) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const body = { room: roomJid,
|
||||
connectionJIDs,
|
||||
message };
|
||||
|
||||
if (displayName !== undefined) {
|
||||
body.displayName = displayName;
|
||||
}
|
||||
|
||||
const res = await fetch(SYSTEM_CHAT_URL, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
return { status: res.status,
|
||||
body: await res.text() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the mod_muc_end_meeting HTTP endpoint to terminate a conference.
|
||||
*
|
||||
* Returns the raw { status, body } rather than throwing on non-2xx so that
|
||||
* tests can assert on error responses (401, 404, etc.) directly.
|
||||
*
|
||||
* @param {string} roomJid e.g. 'room@conference.localhost'
|
||||
* @param {string} token Bearer token (system token for success; login token to test rejection).
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.silentReconnect] If true, adds silent-reconnect=true to the query.
|
||||
* @param {boolean} [opts.omitAuth] If true, omits the Authorization header entirely.
|
||||
* @returns {Promise<{status: number, body: string}>}
|
||||
*/
|
||||
export async function endMeeting(roomJid, token, { silentReconnect = false, omitAuth = false } = {}) {
|
||||
const url = new URL(END_MEETING_URL);
|
||||
|
||||
url.searchParams.set('conference', roomJid);
|
||||
if (silentReconnect) {
|
||||
url.searchParams.set('silent-reconnect', 'true');
|
||||
}
|
||||
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
|
||||
if (!omitAuth) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), { method: 'POST',
|
||||
headers });
|
||||
|
||||
return { status: res.status,
|
||||
body: await res.text() };
|
||||
}
|
||||
1341
tests/prosody/helpers/xmpp_client.js
Normal file
1341
tests/prosody/helpers/xmpp_client.js
Normal file
File diff suppressed because it is too large
Load Diff
69
tests/prosody/helpers/xmpp_utils.js
Normal file
69
tests/prosody/helpers/xmpp_utils.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import { createXmppClient } from './xmpp_client.js';
|
||||
|
||||
/**
|
||||
* Connects a client to the given domain, sends a XEP-0215 extdisco IQ, and
|
||||
* asserts that the response is a valid result containing service entries that
|
||||
* all use the expected port. The connected client is pushed into `clients` for
|
||||
* afterEach cleanup.
|
||||
*
|
||||
* @param {string} domain - XMPP domain to connect to and send the IQ to.
|
||||
* @param {number} expectedPort - Port number all returned service entries must use.
|
||||
* @param {Array} clients - Cleanup array; the new client is appended.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function assertExtdiscoServices(domain, expectedPort, clients) {
|
||||
const c = await createXmppClient({ domain });
|
||||
|
||||
clients.push(c);
|
||||
|
||||
const iq = await c.sendExtdiscoIq(domain);
|
||||
|
||||
assert.equal(iq.attrs.type, 'result', `expected result IQ, got type="${iq.attrs.type}"`);
|
||||
|
||||
const services = iq.getChild('services', 'urn:xmpp:extdisco:1');
|
||||
|
||||
assert.ok(services, 'result IQ must contain <services xmlns="urn:xmpp:extdisco:1">');
|
||||
|
||||
const entries = services.getChildren('service');
|
||||
|
||||
assert.ok(entries.length > 0, 'services element must contain at least one <service>');
|
||||
|
||||
const ports = entries.map(s => s.attrs.port);
|
||||
|
||||
assert.ok(
|
||||
entries.every(s => Number(s.attrs.port) === expectedPort),
|
||||
`all entries must use port ${expectedPort}, got: ${JSON.stringify(ports)}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when a presence stanza represents a successful join.
|
||||
* Per XEP-0045 / RFC 6121, available presence has no type attribute or
|
||||
* type="available"; error presence has type="error".
|
||||
*
|
||||
* @param {object} presence - Presence stanza.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAvailablePresence(presence) {
|
||||
const t = presence.attrs.type;
|
||||
|
||||
return t === undefined || t === 'available';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the value of a named field from a disco#info IQ result stanza.
|
||||
* Returns the string value, or null if the field is absent or has no value.
|
||||
*
|
||||
* @param {object} iq - IQ stanza returned by sendDiscoInfo().
|
||||
* @param {string} varName - The field var attribute to look for.
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function discoField(iq, varName) {
|
||||
const query = iq.getChild('query', 'http://jabber.org/protocol/disco#info');
|
||||
const form = query?.getChild('x', 'jabber:x:data');
|
||||
const field = form?.getChildren('field').find(f => f.attrs.var === varName);
|
||||
|
||||
return field?.getChild('value')?.text() ?? null;
|
||||
}
|
||||
154
tests/prosody/lua/jwk_spec.lua
Normal file
154
tests/prosody/lua/jwk_spec.lua
Normal 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
|
||||
443
tests/prosody/lua/luajwtjitsi_spec.lua
Normal file
443
tests/prosody/lua/luajwtjitsi_spec.lua
Normal 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
|
||||
160
tests/prosody/lua/mod_certs_s2soutinjection_spec.lua
Normal file
160
tests/prosody/lua/mod_certs_s2soutinjection_spec.lua
Normal 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)
|
||||
251
tests/prosody/lua/mod_debug_traceback_spec.lua
Normal file
251
tests/prosody/lua/mod_debug_traceback_spec.lua
Normal 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)
|
||||
273
tests/prosody/lua/mod_jibri_session_spec.lua
Normal file
273
tests/prosody/lua/mod_jibri_session_spec.lua
Normal 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
|
||||
308
tests/prosody/lua/mod_muc_domain_mapper_spec.lua
Normal file
308
tests/prosody/lua/mod_muc_domain_mapper_spec.lua
Normal 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)
|
||||
1416
tests/prosody/lua/mod_muc_message_moderation_spec.lua
Normal file
1416
tests/prosody/lua/mod_muc_message_moderation_spec.lua
Normal file
File diff suppressed because it is too large
Load Diff
669
tests/prosody/lua/mod_short_lived_token_spec.lua
Normal file
669
tests/prosody/lua/mod_short_lived_token_spec.lua
Normal 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
|
||||
221
tests/prosody/lua/mod_token_affiliation_spec.lua
Normal file
221
tests/prosody/lua/mod_token_affiliation_spec.lua
Normal 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
|
||||
113
tests/prosody/lua/otel_spec.lua
Normal file
113
tests/prosody/lua/otel_spec.lua
Normal 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
|
||||
165
tests/prosody/lua/rayo_dial_header_crlf_spec.lua
Normal file
165
tests/prosody/lua/rayo_dial_header_crlf_spec.lua
Normal 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" -> " " and "\n" -> " ". This spec produces a dial IQ
|
||||
-- with such a header and asserts the serialized stanza contains " " 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 = {
|
||||
["'"] = "'",
|
||||
['"'] = """,
|
||||
["<"] = "<",
|
||||
[">"] = ">",
|
||||
["&"] = "&",
|
||||
["\t"] = "	",
|
||||
["\n"] = " ",
|
||||
["\r"] = " ",
|
||||
};
|
||||
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 ", 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(" ", 1, true),
|
||||
"serialized dial IQ must escape the raw CRLF to ");
|
||||
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 Injected%-Header: evil"));
|
||||
end)
|
||||
|
||||
end);
|
||||
329
tests/prosody/lua/speakerstats_component_spec.lua
Normal file
329
tests/prosody/lua/speakerstats_component_spec.lua
Normal 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
|
||||
919
tests/prosody/lua/util_spec.lua
Normal file
919
tests/prosody/lua/util_spec.lua
Normal 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
|
||||
1180
tests/prosody/mod_audio_translation_component_spec.js
Normal file
1180
tests/prosody/mod_audio_translation_component_spec.js
Normal file
File diff suppressed because it is too large
Load Diff
80
tests/prosody/mod_auth_jitsi_anonymous_spec.js
Normal file
80
tests/prosody/mod_auth_jitsi_anonymous_spec.js
Normal 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');
|
||||
});
|
||||
});
|
||||
107
tests/prosody/mod_auth_jitsi_shared_secret_spec.js
Normal file
107
tests/prosody/mod_auth_jitsi_shared_secret_spec.js
Normal 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);
|
||||
// }
|
||||
// });
|
||||
});
|
||||
423
tests/prosody/mod_auth_token_asap_spec.js
Normal file
423
tests/prosody/mod_auth_token_asap_spec.js
Normal 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()}`);
|
||||
});
|
||||
});
|
||||
448
tests/prosody/mod_auth_token_spec.js
Normal file
448
tests/prosody/mod_auth_token_spec.js
Normal 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}")`);
|
||||
}
|
||||
});
|
||||
});
|
||||
85
tests/prosody/mod_conference_duration_spec.js
Normal file
85
tests/prosody/mod_conference_duration_spec.js
Normal 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');
|
||||
});
|
||||
});
|
||||
173
tests/prosody/mod_end_conference_spec.js
Normal file
173
tests/prosody/mod_end_conference_spec.js
Normal 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);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
476
tests/prosody/mod_filesharing_component_spec.js
Normal file
476
tests/prosody/mod_filesharing_component_spec.js
Normal 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}`);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
289
tests/prosody/mod_filter_iq_jibri_spec.js
Normal file
289
tests/prosody/mod_filter_iq_jibri_spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
302
tests/prosody/mod_filter_iq_rayo_spec.js
Normal file
302
tests/prosody/mod_filter_iq_rayo_spec.js
Normal 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);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
283
tests/prosody/mod_filter_messages_spec.js
Normal file
283
tests/prosody/mod_filter_messages_spec.js
Normal 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');
|
||||
});
|
||||
});
|
||||
76
tests/prosody/mod_jiconop_spec.js
Normal file
76
tests/prosody/mod_jiconop_spec.js
Normal 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');
|
||||
});
|
||||
});
|
||||
752
tests/prosody/mod_jitsi_permissions_spec.js
Normal file
752
tests/prosody/mod_jitsi_permissions_spec.js
Normal 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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
106
tests/prosody/mod_jitsi_session_spec.js
Normal file
106
tests/prosody/mod_jitsi_session_spec.js
Normal 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');
|
||||
});
|
||||
});
|
||||
238
tests/prosody/mod_muc_allowners_spec.js
Normal file
238
tests/prosody/mod_muc_allowners_spec.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
262
tests/prosody/mod_muc_auth_ban_spec.js
Normal file
262
tests/prosody/mod_muc_auth_ban_spec.js
Normal 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)'
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
373
tests/prosody/mod_muc_breakout_rooms_spec.js
Normal file
373
tests/prosody/mod_muc_breakout_rooms_spec.js
Normal 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'
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
119
tests/prosody/mod_muc_census_spec.js
Normal file
119
tests/prosody/mod_muc_census_spec.js
Normal 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})`);
|
||||
});
|
||||
});
|
||||
165
tests/prosody/mod_muc_cleanup_backend_services_spec.js
Normal file
165
tests/prosody/mod_muc_cleanup_backend_services_spec.js
Normal 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');
|
||||
});
|
||||
});
|
||||
346
tests/prosody/mod_muc_displayname_spec.js
Normal file
346
tests/prosody/mod_muc_displayname_spec.js
Normal 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');
|
||||
});
|
||||
});
|
||||
137
tests/prosody/mod_muc_domain_mapper_spec.js
Normal file
137
tests/prosody/mod_muc_domain_mapper_spec.js
Normal 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
Reference in New Issue
Block a user