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

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
let _container;
/**
* Stores the testcontainers container reference for use by test helpers.
* Called by setup.js after the container starts.
*
* @param {object} c - The testcontainers container instance.
*/
export function setContainer(c) {
_container = c;
}
/**
* Returns the running testcontainers container.
* Throws if setup.js has not run yet.
*
* @returns {object} The testcontainers container instance.
*/
export function getContainer() {
if (!_container) {
throw new Error('Container not initialised — is setup.js loaded?');
}
return _container;
}

View File

@@ -0,0 +1,145 @@
import fs from 'fs';
import jwt from 'jsonwebtoken';
import path from 'path';
import { fileURLToPath } from 'url';
const DEFAULT_SECRET = 'testsecret';
const DEFAULT_APP_ID = 'jitsi';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Fixed RSA key pair for login ASAP (RS256) tokens.
// The public key is copied into the Prosody Docker image and served via
// mod_test_observer_http at /test-observer/asap-keys/ so that mod_auth_token
// can fetch it when verifying RS256 login tokens.
// The kid must be "test-asap-key"; mod_auth_token derives the fetch URL as:
// <asap_key_server>/<sha256hex(kid)>.pem
// which equals /test-observer/asap-keys/dc6983da...dfc.pem.
export const ASAP_KID = 'test-asap-key';
export const ASAP_PRIVATE_KEY = fs.readFileSync(
path.join(__dirname, '../fixtures/test-asap-private.pem'), 'utf8');
// Separate RSA key pair for system ASAP tokens used by HTTP API modules such as
// mod_muc_end_meeting. The public key is served at /test-observer/system-asap-keys/
// (prosody_password_public_key_repo_url) so that system modules can verify tokens
// signed with this key. Login tokens (signed with ASAP_PRIVATE_KEY) are rejected
// by the system key server, and system tokens are rejected by the login key server.
export const SYSTEM_ASAP_KID = 'test-system-asap-key';
export const SYSTEM_ASAP_PRIVATE_KEY = fs.readFileSync(
path.join(__dirname, '../fixtures/test-system-asap-private.pem'), 'utf8');
/**
* Builds a standard JWT payload with sane defaults.
*
* @param {object} [overrides] Fields to merge / override.
* @param {object} [opts]
* @param {boolean} [opts.expired] If true, set exp to one hour in the past.
* @param {boolean} [opts.notYetValid] If true, set nbf to one hour in the future.
*/
function buildPayload(overrides = {}, { expired = false, notYetValid = false } = {}) {
const now = Math.floor(Date.now() / 1000);
return {
iss: DEFAULT_APP_ID,
aud: DEFAULT_APP_ID,
sub: '*',
iat: now,
exp: expired ? now - 3600 : now + 3600,
...notYetValid ? { nbf: now + 3600 } : {},
...overrides
};
}
/**
* Mints an HS256 JWT compatible with mod_auth_token / luajwtjitsi.
*
* luajwtjitsi.verify() checks:
* - header.typ === "JWT"
* - header.alg === signatureAlgorithm (HS256 in test config for "localhost")
* - HMAC-SHA256 signature with appSecret
* - exp, iss, aud claims
*
* util.lib.lua additionally checks:
* - requireRoomClaim (true; callers must include room claim)
* - sets session.jitsi_meet_context_features from claims.context.features
*
* @param {object} [overrides] Fields to merge / override in the payload.
* @param {object} [opts]
* @param {string} [opts.secret] Signing secret (default: testsecret).
* @param {boolean} [opts.expired] If true, set exp to one hour in the past.
* @returns {string} Signed JWT string.
*/
export function mintToken(overrides = {}, { secret = DEFAULT_SECRET, expired = false, notYetValid = false } = {}) {
return jwt.sign(buildPayload(overrides, { expired,
notYetValid }), secret, { algorithm: 'HS256' });
}
/**
* Mints an RS256 login JWT for ASAP tests.
*
* The token is signed with the test login RSA private key. Prosody fetches the
* matching public key from mod_test_observer_http's /asap-keys/ route
* (configured via asap_key_server on VirtualHost "localhost").
*
* These tokens are REJECTED by mod_muc_end_meeting and other system HTTP API
* modules, which use a separate key server (prosody_password_public_key_repo_url).
*
* @param {object} [overrides] Fields to merge / override in the payload.
* @param {object} [opts]
* @param {string} [opts.privateKey] PEM private key (default: test-asap-private.pem).
* @param {string} [opts.kid] Key ID (default: ASAP_KID).
* @param {boolean} [opts.expired] If true, set exp to one hour in the past.
* @returns {string} Signed JWT string.
*/
export function mintAsapToken(overrides = {}, {
privateKey = ASAP_PRIVATE_KEY,
kid = ASAP_KID,
expired = false,
notYetValid = false
} = {}) {
// sub: '*' satisfies the mandatory sub claim check in process_and_verify_token
// and the domain verification in verify_room (wildcard allows any MUC domain).
return jwt.sign(
buildPayload({ sub: '*',
...overrides }, { expired,
notYetValid }),
privateKey,
{ algorithm: 'RS256',
keyid: kid }
);
}
/**
* Mints an RS256 system JWT for HTTP API modules such as mod_muc_end_meeting.
*
* The token is signed with the test system RSA private key. Prosody fetches
* the matching public key from mod_test_observer_http's /system-asap-keys/ route
* (configured via prosody_password_public_key_repo_url).
*
* These tokens are REJECTED by mod_auth_token (login), which uses a different
* key server (asap_key_server).
*
* @param {object} [overrides] Fields to merge / override in the payload.
* @param {object} [opts]
* @param {string} [opts.privateKey] PEM private key (default: test-system-asap-private.pem).
* @param {string} [opts.kid] Key ID (default: SYSTEM_ASAP_KID).
* @param {boolean} [opts.expired] If true, set exp to one hour in the past.
* @returns {string} Signed JWT string.
*/
export function mintSystemToken(overrides = {}, {
privateKey = SYSTEM_ASAP_PRIVATE_KEY,
kid = SYSTEM_ASAP_KID,
expired = false,
notYetValid = false
} = {}) {
return jwt.sign(
buildPayload({ sub: 'system.localhost',
room: '*',
...overrides }, { expired,
notYetValid }),
privateKey,
{ algorithm: 'RS256',
keyid: kid }
);
}

View File

@@ -0,0 +1,36 @@
import { getContainer } from './container.js';
/**
* Runs a Prosody shell command inside the Docker container (Option I).
* Uses `prosodyctl shell` via stdin so the command executes against the live instance.
*
* Example commands:
* module:load("muc_hide_all", "conference.localhost")
* module:unload("muc_hide_all", "conference.localhost")
*
* @param {string} command Prosody shell command string
* @returns {Promise<string>} stdout from the shell
*/
export async function prosodyShell(command) {
const container = getContainer();
// Escape single quotes in the command before embedding in the shell string.
const escaped = command.replace(/'/g, '\'\\\'\'');
const { output, exitCode } = await container.exec([
'su', '-s', '/bin/sh', 'prosody', '-c',
`echo '${escaped}' | prosodyctl shell`
]);
if (exitCode !== 0) {
throw new Error(`prosodyctl shell failed (exit ${exitCode}):\n${output}`);
}
// prosodyctl shell exits 0 even when the Lua command throws; detect errors
// from the output text. Prosody prefixes error lines with '! '.
if (/^!/m.test(output)) {
throw new Error(`prosodyctl shell command error:\n${output}`);
}
return output;
}

View File

@@ -0,0 +1,101 @@
import { createXmppClient, joinWithFocus, joinWithJibri, joinWithTranscriber } from './xmpp_client.js';
/**
* Creates a per-test context that tracks connected clients and provides
* convenience helpers for connecting as different participant types.
* Call in beforeEach; call cleanup() in afterEach.
*
* @returns {{ connect: Function, connectWhitelisted: Function, connectFocus: Function, cleanup: Function }}
*/
export function createTestContext() {
const clients = [];
return {
/**
* Creates a regular XMPP client and registers it for cleanup.
*
* @param {object} [opts] Options forwarded to createXmppClient (e.g.
* { params: { token } } to authenticate with a JWT).
* @returns {Promise<XmppTestClient>}
*/
async connect(opts) {
const c = await createXmppClient(opts);
clients.push(c);
return c;
},
/**
* Creates a whitelisted XMPP client (domain: whitelist.localhost) and
* registers it for cleanup. Whitelisted clients bypass the occupant limit.
*
* @returns {Promise<XmppTestClient>}
*/
async connectWhitelisted() {
const c = await createXmppClient({ domain: 'whitelist.localhost' });
clients.push(c);
return c;
},
/**
* Joins the room as focus (jicofo), unlocking the mod_muc_meeting_id
* jicofo lock. The focus client is whitelisted and does not count
* against the occupant limit. Registers the client for cleanup.
*
* @param {string} roomJid full room JID, e.g. 'room@conference.localhost'
* @returns {Promise<XmppTestClient>}
*/
async connectFocus(roomJid) {
const c = await joinWithFocus(roomJid);
clients.push(c);
return c;
},
/**
* Joins the room as a Jibri recorder (recorder@recorder.localhost).
* The client's bare JID starts with 'recorder@recorder.' so is_jibri()
* returns true for this client. Registers the client for cleanup.
*
* @param {string} roomJid full room JID, e.g. 'room@conference.localhost'
* @returns {Promise<XmppTestClient>}
*/
async connectJibri(roomJid) {
const c = await joinWithJibri(roomJid);
clients.push(c);
return c;
},
/**
* Joins the room as a transcriber (transcriber@recorder.localhost).
* The client's bare JID starts with 'transcriber@recorder.' so
* is_transcriber() returns true for this client. Registers the client
* for cleanup.
*
* @param {string} roomJid full room JID, e.g. 'room@conference.localhost'
* @returns {Promise<XmppTestClient>}
*/
async connectTranscriber(roomJid) {
const c = await joinWithTranscriber(roomJid);
clients.push(c);
return c;
},
/**
* Disconnects all clients created through this context.
*
* @returns {Promise<void>}
*/
async cleanup() {
await Promise.all(clients.map(c => c.disconnect()));
}
};
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,69 @@
import assert from 'assert';
import { createXmppClient } from './xmpp_client.js';
/**
* Connects a client to the given domain, sends a XEP-0215 extdisco IQ, and
* asserts that the response is a valid result containing service entries that
* all use the expected port. The connected client is pushed into `clients` for
* afterEach cleanup.
*
* @param {string} domain - XMPP domain to connect to and send the IQ to.
* @param {number} expectedPort - Port number all returned service entries must use.
* @param {Array} clients - Cleanup array; the new client is appended.
* @returns {Promise<void>}
*/
export async function assertExtdiscoServices(domain, expectedPort, clients) {
const c = await createXmppClient({ domain });
clients.push(c);
const iq = await c.sendExtdiscoIq(domain);
assert.equal(iq.attrs.type, 'result', `expected result IQ, got type="${iq.attrs.type}"`);
const services = iq.getChild('services', 'urn:xmpp:extdisco:1');
assert.ok(services, 'result IQ must contain <services xmlns="urn:xmpp:extdisco:1">');
const entries = services.getChildren('service');
assert.ok(entries.length > 0, 'services element must contain at least one <service>');
const ports = entries.map(s => s.attrs.port);
assert.ok(
entries.every(s => Number(s.attrs.port) === expectedPort),
`all entries must use port ${expectedPort}, got: ${JSON.stringify(ports)}`
);
}
/**
* Returns true when a presence stanza represents a successful join.
* Per XEP-0045 / RFC 6121, available presence has no type attribute or
* type="available"; error presence has type="error".
*
* @param {object} presence - Presence stanza.
* @returns {boolean}
*/
export function isAvailablePresence(presence) {
const t = presence.attrs.type;
return t === undefined || t === 'available';
}
/**
* Extracts the value of a named field from a disco#info IQ result stanza.
* Returns the string value, or null if the field is absent or has no value.
*
* @param {object} iq - IQ stanza returned by sendDiscoInfo().
* @param {string} varName - The field var attribute to look for.
* @returns {string|null}
*/
export function discoField(iq, varName) {
const query = iq.getChild('query', 'http://jabber.org/protocol/disco#info');
const form = query?.getChild('x', 'jabber:x:data');
const field = form?.getChildren('field').find(f => f.attrs.var === varName);
return field?.getChild('value')?.text() ?? null;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,752 @@
import assert from 'assert';
import { mintAsapToken } from './helpers/jwt.js';
import { getSessionFeatures } from './helpers/test_observer.js';
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
const CONFERENCE = 'conference.localhost';
const PERMISSIONS_NS = 'http://jitsi.org/jitmeet';
let _roomCounter = 0;
const nextRoom = () => `permissions-${++_roomCounter}@${CONFERENCE}`;
// Keys present in the default_permissions table in mod_jitsi_permissions.lua.
const DEFAULT_PERMISSION_KEYS = [
'livestreaming', 'recording', 'transcription',
'outbound-call', 'create-polls', 'send-groupchat', 'flip'
];
/**
* Extracts the <permissions xmlns='http://jitsi.org/jitmeet'> element from a
* presence stanza and returns its children as a plain { name: val } map.
* Returns null when the element is absent.
*
* @param {object} presence
* @returns {object|null}
*/
function getPermissions(presence) {
const el = presence.getChild('permissions', PERMISSIONS_NS);
if (!el) {
return null;
}
const result = {};
for (const p of el.getChildren('p')) {
result[p.attrs.name] = p.attrs.val;
}
return result;
}
describe('mod_jitsi_permissions', () => {
const clients = [];
afterEach(async () => {
await Promise.all(clients.map(c => c.disconnect()));
clients.length = 0;
});
/**
* Creates a connected XMPP client and tracks it for afterEach cleanup.
*
* @param {object} [opts] Options forwarded to createXmppClient.
* @returns {Promise<object>}
*/
async function connect(opts) {
const c = await createXmppClient(opts);
clients.push(c);
return c;
}
// ── moderator token, no features ──────────────────────────────────────────
//
// mod_token_affiliation grants owner/moderator on join.
// mod_jitsi_permissions sees: is_moderator=true, send_default_permissions_to
// flag set (by muc-pre-set-affiliation from set_affiliation call inside
// token_affiliation), no jitsi_meet_context_features on session → injects
// all defaults.
describe('moderator token without features', () => {
it('injects all default permissions into self-presence on join', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const token = mintAsapToken({ room: r.split('@')[0],
context: { user: { moderator: true } } });
const c = await connect({ params: { token } });
const presence = await c.joinRoom(r);
const perms = getPermissions(presence);
assert.ok(perms !== null, 'permissions element should be present');
for (const key of DEFAULT_PERMISSION_KEYS) {
assert.strictEqual(perms[key], 'true', `expected ${key}=true`);
}
});
});
// ── moderator token with features ─────────────────────────────────────────
//
// mod_token_affiliation grants owner/moderator.
// filter_stanza sees auth_token + jitsi_meet_context_features both set →
// skips injection entirely so the client uses its own token-provided features.
describe('moderator token with features', () => {
it('does not inject permissions when token already contains features', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const token = mintAsapToken({
room: r.split('@')[0],
context: {
user: { moderator: true },
features: {
recording: false,
'screen-sharing': true
}
}
});
const c = await connect({ params: { token } });
const presence = await c.joinRoom(r);
assert.strictEqual(
getPermissions(presence),
null,
'permissions should not be injected when token already provides features'
);
});
});
// ── non-moderator token ───────────────────────────────────────────────────
//
// mod_token_affiliation grants member affiliation (participant role).
// filter_stanza checks is_moderator=false → returns stanza unchanged.
describe('non-moderator token', () => {
it('does not inject permissions for member participant', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const token = mintAsapToken({ room: r.split('@')[0],
context: { user: { id: 'user1' } } });
const c = await connect({ params: { token } });
const presence = await c.joinRoom(r);
assert.strictEqual(
getPermissions(presence),
null,
'permissions should not be injected for non-moderator participant'
);
});
it('does not inject permissions for member with features', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const token = mintAsapToken({
room: r.split('@')[0],
context: {
user: { moderator: false },
features: { recording: true }
}
});
const c = await connect({ params: { token } });
const presence = await c.joinRoom(r);
assert.strictEqual(
getPermissions(presence),
null,
'permissions should not be injected for non-moderator even with features'
);
});
});
// ── anonymous user (no token) ─────────────────────────────────────────────
//
// token_affiliation is a no-op → affiliation=none, role=participant.
// muc-pre-set-affiliation never fires → send_default_permissions_to not set.
// filter_stanza: is_moderator=false → no injection.
describe('anonymous user (no token)', () => {
it('does not inject permissions for anonymous participant', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const c = await connect();
const presence = await c.joinRoom(r);
assert.strictEqual(
getPermissions(presence),
null,
'permissions should not be injected for anonymous user'
);
});
});
// ── affiliation grant and revoke ──────────────────────────────────────────
//
// When a moderator grants owner affiliation to another participant,
// process_set_affiliation copies the granting session's feature table onto
// the recipient's session. When the affiliation is revoked, the recipient
// must go back to the features that its own token supplied, or to no
// features at all when the token supplied none. A session that keeps the
// granted table stays allowed by is_feature_allowed(), which does not look
// at the current role once a feature table is present.
describe('affiliation grant and revoke', () => {
/**
* Joins a room as a moderator that holds every default feature.
* Use it as the actor that grants and revokes owner affiliation.
*
* @param {Function} connectFn the spec-local connect helper.
* @param {string} roomJid e.g. 'room@conference.localhost'
* @returns {Promise<object>}
*/
async function joinAsGranter(connectFn, roomJid, extraParams = {}) {
const features = {};
for (const key of DEFAULT_PERMISSION_KEYS) {
features[key] = true;
}
const token = mintAsapToken({
room: roomJid.split('@')[0],
context: {
user: { id: 'granter',
moderator: true },
features
}
});
const c = await connectFn({ params: { token,
...extraParams } });
await c.joinRoom(roomJid);
return c;
}
/**
* Sends a Jibri start IQ and returns the name of the error condition
* that comes back. mod_filter_iq_jibri answers 'forbidden' when it
* blocks the request. Anything else means the IQ was routed on to the
* focus occupant, which has no Jibri handler in these tests.
*
* @param {object} client the sender.
* @param {string} roomJid e.g. 'room@conference.localhost'
* @returns {Promise<string|null>}
*/
async function jibriStartErrorCondition(client, roomJid) {
const wait = client.waitForIq(iq => iq.attrs.type === 'error', 5000);
client.sendJibriIq(roomJid, 'start', 'file');
const error = (await wait).getChild('error');
return error ? error.children[0].name : null;
}
/**
* Sends a Rayo dial IQ and returns the name of the error condition that
* comes back. mod_filter_iq_rayo answers 'forbidden' when it blocks the
* request. Anything else means the IQ was routed on to the focus
* occupant, which has no Rayo handler in these tests.
*
* @param {object} client the sender.
* @param {string} roomJid e.g. 'room@conference.localhost'
* @returns {Promise<string|null>}
*/
async function rayoDialErrorCondition(client, roomJid) {
const reply = await client.sendRayoIqAndWait(roomJid);
const error = reply.getChild('error');
return error ? error.children[0].name : null;
}
it('restores the token features of the recipient when owner is revoked', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const granter = await joinAsGranter(connect, r);
const token = mintAsapToken({
room: r.split('@')[0],
context: {
user: { id: 'user1' },
features: {
recording: false,
livestreaming: false,
transcription: false,
'outbound-call': false
}
}
});
const c = await connect({ params: { token } });
await c.joinRoom(r);
const bare = c.jid.split('/')[0];
await granter.sendMucAdmin(r, { jid: bare,
affiliation: 'owner' });
const granted = await getSessionFeatures(c.jid);
assert.strictEqual(granted.recording, true, 'grant should hand over the actor features');
await granter.sendMucAdmin(r, { jid: bare,
affiliation: 'member' });
const restored = await getSessionFeatures(c.jid);
assert.ok(restored !== null, 'the token features should come back');
assert.strictEqual(restored.recording, false);
assert.strictEqual(restored.livestreaming, false);
assert.strictEqual(restored.transcription, false);
assert.strictEqual(restored['outbound-call'], false);
});
it('clears granted features when the token of the recipient has none', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const granter = await joinAsGranter(connect, r);
const token = mintAsapToken({ room: r.split('@')[0],
context: { user: { id: 'user2' } } });
const c = await connect({ params: { token } });
await c.joinRoom(r);
const bare = c.jid.split('/')[0];
await granter.sendMucAdmin(r, { jid: bare,
affiliation: 'owner' });
assert.ok(await getSessionFeatures(c.jid) !== null, 'grant should set features');
await granter.sendMucAdmin(r, { jid: bare,
affiliation: 'member' });
assert.strictEqual(await getSessionFeatures(c.jid), null,
'the session must hold no features again');
});
it('clears granted features for a participant without a token', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const granter = await joinAsGranter(connect, r);
const c = await connect();
await c.joinRoom(r);
const bare = c.jid.split('/')[0];
await granter.sendMucAdmin(r, { jid: bare,
affiliation: 'owner' });
assert.ok(await getSessionFeatures(c.jid) !== null, 'grant should set features');
await granter.sendMucAdmin(r, { jid: bare,
affiliation: 'member' });
assert.strictEqual(await getSessionFeatures(c.jid), null,
'the session must hold no features again');
});
it('restores the token features when owner is revoked twice in a row', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const granter = await joinAsGranter(connect, r);
const token = mintAsapToken({
room: r.split('@')[0],
context: {
user: { id: 'user3' },
features: { recording: false }
}
});
const c = await connect({ params: { token } });
await c.joinRoom(r);
const bare = c.jid.split('/')[0];
for (let i = 0; i < 2; i++) {
await granter.sendMucAdmin(r, { jid: bare,
affiliation: 'owner' });
await granter.sendMucAdmin(r, { jid: bare,
affiliation: 'member' });
}
const restored = await getSessionFeatures(c.jid);
assert.ok(restored !== null, 'the token features should come back');
assert.strictEqual(restored.recording, false);
});
it('blocks Jibri and Rayo requests from a recipient whose owner affiliation was revoked', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const granter = await joinAsGranter(connect, r);
const token = mintAsapToken({
room: r.split('@')[0],
context: {
user: { id: 'user4' },
features: { recording: false,
'outbound-call': false }
}
});
const c = await connect({ params: { token } });
await c.joinRoom(r);
assert.strictEqual(
await jibriStartErrorCondition(c, r),
'forbidden',
'a member with recording=false must not reach Jibri');
assert.strictEqual(
await rayoDialErrorCondition(c, r),
'forbidden',
'a member with outbound-call=false must not reach Jigasi');
const bare = c.jid.split('/')[0];
await granter.sendMucAdmin(r, { jid: bare,
affiliation: 'owner' });
assert.notStrictEqual(
await jibriStartErrorCondition(c, r),
'forbidden',
'an owner with a granted recording feature must reach Jibri');
await granter.sendMucAdmin(r, { jid: bare,
affiliation: 'member' });
assert.strictEqual(
await jibriStartErrorCondition(c, r),
'forbidden',
'the recipient must not keep the granted recording feature');
assert.strictEqual(
await rayoDialErrorCondition(c, r),
'forbidden',
'the recipient must not keep the granted outbound-call feature');
});
it('restores the token features when the recipient revokes its own owner affiliation', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const granter = await joinAsGranter(connect, r);
const token = mintAsapToken({
room: r.split('@')[0],
context: {
user: { id: 'user5' },
features: { recording: false }
}
});
const c = await connect({ params: { token } });
await c.joinRoom(r);
const bare = c.jid.split('/')[0];
await granter.sendMucAdmin(r, { jid: bare,
affiliation: 'owner' });
// As an owner, the recipient can set its own affiliation.
const reply = await c.sendMucAdmin(r, { jid: bare,
affiliation: 'member' });
assert.strictEqual(reply.attrs.type, 'result');
const restored = await getSessionFeatures(c.jid);
assert.ok(restored !== null, 'the token features should come back');
assert.strictEqual(restored.recording, false);
assert.strictEqual(
await jibriStartErrorCondition(c, r),
'forbidden',
'the recipient must not keep the granted recording feature');
});
for (const target of [ 'admin', 'outcast' ]) {
it(`restores the token features when owner changes to ${target}`, async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const granter = await joinAsGranter(connect, r);
const token = mintAsapToken({
room: r.split('@')[0],
context: {
user: { id: `user-${target}` },
features: { recording: false }
}
});
const c = await connect({ params: { token } });
await c.joinRoom(r);
const bare = c.jid.split('/')[0];
await granter.sendMucAdmin(r, { jid: bare,
affiliation: 'owner' });
assert.strictEqual((await getSessionFeatures(c.jid)).recording, true);
const reply = await granter.sendMucAdmin(r, { jid: bare,
affiliation: target });
assert.strictEqual(reply.attrs.type, 'result');
const restored = await getSessionFeatures(c.jid);
assert.ok(restored !== null, 'the token features should come back');
assert.strictEqual(restored.recording, false);
});
}
it('restores the token features of a recipient that is in a breakout room', async () => {
const BREAKOUT_MUC = 'breakout.conference.localhost';
const r = nextRoom();
const name = r.split('@')[0];
const focus = await joinWithFocus(r);
clients.push(focus);
// mod_muc_breakout_rooms finds the main room of the moderator through
// the ?room= query parameter.
const granter = await joinAsGranter(connect, r, { room: name });
const token = mintAsapToken({
room: name,
context: {
user: { id: 'user-breakout' },
features: { recording: false }
}
});
const c = await connect({ params: { token } });
await c.joinRoom(r);
const bare = c.jid.split('/')[0];
await granter.sendMucAdmin(r, { jid: bare,
affiliation: 'owner' });
assert.strictEqual((await getSessionFeatures(c.jid)).recording, true);
// Create a breakout room and move the recipient into it.
const parseJsonMessage = s => {
try {
return JSON.parse(s.getChild('json-message', PERMISSIONS_NS)?.getText());
} catch {
return null;
}
};
const update = granter.waitForMessage(s => {
const payload = parseJsonMessage(s);
return payload?.type === 'breakout_rooms'
&& payload?.event === 'features/breakout-rooms/update';
}, 6000);
await granter.sendBreakoutRoomsMessage(BREAKOUT_MUC, 'features/breakout-rooms/add', { subject: 'Group 1' });
const payload = parseJsonMessage(await update);
const breakoutJid = Object.values(payload.rooms).find(room => !room.isMainRoom)?.jid;
assert.ok(breakoutJid, 'the update must list the new breakout room');
// focus creates the breakout room, then the recipient moves into it.
await focus.joinRoom(breakoutJid, 'focus');
const breakoutPresence = await c.joinRoom(breakoutJid);
assert.notStrictEqual(breakoutPresence.attrs.type, 'error');
await c.leaveRoom(r);
// The recipient is no longer an occupant of the main room, but its
// affiliation there still decides its role in the breakout room.
const reply = await granter.sendMucAdmin(r, { jid: bare,
affiliation: 'member' });
assert.strictEqual(reply.attrs.type, 'result');
const restored = await getSessionFeatures(c.jid);
assert.ok(restored !== null, 'the token features should come back');
assert.strictEqual(restored.recording, false);
});
it('does not touch the features of a session that is not in this room', async () => {
const roomA = nextRoom();
const roomB = nextRoom();
clients.push(await joinWithFocus(roomA));
clients.push(await joinWithFocus(roomB));
const granter = await joinAsGranter(connect, roomA);
const token = mintAsapToken({
room: roomB.split('@')[0],
context: {
user: { id: 'user-other-room' },
features: { recording: false }
}
});
const other = await connect({ params: { token } });
await other.joinRoom(roomB);
const bare = other.jid.split('/')[0];
// The granter is only a moderator of room A, and the recipient is only
// ever an occupant of room B. An affiliation change on room A must not
// reach a session that shares the same bare JID in an unrelated room.
const reply = await granter.sendMucAdmin(roomA, { jid: bare,
affiliation: 'owner' });
assert.strictEqual(reply.attrs.type, 'result');
const features = await getSessionFeatures(other.jid);
assert.ok(features !== null, 'the token features must be unchanged');
assert.strictEqual(features.recording, false);
assert.strictEqual(
await jibriStartErrorCondition(other, roomB),
'forbidden',
'a cross-room grant must not authorize this session in its own room');
});
});
// ── session features via HTTP ─────────────────────────────────────────────
//
// Verify that jitsi_meet_context_features is set correctly on the Prosody
// session after joining. These assertions read the live session state (not
// the resource-bind snapshot) so they also capture side-effects from
// filter_stanza, which writes default_permissions onto the session for
// moderators who joined without token features.
describe('session features (via HTTP)', () => {
it('sets token features on session for moderator with features', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const token = mintAsapToken({
room: r.split('@')[0],
context: {
user: { moderator: true },
features: {
recording: false,
livestreaming: false
}
}
});
const c = await connect({ params: { token } });
await c.joinRoom(r);
const features = await getSessionFeatures(c.jid);
assert.strictEqual(features.recording, false);
assert.strictEqual(features.livestreaming, false);
});
it('sets token features on session for non-moderator with features', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const token = mintAsapToken({
room: r.split('@')[0],
context: {
user: { id: 'user1' },
features: {
recording: false,
transcription: true
}
}
});
const c = await connect({ params: { token } });
await c.joinRoom(r);
const features = await getSessionFeatures(c.jid);
assert.strictEqual(features.recording, false);
assert.strictEqual(features.transcription, true);
});
it('sets default permissions on session for moderator without token features', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const token = mintAsapToken({ room: r.split('@')[0],
context: { user: { moderator: true } } });
const c = await connect({ params: { token } });
await c.joinRoom(r);
const features = await getSessionFeatures(c.jid);
assert.ok(features !== null, 'features should be set by filter_stanza');
for (const key of DEFAULT_PERMISSION_KEYS) {
assert.strictEqual(features[key], true, `expected ${key}=true`);
}
});
it('sets no features on session for non-moderator without token features', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const token = mintAsapToken({ room: r.split('@')[0],
context: { user: { id: 'user1' } } });
const c = await connect({ params: { token } });
await c.joinRoom(r);
const features = await getSessionFeatures(c.jid);
assert.strictEqual(features, null);
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,169 @@
import assert from 'assert';
import { mintAsapToken, mintSystemToken } from './helpers/jwt.js';
import { endMeeting, getRoomState } from './helpers/test_observer.js';
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
const CONFERENCE = 'conference.localhost';
let _roomCounter = 0;
const room = () => `end-meeting-${++_roomCounter}@${CONFERENCE}`;
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Creates a live room with a focus client and one regular user.
* Returns { roomJid, clients } where clients must be disconnected in teardown.
*/
async function createRoom() {
const roomJid = room();
const focus = await joinWithFocus(roomJid);
const user = await createXmppClient();
await user.joinRoom(roomJid);
return { roomJid,
clients: [ focus, user ] };
}
/**
* Disconnects all provided clients.
*
* @param {Array} clients - Clients to disconnect.
* @returns {Promise<void>}
*/
async function disconnectAll(clients) {
await Promise.all(clients.map(c => c.disconnect()));
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('mod_muc_end_meeting', () => {
// ── Authentication ───────────────────────────────────────────────────────
//
// mod_muc_end_meeting uses prosody_password_public_key_repo_url as its key
// server — a separate key pair from the one used for login tokens
// (asap_key_server on VirtualHost "localhost"). A login token is therefore
// rejected because the system key server does not carry the login public key,
// and vice-versa.
describe('authentication', () => {
it('returns 401 when Authorization header is absent', async () => {
const { roomJid, clients } = await createRoom();
try {
const { status } = await endMeeting(roomJid, null, { omitAuth: true });
assert.strictEqual(status, 401);
} finally {
await disconnectAll(clients);
}
});
it('returns 401 when a login token is used instead of a system token', async () => {
// Login tokens are signed with the login key pair and verified via
// asap_key_server. The system key server does not carry the login
// public key, so verification fails and Prosody returns 401.
const { roomJid, clients } = await createRoom();
try {
const loginToken = mintAsapToken({ room: '*' });
const { status } = await endMeeting(roomJid, loginToken);
assert.strictEqual(status, 401,
'login token must be rejected by the system key server');
} finally {
await disconnectAll(clients);
}
});
it('returns 401 when token is expired', async () => {
const { roomJid, clients } = await createRoom();
try {
const token = mintSystemToken({}, { expired: true });
const { status } = await endMeeting(roomJid, token);
assert.strictEqual(status, 401);
} finally {
await disconnectAll(clients);
}
});
});
// ── Parameter validation ─────────────────────────────────────────────────
describe('parameter validation', () => {
it('returns 400 when conference param is missing', async () => {
// endMeeting always sets conference; call fetch directly to omit it.
const token = mintSystemToken();
const res = await fetch('http://localhost:5280/end-meeting', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` }
});
assert.strictEqual(res.status, 400);
});
});
// ── Room termination ─────────────────────────────────────────────────────
describe('room termination', () => {
it('returns 404 when conference does not exist', async () => {
const token = mintSystemToken();
const { status } = await endMeeting(`nonexistent-room@${CONFERENCE}`, token);
assert.strictEqual(status, 404);
});
it('returns 200 and destroys the room', async () => {
const { roomJid, clients } = await createRoom();
try {
// Verify room exists before ending it.
const before = await getRoomState(roomJid);
assert.ok(before, 'room must exist before end-meeting call');
const token = mintSystemToken();
const { status } = await endMeeting(roomJid, token);
assert.strictEqual(status, 200);
// Room must be gone from Prosody's internal MUC state.
const after = await getRoomState(roomJid);
assert.strictEqual(after, null, 'room must be destroyed after end-meeting');
} finally {
// Clients were kicked by room destruction; disconnect() is a no-op
// or handles the already-closed connection gracefully.
await disconnectAll(clients);
}
});
it('returns 200 with silent-reconnect=true and destroys the room', async () => {
const { roomJid, clients } = await createRoom();
try {
const token = mintSystemToken();
const { status } = await endMeeting(roomJid, token, { silentReconnect: true });
assert.strictEqual(status, 200);
const after = await getRoomState(roomJid);
assert.strictEqual(after, null, 'room must be destroyed after silent-reconnect end-meeting');
} finally {
await disconnectAll(clients);
}
});
});
});

View File

@@ -0,0 +1,66 @@
import assert from 'assert';
import { createTestContext } from './helpers/test_context.js';
import { isAvailablePresence } from './helpers/xmpp_utils.js';
// Uses the isolated "internal" MUC component which has muc_filter_access loaded
// with muc_filter_whitelist = { "whitelist.localhost" }.
// No muc_meeting_id or muc_max_occupants are loaded here, so no focus join is
// needed and there is no occupant limit.
const INTERNAL = 'conference-internal.localhost';
let _roomCounter = 0;
const room = () => `filter-access-${++_roomCounter}@${INTERNAL}`;
describe('mod_muc_filter_access', () => {
let ctx;
beforeEach(() => {
ctx = createTestContext();
});
afterEach(() => ctx.cleanup());
it('allows a client from a whitelisted domain to join', async () => {
const r = room();
const wl = await ctx.connectWhitelisted();
const presence = await wl.joinRoom(r);
assert.ok(isAvailablePresence(presence),
'whitelisted client must be allowed to join');
});
it('blocks a client from a non-whitelisted domain', async () => {
const r = room();
// First create the room with a whitelisted client so there is a room to join.
const wl = await ctx.connectWhitelisted();
await wl.joinRoom(r);
// A regular client (domain: localhost) is not in the whitelist.
// Its presence is silently dropped — joinRoom will time out.
const c = await ctx.connect();
await assert.rejects(
() => c.joinRoom(r, undefined, { timeout: 1000 }),
/Timeout/,
'non-whitelisted client join must time out (presence dropped)'
);
});
it('blocks a non-whitelisted client even when it creates the room', async () => {
const r = room();
const c = await ctx.connect();
// The client sends the first presence to a non-existing room.
// muc_filter_access drops it before MUC can process it, so the room
// is never created and no presence is returned.
await assert.rejects(
() => c.joinRoom(r, undefined, { timeout: 1000 }),
/Timeout/,
'non-whitelisted client must not be able to create a room'
);
});
});

View File

@@ -0,0 +1,388 @@
import { xml } from '@xmpp/client';
import assert from 'assert';
import {
enableLobby, getRoomParticipants, setAffiliation, setRoomMaxOccupants, setSessionContext
} from './helpers/test_observer.js';
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
const CONFERENCE = 'conference.localhost';
let _roomCounter = 0;
const room = () => `flip-${++_roomCounter}@${CONFERENCE}`;
// Shared JWT user IDs used across tests.
const USER_A_ID = 'flip-test-user-a';
const USER_B_ID = 'flip-test-user-b';
/**
* Returns the default MUC nick for a client. Matches the logic in xmpp_client.js:
* first 8 characters of the JID local part, which is also what mod_muc_resource_validate
* enforces in anonymous_strict mode.
*
* @param {string} jid full JID, e.g. 'abc12345def@localhost/resource'
*/
function defaultNick(jid) {
return jid.split('@')[0].slice(0, 8);
}
/** Extracts the occupant nick from a MUC join presence (the resource part of from=). */
function nickFrom(presence) {
return presence.attrs.from.split('/')[1];
}
describe('mod_muc_flip', () => {
let clients;
beforeEach(() => {
clients = [];
});
afterEach(async () => {
await Promise.all(clients.map(c => c.disconnect()));
});
/**
* Creates and connects an XMPP client, tracking it for cleanup.
*
* @returns {Promise<object>}
*/
async function connect() {
const c = await createXmppClient();
clients.push(c);
return c;
}
/**
* Joins a room as focus and tracks the client for cleanup.
*
* @param {string} roomJid - Room JID to join.
* @returns {Promise<object>}
*/
async function focusJoin(roomJid) {
const c = await joinWithFocus(roomJid);
clients.push(c);
return c;
}
// -------------------------------------------------------------------------
// flip_device tag stripping
// -------------------------------------------------------------------------
describe('flip_device tag stripping', () => {
it('strips flip_device tag from guest (no JWT context)', async () => {
const r = room();
await focusJoin(r);
const observer = await connect();
await observer.joinRoom(r);
// Connect guest before joining so we can predict their nick.
const guest = await connect();
// No setSessionContext — guest has no JWT context.
// waitForPresenceFrom is exact-JID so it won't match stale presences
// from other occupants that arrived during the observer's own join.
const joinBroadcast = observer.waitForPresenceFrom(`${r}/${defaultNick(guest.jid)}`);
await guest.joinRoom(r, null, { extensions: [ xml('flip_device') ] });
const presence = await joinBroadcast;
assert.ok(
!presence.getChild('flip_device'),
'flip_device tag must be stripped from guest join presence'
);
});
it('strips flip_device tag when flip feature is disabled in JWT', async () => {
const r = room();
await focusJoin(r);
await setRoomMaxOccupants(r, 10);
const clientA = await connect();
await setSessionContext(clientA.jid, USER_A_ID, { flip: false });
await clientA.joinRoom(r);
const observer = await connect();
await observer.joinRoom(r);
const clientA2 = await connect();
await setSessionContext(clientA2.jid, USER_A_ID, { flip: false });
const joinBroadcast = observer.waitForPresenceFrom(
`${r}/${defaultNick(clientA2.jid)}`
);
await clientA2.joinRoom(r, null, { extensions: [ xml('flip_device') ] });
const presence = await joinBroadcast;
assert.ok(
!presence.getChild('flip_device'),
'flip_device tag must be stripped when flip feature is disabled'
);
const state = await getRoomParticipants(r);
assert.ok(
!state.kicked_participant_nick,
'no participant should have been kicked'
);
});
it('strips flip_device tag when the user is not already in the room', async () => {
const r = room();
await focusJoin(r);
const observer = await connect();
await observer.joinRoom(r);
// USER_B_ID has never joined this room — no entry in participants_details.
const clientB = await connect();
await setSessionContext(clientB.jid, USER_B_ID, { flip: true });
const joinBroadcast = observer.waitForPresenceFrom(
`${r}/${defaultNick(clientB.jid)}`
);
await clientB.joinRoom(r, null, { extensions: [ xml('flip_device') ] });
const presence = await joinBroadcast;
assert.ok(
!presence.getChild('flip_device'),
'flip_device tag must be stripped when user has no existing device in room'
);
const state = await getRoomParticipants(r);
assert.ok(!state.kicked_participant_nick, 'no participant should have been kicked');
});
}); // flip_device tag stripping
// -------------------------------------------------------------------------
// Successful flip
// -------------------------------------------------------------------------
describe('flip', () => {
it('kicks the old device when the same user joins from a new device', async () => {
const r = room();
await focusJoin(r);
const device1 = await connect();
await setSessionContext(device1.jid, USER_A_ID, { flip: true });
const d1Presence = await device1.joinRoom(r);
const d1Nick = nickFrom(d1Presence);
// Start watching for device1 to be kicked before device2 joins,
// so the unavailable presence is not missed if it arrives quickly.
const kickedPromise = device1.waitForPresenceFrom(
`${r}/${d1Nick}`, { type: 'unavailable' }
);
const device2 = await connect();
await setSessionContext(device2.jid, USER_A_ID, { flip: true });
await device2.joinRoom(r, null, { extensions: [ xml('flip_device') ] });
const kickedPresence = await kickedPromise;
assert.equal(
kickedPresence.attrs.type, 'unavailable',
'device1 must receive an unavailable presence (kicked)'
);
});
it('adds flip_device tag to the kicked occupant unavailable presence', async () => {
const r = room();
await focusJoin(r);
await setRoomMaxOccupants(r, 10);
const device1 = await connect();
await setSessionContext(device1.jid, USER_A_ID, { flip: true });
const d1Presence = await device1.joinRoom(r);
const d1Nick = nickFrom(d1Presence);
// Observer receives the broadcast of the kick so we can inspect the stanza.
const observer = await connect();
await observer.joinRoom(r);
const kickBroadcastPromise = observer.waitForPresenceFrom(
`${r}/${d1Nick}`, { type: 'unavailable' }
);
const device2 = await connect();
await setSessionContext(device2.jid, USER_A_ID, { flip: true });
await device2.joinRoom(r, null, { extensions: [ xml('flip_device') ] });
const kickedPresence = await kickBroadcastPromise;
assert.ok(
kickedPresence.getChild('flip_device'),
'unavailable presence for the kicked device must contain <flip_device/>'
);
});
it('transfers moderator role to flip device and tags the kicked occupant presence', async () => {
const r = room();
await focusJoin(r);
await setRoomMaxOccupants(r, 10);
const device1 = await connect();
await setSessionContext(device1.jid, USER_A_ID, { flip: true });
const d1Presence = await device1.joinRoom(r);
const d1Nick = nickFrom(d1Presence);
// Promote device1 to owner so it holds moderator role.
await setAffiliation(r, device1.jid.split('/')[0], 'owner');
// Watch for device1's own kick before device2 joins to avoid missing it.
const d1KickPromise = device1.waitForPresenceFrom(
`${r}/${d1Nick}`, { type: 'unavailable' }
);
const observer = await connect();
const observerJoinPresence = await observer.joinRoom(r);
const observerNick = nickFrom(observerJoinPresence);
const d1FlipTagPromise = observer.waitForPresenceFrom(
`${r}/${d1Nick}`, { type: 'unavailable' }
);
const device2 = await connect();
await setSessionContext(device2.jid, USER_A_ID, { flip: true });
await device2.joinRoom(r, null, { extensions: [ xml('flip_device') ] });
await d1KickPromise;
const d1KickPresence = await d1FlipTagPromise;
assert.ok(
d1KickPresence.getChild('flip_device'),
'kicked device presence must contain flip_device tag'
);
// Verify device2 has moderator role by performing a moderator-only action:
// kicking the observer. Only a moderator can set role='none'.
const observerKickPromise = observer.waitForPresenceFrom(
`${r}/${observerNick}`, { type: 'unavailable' }
);
const kickResponse = await device2.sendMucAdmin(r, {
nick: observerNick,
role: 'none'
});
assert.notEqual(kickResponse.attrs.type, 'error', 'device2 must have moderator role');
await observerKickPromise;
});
}); // flip
// -------------------------------------------------------------------------
// Lobby interactions
// -------------------------------------------------------------------------
describe('lobby interactions', () => {
it('flip device bypasses lobby when first device is in main room', async () => {
const r = room();
await focusJoin(r);
// device1 joins before lobby is enabled.
const device1 = await connect();
await setSessionContext(device1.jid, USER_A_ID, { flip: true });
await device1.joinRoom(r);
await enableLobby(r);
// Start listening for device1's kick before device2 joins.
const d1KickedPromise = device1.waitForPresence(p => p.attrs.type === 'unavailable');
const device2 = await connect();
await setSessionContext(device2.jid, USER_A_ID, { flip: true });
// device2 joins with flip_device — mod_muc_flip grants it member affiliation
// in muc-occupant-pre-join, which causes the lobby module to let it through.
// A displayName is required because the lobby checks for it before the affiliation.
const joinPresence = await device2.joinRoom(
r, null, {
displayName: 'Device Two',
extensions: [ xml('flip_device') ]
});
assert.notEqual(
joinPresence.attrs.type, 'error',
'flip device must bypass lobby when first device is in main room'
);
// The flip also kicks device1 from the main room.
await d1KickedPromise;
});
it('requires first device to be in main room for flip to proceed', async () => {
const r = room();
await focusJoin(r);
await enableLobby(r);
// device1 joins but does not reach the main room.
const device1 = await connect();
await setSessionContext(device1.jid, USER_A_ID, { flip: true });
const d1JoinResult = await device1.joinRoom(r, null, { displayName: 'Device One' });
assert.equal(
d1JoinResult.attrs.type, 'error',
'device1 must not reach the main room (precondition)'
);
// device2 joins with flip_device and the same JWT user id.
// No occupant with that id is in the main room, so the flip tag
// is ignored and device2 is treated as a regular join.
const device2 = await connect();
await setSessionContext(device2.jid, USER_A_ID, { flip: true });
const d2JoinResult = await device2.joinRoom(r, null, {
displayName: 'Device Two',
extensions: [ xml('flip_device') ]
});
assert.equal(
d2JoinResult.attrs.type, 'error',
'flip must not proceed when no matching occupant is in the main room'
);
});
}); // lobby interactions
});

View File

@@ -0,0 +1,113 @@
import assert from 'assert';
import { prosodyShell } from './helpers/prosody_shell.js';
import { createTestContext } from './helpers/test_context.js';
import { clearEvents, getEvents, getRoomState } from './helpers/test_observer.js';
const CONFERENCE = 'conference.localhost';
let _roomCounter = 0;
const room = () => `hide-all-${++_roomCounter}@${CONFERENCE}`;
describe('mod_muc_hide_all', () => {
let ctx;
beforeEach(() => {
ctx = createTestContext();
});
afterEach(() => ctx.cleanup());
// -------------------------------------------------------------------------
// Module DISABLED — Prosody default behaviour applies
// -------------------------------------------------------------------------
describe('when module is disabled', () => {
before(async () => {
// Option I: use prosodyctl shell to unload the module at runtime.
await prosodyShell('module:unload("muc_hide_all", "conference.localhost")');
});
after(async () => {
await prosodyShell('module:load("muc_hide_all", "conference.localhost")');
});
it('non-occupant disco#info to an existing room succeeds', async () => {
const r = room();
await ctx.connectFocus(r);
const owner = await ctx.connect();
const stranger = await ctx.connect();
await owner.joinRoom(r);
const iq = await stranger.sendDiscoInfo(r);
assert.equal(iq.attrs.type, 'result',
'expected IQ result — Prosody default allows disco#info from non-occupants');
});
});
// -------------------------------------------------------------------------
// Module ENABLED (default state)
// -------------------------------------------------------------------------
describe('when module is enabled', () => {
beforeEach(() => clearEvents());
it('new room is set to hidden', async () => {
const r = room();
await ctx.connectFocus(r);
const owner = await ctx.connect();
await owner.joinRoom(r);
// Option II: query Prosody's internal room state through mod_test_observer HTTP.
const state = await getRoomState(r);
assert.ok(state, 'room should exist in Prosody');
assert.equal(state.hidden, true, 'room should be hidden after creation');
});
it('muc-room-pre-create event is fired when room is created', async () => {
const r = room();
await ctx.connectFocus(r);
const events = await getEvents();
const preCreate = events.find(e => e.event === 'muc-room-pre-create' && e.room === r);
assert.ok(preCreate,
`expected muc-room-pre-create event for room ${r}`);
});
it('non-occupant disco#info returns <forbidden>', async () => {
const r = room();
await ctx.connectFocus(r);
const owner = await ctx.connect();
const stranger = await ctx.connect();
await owner.joinRoom(r);
const iq = await stranger.sendDiscoInfo(r);
assert.equal(iq.attrs.type, 'error');
assert.ok(
iq.getChild('error')?.getChild('forbidden'),
'expected <forbidden/> in error stanza'
);
});
it('occupant disco#info succeeds', async () => {
const r = room();
await ctx.connectFocus(r);
const occupant = await ctx.connect();
await occupant.joinRoom(r);
const iq = await occupant.sendDiscoInfo(r);
assert.equal(iq.attrs.type, 'result');
});
});
});

View File

@@ -0,0 +1,270 @@
import assert from 'assert';
import { mintAsapToken, mintSystemToken } from './helpers/jwt.js';
import { inviteJigasi } from './helpers/test_observer.js';
import { createXmppClient, joinWithFocus, joinWithJigasi } from './helpers/xmpp_client.js';
const CONFERENCE = 'conference.localhost';
let _roomCounter = 0;
const room = () => `jigasi-invite-${++_roomCounter}@${CONFERENCE}`;
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Creates a room with a focus participant.
*
* @returns {Promise<{roomJid: string, focus: object}>}
*/
async function createRoom() {
const roomJid = room();
const focus = await joinWithFocus(roomJid);
return { roomJid,
focus };
}
/**
* 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_muc_jigasi_invite', () => {
// ── Authentication ───────────────────────────────────────────────────────
//
// mod_muc_jigasi_invite uses prosody_password_public_key_repo_url as its
// key server — a separate key pair from login tokens (asap_key_server).
// Auth failures return 401.
describe('authentication', () => {
it('returns 401 when Authorization header is absent', async () => {
const { roomJid, focus } = await createRoom();
try {
const { status } = await inviteJigasi(roomJid, '+15551234567', null, { omitAuth: true });
assert.strictEqual(status, 401);
} finally {
await disconnectAll(focus);
}
});
it('returns 401 when a login token is used instead of a system token', async () => {
const { roomJid, focus } = await createRoom();
try {
const loginToken = mintAsapToken({ room: '*' });
const { status } = await inviteJigasi(roomJid, '+15551234567', loginToken);
assert.strictEqual(status, 401,
'login token must be rejected by the system key server');
} finally {
await disconnectAll(focus);
}
});
it('returns 401 when token is expired', async () => {
const { roomJid, focus } = await createRoom();
try {
const token = mintSystemToken({}, { expired: true });
const { status } = await inviteJigasi(roomJid, '+15551234567', token);
assert.strictEqual(status, 401);
} finally {
await disconnectAll(focus);
}
});
});
// ── Parameter validation ─────────────────────────────────────────────────
describe('parameter validation', () => {
it('returns 400 when Content-Type is not application/json', async () => {
const token = mintSystemToken();
const res = await fetch(
'http://localhost:5280/invite-jigasi',
{
method: 'POST',
headers: {
'Content-Type': 'text/plain',
Authorization: `Bearer ${token}`
},
body: '{"conference":"r@conference.localhost","phoneNo":"+1555"}'
}
);
assert.strictEqual(res.status, 400);
});
it('returns 400 when body is empty', async () => {
const token = mintSystemToken();
const res = await fetch(
'http://localhost:5280/invite-jigasi',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: ''
}
);
assert.strictEqual(res.status, 400);
});
it('returns 400 when conference is missing', async () => {
const token = mintSystemToken();
const res = await fetch(
'http://localhost:5280/invite-jigasi',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ phoneNo: '+15551234567' })
}
);
assert.strictEqual(res.status, 400);
});
it('returns 400 when phoneNo is missing', async () => {
const token = mintSystemToken();
const res = await fetch(
'http://localhost:5280/invite-jigasi',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ conference: `r@${CONFERENCE}` })
}
);
assert.strictEqual(res.status, 400);
});
});
// ── Brewery room ─────────────────────────────────────────────────────────
describe('brewery room', () => {
it('returns 404 when the Jigasi brewery room does not exist', async () => {
const { roomJid, focus } = await createRoom();
try {
const token = mintSystemToken();
// The brewery room (jigasibrewery@internal.auth.localhost) is
// never created in the test environment, so the module returns 404.
const { status } = await inviteJigasi(roomJid, '+15551234567', token);
assert.strictEqual(status, 404);
} finally {
await disconnectAll(focus);
}
});
});
// ── Jigasi invite ────────────────────────────────────────────────────────
//
// These tests require a simulated Jigasi instance in the brewery room.
// joinWithJigasi() creates an anonymous client, joins the brewery with a
// colibri stats presence (supports_sip=true, stress_level) so the module
// can select it for dial-out.
describe('jigasi invite', () => {
const BREWERY = 'jigasibrewery@internal.auth.localhost';
it('returns 404 when brewery occupant has no stats element', async () => {
// Bug: stats_child:children() crashed when stats element absent.
// Fixed: occupants without stats are now skipped with a warn log.
const { roomJid, focus } = await createRoom();
// Join brewery without any colibri stats extensions.
const noStats = await createXmppClient();
await noStats.joinRoom(BREWERY);
try {
const { status } = await inviteJigasi(roomJid, '+15550000001', mintSystemToken());
assert.strictEqual(status, 404,
'must return 404 (no eligible Jigasi) not crash');
} finally {
await disconnectAll(focus, noStats);
}
});
it('returns 404 when no SIP-capable Jigasi is in the brewery', async () => {
// Bug: log line accessed least_stressed_jigasi_occupant.jid before
// the nil check, crashing when all occupants have supports_sip=false.
// Fixed: nil check moved before the log.
const { roomJid, focus } = await createRoom();
const nonSip = await joinWithJigasi(BREWERY, undefined, { supportsSip: false });
try {
const { status } = await inviteJigasi(roomJid, '+15550000002', mintSystemToken());
assert.strictEqual(status, 404,
'must return 404 (no SIP Jigasi) not crash');
} finally {
await disconnectAll(focus, nonSip);
}
});
it('sends a Rayo dial IQ to the Jigasi and returns 200', async () => {
const { roomJid, focus } = await createRoom();
const jigasi = await joinWithJigasi(BREWERY);
try {
const token = mintSystemToken();
const phoneNo = '+15551234567';
const { status } = await inviteJigasi(roomJid, phoneNo, token);
assert.strictEqual(status, 200);
// The module sends a Rayo <dial> IQ to the selected Jigasi.
const iq = await jigasi.waitForIq(
s => s.getChild('dial', 'urn:xmpp:rayo:1'));
const dial = iq.getChild('dial', 'urn:xmpp:rayo:1');
assert.ok(dial, 'IQ must contain a Rayo dial element');
assert.strictEqual(iq.attrs.type, 'set');
assert.strictEqual(dial.attrs.to, phoneNo,
'dial target must be the requested phone number');
// The JvbRoomName header carries the conference JID so Jigasi
// knows which room to join after the call is established.
const header = dial.getChild('header', 'urn:xmpp:rayo:1');
assert.ok(header, 'dial must include a JvbRoomName header');
assert.strictEqual(header.attrs.name, 'JvbRoomName');
assert.strictEqual(header.attrs.value, roomJid,
'JvbRoomName must equal the conference room JID');
} finally {
await disconnectAll(focus, jigasi);
}
});
});
});

View File

@@ -0,0 +1,216 @@
import assert from 'assert';
import { mintAsapToken, mintSystemToken } from './helpers/jwt.js';
import { getRoomState, kickParticipant } from './helpers/test_observer.js';
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
const CONFERENCE = 'conference.localhost';
let _roomCounter = 0;
const room = () => `kick-test-${++_roomCounter}@${CONFERENCE}`;
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Creates a room with a focus participant.
*
* @returns {Promise<{roomJid: string, focus: object}>}
*/
async function createRoom() {
const roomJid = room();
const focus = await joinWithFocus(roomJid);
return { roomJid,
focus };
}
/**
* 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_muc_kick_participant', () => {
// ── Authentication ───────────────────────────────────────────────────────
//
// mod_muc_kick_participant uses prosody_password_public_key_repo_url as its
// key server — a separate key pair from login tokens (asap_key_server).
// Auth failures return 403 (not 401).
describe('authentication', () => {
it('returns 403 when Authorization header is absent', async () => {
const { roomJid, focus } = await createRoom();
try {
const { status } = await kickParticipant(roomJid, 'focus', null, { omitAuth: true });
assert.strictEqual(status, 403);
} finally {
await disconnectAll(focus);
}
});
it('returns 403 when a login token is used instead of a system token', async () => {
const { roomJid, focus } = await createRoom();
try {
const loginToken = mintAsapToken({ room: '*' });
const { status } = await kickParticipant(roomJid, 'focus', loginToken);
assert.strictEqual(status, 403,
'login token must be rejected by the system key server');
} finally {
await disconnectAll(focus);
}
});
it('returns 403 when token is expired', async () => {
const { roomJid, focus } = await createRoom();
try {
const token = mintSystemToken({}, { expired: true });
const { status } = await kickParticipant(roomJid, 'focus', token);
assert.strictEqual(status, 403);
} finally {
await disconnectAll(focus);
}
});
});
// ── Parameter validation ─────────────────────────────────────────────────
describe('parameter validation', () => {
it('returns 400 when Content-Type is not application/json', async () => {
const token = mintSystemToken();
const res = await fetch(
'http://localhost:5280/kick-participant?room=kick-test-0',
{
method: 'PUT',
headers: {
'Content-Type': 'text/plain',
Authorization: `Bearer ${token}`
},
body: '{"participantId":"focus"}'
}
);
assert.strictEqual(res.status, 400);
});
it('returns 400 when body is empty', async () => {
const token = mintSystemToken();
const res = await fetch(
'http://localhost:5280/kick-participant?room=kick-test-0',
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: ''
}
);
assert.strictEqual(res.status, 400);
});
it('returns 400 when neither participantId nor number is provided', async () => {
const token = mintSystemToken();
const res = await fetch(
'http://localhost:5280/kick-participant?room=kick-test-0',
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: '{}'
}
);
assert.strictEqual(res.status, 400);
});
it('returns 400 when both participantId and number are provided', async () => {
const token = mintSystemToken();
const res = await fetch(
'http://localhost:5280/kick-participant?room=kick-test-0',
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ participantId: 'focus',
number: '+1234' })
}
);
assert.strictEqual(res.status, 400);
});
});
// ── Kick behaviour ───────────────────────────────────────────────────────
describe('kick behaviour', () => {
it('returns 404 when room does not exist', async () => {
const token = mintSystemToken();
const { status } = await kickParticipant(
`nonexistent-room@${CONFERENCE}`, 'anyone', token
);
assert.strictEqual(status, 404);
});
it('returns 404 when participant is not in the room', async () => {
const { roomJid, focus } = await createRoom();
try {
const token = mintSystemToken();
const { status } = await kickParticipant(roomJid, 'no-such-nick', token);
assert.strictEqual(status, 404);
} finally {
await disconnectAll(focus);
}
});
it('returns 200 and removes the participant', async () => {
const { roomJid, focus } = await createRoom();
const user = await createXmppClient();
await user.joinRoom(roomJid);
const nick = user.nick;
try {
const token = mintSystemToken();
const { status } = await kickParticipant(roomJid, nick, token);
assert.strictEqual(status, 200);
// Verify the user was actually removed — only focus remains.
const state = await getRoomState(roomJid);
assert.strictEqual(state.occupant_count, 1,
'kicked user should no longer be in the room');
} finally {
await disconnectAll(focus, user);
}
});
});
});

View File

@@ -0,0 +1,380 @@
import { xml } from '@xmpp/client';
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';
const LIMIT = 3;
const ERROR_TEXT = 'The message limit for the room has been reached. Messaging is now disabled.';
let _counter = 0;
const nextRoom = () => `limit-${++_counter}@${CONFERENCE}`;
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Connects an anonymous client to the main VirtualHost with ?room= set so that
* session.jitsi_web_query_room is populated — required by mod_muc_limit_messages
* to locate the room via get_room_by_name_and_subdomain.
*
* @param {string} roomJid Full room JID, e.g. 'name@conference.localhost'
*/
function connectAnon(roomJid) {
const roomName = roomJid.split('@')[0];
return createXmppClient({ params: { room: roomName } });
}
/**
* Connects a token-authenticated client. session.auth_token is set by Prosody's
* auth_token module on the VirtualHost, and read by mod_muc_limit_messages when
* muc_limit_messages_check_token = true to permanently lift the room cap.
*
* @param {string} roomJid
* @param {object} [contextUser] JWT context.user claims; moderator: true → owner
*/
function connectWithToken(roomJid, { contextUser } = {}) {
const roomName = roomJid.split('@')[0];
const token = mintAsapToken({ room: roomName,
context: contextUser ? { user: contextUser } : {} });
return createXmppClient({ params: { room: roomName,
token } });
}
const connectModerator = roomJid => connectWithToken(roomJid, { contextUser: { moderator: true } });
/**
* Sends LIMIT messages (all within the cap) then one more that triggers the
* block. Returns the reply stanza for the triggering message.
*/
async function exhaustAndTrigger(sender, roomJid) {
for (let i = 0; i < LIMIT; i++) {
await sender.sendGroupchat(roomJid, `msg ${i + 1}`);
}
return sender.sendGroupchat(roomJid, 'trigger');
}
/**
* Sets groupChatRestricted on the room and waits for the metadata broadcast
* to confirm Prosody has processed it before returning.
*/
async function restrictChat(mod, roomJid) {
mod.sendMetadataUpdate(METADATA_COMPONENT, roomJid, 'permissions', { groupChatRestricted: true });
await mod.waitForMessage(s => {
const j = s.getChild('json-message', JITMEET_NS);
if (!j) {
return false;
}
try {
return JSON.parse(j.getText())?.metadata?.permissions?.groupChatRestricted === true;
} catch {
return false;
}
}, 3000);
}
/**
* Clears groupChatRestricted and waits for the metadata broadcast to confirm.
*/
async function unrestrictChat(mod, roomJid) {
mod.sendMetadataUpdate(METADATA_COMPONENT, roomJid, 'permissions', { groupChatRestricted: false });
await mod.waitForMessage(s => {
const j = s.getChild('json-message', JITMEET_NS);
if (!j) {
return false;
}
try {
return JSON.parse(j.getText())?.metadata?.permissions?.groupChatRestricted === false;
} catch {
return false;
}
}, 3000);
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('mod_muc_limit_messages', () => {
const clients = [];
afterEach(async () => {
await Promise.all(clients.map(c => c.disconnect()));
clients.length = 0;
});
// ── within limit ──────────────────────────────────────────────────────────
it('messages within the cap are delivered', async () => {
const r = nextRoom();
const focus = await joinWithFocus(r);
const sender = await connectAnon(r);
clients.push(focus, sender);
await sender.joinRoom(r);
for (let i = 0; i < LIMIT; i++) {
const reply = await sender.sendGroupchat(r, `message ${i + 1}`);
assert.notEqual(reply.attrs.type, 'error',
`message ${i + 1} must not be blocked (within cap)`);
}
});
// ── at cap boundary ───────────────────────────────────────────────────────
it('first message over cap is rejected with <not-allowed/>', async () => {
const r = nextRoom();
const focus = await joinWithFocus(r);
const sender = await connectAnon(r);
clients.push(focus, sender);
await sender.joinRoom(r);
const reply = await exhaustAndTrigger(sender, r);
assert.strictEqual(reply.attrs.type, 'error',
'triggering message must be rejected');
assert.ok(
reply.getChild('error')?.getChild('not-allowed'),
'error condition must be <not-allowed/>'
);
});
it('room broadcast is sent when cap is first hit', async () => {
const r = nextRoom();
const focus = await joinWithFocus(r);
const sender = await connectAnon(r);
// observer must join before the limit fires so it is an occupant
// when the broadcast is sent; muc_max_occupants=2 so focus (exempt) +
// sender + observer = 2 non-whitelisted users, within the limit.
const observer = await connectAnon(r);
clients.push(focus, sender, observer);
await sender.joinRoom(r);
await observer.joinRoom(r);
for (let i = 0; i < LIMIT; i++) {
await sender.sendGroupchat(r, `msg ${i + 1}`);
}
const [ senderReply, announcement ] = await Promise.all([
sender.sendGroupchat(r, 'trigger'),
observer.waitForMessage(
s => s.attrs.type === 'groupchat'
&& s.getChildText('body') === ERROR_TEXT,
5000
)
]);
assert.strictEqual(senderReply.attrs.type, 'error');
assert.ok(announcement, 'observer must receive the room announcement');
});
// ── after cap is set ──────────────────────────────────────────────────────
it('messages after cap remain blocked', async () => {
const r = nextRoom();
const focus = await joinWithFocus(r);
const sender = await connectAnon(r);
clients.push(focus, sender);
await sender.joinRoom(r);
await exhaustAndTrigger(sender, r);
for (let i = 0; i < 2; i++) {
const reply = await sender.sendGroupchat(r, `post-cap ${i + 1}`);
assert.strictEqual(reply.attrs.type, 'error',
`message ${i + 1} after cap must remain blocked`);
}
});
// ── message type exemptions ───────────────────────────────────────────────
it('body-less groupchat (no json) counts toward the cap and is blocked', async () => {
const r = nextRoom();
const focus = await joinWithFocus(r);
const sender = await connectAnon(r);
clients.push(focus, sender);
await sender.joinRoom(r);
// Fill LIMIT-1 slots with regular messages.
for (let i = 0; i < LIMIT - 1; i++) {
await sender.sendGroupchat(r, `msg ${i + 1}`);
}
// Body-less groupchat with no json: not exempt, consumes the last slot.
const bodyless = await sender.sendGroupchat(r, undefined);
assert.notEqual(bodyless.attrs.type, 'error',
'body-less groupchat within cap must pass through');
// Next message triggers the block.
const over = await sender.sendGroupchat(r, 'now blocked');
assert.strictEqual(over.attrs.type, 'error',
'message after body-less consumed last slot must be blocked');
});
it('answer-poll json-message passes through even after cap is reached', async () => {
const r = nextRoom();
const focus = await joinWithFocus(r);
const sender = await connectAnon(r);
clients.push(focus, sender);
await sender.joinRoom(r);
await exhaustAndTrigger(sender, r);
const pollEl = xml('json-message', { xmlns: JITMEET_NS },
JSON.stringify({ type: 'answer-poll',
pollId: '1',
answers: [] }));
const reply = await sender.sendGroupchat(r, undefined, [ pollEl ]);
assert.notEqual(reply.attrs.type, 'error',
'answer-poll body-less message must pass through regardless of cap');
});
// ── check_token × token_verification interaction ──────────────────────────
//
// muc_limit_messages_check_token = true on conference.localhost.
// A sender whose session.auth_token is set (set by mod_auth_token when the
// client connects with a valid JWT) permanently disables the cap for that room
// by setting room._muc_messages_limit = false, overriding even a cap already
// set to true.
it('authenticated sender lifts the cap permanently', async () => {
const r = nextRoom();
const focus = await joinWithFocus(r);
const anon = await connectAnon(r);
const authed = await connectWithToken(r);
clients.push(focus, anon, authed);
await anon.joinRoom(r);
await authed.joinRoom(r);
// Anonymous sender exhausts and triggers the cap.
await exhaustAndTrigger(anon, r);
// Authenticated sender (session.auth_token set by token_verification)
// sets room._muc_messages_limit = false.
const authedReply = await authed.sendGroupchat(r, 'auth unlocks');
assert.notEqual(authedReply.attrs.type, 'error',
'authenticated sender must pass through');
// Cap is now lifted: anonymous sender can send freely again.
const anonReply = await anon.sendGroupchat(r, 'now unlimited');
assert.notEqual(anonReply.attrs.type, 'error',
'cap must be permanently lifted after authenticated sender');
});
it('unauthenticated sender is still capped when check_token is enabled', async () => {
const r = nextRoom();
const focus = await joinWithFocus(r);
const anon = await connectAnon(r);
clients.push(focus, anon);
await anon.joinRoom(r);
const reply = await exhaustAndTrigger(anon, r);
assert.strictEqual(reply.attrs.type, 'error',
'anonymous sender must still hit the cap with check_token=true');
});
// ── filter_messages fires before muc_limit_messages (priority guard) ────────
//
// mod_filter_messages hooks message/bare at the default priority (0).
// mod_muc_limit_messages hooks message/bare at priority -1 (explicit).
// Higher priority fires first in Prosody, so filter_messages always runs
// before muc_limit_messages — the order is deterministic and does not depend
// on module load order or Prosody's internal sort stability.
//
// Regression guard: if muc_limit_messages ever reverts to the default
// priority (0) the ordering becomes non-deterministic and blocked messages
// could silently exhaust the cap.
it('muc_limit_messages must not count messages already blocked by filter_messages', async () => {
const r = nextRoom();
const focus = await joinWithFocus(r);
const mod = await connectModerator(r);
const anon = await connectAnon(r);
clients.push(focus, mod, anon);
await mod.joinRoom(r);
await anon.joinRoom(r);
// Restrict chat; filter_messages (priority 0) swallows every anon message
// before muc_limit_messages (priority -1) can increment the counter.
await restrictChat(mod, r);
// Send exactly LIMIT messages — all blocked by filter_messages; the room
// counter must stay at zero.
for (let i = 0; i < LIMIT; i++) {
await anon.sendGroupchat(r, `blocked ${i + 1}`);
}
await unrestrictChat(mod, r);
// Counter is still 0, so this message must pass through.
const reply = await anon.sendGroupchat(r, 'should pass');
assert.notEqual(reply.attrs.type, 'error',
'muc_limit_messages must not count messages blocked by filter_messages');
});
// ── filter_messages × muc_limit_messages hook-ordering interaction ────────
//
// mod_muc_limit_messages registers its message/bare hook at priority -1 so
// that modules at the default priority (0) — including filter_messages — run
// first. When filter_messages blocks a message (returns true) the hook chain
// ends; muc_limit_messages never runs and the room counter is not incremented.
it('messages blocked by filter_messages do not count toward the cap', async () => {
const r = nextRoom();
const focus = await joinWithFocus(r);
const mod = await connectModerator(r);
const anon = await connectAnon(r);
clients.push(focus, mod, anon);
await mod.joinRoom(r);
await anon.joinRoom(r);
// Restrict chat so that filter_messages blocks anon messages.
await restrictChat(mod, r);
// Send more than LIMIT messages — all blocked by filter_messages before
// muc_limit_messages runs, so the room counter stays at zero.
for (let i = 0; i < LIMIT + 1; i++) {
const reply = await anon.sendGroupchat(r, `restricted ${i + 1}`);
assert.strictEqual(reply.attrs.type, 'error',
`restricted message ${i + 1} must be blocked`);
}
// Lift the restriction.
await unrestrictChat(mod, r);
// Counter is still 0, so the next LIMIT messages must all pass.
for (let i = 0; i < LIMIT; i++) {
const reply = await anon.sendGroupchat(r, `after unrestrict ${i + 1}`);
assert.notEqual(reply.attrs.type, 'error',
`message ${i + 1} after unrestrict must pass (cap not consumed by blocked messages)`);
}
});
});

View File

@@ -0,0 +1,446 @@
import assert from 'assert';
import { mintAsapToken } from './helpers/jwt.js';
import { disableLobby, enableLobby, setAffiliation } from './helpers/test_observer.js';
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
const CONFERENCE = 'conference.localhost';
const LOBBY = 'lobby.conference.localhost';
const LOBBY_NS = 'http://jitsi.org/jitmeet';
const MUC_NS = 'http://jabber.org/protocol/muc#user';
/**
* Extracts role and affiliation from a self-presence stanza.
* @param {object} presence
* @returns {{ role: string|null, affiliation: string|null }}
*/
function getRoleAndAffiliation(presence) {
const item = presence.getChild('x', MUC_NS)?.getChild('item');
return {
role: item?.attrs.role ?? null,
affiliation: item?.attrs.affiliation ?? null
};
}
let _roomCounter = 0;
const room = () => `lobby-${++_roomCounter}@${CONFERENCE}`;
describe('mod_muc_lobby_rooms', () => {
let clients;
beforeEach(() => {
clients = [];
});
afterEach(async () => {
await Promise.all(clients.map(c => c.disconnect()));
});
/**
* Creates an XMPP client and pushes it to the cleanup list.
* @param {object} opts Options forwarded to createXmppClient.
* @returns {Promise<object>}
*/
async function connect(opts) {
const c = await createXmppClient(opts);
clients.push(c);
return c;
}
/**
* Joins a room as focus and pushes the client to the cleanup list.
* @param {string} roomJid Full room JID.
* @returns {Promise<object>}
*/
async function focusJoin(roomJid) {
const c = await joinWithFocus(roomJid);
clients.push(c);
return c;
}
// -------------------------------------------------------------------------
// join gate — non-member
// -------------------------------------------------------------------------
describe('non-member join gate', () => {
it('blocks a non-member with 407 registration-required', async () => {
const r = room();
await focusJoin(r);
await enableLobby(r);
const c = await connect();
const presence = await c.joinRoom(r, undefined, { displayName: 'Alice' });
assert.equal(presence.attrs.type, 'error', 'join must be rejected');
const error = presence.getChild('error');
assert.ok(error, 'error element must be present');
assert.equal(error.attrs.type, 'auth');
assert.ok(
error.getChild('registration-required'),
'error condition must be registration-required'
);
});
it('includes the lobby room JID in the 407 error', async () => {
const r = room();
await focusJoin(r);
const lobbyJid = await enableLobby(r);
const c = await connect();
const presence = await c.joinRoom(r, undefined, { displayName: 'Alice' });
assert.equal(presence.attrs.type, 'error');
// Prosody builds the error reply with cursor at <error>, so
// Jitsi-specific child tags land inside <error>, not as siblings.
const error = presence.getChild('error');
assert.ok(error, 'error element must be present');
const lobbyroom = error.getChild('lobbyroom', LOBBY_NS);
assert.ok(lobbyroom, 'expected <lobbyroom xmlns="http://jitsi.org/jitmeet"> in error');
assert.equal(
lobbyroom.getText(),
lobbyJid,
'lobbyroom JID in error must match the created lobby room'
);
assert.ok(
lobbyJid.endsWith(`@${LOBBY}`),
'lobby room JID must be on the lobby component'
);
});
it('blocks with 406 not-acceptable when display name is missing', async () => {
const r = room();
await focusJoin(r);
await enableLobby(r);
const c = await connect();
const presence = await c.joinRoom(r); // no displayName
assert.equal(presence.attrs.type, 'error', 'join must be rejected');
const error = presence.getChild('error');
assert.ok(error, 'error element must be present');
assert.equal(error.attrs.type, 'modify');
assert.ok(error.getChild('not-acceptable'), 'error condition must be not-acceptable');
// Same as lobbyroom: cursor is at <error> when tag is appended.
const dnRequired = error.getChild('displayname-required', LOBBY_NS);
assert.ok(
dnRequired,
'expected <displayname-required xmlns="http://jitsi.org/jitmeet"> in error'
);
assert.equal(dnRequired.attrs.lobby, 'true');
});
});
// -------------------------------------------------------------------------
// bypass paths
// -------------------------------------------------------------------------
describe('bypass paths', () => {
it('pre-approved member with display name can join', async () => {
const r = room();
await focusJoin(r);
await enableLobby(r);
// Connect first to learn the server-assigned JID, then pre-approve the
// user by setting member affiliation before they attempt to join.
const c = await connect();
const bareJid = c.jid.split('/')[0];
await setAffiliation(r, bareJid, 'member');
const presence = await c.joinRoom(r, undefined, { displayName: 'Bob' });
assert.notEqual(presence.attrs.type, 'error', 'pre-approved member must be allowed in');
});
it('whitelisted domain bypasses lobby without affiliation', async () => {
const r = room();
await focusJoin(r);
await enableLobby(r);
// whitelist.localhost is in muc_lobby_whitelist in prosody.cfg.lua
const c = await connect({ domain: 'whitelist.localhost' });
const presence = await c.joinRoom(r); // no displayName needed for whitelisted
assert.notEqual(presence.attrs.type, 'error', 'whitelisted domain must bypass lobby');
});
});
// -------------------------------------------------------------------------
// lobby room lifecycle
// -------------------------------------------------------------------------
describe('lifecycle', () => {
it('lobby room JID is advertised in room disco info after enable', async () => {
const r = room();
const focus = await focusJoin(r);
const lobbyJid = await enableLobby(r);
// Query disco info on the main room from the focus client (already joined).
const discoResponse = await focus.sendDiscoInfo(r);
const query = discoResponse.getChild('query', 'http://jabber.org/protocol/disco#info');
assert.ok(query, 'disco#info query element must be present in response');
const dataForm = query.getChild('x', 'jabber:x:data');
assert.ok(dataForm, 'data form must be present in disco#info');
const lobbyField = dataForm
.getChildren('field')
.find(f => f.attrs.var === 'muc#roominfo_lobbyroom');
assert.ok(lobbyField, 'muc#roominfo_lobbyroom field must be present');
assert.equal(
lobbyField.getChildText('value'),
lobbyJid,
'muc#roominfo_lobbyroom value must equal the lobby room JID'
);
});
it('lobby room JID is absent from disco info after disable', async () => {
const r = room();
const focus = await focusJoin(r);
await enableLobby(r);
await disableLobby(r);
const discoResponse = await focus.sendDiscoInfo(r);
const query = discoResponse.getChild('query', 'http://jabber.org/protocol/disco#info');
const dataForm = query?.getChild('x', 'jabber:x:data');
const lobbyField = dataForm
?.getChildren('field')
.find(f => f.attrs.var === 'muc#roominfo_lobbyroom');
assert.ok(!lobbyField, 'muc#roominfo_lobbyroom must not appear after lobby is disabled');
});
it('non-member can join freely after lobby is disabled', async () => {
const r = room();
await focusJoin(r);
await enableLobby(r);
await disableLobby(r);
const c = await connect();
const presence = await c.joinRoom(r);
assert.notEqual(presence.attrs.type, 'error',
'non-member must be allowed after lobby is disabled');
});
});
// -------------------------------------------------------------------------
// token_affiliation interaction
// -------------------------------------------------------------------------
describe('token_affiliation interaction', () => {
it('moderator token holder is blocked by lobby, not bypassed by affiliation grant', async () => {
const r = room();
const roomName = r.split('@')[0];
await focusJoin(r);
await enableLobby(r);
const token = mintAsapToken({ room: roomName,
context: { user: { moderator: true } } });
const c = await connect({ params: { token } });
const presence = await c.joinRoom(r, undefined, { displayName: 'TokenMod' });
assert.equal(presence.attrs.type, 'error',
'moderator token user must be blocked by lobby');
const error = presence.getChild('error');
assert.ok(error, 'error element must be present');
assert.ok(
error.getChild('registration-required'),
'error condition must be registration-required'
);
});
it('non-moderator token holder is blocked by lobby', async () => {
const r = room();
const roomName = r.split('@')[0];
await focusJoin(r);
await enableLobby(r);
const token = mintAsapToken({ room: roomName,
context: { user: { id: 'user1' } } });
const c = await connect({ params: { token } });
const presence = await c.joinRoom(r, undefined, { displayName: 'TokenUser' });
assert.equal(presence.attrs.type, 'error',
'non-moderator token user must be blocked by lobby');
const error = presence.getChild('error');
assert.ok(error, 'error element must be present');
assert.ok(
error.getChild('registration-required'),
'error condition must be registration-required'
);
});
// ---------------------------------------------------------------------
// lobby + room password
// ---------------------------------------------------------------------
it('moderator token user admitted from lobby joins directly without password prompt', async () => {
const r = room();
const roomName = r.split('@')[0];
const focus = await focusJoin(r);
await focus.setRoomPassword(r, 'testpass');
await enableLobby(r);
const token = mintAsapToken({ room: roomName,
context: { user: { moderator: true } } });
const c = await connect({ params: { token } });
// First attempt — blocked by lobby
const blocked = await c.joinRoom(r, undefined, { displayName: 'TokenMod' });
assert.equal(blocked.attrs.type, 'error', 'first join must be blocked by lobby');
assert.ok(
blocked.getChild('error')?.getChild('registration-required'),
'must be blocked with registration-required'
);
// Simulate moderator approval
const userBareJid = c.jid.split('/')[0];
await setAffiliation(r, userBareJid, 'member');
// Second attempt — admitted user must join directly as moderator, no password required
const presence = await c.joinRoom(r, undefined, { displayName: 'TokenMod' });
assert.notEqual(presence.attrs.type, 'error',
'admitted user must not be asked for password');
const { role, affiliation } = getRoleAndAffiliation(presence);
assert.equal(affiliation, 'owner',
'admitted moderator token user must get owner affiliation');
assert.equal(role, 'moderator',
'admitted moderator token user must get moderator role');
});
it('moderator token user who bypasses lobby with room password gets moderator role', async () => {
const r = room();
const roomName = r.split('@')[0];
const focus = await focusJoin(r);
await focus.setRoomPassword(r, 'testpass');
await enableLobby(r);
const token = mintAsapToken({ room: roomName,
context: { user: { moderator: true } } });
const c = await connect({ params: { token } });
// Join with password — must bypass lobby and get moderator role
const presence = await c.joinRoom(r, undefined, {
password: 'testpass',
displayName: 'TokenMod'
});
assert.notEqual(presence.attrs.type, 'error',
'password-bypass join must succeed');
const { role, affiliation } = getRoleAndAffiliation(presence);
assert.equal(affiliation, 'owner',
'moderator token user who bypassed lobby via password must get owner affiliation');
assert.equal(role, 'moderator',
'moderator token user who bypassed lobby via password must get moderator role');
});
});
// -------------------------------------------------------------------------
// muc#admin set — nick reservation must not act before authorization
// -------------------------------------------------------------------------
//
// handle_admin_query_set_command_item's "nickname is reserved" branch kicks
// whoever currently holds the requested nick when the item's jid doesn't
// match. That kick must only happen once set_affiliation has confirmed the
// sender is allowed to act — otherwise an unauthorized sender can remove an
// occupant just by naming their nick, before the affiliation check ever
// gets a chance to reject the request.
describe('admin-set nick reservation', () => {
it('does not remove an occupant before authorizing the affiliation change', async () => {
const r = room();
await focusJoin(r);
const occupant = await connect();
await occupant.joinRoom(r, 'participant', { displayName: 'Participant' });
// Never joins the room — permission is checked from the sender's own
// bare jid, which has no affiliation record here regardless.
const sender = await connect();
const resp = await sender.sendMucAdmin(r, {
nick: 'participant',
jid: 'someone-else@example.com',
affiliation: 'member'
});
// The real proof: a successful kick sends the occupant an unavailable
// self-presence. Its absence means the kick never happened.
const kicked = await occupant.waitForPresenceFrom(`${r}/participant`, { type: 'unavailable',
timeout: 500 })
.then(() => true, () => false);
assert.strictEqual(kicked, false, 'occupant must not be removed by an unauthorized sender');
assert.equal(resp.attrs.type, 'error', 'IQ must be rejected');
});
it('does not remove focus before authorizing the affiliation change', async () => {
const r = room();
const focus = await focusJoin(r);
// Also rejected outright by mod_muc_meeting_id's filter_admin_set,
// which runs at a higher priority for any item naming focus's nick.
// Kept here so a regression in either guard is still caught.
const sender = await connect();
const resp = await sender.sendMucAdmin(r, {
nick: 'focus',
jid: 'someone-else@example.com',
affiliation: 'member'
});
const kicked = await focus.waitForPresenceFrom(`${r}/focus`, { type: 'unavailable',
timeout: 500 })
.then(() => true, () => false);
assert.strictEqual(kicked, false, 'focus must not be removed by an unauthorized sender');
assert.equal(resp.attrs.type, 'error', 'IQ must be rejected');
});
});
});

View File

@@ -0,0 +1,203 @@
import assert from 'assert';
import { createTestContext } from './helpers/test_context.js';
import { setRoomMaxOccupants } from './helpers/test_observer.js';
import { isAvailablePresence } from './helpers/xmpp_utils.js';
const CONFERENCE = 'conference.localhost';
// Each test uses a unique room name to avoid cross-test state.
let _roomCounter = 0;
const room = () => `max-occupants-${++_roomCounter}@${CONFERENCE}`;
describe('mod_muc_max_occupants', () => {
// Prosody config sets muc_max_occupants = 2.
let ctx;
beforeEach(() => {
ctx = createTestContext();
});
afterEach(() => ctx.cleanup());
it('allows join when room is empty', async () => {
const r = room();
await ctx.connectFocus(r);
const c = await ctx.connect();
const presence = await c.joinRoom(r);
assert.ok(isAvailablePresence(presence));
});
it('allows join when room has one occupant (under limit)', async () => {
const r = room();
await ctx.connectFocus(r);
const c1 = await ctx.connect();
const c2 = await ctx.connect();
await c1.joinRoom(r);
const presence = await c2.joinRoom(r);
assert.ok(isAvailablePresence(presence));
});
it('blocks join when room is at the limit', async () => {
const r = room();
await ctx.connectFocus(r);
const c1 = await ctx.connect();
const c2 = await ctx.connect();
const c3 = await ctx.connect();
await c1.joinRoom(r);
await c2.joinRoom(r);
const presence = await c3.joinRoom(r);
assert.equal(presence.attrs.type, 'error');
assert.ok(
presence.getChild('error')?.getChild('service-unavailable'),
'expected <service-unavailable/> in error stanza'
);
});
it('health check room bypasses the occupant limit', async () => {
// mod_muc_meeting_id restricts health-check rooms to focus (jicofo) only,
// so we test the occupant-limit bypass with focus clients.
// is_healthcheck_room() matches rooms starting with '__jicofo-health-check'.
const healthRoom = `__jicofo-health-check-test@${CONFERENCE}`;
await ctx.connectFocus(healthRoom);
// A second focus join would conflict on nick; the meaningful assertion is
// that the first focus client joined without hitting a limit error.
assert.ok(true, 'focus joined health-check room without occupant-limit error');
});
describe('whitelist', () => {
// Prosody config sets muc_access_whitelist = { "whitelist.localhost", "auth.localhost" }.
// Clients created with domain:'whitelist.localhost' get JIDs on that domain
// and are treated as whitelisted by mod_muc_max_occupants.
it('whitelisted user can join a room that is at the limit', async () => {
const r = room();
await ctx.connectFocus(r);
const c1 = await ctx.connect();
const c2 = await ctx.connect();
const wl = await ctx.connectWhitelisted();
await c1.joinRoom(r);
await c2.joinRoom(r); // room now at limit (2 non-whitelisted)
const presence = await wl.joinRoom(r);
assert.ok(isAvailablePresence(presence),
'whitelisted user must bypass the occupant limit');
});
it('whitelisted occupants do not count against the limit for non-whitelisted users', async () => {
const r = room();
await ctx.connectFocus(r);
const wl1 = await ctx.connectWhitelisted();
const wl2 = await ctx.connectWhitelisted();
const c1 = await ctx.connect();
const c2 = await ctx.connect();
const c3 = await ctx.connect();
// Two whitelisted users join — they bypass the check and are not
// counted when a non-whitelisted user evaluates available slots.
await wl1.joinRoom(r);
await wl2.joinRoom(r);
// Non-whitelisted users: only they count against the limit of 2.
const p1 = await c1.joinRoom(r);
assert.ok(isAvailablePresence(p1),
'1st non-whitelisted user must be allowed (0 counted occupants so far)');
const p2 = await c2.joinRoom(r);
assert.ok(isAvailablePresence(p2),
'2nd non-whitelisted user must be allowed (1 counted occupant)');
const p3 = await c3.joinRoom(r);
assert.equal(p3.attrs.type, 'error',
'3rd non-whitelisted user must be blocked (2 counted occupants = limit reached)');
assert.ok(
p3.getChild('error')?.getChild('service-unavailable'),
'expected <service-unavailable/> in error stanza'
);
});
});
describe('per-room limit', () => {
// room._data.max_occupants overrides the global muc_max_occupants (2)
// for an individual room. The HTTP helper sets it after room creation.
it('per-room limit higher than global allows more occupants', async () => {
const r = room();
await ctx.connectFocus(r);
const c1 = await ctx.connect();
await c1.joinRoom(r);
// Override the global limit of 2 with a per-room limit of 4.
await setRoomMaxOccupants(r, 4);
const c2 = await ctx.connect();
const c3 = await ctx.connect();
const c4 = await ctx.connect();
const c5 = await ctx.connect();
const p2 = await c2.joinRoom(r);
assert.ok(isAvailablePresence(p2), 'user 2 should join (limit 4)');
const p3 = await c3.joinRoom(r);
assert.ok(isAvailablePresence(p3),
'user 3 should join (global limit 2 would block, per-room limit 4 allows)');
const p4 = await c4.joinRoom(r);
assert.ok(isAvailablePresence(p4), 'user 4 should join (limit 4)');
const p5 = await c5.joinRoom(r);
assert.equal(p5.attrs.type, 'error', 'user 5 must be blocked (at per-room limit 4)');
assert.ok(
p5.getChild('error')?.getChild('service-unavailable'),
'expected <service-unavailable/> in error stanza'
);
});
it('per-room limit lower than global restricts the room further', async () => {
const r = room();
await ctx.connectFocus(r);
const c1 = await ctx.connect();
await c1.joinRoom(r);
// Override the global limit of 2 with a stricter per-room limit of 1.
await setRoomMaxOccupants(r, 1);
const c2 = await ctx.connect();
const presence = await c2.joinRoom(r);
assert.equal(presence.attrs.type, 'error',
'user 2 must be blocked by the per-room limit of 1');
assert.ok(
presence.getChild('error')?.getChild('service-unavailable'),
'expected <service-unavailable/> in error stanza'
);
});
});
});

View File

@@ -0,0 +1,327 @@
import assert from 'assert';
import { mintAsapToken } from './helpers/jwt.js';
import { createTestContext } from './helpers/test_context.js';
import { getRoomState } from './helpers/test_observer.js';
import { joinWithTranscriber } from './helpers/xmpp_client.js';
import { isAvailablePresence } from './helpers/xmpp_utils.js';
const CONFERENCE = 'conference.localhost';
let _roomCounter = 0;
const room = () => `meeting-id-${++_roomCounter}@${CONFERENCE}`;
const healthRoom = () => `__jicofo-health-check-mid-${++_roomCounter}@${CONFERENCE}`;
/**
* Asserts that an IQ response is a 'cancel'/'not-allowed' error.
*
* @param {object} iq the IQ stanza returned by sendMucAdmin
* @returns {void}
*/
function assertNotAllowed(iq) {
assert.strictEqual(iq.attrs.type, 'error', 'IQ must be rejected');
const error = iq.getChild('error');
assert.ok(error, 'response must carry an <error>');
assert.strictEqual(error.attrs.type, 'cancel');
assert.ok(error.getChild('not-allowed'), 'error condition must be not-allowed');
}
describe('mod_muc_meeting_id', () => {
let ctx;
beforeEach(() => {
ctx = createTestContext();
});
afterEach(() => ctx.cleanup());
// -------------------------------------------------------------------------
// jicofo lock
// -------------------------------------------------------------------------
describe('jicofo lock', () => {
it('focus can join a room', async () => {
// connectFocus throws (timeout) if the join is blocked.
await ctx.connectFocus(room());
});
it('regular user can join after focus has joined', async () => {
const r = room();
await ctx.connectFocus(r);
const c = await ctx.connect();
const presence = await c.joinRoom(r);
assert.ok(isAvailablePresence(presence),
'regular user should be allowed in after focus unlocks');
});
it('a regular user cannot create a room by joining first', async () => {
const r = room();
// restrict_room_creation = true (core Prosody mod_muc) limits
// :create-room to admins. A non-focus client's presence to a
// nonexistent room is rejected at muc-room-pre-create, before the
// room object exists and before mod_muc_meeting_id's jicofo lock
// ever comes into play.
const c = await ctx.connect();
const presence = await c.joinRoom(r);
assert.strictEqual(presence.attrs.type, 'error', 'join must be rejected');
const error = presence.getChild('error');
assert.ok(error, 'response must carry an <error>');
assert.strictEqual(error.attrs.type, 'cancel');
assert.ok(error.getChild('not-allowed'), 'error condition must be not-allowed');
});
it('focus can still create the room after a rejected creation attempt', async () => {
const r = room();
const c = await ctx.connect();
await c.joinRoom(r);
// connectFocus throws (timeout) if the join is blocked.
await ctx.connectFocus(r);
});
});
// -------------------------------------------------------------------------
// set admin filtering
// -------------------------------------------------------------------------
//
// filter_admin_set in mod_muc_meeting_id intercepts muc#admin set IQs at a
// high priority (before Prosody's own handler) and rejects any item that
// targets certain occupants — by nick (role changes) or by real JID
// (affiliation changes).
describe('set admin filtering', () => {
it('a kick (role=none by nick) targeting certain participant is rejected', async () => {
const r = room();
await ctx.connectFocus(r);
const attacker = await ctx.connect();
await attacker.joinRoom(r);
const resp = await attacker.sendMucAdmin(r, { nick: 'focus',
role: 'none' });
assertNotAllowed(resp);
const state = await getRoomState(r);
assert.strictEqual(state.occupant_count, 2, 'must remain in the room after a blocked kick');
});
it('a ban (affiliation=outcast by jid) targeting certain participant is rejected', async () => {
const r = room();
const focus = await ctx.connectFocus(r);
const focusBareJid = focus.jid.split('/')[0];
const attacker = await ctx.connect();
await attacker.joinRoom(r);
const resp = await attacker.sendMucAdmin(r, { jid: focusBareJid,
affiliation: 'outcast' });
assertNotAllowed(resp);
const state = await getRoomState(r);
assert.strictEqual(state.occupant_count, 2, 'must remain in the room after a blocked ban');
});
it('a ban by jid is rejected even when a mismatched nick is also present', async () => {
const r = room();
const focus = await ctx.connectFocus(r);
const focusBareJid = focus.jid.split('/')[0];
// An owner (moderator token) is the only actor Prosody would let
// change another occupant's affiliation, so the item has to be
// filtered by this hook rather than by Prosody's permission check.
const token = mintAsapToken({ room: r.split('@')[0],
context: { user: { moderator: true } } });
const attacker = await ctx.connect({ params: { token } });
await attacker.joinRoom(r);
// The affiliation change acts on the real jid; a nick that resolves
// to no occupant must not cause the jid to be skipped when deciding
// whether the item targets focus.
const resp = await attacker.sendMucAdmin(r, { jid: focusBareJid,
nick: 'decoy',
affiliation: 'outcast' });
// The real proof is on focus's own connection: a successful ban
// makes the room send focus an unavailable self-presence. If the
// item is filtered, none arrives.
const kicked = await focus.waitForPresenceFrom(`${r}/focus`, { type: 'unavailable',
timeout: 500 })
.then(() => true, () => false);
assert.strictEqual(kicked, false, 'focus must not be kicked/banned from the room');
assertNotAllowed(resp);
const state = await getRoomState(r);
assert.strictEqual(state.occupant_count, 2, 'must remain in the room after a blocked ban');
});
it('a role demotion (role=participant) targeting certain participant is rejected', async () => {
const r = room();
await ctx.connectFocus(r);
const attacker = await ctx.connect();
await attacker.joinRoom(r);
const resp = await attacker.sendMucAdmin(r, { nick: 'focus',
role: 'participant' });
assertNotAllowed(resp);
});
it('admin operations targeting a non-focus occupant are not blocked by this hook', async () => {
const r = room();
// focus is the room owner, so it is allowed to kick a regular user.
// This verifies the filtering is scoped and does not
// block legitimate moderation of other participants.
const focus = await ctx.connectFocus(r);
const user = await ctx.connect();
await user.joinRoom(r);
const { nick } = user;
const resp = await focus.sendMucAdmin(r, { nick,
role: 'none' });
assert.strictEqual(resp.attrs.type, 'result',
'must be able to kick a regular participant');
const state = await getRoomState(r);
assert.strictEqual(state.occupant_count, 1,
'kicked regular user should be gone, only focus remains');
});
});
// -------------------------------------------------------------------------
// transcription filter robustness
// -------------------------------------------------------------------------
//
// filterTranscriptionResult() in mod_muc_meeting_id processes groupchat
// messages from transcribers. Two nil-deref bugs exist:
// 1. transcript[1].text crashes when transcript is an empty array.
// 2. participant.id crashes when the participant field is absent.
// Both are reached only from transcriber JIDs (is_transcriber() guard).
// Tests verify the session survives both malformed payloads.
describe('transcription filter robustness', () => {
it('session survives a transcription-result with an empty transcript array', async () => {
const r = room();
await ctx.connectFocus(r);
const transcriber = await joinWithTranscriber(r);
try {
// transcript: [] → transcript[1] is nil → .text crashes
await transcriber.sendJsonGroupchat(r, {
type: 'transcription-result',
transcript: [],
participant: { id: 'p1',
name: 'Alice' },
// eslint-disable-next-line camelcase
is_interim: false
});
await new Promise(resolve => setTimeout(resolve, 400));
const disconnected = await transcriber.waitForDisconnect(300)
.then(() => true, () => false);
assert.strictEqual(disconnected, false,
'transcriber session must survive empty transcript array');
} finally {
await transcriber.disconnect();
}
});
it('session survives a transcription-result with a missing participant field', async () => {
const r = room();
await ctx.connectFocus(r);
const transcriber = await joinWithTranscriber(r);
try {
// participant absent → participant.id crashes
await transcriber.sendJsonGroupchat(r, {
type: 'transcription-result',
transcript: [ { text: 'hello world' } ],
// eslint-disable-next-line camelcase
is_interim: false
});
await new Promise(resolve => setTimeout(resolve, 400));
const disconnected = await transcriber.waitForDisconnect(300)
.then(() => true, () => false);
assert.strictEqual(disconnected, false,
'transcriber session must survive missing participant field');
} finally {
await transcriber.disconnect();
}
});
});
// -------------------------------------------------------------------------
// health check rooms
// -------------------------------------------------------------------------
describe('health check rooms', () => {
it('focus can join a health check room', async () => {
// connectFocus throws on timeout if the join is blocked.
await ctx.connectFocus(healthRoom());
});
it('non-focus participant is blocked from a health check room', async () => {
const r = healthRoom();
const c = await ctx.connect();
const presence = await c.joinRoom(r);
assert.equal(presence.attrs.type, 'error',
'non-focus should not be allowed into health-check rooms');
assert.ok(
presence.getChild('error')?.getChild('service-unavailable'),
'error condition must be service-unavailable'
);
});
it('non-focus is blocked even when focus is already in the room', async () => {
const r = healthRoom();
await ctx.connectFocus(r);
const intruder = await ctx.connect();
const presence = await intruder.joinRoom(r);
assert.equal(presence.attrs.type, 'error',
'non-focus must still be blocked after focus has joined');
assert.ok(
presence.getChild('error')?.getChild('service-unavailable'),
'error condition must be service-unavailable'
);
});
});
});

View File

@@ -0,0 +1,318 @@
import assert from 'assert';
import { mintAsapToken } from './helpers/jwt.js';
import { enableLobby } from './helpers/test_observer.js';
import { joinWithFocus } from './helpers/xmpp_client.js';
const CONFERENCE = 'conference.localhost';
const BASE_URL = 'http://localhost:5280/room-info';
let _roomCounter = 0;
const room = () => `password-check-${++_roomCounter}@${CONFERENCE}`;
describe('mod_muc_password_check', () => {
let clients;
beforeEach(() => {
clients = [];
});
afterEach(async () => {
await Promise.all(clients.map(c => c.disconnect()));
});
/**
* Joins a room as focus and registers the client for cleanup.
*
* @param {string} roomJid Full room JID.
* @returns {Promise<object>} The focus client.
*/
async function focusJoin(roomJid) {
const c = await joinWithFocus(roomJid);
clients.push(c);
return c;
}
/**
* Calls GET /room-info?room=<roomName> and returns the parsed response.
*
* @param {string} roomName The room query param value (bare or full JID).
* @param {string|null} token Bearer token to include, or null to omit.
* @param {object} [opts]
* @param {boolean} [opts.omitAuth=false] When true, omit the Authorization header entirely.
* @returns {Promise<{status: number, body: object|string}>}
*/
async function getRoomInfo(roomName, token, { omitAuth = false } = {}) {
const headers = { 'Content-Type': 'application/json' };
if (!omitAuth && token !== null) {
headers.Authorization = `Bearer ${token}`;
}
const res = await fetch(`${BASE_URL}?room=${encodeURIComponent(roomName)}`, { headers });
const text = await res.text();
let body;
try {
body = JSON.parse(text);
} catch {
body = text;
}
return { status: res.status,
body };
}
/**
* Calls PUT /room-info?room=<roomName> with the given passcode and returns
* the parsed response.
*
* @param {string} roomName The room query param value.
* @param {string} passcode The passcode to submit.
* @param {string|null} token Bearer token, or null to send without value.
* @param {object} [opts]
* @param {boolean} [opts.omitAuth=false] When true, omit the Authorization header entirely.
* @returns {Promise<{status: number, body: object|string}>}
*/
async function validatePasscode(roomName, passcode, token, { omitAuth = false } = {}) {
const headers = { 'Content-Type': 'application/json' };
if (!omitAuth && token !== null) {
headers.Authorization = `Bearer ${token}`;
}
const res = await fetch(`${BASE_URL}?room=${encodeURIComponent(roomName)}`, {
method: 'PUT',
headers,
body: JSON.stringify({ passcode })
});
const text = await res.text();
let body;
try {
body = JSON.parse(text);
} catch {
body = text;
}
return { status: res.status,
body };
}
// -------------------------------------------------------------------------
// authentication
// -------------------------------------------------------------------------
describe('authentication', () => {
it('GET returns 403 when Authorization header is absent', async () => {
const r = room();
await focusJoin(r);
const { status } = await getRoomInfo(r.split('@')[0], null, { omitAuth: true });
assert.strictEqual(status, 403);
});
it('GET returns 403 when token is invalid', async () => {
const r = room();
await focusJoin(r);
const { status } = await getRoomInfo(r.split('@')[0], 'invalid.token.here');
assert.strictEqual(status, 403);
});
it('PUT returns 403 when Authorization header is absent', async () => {
const r = room();
await focusJoin(r);
const { status } = await validatePasscode(r.split('@')[0], 'somepasscode', null, { omitAuth: true });
assert.strictEqual(status, 403);
});
});
// -------------------------------------------------------------------------
// parameter validation
// -------------------------------------------------------------------------
describe('parameter validation', () => {
it('GET returns 400 when room query param is missing', async () => {
const token = mintAsapToken({ room: '*' });
const res = await fetch(BASE_URL, {
headers: { Authorization: `Bearer ${token}` }
});
assert.strictEqual(res.status, 400);
});
it('PUT returns 400 when Content-Type is not application/json', async () => {
const token = mintAsapToken({ room: '*' });
const res = await fetch(`${BASE_URL}?room=someroom`, {
method: 'PUT',
headers: {
'Content-Type': 'text/plain',
Authorization: `Bearer ${token}`
},
body: '{"passcode":"test"}'
});
assert.strictEqual(res.status, 400);
});
it('PUT returns 400 when body is empty', async () => {
const token = mintAsapToken({ room: '*' });
const res = await fetch(`${BASE_URL}?room=someroom`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: ''
});
assert.strictEqual(res.status, 400);
});
it('PUT returns 400 when body is valid JSON but passcode key is missing', async () => {
const token = mintAsapToken({ room: '*' });
const res = await fetch(`${BASE_URL}?room=someroom`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: '{}'
});
assert.strictEqual(res.status, 400);
});
});
// -------------------------------------------------------------------------
// room lookup
// -------------------------------------------------------------------------
describe('room lookup', () => {
it('GET returns 404 when room does not exist', async () => {
const token = mintAsapToken({ room: '*' });
const { status } = await getRoomInfo('nonexistent-room', token);
assert.strictEqual(status, 404);
});
it('PUT returns 404 when room does not exist', async () => {
const token = mintAsapToken({ room: '*' });
const { status } = await validatePasscode('nonexistent-room', 'secret', token);
assert.strictEqual(status, 404);
});
});
// -------------------------------------------------------------------------
// GET room info
// -------------------------------------------------------------------------
describe('GET room info', () => {
it('returns passcodeProtected: false and lobbyEnabled: false for a plain room', async () => {
const r = room();
const roomName = r.split('@')[0];
await focusJoin(r);
const token = mintAsapToken({ room: '*' });
const { status, body } = await getRoomInfo(roomName, token);
assert.strictEqual(status, 200);
assert.strictEqual(body.passcodeProtected, false);
assert.strictEqual(body.lobbyEnabled, false);
});
it('returns passcodeProtected: true when room has a password set', async () => {
const r = room();
const roomName = r.split('@')[0];
const focus = await focusJoin(r);
await focus.setRoomPassword(r, 'secret123');
const token = mintAsapToken({ room: '*' });
const { status, body } = await getRoomInfo(roomName, token);
assert.strictEqual(status, 200);
assert.strictEqual(body.passcodeProtected, true);
});
it('returns lobbyEnabled: true when lobby is enabled', async () => {
const r = room();
const roomName = r.split('@')[0];
await focusJoin(r);
await enableLobby(r);
const token = mintAsapToken({ room: '*' });
const { status, body } = await getRoomInfo(roomName, token);
assert.strictEqual(status, 200);
assert.strictEqual(body.lobbyEnabled, true);
});
it('conference field equals the full room JID', async () => {
const r = room();
const roomName = r.split('@')[0];
await focusJoin(r);
const token = mintAsapToken({ room: '*' });
const { status, body } = await getRoomInfo(roomName, token);
assert.strictEqual(status, 200);
assert.strictEqual(body.conference, `${roomName}@${CONFERENCE}`);
});
});
// -------------------------------------------------------------------------
// PUT passcode validation
// -------------------------------------------------------------------------
describe('PUT passcode validation', () => {
it('returns { valid: true } when submitted passcode matches', async () => {
const r = room();
const roomName = r.split('@')[0];
const focus = await focusJoin(r);
await focus.setRoomPassword(r, 'correct-pass');
const token = mintAsapToken({ room: '*' });
const { status, body } = await validatePasscode(roomName, 'correct-pass', token);
assert.strictEqual(status, 200);
assert.strictEqual(body.valid, true);
});
it('returns { valid: false } when submitted passcode does not match', async () => {
const r = room();
const roomName = r.split('@')[0];
const focus = await focusJoin(r);
await focus.setRoomPassword(r, 'correct-pass');
const token = mintAsapToken({ room: '*' });
const { status, body } = await validatePasscode(roomName, 'wrong-pass', token);
assert.strictEqual(status, 200);
assert.strictEqual(body.valid, false);
});
});
});

View File

@@ -0,0 +1,65 @@
import assert from 'assert';
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
const MUC = 'conference.localhost';
const ROOM = `test-pw-whitelist@${MUC}`;
const PASSWORD = 'roompassword';
describe('mod_muc_password_whitelist', () => {
const clients = [];
afterEach(async () => {
await Promise.all(clients.map(c => c.disconnect()));
clients.length = 0;
});
it('whitelisted domain joins password-protected room without providing password', async () => {
// Focus unlocks the room; focus client is the owner and sets the password.
const focus = await joinWithFocus(ROOM);
clients.push(focus);
await focus.setRoomPassword(ROOM, PASSWORD);
// Client from whitelisted domain joins without supplying the password.
const whitelisted = await createXmppClient({ domain: 'whitelist.localhost' });
clients.push(whitelisted);
const presence = await whitelisted.joinRoom(ROOM);
assert.notEqual(presence.attrs.type, 'error', 'whitelisted client must join without password');
});
it('non-whitelisted client is rejected when joining without password', async () => {
// Focus unlocks the room and sets the password.
const focus = await joinWithFocus(ROOM);
clients.push(focus);
await focus.setRoomPassword(ROOM, PASSWORD);
// Client from non-whitelisted domain joins without a password.
const guest = await createXmppClient({ domain: 'localhost' });
clients.push(guest);
const presence = await guest.joinRoom(ROOM);
assert.equal(presence.attrs.type, 'error', 'non-whitelisted client must be rejected without password');
});
it('non-whitelisted client joins with correct password', async () => {
// Focus unlocks the room and sets the password.
const focus = await joinWithFocus(ROOM);
clients.push(focus);
await focus.setRoomPassword(ROOM, PASSWORD);
// Client from non-whitelisted domain joins with the correct password.
const guest = await createXmppClient({ domain: 'localhost' });
clients.push(guest);
const presence = await guest.joinRoom(ROOM, undefined, { password: PASSWORD });
assert.notEqual(presence.attrs.type, 'error', 'non-whitelisted client must join with correct password');
});
});

View File

@@ -0,0 +1,204 @@
// Tests for mod_muc_rate_limit (resources/prosody-plugins/mod_muc_rate_limit.lua).
//
// The module rate-limits MUC joins and leaves per room. When the configured
// rate is exceeded (muc_rate_joins / muc_rate_leaves, default 3/s and 5/s),
// excess presence events are held in a per-room FIFO queue and replayed one
// at a time by a 1-second recurring timer. This prevents presence storms in
// large meetings.
//
// Joins: muc-occupant-pre-join events beyond the rate are queued; when the
// timer fires it replays them through room:handle_normal_presence, setting
// a delayed_join_skip flag to avoid re-queuing on the second pass.
//
// Leaves: muc-occupant-pre-leave events beyond the rate are queued; the
// occupant's role is set to nil immediately (removing them from the room
// roster) but publicise_occupant_status (the unavailable presence broadcast
// to peers) is deferred until the timer processes the event.
//
// Room destruction: when muc-room-destroyed fires, the queue is marked empty
// so the in-flight timer skips further processing cleanly.
import assert from 'assert';
import { prosodyShell } from './helpers/prosody_shell.js';
import { createTestContext } from './helpers/test_context.js';
const RATE_MUC = 'rate-limited.localhost';
const NUM_CLIENTS = 5;
// 5 clients at rate=1/s means the last client waits ~4 s; allow ample margin.
const JOIN_TIMEOUT = 12000;
let _roomCounter = 0;
const room = () => `rl-${++_roomCounter}@${RATE_MUC}`;
describe('mod_muc_rate_limit', () => {
before(async () => {
const out = await prosodyShell(`module:reload("muc_rate_limit", "${RATE_MUC}")`);
console.log('[prosody] module:reload muc_rate_limit:', out);
});
let ctx;
beforeEach(() => {
ctx = createTestContext();
});
afterEach(() => ctx.cleanup());
// -------------------------------------------------------------------------
// Join rate limiting
// -------------------------------------------------------------------------
describe('join rate limiting', () => {
it('all 5 simultaneous joins complete despite rate=1/s', async () => {
const r = room();
const clients = await Promise.all(
Array.from({ length: NUM_CLIENTS }, () => ctx.connectWhitelisted())
);
// All 5 fire simultaneously; the throttle (1/s) will queue 4 of them.
const presences = await Promise.all(
clients.map(c => c.joinRoom(r, undefined, { timeout: JOIN_TIMEOUT }))
);
assert.equal(presences.length, NUM_CLIENTS, 'all 5 joins must resolve');
for (const p of presences) {
assert.notEqual(p.attrs.type, 'error', 'join must not be rejected with an error');
const x = p.getChild('x', 'http://jabber.org/protocol/muc#user');
const isSelf = x?.getChildren('status').some(s => s.attrs.code === '110');
assert.ok(isSelf, 'each self-presence must carry MUC status 110');
}
});
it('queued joins arrive in order (each after the previous)', async () => {
const r = room();
const clients = await Promise.all(
Array.from({ length: NUM_CLIENTS }, () => ctx.connectWhitelisted())
);
// Record the wall-clock time at which each joinRoom resolves.
const resolvedAt = new Array(NUM_CLIENTS);
const joinPromises = clients.map((c, i) =>
c.joinRoom(r, undefined, { timeout: JOIN_TIMEOUT }).then(p => {
resolvedAt[i] = Date.now();
return p;
})
);
await Promise.all(joinPromises);
// With rate=1/s the resolutions must be spread ≥ 800 ms apart in total.
// (We avoid asserting strict per-join gaps to tolerate scheduling jitter.)
const earliest = Math.min(...resolvedAt);
const latest = Math.max(...resolvedAt);
const span = latest - earliest;
assert.ok(span >= 800,
`joins should be spread over ≥ 800 ms, actual span: ${span} ms`);
});
});
// -------------------------------------------------------------------------
// Leave rate limiting
// -------------------------------------------------------------------------
describe('leave rate limiting', () => {
it('all 5 simultaneous leaves complete and observer receives all unavailable presences', async () => {
const r = room();
// Observer joins first so it can witness the leaves.
const observer = await ctx.connectWhitelisted();
await observer.joinRoom(r, 'observer', { timeout: JOIN_TIMEOUT });
// 5 clients join (rate-limited; each waits its turn).
const leavers = await Promise.all(
Array.from({ length: NUM_CLIENTS }, () => ctx.connectWhitelisted())
);
const joinResults = await Promise.all(
leavers.map(c => c.joinRoom(r, undefined, { timeout: JOIN_TIMEOUT }))
);
// Derive the nick each leaver was assigned from its self-presence.
const nicks = joinResults.map(p => p.attrs.from.split('/')[1]);
// Set up watchers BEFORE disconnecting so no unavailable presence is missed.
const unavailablePromises = nicks.map(nick =>
observer.waitForPresenceFrom(`${r}/${nick}`, {
type: 'unavailable',
timeout: JOIN_TIMEOUT
})
);
// All 5 disconnect simultaneously; the leave throttle (1/s) will queue 4.
await Promise.all(leavers.map(c => c.disconnect()));
const unavailables = await Promise.all(unavailablePromises);
assert.equal(unavailables.length, NUM_CLIENTS,
'observer must receive unavailable presences for all 5 leavers');
for (const p of unavailables) {
assert.equal(p.attrs.type, 'unavailable',
'each received presence must be of type unavailable');
}
});
});
// -------------------------------------------------------------------------
// Room destruction during queued joins
// -------------------------------------------------------------------------
describe('room destruction', () => {
it('room destroyed while joins are queued does not crash the module', async () => {
const r = room();
const clients = await Promise.all(
Array.from({ length: NUM_CLIENTS }, () => ctx.connectWhitelisted())
);
// The first client joins separately so it becomes the room owner and
// can destroy the room via XMPP. This also consumes the throttle
// token so all remaining joins land immediately in the queue.
const [ owner, ...rest ] = clients;
await owner.joinRoom(r, 'owner', { timeout: JOIN_TIMEOUT });
// Fire the remaining 4 joins simultaneously. They are all queued
// (throttle token was just used) and won't be replayed until the
// 1-second timer fires. Use a short timeout so the test doesn't hang
// if the queued joins are never replayed (expected after destroy).
const pendingJoins = rest.map(c =>
c.joinRoom(r, undefined, { timeout: 3000 }).catch(() => null)
);
// Destroy the room as owner before the 1-second timer fires.
await owner.destroyRoom(r);
// Wait for all queued join attempts to settle (they should time out
// because the room was destroyed and the queue was marked empty).
await Promise.allSettled(pendingJoins);
// Verify the module still works: a fresh join on the same component succeeds.
const freshRoom = room();
const freshClient = await ctx.connectWhitelisted();
const p = await freshClient.joinRoom(freshRoom, undefined, { timeout: JOIN_TIMEOUT });
assert.notEqual(p.attrs.type, 'error',
'a fresh join on the same component must succeed after room destruction');
const x = p.getChild('x', 'http://jabber.org/protocol/muc#user');
assert.ok(
x?.getChildren('status').some(s => s.attrs.code === '110'),
'fresh join self-presence must carry status 110'
);
});
});
});

View File

@@ -0,0 +1,199 @@
import assert from 'assert';
import { getContainer } from './helpers/container.js';
import { createTestContext } from './helpers/test_context.js';
import { isAvailablePresence } from './helpers/xmpp_utils.js';
const MUC = 'conference.localhost';
const PROSODY_CFG = '/etc/prosody/cfg/conference.cfg.lua';
let _roomCounter = 0;
const room = () => `validate-${++_roomCounter}@${MUC}`;
/**
* Toggle anonymous_strict on mod_muc_resource_validate by editing the Prosody
* config inside the container and reloading Prosody (SIGHUP via prosodyctl reload).
* mod_muc_resource_validate re-reads config on the config-reloaded event.
*
* The default in the test config is anonymous_strict = true. Call
* setStrictMode(false) to temporarily disable strict checking, then restore
* with setStrictMode(true) in a finally block.
*
* @param {boolean} enabled
*/
async function setStrictMode(enabled) {
const container = getContainer();
const from = enabled ? 'anonymous_strict = false' : 'anonymous_strict = true';
const to = enabled ? 'anonymous_strict = true' : 'anonymous_strict = false';
await container.exec([ 'sed', '-i', `s/${from}/${to}/`, PROSODY_CFG ]);
await container.exec([ 'prosodyctl', 'reload' ]);
// prosodyctl reload sends SIGHUP and returns immediately; give Prosody a moment
// to process the config-reloaded event before the next test action.
await new Promise(resolve => setTimeout(resolve, 500));
}
describe('mod_muc_resource_validate', () => {
let ctx;
beforeEach(() => {
ctx = createTestContext();
});
afterEach(() => ctx.cleanup());
// ── Basic pattern validation ──────────────────────────────────────────────
//
// These tests verify the resource format rules independently of the
// anonymous_strict UUID-prefix constraint, so they temporarily disable
// strict mode to allow arbitrary (but format-valid) nicks.
it('allows a valid alphanumeric resource', async () => {
const r = room();
await ctx.connectFocus(r);
await setStrictMode(false);
try {
const c = await ctx.connect();
const presence = await c.joinRoom(r, 'ValidNick123');
assert.ok(isAvailablePresence(presence),
'valid alphanumeric resource must be allowed');
} finally {
await setStrictMode(true);
}
});
it('allows a resource with underscore after the first character', async () => {
const r = room();
await ctx.connectFocus(r);
await setStrictMode(false);
try {
const c = await ctx.connect();
const presence = await c.joinRoom(r, 'abc_123');
assert.ok(isAvailablePresence(presence),
'resource with internal underscore must be allowed');
} finally {
await setStrictMode(true);
}
});
// Format-invalid resources are rejected by the format check before the
// UUID-prefix check runs, so these tests work regardless of strict mode.
it('rejects a resource starting with an underscore', async () => {
const r = room();
await ctx.connectFocus(r);
const c = await ctx.connect();
const presence = await c.joinRoom(r, '_invalid');
assert.equal(presence.attrs.type, 'error',
'resource starting with underscore must be rejected');
assert.ok(
presence.getChild('error')?.getChild('not-allowed'),
'error stanza must contain <not-allowed/>'
);
});
it('rejects a resource containing a hyphen', async () => {
const r = room();
await ctx.connectFocus(r);
const c = await ctx.connect();
const presence = await c.joinRoom(r, 'invalid-nick');
assert.equal(presence.attrs.type, 'error',
'resource with hyphen must be rejected');
assert.ok(
presence.getChild('error')?.getChild('not-allowed'),
'error stanza must contain <not-allowed/>'
);
});
// ── Anonymous strict mode ─────────────────────────────────────────────────
//
// anonymous_strict = true is the default in the test config, so no toggle
// is needed here.
it('allows resource matching UUID prefix', async () => {
const r = room();
await ctx.connectFocus(r);
const c = await ctx.connect();
// joinRoom defaults to the UUID prefix when no nick is supplied.
const presence = await c.joinRoom(r);
assert.ok(isAvailablePresence(presence),
'resource matching UUID prefix must be allowed');
});
it('rejects resource not matching UUID prefix', async () => {
const r = room();
await ctx.connectFocus(r);
const c = await ctx.connect();
const presence = await c.joinRoom(r, 'wrongnick');
assert.equal(presence.attrs.type, 'error',
'resource not matching UUID prefix must be rejected');
assert.ok(
presence.getChild('error')?.getChild('not-allowed'),
'error stanza must contain <not-allowed/>'
);
});
// ── Nick (resource) changes ───────────────────────────────────────────────
//
// The MUC resource is the stable per-session identity and must not change
// after join. Display-name changes travel in the presence <nick/> child and
// keep the same resource, so they must still be allowed.
it('rejects a nick change to a different resource', async () => {
const r = room();
await ctx.connectFocus(r);
const c = await ctx.connect();
const joined = await c.joinRoom(r);
assert.ok(isAvailablePresence(joined), 'initial join must succeed');
// Send a presence to a different MUC resource, i.e. a nick change.
await c.sendPresence(`${r}/rotatednick`);
const presence = await c.waitForPresenceFrom(`${r}/rotatednick`, { type: 'error' });
assert.equal(presence.attrs.type, 'error', 'nick change must be rejected');
assert.ok(
presence.getChild('error')?.getChild('not-allowed'),
'error stanza must contain <not-allowed/>'
);
});
it('allows a presence update that keeps the same resource', async () => {
const r = room();
await ctx.connectFocus(r);
const c = await ctx.connect();
const joined = await c.joinRoom(r);
assert.ok(isAvailablePresence(joined), 'initial join must succeed');
// A presence update to the same MUC resource (e.g. a display-name change)
// is not a nick change and must be allowed through.
await c.sendPresence(`${r}/${c.nick}`);
const presence = await c.waitForPresenceFrom(`${r}/${c.nick}`);
assert.notEqual(presence.attrs.type, 'error',
'presence update keeping the same resource must be allowed');
});
});

View File

@@ -0,0 +1,242 @@
import assert from 'assert';
import { prosodyShell } from './helpers/prosody_shell.js';
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 = () => `muc-size-${++_roomCounter}@${CONFERENCE}`;
/**
* Query GET /room-size with the given params.
*
* @param {object} params e.g. { room: 'name', domain: 'localhost' }
* @returns {Promise<Response>}
*/
function getRoomSize(params) {
const qs = new URLSearchParams(params).toString();
return fetch(`${BASE}/room-size?${qs}`);
}
/**
* Query GET /room with the given params.
*
* @param {object} params e.g. { room: 'name', domain: 'localhost' }
* @returns {Promise<Response>}
*/
function getRoom(params) {
const qs = new URLSearchParams(params).toString();
return fetch(`${BASE}/room?${qs}`);
}
/**
* Query GET /sessions.
*
* @returns {Promise<Response>}
*/
function getSessions() {
return fetch(`${BASE}/sessions`);
}
describe('mod_muc_size', () => {
before(async () => {
// Confirm the module is loaded; surface Prosody errors early.
const out = await prosodyShell('module:reload("muc_size", "localhost")');
console.log('[prosody] module:reload muc_size:', out);
});
let ctx;
beforeEach(() => {
ctx = createTestContext();
});
afterEach(() => ctx.cleanup());
// -------------------------------------------------------------------------
// GET /room-size
// -------------------------------------------------------------------------
describe('GET /room-size', () => {
it('returns 400 when query string is missing', async () => {
const res = await fetch(`${BASE}/room-size`);
assert.equal(res.status, 400);
});
it('returns 404 when room does not exist', async () => {
const res = await getRoomSize({ room: 'nonexistent-room',
domain: 'localhost' });
assert.equal(res.status, 404);
});
it('returns 200 with participant count for an existing room', async () => {
const r = room();
await ctx.connectFocus(r);
const c1 = await ctx.connect();
const c2 = await ctx.connect();
await c1.joinRoom(r);
await c2.joinRoom(r);
// Extract local part (before @).
const roomName = r.split('@')[0];
const res = await getRoomSize({ room: roomName,
domain: 'localhost' });
assert.equal(res.status, 200, 'expected 200 for existing room');
const body = await res.json();
assert.ok(typeof body.participants === 'number',
'response must have numeric participants field');
// Two regular clients joined (focus is whitelisted/not counted by
// mod_muc_size which subtracts 1 for focus). Expect at least 1.
assert.ok(body.participants >= 1,
`expected participants >= 1, got ${body.participants}`);
});
it('participant count decreases after a client leaves', async () => {
const r = room();
await ctx.connectFocus(r);
const c1 = await ctx.connect();
await c1.joinRoom(r);
// The global muc_max_occupants = 2 would block a 3rd join, so raise
// the per-room limit before the other clients connect.
await setRoomMaxOccupants(r, 5);
const c2 = await ctx.connect();
const c3 = await ctx.connect();
await c2.joinRoom(r);
await c3.joinRoom(r);
const roomName = r.split('@')[0];
// 3 regular clients in room (focus not counted by mod_muc_size)
const res1 = await getRoomSize({ room: roomName,
domain: 'localhost' });
assert.equal(res1.status, 200);
const { participants: count1 } = await res1.json();
// Disconnect one client and re-query.
await c3.disconnect();
// Give Prosody a moment to process the departure.
await new Promise(resolve => setTimeout(resolve, 200));
const res2 = await getRoomSize({ room: roomName,
domain: 'localhost' });
assert.equal(res2.status, 200);
const { participants: count2 } = await res2.json();
assert.ok(count2 < count1,
`count should drop after disconnect (before=${count1}, after=${count2})`);
});
});
// -------------------------------------------------------------------------
// GET /room
// -------------------------------------------------------------------------
describe('GET /room', () => {
it('returns 400 when query string is missing', async () => {
const res = await fetch(`${BASE}/room`);
assert.equal(res.status, 400);
});
it('returns 404 when room does not exist', async () => {
const res = await getRoom({ room: 'nonexistent-room',
domain: 'localhost' });
assert.equal(res.status, 404);
});
it('returns 200 with occupant array for an existing room', async () => {
const r = room();
await ctx.connectFocus(r);
const c1 = await ctx.connect();
await c1.joinRoom(r);
const roomName = r.split('@')[0];
const res = await getRoom({ room: roomName,
domain: 'localhost' });
assert.equal(res.status, 200, 'expected 200 for existing room');
const body = await res.json();
assert.ok(Array.isArray(body), 'response body should be a JSON array');
});
it('occupant objects have jid, email, display_name fields', async () => {
const r = room();
await ctx.connectFocus(r);
const c1 = await ctx.connect();
await c1.joinRoom(r);
const roomName = r.split('@')[0];
const res = await getRoom({ room: roomName,
domain: 'localhost' });
assert.equal(res.status, 200);
const occupants = await res.json();
for (const occ of occupants) {
assert.ok('jid' in occ, 'occupant must have jid');
assert.ok('email' in occ, 'occupant must have email');
assert.ok('display_name' in occ, 'occupant must have display_name');
}
});
});
// -------------------------------------------------------------------------
// GET /sessions
// -------------------------------------------------------------------------
describe('GET /sessions', () => {
it('returns a parseable integer', async () => {
const res = await getSessions();
assert.equal(res.status, 200);
const text = await res.text();
const n = parseInt(text, 10);
assert.ok(!isNaN(n), `expected integer, got: ${text}`);
});
it('session count increases when a client connects', async () => {
const res1 = await getSessions();
const before = parseInt(await res1.text(), 10);
await ctx.connect(); // adds one session
const res2 = await getSessions();
const after = parseInt(await res2.text(), 10);
assert.ok(after > before,
`session count should increase (before=${before}, after=${after})`);
});
});
});

View File

@@ -0,0 +1,237 @@
import assert from 'assert';
import { mintAsapToken } from './helpers/jwt.js';
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
import { isAvailablePresence } from './helpers/xmpp_utils.js';
// Uses a dedicated MUC component that has muc_wait_for_host loaded and no
// muc_meeting_id, so there is no jicofo lock — a JWT-authenticated client can
// join and create a room directly without focus unlocking it first.
// muc_mapper_domain_base = "conference.localhost" causes muc_wait_for_host to
// use lobby.conference.localhost as the lobby component.
const CONFERENCE = 'conference-waitforhost.localhost';
const MUC_NS = 'http://jabber.org/protocol/muc#user';
let _roomCounter = 0;
const room = () => `wfh-${++_roomCounter}@${CONFERENCE}`;
describe('mod_muc_wait_for_host', () => {
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.
* @param {string} roomJid Full room JID.
* @returns {Promise<XmppTestClient>}
*/
async function focusJoin(roomJid) {
const c = await joinWithFocus(roomJid);
clients.push(c);
return c;
}
/**
* Connects as a JWT-authenticated user. The session receives auth_token,
* making it recognised as a host by muc_wait_for_host.
*
* @param {string} roomName Local part of the room JID (e.g. 'wfh-1').
* @returns {Promise<XmppTestClient>}
*/
async function connectAsHost(roomName) {
const token = mintAsapToken({ room: roomName });
const c = await createXmppClient({ params: { token } });
clients.push(c);
return c;
}
// -------------------------------------------------------------------------
// No host present — lobby gate
// -------------------------------------------------------------------------
describe('no host present', () => {
it('guest is blocked with registration-required', async () => {
const r = room();
// Focus creates and unlocks the room first. Without this the guest
// would be the room creator and Prosody grants creators a free pass
// (XEP-0045 §10.1.3 locked-room bypass) regardless of members_only.
await focusJoin(r);
const guest = await connect();
const presence = await guest.joinRoom(r);
assert.equal(presence.attrs.type, 'error', 'join must be rejected');
assert.ok(
presence.getChild('error')?.getChild('registration-required'),
'error condition must be registration-required'
);
});
it('focus joining does not satisfy the host requirement', async () => {
const r = room();
// Focus is a Prosody admin and bypasses the pre-join check, so it
// joins successfully. But it is not counted as an authenticated host,
// so the next anonymous guest should still be blocked.
await focusJoin(r);
const guest = await connect();
const presence = await guest.joinRoom(r);
assert.equal(presence.attrs.type, 'error',
'join must be rejected even when focus is in the room');
assert.ok(
presence.getChild('error')?.getChild('registration-required'),
'error condition must be registration-required'
);
});
});
// -------------------------------------------------------------------------
// Room does not exist yet — creator bypass
// -------------------------------------------------------------------------
describe('room does not exist yet', () => {
it('anonymous guest creates the room and is admitted despite no host present', async () => {
const r = room();
// No focus, no prior occupant: the guest's join is the stanza that
// creates the MUC room. Prosody grants room creators a free pass
// (XEP-0045 §10.1.3 locked-room bypass) before muc_wait_for_host's
// muc-occupant-pre-join check can block it, and joinRoom() submits
// the empty config form to unlock the room on status code 201.
// This is why production locks room creation to jicofo alone
// (restrict_room_creation + the mod_muc_meeting_id jicofo lock) —
// this isolated test component has neither.
const guest = await connect();
const presence = await guest.joinRoom(r);
assert.ok(isAvailablePresence(presence),
'guest should be admitted: it created the room, so the host check never blocked it');
});
});
// -------------------------------------------------------------------------
// Host present — guests admitted
// -------------------------------------------------------------------------
describe('host present', () => {
it('guest is admitted when a JWT host has already joined', async () => {
const r = room();
const roomName = r.split('@')[0];
const host = await connectAsHost(roomName);
await host.joinRoom(r);
const guest = await connect();
const presence = await guest.joinRoom(r);
assert.ok(isAvailablePresence(presence),
'guest should be admitted after JWT host joins');
});
it('room.has_host is cached: guest is admitted after the host leaves', async () => {
const r = room();
const roomName = r.split('@')[0];
const host = await connectAsHost(roomName);
await host.joinRoom(r);
// A guest joins while the host is present. This sets room.has_host = true
// via the existing-occupants loop in muc-occupant-pre-join.
const guest1 = await connect();
await guest1.joinRoom(r);
// Host leaves. guest1 remains, keeping the room alive.
await host.disconnect();
// A new guest should still join because room.has_host is cached.
const guest2 = await connect();
const presence = await guest2.joinRoom(r);
assert.ok(isAvailablePresence(presence),
'guest should join after host leaves because room.has_host is cached');
});
});
// -------------------------------------------------------------------------
// Host arrives after lobby is triggered
// -------------------------------------------------------------------------
it('JWT host arriving after lobby is triggered opens the room for new guests', async () => {
const r = room();
const roomName = r.split('@')[0];
// Focus creates and unlocks the room so the guest is not the creator
// (XEP-0045 locked-room bypass would otherwise admit the first joiner).
await focusJoin(r);
// First guest triggers the lobby; the join is blocked.
const guest1 = await connect();
const blocked = await guest1.joinRoom(r);
assert.equal(blocked.attrs.type, 'error', 'pre-host guest must be blocked');
// JWT host joins — lobby is destroyed and room opens.
const host = await connectAsHost(roomName);
await host.joinRoom(r);
// A new guest can now join freely.
const guest2 = await connect();
const presence = await guest2.joinRoom(r);
assert.ok(isAvailablePresence(presence),
'guest should be admitted after JWT host arrives');
});
// -------------------------------------------------------------------------
// Auto-owner
// -------------------------------------------------------------------------
it('JWT authenticated user receives owner affiliation on join', async () => {
const r = room();
const roomName = r.split('@')[0];
const host = await connectAsHost(roomName);
await host.joinRoom(r);
// muc-occupant-joined fires after the join and calls room:set_affiliation('owner'),
// which sends a subsequent presence update carrying the new affiliation.
const update = await host.waitForPresence(
p => p.getChild('x', MUC_NS)?.getChild('item')?.attrs.affiliation === 'owner'
);
assert.ok(update,
'JWT host should receive an owner-affiliation presence update after joining');
});
});

View File

@@ -0,0 +1,425 @@
import assert from 'assert';
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
// mod_polls_component is loaded as the "polls.localhost" component (see
// docker/prosody.cfg.lua). It hooks message/host for new-poll / answer-poll
// json-messages, keeps per-room poll state (installed on conference.localhost
// through process_host_module) and re-broadcasts poll updates to every
// occupant. The sender must be an occupant of the room, located from
// jitsi_web_query_room (set by mod_jitsi_session from the ?room= param).
const CONFERENCE = 'conference.localhost';
const POLLS_COMPONENT = 'polls.localhost';
const JITMEET_NS = 'http://jitsi.org/jitmeet';
let _roomCounter = 0;
const nextRoom = () => `polls-${++_roomCounter}@${CONFERENCE}`;
let _pollCounter = 0;
const nextPollId = () => `poll-${++_pollCounter}`;
/**
* True when msg is a polls broadcast from the polls component.
*
* @param {object} msg Message stanza.
* @returns {boolean}
*/
function isPollsBroadcast(msg) {
return msg.name === 'message'
&& msg.attrs.from === POLLS_COMPONENT
&& msg.getChild('json-message', JITMEET_NS) !== undefined;
}
/**
* Parses the JSON payload carried by a polls broadcast stanza, or null.
*
* @param {object} msg Message stanza.
* @returns {object|null}
*/
function parsePoll(msg) {
const text = msg.getChild('json-message', JITMEET_NS)?.getText();
try {
return text ? JSON.parse(text) : null;
} catch {
return null;
}
}
/**
* Waits for a polls broadcast carrying the given command.
*
* @param {object} client Connected client.
* @param {string} command 'new-poll' | 'answer-poll' | 'old-polls'
* @param {number} [timeout=3000]
* @returns {Promise<object>} The parsed poll payload.
*/
async function waitForPollCommand(client, command, timeout = 3000) {
const msg = await client.waitForMessage(
s => isPollsBroadcast(s) && parsePoll(s)?.command === command, timeout);
return parsePoll(msg);
}
/**
* Asserts that NO polls broadcast arrives within the given window.
*
* @param {object} client
* @param {number} [timeout=1000]
*/
async function assertNoPollBroadcast(client, timeout = 1000) {
try {
await client.waitForMessage(isPollsBroadcast, timeout);
throw new Error('received unexpected polls broadcast');
} catch (err) {
if (err.message.includes('Timeout')) {
return; // expected — no broadcast arrived
}
throw err;
}
}
describe('mod_polls_component', () => {
let clients;
beforeEach(() => {
clients = [];
});
afterEach(async () => {
await Promise.all(clients.map(c => c.disconnect()));
});
/**
* Focus-joins the room (creating and unlocking it) and registers the client
* for cleanup. conference.localhost has mod_muc_meeting_id, so a room must be
* created by focus before regular clients can join.
*
* @param {string} roomJid Full room JID.
* @returns {Promise<object>}
*/
async function focusJoin(roomJid) {
const c = await joinWithFocus(roomJid);
clients.push(c);
return c;
}
/**
* Connects a client with ?room=<roomName> (so mod_jitsi_session sets
* jitsi_web_query_room), joins the room, and registers it for cleanup.
*
* @param {string} roomJid Full room JID.
* @returns {Promise<object>}
*/
async function joinParticipant(roomJid) {
const roomName = roomJid.split('@')[0];
const c = await createXmppClient({ params: { room: roomName } });
clients.push(c);
await c.joinRoom(roomJid);
return c;
}
// ── Poll creation ───────────────────────────────────────────────────────
describe('poll creation', () => {
it('broadcasts a new poll to all occupants', async () => {
const r = nextRoom();
await focusJoin(r);
const creator = await joinParticipant(r);
const other = await joinParticipant(r);
const pollId = nextPollId();
creator.createPoll(POLLS_COMPONENT, pollId, 'Pick one', [
{ name: 'Option A' },
{ name: 'Option B' }
]);
const [ atCreator, atOther ] = await Promise.all([
waitForPollCommand(creator, 'new-poll'),
waitForPollCommand(other, 'new-poll')
]);
for (const poll of [ atCreator, atOther ]) {
assert.equal(poll.pollId, pollId);
assert.equal(poll.question, 'Pick one');
assert.deepEqual(poll.answers.map(a => a.name), [ 'Option A', 'Option B' ]);
assert.ok(poll.senderId, 'broadcast must carry the server-stamped senderId');
}
});
it('relayed answers contain only names — extra fields are stripped', async () => {
// Regression test: the component must re-broadcast only the sanitized
// answers ({ name }). A type-confused voters field (or any other extra
// field) on an answer must not survive to participants, otherwise it
// reaches the client reducer / results render which assume voters is
// an array.
const r = nextRoom();
await focusJoin(r);
const creator = await joinParticipant(r);
const pollId = nextPollId();
creator.createPoll(POLLS_COMPONENT, pollId, 'Pick one', [
{ name: 'Option A',
voters: 1,
evil: 'payload' },
{ name: 'Option B',
voters: [ { id: 'spoofed',
name: 'spoofed' } ] }
]);
const poll = await waitForPollCommand(creator, 'new-poll');
assert.equal(poll.answers.length, 2);
assert.deepEqual(poll.answers.map(a => a.name), [ 'Option A', 'Option B' ]);
for (const answer of poll.answers) {
assert.strictEqual(answer.voters, undefined, 'voters must be stripped from the broadcast');
assert.strictEqual(answer.evil, undefined, 'extra fields must be stripped from the broadcast');
assert.deepEqual(Object.keys(answer), [ 'name' ], 'answers must expose only the name field');
}
});
it('a duplicate pollId is rejected with an error', async () => {
const r = nextRoom();
await focusJoin(r);
const creator = await joinParticipant(r);
const pollId = nextPollId();
creator.createPoll(POLLS_COMPONENT, pollId, 'First', [ { name: 'A' } ]);
await waitForPollCommand(creator, 'new-poll');
// Same pollId — the module replies with an error and does not
// broadcast a second new-poll.
creator.createPoll(POLLS_COMPONENT, pollId, 'Second', [ { name: 'A' } ]);
const errorReply = await creator.waitForMessage(s => s.attrs.type === 'error');
assert.ok(errorReply.getChild('error'), 'duplicate pollId must be rejected with an error stanza');
});
});
// ── Poll answering ──────────────────────────────────────────────────────
describe('poll answering', () => {
it('broadcasts an answer to all occupants', async () => {
const r = nextRoom();
await focusJoin(r);
const creator = await joinParticipant(r);
const voter = await joinParticipant(r);
const pollId = nextPollId();
creator.createPoll(POLLS_COMPONENT, pollId, 'Pick one', [
{ name: 'Option A' },
{ name: 'Option B' }
]);
await Promise.all([
waitForPollCommand(creator, 'new-poll'),
waitForPollCommand(voter, 'new-poll')
]);
voter.answerPoll(POLLS_COMPONENT, pollId, [ true, false ]);
const [ atCreator, atVoter ] = await Promise.all([
waitForPollCommand(creator, 'answer-poll'),
waitForPollCommand(voter, 'answer-poll')
]);
for (const answer of [ atCreator, atVoter ]) {
assert.equal(answer.pollId, pollId);
assert.deepEqual(answer.answers, [ true, false ]);
assert.ok(answer.senderId, 'answer broadcast must carry the server-stamped senderId');
}
});
it('an answer for an unknown poll is ignored', async () => {
const r = nextRoom();
await focusJoin(r);
const voter = await joinParticipant(r);
voter.answerPoll(POLLS_COMPONENT, nextPollId(), [ true ]);
await assertNoPollBroadcast(voter);
});
});
// ── History delivery ──────────────────────────────────────────────────────
describe('history delivery', () => {
it('a client joining after a poll was created receives it via old-polls', async () => {
const r = nextRoom();
await focusJoin(r);
const creator = await joinParticipant(r);
const pollId = nextPollId();
creator.createPoll(POLLS_COMPONENT, pollId, 'History question', [
{ name: 'A' },
{ name: 'B' }
]);
await waitForPollCommand(creator, 'new-poll');
// A late joiner receives the current poll state as an old-polls
// message right after joining.
const late = await joinParticipant(r);
const history = await waitForPollCommand(late, 'old-polls');
assert.ok(Array.isArray(history.polls), 'old-polls must carry a polls array');
const stored = history.polls.find(p => p.pollId === pollId);
assert.ok(stored, 'the previously created poll must be present in history');
assert.equal(stored.question, 'History question');
assert.deepEqual(stored.answers.map(a => a.name), [ 'A', 'B' ]);
});
});
// ── Validation ──────────────────────────────────────────────────────────
describe('validation', () => {
/**
* Sends a raw new-poll-shaped payload, then asserts no broadcast arrives
* and the module is still functional afterwards.
*
* @param {object} rawPayload The payload object to serialize and send.
*/
async function assertRejected(rawPayload) {
const r = nextRoom();
await focusJoin(r);
const creator = await joinParticipant(r);
creator.sendPollsMessage(POLLS_COMPONENT, JSON.stringify(rawPayload));
await assertNoPollBroadcast(creator);
// The module must still process a well-formed poll afterwards.
const goodId = nextPollId();
creator.createPoll(POLLS_COMPONENT, goodId, 'Still working', [ { name: 'A' } ]);
const poll = await waitForPollCommand(creator, 'new-poll');
assert.equal(poll.pollId, goodId);
}
it('new-poll with a non-string answer name is ignored', () => assertRejected({
answers: [ { name: 42 } ],
command: 'new-poll',
pollId: nextPollId(),
question: 'Q',
type: 'polls'
}));
it('new-poll with an empty answers array is ignored', () => assertRejected({
answers: [],
command: 'new-poll',
pollId: nextPollId(),
question: 'Q',
type: 'polls'
}));
it('new-poll with a missing question is ignored', () => assertRejected({
answers: [ { name: 'A' } ],
command: 'new-poll',
pollId: nextPollId(),
type: 'polls'
}));
it('answer-poll with non-boolean answers is ignored', async () => {
const r = nextRoom();
await focusJoin(r);
const creator = await joinParticipant(r);
const pollId = nextPollId();
creator.createPoll(POLLS_COMPONENT, pollId, 'Pick one', [ { name: 'A' }, { name: 'B' } ]);
await waitForPollCommand(creator, 'new-poll');
// answers must be booleans; a numeric answer must be rejected.
creator.sendPollsMessage(POLLS_COMPONENT, JSON.stringify({
answers: [ 1, 0 ],
command: 'answer-poll',
pollId,
type: 'polls'
}));
await assertNoPollBroadcast(creator);
});
it('invalid JSON is ignored cleanly', async () => {
const r = nextRoom();
await focusJoin(r);
const creator = await joinParticipant(r);
creator.sendPollsMessage(POLLS_COMPONENT, '{not valid json{{');
await assertNoPollBroadcast(creator);
// Module must still be functional after the bad message.
const goodId = nextPollId();
creator.createPoll(POLLS_COMPONENT, goodId, 'After bad JSON', [ { name: 'A' } ]);
const poll = await waitForPollCommand(creator, 'new-poll');
assert.equal(poll.pollId, goodId);
});
it('an oversized payload is rejected', async () => {
const r = nextRoom();
await focusJoin(r);
const creator = await joinParticipant(r);
// The payload size is checked (>= 1024 bytes) before decoding.
creator.createPoll(POLLS_COMPONENT, nextPollId(), 'x'.repeat(1100), [ { name: 'A' } ]);
await assertNoPollBroadcast(creator);
// A normal poll must still go through afterwards.
const goodId = nextPollId();
creator.createPoll(POLLS_COMPONENT, goodId, 'Normal size', [ { name: 'A' } ]);
const poll = await waitForPollCommand(creator, 'new-poll');
assert.equal(poll.pollId, goodId);
});
});
// ── Sender must be an occupant ────────────────────────────────────────────
describe('sender authorization', () => {
it('a connected non-occupant cannot create a poll', async () => {
const r = nextRoom();
await focusJoin(r);
const roomName = r.split('@')[0];
const nonOccupant = await createXmppClient({ params: { room: roomName } });
clients.push(nonOccupant);
// Deliberately do NOT join the room — the sender is not an occupant.
nonOccupant.createPoll(POLLS_COMPONENT, nextPollId(), 'Q', [ { name: 'A' } ]);
await assertNoPollBroadcast(nonOccupant);
});
});
});

View File

@@ -0,0 +1,298 @@
import { xml } from '@xmpp/client';
import assert from 'assert';
import { mintToken } from './helpers/jwt.js';
import { createXmppClient } from './helpers/xmpp_client.js';
const CONFERENCE = 'conference-identity.localhost';
let _roomCounter = 0;
const room = () => `presence-identity-${++_roomCounter}@${CONFERENCE}`;
/**
* Connects to the hs256.localhost VirtualHost with a signed token.
* mod_presence_identity is loaded on this VirtualHost.
*
* @param {string} roomJid full room JID, e.g. 'room@conference-identity.localhost'
* @param {object} [tokenPayload] JWT payload overrides (e.g. { context: { user: ... } }).
* @returns {Promise<XmppTestClient>}
*/
function hs256Client(roomJid, tokenPayload = {}) {
const roomName = roomJid.split('@')[0];
const token = mintToken({ room: roomName,
...tokenPayload });
return createXmppClient({ domain: 'hs256.localhost',
params: { token } });
}
/**
* Returns the first presence stanza in the observer's queue that comes from
* the given room and is NOT the observer's own self-presence.
*
* @param {XmppTestClient} observer
* @param {string} roomJid
* @param {string} observerNick
*/
function waitForOtherPresence(observer, roomJid, observerNick) {
return observer.waitForPresence(
p => p.attrs.from?.startsWith(`${roomJid}/`)
&& p.attrs.from !== `${roomJid}/${observerNick}`
);
}
describe('mod_presence_identity', () => {
const clients = [];
afterEach(async () => {
await Promise.all(clients.map(c => c.disconnect()));
clients.length = 0;
});
// -------------------------------------------------------------------------
// identity injection
// -------------------------------------------------------------------------
describe('identity injection', () => {
it('injects <identity><user> from JWT context.user into MUC presence', async () => {
const r = room();
const identity = await hs256Client(r, {
context: { user: { id: 'u1',
name: 'Alice' } }
});
clients.push(identity);
await identity.joinRoom(r);
const observer = await createXmppClient();
clients.push(observer);
await observer.joinRoom(r);
const presence = await waitForOtherPresence(observer, r, observer.nick);
const identityEl = presence.getChild('identity');
assert.ok(identityEl, 'presence must contain <identity>');
const userEl = identityEl.getChild('user');
assert.ok(userEl, '<identity> must contain <user>');
assert.strictEqual(userEl.getChild('id')?.text(), 'u1', 'id must match token');
assert.strictEqual(userEl.getChild('name')?.text(), 'Alice', 'name must match token');
});
it('adds <group> inside <identity> when context.group is set', async () => {
const r = room();
const identity = await hs256Client(r, {
context: {
user: { id: 'u2' },
group: 'mygroup'
}
});
clients.push(identity);
await identity.joinRoom(r);
const observer = await createXmppClient();
clients.push(observer);
await observer.joinRoom(r);
const presence = await waitForOtherPresence(observer, r, observer.nick);
const identityEl = presence.getChild('identity');
assert.ok(identityEl, 'presence must contain <identity>');
assert.strictEqual(identityEl.getChild('group')?.text(), 'mygroup',
'<group> must match token context.group');
});
it('does not inject <identity> when token has no context.user', async () => {
const r = room();
// Valid token but no context field → no jitsi_meet_context_user on session.
const noContext = await hs256Client(r);
clients.push(noContext);
await noContext.joinRoom(r);
const observer = await createXmppClient();
clients.push(observer);
await observer.joinRoom(r);
const presence = await waitForOtherPresence(observer, r, observer.nick);
assert.ok(!presence.getChild('identity'),
'presence must not contain <identity> when token has no context.user');
});
it('injects <identity> in presence updates (not only on join)', async () => {
const r = room();
const observer = await createXmppClient();
clients.push(observer);
await observer.joinRoom(r);
const identity = await hs256Client(r, {
context: { user: { id: 'u3',
name: 'Bob' } }
});
clients.push(identity);
await identity.joinRoom(r);
// Consume the join presence.
const joinPresence = await waitForOtherPresence(observer, r, observer.nick);
assert.ok(joinPresence.getChild('identity'), 'join presence must have <identity>');
// Send a presence update (no <x muc> element — this is a regular update,
// not a join). Both pre-presence/bare and pre-presence/full hooks fire.
const identityNick = joinPresence.attrs.from.split('/')[1];
await identity.sendPresence(`${r}/${identityNick}`);
// The update must also carry <identity>.
const updatePresence = await observer.waitForPresence(
p => p.attrs.from === `${r}/${identityNick}`
);
assert.ok(updatePresence.getChild('identity'),
'presence update must also carry <identity>');
});
});
// -------------------------------------------------------------------------
// malformed JWT context.user keys
// -------------------------------------------------------------------------
//
// update_presence_identity() iterates over JWT context.user keys and uses
// each key as an XML element name: presence:tag(key):text(tostring(value)).
// Fixed in 2e8980d2b: keys that do not match the XML name pattern
// ^[%a_][%w%-%.%:_]*$ are now silently skipped before reaching tag().
// This test verifies that bad keys are dropped and valid keys are preserved.
describe('malformed JWT context.user keys', () => {
it('survives context.user keys that contain XML-special characters', async () => {
const r = room();
// Key names with '<' and '>' — these become XML element names.
// Prosody / LuaXML may escape, drop, or crash on them.
const identity = await hs256Client(r, {
context: {
user: {
id: 'safe-id',
'bad<key>': 'value'
}
}
});
clients.push(identity);
await identity.joinRoom(r);
const observer = await createXmppClient();
clients.push(observer);
await observer.joinRoom(r);
// If Prosody crashed the hook the join presence would never arrive.
const presence = await waitForOtherPresence(observer, r, observer.nick);
assert.ok(presence, 'join presence must arrive even with unusual key names');
// The safe 'id' element must be present and correct.
const identityEl = presence.getChild('identity');
assert.ok(identityEl, 'presence must still contain <identity>');
assert.strictEqual(
identityEl.getChild('user')?.getChild('id')
?.text(),
'safe-id',
'safe id field must be present and unmodified'
);
});
});
// -------------------------------------------------------------------------
// spoofing prevention
// -------------------------------------------------------------------------
describe('spoofing prevention', () => {
it('strips a client-supplied <identity> when session has no context.user', async () => {
const r = room();
// Token with no context → no jitsi_meet_context_user on session.
// The client manually crafts an <identity> element in the join presence.
const intruder = await hs256Client(r);
clients.push(intruder);
await intruder.joinRoom(r, undefined, {
extensions: [
xml('identity', {},
xml('user', {},
xml('id', {}, 'hacker'),
xml('name', {}, 'Evil Corp')
)
)
]
});
const observer = await createXmppClient();
clients.push(observer);
await observer.joinRoom(r);
const presence = await waitForOtherPresence(observer, r, observer.nick);
assert.ok(!presence.getChild('identity'),
'module must strip the hand-crafted <identity> when no context.user is set');
});
it('strips a client-supplied <identity> and replaces it with the real one', async () => {
const r = room();
// Token with real context.user but also a crafted <identity> in the stanza.
// The module must strip the crafted one and inject the real one from the session.
const identity = await hs256Client(r, {
context: { user: { id: 'real-id',
name: 'Real User' } }
});
clients.push(identity);
await identity.joinRoom(r, undefined, {
extensions: [
xml('identity', {},
xml('user', {},
xml('id', {}, 'spoofed-id'),
xml('name', {}, 'Spoofed Name')
)
)
]
});
const observer = await createXmppClient();
clients.push(observer);
await observer.joinRoom(r);
const presence = await waitForOtherPresence(observer, r, observer.nick);
const identityEl = presence.getChild('identity');
assert.ok(identityEl, 'presence must contain <identity>');
assert.strictEqual(identityEl.getChild('user')?.getChild('id')
?.text(), 'real-id',
'id must be from the verified JWT context, not the spoofed stanza');
assert.strictEqual(identityEl.getChild('user')?.getChild('name')
?.text(), 'Real User',
'name must be from the verified JWT context, not the spoofed stanza');
});
});
});

View File

@@ -0,0 +1,217 @@
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-crlf-${++_roomCounter}@conference.localhost`;
// Header name that mod_filter_iq_rayo adds to the forwarded dial IQ from the
// JWT context. Its value comes from session.jitsi_meet_context_user.id which
// Prosody decodes directly from the JWT (not via XML), so any CRLF in the
// token claim lands in the Lua string and flows through util.stanza
// serialization without the XML-layer whitespace normalisation that applies
// to attribute-sourced values.
const INITIATOR_HEADER = 'X-outbound-call-initiator-user';
/**
* 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();
});
}
describe('mod_filter_iq_rayo — CRLF escaping in re-serialized headers', () => {
const clients = [];
afterEach(async () => {
await Promise.all(clients.map(c => c.disconnect()));
clients.length = 0;
});
/**
* Creates a test client whose JWT embeds userId as context.user.id.
* Joins a fresh room (focus first so the jicofo lock is lifted).
*
* @param {string} userId Value to put in context.user.id.
* @returns {Promise<{client: object, room: string}>}
*/
async function setup(userId) {
const room = nextRoom();
const roomName = room.split('@')[0];
const token = mintAsapToken({
room: roomName,
context: {
features: { 'outbound-call': true },
...userId === undefined ? {} : { user: { id: userId } }
}
});
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 };
}
// ── helper ────────────────────────────────────────────────────────────────
/**
* Sends a well-formed dial IQ and waits for at most one forwarded IQ to
* appear in the test observer. Returns the first captured dial IQ entry.
*
* @param {object} client XmppTestClient
* @param {string} room Full room JID
* @returns {Promise<object>}
*/
async function sendAndCapture(client, room) {
await clearDialIqs();
await client.sendRayoIq(room);
// Give Prosody time to route the IQ to the MUC component.
await new Promise(r => setTimeout(r, 300));
const iqs = await getDialIqs();
assert.strictEqual(iqs.length, 1, 'dial IQ must reach the MUC component');
return iqs[0];
}
// ── CRLF in JWT user id → X-outbound-call-initiator-user header ──────────
/**
* Asserts that the raw XML captured by the test observer contains no raw
* CR or LF bytes in any attribute value.
*
* @param {string} rawXml The raw_xml field from the observer entry.
*/
function assertNoRawCrlf(rawXml) {
assert.ok(!/[\r\n]/.test(rawXml),
'raw XML must contain no raw CR or LF bytes');
}
it('sanitises CRLF in JWT user-id when building the initiator header', async () => {
// CRLF embedded in the token claim. Prosody decodes the JWT directly
// (not via XML), so the Lua string retains the raw \r\n.
// mod_filter_iq_rayo normalises the value via sanitize_header_value()
// before inserting it into the forwarded stanza attribute.
// mod_test_observer captures tostring(stanza) — the serialized bytes
// that would reach the wire — so asserting no raw CR/LF in raw_xml
// directly verifies the normalisation.
const userId = 'legit-user\r\nextra-data';
const { client: c, room } = await setup(userId);
const iq = await sendAndCapture(c, room);
assert.ok(iq.headers?.[INITIATOR_HEADER] !== undefined,
`forwarded dial IQ must contain ${INITIATOR_HEADER}`);
assertNoRawCrlf(iq.raw_xml);
// The CRLF must have been replaced with spaces, not silently dropped.
assert.ok(iq.headers[INITIATOR_HEADER].includes('legit-user'),
'the legitimate part of the user-id must be preserved');
});
it('sanitises bare LF in JWT user-id', async () => {
const { client: c, room } = await setup('user\nextra-data');
const iq = await sendAndCapture(c, room);
assertNoRawCrlf(iq.raw_xml);
});
it('sanitises bare CR in JWT user-id', async () => {
const { client: c, room } = await setup('user\rextra-data');
const iq = await sendAndCapture(c, room);
assertNoRawCrlf(iq.raw_xml);
});
it('passes through a clean user-id unchanged (control)', async () => {
const userId = 'clean-user-id';
const { client: c, room } = await setup(userId);
const iq = await sendAndCapture(c, room);
assert.strictEqual(iq.headers?.[INITIATOR_HEADER], userId,
'a clean user-id must be forwarded verbatim');
assertNoRawCrlf(iq.raw_xml);
});
// ── CRLF sent in a client XML attribute → XML parser normalises it ────────
//
// When the JS client puts \r\n inside an XML attribute value, expat's
// attribute-value normalisation (XML spec §3.3.3) converts each literal
// whitespace character to a space before the Lua application layer ever
// sees the value. Prosody therefore never receives raw CRLF from this
// path; the re-forwarded stanza contains spaces instead.
// These tests confirm that normalisation end-to-end: the JS client sends
// a JvbRoomPassword containing CR/LF, the IQ still passes the filter
// (because JvbRoomName is correct), and the raw_xml captured at the MUC
// component contains no raw CR or LF bytes in any attribute value.
it('XML attribute CRLF is normalised to spaces by expat before reaching Lua', async () => {
// No JWT user context needed here — we are testing the XML-attribute
// path, not the JWT path.
const { client: c, room } = await setup(undefined);
await clearDialIqs();
// @xmpp/client serialises the value as-is into the XML byte stream;
// expat on the Prosody side normalises the literal \r and \n to spaces.
await c.sendRayoIq(room, { roomPassHeader: 'secret\r\nextra-data' });
await new Promise(r => setTimeout(r, 300));
const iqs = await getDialIqs();
assert.strictEqual(iqs.length, 1,
'IQ with CRLF in JvbRoomPassword must still reach the MUC component');
const iq = iqs[0];
// The password value the Lua application received (after expat
// normalisation) must not contain raw CR or LF.
assert.ok(!/[\r\n]/.test(iq.room_pass_header),
'room_pass_header must contain no raw CR/LF after XML attribute normalisation');
// Likewise the serialised wire form must have no raw control bytes.
assert.ok(!/[\r\n]/.test(iq.raw_xml),
'raw_xml must contain no raw CR/LF bytes');
});
});

View File

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

View File

@@ -0,0 +1,336 @@
import assert from 'assert';
import { mintAsapToken, mintSystemToken } from './helpers/jwt.js';
import { sendSystemChatMessage } from './helpers/test_observer.js';
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
const CONFERENCE = 'conference.localhost';
let _roomCounter = 0;
const room = () => `system-chat-${++_roomCounter}@${CONFERENCE}`;
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Creates a room with a focus participant.
*
* @returns {Promise<{roomJid: string, focus: object}>}
*/
async function createRoom() {
const roomJid = room();
const focus = await joinWithFocus(roomJid);
return { roomJid,
focus };
}
/**
* 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_system_chat_message', () => {
// ── Authentication ───────────────────────────────────────────────────────
//
// mod_system_chat_message uses prosody_password_public_key_repo_url as its
// key server — a separate key pair from login tokens (asap_key_server).
// Auth failures return 401.
describe('authentication', () => {
it('returns 401 when Authorization header is absent', async () => {
const { roomJid, focus } = await createRoom();
try {
const { status } = await sendSystemChatMessage(
roomJid, [ focus.jid ], 'hello', null, { omitAuth: true });
assert.strictEqual(status, 401);
} finally {
await disconnectAll(focus);
}
});
it('returns 401 when a login token is used instead of a system token', async () => {
const { roomJid, focus } = await createRoom();
try {
const loginToken = mintAsapToken({ room: '*' });
const { status } = await sendSystemChatMessage(
roomJid, [ focus.jid ], 'hello', loginToken);
assert.strictEqual(status, 401,
'login token must be rejected by the system key server');
} finally {
await disconnectAll(focus);
}
});
it('returns 401 when token is expired', async () => {
const { roomJid, focus } = await createRoom();
try {
const token = mintSystemToken({}, { expired: true });
const { status } = await sendSystemChatMessage(
roomJid, [ focus.jid ], 'hello', token);
assert.strictEqual(status, 401);
} finally {
await disconnectAll(focus);
}
});
});
// ── Parameter validation ─────────────────────────────────────────────────
describe('parameter validation', () => {
it('returns 400 when Content-Type is not application/json', async () => {
const token = mintSystemToken();
const res = await fetch(
'http://localhost:5280/send-system-chat-message',
{
method: 'POST',
headers: {
'Content-Type': 'text/plain',
Authorization: `Bearer ${token}`
},
body: '{"room":"r@conference.localhost","connectionJIDs":[],"message":"hi"}'
}
);
assert.strictEqual(res.status, 400);
});
it('returns 400 when body is empty', async () => {
const token = mintSystemToken();
const res = await fetch(
'http://localhost:5280/send-system-chat-message',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: ''
}
);
assert.strictEqual(res.status, 400);
});
it('returns 400 when message is missing', async () => {
const { roomJid, focus } = await createRoom();
try {
const token = mintSystemToken();
const res = await fetch(
'http://localhost:5280/send-system-chat-message',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ room: roomJid,
connectionJIDs: [ focus.jid ] })
}
);
assert.strictEqual(res.status, 400);
} finally {
await disconnectAll(focus);
}
});
it('returns 400 when connectionJIDs is missing', async () => {
const { roomJid, focus } = await createRoom();
try {
const token = mintSystemToken();
const res = await fetch(
'http://localhost:5280/send-system-chat-message',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ room: roomJid,
message: 'hi' })
}
);
assert.strictEqual(res.status, 400);
} finally {
await disconnectAll(focus);
}
});
it('returns 400 when room is missing', async () => {
const token = mintSystemToken();
const res = await fetch(
'http://localhost:5280/send-system-chat-message',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ connectionJIDs: [],
message: 'hi' })
}
);
assert.strictEqual(res.status, 400);
});
});
// ── Room lookup ──────────────────────────────────────────────────────────
describe('room lookup', () => {
it('returns 404 when room does not exist', async () => {
const token = mintSystemToken();
const { status } = await sendSystemChatMessage(
`nonexistent@${CONFERENCE}`, [], 'hi', token);
assert.strictEqual(status, 404);
});
});
// ── Message delivery ─────────────────────────────────────────────────────
describe('message delivery', () => {
// Filter that matches only system_chat_message payloads, skipping
// metadata broadcasts that arrive on the same json-message namespace.
const isSystemChat = m => {
const el = m.getChild('json-message', 'http://jitsi.org/jitmeet');
if (!el) {
return false;
}
try {
return JSON.parse(el.text()).type === 'system_chat_message';
} catch {
return false;
}
};
it('returns 200 and delivers the message to the recipient', async () => {
const { roomJid, focus } = await createRoom();
const user = await createXmppClient();
await user.joinRoom(roomJid);
try {
const token = mintSystemToken();
const text = 'Hello from system';
const { status } = await sendSystemChatMessage(
roomJid, [ user.jid ], text, token);
assert.strictEqual(status, 200);
const msg = await user.waitForMessage(isSystemChat);
const jsonMessage = msg.getChild('json-message', 'http://jitsi.org/jitmeet');
assert.ok(jsonMessage, 'message must contain a json-message element');
const payload = JSON.parse(jsonMessage.text());
assert.strictEqual(payload.type, 'system_chat_message');
assert.strictEqual(payload.message, text);
} finally {
await disconnectAll(focus, user);
}
});
it('includes displayName in the payload when provided', async () => {
const { roomJid, focus } = await createRoom();
const user = await createXmppClient();
await user.joinRoom(roomJid);
try {
const token = mintSystemToken();
const { status } = await sendSystemChatMessage(
roomJid, [ user.jid ], 'hi', token, { displayName: 'System' });
assert.strictEqual(status, 200);
const msg = await user.waitForMessage(isSystemChat);
const payload = JSON.parse(
msg.getChild('json-message', 'http://jitsi.org/jitmeet').text());
assert.strictEqual(payload.displayName, 'System');
} finally {
await disconnectAll(focus, user);
}
});
it('returns 200 when connectionJIDs contains a JID not in the room (no occupancy validation)', async () => {
// mod_system_chat_message routes stanzas to every JID in
// connectionJIDs without verifying they are room occupants.
// This test documents the current behaviour: the call succeeds
// (200) even for non-occupant JIDs.
const { roomJid, focus } = await createRoom();
try {
const token = mintSystemToken();
const nonOccupant = 'nobody@localhost/ghost-resource';
const { status } = await sendSystemChatMessage(
roomJid, [ nonOccupant ], 'hello', token);
assert.strictEqual(status, 200,
'module must not crash when connectionJIDs contains a non-occupant JID');
} finally {
await disconnectAll(focus);
}
});
it('delivers to multiple recipients', async () => {
const { roomJid, focus } = await createRoom();
const alice = await createXmppClient();
const bob = await createXmppClient();
await alice.joinRoom(roomJid);
await bob.joinRoom(roomJid);
try {
const token = mintSystemToken();
const { status } = await sendSystemChatMessage(
roomJid, [ alice.jid, bob.jid ], 'broadcast', token);
assert.strictEqual(status, 200);
const [ msgA, msgB ] = await Promise.all([
alice.waitForMessage(isSystemChat),
bob.waitForMessage(isSystemChat)
]);
for (const msg of [ msgA, msgB ]) {
const payload = JSON.parse(
msg.getChild('json-message', 'http://jitsi.org/jitmeet').text());
assert.strictEqual(payload.message, 'broadcast');
}
} finally {
await disconnectAll(focus, alice, bob);
}
});
});
});

View File

@@ -0,0 +1,282 @@
import assert from 'assert';
import { prosodyShell } from './helpers/prosody_shell.js';
import { createTestContext } from './helpers/test_context.js';
import { getRoomState } from './helpers/test_observer.js';
import { createXmppClient } from './helpers/xmpp_client.js';
const CONFERENCE = 'conference.localhost';
const JITMEET_NS = 'http://jitsi.org/jitmeet';
// Mirrors docker/prosody.cfg.lua:
// conference_max_minutes = 0.125 -> TIMEOUT = 7.5 s (the meeting's limit)
// conference_grace_seconds = 3 -> room destroyed at 10.5 s
const DURATION_SECONDS = 7.5;
const GRACE_SECONDS = 3;
// A moment safely inside the grace window: past the limit, well before the
// destroy. Used to prove the room outlives its limit by the granted time.
const IN_GRACE_MS = (DURATION_SECONDS + 0.75) * 1000;
// Wait windows with a little slack over the server-side timers. The timing
// info is pushed on join, so it should land almost immediately.
const NOTIFY_WAIT_MS = 5000;
const DESTROY_WAIT_MS = (DURATION_SECONDS + GRACE_SECONDS + 5) * 1000;
// How long to let the meeting run before the late joiner arrives, so its
// `elapsedSeconds` is provably non-zero without risking the 7.5 s destroy.
const LATE_JOIN_DELAY_MS = 3000;
let _roomCounter = 0;
const room = () => `time-restricted-${++_roomCounter}@${CONFERENCE}`;
/**
* Predicate: true for the `time_restricted` json-message the module broadcasts.
*
* @param {object} stanza - A received <message> stanza.
* @returns {boolean}
*/
function isTimeRestrictedMessage(stanza) {
const jsonMsg = stanza.getChild('json-message', JITMEET_NS);
if (!jsonMsg) {
return false;
}
try {
return JSON.parse(jsonMsg.getText())?.type === 'time_restricted';
} catch {
return false;
}
}
/**
* Parses the json-message payload of a time_restricted message stanza.
*
* @param {object} stanza - A received <message> stanza.
* @returns {object}
*/
function payloadOf(stanza) {
return JSON.parse(stanza.getChild('json-message', JITMEET_NS).getText());
}
describe('mod_time_restricted', () => {
let ctx;
// Loaded on demand (not in modules_enabled) so it only governs the rooms in
// this spec and never tears down rooms created by other specs.
before(() => prosodyShell(`module:load("time_restricted", "${CONFERENCE}")`));
after(() => prosodyShell(`module:unload("time_restricted", "${CONFERENCE}")`).catch(() => { /* best effort */ }));
beforeEach(() => {
ctx = createTestContext();
});
afterEach(() => ctx.cleanup());
it('sends a time_restricted json-message to an occupant as soon as they join', async () => {
const r = room();
const focus = await ctx.connectFocus(r);
const c = await ctx.connect();
await c.joinRoom(r);
const msg = await c.waitForMessage(isTimeRestrictedMessage, NOTIFY_WAIT_MS);
// Sent from the bare room JID — that is what makes lib-jitsi-meet surface
// it as a NON_PARTICIPANT_MESSAGE_RECEIVED rather than an endpoint message.
assert.equal(msg.attrs.from, r, 'message must originate from the room JID');
const payload = payloadOf(msg);
assert.equal(payload.type, 'time_restricted');
assert.equal(payload.durationSeconds, DURATION_SECONDS,
'durationSeconds must be the full configured limit');
assert.ok(
payload.elapsedSeconds <= 2,
`a first joiner should be told the meeting just started, got ${payload.elapsedSeconds}`
);
// focus is referenced for cleanup tracking only.
assert.ok(focus);
});
it('tells a late joiner how far into the meeting it already is', async () => {
const r = room();
await ctx.connectFocus(r);
const early = await ctx.connect();
await early.joinRoom(r);
await early.waitForMessage(isTimeRestrictedMessage, NOTIFY_WAIT_MS);
// Let the meeting run a little so the late joiner's elapsed is provably
// ahead of the first joiner's.
await new Promise(resolve => setTimeout(resolve, LATE_JOIN_DELAY_MS));
const late = await ctx.connect();
await late.joinRoom(r);
const msg = await late.waitForMessage(isTimeRestrictedMessage, NOTIFY_WAIT_MS);
const payload = payloadOf(msg);
assert.equal(payload.type, 'time_restricted');
assert.equal(payload.durationSeconds, DURATION_SECONDS);
assert.ok(
payload.elapsedSeconds >= LATE_JOIN_DELAY_MS / 1000,
`late joiner elapsedSeconds must reflect the time already spent, got ${payload.elapsedSeconds}`
);
});
it('destroys the room with a friendly time-limit reason and blocks re-creation', async () => {
const r = room();
// The room is created by the focus joining it — that is when the
// module stamps it and arms the destroy timer, so time everything
// from here.
await ctx.connectFocus(r);
const createdAt = Date.now();
const c = await ctx.connect();
await c.joinRoom(r);
assert.ok(await getRoomState(r), 'room must exist before the limit is reached');
// The limit passes, but conference_grace_seconds keeps the room alive a
// while longer so people can wrap up rather than being cut off.
await new Promise(resolve => setTimeout(resolve, Math.max(0, createdAt + IN_GRACE_MS - Date.now())));
assert.ok(await getRoomState(r), 'room must outlive its limit for the grace period');
// Once the grace elapses the room is destroyed; occupants get an unavailable presence.
const presence = await c.waitForPresence(p => p.attrs.type === 'unavailable', DESTROY_WAIT_MS);
const destroy = presence
.getChild('x', 'http://jabber.org/protocol/muc#user')
?.getChild('destroy');
assert.ok(destroy, 'unavailable presence must carry a <destroy> element');
assert.match(
destroy.getChildText('reason') || '',
/time limit/i,
'destroy reason should explain the time limit'
);
assert.equal(await getRoomState(r), null, 'room must be gone after the limit is reached');
// The room name is remembered as restricted, so re-creating it is refused.
// Use a fresh client: the original occupants still hold the destroy
// (kick) presence in their queue, which would mask the rejection.
const probe = await createXmppClient({ domain: 'auth.localhost',
username: 'focus',
password: 'focussecret' });
try {
const rejoin = await probe.joinRoom(r, 'focus', { timeout: 5000 });
assert.equal(rejoin.attrs.type, 'error', 're-creating a terminated room must be rejected');
assert.ok(
rejoin.getChild('error')?.getChild('resource-constraint'),
'rejection must be a resource-constraint error'
);
} finally {
await probe.disconnect();
}
assert.equal(await getRoomState(r), null, 'the room must not be re-created');
});
// The client only trusts a `time_restricted` message because it arrives from
// the bare room JID: lib-jitsi-meet derives the endpoint id from the stanza's
// resource, and jitsi-meet's time-timer middleware ignores the message unless
// that id is null. These cases prove an occupant cannot manufacture such a
// stanza, so the guard has something real to stand on.
describe('spoofing by an occupant', () => {
const FORGED = {
type: 'time_restricted',
durationSeconds: 60,
elapsedSeconds: 0
};
// Delivery is immediate, so a short window is enough to conclude a
// stanza is never coming — and it keeps these cases comfortably inside
// the room's 10.5 s destroy.
const SPOOF_WAIT_MS = 2500;
/**
* Sets up a room with a victim and an attacker, both past their join-time
* `time_restricted` message so only later ones remain in the queue.
*
* @returns {Promise<object>} the room JID plus both clients.
*/
async function twoOccupants() {
const r = room();
await ctx.connectFocus(r);
const victim = await ctx.connect();
const attacker = await ctx.connect();
// Default nicks only — anonymous_strict requires the MUC resource to
// match the JID local part, so an explicit nick would be refused.
await victim.joinRoom(r);
await victim.waitForMessage(isTimeRestrictedMessage, NOTIFY_WAIT_MS);
await attacker.joinRoom(r);
await attacker.waitForMessage(isTimeRestrictedMessage, NOTIFY_WAIT_MS);
return { r,
victim,
attacker };
}
it('stamps an occupant nick on a json-message sent to the room', async () => {
const { r, victim, attacker } = await twoOccupants();
await attacker.sendJsonGroupchat(r, FORGED);
// The message is delivered — nothing blocks a participant from
// saying whatever they like — but the MUC rewrites `from` to the
// sender's occupant JID. That resource is what makes the endpoint
// id non-null on the receiving client, and non-null is rejected.
const seen = await victim.waitForMessage(isTimeRestrictedMessage, SPOOF_WAIT_MS);
assert.equal(seen.attrs.from, `${r}/${attacker.nick}`,
'the room must attribute the message to the occupant that sent it');
});
it('never delivers a groupchat json-message under a forged room from', async () => {
const { r, victim, attacker } = await twoOccupants();
await attacker.sendJsonMessageRaw(r, FORGED, { from: r,
type: 'groupchat' });
// The claimed `from` is discarded: the stanza still reaches the room,
// stamped with the attacker's occupant JID like any other. A client
// cannot assert an identity, only the server can.
const seen = await victim.waitForMessage(isTimeRestrictedMessage, SPOOF_WAIT_MS);
assert.equal(seen.attrs.from, `${r}/${attacker.nick}`,
'a participant must not be able to speak as the room');
assert.notEqual(seen.attrs.from, r);
});
it('never delivers a private json-message under a forged room from', async () => {
const { r, victim, attacker } = await twoOccupants();
// Addressed straight at the victim's occupant JID rather than the
// room, which is the other way a genuine server message reaches a
// single client (mod_time_restricted sends exactly this shape).
await attacker.sendJsonMessageRaw(`${r}/${victim.nick}`, FORGED, { from: r,
type: 'chat' });
const seen = await victim.waitForMessage(isTimeRestrictedMessage, SPOOF_WAIT_MS);
assert.equal(seen.attrs.from, `${r}/${attacker.nick}`,
'a participant must not be able to private-message as the room');
assert.notEqual(seen.attrs.from, r);
});
});
});

View File

@@ -0,0 +1,165 @@
import assert from 'assert';
import { mintAsapToken } from './helpers/jwt.js';
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
const CONFERENCE = 'conference.localhost';
const MUC_NS = 'http://jabber.org/protocol/muc#user';
let _counter = 0;
const nextRoom = () => `affiliation-${++_counter}@${CONFERENCE}`;
/**
* Extracts the role and affiliation from a self-presence stanza.
*
* @param {object} presence
* @returns {{ role: string|null, affiliation: string|null }}
*/
function getRoleAndAffiliation(presence) {
const item = presence.getChild('x', MUC_NS)?.getChild('item');
return {
role: item?.attrs.role ?? null,
affiliation: item?.attrs.affiliation ?? null
};
}
/**
* Connects a client on the main VirtualHost with the given context.user claims
* embedded in a RS256 token scoped to the given room.
*
* @param {string} roomJid full room JID, e.g. 'room@conference.localhost'
* @param {object} contextUser JWT context.user payload.
* @returns {Promise<XmppTestClient>}
*/
function connectWithToken(roomJid, contextUser) {
const roomName = roomJid.split('@')[0];
const token = mintAsapToken({ room: roomName,
context: { user: contextUser } });
return createXmppClient({ params: { token } });
}
describe('mod_token_affiliation', () => {
const clients = [];
afterEach(async () => {
await Promise.all(clients.map(c => c.disconnect()));
clients.length = 0;
});
// -------------------------------------------------------------------------
// Moderator claim variants
// -------------------------------------------------------------------------
describe('moderator claim variants that grant owner/moderator', () => {
/**
* Asserts that a client with the given JWT context user receives owner/moderator.
* @param {object} contextUser JWT context.user payload.
*/
async function assertModerator(contextUser) {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const c = await connectWithToken(r, contextUser);
clients.push(c);
const presence = await c.joinRoom(r);
const { role, affiliation } = getRoleAndAffiliation(presence);
assert.equal(affiliation, 'owner', `expected affiliation=owner, got ${affiliation}`);
assert.equal(role, 'moderator', `expected role=moderator, got ${role}`);
}
it('moderator=true (boolean)', () => assertModerator({ moderator: true }));
it('moderator=\'true\' (string)', () => assertModerator({ moderator: 'true' }));
it('affiliation=\'owner\'', () => assertModerator({ affiliation: 'owner' }));
it('affiliation=\'moderator\'', () => assertModerator({ affiliation: 'moderator' }));
it('affiliation=\'teacher\'', () => assertModerator({ affiliation: 'teacher' }));
});
// -------------------------------------------------------------------------
// Member (authenticated, non-moderator)
// -------------------------------------------------------------------------
it('grants member affiliation and participant role for authenticated non-moderator', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const c = await connectWithToken(r, { id: 'user1' });
clients.push(c);
const presence = await c.joinRoom(r);
const { role, affiliation } = getRoleAndAffiliation(presence);
assert.equal(affiliation, 'member', `expected affiliation=member, got ${affiliation}`);
assert.equal(role, 'participant', `expected role=participant, got ${role}`);
});
it('grants member affiliation when moderator=false', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const c = await connectWithToken(r, { moderator: false });
clients.push(c);
const { affiliation } = getRoleAndAffiliation(await c.joinRoom(r));
assert.equal(affiliation, 'member');
});
// -------------------------------------------------------------------------
// No token (anonymous)
// -------------------------------------------------------------------------
it('leaves affiliation unchanged for anonymous user (no token)', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const c = await createXmppClient();
clients.push(c);
const presence = await c.joinRoom(r);
const { role, affiliation } = getRoleAndAffiliation(presence);
// No token → token_affiliation is a no-op; Prosody default is none/participant.
assert.equal(affiliation, 'none', `expected affiliation=none, got ${affiliation}`);
assert.equal(role, 'participant', `expected role=participant, got ${role}`);
});
// -------------------------------------------------------------------------
// Multiple participants in the same room
// -------------------------------------------------------------------------
it('correctly assigns different roles to moderator and non-moderator in same room', async () => {
const r = nextRoom();
clients.push(await joinWithFocus(r));
const mod = await connectWithToken(r, { moderator: true });
const member = await connectWithToken(r, { id: 'plain' });
clients.push(mod, member);
const modPresence = await mod.joinRoom(r);
const memberPresence = await member.joinRoom(r);
assert.equal(getRoleAndAffiliation(modPresence).role, 'moderator');
assert.equal(getRoleAndAffiliation(modPresence).affiliation, 'owner');
assert.equal(getRoleAndAffiliation(memberPresence).role, 'participant');
assert.equal(getRoleAndAffiliation(memberPresence).affiliation, 'member');
});
});

View File

@@ -0,0 +1,243 @@
import assert from 'assert';
import { mintAsapToken } from './helpers/jwt.js';
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
import { isAvailablePresence } from './helpers/xmpp_utils.js';
// ─── Constants ───────────────────────────────────────────────────────────────
// All token_verification tests run on the main MUC component.
// token_verification_require_token_for_moderation = true is set on
// conference.localhost in the test config; focus@auth.localhost is a Prosody
// admin and is therefore always exempt from both the join check and the
// moderation IQ check, mirroring production.
const CONFERENCE = 'conference.localhost';
let _roomCounter = 0;
const nextRoom = () => `token-verify-${++_roomCounter}@${CONFERENCE}`;
// ─── Tests ───────────────────────────────────────────────────────────────────
describe('mod_token_verification', () => {
const clients = [];
afterEach(async () => {
await Promise.all(clients.map(c => c.disconnect()));
clients.length = 0;
});
// ── Join access control ──────────────────────────────────────────────────
//
// token_verification is loaded on conference.localhost. The parent
// VirtualHost (localhost) has allow_empty_token = true, so anonymous users
// are always let through (verify_room returns true when auth_token is nil).
//
// focus@auth.localhost is a Prosody admin and is therefore exempt from
// the token check on both muc-room-pre-create and muc-occupant-pre-join.
describe('join access control', () => {
/**
* Creates a room with a focus participant for testing.
*
* @returns {Promise<string>} Room JID.
*/
async function setupRoom() {
const room = nextRoom();
const focus = await joinWithFocus(room);
clients.push(focus);
return room;
}
it('allows anonymous join when allow_empty_token = true', async () => {
const room = await setupRoom();
const c = await createXmppClient();
clients.push(c);
const presence = await c.joinRoom(room);
assert.ok(isAvailablePresence(presence),
'tokenless user must be allowed when allow_empty_token = true');
});
it('rejects connection when token is missing the sub claim', async () => {
// sub: undefined overrides the mintAsapToken default of sub: '*', producing
// a token with no sub field. process_and_verify_token rejects such tokens,
// causing a SASL failure before the client finishes connecting.
const token = mintAsapToken({ sub: undefined });
await assert.rejects(
createXmppClient({ params: { token } }),
'connection must be rejected when the sub claim is absent'
);
});
it('rejects connection when token has no room claim', async () => {
// asap_require_room_claim = true: a token without a room claim is
// rejected at SASL / connection time, before the client can join a room.
const token = mintAsapToken({ room: undefined });
await assert.rejects(
createXmppClient({ params: { token } }),
'connection must be rejected when the room claim is absent'
);
});
it('allows join with token whose room claim matches the room', async () => {
const room = await setupRoom();
const roomName = room.split('@')[0]; // e.g. "token-verify-3"
const token = mintAsapToken({ room: roomName });
const c = await createXmppClient({ params: { token } });
clients.push(c);
const presence = await c.joinRoom(room);
assert.ok(isAvailablePresence(presence),
'token with matching room claim must be allowed');
});
it('blocks join when token room claim does not match the room', async () => {
const room = await setupRoom();
// Token claims access to 'other-room', but we try to join a different room.
const token = mintAsapToken({ room: 'other-room' });
const c = await createXmppClient({ params: { token } });
clients.push(c);
const presence = await c.joinRoom(room);
assert.strictEqual(presence.attrs.type, 'error',
'mismatched room claim must be rejected');
assert.ok(
presence.getChild('error')?.getChild('not-allowed'),
'expected <not-allowed/> error condition');
});
it('blocks join for moderator token scoped to a different room', async () => {
const room = await setupRoom();
// Moderator token scoped to 'other-room' — mod_token_verification
// checks the room claim at priority 99, before mod_token_affiliation
// (priority 0) can grant owner status, so being a moderator is no bypass.
const token = mintAsapToken({ room: 'other-room',
context: { user: { moderator: true } } });
const c = await createXmppClient({ params: { token } });
clients.push(c);
const presence = await c.joinRoom(room);
assert.strictEqual(presence.attrs.type, 'error',
'moderator token for wrong room must be rejected');
assert.ok(
presence.getChild('error')?.getChild('not-allowed'),
'expected <not-allowed/> error condition');
});
it('blocks join for member token scoped to a different room', async () => {
const room = await setupRoom();
const token = mintAsapToken({ room: 'other-room',
context: { user: { id: 'user1' } } });
const c = await createXmppClient({ params: { token } });
clients.push(c);
const presence = await c.joinRoom(room);
assert.strictEqual(presence.attrs.type, 'error',
'member token for wrong room must be rejected');
assert.ok(
presence.getChild('error')?.getChild('not-allowed'),
'expected <not-allowed/> error condition');
});
it('allows join with wildcard (*) room claim', async () => {
const room = await setupRoom();
const token = mintAsapToken({ room: '*' });
const c = await createXmppClient({ params: { token } });
clients.push(c);
const presence = await c.joinRoom(room);
assert.ok(isAvailablePresence(presence),
'wildcard room claim must be allowed in any room');
});
});
// ── token_verification_require_token_for_moderation ──────────────────────
//
// conference.localhost has require_token_for_moderation = true.
// Unauthenticated users (no token) are blocked from sending muc#owner IQs,
// which is how moderator status is granted to other participants and how
// room configuration (e.g. passwords) is changed. Authenticated users
// whose token room claim matches are allowed through.
//
// focus@auth.localhost is a Prosody admin and is always exempt from the
// check, so it can create rooms and set their initial configuration.
describe('token_verification_require_token_for_moderation', () => {
/**
* Creates a room with a focus participant for testing.
*
* @returns {Promise<string>} Room JID.
*/
async function setupRoom() {
const room = nextRoom();
const focus = await joinWithFocus(room);
clients.push(focus);
return room;
}
it('blocks unauthenticated user from sending owner config IQ', async () => {
const room = await setupRoom();
// Anonymous guest joins (allow_empty_token = true on localhost).
const guest = await createXmppClient();
clients.push(guest);
await guest.joinRoom(room);
// Guest attempts to change room config (muc#owner IQ).
const iq = await guest.setRoomPassword(room, 'hacked');
assert.strictEqual(iq.attrs.type, 'error',
'unauthenticated user must be blocked from changing room config');
assert.ok(
iq.getChild('error')?.getChild('not-allowed'),
'expected <not-allowed/> error condition from require_token_for_moderation');
});
it('does not block authenticated user from sending owner config IQ', async () => {
const room = await setupRoom();
const roomName = room.split('@')[0];
const token = mintAsapToken({ room: roomName });
const authUser = await createXmppClient({ params: { token } });
clients.push(authUser);
await authUser.joinRoom(room);
// Authenticated user sends an muc#owner IQ. require_token_for_moderation
// must NOT block it (though Prosody may still deny with 'forbidden' if the
// user is not the room owner — that is a different check).
const iq = await authUser.setRoomPassword(room, 'secret');
// The error must not be 'not-allowed' from require_token_for_moderation.
const error = iq.getChild('error');
assert.ok(
!error?.getChild('not-allowed'),
'require_token_for_moderation must not block authenticated user');
});
});
});

View File

@@ -0,0 +1,123 @@
import assert from 'assert';
import { clearOtlpTraces, getOtlpTraces } from './helpers/test_observer.js';
import { createXmppClient } from './helpers/xmpp_client.js';
// Sender lives on a separate, plain VirtualHost from the receiver under test.
// mod_trace's iq/full hook fires once per host whose event bus a stanza
// passes through (once for the sender's own outbound processing, once for
// delivery to the recipient) — using two different hosts here means the
// hook (loaded only on the receiver's host) fires exactly once, matching
// real jicofo/JVB topology where sender and receiver are never the same host.
const SENDER_DOMAIN = 'whitelist.localhost';
/**
* Connects a sender client (on SENDER_DOMAIN) and a receiver client (on the
* given domain) and registers both for afterEach cleanup.
*
* @param {string} receiverDomain
* @param {Array} clients Cleanup array; both new clients are appended.
* @returns {Promise<[object, object]>}
*/
async function connectPair(receiverDomain, clients) {
const a = await createXmppClient({ domain: SENDER_DOMAIN });
const b = await createXmppClient({ domain: receiverDomain });
clients.push(a, b);
return [ a, b ];
}
describe('mod_trace', () => {
const clients = [];
beforeEach(() => clearOtlpTraces());
afterEach(async () => {
await Promise.all(clients.map(c => c.disconnect()));
clients.length = 0;
});
describe('when otlp_endpoint is unset', () => {
it('leaves a conference-modify IQ with traceparent unmodified and exports nothing', async () => {
const [ a, b ] = await connectPair('trace-disabled.localhost', clients);
const traceId = 'd'.repeat(32);
const parentId = 'e'.repeat(16);
await a.sendConferenceModifyIq(b.jid, { traceId,
parentId });
const iq = await b.waitForIq(s => s.getChild('conference-modify', 'jitsi:colibri2'));
const cm = iq.getChild('conference-modify', 'jitsi:colibri2');
const tp = cm.getChild('traceparent');
assert.ok(tp, 'traceparent element should still be present');
assert.strictEqual(tp.attrs.trace_id, traceId);
assert.strictEqual(tp.attrs.parent_id, parentId,
'parent_id must be left untouched when the module is disabled');
const traces = await getOtlpTraces();
assert.strictEqual(traces.length, 0, 'no span should be exported when otlp_endpoint is unset');
});
});
describe('when otlp_endpoint is set', () => {
it('leaves a conference-modify IQ without traceparent unmodified and exports nothing', async () => {
const [ a, b ] = await connectPair('trace.localhost', clients);
await a.sendConferenceModifyIq(b.jid);
const iq = await b.waitForIq(s => s.getChild('conference-modify', 'jitsi:colibri2'));
const cm = iq.getChild('conference-modify', 'jitsi:colibri2');
assert.ok(!cm.getChild('traceparent'),
'no traceparent should be added when the sender did not include one');
// Give Prosody a moment to (not) export before asserting the negative.
await new Promise(r => setTimeout(r, 300));
const traces = await getOtlpTraces();
assert.strictEqual(traces.length, 0);
});
it('rewrites the traceparent parent_id and exports a span for a conference-modify IQ', async () => {
const [ a, b ] = await connectPair('trace.localhost', clients);
const traceId = 'a'.repeat(32);
const parentId = 'b'.repeat(16);
await a.sendConferenceModifyIq(b.jid, { traceId,
parentId });
const iq = await b.waitForIq(s => s.getChild('conference-modify', 'jitsi:colibri2'));
const cm = iq.getChild('conference-modify', 'jitsi:colibri2');
const tp = cm.getChild('traceparent');
assert.ok(tp, 'traceparent element should still be present');
assert.strictEqual(tp.attrs.trace_id, traceId, 'trace_id must be preserved');
assert.notStrictEqual(tp.attrs.parent_id, parentId, 'parent_id must be rewritten to the new span id');
assert.match(tp.attrs.parent_id, /^[0-9a-f]{16}$/, 'rewritten parent_id must be a 16-hex-char span id');
// Give Prosody a moment to POST the export before polling.
await new Promise(r => setTimeout(r, 300));
const traces = await getOtlpTraces();
assert.strictEqual(traces.length, 1, 'exactly one export request should have been made');
const [ resourceSpan ] = traces[0].resource_spans;
const [ scopeSpan ] = resourceSpan.scope_spans;
const [ span ] = scopeSpan.spans;
assert.strictEqual(scopeSpan.scope.name, 'muc');
assert.strictEqual(span.name, 'colibri.conference-modify');
assert.strictEqual(span.trace_id, traceId);
assert.strictEqual(span.parent_span_id, parentId,
'exported span must record the original parent_id as its own parent');
});
});
});

View File

@@ -0,0 +1,47 @@
import assert from 'assert';
import { assertExtdiscoServices } from './helpers/xmpp_utils.js';
const BASE = 'http://localhost:5280';
describe('mod_turncredentials_http', () => {
it('GET /turn-credentials returns 200 with a JSON array', async () => {
const res = await fetch(`${BASE}/turn-credentials`);
const text = await res.text();
assert.equal(res.status, 200, `expected 200, got ${res.status}: ${text}`);
let body;
try {
body = JSON.parse(text);
} catch (e) {
assert.fail(`GET /turn-credentials returned non-JSON: ${text}`);
}
assert.ok(Array.isArray(body), `body must be a JSON array, got: ${JSON.stringify(body)}`);
assert.ok(
body.every(s => s.port === 3479),
`all entries must use port 3479 (external_services config), got: ${JSON.stringify(body.map(s => s.port))}`
);
});
// mod_turncredentials_http depends on Prosody's external_services module,
// which hooks the same urn:xmpp:extdisco:1 IQ event on the VirtualHost.
// This test verifies that external_services is correctly loaded and serving
// the right config via XMPP, not just via HTTP.
describe('extdisco IQ (via external_services)', () => {
const clients = [];
afterEach(async () => {
await Promise.all(clients.map(c => c.disconnect()));
clients.length = 0;
});
it('serves entries from external_services config (port 3479)', async () => {
await assertExtdiscoServices('localhost', 3479, clients);
});
});
});

5715
tests/prosody/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
{
"name": "prosody-plugin-tests",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"test:lua": "rm -f allure-results/*-result.json allure-results/TEST-lua-unit.xml 2>/dev/null; mkdir -p allure-results && if command -v busted > /dev/null 2>&1; then BUSTED=${HOME}/.luarocks/bin/busted; command -v \"$BUSTED\" > /dev/null 2>&1 || BUSTED=busted; SPEC_DIR=\"$(pwd)\"; (cd ../../resources/prosody-plugins && LUA_PATH=\"$SPEC_DIR/?.lua;;\" ALLURE_RESULTS_DIR=\"$SPEC_DIR/allure-results\" \"$BUSTED\" --output busted_allure \"$SPEC_DIR/lua/\"); fi",
"test:integration": "DEBUG=testcontainers,testcontainers:containers,testcontainers:build mocha",
"test:report": "npx allure generate allure-results --clean -o allure-report && node fix-allure-report.cjs",
"test": "npm run test:lua; LRC=$?; npm run test:integration; IRC=$?; npm run test:report; exit $(( LRC != 0 || IRC != 0 ))",
"test:one": "mocha --no-config --require ./setup.js --timeout 120000",
"build:test-image": "cd ../.. && docker build -t jitsi-meet-prosody-test:latest -f tests/prosody/docker/Dockerfile ."
},
"dependencies": {
"@xmpp/client": "^0.13.1",
"@xmpp/xml": "^0.13.1",
"jsonwebtoken": "^9.0.0",
"testcontainers": "^10.13.2"
},
"devDependencies": {
"allure-commandline": "2.32.0",
"allure-mocha": "^3.7.1",
"mocha": "^10.7.3"
}
}

29
tests/prosody/setup.js Normal file
View File

@@ -0,0 +1,29 @@
import path from 'path';
import { DockerComposeEnvironment, Wait } from 'testcontainers';
import { fileURLToPath } from 'url';
import { setContainer } from './helpers/container.js';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
let environment;
export const mochaHooks = {
async beforeAll() {
environment = await new DockerComposeEnvironment(
path.join(__dirname, 'docker'),
'docker-compose.yml'
)
.withBuild()
.withWaitStrategy('prosody-1', Wait.forListeningPorts())
.up();
setContainer(environment.getContainer('prosody-1'));
},
async afterAll() {
if (environment) {
await environment.down({ removeVolumes: true });
}
}
};