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} + */ + async function connect(opts = {}) { + const c = await createXmppClient({ domain: 'jitsi-anonymous.localhost', + ...opts }); + + clients.push(c); + + return c; + } + + it('assigns a UUID-based JID on anonymous connect', async () => { + const c = await connect(); + + assert.ok(c.jid, 'client must have a JID after connect'); + + const username = c.jid.split('@')[0]; + + // Prosody anonymous auth assigns a UUID (32 hex chars + 4 dashes = 36 chars). + assert.match(username, + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + `JID username must be a UUID, got: ${username}`); + }); + + it('assigns a different JID to each client', async () => { + const c1 = await connect(); + const c2 = await connect(); + + assert.notEqual(c1.jid.split('@')[0], c2.jid.split('@')[0], + 'each anonymous client must get a unique username'); + }); + + it('resumes session with same username when previd matches SM token', async () => { + const c1 = await connect(); + const username = c1.jid.split('@')[0]; + + // Capture the SM resumption token before disconnecting — it is cleared on offline. + const smId = c1.smId; + + assert.ok(smId, 'stream management id must be set after connect'); + + // Drop the connection abruptly (no stream close) so Prosody puts the + // session into smacks hibernation — it stays in full_sessions where + // mod_auth_jitsi-anonymous can find it by resumption_token. + c1.dropConnection(); + + // Reconnect with previd — mod_auth_jitsi-anonymous should reuse the username. + const c2 = await connect({ params: { previd: smId } }); + + assert.equal(c2.jid.split('@')[0], username, + 'reconnecting with previd must yield the same username'); + }); + + it('assigns a new username when previd does not match any session', async () => { + const c = await connect({ params: { previd: 'nonexistent-token' } }); + + const username = c.jid.split('@')[0]; + + assert.match(username, + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + 'unmatched previd must fall back to a fresh UUID username'); + }); +}); diff --git a/tests/prosody/mod_auth_jitsi_shared_secret_spec.js b/tests/prosody/mod_auth_jitsi_shared_secret_spec.js new file mode 100644 index 000000000000..698756a6cf5d --- /dev/null +++ b/tests/prosody/mod_auth_jitsi_shared_secret_spec.js @@ -0,0 +1,107 @@ +import assert from 'assert'; + +import { getContainer } from './helpers/container.js'; +import { prosodyShell } from './helpers/prosody_shell.js'; +import { createXmppClient } from './helpers/xmpp_client.js'; + +const DOMAIN = 'shared-secret.localhost'; +const PROSODY_CFG = '/etc/prosody/prosody.cfg.lua'; + +const SECRET = 'topsecret'; +const PREV_SECRET = 'oldsecret'; + +/** + * Enable or disable shared_secret_prev in the live Prosody config by + * commenting/uncommenting the line, reloading the config, and reloading the + * auth module so it re-reads the options. + * + * @param {boolean} enabled + */ +async function setSharedSecretPrev(enabled) { // eslint-disable-line no-unused-vars + const container = getContainer(); + const from = enabled + ? `-- shared_secret_prev = "${PREV_SECRET}"` + : `shared_secret_prev = "${PREV_SECRET}"`; + const to = enabled + ? `shared_secret_prev = "${PREV_SECRET}"` + : `-- shared_secret_prev = "${PREV_SECRET}"`; + + await container.exec([ 'sed', '-i', `s/${from}/${to}/`, PROSODY_CFG ]); + await container.exec([ 'prosodyctl', 'reload' ]); + await prosodyShell(`module:reload("auth_jitsi-shared-secret", "${DOMAIN}")`); + await new Promise(resolve => setTimeout(resolve, 300)); +} + +/** + * Attempt to connect with the given credentials. + * Returns the connected client on success, or throws on auth failure. + * + * @param {string} username + * @param {string} password + * @returns {Promise} + */ +function connect(username, password) { + return createXmppClient({ domain: DOMAIN, + username, + password }); +} + +describe('mod_auth_jitsi-shared-secret', () => { + + const clients = []; + + afterEach(async () => { + await Promise.all(clients.map(c => c.disconnect())); + clients.length = 0; + }); + + it('allows connection with correct shared secret', async () => { + const c = await connect('alice', SECRET); + + clients.push(c); + assert.ok(c.jid, 'client must have a JID after successful auth'); + }); + + it('allows any username with the correct secret', async () => { + const c1 = await connect('alice', SECRET); + const c2 = await connect('bob', SECRET); + const c3 = await connect('anyusernamehere', SECRET); + + clients.push(c1, c2, c3); + assert.ok(c1.jid, 'alice must be allowed'); + assert.ok(c2.jid, 'bob must be allowed'); + assert.ok(c3.jid, 'arbitrary username must be allowed'); + }); + + it('rejects connection with wrong secret', async () => { + await assert.rejects( + () => connect('alice', 'wrongsecret'), + 'connection with wrong secret must be rejected' + ); + }); + + // TODO: shared_secret_prev is broken for PLAIN SASL. get_sasl_handler() always + // returns shared_secret from its plain callback, so Prosody rejects any password + // other than the current secret. provider.test_password() handles shared_secret_prev + // correctly but is not called in the PLAIN SASL flow. + // it('allows connection with shared_secret_prev', async () => { + // const c = await connect('alice', PREV_SECRET); + // clients.push(c); + // assert.ok(c.jid, 'client must be allowed with prev secret'); + // }); + + // it('rejects shared_secret_prev after it is removed from config', async () => { + // await setSharedSecretPrev(false); + // try { + // await assert.rejects( + // () => connect('alice', PREV_SECRET), + // 'prev secret must be rejected after removal from config' + // ); + // const c = await connect('alice', SECRET); + // clients.push(c); + // assert.ok(c.jid, 'current secret must still be accepted'); + // } finally { + // await setSharedSecretPrev(true); + // } + // }); +}); diff --git a/tests/prosody/mod_auth_token_asap_spec.js b/tests/prosody/mod_auth_token_asap_spec.js new file mode 100644 index 000000000000..23683a3bed94 --- /dev/null +++ b/tests/prosody/mod_auth_token_asap_spec.js @@ -0,0 +1,181 @@ +import assert from 'assert'; +import http from 'http'; + +import { mintAsapToken } from './helpers/jwt.js'; +import { createXmppClient } from './helpers/xmpp_client.js'; + +/** + * Fetches session-info for the given full JID. + * + * @param {string} jid + * @returns {Promise} + */ +function getSessionInfo(jid) { + return new Promise((resolve, reject) => { + const url = `http://localhost:5280/test-observer/session-info?jid=${encodeURIComponent(jid)}`; + + http.get(url, res => { + let body = ''; + + res.on('data', chunk => { + body += chunk; + }); + res.on('end', () => { + if (res.statusCode !== 200) { + reject(new Error(`session-info returned ${res.statusCode}: ${body}`)); + + return; + } + try { + resolve(JSON.parse(body)); + } catch (e) { + reject(new Error(`session-info bad JSON: ${body}`)); + } + }); + }).on('error', reject); + }); +} + +/** + * Connects to the main VirtualHost (RS256 / ASAP key-server auth). + * Tokens must be signed with the test RSA private key; Prosody fetches the + * matching public key from mod_test_observer_http's /asap-keys/ route. + */ +function asapClient(params) { + return createXmppClient({ params }); +} + +describe('mod_auth_token (ASAP / RS256)', () => { + + const clients = []; + + afterEach(async () => { + await Promise.all(clients.map(c => c.disconnect())); + clients.length = 0; + }); + + it('connects successfully with a valid RS256 token', async () => { + const token = mintAsapToken(); + const c = await asapClient({ token }); + + clients.push(c); + assert.ok(c.jid, 'client should have a JID after connecting'); + }); + + it('rejects connection with wrong private key', async () => { + // Sign with a freshly generated key that the server does not know. + const { generateKeyPairSync } = await import('crypto'); + const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const wrongPem = privateKey.export({ type: 'pkcs8', + format: 'pem' }); + const token = mintAsapToken({}, { privateKey: wrongPem }); + + await assert.rejects( + () => asapClient({ token }), + /not-allowed/ + ); + }); + + it('rejects connection with expired token', async () => { + const token = mintAsapToken({}, { expired: true }); + + await assert.rejects( + () => asapClient({ token }), + /not-allowed/ + ); + }); + + it('rejects connection with not-yet-valid token (nbf in the future)', async () => { + const token = mintAsapToken({}, { notYetValid: true }); + + await assert.rejects( + () => asapClient({ token }), + /not-allowed/ + ); + }); + + it('rejects connection with wrong issuer', async () => { + const token = mintAsapToken({ iss: 'other-app' }); + + await assert.rejects( + () => asapClient({ token }), + /not-allowed/ + ); + }); + + it('sets session.jitsi_meet_context_features from token context', async () => { + const token = mintAsapToken({ + context: { + features: { + 'screen-sharing': true, + 'recording': false + } + } + }); + const c = await asapClient({ token }); + + clients.push(c); + const info = await getSessionInfo(c.jid); + + assert.strictEqual(info.jitsi_meet_context_features['screen-sharing'], true); + assert.strictEqual(info.jitsi_meet_context_features.recording, false); + }); + + it('sets session.jitsi_meet_room from room claim', async () => { + const token = mintAsapToken({ room: 'testroom' }); + const c = await asapClient({ token }); + + clients.push(c); + const info = await getSessionInfo(c.jid); + + assert.strictEqual(info.jitsi_meet_room, 'testroom'); + }); + + it('sets session.jitsi_meet_context_user from token context', async () => { + const token = mintAsapToken({ + context: { + user: { id: 'user-123', + name: 'Alice', + email: 'alice@example.com' } + } + }); + const c = await asapClient({ token }); + + clients.push(c); + const info = await getSessionInfo(c.jid); + + assert.strictEqual(info.jitsi_meet_context_user.id, 'user-123'); + assert.strictEqual(info.jitsi_meet_context_user.name, 'Alice'); + assert.strictEqual(info.jitsi_meet_context_user.email, 'alice@example.com'); + }); + + it('sets session.jitsi_meet_context_group from token context', async () => { + const token = mintAsapToken({ + context: { group: 'test-group' } + }); + const c = await asapClient({ token }); + + clients.push(c); + const info = await getSessionInfo(c.jid); + + assert.strictEqual(info.jitsi_meet_context_group, 'test-group'); + }); + + it('sets session.jitsi_meet_context_user.id from top-level user_id when context is absent', async () => { + // eslint-disable-next-line camelcase + const token = mintAsapToken({ user_id: 'legacy-user-456' }); + const c = await asapClient({ token }); + + clients.push(c); + const info = await getSessionInfo(c.jid); + + assert.strictEqual(info.jitsi_meet_context_user.id, 'legacy-user-456'); + }); + + it('allows connection without token when allow_empty_token is true', async () => { + const c = await asapClient({}); + + clients.push(c); + assert.ok(c.jid, 'client should have a JID'); + }); +}); diff --git a/tests/prosody/mod_auth_token_spec.js b/tests/prosody/mod_auth_token_spec.js new file mode 100644 index 000000000000..688b16658b81 --- /dev/null +++ b/tests/prosody/mod_auth_token_spec.js @@ -0,0 +1,125 @@ +import assert from 'assert'; +import http from 'http'; + +import { mintToken } from './helpers/jwt.js'; +import { createXmppClient } from './helpers/xmpp_client.js'; + +/** + * Fetches session-info for the given full JID. + * + * @param {string} jid + * @returns {Promise} + */ +function getSessionInfo(jid) { + return new Promise((resolve, reject) => { + const url = `http://localhost:5280/test-observer/session-info?jid=${encodeURIComponent(jid)}`; + + http.get(url, res => { + let body = ''; + + res.on('data', chunk => { + body += chunk; + }); + res.on('end', () => { + if (res.statusCode !== 200) { + reject(new Error(`session-info returned ${res.statusCode}: ${body}`)); + + return; + } + try { + resolve(JSON.parse(body)); + } catch (e) { + reject(new Error(`session-info bad JSON: ${body}`)); + } + }); + }).on('error', reject); + }); +} + +/** Connects to the HS256 VirtualHost. */ +function hs256Client(params) { + return createXmppClient({ domain: 'hs256.localhost', + params }); +} + +describe('mod_auth_token (HS256 shared secret)', () => { + + const clients = []; + + afterEach(async () => { + await Promise.all(clients.map(c => c.disconnect())); + clients.length = 0; + }); + + it('connects successfully with a valid token', async () => { + const token = mintToken(); + const c = await hs256Client({ token }); + + clients.push(c); + assert.ok(c.jid, 'client should have a JID after connecting'); + }); + + it('rejects connection with wrong secret', async () => { + const token = mintToken({}, { secret: 'wrongsecret' }); + + await assert.rejects( + () => hs256Client({ token }), + /not-allowed/ + ); + }); + + it('rejects connection with expired token', async () => { + const token = mintToken({}, { expired: true }); + + await assert.rejects( + () => hs256Client({ token }), + /not-allowed/ + ); + }); + + it('rejects connection with not-yet-valid token (nbf in the future)', async () => { + const token = mintToken({}, { notYetValid: true }); + + await assert.rejects( + () => hs256Client({ token }), + /not-allowed/ + ); + }); + + it('rejects connection with wrong issuer', async () => { + const token = mintToken({ iss: 'other-app' }); + + await assert.rejects( + () => hs256Client({ token }), + /not-allowed/ + ); + }); + + it('sets session.jitsi_meet_context_features from token context', async () => { + const token = mintToken({ + context: { + features: { + 'screen-sharing': true, + 'recording': false + } + } + }); + const c = await hs256Client({ token }); + + clients.push(c); + const info = await getSessionInfo(c.jid); + + assert.strictEqual(info.jitsi_meet_context_features['screen-sharing'], true); + assert.strictEqual(info.jitsi_meet_context_features.recording, false); + }); + + it('sets session.jitsi_meet_room from room claim', async () => { + const token = mintToken({ room: 'testroom' }); + const c = await hs256Client({ token }); + + clients.push(c); + const info = await getSessionInfo(c.jid); + + assert.strictEqual(info.jitsi_meet_room, 'testroom'); + }); +}); diff --git a/tests/prosody/mod_conference_duration_spec.js b/tests/prosody/mod_conference_duration_spec.js new file mode 100644 index 000000000000..e2b697b5953f --- /dev/null +++ b/tests/prosody/mod_conference_duration_spec.js @@ -0,0 +1,85 @@ +import assert from 'assert'; + +import { createTestContext } from './helpers/test_context.js'; +import { setRoomMaxOccupants } from './helpers/test_observer.js'; +import { discoField } from './helpers/xmpp_utils.js'; + +const CONFERENCE = 'conference.localhost'; + +let _roomCounter = 0; +const room = () => `conf-duration-${++_roomCounter}@${CONFERENCE}`; + +describe('mod_conference_duration', () => { + + let ctx; + + beforeEach(() => { + ctx = createTestContext(); + }); + + afterEach(() => ctx.cleanup()); + + it('created_timestamp is absent before a second occupant joins', async () => { + const r = room(); + const focus = await ctx.connectFocus(r); + + // Only focus is in the room — timestamp should not be set yet. + const iq = await focus.sendDiscoInfo(r); + + assert.equal(iq.attrs.type, 'result'); + + const ts = discoField(iq, 'muc#roominfo_created_timestamp'); + + assert.ok(!ts, `expected empty/absent created_timestamp before second occupant, got: ${ts}`); + }); + + it('created_timestamp is set once a second occupant joins', async () => { + const r = room(); + const focus = await ctx.connectFocus(r); + const c = await ctx.connect(); + + await c.joinRoom(r); + + const iq = await focus.sendDiscoInfo(r); + + assert.equal(iq.attrs.type, 'result'); + + const ts = discoField(iq, 'muc#roominfo_created_timestamp'); + + assert.ok(ts, 'created_timestamp must be present after second occupant joins'); + assert.match(ts, /^\d+$/, 'created_timestamp must be a numeric string (ms since epoch)'); + + const tsNum = parseInt(ts, 10); + const now = Date.now(); + + assert.ok(tsNum > 0, 'timestamp must be positive'); + assert.ok(tsNum <= now, 'timestamp must not be in the future'); + assert.ok(tsNum > now - 30_000, 'timestamp must be recent (within last 30s)'); + }); + + it('created_timestamp does not change when further occupants join', async () => { + const r = room(); + const focus = await ctx.connectFocus(r); + const c1 = await ctx.connect(); + + await c1.joinRoom(r); + + const iq1 = await focus.sendDiscoInfo(r); + const ts1 = discoField(iq1, 'muc#roominfo_created_timestamp'); + + assert.ok(ts1, 'timestamp must be set after second occupant'); + + // Raise per-room limit so the third occupant is not blocked by muc_max_occupants = 2. + await setRoomMaxOccupants(r, 5); + + // Third occupant joins — timestamp must remain unchanged. + const c2 = await ctx.connect(); + + await c2.joinRoom(r); + + const iq2 = await focus.sendDiscoInfo(r); + const ts2 = discoField(iq2, 'muc#roominfo_created_timestamp'); + + assert.equal(ts2, ts1, 'created_timestamp must not change when more occupants join'); + }); +}); diff --git a/tests/prosody/mod_end_conference_spec.js b/tests/prosody/mod_end_conference_spec.js new file mode 100644 index 000000000000..a12e2aa75606 --- /dev/null +++ b/tests/prosody/mod_end_conference_spec.js @@ -0,0 +1,183 @@ +import assert from 'assert'; + +import { mintAsapToken } from './helpers/jwt.js'; +import { getRoomState } from './helpers/test_observer.js'; +import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js'; + +const CONFERENCE = 'conference.localhost'; +const COMPONENT = 'endconference.localhost'; + +let _roomCounter = 0; +const room = () => `end-conf-${++_roomCounter}@${CONFERENCE}`; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Creates a room with: + * - focus (joins first to satisfy the mod_muc_meeting_id jicofo lock) + * - moderator: a token-authenticated client whose ?room= URL param sets + * jitsi_web_query_room so that mod_end_conference can locate the room. + * Focus promotes this user to moderator via an admin IQ. + * + * TODO: Moderator role should ideally be granted from the token's context + * claims (e.g. context.user.moderator = true) rather than via an explicit + * admin IQ from focus. Once the test infrastructure includes the module + * responsible for claim-based role assignment, replace grantModerator() + * with a token that carries the moderator claim. + * + * @returns {{ roomJid, roomName, focus, moderator }} + */ +async function createRoom() { + const roomJid = room(); + const roomName = roomJid.split('@')[0]; + + const focus = await joinWithFocus(roomJid); + + // Connect with ?room= so mod_jitsi_session populates jitsi_web_query_room. + // Include a token for authenticated context. + const token = mintAsapToken({ room: roomName }); + const moderator = await createXmppClient({ params: { room: roomName, + token } }); + + await moderator.joinRoom(roomJid); + + // Explicitly grant moderator role since the test environment does not load + // the module that would promote a user based on token claims. See TODO above. + await focus.grantModerator(roomJid, moderator.nick); + + return { roomJid, + roomName, + focus, + moderator }; +} + +/** + * Disconnects all provided clients. + * + * @param {...object} clients - Clients to disconnect. + * @returns {Promise} + */ +async function disconnectAll(...clients) { + await Promise.all(clients.map(c => c.disconnect())); +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('mod_end_conference', () => { + + // ── Successful destruction ─────────────────────────────────────────────── + + it('moderator can end the conference — room is destroyed', async () => { + const { roomJid, focus, moderator } = await createRoom(); + + try { + const before = await getRoomState(roomJid); + + assert.ok(before, 'room must exist before end_conference'); + + await moderator.sendEndConference(COMPONENT); + + // Room destruction triggers an unavailable presence to all occupants. + await moderator.waitForPresence(p => p.attrs.type === 'unavailable'); + + const after = await getRoomState(roomJid); + + assert.strictEqual(after, null, 'room must be gone after end_conference'); + } finally { + await disconnectAll(focus, moderator); + } + }); + + it('all occupants receive an unavailable presence with a destroy reason', async () => { + const { roomJid, roomName, focus, moderator } = await createRoom(); + + // A second participant so we can verify the unavailable broadcast. + const participant = await createXmppClient({ params: { room: roomName } }); + + await participant.joinRoom(roomJid); + + try { + await moderator.sendEndConference(COMPONENT); + + // Both the moderator and the participant must receive the kick. + const [ modPresence, partPresence ] = await Promise.all([ + moderator.waitForPresence(p => p.attrs.type === 'unavailable'), + participant.waitForPresence(p => p.attrs.type === 'unavailable') + ]); + + for (const [ label, presence ] of [ [ 'moderator', modPresence ], [ 'participant', partPresence ] ]) { + const mucUser = presence.getChild('x', 'http://jabber.org/protocol/muc#user'); + + assert.ok(mucUser, `${label} unavailable presence must contain muc#user `); + assert.ok( + mucUser.getChild('destroy'), + `${label} unavailable presence must contain a element` + ); + } + } finally { + await disconnectAll(focus, moderator, participant); + } + }); + + // ── Non-moderator / non-occupant ───────────────────────────────────────── + + it('non-moderator participant cannot end the conference', async () => { + const { roomJid, roomName, focus, moderator } = await createRoom(); + + // Participant joins with ?room= but is NOT granted moderator. + const participant = await createXmppClient({ params: { room: roomName } }); + + await participant.joinRoom(roomJid); + + try { + await participant.sendEndConference(COMPONENT); + + // No reply is sent; poll briefly to confirm the room is untouched. + await new Promise(resolve => setTimeout(resolve, 300)); + + const state = await getRoomState(roomJid); + + assert.ok(state, 'room must still exist after non-moderator end_conference attempt'); + } finally { + await disconnectAll(focus, moderator, participant); + } + }); + + it('non-occupant cannot end the conference', async () => { + const { roomJid, roomName, focus, moderator } = await createRoom(); + + // Client has the correct ?room= param but has never joined the MUC. + const outsider = await createXmppClient({ params: { room: roomName } }); + + try { + await outsider.sendEndConference(COMPONENT); + + await new Promise(resolve => setTimeout(resolve, 300)); + + const state = await getRoomState(roomJid); + + assert.ok(state, 'room must still exist after non-occupant end_conference attempt'); + } finally { + await disconnectAll(focus, moderator, outsider); + } + }); + + // ── Message filtering ──────────────────────────────────────────────────── + + it('plain message without element is ignored', async () => { + const { roomJid, focus, moderator } = await createRoom(); + + try { + await moderator.sendPlainMessage(COMPONENT); + + await new Promise(resolve => setTimeout(resolve, 300)); + + const state = await getRoomState(roomJid); + + assert.ok(state, 'room must still exist after message without end_conference element'); + } finally { + await disconnectAll(focus, moderator); + } + }); + +}); diff --git a/tests/prosody/mod_filter_iq_jibri_spec.js b/tests/prosody/mod_filter_iq_jibri_spec.js new file mode 100644 index 000000000000..78dcea45a99b --- /dev/null +++ b/tests/prosody/mod_filter_iq_jibri_spec.js @@ -0,0 +1,195 @@ +import assert from 'assert'; +import http from 'http'; + +import { mintAsapToken } from './helpers/jwt.js'; +import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js'; + +let _roomCounter = 0; +const nextRoom = () => `jibri-test-${++_roomCounter}@conference.localhost`; + +// ─── HTTP helpers ──────────────────────────────────────────────────────────── + +/** + * Fetches pending Jibri IQ messages from the test observer. + * + * @returns {Promise} + */ +function getJibriIqs() { + return new Promise((resolve, reject) => { + http.get('http://localhost:5280/test-observer/jibri-iqs', res => { + let body = ''; + + res.on('data', c => { + body += c; + }); + res.on('end', () => resolve(JSON.parse(body))); + }).on('error', reject); + }); +} + +/** + * Clears all Jibri IQ messages from the test observer. + * + * @returns {Promise} + */ +function clearJibriIqs() { + return new Promise((resolve, reject) => { + const req = http.request( + 'http://localhost:5280/test-observer/jibri-iqs', + { method: 'DELETE' }, + res => res.resume().on('end', resolve) + ); + + req.on('error', reject); + req.end(); + }); +} + +/** + * Sends a Jibri IQ and waits briefly for Prosody to route it (or block it), + * then returns the list of Jibri IQs that reached the MUC component. + */ +async function sendAndCollect(client, roomJid, action, recordingMode) { + await clearJibriIqs(); + await client.sendJibriIq(roomJid, action, recordingMode); + + // Give Prosody time to route (or block) the IQ before we poll. + await new Promise(r => setTimeout(r, 300)); + + return getJibriIqs(); +} + +// ─── Test setup ────────────────────────────────────────────────────────────── + +describe('mod_filter_iq_jibri (feature-based authorization)', () => { + + const clients = []; + + afterEach(async () => { + await Promise.all(clients.map(c => c.disconnect())); + clients.length = 0; + }); + + /** + * Creates a test client connected to localhost with the given token, + * joins a fresh room (focus joins first to unlock the jicofo lock), and + * returns { client, room }. The client is a non-moderator participant. + */ + async function setup(token) { + const room = nextRoom(); + const focus = await joinWithFocus(room); + + clients.push(focus); + + const c = await createXmppClient({ params: { token } }); + + clients.push(c); + await c.joinRoom(room); + + return { client: c, + room }; + } + + // ─── recording (recording_mode="file") ─────────────────────────────────── + + describe('recording (recording_mode=file)', () => { + + for (const action of [ 'start', 'stop' ]) { + + it(`passes ${action} IQ when features.recording = true`, async () => { + const token = mintAsapToken({ context: { features: { recording: true } } }); + const { client: c, room } = await setup(token); + const iqs = await sendAndCollect(c, room, action, 'file'); + + assert.strictEqual(iqs.length, 1, 'IQ should reach the MUC'); + assert.strictEqual(iqs[0].action, action); + assert.strictEqual(iqs[0].recording_mode, 'file'); + }); + + it(`blocks ${action} IQ when features.recording = false`, async () => { + const token = mintAsapToken({ context: { features: { recording: false } } }); + const { client: c, room } = await setup(token); + const iqs = await sendAndCollect(c, room, action, 'file'); + + assert.strictEqual(iqs.length, 0); + }); + + it(`blocks ${action} IQ when context.features present but recording key absent`, async () => { + // features object present but no 'recording' key → treated as false + const token = mintAsapToken({ context: { features: { livestreaming: true } } }); + const { client: c, room } = await setup(token); + const iqs = await sendAndCollect(c, room, action, 'file'); + + assert.strictEqual(iqs.length, 0); + }); + + it(`blocks ${action} IQ when token has no context.features (non-moderator fallback)`, async () => { + // No features in token → is_feature_allowed falls back to is_moderator. + // Client joined after focus so it is a non-moderator participant → blocked. + const token = mintAsapToken(); + const { client: c, room } = await setup(token); + const iqs = await sendAndCollect(c, room, action, 'file'); + + assert.strictEqual(iqs.length, 0); + }); + } + + it('passes status IQ regardless of features (only start/stop are gated)', async () => { + const token = mintAsapToken({ context: { features: { recording: false } } }); + const { client: c, room } = await setup(token); + const iqs = await sendAndCollect(c, room, 'status', 'file'); + + assert.strictEqual(iqs.length, 1); + }); + }); + + // ─── livestreaming (recording_mode="stream") ───────────────────────────── + + describe('livestreaming (recording_mode=stream)', () => { + + for (const action of [ 'start', 'stop' ]) { + + it(`passes ${action} IQ when features.livestreaming = true`, async () => { + const token = mintAsapToken({ context: { features: { livestreaming: true } } }); + const { client: c, room } = await setup(token); + const iqs = await sendAndCollect(c, room, action, 'stream'); + + assert.strictEqual(iqs.length, 1); + assert.strictEqual(iqs[0].action, action); + assert.strictEqual(iqs[0].recording_mode, 'stream'); + }); + + it(`blocks ${action} IQ when features.livestreaming = false`, async () => { + const token = mintAsapToken({ context: { features: { livestreaming: false } } }); + const { client: c, room } = await setup(token); + const iqs = await sendAndCollect(c, room, action, 'stream'); + + assert.strictEqual(iqs.length, 0); + }); + + it(`blocks ${action} IQ when context.features present but livestreaming key absent`, async () => { + const token = mintAsapToken({ context: { features: { recording: true } } }); + const { client: c, room } = await setup(token); + const iqs = await sendAndCollect(c, room, action, 'stream'); + + assert.strictEqual(iqs.length, 0); + }); + + it(`blocks ${action} IQ when token has no context.features (non-moderator fallback)`, async () => { + const token = mintAsapToken(); + const { client: c, room } = await setup(token); + const iqs = await sendAndCollect(c, room, action, 'stream'); + + assert.strictEqual(iqs.length, 0); + }); + } + + it('passes status IQ regardless of features (only start/stop are gated)', async () => { + const token = mintAsapToken({ context: { features: { livestreaming: false } } }); + const { client: c, room } = await setup(token); + const iqs = await sendAndCollect(c, room, 'status', 'stream'); + + assert.strictEqual(iqs.length, 1); + }); + }); +}); diff --git a/tests/prosody/mod_filter_iq_rayo_spec.js b/tests/prosody/mod_filter_iq_rayo_spec.js new file mode 100644 index 000000000000..4d88a244d1c6 --- /dev/null +++ b/tests/prosody/mod_filter_iq_rayo_spec.js @@ -0,0 +1,198 @@ +import assert from 'assert'; +import http from 'http'; + +import { mintAsapToken } from './helpers/jwt.js'; +import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js'; + +let _roomCounter = 0; +const nextRoom = () => `rayo-test-${++_roomCounter}@conference.localhost`; + +// ─── HTTP helpers ──────────────────────────────────────────────────────────── + +/** + * Fetches pending dial IQ messages from the test observer. + * + * @returns {Promise} + */ +function getDialIqs() { + return new Promise((resolve, reject) => { + http.get('http://localhost:5280/test-observer/dial-iqs', res => { + let body = ''; + + res.on('data', c => { + body += c; + }); + res.on('end', () => resolve(JSON.parse(body))); + }).on('error', reject); + }); +} + +/** + * Clears all dial IQ messages from the test observer. + * + * @returns {Promise} + */ +function clearDialIqs() { + return new Promise((resolve, reject) => { + const req = http.request( + 'http://localhost:5280/test-observer/dial-iqs', + { method: 'DELETE' }, + res => res.resume().on('end', resolve) + ); + + req.on('error', reject); + req.end(); + }); +} + +/** + * Sends a Rayo dial IQ and waits briefly for Prosody to route or block it, + * then returns the list of dial IQs that reached the MUC component. + * + * @param {object} client XmppTestClient with sendRayoIq + * @param {string} roomJid full room JID + * @param {string} [dialTo] dial `to` attribute (default: 'sip:test@example.com') + * @param {string|null} [roomNameHeader] JvbRoomName header value (default: roomJid) + */ +async function sendAndCollect(client, roomJid, dialTo, roomNameHeader) { + await clearDialIqs(); + await client.sendRayoIq(roomJid, dialTo, roomNameHeader); + + // Give Prosody time to route (or block) the IQ before we poll. + await new Promise(r => setTimeout(r, 300)); + + return getDialIqs(); +} + +// ─── Test setup ────────────────────────────────────────────────────────────── + +describe('mod_filter_iq_rayo (feature-based authorization)', () => { + + const clients = []; + + afterEach(async () => { + await Promise.all(clients.map(c => c.disconnect())); + clients.length = 0; + }); + + /** + * Creates a test client with the given token, joins a fresh room (focus + * joins first so the jicofo lock is lifted and focus gets owner affiliation), + * and returns { client, room }. + */ + async function setup(token) { + const room = nextRoom(); + const focus = await joinWithFocus(room); + + clients.push(focus); + + const c = await createXmppClient({ params: { token } }); + + clients.push(c); + await c.joinRoom(room); + + return { client: c, + room }; + } + + // ─── outbound-call (dial to a SIP/telephony address) ──────────────────── + + describe('outbound-call (dial to non-transcribe address)', () => { + + it('passes IQ when features.outbound-call = true', async () => { + const token = mintAsapToken({ context: { features: { 'outbound-call': true } } }); + const { client: c, room } = await setup(token); + const iqs = await sendAndCollect(c, room); + + assert.strictEqual(iqs.length, 1, 'IQ should reach the MUC'); + assert.strictEqual(iqs[0].dial_to, 'sip:test@example.com'); + }); + + it('blocks IQ when features.outbound-call = false', async () => { + const token = mintAsapToken({ context: { features: { 'outbound-call': false } } }); + const { client: c, room } = await setup(token); + const iqs = await sendAndCollect(c, room); + + assert.strictEqual(iqs.length, 0); + }); + + it('blocks IQ when context.features present but outbound-call key absent', async () => { + const token = mintAsapToken({ context: { features: { recording: true } } }); + const { client: c, room } = await setup(token); + const iqs = await sendAndCollect(c, room); + + assert.strictEqual(iqs.length, 0); + }); + + it('blocks IQ when token has no context.features (non-owner fallback)', async () => { + // No features → fallback to is_moderator = affiliation == 'owner'. + // Regular client joined after focus so has 'member' affiliation → blocked. + const token = mintAsapToken(); + const { client: c, room } = await setup(token); + const iqs = await sendAndCollect(c, room); + + assert.strictEqual(iqs.length, 0); + }); + }); + + // ─── transcription (dial to 'jitsi_meet_transcribe') ──────────────────── + + describe('transcription (dial to jitsi_meet_transcribe)', () => { + + it('passes IQ when features.transcription = true', async () => { + const token = mintAsapToken({ context: { features: { transcription: true } } }); + const { client: c, room } = await setup(token); + const iqs = await sendAndCollect(c, room, 'jitsi_meet_transcribe'); + + assert.strictEqual(iqs.length, 1, 'IQ should reach the MUC'); + assert.strictEqual(iqs[0].dial_to, 'jitsi_meet_transcribe'); + }); + + it('blocks IQ when features.transcription = false', async () => { + const token = mintAsapToken({ context: { features: { transcription: false } } }); + const { client: c, room } = await setup(token); + const iqs = await sendAndCollect(c, room, 'jitsi_meet_transcribe'); + + assert.strictEqual(iqs.length, 0); + }); + + it('blocks IQ when context.features present but transcription key absent', async () => { + const token = mintAsapToken({ context: { features: { 'outbound-call': true } } }); + const { client: c, room } = await setup(token); + const iqs = await sendAndCollect(c, room, 'jitsi_meet_transcribe'); + + assert.strictEqual(iqs.length, 0); + }); + + it('blocks IQ when token has no context.features (non-owner fallback)', async () => { + const token = mintAsapToken(); + const { client: c, room } = await setup(token); + const iqs = await sendAndCollect(c, room, 'jitsi_meet_transcribe'); + + assert.strictEqual(iqs.length, 0); + }); + }); + + // ─── JvbRoomName header validation ────────────────────────────────────── + + describe('JvbRoomName header validation', () => { + + it('blocks IQ when JvbRoomName header is missing', async () => { + const token = mintAsapToken({ context: { features: { 'outbound-call': true } } }); + const { client: c, room } = await setup(token); + + // null → header omitted entirely + const iqs = await sendAndCollect(c, room, 'sip:test@example.com', null); + + assert.strictEqual(iqs.length, 0); + }); + + it('blocks IQ when JvbRoomName header does not match room JID', async () => { + const token = mintAsapToken({ context: { features: { 'outbound-call': true } } }); + const { client: c, room } = await setup(token); + const iqs = await sendAndCollect(c, room, 'sip:test@example.com', 'wrong-room@conference.localhost'); + + assert.strictEqual(iqs.length, 0); + }); + }); +}); diff --git a/tests/prosody/mod_jitsi_session_spec.js b/tests/prosody/mod_jitsi_session_spec.js new file mode 100644 index 000000000000..9588324597bc --- /dev/null +++ b/tests/prosody/mod_jitsi_session_spec.js @@ -0,0 +1,106 @@ +import assert from 'assert'; +import http from 'http'; + +import { createXmppClient } from './helpers/xmpp_client.js'; + +/** + * Fetches the mod_jitsi_session field snapshot for the given full JID from + * the /test-observer/session-info endpoint (served by mod_test_observer_http). + * + * @param {string} jid full JID, e.g. "user@localhost/resource" + * @returns {Promise} + */ +function getSessionInfo(jid) { + return new Promise((resolve, reject) => { + const url = `http://localhost:5280/test-observer/session-info?jid=${encodeURIComponent(jid)}`; + + http.get(url, res => { + let body = ''; + + res.on('data', chunk => { + body += chunk; + }); + res.on('end', () => { + if (res.statusCode !== 200) { + reject(new Error(`session-info returned ${res.statusCode}: ${body}`)); + + return; + } + try { + resolve(JSON.parse(body)); + } catch (e) { + reject(new Error(`session-info bad JSON: ${body}`)); + } + }); + }).on('error', reject); + }); +} + +describe('mod_jitsi_session', () => { + + const clients = []; + + afterEach(async () => { + await Promise.all(clients.map(c => c.disconnect())); + clients.length = 0; + }); + + it('sets session.previd from ?previd query param', async () => { + const c = await createXmppClient({ params: { previd: 'testprevid' } }); + + clients.push(c); + const info = await getSessionInfo(c.jid); + + assert.equal(info.previd, 'testprevid'); + }); + + it('sets session.jitsi_web_query_room from ?room query param', async () => { + const c = await createXmppClient({ params: { room: 'myroom' } }); + + clients.push(c); + const info = await getSessionInfo(c.jid); + + assert.equal(info.jitsi_web_query_room, 'myroom'); + }); + + it('sets session.jitsi_web_query_prefix from ?prefix query param', async () => { + const c = await createXmppClient({ params: { prefix: 'tenant1' } }); + + clients.push(c); + const info = await getSessionInfo(c.jid); + + assert.equal(info.jitsi_web_query_prefix, 'tenant1'); + }); + + it('sets session.jitsi_web_query_prefix to empty string when query string present but ?prefix absent', async () => { + // A query string must be present so the if-block in mod_jitsi_session runs; + // without any query string the field is never assigned and stays nil. + const c = await createXmppClient({ params: { room: 'myroom' } }); + + clients.push(c); + const info = await getSessionInfo(c.jid); + + assert.equal(info.jitsi_web_query_prefix, ''); + }); + + it('rejects connection when ?token is present but invalid', async () => { + // mod_jitsi_session sets session.auth_token from ?token, but with + // authentication="token" (RS256/ASAP on localhost) the auth module + // immediately tries to verify it. An invalid token string fails JWT + // parsing before resource-bind, so the session fields are never + // accessible via getSessionInfo. + await assert.rejects( + () => createXmppClient({ params: { token: 'notavalidjwt' } }), + /not-allowed/ + ); + }); + + it('sets session.customusername from ?customusername query param', async () => { + const c = await createXmppClient({ params: { customusername: 'alice' } }); + + clients.push(c); + const info = await getSessionInfo(c.jid); + + assert.equal(info.customusername, 'alice'); + }); +}); diff --git a/tests/prosody/mod_muc_auth_ban_spec.js b/tests/prosody/mod_muc_auth_ban_spec.js new file mode 100644 index 000000000000..e9d71b17f837 --- /dev/null +++ b/tests/prosody/mod_muc_auth_ban_spec.js @@ -0,0 +1,168 @@ +import assert from 'assert'; + +import { mintAsapToken } from './helpers/jwt.js'; +import { setAccessManagerResponse } from './helpers/test_observer.js'; +import { createXmppClient } from './helpers/xmpp_client.js'; + +// VPaaS tenant prefix — mod_muc_auth_ban only calls the access manager for +// sessions whose jitsi_web_query_prefix starts with this string. +// The prefix is set from the ?prefix= WebSocket URL param by mod_jitsi_session. +const VPAAS_PREFIX = 'vpaas-magic-cookie-test'; + +// Tokens are RS256 (deterministic): same payload in the same second → same +// token string. Use a per-test jti so each test gets a unique token and cannot +// accidentally match a token that was cached as banned by an earlier test. +let _tokenCounter = 0; +const freshToken = () => mintAsapToken({ jti: `ban-test-${++_tokenCounter}` }); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Creates an XMPP client that identifies as a VPaaS session. + * Passes ?prefix=&token= in the WebSocket URL so that + * mod_jitsi_session populates jitsi_web_query_prefix, which mod_muc_auth_ban + * uses to decide whether to call the external access manager. + * + * @param {string} token A valid login JWT (RS256 / HS256). + * @returns {Promise} + */ +function createVpaasClient(token) { + return createXmppClient({ + params: { prefix: VPAAS_PREFIX, + token } + }); +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('mod_muc_auth_ban', () => { + + afterEach(async () => { + // Reset the mock access manager to the default (allow, HTTP 200) + // so that each test starts from a clean state. + await setAccessManagerResponse({ access: true, + status: 200 }); + }); + + // ── No token ───────────────────────────────────────────────────────────── + // + // mod_muc_auth_ban checks session.auth_token first. When nil it returns + // nil (not false), so the ban-check event has no effect and auth proceeds. + + it('user without a token is allowed (ban check is a no-op)', async () => { + // No token param → session.auth_token is nil → shouldAllow returns nil. + // VirtualHost "localhost" has allow_empty_token = true so auth still succeeds. + const c = await createXmppClient(); + + await c.disconnect(); + }); + + // ── Non-VPaaS tenant ───────────────────────────────────────────────────── + // + // mod_muc_auth_ban returns true immediately for tenants whose prefix does + // NOT start with "vpaas-magic-cookie-". The access manager is never called. + + it('non-VPaaS user with a valid token is allowed without an HTTP check', async () => { + // Configure the mock to return an error — if the module calls it for + // non-VPaaS sessions this test will catch that regression. + await setAccessManagerResponse({ status: 500 }); + + const token = freshToken(); + + // No ?prefix= param → jitsi_web_query_prefix is "" → bypasses VPaaS check. + const c = await createXmppClient({ params: { token } }); + + await c.disconnect(); + }); + + // ── VPaaS — access granted ──────────────────────────────────────────────── + + it('VPaaS user is allowed when access manager returns access=true', async () => { + await setAccessManagerResponse({ access: true }); + + const token = freshToken(); + const c = await createVpaasClient(token); + + // Stay connected for a moment to confirm the async HTTP response does + // not trigger a session close. + await new Promise(resolve => setTimeout(resolve, 500)); + + await c.disconnect(); + }); + + // ── VPaaS — access denied ───────────────────────────────────────────────── + // + // When access=false, mod_muc_auth_ban's HTTP callback calls session:close() + // and caches the token. Because the access manager runs on the same Prosody + // process (loopback), the callback resolves within the same event loop tick + // as the SASL auth, so the ban surfaces as a SASL failure rather than a + // post-connect disconnect. + + it('VPaaS user is rejected (SASL failure) when access manager returns access=false', async () => { + await setAccessManagerResponse({ access: false }); + + const token = freshToken(); + + await assert.rejects( + createVpaasClient(token), + /not-allowed/, + 'VPaaS user must be rejected when access manager returns access=false' + ); + }); + + // ── Cached ban ──────────────────────────────────────────────────────────── + // + // When a token is rejected (access=false), mod_muc_auth_ban caches it with a + // 5-minute TTL. Subsequent connections with the same token hit the Lua LRU + // cache (no HTTP request) and are rejected immediately — even after the mock + // access manager is reset to allow. This verifies the cache is active. + + it('cached banned token is rejected even when access manager is reset to allow', async () => { + // Step 1: Ban the token — cb fires, token enters the LRU cache. + await setAccessManagerResponse({ access: false }); + + const token = freshToken(); + + await assert.rejects( + createVpaasClient(token), + /not-allowed/, + 'initial ban must be rejected' + ); + + // Step 2: Reset the mock to allow. A fresh token must now succeed, + // proving the cache — not the mock response — drives the next rejection. + await setAccessManagerResponse({ access: true }); + + const fresh = await createVpaasClient(freshToken()); + + await fresh.disconnect(); + + // Step 3: Same banned token must still be rejected (cache wins over mock). + await assert.rejects( + createVpaasClient(token), + /not-allowed/, + 'cached banned token must be rejected even when mock is reset to allow' + ); + }); + + // ── HTTP error — fail open ──────────────────────────────────────────────── + // + // If the access manager returns a non-200 response, mod_muc_auth_ban logs + // a warning and does nothing — the session is NOT closed (fail open). + + it('HTTP error from access manager does not ban the user (fail open)', async () => { + await setAccessManagerResponse({ status: 500 }); + + const token = freshToken(); + const c = await createVpaasClient(token); + + // Wait long enough for the async HTTP callback to have fired. + await new Promise(resolve => setTimeout(resolve, 500)); + + // The client must still be connected. waitForDisconnect would reject + // after the timeout — instead, assert the connection is alive by + // verifying disconnect() completes cleanly. + await c.disconnect(); + }); + +}); diff --git a/tests/prosody/mod_muc_census_spec.js b/tests/prosody/mod_muc_census_spec.js new file mode 100644 index 000000000000..c084769487da --- /dev/null +++ b/tests/prosody/mod_muc_census_spec.js @@ -0,0 +1,119 @@ +import assert from 'assert'; + +import { createTestContext } from './helpers/test_context.js'; +import { setRoomMaxOccupants } from './helpers/test_observer.js'; + +const CONFERENCE = 'conference.localhost'; +const BASE = 'http://localhost:5280'; + +let _roomCounter = 0; +const room = () => `census-${++_roomCounter}@${CONFERENCE}`; + +/** + * Fetch GET /room-census and return the parsed body. + * + * @returns {Promise<{room_census: Array}>} + */ +async function getRoomCensus() { + const res = await fetch(`${BASE}/room-census`); + const text = await res.text(); + + assert.equal(res.status, 200, `GET /room-census must return 200, got ${res.status}: ${text}`); + + let body; + + try { + body = JSON.parse(text); + } catch (e) { + assert.fail(`GET /room-census returned non-JSON: ${text}`); + } + + return body; +} + +describe('mod_muc_census', () => { + + let ctx; + + beforeEach(() => { + ctx = createTestContext(); + }); + + afterEach(() => ctx.cleanup()); + + it('returns 200 with a room_census array', async () => { + const body = await getRoomCensus(); + + assert.ok(Array.isArray(body.room_census), + `response must have a room_census array, got: ${JSON.stringify(body)}`); + }); + + it('only active rooms appear in census after a room is destroyed', async () => { + const r1 = room(); + const r2 = room(); + const r1Name = r1.split('@')[0]; + const r2Name = r2.split('@')[0]; + + // Bring up both rooms. + const focus1 = await ctx.connectFocus(r1); + + await ctx.connectFocus(r2); + const c1 = await ctx.connect(); + const c2 = await ctx.connect(); + + await c1.joinRoom(r1); + await c2.joinRoom(r2); + + const { room_census: before } = await getRoomCensus(); + + assert.ok(before.find(e => e.room_name && e.room_name.startsWith(r1Name)), + 'room1 must appear in census while active'); + assert.ok(before.find(e => e.room_name && e.room_name.startsWith(r2Name)), + 'room2 must appear in census while active'); + + // Destroy room1 by disconnecting all its occupants. + await c1.disconnect(); + await focus1.disconnect(); + await new Promise(resolve => setTimeout(resolve, 200)); + + const { room_census: after } = await getRoomCensus(); + + assert.ok(!after.find(e => e.room_name && e.room_name.startsWith(r1Name)), + 'room1 must not appear in census after all occupants leave'); + assert.ok(after.find(e => e.room_name && e.room_name.startsWith(r2Name)), + 'room2 must still appear in census'); + }); + + it('tracks room occupancy and updates when clients leave', async () => { + const r = room(); + const roomName = r.split('@')[0]; + + await ctx.connectFocus(r); + await setRoomMaxOccupants(r, 5); + + const c1 = await ctx.connect(); + const c2 = await ctx.connect(); + + await c1.joinRoom(r); + await c2.joinRoom(r); + + // Room must appear with correct participant count and fields. + const { room_census: census1 } = await getRoomCensus(); + const entry1 = census1.find(e => e.room_name && e.room_name.startsWith(roomName)); + + assert.ok(entry1, `room ${roomName} must appear in census after join`); + assert.ok(typeof entry1.created_time !== 'undefined', 'entry must have created_time'); + assert.equal(entry1.participants, 2, 'participants must equal number of non-focus clients'); + + // After one client leaves the count must drop. + await c2.disconnect(); + await new Promise(resolve => setTimeout(resolve, 200)); + + const { room_census: census2 } = await getRoomCensus(); + const entry2 = census2.find(e => e.room_name && e.room_name.startsWith(roomName)); + + assert.ok(entry2, 'room must still appear after one client leaves'); + assert.equal(entry2.participants, 1, + `participant count must be 1 after disconnect (got ${entry2.participants})`); + }); +}); diff --git a/tests/prosody/mod_muc_end_meeting_spec.js b/tests/prosody/mod_muc_end_meeting_spec.js new file mode 100644 index 000000000000..f1248b820377 --- /dev/null +++ b/tests/prosody/mod_muc_end_meeting_spec.js @@ -0,0 +1,169 @@ +import assert from 'assert'; + +import { mintAsapToken, mintSystemToken } from './helpers/jwt.js'; +import { endMeeting, getRoomState } from './helpers/test_observer.js'; +import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js'; + +const CONFERENCE = 'conference.localhost'; + +let _roomCounter = 0; +const room = () => `end-meeting-${++_roomCounter}@${CONFERENCE}`; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Creates a live room with a focus client and one regular user. + * Returns { roomJid, clients } where clients must be disconnected in teardown. + */ +async function createRoom() { + const roomJid = room(); + const focus = await joinWithFocus(roomJid); + const user = await createXmppClient(); + + await user.joinRoom(roomJid); + + return { roomJid, + clients: [ focus, user ] }; +} + +/** + * Disconnects all provided clients. + * + * @param {Array} clients - Clients to disconnect. + * @returns {Promise} + */ +async function disconnectAll(clients) { + await Promise.all(clients.map(c => c.disconnect())); +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('mod_muc_end_meeting', () => { + + // ── Authentication ─────────────────────────────────────────────────────── + // + // mod_muc_end_meeting uses prosody_password_public_key_repo_url as its key + // server — a separate key pair from the one used for login tokens + // (asap_key_server on VirtualHost "localhost"). A login token is therefore + // rejected because the system key server does not carry the login public key, + // and vice-versa. + + describe('authentication', () => { + + it('returns 401 when Authorization header is absent', async () => { + const { roomJid, clients } = await createRoom(); + + try { + const { status } = await endMeeting(roomJid, null, { omitAuth: true }); + + assert.strictEqual(status, 401); + } finally { + await disconnectAll(clients); + } + }); + + it('returns 401 when a login token is used instead of a system token', async () => { + // Login tokens are signed with the login key pair and verified via + // asap_key_server. The system key server does not carry the login + // public key, so verification fails and Prosody returns 401. + const { roomJid, clients } = await createRoom(); + + try { + const loginToken = mintAsapToken(); + const { status } = await endMeeting(roomJid, loginToken); + + assert.strictEqual(status, 401, + 'login token must be rejected by the system key server'); + } finally { + await disconnectAll(clients); + } + }); + + it('returns 401 when token is expired', async () => { + const { roomJid, clients } = await createRoom(); + + try { + const token = mintSystemToken({}, { expired: true }); + const { status } = await endMeeting(roomJid, token); + + assert.strictEqual(status, 401); + } finally { + await disconnectAll(clients); + } + }); + + }); + + // ── Parameter validation ───────────────────────────────────────────────── + + describe('parameter validation', () => { + + it('returns 400 when conference param is missing', async () => { + // endMeeting always sets conference; call fetch directly to omit it. + const token = mintSystemToken(); + const res = await fetch('http://localhost:5280/end-meeting', { + method: 'POST', + headers: { Authorization: `Bearer ${token}` } + }); + + assert.strictEqual(res.status, 400); + }); + + }); + + // ── Room termination ───────────────────────────────────────────────────── + + describe('room termination', () => { + + it('returns 404 when conference does not exist', async () => { + const token = mintSystemToken(); + const { status } = await endMeeting(`nonexistent-room@${CONFERENCE}`, token); + + assert.strictEqual(status, 404); + }); + + it('returns 200 and destroys the room', async () => { + const { roomJid, clients } = await createRoom(); + + try { + // Verify room exists before ending it. + const before = await getRoomState(roomJid); + + assert.ok(before, 'room must exist before end-meeting call'); + + const token = mintSystemToken(); + const { status } = await endMeeting(roomJid, token); + + assert.strictEqual(status, 200); + + // Room must be gone from Prosody's internal MUC state. + const after = await getRoomState(roomJid); + + assert.strictEqual(after, null, 'room must be destroyed after end-meeting'); + } finally { + // Clients were kicked by room destruction; disconnect() is a no-op + // or handles the already-closed connection gracefully. + await disconnectAll(clients); + } + }); + + it('returns 200 with silent-reconnect=true and destroys the room', async () => { + const { roomJid, clients } = await createRoom(); + + try { + const token = mintSystemToken(); + const { status } = await endMeeting(roomJid, token, { silentReconnect: true }); + + assert.strictEqual(status, 200); + + const after = await getRoomState(roomJid); + + assert.strictEqual(after, null, 'room must be destroyed after silent-reconnect end-meeting'); + } finally { + await disconnectAll(clients); + } + }); + + }); + +}); diff --git a/tests/prosody/mod_muc_filter_access_spec.js b/tests/prosody/mod_muc_filter_access_spec.js new file mode 100644 index 000000000000..2761e40232ad --- /dev/null +++ b/tests/prosody/mod_muc_filter_access_spec.js @@ -0,0 +1,66 @@ +import assert from 'assert'; + +import { createTestContext } from './helpers/test_context.js'; +import { isAvailablePresence } from './helpers/xmpp_utils.js'; + +// Uses the isolated "internal" MUC component which has muc_filter_access loaded +// with muc_filter_whitelist = { "whitelist.localhost" }. +// No muc_meeting_id or muc_max_occupants are loaded here, so no focus join is +// needed and there is no occupant limit. +const INTERNAL = 'conference-internal.localhost'; + +let _roomCounter = 0; +const room = () => `filter-access-${++_roomCounter}@${INTERNAL}`; + +describe('mod_muc_filter_access', () => { + + let ctx; + + beforeEach(() => { + ctx = createTestContext(); + }); + + afterEach(() => ctx.cleanup()); + + it('allows a client from a whitelisted domain to join', async () => { + const r = room(); + const wl = await ctx.connectWhitelisted(); + const presence = await wl.joinRoom(r); + + assert.ok(isAvailablePresence(presence), + 'whitelisted client must be allowed to join'); + }); + + it('blocks a client from a non-whitelisted domain', async () => { + const r = room(); + + // First create the room with a whitelisted client so there is a room to join. + const wl = await ctx.connectWhitelisted(); + + await wl.joinRoom(r); + + // A regular client (domain: localhost) is not in the whitelist. + // Its presence is silently dropped — joinRoom will time out. + const c = await ctx.connect(); + + await assert.rejects( + () => c.joinRoom(r, undefined, { timeout: 1000 }), + /Timeout/, + 'non-whitelisted client join must time out (presence dropped)' + ); + }); + + it('blocks a non-whitelisted client even when it creates the room', async () => { + const r = room(); + const c = await ctx.connect(); + + // The client sends the first presence to a non-existing room. + // muc_filter_access drops it before MUC can process it, so the room + // is never created and no presence is returned. + await assert.rejects( + () => c.joinRoom(r, undefined, { timeout: 1000 }), + /Timeout/, + 'non-whitelisted client must not be able to create a room' + ); + }); +}); diff --git a/tests/prosody/mod_muc_flip_spec.js b/tests/prosody/mod_muc_flip_spec.js new file mode 100644 index 000000000000..dbb56bc2d7a9 --- /dev/null +++ b/tests/prosody/mod_muc_flip_spec.js @@ -0,0 +1,304 @@ +import { xml } from '@xmpp/client'; +import assert from 'assert'; + + +import { getRoomParticipants, setRoomMaxOccupants, setSessionContext } from './helpers/test_observer.js'; +import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js'; + +const CONFERENCE = 'conference.localhost'; + +let _roomCounter = 0; +const room = () => `flip-${++_roomCounter}@${CONFERENCE}`; + +// Shared JWT user IDs used across tests. +const USER_A_ID = 'flip-test-user-a'; +const USER_B_ID = 'flip-test-user-b'; + +/** + * Returns the default MUC nick for a client. Matches the logic in xmpp_client.js: + * first 8 characters of the JID local part, which is also what mod_muc_resource_validate + * enforces in anonymous_strict mode. + * + * @param {string} jid full JID, e.g. 'abc12345def@localhost/resource' + */ +function defaultNick(jid) { + return jid.split('@')[0].slice(0, 8); +} + +/** Extracts the occupant nick from a MUC join presence (the resource part of from=). */ +function nickFrom(presence) { + return presence.attrs.from.split('/')[1]; +} + +describe('mod_muc_flip', () => { + + let clients; + + beforeEach(() => { + clients = []; + }); + + afterEach(async () => { + await Promise.all(clients.map(c => c.disconnect())); + }); + + /** + * Creates and connects an XMPP client, tracking it for cleanup. + * + * @returns {Promise} + */ + async function connect() { + const c = await createXmppClient(); + + clients.push(c); + + return c; + } + + /** + * Joins a room as focus and tracks the client for cleanup. + * + * @param {string} roomJid - Room JID to join. + * @returns {Promise} + */ + async function focusJoin(roomJid) { + const c = await joinWithFocus(roomJid); + + clients.push(c); + + return c; + } + + // ------------------------------------------------------------------------- + // flip_device tag stripping + // ------------------------------------------------------------------------- + describe('flip_device tag stripping', () => { + + it('strips flip_device tag from guest (no JWT context)', async () => { + const r = room(); + + await focusJoin(r); + + const observer = await connect(); + + await observer.joinRoom(r); + + // Connect guest before joining so we can predict their nick. + const guest = await connect(); + + // No setSessionContext — guest has no JWT context. + + // waitForPresenceFrom is exact-JID so it won't match stale presences + // from other occupants that arrived during the observer's own join. + const joinBroadcast = observer.waitForPresenceFrom(`${r}/${defaultNick(guest.jid)}`); + + await guest.joinRoom(r, null, { extensions: [ xml('flip_device') ] }); + + const presence = await joinBroadcast; + + assert.ok( + !presence.getChild('flip_device'), + 'flip_device tag must be stripped from guest join presence' + ); + }); + + it('strips flip_device tag when flip feature is disabled in JWT', async () => { + const r = room(); + + await focusJoin(r); + await setRoomMaxOccupants(r, 10); + + const clientA = await connect(); + + await setSessionContext(clientA.jid, USER_A_ID, { flip: false }); + await clientA.joinRoom(r); + + const observer = await connect(); + + await observer.joinRoom(r); + + const clientA2 = await connect(); + + await setSessionContext(clientA2.jid, USER_A_ID, { flip: false }); + + const joinBroadcast = observer.waitForPresenceFrom( + `${r}/${defaultNick(clientA2.jid)}` + ); + + await clientA2.joinRoom(r, null, { extensions: [ xml('flip_device') ] }); + + const presence = await joinBroadcast; + + assert.ok( + !presence.getChild('flip_device'), + 'flip_device tag must be stripped when flip feature is disabled' + ); + + const state = await getRoomParticipants(r); + + assert.ok( + !state.kicked_participant_nick, + 'no participant should have been kicked' + ); + }); + + it('strips flip_device tag when the user is not already in the room', async () => { + const r = room(); + + await focusJoin(r); + + const observer = await connect(); + + await observer.joinRoom(r); + + // USER_B_ID has never joined this room — no entry in participants_details. + const clientB = await connect(); + + await setSessionContext(clientB.jid, USER_B_ID, { flip: true }); + + const joinBroadcast = observer.waitForPresenceFrom( + `${r}/${defaultNick(clientB.jid)}` + ); + + await clientB.joinRoom(r, null, { extensions: [ xml('flip_device') ] }); + + const presence = await joinBroadcast; + + assert.ok( + !presence.getChild('flip_device'), + 'flip_device tag must be stripped when user has no existing device in room' + ); + + const state = await getRoomParticipants(r); + + assert.ok(!state.kicked_participant_nick, 'no participant should have been kicked'); + }); + + }); // flip_device tag stripping + + // ------------------------------------------------------------------------- + // participants_details tracking + // ------------------------------------------------------------------------- + describe('participants_details', () => { + + it('records the occupant nick for a JWT user on join', async () => { + const r = room(); + + await focusJoin(r); + + const clientA = await connect(); + + await setSessionContext(clientA.jid, USER_A_ID, { flip: false }); + const joinPresence = await clientA.joinRoom(r); + const nick = nickFrom(joinPresence); + + const state = await getRoomParticipants(r); + + assert.ok( + state.participants_details[USER_A_ID], + `participants_details must have an entry for user id ${USER_A_ID}` + ); + assert.ok( + state.participants_details[USER_A_ID].endsWith(`/${nick}`), + 'recorded value must reference the occupant nick' + ); + }); + + it('removes the occupant entry when the JWT user leaves', async () => { + const r = room(); + + await focusJoin(r); + + const clientA = await connect(); + + await setSessionContext(clientA.jid, USER_A_ID, { flip: false }); + await clientA.joinRoom(r); + + await clientA.disconnect(); + clients.splice(clients.indexOf(clientA), 1); + + // Give Prosody a moment to process the leave. + await new Promise(res => setTimeout(res, 300)); + + const state = await getRoomParticipants(r); + + assert.ok( + !state.participants_details[USER_A_ID], + 'participants_details entry must be cleared after the user leaves' + ); + }); + + }); // participants_details + + // ------------------------------------------------------------------------- + // Successful flip + // ------------------------------------------------------------------------- + describe('flip', () => { + + it('kicks the old device when the same user joins from a new device', async () => { + const r = room(); + + await focusJoin(r); + + const device1 = await connect(); + + await setSessionContext(device1.jid, USER_A_ID, { flip: true }); + const d1Presence = await device1.joinRoom(r); + const d1Nick = nickFrom(d1Presence); + + // Start watching for device1 to be kicked before device2 joins, + // so the unavailable presence is not missed if it arrives quickly. + const kickedPromise = device1.waitForPresenceFrom( + `${r}/${d1Nick}`, { type: 'unavailable' } + ); + + const device2 = await connect(); + + await setSessionContext(device2.jid, USER_A_ID, { flip: true }); + await device2.joinRoom(r, null, { extensions: [ xml('flip_device') ] }); + + const kickedPresence = await kickedPromise; + + assert.equal( + kickedPresence.attrs.type, 'unavailable', + 'device1 must receive an unavailable presence (kicked)' + ); + }); + + it('adds flip_device tag to the kicked occupant unavailable presence', async () => { + const r = room(); + + await focusJoin(r); + await setRoomMaxOccupants(r, 10); + + const device1 = await connect(); + + await setSessionContext(device1.jid, USER_A_ID, { flip: true }); + const d1Presence = await device1.joinRoom(r); + const d1Nick = nickFrom(d1Presence); + + // Observer receives the broadcast of the kick so we can inspect the stanza. + const observer = await connect(); + + await observer.joinRoom(r); + + const kickBroadcastPromise = observer.waitForPresenceFrom( + `${r}/${d1Nick}`, { type: 'unavailable' } + ); + + const device2 = await connect(); + + await setSessionContext(device2.jid, USER_A_ID, { flip: true }); + await device2.joinRoom(r, null, { extensions: [ xml('flip_device') ] }); + + const kickedPresence = await kickBroadcastPromise; + + assert.ok( + kickedPresence.getChild('flip_device'), + 'unavailable presence for the kicked device must contain ' + ); + }); + + }); // flip + +}); diff --git a/tests/prosody/mod_muc_hide_all_spec.js b/tests/prosody/mod_muc_hide_all_spec.js index b7008e1b959a..b1e9e9de3e39 100644 --- a/tests/prosody/mod_muc_hide_all_spec.js +++ b/tests/prosody/mod_muc_hide_all_spec.js @@ -1,8 +1,8 @@ import assert from 'assert'; import { prosodyShell } from './helpers/prosody_shell.js'; +import { createTestContext } from './helpers/test_context.js'; import { clearEvents, getEvents, getRoomState } from './helpers/test_observer.js'; -import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js'; const CONFERENCE = 'conference.localhost'; @@ -11,43 +11,13 @@ const room = () => `hide-all-${++_roomCounter}@${CONFERENCE}`; describe('mod_muc_hide_all', () => { - let clients; + let ctx; beforeEach(() => { - clients = []; + ctx = createTestContext(); }); - afterEach(async () => { - await Promise.all(clients.map(c => c.disconnect())); - }); - - /** - * Creates a regular XMPP client and registers it for afterEach cleanup. - * - * @returns {Promise} - */ - async function connect() { - const c = await createXmppClient(); - - clients.push(c); - - return c; - } - - /** - * Joins the room as focus (jicofo) to unlock the mod_muc_meeting_id jicofo - * lock, then returns the client. Added to `clients` for afterEach cleanup. - * - * @param {string} roomJid full room JID, e.g. 'room@conference.localhost' - * @returns {Promise} - */ - async function focusJoin(roomJid) { - const c = await joinWithFocus(roomJid); - - clients.push(c); - - return c; - } + afterEach(() => ctx.cleanup()); // ------------------------------------------------------------------------- // Module DISABLED — Prosody default behaviour applies @@ -65,9 +35,9 @@ describe('mod_muc_hide_all', () => { it('non-occupant disco#info to an existing room succeeds', async () => { const r = room(); - await focusJoin(r); - const owner = await connect(); - const stranger = await connect(); + await ctx.connectFocus(r); + const owner = await ctx.connect(); + const stranger = await ctx.connect(); await owner.joinRoom(r); const iq = await stranger.sendDiscoInfo(r); @@ -87,8 +57,8 @@ describe('mod_muc_hide_all', () => { it('new room is set to hidden', async () => { const r = room(); - await focusJoin(r); - const owner = await connect(); + await ctx.connectFocus(r); + const owner = await ctx.connect(); await owner.joinRoom(r); @@ -102,7 +72,7 @@ describe('mod_muc_hide_all', () => { it('muc-room-pre-create event is fired when room is created', async () => { const r = room(); - await focusJoin(r); + await ctx.connectFocus(r); const events = await getEvents(); const preCreate = events.find(e => e.event === 'muc-room-pre-create' && e.room === r); @@ -114,9 +84,9 @@ describe('mod_muc_hide_all', () => { it('non-occupant disco#info returns ', async () => { const r = room(); - await focusJoin(r); - const owner = await connect(); - const stranger = await connect(); + await ctx.connectFocus(r); + const owner = await ctx.connect(); + const stranger = await ctx.connect(); await owner.joinRoom(r); const iq = await stranger.sendDiscoInfo(r); @@ -131,8 +101,8 @@ describe('mod_muc_hide_all', () => { it('occupant disco#info succeeds', async () => { const r = room(); - await focusJoin(r); - const occupant = await connect(); + await ctx.connectFocus(r); + const occupant = await ctx.connect(); await occupant.joinRoom(r); const iq = await occupant.sendDiscoInfo(r); diff --git a/tests/prosody/mod_muc_jigasi_invite_spec.js b/tests/prosody/mod_muc_jigasi_invite_spec.js new file mode 100644 index 000000000000..5189c70560cc --- /dev/null +++ b/tests/prosody/mod_muc_jigasi_invite_spec.js @@ -0,0 +1,233 @@ +import assert from 'assert'; + +import { mintAsapToken, mintSystemToken } from './helpers/jwt.js'; +import { inviteJigasi } from './helpers/test_observer.js'; +import { joinWithFocus, joinWithJigasi } from './helpers/xmpp_client.js'; + +const CONFERENCE = 'conference.localhost'; + +let _roomCounter = 0; +const room = () => `jigasi-invite-${++_roomCounter}@${CONFERENCE}`; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Creates a room with a focus participant. + * + * @returns {Promise<{roomJid: string, focus: object}>} + */ +async function createRoom() { + const roomJid = room(); + const focus = await joinWithFocus(roomJid); + + return { roomJid, + focus }; +} + +/** + * Disconnects all provided clients. + * + * @param {...object} clients - Clients to disconnect. + * @returns {Promise} + */ +async function disconnectAll(...clients) { + await Promise.all(clients.map(c => c.disconnect())); +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('mod_muc_jigasi_invite', () => { + + // ── Authentication ─────────────────────────────────────────────────────── + // + // mod_muc_jigasi_invite uses prosody_password_public_key_repo_url as its + // key server — a separate key pair from login tokens (asap_key_server). + // Auth failures return 401. + + describe('authentication', () => { + + it('returns 401 when Authorization header is absent', async () => { + const { roomJid, focus } = await createRoom(); + + try { + const { status } = await inviteJigasi(roomJid, '+15551234567', null, { omitAuth: true }); + + assert.strictEqual(status, 401); + } finally { + await disconnectAll(focus); + } + }); + + it('returns 401 when a login token is used instead of a system token', async () => { + const { roomJid, focus } = await createRoom(); + + try { + const loginToken = mintAsapToken(); + const { status } = await inviteJigasi(roomJid, '+15551234567', loginToken); + + assert.strictEqual(status, 401, + 'login token must be rejected by the system key server'); + } finally { + await disconnectAll(focus); + } + }); + + it('returns 401 when token is expired', async () => { + const { roomJid, focus } = await createRoom(); + + try { + const token = mintSystemToken({}, { expired: true }); + const { status } = await inviteJigasi(roomJid, '+15551234567', token); + + assert.strictEqual(status, 401); + } finally { + await disconnectAll(focus); + } + }); + + }); + + // ── Parameter validation ───────────────────────────────────────────────── + + describe('parameter validation', () => { + + it('returns 400 when Content-Type is not application/json', async () => { + const token = mintSystemToken(); + const res = await fetch( + 'http://localhost:5280/invite-jigasi', + { + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + Authorization: `Bearer ${token}` + }, + body: '{"conference":"r@conference.localhost","phoneNo":"+1555"}' + } + ); + + assert.strictEqual(res.status, 400); + }); + + it('returns 400 when body is empty', async () => { + const token = mintSystemToken(); + const res = await fetch( + 'http://localhost:5280/invite-jigasi', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: '' + } + ); + + assert.strictEqual(res.status, 400); + }); + + it('returns 400 when conference is missing', async () => { + const token = mintSystemToken(); + const res = await fetch( + 'http://localhost:5280/invite-jigasi', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ phoneNo: '+15551234567' }) + } + ); + + assert.strictEqual(res.status, 400); + }); + + it('returns 400 when phoneNo is missing', async () => { + const token = mintSystemToken(); + const res = await fetch( + 'http://localhost:5280/invite-jigasi', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ conference: `r@${CONFERENCE}` }) + } + ); + + assert.strictEqual(res.status, 400); + }); + + }); + + // ── Brewery room ───────────────────────────────────────────────────────── + + describe('brewery room', () => { + + it('returns 404 when the Jigasi brewery room does not exist', async () => { + const { roomJid, focus } = await createRoom(); + + try { + const token = mintSystemToken(); + + // The brewery room (jigasibrewery@internal.auth.localhost) is + // never created in the test environment, so the module returns 404. + const { status } = await inviteJigasi(roomJid, '+15551234567', token); + + assert.strictEqual(status, 404); + } finally { + await disconnectAll(focus); + } + }); + + }); + + // ── Jigasi invite ──────────────────────────────────────────────────────── + // + // These tests require a simulated Jigasi instance in the brewery room. + // joinWithJigasi() creates an anonymous client, joins the brewery with a + // colibri stats presence (supports_sip=true, stress_level) so the module + // can select it for dial-out. + + describe('jigasi invite', () => { + + const BREWERY = 'jigasibrewery@internal.auth.localhost'; + + it('sends a Rayo dial IQ to the Jigasi and returns 200', async () => { + const { roomJid, focus } = await createRoom(); + const jigasi = await joinWithJigasi(BREWERY); + + try { + const token = mintSystemToken(); + const phoneNo = '+15551234567'; + const { status } = await inviteJigasi(roomJid, phoneNo, token); + + assert.strictEqual(status, 200); + + // The module sends a Rayo IQ to the selected Jigasi. + const iq = await jigasi.waitForIq( + s => s.getChild('dial', 'urn:xmpp:rayo:1')); + const dial = iq.getChild('dial', 'urn:xmpp:rayo:1'); + + assert.ok(dial, 'IQ must contain a Rayo dial element'); + assert.strictEqual(iq.attrs.type, 'set'); + assert.strictEqual(dial.attrs.to, phoneNo, + 'dial target must be the requested phone number'); + + // The JvbRoomName header carries the conference JID so Jigasi + // knows which room to join after the call is established. + const header = dial.getChild('header', 'urn:xmpp:rayo:1'); + + assert.ok(header, 'dial must include a JvbRoomName header'); + assert.strictEqual(header.attrs.name, 'JvbRoomName'); + assert.strictEqual(header.attrs.value, roomJid, + 'JvbRoomName must equal the conference room JID'); + } finally { + await disconnectAll(focus, jigasi); + } + }); + + }); + +}); diff --git a/tests/prosody/mod_muc_kick_participant_spec.js b/tests/prosody/mod_muc_kick_participant_spec.js new file mode 100644 index 000000000000..48970db981b6 --- /dev/null +++ b/tests/prosody/mod_muc_kick_participant_spec.js @@ -0,0 +1,216 @@ +import assert from 'assert'; + +import { mintAsapToken, mintSystemToken } from './helpers/jwt.js'; +import { getRoomState, kickParticipant } from './helpers/test_observer.js'; +import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js'; + +const CONFERENCE = 'conference.localhost'; + +let _roomCounter = 0; +const room = () => `kick-test-${++_roomCounter}@${CONFERENCE}`; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Creates a room with a focus participant. + * + * @returns {Promise<{roomJid: string, focus: object}>} + */ +async function createRoom() { + const roomJid = room(); + const focus = await joinWithFocus(roomJid); + + return { roomJid, + focus }; +} + +/** + * Disconnects all provided clients. + * + * @param {...object} clients - Clients to disconnect. + * @returns {Promise} + */ +async function disconnectAll(...clients) { + await Promise.all(clients.map(c => c.disconnect())); +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('mod_muc_kick_participant', () => { + + // ── Authentication ─────────────────────────────────────────────────────── + // + // mod_muc_kick_participant uses prosody_password_public_key_repo_url as its + // key server — a separate key pair from login tokens (asap_key_server). + // Auth failures return 403 (not 401). + + describe('authentication', () => { + + it('returns 403 when Authorization header is absent', async () => { + const { roomJid, focus } = await createRoom(); + + try { + const { status } = await kickParticipant(roomJid, 'focus', null, { omitAuth: true }); + + assert.strictEqual(status, 403); + } finally { + await disconnectAll(focus); + } + }); + + it('returns 403 when a login token is used instead of a system token', async () => { + const { roomJid, focus } = await createRoom(); + + try { + const loginToken = mintAsapToken(); + const { status } = await kickParticipant(roomJid, 'focus', loginToken); + + assert.strictEqual(status, 403, + 'login token must be rejected by the system key server'); + } finally { + await disconnectAll(focus); + } + }); + + it('returns 403 when token is expired', async () => { + const { roomJid, focus } = await createRoom(); + + try { + const token = mintSystemToken({}, { expired: true }); + const { status } = await kickParticipant(roomJid, 'focus', token); + + assert.strictEqual(status, 403); + } finally { + await disconnectAll(focus); + } + }); + + }); + + // ── Parameter validation ───────────────────────────────────────────────── + + describe('parameter validation', () => { + + it('returns 400 when Content-Type is not application/json', async () => { + const token = mintSystemToken(); + const res = await fetch( + 'http://localhost:5280/kick-participant?room=kick-test-0', + { + method: 'PUT', + headers: { + 'Content-Type': 'text/plain', + Authorization: `Bearer ${token}` + }, + body: '{"participantId":"focus"}' + } + ); + + assert.strictEqual(res.status, 400); + }); + + it('returns 400 when body is empty', async () => { + const token = mintSystemToken(); + const res = await fetch( + 'http://localhost:5280/kick-participant?room=kick-test-0', + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: '' + } + ); + + assert.strictEqual(res.status, 400); + }); + + it('returns 400 when neither participantId nor number is provided', async () => { + const token = mintSystemToken(); + const res = await fetch( + 'http://localhost:5280/kick-participant?room=kick-test-0', + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: '{}' + } + ); + + assert.strictEqual(res.status, 400); + }); + + it('returns 400 when both participantId and number are provided', async () => { + const token = mintSystemToken(); + const res = await fetch( + 'http://localhost:5280/kick-participant?room=kick-test-0', + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ participantId: 'focus', + number: '+1234' }) + } + ); + + assert.strictEqual(res.status, 400); + }); + + }); + + // ── Kick behaviour ─────────────────────────────────────────────────────── + + describe('kick behaviour', () => { + + it('returns 404 when room does not exist', async () => { + const token = mintSystemToken(); + const { status } = await kickParticipant( + `nonexistent-room@${CONFERENCE}`, 'anyone', token + ); + + assert.strictEqual(status, 404); + }); + + it('returns 404 when participant is not in the room', async () => { + const { roomJid, focus } = await createRoom(); + + try { + const token = mintSystemToken(); + const { status } = await kickParticipant(roomJid, 'no-such-nick', token); + + assert.strictEqual(status, 404); + } finally { + await disconnectAll(focus); + } + }); + + it('returns 200 and removes the participant', async () => { + const { roomJid, focus } = await createRoom(); + const user = await createXmppClient(); + + await user.joinRoom(roomJid); + const nick = user.nick; + + try { + const token = mintSystemToken(); + const { status } = await kickParticipant(roomJid, nick, token); + + assert.strictEqual(status, 200); + + // Verify the user was actually removed — only focus remains. + const state = await getRoomState(roomJid); + + assert.strictEqual(state.occupant_count, 1, + 'kicked user should no longer be in the room'); + } finally { + await disconnectAll(focus, user); + } + }); + + }); + +}); diff --git a/tests/prosody/mod_muc_max_occupants_spec.js b/tests/prosody/mod_muc_max_occupants_spec.js index 21669fc43f56..35f6d5b46885 100644 --- a/tests/prosody/mod_muc_max_occupants_spec.js +++ b/tests/prosody/mod_muc_max_occupants_spec.js @@ -1,7 +1,8 @@ import assert from 'assert'; +import { createTestContext } from './helpers/test_context.js'; import { setRoomMaxOccupants } from './helpers/test_observer.js'; -import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js'; +import { isAvailablePresence } from './helpers/xmpp_utils.js'; const CONFERENCE = 'conference.localhost'; @@ -12,91 +13,44 @@ const room = () => `max-occupants-${++_roomCounter}@${CONFERENCE}`; describe('mod_muc_max_occupants', () => { // Prosody config sets muc_max_occupants = 2. - let clients; + let ctx; beforeEach(() => { - clients = []; + ctx = createTestContext(); }); - afterEach(async () => { - // Disconnect all clients. Rooms auto-destroy when the last occupant leaves. - await Promise.all(clients.map(c => c.disconnect())); - }); - - /** - * Creates a regular XMPP client and registers it for afterEach cleanup. - * - * @returns {Promise} - */ - async function connect() { - const c = await createXmppClient(); - - clients.push(c); - - return c; - } - - /** - * Creates a whitelisted XMPP client (domain: whitelist.localhost) and - * registers it for afterEach cleanup. - * - * @returns {Promise} - */ - async function 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 so that subsequent regular clients can join. The focus client is added - * to `clients` for afterEach cleanup and does not count against the occupant - * limit (focus.localhost is whitelisted). - * - * @param {string} roomJid full room JID, e.g. 'room@conference.localhost' - * @returns {Promise} - */ - async function focusJoin(roomJid) { - const c = await joinWithFocus(roomJid); - - clients.push(c); - - return c; - } + afterEach(() => ctx.cleanup()); it('allows join when room is empty', async () => { const r = room(); - await focusJoin(r); - const c = await connect(); + await ctx.connectFocus(r); + const c = await ctx.connect(); const presence = await c.joinRoom(r); - assert.notEqual(presence.attrs.type, 'error'); + assert.ok(isAvailablePresence(presence)); }); it('allows join when room has one occupant (under limit)', async () => { const r = room(); - await focusJoin(r); - const c1 = await connect(); - const c2 = await connect(); + await ctx.connectFocus(r); + const c1 = await ctx.connect(); + const c2 = await ctx.connect(); await c1.joinRoom(r); const presence = await c2.joinRoom(r); - assert.notEqual(presence.attrs.type, 'error'); + assert.ok(isAvailablePresence(presence)); }); it('blocks join when room is at the limit', async () => { const r = room(); - await focusJoin(r); - const c1 = await connect(); - const c2 = await connect(); - const c3 = await connect(); + await ctx.connectFocus(r); + const c1 = await ctx.connect(); + const c2 = await ctx.connect(); + const c3 = await ctx.connect(); await c1.joinRoom(r); await c2.joinRoom(r); @@ -115,9 +69,8 @@ describe('mod_muc_max_occupants', () => { // so we test the occupant-limit bypass with focus clients. // is_healthcheck_room() matches rooms starting with '__jicofo-health-check'. const healthRoom = `__jicofo-health-check-test@${CONFERENCE}`; - const focus = await joinWithFocus(healthRoom); - clients.push(focus); + await ctx.connectFocus(healthRoom); // A second focus join would conflict on nick; the meaningful assertion is // that the first focus client joined without hitting a limit error. @@ -125,36 +78,36 @@ describe('mod_muc_max_occupants', () => { }); describe('whitelist', () => { - // Prosody config sets muc_access_whitelist = { "whitelist.localhost", "focus.localhost" }. + // Prosody config sets muc_access_whitelist = { "whitelist.localhost", "auth.localhost" }. // Clients created with domain:'whitelist.localhost' get JIDs on that domain // and are treated as whitelisted by mod_muc_max_occupants. it('whitelisted user can join a room that is at the limit', async () => { const r = room(); - await focusJoin(r); - const c1 = await connect(); - const c2 = await connect(); - const wl = await connectWhitelisted(); + await ctx.connectFocus(r); + const c1 = await ctx.connect(); + const c2 = await ctx.connect(); + const wl = await ctx.connectWhitelisted(); await c1.joinRoom(r); await c2.joinRoom(r); // room now at limit (2 non-whitelisted) const presence = await wl.joinRoom(r); - assert.notEqual(presence.attrs.type, 'error', + assert.ok(isAvailablePresence(presence), 'whitelisted user must bypass the occupant limit'); }); it('whitelisted occupants do not count against the limit for non-whitelisted users', async () => { const r = room(); - await focusJoin(r); - const wl1 = await connectWhitelisted(); - const wl2 = await connectWhitelisted(); - const c1 = await connect(); - const c2 = await connect(); - const c3 = await connect(); + await ctx.connectFocus(r); + const wl1 = await ctx.connectWhitelisted(); + const wl2 = await ctx.connectWhitelisted(); + const c1 = await ctx.connect(); + const c2 = await ctx.connect(); + const c3 = await ctx.connect(); // Two whitelisted users join — they bypass the check and are not // counted when a non-whitelisted user evaluates available slots. @@ -164,12 +117,12 @@ describe('mod_muc_max_occupants', () => { // Non-whitelisted users: only they count against the limit of 2. const p1 = await c1.joinRoom(r); - assert.notEqual(p1.attrs.type, 'error', + assert.ok(isAvailablePresence(p1), '1st non-whitelisted user must be allowed (0 counted occupants so far)'); const p2 = await c2.joinRoom(r); - assert.notEqual(p2.attrs.type, 'error', + assert.ok(isAvailablePresence(p2), '2nd non-whitelisted user must be allowed (1 counted occupant)'); const p3 = await c3.joinRoom(r); @@ -190,31 +143,31 @@ describe('mod_muc_max_occupants', () => { it('per-room limit higher than global allows more occupants', async () => { const r = room(); - await focusJoin(r); - const c1 = await connect(); + await ctx.connectFocus(r); + const c1 = await ctx.connect(); await c1.joinRoom(r); // Override the global limit of 2 with a per-room limit of 4. await setRoomMaxOccupants(r, 4); - const c2 = await connect(); - const c3 = await connect(); - const c4 = await connect(); - const c5 = await connect(); + const c2 = await ctx.connect(); + const c3 = await ctx.connect(); + const c4 = await ctx.connect(); + const c5 = await ctx.connect(); const p2 = await c2.joinRoom(r); - assert.notEqual(p2.attrs.type, 'error', 'user 2 should join (limit 4)'); + assert.ok(isAvailablePresence(p2), 'user 2 should join (limit 4)'); const p3 = await c3.joinRoom(r); - assert.notEqual(p3.attrs.type, 'error', + assert.ok(isAvailablePresence(p3), 'user 3 should join (global limit 2 would block, per-room limit 4 allows)'); const p4 = await c4.joinRoom(r); - assert.notEqual(p4.attrs.type, 'error', 'user 4 should join (limit 4)'); + assert.ok(isAvailablePresence(p4), 'user 4 should join (limit 4)'); const p5 = await c5.joinRoom(r); @@ -228,15 +181,15 @@ describe('mod_muc_max_occupants', () => { it('per-room limit lower than global restricts the room further', async () => { const r = room(); - await focusJoin(r); - const c1 = await connect(); + await ctx.connectFocus(r); + const c1 = await ctx.connect(); await c1.joinRoom(r); // Override the global limit of 2 with a stricter per-room limit of 1. await setRoomMaxOccupants(r, 1); - const c2 = await connect(); + const c2 = await ctx.connect(); const presence = await c2.joinRoom(r); assert.equal(presence.attrs.type, 'error', diff --git a/tests/prosody/mod_muc_meeting_id_spec.js b/tests/prosody/mod_muc_meeting_id_spec.js index 1c3cd9e896a3..507abe0c26d9 100644 --- a/tests/prosody/mod_muc_meeting_id_spec.js +++ b/tests/prosody/mod_muc_meeting_id_spec.js @@ -1,6 +1,7 @@ import assert from 'assert'; -import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js'; +import { createTestContext } from './helpers/test_context.js'; +import { isAvailablePresence } from './helpers/xmpp_utils.js'; const CONFERENCE = 'conference.localhost'; @@ -10,43 +11,13 @@ const healthRoom = () => `__jicofo-health-check-mid-${++_roomCounter}@${CONFEREN describe('mod_muc_meeting_id', () => { - let clients; + let ctx; beforeEach(() => { - clients = []; + ctx = createTestContext(); }); - afterEach(async () => { - await Promise.all(clients.map(c => c.disconnect())); - }); - - /** - * Creates a regular XMPP client and registers it for afterEach cleanup. - * - * @returns {Promise} - */ - async function connect() { - const c = await createXmppClient(); - - clients.push(c); - - return c; - } - - /** - * Joins the room as focus (jicofo), unlocking the jicofo lock. - * Registers the client for afterEach cleanup. - * - * @param {string} roomJid full room JID, e.g. 'room@conference.localhost' - * @returns {Promise} - */ - async function focusJoin(roomJid) { - const c = await joinWithFocus(roomJid); - - clients.push(c); - - return c; - } + afterEach(() => ctx.cleanup()); // ------------------------------------------------------------------------- // jicofo lock @@ -54,18 +25,18 @@ describe('mod_muc_meeting_id', () => { describe('jicofo lock', () => { it('focus can join a room', async () => { - // joinWithFocus throws (timeout) if the join is blocked. - await focusJoin(room()); + // connectFocus throws (timeout) if the join is blocked. + await ctx.connectFocus(room()); }); it('regular user can join after focus has joined', async () => { const r = room(); - await focusJoin(r); - const c = await connect(); + await ctx.connectFocus(r); + const c = await ctx.connect(); const presence = await c.joinRoom(r); - assert.notEqual(presence.attrs.type, 'error', + assert.ok(isAvailablePresence(presence), 'regular user should be allowed in after focus unlocks'); }); @@ -88,14 +59,14 @@ describe('mod_muc_meeting_id', () => { describe('health check rooms', () => { it('focus can join a health check room', async () => { - // joinWithFocus throws on timeout if the join is blocked. - await focusJoin(healthRoom()); + // connectFocus throws on timeout if the join is blocked. + await ctx.connectFocus(healthRoom()); }); it('non-focus participant is blocked from a health check room', async () => { const r = healthRoom(); - const c = await connect(); - const presence = await c.joinRoom(r, 'regular-user'); + const c = await ctx.connect(); + const presence = await c.joinRoom(r); assert.equal(presence.attrs.type, 'error', 'non-focus should not be allowed into health-check rooms'); @@ -108,10 +79,10 @@ describe('mod_muc_meeting_id', () => { it('non-focus is blocked even when focus is already in the room', async () => { const r = healthRoom(); - await focusJoin(r); + await ctx.connectFocus(r); - const intruder = await connect(); - const presence = await intruder.joinRoom(r, 'intruder'); + const intruder = await ctx.connect(); + const presence = await intruder.joinRoom(r); assert.equal(presence.attrs.type, 'error', 'non-focus must still be blocked after focus has joined'); diff --git a/tests/prosody/mod_muc_password_whitelist_spec.js b/tests/prosody/mod_muc_password_whitelist_spec.js new file mode 100644 index 000000000000..84f22d41133d --- /dev/null +++ b/tests/prosody/mod_muc_password_whitelist_spec.js @@ -0,0 +1,65 @@ +import assert from 'assert'; + +import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js'; + +const MUC = 'conference.localhost'; +const ROOM = `test-pw-whitelist@${MUC}`; +const PASSWORD = 'roompassword'; + +describe('mod_muc_password_whitelist', () => { + + const clients = []; + + afterEach(async () => { + await Promise.all(clients.map(c => c.disconnect())); + clients.length = 0; + }); + + it('whitelisted domain joins password-protected room without providing password', async () => { + // Focus unlocks the room; focus client is the owner and sets the password. + const focus = await joinWithFocus(ROOM); + + clients.push(focus); + await focus.setRoomPassword(ROOM, PASSWORD); + + // Client from whitelisted domain joins without supplying the password. + const whitelisted = await createXmppClient({ domain: 'whitelist.localhost' }); + + clients.push(whitelisted); + const presence = await whitelisted.joinRoom(ROOM); + + assert.notEqual(presence.attrs.type, 'error', 'whitelisted client must join without password'); + }); + + it('non-whitelisted client is rejected when joining without password', async () => { + // Focus unlocks the room and sets the password. + const focus = await joinWithFocus(ROOM); + + clients.push(focus); + await focus.setRoomPassword(ROOM, PASSWORD); + + // Client from non-whitelisted domain joins without a password. + const guest = await createXmppClient({ domain: 'localhost' }); + + clients.push(guest); + const presence = await guest.joinRoom(ROOM); + + assert.equal(presence.attrs.type, 'error', 'non-whitelisted client must be rejected without password'); + }); + + it('non-whitelisted client joins with correct password', async () => { + // Focus unlocks the room and sets the password. + const focus = await joinWithFocus(ROOM); + + clients.push(focus); + await focus.setRoomPassword(ROOM, PASSWORD); + + // Client from non-whitelisted domain joins with the correct password. + const guest = await createXmppClient({ domain: 'localhost' }); + + clients.push(guest); + const presence = await guest.joinRoom(ROOM, undefined, { password: PASSWORD }); + + assert.notEqual(presence.attrs.type, 'error', 'non-whitelisted client must join with correct password'); + }); +}); diff --git a/tests/prosody/mod_muc_resource_validate_spec.js b/tests/prosody/mod_muc_resource_validate_spec.js new file mode 100644 index 000000000000..6a845cf6f1d1 --- /dev/null +++ b/tests/prosody/mod_muc_resource_validate_spec.js @@ -0,0 +1,151 @@ +import assert from 'assert'; + +import { getContainer } from './helpers/container.js'; +import { createTestContext } from './helpers/test_context.js'; +import { isAvailablePresence } from './helpers/xmpp_utils.js'; + +const MUC = 'conference.localhost'; +const PROSODY_CFG = '/etc/prosody/prosody.cfg.lua'; + +let _roomCounter = 0; +const room = () => `validate-${++_roomCounter}@${MUC}`; + +/** + * Toggle anonymous_strict on mod_muc_resource_validate by editing the Prosody + * config inside the container and reloading Prosody (SIGHUP via prosodyctl reload). + * mod_muc_resource_validate re-reads config on the config-reloaded event. + * + * The default in the test config is anonymous_strict = true. Call + * setStrictMode(false) to temporarily disable strict checking, then restore + * with setStrictMode(true) in a finally block. + * + * @param {boolean} enabled + */ +async function setStrictMode(enabled) { + const container = getContainer(); + const from = enabled ? 'anonymous_strict = false' : 'anonymous_strict = true'; + const to = enabled ? 'anonymous_strict = true' : 'anonymous_strict = false'; + + await container.exec([ 'sed', '-i', `s/${from}/${to}/`, PROSODY_CFG ]); + await container.exec([ 'prosodyctl', 'reload' ]); + + // prosodyctl reload sends SIGHUP and returns immediately; give Prosody a moment + // to process the config-reloaded event before the next test action. + await new Promise(resolve => setTimeout(resolve, 500)); +} + +describe('mod_muc_resource_validate', () => { + + let ctx; + + beforeEach(() => { + ctx = createTestContext(); + }); + + afterEach(() => ctx.cleanup()); + + // ── Basic pattern validation ────────────────────────────────────────────── + // + // These tests verify the resource format rules independently of the + // anonymous_strict UUID-prefix constraint, so they temporarily disable + // strict mode to allow arbitrary (but format-valid) nicks. + + it('allows a valid alphanumeric resource', async () => { + const r = room(); + + await ctx.connectFocus(r); + await setStrictMode(false); + try { + const c = await ctx.connect(); + const presence = await c.joinRoom(r, 'ValidNick123'); + + assert.ok(isAvailablePresence(presence), + 'valid alphanumeric resource must be allowed'); + } finally { + await setStrictMode(true); + } + }); + + it('allows a resource with underscore after the first character', async () => { + const r = room(); + + await ctx.connectFocus(r); + await setStrictMode(false); + try { + const c = await ctx.connect(); + const presence = await c.joinRoom(r, 'abc_123'); + + assert.ok(isAvailablePresence(presence), + 'resource with internal underscore must be allowed'); + } finally { + await setStrictMode(true); + } + }); + + // Format-invalid resources are rejected by the format check before the + // UUID-prefix check runs, so these tests work regardless of strict mode. + + it('rejects a resource starting with an underscore', async () => { + const r = room(); + + await ctx.connectFocus(r); + const c = await ctx.connect(); + const presence = await c.joinRoom(r, '_invalid'); + + assert.equal(presence.attrs.type, 'error', + 'resource starting with underscore must be rejected'); + assert.ok( + presence.getChild('error')?.getChild('not-allowed'), + 'error stanza must contain ' + ); + }); + + it('rejects a resource containing a hyphen', async () => { + const r = room(); + + await ctx.connectFocus(r); + const c = await ctx.connect(); + const presence = await c.joinRoom(r, 'invalid-nick'); + + assert.equal(presence.attrs.type, 'error', + 'resource with hyphen must be rejected'); + assert.ok( + presence.getChild('error')?.getChild('not-allowed'), + 'error stanza must contain ' + ); + }); + + // ── Anonymous strict mode ───────────────────────────────────────────────── + // + // anonymous_strict = true is the default in the test config, so no toggle + // is needed here. + + it('allows resource matching UUID prefix', async () => { + const r = room(); + + await ctx.connectFocus(r); + const c = await ctx.connect(); + + // joinRoom defaults to the UUID prefix when no nick is supplied. + const presence = await c.joinRoom(r); + + assert.ok(isAvailablePresence(presence), + 'resource matching UUID prefix must be allowed'); + }); + + it('rejects resource not matching UUID prefix', async () => { + const r = room(); + + await ctx.connectFocus(r); + const c = await ctx.connect(); + const presence = await c.joinRoom(r, 'wrongnick'); + + assert.equal(presence.attrs.type, 'error', + 'resource not matching UUID prefix must be rejected'); + assert.ok( + presence.getChild('error')?.getChild('not-allowed'), + 'error stanza must contain ' + ); + }); + +}); diff --git a/tests/prosody/mod_muc_size_spec.js b/tests/prosody/mod_muc_size_spec.js index 6c3f2679d1f2..2faa0f06220a 100644 --- a/tests/prosody/mod_muc_size_spec.js +++ b/tests/prosody/mod_muc_size_spec.js @@ -1,8 +1,8 @@ import assert from 'assert'; import { prosodyShell } from './helpers/prosody_shell.js'; +import { createTestContext } from './helpers/test_context.js'; import { setRoomMaxOccupants } from './helpers/test_observer.js'; -import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js'; const CONFERENCE = 'conference.localhost'; const BASE = 'http://localhost:5280'; @@ -52,44 +52,13 @@ describe('mod_muc_size', () => { console.log('[prosody] module:reload muc_size:', out); }); - let clients; + let ctx; beforeEach(() => { - clients = []; + ctx = createTestContext(); }); - afterEach(async () => { - await Promise.all(clients.map(c => c.disconnect())); - }); - - /** - * Creates a regular XMPP client and registers it for afterEach cleanup. - * - * @returns {Promise} - */ - async function connect() { - const c = await createXmppClient(); - - clients.push(c); - - return c; - } - - /** - * Joins the room as focus (jicofo) to unlock the mod_muc_meeting_id jicofo - * lock. The focus client is whitelisted and does not count against the - * occupant limit. Registers the client for afterEach cleanup. - * - * @param {string} roomJid full room JID, e.g. 'room@conference.localhost' - * @returns {Promise} - */ - async function focusJoin(roomJid) { - const c = await joinWithFocus(roomJid); - - clients.push(c); - - return c; - } + afterEach(() => ctx.cleanup()); // ------------------------------------------------------------------------- // GET /room-size @@ -112,9 +81,9 @@ describe('mod_muc_size', () => { it('returns 200 with participant count for an existing room', async () => { const r = room(); - await focusJoin(r); - const c1 = await connect(); - const c2 = await connect(); + await ctx.connectFocus(r); + const c1 = await ctx.connect(); + const c2 = await ctx.connect(); await c1.joinRoom(r); await c2.joinRoom(r); @@ -140,8 +109,8 @@ describe('mod_muc_size', () => { it('participant count decreases after a client leaves', async () => { const r = room(); - await focusJoin(r); - const c1 = await connect(); + await ctx.connectFocus(r); + const c1 = await ctx.connect(); await c1.joinRoom(r); @@ -149,8 +118,8 @@ describe('mod_muc_size', () => { // the per-room limit before the other clients connect. await setRoomMaxOccupants(r, 5); - const c2 = await connect(); - const c3 = await connect(); + const c2 = await ctx.connect(); + const c3 = await ctx.connect(); await c2.joinRoom(r); await c3.joinRoom(r); @@ -166,7 +135,6 @@ describe('mod_muc_size', () => { // Disconnect one client and re-query. await c3.disconnect(); - clients.pop(); // Give Prosody a moment to process the departure. await new Promise(resolve => setTimeout(resolve, 200)); @@ -203,8 +171,8 @@ describe('mod_muc_size', () => { it('returns 200 with occupant array for an existing room', async () => { const r = room(); - await focusJoin(r); - const c1 = await connect(); + await ctx.connectFocus(r); + const c1 = await ctx.connect(); await c1.joinRoom(r); @@ -222,8 +190,8 @@ describe('mod_muc_size', () => { it('occupant objects have jid, email, display_name fields', async () => { const r = room(); - await focusJoin(r); - const c1 = await connect(); + await ctx.connectFocus(r); + const c1 = await ctx.connect(); await c1.joinRoom(r); @@ -262,7 +230,7 @@ describe('mod_muc_size', () => { const res1 = await getSessions(); const before = parseInt(await res1.text(), 10); - await connect(); // adds one session + await ctx.connect(); // adds one session const res2 = await getSessions(); const after = parseInt(await res2.text(), 10); diff --git a/tests/prosody/mod_system_chat_message_spec.js b/tests/prosody/mod_system_chat_message_spec.js new file mode 100644 index 000000000000..d013b7ce5d24 --- /dev/null +++ b/tests/prosody/mod_system_chat_message_spec.js @@ -0,0 +1,304 @@ +import assert from 'assert'; + +import { mintAsapToken, mintSystemToken } from './helpers/jwt.js'; +import { sendSystemChatMessage } from './helpers/test_observer.js'; +import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js'; + +const CONFERENCE = 'conference.localhost'; + +let _roomCounter = 0; +const room = () => `system-chat-${++_roomCounter}@${CONFERENCE}`; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Creates a room with a focus participant. + * + * @returns {Promise<{roomJid: string, focus: object}>} + */ +async function createRoom() { + const roomJid = room(); + const focus = await joinWithFocus(roomJid); + + return { roomJid, + focus }; +} + +/** + * Disconnects all provided clients. + * + * @param {...object} clients - Clients to disconnect. + * @returns {Promise} + */ +async function disconnectAll(...clients) { + await Promise.all(clients.map(c => c.disconnect())); +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('mod_system_chat_message', () => { + + // ── Authentication ─────────────────────────────────────────────────────── + // + // mod_system_chat_message uses prosody_password_public_key_repo_url as its + // key server — a separate key pair from login tokens (asap_key_server). + // Auth failures return 401. + + describe('authentication', () => { + + it('returns 401 when Authorization header is absent', async () => { + const { roomJid, focus } = await createRoom(); + + try { + const { status } = await sendSystemChatMessage( + roomJid, [ focus.jid ], 'hello', null, { omitAuth: true }); + + assert.strictEqual(status, 401); + } finally { + await disconnectAll(focus); + } + }); + + it('returns 401 when a login token is used instead of a system token', async () => { + const { roomJid, focus } = await createRoom(); + + try { + const loginToken = mintAsapToken(); + const { status } = await sendSystemChatMessage( + roomJid, [ focus.jid ], 'hello', loginToken); + + assert.strictEqual(status, 401, + 'login token must be rejected by the system key server'); + } finally { + await disconnectAll(focus); + } + }); + + it('returns 401 when token is expired', async () => { + const { roomJid, focus } = await createRoom(); + + try { + const token = mintSystemToken({}, { expired: true }); + const { status } = await sendSystemChatMessage( + roomJid, [ focus.jid ], 'hello', token); + + assert.strictEqual(status, 401); + } finally { + await disconnectAll(focus); + } + }); + + }); + + // ── Parameter validation ───────────────────────────────────────────────── + + describe('parameter validation', () => { + + it('returns 400 when Content-Type is not application/json', async () => { + const token = mintSystemToken(); + const res = await fetch( + 'http://localhost:5280/send-system-chat-message', + { + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + Authorization: `Bearer ${token}` + }, + body: '{"room":"r@conference.localhost","connectionJIDs":[],"message":"hi"}' + } + ); + + assert.strictEqual(res.status, 400); + }); + + it('returns 400 when body is empty', async () => { + const token = mintSystemToken(); + const res = await fetch( + 'http://localhost:5280/send-system-chat-message', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: '' + } + ); + + assert.strictEqual(res.status, 400); + }); + + it('returns 400 when message is missing', async () => { + const { roomJid, focus } = await createRoom(); + + try { + const token = mintSystemToken(); + const res = await fetch( + 'http://localhost:5280/send-system-chat-message', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ room: roomJid, + connectionJIDs: [ focus.jid ] }) + } + ); + + assert.strictEqual(res.status, 400); + } finally { + await disconnectAll(focus); + } + }); + + it('returns 400 when connectionJIDs is missing', async () => { + const { roomJid, focus } = await createRoom(); + + try { + const token = mintSystemToken(); + const res = await fetch( + 'http://localhost:5280/send-system-chat-message', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ room: roomJid, + message: 'hi' }) + } + ); + + assert.strictEqual(res.status, 400); + } finally { + await disconnectAll(focus); + } + }); + + it('returns 400 when room is missing', async () => { + const token = mintSystemToken(); + const res = await fetch( + 'http://localhost:5280/send-system-chat-message', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ connectionJIDs: [], + message: 'hi' }) + } + ); + + assert.strictEqual(res.status, 400); + }); + + }); + + // ── Room lookup ────────────────────────────────────────────────────────── + + describe('room lookup', () => { + + it('returns 404 when room does not exist', async () => { + const token = mintSystemToken(); + const { status } = await sendSystemChatMessage( + `nonexistent@${CONFERENCE}`, [], 'hi', token); + + assert.strictEqual(status, 404); + }); + + }); + + // ── Message delivery ───────────────────────────────────────────────────── + + describe('message delivery', () => { + + it('returns 200 and delivers the message to the recipient', async () => { + const { roomJid, focus } = await createRoom(); + const user = await createXmppClient(); + + await user.joinRoom(roomJid); + + try { + const token = mintSystemToken(); + const text = 'Hello from system'; + const { status } = await sendSystemChatMessage( + roomJid, [ user.jid ], text, token); + + assert.strictEqual(status, 200); + + const msg = await user.waitForMessage( + m => m.getChild('json-message', 'http://jitsi.org/jitmeet')); + const jsonMessage = msg.getChild('json-message', 'http://jitsi.org/jitmeet'); + + assert.ok(jsonMessage, 'message must contain a json-message element'); + + const payload = JSON.parse(jsonMessage.text()); + + assert.strictEqual(payload.type, 'system_chat_message'); + assert.strictEqual(payload.message, text); + } finally { + await disconnectAll(focus, user); + } + }); + + it('includes displayName in the payload when provided', async () => { + const { roomJid, focus } = await createRoom(); + const user = await createXmppClient(); + + await user.joinRoom(roomJid); + + try { + const token = mintSystemToken(); + const { status } = await sendSystemChatMessage( + roomJid, [ user.jid ], 'hi', token, { displayName: 'System' }); + + assert.strictEqual(status, 200); + + const msg = await user.waitForMessage( + m => m.getChild('json-message', 'http://jitsi.org/jitmeet')); + const payload = JSON.parse( + msg.getChild('json-message', 'http://jitsi.org/jitmeet').text()); + + assert.strictEqual(payload.displayName, 'System'); + } finally { + await disconnectAll(focus, user); + } + }); + + it('delivers to multiple recipients', async () => { + const { roomJid, focus } = await createRoom(); + const alice = await createXmppClient(); + const bob = await createXmppClient(); + + await alice.joinRoom(roomJid); + await bob.joinRoom(roomJid); + + try { + const token = mintSystemToken(); + const { status } = await sendSystemChatMessage( + roomJid, [ alice.jid, bob.jid ], 'broadcast', token); + + assert.strictEqual(status, 200); + + const filter = m => m.getChild('json-message', 'http://jitsi.org/jitmeet'); + const [ msgA, msgB ] = await Promise.all([ + alice.waitForMessage(filter), + bob.waitForMessage(filter) + ]); + + for (const msg of [ msgA, msgB ]) { + const payload = JSON.parse( + msg.getChild('json-message', 'http://jitsi.org/jitmeet').text()); + + assert.strictEqual(payload.message, 'broadcast'); + } + } finally { + await disconnectAll(focus, alice, bob); + } + }); + + }); + +}); diff --git a/tests/prosody/mod_token_verification_spec.js b/tests/prosody/mod_token_verification_spec.js new file mode 100644 index 000000000000..bf5431e38865 --- /dev/null +++ b/tests/prosody/mod_token_verification_spec.js @@ -0,0 +1,208 @@ +import assert from 'assert'; + +import { mintAsapToken } from './helpers/jwt.js'; +import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js'; +import { isAvailablePresence } from './helpers/xmpp_utils.js'; + +// ─── Constants ─────────────────────────────────────────────────────────────── + +// All token_verification tests run on the main MUC component. +// token_verification_require_token_for_moderation = true is set on +// conference.localhost in the test config; focus@auth.localhost is a Prosody +// admin and is therefore always exempt from both the join check and the +// moderation IQ check, mirroring production. +const CONFERENCE = 'conference.localhost'; + +let _roomCounter = 0; +const nextRoom = () => `token-verify-${++_roomCounter}@${CONFERENCE}`; + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('mod_token_verification', () => { + + const clients = []; + + afterEach(async () => { + await Promise.all(clients.map(c => c.disconnect())); + clients.length = 0; + }); + + // ── Join access control ────────────────────────────────────────────────── + // + // token_verification is loaded on conference.localhost. The parent + // VirtualHost (localhost) has allow_empty_token = true, so anonymous users + // are always let through (verify_room returns true when auth_token is nil). + // + // focus@auth.localhost is a Prosody admin and is therefore exempt from + // the token check on both muc-room-pre-create and muc-occupant-pre-join. + + describe('join access control', () => { + + /** + * Creates a room with a focus participant for testing. + * + * @returns {Promise} Room JID. + */ + async function setupRoom() { + const room = nextRoom(); + const focus = await joinWithFocus(room); + + clients.push(focus); + + return room; + } + + it('allows anonymous join when allow_empty_token = true', async () => { + const room = await setupRoom(); + const c = await createXmppClient(); + + clients.push(c); + + const presence = await c.joinRoom(room); + + assert.ok(isAvailablePresence(presence), + 'tokenless user must be allowed when allow_empty_token = true'); + }); + + it('rejects connection when token is missing the sub claim', async () => { + // sub: undefined overrides the mintAsapToken default of sub: '*', producing + // a token with no sub field. process_and_verify_token rejects such tokens, + // causing a SASL failure before the client finishes connecting. + const token = mintAsapToken({ sub: undefined }); + + await assert.rejects( + createXmppClient({ params: { token } }), + 'connection must be rejected when the sub claim is absent' + ); + }); + + it('allows join with token that has no room claim', async () => { + const room = await setupRoom(); + + // No 'room' field in payload → session.jitsi_meet_room = nil → passes. + const token = mintAsapToken(); + const c = await createXmppClient({ params: { token } }); + + clients.push(c); + + const presence = await c.joinRoom(room); + + assert.ok(isAvailablePresence(presence), + 'token without a room claim must be allowed (treated as anonymous)'); + }); + + it('allows join with token whose room claim matches the room', async () => { + const room = await setupRoom(); + const roomName = room.split('@')[0]; // e.g. "token-verify-3" + const token = mintAsapToken({ room: roomName }); + const c = await createXmppClient({ params: { token } }); + + clients.push(c); + + const presence = await c.joinRoom(room); + + assert.ok(isAvailablePresence(presence), + 'token with matching room claim must be allowed'); + }); + + it('blocks join when token room claim does not match the room', async () => { + const room = await setupRoom(); + + // Token claims access to 'other-room', but we try to join a different room. + const token = mintAsapToken({ room: 'other-room' }); + const c = await createXmppClient({ params: { token } }); + + clients.push(c); + + const presence = await c.joinRoom(room); + + assert.strictEqual(presence.attrs.type, 'error', + 'mismatched room claim must be rejected'); + assert.ok( + presence.getChild('error')?.getChild('not-allowed'), + 'expected error condition'); + }); + + it('allows join with wildcard (*) room claim', async () => { + const room = await setupRoom(); + const token = mintAsapToken({ room: '*' }); + const c = await createXmppClient({ params: { token } }); + + clients.push(c); + + const presence = await c.joinRoom(room); + + assert.ok(isAvailablePresence(presence), + 'wildcard room claim must be allowed in any room'); + }); + }); + + // ── token_verification_require_token_for_moderation ────────────────────── + // + // conference.localhost has require_token_for_moderation = true. + // Unauthenticated users (no token) are blocked from sending muc#owner IQs, + // which is how moderator status is granted to other participants and how + // room configuration (e.g. passwords) is changed. Authenticated users + // whose token room claim matches are allowed through. + // + // focus@auth.localhost is a Prosody admin and is always exempt from the + // check, so it can create rooms and set their initial configuration. + + describe('token_verification_require_token_for_moderation', () => { + + /** + * Creates a room with a focus participant for testing. + * + * @returns {Promise} Room JID. + */ + async function setupRoom() { + const room = nextRoom(); + const focus = await joinWithFocus(room); + + clients.push(focus); + + return room; + } + + it('blocks unauthenticated user from sending owner config IQ', async () => { + const room = await setupRoom(); + + // Anonymous guest joins (allow_empty_token = true on localhost). + const guest = await createXmppClient(); + + clients.push(guest); + await guest.joinRoom(room); + + // Guest attempts to change room config (muc#owner IQ). + const iq = await guest.setRoomPassword(room, 'hacked'); + + assert.strictEqual(iq.attrs.type, 'error', + 'unauthenticated user must be blocked from changing room config'); + assert.ok( + iq.getChild('error')?.getChild('not-allowed'), + 'expected error condition from require_token_for_moderation'); + }); + + it('does not block authenticated user from sending owner config IQ', async () => { + const room = await setupRoom(); + const roomName = room.split('@')[0]; + const token = mintAsapToken({ room: roomName }); + const authUser = await createXmppClient({ params: { token } }); + + clients.push(authUser); + await authUser.joinRoom(room); + + // Authenticated user sends an muc#owner IQ. require_token_for_moderation + // must NOT block it (though Prosody may still deny with 'forbidden' if the + // user is not the room owner — that is a different check). + const iq = await authUser.setRoomPassword(room, 'secret'); + + // The error must not be 'not-allowed' from require_token_for_moderation. + const error = iq.getChild('error'); + + assert.ok( + !error?.getChild('not-allowed'), + 'require_token_for_moderation must not block authenticated user'); + }); + }); +}); diff --git a/tests/prosody/package-lock.json b/tests/prosody/package-lock.json index 3b21ccf68203..76defea370cc 100644 --- a/tests/prosody/package-lock.json +++ b/tests/prosody/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@xmpp/client": "^0.13.1", "@xmpp/xml": "^0.13.1", + "jsonwebtoken": "^9.0.0", "testcontainers": "^10.13.2" }, "devDependencies": { @@ -1778,6 +1779,12 @@ "node": ">=8.0.0" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -2375,6 +2382,15 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.340", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz", @@ -3622,6 +3638,61 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -3713,6 +3784,48 @@ "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", diff --git a/tests/prosody/package.json b/tests/prosody/package.json index dc4193fabf3c..55d7496568a4 100644 --- a/tests/prosody/package.json +++ b/tests/prosody/package.json @@ -7,11 +7,13 @@ "test:lua": "rm -f allure-results/*-result.json allure-results/TEST-lua-unit.xml 2>/dev/null; mkdir -p allure-results && if command -v busted > /dev/null 2>&1; then BUSTED=${HOME}/.luarocks/bin/busted; command -v \"$BUSTED\" > /dev/null 2>&1 || BUSTED=busted; SPEC_DIR=\"$(pwd)\"; (cd ../../resources/prosody-plugins && LUA_PATH=\"$SPEC_DIR/?.lua;;\" ALLURE_RESULTS_DIR=\"$SPEC_DIR/allure-results\" \"$BUSTED\" --output busted_allure \"$SPEC_DIR/lua/\"); fi", "test:integration": "DEBUG=testcontainers,testcontainers:containers,testcontainers:build mocha", "test:report": "npx allure generate allure-results --clean -o allure-report && node fix-allure-report.cjs", - "test": "npm run test:lua && npm run test:integration && npm run test:report" + "test": "npm run test:lua && npm run test:integration; RC=$?; npm run test:report; exit $RC", + "test:one": "mocha --no-config --require ./setup.js --timeout 120000" }, "dependencies": { "@xmpp/client": "^0.13.1", "@xmpp/xml": "^0.13.1", + "jsonwebtoken": "^9.0.0", "testcontainers": "^10.13.2" }, "devDependencies": { diff --git a/tests/prosody/setup.js b/tests/prosody/setup.js index deb28a98ba97..364b4c244a07 100644 --- a/tests/prosody/setup.js +++ b/tests/prosody/setup.js @@ -14,6 +14,7 @@ export const mochaHooks = { path.join(__dirname, 'docker'), 'docker-compose.yml' ) + .withBuild() .withWaitStrategy('prosody-1', Wait.forListeningPorts()) .up();