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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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