Files
upstream-jitsi-meet/resources/prosody-plugins/mod_test_observer_http.lua
upstream-mirror 1d2bfcac0c
Some checks failed
Prosody Plugin Tests / test (push) Failing after 10m11s
Close stale issues and PRs / stale (push) Failing after 1s
snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
2026-09-15 15:56:32 +05:00

659 lines
30 KiB
Lua

-- Test-only module. Never load in production.
-- Serves HTTP endpoints for test assertions. Must be loaded on the main VirtualHost
-- (not the MUC component) so that plain HTTP on port 5280 is reachable with
-- Host: localhost — the VirtualHost's domain.
--
-- 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)
-- non_json: true → return a 200 with a plain-text body (not valid JSON)
-- (tests the nil-check bug: json.decode returns nil, r['access'] would crash)
-- Reset to the default (allow, 200) between tests via POST /test-observer/access-manager.
local access_manager_state = { access = true, status = 200, non_json = false };
-- Mock OTLP trace receiver for mod_trace tests. Stores every exported
-- resource_spans payload POSTed by the module's Exporter so tests can assert
-- a span was (or was not) exported without needing a real OTLP collector.
local otlp_traces = {};
-- 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: <asap_key_server>/<sha256hex(kid)>.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 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.
local MUC_HOST = module:get_option_string("muc_mapper_domain_base", "localhost");
local MUC_COMPONENT = module:get_option_string("muc_mapper_domain_prefix", "conference") .. "." .. MUC_HOST;
local shared = module:shared("/" .. MUC_COMPONENT .. "/mod_test_observer");
local function urldecode(s)
return (s:gsub("%%(%x%x)", function(hex)
return string.char(tonumber(hex, 16));
end));
end
local function parse_query(q)
local params = {};
for k, v in (q or ""):gmatch("([^&=]+)=([^&]*)") do
params[urldecode(k)] = urldecode(v);
end
return params;
end
module:provides("http", {
default_path = "/test-observer";
route = {
-- GET /test-observer/asap-keys/<sha256hex(kid)>.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/<sha256hex(kid)>.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.
local body = #events == 0 and "[]" or json.encode(events);
return {
status_code = 200;
headers = { ["Content-Type"] = "application/json" };
body = body;
};
end;
["DELETE /events"] = function()
-- Replace with a fresh table; mod_test_observer always reads shared.events
-- via the shared reference so it will see the new table immediately.
shared.events = {};
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/sessions/features?jid=user@localhost/resource
-- Returns the live jitsi_meet_context_features for the session.
-- Reading live (not a snapshot) captures side-effects from modules such as
-- mod_jitsi_permissions, which may set or update features after resource-bind.
-- Returns 200 {"features": <object|null>} or 404.
["GET /sessions/features"] = function(event)
local params = parse_query(event.request.url.query);
local full_jid = params["jid"];
if not full_jid then
return { status_code = 400; body = '{"error":"missing jid param"}' };
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
local features = session.jitsi_meet_context_features;
local body = features and json.encode({ features = features }) or '{"features":null}';
return {
status_code = 200;
headers = { ["Content-Type"] = "application/json" };
body = body;
};
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/hide-display-name
-- Body: { "jid": "room@conference.localhost", "hidden": true|false }
-- Sets room._data.hideDisplayNameForGuests for mod_muc_displayname tests.
["POST /rooms/hide-display-name"] = function(event)
local data = json.decode(event.request.body or "{}") or {};
local room_jid = data.jid;
local hidden = data.hidden;
if room_jid == nil or hidden == nil then
return { status_code = 400; body = '{"error":"missing jid or hidden"}' };
end
local room = (shared.rooms or {})[room_jid];
if not room then
return { status_code = 404; body = '{"error":"room not found"}' };
end
room._data.hideDisplayNameForGuests = hidden;
return {
status_code = 200;
headers = { ["Content-Type"] = "application/json" };
body = '{"ok":true}';
};
end;
-- POST /test-observer/rooms/breakout-rooms-active
-- Body: { "jid": "room@conference.localhost", "active": true|false }
-- Sets room._data.breakout_rooms_active, which mod_muc_cleanup_backend_services
-- checks to skip the destroy-timer logic when breakout rooms are running.
["POST /rooms/breakout-rooms-active"] = function(event)
local data = json.decode(event.request.body or "{}") or {};
local room_jid = data.jid;
local active = data.active;
if room_jid == nil or active == nil then
return { status_code = 400; body = '{"error":"missing jid or active"}' };
end
local room = (shared.rooms or {})[room_jid];
if not room then
return { status_code = 404; body = '{"error":"room not found"}' };
end
room._data.breakout_rooms_active = active;
return {
status_code = 200;
headers = { ["Content-Type"] = "application/json" };
body = '{"ok":true}';
};
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
-- global muc_max_occupants without restarting Prosody.
["POST /rooms/max-occupants"] = function(event)
local data = json.decode(event.request.body or "{}") or {};
local room_jid = data.jid;
local max = tonumber(data.max_occupants);
if not room_jid or not max then
return { status_code = 400; body = '{"error":"missing jid or max_occupants"}' };
end
local room = (shared.rooms or {})[room_jid];
if not room then
return { status_code = 404; body = '{"error":"room not found"}' };
end
room._data.max_occupants = max;
return {
status_code = 200;
headers = { ["Content-Type"] = "application/json" };
body = '{"ok":true}';
};
end;
-- POST /test-observer/otlp-traces
-- Called by mod_trace's Exporter. Body is the raw OTLP
-- ExportTraceServiceRequest JSON; stored verbatim for test assertions.
["POST /otlp-traces"] = function(event)
local data = json.decode(event.request.body or "{}");
if data then
table.insert(otlp_traces, data);
end
return {
status_code = 200;
headers = { ["Content-Type"] = "application/json" };
body = "{}";
};
end;
-- GET /test-observer/otlp-traces
-- Returns every ExportTraceServiceRequest body received so far.
["GET /otlp-traces"] = function()
local body = #otlp_traces == 0 and "[]" or json.encode(otlp_traces);
return {
status_code = 200;
headers = { ["Content-Type"] = "application/json" };
body = body;
};
end;
-- DELETE /test-observer/otlp-traces
-- Clears the recorded export list. Call before each test.
["DELETE /otlp-traces"] = function()
otlp_traces = {};
return { status_code = 204 };
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 <token>` 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
if access_manager_state.non_json then
-- Return a 200 with a non-JSON body to exercise the nil-check
-- bug in mod_muc_auth_ban: json.decode returns nil, and
-- r['access'] would crash if the module doesn't guard for nil.
return {
status_code = 200;
headers = { ["Content-Type"] = "text/plain" };
body = "not-valid-json";
};
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": <http-status-code>, "non_json": true|false }
-- 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
if data.non_json ~= nil then
access_manager_state.non_json = data.non_json;
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/session-live?jid=user@localhost/resource
-- Returns the CURRENT values of the JWT-derived fields on the live c2s
-- session, read straight from prosody.full_sessions. Unlike
-- /session-info (a snapshot taken at resource-bind) this reflects
-- changes made after the session was established -- notably the claim
-- refresh performed by mod_auth_token on XEP-0198 stream resumption,
-- which never binds a new resource.
["GET /session-live"] = 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 session = prosody.full_sessions[jid];
if not session then
return { status_code = 404; body = '{"error":"session not found"}' };
end
return {
status_code = 200;
headers = { ["Content-Type"] = "application/json" };
body = json.encode({
auth_token = session.auth_token,
jitsi_web_query_room = session.jitsi_web_query_room,
jitsi_web_query_prefix = session.jitsi_web_query_prefix,
jitsi_meet_room = session.jitsi_meet_room,
jitsi_meet_domain = session.jitsi_meet_domain,
jitsi_meet_str_tenant = session.jitsi_meet_str_tenant,
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;
-- POST /test-observer/rooms/lobby
-- Body: { "jid": "room@conference.localhost", "enabled": true|false }
-- Enables or disables the lobby for the given room by firing the
-- create-lobby-room / destroy-lobby-room global Prosody events.
-- Enabling also sets members-only; disabling unsets it.
-- Response: { ok, lobbyroom } (lobbyroom is nil when disabling).
["POST /rooms/lobby"] = function(event)
local data = json.decode(event.request.body or "{}") or {};
local room_jid = data.jid;
local enabled = data.enabled;
if not room_jid then
return { status_code = 400; body = '{"error":"missing jid"}' };
end
local room = (shared.rooms or {})[room_jid];
if not room then
return { status_code = 404; body = '{"error":"room not found"}' };
end
if enabled then
prosody.events.fire_event('create-lobby-room', { room = room });
else
room:set_members_only(false);
prosody.events.fire_event('destroy-lobby-room', { room = room });
end
return {
status_code = 200;
headers = { ["Content-Type"] = "application/json" };
body = json.encode({ ok = true; lobbyroom = room._data.lobbyroom });
};
end;
-- POST /test-observer/rooms/affiliation
-- Body: { "jid": "room@conference.localhost", "occupant_jid": "user@localhost", "affiliation": "member" }
-- Sets the affiliation of occupant_jid in the given room.
["POST /rooms/affiliation"] = function(event)
local data = json.decode(event.request.body or "{}") or {};
local room_jid = data.jid;
local occupant_jid = data.occupant_jid;
local affiliation = data.affiliation;
if not room_jid or not occupant_jid or not affiliation then
return { status_code = 400; body = '{"error":"missing required fields"}' };
end
local room = (shared.rooms or {})[room_jid];
if not room then
return { status_code = 404; body = '{"error":"room not found"}' };
end
room:set_affiliation(true, occupant_jid, affiliation);
return {
status_code = 200;
headers = { ["Content-Type"] = "application/json" };
body = '{"ok":true}';
};
end;
-- GET /test-observer/rooms/metadata?jid=room@conference.localhost
-- Returns: { jid, metadata } where metadata is room.jitsiMetadata.
["GET /rooms/metadata"] = 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 rooms = shared.rooms or {};
local room = rooms[room_jid];
if not room then
return { status_code = 404; body = '{"error":"room not found"}' };
end
local encoded, err = json.encode({
jid = room.jid;
metadata = room.jitsiMetadata or {};
});
if not encoded then
return { status_code = 500; body = json.encode({ error = 'encode failed: ' .. tostring(err) }) };
end
return {
status_code = 200;
headers = { ["Content-Type"] = "application/json" };
body = encoded;
};
end;
-- GET /test-observer/rooms/audio-translation-requests?jid=room@conference.localhost
-- Returns: { jid, audioTranslationRequests } where the value is the
-- aggregated map stored on room._data by mod_audio_translation_component
-- (omitted when there are no subscriptions).
["GET /rooms/audio-translation-requests"] = 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 rooms = shared.rooms or {};
local room = rooms[room_jid];
if not room then
return { status_code = 404; body = '{"error":"room not found"}' };
end
local encoded, err = json.encode({
jid = room.jid;
audioTranslationRequests = room._data.audioTranslationRequests;
});
if not encoded then
return { status_code = 500; body = json.encode({ error = 'encode failed: ' .. tostring(err) }) };
end
return {
status_code = 200;
headers = { ["Content-Type"] = "application/json" };
body = encoded;
};
end;
-- GET /test-observer/rooms?jid=room@conference.localhost
-- Returns: { jid, hidden, occupant_count }
["GET /rooms"] = 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 rooms = shared.rooms or {};
local room = rooms[room_jid];
if not room then
return { status_code = 404; body = '{"error":"room not found"}' };
end
local count = 0;
for _ in room:each_occupant() do count = count + 1; end
return {
status_code = 200;
headers = { ["Content-Type"] = "application/json" };
body = json.encode({
jid = room.jid;
hidden = room:get_hidden();
occupant_count = count;
});
};
end;
};
});
module:log("info", "test_observer_http loaded");