diff --git a/resources/prosody-plugins/mod_auth_jitsi-shared-secret.lua b/resources/prosody-plugins/mod_auth_jitsi-shared-secret.lua
index 65d0fc73b1ab..15648baebee8 100644
--- a/resources/prosody-plugins/mod_auth_jitsi-shared-secret.lua
+++ b/resources/prosody-plugins/mod_auth_jitsi-shared-secret.lua
@@ -6,6 +6,10 @@ local saslprep = require "util.encodings".stringprep.saslprep;
local secure_equals = require "util.hashes".equals;
local shared_secret = module:get_option_string('shared_secret');
+-- TODO: shared_secret_prev is probably broken. get_sasl_handler() 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. To fix, get_sasl_handler() should use a plain_test profile instead.
local shared_secret_prev = module:get_option_string('shared_secret_prev');
if shared_secret == nil then
module:log('error', 'No shared_secret specified. No secret to operate on!');
diff --git a/resources/prosody-plugins/mod_av_moderation_component.lua b/resources/prosody-plugins/mod_av_moderation_component.lua
index 746ac3850527..c2b2bcc24932 100644
--- a/resources/prosody-plugins/mod_av_moderation_component.lua
+++ b/resources/prosody-plugins/mod_av_moderation_component.lua
@@ -6,6 +6,7 @@ local room_jid_match_rewrite = util.room_jid_match_rewrite;
local process_host_module = util.process_host_module;
local table_shallow_copy = util.table_shallow_copy;
local is_admin = util.is_admin;
+local is_focus = util.is_focus;
local array = require "util.array";
local json = require 'cjson.safe';
local st = require 'util.stanza';
@@ -161,7 +162,7 @@ function start_av_moderation(room, mediaType, occupant)
-- add all current moderators to the new whitelist
for _, room_occupant in room:each_occupant() do
- if room_occupant.role == 'moderator' and not ends_with(room_occupant.nick, '/focus') then
+ if room_occupant.role == 'moderator' and not is_focus(room_occupant.nick) then
room.av_moderation[mediaType]:push(internal_room_jid_match_rewrite(room_occupant.nick));
end
end
diff --git a/resources/prosody-plugins/mod_conference_duration.lua b/resources/prosody-plugins/mod_conference_duration.lua
index 43f4511b5937..c087dd8b0702 100644
--- a/resources/prosody-plugins/mod_conference_duration.lua
+++ b/resources/prosody-plugins/mod_conference_duration.lua
@@ -1,3 +1,6 @@
+-- Records the timestamp (ms since epoch) when a MUC room first reaches two
+-- occupants and exposes it as the muc#roominfo_created_timestamp field in
+-- disco#info responses, so clients can display how long the conference has run.
local it = require "util.iterators";
local process_host_module = module:require "util".process_host_module;
diff --git a/resources/prosody-plugins/mod_end_conference.lua b/resources/prosody-plugins/mod_end_conference.lua
index 9dff466dfad6..45ea9352e6d3 100644
--- a/resources/prosody-plugins/mod_end_conference.lua
+++ b/resources/prosody-plugins/mod_end_conference.lua
@@ -1,3 +1,10 @@
+-- Allows a conference moderator to destroy a MUC room by sending a message
+-- stanza containing an child element to this component. The
+-- module looks up the room from the sender's jitsi_web_query_room / prefix
+-- session fields (set from the WebSocket URL query string by mod_jitsi_session),
+-- verifies the sender is an occupant with moderator role, and calls room:destroy().
+-- Must be loaded as a dedicated Prosody component (type "end_conference"); the
+-- muc_component option must point to the MUC component host.
--
-- Component "endconference.jitmeet.example.com" "end_conference"
-- muc_component = muc.jitmeet.example.com
diff --git a/resources/prosody-plugins/mod_filter_iq_jibri.lua b/resources/prosody-plugins/mod_filter_iq_jibri.lua
index 0a36c471edab..69a6fe0875f4 100644
--- a/resources/prosody-plugins/mod_filter_iq_jibri.lua
+++ b/resources/prosody-plugins/mod_filter_iq_jibri.lua
@@ -1,4 +1,10 @@
--- This module is enabled under the main virtual host
+-- Filters Jibri recording/livestreaming IQ stanzas (http://jitsi.org/protocol/jibri)
+-- on the main VirtualHost. start/stop actions are allowed only when the sender's JWT
+-- token grants the required feature ('recording' for file mode, 'livestreaming' for
+-- stream mode), or, absent token features, when the sender holds moderator role in the
+-- room. status IQs are always passed through. Blocked stanzas receive an
+-- auth/forbidden error reply. Rate-limited per IP and per room.
+-- This module is enabled under the main virtual host.
local cache = require 'util.cache';
local new_throttle = require 'util.throttle'.create;
local st = require "util.stanza";
diff --git a/resources/prosody-plugins/mod_filter_iq_rayo.lua b/resources/prosody-plugins/mod_filter_iq_rayo.lua
index 6b969c6221ba..740535be222b 100644
--- a/resources/prosody-plugins/mod_filter_iq_rayo.lua
+++ b/resources/prosody-plugins/mod_filter_iq_rayo.lua
@@ -1,4 +1,10 @@
--- This module is enabled under the main virtual host
+-- Filters outbound-call and transcription IQ stanzas (Rayo dial, urn:xmpp:rayo:1)
+-- on the main VirtualHost. Allows a stanza through only when the sender's JWT token
+-- grants the required feature ('outbound-call' or 'transcription'), or, absent token
+-- features, when the sender holds owner affiliation in the room. Blocked stanzas
+-- receive an auth/forbidden error reply. Optionally rate-limits outgoing calls per
+-- session when max_number_outgoing_calls is configured.
+-- This module is enabled under the main virtual host.
local new_throttle = require "util.throttle".create;
local st = require "util.stanza";
local jid = require "util.jid";
diff --git a/resources/prosody-plugins/mod_fmuc.lua b/resources/prosody-plugins/mod_fmuc.lua
index dd7578c1f5f8..726edd9eb73d 100644
--- a/resources/prosody-plugins/mod_fmuc.lua
+++ b/resources/prosody-plugins/mod_fmuc.lua
@@ -26,6 +26,7 @@ local is_vpaas = util.is_vpaas;
local room_jid_match_rewrite = util.room_jid_match_rewrite;
local get_room_from_jid = util.get_room_from_jid;
local get_focus_occupant = util.get_focus_occupant;
+local is_focus_jid = util.is_focus_jid;
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
local presence_check_status = util.presence_check_status;
local respond_iq_result = util.respond_iq_result;
@@ -817,7 +818,7 @@ function filter_stanza(stanza, session)
local f_st = st.clone(stanza);
f_st.skipMapping = true;
return f_st;
- elseif stanza.name == 'presence' and session.type == 'c2s' and jid.node(stanza.attr.to) == 'focus' then
+ elseif stanza.name == 'presence' and session.type == 'c2s' and is_focus_jid(stanza.attr.to) then
local x = stanza:get_child('x', 'http://jabber.org/protocol/muc#user');
if presence_check_status(x, '110') then
return stanza; -- no filter
diff --git a/resources/prosody-plugins/mod_jitsi_permissions.lua b/resources/prosody-plugins/mod_jitsi_permissions.lua
index ddacbcfe227e..ed530f9a03d2 100644
--- a/resources/prosody-plugins/mod_jitsi_permissions.lua
+++ b/resources/prosody-plugins/mod_jitsi_permissions.lua
@@ -7,7 +7,7 @@ local is_admin = util.is_admin;
local get_room_from_jid = util.get_room_from_jid;
local is_healthcheck_room = util.is_healthcheck_room;
local room_jid_match_rewrite = util.room_jid_match_rewrite;
-local ends_with = util.ends_with;
+local is_focus = util.is_focus;
local presence_check_status = util.presence_check_status;
local MUC_NS = 'http://jabber.org/protocol/muc';
@@ -105,7 +105,7 @@ end
-- using token that has features pre-defined (authentication is 'token').
function filter_stanza(stanza, session)
if not stanza.attr or not stanza.attr.to or stanza.name ~= 'presence'
- or stanza.attr.type == 'unavailable' or ends_with(stanza.attr.from, '/focus') then
+ or stanza.attr.type == 'unavailable' or is_focus(stanza.attr.from) then
return stanza;
end
diff --git a/resources/prosody-plugins/mod_jitsi_session.lua b/resources/prosody-plugins/mod_jitsi_session.lua
index d2537f2ba295..567eb13a000e 100644
--- a/resources/prosody-plugins/mod_jitsi_session.lua
+++ b/resources/prosody-plugins/mod_jitsi_session.lua
@@ -1,12 +1,28 @@
-- Jitsi session information
-- Copyright (C) 2021-present 8x8, Inc.
+--
+-- Hooks the BOSH and WebSocket session-init events to extract Jitsi-specific
+-- parameters from the URL query string and request headers, then stores them
+-- on the session object for use by other modules:
+--
+-- session.previd -- ?previd= for smacks session resumption; lets
+-- -- mod_auth_jitsi-anonymous re-use the same
+-- -- anonymous JID after a reconnect
+-- session.customusername -- ?customusername= pre-set JID, used with the
+-- -- "pre-jitsi-authentication" event
+-- session.jitsi_web_query_room -- ?room= Jitsi conference room name
+-- session.jitsi_web_query_prefix -- ?prefix= optional tenant prefix (defaults to "")
+-- session.auth_token -- ?token= or Authorization: Bearer header;
+-- -- populated without validation — downstream
+-- -- modules (mod_auth_token, etc.) validate it
+-- session.user_region -- value of the configurable proxy header
+-- -- (region_header_name option, default x_proxy_region)
+-- session.user_agent_header -- User-Agent request header
module:set_global();
local formdecode = require "util.http".formdecode;
local region_header_name = module:get_option_string('region_header_name', 'x_proxy_region');
--- Extract the following parameters from the URL and set them in the session:
--- * previd: for session resumption
function init_session(event)
local session, request = event.session, event.request;
local query = request.url.query;
diff --git a/resources/prosody-plugins/mod_limits_exception.lua b/resources/prosody-plugins/mod_limits_exception.lua
index fe780b14a2d7..2aa396b69d2d 100644
--- a/resources/prosody-plugins/mod_limits_exception.lua
+++ b/resources/prosody-plugins/mod_limits_exception.lua
@@ -1,7 +1,5 @@
--- we use async to detect Prosody 0.10 and earlier
-local have_async = pcall(require, 'util.async');
-
-if not have_async then
+if not pcall(require, 'util.async') then
+ module:log('warn', 'util.async not available; mod_limits_exception requires Prosody 0.11+, not loading');
return;
end
@@ -14,6 +12,8 @@ if unlimited_jids:empty() then
return;
end
+module:log('info', 'loaded; unlimited_jids=%s unlimited_size=%d', tostring(unlimited_jids), unlimited_stanza_size_limit);
+
module:hook("authentication-success", function (event)
local session = event.session;
local jid = session.username .. "@" .. session.host;
diff --git a/resources/prosody-plugins/mod_log_ringbuffer.lua b/resources/prosody-plugins/mod_log_ringbuffer.lua
index 1521f10776ff..307265569b5c 100644
--- a/resources/prosody-plugins/mod_log_ringbuffer.lua
+++ b/resources/prosody-plugins/mod_log_ringbuffer.lua
@@ -1,3 +1,7 @@
+-- Registers a custom Prosody log sink that buffers recent log lines in memory
+-- using a ringbuffer (or a fixed-line queue) and dumps the buffer to a file
+-- when triggered by a configured OS signal or Prosody event.
+-- Source: https://hg.prosody.im/prosody-modules/file/tip/mod_log_ringbuffer/mod_log_ringbuffer.lua
module:set_global();
local loggingmanager = require "core.loggingmanager";
diff --git a/resources/prosody-plugins/mod_measure_message_count.lua b/resources/prosody-plugins/mod_measure_message_count.lua
index f709b863fe35..142256f11f81 100644
--- a/resources/prosody-plugins/mod_measure_message_count.lua
+++ b/resources/prosody-plugins/mod_measure_message_count.lua
@@ -1,5 +1,9 @@
--- Measure the number of messages used in a meeting. Sends amplitude event.
--- Needs to be activated under the muc component where the limit needs to be applied (main muc and breakout muc)
+-- Counts messages and polls per conference and reports the totals to Amplitude
+-- when the main room is destroyed. Loaded on both the main MUC and breakout MUC
+-- components; in both cases messages are accumulated on the main room object
+-- (session.jitsi_web_query_room always refers to the main conference), so
+-- breakout messages are included in the main room's totals. The Amplitude event
+-- is only sent when the main room is destroyed (skipped for breakout rooms).
-- Copyright (C) 2023-present 8x8, Inc.
local jid = require 'util.jid';
diff --git a/resources/prosody-plugins/mod_muc_census.lua b/resources/prosody-plugins/mod_muc_census.lua
index 88f66a9657a6..af113fa987af 100644
--- a/resources/prosody-plugins/mod_muc_census.lua
+++ b/resources/prosody-plugins/mod_muc_census.lua
@@ -83,9 +83,9 @@ function handle_get_room_census(event)
end
end
- census_resp = json.encode({
- room_census = room_data;
- });
+ -- cjson encodes an empty Lua table as {} (object); force array literal.
+ local rc_json = #room_data == 0 and "[]" or json.encode(room_data);
+ census_resp = '{"room_census":' .. rc_json .. '}';
return { status_code = 200; body = census_resp }
end
diff --git a/resources/prosody-plugins/mod_muc_displayname.lua b/resources/prosody-plugins/mod_muc_displayname.lua
index 34d4c6a07375..cf4b7f9562ed 100644
--- a/resources/prosody-plugins/mod_muc_displayname.lua
+++ b/resources/prosody-plugins/mod_muc_displayname.lua
@@ -10,7 +10,7 @@ local util = module:require 'util';
local filter_identity_from_presence = util.filter_identity_from_presence;
local get_room_by_name_and_subdomain = util.get_room_by_name_and_subdomain;
local is_admin = util.is_admin;
-local ends_with = util.ends_with;
+local is_focus = util.is_focus;
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
local NICK_NS = 'http://jabber.org/protocol/nick';
local DISPLAY_NAME_NS = 'http://jitsi.org/protocol/display-name';
@@ -26,7 +26,7 @@ local joining_moderator_participants = module:shared('moderators/joining_moderat
--- Filter presence sent to non-moderator members of a room when the hideDisplayNameForGuests option is set.
function filter_stanza_out(stanza, session)
if stanza.name ~= 'presence' or stanza.attr.type == 'error'
- or stanza.attr.type == 'unavailable' or ends_with(stanza.attr.from, '/focus') then
+ or stanza.attr.type == 'unavailable' or is_focus(stanza.attr.from) then
return stanza;
end
@@ -60,7 +60,7 @@ function filter_stanza_in(stanza, session)
end
if stanza.name ~= 'presence' or stanza.attr.type == 'error'
- or stanza.attr.type == 'unavailable' or ends_with(stanza.attr.from, '/focus') then
+ or stanza.attr.type == 'unavailable' or is_focus(stanza.attr.from) then
return stanza;
end
diff --git a/resources/prosody-plugins/mod_muc_end_meeting.lua b/resources/prosody-plugins/mod_muc_end_meeting.lua
index 28e163085639..e0325e9e189c 100644
--- a/resources/prosody-plugins/mod_muc_end_meeting.lua
+++ b/resources/prosody-plugins/mod_muc_end_meeting.lua
@@ -1,5 +1,25 @@
--- A global module which can be used as http endpoint to end meetings. The provided token
---- in the request is verified whether it has the right to do so.
+-- Global HTTP module that exposes a POST /end-meeting endpoint for terminating
+-- MUC rooms via an authenticated API call. Intended for internal system use
+-- (e.g. by a backend service), not for end-user clients.
+--
+-- Authentication uses a SEPARATE ASAP key pair from the one used for login
+-- tokens (mod_auth_token). The key server URL is read from
+-- prosody_password_public_key_repo_url (not asap_key_server), so login tokens
+-- are not accepted.
+--
+-- Request format:
+-- POST /end-meeting?conference=[&silent-reconnect=true]
+-- Authorization: Bearer
+--
+-- Responses:
+-- 200 Room destroyed.
+-- 400 Missing or invalid query parameters.
+-- 401 Missing, malformed, or unverifiable token.
+-- 404 Room not found.
+--
+-- When silent-reconnect=true the room is destroyed with an alternate-venue JID
+-- so clients silently reconnect rather than showing a "meeting ended" screen.
+--
-- Copyright (C) 2023-present 8x8, Inc.
module:set_global();
@@ -11,8 +31,7 @@ local room_jid_match_rewrite = util.room_jid_match_rewrite;
local get_room_from_jid = util.get_room_from_jid;
local starts_with = util.starts_with;
-local neturl = require "net.url";
-local parse = neturl.parseQuery;
+local parse = require "util.http".formdecode;
-- will be initialized once the main virtual host module is initialized
local token_util;
diff --git a/resources/prosody-plugins/mod_muc_hide_all.lua b/resources/prosody-plugins/mod_muc_hide_all.lua
index eea874167c03..8bfe961c2ac3 100644
--- a/resources/prosody-plugins/mod_muc_hide_all.lua
+++ b/resources/prosody-plugins/mod_muc_hide_all.lua
@@ -1,4 +1,7 @@
--- This module makes all MUCs in Prosody unavailable on disco#items query
+-- Hides all MUC rooms from disco#items queries on the MUC component, so that
+-- room enumeration by external clients is prevented. Rooms are still joinable
+-- by full JID; they simply do not appear in the public room list.
+-- This module is enabled under the MUC component.
-- Copyright (C) 2023-present 8x8, Inc.
local jid = require 'util.jid';
local st = require 'util.stanza';
diff --git a/resources/prosody-plugins/mod_muc_jigasi_invite.lua b/resources/prosody-plugins/mod_muc_jigasi_invite.lua
index 41ce348c646e..a0af714df58e 100644
--- a/resources/prosody-plugins/mod_muc_jigasi_invite.lua
+++ b/resources/prosody-plugins/mod_muc_jigasi_invite.lua
@@ -1,14 +1,42 @@
--- A http endpoint to invite jigasi to a meeting via http endpoint
--- jwt is used to validate access
+-- HTTP module that exposes a POST /invite-jigasi endpoint for inviting a Jigasi
+-- SIP gateway instance to dial out to a phone number and connect it to a
+-- conference. Intended for internal system use (e.g. by a backend service),
+-- not for end-user clients.
+--
+-- Authentication uses a SEPARATE ASAP key pair from the one used for login
+-- tokens (mod_auth_token). The key server URL is read from
+-- prosody_password_public_key_repo_url (not asap_key_server), so login tokens
+-- are not accepted.
+--
+-- Request format:
+-- POST /invite-jigasi
+-- Content-Type: application/json
+-- Authorization: Bearer
+-- Body: { "conference": "", "phoneNo": "" }
+--
+-- The module selects a Jigasi instance from the brewery room
+-- (muc_jigasi_brewery_jid, default: jigasibrewery@internal.auth.):
+-- 1. Iterates brewery occupants, skips focus.
+-- 2. Filters for occupants with supports_sip=true in their colibri stats.
+-- 3. Among those, picks the one with the lowest stress_level.
+-- 4. Sends a Rayo IQ to that occupant (impersonating focus) with
+-- phoneNo as the dial target and conference as JvbRoomName.
+--
+-- Responses:
+-- 200 Jigasi invited successfully.
+-- 400 Missing or invalid parameters / wrong Content-Type.
+-- 401 Missing, malformed, or unverifiable token.
+-- 404 Brewery room not found, or no SIP-capable Jigasi available.
+--
-- Copyright (C) 2023-present 8x8, Inc.
-local jid = require "util.jid";
local hashes = require "util.hashes";
local random = require "util.random";
local st = require("util.stanza");
local json = require 'cjson.safe';
local util = module:require "util";
local async_handler_wrapper = util.async_handler_wrapper;
+local is_focus = util.is_focus;
local process_host_module = util.process_host_module;
local muc_domain_base = module:get_option_string("muc_mapper_domain_base");
@@ -44,8 +72,7 @@ local function invite_jigasi(conference, phone_no)
local least_stressed_value = math.huge;
local least_stressed_jigasi_occupant;
for occupant_jid, occupant in jigasi_brewery_room:each_occupant() do
- local _, _, resource = jid.split(occupant_jid);
- if resource ~= 'focus' then
+ if not is_focus(occupant_jid) then
local occ = occupant:get_presence();
local stats_child = occ:get_child("stats", "http://jitsi.org/protocol/colibri")
diff --git a/resources/prosody-plugins/mod_muc_kick_participant.lua b/resources/prosody-plugins/mod_muc_kick_participant.lua
index 11b0bbbb32d3..e4c3b665e266 100644
--- a/resources/prosody-plugins/mod_muc_kick_participant.lua
+++ b/resources/prosody-plugins/mod_muc_kick_participant.lua
@@ -1,9 +1,32 @@
--- http endpoint to kick participants, access is based on provided jwt token
--- the correct jigasi we fined based on the display name and the number provided
+-- HTTP module that exposes a PUT /kick-participant endpoint for removing a
+-- specific participant from a MUC room via an authenticated API call.
+-- Intended for internal system use (e.g. by a backend service), not for
+-- end-user clients.
+--
+-- Authentication uses a SEPARATE ASAP key pair from the one used for login
+-- tokens (mod_auth_token). The key server URL is read from
+-- prosody_password_public_key_repo_url (not asap_key_server), so login tokens
+-- are not accepted.
+--
+-- Request format:
+-- PUT /kick-participant?room=[&prefix=]
+-- Content-Type: application/json
+-- Authorization: Bearer
+-- Body: { "participantId": "" }
+-- OR: { "number": "" } (matches SIP Jigasi by display name)
+-- Exactly one of participantId or number must be provided.
+--
+-- Responses:
+-- 200 Participant kicked.
+-- 400 Missing or invalid parameters / wrong Content-Type.
+-- 403 Missing, malformed, or unverifiable token.
+-- 404 Room not found, or no matching participant found.
+--
-- Copyright (C) 2023-present 8x8, Inc.
local util = module:require "util";
local async_handler_wrapper = util.async_handler_wrapper;
+local get_room_from_jid = util.get_room_from_jid;
local is_sip_jigasi = util.is_sip_jigasi;
local starts_with = util.starts_with;
local formdecode = require "util.http".formdecode;
@@ -126,7 +149,7 @@ function handle_kick_participant (event)
if error_code and error_code ~= 200 then
module:log("error", "Error validating %s", error_code);
- return { error_code = 400; }
+ return { status_code = error_code; }
end
if not room then
diff --git a/resources/prosody-plugins/mod_muc_lobby_rooms.lua b/resources/prosody-plugins/mod_muc_lobby_rooms.lua
index 52813f8cb946..93fcdfca592e 100644
--- a/resources/prosody-plugins/mod_muc_lobby_rooms.lua
+++ b/resources/prosody-plugins/mod_muc_lobby_rooms.lua
@@ -46,7 +46,8 @@ local NOTIFY_LOBBY_ACCESS_GRANTED = 'LOBBY-ACCESS-GRANTED';
local NOTIFY_LOBBY_ACCESS_DENIED = 'LOBBY-ACCESS-DENIED';
local util = module:require "util";
-local ends_with = util.ends_with;
+local is_focus = util.is_focus;
+local is_focus_jid = util.is_focus_jid;
local get_room_by_name_and_subdomain = util.get_room_by_name_and_subdomain;
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
local get_room_from_jid = util.get_room_from_jid;
@@ -490,8 +491,7 @@ process_host_module(main_muc_component_config, function(host_module, host)
-- hooks when lobby is enabled to create its room, only done here or by admin
host_module:hook('muc-config-submitted', function(event)
local actor, room = event.actor, event.room;
- local actor_node = jid_split(actor);
- if actor_node == 'focus' then
+ if is_focus_jid(actor) then
return;
end
local members_only = event.fields['muc#roomconfig_membersonly'] and true or nil;
@@ -541,7 +541,7 @@ process_host_module(main_muc_component_config, function(host_module, host)
host_module:hook('muc-occupant-pre-join', function (event)
local occupant, room, stanza = event.occupant, event.room, event.stanza;
- if is_healthcheck_room(room.jid) or not room:get_members_only() or ends_with(occupant.nick, '/focus') then
+ if is_healthcheck_room(room.jid) or not room:get_members_only() or is_focus(occupant.nick) then
return;
end
diff --git a/resources/prosody-plugins/mod_muc_meeting_id.lua b/resources/prosody-plugins/mod_muc_meeting_id.lua
index dcb1a46f4ca8..fd6d30ae3d1d 100644
--- a/resources/prosody-plugins/mod_muc_meeting_id.lua
+++ b/resources/prosody-plugins/mod_muc_meeting_id.lua
@@ -1,3 +1,9 @@
+-- Assigns a unique UUID meeting ID to each MUC room at creation time and
+-- advertises it in the room's config IQ response so that all participants share
+-- a common conference identifier. Also enforces a jicofo lock: non-focus clients
+-- that try to join before jicofo (identified by nick suffix '/focus') are queued
+-- and admitted only after jicofo joins and fires the jicofo-unlock-room event.
+-- This module is enabled under the MUC component.
local jid = require 'util.jid';
local json = require 'cjson.safe';
local st = require "util.stanza";
@@ -5,7 +11,7 @@ local queue = require "util.queue";
local uuid_gen = require "util.uuid".generate;
local main_util = module:require "util";
local is_admin = main_util.is_admin;
-local ends_with = main_util.ends_with;
+local is_focus = main_util.is_focus;
local get_room_from_jid = main_util.get_room_from_jid;
local is_healthcheck_room = main_util.is_healthcheck_room;
local internal_room_jid_match_rewrite = main_util.internal_room_jid_match_rewrite;
@@ -29,6 +35,21 @@ module:depends("jitsi_session");
-- d) Removes any nick that maybe set to messages being sent to the room.
-- e) Fires event for received endpoint messages (optimization to decode them once).
+-- Block non-focus participants from creating health-check rooms. This hook
+-- runs at priority 100, before mod_token_verification (priority 99), so that
+-- the error is service-unavailable (not the token verification not-allowed that
+-- would fire because the room does not yet exist at pre-create time).
+module:hook('muc-room-pre-create', function (event)
+ local stanza = event.stanza;
+ if is_healthcheck_room(jid.bare(stanza.attr.to)) then
+ if not is_focus(stanza.attr.to) then
+ module:log('info', 'Blocking non-focus from creating health-check room');
+ event.origin.send(st.error_reply(stanza, 'cancel', 'service-unavailable'));
+ return true;
+ end
+ end
+end, 100);
+
-- Hook to assign meetingId for new rooms
module:hook("muc-room-created", function(event)
local room = event.room;
@@ -113,7 +134,7 @@ module:hook('muc-occupant-pre-join', function (event)
if is_health_room then
-- Only jicofo (focus) may join health-check rooms.
- if not ends_with(occupant.nick, '/focus') then
+ if not is_focus(occupant.nick) then
module:log('info', 'Blocking non-focus participant from health-check room: %s', room.jid);
event.origin.send(st.error_reply(stanza, 'cancel', 'service-unavailable'));
return true;
@@ -126,7 +147,7 @@ module:hook('muc-occupant-pre-join', function (event)
return;
end
- if ends_with(occupant.nick, '/focus') then
+ if is_focus(occupant.nick) then
module:fire_event('jicofo-unlock-room', { room = room; });
else
room._data.jicofo_lock = true;
diff --git a/resources/prosody-plugins/mod_muc_password_whitelist.lua b/resources/prosody-plugins/mod_muc_password_whitelist.lua
index ea892e96bb67..a9ee01ce93ea 100644
--- a/resources/prosody-plugins/mod_muc_password_whitelist.lua
+++ b/resources/prosody-plugins/mod_muc_password_whitelist.lua
@@ -1,3 +1,9 @@
+-- Allows clients from whitelisted domains (muc_password_whitelist) to join
+-- password-protected MUC rooms without supplying the room password. The module
+-- injects the room's current password into the join stanza on behalf of
+-- whitelisted clients, so the standard password check in Prosody's MUC module
+-- succeeds transparently.
+-- This module is enabled under the MUC component.
--- AUTHOR: https://gist.github.com/legastero Lance Stout
local jid_split = require "util.jid".split;
local whitelist = module:get_option_set("muc_password_whitelist");
diff --git a/resources/prosody-plugins/mod_roster_command.lua b/resources/prosody-plugins/mod_roster_command.lua
index 985a8c251152..a8461ac3f412 100644
--- a/resources/prosody-plugins/mod_roster_command.lua
+++ b/resources/prosody-plugins/mod_roster_command.lua
@@ -8,6 +8,16 @@
-- This project is MIT/X11 licensed. Please see the
-- COPYING file in the source package for more information.
-----------------------------------------------------------
+--
+-- Source: https://modules.prosody.im/mod_roster_command.html (community module,
+-- minor local patch to handle bare host JIDs in subscribe/unsubscribe).
+--
+-- This module is NOT loaded in prosody.cfg.lua. It is a prosodyctl command
+-- plugin, invoked at provisioning time via:
+-- prosodyctl mod_roster_command subscribe
+-- Used by Ansible to subscribe focus. to focus@auth..
+-- Only needed for prosody versions prior 13
+-----------------------------------------------------------
if module.host ~= "*" then
module:log("error", "Do not load this module in Prosody, for correct usage see: https://modules.prosody.im/mod_roster_command.html");
diff --git a/resources/prosody-plugins/mod_speakerstats_component.lua b/resources/prosody-plugins/mod_speakerstats_component.lua
index 9743781d216b..f5da1ace717b 100644
--- a/resources/prosody-plugins/mod_speakerstats_component.lua
+++ b/resources/prosody-plugins/mod_speakerstats_component.lua
@@ -5,6 +5,7 @@ local room_jid_match_rewrite = util.room_jid_match_rewrite;
local is_jibri = util.is_jibri;
local is_healthcheck_room = util.is_healthcheck_room;
local process_host_module = util.process_host_module;
+local is_focus_nick = util.is_focus_nick;
local is_transcriber = util.is_transcriber;
local jid_resource = require "util.jid".resource;
local st = require "util.stanza";
@@ -241,7 +242,7 @@ function occupant_joined(event)
for jid, values in pairs(room.speakerStats) do
-- skip reporting those without a nick('dominantSpeakerId')
-- and skip focus if sneaked into the table
- if values and type(values) == 'table' and values.nick ~= nil and values.nick ~= 'focus' then
+ if values and type(values) == 'table' and values.nick ~= nil and not is_focus_nick(values.nick) then
local totalDominantSpeakerTime = values.totalDominantSpeakerTime;
local faceLandmarks = values.faceLandmarks;
if totalDominantSpeakerTime > 0 or room:get_occupant_jid(jid) == nil or values:isDominantSpeaker()
diff --git a/resources/prosody-plugins/mod_system_chat_message.lua b/resources/prosody-plugins/mod_system_chat_message.lua
index ddb37238bc06..8734331e62bf 100644
--- a/resources/prosody-plugins/mod_system_chat_message.lua
+++ b/resources/prosody-plugins/mod_system_chat_message.lua
@@ -1,9 +1,37 @@
--- Module which can be used as an http endpoint to send system private chat messages to meeting participants. The provided token
---- in the request is verified whether it has the right to do so. This module should be loaded under the virtual host.
+-- HTTP module that exposes a POST /send-system-chat-message endpoint for
+-- delivering private system chat messages to specific participants in a MUC
+-- room. Intended for internal system use (e.g. by a backend service), not for
+-- end-user clients.
+--
+-- Authentication uses a SEPARATE ASAP key pair from the one used for login
+-- tokens (mod_auth_token). The key server URL is read from
+-- prosody_password_public_key_repo_url (not asap_key_server), so login tokens
+-- are not accepted.
+--
+-- Request format:
+-- POST /send-system-chat-message
+-- Content-Type: application/json
+-- Authorization: Bearer
+-- Body: {
+-- "room": "",
+-- "connectionJIDs": ["", ...],
+-- "message": "",
+-- "displayName": "" (optional)
+-- }
+--
+-- Each JID in connectionJIDs receives a private stanza from the
+-- room JID containing a
+-- payload of the form:
+-- { "type": "system_chat_message", "message": "...", "displayName": "..." }
+--
+-- Responses:
+-- 200 Messages dispatched.
+-- 400 Missing or invalid parameters / wrong Content-Type.
+-- 401 Missing, malformed, or unverifiable token.
+-- 404 Room not found.
+--
-- Copyright (C) 2024-present 8x8, Inc.
--- curl https://{host}/send-system-chat-message -d '{"message": "testmessage", "connectionJIDs": ["{connection_jid}"], "room": "{room_jid}"}' -H "content-type: application/json" -H "authorization: Bearer {token}"
-
local util = module:require "util";
local token_util = module:require "token/util".new(module);
diff --git a/resources/prosody-plugins/mod_test_observer.lua b/resources/prosody-plugins/mod_test_observer.lua
index d45f2c38953b..dc5b014381a3 100644
--- a/resources/prosody-plugins/mod_test_observer.lua
+++ b/resources/prosody-plugins/mod_test_observer.lua
@@ -7,8 +7,10 @@
-- can access the same table via module:shared("//mod_test_observer").
-- module:shared() uses the path string as-is — no automatic host prefix.
local shared = module:shared("/" .. module.host .. "/mod_test_observer");
-if not shared.events then shared.events = {}; end
-if not shared.rooms then shared.rooms = {}; end
+if not shared.events then shared.events = {}; end
+if not shared.rooms then shared.rooms = {}; end
+if not shared.jibri_iqs then shared.jibri_iqs = {}; end
+if not shared.dial_iqs then shared.dial_iqs = {}; end
local tracked = {
"muc-room-pre-create";
@@ -39,4 +41,47 @@ module:hook("muc-room-destroyed", function(event)
shared.rooms[event.room.jid] = nil;
end, -999);
+-- Record Jibri IQs that reach the MUC component (i.e. passed mod_filter_iq_jibri).
+-- If the filter blocks an IQ it never arrives here, so absence = blocked.
+-- High priority (500) so this runs before mod_muc's iq/full handler, which
+-- returns non-nil and would prevent lower-priority hooks from firing.
+-- Guard: only record IQs addressed to this component (to = room@/nick).
+-- The MUC re-routes the IQ to the occupant's real JID on another host, which
+-- would fire iq/full a second time — skip those forwarded copies.
+local jid_util = require "util.jid";
+module:hook("iq/full", function(event)
+ local stanza = event.stanza;
+ -- Only capture initial requests; ignore error/result responses routed back
+ -- through the MUC (e.g. @xmpp/client auto-replying to unhandled IQs).
+ local iq_type = stanza.attr.type;
+ if iq_type ~= "set" and iq_type ~= "get" then return; end
+ local _, to_host = jid_util.split(stanza.attr.to);
+ if to_host ~= module.host then return; end
+ local jibri = stanza:get_child('jibri', 'http://jitsi.org/protocol/jibri');
+ if jibri then
+ table.insert(shared.jibri_iqs, {
+ from = stanza.attr.from;
+ to = stanza.attr.to;
+ action = jibri.attr.action;
+ recording_mode = jibri.attr.recording_mode;
+ });
+ end
+ local dial = stanza:get_child('dial', 'urn:xmpp:rayo:1');
+ if dial then
+ -- Extract JvbRoomName header value for assertion.
+ local room_name_header;
+ for _, child in ipairs(dial.tags) do
+ if child.name == "header" and child.attr.name == "JvbRoomName" then
+ room_name_header = child.attr.value;
+ end
+ end
+ table.insert(shared.dial_iqs, {
+ from = stanza.attr.from;
+ to = stanza.attr.to;
+ dial_to = dial.attr.to;
+ room_name_header = room_name_header;
+ });
+ end
+end, 500);
+
module:log("info", "test_observer loaded");
diff --git a/resources/prosody-plugins/mod_test_observer_http.lua b/resources/prosody-plugins/mod_test_observer_http.lua
index e460190c0161..f76f701e5e90 100644
--- a/resources/prosody-plugins/mod_test_observer_http.lua
+++ b/resources/prosody-plugins/mod_test_observer_http.lua
@@ -5,8 +5,106 @@
--
-- Data is supplied by mod_test_observer (loaded on the MUC component) via
-- module:shared. Load order does not matter; shared tables are created lazily.
+--
+-- Also serves a mock access manager endpoint for mod_muc_auth_ban tests:
+-- GET /test-observer/access-manager — called by Prosody; returns configured response
+-- POST /test-observer/access-manager — called by tests to configure the response
+
+local json = require "cjson.safe";
+local io = require "io";
+local jid_lib = require "util.jid";
+
+-- Mock access-manager state for mod_muc_auth_ban tests.
+-- access: true → return {"access": true} (user allowed)
+-- access: false → return {"access": false} (user banned)
+-- status: any non-200 value → return that HTTP status code with no JSON body
+-- (simulates HTTP errors; mod_muc_auth_ban fails open on non-200)
+-- Reset to the default (allow, 200) between tests via POST /test-observer/access-manager.
+local access_manager_state = { access = true, status = 200 };
+
+-- ASAP public key servers: serve test RSA public keys so that Prosody can
+-- fetch them when verifying RS256 tokens signed by the matching private keys.
+-- util.lib.lua constructs the URL as: /.pem
+--
+-- Two separate key pairs are used:
+-- Login tokens (kid "test-asap-key"): signed by the login key pair,
+-- served at /test-observer/asap-keys/
+-- used by mod_auth_token (VirtualHost "localhost")
+-- System tokens (kid "test-system-asap-key"): signed by the system key pair,
+-- served at /test-observer/system-asap-keys/
+-- used by mod_muc_end_meeting and similar HTTP API modules
+local ASAP_KEY_PATH = "/opt/prosody-jitsi-plugins/test-asap-public.pem";
+local ASAP_KID_SHA256 = "dc6983da8e703a3f51d4c1cb92b52c982f7853ce3d5ba20c782fcd13616f6dfc";
+
+local SYSTEM_ASAP_KEY_PATH = "/opt/prosody-jitsi-plugins/test-system-asap-public.pem";
+local SYSTEM_ASAP_KID_SHA256 = "e76ed986a75a90756e5add6e8b56efc3d3f027764436d2744d29a33f0ec24fea";
+
+local function load_pem(path)
+ local f = io.open(path, "r");
+ if not f then return nil; end
+ local data = f:read("*all");
+ f:close();
+ return data;
+end
+
+local asap_public_key = load_pem(ASAP_KEY_PATH);
+if asap_public_key then
+ module:log("info", "Loaded test ASAP public key from %s", ASAP_KEY_PATH);
+else
+ module:log("warn", "Test ASAP public key not found at %s; login ASAP key-server routes will 404", ASAP_KEY_PATH);
+end
+
+local system_asap_public_key = load_pem(SYSTEM_ASAP_KEY_PATH);
+if system_asap_public_key then
+ module:log("info", "Loaded test system ASAP public key from %s", SYSTEM_ASAP_KEY_PATH);
+else
+ module:log("warn", "Test system ASAP public key not found at %s; system ASAP key-server routes will 404", SYSTEM_ASAP_KEY_PATH);
+end
+
+-- session-info: capture mod_jitsi_session / mod_auth_token fields after
+-- resource-bind so tests can assert URL query params and JWT claims were
+-- stored correctly on the session object.
+local session_info = {}; -- full_jid -> field snapshot
-local json = require "cjson.safe";
+local function capture_session_info(event)
+ local session = event.session;
+ local jid = session.full_jid;
+ if jid then
+ session_info[jid] = {
+ previd = session.previd,
+ customusername = session.customusername,
+ jitsi_web_query_room = session.jitsi_web_query_room,
+ jitsi_web_query_prefix = session.jitsi_web_query_prefix,
+ auth_token = session.auth_token,
+ user_region = session.user_region,
+ user_agent_header = session.user_agent_header,
+ -- Fields set by mod_auth_token after JWT verification:
+ jitsi_meet_room = session.jitsi_meet_room,
+ jitsi_meet_context_user = session.jitsi_meet_context_user,
+ jitsi_meet_context_group = session.jitsi_meet_context_group,
+ jitsi_meet_context_features = session.jitsi_meet_context_features,
+ };
+ end
+end
+
+-- resource-bind fires on each VirtualHost's own event bus, not globally.
+-- Register on every host already active at module load time, and on any host
+-- that activates later (e.g. hs256.localhost loads after localhost since
+-- VirtualHosts are initialized in config order).
+local function register_on_host(host_obj)
+ if host_obj and host_obj.events then
+ host_obj.events.add_handler("resource-bind", capture_session_info, 10);
+ end
+end
+
+for _, host_obj in pairs(prosody.hosts) do
+ register_on_host(host_obj);
+end
+
+-- host-activated passes the hostname string directly (not a table).
+module:hook_global("host-activated", function(host)
+ register_on_host(prosody.hosts[host]);
+end);
-- /conference.localhost/mod_test_observer is the absolute path for the shared
-- table created by mod_test_observer running on conference.localhost.
@@ -31,6 +129,35 @@ end
module:provides("http", {
default_path = "/test-observer";
route = {
+ -- GET /test-observer/asap-keys/.pem
+ -- Returns the login RSA public key PEM so mod_auth_token can verify RS256 login tokens.
+ -- kid must be "test-asap-key" (its SHA256 hex is the filename).
+ ["GET /asap-keys/"..ASAP_KID_SHA256..".pem"] = function()
+ if not asap_public_key then
+ return { status_code = 404; body = "key not found" };
+ end
+ return {
+ status_code = 200;
+ headers = { ["Content-Type"] = "application/x-pem-file" };
+ body = asap_public_key;
+ };
+ end;
+
+ -- GET /test-observer/system-asap-keys/.pem
+ -- Returns the system RSA public key PEM for mod_muc_end_meeting and similar
+ -- system HTTP API modules that use prosody_password_public_key_repo_url.
+ -- kid must be "test-system-asap-key" (its SHA256 hex is the filename).
+ ["GET /system-asap-keys/"..SYSTEM_ASAP_KID_SHA256..".pem"] = function()
+ if not system_asap_public_key then
+ return { status_code = 404; body = "key not found" };
+ end
+ return {
+ status_code = 200;
+ headers = { ["Content-Type"] = "application/x-pem-file" };
+ body = system_asap_public_key;
+ };
+ end;
+
["GET /events"] = function()
local events = shared.events or {};
-- cjson encodes an empty Lua table as {} (object); force array literal.
@@ -49,6 +176,108 @@ module:provides("http", {
return { status_code = 204 };
end;
+ -- GET /test-observer/jibri-iqs
+ -- Returns the list of Jibri IQs that reached the MUC (i.e. passed mod_filter_iq_jibri).
+ ["GET /jibri-iqs"] = function()
+ local iqs = shared.jibri_iqs or {};
+ local body = #iqs == 0 and "[]" or json.encode(iqs);
+ return {
+ status_code = 200;
+ headers = { ["Content-Type"] = "application/json" };
+ body = body;
+ };
+ end;
+
+ -- DELETE /test-observer/jibri-iqs
+ -- Clears the recorded Jibri IQ list. Call before each test.
+ ["DELETE /jibri-iqs"] = function()
+ shared.jibri_iqs = {};
+ return { status_code = 204 };
+ end;
+
+ -- GET /test-observer/dial-iqs
+ -- Returns the list of Rayo dial IQs that reached the MUC (i.e. passed mod_filter_iq_rayo).
+ ["GET /dial-iqs"] = function()
+ local iqs = shared.dial_iqs or {};
+ local body = #iqs == 0 and "[]" or json.encode(iqs);
+ return {
+ status_code = 200;
+ headers = { ["Content-Type"] = "application/json" };
+ body = body;
+ };
+ end;
+
+ -- DELETE /test-observer/dial-iqs
+ -- Clears the recorded Rayo dial IQ list. Call before each test.
+ ["DELETE /dial-iqs"] = function()
+ shared.dial_iqs = {};
+ return { status_code = 204 };
+ end;
+
+ -- POST /test-observer/sessions/context
+ -- Body: { "jid": "node@localhost/resource", "user_id": "...", "features": { "flip": true } }
+ -- Sets jitsi_meet_context_user / jitsi_meet_context_features on the c2s session so that
+ -- mod_muc_flip (and other JWT-aware modules) see the same context as they would with a
+ -- real token — without needing a JWT auth module in the test setup.
+ ["POST /sessions/context"] = function(event)
+ local data = json.decode(event.request.body or "{}") or {};
+ local full_jid = data.jid;
+ local user_id = data.user_id;
+ local features = data.features or {};
+ if not full_jid then
+ return { status_code = 400; body = '{"error":"missing jid"}' };
+ end
+ local node, host, resource = jid_lib.split(full_jid);
+ local host_obj = host and prosody.hosts[host];
+ if not host_obj then
+ return { status_code = 404; body = '{"error":"host not found"}' };
+ end
+ local user_obj = host_obj.sessions and host_obj.sessions[node];
+ if not user_obj then
+ return { status_code = 404; body = '{"error":"user not found"}' };
+ end
+ local session = user_obj.sessions and user_obj.sessions[resource];
+ if not session then
+ return { status_code = 404; body = '{"error":"session not found"}' };
+ end
+ if user_id then
+ session.jitsi_meet_context_user = { id = user_id };
+ end
+ session.jitsi_meet_context_features = features;
+ return {
+ status_code = 200;
+ headers = { ["Content-Type"] = "application/json" };
+ body = '{"ok":true}';
+ };
+ end;
+
+ -- GET /test-observer/rooms/participants?jid=room@conference.localhost
+ -- Returns: { participants_details: { userId: fullNick }, kicked_participant_nick?, flip_participant_nick? }
+ -- Exposes mod_muc_flip's per-room tracking tables for test assertions.
+ ["GET /rooms/participants"] = function(event)
+ local params = parse_query(event.request.url.query);
+ local room_jid = params["jid"];
+ if not room_jid then
+ return { status_code = 400; body = '{"error":"missing jid param"}' };
+ end
+ local room = (shared.rooms or {})[room_jid];
+ if not room then
+ return { status_code = 404; body = '{"error":"room not found"}' };
+ end
+ local result = { participants_details = room._data.participants_details or {} };
+ if room._data.kicked_participant_nick ~= nil then
+ result.kicked_participant_nick = room._data.kicked_participant_nick;
+ end
+ if room._data.flip_participant_nick ~= nil then
+ result.flip_participant_nick = room._data.flip_participant_nick;
+ end
+ return {
+ status_code = 200;
+ headers = { ["Content-Type"] = "application/json" };
+ body = json.encode(result);
+ };
+ end;
+
-- POST /test-observer/rooms/max-occupants
-- Body: { "jid": "room@conference.localhost", "max_occupants": 4 }
-- Sets room._data.max_occupants so per-room limit tests can override the
@@ -72,6 +301,58 @@ module:provides("http", {
};
end;
+ -- GET /test-observer/access-manager
+ -- Mock access-manager endpoint called by mod_muc_auth_ban.
+ -- Returns {"access": true} or {"access": false} based on the current
+ -- access_manager_state, or a non-200 status to simulate HTTP errors.
+ -- The real access manager receives `Authorization: Bearer ` but
+ -- this mock ignores it and returns the globally configured response.
+ ["GET /access-manager"] = function()
+ if access_manager_state.status ~= 200 then
+ return { status_code = access_manager_state.status; body = "error" };
+ end
+ return {
+ status_code = 200;
+ headers = { ["Content-Type"] = "application/json" };
+ body = json.encode({ access = access_manager_state.access });
+ };
+ end;
+
+ -- POST /test-observer/access-manager
+ -- Body: { "access": true|false, "status": }
+ -- Configures what the mock access manager returns.
+ -- Call this from tests before connecting the client under test.
+ -- Call with { "access": true, "status": 200 } to reset to the default.
+ ["POST /access-manager"] = function(event)
+ local data = json.decode(event.request.body or "{}") or {};
+ if data.access ~= nil then
+ access_manager_state.access = data.access;
+ end
+ if data.status ~= nil then
+ access_manager_state.status = tonumber(data.status) or 200;
+ end
+ return { status_code = 204 };
+ end;
+
+ -- GET /test-observer/session-info?jid=user@localhost/resource
+ -- Returns the mod_jitsi_session fields captured at resource-bind time.
+ ["GET /session-info"] = function(event)
+ local params = parse_query(event.request.url.query);
+ local jid = params["jid"];
+ if not jid then
+ return { status_code = 400; body = '{"error":"missing jid param"}' };
+ end
+ local info = session_info[jid];
+ if not info then
+ return { status_code = 404; body = '{"error":"session not found"}' };
+ end
+ return {
+ status_code = 200;
+ headers = { ["Content-Type"] = "application/json" };
+ body = json.encode(info);
+ };
+ end;
+
-- GET /test-observer/rooms?jid=room@conference.localhost
-- Returns: { jid, hidden, occupant_count }
["GET /rooms"] = function(event)
diff --git a/resources/prosody-plugins/mod_token_verification.lua b/resources/prosody-plugins/mod_token_verification.lua
index f729d6d871e8..c6526d9315e7 100644
--- a/resources/prosody-plugins/mod_token_verification.lua
+++ b/resources/prosody-plugins/mod_token_verification.lua
@@ -1,3 +1,10 @@
+-- Enforces JWT token room-claim verification at MUC join/create time. Loaded
+-- on the MUC component. For each join or room-create, calls
+-- token_util:verify_room() to check that the token's room claim matches the
+-- target room JID. Admins and domains listed in token_verification_allowlist
+-- are exempt. Anonymous users (no token) are allowed through. When
+-- token_verification_require_token_for_moderation is set, also blocks room
+-- config IQs (e.g. granting moderator status) from unauthenticated users.
-- Token authentication
-- Copyright (C) 2021-present 8x8, Inc.
diff --git a/resources/prosody-plugins/token/util.lib.lua b/resources/prosody-plugins/token/util.lib.lua
index 4db0090d59bb..1df762a6757e 100644
--- a/resources/prosody-plugins/token/util.lib.lua
+++ b/resources/prosody-plugins/token/util.lib.lua
@@ -327,13 +327,16 @@ function Util:process_and_verify_token(session)
if self.requireRoomClaim then
local roomClaim = claims["room"];
if roomClaim == nil then
- return false, "'room' claim is missing";
+ return false, "not-authorized", "room claim is missing";
end
end
-- Binds room name to the session which is later checked on MUC join
session.jitsi_meet_room = claims["room"];
-- Binds domain name to the session
+ if claims["sub"] == nil then
+ return false, "not-authorized", "sub claim is missing";
+ end
session.jitsi_meet_domain = claims["sub"];
session.jitsi_meet_auth_issuer = claims["iss"];
diff --git a/resources/prosody-plugins/util.lib.lua b/resources/prosody-plugins/util.lib.lua
index e235141d57d2..6ba5b41abbf4 100644
--- a/resources/prosody-plugins/util.lib.lua
+++ b/resources/prosody-plugins/util.lib.lua
@@ -365,6 +365,24 @@ function is_focus(nick)
return string.sub(nick, -string.len("/focus")) == "/focus";
end
+--- Returns true when the given bare resource/nick string is the focus nick.
+-- Use this when you have only the resource part in isolation (not a full JID),
+-- e.g. values read from a stats table keyed by resource.
+-- @param resource the bare resource string, e.g. "focus" or "user1"
+-- @return boolean
+local function is_focus_nick(resource)
+ return resource == 'focus';
+end
+
+--- Returns true when the given real (non-MUC) JID belongs to the focus account.
+-- Focus always authenticates with username 'focus' (e.g. focus@auth.example.com).
+-- Use this when you have the actor's real JID rather than a MUC occupant JID.
+-- @param real_jid a real JID string, e.g. "focus@auth.example.com/res"
+-- @return boolean
+local function is_focus_jid(real_jid)
+ return jid.node(real_jid) == 'focus';
+end
+
--- Builds the full MUC room address JID from its components.
-- Uses muc_domain_prefix from module configuration (default: "conference").
-- @param room_name the local part of the room JID (e.g. "myroom")
@@ -776,6 +794,8 @@ return {
async_handler_wrapper = async_handler_wrapper;
build_room_address = build_room_address;
is_focus = is_focus;
+ is_focus_nick = is_focus_nick;
+ is_focus_jid = is_focus_jid;
presence_check_status = presence_check_status;
process_host_module = process_host_module;
respond_iq_result = respond_iq_result;
diff --git a/tests/prosody/.mocharc.cjs b/tests/prosody/.mocharc.cjs
index aef09eba1575..a5d06bac2456 100644
--- a/tests/prosody/.mocharc.cjs
+++ b/tests/prosody/.mocharc.cjs
@@ -2,6 +2,7 @@ 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',
};
diff --git a/tests/prosody/README.md b/tests/prosody/README.md
index 601c93d1bcc4..c797a7dead59 100644
--- a/tests/prosody/README.md
+++ b/tests/prosody/README.md
@@ -1,7 +1,7 @@
# 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 plain XMPP and HTTP.
+inside Docker and connect to it over XMPP WebSocket and HTTP.
## Architecture and Lifecycle
@@ -13,8 +13,8 @@ and torn down after all tests complete.
npm test
└─ Mocha (setup.js beforeAll)
└─ Docker container: Prosody 13
- ├─ XMPP c2s :5222 ← test clients connect here
- └─ HTTP :5280 ← test assertions query here
+ └─ 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
@@ -102,8 +102,12 @@ tests/prosody/
├── lua/
│ └── jwk_spec.lua busted unit tests for token/jwk.lib.lua (no Prosody needed)
│
-├── 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_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
@@ -124,6 +128,11 @@ npm test # runs Lua unit tests, integration tests, and generates All
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 --
+```
+
To open the report after a run:
```bash
npx allure open allure-report
diff --git a/tests/prosody/docker/Dockerfile b/tests/prosody/docker/Dockerfile
index 2574f42d019d..4f395f7ce66b 100644
--- a/tests/prosody/docker/Dockerfile
+++ b/tests/prosody/docker/Dockerfile
@@ -1,15 +1,53 @@
# Prosody 0.13+ with Jitsi plugins for integration testing.
-# If prosody/prosody:0.13 is not yet published, use prosody/prosody:nightly.
+
+# 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 \
+ && 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
+
+# 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
diff --git a/tests/prosody/docker/inspect.lua b/tests/prosody/docker/inspect.lua
new file mode 100644
index 000000000000..1b01685002e2
--- /dev/null
+++ b/tests/prosody/docker/inspect.lua
@@ -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
diff --git a/tests/prosody/docker/prosody.cfg.lua b/tests/prosody/docker/prosody.cfg.lua
index 5b6ff41356e7..fa153564cf77 100644
--- a/tests/prosody/docker/prosody.cfg.lua
+++ b/tests/prosody/docker/prosody.cfg.lua
@@ -1,6 +1,5 @@
-- Minimal Prosody config for integration testing.
-- No TLS: tests connect plaintext over loopback.
--- Anonymous auth: no user provisioning needed.
data_path = "/var/lib/prosody"
pid_file = "/var/run/prosody/prosody.pid"
@@ -18,8 +17,29 @@ modules_enabled = {
"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
@@ -30,8 +50,19 @@ 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" }
+
VirtualHost "localhost"
- authentication = "anonymous"
+ authentication = "token"
+ app_id = "jitsi"
+ asap_key_server = "http://localhost:5280/test-observer/asap-keys"
+ signature_algorithm = "RS256"
+ allow_empty_token = true
+ -- Match production: room claim not required.
+ asap_require_room_claim = false
-- 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
@@ -39,23 +70,70 @@ VirtualHost "localhost"
modules_enabled = {
"test_observer_http";
"muc_size";
+ "muc_census";
+ "conference_duration";
+ "filter_iq_jibri";
+ "filter_iq_rayo";
+ "muc_kick_participant";
+ "system_chat_message";
+ "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";
}
+ -- 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.
+ main_muc = "conference.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 HS256 (shared-secret) token auth tests.
+VirtualHost "hs256.localhost"
+ authentication = "token"
+ app_id = "jitsi"
+ app_secret = "testsecret"
+ signature_algorithm = "HS256"
+ asap_require_room_claim = false
+ allow_empty_token = false
+
-- Second VirtualHost whose domain is listed in muc_access_whitelist on the
-- MUC component below. Clients connecting here get JIDs like
-- @whitelist.localhost and are treated as whitelisted.
-VirtualHost "whitelist.localhost"
- authentication = "anonymous"
+-- 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 used by the focus (jicofo) test helper. Clients connecting here
--- get JIDs like @focus.localhost. The domain is added to
--- muc_access_whitelist so focus clients do not count against the occupant
--- limit and are not counted when evaluating available slots for other users.
-VirtualHost "focus.localhost"
+VirtualHost "whitelist.localhost"
authentication = "anonymous"
Component "conference.localhost" "muc"
@@ -63,18 +141,60 @@ Component "conference.localhost" "muc"
"muc_hide_all";
"muc_max_occupants";
"muc_meeting_id";
+ "muc_resource_validate";
+ "muc_password_whitelist";
+ "token_verification";
+ "muc_flip";
"test_observer";
}
+ anonymous_strict = 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 focus.localhost represent jicofo; they also bypass the limit
- -- and are not counted against it, so existing tests are unaffected when a
- -- focus client unlocks the jicofo lock in mod_muc_meeting_id.
- muc_access_whitelist = { "whitelist.localhost", "focus.localhost" }
+ -- Clients on auth.localhost are the focus (jicofo) admin; they also bypass
+ -- the limit and are not counted against it.
+ muc_access_whitelist = { "whitelist.localhost", "auth.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.
+
+ -- 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
+
+-- 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 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"
diff --git a/tests/prosody/fixtures/test-asap-private.pem b/tests/prosody/fixtures/test-asap-private.pem
new file mode 100644
index 000000000000..e7a930b15d9c
--- /dev/null
+++ b/tests/prosody/fixtures/test-asap-private.pem
@@ -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-----
diff --git a/tests/prosody/fixtures/test-asap-public.pem b/tests/prosody/fixtures/test-asap-public.pem
new file mode 100644
index 000000000000..f8425b2de370
--- /dev/null
+++ b/tests/prosody/fixtures/test-asap-public.pem
@@ -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-----
diff --git a/tests/prosody/fixtures/test-system-asap-private.pem b/tests/prosody/fixtures/test-system-asap-private.pem
new file mode 100644
index 000000000000..a756ba6bb26a
--- /dev/null
+++ b/tests/prosody/fixtures/test-system-asap-private.pem
@@ -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-----
diff --git a/tests/prosody/fixtures/test-system-asap-public.pem b/tests/prosody/fixtures/test-system-asap-public.pem
new file mode 100644
index 000000000000..4a9d24dc781c
--- /dev/null
+++ b/tests/prosody/fixtures/test-system-asap-public.pem
@@ -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-----
diff --git a/tests/prosody/helpers/jwt.js b/tests/prosody/helpers/jwt.js
new file mode 100644
index 000000000000..465db7fcec30
--- /dev/null
+++ b/tests/prosody/helpers/jwt.js
@@ -0,0 +1,144 @@
+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:
+// /.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 (false in test config)
+ * - 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',
+ ...overrides }, { expired,
+ notYetValid }),
+ privateKey,
+ { algorithm: 'RS256',
+ keyid: kid }
+ );
+}
diff --git a/tests/prosody/helpers/prosody_shell.js b/tests/prosody/helpers/prosody_shell.js
index 1e3a6fae6b27..0a7f80771cdb 100644
--- a/tests/prosody/helpers/prosody_shell.js
+++ b/tests/prosody/helpers/prosody_shell.js
@@ -26,5 +26,11 @@ export async function prosodyShell(command) {
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;
}
diff --git a/tests/prosody/helpers/test_context.js b/tests/prosody/helpers/test_context.js
new file mode 100644
index 000000000000..e6e56b7f1e41
--- /dev/null
+++ b/tests/prosody/helpers/test_context.js
@@ -0,0 +1,66 @@
+import { createXmppClient, joinWithFocus } 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.
+ *
+ * @returns {Promise}
+ */
+ async connect() {
+ const c = await createXmppClient();
+
+ 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}
+ */
+ 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}
+ */
+ async connectFocus(roomJid) {
+ const c = await joinWithFocus(roomJid);
+
+ clients.push(c);
+
+ return c;
+ },
+
+ /**
+ * Disconnects all clients created through this context.
+ *
+ * @returns {Promise}
+ */
+ async cleanup() {
+ await Promise.all(clients.map(c => c.disconnect()));
+ }
+ };
+}
diff --git a/tests/prosody/helpers/test_observer.js b/tests/prosody/helpers/test_observer.js
index 6c13e245b572..5d55989910d4 100644
--- a/tests/prosody/helpers/test_observer.js
+++ b/tests/prosody/helpers/test_observer.js
@@ -1,4 +1,36 @@
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.
+ */
+export async function setAccessManagerResponse({ access = true, status = 200 } = {}) {
+ const res = await fetch(ACCESS_MANAGER_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ access,
+ status })
+ });
+
+ 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.
@@ -49,10 +81,53 @@ export async function setRoomMaxOccupants(roomJid, max) {
}
}
+/**
+ * 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();
+}
+
/**
* 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}>}
+ * @returns {Promise<{jid: string, hidden: boolean, occupant_count: number}|null>}
*/
export async function getRoomState(roomJid) {
const res = await fetch(`${BASE}/rooms?jid=${encodeURIComponent(roomJid)}`);
@@ -66,3 +141,144 @@ export async function getRoomState(roomJid) {
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() };
+}
diff --git a/tests/prosody/helpers/xmpp_client.js b/tests/prosody/helpers/xmpp_client.js
index beb22d3c63b6..f3e6c48afa7f 100644
--- a/tests/prosody/helpers/xmpp_client.js
+++ b/tests/prosody/helpers/xmpp_client.js
@@ -3,12 +3,43 @@ import { client, xml } from '@xmpp/client';
let _counter = 0;
/**
- * Connects as a focus (jicofo) participant, joins roomJid with nick 'focus',
- * and returns the client. This unlocks the mod_muc_meeting_id jicofo lock so
- * that regular clients can subsequently join the same room.
+ * Creates an anonymous XMPP client and joins a Jigasi brewery MUC with a
+ * colibri stats presence extension, simulating a Jigasi SIP gateway instance.
+ * The presence advertises supports_sip and stress_level so that
+ * mod_muc_jigasi_invite can select this instance for dial-out.
*
- * The focus client uses domain 'focus.localhost', which is whitelisted in
- * mod_muc_max_occupants so it never counts against the occupant limit.
+ * @param {string} breweryJid Full brewery room JID, e.g.
+ * 'jigasibrewery@internal.auth.localhost'
+ * @param {string} [nick] MUC nick (must not be 'focus'). Defaults to a
+ * unique generated nick.
+ * @param {object} [opts]
+ * @param {boolean} [opts.supportsSip=true] Advertise SIP support.
+ * @param {number} [opts.stressLevel=0.1] Stress level (lower = preferred).
+ * @returns {Promise}
+ */
+export async function joinWithJigasi(breweryJid, nick, { supportsSip = true, stressLevel = 0.1 } = {}) {
+ const statsEl = xml('stats', { xmlns: 'http://jitsi.org/protocol/colibri' },
+ xml('stat', { name: 'supports_sip',
+ value: supportsSip ? 'true' : 'false' }),
+ xml('stat', { name: 'stress_level',
+ value: String(stressLevel) })
+ );
+
+ const c = await createXmppClient();
+
+ await c.joinRoom(breweryJid, nick, { extensions: [ statsEl ] });
+
+ return c;
+}
+
+/**
+ * Connects as the focus (jicofo) admin participant, joins roomJid with nick
+ * 'focus', and returns the client. This unlocks the mod_muc_meeting_id jicofo
+ * lock so that regular clients can subsequently join the same room.
+ *
+ * The focus client authenticates as focus@auth.localhost (a Prosody admin), so
+ * it is exempt from token_verification checks, is never counted against occupant
+ * limits (auth.localhost is in muc_access_whitelist), and can act as room owner.
*
* The caller is responsible for disconnecting the returned client (typically
* by pushing it into the test's `clients` array for afterEach cleanup).
@@ -17,7 +48,11 @@ let _counter = 0;
* @returns {Promise}
*/
export async function joinWithFocus(roomJid) {
- const c = await createXmppClient({ domain: 'focus.localhost' });
+ const c = await createXmppClient({
+ domain: 'auth.localhost',
+ username: 'focus',
+ password: 'focussecret'
+ });
await c.joinRoom(roomJid, 'focus');
@@ -29,20 +64,39 @@ export async function joinWithFocus(roomJid) {
* Prosody must be configured with `authentication = "anonymous"` and no TLS.
*
* @param {object} opts
- * @param {string} [opts.host='localhost'] TCP host to connect to.
- * @param {number} [opts.port=5222] TCP port.
- * @param {string} [opts.domain] XMPP domain (stream header). Defaults to host.
- * Set to a different VirtualHost name (e.g.
- * 'whitelist.localhost') to get a JID on that
- * domain without changing the TCP target.
+ * @param {string} [opts.host='localhost'] TCP host to connect to.
+ * @param {string} [opts.domain] XMPP domain (stream header). Defaults to host.
+ * Set to a different VirtualHost name (e.g.
+ * 'whitelist.localhost') to get a JID on that
+ * domain without changing the TCP target.
+ * @param {object} [opts.params] Optional query parameters appended to the
+ * WebSocket URL (e.g. { previd: 'token' }).
+ * @param {string} [opts.username] SASL username for PLAIN auth. When omitted,
+ * ANONYMOUS auth is used.
+ * @param {string} [opts.password] SASL password for PLAIN auth.
* @returns {Promise}
*/
-export async function createXmppClient({ host = 'localhost', port = 5222, domain } = {}) {
+export async function createXmppClient({ host = 'localhost', domain, params, username, password } = {}) {
+ const url = new URL(`ws://${host}:5280/xmpp-websocket`);
+
+ if (params) {
+ for (const [ k, v ] of Object.entries(params)) {
+ url.searchParams.set(k, v);
+ }
+ }
+
const xmpp = client({
- service: `xmpp://${host}:${port}`,
- domain: domain ?? host
+ service: url.toString(),
+ domain: domain ?? host,
+ ...username === undefined ? {} : { username,
+ password }
});
+ // Suppress unhandled 'error' events (e.g. WebSocket close after auth failure).
+ // Errors surface through the xmpp.start() promise rejection instead.
+ // eslint-disable-next-line no-empty-function
+ xmpp.on('error', () => {});
+
// id -> { resolve, reject, timer }
const pendingIqs = new Map();
const stanzaQueue = [];
@@ -67,21 +121,58 @@ export async function createXmppClient({ host = 'localhost', port = 5222, domain
return {
jid: xmpp.jid?.toString(),
+ /**
+ * The default MUC nick for this client: first 8 characters of the
+ * server-assigned JID local part. Matches the UUID prefix required by
+ * Prosody's anonymous_strict mode. Null when the JID is not yet set.
+ *
+ * @returns {string|null}
+ */
+ get nick() {
+ const local = xmpp.jid?.toString().split('@')[0] ?? '';
+
+ return local ? local.slice(0, 8) : null;
+ },
+
+ /**
+ * Returns the XEP-0198 stream management resumption token assigned by
+ * the server. Only available after the session is online and stream
+ * management has been negotiated. Must be read before disconnecting
+ * (the token is cleared on offline).
+ *
+ * @returns {string}
+ */
+ get smId() {
+ return xmpp.streamManagement.id;
+ },
+
/**
* Joins a MUC room. Resolves with the self-presence stanza (may have type='error').
- * @param {string} roomJid e.g. 'room@conference.localhost'
- * @param {string} [nick] defaults to a unique generated nick
+ * Rejects with a timeout error if no presence is received within the timeout.
+ * @param {string} roomJid e.g. 'room@conference.localhost'
+ * @param {string} [nick] defaults to a unique generated nick
+ * @param {object} [opts]
+ * @param {number} [opts.timeout=5000] ms to wait for presence before rejecting
+ * @param {string} [opts.password] room password to include in the join stanza
*/
- async joinRoom(roomJid, nick) {
- const n = nick ?? `user${++_counter}`;
+ async joinRoom(roomJid, nick, { timeout = 5000, password: roomPassword, extensions = [] } = {}) {
+ // Default to the first 8 characters of the local part of the
+ // server-assigned JID. Prosody's anonymous_strict mode requires MUC
+ // resources to match this prefix, so callers that do not pass an
+ // explicit nick automatically satisfy the constraint.
+ const selfLocal = xmpp.jid?.toString().split('@')[0] ?? '';
+ const n = nick ?? (selfLocal ? selfLocal.slice(0, 8) : `user${++_counter}`);
+ const mucX = xml('x', { xmlns: 'http://jabber.org/protocol/muc' });
+
+ if (roomPassword !== undefined) {
+ mucX.c('password').t(roomPassword);
+ }
await xmpp.send(
- xml('presence', { to: `${roomJid}/${n}` },
- xml('x', { xmlns: 'http://jabber.org/protocol/muc' })
- )
+ xml('presence', { to: `${roomJid}/${n}` }, mucX, ...extensions)
);
- const presence = await waitForPresence(stanzaQueue, roomJid);
+ const presence = await waitForPresence(stanzaQueue, roomJid, timeout);
// Prosody 13 locks newly created rooms (XEP-0045 §10.1.3) until the
// owner submits a configuration form. Status 201 means the room was
@@ -104,6 +195,99 @@ export async function createXmppClient({ host = 'localhost', port = 5222, domain
return presence;
},
+ /**
+ * Sets (or clears) the password on a MUC room. The client must be the room owner.
+ * Resolves when the server acknowledges the configuration change.
+ * @param {string} roomJid e.g. 'room@conference.localhost'
+ * @param {string} password pass empty string to remove the password
+ */
+ setRoomPassword(roomJid, roomPassword) {
+ return sendIq(xmpp, pendingIqs,
+ xml('iq', { type: 'set',
+ to: roomJid,
+ id: `cfg-${++_counter}` },
+ xml('query', { xmlns: 'http://jabber.org/protocol/muc#owner' },
+ xml('x', { xmlns: 'jabber:x:data',
+ type: 'submit' },
+ xml('field', { var: 'FORM_TYPE' },
+ xml('value', {}, 'http://jabber.org/protocol/muc#roomconfig')
+ ),
+ xml('field', { var: 'muc#roomconfig_roomsecret' },
+ xml('value', {}, roomPassword)
+ )
+ )
+ )
+ )
+ );
+ },
+
+ /**
+ * Sends a Jibri IQ (http://jitsi.org/protocol/jibri) to the focus
+ * occupant of the given room. Fire-and-forget — does NOT wait for a
+ * response, because mod_filter_iq_jibri may block it before it reaches
+ * any handler that would reply, and the test asserts via the
+ * /jibri-iqs HTTP endpoint instead.
+ *
+ * @param {string} roomJid e.g. 'room@conference.localhost'
+ * @param {string} action 'start' | 'stop' | 'status' etc.
+ * @param {string} recordingMode 'file' (recording) | 'stream' (livestreaming)
+ */
+ sendJibriIq(roomJid, action, recordingMode) {
+ // Directed to focus's occupant full JID so `pre-iq/full` fires on the
+ // main VirtualHost where mod_filter_iq_jibri is loaded.
+ return xmpp.send(
+ xml('iq', { type: 'set',
+ to: `${roomJid}/focus`,
+ id: `jibri-${++_counter}` },
+ xml('jibri', {
+ xmlns: 'http://jitsi.org/protocol/jibri',
+ action,
+ // eslint-disable-next-line camelcase
+ recording_mode: recordingMode
+ })
+ )
+ );
+ },
+
+ /**
+ * Sends a Rayo dial IQ (urn:xmpp:rayo:1) to the focus occupant of the
+ * given room. Fire-and-forget — does NOT wait for a response, because
+ * mod_filter_iq_rayo may block it, and the test asserts via the
+ * /dial-iqs HTTP endpoint instead.
+ *
+ * @param {string} roomJid e.g. 'room@conference.localhost'
+ * @param {string} [dialTo='sip:test@example.com'] value for dial's `to` attribute.
+ * Pass 'jitsi_meet_transcribe' to trigger
+ * the transcription feature gate.
+ * @param {string|null} [roomNameHeader] value for the JvbRoomName header.
+ * Defaults to `roomJid` (correct value).
+ * Pass null to omit the header entirely.
+ * Pass any other string for a mismatch test.
+ */
+ sendRayoIq(roomJid, dialTo = 'sip:test@example.com', roomNameHeader = roomJid) {
+ const headers = [];
+
+ if (roomNameHeader !== null) {
+ headers.push(xml('header', {
+ xmlns: 'urn:xmpp:rayo:1',
+ name: 'JvbRoomName',
+ value: roomNameHeader
+ }));
+ }
+
+ return xmpp.send(
+ xml('iq', { type: 'set',
+ to: `${roomJid}/focus`,
+ id: `rayo-${++_counter}` },
+ xml('dial', {
+ xmlns: 'urn:xmpp:rayo:1',
+ to: dialTo,
+ from: 'fromdomain'
+ }, ...headers)
+ )
+ );
+ },
+
/**
* Sends a disco#info IQ and resolves with the response stanza.
* @param {string} targetJid
@@ -118,10 +302,223 @@ export async function createXmppClient({ host = 'localhost', port = 5222, domain
);
},
+ /**
+ * Sends an message to the given component JID.
+ * mod_end_conference uses the sender's jitsi_web_query_room session field
+ * (populated by mod_jitsi_session from the ?room= WebSocket URL param) to
+ * locate and destroy the target room. Fire-and-forget — the module returns
+ * no reply stanza; verify the side-effect via getRoomState.
+ *
+ * @param {string} componentJid e.g. 'endconference.localhost'
+ */
+ sendEndConference(componentJid) {
+ return xmpp.send(
+ xml('message', { to: componentJid,
+ id: `ec-${++_counter}` },
+ xml('end_conference')
+ )
+ );
+ },
+
+ /**
+ * Sends a plain stanza to the given JID with no special children.
+ * Useful for testing that the end_conference component ignores messages
+ * that lack the child element.
+ *
+ * @param {string} to Destination JID.
+ */
+ sendPlainMessage(to) {
+ return xmpp.send(
+ xml('message', { to,
+ id: `msg-${++_counter}` })
+ );
+ },
+
+ /**
+ * Grants moderator role to the occupant identified by nick.
+ * The caller must be the room owner (e.g. the focus client).
+ * Resolves with the server's IQ response.
+ *
+ * @param {string} roomJid e.g. 'room@conference.localhost'
+ * @param {string} nick MUC nick of the occupant to promote.
+ */
+ grantModerator(roomJid, nick) {
+ return sendIq(xmpp, pendingIqs,
+ xml('iq', { type: 'set',
+ to: roomJid,
+ id: `mod-${++_counter}` },
+ xml('query', { xmlns: 'http://jabber.org/protocol/muc#admin' },
+ xml('item', { nick,
+ role: 'moderator' })
+ )
+ )
+ );
+ },
+
+ /**
+ * Waits for an incoming stanza that satisfies an optional
+ * filter predicate and resolves with it. Non-matching presences are left
+ * in the queue. Rejects with a timeout error if no matching presence
+ * arrives within the timeout.
+ * @param {Function} [filter] Predicate; defaults to accepting any presence.
+ * @param {number} [timeout=5000]
+ */
+ waitForPresence(filter = null, timeout = 5000) {
+ const pred = filter ?? (() => true);
+
+ return new Promise((resolve, reject) => {
+ const deadline = Date.now() + timeout;
+
+ const check = () => {
+ for (let i = 0; i < stanzaQueue.length; i++) {
+ const s = stanzaQueue[i];
+
+ if (s.name === 'presence' && pred(s)) {
+ resolve(stanzaQueue.splice(i, 1)[0]);
+
+ return;
+ }
+ }
+ if (Date.now() >= deadline) {
+ reject(new Error('Timeout waiting for presence stanza'));
+
+ return;
+ }
+ setTimeout(check, 50);
+ };
+
+ check();
+ });
+ },
+
+ /**
+ * Convenience wrapper around waitForPresence that matches by full from-JID
+ * and optional presence type.
+ *
+ * @param {string} from full JID, e.g. 'room@conference.localhost/nick'
+ * @param {object} [opts]
+ * @param {string} [opts.type] presence type to match (e.g. 'unavailable')
+ * @param {number} [opts.timeout=5000]
+ */
+ waitForPresenceFrom(from, { type, timeout = 5000 } = {}) {
+ return this.waitForPresence(
+ s => s.attrs.from === from && (type === undefined || s.attrs.type === type),
+ timeout
+ );
+ },
+
+ /**
+ * Waits for an incoming stanza that satisfies an optional filter
+ * predicate and resolves with it. Only unsolicited IQs land here;
+ * responses to IQs sent with sendIq are handled via pendingIqs.
+ * @param {Function} [filter] Predicate; defaults to accepting any IQ.
+ * @param {number} [timeout=5000]
+ */
+ waitForIq(filter = null, timeout = 5000) {
+ const pred = filter ?? (() => true);
+
+ return new Promise((resolve, reject) => {
+ const deadline = Date.now() + timeout;
+
+ const check = () => {
+ for (let i = 0; i < stanzaQueue.length; i++) {
+ const s = stanzaQueue[i];
+
+ if (s.name === 'iq' && pred(s)) {
+ resolve(stanzaQueue.splice(i, 1)[0]);
+
+ return;
+ }
+ }
+ if (Date.now() >= deadline) {
+ reject(new Error('Timeout waiting for IQ stanza'));
+
+ return;
+ }
+ setTimeout(check, 50);
+ };
+
+ check();
+ });
+ },
+
+ /**
+ * Waits for an incoming stanza that satisfies an optional
+ * filter predicate and resolves with it. Non-matching messages are left
+ * in the queue. Rejects with a timeout error if no matching message
+ * arrives within the timeout.
+ * @param {Function} [filter] Predicate; defaults to accepting any message.
+ * @param {number} [timeout=5000]
+ */
+ waitForMessage(filter = null, timeout = 5000) {
+ const pred = filter ?? (() => true);
+
+ return new Promise((resolve, reject) => {
+ const deadline = Date.now() + timeout;
+
+ const check = () => {
+ for (let i = 0; i < stanzaQueue.length; i++) {
+ const s = stanzaQueue[i];
+
+ if (s.name === 'message' && pred(s)) {
+ resolve(stanzaQueue.splice(i, 1)[0]);
+
+ return;
+ }
+ }
+ if (Date.now() >= deadline) {
+ reject(new Error('Timeout waiting for message stanza'));
+
+ return;
+ }
+ setTimeout(check, 50);
+ };
+
+ check();
+ });
+ },
+
+ /**
+ * Resolves when the underlying XMPP connection goes offline (e.g.
+ * because Prosody closed the session). Useful for verifying that
+ * mod_muc_auth_ban's async ban — which calls session:close() from
+ * the HTTP callback — actually terminates the connection.
+ *
+ * Rejects with a timeout error if the connection does not drop within
+ * the given timeout.
+ *
+ * @param {number} [timeout=5000]
+ */
+ waitForDisconnect(timeout = 5000) {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(
+ () => reject(new Error('Timeout waiting for disconnect')),
+ timeout
+ );
+
+ xmpp.once('offline', () => {
+ clearTimeout(timer);
+ resolve();
+ });
+ });
+ },
+
async disconnect() {
try {
await xmpp.stop();
} catch { /* ignore on teardown */ }
+ },
+
+ /**
+ * Abruptly closes the underlying WebSocket without sending a stream
+ * close. Prosody will put the session into smacks hibernation, keeping
+ * it in full_sessions so mod_auth_jitsi-anonymous can find it by
+ * resumption_token on the next connect.
+ */
+ dropConnection() {
+ try {
+ xmpp.websocket?.socket?.end();
+ } catch { /* ignore */ }
}
};
}
diff --git a/tests/prosody/helpers/xmpp_utils.js b/tests/prosody/helpers/xmpp_utils.js
new file mode 100644
index 000000000000..a971bb66debf
--- /dev/null
+++ b/tests/prosody/helpers/xmpp_utils.js
@@ -0,0 +1,29 @@
+/**
+ * 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;
+}
diff --git a/tests/prosody/lua/mod_certs_s2soutinjection_spec.lua b/tests/prosody/lua/mod_certs_s2soutinjection_spec.lua
new file mode 100644
index 000000000000..a71190cf6eed
--- /dev/null
+++ b/tests/prosody/lua/mod_certs_s2soutinjection_spec.lua
@@ -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)
diff --git a/tests/prosody/lua/mod_debug_traceback_spec.lua b/tests/prosody/lua/mod_debug_traceback_spec.lua
new file mode 100644
index 000000000000..49fb04e2d7d0
--- /dev/null
+++ b/tests/prosody/lua/mod_debug_traceback_spec.lua
@@ -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)
diff --git a/tests/prosody/lua/mod_short_lived_token_spec.lua b/tests/prosody/lua/mod_short_lived_token_spec.lua
new file mode 100644
index 000000000000..fe8808290530
--- /dev/null
+++ b/tests/prosody/lua/mod_short_lived_token_spec.lua
@@ -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['prosody.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
diff --git a/tests/prosody/lua/util_spec.lua b/tests/prosody/lua/util_spec.lua
index e413d4dfc234..4e38293367dc 100644
--- a/tests/prosody/lua/util_spec.lua
+++ b/tests/prosody/lua/util_spec.lua
@@ -799,6 +799,58 @@ describe("util.lib", function()
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)
diff --git a/tests/prosody/mod_auth_jitsi_anonymous_spec.js b/tests/prosody/mod_auth_jitsi_anonymous_spec.js
new file mode 100644
index 000000000000..13c851e5bd80
--- /dev/null
+++ b/tests/prosody/mod_auth_jitsi_anonymous_spec.js
@@ -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