snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
This commit is contained in:
7
tests/prosody/.eslintrc.cjs
Normal file
7
tests/prosody/.eslintrc.cjs
Normal file
@@ -0,0 +1,7 @@
|
||||
// ESLint overrides for the Prosody test suite.
|
||||
// Enables mocha globals (before/after/describe/it) on top of the root config.
|
||||
module.exports = {
|
||||
env: {
|
||||
mocha: true
|
||||
}
|
||||
};
|
||||
4
tests/prosody/.gitignore
vendored
Normal file
4
tests/prosody/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
allure-results/
|
||||
allure-report/
|
||||
xunit.xml
|
||||
8
tests/prosody/.mocharc.cjs
Normal file
8
tests/prosody/.mocharc.cjs
Normal file
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
require: ['./setup.js'],
|
||||
spec: './**/*_spec.js',
|
||||
timeout: 120000,
|
||||
exit: true, // force-exit after tests: @xmpp/reconnect leaves open handles
|
||||
reporter: 'allure-mocha',
|
||||
reporterOptions: 'resultsDir=./allure-results,extraReporters=spec',
|
||||
};
|
||||
189
tests/prosody/README.md
Normal file
189
tests/prosody/README.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# Prosody Plugin Integration Tests
|
||||
|
||||
Integration tests for the Jitsi Prosody plugins. Tests run against a real Prosody 13 instance
|
||||
inside Docker and connect to it over XMPP WebSocket and HTTP.
|
||||
|
||||
## Architecture and Lifecycle
|
||||
|
||||
One Docker container runs for the entire test suite (not per-test). The container is started
|
||||
by the Mocha global setup hook (`setup.js`) using [testcontainers](https://testcontainers.com/),
|
||||
and torn down after all tests complete.
|
||||
|
||||
```
|
||||
npm test
|
||||
└─ Mocha (setup.js beforeAll)
|
||||
└─ Docker container: Prosody 13
|
||||
└─ HTTP :5280 ← test clients connect via WebSocket (/xmpp-websocket)
|
||||
← test assertions query HTTP endpoints here
|
||||
```
|
||||
|
||||
Test clients are anonymous XMPP connections (`@xmpp/client`). Rooms are created by joining
|
||||
them; they are automatically destroyed by Prosody when all occupants disconnect.
|
||||
|
||||
Because rooms live only as long as their occupants, tests are effectively isolated as long as
|
||||
each test disconnects its clients in a `finally` block. Room names are unique per test (counter
|
||||
suffix) so there is no cross-test interference.
|
||||
|
||||
## Assertion Strategies
|
||||
|
||||
Two complementary mechanisms are available for asserting internal Prosody state.
|
||||
|
||||
### Option I — `prosodyctl shell` (black-box runtime control)
|
||||
|
||||
`helpers/prosody_shell.js` runs Lua expressions inside the live Prosody process via
|
||||
`prosodyctl shell` (executed through `docker exec`). Use this when you need to:
|
||||
|
||||
- Load or unload a module at runtime to test behaviour with/without it.
|
||||
- Execute arbitrary Lua to inspect or mutate server state.
|
||||
|
||||
```js
|
||||
import { prosodyShell } from './helpers/prosody_shell.js';
|
||||
|
||||
await prosodyShell('module:unload("muc_hide_all", "conference.localhost")');
|
||||
// ... run test ...
|
||||
await prosodyShell('module:load("muc_hide_all", "conference.localhost")');
|
||||
```
|
||||
|
||||
### Option II — `mod_test_observer` HTTP endpoints (event log + room state)
|
||||
|
||||
`mod_test_observer.lua` (loaded on the MUC component) hooks MUC events and stores them in a
|
||||
Lua shared table. `mod_test_observer_http.lua` (loaded on the main VirtualHost) serves that
|
||||
shared state over plain HTTP on port 5280.
|
||||
|
||||
> **Why two modules?** Prosody routes HTTP by `Host` header. A module on the MUC component
|
||||
> (`conference.localhost`) would only be reachable at `Host: conference.localhost`, which is
|
||||
> not resolvable from the host machine. Loading the HTTP server on the main VirtualHost
|
||||
> (`localhost`) makes port 5280 directly reachable. The two modules share data through
|
||||
> `module:shared()`.
|
||||
|
||||
`helpers/test_observer.js` wraps the three endpoints:
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|---|---|
|
||||
| `GET /test-observer/events` | Return all MUC events fired since the last clear |
|
||||
| `DELETE /test-observer/events` | Clear the event log (call in `beforeEach`) |
|
||||
| `GET /test-observer/rooms?jid=<room-jid>` | Return live room state: `{ jid, hidden, occupant_count }` |
|
||||
|
||||
```js
|
||||
import { clearEvents, getEvents, getRoomState } from './helpers/test_observer.js';
|
||||
|
||||
beforeEach(() => clearEvents());
|
||||
|
||||
// Check fired events:
|
||||
const events = await getEvents();
|
||||
assert.ok(events.map(e => e.event).includes('muc-room-pre-create'));
|
||||
|
||||
// Check internal room state:
|
||||
const state = await getRoomState('room@conference.localhost');
|
||||
assert.equal(state.hidden, true);
|
||||
```
|
||||
|
||||
## Test Placement Guidelines
|
||||
|
||||
**Pure Lua code (no Prosody API needed)** — test in `lua/` using
|
||||
[busted](https://lunarmodules.github.io/busted/). These run without Docker and
|
||||
are fast. A good fit for library files (e.g. `token/jwk.lib.lua`) and any
|
||||
module logic that can be extracted into plain functions.
|
||||
|
||||
**Code that requires Prosody stubs or live server behaviour** — test via the
|
||||
Docker/JS path (Mocha + `*_spec.js`). This covers anything that calls
|
||||
`module:hook`, `module:shared`, `prosody.hosts`, MUC APIs, etc.
|
||||
|
||||
**Prefer integration over isolation.** When adding a new module under test,
|
||||
load it on the main VirtualHost or MUC component in
|
||||
`docker/prosody.cfg.lua` rather than testing it in isolation with stubs.
|
||||
This exercises real module interactions (hooks fire in the correct order,
|
||||
shared state is visible across modules) and catches interoperability bugs that
|
||||
unit tests miss.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
tests/prosody/
|
||||
├── README.md this file
|
||||
├── setup.js Mocha root hooks: start/stop Docker container
|
||||
├── .mocharc.cjs Mocha configuration (spec glob, timeout, reporters)
|
||||
├── mocha-reporter-config.json Multi-reporter config: spec + allure-mocha
|
||||
├── package.json Node dependencies (mocha, testcontainers, @xmpp/client, allure)
|
||||
│
|
||||
├── docker/
|
||||
│ ├── Dockerfile Prosody 13 image with lua-cjson + Jitsi plugins
|
||||
│ ├── docker-compose.yml Exposes :5222 (XMPP) and :5280 (HTTP)
|
||||
│ ├── prosody.cfg.lua Top-level config: Includes the cfg/ fragments below
|
||||
│ └── cfg/ Config fragments (see prosody.cfg.lua header)
|
||||
│ ├── global.cfg.lua Global settings: no TLS, anonymous auth, HTTP on :5280
|
||||
│ ├── virtualhosts.cfg.lua All VirtualHost blocks
|
||||
│ ├── components.cfg.lua All Component blocks except conference.localhost
|
||||
│ └── conference.cfg.lua conference.localhost (separate so extending images can re-declare it)
|
||||
│
|
||||
├── helpers/
|
||||
│ ├── container.js Holds the testcontainers container reference (set in setup.js)
|
||||
│ ├── xmpp_client.js Creates anonymous XMPP clients with joinRoom / sendDiscoInfo
|
||||
│ ├── prosody_shell.js Runs prosodyctl shell commands inside the container (Option I)
|
||||
│ └── test_observer.js HTTP client for mod_test_observer endpoints (Option II)
|
||||
│
|
||||
├── lua/
|
||||
│ └── jwk_spec.lua busted unit tests for token/jwk.lib.lua (no Prosody needed)
|
||||
│
|
||||
├── mod_conference_duration_spec.js Tests for mod_conference_duration
|
||||
├── mod_muc_filter_access_spec.js Tests for mod_muc_filter_access
|
||||
├── mod_muc_hide_all_spec.js Tests for mod_muc_hide_all
|
||||
├── mod_muc_max_occupants_spec.js Tests for mod_muc_max_occupants
|
||||
├── mod_muc_meeting_id_spec.js Tests for mod_muc_meeting_id
|
||||
└── mod_muc_size_spec.js Tests for mod_muc_size
|
||||
```
|
||||
|
||||
The plugin sources under test (`mod_muc_hide_all.lua`, `mod_muc_max_occupants.lua`, etc.) live
|
||||
in `resources/prosody-plugins/` and are copied into the Docker image at build time. The test
|
||||
observer modules (`mod_test_observer.lua`, `mod_test_observer_http.lua`) are test-only and
|
||||
should never be loaded in production.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
cd tests/prosody
|
||||
npm install # first time only
|
||||
npm test # runs Lua unit tests, integration tests, and generates Allure report
|
||||
```
|
||||
|
||||
`npm test` runs three steps in sequence:
|
||||
1. `test:lua` — busted unit tests for pure Lua code (skipped gracefully if busted is not installed)
|
||||
2. `test:integration` — Mocha integration tests against a Docker Prosody instance
|
||||
3. `test:report` — generates `allure-report/` from both test suites
|
||||
|
||||
To run a single spec file with container debug output:
|
||||
```bash
|
||||
DEBUG=testcontainers,testcontainers:containers npm run test:one -- <spec-file>
|
||||
```
|
||||
|
||||
To open the report after a run:
|
||||
```bash
|
||||
npx allure open allure-report
|
||||
```
|
||||
|
||||
On macOS with Colima the Docker socket is not at the default path. Export it before running:
|
||||
|
||||
```bash
|
||||
export DOCKER_HOST=unix:///Users/$(whoami)/.colima/default/docker.sock
|
||||
export TESTCONTAINERS_RYUK_DISABLED=true # Ryuk reaper fails under Colima
|
||||
npm test
|
||||
```
|
||||
|
||||
## Extending from another repo
|
||||
|
||||
Repos with private Prosody plugins (e.g. jitsi-meet-branding) can reuse this
|
||||
setup by building on top of the test image:
|
||||
|
||||
```bash
|
||||
npm run build:test-image # builds jitsi-meet-prosody-test:latest
|
||||
```
|
||||
|
||||
The extending image starts `FROM jitsi-meet-prosody-test:latest`, copies its
|
||||
plugins into an additional plugin path, and replaces
|
||||
`/etc/prosody/prosody.cfg.lua` with a config that Includes the baked-in
|
||||
`/etc/prosody/cfg/global.cfg.lua`, `virtualhosts.cfg.lua` and
|
||||
`components.cfg.lua` fragments, then re-declares `conference.localhost`
|
||||
(based on `cfg/conference.cfg.lua`) with the extra modules appended.
|
||||
Prosody Component blocks cannot be extended in place, which is why
|
||||
`conference.localhost` lives in its own fragment that extending configs
|
||||
replace wholesale.
|
||||
182
tests/prosody/busted_allure.lua
Normal file
182
tests/prosody/busted_allure.lua
Normal file
@@ -0,0 +1,182 @@
|
||||
-- Custom busted output handler that writes Allure-native JSON result files.
|
||||
-- Produces {uuid}-result.json files in spec/allure-results/ with proper
|
||||
-- parentSuite/suite/subSuite labels derived from the describe() nesting.
|
||||
--
|
||||
-- Usage (run from tests/prosody/):
|
||||
-- busted --output busted_allure lua/
|
||||
|
||||
local io = io
|
||||
local os = os
|
||||
local math = math
|
||||
local string = string
|
||||
local table = table
|
||||
|
||||
return function(options)
|
||||
local busted = require 'busted'
|
||||
local handler = require 'busted.outputHandlers.base'()
|
||||
|
||||
-- Output directory. When run via npm (from tests/prosody/) the ALLURE_RESULTS_DIR
|
||||
-- env var is set to an absolute path so results land in tests/prosody/allure-results/
|
||||
-- regardless of busted's CWD (resources/prosody-plugins/).
|
||||
local results_dir = os.getenv('ALLURE_RESULTS_DIR') or 'allure-results'
|
||||
local test_start_ms = 0
|
||||
local current_status = 'passed'
|
||||
local current_message = nil
|
||||
local current_trace = nil
|
||||
local counter = 0
|
||||
local passed = 0
|
||||
local failed = 0
|
||||
local skipped = 0
|
||||
|
||||
math.randomseed(os.time())
|
||||
|
||||
local function now_ms()
|
||||
return math.floor(os.time() * 1000)
|
||||
end
|
||||
|
||||
local function uuid()
|
||||
counter = counter + 1
|
||||
return string.format('%08x-%04x-4%03x-%04x-%08x%04x',
|
||||
os.time() % 0xffffffff,
|
||||
math.random(0, 0xffff),
|
||||
math.random(0, 0xfff),
|
||||
math.random(0x8000, 0xbfff),
|
||||
counter,
|
||||
math.random(0, 0xffff)
|
||||
)
|
||||
end
|
||||
|
||||
local function escape_json(s)
|
||||
s = tostring(s or '')
|
||||
s = s:gsub('\\', '\\\\')
|
||||
s = s:gsub('"', '\\"')
|
||||
s = s:gsub('\n', '\\n')
|
||||
s = s:gsub('\r', '\\r')
|
||||
s = s:gsub('\t', '\\t')
|
||||
return s
|
||||
end
|
||||
|
||||
-- Traverse element's parent chain to collect describe() names.
|
||||
-- Returns an array like {'luajwtjitsi', 'verify', 'HS256'}.
|
||||
local function get_describe_stack(element)
|
||||
local stack = {}
|
||||
local parent = busted.parent(element)
|
||||
while parent and parent.descriptor ~= 'file' do
|
||||
table.insert(stack, 1, parent.name)
|
||||
parent = busted.parent(parent)
|
||||
end
|
||||
return stack
|
||||
end
|
||||
|
||||
local function write_result(element, allure_status, start_ms, stop_ms, message, trace)
|
||||
local stack = get_describe_stack(element)
|
||||
local labels = {}
|
||||
|
||||
if stack[1] then
|
||||
table.insert(labels, string.format(
|
||||
'{"name":"parentSuite","value":"%s"}', escape_json(stack[1])))
|
||||
end
|
||||
if stack[2] then
|
||||
table.insert(labels, string.format(
|
||||
'{"name":"suite","value":"%s"}', escape_json(stack[2])))
|
||||
end
|
||||
if #stack >= 3 then
|
||||
local parts = {}
|
||||
for i = 3, #stack do parts[#parts + 1] = stack[i] end
|
||||
table.insert(labels, string.format(
|
||||
'{"name":"subSuite","value":"%s"}', escape_json(table.concat(parts, ' \xe2\x80\xba '))))
|
||||
end
|
||||
table.insert(labels, '{"name":"framework","value":"busted"}')
|
||||
table.insert(labels, '{"name":"language","value":"lua"}')
|
||||
|
||||
local status_block = ''
|
||||
if message or trace then
|
||||
status_block = string.format(
|
||||
',"statusDetails":{"message":"%s","trace":"%s"}',
|
||||
escape_json(message), escape_json(trace))
|
||||
end
|
||||
|
||||
local id = uuid()
|
||||
local json = string.format(
|
||||
'{"uuid":"%s","name":"%s","status":"%s","start":%d,"stop":%d,"labels":[%s]%s}',
|
||||
id,
|
||||
escape_json(element.name),
|
||||
allure_status,
|
||||
start_ms,
|
||||
stop_ms,
|
||||
table.concat(labels, ','),
|
||||
status_block
|
||||
)
|
||||
|
||||
local f = io.open(results_dir .. '/' .. id .. '-result.json', 'w')
|
||||
if f then
|
||||
f:write(json)
|
||||
f:write('\n')
|
||||
f:close()
|
||||
end
|
||||
end
|
||||
|
||||
handler.testStart = function(element, parent)
|
||||
test_start_ms = now_ms()
|
||||
current_status = 'passed'
|
||||
current_message = nil
|
||||
current_trace = nil
|
||||
return nil, true
|
||||
end
|
||||
|
||||
handler.testEnd = function(element, parent, status)
|
||||
local stop_ms = now_ms()
|
||||
local allure_status
|
||||
|
||||
if status == 'success' then
|
||||
allure_status = 'passed'
|
||||
passed = passed + 1
|
||||
io.write('.')
|
||||
elseif status == 'pending' then
|
||||
allure_status = 'skipped'
|
||||
skipped = skipped + 1
|
||||
local p = handler.pendings[#handler.pendings]
|
||||
if p then current_message = p.message end
|
||||
io.write('S')
|
||||
else
|
||||
-- failure or error — current_status set by failureTest/errorTest
|
||||
allure_status = current_status
|
||||
failed = failed + 1
|
||||
io.write('F')
|
||||
end
|
||||
io.flush()
|
||||
|
||||
write_result(element, allure_status, test_start_ms, stop_ms,
|
||||
current_message, current_trace)
|
||||
return nil, true
|
||||
end
|
||||
|
||||
handler.failureTest = function(element, parent, message, trace)
|
||||
current_status = 'failed'
|
||||
current_message = message
|
||||
current_trace = trace and trace.traceback
|
||||
return nil, true
|
||||
end
|
||||
|
||||
handler.errorTest = function(element, parent, message, trace)
|
||||
current_status = 'broken'
|
||||
current_message = message
|
||||
current_trace = trace and trace.traceback
|
||||
return nil, true
|
||||
end
|
||||
|
||||
handler.exit = function()
|
||||
io.write(string.format('\nLua tests: %d passed, %d failed, %d skipped\n',
|
||||
passed, failed, skipped))
|
||||
io.flush()
|
||||
return nil, true
|
||||
end
|
||||
|
||||
busted.subscribe({'test', 'start'}, handler.testStart, {predicate = handler.cancelOnPending})
|
||||
busted.subscribe({'test', 'end'}, handler.testEnd, {predicate = handler.cancelOnPending})
|
||||
busted.subscribe({'error', 'it'}, handler.errorTest)
|
||||
busted.subscribe({'failure', 'it'}, handler.failureTest)
|
||||
busted.subscribe({'exit'}, handler.exit)
|
||||
|
||||
return handler
|
||||
end
|
||||
61
tests/prosody/docker/Dockerfile
Normal file
61
tests/prosody/docker/Dockerfile
Normal file
@@ -0,0 +1,61 @@
|
||||
# Prosody 0.13+ with Jitsi plugins for integration testing.
|
||||
|
||||
# Builder stage: compile/install Lua libraries that aren't available as apt
|
||||
# packages for Lua 5.4 in the prosodyim base image.
|
||||
FROM prosodyim/prosody:13.0 AS builder
|
||||
|
||||
USER root
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
liblua5.4-dev \
|
||||
&& luarocks --lua-version 5.4 install basexx \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Final image
|
||||
FROM prosodyim/prosody:13.0
|
||||
|
||||
USER root
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
lua-cjson \
|
||||
lua-inspect \
|
||||
lua-luaossl \
|
||||
&& mv /usr/share/lua/5.3/inspect.lua /usr/share/lua/5.4/ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy luarocks-installed pure-Lua libs from builder.
|
||||
COPY --from=builder /usr/local/share/lua/5.4 /usr/local/share/lua/5.4
|
||||
|
||||
# Copy all plugins into a dedicated path (added to plugin_paths in the config).
|
||||
# The tests/ directory is also copied but Prosody only loads explicitly named modules.
|
||||
COPY --chown=prosody:prosody resources/prosody-plugins/ /opt/prosody-jitsi-plugins/
|
||||
|
||||
# inspect.lua is required by several Jitsi plugins for debug logging.
|
||||
# Provide a minimal stub so those modules load without needing luarocks.
|
||||
COPY --chown=prosody:prosody tests/prosody/docker/inspect.lua /opt/prosody-jitsi-plugins/inspect.lua
|
||||
|
||||
COPY --chown=prosody:prosody tests/prosody/docker/prosody.cfg.lua /etc/prosody/prosody.cfg.lua
|
||||
COPY --chown=prosody:prosody tests/prosody/docker/cfg/ /etc/prosody/cfg/
|
||||
|
||||
# Test ASAP public keys — served by mod_test_observer_http for RS256 token verification tests.
|
||||
# Two separate key pairs are used: one for login tokens (mod_auth_token) and one for system
|
||||
# tokens (mod_muc_end_meeting and similar HTTP API modules).
|
||||
COPY --chown=prosody:prosody tests/prosody/fixtures/test-asap-public.pem /opt/prosody-jitsi-plugins/test-asap-public.pem
|
||||
COPY --chown=prosody:prosody tests/prosody/fixtures/test-system-asap-public.pem /opt/prosody-jitsi-plugins/test-system-asap-public.pem
|
||||
|
||||
# Pre-create the focus admin account (focus@auth.localhost, password "focussecret").
|
||||
# prosodyctl register writes properly hashed credentials (SCRAM-SHA-*) directly
|
||||
# to the data directory without requiring a running Prosody instance — it just
|
||||
# reads the config for data_path and the VirtualHost definition.
|
||||
RUN prosodyctl register focus auth.localhost focussecret \
|
||||
&& chown -R prosody:prosody /var/lib/prosody
|
||||
|
||||
# Pre-create Jibri and transcriber accounts used by mod_muc_cleanup_backend_services tests.
|
||||
# recorder@recorder.localhost — is_jibri() matches (starts with 'recorder@recorder.')
|
||||
# transcriber@recorder.localhost — is_transcriber() matches (starts with 'transcriber@recorder.')
|
||||
RUN prosodyctl register recorder recorder.localhost recordersecret \
|
||||
&& prosodyctl register transcriber recorder.localhost transcribersecret \
|
||||
&& chown -R prosody:prosody /var/lib/prosody
|
||||
136
tests/prosody/docker/cfg/components.cfg.lua
Normal file
136
tests/prosody/docker/cfg/components.cfg.lua
Normal file
@@ -0,0 +1,136 @@
|
||||
-- Component blocks fragment (all Components except conference.localhost).
|
||||
-- See prosody.cfg.lua for the fragment layout.
|
||||
|
||||
Component "lobby.conference.localhost" "muc"
|
||||
storage = "memory"
|
||||
muc_room_cache_size = 1000
|
||||
restrict_room_creation = true
|
||||
muc_room_locking = false
|
||||
muc_room_default_public_jids = true
|
||||
|
||||
-- Breakout MUC component for mod_muc_breakout_rooms tests.
|
||||
-- Only Prosody admins (focus@auth.localhost) may create rooms here, matching
|
||||
-- real-life behaviour where Jicofo creates breakout rooms on behalf of the
|
||||
-- moderator. The on_breakout_room_pre_create hook then enforces that the UUID
|
||||
-- is registered in the main room before admitting non-admin occupants.
|
||||
Component "breakout.conference.localhost" "muc"
|
||||
storage = "memory"
|
||||
restrict_room_creation = true
|
||||
muc_room_locking = false
|
||||
muc_room_default_public_jids = true
|
||||
|
||||
-- Internal MUC used by mod_muc_jigasi_invite: the module resolves the Jigasi
|
||||
-- brewery room from this component via process_host_module. Without this
|
||||
-- component main_muc_service would remain nil and requests that reach the
|
||||
-- invite_jigasi() path would crash instead of returning 404.
|
||||
Component "internal.auth.localhost" "muc"
|
||||
|
||||
-- Minimal MUC component used to test mod_muc_filter_access in isolation.
|
||||
-- Only clients from whitelist.localhost are permitted to join rooms here.
|
||||
Component "conference-internal.localhost" "muc"
|
||||
modules_enabled = {
|
||||
"muc_filter_access";
|
||||
}
|
||||
muc_filter_whitelist = { "whitelist.localhost" }
|
||||
|
||||
-- Component for mod_end_conference tests. Clients send a message stanza with
|
||||
-- an <end_conference/> child to this component; the module looks up the room
|
||||
-- from the sender's jitsi_web_query_room session field (set by mod_jitsi_session
|
||||
-- from the ?room= WebSocket URL param) and destroys it if the sender is a
|
||||
-- moderator occupant.
|
||||
Component "endconference.localhost" "end_conference"
|
||||
muc_component = "conference.localhost"
|
||||
muc_mapper_domain_base = "localhost"
|
||||
muc_mapper_domain_prefix = "conference"
|
||||
|
||||
-- MUC component for mod_muc_rate_limit integration tests.
|
||||
-- Intentionally low join/leave rates (1/s) so that 5 simultaneous clients
|
||||
-- exercise the queuing and timer logic without long waits.
|
||||
Component "rate-limited.localhost" "muc"
|
||||
modules_enabled = { "muc_rate_limit" }
|
||||
muc_rate_joins = 1
|
||||
muc_rate_leaves = 1
|
||||
|
||||
-- Metadata component for mod_room_metadata_component tests.
|
||||
Component "metadata.localhost" "room_metadata_component"
|
||||
muc_component = "conference.localhost"
|
||||
muc_mapper_domain_base = "localhost"
|
||||
muc_mapper_domain_prefix = "conference"
|
||||
|
||||
-- Component for mod_filesharing_component tests. Clients send file-sharing
|
||||
-- messages here; the component looks up the room from jitsi_web_query_room
|
||||
-- (set by mod_jitsi_session) and broadcasts add/remove notifications.
|
||||
Component "filesharing.localhost" "filesharing_component"
|
||||
muc_component = "conference.localhost"
|
||||
muc_mapper_domain_base = "localhost"
|
||||
muc_mapper_domain_prefix = "conference"
|
||||
|
||||
-- Component for mod_audio_translation_component tests. Receivers send
|
||||
-- <audio-translation> messages here; the component aggregates per-receiver
|
||||
-- subscriptions and exposes the map to jicofo via room._data.audioTranslationRequests
|
||||
-- (forwarded by mod_room_metadata_component on the metadata.localhost component).
|
||||
Component "audiotranslation.localhost" "audio_translation_component"
|
||||
muc_component = "conference.localhost"
|
||||
muc_mapper_domain_base = "localhost"
|
||||
muc_mapper_domain_prefix = "conference"
|
||||
-- Short debounce so tests don't wait long for the aggregate broadcast.
|
||||
audio_translation_debounce_interval = 0.1
|
||||
-- Low limit so the per-receiver cap is exercised without many subscriptions.
|
||||
audio_translation_max_subscriptions = 3
|
||||
|
||||
-- Component for mod_polls_component tests. The module hooks message/host for
|
||||
-- new-poll / answer-poll json-messages, keeps per-room poll state (installed on
|
||||
-- conference.localhost via process_host_module) and re-broadcasts poll updates
|
||||
-- to all occupants. The sender must be an occupant of the room, located from
|
||||
-- jitsi_web_query_room (set by mod_jitsi_session from the ?room= param).
|
||||
Component "polls.localhost" "polls_component"
|
||||
muc_mapper_domain_base = "localhost"
|
||||
muc_mapper_domain_prefix = "conference"
|
||||
|
||||
-- Plain MUC for mod_presence_identity tests. No token verification and no
|
||||
-- muc_meeting_id lock so any client can join freely without focus.
|
||||
Component "conference-identity.localhost" "muc"
|
||||
|
||||
-- Isolated MUC component for mod_muc_wait_for_host tests.
|
||||
-- No muc_meeting_id, so no jicofo lock — a JWT-authenticated client can join
|
||||
-- directly as host without focus unlocking the room first.
|
||||
-- muc_mapper_domain_base is set to "conference.localhost" so that:
|
||||
-- lobby_muc_component_config = "lobby." .. "conference.localhost"
|
||||
-- = "lobby.conference.localhost" (already configured above)
|
||||
-- This means the existing lobby component is reused for the lobby rooms created
|
||||
-- by this component, and no additional lobby MUC component is needed.
|
||||
Component "conference-waitforhost.localhost" "muc"
|
||||
storage = "memory"
|
||||
modules_enabled = {
|
||||
"muc_wait_for_host";
|
||||
}
|
||||
muc_mapper_domain_base = "conference.localhost"
|
||||
muc_mapper_domain_prefix = "conference"
|
||||
|
||||
-- Isolated MUC component for mod_muc_allowners tests.
|
||||
-- No muc_meeting_id (no jicofo lock) and no token_verification, so anonymous
|
||||
-- clients can join freely. In non-moderated rooms every occupant is promoted
|
||||
-- to owner on join and muc#admin set stanzas revoking affiliations are
|
||||
-- rejected. Room names listed in allowners_moderated_rooms are moderated:
|
||||
-- only JWT-authenticated users with a matching room claim are promoted and
|
||||
-- the revoke filtering is skipped.
|
||||
Component "conference-allowners.localhost" "muc"
|
||||
storage = "memory"
|
||||
modules_enabled = {
|
||||
"muc_allowners";
|
||||
}
|
||||
muc_mapper_domain_base = "localhost"
|
||||
muc_mapper_domain_prefix = "conference"
|
||||
allowners_moderated_rooms = { "moderated-room-1"; "moderated-room-2" }
|
||||
|
||||
-- MUC component paired with VirtualHost "noregex.test". Its parent host
|
||||
-- (derived from the component name by mod_token_verification) sets
|
||||
-- token_regex_enabled = false, so a join whose token carries a room-claim
|
||||
-- pattern is refused with <invalid-regex/>.
|
||||
Component "conference.noregex.test" "muc"
|
||||
storage = "memory"
|
||||
modules_enabled = {
|
||||
"token_verification";
|
||||
}
|
||||
muc_mapper_domain_base = "noregex.test"
|
||||
muc_mapper_domain_prefix = "conference"
|
||||
79
tests/prosody/docker/cfg/conference.cfg.lua
Normal file
79
tests/prosody/docker/cfg/conference.cfg.lua
Normal file
@@ -0,0 +1,79 @@
|
||||
-- conference.localhost fragment. See prosody.cfg.lua for the fragment layout.
|
||||
--
|
||||
-- Kept in its own fragment so that extending environments (e.g. the
|
||||
-- jitsi-meet-branding private plugins) can skip this Include and re-declare
|
||||
-- the component with extra modules appended. Keep this block self-contained:
|
||||
-- everything conference.localhost needs must live in this file.
|
||||
|
||||
Component "conference.localhost" "muc"
|
||||
-- Module order follows prod (conference.8x8.vc) so that same-priority hook
|
||||
-- execution order matches production. Test-only modules are inserted at
|
||||
-- sensible positions relative to the shared modules they interact with.
|
||||
modules_enabled = {
|
||||
"muc_hide_all";
|
||||
"token_verification"; -- prod pos 2: gate check runs before meeting-id / password logic
|
||||
"muc_max_occupants";
|
||||
"muc_resource_validate"; -- test-only: occupant gate, alongside muc_max_occupants
|
||||
"muc_password_whitelist";
|
||||
"token_affiliation"; -- test-only: reads auth_token set by token_verification above
|
||||
"muc_meeting_id"; -- prod pos 9: after gate checks
|
||||
"muc_flip";
|
||||
"test_observer"; -- test-only
|
||||
"filter_messages";
|
||||
"muc_displayname"; -- prod pos 21: after filter_messages
|
||||
-- mod_muc_limit_messages registers its message/bare hook at priority -1
|
||||
-- (below the default 0 used by filter_messages), so filter_messages
|
||||
-- always fires first. Messages it blocks (return true) never reach
|
||||
-- muc_limit_messages and do not count toward the per-room cap.
|
||||
"muc_limit_messages";
|
||||
-- Destroys a room after a short timeout when only Jibri/transcriber remain.
|
||||
"muc_cleanup_backend_services";
|
||||
}
|
||||
|
||||
anonymous_strict = true
|
||||
|
||||
-- Same as prod: only Prosody admins (jicofo, i.e. focus@auth.localhost) may
|
||||
-- create a room. Regular clients (anonymous or JWT) can only join a room
|
||||
-- that focus has already created.
|
||||
restrict_room_creation = true
|
||||
|
||||
-- Used by mod_muc_max_occupants tests (2 occupants max).
|
||||
muc_max_occupants = 2
|
||||
|
||||
-- Clients on whitelist.localhost bypass the occupant limit.
|
||||
-- Clients on auth.localhost are the focus (jicofo) admin; they also bypass
|
||||
-- the limit and are not counted against it.
|
||||
-- Clients on recorder.localhost are Jibri/transcriber accounts; they bypass
|
||||
-- token_verification (is_jibri / is_transcriber JID prefix checks still apply).
|
||||
muc_access_whitelist = { "whitelist.localhost", "auth.localhost", "recorder.localhost" }
|
||||
|
||||
-- Used by mod_muc_password_whitelist tests: clients from whitelist.localhost
|
||||
-- are injected with the room password and bypass the password check.
|
||||
muc_password_whitelist = { "whitelist.localhost" }
|
||||
|
||||
-- Required by util.lib.lua domain-mapping helpers and mod_jitsi_permissions.
|
||||
muc_mapper_domain_base = "localhost"
|
||||
muc_mapper_domain_prefix = "conference"
|
||||
|
||||
-- focus@auth.localhost is a Prosody admin and is therefore exempt from
|
||||
-- token_verification on both muc-room-pre-create and muc-occupant-pre-join,
|
||||
-- mirroring production where jicofo is a Prosody admin.
|
||||
|
||||
-- mod_muc_cleanup_backend_services: short timeout so tests don't wait 20 s.
|
||||
services_empty_meeting_timeout = 1
|
||||
|
||||
-- mod_time_restricted (loaded on demand by mod_time_restricted_spec, not in
|
||||
-- modules_enabled, so it does not destroy other tests' rooms): a fractional
|
||||
-- limit keeps the test fast — 0.125 min = 7.5 s to the limit, plus a 3 s
|
||||
-- wrap-up grace before the room is actually destroyed.
|
||||
conference_max_minutes = 0.125
|
||||
conference_grace_seconds = 3
|
||||
|
||||
-- mod_muc_limit_messages: cap per room and honour auth tokens.
|
||||
muc_limit_messages_count = 3
|
||||
muc_limit_messages_check_token = true
|
||||
|
||||
-- Blocks unauthenticated users from sending room-owner config IQs
|
||||
-- (muc#owner queries), which is how moderator status is granted to other
|
||||
-- participants and how room configuration is changed.
|
||||
token_verification_require_token_for_moderation = true
|
||||
58
tests/prosody/docker/cfg/global.cfg.lua
Normal file
58
tests/prosody/docker/cfg/global.cfg.lua
Normal file
@@ -0,0 +1,58 @@
|
||||
-- Global settings fragment. See prosody.cfg.lua for the fragment layout.
|
||||
--
|
||||
-- Minimal Prosody config for integration testing.
|
||||
-- No TLS: tests connect plaintext over loopback.
|
||||
|
||||
data_path = "/var/lib/prosody"
|
||||
pid_file = "/var/run/prosody/prosody.pid"
|
||||
|
||||
plugin_paths = { "/usr/lib/prosody/modules", "/opt/prosody-jitsi-plugins" }
|
||||
|
||||
log = {
|
||||
{ levels = { min = "debug" }, to = "console" };
|
||||
}
|
||||
|
||||
-- No TLS module means no STARTTLS offered, so clients connect plaintext.
|
||||
modules_enabled = {
|
||||
"saslauth";
|
||||
"disco";
|
||||
"ping";
|
||||
"admin_shell";
|
||||
"http";
|
||||
"websocket";
|
||||
"smacks";
|
||||
-- Global HTTP API modules.
|
||||
"muc_end_meeting";
|
||||
-- Sets jitsi_web_query_room on sessions from the ?room= WebSocket URL param;
|
||||
-- required by mod_end_conference to locate the target MUC room.
|
||||
"jitsi_session";
|
||||
}
|
||||
|
||||
-- Required by mod_muc_end_meeting (global module) to locate the MUC component
|
||||
-- and to attach its HTTP handler to the correct VirtualHost.
|
||||
muc_mapper_domain_base = "localhost"
|
||||
muc_mapper_domain_prefix = "conference"
|
||||
|
||||
-- System token key server for mod_muc_end_meeting and similar HTTP API modules.
|
||||
-- These tokens are signed with a separate key pair from login tokens, providing
|
||||
-- key-level separation between user-facing and system-facing authentication.
|
||||
-- The endpoint is served by mod_test_observer_http at /test-observer/system-asap-keys/.
|
||||
prosody_password_public_key_repo_url = "http://localhost:5280/test-observer/system-asap-keys"
|
||||
|
||||
-- Allow WebSocket connections without TLS (tests run over loopback).
|
||||
consider_websocket_secure = true
|
||||
|
||||
c2s_require_encryption = false
|
||||
s2s_require_encryption = false
|
||||
allow_unencrypted_plain_auth = true
|
||||
|
||||
-- HTTP server for mod_test_observer_http endpoints.
|
||||
-- Disable HTTPS so all HTTP routes land on port 5280 (not 5281).
|
||||
http_ports = { 5280 }
|
||||
http_interfaces = { "*" }
|
||||
https_ports = {}
|
||||
|
||||
-- The focus (jicofo) test helper authenticates as focus@auth.localhost.
|
||||
-- Prosody admins are exempt from token_verification checks and are used as
|
||||
-- room owners, mirroring production where jicofo is a Prosody admin.
|
||||
admins = { "focus@auth.localhost" }
|
||||
161
tests/prosody/docker/cfg/virtualhosts.cfg.lua
Normal file
161
tests/prosody/docker/cfg/virtualhosts.cfg.lua
Normal file
@@ -0,0 +1,161 @@
|
||||
-- VirtualHost blocks fragment. See prosody.cfg.lua for the fragment layout.
|
||||
|
||||
VirtualHost "localhost"
|
||||
authentication = "token"
|
||||
app_id = "jitsi"
|
||||
asap_key_server = "http://localhost:5280/test-observer/asap-keys"
|
||||
signature_algorithm = "RS256"
|
||||
allow_empty_token = true
|
||||
|
||||
-- Serve test_observer HTTP endpoints here so plain HTTP on port 5280 is
|
||||
-- reachable. Component HTTP routes end up on HTTPS 5281 due to Prosody's
|
||||
-- virtual-host routing, which does not match Host: localhost on port 5280.
|
||||
modules_enabled = {
|
||||
-- test-only: HTTP endpoints and census helpers
|
||||
"test_observer_http";
|
||||
"muc_size";
|
||||
"muc_census";
|
||||
-- Module order below follows prod (conference.8x8.vc VirtualHost) so that
|
||||
-- same-priority hook execution order matches production.
|
||||
-- Handles 'jitsi-add-identity' so components (room_metadata,
|
||||
-- audio_translation, ...) can advertise a disco identity on this host.
|
||||
"features_identity";
|
||||
"conference_duration";
|
||||
"filter_iq_rayo"; -- priority 1 on pre-iq/full; listed before filter_iq_jibri (matches prod)
|
||||
"filter_iq_jibri";
|
||||
"muc_lobby_rooms";
|
||||
"jiconop";
|
||||
"muc_kick_participant";
|
||||
"muc_jigasi_invite";
|
||||
-- Loaded here so that the global jitsi-access-ban-check event handler
|
||||
-- is registered and mod_auth_token can fire it for token-authenticated
|
||||
-- sessions. muc_prosody_jitsi_access_manager_url points at the mock
|
||||
-- access manager served by mod_test_observer_http on the same host.
|
||||
"muc_auth_ban";
|
||||
-- test-only
|
||||
"turncredentials_http";
|
||||
-- Rewrites MUC JIDs between external subdomain form
|
||||
-- (room@conference.sub.localhost) and internal bracket form
|
||||
-- ([sub]room@conference.localhost) for all sessions on all hosts.
|
||||
"muc_domain_mapper";
|
||||
"persistent_lobby";
|
||||
"system_chat_message";
|
||||
-- test-only
|
||||
"muc_password_check";
|
||||
"muc_breakout_rooms";
|
||||
}
|
||||
|
||||
-- mod_muc_password_check: verify Bearer tokens with the login ASAP key server.
|
||||
enable_password_token_verification = true
|
||||
|
||||
shard_name = "test-shard"
|
||||
region_name = "test-region"
|
||||
release_number = "test-release"
|
||||
|
||||
-- mod_turncredentials_http (HTTP via external_services): port 3479.
|
||||
external_services = {
|
||||
{ type = "stun", host = "127.0.0.1", port = 3479 };
|
||||
{ type = "turn", host = "127.0.0.1", port = 3479, secret = "http-turn-secret" };
|
||||
}
|
||||
|
||||
-- Required by mod_muc_auth_ban: URL of the access manager to call.
|
||||
-- Points at the mock endpoint served by mod_test_observer_http.
|
||||
muc_prosody_jitsi_access_manager_url = "http://localhost:5280/test-observer/access-manager"
|
||||
|
||||
-- Required by mod_test_observer_http to locate the shared MUC data.
|
||||
muc_mapper_domain_base = "localhost"
|
||||
muc_mapper_domain_prefix = "conference"
|
||||
|
||||
-- Required by mod_conference_duration to find the MUC component.
|
||||
-- Required by mod_muc_lobby_rooms.
|
||||
-- Required by mod_muc_breakout_rooms.
|
||||
main_muc = "conference.localhost"
|
||||
lobby_muc = "lobby.conference.localhost"
|
||||
breakout_rooms_muc = "breakout.conference.localhost"
|
||||
|
||||
-- Clients on whitelist.localhost bypass the lobby.
|
||||
muc_lobby_whitelist = { "whitelist.localhost" }
|
||||
|
||||
-- VirtualHost for the focus (jicofo) test helper.
|
||||
-- Clients authenticate with username "focus" and password "focussecret".
|
||||
-- The account is pre-created in the Docker image (see Dockerfile).
|
||||
-- focus@auth.localhost is listed in the global admins table, so it is exempt
|
||||
-- from token_verification and can act as room owner on all MUC components.
|
||||
VirtualHost "auth.localhost"
|
||||
authentication = "internal_hashed"
|
||||
-- @xmpp/sasl (v0.13) does not await async SASL responses; the SCRAM-SHA-1
|
||||
-- mechanism in sasl-scram-sha-1 1.4+ is async, so the client sends an
|
||||
-- empty client-final message which Prosody rejects as malformed-request.
|
||||
-- Disable SCRAM and force PLAIN (safe on loopback in the test environment).
|
||||
disable_sasl_mechanisms = { "SCRAM-SHA-1", "SCRAM-SHA-1-PLUS", "SCRAM-SHA-256", "SCRAM-SHA-256-PLUS" }
|
||||
|
||||
-- VirtualHost for Jibri (recorder) and transcriber accounts used by
|
||||
-- mod_muc_cleanup_backend_services tests. Accounts on this domain get JIDs whose
|
||||
-- bare form starts with 'recorder@recorder.' or 'transcriber@recorder.', satisfying
|
||||
-- is_jibri() and is_transcriber() respectively.
|
||||
-- recorder.localhost is added to muc_access_whitelist on the MUC component so
|
||||
-- these clients bypass token_verification without needing JWT tokens.
|
||||
VirtualHost "recorder.localhost"
|
||||
authentication = "internal_hashed"
|
||||
disable_sasl_mechanisms = { "SCRAM-SHA-1", "SCRAM-SHA-1-PLUS", "SCRAM-SHA-256", "SCRAM-SHA-256-PLUS" }
|
||||
|
||||
-- VirtualHost for HS256 (shared-secret) token auth tests and mod_presence_identity tests.
|
||||
VirtualHost "hs256.localhost"
|
||||
authentication = "token"
|
||||
app_id = "jitsi"
|
||||
app_secret = "testsecret"
|
||||
signature_algorithm = "HS256"
|
||||
allow_empty_token = false
|
||||
modules_enabled = { "presence_identity" }
|
||||
|
||||
-- Second VirtualHost whose domain is listed in muc_access_whitelist on the
|
||||
-- MUC component below. Clients connecting here get JIDs like
|
||||
-- <random>@whitelist.localhost and are treated as whitelisted.
|
||||
-- VirtualHost with room-claim patterns turned off (token_regex_enabled), used
|
||||
-- to test that a token flagged with context.room.regex is refused rather than
|
||||
-- matched. mod_token_verification reads token options from the PARENT host of
|
||||
-- the MUC component, so the option must live here and not on the component.
|
||||
-- muc_mapper_domain_base is set so that verify_room builds the MUC domain of
|
||||
-- the paired component below, and not the global conference.localhost.
|
||||
-- The host deliberately sits outside .localhost: mod_muc_domain_mapper is
|
||||
-- enabled for every host and would read "conference.noregex.localhost" as the
|
||||
-- multidomain form of "[noregex]room@conference.localhost", moving the room
|
||||
-- off this component entirely.
|
||||
VirtualHost "noregex.test"
|
||||
authentication = "token"
|
||||
app_id = "jitsi"
|
||||
asap_key_server = "http://localhost:5280/test-observer/asap-keys"
|
||||
signature_algorithm = "RS256"
|
||||
allow_empty_token = true
|
||||
token_regex_enabled = false
|
||||
muc_mapper_domain_base = "noregex.test"
|
||||
|
||||
-- VirtualHost used by mod_auth_jitsi-anonymous tests.
|
||||
VirtualHost "jitsi-anonymous.localhost"
|
||||
authentication = "jitsi-anonymous"
|
||||
|
||||
-- VirtualHost used by mod_auth_jitsi-shared-secret tests.
|
||||
VirtualHost "shared-secret.localhost"
|
||||
authentication = "jitsi-shared-secret"
|
||||
shared_secret = "topsecret"
|
||||
shared_secret_prev = "oldsecret"
|
||||
-- Disable SCRAM so Prosody only offers PLAIN. SCRAM requires per-user key
|
||||
-- derivation which is incompatible with a shared-secret auth provider.
|
||||
disable_sasl_mechanisms = { "SCRAM-SHA-1", "SCRAM-SHA-1-PLUS" }
|
||||
|
||||
VirtualHost "whitelist.localhost"
|
||||
authentication = "anonymous"
|
||||
|
||||
-- VirtualHost for mod_trace tests, with otlp_endpoint configured so the
|
||||
-- module is active. Points at the mock OTLP receiver served by
|
||||
-- mod_test_observer_http on the main VirtualHost.
|
||||
VirtualHost "trace.localhost"
|
||||
authentication = "anonymous"
|
||||
modules_enabled = { "trace" }
|
||||
otlp_endpoint = "http://localhost:5280/test-observer/otlp-traces"
|
||||
|
||||
-- VirtualHost for mod_trace tests with otlp_endpoint left unset: the module
|
||||
-- must log a warning and no-op rather than hook anything.
|
||||
VirtualHost "trace-disabled.localhost"
|
||||
authentication = "anonymous"
|
||||
modules_enabled = { "trace" }
|
||||
14
tests/prosody/docker/docker-compose.yml
Normal file
14
tests/prosody/docker/docker-compose.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
services:
|
||||
prosody:
|
||||
build:
|
||||
context: ../../..
|
||||
dockerfile: tests/prosody/docker/Dockerfile
|
||||
ports:
|
||||
- "5222:5222"
|
||||
- "5280:5280"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "prosodyctl status | grep -q running"]
|
||||
interval: 2s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 5s
|
||||
11
tests/prosody/docker/inspect.lua
Normal file
11
tests/prosody/docker/inspect.lua
Normal file
@@ -0,0 +1,11 @@
|
||||
-- Minimal inspect stub for test Docker image.
|
||||
-- Several Jitsi Prosody plugins require('inspect') for debug logging only.
|
||||
-- This stub satisfies the require without pulling in the full library.
|
||||
return function(val, _opts)
|
||||
if type(val) ~= 'table' then return tostring(val) end
|
||||
local parts = {}
|
||||
for k, v in pairs(val) do
|
||||
parts[#parts + 1] = tostring(k) .. '=' .. tostring(v)
|
||||
end
|
||||
return '{' .. table.concat(parts, ', ') .. '}'
|
||||
end
|
||||
20
tests/prosody/docker/prosody.cfg.lua
Normal file
20
tests/prosody/docker/prosody.cfg.lua
Normal file
@@ -0,0 +1,20 @@
|
||||
-- Prosody config for integration testing.
|
||||
-- Assembled from fragment files so that other environments (e.g. private
|
||||
-- plugin repos) can extend specific sections without duplicating the entire
|
||||
-- config.
|
||||
--
|
||||
-- Fragment layout:
|
||||
-- cfg/global.cfg.lua — global settings (data_path, plugin_paths, modules_enabled, …)
|
||||
-- cfg/virtualhosts.cfg.lua — all VirtualHost blocks
|
||||
-- cfg/components.cfg.lua — all Component blocks except conference.localhost
|
||||
-- cfg/conference.cfg.lua — conference.localhost (separate so it can be re-declared)
|
||||
--
|
||||
-- An extending image keeps the fragments and replaces this file with one that
|
||||
-- Includes cfg/global + cfg/virtualhosts + cfg/components, then declares its
|
||||
-- own conference.localhost block (based on cfg/conference.cfg.lua) with extra
|
||||
-- modules appended.
|
||||
|
||||
Include "/etc/prosody/cfg/global.cfg.lua"
|
||||
Include "/etc/prosody/cfg/virtualhosts.cfg.lua"
|
||||
Include "/etc/prosody/cfg/components.cfg.lua"
|
||||
Include "/etc/prosody/cfg/conference.cfg.lua"
|
||||
20
tests/prosody/fix-allure-report.cjs
Normal file
20
tests/prosody/fix-allure-report.cjs
Normal file
@@ -0,0 +1,20 @@
|
||||
// Post-process the generated Allure report to set default sort order to
|
||||
// "order" (the order tests ran) instead of alphabetical. Mirrors the same
|
||||
// hack used in tests/wdio.conf.ts.
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const indexFile = path.join(__dirname, 'allure-report', 'index.html');
|
||||
|
||||
if (!fs.existsSync(indexFile)) {
|
||||
console.warn('fix-allure-report: index.html not found, skipping');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const setting = '{"treeSorting":{"sorter":"sorter.order","ascending":true}}';
|
||||
const script = `<script>localStorage.setItem("ALLURE_REPORT_SETTINGS_SUITES", '${setting}');</script>`;
|
||||
|
||||
const content = fs.readFileSync(indexFile, 'utf8');
|
||||
fs.writeFileSync(indexFile, content.replace('<body>', `<body>${script}`));
|
||||
28
tests/prosody/fixtures/test-asap-private.pem
Normal file
28
tests/prosody/fixtures/test-asap-private.pem
Normal file
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC+OojmOlyLskdV
|
||||
sDnBHbCdShlarY1kyA9U2oKxe4oNUMKN31YlvgeRfht30Z/fmRGI0JPPU0Lfe5a+
|
||||
YELVFXz7eDtZlEFQo8qyux7hPd5KAELSOOjOJI/fVI3ZPS4mOVrH5mOHSh8SvORL
|
||||
ukKxRA8AMt8YRD7jr9AjapIdn9c+/8vs84TKMQznlbmDsVcp5Bt+VY0ohmREOfR5
|
||||
pX8uPAgDrui8vfbtuSX5KvdPuVcgR3r9RfeHfLG5bQ6oWhtAxiGdhdUQYbLdPkR/
|
||||
b/UVY7sxEgY8k88gvYDIOg4nWMMk7v7AcWfkj9Eb3ke0OO+5FfJ6wKAhDMsXe/P2
|
||||
9N788XJTAgMBAAECggEAEExVlFwlt7ZTlEzf9eLEUgWaPIGoHp1hauS509kAz/k+
|
||||
YgdjiyJH5bhHRDHKn86uiOlN8LJyhVFCbhczQqxvo9/+PcONAQq3gC62C5hQZki1
|
||||
cOt9TsQlK35EFPu/63h4ha4AkwPPu7xBVxejRSrOmjbBlIOsW7ehfpdP44fhWj1M
|
||||
csYiI6hNvehN84bfbGqonZlD1vRwwBQTpjBthKl01hk6dHvzYs6EYSqRi9tHLzBg
|
||||
ZqbicT0hw/V2dL5sE3VEl0Z9c837PZjBpLJuP5wyUJH+WpUQNOLVxuqDsyiFRYlo
|
||||
vbbLdiDpU4HV+DXNz1ouHvVFJKct9vB7EQBYvA4KSQKBgQDmiZdLafQYkj331s1U
|
||||
WqbtfyTSzwnMNmVdmnurt8OiOOS63KUYSrqVkZ/JLFDUfT7uwWDQnDy6LaCqrUH6
|
||||
FyiiN15MlVivSbeTl5ime26apHgSBpUMUAdZJWbmbiGDlvFrw8DMEhePL9o9KQET
|
||||
TAA/RpdM1OkZ5NRCj/nZe3FOJwKBgQDTPTf7eKfCN8wF45BOV7FNK46VC46SGnej
|
||||
q8cKiLVU5dKuAKGm7yNB3RUnvGxt6q2/NGetl7Lnla52krGZ2bjZyJWPNv3u2d/+
|
||||
waGlxZGmAhQNoAYFECDbGR+KIFXSLSrVox9YE2BBWxInLHFCulF+z+LtLKqGkFOe
|
||||
6lu8CeCB9QKBgQCd2+Fplme624jrSH7ZICnlvoYsg/ClkSnf6oR8lRy03FhjS+sQ
|
||||
szsIZ+sOCfZfSlPpkSkGL7W1lsDJnlHrOihsy5Uaw7kybjvyKIAyn5qg8bX2QeOV
|
||||
xscBWAcaCpeQT6+Ip0ZBdrIDLjU2Y/mEiSoyKdg4mCH1xSdDXOss7MYtSQKBgEd8
|
||||
yUxWWde1kFtR1w1cSgmGuxsfrSEuydxfDt42w782g+UVG5/mADWS/0zSTJOqPyCd
|
||||
OJUb6JTNKBzCqk4Zy6AQbOTpxGgn3dFWcEEsqozW2Th/NmpSOfxL9UuGg+S8Gmnw
|
||||
aXQiIoobqvoM5UuiyF+1NOP1IMSnZVU7lM3/PbZdAoGBAJlGJxAN6hb7CTTw3jHi
|
||||
4Ikw7h7bAUQOPRebV4ExVgRpvLKMTmRN+1cNDI5XA+Hv+Ln9bxu6h6TVEj1lThOW
|
||||
1yBUTacr2iWic2JcgT1A8zoO2YhLPAP3ambPpeVAY92dfU3aBhOj2cJJ5HLct1xx
|
||||
nQOPlio964MhT6udIC7dtGt1
|
||||
-----END PRIVATE KEY-----
|
||||
9
tests/prosody/fixtures/test-asap-public.pem
Normal file
9
tests/prosody/fixtures/test-asap-public.pem
Normal file
@@ -0,0 +1,9 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvjqI5jpci7JHVbA5wR2w
|
||||
nUoZWq2NZMgPVNqCsXuKDVDCjd9WJb4HkX4bd9Gf35kRiNCTz1NC33uWvmBC1RV8
|
||||
+3g7WZRBUKPKsrse4T3eSgBC0jjoziSP31SN2T0uJjlax+Zjh0ofErzkS7pCsUQP
|
||||
ADLfGEQ+46/QI2qSHZ/XPv/L7POEyjEM55W5g7FXKeQbflWNKIZkRDn0eaV/LjwI
|
||||
A67ovL327bkl+Sr3T7lXIEd6/UX3h3yxuW0OqFobQMYhnYXVEGGy3T5Ef2/1FWO7
|
||||
MRIGPJPPIL2AyDoOJ1jDJO7+wHFn5I/RG95HtDjvuRXyesCgIQzLF3vz9vTe/PFy
|
||||
UwIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
28
tests/prosody/fixtures/test-system-asap-private.pem
Normal file
28
tests/prosody/fixtures/test-system-asap-private.pem
Normal file
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDKjbOGjK6U2SxE
|
||||
M2EW86joXHg67aj8vYaukGGnf9XQG7Fp7YcDV53fX7m+gVG7zBge9yf3lbTtF32X
|
||||
uQ2tJTzw0xaZK+y81nAdGk+LdWZiua00qawizUXV2pFZAButLkoo2kI2ve4m948G
|
||||
NUuzrfvWU6P6iTY+UKAX15R9eiWjci3nUAhUZoqBlxGF+DJulxX+Q/BMhsCoYZvz
|
||||
vH3bXU9LrR31LIBVNASy1BlrWuDdvHQcmbWFVEsqr2v09qaoA7/xzSukEgGET9Co
|
||||
4g8Bt2c8OwVEB9HJ3tIMaJPbwmXlbXyDdj/resyzUZ4liPgjBE/ZPduQDf3ZzvXb
|
||||
tJhsZnUHAgMBAAECggEAU/eqFHz9YnclAqDJ/tnDi4/Jx6P+CcgHrRXtZaJ44Gya
|
||||
f28YKSqJJ7BnL7IsT82rsiqDRv+ooSC7z8nHAaAOQ0c+dwDeguniUC44C3f/ma2f
|
||||
P9WWplayPJT+7AY/cutdktHn4QmbUEwP3mL5nuLhI1hJAfMfqXWC6F9WDy4zrC1M
|
||||
QXsLOtqi8q5b4As+oPuYRVxehqA5AHTpie/2Tn6wLMCLe1vBP2zSGX7Bq/QucQIv
|
||||
ow5ZEfJ6bYUS6cB3tItZ0x2pI+m7Lq2LFx6Vl6VcXMzdVir0J9lH/8MgNkyhnc/0
|
||||
ZSx282redRJjBNxoOUPJa33vsZpZLTOSTyLQ+1ghAQKBgQDo76lu+8J42yRsCDvI
|
||||
O/k+i1whszoCJxlH1mzaYxEHuEPBLSN3vEBWUeypA+oHEfKPjJSRkMFE0OnKBiwV
|
||||
MpYyrwETQXrK/P+mdXqjoPUrZo8qcf+AmiueuMPsHhwI3qOfhU0MOrtu6PRwcJDn
|
||||
zD1FFtuqeqptwLIajfp507W/CwKBgQDem+qgEzvChgVa4JI2hUphiogvXLQsQYdB
|
||||
IP2eqPK8w3+/uOP6K10NTSVMa8ID9iXakAoAXzUhyj9yc+KtTZjWPvUjhuXr0gK6
|
||||
5E/HvRMoNvqcEsiYxgmXMvkVj2UB3Z1d2V20cgMK3rWaCgGW61G4mU5WBoTuZ/5o
|
||||
F9r8PT2PdQKBgDFybq04lFfDbT/hn48p7Aby3mPo/+9lDWDKi+DwFrVk0D05r8XD
|
||||
GIU6btqSEiPeE3eViBQ+fkh1cKuKE+GME4Y+0COeSsq8Wiij15zUljbYVpvUB0Dt
|
||||
eUUAQ9bjrV/UozdBvNFTxmYM3ZbgzmHmYTtBVvAhifwyY5xvdzRVVMdxAoGBAJ4V
|
||||
4aguIHlDDdh8tLjtLWZZp97imbz4CCJTWGjtF/y/ZSB1H8lQNDO2/m7n8482paky
|
||||
MzgSZLwLUcVo0Kg7+/biHNpO+UbgDDpG2vVAq7MaYByoJjaAJN1wQH10KMoLZK76
|
||||
J1Z2xPxaLmMnCfvwP0e173CeDpbz2TJ5BnWs0+PlAoGATsVxvhghfHFYT8wJMyx5
|
||||
ArmD0qF/4AckJfKRnDXXKsRAh1UF/2fbIaP5Y1T7czK+ATJW44NnorR/RG/kl8OR
|
||||
81qoONPhL2A+qhtPD7maaPFvXJ8lBkd+kTy4EsYCyB6Gy18lkqQaGvHK6Agnt5Ts
|
||||
izKr6gzVOVg1TAf/2ibw0XM=
|
||||
-----END PRIVATE KEY-----
|
||||
9
tests/prosody/fixtures/test-system-asap-public.pem
Normal file
9
tests/prosody/fixtures/test-system-asap-public.pem
Normal file
@@ -0,0 +1,9 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyo2zhoyulNksRDNhFvOo
|
||||
6Fx4Ou2o/L2GrpBhp3/V0Buxae2HA1ed31+5voFRu8wYHvcn95W07Rd9l7kNrSU8
|
||||
8NMWmSvsvNZwHRpPi3VmYrmtNKmsIs1F1dqRWQAbrS5KKNpCNr3uJvePBjVLs637
|
||||
1lOj+ok2PlCgF9eUfXolo3It51AIVGaKgZcRhfgybpcV/kPwTIbAqGGb87x9211P
|
||||
S60d9SyAVTQEstQZa1rg3bx0HJm1hVRLKq9r9PamqAO/8c0rpBIBhE/QqOIPAbdn
|
||||
PDsFRAfRyd7SDGiT28Jl5W18g3Y/63rMs1GeJYj4IwRP2T3bkA392c7127SYbGZ1
|
||||
BwIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
25
tests/prosody/helpers/container.js
Normal file
25
tests/prosody/helpers/container.js
Normal file
@@ -0,0 +1,25 @@
|
||||
let _container;
|
||||
|
||||
/**
|
||||
* Stores the testcontainers container reference for use by test helpers.
|
||||
* Called by setup.js after the container starts.
|
||||
*
|
||||
* @param {object} c - The testcontainers container instance.
|
||||
*/
|
||||
export function setContainer(c) {
|
||||
_container = c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the running testcontainers container.
|
||||
* Throws if setup.js has not run yet.
|
||||
*
|
||||
* @returns {object} The testcontainers container instance.
|
||||
*/
|
||||
export function getContainer() {
|
||||
if (!_container) {
|
||||
throw new Error('Container not initialised — is setup.js loaded?');
|
||||
}
|
||||
|
||||
return _container;
|
||||
}
|
||||
145
tests/prosody/helpers/jwt.js
Normal file
145
tests/prosody/helpers/jwt.js
Normal file
@@ -0,0 +1,145 @@
|
||||
import fs from 'fs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
|
||||
const DEFAULT_SECRET = 'testsecret';
|
||||
const DEFAULT_APP_ID = 'jitsi';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Fixed RSA key pair for login ASAP (RS256) tokens.
|
||||
// The public key is copied into the Prosody Docker image and served via
|
||||
// mod_test_observer_http at /test-observer/asap-keys/ so that mod_auth_token
|
||||
// can fetch it when verifying RS256 login tokens.
|
||||
// The kid must be "test-asap-key"; mod_auth_token derives the fetch URL as:
|
||||
// <asap_key_server>/<sha256hex(kid)>.pem
|
||||
// which equals /test-observer/asap-keys/dc6983da...dfc.pem.
|
||||
export const ASAP_KID = 'test-asap-key';
|
||||
export const ASAP_PRIVATE_KEY = fs.readFileSync(
|
||||
path.join(__dirname, '../fixtures/test-asap-private.pem'), 'utf8');
|
||||
|
||||
// Separate RSA key pair for system ASAP tokens used by HTTP API modules such as
|
||||
// mod_muc_end_meeting. The public key is served at /test-observer/system-asap-keys/
|
||||
// (prosody_password_public_key_repo_url) so that system modules can verify tokens
|
||||
// signed with this key. Login tokens (signed with ASAP_PRIVATE_KEY) are rejected
|
||||
// by the system key server, and system tokens are rejected by the login key server.
|
||||
export const SYSTEM_ASAP_KID = 'test-system-asap-key';
|
||||
export const SYSTEM_ASAP_PRIVATE_KEY = fs.readFileSync(
|
||||
path.join(__dirname, '../fixtures/test-system-asap-private.pem'), 'utf8');
|
||||
|
||||
/**
|
||||
* Builds a standard JWT payload with sane defaults.
|
||||
*
|
||||
* @param {object} [overrides] Fields to merge / override.
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.expired] If true, set exp to one hour in the past.
|
||||
* @param {boolean} [opts.notYetValid] If true, set nbf to one hour in the future.
|
||||
*/
|
||||
function buildPayload(overrides = {}, { expired = false, notYetValid = false } = {}) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
return {
|
||||
iss: DEFAULT_APP_ID,
|
||||
aud: DEFAULT_APP_ID,
|
||||
sub: '*',
|
||||
iat: now,
|
||||
exp: expired ? now - 3600 : now + 3600,
|
||||
...notYetValid ? { nbf: now + 3600 } : {},
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints an HS256 JWT compatible with mod_auth_token / luajwtjitsi.
|
||||
*
|
||||
* luajwtjitsi.verify() checks:
|
||||
* - header.typ === "JWT"
|
||||
* - header.alg === signatureAlgorithm (HS256 in test config for "localhost")
|
||||
* - HMAC-SHA256 signature with appSecret
|
||||
* - exp, iss, aud claims
|
||||
*
|
||||
* util.lib.lua additionally checks:
|
||||
* - requireRoomClaim (true; callers must include room claim)
|
||||
* - sets session.jitsi_meet_context_features from claims.context.features
|
||||
*
|
||||
* @param {object} [overrides] Fields to merge / override in the payload.
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.secret] Signing secret (default: testsecret).
|
||||
* @param {boolean} [opts.expired] If true, set exp to one hour in the past.
|
||||
* @returns {string} Signed JWT string.
|
||||
*/
|
||||
export function mintToken(overrides = {}, { secret = DEFAULT_SECRET, expired = false, notYetValid = false } = {}) {
|
||||
return jwt.sign(buildPayload(overrides, { expired,
|
||||
notYetValid }), secret, { algorithm: 'HS256' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints an RS256 login JWT for ASAP tests.
|
||||
*
|
||||
* The token is signed with the test login RSA private key. Prosody fetches the
|
||||
* matching public key from mod_test_observer_http's /asap-keys/ route
|
||||
* (configured via asap_key_server on VirtualHost "localhost").
|
||||
*
|
||||
* These tokens are REJECTED by mod_muc_end_meeting and other system HTTP API
|
||||
* modules, which use a separate key server (prosody_password_public_key_repo_url).
|
||||
*
|
||||
* @param {object} [overrides] Fields to merge / override in the payload.
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.privateKey] PEM private key (default: test-asap-private.pem).
|
||||
* @param {string} [opts.kid] Key ID (default: ASAP_KID).
|
||||
* @param {boolean} [opts.expired] If true, set exp to one hour in the past.
|
||||
* @returns {string} Signed JWT string.
|
||||
*/
|
||||
export function mintAsapToken(overrides = {}, {
|
||||
privateKey = ASAP_PRIVATE_KEY,
|
||||
kid = ASAP_KID,
|
||||
expired = false,
|
||||
notYetValid = false
|
||||
} = {}) {
|
||||
// sub: '*' satisfies the mandatory sub claim check in process_and_verify_token
|
||||
// and the domain verification in verify_room (wildcard allows any MUC domain).
|
||||
return jwt.sign(
|
||||
buildPayload({ sub: '*',
|
||||
...overrides }, { expired,
|
||||
notYetValid }),
|
||||
privateKey,
|
||||
{ algorithm: 'RS256',
|
||||
keyid: kid }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints an RS256 system JWT for HTTP API modules such as mod_muc_end_meeting.
|
||||
*
|
||||
* The token is signed with the test system RSA private key. Prosody fetches
|
||||
* the matching public key from mod_test_observer_http's /system-asap-keys/ route
|
||||
* (configured via prosody_password_public_key_repo_url).
|
||||
*
|
||||
* These tokens are REJECTED by mod_auth_token (login), which uses a different
|
||||
* key server (asap_key_server).
|
||||
*
|
||||
* @param {object} [overrides] Fields to merge / override in the payload.
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.privateKey] PEM private key (default: test-system-asap-private.pem).
|
||||
* @param {string} [opts.kid] Key ID (default: SYSTEM_ASAP_KID).
|
||||
* @param {boolean} [opts.expired] If true, set exp to one hour in the past.
|
||||
* @returns {string} Signed JWT string.
|
||||
*/
|
||||
export function mintSystemToken(overrides = {}, {
|
||||
privateKey = SYSTEM_ASAP_PRIVATE_KEY,
|
||||
kid = SYSTEM_ASAP_KID,
|
||||
expired = false,
|
||||
notYetValid = false
|
||||
} = {}) {
|
||||
return jwt.sign(
|
||||
buildPayload({ sub: 'system.localhost',
|
||||
room: '*',
|
||||
...overrides }, { expired,
|
||||
notYetValid }),
|
||||
privateKey,
|
||||
{ algorithm: 'RS256',
|
||||
keyid: kid }
|
||||
);
|
||||
}
|
||||
36
tests/prosody/helpers/prosody_shell.js
Normal file
36
tests/prosody/helpers/prosody_shell.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { getContainer } from './container.js';
|
||||
|
||||
/**
|
||||
* Runs a Prosody shell command inside the Docker container (Option I).
|
||||
* Uses `prosodyctl shell` via stdin so the command executes against the live instance.
|
||||
*
|
||||
* Example commands:
|
||||
* module:load("muc_hide_all", "conference.localhost")
|
||||
* module:unload("muc_hide_all", "conference.localhost")
|
||||
*
|
||||
* @param {string} command Prosody shell command string
|
||||
* @returns {Promise<string>} stdout from the shell
|
||||
*/
|
||||
export async function prosodyShell(command) {
|
||||
const container = getContainer();
|
||||
|
||||
// Escape single quotes in the command before embedding in the shell string.
|
||||
const escaped = command.replace(/'/g, '\'\\\'\'');
|
||||
|
||||
const { output, exitCode } = await container.exec([
|
||||
'su', '-s', '/bin/sh', 'prosody', '-c',
|
||||
`echo '${escaped}' | prosodyctl shell`
|
||||
]);
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`prosodyctl shell failed (exit ${exitCode}):\n${output}`);
|
||||
}
|
||||
|
||||
// prosodyctl shell exits 0 even when the Lua command throws; detect errors
|
||||
// from the output text. Prosody prefixes error lines with '! '.
|
||||
if (/^!/m.test(output)) {
|
||||
throw new Error(`prosodyctl shell command error:\n${output}`);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
101
tests/prosody/helpers/test_context.js
Normal file
101
tests/prosody/helpers/test_context.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import { createXmppClient, joinWithFocus, joinWithJibri, joinWithTranscriber } from './xmpp_client.js';
|
||||
|
||||
/**
|
||||
* Creates a per-test context that tracks connected clients and provides
|
||||
* convenience helpers for connecting as different participant types.
|
||||
* Call in beforeEach; call cleanup() in afterEach.
|
||||
*
|
||||
* @returns {{ connect: Function, connectWhitelisted: Function, connectFocus: Function, cleanup: Function }}
|
||||
*/
|
||||
export function createTestContext() {
|
||||
const clients = [];
|
||||
|
||||
return {
|
||||
/**
|
||||
* Creates a regular XMPP client and registers it for cleanup.
|
||||
*
|
||||
* @param {object} [opts] Options forwarded to createXmppClient (e.g.
|
||||
* { params: { token } } to authenticate with a JWT).
|
||||
* @returns {Promise<XmppTestClient>}
|
||||
*/
|
||||
async connect(opts) {
|
||||
const c = await createXmppClient(opts);
|
||||
|
||||
clients.push(c);
|
||||
|
||||
return c;
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a whitelisted XMPP client (domain: whitelist.localhost) and
|
||||
* registers it for cleanup. Whitelisted clients bypass the occupant limit.
|
||||
*
|
||||
* @returns {Promise<XmppTestClient>}
|
||||
*/
|
||||
async connectWhitelisted() {
|
||||
const c = await createXmppClient({ domain: 'whitelist.localhost' });
|
||||
|
||||
clients.push(c);
|
||||
|
||||
return c;
|
||||
},
|
||||
|
||||
/**
|
||||
* Joins the room as focus (jicofo), unlocking the mod_muc_meeting_id
|
||||
* jicofo lock. The focus client is whitelisted and does not count
|
||||
* against the occupant limit. Registers the client for cleanup.
|
||||
*
|
||||
* @param {string} roomJid full room JID, e.g. 'room@conference.localhost'
|
||||
* @returns {Promise<XmppTestClient>}
|
||||
*/
|
||||
async connectFocus(roomJid) {
|
||||
const c = await joinWithFocus(roomJid);
|
||||
|
||||
clients.push(c);
|
||||
|
||||
return c;
|
||||
},
|
||||
|
||||
/**
|
||||
* Joins the room as a Jibri recorder (recorder@recorder.localhost).
|
||||
* The client's bare JID starts with 'recorder@recorder.' so is_jibri()
|
||||
* returns true for this client. Registers the client for cleanup.
|
||||
*
|
||||
* @param {string} roomJid full room JID, e.g. 'room@conference.localhost'
|
||||
* @returns {Promise<XmppTestClient>}
|
||||
*/
|
||||
async connectJibri(roomJid) {
|
||||
const c = await joinWithJibri(roomJid);
|
||||
|
||||
clients.push(c);
|
||||
|
||||
return c;
|
||||
},
|
||||
|
||||
/**
|
||||
* Joins the room as a transcriber (transcriber@recorder.localhost).
|
||||
* The client's bare JID starts with 'transcriber@recorder.' so
|
||||
* is_transcriber() returns true for this client. Registers the client
|
||||
* for cleanup.
|
||||
*
|
||||
* @param {string} roomJid full room JID, e.g. 'room@conference.localhost'
|
||||
* @returns {Promise<XmppTestClient>}
|
||||
*/
|
||||
async connectTranscriber(roomJid) {
|
||||
const c = await joinWithTranscriber(roomJid);
|
||||
|
||||
clients.push(c);
|
||||
|
||||
return c;
|
||||
},
|
||||
|
||||
/**
|
||||
* Disconnects all clients created through this context.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async cleanup() {
|
||||
await Promise.all(clients.map(c => c.disconnect()));
|
||||
}
|
||||
};
|
||||
}
|
||||
496
tests/prosody/helpers/test_observer.js
Normal file
496
tests/prosody/helpers/test_observer.js
Normal file
@@ -0,0 +1,496 @@
|
||||
const BASE = 'http://localhost:5280/test-observer';
|
||||
const ACCESS_MANAGER_URL = `${BASE}/access-manager`;
|
||||
const END_MEETING_URL = 'http://localhost:5280/end-meeting';
|
||||
const KICK_PARTICIPANT_URL = 'http://localhost:5280/kick-participant';
|
||||
const SYSTEM_CHAT_URL = 'http://localhost:5280/send-system-chat-message';
|
||||
const JIGASI_INVITE_URL = 'http://localhost:5280/invite-jigasi';
|
||||
|
||||
/**
|
||||
* Configures the mock access manager endpoint (served by mod_test_observer_http).
|
||||
* mod_muc_auth_ban calls this endpoint for VPaaS sessions
|
||||
* (jitsi_web_query_prefix starting with "vpaas-magic-cookie-").
|
||||
*
|
||||
* Call this before connecting the client under test.
|
||||
* Call with { access: true, status: 200 } (the defaults) to reset between tests.
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.access=true] true → allow, false → ban
|
||||
* @param {number} [opts.status=200] HTTP status code to return.
|
||||
* Non-200 values simulate HTTP errors;
|
||||
* mod_muc_auth_ban fails open on errors.
|
||||
* @param {boolean} [opts.nonJson=false] When true, return a 200 with a non-JSON
|
||||
* body. Tests the nil-check bug:
|
||||
* json.decode returns nil and the module
|
||||
* must not crash on r['access'].
|
||||
*/
|
||||
export async function setAccessManagerResponse({ access = true, status = 200, nonJson = false } = {}) {
|
||||
const res = await fetch(ACCESS_MANAGER_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
access,
|
||||
status,
|
||||
// eslint-disable-next-line camelcase
|
||||
non_json: nonJson
|
||||
})
|
||||
});
|
||||
|
||||
if (res.status !== 204) {
|
||||
throw new Error(`setAccessManagerResponse failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all MUC events recorded by mod_test_observer since the last clear.
|
||||
* @returns {Promise<Array<{event: string, room?: string, occupant?: string, timestamp: number}>>}
|
||||
*/
|
||||
export async function getEvents() {
|
||||
const res = await fetch(`${BASE}/events`);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`GET /events failed: ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the recorded event list.
|
||||
*/
|
||||
export async function clearEvents() {
|
||||
const res = await fetch(`${BASE}/events`, { method: 'DELETE' });
|
||||
|
||||
if (res.status !== 204) {
|
||||
throw new Error(`DELETE /events failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets room._data.hideDisplayNameForGuests for mod_muc_displayname tests.
|
||||
* The room must already exist (at least one occupant).
|
||||
*
|
||||
* @param {string} roomJid e.g. 'room@conference.localhost'
|
||||
* @param {boolean} hidden true to enable filtering; false to disable
|
||||
*/
|
||||
export async function setHideDisplayNameForGuests(roomJid, hidden) {
|
||||
const res = await fetch(`${BASE}/rooms/hide-display-name`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jid: roomJid,
|
||||
hidden })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`setHideDisplayNameForGuests failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets room._data.breakout_rooms_active for mod_muc_cleanup_backend_services tests.
|
||||
* When active is true, the module skips the destroy-timer logic even if only
|
||||
* backend services remain. The room must already exist (at least one occupant).
|
||||
*
|
||||
* @param {string} roomJid e.g. 'room@conference.localhost'
|
||||
* @param {boolean} active true to simulate active breakout rooms; false to clear
|
||||
*/
|
||||
export async function setBreakoutRoomsActive(roomJid, active) {
|
||||
const res = await fetch(`${BASE}/rooms/breakout-rooms-active`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jid: roomJid,
|
||||
active })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`setBreakoutRoomsActive failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the per-room max_occupants limit for an existing room.
|
||||
* The room must already exist (at least one occupant).
|
||||
* This overrides the global muc_max_occupants for this room only.
|
||||
*
|
||||
* @param {string} roomJid e.g. 'room@conference.localhost'
|
||||
* @param {number} max new occupant limit
|
||||
*/
|
||||
export async function setRoomMaxOccupants(roomJid, max) {
|
||||
const res = await fetch(`${BASE}/rooms/max-occupants`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jid: roomJid,
|
||||
// eslint-disable-next-line camelcase
|
||||
max_occupants: max
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`setRoomMaxOccupants failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the live jitsi_meet_context_features for a session.
|
||||
* Unlike session-info (which snapshots at resource-bind), this reads the current
|
||||
* value so it reflects any updates made after join (e.g. by mod_jitsi_permissions).
|
||||
*
|
||||
* @param {string} fullJid e.g. 'abc123@localhost/res1'
|
||||
* @returns {Promise<object|null>} feature map, or null when none are set
|
||||
*/
|
||||
export async function getSessionFeatures(fullJid) {
|
||||
const res = await fetch(`${BASE}/sessions/features?jid=${encodeURIComponent(fullJid)}`);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`getSessionFeatures failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
return (await res.json()).features;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets jitsi_meet_context_user and jitsi_meet_context_features on an active c2s
|
||||
* session identified by full JID. Allows tests to simulate JWT token context
|
||||
* without running a real token-auth module.
|
||||
*
|
||||
* @param {string} fullJid e.g. 'abc123@localhost/res1'
|
||||
* @param {string} userId value for jitsi_meet_context_user.id
|
||||
* @param {object} features key/value feature flags, e.g. { flip: true }
|
||||
*/
|
||||
export async function setSessionContext(fullJid, userId, features = {}) {
|
||||
const res = await fetch(`${BASE}/sessions/context`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jid: fullJid,
|
||||
// eslint-disable-next-line camelcase
|
||||
user_id: userId,
|
||||
features })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`setSessionContext failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns mod_muc_flip's per-room participant tracking state.
|
||||
*
|
||||
* @param {string} roomJid e.g. 'room@conference.localhost'
|
||||
* @returns {Promise<{participants_details: object, kicked_participant_nick?: string, flip_participant_nick?: string}>}
|
||||
*/
|
||||
export async function getRoomParticipants(roomJid) {
|
||||
const res = await fetch(`${BASE}/rooms/participants?jid=${encodeURIComponent(roomJid)}`);
|
||||
|
||||
if (res.status === 404) {
|
||||
return null;
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`getRoomParticipants failed: ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables or disables the lobby for the given room.
|
||||
*
|
||||
* @param {string} roomJid e.g. 'room@conference.localhost'
|
||||
* @param {boolean} enabled
|
||||
* @returns {Promise<{ok: boolean, lobbyroom: string|null}>}
|
||||
*/
|
||||
async function setLobby(roomJid, enabled) {
|
||||
const res = await fetch(`${BASE}/rooms/lobby`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jid: roomJid,
|
||||
enabled
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`setLobby(${enabled}) failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables the lobby for the given room.
|
||||
* Returns the lobby room JID.
|
||||
*
|
||||
* @param {string} roomJid e.g. 'room@conference.localhost'
|
||||
* @returns {Promise<string>} lobby room JID
|
||||
*/
|
||||
export async function enableLobby(roomJid) {
|
||||
const data = await setLobby(roomJid, true);
|
||||
|
||||
return data.lobbyroom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables the lobby for the given room and unsets members-only.
|
||||
*
|
||||
* @param {string} roomJid e.g. 'room@conference.localhost'
|
||||
*/
|
||||
export async function disableLobby(roomJid) {
|
||||
await setLobby(roomJid, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the affiliation of a JID in a room.
|
||||
*
|
||||
* @param {string} roomJid e.g. 'room@conference.localhost'
|
||||
* @param {string} occupantJid bare JID of the user, e.g. 'user@localhost'
|
||||
* @param {string} affiliation e.g. 'member', 'owner', 'none'
|
||||
*/
|
||||
export async function setAffiliation(roomJid, occupantJid, affiliation) {
|
||||
const res = await fetch(`${BASE}/rooms/affiliation`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jid: roomJid,
|
||||
// eslint-disable-next-line camelcase
|
||||
occupant_jid: occupantJid,
|
||||
affiliation
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`setAffiliation failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the jitsiMetadata table for a room as seen by Prosody.
|
||||
* Useful for asserting server-side state after metadata updates.
|
||||
*
|
||||
* Returns null if the room does not exist.
|
||||
* Throws if the server cannot encode the metadata (indicates corrupt state).
|
||||
*
|
||||
* @param {string} roomJid e.g. 'room@conference.localhost'
|
||||
* @returns {Promise<{jid: string, metadata: object}|null>}
|
||||
*/
|
||||
export async function getRoomMetadata(roomJid) {
|
||||
const res = await fetch(`${BASE}/rooms/metadata?jid=${encodeURIComponent(roomJid)}`);
|
||||
|
||||
if (res.status === 404) {
|
||||
return null;
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`GET /rooms/metadata failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the aggregated live-translation request map stored on room._data by
|
||||
* mod_audio_translation_component. Reads server state directly, so it is robust
|
||||
* against broadcast de-duplication (no broadcast fires when the aggregate is
|
||||
* unchanged).
|
||||
*
|
||||
* Returns null if the room does not exist; the map is undefined when there are
|
||||
* no subscriptions.
|
||||
*
|
||||
* @param {string} roomJid e.g. 'room@conference.localhost'
|
||||
* @returns {Promise<{jid: string, audioTranslationRequests?: object}|null>}
|
||||
*/
|
||||
export async function getAudioTranslationRequests(roomJid) {
|
||||
const res = await fetch(
|
||||
`${BASE}/rooms/audio-translation-requests?jid=${encodeURIComponent(roomJid)}`);
|
||||
|
||||
if (res.status === 404) {
|
||||
return null;
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`GET /rooms/audio-translation-requests failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns every OTLP ExportTraceServiceRequest body received by the mock
|
||||
* receiver (mod_test_observer_http) so far. Used by mod_trace tests.
|
||||
* @returns {Promise<Array<object>>}
|
||||
*/
|
||||
export async function getOtlpTraces() {
|
||||
const res = await fetch(`${BASE}/otlp-traces`);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`GET /otlp-traces failed: ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the recorded OTLP export list.
|
||||
*/
|
||||
export async function clearOtlpTraces() {
|
||||
const res = await fetch(`${BASE}/otlp-traces`, { method: 'DELETE' });
|
||||
|
||||
if (res.status !== 204) {
|
||||
throw new Error(`DELETE /otlp-traces failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns room state from Prosody's internal MUC state.
|
||||
* @param {string} roomJid e.g. 'room@conference.localhost'
|
||||
* @returns {Promise<{jid: string, hidden: boolean, occupant_count: number}|null>}
|
||||
*/
|
||||
export async function getRoomState(roomJid) {
|
||||
const res = await fetch(`${BASE}/rooms?jid=${encodeURIComponent(roomJid)}`);
|
||||
|
||||
if (res.status === 404) {
|
||||
return null;
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`GET /rooms failed: ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the mod_muc_kick_participant HTTP endpoint to kick a participant.
|
||||
*
|
||||
* Returns raw { status, body } so tests can assert on error responses.
|
||||
*
|
||||
* @param {string} roomJid e.g. 'room@conference.localhost'
|
||||
* @param {string} participantId Occupant nick / resource to kick.
|
||||
* @param {string} token Bearer token.
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.omitAuth] If true, omits the Authorization header.
|
||||
* @returns {Promise<{status: number, body: string}>}
|
||||
*/
|
||||
export async function kickParticipant(roomJid, participantId, token, { omitAuth = false } = {}) {
|
||||
const roomName = roomJid.split('@')[0];
|
||||
const url = new URL(KICK_PARTICIPANT_URL);
|
||||
|
||||
url.searchParams.set('room', roomName);
|
||||
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
|
||||
if (!omitAuth) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify({ participantId })
|
||||
});
|
||||
|
||||
return { status: res.status,
|
||||
body: await res.text() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the mod_muc_jigasi_invite HTTP endpoint to invite Jigasi to dial out.
|
||||
*
|
||||
* Returns raw { status, body } so tests can assert on error responses.
|
||||
*
|
||||
* @param {string} roomJid Conference room JID, e.g. 'room@conference.localhost'
|
||||
* @param {string} phoneNo Dial target, e.g. '+15551234567'
|
||||
* @param {string} token Bearer token.
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.omitAuth] If true, omits the Authorization header.
|
||||
* @returns {Promise<{status: number, body: string}>}
|
||||
*/
|
||||
export async function inviteJigasi(roomJid, phoneNo, token, { omitAuth = false } = {}) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
|
||||
if (!omitAuth) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(JIGASI_INVITE_URL, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ conference: roomJid,
|
||||
phoneNo })
|
||||
});
|
||||
|
||||
return { status: res.status,
|
||||
body: await res.text() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the mod_system_chat_message HTTP endpoint to send a system chat message
|
||||
* to one or more participants.
|
||||
*
|
||||
* Returns raw { status, body } so tests can assert on error responses.
|
||||
*
|
||||
* @param {string} roomJid e.g. 'room@conference.localhost'
|
||||
* @param {string[]} connectionJIDs Full JIDs of recipients, e.g. ['user@localhost/res']
|
||||
* @param {string} message Message text.
|
||||
* @param {string} token Bearer token.
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.omitAuth] If true, omits the Authorization header.
|
||||
* @param {string} [opts.displayName] Optional display name to include.
|
||||
* @returns {Promise<{status: number, body: string}>}
|
||||
*/
|
||||
export async function sendSystemChatMessage(roomJid, connectionJIDs, message, token, { // eslint-disable-line max-params
|
||||
omitAuth = false,
|
||||
displayName
|
||||
} = {}) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
|
||||
if (!omitAuth) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const body = { room: roomJid,
|
||||
connectionJIDs,
|
||||
message };
|
||||
|
||||
if (displayName !== undefined) {
|
||||
body.displayName = displayName;
|
||||
}
|
||||
|
||||
const res = await fetch(SYSTEM_CHAT_URL, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
return { status: res.status,
|
||||
body: await res.text() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the mod_muc_end_meeting HTTP endpoint to terminate a conference.
|
||||
*
|
||||
* Returns the raw { status, body } rather than throwing on non-2xx so that
|
||||
* tests can assert on error responses (401, 404, etc.) directly.
|
||||
*
|
||||
* @param {string} roomJid e.g. 'room@conference.localhost'
|
||||
* @param {string} token Bearer token (system token for success; login token to test rejection).
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.silentReconnect] If true, adds silent-reconnect=true to the query.
|
||||
* @param {boolean} [opts.omitAuth] If true, omits the Authorization header entirely.
|
||||
* @returns {Promise<{status: number, body: string}>}
|
||||
*/
|
||||
export async function endMeeting(roomJid, token, { silentReconnect = false, omitAuth = false } = {}) {
|
||||
const url = new URL(END_MEETING_URL);
|
||||
|
||||
url.searchParams.set('conference', roomJid);
|
||||
if (silentReconnect) {
|
||||
url.searchParams.set('silent-reconnect', 'true');
|
||||
}
|
||||
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
|
||||
if (!omitAuth) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), { method: 'POST',
|
||||
headers });
|
||||
|
||||
return { status: res.status,
|
||||
body: await res.text() };
|
||||
}
|
||||
1341
tests/prosody/helpers/xmpp_client.js
Normal file
1341
tests/prosody/helpers/xmpp_client.js
Normal file
File diff suppressed because it is too large
Load Diff
69
tests/prosody/helpers/xmpp_utils.js
Normal file
69
tests/prosody/helpers/xmpp_utils.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import { createXmppClient } from './xmpp_client.js';
|
||||
|
||||
/**
|
||||
* Connects a client to the given domain, sends a XEP-0215 extdisco IQ, and
|
||||
* asserts that the response is a valid result containing service entries that
|
||||
* all use the expected port. The connected client is pushed into `clients` for
|
||||
* afterEach cleanup.
|
||||
*
|
||||
* @param {string} domain - XMPP domain to connect to and send the IQ to.
|
||||
* @param {number} expectedPort - Port number all returned service entries must use.
|
||||
* @param {Array} clients - Cleanup array; the new client is appended.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function assertExtdiscoServices(domain, expectedPort, clients) {
|
||||
const c = await createXmppClient({ domain });
|
||||
|
||||
clients.push(c);
|
||||
|
||||
const iq = await c.sendExtdiscoIq(domain);
|
||||
|
||||
assert.equal(iq.attrs.type, 'result', `expected result IQ, got type="${iq.attrs.type}"`);
|
||||
|
||||
const services = iq.getChild('services', 'urn:xmpp:extdisco:1');
|
||||
|
||||
assert.ok(services, 'result IQ must contain <services xmlns="urn:xmpp:extdisco:1">');
|
||||
|
||||
const entries = services.getChildren('service');
|
||||
|
||||
assert.ok(entries.length > 0, 'services element must contain at least one <service>');
|
||||
|
||||
const ports = entries.map(s => s.attrs.port);
|
||||
|
||||
assert.ok(
|
||||
entries.every(s => Number(s.attrs.port) === expectedPort),
|
||||
`all entries must use port ${expectedPort}, got: ${JSON.stringify(ports)}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when a presence stanza represents a successful join.
|
||||
* Per XEP-0045 / RFC 6121, available presence has no type attribute or
|
||||
* type="available"; error presence has type="error".
|
||||
*
|
||||
* @param {object} presence - Presence stanza.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAvailablePresence(presence) {
|
||||
const t = presence.attrs.type;
|
||||
|
||||
return t === undefined || t === 'available';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the value of a named field from a disco#info IQ result stanza.
|
||||
* Returns the string value, or null if the field is absent or has no value.
|
||||
*
|
||||
* @param {object} iq - IQ stanza returned by sendDiscoInfo().
|
||||
* @param {string} varName - The field var attribute to look for.
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function discoField(iq, varName) {
|
||||
const query = iq.getChild('query', 'http://jabber.org/protocol/disco#info');
|
||||
const form = query?.getChild('x', 'jabber:x:data');
|
||||
const field = form?.getChildren('field').find(f => f.attrs.var === varName);
|
||||
|
||||
return field?.getChild('value')?.text() ?? null;
|
||||
}
|
||||
154
tests/prosody/lua/jwk_spec.lua
Normal file
154
tests/prosody/lua/jwk_spec.lua
Normal file
@@ -0,0 +1,154 @@
|
||||
-- Unit tests for token/jwk.lib.lua
|
||||
-- Run with busted from resources/prosody-plugins/:
|
||||
-- busted spec/lua/
|
||||
-- Requires: basexx (luarocks install basexx)
|
||||
|
||||
-- dofile resolves relative to CWD, so run from resources/prosody-plugins/.
|
||||
-- The module sets ASN1, base64_encode, and base64url_decode as globals.
|
||||
local M = dofile("token/jwk.lib.lua")
|
||||
local ASN1 = M.ASN1
|
||||
|
||||
describe("jwk.lib", function()
|
||||
|
||||
describe("ASN1", function()
|
||||
|
||||
describe("encode_length", function()
|
||||
it("encodes single-byte length for 0", function()
|
||||
assert.equal(string.char(0x00), ASN1.encode_length(0))
|
||||
end)
|
||||
|
||||
it("encodes single-byte length for 127", function()
|
||||
assert.equal(string.char(0x7F), ASN1.encode_length(127))
|
||||
end)
|
||||
|
||||
it("encodes two-byte form for 128", function()
|
||||
assert.equal(string.char(0x81, 0x80), ASN1.encode_length(128))
|
||||
end)
|
||||
|
||||
it("encodes two-byte form for 255", function()
|
||||
assert.equal(string.char(0x81, 0xFF), ASN1.encode_length(255))
|
||||
end)
|
||||
|
||||
it("encodes three-byte form for 256", function()
|
||||
assert.equal(string.char(0x82, 0x01, 0x00), ASN1.encode_length(256))
|
||||
end)
|
||||
|
||||
it("encodes three-byte form for 65535", function()
|
||||
assert.equal(string.char(0x82, 0xFF, 0xFF), ASN1.encode_length(65535))
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("encode_null", function()
|
||||
it("returns NULL tag (0x05) with zero length", function()
|
||||
assert.equal(string.char(0x05, 0x00), ASN1.encode_null())
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("encode_integer", function()
|
||||
it("tags with 0x02 and does not pad when high bit is clear", function()
|
||||
assert.equal(string.char(0x02, 0x01, 0x7F), ASN1.encode_integer(string.char(0x7F)))
|
||||
end)
|
||||
|
||||
it("prepends 0x00 when high bit is set to indicate positive number", function()
|
||||
assert.equal(string.char(0x02, 0x02, 0x00, 0x80), ASN1.encode_integer(string.char(0x80)))
|
||||
end)
|
||||
|
||||
it("prepends 0x00 when first byte is 0xFF", function()
|
||||
assert.equal(string.char(0x02, 0x02, 0x00, 0xFF), ASN1.encode_integer(string.char(0xFF)))
|
||||
end)
|
||||
|
||||
it("handles multi-byte integer without high bit", function()
|
||||
assert.equal(string.char(0x02, 0x02, 0x01, 0x00), ASN1.encode_integer(string.char(0x01, 0x00)))
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("encode_sequence", function()
|
||||
it("wraps content with SEQUENCE tag 0x30", function()
|
||||
assert.equal(string.char(0x30, 0x02, 0xAB, 0xCD), ASN1.encode_sequence(string.char(0xAB, 0xCD)))
|
||||
end)
|
||||
|
||||
it("handles empty content", function()
|
||||
assert.equal(string.char(0x30, 0x00), ASN1.encode_sequence(""))
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("encode_bit_string", function()
|
||||
it("wraps with BIT STRING tag (0x03) and prepends unused-bits byte (0x00)", function()
|
||||
-- tag=0x03, length=2 (0x00 + one content byte), 0x00 unused bits, 0xAB
|
||||
assert.equal(string.char(0x03, 0x02, 0x00, 0xAB), ASN1.encode_bit_string(string.char(0xAB)))
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("encode_oid", function()
|
||||
it("wraps bytes with OID tag 0x06", function()
|
||||
local oid_bytes = string.char(0x2A, 0x86, 0x48)
|
||||
assert.equal(string.char(0x06, 0x03) .. oid_bytes, ASN1.encode_oid(oid_bytes))
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("der_to_pem", function()
|
||||
it("wraps DER bytes in PEM format with default PUBLIC KEY label", function()
|
||||
local der = string.char(0x30, 0x00)
|
||||
local pem = ASN1.der_to_pem(der)
|
||||
assert.truthy(pem:match("^-----BEGIN PUBLIC KEY-----\n"))
|
||||
assert.truthy(pem:match("-----END PUBLIC KEY-----\n$"))
|
||||
end)
|
||||
|
||||
it("uses custom label when provided", function()
|
||||
local der = string.char(0x30, 0x00)
|
||||
local pem = ASN1.der_to_pem(der, "RSA PUBLIC KEY")
|
||||
assert.truthy(pem:match("^-----BEGIN RSA PUBLIC KEY-----\n"))
|
||||
assert.truthy(pem:match("-----END RSA PUBLIC KEY-----\n$"))
|
||||
end)
|
||||
|
||||
it("breaks base64 into 64-character lines", function()
|
||||
-- 48 bytes of DER = 64 chars of base64 (exactly one line)
|
||||
local der = string.rep(string.char(0x00), 48)
|
||||
local pem = ASN1.der_to_pem(der)
|
||||
-- Should have exactly one data line between the markers
|
||||
local data = pem:match("-----BEGIN PUBLIC KEY-----\n(.-)\n-----END PUBLIC KEY-----")
|
||||
assert.falsy(data:match("\n")) -- no newline = single line
|
||||
assert.equal(64, #data)
|
||||
end)
|
||||
end)
|
||||
|
||||
end) -- ASN1
|
||||
|
||||
describe("jwk_to_pem", function()
|
||||
-- Test vectors computed independently with Python's cryptography primitives.
|
||||
-- n=0x0F (15), e=0x03 (3) — minimal key with no high-bit padding needed.
|
||||
it("converts a minimal RSA JWK (no 0x00 padding) to PEM", function()
|
||||
local pem = M.jwk_to_pem({ n = "Dw", e = "Aw" })
|
||||
local expected = "-----BEGIN PUBLIC KEY-----\n"
|
||||
.. "MBowDQYJKoZIhvcNAQEBBQADCQAwBgIBDwIBAw==\n"
|
||||
.. "-----END PUBLIC KEY-----\n"
|
||||
assert.equal(expected, pem)
|
||||
end)
|
||||
|
||||
-- n=0x8001, e=65537 — modulus has high bit set, so ASN.1 must prepend 0x00.
|
||||
it("converts a JWK with high-bit modulus (requires 0x00 padding) to PEM", function()
|
||||
local pem = M.jwk_to_pem({ n = "gAE", e = "AQAB" })
|
||||
local expected = "-----BEGIN PUBLIC KEY-----\n"
|
||||
.. "MB4wDQYJKoZIhvcNAQEBBQADDQAwCgIDAIABAgMBAAE=\n"
|
||||
.. "-----END PUBLIC KEY-----\n"
|
||||
assert.equal(expected, pem)
|
||||
end)
|
||||
|
||||
it("output begins and ends with correct PEM markers", function()
|
||||
local pem = M.jwk_to_pem({ n = "Dw", e = "Aw" })
|
||||
assert.truthy(pem:match("^-----BEGIN PUBLIC KEY-----\n"))
|
||||
assert.truthy(pem:match("-----END PUBLIC KEY-----\n$"))
|
||||
end)
|
||||
|
||||
it("returns nil when n is missing", function()
|
||||
local pem = M.jwk_to_pem({ e = "Aw" })
|
||||
assert.is_nil(pem)
|
||||
end)
|
||||
|
||||
it("returns nil when e is missing", function()
|
||||
local pem = M.jwk_to_pem({ n = "Dw" })
|
||||
assert.is_nil(pem)
|
||||
end)
|
||||
end) -- jwk_to_pem
|
||||
|
||||
end) -- jwk.lib
|
||||
443
tests/prosody/lua/luajwtjitsi_spec.lua
Normal file
443
tests/prosody/lua/luajwtjitsi_spec.lua
Normal file
@@ -0,0 +1,443 @@
|
||||
-- Unit tests for luajwtjitsi.lib.lua
|
||||
-- Run with busted from resources/prosody-plugins/:
|
||||
-- busted spec/lua/
|
||||
-- Requires: basexx, lua-cjson, openssl (luarocks package 'openssl')
|
||||
|
||||
-- Compatibility shim: the luarocks 'openssl' package (v0.11+) has a different
|
||||
-- API from the old 'lua-openssl' that the production code was written for.
|
||||
-- Differences handled here:
|
||||
-- 1. require style: monolithic (require 'openssl') vs submodule (require 'openssl.digest')
|
||||
-- 2. hmac.new: old=(key,algo), new=(algo,key)
|
||||
-- 3. digest:update(): old returns self (chainable), new returns bool
|
||||
-- 4. pkey.new(pem): not supported in new API; use pkey.read(pem, private, 'pem')
|
||||
-- 5. pkey:sign(digest_obj): old takes digest obj, new takes (data, algo)
|
||||
-- 6. pkey:verify(sig, digest_obj): old order; new is verify(data, sig, algo)
|
||||
local _ok, _ssl = pcall(require, 'openssl')
|
||||
if _ok and type(_ssl) == 'table' then
|
||||
|
||||
-- 1+2. hmac: swap args and register as submodule
|
||||
if not package.preload['openssl.hmac'] then
|
||||
local _hmac_raw = _ssl.hmac
|
||||
local _hmac_compat = setmetatable({}, { __index = _hmac_raw })
|
||||
_hmac_compat.new = function(key, algo) return _hmac_raw.new(algo, key) end
|
||||
package.preload['openssl.hmac'] = function() return _hmac_compat end
|
||||
end
|
||||
|
||||
-- 1+3. digest: make :update() return self, store data+algo for pkey compat
|
||||
if not package.preload['openssl.digest'] then
|
||||
local _digest_raw = _ssl.digest
|
||||
local _digest_compat = {}
|
||||
_digest_compat.new = function(algo)
|
||||
local inner = _digest_raw.new(algo)
|
||||
local w = { _inner = inner, _algo = algo, _data = '' }
|
||||
return setmetatable(w, {
|
||||
__index = function(self, k)
|
||||
if k == 'update' then
|
||||
return function(s, d)
|
||||
s._inner:update(d)
|
||||
s._data = s._data .. d
|
||||
return s -- return self for chaining (old API)
|
||||
end
|
||||
elseif k == 'final' then
|
||||
return function(s) return s._inner:final() end
|
||||
end
|
||||
end
|
||||
})
|
||||
end
|
||||
package.preload['openssl.digest'] = function() return _digest_compat end
|
||||
end
|
||||
|
||||
-- 1+4+5+6. pkey: map new() to read(), adapt sign/verify signatures
|
||||
if not package.preload['openssl.pkey'] then
|
||||
local _pkey_raw = _ssl.pkey
|
||||
local function wrap_key(raw)
|
||||
return setmetatable({}, {
|
||||
__index = function(_, k)
|
||||
if k == 'sign' then
|
||||
-- old: sign(digest_obj) → new: sign(data, algo)
|
||||
return function(_, dobj)
|
||||
return raw:sign(dobj._data, dobj._algo)
|
||||
end
|
||||
elseif k == 'verify' then
|
||||
-- old: verify(sig, digest_obj) → new: verify(data, sig, algo)
|
||||
return function(_, sig, dobj)
|
||||
return raw:verify(dobj._data, sig, dobj._algo)
|
||||
end
|
||||
else
|
||||
return raw[k]
|
||||
end
|
||||
end
|
||||
})
|
||||
end
|
||||
local _pkey_compat = setmetatable({}, { __index = _pkey_raw })
|
||||
_pkey_compat.new = function(pem)
|
||||
local ok1, k1 = pcall(_pkey_raw.read, pem, true, 'pem') -- private
|
||||
if ok1 and k1 then return wrap_key(k1) end
|
||||
local ok2, k2 = pcall(_pkey_raw.read, pem, false, 'pem') -- public
|
||||
if ok2 and k2 then return wrap_key(k2) end
|
||||
return nil -- signal invalid key (matches old API behaviour)
|
||||
end
|
||||
package.preload['openssl.pkey'] = function() return _pkey_compat end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
-- Guard: skip the whole suite if the module's hard dependencies aren't installed.
|
||||
local ok, M = pcall(dofile, "luajwtjitsi.lib.lua")
|
||||
if not ok then
|
||||
describe("luajwtjitsi", function()
|
||||
it("skipped — missing dependencies (lua-cjson and/or lua-openssl not installed)", function()
|
||||
pending(tostring(M):match("([^\n]+)"))
|
||||
end)
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
local I = M._internals
|
||||
|
||||
-- Test RSA key pair (1024-bit PKCS#1, test use only — not for production).
|
||||
-- Generated with: openssl genrsa -traditional 1024
|
||||
local PRIVATE_PEM = [[
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIICXQIBAAKBgQDElXxE6H6t622jPZbwgXi+WuJpLDIPr2znPUqtzTJCAVnK1+Iw
|
||||
CoTjKYmtkiaszVCn/QL41Hr1JXHA/zzo/dLedvKQhs6dvadUVt4Ch2D7hcqGzn7T
|
||||
oCTCzD1CyaIb9jGR0KOh8gMpL7JE6Zk9f1ku+91QPTe55FlyHPJ4fZfh/wIDAQAB
|
||||
AoGBAK6uUlV87owmxi6m3MfrlFo5sjRYhEdxz2iPCxvzWTRTtPFM8f1Ua47bay6j
|
||||
zuAANSqA5bIsRPIvCqy/YOV8HnHnNVl1ARCEAWMl2it0BsqOxuJKhqUsbp1gPAtM
|
||||
mJE51yPwfN8fcvMNw+6dwV0h3jp+Y57RR0JAhMRWZNrBSmPhAkEA4smEZX8tjQzB
|
||||
W3FoPIz5a8hgTDfyHYZftC6HqTdEx/NK1z2+EkirS/4WAcMkIb63gWtxSs/9H+bE
|
||||
tEKcIGzJsQJBAN3n/iLa+2SIseykvzD5bJrdqdmpFYTMzzs8mua5ngkVGd5hkhJ1
|
||||
MhhfRe3B/ytBLsd74A889k6vFUxoIYMYoq8CQDinhv+kCckWyqcBabzWquiWIhho
|
||||
UX+2tenZReKr/+7DhIrIzdbbeI9/ZhgCm+AiOM2H/cFmvCFgEPvHOCGRi8ECQQC3
|
||||
qIe0j2sbZOtlgWDgBr+1WRp3lDLEK8KkM1+88Al1WOqFHFoKPscMVuYIhtRLF3LB
|
||||
Fax20CzHKK9HArQhzWTvAkBezAcZyo/TBNq+gHKAQw66mFbr7QSd7BCMIQAZg6Cn
|
||||
VfvTq/eTsOOIgqKYyQzlSY1Jwu27pVQtRzGPOitHbO3E
|
||||
-----END RSA PRIVATE KEY-----
|
||||
]]
|
||||
|
||||
local PUBLIC_PEM = [[
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDElXxE6H6t622jPZbwgXi+WuJp
|
||||
LDIPr2znPUqtzTJCAVnK1+IwCoTjKYmtkiaszVCn/QL41Hr1JXHA/zzo/dLedvKQ
|
||||
hs6dvadUVt4Ch2D7hcqGzn7ToCTCzD1CyaIb9jGR0KOh8gMpL7JE6Zk9f1ku+91Q
|
||||
PTe55FlyHPJ4fZfh/wIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
]]
|
||||
|
||||
-- Returns a payload with exp set one hour in the future (so verify's exp check passes).
|
||||
-- Extra fields can be merged in via the `extra` table.
|
||||
local function payload(extra)
|
||||
local p = { sub = 'test', exp = os.time() + 3600 }
|
||||
if extra then
|
||||
for k, v in pairs(extra) do p[k] = v end
|
||||
end
|
||||
return p
|
||||
end
|
||||
|
||||
describe("luajwtjitsi", function()
|
||||
|
||||
describe("internals", function()
|
||||
|
||||
describe("verify_claim", function()
|
||||
it("exact match returns true", function()
|
||||
assert.is_true(I.verify_claim('foo', { 'foo', 'bar' }))
|
||||
end)
|
||||
|
||||
it("non-matching value returns false", function()
|
||||
assert.is_false(I.verify_claim('baz', { 'foo', 'bar' }))
|
||||
end)
|
||||
|
||||
it("wildcard '*' matches any value", function()
|
||||
assert.is_true(I.verify_claim('anything', { '*' }))
|
||||
end)
|
||||
|
||||
it("empty list returns false", function()
|
||||
assert.is_false(I.verify_claim('foo', {}))
|
||||
end)
|
||||
|
||||
it("wildcard in mixed list still matches", function()
|
||||
assert.is_true(I.verify_claim('x', { 'a', '*', 'b' }))
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("split_token", function()
|
||||
it("splits three segments correctly", function()
|
||||
local parts = I.split_token("aaa.bbb.ccc")
|
||||
assert.equal(3, #parts)
|
||||
assert.equal('aaa', parts[1])
|
||||
assert.equal('bbb', parts[2])
|
||||
assert.equal('ccc', parts[3])
|
||||
end)
|
||||
|
||||
it("returns one element when there are no dots", function()
|
||||
local parts = I.split_token("nodots")
|
||||
assert.equal(1, #parts)
|
||||
assert.equal('nodots', parts[1])
|
||||
end)
|
||||
|
||||
it("returns two elements for two segments", function()
|
||||
local parts = I.split_token("hdr.body")
|
||||
assert.equal(2, #parts)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("strip_signature", function()
|
||||
it("removes the signature segment", function()
|
||||
local result = I.strip_signature("hdr.body.sig")
|
||||
assert.equal("hdr.body", result)
|
||||
end)
|
||||
|
||||
it("returns nil and error for two-segment input", function()
|
||||
local result, _, _, err = I.strip_signature("hdr.body")
|
||||
assert.is_nil(result)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
|
||||
it("returns nil and error for single-segment input", function()
|
||||
local result, _, _, err = I.strip_signature("nodots")
|
||||
assert.is_nil(result)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("parse_token", function()
|
||||
it("parses a valid HS256 token into header, body, and signature", function()
|
||||
local tok = assert(M.encode({ sub = 'alice', exp = os.time() + 3600 }, 'secret', 'HS256'))
|
||||
local header, body, sig = I.parse_token(tok)
|
||||
assert.is_table(header)
|
||||
assert.is_table(body)
|
||||
assert.is_string(sig)
|
||||
assert.equal('JWT', header.typ)
|
||||
assert.equal('HS256', header.alg)
|
||||
assert.equal('alice', body.sub)
|
||||
end)
|
||||
|
||||
it("returns error for token with only two segments", function()
|
||||
local h, b, s, err = I.parse_token("hdr.body")
|
||||
assert.is_nil(h)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
|
||||
it("returns error or throws for token with invalid base64 header", function()
|
||||
-- Older basexx returns garbage bytes (decode attempt fails gracefully);
|
||||
-- newer basexx returns nil causing cjson.decode to throw. Both are
|
||||
-- acceptable outcomes — just verify we never get a valid header back.
|
||||
local ok, h = pcall(I.parse_token, "!!!!.body.sig")
|
||||
if ok then
|
||||
assert.is_nil(h) -- graceful nil-return path
|
||||
end
|
||||
-- if not ok: threw an error, which is also acceptable
|
||||
end)
|
||||
|
||||
it("returns error for token with invalid JSON body", function()
|
||||
local basexx = require 'basexx'
|
||||
local good_hdr = basexx.to_url64('{"typ":"JWT","alg":"HS256"}')
|
||||
local bad_body = basexx.to_url64('not-valid-json{{')
|
||||
local h, b, s, err = I.parse_token(good_hdr .. '.' .. bad_body .. '.sig')
|
||||
assert.is_nil(h)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
end)
|
||||
|
||||
end) -- internals
|
||||
|
||||
describe("encode", function()
|
||||
|
||||
describe("argument validation", function()
|
||||
it("non-table data returns error", function()
|
||||
local tok, err = M.encode("not-a-table", "key", "HS256")
|
||||
assert.is_nil(tok)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
|
||||
it("non-string key returns error", function()
|
||||
local tok, err = M.encode({}, 123, "HS256")
|
||||
assert.is_nil(tok)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
|
||||
it("unsupported algorithm returns error", function()
|
||||
local tok, err = M.encode({}, "key", "XX999")
|
||||
assert.is_nil(tok)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
end)
|
||||
|
||||
end) -- encode
|
||||
|
||||
describe("verify", function()
|
||||
|
||||
describe("argument validation", function()
|
||||
it("non-string token returns error", function()
|
||||
local body, err = M.verify(123, "HS256", "key")
|
||||
assert.is_nil(body)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
|
||||
it("non-string algorithm returns error", function()
|
||||
local body, err = M.verify("a.b.c", 42, "key")
|
||||
assert.is_nil(body)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
|
||||
it("unsupported algorithm returns error", function()
|
||||
local body, err = M.verify("a.b.c", "XX999", "key")
|
||||
assert.is_nil(body)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
|
||||
it("malformed token (two segments) returns error", function()
|
||||
local body, err = M.verify("hdr.body", "HS256", "key")
|
||||
assert.is_nil(body)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("HS256", function()
|
||||
it("encode then verify returns original payload", function()
|
||||
local tok = assert(M.encode(payload({ iss = 'me' }), 'my-secret', 'HS256'))
|
||||
local body = assert(M.verify(tok, 'HS256', 'my-secret'))
|
||||
assert.equal('test', body.sub)
|
||||
assert.equal('me', body.iss)
|
||||
end)
|
||||
|
||||
it("wrong key fails verification", function()
|
||||
local tok = assert(M.encode(payload(), 'correct', 'HS256'))
|
||||
local body, err = M.verify(tok, 'HS256', 'wrong')
|
||||
assert.is_nil(body)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
|
||||
it("tampered payload fails verification", function()
|
||||
local tok = assert(M.encode(payload(), 'secret', 'HS256'))
|
||||
local basexx = require 'basexx'
|
||||
local parts = I.split_token(tok)
|
||||
parts[2] = basexx.to_url64('{"sub":"hacker","exp":9999999999}')
|
||||
local body, err = M.verify(table.concat(parts, '.'), 'HS256', 'secret')
|
||||
assert.is_nil(body)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("HS384", function()
|
||||
it("encode then verify returns original payload", function()
|
||||
local tok = assert(M.encode(payload(), 'secret', 'HS384'))
|
||||
local body = assert(M.verify(tok, 'HS384', 'secret'))
|
||||
assert.equal('test', body.sub)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("HS512", function()
|
||||
it("encode then verify returns original payload", function()
|
||||
local tok = assert(M.encode(payload(), 'secret', 'HS512'))
|
||||
local body = assert(M.verify(tok, 'HS512', 'secret'))
|
||||
assert.equal('test', body.sub)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("RS256", function()
|
||||
it("encode with private key, verify with public key", function()
|
||||
local tok = assert(M.encode(payload(), PRIVATE_PEM, 'RS256'))
|
||||
local body = assert(M.verify(tok, 'RS256', PUBLIC_PEM))
|
||||
assert.equal('test', body.sub)
|
||||
end)
|
||||
|
||||
it("tampered signature fails verification", function()
|
||||
local tok = assert(M.encode(payload(), PRIVATE_PEM, 'RS256'))
|
||||
-- Corrupt the middle of the signature segment so the decoded bytes change.
|
||||
-- (Avoid the last 1-2 chars: they may carry padding bits that decode identically.)
|
||||
local mid = math.floor(#tok / 2)
|
||||
local c = tok:sub(mid, mid)
|
||||
local replacement = (c == 'A') and 'B' or 'A'
|
||||
local tampered = tok:sub(1, mid - 1) .. replacement .. tok:sub(mid + 1)
|
||||
local body, err = M.verify(tampered, 'RS256', PUBLIC_PEM)
|
||||
assert.is_nil(body)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("claims", function()
|
||||
it("expired token is rejected", function()
|
||||
local tok = assert(M.encode({ sub = 'test', exp = os.time() - 1 }, 'secret', 'HS256'))
|
||||
local body, err = M.verify(tok, 'HS256', 'secret')
|
||||
assert.is_nil(body)
|
||||
assert.truthy(err:match('exp'))
|
||||
end)
|
||||
|
||||
it("nbf in the future is rejected", function()
|
||||
local tok = assert(M.encode(
|
||||
{ sub = 'test', exp = os.time() + 3600, nbf = os.time() + 9999 },
|
||||
'secret', 'HS256'
|
||||
))
|
||||
local body, err = M.verify(tok, 'HS256', 'secret')
|
||||
assert.is_nil(body)
|
||||
assert.truthy(err:match('nbf'))
|
||||
end)
|
||||
|
||||
it("alg mismatch between header and expectedAlgo is rejected", function()
|
||||
local tok = assert(M.encode(payload(), 'secret', 'HS256'))
|
||||
local body, err = M.verify(tok, 'HS512', 'secret')
|
||||
assert.is_nil(body)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("issuer", function()
|
||||
it("accepted issuer passes", function()
|
||||
local tok = assert(M.encode(payload({ iss = 'jitsi' }), 'secret', 'HS256'))
|
||||
local body = assert(M.verify(tok, 'HS256', 'secret', { 'jitsi' }))
|
||||
assert.equal('jitsi', body.iss)
|
||||
end)
|
||||
|
||||
it("wrong issuer is rejected", function()
|
||||
local tok = assert(M.encode(payload({ iss = 'evil' }), 'secret', 'HS256'))
|
||||
local body, err = M.verify(tok, 'HS256', 'secret', { 'jitsi' })
|
||||
assert.is_nil(body)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
|
||||
it("missing iss claim when acceptedIssuers provided is rejected", function()
|
||||
local tok = assert(M.encode(payload(), 'secret', 'HS256'))
|
||||
local body, err = M.verify(tok, 'HS256', 'secret', { 'jitsi' })
|
||||
assert.is_nil(body)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
|
||||
it("wildcard matches any value", function()
|
||||
local tok = assert(M.encode(payload({ iss = 'anyone' }), 'secret', 'HS256'))
|
||||
local body = assert(M.verify(tok, 'HS256', 'secret', { '*' }))
|
||||
assert.equal('anyone', body.iss)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("audience", function()
|
||||
it("accepted audience passes", function()
|
||||
local tok = assert(M.encode(payload({ aud = 'jitsi' }), 'secret', 'HS256'))
|
||||
local body = assert(M.verify(tok, 'HS256', 'secret', nil, { 'jitsi' }))
|
||||
assert.equal('jitsi', body.aud)
|
||||
end)
|
||||
|
||||
it("wrong audience is rejected", function()
|
||||
local tok = assert(M.encode(payload({ aud = 'wrong' }), 'secret', 'HS256'))
|
||||
local body, err = M.verify(tok, 'HS256', 'secret', nil, { 'jitsi' })
|
||||
assert.is_nil(body)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
|
||||
it("missing aud claim when acceptedAudiences provided is rejected", function()
|
||||
local tok = assert(M.encode(payload(), 'secret', 'HS256'))
|
||||
local body, err = M.verify(tok, 'HS256', 'secret', nil, { 'jitsi' })
|
||||
assert.is_nil(body)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
|
||||
it("wildcard matches any value", function()
|
||||
local tok = assert(M.encode(payload({ aud = 'anything' }), 'secret', 'HS256'))
|
||||
local body = assert(M.verify(tok, 'HS256', 'secret', nil, { '*' }))
|
||||
assert.equal('anything', body.aud)
|
||||
end)
|
||||
end)
|
||||
|
||||
end) -- verify
|
||||
|
||||
end) -- luajwtjitsi
|
||||
160
tests/prosody/lua/mod_certs_s2soutinjection_spec.lua
Normal file
160
tests/prosody/lua/mod_certs_s2soutinjection_spec.lua
Normal file
@@ -0,0 +1,160 @@
|
||||
-- Unit tests for mod_certs_s2soutinjection.lua
|
||||
-- Run with busted from resources/prosody-plugins/:
|
||||
-- busted spec/lua/
|
||||
--
|
||||
-- No Prosody installation required — all dependencies are stubbed.
|
||||
-- The module exposes a global `attach` function that is called directly.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Helper: load the module with given option values
|
||||
--
|
||||
-- Returns attach, wrapped_events table so callers can inspect both.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function load_module(options)
|
||||
options = options or {}
|
||||
|
||||
local wrapped_events = {}
|
||||
|
||||
local mod = {
|
||||
set_global = function() end,
|
||||
get_option = function(_, key) return options[key] end,
|
||||
wrap_event = function(_, event_name, handler)
|
||||
wrapped_events[event_name] = handler
|
||||
end,
|
||||
log = function() end,
|
||||
}
|
||||
|
||||
_G.module = mod
|
||||
_G.attach = nil -- reset from any previous load
|
||||
|
||||
local ok, err = pcall(dofile, "mod_certs_s2soutinjection.lua")
|
||||
assert(ok, "module load failed: " .. tostring(err))
|
||||
|
||||
return _G.attach, wrapped_events
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Tests
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
describe("mod_certs_s2soutinjection", function()
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("attach()", function()
|
||||
|
||||
it("marks cert valid when host is in s2s_connect_overrides", function()
|
||||
local attach = load_module({
|
||||
s2s_connect_overrides = { ["bridge.example.com"] = "10.0.0.1:5269" },
|
||||
})
|
||||
|
||||
local session = {}
|
||||
local result = attach({ session = session, host = "bridge.example.com" })
|
||||
|
||||
assert.equal("valid", session.cert_chain_status)
|
||||
assert.equal("valid", session.cert_identity_status)
|
||||
assert.is_true(result)
|
||||
end)
|
||||
|
||||
it("does not touch cert status for host not in overrides", function()
|
||||
local attach = load_module({
|
||||
s2s_connect_overrides = { ["bridge.example.com"] = "10.0.0.1:5269" },
|
||||
})
|
||||
|
||||
local session = {}
|
||||
local result = attach({ session = session, host = "other.example.com" })
|
||||
|
||||
assert.is_nil(session.cert_chain_status)
|
||||
assert.is_nil(session.cert_identity_status)
|
||||
assert.is_falsy(result)
|
||||
end)
|
||||
|
||||
it("returns falsy and does not error when no overrides configured", function()
|
||||
local attach = load_module({})
|
||||
|
||||
local session = {}
|
||||
local result = attach({ session = session, host = "bridge.example.com" })
|
||||
|
||||
assert.is_nil(session.cert_chain_status)
|
||||
assert.is_falsy(result)
|
||||
end)
|
||||
|
||||
it("falls back to s2sout_override when s2s_connect_overrides is absent", function()
|
||||
local attach = load_module({
|
||||
s2sout_override = { ["bridge.example.com"] = "10.0.0.1:5269" },
|
||||
})
|
||||
|
||||
local session = {}
|
||||
local result = attach({ session = session, host = "bridge.example.com" })
|
||||
|
||||
assert.equal("valid", session.cert_chain_status)
|
||||
assert.equal("valid", session.cert_identity_status)
|
||||
assert.is_true(result)
|
||||
end)
|
||||
|
||||
it("prefers s2s_connect_overrides — s2sout_override hosts are ignored", function()
|
||||
local attach = load_module({
|
||||
s2s_connect_overrides = { ["bridge.example.com"] = "10.0.0.1:5269" },
|
||||
s2sout_override = { ["legacy.example.com"] = "10.0.0.2:5269" },
|
||||
})
|
||||
|
||||
-- host only in s2s_connect_overrides → valid
|
||||
local s1 = {}
|
||||
attach({ session = s1, host = "bridge.example.com" })
|
||||
assert.equal("valid", s1.cert_chain_status)
|
||||
|
||||
-- host only in s2sout_override (legacy, ignored when primary present) → not set
|
||||
local s2 = {}
|
||||
attach({ session = s2, host = "legacy.example.com" })
|
||||
assert.is_nil(s2.cert_chain_status)
|
||||
end)
|
||||
|
||||
it("handles multiple hosts in override map independently", function()
|
||||
local attach = load_module({
|
||||
s2s_connect_overrides = {
|
||||
["bridge1.example.com"] = "10.0.0.1:5269",
|
||||
["bridge2.example.com"] = "10.0.0.2:5269",
|
||||
},
|
||||
})
|
||||
|
||||
local s1 = {}
|
||||
attach({ session = s1, host = "bridge1.example.com" })
|
||||
assert.equal("valid", s1.cert_chain_status)
|
||||
|
||||
local s2 = {}
|
||||
attach({ session = s2, host = "bridge2.example.com" })
|
||||
assert.equal("valid", s2.cert_chain_status)
|
||||
|
||||
local s3 = {}
|
||||
attach({ session = s3, host = "unlisted.example.com" })
|
||||
assert.is_nil(s3.cert_chain_status)
|
||||
end)
|
||||
|
||||
end) -- attach()
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("event wrapping", function()
|
||||
|
||||
it("registers handler on s2s-check-certificate event", function()
|
||||
local _, wrapped = load_module({})
|
||||
assert.is_function(wrapped["s2s-check-certificate"])
|
||||
end)
|
||||
|
||||
it("wrapped handler delegates to attach and returns its result", function()
|
||||
local _, wrapped = load_module({
|
||||
s2s_connect_overrides = { ["bridge.example.com"] = "10.0.0.1" },
|
||||
})
|
||||
|
||||
local session = {}
|
||||
local handler = wrapped["s2s-check-certificate"]
|
||||
-- handler signature: (handlers, event_name, event_data)
|
||||
local result = handler(nil, "s2s-check-certificate",
|
||||
{ session = session, host = "bridge.example.com" })
|
||||
|
||||
assert.equal("valid", session.cert_chain_status)
|
||||
assert.is_true(result)
|
||||
end)
|
||||
|
||||
end) -- event wrapping
|
||||
|
||||
end)
|
||||
251
tests/prosody/lua/mod_debug_traceback_spec.lua
Normal file
251
tests/prosody/lua/mod_debug_traceback_spec.lua
Normal file
@@ -0,0 +1,251 @@
|
||||
-- Unit tests for mod_debug_traceback.lua
|
||||
-- Run with busted from resources/prosody-plugins/:
|
||||
-- busted spec/lua/
|
||||
--
|
||||
-- No Prosody installation required — all dependencies are stubbed.
|
||||
-- The module exposes a global `dump_traceback` function that is called directly.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Package preloads (must be registered before any dofile() call)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
package.preload['util.debug'] = function()
|
||||
return { traceback = function() return "fake traceback\nframe 1\nframe 2" end }
|
||||
end
|
||||
|
||||
package.preload['util.pposix'] = function()
|
||||
return { getpid = function() return 99 end }
|
||||
end
|
||||
|
||||
-- Minimal interpolation stub: substitutes {key} and {key.subkey} tokens.
|
||||
-- Filters (yyyymmdd, hhmmss) are invoked when the key matches.
|
||||
package.preload['util.interpolation'] = function()
|
||||
return {
|
||||
new = function(_, _, filters)
|
||||
return function(template, vars)
|
||||
return (template:gsub('{([^}]+)}', function(key)
|
||||
-- Try filters first (yyyymmdd, hhmmss, ...)
|
||||
if filters and filters[key] and vars.time then
|
||||
return tostring(filters[key](vars.time))
|
||||
end
|
||||
-- Nested key lookup (e.g. paths.data)
|
||||
local val = vars
|
||||
for part in key:gmatch('[^%.]+') do
|
||||
if type(val) == 'table' then val = val[part]
|
||||
else val = nil; break end
|
||||
end
|
||||
return val ~= nil and tostring(val) or ('{' .. key .. '}')
|
||||
end))
|
||||
end
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
-- util.signal is required when mod_posix lacks signal_events support
|
||||
package.preload['util.signal'] = function()
|
||||
return { signal = function() end }
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Prosody global required by get_filename (prosody.paths.data)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
_G.prosody = { paths = { data = "/tmp/prosody-test" } }
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Helper: load module with given option overrides
|
||||
--
|
||||
-- Returns: dump_traceback fn, log_calls list, fired_events list
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function load_module(options)
|
||||
options = options or {}
|
||||
local log_calls = {}
|
||||
local fired_events = {}
|
||||
|
||||
local mod = {
|
||||
set_global = function() end,
|
||||
get_option_string = function(_, key, default)
|
||||
local v = options[key]
|
||||
return v ~= nil and v or default
|
||||
end,
|
||||
log = function(_, level, fmt, ...)
|
||||
local ok, msg = pcall(string.format, fmt, ...)
|
||||
table.insert(log_calls, { level = level, msg = ok and msg or fmt })
|
||||
end,
|
||||
fire_event = function(_, name, data)
|
||||
table.insert(fired_events, { name = name, data = data })
|
||||
end,
|
||||
depends = function() return {} end, -- no features.signal_events → util.signal path
|
||||
hook = function() end,
|
||||
wrap_event = function() end,
|
||||
}
|
||||
|
||||
_G.module = mod
|
||||
_G.dump_traceback = nil
|
||||
|
||||
local ok, err = pcall(dofile, "mod_debug_traceback.lua")
|
||||
assert(ok, "module load failed: " .. tostring(err))
|
||||
|
||||
return _G.dump_traceback, log_calls, fired_events
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- io.open helpers
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Replace io.open for the duration of fn(); collect written content per path.
|
||||
local function with_mock_io(files_written, fn)
|
||||
local orig = io.open
|
||||
io.open = function(path, _mode)
|
||||
local buf = {}
|
||||
files_written[path] = buf
|
||||
return {
|
||||
write = function(_, s) buf[#buf + 1] = s end,
|
||||
close = function() end,
|
||||
}
|
||||
end
|
||||
fn()
|
||||
io.open = orig
|
||||
end
|
||||
|
||||
-- Replace io.open with one that always fails.
|
||||
local function with_failing_io(fn)
|
||||
local orig = io.open
|
||||
io.open = function() return nil, "permission denied" end
|
||||
fn()
|
||||
io.open = orig
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Tests
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
describe("mod_debug_traceback", function()
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("dump_traceback()", function()
|
||||
|
||||
it("writes traceback content to file", function()
|
||||
local dump = load_module()
|
||||
local files = {}
|
||||
with_mock_io(files, function() dump() end)
|
||||
|
||||
local path = next(files)
|
||||
local content = table.concat(files[path])
|
||||
assert.is_truthy(content:find("fake traceback", 1, true))
|
||||
end)
|
||||
|
||||
it("surrounds traceback with header and footer lines", function()
|
||||
local dump = load_module()
|
||||
local files = {}
|
||||
with_mock_io(files, function() dump() end)
|
||||
|
||||
local content = table.concat(files[next(files)])
|
||||
assert.is_truthy(content:find("-- Traceback generated at", 1, true))
|
||||
assert.is_truthy(content:find("-- End of traceback --", 1, true))
|
||||
end)
|
||||
|
||||
it("fires debug_traceback/triggered event with a traceback string", function()
|
||||
local dump, _, events = load_module()
|
||||
local files = {}
|
||||
with_mock_io(files, function() dump() end)
|
||||
|
||||
assert.equal(1, #events)
|
||||
assert.equal("debug_traceback/triggered", events[1].name)
|
||||
assert.is_string(events[1].data.traceback)
|
||||
end)
|
||||
|
||||
it("fires event before attempting file write (even on io failure)", function()
|
||||
local dump, _, events = load_module()
|
||||
with_failing_io(function() dump() end)
|
||||
|
||||
assert.equal(1, #events)
|
||||
assert.equal("debug_traceback/triggered", events[1].name)
|
||||
end)
|
||||
|
||||
it("logs an error when the file cannot be opened", function()
|
||||
local dump, log_calls = load_module()
|
||||
with_failing_io(function() dump() end)
|
||||
|
||||
local found = false
|
||||
for _, e in ipairs(log_calls) do
|
||||
if e.level == "error" and e.msg:find("permission denied", 1, true) then
|
||||
found = true
|
||||
end
|
||||
end
|
||||
assert.is_true(found, "expected error log mentioning 'permission denied'")
|
||||
end)
|
||||
|
||||
it("does not write a file when io.open fails", function()
|
||||
local dump = load_module()
|
||||
local files = {}
|
||||
with_failing_io(function() dump() end)
|
||||
-- files table should remain empty (no successful open)
|
||||
assert.is_nil(next(files))
|
||||
end)
|
||||
|
||||
it("default filename contains pid and starts with data path", function()
|
||||
local dump = load_module()
|
||||
local files = {}
|
||||
with_mock_io(files, function() dump() end)
|
||||
|
||||
local path = next(files)
|
||||
assert.is_truthy(path:find("/tmp/prosody-test", 1, true))
|
||||
assert.is_truthy(path:find("99", 1, true)) -- pid = 99
|
||||
end)
|
||||
|
||||
it("default filename embeds count (starting at 0)", function()
|
||||
local dump = load_module()
|
||||
local files = {}
|
||||
with_mock_io(files, function() dump() end)
|
||||
|
||||
local path = next(files)
|
||||
-- default template: traceback-{pid}-{count}.log → count=0 on first call
|
||||
assert.is_truthy(path:find("%-0%.log$"))
|
||||
end)
|
||||
|
||||
it("increments count between calls producing distinct filenames", function()
|
||||
local dump = load_module()
|
||||
local paths = {}
|
||||
local orig = io.open
|
||||
io.open = function(path, _)
|
||||
paths[#paths + 1] = path
|
||||
return { write = function() end, close = function() end }
|
||||
end
|
||||
dump()
|
||||
dump()
|
||||
io.open = orig
|
||||
|
||||
assert.equal(2, #paths)
|
||||
assert.not_equal(paths[1], paths[2])
|
||||
end)
|
||||
|
||||
it("uses custom signal name in info log message", function()
|
||||
local dump, log_calls = load_module({ debug_traceback_signal = "SIGUSR2" })
|
||||
local files = {}
|
||||
with_mock_io(files, function() dump() end)
|
||||
|
||||
local found = false
|
||||
for _, e in ipairs(log_calls) do
|
||||
if e.level == "info" and e.msg:find("SIGUSR2", 1, true) then
|
||||
found = true
|
||||
end
|
||||
end
|
||||
assert.is_true(found, "expected info log mentioning 'SIGUSR2'")
|
||||
end)
|
||||
|
||||
it("uses custom filename template from config", function()
|
||||
local dump = load_module({
|
||||
debug_traceback_filename = "/custom/tb-{pid}.log",
|
||||
})
|
||||
local files = {}
|
||||
with_mock_io(files, function() dump() end)
|
||||
|
||||
local path = next(files)
|
||||
assert.equal("/custom/tb-99.log", path)
|
||||
end)
|
||||
|
||||
end) -- dump_traceback()
|
||||
|
||||
end)
|
||||
273
tests/prosody/lua/mod_jibri_session_spec.lua
Normal file
273
tests/prosody/lua/mod_jibri_session_spec.lua
Normal file
@@ -0,0 +1,273 @@
|
||||
-- Unit tests for mod_jibri_session.lua
|
||||
-- Run with busted from resources/prosody-plugins/:
|
||||
-- busted spec/lua/
|
||||
--
|
||||
-- Stubs every Prosody dependency so no Prosody installation is needed.
|
||||
-- The module is loaded once; the hooked attachJibriSessionId function is
|
||||
-- extracted and called directly in each test.
|
||||
|
||||
-- cjson is required by the module under test — skip gracefully if absent.
|
||||
local ok_json, json = pcall(require, 'cjson')
|
||||
if not ok_json then
|
||||
describe("mod_jibri_session", function()
|
||||
it("skipped — lua-cjson not installed", function()
|
||||
pending("lua-cjson is required: luarocks install lua-cjson")
|
||||
end)
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Shared mutable mock state — reset in before_each
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local mock = {
|
||||
room_from_jid = nil, -- returned by get_room_from_jid; nil = "not found"
|
||||
rewrite_jid = nil, -- if set, room_jid_match_rewrite returns this; else identity
|
||||
last_room_jid = nil, -- records the jid passed to get_room_from_jid
|
||||
}
|
||||
|
||||
local util_stub = {
|
||||
room_jid_match_rewrite = function(jid)
|
||||
return mock.rewrite_jid or jid
|
||||
end,
|
||||
get_room_from_jid = function(jid)
|
||||
mock.last_room_jid = jid
|
||||
return mock.room_from_jid
|
||||
end,
|
||||
}
|
||||
|
||||
-- Capture the hook function installed by the module at load time.
|
||||
local hooked_fn = nil
|
||||
|
||||
local mod_stub = { host = 'test.example.com' }
|
||||
function mod_stub:require(name) -- luacheck: ignore 212
|
||||
if name == 'util' then return util_stub end
|
||||
return {}
|
||||
end
|
||||
function mod_stub:hook(event, fn) -- luacheck: ignore 212
|
||||
if event == 'pre-iq/full' then hooked_fn = fn end
|
||||
end
|
||||
function mod_stub:log() end -- luacheck: ignore 212
|
||||
|
||||
_G.module = mod_stub
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Load the module under test
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local ok_mod, load_err = pcall(dofile, 'mod_jibri_session.lua')
|
||||
if not ok_mod then
|
||||
describe("mod_jibri_session", function()
|
||||
it("skipped — failed to load module", function()
|
||||
pending(tostring(load_err):match("([^\n]+)") or tostring(load_err))
|
||||
end)
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
assert(hooked_fn, "mod_jibri_session did not hook 'pre-iq/full'")
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Test helpers
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local JIBRI_NS = 'http://jitsi.org/protocol/jibri'
|
||||
|
||||
local function make_jibri_elem(attrs)
|
||||
local e = { name = 'jibri', attr = attrs or {} }
|
||||
function e:up() return self end
|
||||
return e
|
||||
end
|
||||
|
||||
local function make_iq(jibri_elem)
|
||||
local s = { name = 'iq', attr = {} }
|
||||
function s:get_child(tag, ns)
|
||||
if tag == 'jibri' and ns == JIBRI_NS then return jibri_elem end
|
||||
return nil
|
||||
end
|
||||
function s:up() return self end
|
||||
return s
|
||||
end
|
||||
|
||||
local function make_event(stanza, origin)
|
||||
return { stanza = stanza, origin = origin }
|
||||
end
|
||||
|
||||
local function decoded_app_data(jibri_elem)
|
||||
return json.decode(jibri_elem.attr.app_data)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Tests
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
describe("mod_jibri_session", function()
|
||||
|
||||
before_each(function()
|
||||
mock.room_from_jid = nil
|
||||
mock.rewrite_jid = nil
|
||||
mock.last_room_jid = nil
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("non-matching stanzas pass through unchanged", function()
|
||||
|
||||
it("ignores a non-IQ stanza", function()
|
||||
local s = { name = 'message', attr = {} }
|
||||
function s:get_child() return nil end
|
||||
assert.has_no.errors(function() hooked_fn(make_event(s)) end)
|
||||
end)
|
||||
|
||||
it("ignores an IQ with no jibri child", function()
|
||||
local s = { name = 'iq', attr = {} }
|
||||
function s:get_child() return nil end
|
||||
assert.has_no.errors(function() hooked_fn(make_event(s)) end)
|
||||
end)
|
||||
|
||||
it("ignores a jibri IQ with action 'stop'", function()
|
||||
local jibri = make_jibri_elem({ action = 'stop' })
|
||||
hooked_fn(make_event(make_iq(jibri)))
|
||||
assert.is_nil(jibri.attr.app_data)
|
||||
end)
|
||||
|
||||
it("ignores a jibri IQ with no action attribute", function()
|
||||
local jibri = make_jibri_elem({})
|
||||
hooked_fn(make_event(make_iq(jibri)))
|
||||
assert.is_nil(jibri.attr.app_data)
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
-- Client → jicofo pass: no `room` attribute on the jibri element.
|
||||
-- The module reads initiator identity from the XMPP session (event.origin).
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("client-to-jicofo pass (no room attr on jibri element)", function()
|
||||
|
||||
it("sets initiator.id from jitsi_meet_context_user.id", function()
|
||||
local jibri = make_jibri_elem({ action = 'start' })
|
||||
local session = { jitsi_meet_context_user = { id = 'user-abc' } }
|
||||
hooked_fn(make_event(make_iq(jibri), session))
|
||||
assert.equal('user-abc', decoded_app_data(jibri).file_recording_metadata.initiator.id)
|
||||
end)
|
||||
|
||||
it("falls back to granted_jitsi_meet_context_user_id when context_user is nil", function()
|
||||
local jibri = make_jibri_elem({ action = 'start' })
|
||||
local session = { granted_jitsi_meet_context_user_id = 'granted-id-xyz' }
|
||||
hooked_fn(make_event(make_iq(jibri), session))
|
||||
assert.equal('granted-id-xyz', decoded_app_data(jibri).file_recording_metadata.initiator.id)
|
||||
end)
|
||||
|
||||
it("sets initiator.group from jitsi_meet_context_group", function()
|
||||
local jibri = make_jibri_elem({ action = 'start' })
|
||||
local session = {
|
||||
jitsi_meet_context_user = { id = 'user-1' },
|
||||
jitsi_meet_context_group = 'group-A',
|
||||
}
|
||||
hooked_fn(make_event(make_iq(jibri), session))
|
||||
assert.equal('group-A', decoded_app_data(jibri).file_recording_metadata.initiator.group)
|
||||
end)
|
||||
|
||||
it("falls back to granted_jitsi_meet_context_group_id when context_group is nil", function()
|
||||
local jibri = make_jibri_elem({ action = 'start' })
|
||||
local session = {
|
||||
jitsi_meet_context_user = { id = 'user-1' },
|
||||
granted_jitsi_meet_context_group_id = 'granted-group-B',
|
||||
}
|
||||
hooked_fn(make_event(make_iq(jibri), session))
|
||||
assert.equal('granted-group-B', decoded_app_data(jibri).file_recording_metadata.initiator.group)
|
||||
end)
|
||||
|
||||
it("does not modify app_data when origin (session) is nil", function()
|
||||
local jibri = make_jibri_elem({ action = 'start' })
|
||||
hooked_fn(make_event(make_iq(jibri), nil))
|
||||
assert.is_nil(jibri.attr.app_data)
|
||||
end)
|
||||
|
||||
it("preserves pre-existing top-level app_data keys", function()
|
||||
local existing = json.encode({ custom_key = 'custom_value' })
|
||||
local jibri = make_jibri_elem({ action = 'start', app_data = existing })
|
||||
local session = { jitsi_meet_context_user = { id = 'user-1' } }
|
||||
hooked_fn(make_event(make_iq(jibri), session))
|
||||
local data = decoded_app_data(jibri)
|
||||
assert.equal('custom_value', data.custom_key)
|
||||
assert.equal('user-1', data.file_recording_metadata.initiator.id)
|
||||
end)
|
||||
|
||||
it("preserves pre-existing file_recording_metadata fields", function()
|
||||
local existing = json.encode({
|
||||
file_recording_metadata = { existing_field = 'existing_value' }
|
||||
})
|
||||
local jibri = make_jibri_elem({ action = 'start', app_data = existing })
|
||||
local session = { jitsi_meet_context_user = { id = 'user-1' } }
|
||||
hooked_fn(make_event(make_iq(jibri), session))
|
||||
local data = decoded_app_data(jibri)
|
||||
assert.equal('existing_value', data.file_recording_metadata.existing_field)
|
||||
assert.equal('user-1', data.file_recording_metadata.initiator.id)
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
-- Jicofo → jibri pass: `room` attribute present on the jibri element.
|
||||
-- The module looks up the room and attaches its meetingId as session_id.
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("jicofo-to-jibri pass (room attr present on jibri element)", function()
|
||||
|
||||
it("sets conference_details.session_id from room meetingId", function()
|
||||
mock.room_from_jid = { _data = { meetingId = 'meeting-123' } }
|
||||
local jibri = make_jibri_elem({ action = 'start', room = 'room@conference.example.com' })
|
||||
hooked_fn(make_event(make_iq(jibri)))
|
||||
assert.equal('meeting-123',
|
||||
decoded_app_data(jibri).file_recording_metadata.conference_details.session_id)
|
||||
end)
|
||||
|
||||
it("does not modify app_data when room is not found", function()
|
||||
mock.room_from_jid = nil
|
||||
local jibri = make_jibri_elem({ action = 'start', room = 'missing@conference.example.com' })
|
||||
hooked_fn(make_event(make_iq(jibri)))
|
||||
assert.is_nil(jibri.attr.app_data)
|
||||
end)
|
||||
|
||||
it("preserves pre-existing top-level app_data keys", function()
|
||||
mock.room_from_jid = { _data = { meetingId = 'meeting-456' } }
|
||||
local existing = json.encode({ custom_key = 'custom_value' })
|
||||
local jibri = make_jibri_elem({
|
||||
action = 'start',
|
||||
room = 'room@conference.example.com',
|
||||
app_data = existing,
|
||||
})
|
||||
hooked_fn(make_event(make_iq(jibri)))
|
||||
local data = decoded_app_data(jibri)
|
||||
assert.equal('custom_value', data.custom_key)
|
||||
assert.equal('meeting-456', data.file_recording_metadata.conference_details.session_id)
|
||||
end)
|
||||
|
||||
it("passes the room JID through room_jid_match_rewrite before lookup", function()
|
||||
mock.rewrite_jid = 'rewritten@conference.example.com'
|
||||
mock.room_from_jid = { _data = { meetingId = 'meeting-789' } }
|
||||
local jibri = make_jibri_elem({
|
||||
action = 'start',
|
||||
room = 'original@conference.sub.example.com',
|
||||
})
|
||||
hooked_fn(make_event(make_iq(jibri)))
|
||||
assert.equal('rewritten@conference.example.com', mock.last_room_jid)
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("malformed app_data", function()
|
||||
|
||||
it("silently ignores invalid JSON in app_data (cjson.safe: returns nil, no throw)", function()
|
||||
local raw = '{not valid json{{'
|
||||
local jibri = make_jibri_elem({ action = 'start', app_data = raw })
|
||||
local session = { jitsi_meet_context_user = { id = 'user-1' } }
|
||||
assert.has_no.errors(function() hooked_fn(make_event(make_iq(jibri), session)) end)
|
||||
assert.equal(raw, jibri.attr.app_data) -- unchanged: module returned early
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
end) -- mod_jibri_session
|
||||
308
tests/prosody/lua/mod_muc_domain_mapper_spec.lua
Normal file
308
tests/prosody/lua/mod_muc_domain_mapper_spec.lua
Normal file
@@ -0,0 +1,308 @@
|
||||
-- Unit tests for mod_muc_domain_mapper.lua
|
||||
-- Run with busted from resources/prosody-plugins/:
|
||||
-- busted spec/lua/
|
||||
--
|
||||
-- Stubs every Prosody dependency so no Prosody installation is needed.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Stanza / session helpers
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function make_stanza(name, attrs, children)
|
||||
local s = { name = name, attr = attrs or {}, _children = children or {} }
|
||||
function s:get_child(cname, _ns)
|
||||
for _, c in ipairs(self._children) do
|
||||
if c.name == cname then return c end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
return s
|
||||
end
|
||||
|
||||
local function make_session(session_type)
|
||||
local logs = {}
|
||||
return {
|
||||
type = session_type or "c2s",
|
||||
log_calls = logs,
|
||||
-- session.log is called as session.log(level, msg, ...) (not method syntax)
|
||||
log = function(level, _msg, ...)
|
||||
table.insert(logs, level)
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Call-tracking rewrite stubs (reset before each test via clear helpers)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local rewrite_to_calls = {}
|
||||
local rewrite_from_calls = {}
|
||||
|
||||
local function clear_calls()
|
||||
for i = #rewrite_to_calls, 1, -1 do rewrite_to_calls[i] = nil end
|
||||
for i = #rewrite_from_calls, 1, -1 do rewrite_from_calls[i] = nil end
|
||||
end
|
||||
|
||||
local mock_util = {
|
||||
room_jid_match_rewrite = function(jid, _stanza)
|
||||
table.insert(rewrite_to_calls, jid)
|
||||
return "MAPPED:" .. (jid or "nil")
|
||||
end,
|
||||
internal_room_jid_match_rewrite = function(jid, _stanza)
|
||||
table.insert(rewrite_from_calls, jid)
|
||||
return "UNMAPPED:" .. (jid or "nil")
|
||||
end,
|
||||
}
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Package preloads — must be registered before dofile()
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
package.preload['util.filters'] = function()
|
||||
return {
|
||||
add_filter_hook = function(_fn) end,
|
||||
remove_filter_hook = function(_fn) end,
|
||||
add_filter = function(_session, _type, _fn) end,
|
||||
}
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Global stubs required by the module at load time
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
_G.prosody = {
|
||||
events = { add_handler = function() end },
|
||||
hosts = {},
|
||||
}
|
||||
|
||||
_G.module = {
|
||||
host = "localhost",
|
||||
log = function() end,
|
||||
get_option_string = function(_, key, default)
|
||||
if key == "muc_mapper_domain_base" then return "localhost" end
|
||||
return default
|
||||
end,
|
||||
get_option_boolean = function(_, key, default)
|
||||
if key == "muc_mapper_log_not_allowed_errors" then return true end
|
||||
return default
|
||||
end,
|
||||
require = function(_, name)
|
||||
if name == "util" then return mock_util end
|
||||
return nil
|
||||
end,
|
||||
context = function(_, _host)
|
||||
return { hook = function() end }
|
||||
end,
|
||||
}
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Load the module under test
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local ok, err = pcall(dofile, "mod_muc_domain_mapper.lua")
|
||||
if not ok then
|
||||
describe("mod_muc_domain_mapper", function()
|
||||
it("skipped — failed to load", function()
|
||||
pending(tostring(err):match("([^\n]+)") or tostring(err))
|
||||
end)
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
local filter_stanza = _G.filter_stanza
|
||||
assert(filter_stanza, "filter_stanza must be a global after dofile()")
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Tests
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
describe("mod_muc_domain_mapper", function()
|
||||
|
||||
before_each(clear_calls)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("filter_stanza", function()
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
describe("message stanzas", function()
|
||||
|
||||
it("rewrites 'to' via room_jid_match_rewrite", function()
|
||||
local stanza = make_stanza("message", { to = "room@conference.sub.example.com" })
|
||||
local session = make_session("c2s")
|
||||
|
||||
local result = filter_stanza(stanza, session)
|
||||
|
||||
assert.equal(1, #rewrite_to_calls)
|
||||
assert.equal("room@conference.sub.example.com", rewrite_to_calls[1])
|
||||
assert.equal("MAPPED:room@conference.sub.example.com", result.attr.to)
|
||||
end)
|
||||
|
||||
it("rewrites 'from' via internal_room_jid_match_rewrite", function()
|
||||
local stanza = make_stanza("message", { from = "[sub]room@conference.example.com/nick" })
|
||||
local session = make_session("c2s")
|
||||
|
||||
local result = filter_stanza(stanza, session)
|
||||
|
||||
assert.equal(1, #rewrite_from_calls)
|
||||
assert.equal("[sub]room@conference.example.com/nick", rewrite_from_calls[1])
|
||||
assert.equal("UNMAPPED:[sub]room@conference.example.com/nick", result.attr.from)
|
||||
end)
|
||||
|
||||
it("rewrites both 'to' and 'from' when both are set", function()
|
||||
local stanza = make_stanza("message", {
|
||||
to = "room@conference.sub.example.com",
|
||||
from = "[sub]room@conference.example.com/nick",
|
||||
})
|
||||
filter_stanza(stanza, make_session("c2s"))
|
||||
|
||||
assert.equal(1, #rewrite_to_calls)
|
||||
assert.equal(1, #rewrite_from_calls)
|
||||
end)
|
||||
|
||||
it("does not call rewrites when 'to' and 'from' are absent", function()
|
||||
local stanza = make_stanza("message", {})
|
||||
filter_stanza(stanza, make_session("c2s"))
|
||||
|
||||
assert.equal(0, #rewrite_to_calls)
|
||||
assert.equal(0, #rewrite_from_calls)
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
describe("presence stanzas", function()
|
||||
|
||||
it("rewrites 'to' and 'from'", function()
|
||||
local stanza = make_stanza("presence", {
|
||||
to = "room@conference.sub.example.com/nick",
|
||||
from = "[sub]room@conference.example.com/nick",
|
||||
})
|
||||
filter_stanza(stanza, make_session("c2s"))
|
||||
|
||||
assert.equal(1, #rewrite_to_calls)
|
||||
assert.equal(1, #rewrite_from_calls)
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
describe("iq stanzas", function()
|
||||
|
||||
it("rewrites 'to' and 'from'", function()
|
||||
local stanza = make_stanza("iq", {
|
||||
to = "room@conference.sub.example.com",
|
||||
from = "[sub]room@conference.example.com",
|
||||
})
|
||||
filter_stanza(stanza, make_session("c2s"))
|
||||
|
||||
assert.equal(1, #rewrite_to_calls)
|
||||
assert.equal(1, #rewrite_from_calls)
|
||||
end)
|
||||
|
||||
it("rewrites <conference room> attribute when child is present", function()
|
||||
local conf = make_stanza("conference", { room = "room@conference.sub.example.com" })
|
||||
local stanza = make_stanza("iq", { to = "focus@example.com" }, { conf })
|
||||
filter_stanza(stanza, make_session("c2s"))
|
||||
|
||||
-- room attr on the child gets rewritten
|
||||
assert.equal("MAPPED:room@conference.sub.example.com", conf.attr.room)
|
||||
-- 'to' also gets rewritten (focus@example.com passes through the mock)
|
||||
assert.equal(2, #rewrite_to_calls)
|
||||
end)
|
||||
|
||||
it("does not touch stanzas with no <conference> child", function()
|
||||
local stanza = make_stanza("iq", { to = "focus@example.com" })
|
||||
filter_stanza(stanza, make_session("c2s"))
|
||||
|
||||
assert.equal(1, #rewrite_to_calls)
|
||||
assert.equal(0, #rewrite_from_calls)
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
describe("skipped stanzas", function()
|
||||
|
||||
it("passes stanza through unchanged when skipMapping is true", function()
|
||||
local stanza = make_stanza("message", { to = "room@conference.sub.example.com" })
|
||||
stanza.skipMapping = true
|
||||
filter_stanza(stanza, make_session("c2s"))
|
||||
|
||||
assert.equal(0, #rewrite_to_calls)
|
||||
assert.equal("room@conference.sub.example.com", stanza.attr.to)
|
||||
end)
|
||||
|
||||
it("passes stanza through unchanged for s2sout sessions", function()
|
||||
local stanza = make_stanza("message", { to = "room@conference.sub.example.com" })
|
||||
filter_stanza(stanza, make_session("s2sout"))
|
||||
|
||||
assert.equal(0, #rewrite_to_calls)
|
||||
end)
|
||||
|
||||
it("passes non-message/iq/presence stanzas through unchanged", function()
|
||||
local stanza = make_stanza("stream:features", { to = "example.com" })
|
||||
filter_stanza(stanza, make_session("c2s"))
|
||||
|
||||
assert.equal(0, #rewrite_to_calls)
|
||||
assert.equal(0, #rewrite_from_calls)
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
describe("log_not_allowed_errors", function()
|
||||
|
||||
local function make_not_allowed_presence()
|
||||
local not_allowed = make_stanza("not-allowed",
|
||||
{ xmlns = "urn:ietf:params:xml:ns:xmpp-stanzas" })
|
||||
local error_el = make_stanza("error", { type = "cancel" }, { not_allowed })
|
||||
return make_stanza("presence", { type = "error" }, { error_el })
|
||||
end
|
||||
|
||||
it("logs a not-allowed presence error once per session", function()
|
||||
local stanza = make_not_allowed_presence()
|
||||
local session = make_session("c2s")
|
||||
|
||||
filter_stanza(stanza, session)
|
||||
|
||||
assert.equal(1, #session.log_calls)
|
||||
assert.equal("error", session.log_calls[1])
|
||||
assert.is_true(session.jitsi_not_allowed_logged)
|
||||
end)
|
||||
|
||||
it("does not log a second time on the same session", function()
|
||||
local stanza = make_not_allowed_presence()
|
||||
local session = make_session("c2s")
|
||||
|
||||
filter_stanza(stanza, session)
|
||||
filter_stanza(stanza, session)
|
||||
|
||||
assert.equal(1, #session.log_calls)
|
||||
end)
|
||||
|
||||
it("does not log for non-error presence", function()
|
||||
local stanza = make_stanza("presence", { to = "room@conference.sub.example.com" })
|
||||
local session = make_session("c2s")
|
||||
|
||||
filter_stanza(stanza, session)
|
||||
|
||||
assert.equal(0, #session.log_calls)
|
||||
end)
|
||||
|
||||
it("does not log when error type is not 'cancel'", function()
|
||||
local not_allowed = make_stanza("not-allowed",
|
||||
{ xmlns = "urn:ietf:params:xml:ns:xmpp-stanzas" })
|
||||
local error_el = make_stanza("error", { type = "auth" }, { not_allowed })
|
||||
local stanza = make_stanza("presence", { type = "error" }, { error_el })
|
||||
local session = make_session("c2s")
|
||||
|
||||
filter_stanza(stanza, session)
|
||||
|
||||
assert.equal(0, #session.log_calls)
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
end)
|
||||
1416
tests/prosody/lua/mod_muc_message_moderation_spec.lua
Normal file
1416
tests/prosody/lua/mod_muc_message_moderation_spec.lua
Normal file
File diff suppressed because it is too large
Load Diff
669
tests/prosody/lua/mod_short_lived_token_spec.lua
Normal file
669
tests/prosody/lua/mod_short_lived_token_spec.lua
Normal file
@@ -0,0 +1,669 @@
|
||||
-- Unit tests for mod_short_lived_token.lua
|
||||
-- Run with busted from resources/prosody-plugins/:
|
||||
-- busted ../../tests/prosody/lua/mod_short_lived_token_spec.lua
|
||||
--
|
||||
-- Stubs every Prosody dependency so no Prosody installation is needed.
|
||||
-- Uses a controllable jwt stub to capture payload fields without requiring
|
||||
-- the openssl library.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Package preloads — Prosody dependencies
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
package.preload['util.jid'] = function()
|
||||
local function split(j)
|
||||
if not j then return nil, nil, nil end
|
||||
local node, host, resource
|
||||
local at = j:find('@')
|
||||
if at then
|
||||
local slash = j:find('/', at + 1)
|
||||
node = j:sub(1, at - 1)
|
||||
if slash then
|
||||
host = j:sub(at + 1, slash - 1)
|
||||
resource = j:sub(slash + 1)
|
||||
else
|
||||
host = j:sub(at + 1)
|
||||
end
|
||||
else
|
||||
local slash = j:find('/')
|
||||
if slash then
|
||||
host = j:sub(1, slash - 1)
|
||||
resource = j:sub(slash + 1)
|
||||
else
|
||||
host = j
|
||||
end
|
||||
end
|
||||
node = (node and #node > 0) and node or nil
|
||||
host = (host and #host > 0) and host or nil
|
||||
resource = (resource and #resource > 0) and resource or nil
|
||||
return node, host, resource
|
||||
end
|
||||
return {
|
||||
split = split,
|
||||
node = function(j) local n = split(j); return n end,
|
||||
resource = function(j) local _, _, r = split(j); return r end,
|
||||
bare = function(j)
|
||||
local n, h = split(j)
|
||||
if n then return n .. '@' .. (h or '') end
|
||||
return h
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
package.preload['util.stanza'] = function()
|
||||
return {
|
||||
error_reply = function(stanza, etype, condition)
|
||||
return { is_error_reply = true, type = etype, condition = condition, original = stanza }
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
package.preload['util.throttle'] = function()
|
||||
return { create = function() return {} end }
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Helpers
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Single-pass iterator over a list — mimics Prosody's requested_credentials.
|
||||
local function iter(t)
|
||||
local i = 0
|
||||
return function()
|
||||
i = i + 1
|
||||
return t[i]
|
||||
end
|
||||
end
|
||||
|
||||
-- Minimal presence stanza stub.
|
||||
local function make_presence(nick, email)
|
||||
return {
|
||||
get_child_text = function(_, tag, _ns)
|
||||
if tag == 'nick' then return nick end
|
||||
if tag == 'email' then return email end
|
||||
return nil
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
-- Minimal occupant stub.
|
||||
local function make_occupant(full_jid, nick, presence_nick, presence_email)
|
||||
local p = make_presence(presence_nick, presence_email)
|
||||
return {
|
||||
nick = nick or ('conference.example.com/' .. (presence_nick or 'Alice')),
|
||||
get_presence = function(_, _jid) return p end,
|
||||
}
|
||||
end
|
||||
|
||||
-- Minimal room stub.
|
||||
local function make_room(jid, meeting_id, occupant_map)
|
||||
return {
|
||||
jid = jid,
|
||||
_data = { meetingId = meeting_id },
|
||||
get_occupant_by_real_jid = function(_, real_jid)
|
||||
if occupant_map then return occupant_map[real_jid] end
|
||||
return nil
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Controllable JWT stub.
|
||||
-- Exposes captured_payload and captured_key so tests can inspect them.
|
||||
-- Set .fail = true to simulate an encode error.
|
||||
-- ---------------------------------------------------------------------------
|
||||
local mock_jwt = {
|
||||
fail = false,
|
||||
captured_payload = nil,
|
||||
captured_key = nil,
|
||||
encode = function(self, payload, key, _alg, _headers)
|
||||
self.captured_payload = payload
|
||||
self.captured_key = key
|
||||
if self.fail then
|
||||
return nil, 'mock-encode-error'
|
||||
end
|
||||
return 'mock.jwt.token', nil
|
||||
end,
|
||||
}
|
||||
-- Make encode callable without explicit self (module calls jwt.encode(...))
|
||||
setmetatable(mock_jwt, {
|
||||
__index = mock_jwt,
|
||||
})
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Module stub factory.
|
||||
-- Returns a fresh (stub, hooks, log_calls) triple.
|
||||
-- ---------------------------------------------------------------------------
|
||||
local function make_module_stub(options_override)
|
||||
local hooks = {}
|
||||
local log_calls = {}
|
||||
|
||||
local opts = options_override or {
|
||||
issuer = 'test-issuer',
|
||||
accepted_audiences = { 'file-sharing' },
|
||||
key_path = '/fake/key.pem',
|
||||
key_id = 'kid-1',
|
||||
ttl_seconds = 30,
|
||||
}
|
||||
|
||||
local stub = {
|
||||
host = 'test.example.com',
|
||||
set_global = function() end,
|
||||
log = function(_, level, fmt, ...)
|
||||
-- fmt may contain no format specifiers; guard with pcall
|
||||
local ok, msg = pcall(string.format, fmt, ...)
|
||||
log_calls[#log_calls + 1] = {
|
||||
level = level,
|
||||
msg = ok and msg or fmt,
|
||||
}
|
||||
end,
|
||||
hook = function(_, name, handler)
|
||||
hooks[name] = handler
|
||||
end,
|
||||
get_option = function(_, key)
|
||||
if key == 'short_lived_token' then return opts end
|
||||
return nil
|
||||
end,
|
||||
get_option_string = function(_, key, default)
|
||||
if key == 'region_name' then return 'us-east-1' end
|
||||
if key == 'main_muc' then return 'conference.test.example.com' end
|
||||
return default
|
||||
end,
|
||||
require = function(_, name)
|
||||
if name == 'luajwtjitsi' then
|
||||
-- Return a table whose encode method delegates to mock_jwt
|
||||
return {
|
||||
encode = function(payload, key, alg, headers)
|
||||
return mock_jwt:encode(payload, key, alg, headers)
|
||||
end,
|
||||
}
|
||||
end
|
||||
if name == 'util' then
|
||||
return {
|
||||
is_vpaas = function() return false end,
|
||||
process_host_module = function() end,
|
||||
table_find = function(t, v)
|
||||
for _, x in ipairs(t) do if x == v then return true end end
|
||||
return nil
|
||||
end,
|
||||
}
|
||||
end
|
||||
error('unexpected module:require("' .. name .. '")')
|
||||
end,
|
||||
}
|
||||
|
||||
return stub, hooks, log_calls
|
||||
end
|
||||
|
||||
-- Patch io.open so key-file read at load-time returns a fake key string.
|
||||
local real_io_open = io.open
|
||||
local function patch_io()
|
||||
io.open = function(path, mode)
|
||||
if path == '/fake/key.pem' and mode == 'r' then
|
||||
return {
|
||||
read = function(_, _fmt) return 'FAKE-PRIVATE-KEY' end,
|
||||
close = function() end,
|
||||
}
|
||||
end
|
||||
return real_io_open(path, mode)
|
||||
end
|
||||
end
|
||||
local function restore_io() io.open = real_io_open end
|
||||
|
||||
-- Load the module under test into _G.
|
||||
local function load_module(module_stub)
|
||||
mock_jwt.fail = false
|
||||
mock_jwt.captured_payload = nil
|
||||
mock_jwt.captured_key = nil
|
||||
_G.module = module_stub
|
||||
_G.prosody = { hosts = {} }
|
||||
_G.extract_subdomain = function(node) return nil, node, nil end
|
||||
_G.get_room_by_name_and_subdomain = function() return nil end
|
||||
patch_io()
|
||||
local ok, err = pcall(dofile, 'mod_short_lived_token.lua')
|
||||
restore_io()
|
||||
return ok, err
|
||||
end
|
||||
|
||||
-- Convenience: load + return hooks (asserts successful load).
|
||||
local function load_and_get_hooks(opts_override)
|
||||
local stub, hooks, log_calls = make_module_stub(opts_override)
|
||||
local ok, err = load_module(stub)
|
||||
assert.is_true(ok, tostring(err))
|
||||
return hooks, log_calls, stub
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Tests
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
describe('mod_short_lived_token', function()
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
-- Config validation
|
||||
-- -----------------------------------------------------------------------
|
||||
describe('config validation', function()
|
||||
|
||||
it('loads cleanly with all required options present', function()
|
||||
local _, log_calls = load_and_get_hooks()
|
||||
local errors = {}
|
||||
for _, e in ipairs(log_calls) do
|
||||
if e.level == 'error' then errors[#errors + 1] = e.msg end
|
||||
end
|
||||
assert.equal(0, #errors, 'unexpected error: ' .. table.concat(errors, '; '))
|
||||
end)
|
||||
|
||||
for _, missing in ipairs({ 'issuer', 'accepted_audiences', 'key_path', 'key_id', 'ttl_seconds' }) do
|
||||
it('logs error and skips hook when ' .. missing .. ' is absent', function()
|
||||
local opts = {
|
||||
issuer = 'test-issuer',
|
||||
accepted_audiences = { 'file-sharing' },
|
||||
key_path = '/fake/key.pem',
|
||||
key_id = 'kid-1',
|
||||
ttl_seconds = 30,
|
||||
}
|
||||
opts[missing] = nil
|
||||
|
||||
local stub, hooks, log_calls = make_module_stub(opts)
|
||||
local ok, err = load_module(stub)
|
||||
assert.is_true(ok, tostring(err))
|
||||
assert.is_nil(hooks['external_service/credentials'],
|
||||
'hook must not register when config is incomplete')
|
||||
|
||||
local found_error = false
|
||||
for _, e in ipairs(log_calls) do
|
||||
if e.level == 'error' then found_error = true end
|
||||
end
|
||||
assert.is_true(found_error, 'expected an error log entry')
|
||||
end)
|
||||
end
|
||||
|
||||
it('logs error and does not register hook when main_muc is absent', function()
|
||||
local stub, hooks, log_calls = make_module_stub()
|
||||
stub.get_option_string = function(_, key, default)
|
||||
if key == 'region_name' then return 'us-east-1' end
|
||||
if key == 'main_muc' then return nil end
|
||||
return default
|
||||
end
|
||||
local ok = load_module(stub)
|
||||
assert.is_true(ok)
|
||||
assert.is_nil(hooks['external_service/credentials'])
|
||||
local found_error = false
|
||||
for _, e in ipairs(log_calls) do
|
||||
if e.level == 'error' then found_error = true end
|
||||
end
|
||||
assert.is_true(found_error)
|
||||
end)
|
||||
|
||||
end) -- config validation
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
-- external_service/credentials handler — error cases
|
||||
-- -----------------------------------------------------------------------
|
||||
describe('external_service/credentials handler', function()
|
||||
|
||||
local hooks
|
||||
|
||||
before_each(function()
|
||||
hooks = load_and_get_hooks()
|
||||
end)
|
||||
|
||||
it('sends not-allowed when room is not found', function()
|
||||
_G.get_room_by_name_and_subdomain = function() return nil end
|
||||
local error_sent
|
||||
local session = {
|
||||
full_jid = 'user@example.com/res',
|
||||
jitsi_web_query_room = 'missing',
|
||||
jitsi_web_query_prefix = '',
|
||||
send = function(reply) error_sent = reply end,
|
||||
}
|
||||
hooks['external_service/credentials']({
|
||||
requested_credentials = iter({ 'short-lived-token:file-sharing:0' }),
|
||||
services = { push = function() end },
|
||||
origin = session,
|
||||
stanza = { id = 'iq1' },
|
||||
})
|
||||
assert.is_not_nil(error_sent)
|
||||
assert.equal('not-allowed', error_sent.condition)
|
||||
end)
|
||||
|
||||
it('sends not-allowed when session is not an occupant', function()
|
||||
local room = make_room('r@conference.example.com', 'mid', {})
|
||||
_G.get_room_by_name_and_subdomain = function() return room end
|
||||
local error_sent
|
||||
local session = {
|
||||
full_jid = 'outsider@example.com/res',
|
||||
jitsi_web_query_room = 'r',
|
||||
jitsi_web_query_prefix = '',
|
||||
send = function(reply) error_sent = reply end,
|
||||
}
|
||||
hooks['external_service/credentials']({
|
||||
requested_credentials = iter({ 'short-lived-token:file-sharing:0' }),
|
||||
services = { push = function() end },
|
||||
origin = session,
|
||||
stanza = { id = 'iq2' },
|
||||
})
|
||||
assert.is_not_nil(error_sent)
|
||||
assert.equal('not-allowed', error_sent.condition)
|
||||
end)
|
||||
|
||||
it('does not push service when credential key is not in accepted map', function()
|
||||
local occupant = make_occupant('user@example.com/res', nil, 'Alice', nil)
|
||||
local room = make_room('r@conference.example.com', 'mid',
|
||||
{ ['user@example.com/res'] = occupant })
|
||||
_G.get_room_by_name_and_subdomain = function() return room end
|
||||
local pushed = {}
|
||||
local services = { push = function(_, item) pushed[#pushed + 1] = item end }
|
||||
hooks['external_service/credentials']({
|
||||
requested_credentials = iter({ 'short-lived-token:unknown-audience:0' }),
|
||||
services = services,
|
||||
origin = { full_jid = 'user@example.com/res', jitsi_web_query_room = 'r',
|
||||
jitsi_web_query_prefix = '' },
|
||||
stanza = {},
|
||||
})
|
||||
assert.equal(0, #pushed)
|
||||
end)
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- Service entry shape
|
||||
-- -------------------------------------------------------------------
|
||||
describe('valid request service entry', function()
|
||||
|
||||
local pushed_item
|
||||
|
||||
before_each(function()
|
||||
local occupant = make_occupant('user@example.com/res', nil, 'Alice', nil)
|
||||
local room = make_room('r@conference.example.com', 'meet-123',
|
||||
{ ['user@example.com/res'] = occupant })
|
||||
_G.get_room_by_name_and_subdomain = function() return room end
|
||||
local items = {}
|
||||
local services = { push = function(_, item) items[#items + 1] = item end }
|
||||
hooks['external_service/credentials']({
|
||||
requested_credentials = iter({ 'short-lived-token:file-sharing:0' }),
|
||||
services = services,
|
||||
origin = { full_jid = 'user@example.com/res', jitsi_web_query_room = 'r',
|
||||
jitsi_web_query_prefix = '' },
|
||||
stanza = {},
|
||||
})
|
||||
pushed_item = items[1]
|
||||
end)
|
||||
|
||||
it('type is short-lived-token', function() assert.equal('short-lived-token', pushed_item.type) end)
|
||||
it('host is audience value', function() assert.equal('file-sharing', pushed_item.host) end)
|
||||
it('username is token', function() assert.equal('token', pushed_item.username) end)
|
||||
it('transport is https', function() assert.equal('https', pushed_item.transport) end)
|
||||
it('port is 443', function() assert.equal(443, pushed_item.port) end)
|
||||
it('restricted is true', function() assert.is_true(pushed_item.restricted) end)
|
||||
it('expires = now + ttl', function()
|
||||
local now = os.time()
|
||||
assert.is_true(pushed_item.expires >= now + 29 and pushed_item.expires <= now + 31)
|
||||
end)
|
||||
it('password is non-empty string', function()
|
||||
assert.is_string(pushed_item.password)
|
||||
assert.is_true(#pushed_item.password > 0)
|
||||
end)
|
||||
end)
|
||||
|
||||
it('multiple audiences produce one service entry each', function()
|
||||
local hooks2 = load_and_get_hooks({
|
||||
issuer = 'iss',
|
||||
accepted_audiences = { 'aud-a', 'aud-b' },
|
||||
key_path = '/fake/key.pem',
|
||||
key_id = 'kid',
|
||||
ttl_seconds = 30,
|
||||
})
|
||||
local occupant = make_occupant('user@example.com/res', nil, 'Bob', nil)
|
||||
local room = make_room('r@conference.example.com', 'mid',
|
||||
{ ['user@example.com/res'] = occupant })
|
||||
_G.get_room_by_name_and_subdomain = function() return room end
|
||||
local items = {}
|
||||
local services = { push = function(_, item) items[#items + 1] = item end }
|
||||
hooks2['external_service/credentials']({
|
||||
requested_credentials = iter({
|
||||
'short-lived-token:aud-a:0',
|
||||
'short-lived-token:aud-b:0',
|
||||
}),
|
||||
services = services,
|
||||
origin = { full_jid = 'user@example.com/res', jitsi_web_query_room = 'r',
|
||||
jitsi_web_query_prefix = '' },
|
||||
stanza = {},
|
||||
})
|
||||
assert.equal(2, #items)
|
||||
assert.equal('aud-a', items[1].host)
|
||||
assert.equal('aud-b', items[2].host)
|
||||
end)
|
||||
|
||||
end) -- handler
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
-- generateToken — JWT payload claims
|
||||
-- Tested by capturing mock_jwt.captured_payload after invoking the handler.
|
||||
-- -----------------------------------------------------------------------
|
||||
describe('generateToken payload', function()
|
||||
|
||||
-- Invoke handler with given session fields; returns captured payload.
|
||||
-- Optional extract_subdomain_fn overrides the global after module load.
|
||||
local function get_payload(session_fields, room_override, extract_subdomain_fn)
|
||||
hooks = load_and_get_hooks()
|
||||
mock_jwt.captured_payload = nil
|
||||
|
||||
-- Apply extract_subdomain override AFTER load_module (which resets it).
|
||||
if extract_subdomain_fn then
|
||||
_G.extract_subdomain = extract_subdomain_fn
|
||||
end
|
||||
|
||||
local full_jid = session_fields.full_jid or 'user@example.com/res'
|
||||
local pres_nick = session_fields._presence_nick or 'Alice'
|
||||
local pres_email = session_fields._presence_email or nil
|
||||
|
||||
local occupant = make_occupant(full_jid, nil, pres_nick, pres_email)
|
||||
local room = room_override or make_room(
|
||||
'myroom@conference.example.com', 'meeting-xyz',
|
||||
{ [full_jid] = occupant }
|
||||
)
|
||||
_G.get_room_by_name_and_subdomain = function() return room end
|
||||
|
||||
local session = {}
|
||||
for k, v in pairs(session_fields) do
|
||||
if k:sub(1, 1) ~= '_' then session[k] = v end
|
||||
end
|
||||
|
||||
local items = {}
|
||||
local services = { push = function(_, item) items[#items + 1] = item end }
|
||||
hooks['external_service/credentials']({
|
||||
requested_credentials = iter({ 'short-lived-token:file-sharing:0' }),
|
||||
services = services,
|
||||
origin = session,
|
||||
stanza = {},
|
||||
})
|
||||
return mock_jwt.captured_payload
|
||||
end
|
||||
|
||||
local base_session = {
|
||||
full_jid = 'user@example.com/res',
|
||||
jitsi_web_query_room = 'myroom',
|
||||
jitsi_web_query_prefix = '',
|
||||
}
|
||||
|
||||
it('iss is set to configured issuer', function()
|
||||
local p = get_payload(base_session)
|
||||
assert.equal('test-issuer', p.iss)
|
||||
end)
|
||||
|
||||
it('aud is set to requested audience', function()
|
||||
local p = get_payload(base_session)
|
||||
assert.equal('file-sharing', p.aud)
|
||||
end)
|
||||
|
||||
it('exp is approximately now + ttl_seconds', function()
|
||||
local before = os.time()
|
||||
local p = get_payload(base_session)
|
||||
local after = os.time()
|
||||
assert.is_true(p.exp >= before + 30 and p.exp <= after + 30)
|
||||
end)
|
||||
|
||||
it('nbf is approximately now', function()
|
||||
local before = os.time()
|
||||
local p = get_payload(base_session)
|
||||
local after = os.time()
|
||||
assert.is_true(p.nbf >= before - 1 and p.nbf <= after)
|
||||
end)
|
||||
|
||||
it('sub defaults to module.host when jitsi_web_query_prefix is nil', function()
|
||||
local p = get_payload({
|
||||
full_jid = 'user@example.com/res',
|
||||
jitsi_web_query_room = 'myroom',
|
||||
jitsi_web_query_prefix = nil,
|
||||
})
|
||||
assert.equal('test.example.com', p.sub)
|
||||
end)
|
||||
|
||||
it('sub defaults to module.host when jitsi_web_query_prefix is empty string', function()
|
||||
local p = get_payload(base_session)
|
||||
assert.equal('test.example.com', p.sub)
|
||||
end)
|
||||
|
||||
it('sub is jitsi_web_query_prefix when non-empty', function()
|
||||
local p = get_payload({
|
||||
full_jid = 'user@example.com/res',
|
||||
jitsi_web_query_room = 'myroom',
|
||||
jitsi_web_query_prefix = 'mysubdomain',
|
||||
})
|
||||
assert.equal('mysubdomain', p.sub)
|
||||
end)
|
||||
|
||||
it('context.user taken from session when jitsi_meet_context_user present', function()
|
||||
local cu = { id = 'uid-99', name = 'Session User', email = 'su@example.com' }
|
||||
local p = get_payload({
|
||||
full_jid = 'user@example.com/res',
|
||||
jitsi_web_query_room = 'myroom',
|
||||
jitsi_web_query_prefix = '',
|
||||
jitsi_meet_context_user = cu,
|
||||
})
|
||||
assert.same(cu, p.context.user)
|
||||
end)
|
||||
|
||||
it('context.user built from presence when jitsi_meet_context_user absent', function()
|
||||
local p = get_payload({
|
||||
full_jid = 'user@example.com/res',
|
||||
jitsi_web_query_room = 'myroom',
|
||||
jitsi_web_query_prefix = '',
|
||||
_presence_nick = 'PresenceNick',
|
||||
_presence_email = 'presence@example.com',
|
||||
})
|
||||
assert.equal('PresenceNick', p.context.user.name)
|
||||
assert.equal('presence@example.com', p.context.user.email)
|
||||
assert.equal('user@example.com/res', p.context.user.id)
|
||||
end)
|
||||
|
||||
it('context.group uses jitsi_meet_context_group when present', function()
|
||||
local p = get_payload({
|
||||
full_jid = 'user@example.com/res',
|
||||
jitsi_web_query_room = 'myroom',
|
||||
jitsi_web_query_prefix = '',
|
||||
jitsi_meet_context_group = 'group-primary',
|
||||
granted_jitsi_meet_context_group_id = 'group-fallback',
|
||||
})
|
||||
assert.equal('group-primary', p.context.group)
|
||||
end)
|
||||
|
||||
it('context.group falls back to granted_jitsi_meet_context_group_id', function()
|
||||
local p = get_payload({
|
||||
full_jid = 'user@example.com/res',
|
||||
jitsi_web_query_room = 'myroom',
|
||||
jitsi_web_query_prefix = '',
|
||||
jitsi_meet_context_group = nil,
|
||||
granted_jitsi_meet_context_group_id = 'group-fallback',
|
||||
})
|
||||
assert.equal('group-fallback', p.context.group)
|
||||
end)
|
||||
|
||||
it('meeting_id taken from room._data.meetingId', function()
|
||||
local p = get_payload(base_session)
|
||||
assert.equal('meeting-xyz', p.meeting_id)
|
||||
end)
|
||||
|
||||
it('backend_region taken from region_name config', function()
|
||||
local p = get_payload(base_session)
|
||||
assert.equal('us-east-1', p.backend_region)
|
||||
end)
|
||||
|
||||
it('user_region propagated from session', function()
|
||||
local p = get_payload({
|
||||
full_jid = 'user@example.com/res',
|
||||
jitsi_web_query_room = 'myroom',
|
||||
jitsi_web_query_prefix = '',
|
||||
user_region = 'eu-west-1',
|
||||
})
|
||||
assert.equal('eu-west-1', p.user_region)
|
||||
end)
|
||||
|
||||
it('customer_id uses extracted subdomain customer_id when present', function()
|
||||
local p = get_payload(base_session, nil, function(_node)
|
||||
return 'vpaas-magic-cookie-abc123', 'myroom', 'abc123'
|
||||
end)
|
||||
assert.equal('abc123', p.customer_id)
|
||||
end)
|
||||
|
||||
it('customer_id falls back to group when extract_subdomain returns no customer_id', function()
|
||||
local p = get_payload({
|
||||
full_jid = 'user@example.com/res',
|
||||
jitsi_web_query_room = 'myroom',
|
||||
jitsi_web_query_prefix = '',
|
||||
jitsi_meet_context_group = 'my-group',
|
||||
}, nil, function(node) return nil, node, nil end)
|
||||
assert.equal('my-group', p.customer_id)
|
||||
end)
|
||||
|
||||
it('granted_from set from session.granted_jitsi_meet_context_user_id', function()
|
||||
local p = get_payload({
|
||||
full_jid = 'user@example.com/res',
|
||||
jitsi_web_query_room = 'myroom',
|
||||
jitsi_web_query_prefix = '',
|
||||
granted_jitsi_meet_context_user_id = 'granter-uid',
|
||||
})
|
||||
assert.equal('granter-uid', p.granted_from)
|
||||
end)
|
||||
|
||||
it('key passed to jwt.encode is the key read from key_path', function()
|
||||
get_payload(base_session)
|
||||
assert.equal('FAKE-PRIVATE-KEY', mock_jwt.captured_key)
|
||||
end)
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- encode error path
|
||||
-- -------------------------------------------------------------------
|
||||
it('password is empty string when jwt.encode fails', function()
|
||||
local stub, hooks2, log_calls = make_module_stub()
|
||||
load_module(stub)
|
||||
mock_jwt.fail = true
|
||||
|
||||
local occupant = make_occupant('user@example.com/res', nil, 'Alice', nil)
|
||||
local room = make_room('r@conference.example.com', 'mid',
|
||||
{ ['user@example.com/res'] = occupant })
|
||||
_G.get_room_by_name_and_subdomain = function() return room end
|
||||
|
||||
local items = {}
|
||||
local services = { push = function(_, item) items[#items + 1] = item end }
|
||||
hooks2['external_service/credentials']({
|
||||
requested_credentials = iter({ 'short-lived-token:file-sharing:0' }),
|
||||
services = services,
|
||||
origin = { full_jid = 'user@example.com/res', jitsi_web_query_room = 'r',
|
||||
jitsi_web_query_prefix = '' },
|
||||
stanza = {},
|
||||
})
|
||||
mock_jwt.fail = false
|
||||
|
||||
assert.equal(1, #items, 'service entry still pushed')
|
||||
assert.equal('', items[1].password, 'password empty on encode error')
|
||||
|
||||
local found_error = false
|
||||
for _, e in ipairs(log_calls) do
|
||||
if e.level == 'error' then found_error = true end
|
||||
end
|
||||
assert.is_true(found_error, 'error logged on encode failure')
|
||||
end)
|
||||
|
||||
end) -- generateToken payload
|
||||
|
||||
end) -- mod_short_lived_token
|
||||
221
tests/prosody/lua/mod_token_affiliation_spec.lua
Normal file
221
tests/prosody/lua/mod_token_affiliation_spec.lua
Normal file
@@ -0,0 +1,221 @@
|
||||
-- Unit tests for mod_token_affiliation.lua
|
||||
-- Run with busted from resources/prosody-plugins/:
|
||||
-- busted ../../tests/prosody/lua/
|
||||
--
|
||||
-- Stubs every Prosody dependency so no Prosody installation is needed.
|
||||
--
|
||||
-- JWT claim correctness and role/affiliation values are covered by the
|
||||
-- integration tests in mod_token_affiliation_spec.js. These unit tests
|
||||
-- focus on skip conditions (healthcheck rooms, admin occupants, missing tokens)
|
||||
-- and the correct affiliation derived from various token claims.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Captured hooks
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local pre_join_fn = nil
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Stubs
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local is_healthcheck_stub = false
|
||||
local is_admin_stub = false
|
||||
|
||||
local util_stub = {
|
||||
is_admin = function(_jid) return is_admin_stub end,
|
||||
is_healthcheck_room = function(_jid) return is_healthcheck_stub end,
|
||||
}
|
||||
|
||||
_G.module = {
|
||||
host = 'conference.example.com',
|
||||
log = function() end,
|
||||
get_option_string = function(_, _key, default) return default end,
|
||||
require = function(_, name)
|
||||
if name == 'util' then return util_stub end
|
||||
return {}
|
||||
end,
|
||||
hook = function(_, event, fn)
|
||||
if event == 'muc-occupant-pre-join' then pre_join_fn = fn end
|
||||
end,
|
||||
}
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Load the module under test
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local ok, load_err = pcall(dofile, 'mod_token_affiliation.lua')
|
||||
if not ok then
|
||||
describe("mod_token_affiliation", function()
|
||||
it("skipped — failed to load module", function()
|
||||
pending(tostring(load_err):match("([^\n]+)") or tostring(load_err))
|
||||
end)
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
assert(pre_join_fn, "mod_token_affiliation did not hook 'muc-occupant-pre-join'")
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Test helpers
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local jid_counter = 0
|
||||
local function fresh_jid()
|
||||
jid_counter = jid_counter + 1
|
||||
return string.format("user%d@example.com", jid_counter)
|
||||
end
|
||||
|
||||
local function make_room(opts)
|
||||
opts = opts or {}
|
||||
local room = {
|
||||
jid = 'testroom@conference.example.com',
|
||||
affiliation_log = {},
|
||||
_members_only = opts.members_only or false,
|
||||
_affiliations = opts.affiliations or {},
|
||||
}
|
||||
function room:set_affiliation(actor, bare_jid, affiliation)
|
||||
table.insert(self.affiliation_log, { actor = actor, jid = bare_jid, affiliation = affiliation })
|
||||
end
|
||||
function room:get_members_only()
|
||||
return self._members_only
|
||||
end
|
||||
function room:get_affiliation(bare_jid)
|
||||
return self._affiliations[bare_jid]
|
||||
end
|
||||
return room
|
||||
end
|
||||
|
||||
local function make_occupant(bare_jid)
|
||||
return { jid = bare_jid .. '/res', bare_jid = bare_jid, role = 'participant' }
|
||||
end
|
||||
|
||||
local function make_session(opts)
|
||||
opts = opts or {}
|
||||
local auth_token
|
||||
if not opts.no_token then auth_token = 'test.token.value' end
|
||||
return {
|
||||
auth_token = auth_token,
|
||||
jitsi_meet_context_user = opts.context_user,
|
||||
}
|
||||
end
|
||||
|
||||
local function run_pre_join(room, occupant, session)
|
||||
pre_join_fn({ room = room, occupant = occupant, origin = session })
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Tests
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
describe("mod_token_affiliation", function()
|
||||
|
||||
before_each(function()
|
||||
is_healthcheck_stub = false
|
||||
is_admin_stub = false
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("skip conditions in muc-occupant-pre-join", function()
|
||||
|
||||
it("healthcheck room — no affiliation set", function()
|
||||
is_healthcheck_stub = true
|
||||
local room = make_room()
|
||||
local occupant = make_occupant(fresh_jid())
|
||||
local session = make_session({ context_user = { moderator = true } })
|
||||
run_pre_join(room, occupant, session)
|
||||
assert.equal(0, #room.affiliation_log)
|
||||
end)
|
||||
|
||||
it("admin occupant — no affiliation set", function()
|
||||
is_admin_stub = true
|
||||
local room = make_room()
|
||||
local occupant = make_occupant(fresh_jid())
|
||||
local session = make_session({ context_user = { moderator = true } })
|
||||
run_pre_join(room, occupant, session)
|
||||
assert.equal(0, #room.affiliation_log)
|
||||
end)
|
||||
|
||||
it("no token — no affiliation set", function()
|
||||
local room = make_room()
|
||||
local occupant = make_occupant(fresh_jid())
|
||||
local session = make_session({ no_token = true, context_user = { moderator = true } })
|
||||
run_pre_join(room, occupant, session)
|
||||
assert.equal(0, #room.affiliation_log)
|
||||
end)
|
||||
|
||||
it("nil session — no affiliation set", function()
|
||||
local room = make_room()
|
||||
local occupant = make_occupant(fresh_jid())
|
||||
run_pre_join(room, occupant, nil)
|
||||
assert.equal(0, #room.affiliation_log)
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("affiliation assignment", function()
|
||||
|
||||
it("moderator=true sets owner affiliation and moderator role", function()
|
||||
local room = make_room()
|
||||
local occupant = make_occupant(fresh_jid())
|
||||
local session = make_session({ context_user = { moderator = true } })
|
||||
run_pre_join(room, occupant, session)
|
||||
assert.equal(1, #room.affiliation_log)
|
||||
assert.equal("owner", room.affiliation_log[1].affiliation)
|
||||
assert.equal("moderator", occupant.role)
|
||||
end)
|
||||
|
||||
it("moderator='true' sets owner affiliation", function()
|
||||
local room = make_room()
|
||||
local occupant = make_occupant(fresh_jid())
|
||||
local session = make_session({ context_user = { moderator = 'true' } })
|
||||
run_pre_join(room, occupant, session)
|
||||
assert.equal("owner", room.affiliation_log[1].affiliation)
|
||||
end)
|
||||
|
||||
it("affiliation='owner' sets owner", function()
|
||||
local room = make_room()
|
||||
local occupant = make_occupant(fresh_jid())
|
||||
local session = make_session({ context_user = { affiliation = 'owner' } })
|
||||
run_pre_join(room, occupant, session)
|
||||
assert.equal("owner", room.affiliation_log[1].affiliation)
|
||||
end)
|
||||
|
||||
it("affiliation='moderator' sets owner", function()
|
||||
local room = make_room()
|
||||
local occupant = make_occupant(fresh_jid())
|
||||
local session = make_session({ context_user = { affiliation = 'moderator' } })
|
||||
run_pre_join(room, occupant, session)
|
||||
assert.equal("owner", room.affiliation_log[1].affiliation)
|
||||
end)
|
||||
|
||||
it("affiliation='teacher' sets owner", function()
|
||||
local room = make_room()
|
||||
local occupant = make_occupant(fresh_jid())
|
||||
local session = make_session({ context_user = { affiliation = 'teacher' } })
|
||||
run_pre_join(room, occupant, session)
|
||||
assert.equal("owner", room.affiliation_log[1].affiliation)
|
||||
end)
|
||||
|
||||
it("authenticated non-moderator sets member affiliation, role unchanged", function()
|
||||
local room = make_room()
|
||||
local occupant = make_occupant(fresh_jid())
|
||||
local session = make_session({ context_user = { id = 'plainuser' } })
|
||||
run_pre_join(room, occupant, session)
|
||||
assert.equal(1, #room.affiliation_log)
|
||||
assert.equal("member", room.affiliation_log[1].affiliation)
|
||||
assert.equal("participant", occupant.role)
|
||||
end)
|
||||
|
||||
it("moderator=false sets member affiliation", function()
|
||||
local room = make_room()
|
||||
local occupant = make_occupant(fresh_jid())
|
||||
local session = make_session({ context_user = { moderator = false } })
|
||||
run_pre_join(room, occupant, session)
|
||||
assert.equal("member", room.affiliation_log[1].affiliation)
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
end) -- mod_token_affiliation
|
||||
113
tests/prosody/lua/otel_spec.lua
Normal file
113
tests/prosody/lua/otel_spec.lua
Normal file
@@ -0,0 +1,113 @@
|
||||
-- Unit tests for otel.lib.lua
|
||||
-- Run with busted from resources/prosody-plugins/:
|
||||
-- busted spec/lua/
|
||||
-- Requires: lua-cjson
|
||||
--
|
||||
-- Stubs every Prosody dependency so no Prosody installation is needed.
|
||||
|
||||
local os = require "os"
|
||||
local cjson = require "cjson"
|
||||
|
||||
package.preload['util.json'] = function()
|
||||
return {
|
||||
encode = function(x)
|
||||
return cjson.encode(x)
|
||||
end
|
||||
}
|
||||
end
|
||||
package.preload['util.array'] = function()
|
||||
return function(t) return t end
|
||||
end
|
||||
package.preload['util.time'] = function()
|
||||
return {
|
||||
now = function()
|
||||
return os.time();
|
||||
end
|
||||
}
|
||||
end
|
||||
package.preload['util.random'] = function()
|
||||
return {
|
||||
bytes = function(n)
|
||||
local out = {}
|
||||
for i = 1, n do
|
||||
out[i] = string.char(math.random(0, 255))
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
}
|
||||
end
|
||||
package.preload['util.hex'] = function()
|
||||
return {
|
||||
to = function(bytes)
|
||||
local out = {}
|
||||
for i = 1, #bytes do
|
||||
out[i] = string.format("%02x", bytes:byte(i))
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
-- Guard: skip the whole suite if the module's hard dependencies aren't installed.
|
||||
local ok, M = pcall(dofile, "otel.lib.lua")
|
||||
if not ok then
|
||||
describe("otel", function()
|
||||
it("skipped", function()
|
||||
pending(tostring(M):match("([^\n]+)"))
|
||||
end)
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
describe("otel", function()
|
||||
describe("span", function()
|
||||
it("create and export span", function()
|
||||
local request
|
||||
local request_fn = function(url, options, callback) request = options end
|
||||
|
||||
local exporter = M.Exporter.new(request_fn, "endpoint")
|
||||
local processor = M.Processor.new(exporter)
|
||||
local tracer = M.Tracer.new(processor, "service", "scope")
|
||||
|
||||
local span = tracer:start_span("name", {
|
||||
trace_id = "deadbeefdeadbeefdeadbeefdeadbeef",
|
||||
span_id = "deadbeefdeadbeef",
|
||||
})
|
||||
span:set_attribute("key", M.Attribute.string("value"))
|
||||
span:end_span()
|
||||
|
||||
local body = cjson.decode(request.body)
|
||||
assert.are.same(
|
||||
{
|
||||
attributes = {
|
||||
{ key = "service.name", value = { string_value = "service" } }
|
||||
}
|
||||
},
|
||||
body.resource_spans[1].resource)
|
||||
assert.are.same(
|
||||
{
|
||||
name = "scope"
|
||||
},
|
||||
body.resource_spans[1].scope_spans[1].scope
|
||||
)
|
||||
|
||||
local span = body.resource_spans[1].scope_spans[1].spans[1]
|
||||
assert.are.same(
|
||||
{ { key = "key", value = { string_value = "value" } } },
|
||||
span.attributes
|
||||
)
|
||||
assert.are.same(
|
||||
"deadbeefdeadbeefdeadbeefdeadbeef",
|
||||
span.trace_id
|
||||
)
|
||||
assert.are.same(
|
||||
"deadbeefdeadbeef",
|
||||
span.parent_span_id
|
||||
)
|
||||
assert.are.same(1, span.kind)
|
||||
assert.are.same("name", span.name)
|
||||
assert.are.same({ code = 1 }, span.status)
|
||||
end)
|
||||
end)
|
||||
end) -- otel
|
||||
165
tests/prosody/lua/rayo_dial_header_crlf_spec.lua
Normal file
165
tests/prosody/lua/rayo_dial_header_crlf_spec.lua
Normal file
@@ -0,0 +1,165 @@
|
||||
-- Unit test for Rayo dial IQ header serialization.
|
||||
-- Run with busted from resources/prosody-plugins/:
|
||||
-- busted spec/lua/
|
||||
--
|
||||
-- Purpose: a Rayo "dial" IQ (urn:xmpp:rayo:1) carries client-influenced header
|
||||
-- values (e.g. JvbRoomName / JvbRoomPassword). If a header value contains a raw
|
||||
-- carriage-return / line-feed, an unescaped serialization would let a client
|
||||
-- inject extra lines into the XMPP stream — and, downstream, into the SIP
|
||||
-- headers jigasi builds from these values (CRLF / header injection).
|
||||
--
|
||||
-- Prosody's util.stanza serializer neutralizes this by XML-escaping control
|
||||
-- characters: "\r" -> " " and "\n" -> " ". This spec produces a dial IQ
|
||||
-- with such a header and asserts the serialized stanza contains " " and
|
||||
-- leaks no raw CR/LF bytes.
|
||||
--
|
||||
-- It uses Prosody's real util.stanza when it resolves on LUA_PATH; otherwise it
|
||||
-- falls back to a small in-file serializer that mirrors Prosody's escape table,
|
||||
-- so the contract is still asserted in CI (where Prosody is not installed).
|
||||
|
||||
local RAYO_NS = 'urn:xmpp:rayo:1';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Obtain a stanza builder: prefer the real util.stanza, else a faithful stub.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local st, source;
|
||||
|
||||
local ok_real, real = pcall(require, 'util.stanza');
|
||||
if ok_real and type(real) == 'table' and real.iq then
|
||||
st = real;
|
||||
source = 'util.stanza (real Prosody)';
|
||||
else
|
||||
source = 'in-file reference serializer';
|
||||
|
||||
-- Mirrors Prosody's util.stanza escape table: control characters are
|
||||
-- escaped to numeric entities so they cannot break out of an attribute.
|
||||
local escape_table = {
|
||||
["'"] = "'",
|
||||
['"'] = """,
|
||||
["<"] = "<",
|
||||
[">"] = ">",
|
||||
["&"] = "&",
|
||||
["\t"] = "	",
|
||||
["\n"] = " ",
|
||||
["\r"] = " ",
|
||||
};
|
||||
local function xml_escape(str)
|
||||
return (str:gsub("['&<>\"\t\n\r]", escape_table));
|
||||
end
|
||||
|
||||
local stanza_mt = {};
|
||||
stanza_mt.__index = stanza_mt;
|
||||
|
||||
local function new_stanza(name, attr)
|
||||
return setmetatable({
|
||||
name = name,
|
||||
attr = attr or {},
|
||||
tags = {},
|
||||
last_add = {},
|
||||
}, stanza_mt);
|
||||
end
|
||||
|
||||
function stanza_mt:tag(name, attr)
|
||||
local child = new_stanza(name, attr);
|
||||
local parent = self.last_add[#self.last_add] or self;
|
||||
|
||||
table.insert(parent.tags, child);
|
||||
table.insert(self.last_add, child);
|
||||
|
||||
return self;
|
||||
end
|
||||
|
||||
function stanza_mt:up()
|
||||
table.remove(self.last_add);
|
||||
|
||||
return self;
|
||||
end
|
||||
|
||||
local function serialize(node, buf)
|
||||
table.insert(buf, "<" .. node.name);
|
||||
for k, v in pairs(node.attr) do
|
||||
table.insert(buf, " " .. k .. '="' .. xml_escape(tostring(v)) .. '"');
|
||||
end
|
||||
|
||||
if #node.tags == 0 then
|
||||
table.insert(buf, "/>");
|
||||
else
|
||||
table.insert(buf, ">");
|
||||
for _, child in ipairs(node.tags) do
|
||||
serialize(child, buf);
|
||||
end
|
||||
table.insert(buf, "</" .. node.name .. ">");
|
||||
end
|
||||
|
||||
return buf;
|
||||
end
|
||||
|
||||
stanza_mt.__tostring = function(self)
|
||||
return table.concat(serialize(self, {}));
|
||||
end
|
||||
|
||||
st = {
|
||||
iq = function(attr)
|
||||
return new_stanza('iq', attr);
|
||||
end,
|
||||
};
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Test
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
describe("Rayo dial IQ header CRLF escaping [" .. source .. "]", function()
|
||||
|
||||
-- Builds a dial IQ the same shape mod_muc_jigasi_invite.lua produces:
|
||||
-- <iq><dial xmlns=rayo><header name=... value=.../></dial></iq>
|
||||
-- with a JvbRoomName header whose value is spiked with a raw CRLF.
|
||||
local function build_dial_iq(room_value)
|
||||
return st.iq({
|
||||
xmlns = "jabber:client",
|
||||
type = "set",
|
||||
to = "jvb@example.com",
|
||||
from = "focus@example.com",
|
||||
id = "dial-1",
|
||||
})
|
||||
:tag("dial", { xmlns = RAYO_NS, from = "fromnumber", to = "sip:test@example.com" })
|
||||
:tag("header", { xmlns = RAYO_NS, name = "JvbRoomName", value = room_value })
|
||||
:up()
|
||||
:up();
|
||||
end
|
||||
|
||||
it("escapes a CRLF-injected header value to ", function()
|
||||
-- A client attempting to smuggle an extra SIP header via CRLF injection.
|
||||
local malicious = "room@conference.example.com\r\nInjected-Header: evil";
|
||||
local iq = build_dial_iq(malicious);
|
||||
|
||||
local xml = tostring(iq);
|
||||
|
||||
assert.is_truthy(xml:find(" ", 1, true),
|
||||
"serialized dial IQ must escape the raw CRLF to ");
|
||||
end)
|
||||
|
||||
it("leaks no raw CR or LF bytes in the serialized stanza", function()
|
||||
local malicious = "room@conference.example.com\r\nInjected-Header: evil";
|
||||
local iq = build_dial_iq(malicious);
|
||||
|
||||
local xml = tostring(iq);
|
||||
|
||||
assert.is_nil(xml:find("[\r\n]"),
|
||||
"serialized dial IQ must not contain any raw CR/LF byte");
|
||||
end)
|
||||
|
||||
it("preserves the room name text around the escaped CRLF", function()
|
||||
local malicious = "room@conference.example.com\r\nInjected-Header: evil";
|
||||
local iq = build_dial_iq(malicious);
|
||||
|
||||
local xml = tostring(iq);
|
||||
|
||||
-- The header name and the surrounding value text survive; only the
|
||||
-- control characters are entity-encoded.
|
||||
assert.is_truthy(xml:find("JvbRoomName", 1, true));
|
||||
assert.is_truthy(xml:find("room@conference.example.com Injected%-Header: evil"));
|
||||
end)
|
||||
|
||||
end);
|
||||
329
tests/prosody/lua/speakerstats_component_spec.lua
Normal file
329
tests/prosody/lua/speakerstats_component_spec.lua
Normal file
@@ -0,0 +1,329 @@
|
||||
-- Unit tests for SpeakerStats in mod_speakerstats_component.lua
|
||||
-- Run with busted from resources/prosody-plugins/:
|
||||
-- busted spec/lua/
|
||||
--
|
||||
-- Stubs every Prosody dependency so no Prosody installation is needed.
|
||||
-- Only the pure SpeakerStats class (new_SpeakerStats / setDominantSpeaker /
|
||||
-- isDominantSpeaker / isSilent) is exercised here. Functions that depend on
|
||||
-- live rooms, stanzas, or XMPP routing are covered by the integration tests.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Controllable clock
|
||||
-- socket.gettime() returns seconds (float); the module multiplies by 1000.
|
||||
-- Call set_clock(ms) to advance time before each action.
|
||||
-- ---------------------------------------------------------------------------
|
||||
local _clock_ms = 0
|
||||
|
||||
local function set_clock(ms)
|
||||
_clock_ms = ms
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Package preloads — registered before dofile() so the module sees them
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
package.preload['socket'] = function()
|
||||
return {
|
||||
gettime = function() return _clock_ms / 1000 end,
|
||||
}
|
||||
end
|
||||
|
||||
package.preload['util.jid'] = function()
|
||||
local function split(jid)
|
||||
if not jid then return nil, nil, nil end
|
||||
local node = jid:match('^([^@]+)@')
|
||||
local host = jid:match('@([^/]+)')
|
||||
local res = jid:match('/(.+)$')
|
||||
return node, host, res
|
||||
end
|
||||
return {
|
||||
resource = function(jid)
|
||||
if not jid then return nil end
|
||||
return jid:match('/(.+)$')
|
||||
end,
|
||||
split = split,
|
||||
}
|
||||
end
|
||||
|
||||
package.preload['util.stanza'] = function()
|
||||
-- Only used by occupant_joined (route_stanza) — not exercised here.
|
||||
return {
|
||||
message = function()
|
||||
local s = {}
|
||||
s.tag = function(self, _, _) return self end
|
||||
s.text = function(self, _) return self end
|
||||
s.up = function(self) return self end
|
||||
return s
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
package.preload['cjson.safe'] = function()
|
||||
return {
|
||||
encode = function(_) return '{}', nil end,
|
||||
decode = function(_) return {}, nil end,
|
||||
}
|
||||
end
|
||||
|
||||
package.preload['util.queue'] = function()
|
||||
return {
|
||||
new = function(max_size)
|
||||
local items = {}
|
||||
local q = {}
|
||||
function q:push(v)
|
||||
table.insert(items, v)
|
||||
while #items > max_size do
|
||||
table.remove(items, 1)
|
||||
end
|
||||
end
|
||||
function q:items()
|
||||
local i = 0
|
||||
return function()
|
||||
i = i + 1
|
||||
return items[i]
|
||||
end
|
||||
end
|
||||
function q:count() return #items end
|
||||
return q
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
package.preload['util.array'] = function()
|
||||
-- Prosody's util.array module is itself callable as a constructor.
|
||||
local arr_mt = {}
|
||||
arr_mt.__index = arr_mt
|
||||
return setmetatable({}, {
|
||||
__call = function(_, iter)
|
||||
local t = setmetatable({}, arr_mt)
|
||||
if iter then
|
||||
for v in iter do t[#t + 1] = v end
|
||||
end
|
||||
return t
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
package.preload['util.async'] = function() return {} end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Global stubs required by mod_speakerstats_component.lua at load time
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
_G.prosody = { hosts = {}, version = 'test' }
|
||||
|
||||
-- Stub for the util library loaded via module:require "util"
|
||||
local util_stub = {
|
||||
is_admin = function() return false end,
|
||||
get_room_from_jid = function() return nil end,
|
||||
room_jid_match_rewrite = function(jid) return jid end,
|
||||
is_jibri = function() return false end,
|
||||
is_healthcheck_room = function() return false end,
|
||||
is_transcriber = function() return false end,
|
||||
-- noop: prevents hook registration against live muc components
|
||||
process_host_module = function() end,
|
||||
}
|
||||
|
||||
local mod = { host = 'speakerstats.test.example.com' }
|
||||
mod.log = function() end
|
||||
mod.get_option_string = function(_, key, default)
|
||||
if key == 'muc_component' then return 'conference.test.example.com' end
|
||||
if key == 'muc_mapper_domain_base' then return 'test.example.com' end
|
||||
return default
|
||||
end
|
||||
mod.get_option_number = function(_, _, default) return default end
|
||||
mod.hook = function() end
|
||||
mod.fire_event = function() end
|
||||
mod.context = function() return { fire_event = function() end } end
|
||||
mod.require = function(_, name)
|
||||
if name == 'util' then return util_stub end
|
||||
error('unexpected module:require("' .. tostring(name) .. '")')
|
||||
end
|
||||
|
||||
_G.module = mod
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Load the module under test
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local ok, err = pcall(dofile, 'mod_speakerstats_component.lua')
|
||||
if not ok then
|
||||
describe('mod_speakerstats_component', function()
|
||||
it('skipped — failed to load', function()
|
||||
pending(tostring(err):match('([^\n]+)') or tostring(err))
|
||||
end)
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
-- new_SpeakerStats is a module-level global (no `local` prefix in the source).
|
||||
assert(type(new_SpeakerStats) == 'function',
|
||||
'new_SpeakerStats must be a global function after loading the module')
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Helpers
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function new_stats(nick)
|
||||
return new_SpeakerStats(nick or 'testnick', nil)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Tests
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
describe('SpeakerStats', function()
|
||||
|
||||
-- Reset clock before every test so time never leaks between cases.
|
||||
before_each(function() set_clock(0) end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe('initial state', function()
|
||||
|
||||
it('is not dominant speaker', function()
|
||||
assert.is_false(new_stats():isDominantSpeaker())
|
||||
end)
|
||||
|
||||
it('is not silent', function()
|
||||
assert.is_false(new_stats():isSilent())
|
||||
end)
|
||||
|
||||
it('has zero total speaking time', function()
|
||||
assert.equal(0, new_stats().totalDominantSpeakerTime)
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe('setDominantSpeaker', function()
|
||||
|
||||
it('accumulates time when dominant speaker stops', function()
|
||||
local s = new_stats()
|
||||
|
||||
set_clock(0)
|
||||
s:setDominantSpeaker(true, false)
|
||||
|
||||
set_clock(1000)
|
||||
s:setDominantSpeaker(false, false)
|
||||
|
||||
assert.equal(1000, s.totalDominantSpeakerTime)
|
||||
end)
|
||||
|
||||
it('does not count time when starting dominant+silent', function()
|
||||
local s = new_stats()
|
||||
|
||||
set_clock(0)
|
||||
s:setDominantSpeaker(true, true) -- dominant but muted from the start
|
||||
|
||||
set_clock(5000)
|
||||
s:setDominantSpeaker(false, false)
|
||||
|
||||
assert.equal(0, s.totalDominantSpeakerTime)
|
||||
end)
|
||||
|
||||
it('accumulates time up to the moment of going silent', function()
|
||||
local s = new_stats()
|
||||
|
||||
set_clock(0)
|
||||
s:setDominantSpeaker(true, false) -- start speaking
|
||||
|
||||
set_clock(500)
|
||||
s:setDominantSpeaker(true, true) -- go silent while still dominant
|
||||
|
||||
assert.equal(500, s.totalDominantSpeakerTime)
|
||||
end)
|
||||
|
||||
it('resumes accumulating after returning from silence', function()
|
||||
local s = new_stats()
|
||||
|
||||
set_clock(0)
|
||||
s:setDominantSpeaker(true, false) -- speak: timer starts at 0
|
||||
|
||||
set_clock(500)
|
||||
s:setDominantSpeaker(true, true) -- mute: +500 ms accumulated
|
||||
|
||||
set_clock(700)
|
||||
s:setDominantSpeaker(true, false) -- unmute: timer restarts at 700
|
||||
|
||||
set_clock(900)
|
||||
s:setDominantSpeaker(false, false) -- stop: +200 ms
|
||||
|
||||
assert.equal(700, s.totalDominantSpeakerTime) -- 500 + 200
|
||||
end)
|
||||
|
||||
it('does not double-count when stopped twice in a row', function()
|
||||
local s = new_stats()
|
||||
|
||||
set_clock(0)
|
||||
s:setDominantSpeaker(true, false)
|
||||
|
||||
set_clock(300)
|
||||
s:setDominantSpeaker(false, false) -- first stop: +300 ms
|
||||
|
||||
set_clock(9999)
|
||||
s:setDominantSpeaker(false, false) -- second stop — must be a no-op
|
||||
|
||||
assert.equal(300, s.totalDominantSpeakerTime)
|
||||
end)
|
||||
|
||||
it('accumulates across multiple dominant speaker episodes', function()
|
||||
local s = new_stats()
|
||||
|
||||
set_clock(0)
|
||||
s:setDominantSpeaker(true, false)
|
||||
|
||||
set_clock(400)
|
||||
s:setDominantSpeaker(false, false) -- first episode: +400 ms
|
||||
|
||||
set_clock(1000)
|
||||
s:setDominantSpeaker(true, false)
|
||||
|
||||
set_clock(1600)
|
||||
s:setDominantSpeaker(false, false) -- second episode: +600 ms
|
||||
|
||||
assert.equal(1000, s.totalDominantSpeakerTime)
|
||||
end)
|
||||
|
||||
it('updates isDominantSpeaker state', function()
|
||||
local s = new_stats()
|
||||
|
||||
s:setDominantSpeaker(true, false)
|
||||
assert.is_true(s:isDominantSpeaker())
|
||||
|
||||
s:setDominantSpeaker(false, false)
|
||||
assert.is_false(s:isDominantSpeaker())
|
||||
end)
|
||||
|
||||
it('updates isSilent state', function()
|
||||
local s = new_stats()
|
||||
|
||||
s:setDominantSpeaker(true, false)
|
||||
assert.is_false(s:isSilent())
|
||||
|
||||
s:setDominantSpeaker(true, true)
|
||||
assert.is_true(s:isSilent())
|
||||
|
||||
s:setDominantSpeaker(true, false)
|
||||
assert.is_false(s:isSilent())
|
||||
end)
|
||||
|
||||
it('does not start timer when becoming dominant while silent', function()
|
||||
-- Silence=true on the transition to dominant → no timer started.
|
||||
-- Stopping later must not add spurious time.
|
||||
local s = new_stats()
|
||||
|
||||
set_clock(0)
|
||||
s:setDominantSpeaker(true, true) -- dominant+silent, no timer
|
||||
|
||||
set_clock(2000)
|
||||
s:setDominantSpeaker(true, false) -- unmute: timer starts NOW at t=2000
|
||||
|
||||
set_clock(2500)
|
||||
s:setDominantSpeaker(false, false) -- stop: +500 ms
|
||||
|
||||
assert.equal(500, s.totalDominantSpeakerTime)
|
||||
end)
|
||||
|
||||
end) -- setDominantSpeaker
|
||||
|
||||
end) -- SpeakerStats
|
||||
919
tests/prosody/lua/util_spec.lua
Normal file
919
tests/prosody/lua/util_spec.lua
Normal file
@@ -0,0 +1,919 @@
|
||||
-- Unit tests for util.lib.lua
|
||||
-- Run with busted from resources/prosody-plugins/:
|
||||
-- busted spec/lua/
|
||||
--
|
||||
-- Stubs every Prosody dependency so no Prosody installation is needed.
|
||||
-- Functions that rely on live Prosody objects (async HTTP, room lookup,
|
||||
-- stanza mutation, etc.) are not covered here — they are exercised by the
|
||||
-- integration tests.
|
||||
--
|
||||
-- Module is loaded with:
|
||||
-- muc_domain_prefix = "conference" (default)
|
||||
-- muc_domain_base = "test.example.com" (module.host default)
|
||||
-- muc_domain = "conference.test.example.com"
|
||||
-- moderated_subdomains = {"moderated-subdomain"}
|
||||
-- moderated_rooms = {"moderatedroom"}
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Minimal Set implementation (mirrors Prosody's util.set behaviour)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local Set_mt = {}
|
||||
Set_mt.__index = Set_mt
|
||||
-- set / fn → new set with fn mapped over every element
|
||||
Set_mt.__div = function(self, fn)
|
||||
local new_s = setmetatable({ _data = {}, _is_set = true }, Set_mt)
|
||||
for k in pairs(self._data) do
|
||||
local mapped = fn(k)
|
||||
if mapped ~= nil then new_s._data[mapped] = true end
|
||||
end
|
||||
return new_s
|
||||
end
|
||||
function Set_mt:contains(v) return self._data[v] == true end
|
||||
function Set_mt:empty() return next(self._data) == nil end
|
||||
function Set_mt:items() return pairs(self._data) end
|
||||
|
||||
local function new_set(list)
|
||||
local s = setmetatable({ _data = {}, _is_set = true }, Set_mt)
|
||||
for _, v in ipairs(list or {}) do s._data[v] = true end
|
||||
return s
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Package preloads — must be registered before dofile()
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
package.preload['util.set'] = function()
|
||||
return {
|
||||
is_set = function(v) return type(v) == 'table' and v._is_set == true end,
|
||||
new = new_set,
|
||||
}
|
||||
end
|
||||
|
||||
package.preload['util.jid'] = function()
|
||||
local function jid_split(j)
|
||||
if not j then return nil, nil, nil end
|
||||
local node, host, resource
|
||||
local at = j:find('@')
|
||||
local slash
|
||||
if at then
|
||||
slash = j:find('/', at + 1)
|
||||
node = j:sub(1, at - 1)
|
||||
if slash then
|
||||
host = j:sub(at + 1, slash - 1)
|
||||
resource = j:sub(slash + 1)
|
||||
else
|
||||
host = j:sub(at + 1)
|
||||
end
|
||||
else
|
||||
slash = j:find('/')
|
||||
if slash then
|
||||
host = j:sub(1, slash - 1)
|
||||
resource = j:sub(slash + 1)
|
||||
else
|
||||
host = j
|
||||
end
|
||||
end
|
||||
node = (node and #node > 0) and node or nil
|
||||
host = (host and #host > 0) and host or nil
|
||||
resource = (resource and #resource > 0) and resource or nil
|
||||
return node, host, resource
|
||||
end
|
||||
|
||||
return {
|
||||
split = jid_split,
|
||||
join = function(node, host, resource)
|
||||
local j = ''
|
||||
if node then j = node .. '@' end
|
||||
j = j .. (host or '')
|
||||
if resource then j = j .. '/' .. resource end
|
||||
return j
|
||||
end,
|
||||
prep = function(j) return j end,
|
||||
bare = function(j)
|
||||
if not j then return nil end
|
||||
local node, host = jid_split(j)
|
||||
if node then return node .. '@' .. (host or '') end
|
||||
return host
|
||||
end,
|
||||
node = function(j)
|
||||
if not j then return nil end
|
||||
local node = jid_split(j)
|
||||
return node
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
package.preload['util.cache'] = function()
|
||||
return {
|
||||
new = function(_size)
|
||||
local t = {}
|
||||
return {
|
||||
get = function(_, k) return t[k] end,
|
||||
set = function(_, k, v) t[k] = v end,
|
||||
}
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
package.preload['util.array'] = function()
|
||||
local Array_mt = {}
|
||||
Array_mt.__index = Array_mt
|
||||
function Array_mt:push(v)
|
||||
self[#self + 1] = v
|
||||
return self
|
||||
end
|
||||
return function()
|
||||
return setmetatable({}, Array_mt)
|
||||
end
|
||||
end
|
||||
|
||||
-- Modules used only by functions we do not test — empty stubs suffice.
|
||||
package.preload['util.stanza'] = function() return {} end
|
||||
package.preload['util.timer'] = function() return { add_task = function() end } end
|
||||
package.preload['util.async'] = function() return {} end
|
||||
package.preload['net.http'] = function() return {} end
|
||||
package.preload['net.http.server'] = function() return {} end
|
||||
package.preload['core.usermanager'] = function() return {} end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Global stubs required by util.lib.lua at module load time
|
||||
--
|
||||
-- Busted sandboxes spec files via setfenv: bare assignments like
|
||||
-- module = {...}
|
||||
-- go into the busted context env, not _G. But dofile() always runs chunks
|
||||
-- in _G. So any global that util.lib.lua reads at load time must be written
|
||||
-- directly into _G via _G.key = value.
|
||||
-- package.preload is exempt — setting a key there mutates the real table.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
_G.prosody = { version = "test", platform = "posix" }
|
||||
_G.hosts = {}
|
||||
|
||||
local mod = { host = "test.example.com" }
|
||||
|
||||
mod.log = function() end
|
||||
mod.get_option_string = function(_, _key, default) return default end
|
||||
|
||||
mod.get_option_set = function(_, key, default)
|
||||
-- Populate the sets inspected by is_moderated() with test fixtures.
|
||||
if key == "allowners_moderated_subdomains" then
|
||||
return new_set({ "moderated-subdomain" })
|
||||
elseif key == "allowners_moderated_rooms" then
|
||||
return new_set({ "moderatedroom" })
|
||||
end
|
||||
return new_set(type(default) == 'table' and default or {})
|
||||
end
|
||||
|
||||
mod.get_option_inherited_set = function(_, _key, default)
|
||||
return new_set(type(default) == 'table' and default or {})
|
||||
end
|
||||
|
||||
mod.context = function(_, _host)
|
||||
return {
|
||||
get_option_set = function(_, _key, default)
|
||||
return new_set(type(default) == 'table' and default or {})
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
_G.module = mod
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Load the module under test
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local ok, M = pcall(dofile, "util.lib.lua")
|
||||
if not ok then
|
||||
describe("util.lib", function()
|
||||
it("skipped — failed to load", function()
|
||||
pending(tostring(M):match("([^\n]+)") or tostring(M))
|
||||
end)
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
-- Convenience: muc_domain derived from the defaults above
|
||||
local MUC_DOMAIN = "conference.test.example.com"
|
||||
local MUC_BASE = "test.example.com"
|
||||
local MUC_PREFIX = "conference"
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Tests
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
describe("util.lib", function()
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("starts_with", function()
|
||||
it("returns false for nil string", function()
|
||||
assert.is_false(M.starts_with(nil, "x"))
|
||||
end)
|
||||
|
||||
it("returns true when string starts with prefix", function()
|
||||
assert.is_true(M.starts_with("hello world", "hello"))
|
||||
end)
|
||||
|
||||
it("returns false when string does not start with prefix", function()
|
||||
assert.is_false(M.starts_with("hello", "world"))
|
||||
end)
|
||||
|
||||
it("returns true for empty prefix", function()
|
||||
assert.is_true(M.starts_with("hello", ""))
|
||||
end)
|
||||
|
||||
it("returns true when string equals prefix exactly", function()
|
||||
assert.is_true(M.starts_with("abc", "abc"))
|
||||
end)
|
||||
|
||||
it("returns false when prefix is longer than string", function()
|
||||
assert.is_false(M.starts_with("hi", "hello"))
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("ends_with", function()
|
||||
it("returns false for nil string", function()
|
||||
assert.is_false(M.ends_with(nil, "x"))
|
||||
end)
|
||||
|
||||
it("returns true when string ends with suffix", function()
|
||||
assert.is_true(M.ends_with("hello", "llo"))
|
||||
end)
|
||||
|
||||
it("returns false when string does not end with suffix", function()
|
||||
assert.is_false(M.ends_with("hello", "world"))
|
||||
end)
|
||||
|
||||
it("returns true for empty suffix", function()
|
||||
assert.is_true(M.ends_with("hello", ""))
|
||||
end)
|
||||
|
||||
it("returns true when string equals suffix exactly", function()
|
||||
assert.is_true(M.ends_with("abc", "abc"))
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("starts_with_one_of", function()
|
||||
it("returns false for nil string", function()
|
||||
assert.is_false(M.starts_with_one_of(nil, { "a" }))
|
||||
end)
|
||||
|
||||
it("returns false for nil prefixes", function()
|
||||
assert.is_false(M.starts_with_one_of("hello", nil))
|
||||
end)
|
||||
|
||||
it("matches against a plain table of prefixes", function()
|
||||
assert.is_truthy(M.starts_with_one_of("recorder@example.com", { "recorder@", "jibri@" }))
|
||||
end)
|
||||
|
||||
it("returns false when no prefix in table matches", function()
|
||||
assert.is_false(M.starts_with_one_of("user@example.com", { "recorder@", "jibri@" }))
|
||||
end)
|
||||
|
||||
it("matches against a Set of prefixes", function()
|
||||
local prefixes = new_set({ "recorder@", "jibri@" })
|
||||
assert.is_truthy(M.starts_with_one_of("recorder@example.com", prefixes))
|
||||
end)
|
||||
|
||||
it("returns false when no prefix in Set matches", function()
|
||||
local prefixes = new_set({ "recorder@", "jibri@" })
|
||||
assert.is_false(M.starts_with_one_of("user@example.com", prefixes))
|
||||
end)
|
||||
|
||||
it("returns the matched prefix value (table)", function()
|
||||
assert.equal("rec@", M.starts_with_one_of("rec@host", { "rec@", "other@" }))
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("is_healthcheck_room", function()
|
||||
it("recognises a jicofo health-check room", function()
|
||||
assert.is_true(M.is_healthcheck_room("__jicofo-health-check-abc@conference.example.com"))
|
||||
end)
|
||||
|
||||
it("recognises the bare prefix itself", function()
|
||||
assert.is_true(M.is_healthcheck_room("__jicofo-health-check"))
|
||||
end)
|
||||
|
||||
it("returns false for a regular room", function()
|
||||
assert.is_false(M.is_healthcheck_room("myroom@conference.example.com"))
|
||||
end)
|
||||
|
||||
it("returns false for nil", function()
|
||||
assert.is_false(M.is_healthcheck_room(nil))
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("is_feature_allowed", function()
|
||||
it("returns is_moderator when features table is absent", function()
|
||||
assert.is_true(M.is_feature_allowed("feature", nil, true))
|
||||
assert.is_false(M.is_feature_allowed("feature", nil, false))
|
||||
end)
|
||||
|
||||
it("returns true when feature value is the string 'true'", function()
|
||||
assert.is_true(M.is_feature_allowed("f", { f = "true" }, false))
|
||||
end)
|
||||
|
||||
it("returns true when feature value is boolean true", function()
|
||||
assert.is_true(M.is_feature_allowed("f", { f = true }, false))
|
||||
end)
|
||||
|
||||
it("returns false when feature is present but set to false", function()
|
||||
assert.is_false(M.is_feature_allowed("f", { f = false }, true))
|
||||
end)
|
||||
|
||||
it("returns false when feature is absent from table", function()
|
||||
assert.is_false(M.is_feature_allowed("f", { g = "true" }, false))
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("extract_subdomain", function()
|
||||
it("returns nil subdomain and the node itself when no brackets", function()
|
||||
local subdomain, room, customer_id = M.extract_subdomain("myroom")
|
||||
assert.is_nil(subdomain)
|
||||
assert.equal("myroom", room)
|
||||
assert.is_nil(customer_id)
|
||||
end)
|
||||
|
||||
it("extracts subdomain and room name from [sub]room format", function()
|
||||
local subdomain, room = M.extract_subdomain("[foo]myroom")
|
||||
assert.equal("foo", subdomain)
|
||||
assert.equal("myroom", room)
|
||||
end)
|
||||
|
||||
it("extracts subdomain from vpaas magic cookie node", function()
|
||||
local subdomain, room, customer_id =
|
||||
M.extract_subdomain("[vpaas-magic-cookie-abc123]myroom")
|
||||
assert.equal("vpaas-magic-cookie-abc123", subdomain)
|
||||
assert.equal("myroom", room)
|
||||
assert.equal("abc123", customer_id)
|
||||
end)
|
||||
|
||||
it("returns no customer_id for non-vpaas subdomain", function()
|
||||
local subdomain, room, customer_id = M.extract_subdomain("[tenant]myroom")
|
||||
assert.equal("tenant", subdomain)
|
||||
assert.equal("myroom", room)
|
||||
assert.is_nil(customer_id)
|
||||
end)
|
||||
|
||||
it("returns nil subdomain for empty brackets (pattern requires >=1 char)", function()
|
||||
-- "[]room" does not match ^%[([^%]]+)%](.+)$
|
||||
local subdomain, room = M.extract_subdomain("[]room")
|
||||
assert.is_nil(subdomain)
|
||||
assert.equal("[]room", room)
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
-- room_jid_split_subdomain / room_jid_match_rewrite /
|
||||
-- internal_room_jid_match_rewrite
|
||||
--
|
||||
-- With the default stubs:
|
||||
-- muc_domain_prefix = "conference"
|
||||
-- muc_domain_base = "test.example.com"
|
||||
-- target_subdomain_pattern matches conference.<sub>.test.example.com
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("room_jid_split_subdomain", function()
|
||||
it("extracts node, host, resource and subdomain from a multidomain JID", function()
|
||||
local node, host, resource, subdomain =
|
||||
M.room_jid_split_subdomain("room1@conference.foo.test.example.com/res")
|
||||
assert.equal("room1", node)
|
||||
assert.equal("conference.foo.test.example.com", host)
|
||||
assert.equal("res", resource)
|
||||
assert.equal("foo", subdomain)
|
||||
end)
|
||||
|
||||
it("returns nil subdomain for a non-multidomain MUC JID", function()
|
||||
local node, host, _, subdomain =
|
||||
M.room_jid_split_subdomain("room1@conference.test.example.com")
|
||||
assert.equal("room1", node)
|
||||
assert.equal("conference.test.example.com", host)
|
||||
assert.is_nil(subdomain)
|
||||
end)
|
||||
|
||||
it("handles JID with no node (bare host)", function()
|
||||
local node, host, _, subdomain =
|
||||
M.room_jid_split_subdomain("conference.foo.test.example.com")
|
||||
assert.is_nil(node)
|
||||
assert.equal("conference.foo.test.example.com", host)
|
||||
assert.equal("foo", subdomain)
|
||||
end)
|
||||
|
||||
-- Regression: target_subdomain_pattern used an unescaped '.' between the
|
||||
-- prefix and the subdomain capture group. In Lua patterns '.' matches any
|
||||
-- character, so "conference-foo.test.example.com" (hyphen) was incorrectly
|
||||
-- treated as a subdomain JID with subdomain "foo". The correct pattern
|
||||
-- requires a literal dot: "conference%.foo.test.example.com".
|
||||
it("returns nil subdomain for a hyphenated-prefix host (regression: unescaped dot)", function()
|
||||
-- 'conference-foo' shares the same base domain but is NOT a subdomain
|
||||
-- MUC component; the separator is a hyphen, not a dot.
|
||||
local _, _, _, subdomain =
|
||||
M.room_jid_split_subdomain("room@conference-foo.test.example.com")
|
||||
assert.is_nil(subdomain)
|
||||
end)
|
||||
|
||||
it("returns nil subdomain when only the suffix differs by a non-dot character", function()
|
||||
-- e.g. conference.foo-test.example.com — the subdomain capture is
|
||||
-- [^%.]+, so a hyphen inside 'foo-test' is fine, but this test
|
||||
-- confirms the prefix boundary is strict.
|
||||
local _, _, _, subdomain =
|
||||
M.room_jid_split_subdomain("room@conferenceXfoo.test.example.com")
|
||||
assert.is_nil(subdomain)
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("room_jid_match_rewrite", function()
|
||||
it("rewrites a multidomain JID to internal [sub]room@muc_domain form", function()
|
||||
local result = M.room_jid_match_rewrite("room1@conference.foo.test.example.com")
|
||||
assert.equal("[foo]room1@" .. MUC_DOMAIN, result)
|
||||
end)
|
||||
|
||||
it("preserves the resource in the rewritten JID", function()
|
||||
local result = M.room_jid_match_rewrite("room1@conference.foo.test.example.com/r")
|
||||
assert.equal("[foo]room1@" .. MUC_DOMAIN .. "/r", result)
|
||||
end)
|
||||
|
||||
it("returns the JID unchanged when there is no subdomain", function()
|
||||
local jid = "room1@" .. MUC_DOMAIN
|
||||
assert.equal(jid, M.room_jid_match_rewrite(jid))
|
||||
end)
|
||||
|
||||
it("handles host-only JID (no node) — stores id in roomless_iqs and rewrites host", function()
|
||||
local stanza = { attr = { id = "iq-roomless-1", to = "conference.foo.test.example.com" } }
|
||||
local result = M.room_jid_match_rewrite("conference.foo.test.example.com", stanza)
|
||||
-- No node → only host is rewritten
|
||||
assert.equal(MUC_DOMAIN, result)
|
||||
end)
|
||||
|
||||
-- Regression: unescaped '.' in target_subdomain_pattern caused
|
||||
-- "conference-internal.test.example.com" to be treated as a subdomain
|
||||
-- MUC host and rewritten to "[internal]room@conference.test.example.com".
|
||||
it("does not rewrite a JID whose host uses a hyphen after the prefix (regression)", function()
|
||||
local jid = "room@conference-internal.test.example.com"
|
||||
assert.equal(jid, M.room_jid_match_rewrite(jid))
|
||||
end)
|
||||
|
||||
it("does not rewrite a JID whose host uses an arbitrary non-dot separator after the prefix", function()
|
||||
local jid = "room@conferenceXinternal.test.example.com"
|
||||
assert.equal(jid, M.room_jid_match_rewrite(jid))
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("internal_room_jid_match_rewrite", function()
|
||||
it("rewrites an internal [sub]room JID back to pretty conference.sub.base/room form", function()
|
||||
local result = M.internal_room_jid_match_rewrite("[foo]room1@" .. MUC_DOMAIN)
|
||||
assert.equal("room1@" .. MUC_PREFIX .. ".foo." .. MUC_BASE, result)
|
||||
end)
|
||||
|
||||
it("preserves resource in the rewritten JID", function()
|
||||
local result = M.internal_room_jid_match_rewrite("[foo]room1@" .. MUC_DOMAIN .. "/res")
|
||||
assert.equal("room1@" .. MUC_PREFIX .. ".foo." .. MUC_BASE .. "/res", result)
|
||||
end)
|
||||
|
||||
it("returns JID unchanged when host is not the MUC domain", function()
|
||||
local jid = "room1@other.domain.com"
|
||||
assert.equal(jid, M.internal_room_jid_match_rewrite(jid))
|
||||
end)
|
||||
|
||||
it("returns JID unchanged when node lacks [sub] prefix", function()
|
||||
local jid = "room1@" .. MUC_DOMAIN
|
||||
assert.equal(jid, M.internal_room_jid_match_rewrite(jid))
|
||||
end)
|
||||
|
||||
it("resolves a roomless IQ stored by room_jid_match_rewrite", function()
|
||||
local stanza = { attr = { id = "iq-roomless-2", to = "conference.bar.test.example.com" } }
|
||||
-- Trigger storage of the roomless IQ
|
||||
M.room_jid_match_rewrite("conference.bar.test.example.com", stanza)
|
||||
-- internal_room_jid_match_rewrite should return the stored value
|
||||
local result = M.internal_room_jid_match_rewrite("anything@" .. MUC_DOMAIN, stanza)
|
||||
assert.equal("conference.bar.test.example.com", result)
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("table helpers", function()
|
||||
|
||||
describe("table_shallow_copy", function()
|
||||
it("copies all key-value pairs", function()
|
||||
local orig = { a = 1, b = "x", c = true }
|
||||
local copy = M.table_shallow_copy(orig)
|
||||
assert.equal(1, copy.a)
|
||||
assert.equal("x", copy.b)
|
||||
assert.equal(true, copy.c)
|
||||
end)
|
||||
|
||||
it("returns a distinct table (not the same reference)", function()
|
||||
local orig = { x = 1 }
|
||||
local copy = M.table_shallow_copy(orig)
|
||||
assert.not_equal(orig, copy)
|
||||
end)
|
||||
|
||||
it("does not deep-copy nested tables", function()
|
||||
local inner = { z = 9 }
|
||||
local orig = { a = inner }
|
||||
local copy = M.table_shallow_copy(orig)
|
||||
assert.equal(inner, copy.a) -- same reference
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
describe("table_find", function()
|
||||
it("returns the index of a found value", function()
|
||||
assert.equal(2, M.table_find({ "a", "b", "c" }, "b"))
|
||||
end)
|
||||
|
||||
it("returns nil when value is not present", function()
|
||||
assert.is_nil(M.table_find({ "a", "b" }, "z"))
|
||||
end)
|
||||
|
||||
it("returns nil for nil table", function()
|
||||
assert.is_nil(M.table_find(nil, "x"))
|
||||
end)
|
||||
|
||||
it("returns nil when searching for nil", function()
|
||||
assert.is_nil(M.table_find({ "a", "b" }, nil))
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
describe("table_add", function()
|
||||
it("appends all values from t2 onto t1", function()
|
||||
local t1 = { "a", "b" }
|
||||
local t2 = { "c", "d" }
|
||||
M.table_add(t1, t2)
|
||||
assert.same({ "a", "b", "c", "d" }, t1)
|
||||
end)
|
||||
|
||||
it("leaves t1 unchanged when t2 is empty", function()
|
||||
local t1 = { "a" }
|
||||
M.table_add(t1, {})
|
||||
assert.same({ "a" }, t1)
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
describe("table_compare", function()
|
||||
it("reports removed keys", function()
|
||||
local removed, added, modified = M.table_compare({ a = 1, b = 2 }, { a = 1 })
|
||||
assert.equal(1, #removed)
|
||||
assert.equal("b", removed[1])
|
||||
assert.equal(0, #added)
|
||||
assert.equal(0, #modified)
|
||||
end)
|
||||
|
||||
it("reports added keys", function()
|
||||
local removed, added, modified = M.table_compare({ a = 1 }, { a = 1, b = 2 })
|
||||
assert.equal(0, #removed)
|
||||
assert.equal(1, #added)
|
||||
assert.equal("b", added[1])
|
||||
assert.equal(0, #modified)
|
||||
end)
|
||||
|
||||
it("reports modified values", function()
|
||||
local removed, added, modified = M.table_compare({ a = 1 }, { a = 99 })
|
||||
assert.equal(0, #removed)
|
||||
assert.equal(0, #added)
|
||||
assert.equal(1, #modified)
|
||||
assert.equal("a", modified[1])
|
||||
end)
|
||||
|
||||
it("returns three empty lists for identical tables", function()
|
||||
local removed, added, modified = M.table_compare({ x = 1 }, { x = 1 })
|
||||
assert.equal(0, #removed)
|
||||
assert.equal(0, #added)
|
||||
assert.equal(0, #modified)
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
describe("table_equals", function()
|
||||
it("returns true for two identical tables", function()
|
||||
assert.is_true(M.table_equals({ a = 1, b = 2 }, { a = 1, b = 2 }))
|
||||
end)
|
||||
|
||||
it("returns false when values differ", function()
|
||||
assert.is_false(M.table_equals({ a = 1 }, { a = 2 }))
|
||||
end)
|
||||
|
||||
it("returns false when keys differ", function()
|
||||
assert.is_false(M.table_equals({ a = 1 }, { b = 1 }))
|
||||
end)
|
||||
|
||||
it("returns true when both arguments are nil", function()
|
||||
assert.is_true(M.table_equals(nil, nil))
|
||||
end)
|
||||
|
||||
it("returns false when only one argument is nil", function()
|
||||
assert.is_false(M.table_equals(nil, {}))
|
||||
assert.is_false(M.table_equals({}, nil))
|
||||
end)
|
||||
end)
|
||||
|
||||
end) -- table helpers
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("split_string", function()
|
||||
it("splits a simple comma-delimited string", function()
|
||||
local result = M.split_string("a,b,c", ",")
|
||||
assert.equal("a", result[1])
|
||||
assert.equal("b", result[2])
|
||||
assert.equal("c", result[3])
|
||||
end)
|
||||
|
||||
it("produces a single element when delimiter is absent", function()
|
||||
local result = M.split_string("hello", ",")
|
||||
assert.equal("hello", result[1])
|
||||
assert.is_nil(result[2])
|
||||
end)
|
||||
|
||||
it("produces empty strings at boundaries", function()
|
||||
local result = M.split_string(",a,", ",")
|
||||
assert.equal("", result[1])
|
||||
assert.equal("a", result[2])
|
||||
assert.equal("", result[3])
|
||||
end)
|
||||
|
||||
it("handles multi-character delimiter", function()
|
||||
local result = M.split_string("x::y::z", "::")
|
||||
assert.equal("x", result[1])
|
||||
assert.equal("y", result[2])
|
||||
assert.equal("z", result[3])
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("get_sip_jibri_email_prefix", function()
|
||||
it("returns nil for nil email", function()
|
||||
assert.is_nil(M.get_sip_jibri_email_prefix(nil))
|
||||
end)
|
||||
|
||||
it("returns nil for unrecognised email", function()
|
||||
assert.is_nil(M.get_sip_jibri_email_prefix("user@example.com"))
|
||||
end)
|
||||
|
||||
it("recognises outbound-sip-jibri@ prefix", function()
|
||||
assert.equal("outbound-sip-jibri@",
|
||||
M.get_sip_jibri_email_prefix("outbound-sip-jibri@example.com"))
|
||||
end)
|
||||
|
||||
it("recognises sipjibriouta@ prefix", function()
|
||||
assert.equal("sipjibriouta@",
|
||||
M.get_sip_jibri_email_prefix("sipjibriouta@example.com"))
|
||||
end)
|
||||
|
||||
it("recognises sipjibrioutb@ prefix", function()
|
||||
assert.equal("sipjibrioutb@",
|
||||
M.get_sip_jibri_email_prefix("sipjibrioutb@example.com"))
|
||||
end)
|
||||
|
||||
it("recognises inbound-sip-jibri@ prefix", function()
|
||||
assert.equal("inbound-sip-jibri@",
|
||||
M.get_sip_jibri_email_prefix("inbound-sip-jibri@example.com"))
|
||||
end)
|
||||
|
||||
it("recognises sipjibriina@ prefix", function()
|
||||
assert.equal("sipjibriina@",
|
||||
M.get_sip_jibri_email_prefix("sipjibriina@example.com"))
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("is_jibri", function()
|
||||
it("returns truthy for recorder@ prefixed JID string", function()
|
||||
assert.is_truthy(M.is_jibri("recorder@recorder.example.com"))
|
||||
end)
|
||||
|
||||
it("returns truthy for jibria@ prefixed JID string", function()
|
||||
assert.is_truthy(M.is_jibri("jibria@recorder.example.com"))
|
||||
end)
|
||||
|
||||
it("returns truthy for jibrib@ prefixed JID string", function()
|
||||
assert.is_truthy(M.is_jibri("jibrib@recorder.example.com"))
|
||||
end)
|
||||
|
||||
it("returns false for a non-recorder JID string", function()
|
||||
assert.is_false(M.is_jibri("user@example.com"))
|
||||
end)
|
||||
|
||||
it("accepts an occupant object and inspects its .jid field", function()
|
||||
local occupant = { jid = "recorder@recorder.example.com" }
|
||||
assert.is_truthy(M.is_jibri(occupant))
|
||||
end)
|
||||
|
||||
it("returns false for occupant object with non-recorder JID", function()
|
||||
local occupant = { jid = "user@example.com" }
|
||||
assert.is_false(M.is_jibri(occupant))
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("is_transcriber", function()
|
||||
it("returns truthy for transcriber@ JID", function()
|
||||
assert.is_truthy(M.is_transcriber("transcriber@recorder.example.com"))
|
||||
end)
|
||||
|
||||
it("returns truthy for transcribera@ JID", function()
|
||||
assert.is_truthy(M.is_transcriber("transcribera@recorder.example.com"))
|
||||
end)
|
||||
|
||||
it("returns truthy for transcriberb@ JID", function()
|
||||
assert.is_truthy(M.is_transcriber("transcriberb@recorder.example.com"))
|
||||
end)
|
||||
|
||||
it("returns false for a regular user JID", function()
|
||||
assert.is_false(M.is_transcriber("user@example.com"))
|
||||
end)
|
||||
|
||||
it("returns false for nil", function()
|
||||
assert.is_false(M.is_transcriber(nil))
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("is_vpaas", function()
|
||||
-- muc_domain = "conference.test.example.com" (derived from defaults)
|
||||
|
||||
it("returns false for nil room", function()
|
||||
assert.is_false(M.is_vpaas(nil))
|
||||
end)
|
||||
|
||||
it("returns true for a room whose tenant starts with vpaas-magic-cookie-", function()
|
||||
local room = { jid = "[vpaas-magic-cookie-xyz]myroom@" .. MUC_DOMAIN }
|
||||
assert.is_true(M.is_vpaas(room))
|
||||
end)
|
||||
|
||||
it("caches the result in room.is_vpaas", function()
|
||||
local room = { jid = "[vpaas-magic-cookie-xyz]myroom@" .. MUC_DOMAIN }
|
||||
M.is_vpaas(room)
|
||||
assert.is_true(room.is_vpaas)
|
||||
end)
|
||||
|
||||
it("returns false for a room on the wrong MUC domain", function()
|
||||
local room = { jid = "[vpaas-magic-cookie-xyz]myroom@other.domain.com" }
|
||||
assert.is_false(M.is_vpaas(room))
|
||||
end)
|
||||
|
||||
it("returns false for a room without a tenant bracket prefix", function()
|
||||
local room = { jid = "myroom@" .. MUC_DOMAIN }
|
||||
assert.is_false(M.is_vpaas(room))
|
||||
end)
|
||||
|
||||
it("returns false for a room whose tenant does not start with vpaas-magic-cookie-", function()
|
||||
local room = { jid = "[othertenant]myroom@" .. MUC_DOMAIN }
|
||||
assert.is_false(M.is_vpaas(room))
|
||||
end)
|
||||
|
||||
it("returns cached is_vpaas value when present", function()
|
||||
local room = { jid = "[vpaas-magic-cookie-xyz]myroom@" .. MUC_DOMAIN, is_vpaas = false }
|
||||
-- Cached false should be returned without re-evaluating the JID
|
||||
assert.is_false(M.is_vpaas(room))
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("is_moderated", function()
|
||||
-- moderated_subdomains = {"moderated-subdomain"} (configured in stub)
|
||||
-- moderated_rooms = {"moderatedroom"}
|
||||
|
||||
it("returns true and room/subdomain for a moderated subdomain", function()
|
||||
local result, room_name, subdomain =
|
||||
M.is_moderated("[moderated-subdomain]someroom@" .. MUC_DOMAIN)
|
||||
assert.is_true(result)
|
||||
assert.equal("someroom", room_name)
|
||||
assert.equal("moderated-subdomain", subdomain)
|
||||
end)
|
||||
|
||||
it("returns true and room name for a moderated room (no subdomain)", function()
|
||||
local result, room_name, subdomain =
|
||||
M.is_moderated("moderatedroom@" .. MUC_DOMAIN)
|
||||
assert.is_true(result)
|
||||
assert.equal("moderatedroom", room_name)
|
||||
assert.is_nil(subdomain)
|
||||
end)
|
||||
|
||||
it("returns false for an unmoderated room with a subdomain", function()
|
||||
assert.is_false(M.is_moderated("[other-subdomain]room@" .. MUC_DOMAIN))
|
||||
end)
|
||||
|
||||
it("returns false for an unmoderated room without a subdomain", function()
|
||||
assert.is_false(M.is_moderated("normalroom@" .. MUC_DOMAIN))
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("is_focus", function()
|
||||
|
||||
it("nick ending in /focus returns true", function()
|
||||
assert.is_true(M.is_focus("room@conference.example.com/focus"))
|
||||
end)
|
||||
|
||||
it("plain /focus string returns true", function()
|
||||
assert.is_true(M.is_focus("/focus"))
|
||||
end)
|
||||
|
||||
it("regular nick returns false", function()
|
||||
assert.is_false(M.is_focus("room@conference.example.com/user1"))
|
||||
end)
|
||||
|
||||
it("nick containing focus but not ending in /focus returns false", function()
|
||||
assert.is_false(M.is_focus("room@conference.example.com/focususer"))
|
||||
end)
|
||||
|
||||
it("bare word focus without slash returns false", function()
|
||||
assert.is_false(M.is_focus("focus"))
|
||||
end)
|
||||
|
||||
it("empty string returns false", function()
|
||||
assert.is_false(M.is_focus(""))
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
describe("is_focus_nick", function()
|
||||
|
||||
it("returns true for the bare string 'focus'", function()
|
||||
assert.is_true(M.is_focus_nick("focus"))
|
||||
end)
|
||||
|
||||
it("returns false for a regular nick", function()
|
||||
assert.is_false(M.is_focus_nick("user1"))
|
||||
end)
|
||||
|
||||
it("returns false for a nick that contains focus but is not exactly focus", function()
|
||||
assert.is_false(M.is_focus_nick("focususer"))
|
||||
end)
|
||||
|
||||
it("returns false for a full MUC JID (slash-qualified)", function()
|
||||
assert.is_false(M.is_focus_nick("room@conference.example.com/focus"))
|
||||
end)
|
||||
|
||||
it("returns false for empty string", function()
|
||||
assert.is_false(M.is_focus_nick(""))
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
describe("is_focus_jid", function()
|
||||
|
||||
it("returns true for focus@auth.example.com", function()
|
||||
assert.is_true(M.is_focus_jid("focus@auth.example.com"))
|
||||
end)
|
||||
|
||||
it("returns true for focus@auth.example.com/resource", function()
|
||||
assert.is_true(M.is_focus_jid("focus@auth.example.com/res123"))
|
||||
end)
|
||||
|
||||
it("returns false for a regular user JID", function()
|
||||
assert.is_false(M.is_focus_jid("user@auth.example.com"))
|
||||
end)
|
||||
|
||||
it("returns false for JID whose node contains but is not exactly focus", function()
|
||||
assert.is_false(M.is_focus_jid("focususer@auth.example.com"))
|
||||
end)
|
||||
|
||||
it("returns false for a host-only JID (no node)", function()
|
||||
assert.is_false(M.is_focus_jid("auth.example.com"))
|
||||
end)
|
||||
|
||||
it("returns false for nil", function()
|
||||
assert.is_false(M.is_focus_jid(nil))
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
describe("build_room_address", function()
|
||||
-- muc_domain_prefix = "conference" (set in module stub above)
|
||||
|
||||
it("builds room JID with no subdomain", function()
|
||||
assert.equal("myroom@conference.example.com",
|
||||
M.build_room_address("myroom", "example.com", nil))
|
||||
end)
|
||||
|
||||
it("prepends [subdomain] when subdomain is given", function()
|
||||
assert.equal("[tenant]myroom@conference.example.com",
|
||||
M.build_room_address("myroom", "example.com", "tenant"))
|
||||
end)
|
||||
|
||||
it("treats empty-string subdomain as no subdomain", function()
|
||||
assert.equal("myroom@conference.example.com",
|
||||
M.build_room_address("myroom", "example.com", ""))
|
||||
end)
|
||||
|
||||
it("uses the configured muc_domain_prefix (conference)", function()
|
||||
assert.equal("room@conference.meet.jit.si",
|
||||
M.build_room_address("room", "meet.jit.si", nil))
|
||||
end)
|
||||
|
||||
it("combines subdomain with multi-part base domain", function()
|
||||
assert.equal("[vpaas-magic-cookie-abc]r@conference.meet.jit.si",
|
||||
M.build_room_address("r", "meet.jit.si", "vpaas-magic-cookie-abc"))
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
end) -- util.lib
|
||||
1180
tests/prosody/mod_audio_translation_component_spec.js
Normal file
1180
tests/prosody/mod_audio_translation_component_spec.js
Normal file
File diff suppressed because it is too large
Load Diff
80
tests/prosody/mod_auth_jitsi_anonymous_spec.js
Normal file
80
tests/prosody/mod_auth_jitsi_anonymous_spec.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import { createXmppClient } from './helpers/xmpp_client.js';
|
||||
|
||||
describe('mod_auth_jitsi-anonymous', () => {
|
||||
|
||||
const clients = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(clients.map(c => c.disconnect()));
|
||||
clients.length = 0;
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates and connects an XMPP client.
|
||||
*
|
||||
* @param {object} opts - Options passed to createXmppClient.
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
async function connect(opts = {}) {
|
||||
const c = await createXmppClient({ domain: 'jitsi-anonymous.localhost',
|
||||
...opts });
|
||||
|
||||
clients.push(c);
|
||||
|
||||
return c;
|
||||
}
|
||||
|
||||
it('assigns a UUID-based JID on anonymous connect', async () => {
|
||||
const c = await connect();
|
||||
|
||||
assert.ok(c.jid, 'client must have a JID after connect');
|
||||
|
||||
const username = c.jid.split('@')[0];
|
||||
|
||||
// Prosody anonymous auth assigns a UUID (32 hex chars + 4 dashes = 36 chars).
|
||||
assert.match(username,
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
||||
`JID username must be a UUID, got: ${username}`);
|
||||
});
|
||||
|
||||
it('assigns a different JID to each client', async () => {
|
||||
const c1 = await connect();
|
||||
const c2 = await connect();
|
||||
|
||||
assert.notEqual(c1.jid.split('@')[0], c2.jid.split('@')[0],
|
||||
'each anonymous client must get a unique username');
|
||||
});
|
||||
|
||||
it('resumes session with same username when previd matches SM token', async () => {
|
||||
const c1 = await connect();
|
||||
const username = c1.jid.split('@')[0];
|
||||
|
||||
// Capture the SM resumption token before disconnecting — it is cleared on offline.
|
||||
const smId = c1.smId;
|
||||
|
||||
assert.ok(smId, 'stream management id must be set after connect');
|
||||
|
||||
// Drop the connection abruptly (no stream close) so Prosody puts the
|
||||
// session into smacks hibernation — it stays in full_sessions where
|
||||
// mod_auth_jitsi-anonymous can find it by resumption_token.
|
||||
c1.dropConnection();
|
||||
|
||||
// Reconnect with previd — mod_auth_jitsi-anonymous should reuse the username.
|
||||
const c2 = await connect({ params: { previd: smId } });
|
||||
|
||||
assert.equal(c2.jid.split('@')[0], username,
|
||||
'reconnecting with previd must yield the same username');
|
||||
});
|
||||
|
||||
it('assigns a new username when previd does not match any session', async () => {
|
||||
const c = await connect({ params: { previd: 'nonexistent-token' } });
|
||||
|
||||
const username = c.jid.split('@')[0];
|
||||
|
||||
assert.match(username,
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
||||
'unmatched previd must fall back to a fresh UUID username');
|
||||
});
|
||||
});
|
||||
107
tests/prosody/mod_auth_jitsi_shared_secret_spec.js
Normal file
107
tests/prosody/mod_auth_jitsi_shared_secret_spec.js
Normal file
@@ -0,0 +1,107 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import { getContainer } from './helpers/container.js';
|
||||
import { prosodyShell } from './helpers/prosody_shell.js';
|
||||
import { createXmppClient } from './helpers/xmpp_client.js';
|
||||
|
||||
const DOMAIN = 'shared-secret.localhost';
|
||||
const PROSODY_CFG = '/etc/prosody/cfg/virtualhosts.cfg.lua';
|
||||
|
||||
const SECRET = 'topsecret';
|
||||
const PREV_SECRET = 'oldsecret';
|
||||
|
||||
/**
|
||||
* Enable or disable shared_secret_prev in the live Prosody config by
|
||||
* commenting/uncommenting the line, reloading the config, and reloading the
|
||||
* auth module so it re-reads the options.
|
||||
*
|
||||
* @param {boolean} enabled
|
||||
*/
|
||||
async function setSharedSecretPrev(enabled) { // eslint-disable-line no-unused-vars
|
||||
const container = getContainer();
|
||||
const from = enabled
|
||||
? `-- shared_secret_prev = "${PREV_SECRET}"`
|
||||
: `shared_secret_prev = "${PREV_SECRET}"`;
|
||||
const to = enabled
|
||||
? `shared_secret_prev = "${PREV_SECRET}"`
|
||||
: `-- shared_secret_prev = "${PREV_SECRET}"`;
|
||||
|
||||
await container.exec([ 'sed', '-i', `s/${from}/${to}/`, PROSODY_CFG ]);
|
||||
await container.exec([ 'prosodyctl', 'reload' ]);
|
||||
await prosodyShell(`module:reload("auth_jitsi-shared-secret", "${DOMAIN}")`);
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to connect with the given credentials.
|
||||
* Returns the connected client on success, or throws on auth failure.
|
||||
*
|
||||
* @param {string} username
|
||||
* @param {string} password
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
function connect(username, password) {
|
||||
return createXmppClient({ domain: DOMAIN,
|
||||
username,
|
||||
password });
|
||||
}
|
||||
|
||||
describe('mod_auth_jitsi-shared-secret', () => {
|
||||
|
||||
const clients = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(clients.map(c => c.disconnect()));
|
||||
clients.length = 0;
|
||||
});
|
||||
|
||||
it('allows connection with correct shared secret', async () => {
|
||||
const c = await connect('alice', SECRET);
|
||||
|
||||
clients.push(c);
|
||||
assert.ok(c.jid, 'client must have a JID after successful auth');
|
||||
});
|
||||
|
||||
it('allows any username with the correct secret', async () => {
|
||||
const c1 = await connect('alice', SECRET);
|
||||
const c2 = await connect('bob', SECRET);
|
||||
const c3 = await connect('anyusernamehere', SECRET);
|
||||
|
||||
clients.push(c1, c2, c3);
|
||||
assert.ok(c1.jid, 'alice must be allowed');
|
||||
assert.ok(c2.jid, 'bob must be allowed');
|
||||
assert.ok(c3.jid, 'arbitrary username must be allowed');
|
||||
});
|
||||
|
||||
it('rejects connection with wrong secret', async () => {
|
||||
await assert.rejects(
|
||||
() => connect('alice', 'wrongsecret'),
|
||||
'connection with wrong secret must be rejected'
|
||||
);
|
||||
});
|
||||
|
||||
// TODO: shared_secret_prev is broken for PLAIN SASL. get_sasl_handler() always
|
||||
// returns shared_secret from its plain callback, so Prosody rejects any password
|
||||
// other than the current secret. provider.test_password() handles shared_secret_prev
|
||||
// correctly but is not called in the PLAIN SASL flow.
|
||||
// it('allows connection with shared_secret_prev', async () => {
|
||||
// const c = await connect('alice', PREV_SECRET);
|
||||
// clients.push(c);
|
||||
// assert.ok(c.jid, 'client must be allowed with prev secret');
|
||||
// });
|
||||
|
||||
// it('rejects shared_secret_prev after it is removed from config', async () => {
|
||||
// await setSharedSecretPrev(false);
|
||||
// try {
|
||||
// await assert.rejects(
|
||||
// () => connect('alice', PREV_SECRET),
|
||||
// 'prev secret must be rejected after removal from config'
|
||||
// );
|
||||
// const c = await connect('alice', SECRET);
|
||||
// clients.push(c);
|
||||
// assert.ok(c.jid, 'current secret must still be accepted');
|
||||
// } finally {
|
||||
// await setSharedSecretPrev(true);
|
||||
// }
|
||||
// });
|
||||
});
|
||||
423
tests/prosody/mod_auth_token_asap_spec.js
Normal file
423
tests/prosody/mod_auth_token_asap_spec.js
Normal file
@@ -0,0 +1,423 @@
|
||||
import assert from 'assert';
|
||||
import http from 'http';
|
||||
|
||||
import { mintAsapToken } from './helpers/jwt.js';
|
||||
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
|
||||
|
||||
/**
|
||||
* Fetches session-info for the given full JID.
|
||||
*
|
||||
* @param {string} jid
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
function getSessionInfo(jid) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = `http://localhost:5280/test-observer/session-info?jid=${encodeURIComponent(jid)}`;
|
||||
|
||||
http.get(url, res => {
|
||||
let body = '';
|
||||
|
||||
res.on('data', chunk => {
|
||||
body += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`session-info returned ${res.statusCode}: ${body}`));
|
||||
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(body));
|
||||
} catch (e) {
|
||||
reject(new Error(`session-info bad JSON: ${body}`));
|
||||
}
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to the main VirtualHost (RS256 / ASAP key-server auth).
|
||||
* Tokens must be signed with the test RSA private key; Prosody fetches the
|
||||
* matching public key from mod_test_observer_http's /asap-keys/ route.
|
||||
*/
|
||||
function asapClient(params) {
|
||||
return createXmppClient({ params });
|
||||
}
|
||||
|
||||
describe('mod_auth_token (ASAP / RS256)', () => {
|
||||
|
||||
const clients = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(clients.map(c => c.disconnect()));
|
||||
clients.length = 0;
|
||||
});
|
||||
|
||||
it('connects successfully with a valid RS256 token', async () => {
|
||||
const token = mintAsapToken({ room: '*' });
|
||||
const c = await asapClient({ token });
|
||||
|
||||
clients.push(c);
|
||||
assert.ok(c.jid, 'client should have a JID after connecting');
|
||||
});
|
||||
|
||||
it('rejects connection with wrong private key', async () => {
|
||||
// Sign with a freshly generated key that the server does not know.
|
||||
const { generateKeyPairSync } = await import('crypto');
|
||||
const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
const wrongPem = privateKey.export({ type: 'pkcs8',
|
||||
format: 'pem' });
|
||||
const token = mintAsapToken({}, { privateKey: wrongPem });
|
||||
|
||||
await assert.rejects(
|
||||
() => asapClient({ token }),
|
||||
/not-allowed/
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects connection with expired token', async () => {
|
||||
const token = mintAsapToken({}, { expired: true });
|
||||
|
||||
await assert.rejects(
|
||||
() => asapClient({ token }),
|
||||
/not-allowed/
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects connection with not-yet-valid token (nbf in the future)', async () => {
|
||||
const token = mintAsapToken({}, { notYetValid: true });
|
||||
|
||||
await assert.rejects(
|
||||
() => asapClient({ token }),
|
||||
/not-allowed/
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects connection with wrong issuer', async () => {
|
||||
const token = mintAsapToken({ iss: 'other-app' });
|
||||
|
||||
await assert.rejects(
|
||||
() => asapClient({ token }),
|
||||
/not-allowed/
|
||||
);
|
||||
});
|
||||
|
||||
it('sets session.jitsi_meet_context_features from token context', async () => {
|
||||
const token = mintAsapToken({
|
||||
room: '*',
|
||||
context: {
|
||||
features: {
|
||||
'screen-sharing': true,
|
||||
'recording': false
|
||||
}
|
||||
}
|
||||
});
|
||||
const c = await asapClient({ token });
|
||||
|
||||
clients.push(c);
|
||||
const info = await getSessionInfo(c.jid);
|
||||
|
||||
assert.strictEqual(info.jitsi_meet_context_features['screen-sharing'], true);
|
||||
assert.strictEqual(info.jitsi_meet_context_features.recording, false);
|
||||
});
|
||||
|
||||
it('sets session.jitsi_meet_room from room claim', async () => {
|
||||
const token = mintAsapToken({ room: 'testroom' });
|
||||
const c = await asapClient({ token });
|
||||
|
||||
clients.push(c);
|
||||
const info = await getSessionInfo(c.jid);
|
||||
|
||||
assert.strictEqual(info.jitsi_meet_room, 'testroom');
|
||||
});
|
||||
|
||||
it('sets session.jitsi_meet_context_user from token context', async () => {
|
||||
const token = mintAsapToken({
|
||||
room: '*',
|
||||
context: {
|
||||
user: { id: 'user-123',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com' }
|
||||
}
|
||||
});
|
||||
const c = await asapClient({ token });
|
||||
|
||||
clients.push(c);
|
||||
const info = await getSessionInfo(c.jid);
|
||||
|
||||
assert.strictEqual(info.jitsi_meet_context_user.id, 'user-123');
|
||||
assert.strictEqual(info.jitsi_meet_context_user.name, 'Alice');
|
||||
assert.strictEqual(info.jitsi_meet_context_user.email, 'alice@example.com');
|
||||
});
|
||||
|
||||
it('sets session.jitsi_meet_context_group from token context', async () => {
|
||||
const token = mintAsapToken({
|
||||
room: '*',
|
||||
context: { group: 'test-group' }
|
||||
});
|
||||
const c = await asapClient({ token });
|
||||
|
||||
clients.push(c);
|
||||
const info = await getSessionInfo(c.jid);
|
||||
|
||||
assert.strictEqual(info.jitsi_meet_context_group, 'test-group');
|
||||
});
|
||||
|
||||
it('sets session.jitsi_meet_context_user.id from top-level user_id when context is absent', async () => {
|
||||
const token = mintAsapToken({ room: '*',
|
||||
// eslint-disable-next-line camelcase
|
||||
user_id: 'legacy-user-456' });
|
||||
const c = await asapClient({ token });
|
||||
|
||||
clients.push(c);
|
||||
const info = await getSessionInfo(c.jid);
|
||||
|
||||
assert.strictEqual(info.jitsi_meet_context_user.id, 'legacy-user-456');
|
||||
});
|
||||
|
||||
it('allows connection without token when allow_empty_token is true', async () => {
|
||||
const c = await asapClient({});
|
||||
|
||||
clients.push(c);
|
||||
assert.ok(c.jid, 'client should have a JID');
|
||||
});
|
||||
|
||||
// ── Room regex claim: invalid Lua pattern ─────────────────────────────────
|
||||
//
|
||||
// When context.room.regex == true, util.lib.lua uses the JWT room claim as
|
||||
// a Lua pattern via string:match() inside verify_room() at MUC join time.
|
||||
// An invalid Lua pattern (e.g. unbalanced parenthesis) must be rejected
|
||||
// cleanly rather than crashing Prosody with a Lua error.
|
||||
|
||||
it('does not crash on connect with invalid Lua regex room claim', async () => {
|
||||
// process_and_verify_token only stores the claim; it does not evaluate
|
||||
// the pattern. The crash path is verify_room(), called at MUC join.
|
||||
const token = mintAsapToken({
|
||||
room: '(invalid',
|
||||
context: { room: { regex: true } }
|
||||
});
|
||||
const c = await asapClient({ token });
|
||||
|
||||
clients.push(c);
|
||||
assert.ok(c.jid, 'connection must succeed: pattern not evaluated at auth time');
|
||||
});
|
||||
|
||||
it('rejects MUC join with invalid Lua regex room claim', async () => {
|
||||
const CONFERENCE = 'conference.localhost';
|
||||
const roomJid = `regex-invalid-${Date.now()}@${CONFERENCE}`;
|
||||
|
||||
// '(invalid' is an unbalanced Lua pattern capture — string:match() would
|
||||
// raise "unfinished capture" without the pcall guard in verify_room().
|
||||
const token = mintAsapToken({
|
||||
room: '(invalid',
|
||||
context: { room: { regex: true } }
|
||||
});
|
||||
|
||||
const focus = await joinWithFocus(roomJid);
|
||||
const c = await asapClient({ token });
|
||||
|
||||
clients.push(focus, c);
|
||||
|
||||
const presence = await c.joinRoom(roomJid);
|
||||
|
||||
assert.equal(presence.attrs.type, 'error', 'join must be rejected');
|
||||
assert.ok(
|
||||
presence.getChild('error')?.getChild('invalid-regex'),
|
||||
'error must contain <invalid-regex/>'
|
||||
);
|
||||
});
|
||||
|
||||
// ── Room regex claim: pattern is used as written ──────────────────────────
|
||||
//
|
||||
// Lua pattern classes are case-sensitive: %d is a digit, %D is anything
|
||||
// but a digit. verify_room() must therefore not change the case of a room
|
||||
// claim that is flagged as a pattern.
|
||||
|
||||
// Letters only, so that the room name matches %D+ ("not a digit").
|
||||
const lettersOnly = () => Date.now()
|
||||
.toString(36)
|
||||
.replace(/[0-9]/g, 'x');
|
||||
|
||||
it('accepts MUC join when the regex room claim uses an uppercase class', async () => {
|
||||
const CONFERENCE = 'conference.localhost';
|
||||
const roomJid = `rxcase${lettersOnly()}@${CONFERENCE}`;
|
||||
|
||||
const token = mintAsapToken({
|
||||
room: 'rxcase%D+',
|
||||
context: { room: { regex: true } }
|
||||
});
|
||||
|
||||
const focus = await joinWithFocus(roomJid);
|
||||
const c = await asapClient({ token });
|
||||
|
||||
clients.push(focus, c);
|
||||
|
||||
const presence = await c.joinRoom(roomJid);
|
||||
|
||||
assert.notEqual(presence.attrs.type, 'error',
|
||||
`join must succeed: ${presence.toString()}`);
|
||||
});
|
||||
|
||||
it('rejects MUC join when the room does not match the regex room claim', async () => {
|
||||
const CONFERENCE = 'conference.localhost';
|
||||
const roomJid = `rxcase${Date.now()}@${CONFERENCE}`;
|
||||
|
||||
// 'rxcase%D+' does not match a room name that ends in digits.
|
||||
const token = mintAsapToken({
|
||||
room: 'rxcase%D+',
|
||||
context: { room: { regex: true } }
|
||||
});
|
||||
|
||||
const focus = await joinWithFocus(roomJid);
|
||||
const c = await asapClient({ token });
|
||||
|
||||
clients.push(focus, c);
|
||||
|
||||
const presence = await c.joinRoom(roomJid);
|
||||
|
||||
assert.equal(presence.attrs.type, 'error', 'join must be rejected');
|
||||
assert.ok(
|
||||
presence.getChild('error')?.getChild('room-name-does-not-match'),
|
||||
'error must contain <room-name-does-not-match/>'
|
||||
);
|
||||
});
|
||||
|
||||
// ── Room regex claim: matching limits ─────────────────────────────────────
|
||||
//
|
||||
// Lua patterns backtrack, and one match blocks Prosody. verify_room()
|
||||
// therefore refuses to match a room name that is longer than
|
||||
// token_regex_room_max_length (128), or a pattern that has more than
|
||||
// token_regex_max_quantifiers (3) quantifiers. Both refusals report
|
||||
// <invalid-regex/>, which distinguishes them from a pattern that was
|
||||
// evaluated and did not match (<room-name-does-not-match/>).
|
||||
|
||||
it('accepts MUC join when the regex room claim is at the quantifier limit', async () => {
|
||||
const CONFERENCE = 'conference.localhost';
|
||||
const roomJid = `rxlimit${lettersOnly()}@${CONFERENCE}`;
|
||||
|
||||
// Three quantifiers is the default limit, and must still be allowed.
|
||||
const token = mintAsapToken({
|
||||
room: 'rxlimit%D*%D*%D+',
|
||||
context: { room: { regex: true } }
|
||||
});
|
||||
|
||||
const focus = await joinWithFocus(roomJid);
|
||||
const c = await asapClient({ token });
|
||||
|
||||
clients.push(focus, c);
|
||||
|
||||
const presence = await c.joinRoom(roomJid);
|
||||
|
||||
assert.notEqual(presence.attrs.type, 'error',
|
||||
`join must succeed: ${presence.toString()}`);
|
||||
});
|
||||
|
||||
it('rejects MUC join when the regex room claim has too many quantifiers', async () => {
|
||||
const CONFERENCE = 'conference.localhost';
|
||||
|
||||
// Under the 128-char room-name limit, so the quantifier limit is what
|
||||
// must reject this join.
|
||||
const roomJid = `${'a'.repeat(90)}${lettersOnly()}@${CONFERENCE}`;
|
||||
|
||||
// The pathological case: four quantifiers over a long run of 'a's, and
|
||||
// a trailing '%d' that a letters-only room name can never satisfy, so
|
||||
// every combination is tried before the match fails.
|
||||
const token = mintAsapToken({
|
||||
room: 'a*a*a*a*%d',
|
||||
context: { room: { regex: true } }
|
||||
});
|
||||
|
||||
const focus = await joinWithFocus(roomJid);
|
||||
const c = await asapClient({ token });
|
||||
|
||||
clients.push(focus, c);
|
||||
|
||||
const presence = await c.joinRoom(roomJid);
|
||||
|
||||
assert.equal(presence.attrs.type, 'error', 'join must be rejected');
|
||||
assert.ok(
|
||||
presence.getChild('error')?.getChild('invalid-regex'),
|
||||
`pattern must be refused unevaluated: ${presence.toString()}`
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects MUC join when the room name is too long to match a regex claim', async () => {
|
||||
const CONFERENCE = 'conference.localhost';
|
||||
|
||||
// Over the 128-char room-name limit.
|
||||
const roomJid = `${'x'.repeat(150)}${lettersOnly()}@${CONFERENCE}`;
|
||||
|
||||
// '.*' matches any room name, so without the length limit this join
|
||||
// would be allowed.
|
||||
const token = mintAsapToken({
|
||||
room: '.*',
|
||||
context: { room: { regex: true } }
|
||||
});
|
||||
|
||||
const focus = await joinWithFocus(roomJid);
|
||||
const c = await asapClient({ token });
|
||||
|
||||
clients.push(focus, c);
|
||||
|
||||
const presence = await c.joinRoom(roomJid);
|
||||
|
||||
assert.equal(presence.attrs.type, 'error', 'join must be rejected');
|
||||
assert.ok(
|
||||
presence.getChild('error')?.getChild('invalid-regex'),
|
||||
`long room name must not be matched: ${presence.toString()}`
|
||||
);
|
||||
});
|
||||
|
||||
// ── Room regex claim: token_regex_enabled = false ─────────────────────────
|
||||
//
|
||||
// conference.noregex.test has a parent host that sets
|
||||
// token_regex_enabled = false, so a room claim flagged as a pattern is
|
||||
// refused instead of matched. The same claim is accepted on
|
||||
// conference.localhost, where the option keeps its default.
|
||||
|
||||
it('rejects MUC join with a regex room claim when patterns are disabled', async () => {
|
||||
const roomName = `rxoff${lettersOnly()}`;
|
||||
const roomJid = `${roomName}@conference.noregex.test`;
|
||||
|
||||
// Matches the room name, so only the disabled option can reject it.
|
||||
const token = mintAsapToken({
|
||||
room: 'rxoff%D+',
|
||||
context: { room: { regex: true } }
|
||||
});
|
||||
|
||||
const focus = await joinWithFocus(roomJid);
|
||||
const c = await createXmppClient({ domain: 'noregex.test',
|
||||
params: { token } });
|
||||
|
||||
clients.push(focus, c);
|
||||
|
||||
const presence = await c.joinRoom(roomJid);
|
||||
|
||||
assert.equal(presence.attrs.type, 'error', 'join must be rejected');
|
||||
assert.ok(
|
||||
presence.getChild('error')?.getChild('invalid-regex'),
|
||||
`pattern must be refused: ${presence.toString()}`
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts the same regex room claim on a host where patterns are enabled', async () => {
|
||||
const roomName = `rxoff${lettersOnly()}`;
|
||||
const roomJid = `${roomName}@conference.localhost`;
|
||||
|
||||
const token = mintAsapToken({
|
||||
room: 'rxoff%D+',
|
||||
context: { room: { regex: true } }
|
||||
});
|
||||
|
||||
const focus = await joinWithFocus(roomJid);
|
||||
const c = await asapClient({ token });
|
||||
|
||||
clients.push(focus, c);
|
||||
|
||||
const presence = await c.joinRoom(roomJid);
|
||||
|
||||
assert.notEqual(presence.attrs.type, 'error',
|
||||
`join must succeed: ${presence.toString()}`);
|
||||
});
|
||||
});
|
||||
448
tests/prosody/mod_auth_token_spec.js
Normal file
448
tests/prosody/mod_auth_token_spec.js
Normal file
@@ -0,0 +1,448 @@
|
||||
import assert from 'assert';
|
||||
import http from 'http';
|
||||
|
||||
import { mintAsapToken, mintToken } from './helpers/jwt.js';
|
||||
import { prosodyShell } from './helpers/prosody_shell.js';
|
||||
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
|
||||
|
||||
/**
|
||||
* GETs a test-observer endpoint and parses the JSON response.
|
||||
*
|
||||
* @param {string} route e.g. 'session-info'
|
||||
* @param {string} jid full JID to look up
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
function getObserverJson(route, jid) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = `http://localhost:5280/test-observer/${route}?jid=${encodeURIComponent(jid)}`;
|
||||
|
||||
http.get(url, res => {
|
||||
let body = '';
|
||||
|
||||
res.on('data', chunk => {
|
||||
body += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`${route} returned ${res.statusCode}: ${body}`));
|
||||
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(body));
|
||||
} catch (e) {
|
||||
reject(new Error(`${route} bad JSON: ${body}`));
|
||||
}
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the session fields snapshotted at resource-bind time.
|
||||
*
|
||||
* @param {string} jid
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
function getSessionInfo(jid) {
|
||||
return getObserverJson('session-info', jid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the CURRENT JWT-derived fields of a live session. Needed after a
|
||||
* XEP-0198 resume, which refreshes the claims without binding a new resource
|
||||
* (so the /session-info snapshot is not updated).
|
||||
*
|
||||
* @param {string} jid
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
function getSessionLive(jid) {
|
||||
return getObserverJson('session-live', jid);
|
||||
}
|
||||
|
||||
/** Connects to the HS256 VirtualHost. */
|
||||
function hs256Client(params) {
|
||||
return createXmppClient({ domain: 'hs256.localhost',
|
||||
params });
|
||||
}
|
||||
|
||||
describe('mod_auth_token (HS256 shared secret)', () => {
|
||||
|
||||
const clients = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(clients.map(c => c.disconnect()));
|
||||
clients.length = 0;
|
||||
});
|
||||
|
||||
it('connects successfully with a valid token', async () => {
|
||||
const token = mintToken({ room: '*' });
|
||||
const c = await hs256Client({ token });
|
||||
|
||||
clients.push(c);
|
||||
assert.ok(c.jid, 'client should have a JID after connecting');
|
||||
});
|
||||
|
||||
it('rejects connection with wrong secret', async () => {
|
||||
const token = mintToken({}, { secret: 'wrongsecret' });
|
||||
|
||||
await assert.rejects(
|
||||
() => hs256Client({ token }),
|
||||
/not-allowed/
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects connection with expired token', async () => {
|
||||
const token = mintToken({}, { expired: true });
|
||||
|
||||
await assert.rejects(
|
||||
() => hs256Client({ token }),
|
||||
/not-allowed/
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects connection with not-yet-valid token (nbf in the future)', async () => {
|
||||
const token = mintToken({}, { notYetValid: true });
|
||||
|
||||
await assert.rejects(
|
||||
() => hs256Client({ token }),
|
||||
/not-allowed/
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects connection with wrong issuer', async () => {
|
||||
const token = mintToken({ iss: 'other-app' });
|
||||
|
||||
await assert.rejects(
|
||||
() => hs256Client({ token }),
|
||||
/not-allowed/
|
||||
);
|
||||
});
|
||||
|
||||
it('sets session.jitsi_meet_context_features from token context', async () => {
|
||||
const token = mintToken({
|
||||
room: '*',
|
||||
context: {
|
||||
features: {
|
||||
'screen-sharing': true,
|
||||
'recording': false
|
||||
}
|
||||
}
|
||||
});
|
||||
const c = await hs256Client({ token });
|
||||
|
||||
clients.push(c);
|
||||
const info = await getSessionInfo(c.jid);
|
||||
|
||||
assert.strictEqual(info.jitsi_meet_context_features['screen-sharing'], true);
|
||||
assert.strictEqual(info.jitsi_meet_context_features.recording, false);
|
||||
});
|
||||
|
||||
it('sets session.jitsi_meet_room from room claim', async () => {
|
||||
const token = mintToken({ room: 'testroom' });
|
||||
const c = await hs256Client({ token });
|
||||
|
||||
clients.push(c);
|
||||
const info = await getSessionInfo(c.jid);
|
||||
|
||||
assert.strictEqual(info.jitsi_meet_room, 'testroom');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* XEP-0198 stream resumption re-runs SASL on the new connection, so a client
|
||||
* may present a different JWT than the one it joined with. mod_auth_token
|
||||
* refreshes the hibernating session's claims from that token — that is how a
|
||||
* client rotates an expiring token across a reconnect — and the claims it
|
||||
* adopts have to stay scoped to the conference the session is an occupant of,
|
||||
* the same way the room claim is scoped at join time.
|
||||
*
|
||||
* The room claim of the refreshed token is therefore re-checked against the
|
||||
* rooms the session already occupies (mod_token_verification answers the
|
||||
* 'jitsi-verify-session-rooms' event), and the claims verified on join are kept
|
||||
* when it does not cover them. The file-sharing component is used here as the
|
||||
* observable effect of context.features on a live session.
|
||||
*/
|
||||
describe('mod_auth_token (claims on XEP-0198 resumption)', () => {
|
||||
|
||||
const CONFERENCE = 'conference.localhost';
|
||||
const BREAKOUT_MUC = 'breakout.conference.localhost';
|
||||
const FILESHARING_COMPONENT = 'filesharing.localhost';
|
||||
const JITMEET_NS = 'http://jitsi.org/jitmeet';
|
||||
|
||||
const clients = [];
|
||||
let roomCounter = 0;
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(clients.map(c => c.disconnect()));
|
||||
clients.length = 0;
|
||||
});
|
||||
|
||||
/** Connects a client carrying ?room= and ?token= on the token VirtualHost. */
|
||||
function tokenClient(roomName, token) {
|
||||
return createXmppClient({ params: { room: roomName,
|
||||
token } });
|
||||
}
|
||||
|
||||
/** Mints an RS256 login token scoped to `room`, optionally with features. */
|
||||
function tokenFor(room, features) {
|
||||
return mintAsapToken({
|
||||
room,
|
||||
context: {
|
||||
user: { id: 'user-a',
|
||||
name: 'user-a' },
|
||||
...features ? { features } : {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a file-sharing add with a caller-chosen stanza id, so the reply can
|
||||
* be matched by id. After a resume the server flushes the unacked stanzas
|
||||
* queued during hibernation, so an earlier error can be redelivered.
|
||||
*/
|
||||
function addFile(client, id, fileId) {
|
||||
return client.sendFileSharingRaw(
|
||||
FILESHARING_COMPONENT,
|
||||
{ id },
|
||||
{ type: 'add' },
|
||||
JSON.stringify({ fileId,
|
||||
name: 'shared.txt',
|
||||
size: 0,
|
||||
type: 'text/plain' })
|
||||
);
|
||||
}
|
||||
|
||||
/** Waits for the reply to the stanza with the given id. */
|
||||
function waitForReply(client, id, timeout = 3000) {
|
||||
return client.waitForMessage(s => s.attrs.id === id, timeout);
|
||||
}
|
||||
|
||||
/** Asserts a reply is an auth/forbidden error. */
|
||||
function assertForbidden(stanza, message) {
|
||||
assert.equal(stanza.attrs.type, 'error', message);
|
||||
assert.ok(stanza.getChild('error')?.getChild('forbidden'),
|
||||
`${message} (expected <forbidden/>, got ${stanza.toString()})`);
|
||||
}
|
||||
|
||||
/** Resolves with the filesharing broadcast payload received by `client`. */
|
||||
async function waitForBroadcast(client, timeout = 3000) {
|
||||
const stanza = await client.waitForMessage(
|
||||
s => s.attrs.from === FILESHARING_COMPONENT && s.getChild('json-message', JITMEET_NS) !== undefined,
|
||||
timeout);
|
||||
|
||||
return JSON.parse(stanza.getChild('json-message', JITMEET_NS).getText());
|
||||
}
|
||||
|
||||
/** Asserts that no filesharing broadcast reaches `client` within the window. */
|
||||
async function assertNoBroadcast(client, timeout = 1000) {
|
||||
try {
|
||||
await waitForBroadcast(client, timeout);
|
||||
} catch (err) {
|
||||
if (err.message.includes('Timeout')) {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
throw new Error('unexpected file-sharing broadcast');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a meeting with a participant holding a token that grants no
|
||||
* features, plus a second occupant that receives the room's broadcasts.
|
||||
*/
|
||||
async function setupMeeting() {
|
||||
const roomName = `resume-${++roomCounter}`;
|
||||
const roomJid = `${roomName}@${CONFERENCE}`;
|
||||
const focus = await joinWithFocus(roomJid);
|
||||
const participant = await tokenClient(roomName, tokenFor(roomName));
|
||||
const other = await tokenClient(roomName, tokenFor(roomName));
|
||||
|
||||
clients.push(focus, participant, other);
|
||||
await participant.joinRoom(roomJid);
|
||||
await other.joinRoom(roomJid);
|
||||
|
||||
// Baseline: without a file-upload feature the operation is refused.
|
||||
addFile(participant, 'baseline-1', 'file-before-resume');
|
||||
assertForbidden(await waitForReply(participant, 'baseline-1'),
|
||||
'file-upload must be refused before the token swap');
|
||||
await assertNoBroadcast(other);
|
||||
|
||||
return { roomName,
|
||||
roomJid,
|
||||
participant,
|
||||
other };
|
||||
}
|
||||
|
||||
it('ignores features from a token issued for a different room', async () => {
|
||||
const { roomName, participant, other } = await setupMeeting();
|
||||
|
||||
// Same tenant, scoped to another room, and granting file-upload there.
|
||||
const otherRoomToken = tokenFor(`${roomName}-other`, { 'file-upload': true });
|
||||
|
||||
const reconnected = participant.waitForReconnect();
|
||||
|
||||
participant.dropConnection({ token: otherRoomToken });
|
||||
await reconnected;
|
||||
|
||||
const session = await getSessionLive(participant.jid);
|
||||
|
||||
assert.equal(session.jitsi_meet_room, roomName,
|
||||
'room claim of a token that does not cover the joined conference must not be adopted');
|
||||
assert.ok(!session.jitsi_meet_context_features?.['file-upload'],
|
||||
'features of a token that does not cover the joined conference must not be adopted');
|
||||
|
||||
addFile(participant, 'after-1', 'file-after-resume');
|
||||
assertForbidden(await waitForReply(participant, 'after-1'),
|
||||
'file-upload must still be refused after resuming with a token for another room');
|
||||
await assertNoBroadcast(other);
|
||||
});
|
||||
|
||||
it('adopts the claims of a rotated token issued for the same room', async () => {
|
||||
const { roomName, participant, other } = await setupMeeting();
|
||||
|
||||
// Same conference, new token: this is the token rotation the
|
||||
// c2s-session-updated handler exists for, and it must keep working.
|
||||
const rotatedToken = tokenFor(roomName, { 'file-upload': true });
|
||||
|
||||
const reconnected = participant.waitForReconnect();
|
||||
|
||||
participant.dropConnection({ token: rotatedToken });
|
||||
await reconnected;
|
||||
|
||||
const session = await getSessionLive(participant.jid);
|
||||
|
||||
assert.equal(session.jitsi_meet_context_features['file-upload'], true,
|
||||
'features of a token covering the joined conference must be adopted');
|
||||
|
||||
addFile(participant, 'after-2', 'file-after-resume');
|
||||
|
||||
const payload = await waitForBroadcast(other);
|
||||
|
||||
assert.equal(payload.event, 'add');
|
||||
assert.equal(payload.file.fileId, 'file-after-resume');
|
||||
});
|
||||
|
||||
/**
|
||||
* Sets up a meeting whose moderator has moved into a breakout room: it
|
||||
* joins the main room, registers a breakout room, joins it and then leaves
|
||||
* the main room, which is what the client does when switching rooms.
|
||||
*
|
||||
* The session is left occupying only the breakout room, while the
|
||||
* conference its claims have to cover is still the main room.
|
||||
*/
|
||||
async function setupBreakout() {
|
||||
const roomName = `resume-br-${++roomCounter}`;
|
||||
const mainJid = `${roomName}@${CONFERENCE}`;
|
||||
const focus = await joinWithFocus(mainJid);
|
||||
const moderator = await tokenClient(
|
||||
roomName,
|
||||
mintAsapToken({ room: roomName,
|
||||
context: { user: { moderator: true } } }));
|
||||
|
||||
clients.push(focus, moderator);
|
||||
await moderator.joinRoom(mainJid);
|
||||
|
||||
// Register a breakout room, then let focus create it on the breakout
|
||||
// component (restrict_room_creation allows only Prosody admins there).
|
||||
moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, 'features/breakout-rooms/add',
|
||||
{ subject: 'Breakout 1' });
|
||||
|
||||
const update = await moderator.waitForMessage(s2 => {
|
||||
const jm = s2.getChild('json-message', JITMEET_NS);
|
||||
|
||||
return jm !== undefined && JSON.parse(jm.getText()).type === 'breakout_rooms';
|
||||
}, 6000);
|
||||
const rooms = JSON.parse(update.getChild('json-message', JITMEET_NS).getText()).rooms;
|
||||
const breakoutJid = Object.values(rooms).find(r => !r.isMainRoom)?.jid;
|
||||
|
||||
assert.ok(breakoutJid, 'breakout room JID must be in the update broadcast');
|
||||
await focus.joinRoom(breakoutJid, 'focus');
|
||||
|
||||
const presence = await moderator.joinRoom(breakoutJid);
|
||||
|
||||
assert.notEqual(presence.attrs.type, 'error', 'moderator must be able to join the breakout room');
|
||||
|
||||
// Moving into a breakout room means leaving the main room.
|
||||
await moderator.leaveRoom(mainJid);
|
||||
|
||||
return { roomName,
|
||||
mainJid,
|
||||
breakoutJid,
|
||||
moderator };
|
||||
}
|
||||
|
||||
it('ignores a token for another room while the session sits in a breakout room', async () => {
|
||||
// A participant that moved into a breakout room is no longer an
|
||||
// occupant of the main room, but the conference it belongs to — and so
|
||||
// the room its token has to cover — is still the main room.
|
||||
const { roomName, moderator } = await setupBreakout();
|
||||
|
||||
const reconnected = moderator.waitForReconnect();
|
||||
|
||||
moderator.dropConnection({ token: tokenFor(`${roomName}-other`, { 'file-upload': true }) });
|
||||
await reconnected;
|
||||
|
||||
const session = await getSessionLive(moderator.jid);
|
||||
|
||||
assert.equal(session.jitsi_meet_room, roomName,
|
||||
'claims of a token that does not cover the main room must not be adopted in a breakout room');
|
||||
assert.ok(!session.jitsi_meet_context_features?.['file-upload'],
|
||||
'features of a token that does not cover the main room must not be adopted in a breakout room');
|
||||
});
|
||||
|
||||
it('adopts the claims of a rotated token for the main room while the session sits in a breakout room',
|
||||
async () => {
|
||||
// The counterpart of the check above: the room the claims are
|
||||
// verified against is the main room the session joined, not the
|
||||
// breakout room it currently occupies, so a token that does cover
|
||||
// that main room has to be adopted. Without this, a rotation that
|
||||
// named the breakout room instead would fail closed and token
|
||||
// rotation would silently stop working inside breakout rooms.
|
||||
const { roomName, moderator } = await setupBreakout();
|
||||
|
||||
const rotatedToken = mintAsapToken({
|
||||
room: roomName,
|
||||
context: {
|
||||
user: { moderator: true },
|
||||
features: { 'file-upload': true }
|
||||
}
|
||||
});
|
||||
|
||||
const reconnected = moderator.waitForReconnect();
|
||||
|
||||
moderator.dropConnection({ token: rotatedToken });
|
||||
await reconnected;
|
||||
|
||||
const session = await getSessionLive(moderator.jid);
|
||||
|
||||
assert.equal(session.jitsi_meet_room, roomName,
|
||||
'the room claim of a token covering the main room must be adopted in a breakout room');
|
||||
assert.equal(session.jitsi_meet_context_features['file-upload'], true,
|
||||
'features of a token covering the main room must be adopted in a breakout room');
|
||||
});
|
||||
|
||||
it('refreshes the claims unverified when no module answers the verification event', async () => {
|
||||
// mod_token_verification owns room verification: without it a join is
|
||||
// not room-checked either, so there is nothing to answer the
|
||||
// 'jitsi-verify-session-rooms' event with and the refreshed claims are
|
||||
// taken as they come.
|
||||
const { roomName, participant } = await setupMeeting();
|
||||
|
||||
await prosodyShell(`module:unload("token_verification", "${CONFERENCE}")`);
|
||||
|
||||
try {
|
||||
const reconnected = participant.waitForReconnect();
|
||||
|
||||
participant.dropConnection({ token: tokenFor(`${roomName}-other`, { 'file-upload': true }) });
|
||||
await reconnected;
|
||||
|
||||
const session = await getSessionLive(participant.jid);
|
||||
|
||||
assert.equal(session.jitsi_meet_room, `${roomName}-other`,
|
||||
'with no verifier loaded the refreshed claims are adopted as-is');
|
||||
} finally {
|
||||
await prosodyShell(`module:load("token_verification", "${CONFERENCE}")`);
|
||||
}
|
||||
});
|
||||
});
|
||||
85
tests/prosody/mod_conference_duration_spec.js
Normal file
85
tests/prosody/mod_conference_duration_spec.js
Normal file
@@ -0,0 +1,85 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import { createTestContext } from './helpers/test_context.js';
|
||||
import { setRoomMaxOccupants } from './helpers/test_observer.js';
|
||||
import { discoField } from './helpers/xmpp_utils.js';
|
||||
|
||||
const CONFERENCE = 'conference.localhost';
|
||||
|
||||
let _roomCounter = 0;
|
||||
const room = () => `conf-duration-${++_roomCounter}@${CONFERENCE}`;
|
||||
|
||||
describe('mod_conference_duration', () => {
|
||||
|
||||
let ctx;
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = createTestContext();
|
||||
});
|
||||
|
||||
afterEach(() => ctx.cleanup());
|
||||
|
||||
it('created_timestamp is absent before a second occupant joins', async () => {
|
||||
const r = room();
|
||||
const focus = await ctx.connectFocus(r);
|
||||
|
||||
// Only focus is in the room — timestamp should not be set yet.
|
||||
const iq = await focus.sendDiscoInfo(r);
|
||||
|
||||
assert.equal(iq.attrs.type, 'result');
|
||||
|
||||
const ts = discoField(iq, 'muc#roominfo_created_timestamp');
|
||||
|
||||
assert.ok(!ts, `expected empty/absent created_timestamp before second occupant, got: ${ts}`);
|
||||
});
|
||||
|
||||
it('created_timestamp is set once a second occupant joins', async () => {
|
||||
const r = room();
|
||||
const focus = await ctx.connectFocus(r);
|
||||
const c = await ctx.connect();
|
||||
|
||||
await c.joinRoom(r);
|
||||
|
||||
const iq = await focus.sendDiscoInfo(r);
|
||||
|
||||
assert.equal(iq.attrs.type, 'result');
|
||||
|
||||
const ts = discoField(iq, 'muc#roominfo_created_timestamp');
|
||||
|
||||
assert.ok(ts, 'created_timestamp must be present after second occupant joins');
|
||||
assert.match(ts, /^\d+$/, 'created_timestamp must be a numeric string (ms since epoch)');
|
||||
|
||||
const tsNum = parseInt(ts, 10);
|
||||
const now = Date.now();
|
||||
|
||||
assert.ok(tsNum > 0, 'timestamp must be positive');
|
||||
assert.ok(tsNum <= now, 'timestamp must not be in the future');
|
||||
assert.ok(tsNum > now - 30_000, 'timestamp must be recent (within last 30s)');
|
||||
});
|
||||
|
||||
it('created_timestamp does not change when further occupants join', async () => {
|
||||
const r = room();
|
||||
const focus = await ctx.connectFocus(r);
|
||||
const c1 = await ctx.connect();
|
||||
|
||||
await c1.joinRoom(r);
|
||||
|
||||
const iq1 = await focus.sendDiscoInfo(r);
|
||||
const ts1 = discoField(iq1, 'muc#roominfo_created_timestamp');
|
||||
|
||||
assert.ok(ts1, 'timestamp must be set after second occupant');
|
||||
|
||||
// Raise per-room limit so the third occupant is not blocked by muc_max_occupants = 2.
|
||||
await setRoomMaxOccupants(r, 5);
|
||||
|
||||
// Third occupant joins — timestamp must remain unchanged.
|
||||
const c2 = await ctx.connect();
|
||||
|
||||
await c2.joinRoom(r);
|
||||
|
||||
const iq2 = await focus.sendDiscoInfo(r);
|
||||
const ts2 = discoField(iq2, 'muc#roominfo_created_timestamp');
|
||||
|
||||
assert.equal(ts2, ts1, 'created_timestamp must not change when more occupants join');
|
||||
});
|
||||
});
|
||||
173
tests/prosody/mod_end_conference_spec.js
Normal file
173
tests/prosody/mod_end_conference_spec.js
Normal file
@@ -0,0 +1,173 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import { mintAsapToken } from './helpers/jwt.js';
|
||||
import { getRoomState } from './helpers/test_observer.js';
|
||||
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
|
||||
|
||||
const CONFERENCE = 'conference.localhost';
|
||||
const COMPONENT = 'endconference.localhost';
|
||||
|
||||
let _roomCounter = 0;
|
||||
const room = () => `end-conf-${++_roomCounter}@${CONFERENCE}`;
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Creates a room with:
|
||||
* - focus (joins first to satisfy the mod_muc_meeting_id jicofo lock)
|
||||
* - moderator: a client whose token carries context.user.moderator=true,
|
||||
* which mod_token_affiliation promotes to owner/moderator on join.
|
||||
* The ?room= URL param sets jitsi_web_query_room so that
|
||||
* mod_end_conference can locate the room.
|
||||
*
|
||||
* @returns {{ roomJid, roomName, focus, moderator }}
|
||||
*/
|
||||
async function createRoom() {
|
||||
const roomJid = room();
|
||||
const roomName = roomJid.split('@')[0];
|
||||
|
||||
const focus = await joinWithFocus(roomJid);
|
||||
|
||||
const token = mintAsapToken({ room: roomName,
|
||||
context: { user: { moderator: true } } });
|
||||
const moderator = await createXmppClient({ params: { room: roomName,
|
||||
token } });
|
||||
|
||||
await moderator.joinRoom(roomJid);
|
||||
|
||||
return { roomJid,
|
||||
roomName,
|
||||
focus,
|
||||
moderator };
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnects all provided clients.
|
||||
*
|
||||
* @param {...object} clients - Clients to disconnect.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function disconnectAll(...clients) {
|
||||
await Promise.all(clients.map(c => c.disconnect()));
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('mod_end_conference', () => {
|
||||
|
||||
// ── Successful destruction ───────────────────────────────────────────────
|
||||
|
||||
it('moderator can end the conference — room is destroyed', async () => {
|
||||
const { roomJid, focus, moderator } = await createRoom();
|
||||
|
||||
try {
|
||||
const before = await getRoomState(roomJid);
|
||||
|
||||
assert.ok(before, 'room must exist before end_conference');
|
||||
|
||||
await moderator.sendEndConference(COMPONENT);
|
||||
|
||||
// Room destruction triggers an unavailable presence to all occupants.
|
||||
await moderator.waitForPresence(p => p.attrs.type === 'unavailable');
|
||||
|
||||
const after = await getRoomState(roomJid);
|
||||
|
||||
assert.strictEqual(after, null, 'room must be gone after end_conference');
|
||||
} finally {
|
||||
await disconnectAll(focus, moderator);
|
||||
}
|
||||
});
|
||||
|
||||
it('all occupants receive an unavailable presence with a destroy reason', async () => {
|
||||
const { roomJid, roomName, focus, moderator } = await createRoom();
|
||||
|
||||
// A second participant so we can verify the unavailable broadcast.
|
||||
const participant = await createXmppClient({ params: { room: roomName } });
|
||||
|
||||
await participant.joinRoom(roomJid);
|
||||
|
||||
try {
|
||||
await moderator.sendEndConference(COMPONENT);
|
||||
|
||||
// Both the moderator and the participant must receive the kick.
|
||||
const [ modPresence, partPresence ] = await Promise.all([
|
||||
moderator.waitForPresence(p => p.attrs.type === 'unavailable'),
|
||||
participant.waitForPresence(p => p.attrs.type === 'unavailable')
|
||||
]);
|
||||
|
||||
for (const [ label, presence ] of [ [ 'moderator', modPresence ], [ 'participant', partPresence ] ]) {
|
||||
const mucUser = presence.getChild('x', 'http://jabber.org/protocol/muc#user');
|
||||
|
||||
assert.ok(mucUser, `${label} unavailable presence must contain muc#user <x>`);
|
||||
assert.ok(
|
||||
mucUser.getChild('destroy'),
|
||||
`${label} unavailable presence must contain a <destroy> element`
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await disconnectAll(focus, moderator, participant);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Non-moderator / non-occupant ─────────────────────────────────────────
|
||||
|
||||
it('non-moderator participant cannot end the conference', async () => {
|
||||
const { roomJid, roomName, focus, moderator } = await createRoom();
|
||||
|
||||
// Participant joins with ?room= but is NOT granted moderator.
|
||||
const participant = await createXmppClient({ params: { room: roomName } });
|
||||
|
||||
await participant.joinRoom(roomJid);
|
||||
|
||||
try {
|
||||
await participant.sendEndConference(COMPONENT);
|
||||
|
||||
// No reply is sent; poll briefly to confirm the room is untouched.
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
const state = await getRoomState(roomJid);
|
||||
|
||||
assert.ok(state, 'room must still exist after non-moderator end_conference attempt');
|
||||
} finally {
|
||||
await disconnectAll(focus, moderator, participant);
|
||||
}
|
||||
});
|
||||
|
||||
it('non-occupant cannot end the conference', async () => {
|
||||
const { roomJid, roomName, focus, moderator } = await createRoom();
|
||||
|
||||
// Client has the correct ?room= param but has never joined the MUC.
|
||||
const outsider = await createXmppClient({ params: { room: roomName } });
|
||||
|
||||
try {
|
||||
await outsider.sendEndConference(COMPONENT);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
const state = await getRoomState(roomJid);
|
||||
|
||||
assert.ok(state, 'room must still exist after non-occupant end_conference attempt');
|
||||
} finally {
|
||||
await disconnectAll(focus, moderator, outsider);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Message filtering ────────────────────────────────────────────────────
|
||||
|
||||
it('plain message without <end_conference/> element is ignored', async () => {
|
||||
const { roomJid, focus, moderator } = await createRoom();
|
||||
|
||||
try {
|
||||
await moderator.sendPlainMessage(COMPONENT);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
const state = await getRoomState(roomJid);
|
||||
|
||||
assert.ok(state, 'room must still exist after message without end_conference element');
|
||||
} finally {
|
||||
await disconnectAll(focus, moderator);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
476
tests/prosody/mod_filesharing_component_spec.js
Normal file
476
tests/prosody/mod_filesharing_component_spec.js
Normal file
@@ -0,0 +1,476 @@
|
||||
/**
|
||||
* Integration tests for mod_filesharing_component.
|
||||
*
|
||||
* The module runs as a Prosody component (filesharing.localhost). Clients send
|
||||
* <file-sharing xmlns='http://jitsi.org/jitmeet' type='add|remove'> messages
|
||||
* to it; the component looks up the room from jitsi_web_query_room (set by
|
||||
* mod_jitsi_session from the ?room= WebSocket URL parameter), validates that
|
||||
* the sender is a room occupant, applies a feature gate, and then:
|
||||
*
|
||||
* add — stores the file in room.jitsi_shared_files and broadcasts a
|
||||
* json-message with event='add' to all non-admin, non-sender occupants.
|
||||
* remove — deletes the file from room.jitsi_shared_files and broadcasts
|
||||
* event='remove' to all non-admin, non-sender occupants.
|
||||
*
|
||||
* When a new occupant joins a room that already has shared files, the
|
||||
* muc-occupant-joined hook sends them a json-message with event='list'
|
||||
* containing the current file set.
|
||||
*
|
||||
* Author fields (authorParticipantId, authorParticipantJid, authorParticipantName)
|
||||
* in add payloads are overwritten by Prosody from the real session state,
|
||||
* preventing clients from spoofing each other's identity.
|
||||
*
|
||||
* Feature gate: the sender must have jitsi_meet_context_features containing
|
||||
* file-upload=true. The is_owner fallback in is_feature_allowed is effectively
|
||||
* unreachable in this environment because mod_jitsi_permissions (auto-loaded by
|
||||
* muc_meeting_id) always sets at least default_permissions on moderator sessions,
|
||||
* making jitsi_meet_context_features truthy before the fallback is evaluated.
|
||||
*/
|
||||
|
||||
import assert from 'assert';
|
||||
|
||||
import { setRoomMaxOccupants, setSessionContext } from './helpers/test_observer.js';
|
||||
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
|
||||
|
||||
const CONFERENCE = 'conference.localhost';
|
||||
const FILESHARING_COMPONENT = 'filesharing.localhost';
|
||||
const JITMEET_NS = 'http://jitsi.org/jitmeet';
|
||||
|
||||
let _counter = 0;
|
||||
const nextRoom = () => `fs-${++_counter}@${CONFERENCE}`;
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Connects an anonymous client on the main VirtualHost with ?room=<roomName>
|
||||
* so that mod_jitsi_session sets jitsi_web_query_room. The filesharing
|
||||
* component requires this field before processing any message.
|
||||
*
|
||||
* @param {string} roomJid full room JID, e.g. 'room@conference.localhost'
|
||||
* @returns {Promise<XmppTestClient>}
|
||||
*/
|
||||
function connect(roomJid) {
|
||||
const roomName = roomJid.split('@')[0];
|
||||
|
||||
return createXmppClient({ params: { room: roomName } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when stanza is a filesharing broadcast from the component
|
||||
* (a <message> carrying a <json-message xmlns='http://jitsi.org/jitmeet'> child).
|
||||
*
|
||||
* @param {object} stanza
|
||||
*/
|
||||
function isFilesharingBroadcast(stanza) {
|
||||
return stanza.name === 'message'
|
||||
&& stanza.attrs.from === FILESHARING_COMPONENT
|
||||
&& stanza.getChild('json-message', JITMEET_NS) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the JSON payload from a filesharing broadcast stanza.
|
||||
* Returns the decoded object: { type, event, file?, files?, fileId? }
|
||||
*
|
||||
* @param {object} stanza
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function parseBroadcast(stanza) {
|
||||
const text = stanza.getChild('json-message', JITMEET_NS)?.getText();
|
||||
|
||||
return text ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for a filesharing broadcast stanza on the given client.
|
||||
*
|
||||
* @param {object} client
|
||||
* @param {number} [timeout=3000]
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
function waitForFilesharing(client, timeout = 3000) {
|
||||
return client.waitForMessage(isFilesharingBroadcast, timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that no filesharing broadcast arrives within the given window.
|
||||
* Throws if one does arrive.
|
||||
*
|
||||
* @param {object} client
|
||||
* @param {number} [timeout=1000]
|
||||
*/
|
||||
async function assertNoFilesharing(client, timeout = 1000) {
|
||||
try {
|
||||
await client.waitForMessage(isFilesharingBroadcast, timeout);
|
||||
throw new Error('received unexpected filesharing broadcast');
|
||||
} catch (err) {
|
||||
if (err.message.includes('Timeout')) {
|
||||
return; // expected — no broadcast
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('mod_filesharing_component', () => {
|
||||
|
||||
const clients = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(clients.map(c => c.disconnect()));
|
||||
clients.length = 0;
|
||||
});
|
||||
|
||||
// ── add file ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('add file', () => {
|
||||
|
||||
it('broadcasts add event to other occupants', async () => {
|
||||
const r = nextRoom();
|
||||
const foc = await joinWithFocus(r);
|
||||
const sender = await connect(r);
|
||||
const receiver = await connect(r);
|
||||
|
||||
clients.push(foc, sender, receiver);
|
||||
await sender.joinRoom(r);
|
||||
await receiver.joinRoom(r);
|
||||
|
||||
await setSessionContext(sender.jid, 'user-a', { 'file-upload': true });
|
||||
sender.sendFileSharingAdd(FILESHARING_COMPONENT, { fileId: 'file1',
|
||||
name: 'test.pdf' });
|
||||
|
||||
const broadcast = await waitForFilesharing(receiver);
|
||||
const payload = parseBroadcast(broadcast);
|
||||
|
||||
assert.equal(payload.type, 'file-sharing');
|
||||
assert.equal(payload.event, 'add');
|
||||
assert.equal(payload.file.fileId, 'file1');
|
||||
});
|
||||
|
||||
it('does not send broadcast back to sender', async () => {
|
||||
const r = nextRoom();
|
||||
const foc = await joinWithFocus(r);
|
||||
const sender = await connect(r);
|
||||
const receiver = await connect(r);
|
||||
|
||||
clients.push(foc, sender, receiver);
|
||||
await sender.joinRoom(r);
|
||||
await receiver.joinRoom(r);
|
||||
|
||||
await setSessionContext(sender.jid, 'user-a', { 'file-upload': true });
|
||||
sender.sendFileSharingAdd(FILESHARING_COMPONENT, { fileId: 'file1',
|
||||
name: 'test.pdf' });
|
||||
|
||||
// Wait for receiver to confirm Prosody processed the add, then
|
||||
// assert sender received nothing.
|
||||
await waitForFilesharing(receiver);
|
||||
await assertNoFilesharing(sender);
|
||||
});
|
||||
|
||||
it('does not send broadcast to admin (focus)', async () => {
|
||||
const r = nextRoom();
|
||||
const foc = await joinWithFocus(r);
|
||||
const sender = await connect(r);
|
||||
|
||||
clients.push(foc, sender);
|
||||
await sender.joinRoom(r);
|
||||
|
||||
await setSessionContext(sender.jid, 'user-a', { 'file-upload': true });
|
||||
sender.sendFileSharingAdd(FILESHARING_COMPONENT, { fileId: 'file1',
|
||||
name: 'test.pdf' });
|
||||
|
||||
await assertNoFilesharing(foc);
|
||||
});
|
||||
|
||||
it('overwrites author fields to prevent spoofing', async () => {
|
||||
const r = nextRoom();
|
||||
const foc = await joinWithFocus(r);
|
||||
const sender = await connect(r);
|
||||
const receiver = await connect(r);
|
||||
|
||||
clients.push(foc, sender, receiver);
|
||||
await sender.joinRoom(r);
|
||||
await receiver.joinRoom(r);
|
||||
|
||||
await setSessionContext(sender.jid, 'user-a', { 'file-upload': true });
|
||||
sender.sendFileSharingAdd(FILESHARING_COMPONENT, {
|
||||
fileId: 'file1',
|
||||
authorParticipantId: 'FAKE_ID',
|
||||
authorParticipantJid: 'fake@evil.com'
|
||||
});
|
||||
|
||||
const { file } = parseBroadcast(await waitForFilesharing(receiver));
|
||||
|
||||
assert.notEqual(file.authorParticipantId, 'FAKE_ID',
|
||||
'authorParticipantId must be overwritten by Prosody');
|
||||
assert.notEqual(file.authorParticipantJid, 'fake@evil.com',
|
||||
'authorParticipantJid must be overwritten by Prosody');
|
||||
assert.ok(file.authorParticipantId, 'authorParticipantId must be set');
|
||||
assert.ok(file.authorParticipantJid, 'authorParticipantJid must be set');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ── remove file ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('remove file', () => {
|
||||
|
||||
it('broadcasts remove event to other occupants', async () => {
|
||||
const r = nextRoom();
|
||||
const foc = await joinWithFocus(r);
|
||||
const sender = await connect(r);
|
||||
const receiver = await connect(r);
|
||||
|
||||
clients.push(foc, sender, receiver);
|
||||
await sender.joinRoom(r);
|
||||
await receiver.joinRoom(r);
|
||||
|
||||
await setSessionContext(sender.jid, 'user-a', { 'file-upload': true });
|
||||
|
||||
// Add first, wait for add broadcast to confirm it landed.
|
||||
sender.sendFileSharingAdd(FILESHARING_COMPONENT, { fileId: 'file1',
|
||||
name: 'test.pdf' });
|
||||
await waitForFilesharing(receiver);
|
||||
|
||||
// Now remove.
|
||||
sender.sendFileSharingRemove(FILESHARING_COMPONENT, 'file1');
|
||||
|
||||
const broadcast = await waitForFilesharing(receiver);
|
||||
const payload = parseBroadcast(broadcast);
|
||||
|
||||
assert.equal(payload.type, 'file-sharing');
|
||||
assert.equal(payload.event, 'remove');
|
||||
assert.equal(payload.fileId, 'file1');
|
||||
});
|
||||
|
||||
it('removed file is not delivered to subsequent joiners', async () => {
|
||||
const r = nextRoom();
|
||||
const foc = await joinWithFocus(r);
|
||||
|
||||
// Raise the limit: need sender + watcher + late joiner (3 regular occupants).
|
||||
await setRoomMaxOccupants(r, 10);
|
||||
|
||||
const sender = await connect(r);
|
||||
const watcher = await connect(r);
|
||||
|
||||
clients.push(foc, sender, watcher);
|
||||
await sender.joinRoom(r);
|
||||
await watcher.joinRoom(r);
|
||||
|
||||
await setSessionContext(sender.jid, 'user-a', { 'file-upload': true });
|
||||
|
||||
// Add then remove — observe both broadcasts on watcher so we know
|
||||
// Prosody has fully processed them before the late joiner connects.
|
||||
sender.sendFileSharingAdd(FILESHARING_COMPONENT, { fileId: 'file1',
|
||||
name: 'test.pdf' });
|
||||
await waitForFilesharing(watcher);
|
||||
|
||||
sender.sendFileSharingRemove(FILESHARING_COMPONENT, 'file1');
|
||||
await waitForFilesharing(watcher);
|
||||
|
||||
const late = await connect(r);
|
||||
|
||||
clients.push(late);
|
||||
await late.joinRoom(r);
|
||||
|
||||
await assertNoFilesharing(late);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ── new joiner receives file list ─────────────────────────────────────────
|
||||
|
||||
describe('new joiner receives file list', () => {
|
||||
|
||||
it('joiner receives list of existing files', async () => {
|
||||
const r = nextRoom();
|
||||
const foc = await joinWithFocus(r);
|
||||
|
||||
// Raise the limit: need sender + watcher + late joiner (3 regular occupants).
|
||||
await setRoomMaxOccupants(r, 10);
|
||||
|
||||
const sender = await connect(r);
|
||||
const watcher = await connect(r);
|
||||
|
||||
clients.push(foc, sender, watcher);
|
||||
await sender.joinRoom(r);
|
||||
await watcher.joinRoom(r);
|
||||
|
||||
await setSessionContext(sender.jid, 'user-a', { 'file-upload': true });
|
||||
|
||||
// Add a file and wait for it to land before the late joiner connects.
|
||||
sender.sendFileSharingAdd(FILESHARING_COMPONENT, { fileId: 'file1',
|
||||
name: 'test.pdf' });
|
||||
await waitForFilesharing(watcher);
|
||||
|
||||
const late = await connect(r);
|
||||
|
||||
clients.push(late);
|
||||
await late.joinRoom(r);
|
||||
|
||||
const broadcast = await waitForFilesharing(late);
|
||||
const payload = parseBroadcast(broadcast);
|
||||
|
||||
assert.equal(payload.type, 'file-sharing');
|
||||
assert.equal(payload.event, 'list');
|
||||
assert.ok(payload.files?.file1, 'file list must include the added file');
|
||||
});
|
||||
|
||||
it('joiner receives nothing when no files have been shared', async () => {
|
||||
const r = nextRoom();
|
||||
const foc = await joinWithFocus(r);
|
||||
const first = await connect(r);
|
||||
const late = await connect(r);
|
||||
|
||||
clients.push(foc, first, late);
|
||||
await first.joinRoom(r);
|
||||
await late.joinRoom(r);
|
||||
|
||||
await assertNoFilesharing(late);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ── authorization ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('authorization', () => {
|
||||
|
||||
it('non-occupant message is silently ignored', async () => {
|
||||
const r = nextRoom();
|
||||
const foc = await joinWithFocus(r);
|
||||
const nonOccupant = await connect(r); // connected but NOT joined
|
||||
const receiver = await connect(r);
|
||||
|
||||
clients.push(foc, nonOccupant, receiver);
|
||||
await receiver.joinRoom(r);
|
||||
|
||||
await setSessionContext(nonOccupant.jid, 'user-a', { 'file-upload': true });
|
||||
nonOccupant.sendFileSharingAdd(FILESHARING_COMPONENT, { fileId: 'file1' });
|
||||
|
||||
await assertNoFilesharing(receiver);
|
||||
});
|
||||
|
||||
it('message without jitsi_web_query_room is silently ignored', async () => {
|
||||
const r = nextRoom();
|
||||
const foc = await joinWithFocus(r);
|
||||
const noRoom = await createXmppClient(); // no ?room= param
|
||||
const receiver = await connect(r);
|
||||
|
||||
clients.push(foc, noRoom, receiver);
|
||||
await receiver.joinRoom(r);
|
||||
|
||||
noRoom.sendFileSharingAdd(FILESHARING_COMPONENT, { fileId: 'file1' });
|
||||
|
||||
await assertNoFilesharing(receiver);
|
||||
});
|
||||
|
||||
it('non-owner with no token features receives forbidden error', async () => {
|
||||
const r = nextRoom();
|
||||
const foc = await joinWithFocus(r);
|
||||
const member = await connect(r);
|
||||
|
||||
clients.push(foc, member);
|
||||
await member.joinRoom(r);
|
||||
|
||||
// No setSessionContext — no features, member affiliation.
|
||||
|
||||
member.sendFileSharingAdd(FILESHARING_COMPONENT, { fileId: 'file1' });
|
||||
|
||||
const err = await member.waitForMessage(s => s.attrs.type === 'error', 3000);
|
||||
|
||||
assert.equal(err.attrs.type, 'error');
|
||||
assert.ok(
|
||||
err.getChild('error')?.getChild('forbidden'),
|
||||
'expected <forbidden/> in error stanza'
|
||||
);
|
||||
});
|
||||
|
||||
it('client with file-upload feature can add file', async () => {
|
||||
const r = nextRoom();
|
||||
const foc = await joinWithFocus(r);
|
||||
const sender = await connect(r);
|
||||
const receiver = await connect(r);
|
||||
|
||||
clients.push(foc, sender, receiver);
|
||||
await sender.joinRoom(r);
|
||||
await receiver.joinRoom(r);
|
||||
|
||||
await setSessionContext(sender.jid, 'user-a', { 'file-upload': true });
|
||||
sender.sendFileSharingAdd(FILESHARING_COMPONENT, { fileId: 'file1',
|
||||
name: 'test.pdf' });
|
||||
|
||||
const broadcast = await waitForFilesharing(receiver);
|
||||
|
||||
assert.equal(parseBroadcast(broadcast).event, 'add');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ── invalid payloads ──────────────────────────────────────────────────────
|
||||
|
||||
describe('invalid payloads', () => {
|
||||
|
||||
it('add without fileId in JSON body is dropped silently', async () => {
|
||||
const r = nextRoom();
|
||||
const foc = await joinWithFocus(r);
|
||||
const sender = await connect(r);
|
||||
const receiver = await connect(r);
|
||||
|
||||
clients.push(foc, sender, receiver);
|
||||
await sender.joinRoom(r);
|
||||
await receiver.joinRoom(r);
|
||||
|
||||
await setSessionContext(sender.jid, 'user-a', { 'file-upload': true });
|
||||
sender.sendFileSharingAdd(FILESHARING_COMPONENT, { name: 'no-id.pdf' }); // no fileId
|
||||
|
||||
await assertNoFilesharing(receiver);
|
||||
});
|
||||
|
||||
it('remove without fileId attribute is dropped silently', async () => {
|
||||
const r = nextRoom();
|
||||
const foc = await joinWithFocus(r);
|
||||
const sender = await connect(r);
|
||||
const receiver = await connect(r);
|
||||
|
||||
clients.push(foc, sender, receiver);
|
||||
await sender.joinRoom(r);
|
||||
await receiver.joinRoom(r);
|
||||
|
||||
await setSessionContext(sender.jid, 'user-a', { 'file-upload': true });
|
||||
|
||||
// Remove stanza with no fileId attribute — the module logs an error
|
||||
// and returns true without firing any event.
|
||||
sender.sendFileSharingRaw(FILESHARING_COMPONENT, {}, { type: 'remove' });
|
||||
|
||||
await assertNoFilesharing(receiver);
|
||||
});
|
||||
|
||||
it('error-type message is ignored without triggering a reply', async () => {
|
||||
const r = nextRoom();
|
||||
const foc = await joinWithFocus(r);
|
||||
const sender = await connect(r);
|
||||
|
||||
clients.push(foc, sender);
|
||||
await sender.joinRoom(r);
|
||||
|
||||
// type='error' stanzas must be silently dropped (early return in on_message).
|
||||
sender.sendFileSharingRaw(
|
||||
FILESHARING_COMPONENT,
|
||||
{ type: 'error' },
|
||||
{ type: 'add' },
|
||||
JSON.stringify({ fileId: 'f1' })
|
||||
);
|
||||
|
||||
// Module must not reply with another error (would cause a loop).
|
||||
try {
|
||||
await sender.waitForMessage(s => s.attrs.type === 'error', 1000);
|
||||
assert.fail('received unexpected error reply to error stanza');
|
||||
} catch (err) {
|
||||
assert.ok(err.message.includes('Timeout'),
|
||||
`unexpected error: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
289
tests/prosody/mod_filter_iq_jibri_spec.js
Normal file
289
tests/prosody/mod_filter_iq_jibri_spec.js
Normal file
@@ -0,0 +1,289 @@
|
||||
import assert from 'assert';
|
||||
import http from 'http';
|
||||
|
||||
import { mintAsapToken } from './helpers/jwt.js';
|
||||
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
|
||||
|
||||
let _roomCounter = 0;
|
||||
const nextRoom = () => `jibri-test-${++_roomCounter}@conference.localhost`;
|
||||
|
||||
// ─── HTTP helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetches pending Jibri IQ messages from the test observer.
|
||||
*
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
function getJibriIqs() {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get('http://localhost:5280/test-observer/jibri-iqs', res => {
|
||||
let body = '';
|
||||
|
||||
res.on('data', c => {
|
||||
body += c;
|
||||
});
|
||||
res.on('end', () => resolve(JSON.parse(body)));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all Jibri IQ messages from the test observer.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function clearJibriIqs() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
'http://localhost:5280/test-observer/jibri-iqs',
|
||||
{ method: 'DELETE' },
|
||||
res => res.resume().on('end', resolve)
|
||||
);
|
||||
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a Jibri IQ and waits briefly for Prosody to route it (or block it),
|
||||
* then returns the list of Jibri IQs that reached the MUC component.
|
||||
*/
|
||||
async function sendAndCollect(client, roomJid, action, recordingMode) {
|
||||
await clearJibriIqs();
|
||||
await client.sendJibriIq(roomJid, action, recordingMode);
|
||||
|
||||
// Give Prosody time to route (or block) the IQ before we poll.
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
|
||||
return getJibriIqs();
|
||||
}
|
||||
|
||||
// ─── Test setup ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('mod_filter_iq_jibri (feature-based authorization)', () => {
|
||||
|
||||
const clients = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(clients.map(c => c.disconnect()));
|
||||
clients.length = 0;
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a test client with a token scoped to a fresh room, joins that room
|
||||
* (focus joins first to unlock the jicofo lock), and returns { client, room }.
|
||||
*
|
||||
* @param {object} [overrides] JWT payload overrides merged into the token.
|
||||
* @returns {Promise<{client: object, room: string}>}
|
||||
*/
|
||||
async function setup(overrides = {}) {
|
||||
const room = nextRoom();
|
||||
const roomName = room.split('@')[0];
|
||||
const token = mintAsapToken({ room: roomName,
|
||||
...overrides });
|
||||
const focus = await joinWithFocus(room);
|
||||
|
||||
clients.push(focus);
|
||||
|
||||
const c = await createXmppClient({ params: { token } });
|
||||
|
||||
clients.push(c);
|
||||
await c.joinRoom(room);
|
||||
|
||||
return { client: c,
|
||||
room };
|
||||
}
|
||||
|
||||
// ─── recording (recording_mode="file") ───────────────────────────────────
|
||||
|
||||
describe('recording (recording_mode=file)', () => {
|
||||
|
||||
for (const action of [ 'start', 'stop' ]) {
|
||||
|
||||
it(`passes ${action} IQ when features.recording = true`, async () => {
|
||||
const { client: c, room } = await setup({ context: { features: { recording: true } } });
|
||||
const iqs = await sendAndCollect(c, room, action, 'file');
|
||||
|
||||
assert.strictEqual(iqs.length, 1, 'IQ should reach the MUC');
|
||||
assert.strictEqual(iqs[0].action, action);
|
||||
assert.strictEqual(iqs[0].recording_mode, 'file');
|
||||
});
|
||||
|
||||
it(`blocks ${action} IQ when features.recording = false`, async () => {
|
||||
const { client: c, room } = await setup({ context: { features: { recording: false } } });
|
||||
const iqs = await sendAndCollect(c, room, action, 'file');
|
||||
|
||||
assert.strictEqual(iqs.length, 0);
|
||||
});
|
||||
|
||||
it(`blocks ${action} IQ when context.features present but recording key absent`, async () => {
|
||||
// features object present but no 'recording' key → treated as false
|
||||
const { client: c, room } = await setup({ context: { features: { livestreaming: true } } });
|
||||
const iqs = await sendAndCollect(c, room, action, 'file');
|
||||
|
||||
assert.strictEqual(iqs.length, 0);
|
||||
});
|
||||
|
||||
it(`blocks ${action} IQ when token has no context.features (non-moderator fallback)`, async () => {
|
||||
// No features in token → is_feature_allowed falls back to is_moderator.
|
||||
// Client joined after focus so it is a non-moderator participant → blocked.
|
||||
const { client: c, room } = await setup();
|
||||
const iqs = await sendAndCollect(c, room, action, 'file');
|
||||
|
||||
assert.strictEqual(iqs.length, 0);
|
||||
});
|
||||
}
|
||||
|
||||
it('passes status IQ regardless of features (only start/stop are gated)', async () => {
|
||||
const { client: c, room } = await setup({ context: { features: { recording: false } } });
|
||||
const iqs = await sendAndCollect(c, room, 'status', 'file');
|
||||
|
||||
assert.strictEqual(iqs.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── livestreaming (recording_mode="stream") ─────────────────────────────
|
||||
|
||||
describe('livestreaming (recording_mode=stream)', () => {
|
||||
|
||||
for (const action of [ 'start', 'stop' ]) {
|
||||
|
||||
it(`passes ${action} IQ when features.livestreaming = true`, async () => {
|
||||
const { client: c, room } = await setup({ context: { features: { livestreaming: true } } });
|
||||
const iqs = await sendAndCollect(c, room, action, 'stream');
|
||||
|
||||
assert.strictEqual(iqs.length, 1);
|
||||
assert.strictEqual(iqs[0].action, action);
|
||||
assert.strictEqual(iqs[0].recording_mode, 'stream');
|
||||
});
|
||||
|
||||
it(`blocks ${action} IQ when features.livestreaming = false`, async () => {
|
||||
const { client: c, room } = await setup({ context: { features: { livestreaming: false } } });
|
||||
const iqs = await sendAndCollect(c, room, action, 'stream');
|
||||
|
||||
assert.strictEqual(iqs.length, 0);
|
||||
});
|
||||
|
||||
it(`blocks ${action} IQ when context.features present but livestreaming key absent`, async () => {
|
||||
const { client: c, room } = await setup({ context: { features: { recording: true } } });
|
||||
const iqs = await sendAndCollect(c, room, action, 'stream');
|
||||
|
||||
assert.strictEqual(iqs.length, 0);
|
||||
});
|
||||
|
||||
it(`blocks ${action} IQ when token has no context.features (non-moderator fallback)`, async () => {
|
||||
const { client: c, room } = await setup();
|
||||
const iqs = await sendAndCollect(c, room, action, 'stream');
|
||||
|
||||
assert.strictEqual(iqs.length, 0);
|
||||
});
|
||||
}
|
||||
|
||||
it('passes status IQ regardless of features (only start/stop are gated)', async () => {
|
||||
const { client: c, room } = await setup({ context: { features: { livestreaming: false } } });
|
||||
const iqs = await sendAndCollect(c, room, 'status', 'stream');
|
||||
|
||||
assert.strictEqual(iqs.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── robustness: nil room ─────────────────────────────────────────────────
|
||||
//
|
||||
// mod_filter_iq_jibri calls get_room_from_jid() on the IQ target and then
|
||||
// immediately dereferences the result with room:get_occupant_by_real_jid().
|
||||
// If the room does not exist, get_room_from_jid returns nil and the next
|
||||
// line crashes. This test verifies the session survives such an IQ.
|
||||
|
||||
describe('robustness', () => {
|
||||
|
||||
it('session survives a Jibri start IQ targeting a non-existent room', async () => {
|
||||
// We need a connected client but the IQ target room must never have
|
||||
// been created. Use a wildcard token so the connection is accepted.
|
||||
const token = mintAsapToken({ room: '*' });
|
||||
const c = await createXmppClient({ params: { token } });
|
||||
|
||||
clients.push(c);
|
||||
|
||||
// Target a room that has never been created — get_room_from_jid
|
||||
// returns nil, which would crash room:get_occupant_by_real_jid.
|
||||
const ghost = 'ghost-room-nil-test@conference.localhost';
|
||||
|
||||
await clearJibriIqs();
|
||||
await c.sendJibriIq(ghost, 'start', 'file');
|
||||
|
||||
// Give Prosody time to process the stanza.
|
||||
await new Promise(r => setTimeout(r, 400));
|
||||
|
||||
// If the hook crashed and killed the session this resolves immediately;
|
||||
// we expect it to time out (session still alive).
|
||||
const disconnected = await c.waitForDisconnect(400).then(() => true, () => false);
|
||||
|
||||
assert.strictEqual(disconnected, false,
|
||||
'client session must remain alive after IQ to non-existent room');
|
||||
});
|
||||
|
||||
it('returns item-not-found error reply for Jibri IQ targeting a non-existent room', async () => {
|
||||
// When the room does not exist, get_room_from_jid should return <item-not-found/> (as opposed to nil which
|
||||
// may be returned if an internal error is triggered).
|
||||
const token = mintAsapToken({ room: '*' });
|
||||
const c = await createXmppClient({ params: { token } });
|
||||
|
||||
clients.push(c);
|
||||
|
||||
const ghost = 'ghost-room-error-reply-test@conference.localhost';
|
||||
|
||||
await c.sendJibriIq(ghost, 'start', 'file');
|
||||
|
||||
const reply = await c.waitForIq(s => s.attrs.type === 'error', 2000);
|
||||
|
||||
assert.strictEqual(reply.attrs.type, 'error');
|
||||
const error = reply.getChild('error');
|
||||
|
||||
assert.ok(error, 'expected <error/> child in IQ reply');
|
||||
assert.ok(
|
||||
error.getChild('item-not-found'),
|
||||
'expected <item-not-found/> error condition — room does not exist'
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ─── attribute case normalisation ────────────────────────────────────────
|
||||
|
||||
describe('attribute case normalisation', () => {
|
||||
|
||||
for (const action of [ 'START', 'STOP', 'Start', 'Stop' ]) {
|
||||
|
||||
it(`blocks ${action} (recording_mode=file) when features.recording = false`, async () => {
|
||||
const { client: c, room } = await setup({ context: { features: { recording: false } } });
|
||||
const iqs = await sendAndCollect(c, room, action, 'file');
|
||||
|
||||
assert.strictEqual(iqs.length, 0);
|
||||
});
|
||||
}
|
||||
|
||||
it('passes START (recording_mode=file) when features.recording = true', async () => {
|
||||
const { client: c, room } = await setup({ context: { features: { recording: true } } });
|
||||
const iqs = await sendAndCollect(c, room, 'START', 'file');
|
||||
|
||||
assert.strictEqual(iqs.length, 1);
|
||||
});
|
||||
|
||||
it('blocks START (recording_mode=FILE) when features.recording = false', async () => {
|
||||
const { client: c, room } = await setup({ context: { features: { recording: false } } });
|
||||
const iqs = await sendAndCollect(c, room, 'START', 'FILE');
|
||||
|
||||
assert.strictEqual(iqs.length, 0);
|
||||
});
|
||||
|
||||
it('blocks START (recording_mode=FILE) when only features.livestreaming = true (cross-feature)', async () => {
|
||||
// recording_mode='FILE' must still route to the recording gate, not livestreaming
|
||||
const { client: c, room } = await setup({ context: { features: { livestreaming: true,
|
||||
recording: false } } });
|
||||
const iqs = await sendAndCollect(c, room, 'START', 'FILE');
|
||||
|
||||
assert.strictEqual(iqs.length, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
302
tests/prosody/mod_filter_iq_rayo_spec.js
Normal file
302
tests/prosody/mod_filter_iq_rayo_spec.js
Normal file
@@ -0,0 +1,302 @@
|
||||
import assert from 'assert';
|
||||
import http from 'http';
|
||||
|
||||
import { mintAsapToken } from './helpers/jwt.js';
|
||||
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
|
||||
|
||||
let _roomCounter = 0;
|
||||
const nextRoom = () => `rayo-test-${++_roomCounter}@conference.localhost`;
|
||||
|
||||
// ─── HTTP helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetches pending dial IQ messages from the test observer.
|
||||
*
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
function getDialIqs() {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get('http://localhost:5280/test-observer/dial-iqs', res => {
|
||||
let body = '';
|
||||
|
||||
res.on('data', c => {
|
||||
body += c;
|
||||
});
|
||||
res.on('end', () => resolve(JSON.parse(body)));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all dial IQ messages from the test observer.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function clearDialIqs() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
'http://localhost:5280/test-observer/dial-iqs',
|
||||
{ method: 'DELETE' },
|
||||
res => res.resume().on('end', resolve)
|
||||
);
|
||||
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a Rayo dial IQ and waits briefly for Prosody to route or block it,
|
||||
* then returns the list of dial IQs that reached the MUC component.
|
||||
*
|
||||
* @param {object} client XmppTestClient with sendRayoIq
|
||||
* @param {string} roomJid full room JID
|
||||
* @param {object} [opts] options forwarded to sendRayoIq
|
||||
*/
|
||||
async function sendAndCollect(client, roomJid, opts) {
|
||||
await clearDialIqs();
|
||||
await client.sendRayoIq(roomJid, opts);
|
||||
|
||||
// Give Prosody time to route (or block) the IQ before we poll.
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
|
||||
return getDialIqs();
|
||||
}
|
||||
|
||||
// ─── Test setup ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('mod_filter_iq_rayo (feature-based authorization)', () => {
|
||||
|
||||
const clients = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(clients.map(c => c.disconnect()));
|
||||
clients.length = 0;
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a test client with a token scoped to a fresh room, joins that room
|
||||
* (focus joins first so the jicofo lock is lifted), and returns { client, room }.
|
||||
*
|
||||
* @param {object} [overrides] JWT payload overrides merged into the token.
|
||||
* @returns {Promise<{client: object, room: string}>}
|
||||
*/
|
||||
async function setup(overrides = {}) {
|
||||
const room = nextRoom();
|
||||
const roomName = room.split('@')[0];
|
||||
const token = mintAsapToken({ room: roomName,
|
||||
...overrides });
|
||||
const focus = await joinWithFocus(room);
|
||||
|
||||
clients.push(focus);
|
||||
|
||||
const c = await createXmppClient({ params: { token } });
|
||||
|
||||
clients.push(c);
|
||||
await c.joinRoom(room);
|
||||
|
||||
return { client: c,
|
||||
room };
|
||||
}
|
||||
|
||||
// ─── outbound-call (dial to a SIP/telephony address) ────────────────────
|
||||
|
||||
describe('outbound-call (dial to non-transcribe address)', () => {
|
||||
|
||||
it('passes IQ when features.outbound-call = true', async () => {
|
||||
const { client: c, room } = await setup({ context: { features: { 'outbound-call': true } } });
|
||||
const iqs = await sendAndCollect(c, room);
|
||||
|
||||
assert.strictEqual(iqs.length, 1, 'IQ should reach the MUC');
|
||||
assert.strictEqual(iqs[0].dial_to, 'sip:test@example.com');
|
||||
});
|
||||
|
||||
it('blocks IQ when features.outbound-call = false', async () => {
|
||||
const { client: c, room } = await setup({ context: { features: { 'outbound-call': false } } });
|
||||
const iqs = await sendAndCollect(c, room);
|
||||
|
||||
assert.strictEqual(iqs.length, 0);
|
||||
});
|
||||
|
||||
it('blocks IQ when context.features present but outbound-call key absent', async () => {
|
||||
const { client: c, room } = await setup({ context: { features: { recording: true } } });
|
||||
const iqs = await sendAndCollect(c, room);
|
||||
|
||||
assert.strictEqual(iqs.length, 0);
|
||||
});
|
||||
|
||||
it('blocks IQ when token has no context.features (non-owner fallback)', async () => {
|
||||
// No features → fallback to is_moderator = affiliation == 'owner'.
|
||||
// Regular client joined after focus so has 'member' affiliation → blocked.
|
||||
const { client: c, room } = await setup();
|
||||
const iqs = await sendAndCollect(c, room);
|
||||
|
||||
assert.strictEqual(iqs.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── transcription (dial to 'jitsi_meet_transcribe') ────────────────────
|
||||
|
||||
describe('transcription (dial to jitsi_meet_transcribe)', () => {
|
||||
|
||||
it('passes IQ when features.transcription = true', async () => {
|
||||
const { client: c, room } = await setup({ context: { features: { transcription: true } } });
|
||||
const iqs = await sendAndCollect(c, room, { dialTo: 'jitsi_meet_transcribe' });
|
||||
|
||||
assert.strictEqual(iqs.length, 1, 'IQ should reach the MUC');
|
||||
assert.strictEqual(iqs[0].dial_to, 'jitsi_meet_transcribe');
|
||||
});
|
||||
|
||||
it('blocks IQ when features.transcription = false', async () => {
|
||||
const { client: c, room } = await setup({ context: { features: { transcription: false } } });
|
||||
const iqs = await sendAndCollect(c, room, { dialTo: 'jitsi_meet_transcribe' });
|
||||
|
||||
assert.strictEqual(iqs.length, 0);
|
||||
});
|
||||
|
||||
it('blocks IQ when context.features present but transcription key absent', async () => {
|
||||
const { client: c, room } = await setup({ context: { features: { 'outbound-call': true } } });
|
||||
const iqs = await sendAndCollect(c, room, { dialTo: 'jitsi_meet_transcribe' });
|
||||
|
||||
assert.strictEqual(iqs.length, 0);
|
||||
});
|
||||
|
||||
it('blocks IQ when token has no context.features (non-owner fallback)', async () => {
|
||||
const { client: c, room } = await setup();
|
||||
const iqs = await sendAndCollect(c, room, { dialTo: 'jitsi_meet_transcribe' });
|
||||
|
||||
assert.strictEqual(iqs.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── JvbRoomName header validation ──────────────────────────────────────
|
||||
|
||||
describe('JvbRoomName header validation', () => {
|
||||
|
||||
it('blocks IQ when JvbRoomName header is missing', async () => {
|
||||
const { client: c, room } = await setup({ context: { features: { 'outbound-call': true } } });
|
||||
|
||||
// null → header omitted entirely
|
||||
const iqs = await sendAndCollect(c, room, { roomNameHeader: null });
|
||||
|
||||
assert.strictEqual(iqs.length, 0);
|
||||
});
|
||||
|
||||
it('blocks IQ when JvbRoomName header does not match room JID', async () => {
|
||||
const { client: c, room } = await setup({ context: { features: { 'outbound-call': true } } });
|
||||
const iqs = await sendAndCollect(c, room, { roomNameHeader: 'wrong-room@conference.localhost' });
|
||||
|
||||
assert.strictEqual(iqs.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Per-room transcription throttle ────────────────────────────────────
|
||||
|
||||
describe('transcription per-room throttle', () => {
|
||||
const transcribeOpts = { dialTo: 'jitsi_meet_transcribe' };
|
||||
|
||||
it('allows the first transcription dial in a room', async () => {
|
||||
const { client: c, room } = await setup({ context: { features: { transcription: true } } });
|
||||
const iqs = await sendAndCollect(c, room, transcribeOpts);
|
||||
|
||||
assert.strictEqual(iqs.length, 1, 'first dial should reach the MUC');
|
||||
assert.strictEqual(iqs[0].dial_to, 'jitsi_meet_transcribe');
|
||||
});
|
||||
|
||||
it('drops a second transcription dial to the same room within 10 s', async () => {
|
||||
const { client: c, room } = await setup({ context: { features: { transcription: true } } });
|
||||
|
||||
const first = await sendAndCollect(c, room, transcribeOpts);
|
||||
|
||||
assert.strictEqual(first.length, 1, 'first dial should reach the MUC');
|
||||
|
||||
// Second dial: throttle budget exhausted, should be blocked
|
||||
const second = await sendAndCollect(c, room, transcribeOpts);
|
||||
|
||||
assert.strictEqual(second.length, 0, 'second dial within 10 s should be throttled');
|
||||
});
|
||||
|
||||
it('allows only one transcription dial when multiple are sent concurrently', async () => {
|
||||
const { client: c, room } = await setup({ context: { features: { transcription: true } } });
|
||||
|
||||
await clearDialIqs();
|
||||
|
||||
// Fire three dials before Prosody has processed any of them
|
||||
await Promise.all([
|
||||
c.sendRayoIq(room, transcribeOpts),
|
||||
c.sendRayoIq(room, transcribeOpts),
|
||||
c.sendRayoIq(room, transcribeOpts)
|
||||
]);
|
||||
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
|
||||
const iqs = await getDialIqs();
|
||||
|
||||
assert.strictEqual(iqs.length, 1, 'exactly one concurrent transcription dial should pass');
|
||||
});
|
||||
|
||||
it('does not apply the throttle across different rooms', async () => {
|
||||
const { client: c1, room: room1 } = await setup({ context: { features: { transcription: true } } });
|
||||
const { client: c2, room: room2 } = await setup({ context: { features: { transcription: true } } });
|
||||
|
||||
// First dial to room1 consumes room1's throttle budget
|
||||
const iqs1 = await sendAndCollect(c1, room1, transcribeOpts);
|
||||
|
||||
assert.strictEqual(iqs1.length, 1, 'first dial to room1 should pass');
|
||||
|
||||
// First dial to room2 has its own independent throttle bucket
|
||||
const iqs2 = await sendAndCollect(c2, room2, transcribeOpts);
|
||||
|
||||
assert.strictEqual(iqs2.length, 1, 'first dial to room2 should pass independently');
|
||||
});
|
||||
|
||||
it('does not throttle outbound-call dials', async () => {
|
||||
const { client: c, room } = await setup({ context: { features: { 'outbound-call': true } } });
|
||||
|
||||
const first = await sendAndCollect(c, room);
|
||||
|
||||
assert.strictEqual(first.length, 1, 'first outbound-call should pass');
|
||||
|
||||
// A second outbound-call to the same room must not be caught by the
|
||||
// transcription throttle (which only applies to jitsi_meet_transcribe dials)
|
||||
const second = await sendAndCollect(c, room);
|
||||
|
||||
assert.strictEqual(second.length, 1, 'second outbound-call should also pass');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Header stripping ───────────────────────────────────────────────────
|
||||
|
||||
describe('header stripping', () => {
|
||||
|
||||
it('strips arbitrary client-supplied headers', async () => {
|
||||
const { client: c, room } = await setup({ context: { features: { 'outbound-call': true } } });
|
||||
const iqs = await sendAndCollect(c, room, { extraHeaders: { SomeHeader: 'spoofed-value' } });
|
||||
|
||||
assert.strictEqual(iqs.length, 1, 'IQ should reach the MUC');
|
||||
assert.strictEqual(iqs[0].headers?.SomeHeader, undefined, 'custom header must be stripped');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── JvbRoomPassword header ──────────────────────────────────────────────
|
||||
|
||||
describe('JvbRoomPassword header', () => {
|
||||
|
||||
it('passes JvbRoomPassword through when provided', async () => {
|
||||
const { client: c, room } = await setup({ context: { features: { 'outbound-call': true } } });
|
||||
const iqs = await sendAndCollect(c, room, { roomPassHeader: 'secret123' });
|
||||
|
||||
assert.strictEqual(iqs.length, 1);
|
||||
assert.strictEqual(iqs[0].room_pass_header, 'secret123');
|
||||
});
|
||||
|
||||
it('omits JvbRoomPassword in forwarded IQ when not provided by client', async () => {
|
||||
const { client: c, room } = await setup({ context: { features: { 'outbound-call': true } } });
|
||||
const iqs = await sendAndCollect(c, room);
|
||||
|
||||
assert.strictEqual(iqs.length, 1);
|
||||
assert.strictEqual(iqs[0].room_pass_header, undefined);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
283
tests/prosody/mod_filter_messages_spec.js
Normal file
283
tests/prosody/mod_filter_messages_spec.js
Normal file
@@ -0,0 +1,283 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import { mintAsapToken } from './helpers/jwt.js';
|
||||
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
|
||||
|
||||
const CONFERENCE = 'conference.localhost';
|
||||
const METADATA_COMPONENT = 'metadata.localhost';
|
||||
const JITMEET_NS = 'http://jitsi.org/jitmeet';
|
||||
|
||||
let _counter = 0;
|
||||
const nextRoom = () => `filter-msg-${++_counter}@${CONFERENCE}`;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Connects an authenticated client to the main VirtualHost.
|
||||
*
|
||||
* @param {string} roomJid Full room JID — used as the room claim in the token
|
||||
* and as the ?room= WebSocket param.
|
||||
* @param {object} [contextUser] JWT context.user claims; moderator: true → owner.
|
||||
* @param {object} [features] JWT context.features claims.
|
||||
*/
|
||||
function connect(roomJid, { contextUser, features } = {}) {
|
||||
const roomName = roomJid.split('@')[0];
|
||||
const context = {};
|
||||
|
||||
if (contextUser !== undefined) {
|
||||
context.user = contextUser;
|
||||
}
|
||||
if (features !== undefined) {
|
||||
context.features = features;
|
||||
}
|
||||
const token = mintAsapToken({ room: roomName,
|
||||
context });
|
||||
const params = { room: roomName,
|
||||
token };
|
||||
|
||||
return createXmppClient({ params });
|
||||
}
|
||||
|
||||
const connectModerator = roomJid => connect(roomJid, { contextUser: { moderator: true } });
|
||||
|
||||
/**
|
||||
* Sets groupChatRestricted on the room and waits for the metadata broadcast
|
||||
* to confirm Prosody has processed the update before returning.
|
||||
*
|
||||
* @param {object} mod Moderator XMPP client (occupant of the room).
|
||||
* @param {string} roomJid Full room JID.
|
||||
*/
|
||||
async function restrictChat(mod, roomJid) {
|
||||
mod.sendMetadataUpdate(METADATA_COMPONENT, roomJid, 'permissions', { groupChatRestricted: true });
|
||||
await mod.waitForMessage(s => {
|
||||
const jsonMsg = s.getChild('json-message', JITMEET_NS);
|
||||
|
||||
if (!jsonMsg) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(jsonMsg.getText());
|
||||
|
||||
return parsed?.metadata?.permissions?.groupChatRestricted === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('mod_filter_messages', () => {
|
||||
|
||||
const clients = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(clients.map(c => c.disconnect()));
|
||||
clients.length = 0;
|
||||
});
|
||||
|
||||
// ── unrestricted ──────────────────────────────────────────────────────────
|
||||
|
||||
it('message is delivered when groupChatRestricted is not set', async () => {
|
||||
const r = nextRoom();
|
||||
|
||||
const focus = await joinWithFocus(r);
|
||||
|
||||
clients.push(focus);
|
||||
|
||||
const sender = await createXmppClient();
|
||||
|
||||
clients.push(sender);
|
||||
await sender.joinRoom(r);
|
||||
|
||||
const reply = await sender.sendGroupchat(r, 'hello');
|
||||
|
||||
assert.notEqual(reply.attrs.type, 'error', 'message must not be blocked when chat is unrestricted');
|
||||
});
|
||||
|
||||
// ── restricted — blocked ──────────────────────────────────────────────────
|
||||
|
||||
it('anonymous sender is blocked when groupChatRestricted is true', async () => {
|
||||
const r = nextRoom();
|
||||
|
||||
const focus = await joinWithFocus(r);
|
||||
const mod = await connectModerator(r);
|
||||
|
||||
clients.push(focus, mod);
|
||||
await mod.joinRoom(r);
|
||||
|
||||
// Anonymous sender joins.
|
||||
const sender = await createXmppClient();
|
||||
|
||||
clients.push(sender);
|
||||
await sender.joinRoom(r);
|
||||
|
||||
await restrictChat(mod, r);
|
||||
|
||||
const reply = await sender.sendGroupchat(r, 'blocked?');
|
||||
|
||||
assert.strictEqual(reply.attrs.type, 'error',
|
||||
'anonymous sender must be blocked when groupChatRestricted');
|
||||
assert.ok(
|
||||
reply.getChild('error')?.getChild('not-allowed'),
|
||||
'expected <not-allowed/> error condition'
|
||||
);
|
||||
});
|
||||
|
||||
it('sender with send-groupchat=false is blocked when groupChatRestricted is true', async () => {
|
||||
const r = nextRoom();
|
||||
|
||||
const focus = await joinWithFocus(r);
|
||||
const mod = await connectModerator(r);
|
||||
|
||||
clients.push(focus, mod);
|
||||
await mod.joinRoom(r);
|
||||
|
||||
const sender = await connect(r, { features: { 'send-groupchat': false } });
|
||||
|
||||
clients.push(sender);
|
||||
await sender.joinRoom(r);
|
||||
|
||||
await restrictChat(mod, r);
|
||||
|
||||
const reply = await sender.sendGroupchat(r, 'blocked?');
|
||||
|
||||
assert.strictEqual(reply.attrs.type, 'error',
|
||||
'sender with send-groupchat=false must be blocked');
|
||||
assert.ok(
|
||||
reply.getChild('error')?.getChild('not-allowed'),
|
||||
'expected <not-allowed/> error condition'
|
||||
);
|
||||
});
|
||||
|
||||
it('sender with no send-groupchat key in features is blocked when groupChatRestricted is true', async () => {
|
||||
const r = nextRoom();
|
||||
|
||||
const focus = await joinWithFocus(r);
|
||||
const mod = await connectModerator(r);
|
||||
|
||||
clients.push(focus, mod);
|
||||
await mod.joinRoom(r);
|
||||
|
||||
// Token has a features object but no send-groupchat key.
|
||||
const sender = await connect(r, { features: { 'screen-sharing': true } });
|
||||
|
||||
clients.push(sender);
|
||||
await sender.joinRoom(r);
|
||||
|
||||
await restrictChat(mod, r);
|
||||
|
||||
const reply = await sender.sendGroupchat(r, 'blocked?');
|
||||
|
||||
assert.strictEqual(reply.attrs.type, 'error',
|
||||
'sender without send-groupchat key must be blocked');
|
||||
assert.ok(
|
||||
reply.getChild('error')?.getChild('not-allowed'),
|
||||
'expected <not-allowed/> error condition'
|
||||
);
|
||||
});
|
||||
|
||||
it('moderator with no features in token is allowed when groupChatRestricted is true', async () => {
|
||||
// mod_jitsi_permissions (auto-loaded by muc_meeting_id) injects
|
||||
// default_permissions (which includes send-groupchat=true) into
|
||||
// session.jitsi_meet_context_features for moderators whose token
|
||||
// has no explicit context.features.
|
||||
const r = nextRoom();
|
||||
|
||||
const focus = await joinWithFocus(r);
|
||||
const mod = await connectModerator(r);
|
||||
|
||||
clients.push(focus, mod);
|
||||
await mod.joinRoom(r);
|
||||
|
||||
const sender = await connect(r, { contextUser: { moderator: true } });
|
||||
|
||||
clients.push(sender);
|
||||
await sender.joinRoom(r);
|
||||
|
||||
await restrictChat(mod, r);
|
||||
|
||||
const reply = await sender.sendGroupchat(r, 'allowed?');
|
||||
|
||||
assert.notEqual(reply.attrs.type, 'error',
|
||||
'moderator with no explicit features gets default permissions (send-groupchat=true)');
|
||||
});
|
||||
|
||||
it('moderator with empty features in token is blocked when groupChatRestricted is true', async () => {
|
||||
const r = nextRoom();
|
||||
|
||||
const focus = await joinWithFocus(r);
|
||||
const mod = await connectModerator(r);
|
||||
|
||||
clients.push(focus, mod);
|
||||
await mod.joinRoom(r);
|
||||
|
||||
// Moderator token with an empty features object.
|
||||
const sender = await connect(r, { contextUser: { moderator: true },
|
||||
features: {} });
|
||||
|
||||
clients.push(sender);
|
||||
await sender.joinRoom(r);
|
||||
|
||||
await restrictChat(mod, r);
|
||||
|
||||
const reply = await sender.sendGroupchat(r, 'blocked?');
|
||||
|
||||
assert.strictEqual(reply.attrs.type, 'error',
|
||||
'moderator with empty features must be blocked');
|
||||
assert.ok(
|
||||
reply.getChild('error')?.getChild('not-allowed'),
|
||||
'expected <not-allowed/> error condition'
|
||||
);
|
||||
});
|
||||
|
||||
// ── restricted — allowed ──────────────────────────────────────────────────
|
||||
|
||||
it('sender with send-groupchat=true is allowed when groupChatRestricted is true', async () => {
|
||||
const r = nextRoom();
|
||||
|
||||
const focus = await joinWithFocus(r);
|
||||
const mod = await connectModerator(r);
|
||||
|
||||
clients.push(focus, mod);
|
||||
await mod.joinRoom(r);
|
||||
|
||||
const sender = await connect(r, { features: { 'send-groupchat': true } });
|
||||
|
||||
clients.push(sender);
|
||||
await sender.joinRoom(r);
|
||||
|
||||
await restrictChat(mod, r);
|
||||
|
||||
const reply = await sender.sendGroupchat(r, 'allowed!');
|
||||
|
||||
assert.notEqual(reply.attrs.type, 'error',
|
||||
'sender with send-groupchat=true must be allowed');
|
||||
});
|
||||
|
||||
// ── body-less message ─────────────────────────────────────────────────────
|
||||
|
||||
it('body-less message is always delivered even when groupChatRestricted is true', async () => {
|
||||
const r = nextRoom();
|
||||
|
||||
const focus = await joinWithFocus(r);
|
||||
const mod = await connectModerator(r);
|
||||
|
||||
clients.push(focus, mod);
|
||||
await mod.joinRoom(r);
|
||||
|
||||
// Anonymous sender — would normally be blocked for body messages.
|
||||
const sender = await createXmppClient();
|
||||
|
||||
clients.push(sender);
|
||||
await sender.joinRoom(r);
|
||||
|
||||
await restrictChat(mod, r);
|
||||
|
||||
// Send a groupchat message with no <body> child.
|
||||
const reply = await sender.sendGroupchat(r);
|
||||
|
||||
assert.notEqual(reply.attrs.type, 'error',
|
||||
'body-less message must pass through even when chat is restricted');
|
||||
});
|
||||
});
|
||||
76
tests/prosody/mod_jiconop_spec.js
Normal file
76
tests/prosody/mod_jiconop_spec.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import { createXmppClient } from './helpers/xmpp_client.js';
|
||||
|
||||
const DISCO_INFO_NS = 'http://jabber.org/protocol/disco#info';
|
||||
|
||||
describe('mod_jiconop', () => {
|
||||
|
||||
const clients = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(clients.map(c => c.disconnect()));
|
||||
clients.length = 0;
|
||||
});
|
||||
|
||||
/**
|
||||
* Connects a client and waits for the unsolicited jiconop message.
|
||||
*
|
||||
* @returns {Promise<{client: object, msg: object}>}
|
||||
*/
|
||||
async function connectAndGetJiconop() {
|
||||
const c = await createXmppClient();
|
||||
|
||||
clients.push(c);
|
||||
|
||||
const msg = await c.waitForMessage(m => m.getChild('query', DISCO_INFO_NS) !== null);
|
||||
|
||||
return { client: c,
|
||||
msg };
|
||||
}
|
||||
|
||||
it('sends an unsolicited message with a disco#info query on connect', async () => {
|
||||
const { msg } = await connectAndGetJiconop();
|
||||
|
||||
assert.ok(msg, 'must receive a message stanza after bind');
|
||||
assert.ok(msg.getChild('query', DISCO_INFO_NS),
|
||||
'message must contain <query xmlns="http://jabber.org/protocol/disco#info">');
|
||||
});
|
||||
|
||||
it('message is addressed to the client full JID', async () => {
|
||||
const { client,
|
||||
msg } = await connectAndGetJiconop();
|
||||
|
||||
assert.equal(msg.attrs.to, client.jid, 'message must be addressed to the client full JID');
|
||||
});
|
||||
|
||||
it('query contains shard identity', async () => {
|
||||
const { msg } = await connectAndGetJiconop();
|
||||
const query = msg.getChild('query', DISCO_INFO_NS);
|
||||
const identities = query.getChildren('identity');
|
||||
const shard = identities.find(i => i.attrs.category === 'server' && i.attrs.type === 'shard');
|
||||
|
||||
assert.ok(shard, 'query must contain a server/shard identity');
|
||||
assert.equal(shard.attrs.name, 'test-shard');
|
||||
});
|
||||
|
||||
it('query contains region identity', async () => {
|
||||
const { msg } = await connectAndGetJiconop();
|
||||
const query = msg.getChild('query', DISCO_INFO_NS);
|
||||
const identities = query.getChildren('identity');
|
||||
const region = identities.find(i => i.attrs.category === 'server' && i.attrs.type === 'region');
|
||||
|
||||
assert.ok(region, 'query must contain a server/region identity');
|
||||
assert.equal(region.attrs.name, 'test-region');
|
||||
});
|
||||
|
||||
it('query contains release identity', async () => {
|
||||
const { msg } = await connectAndGetJiconop();
|
||||
const query = msg.getChild('query', DISCO_INFO_NS);
|
||||
const identities = query.getChildren('identity');
|
||||
const release = identities.find(i => i.attrs.category === 'server' && i.attrs.type === 'release');
|
||||
|
||||
assert.ok(release, 'query must contain a server/release identity');
|
||||
assert.equal(release.attrs.name, 'test-release');
|
||||
});
|
||||
});
|
||||
752
tests/prosody/mod_jitsi_permissions_spec.js
Normal file
752
tests/prosody/mod_jitsi_permissions_spec.js
Normal file
@@ -0,0 +1,752 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import { mintAsapToken } from './helpers/jwt.js';
|
||||
import { getSessionFeatures } from './helpers/test_observer.js';
|
||||
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
|
||||
|
||||
const CONFERENCE = 'conference.localhost';
|
||||
const PERMISSIONS_NS = 'http://jitsi.org/jitmeet';
|
||||
|
||||
let _roomCounter = 0;
|
||||
const nextRoom = () => `permissions-${++_roomCounter}@${CONFERENCE}`;
|
||||
|
||||
// Keys present in the default_permissions table in mod_jitsi_permissions.lua.
|
||||
const DEFAULT_PERMISSION_KEYS = [
|
||||
'livestreaming', 'recording', 'transcription',
|
||||
'outbound-call', 'create-polls', 'send-groupchat', 'flip'
|
||||
];
|
||||
|
||||
/**
|
||||
* Extracts the <permissions xmlns='http://jitsi.org/jitmeet'> element from a
|
||||
* presence stanza and returns its children as a plain { name: val } map.
|
||||
* Returns null when the element is absent.
|
||||
*
|
||||
* @param {object} presence
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function getPermissions(presence) {
|
||||
const el = presence.getChild('permissions', PERMISSIONS_NS);
|
||||
|
||||
if (!el) {
|
||||
return null;
|
||||
}
|
||||
const result = {};
|
||||
|
||||
for (const p of el.getChildren('p')) {
|
||||
result[p.attrs.name] = p.attrs.val;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
describe('mod_jitsi_permissions', () => {
|
||||
|
||||
const clients = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(clients.map(c => c.disconnect()));
|
||||
clients.length = 0;
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a connected XMPP client and tracks it for afterEach cleanup.
|
||||
*
|
||||
* @param {object} [opts] Options forwarded to createXmppClient.
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
async function connect(opts) {
|
||||
const c = await createXmppClient(opts);
|
||||
|
||||
clients.push(c);
|
||||
|
||||
return c;
|
||||
}
|
||||
|
||||
// ── moderator token, no features ──────────────────────────────────────────
|
||||
//
|
||||
// mod_token_affiliation grants owner/moderator on join.
|
||||
// mod_jitsi_permissions sees: is_moderator=true, send_default_permissions_to
|
||||
// flag set (by muc-pre-set-affiliation from set_affiliation call inside
|
||||
// token_affiliation), no jitsi_meet_context_features on session → injects
|
||||
// all defaults.
|
||||
|
||||
describe('moderator token without features', () => {
|
||||
|
||||
it('injects all default permissions into self-presence on join', async () => {
|
||||
const r = nextRoom();
|
||||
|
||||
clients.push(await joinWithFocus(r));
|
||||
|
||||
const token = mintAsapToken({ room: r.split('@')[0],
|
||||
context: { user: { moderator: true } } });
|
||||
const c = await connect({ params: { token } });
|
||||
const presence = await c.joinRoom(r);
|
||||
|
||||
const perms = getPermissions(presence);
|
||||
|
||||
assert.ok(perms !== null, 'permissions element should be present');
|
||||
|
||||
for (const key of DEFAULT_PERMISSION_KEYS) {
|
||||
assert.strictEqual(perms[key], 'true', `expected ${key}=true`);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ── moderator token with features ─────────────────────────────────────────
|
||||
//
|
||||
// mod_token_affiliation grants owner/moderator.
|
||||
// filter_stanza sees auth_token + jitsi_meet_context_features both set →
|
||||
// skips injection entirely so the client uses its own token-provided features.
|
||||
|
||||
describe('moderator token with features', () => {
|
||||
|
||||
it('does not inject permissions when token already contains features', async () => {
|
||||
const r = nextRoom();
|
||||
|
||||
clients.push(await joinWithFocus(r));
|
||||
|
||||
const token = mintAsapToken({
|
||||
room: r.split('@')[0],
|
||||
context: {
|
||||
user: { moderator: true },
|
||||
features: {
|
||||
recording: false,
|
||||
'screen-sharing': true
|
||||
}
|
||||
}
|
||||
});
|
||||
const c = await connect({ params: { token } });
|
||||
const presence = await c.joinRoom(r);
|
||||
|
||||
assert.strictEqual(
|
||||
getPermissions(presence),
|
||||
null,
|
||||
'permissions should not be injected when token already provides features'
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ── non-moderator token ───────────────────────────────────────────────────
|
||||
//
|
||||
// mod_token_affiliation grants member affiliation (participant role).
|
||||
// filter_stanza checks is_moderator=false → returns stanza unchanged.
|
||||
|
||||
describe('non-moderator token', () => {
|
||||
|
||||
it('does not inject permissions for member participant', async () => {
|
||||
const r = nextRoom();
|
||||
|
||||
clients.push(await joinWithFocus(r));
|
||||
|
||||
const token = mintAsapToken({ room: r.split('@')[0],
|
||||
context: { user: { id: 'user1' } } });
|
||||
const c = await connect({ params: { token } });
|
||||
const presence = await c.joinRoom(r);
|
||||
|
||||
assert.strictEqual(
|
||||
getPermissions(presence),
|
||||
null,
|
||||
'permissions should not be injected for non-moderator participant'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not inject permissions for member with features', async () => {
|
||||
const r = nextRoom();
|
||||
|
||||
clients.push(await joinWithFocus(r));
|
||||
|
||||
const token = mintAsapToken({
|
||||
room: r.split('@')[0],
|
||||
context: {
|
||||
user: { moderator: false },
|
||||
features: { recording: true }
|
||||
}
|
||||
});
|
||||
const c = await connect({ params: { token } });
|
||||
const presence = await c.joinRoom(r);
|
||||
|
||||
assert.strictEqual(
|
||||
getPermissions(presence),
|
||||
null,
|
||||
'permissions should not be injected for non-moderator even with features'
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ── anonymous user (no token) ─────────────────────────────────────────────
|
||||
//
|
||||
// token_affiliation is a no-op → affiliation=none, role=participant.
|
||||
// muc-pre-set-affiliation never fires → send_default_permissions_to not set.
|
||||
// filter_stanza: is_moderator=false → no injection.
|
||||
|
||||
describe('anonymous user (no token)', () => {
|
||||
|
||||
it('does not inject permissions for anonymous participant', async () => {
|
||||
const r = nextRoom();
|
||||
|
||||
clients.push(await joinWithFocus(r));
|
||||
|
||||
const c = await connect();
|
||||
const presence = await c.joinRoom(r);
|
||||
|
||||
assert.strictEqual(
|
||||
getPermissions(presence),
|
||||
null,
|
||||
'permissions should not be injected for anonymous user'
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
// ── affiliation grant and revoke ──────────────────────────────────────────
|
||||
//
|
||||
// When a moderator grants owner affiliation to another participant,
|
||||
// process_set_affiliation copies the granting session's feature table onto
|
||||
// the recipient's session. When the affiliation is revoked, the recipient
|
||||
// must go back to the features that its own token supplied, or to no
|
||||
// features at all when the token supplied none. A session that keeps the
|
||||
// granted table stays allowed by is_feature_allowed(), which does not look
|
||||
// at the current role once a feature table is present.
|
||||
|
||||
describe('affiliation grant and revoke', () => {
|
||||
|
||||
/**
|
||||
* Joins a room as a moderator that holds every default feature.
|
||||
* Use it as the actor that grants and revokes owner affiliation.
|
||||
*
|
||||
* @param {Function} connectFn the spec-local connect helper.
|
||||
* @param {string} roomJid e.g. 'room@conference.localhost'
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
async function joinAsGranter(connectFn, roomJid, extraParams = {}) {
|
||||
const features = {};
|
||||
|
||||
for (const key of DEFAULT_PERMISSION_KEYS) {
|
||||
features[key] = true;
|
||||
}
|
||||
|
||||
const token = mintAsapToken({
|
||||
room: roomJid.split('@')[0],
|
||||
context: {
|
||||
user: { id: 'granter',
|
||||
moderator: true },
|
||||
features
|
||||
}
|
||||
});
|
||||
const c = await connectFn({ params: { token,
|
||||
...extraParams } });
|
||||
|
||||
await c.joinRoom(roomJid);
|
||||
|
||||
return c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a Jibri start IQ and returns the name of the error condition
|
||||
* that comes back. mod_filter_iq_jibri answers 'forbidden' when it
|
||||
* blocks the request. Anything else means the IQ was routed on to the
|
||||
* focus occupant, which has no Jibri handler in these tests.
|
||||
*
|
||||
* @param {object} client the sender.
|
||||
* @param {string} roomJid e.g. 'room@conference.localhost'
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async function jibriStartErrorCondition(client, roomJid) {
|
||||
const wait = client.waitForIq(iq => iq.attrs.type === 'error', 5000);
|
||||
|
||||
client.sendJibriIq(roomJid, 'start', 'file');
|
||||
|
||||
const error = (await wait).getChild('error');
|
||||
|
||||
return error ? error.children[0].name : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a Rayo dial IQ and returns the name of the error condition that
|
||||
* comes back. mod_filter_iq_rayo answers 'forbidden' when it blocks the
|
||||
* request. Anything else means the IQ was routed on to the focus
|
||||
* occupant, which has no Rayo handler in these tests.
|
||||
*
|
||||
* @param {object} client the sender.
|
||||
* @param {string} roomJid e.g. 'room@conference.localhost'
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async function rayoDialErrorCondition(client, roomJid) {
|
||||
const reply = await client.sendRayoIqAndWait(roomJid);
|
||||
const error = reply.getChild('error');
|
||||
|
||||
return error ? error.children[0].name : null;
|
||||
}
|
||||
|
||||
it('restores the token features of the recipient when owner is revoked', async () => {
|
||||
const r = nextRoom();
|
||||
|
||||
clients.push(await joinWithFocus(r));
|
||||
|
||||
const granter = await joinAsGranter(connect, r);
|
||||
|
||||
const token = mintAsapToken({
|
||||
room: r.split('@')[0],
|
||||
context: {
|
||||
user: { id: 'user1' },
|
||||
features: {
|
||||
recording: false,
|
||||
livestreaming: false,
|
||||
transcription: false,
|
||||
'outbound-call': false
|
||||
}
|
||||
}
|
||||
});
|
||||
const c = await connect({ params: { token } });
|
||||
|
||||
await c.joinRoom(r);
|
||||
|
||||
const bare = c.jid.split('/')[0];
|
||||
|
||||
await granter.sendMucAdmin(r, { jid: bare,
|
||||
affiliation: 'owner' });
|
||||
|
||||
const granted = await getSessionFeatures(c.jid);
|
||||
|
||||
assert.strictEqual(granted.recording, true, 'grant should hand over the actor features');
|
||||
|
||||
await granter.sendMucAdmin(r, { jid: bare,
|
||||
affiliation: 'member' });
|
||||
|
||||
const restored = await getSessionFeatures(c.jid);
|
||||
|
||||
assert.ok(restored !== null, 'the token features should come back');
|
||||
assert.strictEqual(restored.recording, false);
|
||||
assert.strictEqual(restored.livestreaming, false);
|
||||
assert.strictEqual(restored.transcription, false);
|
||||
assert.strictEqual(restored['outbound-call'], false);
|
||||
});
|
||||
|
||||
it('clears granted features when the token of the recipient has none', async () => {
|
||||
const r = nextRoom();
|
||||
|
||||
clients.push(await joinWithFocus(r));
|
||||
|
||||
const granter = await joinAsGranter(connect, r);
|
||||
|
||||
const token = mintAsapToken({ room: r.split('@')[0],
|
||||
context: { user: { id: 'user2' } } });
|
||||
const c = await connect({ params: { token } });
|
||||
|
||||
await c.joinRoom(r);
|
||||
|
||||
const bare = c.jid.split('/')[0];
|
||||
|
||||
await granter.sendMucAdmin(r, { jid: bare,
|
||||
affiliation: 'owner' });
|
||||
|
||||
assert.ok(await getSessionFeatures(c.jid) !== null, 'grant should set features');
|
||||
|
||||
await granter.sendMucAdmin(r, { jid: bare,
|
||||
affiliation: 'member' });
|
||||
|
||||
assert.strictEqual(await getSessionFeatures(c.jid), null,
|
||||
'the session must hold no features again');
|
||||
});
|
||||
|
||||
it('clears granted features for a participant without a token', async () => {
|
||||
const r = nextRoom();
|
||||
|
||||
clients.push(await joinWithFocus(r));
|
||||
|
||||
const granter = await joinAsGranter(connect, r);
|
||||
const c = await connect();
|
||||
|
||||
await c.joinRoom(r);
|
||||
|
||||
const bare = c.jid.split('/')[0];
|
||||
|
||||
await granter.sendMucAdmin(r, { jid: bare,
|
||||
affiliation: 'owner' });
|
||||
|
||||
assert.ok(await getSessionFeatures(c.jid) !== null, 'grant should set features');
|
||||
|
||||
await granter.sendMucAdmin(r, { jid: bare,
|
||||
affiliation: 'member' });
|
||||
|
||||
assert.strictEqual(await getSessionFeatures(c.jid), null,
|
||||
'the session must hold no features again');
|
||||
});
|
||||
|
||||
it('restores the token features when owner is revoked twice in a row', async () => {
|
||||
const r = nextRoom();
|
||||
|
||||
clients.push(await joinWithFocus(r));
|
||||
|
||||
const granter = await joinAsGranter(connect, r);
|
||||
|
||||
const token = mintAsapToken({
|
||||
room: r.split('@')[0],
|
||||
context: {
|
||||
user: { id: 'user3' },
|
||||
features: { recording: false }
|
||||
}
|
||||
});
|
||||
const c = await connect({ params: { token } });
|
||||
|
||||
await c.joinRoom(r);
|
||||
|
||||
const bare = c.jid.split('/')[0];
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await granter.sendMucAdmin(r, { jid: bare,
|
||||
affiliation: 'owner' });
|
||||
await granter.sendMucAdmin(r, { jid: bare,
|
||||
affiliation: 'member' });
|
||||
}
|
||||
|
||||
const restored = await getSessionFeatures(c.jid);
|
||||
|
||||
assert.ok(restored !== null, 'the token features should come back');
|
||||
assert.strictEqual(restored.recording, false);
|
||||
});
|
||||
|
||||
it('blocks Jibri and Rayo requests from a recipient whose owner affiliation was revoked', async () => {
|
||||
const r = nextRoom();
|
||||
|
||||
clients.push(await joinWithFocus(r));
|
||||
|
||||
const granter = await joinAsGranter(connect, r);
|
||||
|
||||
const token = mintAsapToken({
|
||||
room: r.split('@')[0],
|
||||
context: {
|
||||
user: { id: 'user4' },
|
||||
features: { recording: false,
|
||||
'outbound-call': false }
|
||||
}
|
||||
});
|
||||
const c = await connect({ params: { token } });
|
||||
|
||||
await c.joinRoom(r);
|
||||
|
||||
assert.strictEqual(
|
||||
await jibriStartErrorCondition(c, r),
|
||||
'forbidden',
|
||||
'a member with recording=false must not reach Jibri');
|
||||
assert.strictEqual(
|
||||
await rayoDialErrorCondition(c, r),
|
||||
'forbidden',
|
||||
'a member with outbound-call=false must not reach Jigasi');
|
||||
|
||||
const bare = c.jid.split('/')[0];
|
||||
|
||||
await granter.sendMucAdmin(r, { jid: bare,
|
||||
affiliation: 'owner' });
|
||||
|
||||
assert.notStrictEqual(
|
||||
await jibriStartErrorCondition(c, r),
|
||||
'forbidden',
|
||||
'an owner with a granted recording feature must reach Jibri');
|
||||
|
||||
await granter.sendMucAdmin(r, { jid: bare,
|
||||
affiliation: 'member' });
|
||||
|
||||
assert.strictEqual(
|
||||
await jibriStartErrorCondition(c, r),
|
||||
'forbidden',
|
||||
'the recipient must not keep the granted recording feature');
|
||||
assert.strictEqual(
|
||||
await rayoDialErrorCondition(c, r),
|
||||
'forbidden',
|
||||
'the recipient must not keep the granted outbound-call feature');
|
||||
});
|
||||
|
||||
it('restores the token features when the recipient revokes its own owner affiliation', async () => {
|
||||
const r = nextRoom();
|
||||
|
||||
clients.push(await joinWithFocus(r));
|
||||
|
||||
const granter = await joinAsGranter(connect, r);
|
||||
|
||||
const token = mintAsapToken({
|
||||
room: r.split('@')[0],
|
||||
context: {
|
||||
user: { id: 'user5' },
|
||||
features: { recording: false }
|
||||
}
|
||||
});
|
||||
const c = await connect({ params: { token } });
|
||||
|
||||
await c.joinRoom(r);
|
||||
|
||||
const bare = c.jid.split('/')[0];
|
||||
|
||||
await granter.sendMucAdmin(r, { jid: bare,
|
||||
affiliation: 'owner' });
|
||||
|
||||
// As an owner, the recipient can set its own affiliation.
|
||||
const reply = await c.sendMucAdmin(r, { jid: bare,
|
||||
affiliation: 'member' });
|
||||
|
||||
assert.strictEqual(reply.attrs.type, 'result');
|
||||
|
||||
const restored = await getSessionFeatures(c.jid);
|
||||
|
||||
assert.ok(restored !== null, 'the token features should come back');
|
||||
assert.strictEqual(restored.recording, false);
|
||||
assert.strictEqual(
|
||||
await jibriStartErrorCondition(c, r),
|
||||
'forbidden',
|
||||
'the recipient must not keep the granted recording feature');
|
||||
});
|
||||
|
||||
for (const target of [ 'admin', 'outcast' ]) {
|
||||
it(`restores the token features when owner changes to ${target}`, async () => {
|
||||
const r = nextRoom();
|
||||
|
||||
clients.push(await joinWithFocus(r));
|
||||
|
||||
const granter = await joinAsGranter(connect, r);
|
||||
|
||||
const token = mintAsapToken({
|
||||
room: r.split('@')[0],
|
||||
context: {
|
||||
user: { id: `user-${target}` },
|
||||
features: { recording: false }
|
||||
}
|
||||
});
|
||||
const c = await connect({ params: { token } });
|
||||
|
||||
await c.joinRoom(r);
|
||||
|
||||
const bare = c.jid.split('/')[0];
|
||||
|
||||
await granter.sendMucAdmin(r, { jid: bare,
|
||||
affiliation: 'owner' });
|
||||
|
||||
assert.strictEqual((await getSessionFeatures(c.jid)).recording, true);
|
||||
|
||||
const reply = await granter.sendMucAdmin(r, { jid: bare,
|
||||
affiliation: target });
|
||||
|
||||
assert.strictEqual(reply.attrs.type, 'result');
|
||||
|
||||
const restored = await getSessionFeatures(c.jid);
|
||||
|
||||
assert.ok(restored !== null, 'the token features should come back');
|
||||
assert.strictEqual(restored.recording, false);
|
||||
});
|
||||
}
|
||||
|
||||
it('restores the token features of a recipient that is in a breakout room', async () => {
|
||||
const BREAKOUT_MUC = 'breakout.conference.localhost';
|
||||
const r = nextRoom();
|
||||
const name = r.split('@')[0];
|
||||
const focus = await joinWithFocus(r);
|
||||
|
||||
clients.push(focus);
|
||||
|
||||
// mod_muc_breakout_rooms finds the main room of the moderator through
|
||||
// the ?room= query parameter.
|
||||
const granter = await joinAsGranter(connect, r, { room: name });
|
||||
|
||||
const token = mintAsapToken({
|
||||
room: name,
|
||||
context: {
|
||||
user: { id: 'user-breakout' },
|
||||
features: { recording: false }
|
||||
}
|
||||
});
|
||||
const c = await connect({ params: { token } });
|
||||
|
||||
await c.joinRoom(r);
|
||||
|
||||
const bare = c.jid.split('/')[0];
|
||||
|
||||
await granter.sendMucAdmin(r, { jid: bare,
|
||||
affiliation: 'owner' });
|
||||
|
||||
assert.strictEqual((await getSessionFeatures(c.jid)).recording, true);
|
||||
|
||||
// Create a breakout room and move the recipient into it.
|
||||
const parseJsonMessage = s => {
|
||||
try {
|
||||
return JSON.parse(s.getChild('json-message', PERMISSIONS_NS)?.getText());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const update = granter.waitForMessage(s => {
|
||||
const payload = parseJsonMessage(s);
|
||||
|
||||
return payload?.type === 'breakout_rooms'
|
||||
&& payload?.event === 'features/breakout-rooms/update';
|
||||
}, 6000);
|
||||
|
||||
await granter.sendBreakoutRoomsMessage(BREAKOUT_MUC, 'features/breakout-rooms/add', { subject: 'Group 1' });
|
||||
|
||||
const payload = parseJsonMessage(await update);
|
||||
const breakoutJid = Object.values(payload.rooms).find(room => !room.isMainRoom)?.jid;
|
||||
|
||||
assert.ok(breakoutJid, 'the update must list the new breakout room');
|
||||
|
||||
// focus creates the breakout room, then the recipient moves into it.
|
||||
await focus.joinRoom(breakoutJid, 'focus');
|
||||
const breakoutPresence = await c.joinRoom(breakoutJid);
|
||||
|
||||
assert.notStrictEqual(breakoutPresence.attrs.type, 'error');
|
||||
await c.leaveRoom(r);
|
||||
|
||||
// The recipient is no longer an occupant of the main room, but its
|
||||
// affiliation there still decides its role in the breakout room.
|
||||
const reply = await granter.sendMucAdmin(r, { jid: bare,
|
||||
affiliation: 'member' });
|
||||
|
||||
assert.strictEqual(reply.attrs.type, 'result');
|
||||
|
||||
const restored = await getSessionFeatures(c.jid);
|
||||
|
||||
assert.ok(restored !== null, 'the token features should come back');
|
||||
assert.strictEqual(restored.recording, false);
|
||||
});
|
||||
|
||||
it('does not touch the features of a session that is not in this room', async () => {
|
||||
const roomA = nextRoom();
|
||||
const roomB = nextRoom();
|
||||
|
||||
clients.push(await joinWithFocus(roomA));
|
||||
clients.push(await joinWithFocus(roomB));
|
||||
|
||||
const granter = await joinAsGranter(connect, roomA);
|
||||
|
||||
const token = mintAsapToken({
|
||||
room: roomB.split('@')[0],
|
||||
context: {
|
||||
user: { id: 'user-other-room' },
|
||||
features: { recording: false }
|
||||
}
|
||||
});
|
||||
const other = await connect({ params: { token } });
|
||||
|
||||
await other.joinRoom(roomB);
|
||||
|
||||
const bare = other.jid.split('/')[0];
|
||||
|
||||
// The granter is only a moderator of room A, and the recipient is only
|
||||
// ever an occupant of room B. An affiliation change on room A must not
|
||||
// reach a session that shares the same bare JID in an unrelated room.
|
||||
const reply = await granter.sendMucAdmin(roomA, { jid: bare,
|
||||
affiliation: 'owner' });
|
||||
|
||||
assert.strictEqual(reply.attrs.type, 'result');
|
||||
|
||||
const features = await getSessionFeatures(other.jid);
|
||||
|
||||
assert.ok(features !== null, 'the token features must be unchanged');
|
||||
assert.strictEqual(features.recording, false);
|
||||
assert.strictEqual(
|
||||
await jibriStartErrorCondition(other, roomB),
|
||||
'forbidden',
|
||||
'a cross-room grant must not authorize this session in its own room');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ── session features via HTTP ─────────────────────────────────────────────
|
||||
//
|
||||
// Verify that jitsi_meet_context_features is set correctly on the Prosody
|
||||
// session after joining. These assertions read the live session state (not
|
||||
// the resource-bind snapshot) so they also capture side-effects from
|
||||
// filter_stanza, which writes default_permissions onto the session for
|
||||
// moderators who joined without token features.
|
||||
|
||||
describe('session features (via HTTP)', () => {
|
||||
|
||||
it('sets token features on session for moderator with features', async () => {
|
||||
const r = nextRoom();
|
||||
|
||||
clients.push(await joinWithFocus(r));
|
||||
|
||||
const token = mintAsapToken({
|
||||
room: r.split('@')[0],
|
||||
context: {
|
||||
user: { moderator: true },
|
||||
features: {
|
||||
recording: false,
|
||||
livestreaming: false
|
||||
}
|
||||
}
|
||||
});
|
||||
const c = await connect({ params: { token } });
|
||||
|
||||
await c.joinRoom(r);
|
||||
|
||||
const features = await getSessionFeatures(c.jid);
|
||||
|
||||
assert.strictEqual(features.recording, false);
|
||||
assert.strictEqual(features.livestreaming, false);
|
||||
});
|
||||
|
||||
it('sets token features on session for non-moderator with features', async () => {
|
||||
const r = nextRoom();
|
||||
|
||||
clients.push(await joinWithFocus(r));
|
||||
|
||||
const token = mintAsapToken({
|
||||
room: r.split('@')[0],
|
||||
context: {
|
||||
user: { id: 'user1' },
|
||||
features: {
|
||||
recording: false,
|
||||
transcription: true
|
||||
}
|
||||
}
|
||||
});
|
||||
const c = await connect({ params: { token } });
|
||||
|
||||
await c.joinRoom(r);
|
||||
|
||||
const features = await getSessionFeatures(c.jid);
|
||||
|
||||
assert.strictEqual(features.recording, false);
|
||||
assert.strictEqual(features.transcription, true);
|
||||
});
|
||||
|
||||
it('sets default permissions on session for moderator without token features', async () => {
|
||||
const r = nextRoom();
|
||||
|
||||
clients.push(await joinWithFocus(r));
|
||||
|
||||
const token = mintAsapToken({ room: r.split('@')[0],
|
||||
context: { user: { moderator: true } } });
|
||||
const c = await connect({ params: { token } });
|
||||
|
||||
await c.joinRoom(r);
|
||||
|
||||
const features = await getSessionFeatures(c.jid);
|
||||
|
||||
assert.ok(features !== null, 'features should be set by filter_stanza');
|
||||
for (const key of DEFAULT_PERMISSION_KEYS) {
|
||||
assert.strictEqual(features[key], true, `expected ${key}=true`);
|
||||
}
|
||||
});
|
||||
|
||||
it('sets no features on session for non-moderator without token features', async () => {
|
||||
const r = nextRoom();
|
||||
|
||||
clients.push(await joinWithFocus(r));
|
||||
|
||||
const token = mintAsapToken({ room: r.split('@')[0],
|
||||
context: { user: { id: 'user1' } } });
|
||||
const c = await connect({ params: { token } });
|
||||
|
||||
await c.joinRoom(r);
|
||||
|
||||
const features = await getSessionFeatures(c.jid);
|
||||
|
||||
assert.strictEqual(features, null);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
106
tests/prosody/mod_jitsi_session_spec.js
Normal file
106
tests/prosody/mod_jitsi_session_spec.js
Normal file
@@ -0,0 +1,106 @@
|
||||
import assert from 'assert';
|
||||
import http from 'http';
|
||||
|
||||
import { createXmppClient } from './helpers/xmpp_client.js';
|
||||
|
||||
/**
|
||||
* Fetches the mod_jitsi_session field snapshot for the given full JID from
|
||||
* the /test-observer/session-info endpoint (served by mod_test_observer_http).
|
||||
*
|
||||
* @param {string} jid full JID, e.g. "user@localhost/resource"
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
function getSessionInfo(jid) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = `http://localhost:5280/test-observer/session-info?jid=${encodeURIComponent(jid)}`;
|
||||
|
||||
http.get(url, res => {
|
||||
let body = '';
|
||||
|
||||
res.on('data', chunk => {
|
||||
body += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`session-info returned ${res.statusCode}: ${body}`));
|
||||
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(body));
|
||||
} catch (e) {
|
||||
reject(new Error(`session-info bad JSON: ${body}`));
|
||||
}
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
describe('mod_jitsi_session', () => {
|
||||
|
||||
const clients = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(clients.map(c => c.disconnect()));
|
||||
clients.length = 0;
|
||||
});
|
||||
|
||||
it('sets session.previd from ?previd query param', async () => {
|
||||
const c = await createXmppClient({ params: { previd: 'testprevid' } });
|
||||
|
||||
clients.push(c);
|
||||
const info = await getSessionInfo(c.jid);
|
||||
|
||||
assert.equal(info.previd, 'testprevid');
|
||||
});
|
||||
|
||||
it('sets session.jitsi_web_query_room from ?room query param', async () => {
|
||||
const c = await createXmppClient({ params: { room: 'myroom' } });
|
||||
|
||||
clients.push(c);
|
||||
const info = await getSessionInfo(c.jid);
|
||||
|
||||
assert.equal(info.jitsi_web_query_room, 'myroom');
|
||||
});
|
||||
|
||||
it('sets session.jitsi_web_query_prefix from ?prefix query param', async () => {
|
||||
const c = await createXmppClient({ params: { prefix: 'tenant1' } });
|
||||
|
||||
clients.push(c);
|
||||
const info = await getSessionInfo(c.jid);
|
||||
|
||||
assert.equal(info.jitsi_web_query_prefix, 'tenant1');
|
||||
});
|
||||
|
||||
it('sets session.jitsi_web_query_prefix to empty string when query string present but ?prefix absent', async () => {
|
||||
// A query string must be present so the if-block in mod_jitsi_session runs;
|
||||
// without any query string the field is never assigned and stays nil.
|
||||
const c = await createXmppClient({ params: { room: 'myroom' } });
|
||||
|
||||
clients.push(c);
|
||||
const info = await getSessionInfo(c.jid);
|
||||
|
||||
assert.equal(info.jitsi_web_query_prefix, '');
|
||||
});
|
||||
|
||||
it('rejects connection when ?token is present but invalid', async () => {
|
||||
// mod_jitsi_session sets session.auth_token from ?token, but with
|
||||
// authentication="token" (RS256/ASAP on localhost) the auth module
|
||||
// immediately tries to verify it. An invalid token string fails JWT
|
||||
// parsing before resource-bind, so the session fields are never
|
||||
// accessible via getSessionInfo.
|
||||
await assert.rejects(
|
||||
() => createXmppClient({ params: { token: 'notavalidjwt' } }),
|
||||
/not-allowed/
|
||||
);
|
||||
});
|
||||
|
||||
it('sets session.customusername from ?customusername query param', async () => {
|
||||
const c = await createXmppClient({ params: { customusername: 'alice' } });
|
||||
|
||||
clients.push(c);
|
||||
const info = await getSessionInfo(c.jid);
|
||||
|
||||
assert.equal(info.customusername, 'alice');
|
||||
});
|
||||
});
|
||||
238
tests/prosody/mod_muc_allowners_spec.js
Normal file
238
tests/prosody/mod_muc_allowners_spec.js
Normal file
@@ -0,0 +1,238 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import { mintAsapToken } from './helpers/jwt.js';
|
||||
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
|
||||
|
||||
// Uses a dedicated MUC component that has muc_allowners loaded and neither
|
||||
// muc_meeting_id (no jicofo lock) nor token_verification, so anonymous clients
|
||||
// can join freely. Room names listed in allowners_moderated_rooms in
|
||||
// docker/prosody.cfg.lua ('moderated-room-1', 'moderated-room-2') are treated
|
||||
// as moderated by util.is_moderated().
|
||||
const CONFERENCE = 'conference-allowners.localhost';
|
||||
const MUC_USER_NS = 'http://jabber.org/protocol/muc#user';
|
||||
|
||||
let _roomCounter = 0;
|
||||
const room = () => `allowners-${++_roomCounter}@${CONFERENCE}`;
|
||||
|
||||
/**
|
||||
* Returns the affiliation carried by a MUC presence stanza, or undefined.
|
||||
* @param {object} presence Presence stanza.
|
||||
* @returns {string|undefined}
|
||||
*/
|
||||
function affiliationOf(presence) {
|
||||
return presence.getChild('x', MUC_USER_NS)?.getChild('item')?.attrs.affiliation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when an IQ response is the auth/forbidden error reply sent by
|
||||
* filter_admin_set_query in mod_muc_allowners.
|
||||
* @param {object} iq IQ response stanza.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isForbidden(iq) {
|
||||
return iq.attrs.type === 'error'
|
||||
&& Boolean(iq.getChild('error')?.getChild('forbidden'));
|
||||
}
|
||||
|
||||
describe('mod_muc_allowners', () => {
|
||||
|
||||
let clients;
|
||||
|
||||
beforeEach(() => {
|
||||
clients = [];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(clients.map(c => c.disconnect()));
|
||||
});
|
||||
|
||||
/**
|
||||
* Connects an anonymous client and registers it for cleanup.
|
||||
* @param {object} [opts] Options forwarded to createXmppClient.
|
||||
* @returns {Promise<XmppTestClient>}
|
||||
*/
|
||||
async function connect(opts) {
|
||||
const c = await createXmppClient(opts);
|
||||
|
||||
clients.push(c);
|
||||
|
||||
return c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins as focus (jicofo admin) and registers the client for cleanup.
|
||||
* Focus is a Prosody admin, so mod_muc_allowners skips it and it creates
|
||||
* and unlocks the room as in production. Rooms must be created by focus:
|
||||
* a regular creator's initial owner self-presence would be filtered by
|
||||
* filter_stanza and the room would never be unlocked.
|
||||
*
|
||||
* @param {string} roomJid Full room JID.
|
||||
* @returns {Promise<XmppTestClient>}
|
||||
*/
|
||||
async function focusJoin(roomJid) {
|
||||
const c = await joinWithFocus(roomJid);
|
||||
|
||||
clients.push(c);
|
||||
|
||||
return c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins a room and resolves once the occupant holds the owner affiliation.
|
||||
* mod_muc_allowners filters out the initial 'participant' self-presence of
|
||||
* a joining occupant, so joinRoom usually resolves with the promotion
|
||||
* presence directly; handle both event orders to be safe.
|
||||
*
|
||||
* @param {XmppTestClient} c Connected client.
|
||||
* @param {string} roomJid Full room JID.
|
||||
* @returns {Promise<object>} The self-presence carrying the owner affiliation.
|
||||
*/
|
||||
async function joinAsOwner(c, roomJid) {
|
||||
const presence = await c.joinRoom(roomJid);
|
||||
|
||||
if (affiliationOf(presence) === 'owner') {
|
||||
return presence;
|
||||
}
|
||||
|
||||
return c.waitForPresence(p =>
|
||||
p.getChild('x', MUC_USER_NS)?.getChildren('status')
|
||||
.some(s => s.attrs.code === '110')
|
||||
&& affiliationOf(p) === 'owner');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the bare JID of a connected client.
|
||||
* @param {XmppTestClient} c Connected client.
|
||||
* @returns {string}
|
||||
*/
|
||||
const bareJid = c => c.jid.split('/')[0];
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Non-moderated rooms
|
||||
// -------------------------------------------------------------------------
|
||||
describe('non-moderated room', () => {
|
||||
|
||||
it('anonymous participants are promoted to owner on join', async () => {
|
||||
const r = room();
|
||||
|
||||
await focusJoin(r);
|
||||
|
||||
const p1 = await connect();
|
||||
const p2 = await connect();
|
||||
|
||||
const presence1 = await joinAsOwner(p1, r);
|
||||
const presence2 = await joinAsOwner(p2, r);
|
||||
|
||||
assert.equal(affiliationOf(presence1), 'owner', 'first participant must be promoted');
|
||||
assert.equal(affiliationOf(presence2), 'owner', 'second participant must be promoted');
|
||||
});
|
||||
|
||||
it('revoking an affiliation is rejected with forbidden', async () => {
|
||||
const r = room();
|
||||
|
||||
await focusJoin(r);
|
||||
|
||||
const p1 = await connect();
|
||||
const p2 = await connect();
|
||||
|
||||
await joinAsOwner(p1, r);
|
||||
await joinAsOwner(p2, r);
|
||||
|
||||
for (const affiliation of [ 'none', 'outcast', 'member' ]) {
|
||||
const response = await p1.sendMucAdmin(r, { jid: bareJid(p2),
|
||||
affiliation });
|
||||
|
||||
assert.ok(isForbidden(response),
|
||||
`affiliation='${affiliation}' must be rejected with forbidden`);
|
||||
}
|
||||
});
|
||||
|
||||
it('a revoke item after a benign first item is also rejected', async () => {
|
||||
const r = room();
|
||||
|
||||
await focusJoin(r);
|
||||
|
||||
const p1 = await connect();
|
||||
const p2 = await connect();
|
||||
const p3 = await connect();
|
||||
|
||||
await joinAsOwner(p1, r);
|
||||
await joinAsOwner(p2, r);
|
||||
await joinAsOwner(p3, r);
|
||||
|
||||
// The first item is an allowed grant; the second revokes p3. The
|
||||
// filter must inspect every item, not just the first one.
|
||||
const response = await p1.sendMucAdminItems(r, [
|
||||
{ jid: bareJid(p2),
|
||||
affiliation: 'owner' },
|
||||
{ jid: bareJid(p3),
|
||||
affiliation: 'none' }
|
||||
]);
|
||||
|
||||
assert.ok(isForbidden(response),
|
||||
'a batched revoke hidden behind a benign first item must be rejected');
|
||||
});
|
||||
|
||||
it('granting owner is allowed', async () => {
|
||||
const r = room();
|
||||
|
||||
await focusJoin(r);
|
||||
|
||||
const p1 = await connect();
|
||||
const p2 = await connect();
|
||||
|
||||
await joinAsOwner(p1, r);
|
||||
await joinAsOwner(p2, r);
|
||||
|
||||
const response = await p1.sendMucAdmin(r, { jid: bareJid(p2),
|
||||
affiliation: 'owner' });
|
||||
|
||||
assert.equal(response.attrs.type, 'result', 'granting owner must not be filtered');
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Moderated rooms (listed in allowners_moderated_rooms)
|
||||
// -------------------------------------------------------------------------
|
||||
describe('moderated room', () => {
|
||||
|
||||
it('anonymous participants are not promoted', async () => {
|
||||
const r = `moderated-room-1@${CONFERENCE}`;
|
||||
|
||||
await focusJoin(r);
|
||||
|
||||
const guest = await connect();
|
||||
const presence = await guest.joinRoom(r);
|
||||
|
||||
assert.equal(affiliationOf(presence), 'none',
|
||||
'anonymous participant must not be promoted in a moderated room');
|
||||
});
|
||||
|
||||
it('JWT host is promoted and may revoke affiliations', async () => {
|
||||
const roomName = 'moderated-room-2';
|
||||
const r = `${roomName}@${CONFERENCE}`;
|
||||
|
||||
await focusJoin(r);
|
||||
|
||||
const token = mintAsapToken({ room: roomName });
|
||||
const host = await connect({ params: { token } });
|
||||
|
||||
const presence = await joinAsOwner(host, r);
|
||||
|
||||
assert.equal(affiliationOf(presence), 'owner',
|
||||
'JWT host must be promoted in a moderated room');
|
||||
|
||||
const guest = await connect();
|
||||
|
||||
await guest.joinRoom(r);
|
||||
|
||||
// Revoking is filtered only in non-moderated rooms; in a moderated
|
||||
// room the owner may ban an occupant.
|
||||
const response = await host.sendMucAdmin(r, { jid: bareJid(guest),
|
||||
affiliation: 'outcast' });
|
||||
|
||||
assert.equal(response.attrs.type, 'result',
|
||||
'revoking must be allowed in a moderated room');
|
||||
});
|
||||
});
|
||||
});
|
||||
262
tests/prosody/mod_muc_auth_ban_spec.js
Normal file
262
tests/prosody/mod_muc_auth_ban_spec.js
Normal file
@@ -0,0 +1,262 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import { getContainer } from './helpers/container.js';
|
||||
import { mintAsapToken } from './helpers/jwt.js';
|
||||
import { setAccessManagerResponse } from './helpers/test_observer.js';
|
||||
import { createXmppClient } from './helpers/xmpp_client.js';
|
||||
|
||||
// VPaaS tenant prefix — mod_muc_auth_ban only calls the access manager for
|
||||
// sessions whose jitsi_web_query_prefix starts with this string.
|
||||
// The prefix is set from the ?prefix= WebSocket URL param by mod_jitsi_session.
|
||||
const VPAAS_PREFIX = 'vpaas-magic-cookie-test';
|
||||
|
||||
// Tokens are RS256 (deterministic): same payload in the same second → same
|
||||
// token string. Use a per-test jti so each test gets a unique token and cannot
|
||||
// accidentally match a token that was cached as banned by an earlier test.
|
||||
let _tokenCounter = 0;
|
||||
const freshToken = () => mintAsapToken({ room: '*',
|
||||
jti: `ban-test-${++_tokenCounter}` });
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Creates an XMPP client that identifies as a VPaaS session.
|
||||
* Passes ?prefix=<VPAAS_PREFIX>&token=<token> in the WebSocket URL so that
|
||||
* mod_jitsi_session populates jitsi_web_query_prefix, which mod_muc_auth_ban
|
||||
* uses to decide whether to call the external access manager.
|
||||
*
|
||||
* @param {string} token A valid login JWT (RS256 / HS256).
|
||||
* @returns {Promise<XmppTestClient>}
|
||||
*/
|
||||
function createVpaasClient(token) {
|
||||
return createXmppClient({
|
||||
params: { prefix: VPAAS_PREFIX,
|
||||
token }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a VPaaS session is banned, whichever way the ban surfaces.
|
||||
*
|
||||
* mod_muc_auth_ban's HTTP callback calls session:close(), and where that lands
|
||||
* depends on when the access manager answers. Answering within the same event
|
||||
* loop tick as the SASL auth refuses the connection outright; answering a tick
|
||||
* later lets the session establish and then closes it. Both are the same ban,
|
||||
* and which one happens is timing, so accept either rather than only the first.
|
||||
*
|
||||
* @param {string} token A login JWT the access manager will reject.
|
||||
* @param {string} message Assertion message.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function assertBanned(token, message) {
|
||||
let client;
|
||||
|
||||
try {
|
||||
client = await createVpaasClient(token);
|
||||
} catch (e) {
|
||||
// refused during SASL
|
||||
assert.match(String(e), /not-allowed/, message);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// established, so the close must follow
|
||||
const disconnected = await client.waitForDisconnect(5000).then(() => true, () => false);
|
||||
|
||||
if (!disconnected) {
|
||||
await client.disconnect();
|
||||
}
|
||||
|
||||
assert.ok(disconnected, message);
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('mod_muc_auth_ban', () => {
|
||||
|
||||
afterEach(async () => {
|
||||
// Reset the mock access manager to the default (allow, HTTP 200)
|
||||
// so that each test starts from a clean state.
|
||||
await setAccessManagerResponse({ access: true,
|
||||
status: 200 });
|
||||
});
|
||||
|
||||
// ── No token ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// mod_muc_auth_ban checks session.auth_token first. When nil it returns
|
||||
// nil (not false), so the ban-check event has no effect and auth proceeds.
|
||||
|
||||
it('user without a token is allowed (ban check is a no-op)', async () => {
|
||||
// No token param → session.auth_token is nil → shouldAllow returns nil.
|
||||
// VirtualHost "localhost" has allow_empty_token = true so auth still succeeds.
|
||||
const c = await createXmppClient();
|
||||
|
||||
await c.disconnect();
|
||||
});
|
||||
|
||||
// ── Non-VPaaS tenant ─────────────────────────────────────────────────────
|
||||
//
|
||||
// mod_muc_auth_ban returns true immediately for tenants whose prefix does
|
||||
// NOT start with "vpaas-magic-cookie-". The access manager is never called.
|
||||
|
||||
it('non-VPaaS user with a valid token is allowed without an HTTP check', async () => {
|
||||
// Configure the mock to return an error — if the module calls it for
|
||||
// non-VPaaS sessions this test will catch that regression.
|
||||
await setAccessManagerResponse({ status: 500 });
|
||||
|
||||
const token = freshToken();
|
||||
|
||||
// No ?prefix= param → jitsi_web_query_prefix is "" → bypasses VPaaS check.
|
||||
const c = await createXmppClient({ params: { token } });
|
||||
|
||||
await c.disconnect();
|
||||
});
|
||||
|
||||
// ── VPaaS — access granted ────────────────────────────────────────────────
|
||||
|
||||
it('VPaaS user is allowed when access manager returns access=true', async () => {
|
||||
await setAccessManagerResponse({ access: true });
|
||||
|
||||
const token = freshToken();
|
||||
const c = await createVpaasClient(token);
|
||||
|
||||
// Stay connected for a moment to confirm the async HTTP response does
|
||||
// not trigger a session close.
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
await c.disconnect();
|
||||
});
|
||||
|
||||
// ── VPaaS — access denied ─────────────────────────────────────────────────
|
||||
//
|
||||
// When access=false, mod_muc_auth_ban's HTTP callback calls session:close()
|
||||
// and caches the token. The access manager runs on the same Prosody process
|
||||
// (loopback), so the callback usually resolves within the same event loop
|
||||
// tick as the SASL auth and the ban surfaces as a SASL failure, but under
|
||||
// load it can resolve a tick later and close an established session instead.
|
||||
// assertBanned accepts either.
|
||||
|
||||
it('VPaaS user is rejected when access manager returns access=false', async () => {
|
||||
await setAccessManagerResponse({ access: false });
|
||||
|
||||
await assertBanned(freshToken(),
|
||||
'VPaaS user must be rejected when access manager returns access=false');
|
||||
});
|
||||
|
||||
// ── Cached ban ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// When a token is rejected (access=false), mod_muc_auth_ban caches it with a
|
||||
// 5-minute TTL. Subsequent connections with the same token hit the Lua LRU
|
||||
// cache (no HTTP request) and are rejected immediately — even after the mock
|
||||
// access manager is reset to allow. This verifies the cache is active.
|
||||
|
||||
it('cached banned token is rejected even when access manager is reset to allow', async () => {
|
||||
// Step 1: Ban the token — cb fires, token enters the LRU cache.
|
||||
await setAccessManagerResponse({ access: false });
|
||||
|
||||
const token = freshToken();
|
||||
|
||||
await assertBanned(token, 'initial ban must be rejected');
|
||||
|
||||
// Step 2: Reset the mock to allow. A fresh token must now succeed,
|
||||
// proving the cache — not the mock response — drives the next rejection.
|
||||
await setAccessManagerResponse({ access: true });
|
||||
|
||||
const fresh = await createVpaasClient(freshToken());
|
||||
|
||||
await fresh.disconnect();
|
||||
|
||||
// Step 3: Same banned token must still be rejected (cache wins over mock).
|
||||
await assertBanned(token,
|
||||
'cached banned token must be rejected even when mock is reset to allow');
|
||||
});
|
||||
|
||||
// ── HTTP error — fail open ────────────────────────────────────────────────
|
||||
//
|
||||
// If the access manager returns a non-200 response, mod_muc_auth_ban logs
|
||||
// a warning and does nothing — the session is NOT closed (fail open).
|
||||
|
||||
it('HTTP error from access manager does not ban the user (fail open)', async () => {
|
||||
await setAccessManagerResponse({ status: 500 });
|
||||
|
||||
const token = freshToken();
|
||||
const c = await createVpaasClient(token);
|
||||
|
||||
// Wait long enough for the async HTTP callback to have fired.
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// The client must still be connected. waitForDisconnect would reject
|
||||
// after the timeout — instead, assert the connection is alive by
|
||||
// verifying disconnect() completes cleanly.
|
||||
await c.disconnect();
|
||||
});
|
||||
|
||||
// ── Non-JSON 200 — should fail open without crashing ─────────────────────
|
||||
//
|
||||
// When the access manager returns HTTP 200 but with a non-JSON body,
|
||||
// json.decode() returns nil. mod_muc_auth_ban then calls r['access'] on
|
||||
// nil, which crashes the callback. The crash must not affect the session
|
||||
// (fail open: user stays connected).
|
||||
|
||||
it('non-JSON 200 from access manager does not crash or ban the user (fail open)', async () => {
|
||||
await setAccessManagerResponse({ nonJson: true });
|
||||
|
||||
const token = freshToken();
|
||||
const c = await createVpaasClient(token);
|
||||
|
||||
// Wait long enough for the async HTTP callback to have fired and crashed.
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Session must still be alive despite the callback crash.
|
||||
const disconnected = await c.waitForDisconnect(300).then(() => true, () => false);
|
||||
|
||||
assert.strictEqual(disconnected, false,
|
||||
'session must remain alive when access manager returns non-JSON 200');
|
||||
|
||||
await c.disconnect();
|
||||
});
|
||||
|
||||
// ── Non-JSON 200 — Lua error in callback ─────────────────────────────────
|
||||
//
|
||||
// cjson.safe returns nil (not an error) for invalid JSON, so json.decode()
|
||||
// returns nil when the access manager body is not JSON. Without a nil guard,
|
||||
// r['access'] on the nil value crashes the callback with "attempt to index
|
||||
// a nil value". This test catches that crash by asserting that no such error
|
||||
// appears in the Prosody log during the test window.
|
||||
|
||||
it('non-JSON 200 does not produce a Lua error in the HTTP callback', async () => {
|
||||
const since = Math.floor(Date.now() / 1000);
|
||||
|
||||
await setAccessManagerResponse({ nonJson: true });
|
||||
const token = freshToken();
|
||||
const c = await createVpaasClient(token);
|
||||
|
||||
// Give the async HTTP callback time to fire (and crash if the bug is present).
|
||||
await new Promise(resolve => setTimeout(resolve, 600));
|
||||
await c.disconnect();
|
||||
|
||||
// Collect Prosody log output from this test's window.
|
||||
const until = Math.floor(Date.now() / 1000) + 1;
|
||||
const container = getContainer();
|
||||
const stream = await container.logs({ since,
|
||||
until });
|
||||
const logs = await new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
const timer = setTimeout(() => resolve(chunks.join('')), 2000);
|
||||
|
||||
stream.on('data', chunk => chunks.push(chunk.toString()));
|
||||
stream.on('end', () => {
|
||||
clearTimeout(timer);
|
||||
resolve(chunks.join(''));
|
||||
});
|
||||
stream.on('error', reject);
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
!logs.includes('attempt to index a nil value'),
|
||||
'mod_muc_auth_ban HTTP callback must not crash on non-JSON 200 response '
|
||||
+ '(missing nil guard on json.decode result)'
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
373
tests/prosody/mod_muc_breakout_rooms_spec.js
Normal file
373
tests/prosody/mod_muc_breakout_rooms_spec.js
Normal file
@@ -0,0 +1,373 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import { mintAsapToken } from './helpers/jwt.js';
|
||||
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
|
||||
|
||||
const CONFERENCE = 'conference.localhost';
|
||||
const BREAKOUT_MUC = 'breakout.conference.localhost';
|
||||
const JITMEET_NS = 'http://jitsi.org/jitmeet';
|
||||
|
||||
// JSON message type constants (must match mod_muc_breakout_rooms.lua)
|
||||
const BREAKOUT_ROOMS_TYPE = 'breakout_rooms';
|
||||
const EVENT_UPDATE = 'features/breakout-rooms/update';
|
||||
const OP_ADD = 'features/breakout-rooms/add';
|
||||
const OP_REMOVE = 'features/breakout-rooms/remove';
|
||||
const OP_RENAME = 'features/breakout-rooms/rename';
|
||||
|
||||
let _roomCounter = 0;
|
||||
|
||||
const nextRoomName = () => `br-test-${++_roomCounter}`;
|
||||
const roomJid = name => `${name}@${CONFERENCE}`;
|
||||
|
||||
/**
|
||||
* Parses the JSON payload from a <json-message xmlns="http://jitsi.org/jitmeet"> stanza.
|
||||
* Returns null if the stanza has no such child or the body is not valid JSON.
|
||||
*
|
||||
* @param {object} stanza
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function parseJsonMessage(stanza) {
|
||||
const jm = stanza.getChild('json-message', JITMEET_NS);
|
||||
|
||||
if (!jm) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(jm.getText());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for a breakout_rooms update broadcast on the given client.
|
||||
* Resolves with the parsed JSON payload.
|
||||
*
|
||||
* mod_muc_breakout_rooms broadcasts only to non-admin occupants, so always
|
||||
* wait on a JWT moderator client, never on the focus (admin) client.
|
||||
*
|
||||
* @param {object} client
|
||||
* @param {number} [timeout=6000] ms — must exceed BROADCAST_ROOMS_INTERVAL (300 ms)
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
async function waitForBreakoutUpdate(client, timeout = 6000) {
|
||||
const stanza = await client.waitForMessage(s => {
|
||||
const p = parseJsonMessage(s);
|
||||
|
||||
return p?.type === BREAKOUT_ROOMS_TYPE && p?.event === EVENT_UPDATE;
|
||||
}, timeout);
|
||||
|
||||
return parseJsonMessage(stanza);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a conference room with focus and an authenticated moderator.
|
||||
*
|
||||
* Focus joins first to satisfy the mod_muc_meeting_id jicofo lock so that
|
||||
* regular clients can join. The moderator connects with ?room=name so that
|
||||
* mod_jitsi_session sets jitsi_web_query_room, which mod_muc_breakout_rooms
|
||||
* uses to look up the main room. The JWT token carries context.user.moderator=true
|
||||
* so mod_token_affiliation promotes the client to moderator on join.
|
||||
*
|
||||
* mod_muc_breakout_rooms excludes Prosody admins (focus) from update broadcasts,
|
||||
* so the moderator — who is NOT a Prosody admin — acts as the broadcast receiver
|
||||
* in tests.
|
||||
*
|
||||
* @param {string} name bare room name, e.g. 'br-test-1'
|
||||
* @returns {Promise<{focus: object, moderator: object}>}
|
||||
*/
|
||||
async function createRoom(name) {
|
||||
const jid = roomJid(name);
|
||||
const focus = await joinWithFocus(jid);
|
||||
const token = mintAsapToken({
|
||||
room: name,
|
||||
context: { user: { moderator: true } }
|
||||
});
|
||||
const moderator = await createXmppClient({ params: { room: name,
|
||||
token } });
|
||||
|
||||
await moderator.joinRoom(jid);
|
||||
|
||||
return { focus,
|
||||
moderator };
|
||||
}
|
||||
|
||||
describe('mod_muc_breakout_rooms', () => {
|
||||
|
||||
let clients;
|
||||
|
||||
beforeEach(() => {
|
||||
clients = [];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(clients.map(c => c.disconnect()));
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// breakout room creation
|
||||
// -------------------------------------------------------------------------
|
||||
describe('create breakout room', () => {
|
||||
|
||||
it('moderator receives an update broadcast after creating a breakout room', async () => {
|
||||
const name = nextRoomName();
|
||||
const { focus, moderator } = await createRoom(name);
|
||||
|
||||
clients.push(focus, moderator);
|
||||
|
||||
await moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_ADD, { subject: 'Group 1' });
|
||||
|
||||
const payload = await waitForBreakoutUpdate(moderator);
|
||||
|
||||
assert.equal(payload.type, BREAKOUT_ROOMS_TYPE);
|
||||
assert.equal(payload.event, EVENT_UPDATE);
|
||||
});
|
||||
|
||||
it('update payload contains the main room and the new breakout room', async () => {
|
||||
const name = nextRoomName();
|
||||
const { focus, moderator } = await createRoom(name);
|
||||
|
||||
clients.push(focus, moderator);
|
||||
|
||||
await moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_ADD, { subject: 'Group A' });
|
||||
|
||||
const payload = await waitForBreakoutUpdate(moderator);
|
||||
const rooms = Object.values(payload.rooms);
|
||||
|
||||
assert.equal(rooms.length, 2, 'payload must contain main room + one breakout room');
|
||||
|
||||
const mainRoom = rooms.find(r => r.isMainRoom);
|
||||
const breakoutRoom = rooms.find(r => !r.isMainRoom);
|
||||
|
||||
assert.ok(mainRoom, 'one room must be flagged as the main room');
|
||||
assert.ok(breakoutRoom, 'one room must be the breakout room');
|
||||
assert.equal(breakoutRoom.name, 'Group A');
|
||||
assert.ok(
|
||||
breakoutRoom.jid.endsWith(`@${BREAKOUT_MUC}`),
|
||||
`breakout room JID must be on ${BREAKOUT_MUC}`
|
||||
);
|
||||
});
|
||||
|
||||
it('all non-admin occupants of the main room receive the update broadcast', async () => {
|
||||
const name = nextRoomName();
|
||||
const { focus, moderator } = await createRoom(name);
|
||||
|
||||
// A second non-admin participant.
|
||||
const participant = await createXmppClient({ params: { room: name } });
|
||||
|
||||
clients.push(focus, moderator, participant);
|
||||
await participant.joinRoom(roomJid(name));
|
||||
|
||||
await moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_ADD, { subject: 'Team Room' });
|
||||
|
||||
const [ modPayload, partPayload ] = await Promise.all([
|
||||
waitForBreakoutUpdate(moderator),
|
||||
waitForBreakoutUpdate(participant)
|
||||
]);
|
||||
|
||||
assert.equal(modPayload.event, EVENT_UPDATE, 'moderator must receive the update');
|
||||
assert.equal(partPayload.event, EVENT_UPDATE, 'regular participant must receive the update');
|
||||
});
|
||||
|
||||
it('roomCounter increments with each created breakout room', async () => {
|
||||
const name = nextRoomName();
|
||||
const { focus, moderator } = await createRoom(name);
|
||||
|
||||
clients.push(focus, moderator);
|
||||
|
||||
await moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_ADD, { subject: 'Room 1' });
|
||||
const first = await waitForBreakoutUpdate(moderator);
|
||||
|
||||
await moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_ADD, { subject: 'Room 2' });
|
||||
const second = await waitForBreakoutUpdate(moderator);
|
||||
|
||||
assert.ok(second.roomCounter > first.roomCounter, 'roomCounter must increase');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// breakout room removal
|
||||
// -------------------------------------------------------------------------
|
||||
describe('remove breakout room', () => {
|
||||
|
||||
it('moderator can remove a breakout room and receives an update', async () => {
|
||||
const name = nextRoomName();
|
||||
const { focus, moderator } = await createRoom(name);
|
||||
|
||||
clients.push(focus, moderator);
|
||||
|
||||
// Create
|
||||
await moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_ADD, { subject: 'Temp Room' });
|
||||
const createPayload = await waitForBreakoutUpdate(moderator);
|
||||
|
||||
const breakoutJid = Object.values(createPayload.rooms)
|
||||
.find(r => !r.isMainRoom)?.jid;
|
||||
|
||||
assert.ok(breakoutJid, 'breakout room JID must be present in create update');
|
||||
|
||||
// Remove
|
||||
await moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_REMOVE, {
|
||||
breakoutRoomJid: breakoutJid
|
||||
});
|
||||
|
||||
const removePayload = await waitForBreakoutUpdate(moderator);
|
||||
const rooms = Object.values(removePayload.rooms);
|
||||
|
||||
assert.equal(rooms.length, 1, 'only the main room must remain after removal');
|
||||
assert.ok(rooms[0].isMainRoom, 'remaining room must be the main room');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// breakout room rename
|
||||
// -------------------------------------------------------------------------
|
||||
describe('rename breakout room', () => {
|
||||
|
||||
it('moderator can rename a breakout room and receives an update with the new name', async () => {
|
||||
const name = nextRoomName();
|
||||
const { focus, moderator } = await createRoom(name);
|
||||
|
||||
clients.push(focus, moderator);
|
||||
|
||||
await moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_ADD, { subject: 'Original Name' });
|
||||
const createPayload = await waitForBreakoutUpdate(moderator);
|
||||
|
||||
const breakoutJid = Object.values(createPayload.rooms)
|
||||
.find(r => !r.isMainRoom)?.jid;
|
||||
|
||||
await moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_RENAME, {
|
||||
breakoutRoomJid: breakoutJid,
|
||||
subject: 'Renamed Room'
|
||||
});
|
||||
|
||||
const renamePayload = await waitForBreakoutUpdate(moderator);
|
||||
const renamedRoom = Object.values(renamePayload.rooms).find(r => !r.isMainRoom);
|
||||
|
||||
assert.ok(renamedRoom, 'breakout room must still exist after rename');
|
||||
assert.equal(renamedRoom.name, 'Renamed Room');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// smacks regression: eb43cf601
|
||||
// -------------------------------------------------------------------------
|
||||
describe('smacks session resume', () => {
|
||||
|
||||
it('participant can join breakout room after smacks session resume', async () => {
|
||||
// Regression test for eb43cf601:
|
||||
// mod_auth_token.lua's c2s-session-updated handler used to copy
|
||||
// jitsi_breakout_main_jid from the fresh TCP session (which never
|
||||
// joined a room, so the field is nil) onto the old hibernating
|
||||
// session, silently overwriting the valid value set when the user
|
||||
// originally joined the main room. Without the fix, the subsequent
|
||||
// on_occupant_pre_join_or_change check
|
||||
// origin.jitsi_breakout_main_jid ~= main_room.jid
|
||||
// fails with nil ~= main_room_jid → not-allowed error.
|
||||
const name = nextRoomName();
|
||||
const { focus, moderator } = await createRoom(name);
|
||||
|
||||
clients.push(focus, moderator);
|
||||
|
||||
// Register a breakout room via the OP_ADD message flow and capture its JID.
|
||||
await moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_ADD, { subject: 'Breakout 1' });
|
||||
const createPayload = await waitForBreakoutUpdate(moderator);
|
||||
const breakoutJid = Object.values(createPayload.rooms).find(r => !r.isMainRoom)?.jid;
|
||||
|
||||
assert.ok(breakoutJid, 'breakout room JID must be in the create broadcast');
|
||||
|
||||
// Focus (jicofo) joins the breakout room first to physically create it,
|
||||
// matching the real-life flow where Jicofo creates the room on the
|
||||
// breakout component (which has restrict_room_creation=true, so only
|
||||
// Prosody admins like focus@auth.localhost can create rooms here).
|
||||
const focusBreakoutPresence = await focus.joinRoom(breakoutJid, 'focus');
|
||||
|
||||
assert.notEqual(
|
||||
focusBreakoutPresence.attrs.type,
|
||||
'error',
|
||||
'focus must be able to join (create) the breakout room'
|
||||
);
|
||||
|
||||
// Arm the reconnect listener BEFORE dropping so we cannot miss the
|
||||
// 'reconnected' event even if the reconnect is very fast.
|
||||
const reconnected = moderator.waitForReconnect();
|
||||
|
||||
// Snap the WebSocket. @xmpp/client schedules a reconnect after ~1 s;
|
||||
// on reconnect it sends <resume> which triggers Prosody's
|
||||
// c2s-session-updated — the event where the bug cleared jitsi_breakout_main_jid.
|
||||
moderator.dropConnection();
|
||||
|
||||
// Wait until SMACKS resume is fully complete (entity.status === 'online').
|
||||
// waitForReconnect() polls entity.status after the @xmpp/reconnect
|
||||
// 'reconnected' event, because that event fires on stream-open which
|
||||
// is before stream features (including SMACKS) are negotiated.
|
||||
await reconnected;
|
||||
|
||||
// The moderator now tries to join the breakout room.
|
||||
// With the bug: jitsi_breakout_main_jid is nil → not-allowed error.
|
||||
// With the fix: jitsi_breakout_main_jid is preserved → join succeeds.
|
||||
const presence = await moderator.joinRoom(breakoutJid);
|
||||
|
||||
assert.notEqual(
|
||||
presence.attrs.type,
|
||||
'error',
|
||||
'participant must be able to join breakout room after smacks session resume'
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// access control
|
||||
// -------------------------------------------------------------------------
|
||||
describe('access control', () => {
|
||||
|
||||
it('non-admin cannot create rooms on the breakout component (restrict_room_creation)', async () => {
|
||||
// Any anonymous or JWT-authenticated client that is NOT a Prosody
|
||||
// admin (i.e. not focus@auth.localhost) should receive a <forbidden/>
|
||||
// or similar error when trying to create a new room on the breakout
|
||||
// MUC component. restrict_room_creation=true on the component is the
|
||||
// first line of defence; on_breakout_room_pre_create is the second.
|
||||
const name = nextRoomName();
|
||||
const user = await createXmppClient({ params: { room: name } });
|
||||
|
||||
clients.push(user);
|
||||
|
||||
const fakeBreakoutJid = `00000000-0000-0000-0000-000000000000@${BREAKOUT_MUC}`;
|
||||
const presence = await user.joinRoom(fakeBreakoutJid, undefined, { timeout: 15000 });
|
||||
|
||||
assert.equal(
|
||||
presence.attrs.type,
|
||||
'error',
|
||||
'non-admin must receive an error when attempting to create a breakout room'
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores create requests from non-moderator participants', async () => {
|
||||
const name = nextRoomName();
|
||||
const { focus, moderator } = await createRoom(name);
|
||||
|
||||
// Anonymous participant — no moderator token means participant role.
|
||||
const participant = await createXmppClient({ params: { room: name } });
|
||||
|
||||
clients.push(focus, moderator, participant);
|
||||
await participant.joinRoom(roomJid(name));
|
||||
|
||||
// Participant sends a create request — the module must ignore it.
|
||||
await participant.sendBreakoutRoomsMessage(BREAKOUT_MUC, OP_ADD, {
|
||||
subject: 'Unauthorized Room'
|
||||
});
|
||||
|
||||
// A broadcast would only arrive if the room was actually created.
|
||||
await assert.rejects(
|
||||
waitForBreakoutUpdate(participant, 1500),
|
||||
/Timeout/,
|
||||
'no broadcast must be received when a non-moderator attempts to create a room'
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
119
tests/prosody/mod_muc_census_spec.js
Normal file
119
tests/prosody/mod_muc_census_spec.js
Normal file
@@ -0,0 +1,119 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import { createTestContext } from './helpers/test_context.js';
|
||||
import { setRoomMaxOccupants } from './helpers/test_observer.js';
|
||||
|
||||
const CONFERENCE = 'conference.localhost';
|
||||
const BASE = 'http://localhost:5280';
|
||||
|
||||
let _roomCounter = 0;
|
||||
const room = () => `census-${++_roomCounter}@${CONFERENCE}`;
|
||||
|
||||
/**
|
||||
* Fetch GET /room-census and return the parsed body.
|
||||
*
|
||||
* @returns {Promise<{room_census: Array}>}
|
||||
*/
|
||||
async function getRoomCensus() {
|
||||
const res = await fetch(`${BASE}/room-census`);
|
||||
const text = await res.text();
|
||||
|
||||
assert.equal(res.status, 200, `GET /room-census must return 200, got ${res.status}: ${text}`);
|
||||
|
||||
let body;
|
||||
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch (e) {
|
||||
assert.fail(`GET /room-census returned non-JSON: ${text}`);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
describe('mod_muc_census', () => {
|
||||
|
||||
let ctx;
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = createTestContext();
|
||||
});
|
||||
|
||||
afterEach(() => ctx.cleanup());
|
||||
|
||||
it('returns 200 with a room_census array', async () => {
|
||||
const body = await getRoomCensus();
|
||||
|
||||
assert.ok(Array.isArray(body.room_census),
|
||||
`response must have a room_census array, got: ${JSON.stringify(body)}`);
|
||||
});
|
||||
|
||||
it('only active rooms appear in census after a room is destroyed', async () => {
|
||||
const r1 = room();
|
||||
const r2 = room();
|
||||
const r1Name = r1.split('@')[0];
|
||||
const r2Name = r2.split('@')[0];
|
||||
|
||||
// Bring up both rooms.
|
||||
const focus1 = await ctx.connectFocus(r1);
|
||||
|
||||
await ctx.connectFocus(r2);
|
||||
const c1 = await ctx.connect();
|
||||
const c2 = await ctx.connect();
|
||||
|
||||
await c1.joinRoom(r1);
|
||||
await c2.joinRoom(r2);
|
||||
|
||||
const { room_census: before } = await getRoomCensus();
|
||||
|
||||
assert.ok(before.find(e => e.room_name && e.room_name.startsWith(r1Name)),
|
||||
'room1 must appear in census while active');
|
||||
assert.ok(before.find(e => e.room_name && e.room_name.startsWith(r2Name)),
|
||||
'room2 must appear in census while active');
|
||||
|
||||
// Destroy room1 by disconnecting all its occupants.
|
||||
await c1.disconnect();
|
||||
await focus1.disconnect();
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
const { room_census: after } = await getRoomCensus();
|
||||
|
||||
assert.ok(!after.find(e => e.room_name && e.room_name.startsWith(r1Name)),
|
||||
'room1 must not appear in census after all occupants leave');
|
||||
assert.ok(after.find(e => e.room_name && e.room_name.startsWith(r2Name)),
|
||||
'room2 must still appear in census');
|
||||
});
|
||||
|
||||
it('tracks room occupancy and updates when clients leave', async () => {
|
||||
const r = room();
|
||||
const roomName = r.split('@')[0];
|
||||
|
||||
await ctx.connectFocus(r);
|
||||
await setRoomMaxOccupants(r, 5);
|
||||
|
||||
const c1 = await ctx.connect();
|
||||
const c2 = await ctx.connect();
|
||||
|
||||
await c1.joinRoom(r);
|
||||
await c2.joinRoom(r);
|
||||
|
||||
// Room must appear with correct participant count and fields.
|
||||
const { room_census: census1 } = await getRoomCensus();
|
||||
const entry1 = census1.find(e => e.room_name && e.room_name.startsWith(roomName));
|
||||
|
||||
assert.ok(entry1, `room ${roomName} must appear in census after join`);
|
||||
assert.ok(typeof entry1.created_time !== 'undefined', 'entry must have created_time');
|
||||
assert.equal(entry1.participants, 2, 'participants must equal number of non-focus clients');
|
||||
|
||||
// After one client leaves the count must drop.
|
||||
await c2.disconnect();
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
const { room_census: census2 } = await getRoomCensus();
|
||||
const entry2 = census2.find(e => e.room_name && e.room_name.startsWith(roomName));
|
||||
|
||||
assert.ok(entry2, 'room must still appear after one client leaves');
|
||||
assert.equal(entry2.participants, 1,
|
||||
`participant count must be 1 after disconnect (got ${entry2.participants})`);
|
||||
});
|
||||
});
|
||||
165
tests/prosody/mod_muc_cleanup_backend_services_spec.js
Normal file
165
tests/prosody/mod_muc_cleanup_backend_services_spec.js
Normal file
@@ -0,0 +1,165 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import { createTestContext } from './helpers/test_context.js';
|
||||
import { getRoomState, setBreakoutRoomsActive } from './helpers/test_observer.js';
|
||||
|
||||
const CONFERENCE = 'conference.localhost';
|
||||
|
||||
let _roomCounter = 0;
|
||||
const room = () => `cleanup-svc-${++_roomCounter}@${CONFERENCE}`;
|
||||
|
||||
/** Resolves after ms milliseconds. */
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
// services_empty_meeting_timeout is set to 1 s in prosody.cfg.lua.
|
||||
// We wait TIMER_MS + BUFFER_MS to be sure the timer has either fired or not.
|
||||
const TIMER_MS = 1000;
|
||||
const BUFFER_MS = 1000;
|
||||
|
||||
describe('mod_muc_cleanup_backend_services', () => {
|
||||
let ctx;
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = createTestContext();
|
||||
});
|
||||
|
||||
afterEach(() => ctx.cleanup());
|
||||
|
||||
it('destroys room when last regular user leaves and only jibri remains', async () => {
|
||||
const r = room();
|
||||
|
||||
await ctx.connectFocus(r);
|
||||
await ctx.connectJibri(r);
|
||||
const user = await ctx.connect();
|
||||
|
||||
await user.joinRoom(r);
|
||||
await user.disconnect();
|
||||
|
||||
await sleep(TIMER_MS + BUFFER_MS);
|
||||
|
||||
const state = await getRoomState(r);
|
||||
|
||||
assert.equal(state, null, 'room should be destroyed after timeout');
|
||||
});
|
||||
|
||||
it('destroys room when last regular user leaves and only transcriber remains', async () => {
|
||||
const r = room();
|
||||
|
||||
await ctx.connectFocus(r);
|
||||
await ctx.connectTranscriber(r);
|
||||
const user = await ctx.connect();
|
||||
|
||||
await user.joinRoom(r);
|
||||
await user.disconnect();
|
||||
|
||||
await sleep(TIMER_MS + BUFFER_MS);
|
||||
|
||||
const state = await getRoomState(r);
|
||||
|
||||
assert.equal(state, null, 'room should be destroyed after timeout');
|
||||
});
|
||||
|
||||
it('does not destroy room when a second regular user is still present', async () => {
|
||||
const r = room();
|
||||
|
||||
await ctx.connectFocus(r);
|
||||
await ctx.connectJibri(r);
|
||||
const user1 = await ctx.connect();
|
||||
const user2 = await ctx.connect();
|
||||
|
||||
await user1.joinRoom(r);
|
||||
await user2.joinRoom(r);
|
||||
await user1.disconnect();
|
||||
|
||||
await sleep(TIMER_MS + BUFFER_MS);
|
||||
|
||||
const state = await getRoomState(r);
|
||||
|
||||
assert.ok(state, 'room should still exist — user2 is still a regular participant');
|
||||
});
|
||||
|
||||
it('cancels the destroy timer when a regular user joins after timer starts', async () => {
|
||||
const r = room();
|
||||
|
||||
await ctx.connectFocus(r);
|
||||
await ctx.connectJibri(r);
|
||||
const user = await ctx.connect();
|
||||
|
||||
await user.joinRoom(r);
|
||||
|
||||
// user leaves — timer starts counting down
|
||||
await user.disconnect();
|
||||
|
||||
// a new regular user joins immediately, which must cancel the timer
|
||||
const user2 = await ctx.connect();
|
||||
|
||||
await user2.joinRoom(r);
|
||||
|
||||
await sleep(TIMER_MS + BUFFER_MS);
|
||||
|
||||
const state = await getRoomState(r);
|
||||
|
||||
assert.ok(state, 'room should survive — destroy timer was cancelled by user2 joining');
|
||||
});
|
||||
|
||||
it('does not start timer when jibri leaves and a regular user is still present', async () => {
|
||||
const r = room();
|
||||
|
||||
await ctx.connectFocus(r);
|
||||
const jibri = await ctx.connectJibri(r);
|
||||
const user = await ctx.connect();
|
||||
|
||||
await user.joinRoom(r);
|
||||
|
||||
// jibri leaves — muc-occupant-left hook returns early (leaver is jibri)
|
||||
await jibri.disconnect();
|
||||
|
||||
await sleep(TIMER_MS + BUFFER_MS);
|
||||
|
||||
const state = await getRoomState(r);
|
||||
|
||||
assert.ok(state, 'room should survive — timer is not started when jibri is the leaver');
|
||||
});
|
||||
|
||||
it('does not start timer when focus (admin) leaves', async () => {
|
||||
const r = room();
|
||||
|
||||
const focus = await ctx.connectFocus(r);
|
||||
|
||||
await ctx.connectJibri(r);
|
||||
const user = await ctx.connect();
|
||||
|
||||
await user.joinRoom(r);
|
||||
|
||||
// admin leaves — muc-occupant-left hook returns early (leaver is admin)
|
||||
await focus.disconnect();
|
||||
|
||||
await sleep(TIMER_MS + BUFFER_MS);
|
||||
|
||||
const state = await getRoomState(r);
|
||||
|
||||
assert.ok(state, 'room should survive — timer is not started when an admin leaves');
|
||||
});
|
||||
|
||||
it('does not start timer when breakout rooms are active', async () => {
|
||||
const r = room();
|
||||
|
||||
await ctx.connectFocus(r);
|
||||
await ctx.connectJibri(r);
|
||||
const user = await ctx.connect();
|
||||
|
||||
await user.joinRoom(r);
|
||||
|
||||
// Simulate active breakout rooms — hook returns early when this flag is set.
|
||||
await setBreakoutRoomsActive(r, true);
|
||||
|
||||
// user leaves — would normally start the timer, but breakout_rooms_active prevents it
|
||||
await user.disconnect();
|
||||
|
||||
await sleep(TIMER_MS + BUFFER_MS);
|
||||
|
||||
const state = await getRoomState(r);
|
||||
|
||||
assert.ok(state, 'room should survive — breakout_rooms_active prevents the destroy timer');
|
||||
});
|
||||
});
|
||||
346
tests/prosody/mod_muc_displayname_spec.js
Normal file
346
tests/prosody/mod_muc_displayname_spec.js
Normal file
@@ -0,0 +1,346 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import { mintAsapToken } from './helpers/jwt.js';
|
||||
import { setHideDisplayNameForGuests, setRoomMaxOccupants } from './helpers/test_observer.js';
|
||||
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
|
||||
|
||||
const CONFERENCE = 'conference.localhost';
|
||||
const NICK_NS = 'http://jabber.org/protocol/nick';
|
||||
const DISPLAY_NAME_NS = 'http://jitsi.org/protocol/display-name';
|
||||
|
||||
let _counter = 0;
|
||||
const nextRoom = () => `displayname-${++_counter}@${CONFERENCE}`;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Connects a client on the main VirtualHost with a given token context.
|
||||
* Always passes ?room= so that session.jitsi_web_query_room is set —
|
||||
* required by the hideDisplayNameForGuests outbound filter.
|
||||
*
|
||||
* @param {string} roomJid
|
||||
* @param {object} [contextUser] JWT context.user claims (e.g. { name: 'Alice' })
|
||||
* @param {object} [features] JWT context.features claims
|
||||
*/
|
||||
function connect(roomJid, { contextUser, features } = {}) {
|
||||
const roomName = roomJid.split('@')[0];
|
||||
const context = {};
|
||||
|
||||
if (contextUser !== undefined) {
|
||||
context.user = contextUser;
|
||||
}
|
||||
if (features !== undefined) {
|
||||
context.features = features;
|
||||
}
|
||||
const token = mintAsapToken({ room: roomName,
|
||||
context });
|
||||
|
||||
return createXmppClient({ params: { room: roomName,
|
||||
token } });
|
||||
}
|
||||
|
||||
const connectModerator = roomJid => connect(roomJid, { contextUser: { moderator: true } });
|
||||
|
||||
/**
|
||||
* Waits for a non-error, non-unavailable presence from a specific occupant nick.
|
||||
* Skips any presences already queued from earlier occupants.
|
||||
*
|
||||
* @param {object} client observer client
|
||||
* @param {string} roomJid
|
||||
* @param {string} nick MUC nick of the occupant to wait for
|
||||
*/
|
||||
function waitForOtherPresence(client, roomJid, nick) {
|
||||
return client.waitForPresence(
|
||||
s => s.attrs.from === `${roomJid}/${nick}` && !s.attrs.type,
|
||||
5000
|
||||
);
|
||||
}
|
||||
|
||||
// ─── name-readonly ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('mod_muc_displayname — name-readonly', () => {
|
||||
|
||||
const clients = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(clients.map(c => c.disconnect()));
|
||||
clients.length = 0;
|
||||
});
|
||||
|
||||
it('enforces token name: observer sees token name even when sender sent a different nick', async () => {
|
||||
const r = nextRoom();
|
||||
|
||||
const focus = await joinWithFocus(r);
|
||||
const observer = await createXmppClient({ params: { room: r.split('@')[0] } });
|
||||
|
||||
clients.push(focus, observer);
|
||||
await observer.joinRoom(r);
|
||||
|
||||
// Alice connects with name-readonly=true and name="Alice" in the token.
|
||||
const alice = await connect(r, {
|
||||
contextUser: { name: 'Alice' },
|
||||
features: { 'name-readonly': true }
|
||||
});
|
||||
|
||||
clients.push(alice);
|
||||
|
||||
// Alice joins with a different display name — the inbound filter must replace it.
|
||||
await alice.joinRoom(r, undefined, { displayName: 'WrongName' });
|
||||
|
||||
// The observer sees Alice's join presence; nick must be "Alice" (from token).
|
||||
const presence = await waitForOtherPresence(observer, r, alice.nick);
|
||||
const nick = presence.getChild('nick', NICK_NS);
|
||||
|
||||
assert.ok(nick, 'presence must contain a <nick> element');
|
||||
assert.strictEqual(nick.getText(), 'Alice',
|
||||
'nick must be the token name, not the client-supplied name');
|
||||
});
|
||||
|
||||
it('strips nick when name-readonly=true and token has no name', async () => {
|
||||
const r = nextRoom();
|
||||
|
||||
const focus = await joinWithFocus(r);
|
||||
const observer = await createXmppClient({ params: { room: r.split('@')[0] } });
|
||||
|
||||
clients.push(focus, observer);
|
||||
await observer.joinRoom(r);
|
||||
|
||||
// No context.user.name — name-readonly with no name means nick is stripped.
|
||||
const alice = await connect(r, { features: { 'name-readonly': true } });
|
||||
|
||||
clients.push(alice);
|
||||
await alice.joinRoom(r, undefined, { displayName: 'SomeName' });
|
||||
|
||||
const presence = await waitForOtherPresence(observer, r, alice.nick);
|
||||
|
||||
assert.ok(
|
||||
!presence.getChild('nick', NICK_NS),
|
||||
'nick element must be absent when name-readonly and token has no name'
|
||||
);
|
||||
});
|
||||
|
||||
it('passes nick unchanged when name-readonly is not set', async () => {
|
||||
const r = nextRoom();
|
||||
|
||||
const focus = await joinWithFocus(r);
|
||||
const observer = await createXmppClient({ params: { room: r.split('@')[0] } });
|
||||
|
||||
clients.push(focus, observer);
|
||||
await observer.joinRoom(r);
|
||||
|
||||
// No name-readonly feature — client's own nick must be preserved.
|
||||
const alice = await connect(r, { contextUser: { name: 'Alice' } });
|
||||
|
||||
clients.push(alice);
|
||||
await alice.joinRoom(r, undefined, { displayName: 'ChosenName' });
|
||||
|
||||
const presence = await waitForOtherPresence(observer, r, alice.nick);
|
||||
const nick = presence.getChild('nick', NICK_NS);
|
||||
|
||||
assert.ok(nick, 'presence must contain a <nick> element');
|
||||
assert.strictEqual(nick.getText(), 'ChosenName',
|
||||
'nick must be the client-supplied name when name-readonly is not set');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── hideDisplayNameForGuests ─────────────────────────────────────────────────
|
||||
|
||||
describe('mod_muc_displayname — hideDisplayNameForGuests', () => {
|
||||
|
||||
const clients = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(clients.map(c => c.disconnect()));
|
||||
clients.length = 0;
|
||||
});
|
||||
|
||||
it('non-moderator recipient receives identity-stripped presence', async () => {
|
||||
const r = nextRoom();
|
||||
const roomName = r.split('@')[0];
|
||||
|
||||
const focus = await joinWithFocus(r);
|
||||
|
||||
await setRoomMaxOccupants(r, 10);
|
||||
|
||||
// Non-moderator guest — must receive filtered presences.
|
||||
const guest = await createXmppClient({ params: { room: roomName } });
|
||||
|
||||
// Moderator — must receive full presences.
|
||||
const mod = await connectModerator(r);
|
||||
|
||||
clients.push(focus, guest, mod);
|
||||
await guest.joinRoom(r);
|
||||
await mod.joinRoom(r);
|
||||
|
||||
await setHideDisplayNameForGuests(r, true);
|
||||
|
||||
// Alice joins with a display name after the flag is set.
|
||||
const alice = await connect(r, { contextUser: { name: 'Alice' } });
|
||||
|
||||
clients.push(alice);
|
||||
await alice.joinRoom(r, undefined, { displayName: 'Alice' });
|
||||
|
||||
// Guest sees Alice's join presence — identity must be stripped.
|
||||
const guestPresence = await waitForOtherPresence(guest, r, alice.nick);
|
||||
|
||||
assert.ok(
|
||||
!guestPresence.getChild('nick', NICK_NS),
|
||||
'non-moderator must not see <nick> when hideDisplayNameForGuests is true'
|
||||
);
|
||||
});
|
||||
|
||||
it('moderator recipient receives full presence with identity intact', async () => {
|
||||
const r = nextRoom();
|
||||
const roomName = r.split('@')[0];
|
||||
|
||||
const focus = await joinWithFocus(r);
|
||||
|
||||
await setRoomMaxOccupants(r, 10);
|
||||
|
||||
const guest = await createXmppClient({ params: { room: roomName } });
|
||||
const mod = await connectModerator(r);
|
||||
|
||||
clients.push(focus, guest, mod);
|
||||
await guest.joinRoom(r);
|
||||
await mod.joinRoom(r);
|
||||
|
||||
await setHideDisplayNameForGuests(r, true);
|
||||
|
||||
const alice = await connect(r, { contextUser: { name: 'Alice' } });
|
||||
|
||||
clients.push(alice);
|
||||
await alice.joinRoom(r, undefined, { displayName: 'Alice' });
|
||||
|
||||
// Drain guest's copy; moderator's copy is what we assert on.
|
||||
await waitForOtherPresence(guest, r, alice.nick);
|
||||
|
||||
// Mod sees Alice's join presence — identity must be intact.
|
||||
const modPresence = await waitForOtherPresence(mod, r, alice.nick);
|
||||
const nick = modPresence.getChild('nick', NICK_NS);
|
||||
|
||||
assert.ok(nick, 'moderator must see <nick> when hideDisplayNameForGuests is true');
|
||||
assert.strictEqual(nick.getText(), 'Alice');
|
||||
});
|
||||
|
||||
it('non-moderator receives full presence when hideDisplayNameForGuests is false', async () => {
|
||||
const r = nextRoom();
|
||||
const roomName = r.split('@')[0];
|
||||
|
||||
const focus = await joinWithFocus(r);
|
||||
const guest = await createXmppClient({ params: { room: roomName } });
|
||||
|
||||
clients.push(focus, guest);
|
||||
await guest.joinRoom(r);
|
||||
|
||||
// Flag is not set — guest must receive full presence.
|
||||
const alice = await connect(r, { contextUser: { name: 'Alice' } });
|
||||
|
||||
clients.push(alice);
|
||||
await alice.joinRoom(r, undefined, { displayName: 'Alice' });
|
||||
|
||||
const presence = await waitForOtherPresence(guest, r, alice.nick);
|
||||
const nick = presence.getChild('nick', NICK_NS);
|
||||
|
||||
assert.ok(nick, 'guest must see <nick> when hideDisplayNameForGuests is false');
|
||||
assert.strictEqual(nick.getText(), 'Alice');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── message display-name handling ───────────────────────────────────────────
|
||||
|
||||
describe('mod_muc_displayname — message display-name handling', () => {
|
||||
|
||||
const clients = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(clients.map(c => c.disconnect()));
|
||||
clients.length = 0;
|
||||
});
|
||||
|
||||
it('live broadcast strips <nick> and <display-name> from groupchat messages', async () => {
|
||||
const r = nextRoom();
|
||||
const roomName = r.split('@')[0];
|
||||
|
||||
const focus = await joinWithFocus(r);
|
||||
|
||||
// Alice is the sender; Bob observes live delivery.
|
||||
const alice = await connect(r, { contextUser: { name: 'Alice' } });
|
||||
const bob = await createXmppClient({ params: { room: roomName } });
|
||||
|
||||
clients.push(focus, alice, bob);
|
||||
await alice.joinRoom(r, undefined, { displayName: 'Alice' });
|
||||
await bob.joinRoom(r);
|
||||
|
||||
await alice.sendGroupchat(r, 'hello');
|
||||
|
||||
// Bob receives the live broadcast; it must not contain nick or display-name.
|
||||
const msg = await bob.waitForMessage(
|
||||
s => s.attrs.type === 'groupchat' && s.getChildText('body') === 'hello'
|
||||
);
|
||||
|
||||
assert.ok(!msg.getChild('nick', NICK_NS),
|
||||
'live message must not contain <nick>');
|
||||
assert.ok(!msg.getChild('display-name', DISPLAY_NAME_NS),
|
||||
'live message must not contain <display-name>');
|
||||
});
|
||||
|
||||
it('history message contains <display-name> injected from token name', async () => {
|
||||
const r = nextRoom();
|
||||
|
||||
const focus = await joinWithFocus(r);
|
||||
|
||||
// Alice has a token name; her message must be stored in history with display-name.
|
||||
const alice = await connect(r, { contextUser: { name: 'Alice',
|
||||
id: 'alice-id' } });
|
||||
|
||||
clients.push(focus, alice);
|
||||
await alice.joinRoom(r);
|
||||
await alice.sendGroupchat(r, 'history-msg');
|
||||
|
||||
// Bob joins after the message — he receives it as history (with <delay>).
|
||||
const bob = await createXmppClient({ params: { room: r.split('@')[0] } });
|
||||
|
||||
clients.push(bob);
|
||||
await bob.joinRoom(r);
|
||||
|
||||
const historyMsg = await bob.waitForMessage(
|
||||
s => s.attrs.type === 'groupchat'
|
||||
&& s.getChildText('body') === 'history-msg'
|
||||
&& s.getChild('delay') !== undefined
|
||||
);
|
||||
const displayName = historyMsg.getChild('display-name', DISPLAY_NAME_NS);
|
||||
|
||||
assert.ok(displayName, 'history message must contain <display-name>');
|
||||
assert.strictEqual(displayName.getText(), 'Alice');
|
||||
assert.strictEqual(displayName.attrs.source, 'token');
|
||||
});
|
||||
|
||||
it('history message contains <display-name> from nick for anonymous sender', async () => {
|
||||
const r = nextRoom();
|
||||
|
||||
const focus = await joinWithFocus(r);
|
||||
|
||||
// Anonymous sender — display name comes from their <nick> element, not token.
|
||||
const anon = await createXmppClient({ params: { room: r.split('@')[0] } });
|
||||
|
||||
clients.push(focus, anon);
|
||||
await anon.joinRoom(r, undefined, { displayName: 'GuestUser' });
|
||||
await anon.sendGroupchat(r, 'anon-msg');
|
||||
|
||||
const bob = await createXmppClient({ params: { room: r.split('@')[0] } });
|
||||
|
||||
clients.push(bob);
|
||||
await bob.joinRoom(r);
|
||||
|
||||
const historyMsg = await bob.waitForMessage(
|
||||
s => s.attrs.type === 'groupchat'
|
||||
&& s.getChildText('body') === 'anon-msg'
|
||||
&& s.getChild('delay') !== undefined
|
||||
);
|
||||
const displayName = historyMsg.getChild('display-name', DISPLAY_NAME_NS);
|
||||
|
||||
assert.ok(displayName, 'history message must contain <display-name>');
|
||||
assert.strictEqual(displayName.getText(), 'GuestUser');
|
||||
assert.strictEqual(displayName.attrs.source, 'guest');
|
||||
});
|
||||
});
|
||||
137
tests/prosody/mod_muc_domain_mapper_spec.js
Normal file
137
tests/prosody/mod_muc_domain_mapper_spec.js
Normal file
@@ -0,0 +1,137 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import { createTestContext } from './helpers/test_context.js';
|
||||
import { isAvailablePresence } from './helpers/xmpp_utils.js';
|
||||
|
||||
// mod_muc_domain_mapper rewrites JIDs between:
|
||||
// external: room@conference.subdomain.localhost (what clients send/receive)
|
||||
// internal: [subdomain]room@conference.localhost (what Prosody routes)
|
||||
//
|
||||
// Both focus and regular clients use the external subdomain JID. The mapper
|
||||
// rewrites 'to' on the way in (before routing) and 'from' on the way out
|
||||
// (before the client sees it), so the subdomain format is transparent.
|
||||
|
||||
const CONFERENCE_SUB = 'conference.subdomain.localhost';
|
||||
const CONFERENCE = 'conference.localhost';
|
||||
|
||||
let _roomCounter = 0;
|
||||
const subRoom = () => `mapper-${++_roomCounter}@${CONFERENCE_SUB}`;
|
||||
const baseRoom = () => `mapper-base-${++_roomCounter}@${CONFERENCE}`;
|
||||
|
||||
describe('mod_muc_domain_mapper', () => {
|
||||
|
||||
let ctx;
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = createTestContext();
|
||||
});
|
||||
|
||||
afterEach(() => ctx.cleanup());
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Subdomain room join
|
||||
// -------------------------------------------------------------------------
|
||||
describe('subdomain room join', () => {
|
||||
|
||||
it('focus can join a subdomain room', async () => {
|
||||
// connectFocus throws on timeout if the join fails.
|
||||
await ctx.connectFocus(subRoom());
|
||||
});
|
||||
|
||||
it('regular user receives available self-presence with subdomain from-JID', async () => {
|
||||
const r = subRoom();
|
||||
|
||||
await ctx.connectFocus(r);
|
||||
const c = await ctx.connect();
|
||||
const presence = await c.joinRoom(r);
|
||||
|
||||
assert.ok(isAvailablePresence(presence),
|
||||
'join must succeed');
|
||||
assert.ok(
|
||||
presence.attrs.from?.startsWith(`${r}/`),
|
||||
`from must use the subdomain JID (got ${presence.attrs.from})`
|
||||
);
|
||||
});
|
||||
|
||||
it('non-subdomain room still works after mapper is loaded', async () => {
|
||||
const r = baseRoom();
|
||||
|
||||
await ctx.connectFocus(r);
|
||||
const c = await ctx.connect();
|
||||
const presence = await c.joinRoom(r);
|
||||
|
||||
assert.ok(isAvailablePresence(presence),
|
||||
'plain conference.localhost join must still succeed');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Presence exchange between occupants
|
||||
// -------------------------------------------------------------------------
|
||||
describe('occupant presence exchange', () => {
|
||||
|
||||
it('two clients in the same subdomain room receive each other\'s presence', async () => {
|
||||
const r = subRoom();
|
||||
|
||||
await ctx.connectFocus(r);
|
||||
|
||||
const c1 = await ctx.connect();
|
||||
const c2 = await ctx.connect();
|
||||
|
||||
await c1.joinRoom(r);
|
||||
await c2.joinRoom(r);
|
||||
|
||||
// c1 must receive c2's join presence with the subdomain from-JID
|
||||
const p = await c1.waitForPresenceFrom(`${r}/${c2.nick}`);
|
||||
|
||||
assert.ok(isAvailablePresence(p),
|
||||
'c1 must see c2 as available');
|
||||
assert.equal(p.attrs.from, `${r}/${c2.nick}`,
|
||||
'from must be the subdomain JID of c2');
|
||||
});
|
||||
|
||||
it('two clients in different subdomain rooms do not see each other', async () => {
|
||||
const r1 = subRoom();
|
||||
const r2 = subRoom();
|
||||
|
||||
await ctx.connectFocus(r1);
|
||||
await ctx.connectFocus(r2);
|
||||
|
||||
const c1 = await ctx.connect();
|
||||
const c2 = await ctx.connect();
|
||||
|
||||
await c1.joinRoom(r1);
|
||||
await c2.joinRoom(r2);
|
||||
|
||||
await assert.rejects(
|
||||
() => c1.waitForPresenceFrom(`${r1}/${c2.nick}`, { timeout: 1000 }),
|
||||
/Timeout/,
|
||||
'c1 must not see c2 who joined a different room'
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// IQ routing through subdomain
|
||||
// -------------------------------------------------------------------------
|
||||
describe('IQ routing', () => {
|
||||
|
||||
it('disco#info to a subdomain room JID returns a result', async () => {
|
||||
const r = subRoom();
|
||||
|
||||
await ctx.connectFocus(r);
|
||||
const c = await ctx.connect();
|
||||
|
||||
await c.joinRoom(r);
|
||||
|
||||
const iq = await c.sendDiscoInfo(r);
|
||||
|
||||
assert.equal(iq.attrs.type, 'result',
|
||||
'disco#info to subdomain room must return a result IQ');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
169
tests/prosody/mod_muc_end_meeting_spec.js
Normal file
169
tests/prosody/mod_muc_end_meeting_spec.js
Normal 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);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
66
tests/prosody/mod_muc_filter_access_spec.js
Normal file
66
tests/prosody/mod_muc_filter_access_spec.js
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
388
tests/prosody/mod_muc_flip_spec.js
Normal file
388
tests/prosody/mod_muc_flip_spec.js
Normal 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
|
||||
|
||||
});
|
||||
113
tests/prosody/mod_muc_hide_all_spec.js
Normal file
113
tests/prosody/mod_muc_hide_all_spec.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
270
tests/prosody/mod_muc_jigasi_invite_spec.js
Normal file
270
tests/prosody/mod_muc_jigasi_invite_spec.js
Normal 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);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
216
tests/prosody/mod_muc_kick_participant_spec.js
Normal file
216
tests/prosody/mod_muc_kick_participant_spec.js
Normal 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);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
380
tests/prosody/mod_muc_limit_messages_spec.js
Normal file
380
tests/prosody/mod_muc_limit_messages_spec.js
Normal 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)`);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
446
tests/prosody/mod_muc_lobby_rooms_spec.js
Normal file
446
tests/prosody/mod_muc_lobby_rooms_spec.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
203
tests/prosody/mod_muc_max_occupants_spec.js
Normal file
203
tests/prosody/mod_muc_max_occupants_spec.js
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
327
tests/prosody/mod_muc_meeting_id_spec.js
Normal file
327
tests/prosody/mod_muc_meeting_id_spec.js
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
318
tests/prosody/mod_muc_password_check_spec.js
Normal file
318
tests/prosody/mod_muc_password_check_spec.js
Normal 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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
65
tests/prosody/mod_muc_password_whitelist_spec.js
Normal file
65
tests/prosody/mod_muc_password_whitelist_spec.js
Normal 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');
|
||||
});
|
||||
});
|
||||
204
tests/prosody/mod_muc_rate_limit_spec.js
Normal file
204
tests/prosody/mod_muc_rate_limit_spec.js
Normal 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'
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
199
tests/prosody/mod_muc_resource_validate_spec.js
Normal file
199
tests/prosody/mod_muc_resource_validate_spec.js
Normal 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');
|
||||
});
|
||||
|
||||
});
|
||||
242
tests/prosody/mod_muc_size_spec.js
Normal file
242
tests/prosody/mod_muc_size_spec.js
Normal 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})`);
|
||||
});
|
||||
});
|
||||
});
|
||||
237
tests/prosody/mod_muc_wait_for_host_spec.js
Normal file
237
tests/prosody/mod_muc_wait_for_host_spec.js
Normal 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');
|
||||
});
|
||||
});
|
||||
425
tests/prosody/mod_polls_component_spec.js
Normal file
425
tests/prosody/mod_polls_component_spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
298
tests/prosody/mod_presence_identity_spec.js
Normal file
298
tests/prosody/mod_presence_identity_spec.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
217
tests/prosody/mod_rayo_dial_header_crlf_spec.js
Normal file
217
tests/prosody/mod_rayo_dial_header_crlf_spec.js
Normal 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');
|
||||
});
|
||||
});
|
||||
|
||||
840
tests/prosody/mod_room_metadata_component_spec.js
Normal file
840
tests/prosody/mod_room_metadata_component_spec.js
Normal 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');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
336
tests/prosody/mod_system_chat_message_spec.js
Normal file
336
tests/prosody/mod_system_chat_message_spec.js
Normal 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);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
282
tests/prosody/mod_time_restricted_spec.js
Normal file
282
tests/prosody/mod_time_restricted_spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
165
tests/prosody/mod_token_affiliation_spec.js
Normal file
165
tests/prosody/mod_token_affiliation_spec.js
Normal 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');
|
||||
});
|
||||
|
||||
});
|
||||
243
tests/prosody/mod_token_verification_spec.js
Normal file
243
tests/prosody/mod_token_verification_spec.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
123
tests/prosody/mod_trace_spec.js
Normal file
123
tests/prosody/mod_trace_spec.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
47
tests/prosody/mod_turncredentials_http_spec.js
Normal file
47
tests/prosody/mod_turncredentials_http_spec.js
Normal 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
5715
tests/prosody/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
tests/prosody/package.json
Normal file
25
tests/prosody/package.json
Normal 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
29
tests/prosody/setup.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user