snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
This commit is contained in:
154
tests/prosody/lua/jwk_spec.lua
Normal file
154
tests/prosody/lua/jwk_spec.lua
Normal file
@@ -0,0 +1,154 @@
|
||||
-- Unit tests for token/jwk.lib.lua
|
||||
-- Run with busted from resources/prosody-plugins/:
|
||||
-- busted spec/lua/
|
||||
-- Requires: basexx (luarocks install basexx)
|
||||
|
||||
-- dofile resolves relative to CWD, so run from resources/prosody-plugins/.
|
||||
-- The module sets ASN1, base64_encode, and base64url_decode as globals.
|
||||
local M = dofile("token/jwk.lib.lua")
|
||||
local ASN1 = M.ASN1
|
||||
|
||||
describe("jwk.lib", function()
|
||||
|
||||
describe("ASN1", function()
|
||||
|
||||
describe("encode_length", function()
|
||||
it("encodes single-byte length for 0", function()
|
||||
assert.equal(string.char(0x00), ASN1.encode_length(0))
|
||||
end)
|
||||
|
||||
it("encodes single-byte length for 127", function()
|
||||
assert.equal(string.char(0x7F), ASN1.encode_length(127))
|
||||
end)
|
||||
|
||||
it("encodes two-byte form for 128", function()
|
||||
assert.equal(string.char(0x81, 0x80), ASN1.encode_length(128))
|
||||
end)
|
||||
|
||||
it("encodes two-byte form for 255", function()
|
||||
assert.equal(string.char(0x81, 0xFF), ASN1.encode_length(255))
|
||||
end)
|
||||
|
||||
it("encodes three-byte form for 256", function()
|
||||
assert.equal(string.char(0x82, 0x01, 0x00), ASN1.encode_length(256))
|
||||
end)
|
||||
|
||||
it("encodes three-byte form for 65535", function()
|
||||
assert.equal(string.char(0x82, 0xFF, 0xFF), ASN1.encode_length(65535))
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("encode_null", function()
|
||||
it("returns NULL tag (0x05) with zero length", function()
|
||||
assert.equal(string.char(0x05, 0x00), ASN1.encode_null())
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("encode_integer", function()
|
||||
it("tags with 0x02 and does not pad when high bit is clear", function()
|
||||
assert.equal(string.char(0x02, 0x01, 0x7F), ASN1.encode_integer(string.char(0x7F)))
|
||||
end)
|
||||
|
||||
it("prepends 0x00 when high bit is set to indicate positive number", function()
|
||||
assert.equal(string.char(0x02, 0x02, 0x00, 0x80), ASN1.encode_integer(string.char(0x80)))
|
||||
end)
|
||||
|
||||
it("prepends 0x00 when first byte is 0xFF", function()
|
||||
assert.equal(string.char(0x02, 0x02, 0x00, 0xFF), ASN1.encode_integer(string.char(0xFF)))
|
||||
end)
|
||||
|
||||
it("handles multi-byte integer without high bit", function()
|
||||
assert.equal(string.char(0x02, 0x02, 0x01, 0x00), ASN1.encode_integer(string.char(0x01, 0x00)))
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("encode_sequence", function()
|
||||
it("wraps content with SEQUENCE tag 0x30", function()
|
||||
assert.equal(string.char(0x30, 0x02, 0xAB, 0xCD), ASN1.encode_sequence(string.char(0xAB, 0xCD)))
|
||||
end)
|
||||
|
||||
it("handles empty content", function()
|
||||
assert.equal(string.char(0x30, 0x00), ASN1.encode_sequence(""))
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("encode_bit_string", function()
|
||||
it("wraps with BIT STRING tag (0x03) and prepends unused-bits byte (0x00)", function()
|
||||
-- tag=0x03, length=2 (0x00 + one content byte), 0x00 unused bits, 0xAB
|
||||
assert.equal(string.char(0x03, 0x02, 0x00, 0xAB), ASN1.encode_bit_string(string.char(0xAB)))
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("encode_oid", function()
|
||||
it("wraps bytes with OID tag 0x06", function()
|
||||
local oid_bytes = string.char(0x2A, 0x86, 0x48)
|
||||
assert.equal(string.char(0x06, 0x03) .. oid_bytes, ASN1.encode_oid(oid_bytes))
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("der_to_pem", function()
|
||||
it("wraps DER bytes in PEM format with default PUBLIC KEY label", function()
|
||||
local der = string.char(0x30, 0x00)
|
||||
local pem = ASN1.der_to_pem(der)
|
||||
assert.truthy(pem:match("^-----BEGIN PUBLIC KEY-----\n"))
|
||||
assert.truthy(pem:match("-----END PUBLIC KEY-----\n$"))
|
||||
end)
|
||||
|
||||
it("uses custom label when provided", function()
|
||||
local der = string.char(0x30, 0x00)
|
||||
local pem = ASN1.der_to_pem(der, "RSA PUBLIC KEY")
|
||||
assert.truthy(pem:match("^-----BEGIN RSA PUBLIC KEY-----\n"))
|
||||
assert.truthy(pem:match("-----END RSA PUBLIC KEY-----\n$"))
|
||||
end)
|
||||
|
||||
it("breaks base64 into 64-character lines", function()
|
||||
-- 48 bytes of DER = 64 chars of base64 (exactly one line)
|
||||
local der = string.rep(string.char(0x00), 48)
|
||||
local pem = ASN1.der_to_pem(der)
|
||||
-- Should have exactly one data line between the markers
|
||||
local data = pem:match("-----BEGIN PUBLIC KEY-----\n(.-)\n-----END PUBLIC KEY-----")
|
||||
assert.falsy(data:match("\n")) -- no newline = single line
|
||||
assert.equal(64, #data)
|
||||
end)
|
||||
end)
|
||||
|
||||
end) -- ASN1
|
||||
|
||||
describe("jwk_to_pem", function()
|
||||
-- Test vectors computed independently with Python's cryptography primitives.
|
||||
-- n=0x0F (15), e=0x03 (3) — minimal key with no high-bit padding needed.
|
||||
it("converts a minimal RSA JWK (no 0x00 padding) to PEM", function()
|
||||
local pem = M.jwk_to_pem({ n = "Dw", e = "Aw" })
|
||||
local expected = "-----BEGIN PUBLIC KEY-----\n"
|
||||
.. "MBowDQYJKoZIhvcNAQEBBQADCQAwBgIBDwIBAw==\n"
|
||||
.. "-----END PUBLIC KEY-----\n"
|
||||
assert.equal(expected, pem)
|
||||
end)
|
||||
|
||||
-- n=0x8001, e=65537 — modulus has high bit set, so ASN.1 must prepend 0x00.
|
||||
it("converts a JWK with high-bit modulus (requires 0x00 padding) to PEM", function()
|
||||
local pem = M.jwk_to_pem({ n = "gAE", e = "AQAB" })
|
||||
local expected = "-----BEGIN PUBLIC KEY-----\n"
|
||||
.. "MB4wDQYJKoZIhvcNAQEBBQADDQAwCgIDAIABAgMBAAE=\n"
|
||||
.. "-----END PUBLIC KEY-----\n"
|
||||
assert.equal(expected, pem)
|
||||
end)
|
||||
|
||||
it("output begins and ends with correct PEM markers", function()
|
||||
local pem = M.jwk_to_pem({ n = "Dw", e = "Aw" })
|
||||
assert.truthy(pem:match("^-----BEGIN PUBLIC KEY-----\n"))
|
||||
assert.truthy(pem:match("-----END PUBLIC KEY-----\n$"))
|
||||
end)
|
||||
|
||||
it("returns nil when n is missing", function()
|
||||
local pem = M.jwk_to_pem({ e = "Aw" })
|
||||
assert.is_nil(pem)
|
||||
end)
|
||||
|
||||
it("returns nil when e is missing", function()
|
||||
local pem = M.jwk_to_pem({ n = "Dw" })
|
||||
assert.is_nil(pem)
|
||||
end)
|
||||
end) -- jwk_to_pem
|
||||
|
||||
end) -- jwk.lib
|
||||
443
tests/prosody/lua/luajwtjitsi_spec.lua
Normal file
443
tests/prosody/lua/luajwtjitsi_spec.lua
Normal file
@@ -0,0 +1,443 @@
|
||||
-- Unit tests for luajwtjitsi.lib.lua
|
||||
-- Run with busted from resources/prosody-plugins/:
|
||||
-- busted spec/lua/
|
||||
-- Requires: basexx, lua-cjson, openssl (luarocks package 'openssl')
|
||||
|
||||
-- Compatibility shim: the luarocks 'openssl' package (v0.11+) has a different
|
||||
-- API from the old 'lua-openssl' that the production code was written for.
|
||||
-- Differences handled here:
|
||||
-- 1. require style: monolithic (require 'openssl') vs submodule (require 'openssl.digest')
|
||||
-- 2. hmac.new: old=(key,algo), new=(algo,key)
|
||||
-- 3. digest:update(): old returns self (chainable), new returns bool
|
||||
-- 4. pkey.new(pem): not supported in new API; use pkey.read(pem, private, 'pem')
|
||||
-- 5. pkey:sign(digest_obj): old takes digest obj, new takes (data, algo)
|
||||
-- 6. pkey:verify(sig, digest_obj): old order; new is verify(data, sig, algo)
|
||||
local _ok, _ssl = pcall(require, 'openssl')
|
||||
if _ok and type(_ssl) == 'table' then
|
||||
|
||||
-- 1+2. hmac: swap args and register as submodule
|
||||
if not package.preload['openssl.hmac'] then
|
||||
local _hmac_raw = _ssl.hmac
|
||||
local _hmac_compat = setmetatable({}, { __index = _hmac_raw })
|
||||
_hmac_compat.new = function(key, algo) return _hmac_raw.new(algo, key) end
|
||||
package.preload['openssl.hmac'] = function() return _hmac_compat end
|
||||
end
|
||||
|
||||
-- 1+3. digest: make :update() return self, store data+algo for pkey compat
|
||||
if not package.preload['openssl.digest'] then
|
||||
local _digest_raw = _ssl.digest
|
||||
local _digest_compat = {}
|
||||
_digest_compat.new = function(algo)
|
||||
local inner = _digest_raw.new(algo)
|
||||
local w = { _inner = inner, _algo = algo, _data = '' }
|
||||
return setmetatable(w, {
|
||||
__index = function(self, k)
|
||||
if k == 'update' then
|
||||
return function(s, d)
|
||||
s._inner:update(d)
|
||||
s._data = s._data .. d
|
||||
return s -- return self for chaining (old API)
|
||||
end
|
||||
elseif k == 'final' then
|
||||
return function(s) return s._inner:final() end
|
||||
end
|
||||
end
|
||||
})
|
||||
end
|
||||
package.preload['openssl.digest'] = function() return _digest_compat end
|
||||
end
|
||||
|
||||
-- 1+4+5+6. pkey: map new() to read(), adapt sign/verify signatures
|
||||
if not package.preload['openssl.pkey'] then
|
||||
local _pkey_raw = _ssl.pkey
|
||||
local function wrap_key(raw)
|
||||
return setmetatable({}, {
|
||||
__index = function(_, k)
|
||||
if k == 'sign' then
|
||||
-- old: sign(digest_obj) → new: sign(data, algo)
|
||||
return function(_, dobj)
|
||||
return raw:sign(dobj._data, dobj._algo)
|
||||
end
|
||||
elseif k == 'verify' then
|
||||
-- old: verify(sig, digest_obj) → new: verify(data, sig, algo)
|
||||
return function(_, sig, dobj)
|
||||
return raw:verify(dobj._data, sig, dobj._algo)
|
||||
end
|
||||
else
|
||||
return raw[k]
|
||||
end
|
||||
end
|
||||
})
|
||||
end
|
||||
local _pkey_compat = setmetatable({}, { __index = _pkey_raw })
|
||||
_pkey_compat.new = function(pem)
|
||||
local ok1, k1 = pcall(_pkey_raw.read, pem, true, 'pem') -- private
|
||||
if ok1 and k1 then return wrap_key(k1) end
|
||||
local ok2, k2 = pcall(_pkey_raw.read, pem, false, 'pem') -- public
|
||||
if ok2 and k2 then return wrap_key(k2) end
|
||||
return nil -- signal invalid key (matches old API behaviour)
|
||||
end
|
||||
package.preload['openssl.pkey'] = function() return _pkey_compat end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
-- Guard: skip the whole suite if the module's hard dependencies aren't installed.
|
||||
local ok, M = pcall(dofile, "luajwtjitsi.lib.lua")
|
||||
if not ok then
|
||||
describe("luajwtjitsi", function()
|
||||
it("skipped — missing dependencies (lua-cjson and/or lua-openssl not installed)", function()
|
||||
pending(tostring(M):match("([^\n]+)"))
|
||||
end)
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
local I = M._internals
|
||||
|
||||
-- Test RSA key pair (1024-bit PKCS#1, test use only — not for production).
|
||||
-- Generated with: openssl genrsa -traditional 1024
|
||||
local PRIVATE_PEM = [[
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIICXQIBAAKBgQDElXxE6H6t622jPZbwgXi+WuJpLDIPr2znPUqtzTJCAVnK1+Iw
|
||||
CoTjKYmtkiaszVCn/QL41Hr1JXHA/zzo/dLedvKQhs6dvadUVt4Ch2D7hcqGzn7T
|
||||
oCTCzD1CyaIb9jGR0KOh8gMpL7JE6Zk9f1ku+91QPTe55FlyHPJ4fZfh/wIDAQAB
|
||||
AoGBAK6uUlV87owmxi6m3MfrlFo5sjRYhEdxz2iPCxvzWTRTtPFM8f1Ua47bay6j
|
||||
zuAANSqA5bIsRPIvCqy/YOV8HnHnNVl1ARCEAWMl2it0BsqOxuJKhqUsbp1gPAtM
|
||||
mJE51yPwfN8fcvMNw+6dwV0h3jp+Y57RR0JAhMRWZNrBSmPhAkEA4smEZX8tjQzB
|
||||
W3FoPIz5a8hgTDfyHYZftC6HqTdEx/NK1z2+EkirS/4WAcMkIb63gWtxSs/9H+bE
|
||||
tEKcIGzJsQJBAN3n/iLa+2SIseykvzD5bJrdqdmpFYTMzzs8mua5ngkVGd5hkhJ1
|
||||
MhhfRe3B/ytBLsd74A889k6vFUxoIYMYoq8CQDinhv+kCckWyqcBabzWquiWIhho
|
||||
UX+2tenZReKr/+7DhIrIzdbbeI9/ZhgCm+AiOM2H/cFmvCFgEPvHOCGRi8ECQQC3
|
||||
qIe0j2sbZOtlgWDgBr+1WRp3lDLEK8KkM1+88Al1WOqFHFoKPscMVuYIhtRLF3LB
|
||||
Fax20CzHKK9HArQhzWTvAkBezAcZyo/TBNq+gHKAQw66mFbr7QSd7BCMIQAZg6Cn
|
||||
VfvTq/eTsOOIgqKYyQzlSY1Jwu27pVQtRzGPOitHbO3E
|
||||
-----END RSA PRIVATE KEY-----
|
||||
]]
|
||||
|
||||
local PUBLIC_PEM = [[
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDElXxE6H6t622jPZbwgXi+WuJp
|
||||
LDIPr2znPUqtzTJCAVnK1+IwCoTjKYmtkiaszVCn/QL41Hr1JXHA/zzo/dLedvKQ
|
||||
hs6dvadUVt4Ch2D7hcqGzn7ToCTCzD1CyaIb9jGR0KOh8gMpL7JE6Zk9f1ku+91Q
|
||||
PTe55FlyHPJ4fZfh/wIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
]]
|
||||
|
||||
-- Returns a payload with exp set one hour in the future (so verify's exp check passes).
|
||||
-- Extra fields can be merged in via the `extra` table.
|
||||
local function payload(extra)
|
||||
local p = { sub = 'test', exp = os.time() + 3600 }
|
||||
if extra then
|
||||
for k, v in pairs(extra) do p[k] = v end
|
||||
end
|
||||
return p
|
||||
end
|
||||
|
||||
describe("luajwtjitsi", function()
|
||||
|
||||
describe("internals", function()
|
||||
|
||||
describe("verify_claim", function()
|
||||
it("exact match returns true", function()
|
||||
assert.is_true(I.verify_claim('foo', { 'foo', 'bar' }))
|
||||
end)
|
||||
|
||||
it("non-matching value returns false", function()
|
||||
assert.is_false(I.verify_claim('baz', { 'foo', 'bar' }))
|
||||
end)
|
||||
|
||||
it("wildcard '*' matches any value", function()
|
||||
assert.is_true(I.verify_claim('anything', { '*' }))
|
||||
end)
|
||||
|
||||
it("empty list returns false", function()
|
||||
assert.is_false(I.verify_claim('foo', {}))
|
||||
end)
|
||||
|
||||
it("wildcard in mixed list still matches", function()
|
||||
assert.is_true(I.verify_claim('x', { 'a', '*', 'b' }))
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("split_token", function()
|
||||
it("splits three segments correctly", function()
|
||||
local parts = I.split_token("aaa.bbb.ccc")
|
||||
assert.equal(3, #parts)
|
||||
assert.equal('aaa', parts[1])
|
||||
assert.equal('bbb', parts[2])
|
||||
assert.equal('ccc', parts[3])
|
||||
end)
|
||||
|
||||
it("returns one element when there are no dots", function()
|
||||
local parts = I.split_token("nodots")
|
||||
assert.equal(1, #parts)
|
||||
assert.equal('nodots', parts[1])
|
||||
end)
|
||||
|
||||
it("returns two elements for two segments", function()
|
||||
local parts = I.split_token("hdr.body")
|
||||
assert.equal(2, #parts)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("strip_signature", function()
|
||||
it("removes the signature segment", function()
|
||||
local result = I.strip_signature("hdr.body.sig")
|
||||
assert.equal("hdr.body", result)
|
||||
end)
|
||||
|
||||
it("returns nil and error for two-segment input", function()
|
||||
local result, _, _, err = I.strip_signature("hdr.body")
|
||||
assert.is_nil(result)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
|
||||
it("returns nil and error for single-segment input", function()
|
||||
local result, _, _, err = I.strip_signature("nodots")
|
||||
assert.is_nil(result)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("parse_token", function()
|
||||
it("parses a valid HS256 token into header, body, and signature", function()
|
||||
local tok = assert(M.encode({ sub = 'alice', exp = os.time() + 3600 }, 'secret', 'HS256'))
|
||||
local header, body, sig = I.parse_token(tok)
|
||||
assert.is_table(header)
|
||||
assert.is_table(body)
|
||||
assert.is_string(sig)
|
||||
assert.equal('JWT', header.typ)
|
||||
assert.equal('HS256', header.alg)
|
||||
assert.equal('alice', body.sub)
|
||||
end)
|
||||
|
||||
it("returns error for token with only two segments", function()
|
||||
local h, b, s, err = I.parse_token("hdr.body")
|
||||
assert.is_nil(h)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
|
||||
it("returns error or throws for token with invalid base64 header", function()
|
||||
-- Older basexx returns garbage bytes (decode attempt fails gracefully);
|
||||
-- newer basexx returns nil causing cjson.decode to throw. Both are
|
||||
-- acceptable outcomes — just verify we never get a valid header back.
|
||||
local ok, h = pcall(I.parse_token, "!!!!.body.sig")
|
||||
if ok then
|
||||
assert.is_nil(h) -- graceful nil-return path
|
||||
end
|
||||
-- if not ok: threw an error, which is also acceptable
|
||||
end)
|
||||
|
||||
it("returns error for token with invalid JSON body", function()
|
||||
local basexx = require 'basexx'
|
||||
local good_hdr = basexx.to_url64('{"typ":"JWT","alg":"HS256"}')
|
||||
local bad_body = basexx.to_url64('not-valid-json{{')
|
||||
local h, b, s, err = I.parse_token(good_hdr .. '.' .. bad_body .. '.sig')
|
||||
assert.is_nil(h)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
end)
|
||||
|
||||
end) -- internals
|
||||
|
||||
describe("encode", function()
|
||||
|
||||
describe("argument validation", function()
|
||||
it("non-table data returns error", function()
|
||||
local tok, err = M.encode("not-a-table", "key", "HS256")
|
||||
assert.is_nil(tok)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
|
||||
it("non-string key returns error", function()
|
||||
local tok, err = M.encode({}, 123, "HS256")
|
||||
assert.is_nil(tok)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
|
||||
it("unsupported algorithm returns error", function()
|
||||
local tok, err = M.encode({}, "key", "XX999")
|
||||
assert.is_nil(tok)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
end)
|
||||
|
||||
end) -- encode
|
||||
|
||||
describe("verify", function()
|
||||
|
||||
describe("argument validation", function()
|
||||
it("non-string token returns error", function()
|
||||
local body, err = M.verify(123, "HS256", "key")
|
||||
assert.is_nil(body)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
|
||||
it("non-string algorithm returns error", function()
|
||||
local body, err = M.verify("a.b.c", 42, "key")
|
||||
assert.is_nil(body)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
|
||||
it("unsupported algorithm returns error", function()
|
||||
local body, err = M.verify("a.b.c", "XX999", "key")
|
||||
assert.is_nil(body)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
|
||||
it("malformed token (two segments) returns error", function()
|
||||
local body, err = M.verify("hdr.body", "HS256", "key")
|
||||
assert.is_nil(body)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("HS256", function()
|
||||
it("encode then verify returns original payload", function()
|
||||
local tok = assert(M.encode(payload({ iss = 'me' }), 'my-secret', 'HS256'))
|
||||
local body = assert(M.verify(tok, 'HS256', 'my-secret'))
|
||||
assert.equal('test', body.sub)
|
||||
assert.equal('me', body.iss)
|
||||
end)
|
||||
|
||||
it("wrong key fails verification", function()
|
||||
local tok = assert(M.encode(payload(), 'correct', 'HS256'))
|
||||
local body, err = M.verify(tok, 'HS256', 'wrong')
|
||||
assert.is_nil(body)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
|
||||
it("tampered payload fails verification", function()
|
||||
local tok = assert(M.encode(payload(), 'secret', 'HS256'))
|
||||
local basexx = require 'basexx'
|
||||
local parts = I.split_token(tok)
|
||||
parts[2] = basexx.to_url64('{"sub":"hacker","exp":9999999999}')
|
||||
local body, err = M.verify(table.concat(parts, '.'), 'HS256', 'secret')
|
||||
assert.is_nil(body)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("HS384", function()
|
||||
it("encode then verify returns original payload", function()
|
||||
local tok = assert(M.encode(payload(), 'secret', 'HS384'))
|
||||
local body = assert(M.verify(tok, 'HS384', 'secret'))
|
||||
assert.equal('test', body.sub)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("HS512", function()
|
||||
it("encode then verify returns original payload", function()
|
||||
local tok = assert(M.encode(payload(), 'secret', 'HS512'))
|
||||
local body = assert(M.verify(tok, 'HS512', 'secret'))
|
||||
assert.equal('test', body.sub)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("RS256", function()
|
||||
it("encode with private key, verify with public key", function()
|
||||
local tok = assert(M.encode(payload(), PRIVATE_PEM, 'RS256'))
|
||||
local body = assert(M.verify(tok, 'RS256', PUBLIC_PEM))
|
||||
assert.equal('test', body.sub)
|
||||
end)
|
||||
|
||||
it("tampered signature fails verification", function()
|
||||
local tok = assert(M.encode(payload(), PRIVATE_PEM, 'RS256'))
|
||||
-- Corrupt the middle of the signature segment so the decoded bytes change.
|
||||
-- (Avoid the last 1-2 chars: they may carry padding bits that decode identically.)
|
||||
local mid = math.floor(#tok / 2)
|
||||
local c = tok:sub(mid, mid)
|
||||
local replacement = (c == 'A') and 'B' or 'A'
|
||||
local tampered = tok:sub(1, mid - 1) .. replacement .. tok:sub(mid + 1)
|
||||
local body, err = M.verify(tampered, 'RS256', PUBLIC_PEM)
|
||||
assert.is_nil(body)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("claims", function()
|
||||
it("expired token is rejected", function()
|
||||
local tok = assert(M.encode({ sub = 'test', exp = os.time() - 1 }, 'secret', 'HS256'))
|
||||
local body, err = M.verify(tok, 'HS256', 'secret')
|
||||
assert.is_nil(body)
|
||||
assert.truthy(err:match('exp'))
|
||||
end)
|
||||
|
||||
it("nbf in the future is rejected", function()
|
||||
local tok = assert(M.encode(
|
||||
{ sub = 'test', exp = os.time() + 3600, nbf = os.time() + 9999 },
|
||||
'secret', 'HS256'
|
||||
))
|
||||
local body, err = M.verify(tok, 'HS256', 'secret')
|
||||
assert.is_nil(body)
|
||||
assert.truthy(err:match('nbf'))
|
||||
end)
|
||||
|
||||
it("alg mismatch between header and expectedAlgo is rejected", function()
|
||||
local tok = assert(M.encode(payload(), 'secret', 'HS256'))
|
||||
local body, err = M.verify(tok, 'HS512', 'secret')
|
||||
assert.is_nil(body)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("issuer", function()
|
||||
it("accepted issuer passes", function()
|
||||
local tok = assert(M.encode(payload({ iss = 'jitsi' }), 'secret', 'HS256'))
|
||||
local body = assert(M.verify(tok, 'HS256', 'secret', { 'jitsi' }))
|
||||
assert.equal('jitsi', body.iss)
|
||||
end)
|
||||
|
||||
it("wrong issuer is rejected", function()
|
||||
local tok = assert(M.encode(payload({ iss = 'evil' }), 'secret', 'HS256'))
|
||||
local body, err = M.verify(tok, 'HS256', 'secret', { 'jitsi' })
|
||||
assert.is_nil(body)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
|
||||
it("missing iss claim when acceptedIssuers provided is rejected", function()
|
||||
local tok = assert(M.encode(payload(), 'secret', 'HS256'))
|
||||
local body, err = M.verify(tok, 'HS256', 'secret', { 'jitsi' })
|
||||
assert.is_nil(body)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
|
||||
it("wildcard matches any value", function()
|
||||
local tok = assert(M.encode(payload({ iss = 'anyone' }), 'secret', 'HS256'))
|
||||
local body = assert(M.verify(tok, 'HS256', 'secret', { '*' }))
|
||||
assert.equal('anyone', body.iss)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("audience", function()
|
||||
it("accepted audience passes", function()
|
||||
local tok = assert(M.encode(payload({ aud = 'jitsi' }), 'secret', 'HS256'))
|
||||
local body = assert(M.verify(tok, 'HS256', 'secret', nil, { 'jitsi' }))
|
||||
assert.equal('jitsi', body.aud)
|
||||
end)
|
||||
|
||||
it("wrong audience is rejected", function()
|
||||
local tok = assert(M.encode(payload({ aud = 'wrong' }), 'secret', 'HS256'))
|
||||
local body, err = M.verify(tok, 'HS256', 'secret', nil, { 'jitsi' })
|
||||
assert.is_nil(body)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
|
||||
it("missing aud claim when acceptedAudiences provided is rejected", function()
|
||||
local tok = assert(M.encode(payload(), 'secret', 'HS256'))
|
||||
local body, err = M.verify(tok, 'HS256', 'secret', nil, { 'jitsi' })
|
||||
assert.is_nil(body)
|
||||
assert.is_string(err)
|
||||
end)
|
||||
|
||||
it("wildcard matches any value", function()
|
||||
local tok = assert(M.encode(payload({ aud = 'anything' }), 'secret', 'HS256'))
|
||||
local body = assert(M.verify(tok, 'HS256', 'secret', nil, { '*' }))
|
||||
assert.equal('anything', body.aud)
|
||||
end)
|
||||
end)
|
||||
|
||||
end) -- verify
|
||||
|
||||
end) -- luajwtjitsi
|
||||
160
tests/prosody/lua/mod_certs_s2soutinjection_spec.lua
Normal file
160
tests/prosody/lua/mod_certs_s2soutinjection_spec.lua
Normal file
@@ -0,0 +1,160 @@
|
||||
-- Unit tests for mod_certs_s2soutinjection.lua
|
||||
-- Run with busted from resources/prosody-plugins/:
|
||||
-- busted spec/lua/
|
||||
--
|
||||
-- No Prosody installation required — all dependencies are stubbed.
|
||||
-- The module exposes a global `attach` function that is called directly.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Helper: load the module with given option values
|
||||
--
|
||||
-- Returns attach, wrapped_events table so callers can inspect both.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function load_module(options)
|
||||
options = options or {}
|
||||
|
||||
local wrapped_events = {}
|
||||
|
||||
local mod = {
|
||||
set_global = function() end,
|
||||
get_option = function(_, key) return options[key] end,
|
||||
wrap_event = function(_, event_name, handler)
|
||||
wrapped_events[event_name] = handler
|
||||
end,
|
||||
log = function() end,
|
||||
}
|
||||
|
||||
_G.module = mod
|
||||
_G.attach = nil -- reset from any previous load
|
||||
|
||||
local ok, err = pcall(dofile, "mod_certs_s2soutinjection.lua")
|
||||
assert(ok, "module load failed: " .. tostring(err))
|
||||
|
||||
return _G.attach, wrapped_events
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Tests
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
describe("mod_certs_s2soutinjection", function()
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("attach()", function()
|
||||
|
||||
it("marks cert valid when host is in s2s_connect_overrides", function()
|
||||
local attach = load_module({
|
||||
s2s_connect_overrides = { ["bridge.example.com"] = "10.0.0.1:5269" },
|
||||
})
|
||||
|
||||
local session = {}
|
||||
local result = attach({ session = session, host = "bridge.example.com" })
|
||||
|
||||
assert.equal("valid", session.cert_chain_status)
|
||||
assert.equal("valid", session.cert_identity_status)
|
||||
assert.is_true(result)
|
||||
end)
|
||||
|
||||
it("does not touch cert status for host not in overrides", function()
|
||||
local attach = load_module({
|
||||
s2s_connect_overrides = { ["bridge.example.com"] = "10.0.0.1:5269" },
|
||||
})
|
||||
|
||||
local session = {}
|
||||
local result = attach({ session = session, host = "other.example.com" })
|
||||
|
||||
assert.is_nil(session.cert_chain_status)
|
||||
assert.is_nil(session.cert_identity_status)
|
||||
assert.is_falsy(result)
|
||||
end)
|
||||
|
||||
it("returns falsy and does not error when no overrides configured", function()
|
||||
local attach = load_module({})
|
||||
|
||||
local session = {}
|
||||
local result = attach({ session = session, host = "bridge.example.com" })
|
||||
|
||||
assert.is_nil(session.cert_chain_status)
|
||||
assert.is_falsy(result)
|
||||
end)
|
||||
|
||||
it("falls back to s2sout_override when s2s_connect_overrides is absent", function()
|
||||
local attach = load_module({
|
||||
s2sout_override = { ["bridge.example.com"] = "10.0.0.1:5269" },
|
||||
})
|
||||
|
||||
local session = {}
|
||||
local result = attach({ session = session, host = "bridge.example.com" })
|
||||
|
||||
assert.equal("valid", session.cert_chain_status)
|
||||
assert.equal("valid", session.cert_identity_status)
|
||||
assert.is_true(result)
|
||||
end)
|
||||
|
||||
it("prefers s2s_connect_overrides — s2sout_override hosts are ignored", function()
|
||||
local attach = load_module({
|
||||
s2s_connect_overrides = { ["bridge.example.com"] = "10.0.0.1:5269" },
|
||||
s2sout_override = { ["legacy.example.com"] = "10.0.0.2:5269" },
|
||||
})
|
||||
|
||||
-- host only in s2s_connect_overrides → valid
|
||||
local s1 = {}
|
||||
attach({ session = s1, host = "bridge.example.com" })
|
||||
assert.equal("valid", s1.cert_chain_status)
|
||||
|
||||
-- host only in s2sout_override (legacy, ignored when primary present) → not set
|
||||
local s2 = {}
|
||||
attach({ session = s2, host = "legacy.example.com" })
|
||||
assert.is_nil(s2.cert_chain_status)
|
||||
end)
|
||||
|
||||
it("handles multiple hosts in override map independently", function()
|
||||
local attach = load_module({
|
||||
s2s_connect_overrides = {
|
||||
["bridge1.example.com"] = "10.0.0.1:5269",
|
||||
["bridge2.example.com"] = "10.0.0.2:5269",
|
||||
},
|
||||
})
|
||||
|
||||
local s1 = {}
|
||||
attach({ session = s1, host = "bridge1.example.com" })
|
||||
assert.equal("valid", s1.cert_chain_status)
|
||||
|
||||
local s2 = {}
|
||||
attach({ session = s2, host = "bridge2.example.com" })
|
||||
assert.equal("valid", s2.cert_chain_status)
|
||||
|
||||
local s3 = {}
|
||||
attach({ session = s3, host = "unlisted.example.com" })
|
||||
assert.is_nil(s3.cert_chain_status)
|
||||
end)
|
||||
|
||||
end) -- attach()
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("event wrapping", function()
|
||||
|
||||
it("registers handler on s2s-check-certificate event", function()
|
||||
local _, wrapped = load_module({})
|
||||
assert.is_function(wrapped["s2s-check-certificate"])
|
||||
end)
|
||||
|
||||
it("wrapped handler delegates to attach and returns its result", function()
|
||||
local _, wrapped = load_module({
|
||||
s2s_connect_overrides = { ["bridge.example.com"] = "10.0.0.1" },
|
||||
})
|
||||
|
||||
local session = {}
|
||||
local handler = wrapped["s2s-check-certificate"]
|
||||
-- handler signature: (handlers, event_name, event_data)
|
||||
local result = handler(nil, "s2s-check-certificate",
|
||||
{ session = session, host = "bridge.example.com" })
|
||||
|
||||
assert.equal("valid", session.cert_chain_status)
|
||||
assert.is_true(result)
|
||||
end)
|
||||
|
||||
end) -- event wrapping
|
||||
|
||||
end)
|
||||
251
tests/prosody/lua/mod_debug_traceback_spec.lua
Normal file
251
tests/prosody/lua/mod_debug_traceback_spec.lua
Normal file
@@ -0,0 +1,251 @@
|
||||
-- Unit tests for mod_debug_traceback.lua
|
||||
-- Run with busted from resources/prosody-plugins/:
|
||||
-- busted spec/lua/
|
||||
--
|
||||
-- No Prosody installation required — all dependencies are stubbed.
|
||||
-- The module exposes a global `dump_traceback` function that is called directly.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Package preloads (must be registered before any dofile() call)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
package.preload['util.debug'] = function()
|
||||
return { traceback = function() return "fake traceback\nframe 1\nframe 2" end }
|
||||
end
|
||||
|
||||
package.preload['util.pposix'] = function()
|
||||
return { getpid = function() return 99 end }
|
||||
end
|
||||
|
||||
-- Minimal interpolation stub: substitutes {key} and {key.subkey} tokens.
|
||||
-- Filters (yyyymmdd, hhmmss) are invoked when the key matches.
|
||||
package.preload['util.interpolation'] = function()
|
||||
return {
|
||||
new = function(_, _, filters)
|
||||
return function(template, vars)
|
||||
return (template:gsub('{([^}]+)}', function(key)
|
||||
-- Try filters first (yyyymmdd, hhmmss, ...)
|
||||
if filters and filters[key] and vars.time then
|
||||
return tostring(filters[key](vars.time))
|
||||
end
|
||||
-- Nested key lookup (e.g. paths.data)
|
||||
local val = vars
|
||||
for part in key:gmatch('[^%.]+') do
|
||||
if type(val) == 'table' then val = val[part]
|
||||
else val = nil; break end
|
||||
end
|
||||
return val ~= nil and tostring(val) or ('{' .. key .. '}')
|
||||
end))
|
||||
end
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
-- util.signal is required when mod_posix lacks signal_events support
|
||||
package.preload['util.signal'] = function()
|
||||
return { signal = function() end }
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Prosody global required by get_filename (prosody.paths.data)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
_G.prosody = { paths = { data = "/tmp/prosody-test" } }
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Helper: load module with given option overrides
|
||||
--
|
||||
-- Returns: dump_traceback fn, log_calls list, fired_events list
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function load_module(options)
|
||||
options = options or {}
|
||||
local log_calls = {}
|
||||
local fired_events = {}
|
||||
|
||||
local mod = {
|
||||
set_global = function() end,
|
||||
get_option_string = function(_, key, default)
|
||||
local v = options[key]
|
||||
return v ~= nil and v or default
|
||||
end,
|
||||
log = function(_, level, fmt, ...)
|
||||
local ok, msg = pcall(string.format, fmt, ...)
|
||||
table.insert(log_calls, { level = level, msg = ok and msg or fmt })
|
||||
end,
|
||||
fire_event = function(_, name, data)
|
||||
table.insert(fired_events, { name = name, data = data })
|
||||
end,
|
||||
depends = function() return {} end, -- no features.signal_events → util.signal path
|
||||
hook = function() end,
|
||||
wrap_event = function() end,
|
||||
}
|
||||
|
||||
_G.module = mod
|
||||
_G.dump_traceback = nil
|
||||
|
||||
local ok, err = pcall(dofile, "mod_debug_traceback.lua")
|
||||
assert(ok, "module load failed: " .. tostring(err))
|
||||
|
||||
return _G.dump_traceback, log_calls, fired_events
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- io.open helpers
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Replace io.open for the duration of fn(); collect written content per path.
|
||||
local function with_mock_io(files_written, fn)
|
||||
local orig = io.open
|
||||
io.open = function(path, _mode)
|
||||
local buf = {}
|
||||
files_written[path] = buf
|
||||
return {
|
||||
write = function(_, s) buf[#buf + 1] = s end,
|
||||
close = function() end,
|
||||
}
|
||||
end
|
||||
fn()
|
||||
io.open = orig
|
||||
end
|
||||
|
||||
-- Replace io.open with one that always fails.
|
||||
local function with_failing_io(fn)
|
||||
local orig = io.open
|
||||
io.open = function() return nil, "permission denied" end
|
||||
fn()
|
||||
io.open = orig
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Tests
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
describe("mod_debug_traceback", function()
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("dump_traceback()", function()
|
||||
|
||||
it("writes traceback content to file", function()
|
||||
local dump = load_module()
|
||||
local files = {}
|
||||
with_mock_io(files, function() dump() end)
|
||||
|
||||
local path = next(files)
|
||||
local content = table.concat(files[path])
|
||||
assert.is_truthy(content:find("fake traceback", 1, true))
|
||||
end)
|
||||
|
||||
it("surrounds traceback with header and footer lines", function()
|
||||
local dump = load_module()
|
||||
local files = {}
|
||||
with_mock_io(files, function() dump() end)
|
||||
|
||||
local content = table.concat(files[next(files)])
|
||||
assert.is_truthy(content:find("-- Traceback generated at", 1, true))
|
||||
assert.is_truthy(content:find("-- End of traceback --", 1, true))
|
||||
end)
|
||||
|
||||
it("fires debug_traceback/triggered event with a traceback string", function()
|
||||
local dump, _, events = load_module()
|
||||
local files = {}
|
||||
with_mock_io(files, function() dump() end)
|
||||
|
||||
assert.equal(1, #events)
|
||||
assert.equal("debug_traceback/triggered", events[1].name)
|
||||
assert.is_string(events[1].data.traceback)
|
||||
end)
|
||||
|
||||
it("fires event before attempting file write (even on io failure)", function()
|
||||
local dump, _, events = load_module()
|
||||
with_failing_io(function() dump() end)
|
||||
|
||||
assert.equal(1, #events)
|
||||
assert.equal("debug_traceback/triggered", events[1].name)
|
||||
end)
|
||||
|
||||
it("logs an error when the file cannot be opened", function()
|
||||
local dump, log_calls = load_module()
|
||||
with_failing_io(function() dump() end)
|
||||
|
||||
local found = false
|
||||
for _, e in ipairs(log_calls) do
|
||||
if e.level == "error" and e.msg:find("permission denied", 1, true) then
|
||||
found = true
|
||||
end
|
||||
end
|
||||
assert.is_true(found, "expected error log mentioning 'permission denied'")
|
||||
end)
|
||||
|
||||
it("does not write a file when io.open fails", function()
|
||||
local dump = load_module()
|
||||
local files = {}
|
||||
with_failing_io(function() dump() end)
|
||||
-- files table should remain empty (no successful open)
|
||||
assert.is_nil(next(files))
|
||||
end)
|
||||
|
||||
it("default filename contains pid and starts with data path", function()
|
||||
local dump = load_module()
|
||||
local files = {}
|
||||
with_mock_io(files, function() dump() end)
|
||||
|
||||
local path = next(files)
|
||||
assert.is_truthy(path:find("/tmp/prosody-test", 1, true))
|
||||
assert.is_truthy(path:find("99", 1, true)) -- pid = 99
|
||||
end)
|
||||
|
||||
it("default filename embeds count (starting at 0)", function()
|
||||
local dump = load_module()
|
||||
local files = {}
|
||||
with_mock_io(files, function() dump() end)
|
||||
|
||||
local path = next(files)
|
||||
-- default template: traceback-{pid}-{count}.log → count=0 on first call
|
||||
assert.is_truthy(path:find("%-0%.log$"))
|
||||
end)
|
||||
|
||||
it("increments count between calls producing distinct filenames", function()
|
||||
local dump = load_module()
|
||||
local paths = {}
|
||||
local orig = io.open
|
||||
io.open = function(path, _)
|
||||
paths[#paths + 1] = path
|
||||
return { write = function() end, close = function() end }
|
||||
end
|
||||
dump()
|
||||
dump()
|
||||
io.open = orig
|
||||
|
||||
assert.equal(2, #paths)
|
||||
assert.not_equal(paths[1], paths[2])
|
||||
end)
|
||||
|
||||
it("uses custom signal name in info log message", function()
|
||||
local dump, log_calls = load_module({ debug_traceback_signal = "SIGUSR2" })
|
||||
local files = {}
|
||||
with_mock_io(files, function() dump() end)
|
||||
|
||||
local found = false
|
||||
for _, e in ipairs(log_calls) do
|
||||
if e.level == "info" and e.msg:find("SIGUSR2", 1, true) then
|
||||
found = true
|
||||
end
|
||||
end
|
||||
assert.is_true(found, "expected info log mentioning 'SIGUSR2'")
|
||||
end)
|
||||
|
||||
it("uses custom filename template from config", function()
|
||||
local dump = load_module({
|
||||
debug_traceback_filename = "/custom/tb-{pid}.log",
|
||||
})
|
||||
local files = {}
|
||||
with_mock_io(files, function() dump() end)
|
||||
|
||||
local path = next(files)
|
||||
assert.equal("/custom/tb-99.log", path)
|
||||
end)
|
||||
|
||||
end) -- dump_traceback()
|
||||
|
||||
end)
|
||||
273
tests/prosody/lua/mod_jibri_session_spec.lua
Normal file
273
tests/prosody/lua/mod_jibri_session_spec.lua
Normal file
@@ -0,0 +1,273 @@
|
||||
-- Unit tests for mod_jibri_session.lua
|
||||
-- Run with busted from resources/prosody-plugins/:
|
||||
-- busted spec/lua/
|
||||
--
|
||||
-- Stubs every Prosody dependency so no Prosody installation is needed.
|
||||
-- The module is loaded once; the hooked attachJibriSessionId function is
|
||||
-- extracted and called directly in each test.
|
||||
|
||||
-- cjson is required by the module under test — skip gracefully if absent.
|
||||
local ok_json, json = pcall(require, 'cjson')
|
||||
if not ok_json then
|
||||
describe("mod_jibri_session", function()
|
||||
it("skipped — lua-cjson not installed", function()
|
||||
pending("lua-cjson is required: luarocks install lua-cjson")
|
||||
end)
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Shared mutable mock state — reset in before_each
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local mock = {
|
||||
room_from_jid = nil, -- returned by get_room_from_jid; nil = "not found"
|
||||
rewrite_jid = nil, -- if set, room_jid_match_rewrite returns this; else identity
|
||||
last_room_jid = nil, -- records the jid passed to get_room_from_jid
|
||||
}
|
||||
|
||||
local util_stub = {
|
||||
room_jid_match_rewrite = function(jid)
|
||||
return mock.rewrite_jid or jid
|
||||
end,
|
||||
get_room_from_jid = function(jid)
|
||||
mock.last_room_jid = jid
|
||||
return mock.room_from_jid
|
||||
end,
|
||||
}
|
||||
|
||||
-- Capture the hook function installed by the module at load time.
|
||||
local hooked_fn = nil
|
||||
|
||||
local mod_stub = { host = 'test.example.com' }
|
||||
function mod_stub:require(name) -- luacheck: ignore 212
|
||||
if name == 'util' then return util_stub end
|
||||
return {}
|
||||
end
|
||||
function mod_stub:hook(event, fn) -- luacheck: ignore 212
|
||||
if event == 'pre-iq/full' then hooked_fn = fn end
|
||||
end
|
||||
function mod_stub:log() end -- luacheck: ignore 212
|
||||
|
||||
_G.module = mod_stub
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Load the module under test
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local ok_mod, load_err = pcall(dofile, 'mod_jibri_session.lua')
|
||||
if not ok_mod then
|
||||
describe("mod_jibri_session", function()
|
||||
it("skipped — failed to load module", function()
|
||||
pending(tostring(load_err):match("([^\n]+)") or tostring(load_err))
|
||||
end)
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
assert(hooked_fn, "mod_jibri_session did not hook 'pre-iq/full'")
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Test helpers
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local JIBRI_NS = 'http://jitsi.org/protocol/jibri'
|
||||
|
||||
local function make_jibri_elem(attrs)
|
||||
local e = { name = 'jibri', attr = attrs or {} }
|
||||
function e:up() return self end
|
||||
return e
|
||||
end
|
||||
|
||||
local function make_iq(jibri_elem)
|
||||
local s = { name = 'iq', attr = {} }
|
||||
function s:get_child(tag, ns)
|
||||
if tag == 'jibri' and ns == JIBRI_NS then return jibri_elem end
|
||||
return nil
|
||||
end
|
||||
function s:up() return self end
|
||||
return s
|
||||
end
|
||||
|
||||
local function make_event(stanza, origin)
|
||||
return { stanza = stanza, origin = origin }
|
||||
end
|
||||
|
||||
local function decoded_app_data(jibri_elem)
|
||||
return json.decode(jibri_elem.attr.app_data)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Tests
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
describe("mod_jibri_session", function()
|
||||
|
||||
before_each(function()
|
||||
mock.room_from_jid = nil
|
||||
mock.rewrite_jid = nil
|
||||
mock.last_room_jid = nil
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("non-matching stanzas pass through unchanged", function()
|
||||
|
||||
it("ignores a non-IQ stanza", function()
|
||||
local s = { name = 'message', attr = {} }
|
||||
function s:get_child() return nil end
|
||||
assert.has_no.errors(function() hooked_fn(make_event(s)) end)
|
||||
end)
|
||||
|
||||
it("ignores an IQ with no jibri child", function()
|
||||
local s = { name = 'iq', attr = {} }
|
||||
function s:get_child() return nil end
|
||||
assert.has_no.errors(function() hooked_fn(make_event(s)) end)
|
||||
end)
|
||||
|
||||
it("ignores a jibri IQ with action 'stop'", function()
|
||||
local jibri = make_jibri_elem({ action = 'stop' })
|
||||
hooked_fn(make_event(make_iq(jibri)))
|
||||
assert.is_nil(jibri.attr.app_data)
|
||||
end)
|
||||
|
||||
it("ignores a jibri IQ with no action attribute", function()
|
||||
local jibri = make_jibri_elem({})
|
||||
hooked_fn(make_event(make_iq(jibri)))
|
||||
assert.is_nil(jibri.attr.app_data)
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
-- Client → jicofo pass: no `room` attribute on the jibri element.
|
||||
-- The module reads initiator identity from the XMPP session (event.origin).
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("client-to-jicofo pass (no room attr on jibri element)", function()
|
||||
|
||||
it("sets initiator.id from jitsi_meet_context_user.id", function()
|
||||
local jibri = make_jibri_elem({ action = 'start' })
|
||||
local session = { jitsi_meet_context_user = { id = 'user-abc' } }
|
||||
hooked_fn(make_event(make_iq(jibri), session))
|
||||
assert.equal('user-abc', decoded_app_data(jibri).file_recording_metadata.initiator.id)
|
||||
end)
|
||||
|
||||
it("falls back to granted_jitsi_meet_context_user_id when context_user is nil", function()
|
||||
local jibri = make_jibri_elem({ action = 'start' })
|
||||
local session = { granted_jitsi_meet_context_user_id = 'granted-id-xyz' }
|
||||
hooked_fn(make_event(make_iq(jibri), session))
|
||||
assert.equal('granted-id-xyz', decoded_app_data(jibri).file_recording_metadata.initiator.id)
|
||||
end)
|
||||
|
||||
it("sets initiator.group from jitsi_meet_context_group", function()
|
||||
local jibri = make_jibri_elem({ action = 'start' })
|
||||
local session = {
|
||||
jitsi_meet_context_user = { id = 'user-1' },
|
||||
jitsi_meet_context_group = 'group-A',
|
||||
}
|
||||
hooked_fn(make_event(make_iq(jibri), session))
|
||||
assert.equal('group-A', decoded_app_data(jibri).file_recording_metadata.initiator.group)
|
||||
end)
|
||||
|
||||
it("falls back to granted_jitsi_meet_context_group_id when context_group is nil", function()
|
||||
local jibri = make_jibri_elem({ action = 'start' })
|
||||
local session = {
|
||||
jitsi_meet_context_user = { id = 'user-1' },
|
||||
granted_jitsi_meet_context_group_id = 'granted-group-B',
|
||||
}
|
||||
hooked_fn(make_event(make_iq(jibri), session))
|
||||
assert.equal('granted-group-B', decoded_app_data(jibri).file_recording_metadata.initiator.group)
|
||||
end)
|
||||
|
||||
it("does not modify app_data when origin (session) is nil", function()
|
||||
local jibri = make_jibri_elem({ action = 'start' })
|
||||
hooked_fn(make_event(make_iq(jibri), nil))
|
||||
assert.is_nil(jibri.attr.app_data)
|
||||
end)
|
||||
|
||||
it("preserves pre-existing top-level app_data keys", function()
|
||||
local existing = json.encode({ custom_key = 'custom_value' })
|
||||
local jibri = make_jibri_elem({ action = 'start', app_data = existing })
|
||||
local session = { jitsi_meet_context_user = { id = 'user-1' } }
|
||||
hooked_fn(make_event(make_iq(jibri), session))
|
||||
local data = decoded_app_data(jibri)
|
||||
assert.equal('custom_value', data.custom_key)
|
||||
assert.equal('user-1', data.file_recording_metadata.initiator.id)
|
||||
end)
|
||||
|
||||
it("preserves pre-existing file_recording_metadata fields", function()
|
||||
local existing = json.encode({
|
||||
file_recording_metadata = { existing_field = 'existing_value' }
|
||||
})
|
||||
local jibri = make_jibri_elem({ action = 'start', app_data = existing })
|
||||
local session = { jitsi_meet_context_user = { id = 'user-1' } }
|
||||
hooked_fn(make_event(make_iq(jibri), session))
|
||||
local data = decoded_app_data(jibri)
|
||||
assert.equal('existing_value', data.file_recording_metadata.existing_field)
|
||||
assert.equal('user-1', data.file_recording_metadata.initiator.id)
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
-- Jicofo → jibri pass: `room` attribute present on the jibri element.
|
||||
-- The module looks up the room and attaches its meetingId as session_id.
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("jicofo-to-jibri pass (room attr present on jibri element)", function()
|
||||
|
||||
it("sets conference_details.session_id from room meetingId", function()
|
||||
mock.room_from_jid = { _data = { meetingId = 'meeting-123' } }
|
||||
local jibri = make_jibri_elem({ action = 'start', room = 'room@conference.example.com' })
|
||||
hooked_fn(make_event(make_iq(jibri)))
|
||||
assert.equal('meeting-123',
|
||||
decoded_app_data(jibri).file_recording_metadata.conference_details.session_id)
|
||||
end)
|
||||
|
||||
it("does not modify app_data when room is not found", function()
|
||||
mock.room_from_jid = nil
|
||||
local jibri = make_jibri_elem({ action = 'start', room = 'missing@conference.example.com' })
|
||||
hooked_fn(make_event(make_iq(jibri)))
|
||||
assert.is_nil(jibri.attr.app_data)
|
||||
end)
|
||||
|
||||
it("preserves pre-existing top-level app_data keys", function()
|
||||
mock.room_from_jid = { _data = { meetingId = 'meeting-456' } }
|
||||
local existing = json.encode({ custom_key = 'custom_value' })
|
||||
local jibri = make_jibri_elem({
|
||||
action = 'start',
|
||||
room = 'room@conference.example.com',
|
||||
app_data = existing,
|
||||
})
|
||||
hooked_fn(make_event(make_iq(jibri)))
|
||||
local data = decoded_app_data(jibri)
|
||||
assert.equal('custom_value', data.custom_key)
|
||||
assert.equal('meeting-456', data.file_recording_metadata.conference_details.session_id)
|
||||
end)
|
||||
|
||||
it("passes the room JID through room_jid_match_rewrite before lookup", function()
|
||||
mock.rewrite_jid = 'rewritten@conference.example.com'
|
||||
mock.room_from_jid = { _data = { meetingId = 'meeting-789' } }
|
||||
local jibri = make_jibri_elem({
|
||||
action = 'start',
|
||||
room = 'original@conference.sub.example.com',
|
||||
})
|
||||
hooked_fn(make_event(make_iq(jibri)))
|
||||
assert.equal('rewritten@conference.example.com', mock.last_room_jid)
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("malformed app_data", function()
|
||||
|
||||
it("silently ignores invalid JSON in app_data (cjson.safe: returns nil, no throw)", function()
|
||||
local raw = '{not valid json{{'
|
||||
local jibri = make_jibri_elem({ action = 'start', app_data = raw })
|
||||
local session = { jitsi_meet_context_user = { id = 'user-1' } }
|
||||
assert.has_no.errors(function() hooked_fn(make_event(make_iq(jibri), session)) end)
|
||||
assert.equal(raw, jibri.attr.app_data) -- unchanged: module returned early
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
end) -- mod_jibri_session
|
||||
308
tests/prosody/lua/mod_muc_domain_mapper_spec.lua
Normal file
308
tests/prosody/lua/mod_muc_domain_mapper_spec.lua
Normal file
@@ -0,0 +1,308 @@
|
||||
-- Unit tests for mod_muc_domain_mapper.lua
|
||||
-- Run with busted from resources/prosody-plugins/:
|
||||
-- busted spec/lua/
|
||||
--
|
||||
-- Stubs every Prosody dependency so no Prosody installation is needed.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Stanza / session helpers
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function make_stanza(name, attrs, children)
|
||||
local s = { name = name, attr = attrs or {}, _children = children or {} }
|
||||
function s:get_child(cname, _ns)
|
||||
for _, c in ipairs(self._children) do
|
||||
if c.name == cname then return c end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
return s
|
||||
end
|
||||
|
||||
local function make_session(session_type)
|
||||
local logs = {}
|
||||
return {
|
||||
type = session_type or "c2s",
|
||||
log_calls = logs,
|
||||
-- session.log is called as session.log(level, msg, ...) (not method syntax)
|
||||
log = function(level, _msg, ...)
|
||||
table.insert(logs, level)
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Call-tracking rewrite stubs (reset before each test via clear helpers)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local rewrite_to_calls = {}
|
||||
local rewrite_from_calls = {}
|
||||
|
||||
local function clear_calls()
|
||||
for i = #rewrite_to_calls, 1, -1 do rewrite_to_calls[i] = nil end
|
||||
for i = #rewrite_from_calls, 1, -1 do rewrite_from_calls[i] = nil end
|
||||
end
|
||||
|
||||
local mock_util = {
|
||||
room_jid_match_rewrite = function(jid, _stanza)
|
||||
table.insert(rewrite_to_calls, jid)
|
||||
return "MAPPED:" .. (jid or "nil")
|
||||
end,
|
||||
internal_room_jid_match_rewrite = function(jid, _stanza)
|
||||
table.insert(rewrite_from_calls, jid)
|
||||
return "UNMAPPED:" .. (jid or "nil")
|
||||
end,
|
||||
}
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Package preloads — must be registered before dofile()
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
package.preload['util.filters'] = function()
|
||||
return {
|
||||
add_filter_hook = function(_fn) end,
|
||||
remove_filter_hook = function(_fn) end,
|
||||
add_filter = function(_session, _type, _fn) end,
|
||||
}
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Global stubs required by the module at load time
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
_G.prosody = {
|
||||
events = { add_handler = function() end },
|
||||
hosts = {},
|
||||
}
|
||||
|
||||
_G.module = {
|
||||
host = "localhost",
|
||||
log = function() end,
|
||||
get_option_string = function(_, key, default)
|
||||
if key == "muc_mapper_domain_base" then return "localhost" end
|
||||
return default
|
||||
end,
|
||||
get_option_boolean = function(_, key, default)
|
||||
if key == "muc_mapper_log_not_allowed_errors" then return true end
|
||||
return default
|
||||
end,
|
||||
require = function(_, name)
|
||||
if name == "util" then return mock_util end
|
||||
return nil
|
||||
end,
|
||||
context = function(_, _host)
|
||||
return { hook = function() end }
|
||||
end,
|
||||
}
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Load the module under test
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local ok, err = pcall(dofile, "mod_muc_domain_mapper.lua")
|
||||
if not ok then
|
||||
describe("mod_muc_domain_mapper", function()
|
||||
it("skipped — failed to load", function()
|
||||
pending(tostring(err):match("([^\n]+)") or tostring(err))
|
||||
end)
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
local filter_stanza = _G.filter_stanza
|
||||
assert(filter_stanza, "filter_stanza must be a global after dofile()")
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Tests
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
describe("mod_muc_domain_mapper", function()
|
||||
|
||||
before_each(clear_calls)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("filter_stanza", function()
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
describe("message stanzas", function()
|
||||
|
||||
it("rewrites 'to' via room_jid_match_rewrite", function()
|
||||
local stanza = make_stanza("message", { to = "room@conference.sub.example.com" })
|
||||
local session = make_session("c2s")
|
||||
|
||||
local result = filter_stanza(stanza, session)
|
||||
|
||||
assert.equal(1, #rewrite_to_calls)
|
||||
assert.equal("room@conference.sub.example.com", rewrite_to_calls[1])
|
||||
assert.equal("MAPPED:room@conference.sub.example.com", result.attr.to)
|
||||
end)
|
||||
|
||||
it("rewrites 'from' via internal_room_jid_match_rewrite", function()
|
||||
local stanza = make_stanza("message", { from = "[sub]room@conference.example.com/nick" })
|
||||
local session = make_session("c2s")
|
||||
|
||||
local result = filter_stanza(stanza, session)
|
||||
|
||||
assert.equal(1, #rewrite_from_calls)
|
||||
assert.equal("[sub]room@conference.example.com/nick", rewrite_from_calls[1])
|
||||
assert.equal("UNMAPPED:[sub]room@conference.example.com/nick", result.attr.from)
|
||||
end)
|
||||
|
||||
it("rewrites both 'to' and 'from' when both are set", function()
|
||||
local stanza = make_stanza("message", {
|
||||
to = "room@conference.sub.example.com",
|
||||
from = "[sub]room@conference.example.com/nick",
|
||||
})
|
||||
filter_stanza(stanza, make_session("c2s"))
|
||||
|
||||
assert.equal(1, #rewrite_to_calls)
|
||||
assert.equal(1, #rewrite_from_calls)
|
||||
end)
|
||||
|
||||
it("does not call rewrites when 'to' and 'from' are absent", function()
|
||||
local stanza = make_stanza("message", {})
|
||||
filter_stanza(stanza, make_session("c2s"))
|
||||
|
||||
assert.equal(0, #rewrite_to_calls)
|
||||
assert.equal(0, #rewrite_from_calls)
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
describe("presence stanzas", function()
|
||||
|
||||
it("rewrites 'to' and 'from'", function()
|
||||
local stanza = make_stanza("presence", {
|
||||
to = "room@conference.sub.example.com/nick",
|
||||
from = "[sub]room@conference.example.com/nick",
|
||||
})
|
||||
filter_stanza(stanza, make_session("c2s"))
|
||||
|
||||
assert.equal(1, #rewrite_to_calls)
|
||||
assert.equal(1, #rewrite_from_calls)
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
describe("iq stanzas", function()
|
||||
|
||||
it("rewrites 'to' and 'from'", function()
|
||||
local stanza = make_stanza("iq", {
|
||||
to = "room@conference.sub.example.com",
|
||||
from = "[sub]room@conference.example.com",
|
||||
})
|
||||
filter_stanza(stanza, make_session("c2s"))
|
||||
|
||||
assert.equal(1, #rewrite_to_calls)
|
||||
assert.equal(1, #rewrite_from_calls)
|
||||
end)
|
||||
|
||||
it("rewrites <conference room> attribute when child is present", function()
|
||||
local conf = make_stanza("conference", { room = "room@conference.sub.example.com" })
|
||||
local stanza = make_stanza("iq", { to = "focus@example.com" }, { conf })
|
||||
filter_stanza(stanza, make_session("c2s"))
|
||||
|
||||
-- room attr on the child gets rewritten
|
||||
assert.equal("MAPPED:room@conference.sub.example.com", conf.attr.room)
|
||||
-- 'to' also gets rewritten (focus@example.com passes through the mock)
|
||||
assert.equal(2, #rewrite_to_calls)
|
||||
end)
|
||||
|
||||
it("does not touch stanzas with no <conference> child", function()
|
||||
local stanza = make_stanza("iq", { to = "focus@example.com" })
|
||||
filter_stanza(stanza, make_session("c2s"))
|
||||
|
||||
assert.equal(1, #rewrite_to_calls)
|
||||
assert.equal(0, #rewrite_from_calls)
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
describe("skipped stanzas", function()
|
||||
|
||||
it("passes stanza through unchanged when skipMapping is true", function()
|
||||
local stanza = make_stanza("message", { to = "room@conference.sub.example.com" })
|
||||
stanza.skipMapping = true
|
||||
filter_stanza(stanza, make_session("c2s"))
|
||||
|
||||
assert.equal(0, #rewrite_to_calls)
|
||||
assert.equal("room@conference.sub.example.com", stanza.attr.to)
|
||||
end)
|
||||
|
||||
it("passes stanza through unchanged for s2sout sessions", function()
|
||||
local stanza = make_stanza("message", { to = "room@conference.sub.example.com" })
|
||||
filter_stanza(stanza, make_session("s2sout"))
|
||||
|
||||
assert.equal(0, #rewrite_to_calls)
|
||||
end)
|
||||
|
||||
it("passes non-message/iq/presence stanzas through unchanged", function()
|
||||
local stanza = make_stanza("stream:features", { to = "example.com" })
|
||||
filter_stanza(stanza, make_session("c2s"))
|
||||
|
||||
assert.equal(0, #rewrite_to_calls)
|
||||
assert.equal(0, #rewrite_from_calls)
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
describe("log_not_allowed_errors", function()
|
||||
|
||||
local function make_not_allowed_presence()
|
||||
local not_allowed = make_stanza("not-allowed",
|
||||
{ xmlns = "urn:ietf:params:xml:ns:xmpp-stanzas" })
|
||||
local error_el = make_stanza("error", { type = "cancel" }, { not_allowed })
|
||||
return make_stanza("presence", { type = "error" }, { error_el })
|
||||
end
|
||||
|
||||
it("logs a not-allowed presence error once per session", function()
|
||||
local stanza = make_not_allowed_presence()
|
||||
local session = make_session("c2s")
|
||||
|
||||
filter_stanza(stanza, session)
|
||||
|
||||
assert.equal(1, #session.log_calls)
|
||||
assert.equal("error", session.log_calls[1])
|
||||
assert.is_true(session.jitsi_not_allowed_logged)
|
||||
end)
|
||||
|
||||
it("does not log a second time on the same session", function()
|
||||
local stanza = make_not_allowed_presence()
|
||||
local session = make_session("c2s")
|
||||
|
||||
filter_stanza(stanza, session)
|
||||
filter_stanza(stanza, session)
|
||||
|
||||
assert.equal(1, #session.log_calls)
|
||||
end)
|
||||
|
||||
it("does not log for non-error presence", function()
|
||||
local stanza = make_stanza("presence", { to = "room@conference.sub.example.com" })
|
||||
local session = make_session("c2s")
|
||||
|
||||
filter_stanza(stanza, session)
|
||||
|
||||
assert.equal(0, #session.log_calls)
|
||||
end)
|
||||
|
||||
it("does not log when error type is not 'cancel'", function()
|
||||
local not_allowed = make_stanza("not-allowed",
|
||||
{ xmlns = "urn:ietf:params:xml:ns:xmpp-stanzas" })
|
||||
local error_el = make_stanza("error", { type = "auth" }, { not_allowed })
|
||||
local stanza = make_stanza("presence", { type = "error" }, { error_el })
|
||||
local session = make_session("c2s")
|
||||
|
||||
filter_stanza(stanza, session)
|
||||
|
||||
assert.equal(0, #session.log_calls)
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
end)
|
||||
1416
tests/prosody/lua/mod_muc_message_moderation_spec.lua
Normal file
1416
tests/prosody/lua/mod_muc_message_moderation_spec.lua
Normal file
File diff suppressed because it is too large
Load Diff
669
tests/prosody/lua/mod_short_lived_token_spec.lua
Normal file
669
tests/prosody/lua/mod_short_lived_token_spec.lua
Normal file
@@ -0,0 +1,669 @@
|
||||
-- Unit tests for mod_short_lived_token.lua
|
||||
-- Run with busted from resources/prosody-plugins/:
|
||||
-- busted ../../tests/prosody/lua/mod_short_lived_token_spec.lua
|
||||
--
|
||||
-- Stubs every Prosody dependency so no Prosody installation is needed.
|
||||
-- Uses a controllable jwt stub to capture payload fields without requiring
|
||||
-- the openssl library.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Package preloads — Prosody dependencies
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
package.preload['util.jid'] = function()
|
||||
local function split(j)
|
||||
if not j then return nil, nil, nil end
|
||||
local node, host, resource
|
||||
local at = j:find('@')
|
||||
if at then
|
||||
local slash = j:find('/', at + 1)
|
||||
node = j:sub(1, at - 1)
|
||||
if slash then
|
||||
host = j:sub(at + 1, slash - 1)
|
||||
resource = j:sub(slash + 1)
|
||||
else
|
||||
host = j:sub(at + 1)
|
||||
end
|
||||
else
|
||||
local slash = j:find('/')
|
||||
if slash then
|
||||
host = j:sub(1, slash - 1)
|
||||
resource = j:sub(slash + 1)
|
||||
else
|
||||
host = j
|
||||
end
|
||||
end
|
||||
node = (node and #node > 0) and node or nil
|
||||
host = (host and #host > 0) and host or nil
|
||||
resource = (resource and #resource > 0) and resource or nil
|
||||
return node, host, resource
|
||||
end
|
||||
return {
|
||||
split = split,
|
||||
node = function(j) local n = split(j); return n end,
|
||||
resource = function(j) local _, _, r = split(j); return r end,
|
||||
bare = function(j)
|
||||
local n, h = split(j)
|
||||
if n then return n .. '@' .. (h or '') end
|
||||
return h
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
package.preload['util.stanza'] = function()
|
||||
return {
|
||||
error_reply = function(stanza, etype, condition)
|
||||
return { is_error_reply = true, type = etype, condition = condition, original = stanza }
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
package.preload['util.throttle'] = function()
|
||||
return { create = function() return {} end }
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Helpers
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Single-pass iterator over a list — mimics Prosody's requested_credentials.
|
||||
local function iter(t)
|
||||
local i = 0
|
||||
return function()
|
||||
i = i + 1
|
||||
return t[i]
|
||||
end
|
||||
end
|
||||
|
||||
-- Minimal presence stanza stub.
|
||||
local function make_presence(nick, email)
|
||||
return {
|
||||
get_child_text = function(_, tag, _ns)
|
||||
if tag == 'nick' then return nick end
|
||||
if tag == 'email' then return email end
|
||||
return nil
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
-- Minimal occupant stub.
|
||||
local function make_occupant(full_jid, nick, presence_nick, presence_email)
|
||||
local p = make_presence(presence_nick, presence_email)
|
||||
return {
|
||||
nick = nick or ('conference.example.com/' .. (presence_nick or 'Alice')),
|
||||
get_presence = function(_, _jid) return p end,
|
||||
}
|
||||
end
|
||||
|
||||
-- Minimal room stub.
|
||||
local function make_room(jid, meeting_id, occupant_map)
|
||||
return {
|
||||
jid = jid,
|
||||
_data = { meetingId = meeting_id },
|
||||
get_occupant_by_real_jid = function(_, real_jid)
|
||||
if occupant_map then return occupant_map[real_jid] end
|
||||
return nil
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Controllable JWT stub.
|
||||
-- Exposes captured_payload and captured_key so tests can inspect them.
|
||||
-- Set .fail = true to simulate an encode error.
|
||||
-- ---------------------------------------------------------------------------
|
||||
local mock_jwt = {
|
||||
fail = false,
|
||||
captured_payload = nil,
|
||||
captured_key = nil,
|
||||
encode = function(self, payload, key, _alg, _headers)
|
||||
self.captured_payload = payload
|
||||
self.captured_key = key
|
||||
if self.fail then
|
||||
return nil, 'mock-encode-error'
|
||||
end
|
||||
return 'mock.jwt.token', nil
|
||||
end,
|
||||
}
|
||||
-- Make encode callable without explicit self (module calls jwt.encode(...))
|
||||
setmetatable(mock_jwt, {
|
||||
__index = mock_jwt,
|
||||
})
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Module stub factory.
|
||||
-- Returns a fresh (stub, hooks, log_calls) triple.
|
||||
-- ---------------------------------------------------------------------------
|
||||
local function make_module_stub(options_override)
|
||||
local hooks = {}
|
||||
local log_calls = {}
|
||||
|
||||
local opts = options_override or {
|
||||
issuer = 'test-issuer',
|
||||
accepted_audiences = { 'file-sharing' },
|
||||
key_path = '/fake/key.pem',
|
||||
key_id = 'kid-1',
|
||||
ttl_seconds = 30,
|
||||
}
|
||||
|
||||
local stub = {
|
||||
host = 'test.example.com',
|
||||
set_global = function() end,
|
||||
log = function(_, level, fmt, ...)
|
||||
-- fmt may contain no format specifiers; guard with pcall
|
||||
local ok, msg = pcall(string.format, fmt, ...)
|
||||
log_calls[#log_calls + 1] = {
|
||||
level = level,
|
||||
msg = ok and msg or fmt,
|
||||
}
|
||||
end,
|
||||
hook = function(_, name, handler)
|
||||
hooks[name] = handler
|
||||
end,
|
||||
get_option = function(_, key)
|
||||
if key == 'short_lived_token' then return opts end
|
||||
return nil
|
||||
end,
|
||||
get_option_string = function(_, key, default)
|
||||
if key == 'region_name' then return 'us-east-1' end
|
||||
if key == 'main_muc' then return 'conference.test.example.com' end
|
||||
return default
|
||||
end,
|
||||
require = function(_, name)
|
||||
if name == 'luajwtjitsi' then
|
||||
-- Return a table whose encode method delegates to mock_jwt
|
||||
return {
|
||||
encode = function(payload, key, alg, headers)
|
||||
return mock_jwt:encode(payload, key, alg, headers)
|
||||
end,
|
||||
}
|
||||
end
|
||||
if name == 'util' then
|
||||
return {
|
||||
is_vpaas = function() return false end,
|
||||
process_host_module = function() end,
|
||||
table_find = function(t, v)
|
||||
for _, x in ipairs(t) do if x == v then return true end end
|
||||
return nil
|
||||
end,
|
||||
}
|
||||
end
|
||||
error('unexpected module:require("' .. name .. '")')
|
||||
end,
|
||||
}
|
||||
|
||||
return stub, hooks, log_calls
|
||||
end
|
||||
|
||||
-- Patch io.open so key-file read at load-time returns a fake key string.
|
||||
local real_io_open = io.open
|
||||
local function patch_io()
|
||||
io.open = function(path, mode)
|
||||
if path == '/fake/key.pem' and mode == 'r' then
|
||||
return {
|
||||
read = function(_, _fmt) return 'FAKE-PRIVATE-KEY' end,
|
||||
close = function() end,
|
||||
}
|
||||
end
|
||||
return real_io_open(path, mode)
|
||||
end
|
||||
end
|
||||
local function restore_io() io.open = real_io_open end
|
||||
|
||||
-- Load the module under test into _G.
|
||||
local function load_module(module_stub)
|
||||
mock_jwt.fail = false
|
||||
mock_jwt.captured_payload = nil
|
||||
mock_jwt.captured_key = nil
|
||||
_G.module = module_stub
|
||||
_G.prosody = { hosts = {} }
|
||||
_G.extract_subdomain = function(node) return nil, node, nil end
|
||||
_G.get_room_by_name_and_subdomain = function() return nil end
|
||||
patch_io()
|
||||
local ok, err = pcall(dofile, 'mod_short_lived_token.lua')
|
||||
restore_io()
|
||||
return ok, err
|
||||
end
|
||||
|
||||
-- Convenience: load + return hooks (asserts successful load).
|
||||
local function load_and_get_hooks(opts_override)
|
||||
local stub, hooks, log_calls = make_module_stub(opts_override)
|
||||
local ok, err = load_module(stub)
|
||||
assert.is_true(ok, tostring(err))
|
||||
return hooks, log_calls, stub
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Tests
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
describe('mod_short_lived_token', function()
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
-- Config validation
|
||||
-- -----------------------------------------------------------------------
|
||||
describe('config validation', function()
|
||||
|
||||
it('loads cleanly with all required options present', function()
|
||||
local _, log_calls = load_and_get_hooks()
|
||||
local errors = {}
|
||||
for _, e in ipairs(log_calls) do
|
||||
if e.level == 'error' then errors[#errors + 1] = e.msg end
|
||||
end
|
||||
assert.equal(0, #errors, 'unexpected error: ' .. table.concat(errors, '; '))
|
||||
end)
|
||||
|
||||
for _, missing in ipairs({ 'issuer', 'accepted_audiences', 'key_path', 'key_id', 'ttl_seconds' }) do
|
||||
it('logs error and skips hook when ' .. missing .. ' is absent', function()
|
||||
local opts = {
|
||||
issuer = 'test-issuer',
|
||||
accepted_audiences = { 'file-sharing' },
|
||||
key_path = '/fake/key.pem',
|
||||
key_id = 'kid-1',
|
||||
ttl_seconds = 30,
|
||||
}
|
||||
opts[missing] = nil
|
||||
|
||||
local stub, hooks, log_calls = make_module_stub(opts)
|
||||
local ok, err = load_module(stub)
|
||||
assert.is_true(ok, tostring(err))
|
||||
assert.is_nil(hooks['external_service/credentials'],
|
||||
'hook must not register when config is incomplete')
|
||||
|
||||
local found_error = false
|
||||
for _, e in ipairs(log_calls) do
|
||||
if e.level == 'error' then found_error = true end
|
||||
end
|
||||
assert.is_true(found_error, 'expected an error log entry')
|
||||
end)
|
||||
end
|
||||
|
||||
it('logs error and does not register hook when main_muc is absent', function()
|
||||
local stub, hooks, log_calls = make_module_stub()
|
||||
stub.get_option_string = function(_, key, default)
|
||||
if key == 'region_name' then return 'us-east-1' end
|
||||
if key == 'main_muc' then return nil end
|
||||
return default
|
||||
end
|
||||
local ok = load_module(stub)
|
||||
assert.is_true(ok)
|
||||
assert.is_nil(hooks['external_service/credentials'])
|
||||
local found_error = false
|
||||
for _, e in ipairs(log_calls) do
|
||||
if e.level == 'error' then found_error = true end
|
||||
end
|
||||
assert.is_true(found_error)
|
||||
end)
|
||||
|
||||
end) -- config validation
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
-- external_service/credentials handler — error cases
|
||||
-- -----------------------------------------------------------------------
|
||||
describe('external_service/credentials handler', function()
|
||||
|
||||
local hooks
|
||||
|
||||
before_each(function()
|
||||
hooks = load_and_get_hooks()
|
||||
end)
|
||||
|
||||
it('sends not-allowed when room is not found', function()
|
||||
_G.get_room_by_name_and_subdomain = function() return nil end
|
||||
local error_sent
|
||||
local session = {
|
||||
full_jid = 'user@example.com/res',
|
||||
jitsi_web_query_room = 'missing',
|
||||
jitsi_web_query_prefix = '',
|
||||
send = function(reply) error_sent = reply end,
|
||||
}
|
||||
hooks['external_service/credentials']({
|
||||
requested_credentials = iter({ 'short-lived-token:file-sharing:0' }),
|
||||
services = { push = function() end },
|
||||
origin = session,
|
||||
stanza = { id = 'iq1' },
|
||||
})
|
||||
assert.is_not_nil(error_sent)
|
||||
assert.equal('not-allowed', error_sent.condition)
|
||||
end)
|
||||
|
||||
it('sends not-allowed when session is not an occupant', function()
|
||||
local room = make_room('r@conference.example.com', 'mid', {})
|
||||
_G.get_room_by_name_and_subdomain = function() return room end
|
||||
local error_sent
|
||||
local session = {
|
||||
full_jid = 'outsider@example.com/res',
|
||||
jitsi_web_query_room = 'r',
|
||||
jitsi_web_query_prefix = '',
|
||||
send = function(reply) error_sent = reply end,
|
||||
}
|
||||
hooks['external_service/credentials']({
|
||||
requested_credentials = iter({ 'short-lived-token:file-sharing:0' }),
|
||||
services = { push = function() end },
|
||||
origin = session,
|
||||
stanza = { id = 'iq2' },
|
||||
})
|
||||
assert.is_not_nil(error_sent)
|
||||
assert.equal('not-allowed', error_sent.condition)
|
||||
end)
|
||||
|
||||
it('does not push service when credential key is not in accepted map', function()
|
||||
local occupant = make_occupant('user@example.com/res', nil, 'Alice', nil)
|
||||
local room = make_room('r@conference.example.com', 'mid',
|
||||
{ ['user@example.com/res'] = occupant })
|
||||
_G.get_room_by_name_and_subdomain = function() return room end
|
||||
local pushed = {}
|
||||
local services = { push = function(_, item) pushed[#pushed + 1] = item end }
|
||||
hooks['external_service/credentials']({
|
||||
requested_credentials = iter({ 'short-lived-token:unknown-audience:0' }),
|
||||
services = services,
|
||||
origin = { full_jid = 'user@example.com/res', jitsi_web_query_room = 'r',
|
||||
jitsi_web_query_prefix = '' },
|
||||
stanza = {},
|
||||
})
|
||||
assert.equal(0, #pushed)
|
||||
end)
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- Service entry shape
|
||||
-- -------------------------------------------------------------------
|
||||
describe('valid request service entry', function()
|
||||
|
||||
local pushed_item
|
||||
|
||||
before_each(function()
|
||||
local occupant = make_occupant('user@example.com/res', nil, 'Alice', nil)
|
||||
local room = make_room('r@conference.example.com', 'meet-123',
|
||||
{ ['user@example.com/res'] = occupant })
|
||||
_G.get_room_by_name_and_subdomain = function() return room end
|
||||
local items = {}
|
||||
local services = { push = function(_, item) items[#items + 1] = item end }
|
||||
hooks['external_service/credentials']({
|
||||
requested_credentials = iter({ 'short-lived-token:file-sharing:0' }),
|
||||
services = services,
|
||||
origin = { full_jid = 'user@example.com/res', jitsi_web_query_room = 'r',
|
||||
jitsi_web_query_prefix = '' },
|
||||
stanza = {},
|
||||
})
|
||||
pushed_item = items[1]
|
||||
end)
|
||||
|
||||
it('type is short-lived-token', function() assert.equal('short-lived-token', pushed_item.type) end)
|
||||
it('host is audience value', function() assert.equal('file-sharing', pushed_item.host) end)
|
||||
it('username is token', function() assert.equal('token', pushed_item.username) end)
|
||||
it('transport is https', function() assert.equal('https', pushed_item.transport) end)
|
||||
it('port is 443', function() assert.equal(443, pushed_item.port) end)
|
||||
it('restricted is true', function() assert.is_true(pushed_item.restricted) end)
|
||||
it('expires = now + ttl', function()
|
||||
local now = os.time()
|
||||
assert.is_true(pushed_item.expires >= now + 29 and pushed_item.expires <= now + 31)
|
||||
end)
|
||||
it('password is non-empty string', function()
|
||||
assert.is_string(pushed_item.password)
|
||||
assert.is_true(#pushed_item.password > 0)
|
||||
end)
|
||||
end)
|
||||
|
||||
it('multiple audiences produce one service entry each', function()
|
||||
local hooks2 = load_and_get_hooks({
|
||||
issuer = 'iss',
|
||||
accepted_audiences = { 'aud-a', 'aud-b' },
|
||||
key_path = '/fake/key.pem',
|
||||
key_id = 'kid',
|
||||
ttl_seconds = 30,
|
||||
})
|
||||
local occupant = make_occupant('user@example.com/res', nil, 'Bob', nil)
|
||||
local room = make_room('r@conference.example.com', 'mid',
|
||||
{ ['user@example.com/res'] = occupant })
|
||||
_G.get_room_by_name_and_subdomain = function() return room end
|
||||
local items = {}
|
||||
local services = { push = function(_, item) items[#items + 1] = item end }
|
||||
hooks2['external_service/credentials']({
|
||||
requested_credentials = iter({
|
||||
'short-lived-token:aud-a:0',
|
||||
'short-lived-token:aud-b:0',
|
||||
}),
|
||||
services = services,
|
||||
origin = { full_jid = 'user@example.com/res', jitsi_web_query_room = 'r',
|
||||
jitsi_web_query_prefix = '' },
|
||||
stanza = {},
|
||||
})
|
||||
assert.equal(2, #items)
|
||||
assert.equal('aud-a', items[1].host)
|
||||
assert.equal('aud-b', items[2].host)
|
||||
end)
|
||||
|
||||
end) -- handler
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
-- generateToken — JWT payload claims
|
||||
-- Tested by capturing mock_jwt.captured_payload after invoking the handler.
|
||||
-- -----------------------------------------------------------------------
|
||||
describe('generateToken payload', function()
|
||||
|
||||
-- Invoke handler with given session fields; returns captured payload.
|
||||
-- Optional extract_subdomain_fn overrides the global after module load.
|
||||
local function get_payload(session_fields, room_override, extract_subdomain_fn)
|
||||
hooks = load_and_get_hooks()
|
||||
mock_jwt.captured_payload = nil
|
||||
|
||||
-- Apply extract_subdomain override AFTER load_module (which resets it).
|
||||
if extract_subdomain_fn then
|
||||
_G.extract_subdomain = extract_subdomain_fn
|
||||
end
|
||||
|
||||
local full_jid = session_fields.full_jid or 'user@example.com/res'
|
||||
local pres_nick = session_fields._presence_nick or 'Alice'
|
||||
local pres_email = session_fields._presence_email or nil
|
||||
|
||||
local occupant = make_occupant(full_jid, nil, pres_nick, pres_email)
|
||||
local room = room_override or make_room(
|
||||
'myroom@conference.example.com', 'meeting-xyz',
|
||||
{ [full_jid] = occupant }
|
||||
)
|
||||
_G.get_room_by_name_and_subdomain = function() return room end
|
||||
|
||||
local session = {}
|
||||
for k, v in pairs(session_fields) do
|
||||
if k:sub(1, 1) ~= '_' then session[k] = v end
|
||||
end
|
||||
|
||||
local items = {}
|
||||
local services = { push = function(_, item) items[#items + 1] = item end }
|
||||
hooks['external_service/credentials']({
|
||||
requested_credentials = iter({ 'short-lived-token:file-sharing:0' }),
|
||||
services = services,
|
||||
origin = session,
|
||||
stanza = {},
|
||||
})
|
||||
return mock_jwt.captured_payload
|
||||
end
|
||||
|
||||
local base_session = {
|
||||
full_jid = 'user@example.com/res',
|
||||
jitsi_web_query_room = 'myroom',
|
||||
jitsi_web_query_prefix = '',
|
||||
}
|
||||
|
||||
it('iss is set to configured issuer', function()
|
||||
local p = get_payload(base_session)
|
||||
assert.equal('test-issuer', p.iss)
|
||||
end)
|
||||
|
||||
it('aud is set to requested audience', function()
|
||||
local p = get_payload(base_session)
|
||||
assert.equal('file-sharing', p.aud)
|
||||
end)
|
||||
|
||||
it('exp is approximately now + ttl_seconds', function()
|
||||
local before = os.time()
|
||||
local p = get_payload(base_session)
|
||||
local after = os.time()
|
||||
assert.is_true(p.exp >= before + 30 and p.exp <= after + 30)
|
||||
end)
|
||||
|
||||
it('nbf is approximately now', function()
|
||||
local before = os.time()
|
||||
local p = get_payload(base_session)
|
||||
local after = os.time()
|
||||
assert.is_true(p.nbf >= before - 1 and p.nbf <= after)
|
||||
end)
|
||||
|
||||
it('sub defaults to module.host when jitsi_web_query_prefix is nil', function()
|
||||
local p = get_payload({
|
||||
full_jid = 'user@example.com/res',
|
||||
jitsi_web_query_room = 'myroom',
|
||||
jitsi_web_query_prefix = nil,
|
||||
})
|
||||
assert.equal('test.example.com', p.sub)
|
||||
end)
|
||||
|
||||
it('sub defaults to module.host when jitsi_web_query_prefix is empty string', function()
|
||||
local p = get_payload(base_session)
|
||||
assert.equal('test.example.com', p.sub)
|
||||
end)
|
||||
|
||||
it('sub is jitsi_web_query_prefix when non-empty', function()
|
||||
local p = get_payload({
|
||||
full_jid = 'user@example.com/res',
|
||||
jitsi_web_query_room = 'myroom',
|
||||
jitsi_web_query_prefix = 'mysubdomain',
|
||||
})
|
||||
assert.equal('mysubdomain', p.sub)
|
||||
end)
|
||||
|
||||
it('context.user taken from session when jitsi_meet_context_user present', function()
|
||||
local cu = { id = 'uid-99', name = 'Session User', email = 'su@example.com' }
|
||||
local p = get_payload({
|
||||
full_jid = 'user@example.com/res',
|
||||
jitsi_web_query_room = 'myroom',
|
||||
jitsi_web_query_prefix = '',
|
||||
jitsi_meet_context_user = cu,
|
||||
})
|
||||
assert.same(cu, p.context.user)
|
||||
end)
|
||||
|
||||
it('context.user built from presence when jitsi_meet_context_user absent', function()
|
||||
local p = get_payload({
|
||||
full_jid = 'user@example.com/res',
|
||||
jitsi_web_query_room = 'myroom',
|
||||
jitsi_web_query_prefix = '',
|
||||
_presence_nick = 'PresenceNick',
|
||||
_presence_email = 'presence@example.com',
|
||||
})
|
||||
assert.equal('PresenceNick', p.context.user.name)
|
||||
assert.equal('presence@example.com', p.context.user.email)
|
||||
assert.equal('user@example.com/res', p.context.user.id)
|
||||
end)
|
||||
|
||||
it('context.group uses jitsi_meet_context_group when present', function()
|
||||
local p = get_payload({
|
||||
full_jid = 'user@example.com/res',
|
||||
jitsi_web_query_room = 'myroom',
|
||||
jitsi_web_query_prefix = '',
|
||||
jitsi_meet_context_group = 'group-primary',
|
||||
granted_jitsi_meet_context_group_id = 'group-fallback',
|
||||
})
|
||||
assert.equal('group-primary', p.context.group)
|
||||
end)
|
||||
|
||||
it('context.group falls back to granted_jitsi_meet_context_group_id', function()
|
||||
local p = get_payload({
|
||||
full_jid = 'user@example.com/res',
|
||||
jitsi_web_query_room = 'myroom',
|
||||
jitsi_web_query_prefix = '',
|
||||
jitsi_meet_context_group = nil,
|
||||
granted_jitsi_meet_context_group_id = 'group-fallback',
|
||||
})
|
||||
assert.equal('group-fallback', p.context.group)
|
||||
end)
|
||||
|
||||
it('meeting_id taken from room._data.meetingId', function()
|
||||
local p = get_payload(base_session)
|
||||
assert.equal('meeting-xyz', p.meeting_id)
|
||||
end)
|
||||
|
||||
it('backend_region taken from region_name config', function()
|
||||
local p = get_payload(base_session)
|
||||
assert.equal('us-east-1', p.backend_region)
|
||||
end)
|
||||
|
||||
it('user_region propagated from session', function()
|
||||
local p = get_payload({
|
||||
full_jid = 'user@example.com/res',
|
||||
jitsi_web_query_room = 'myroom',
|
||||
jitsi_web_query_prefix = '',
|
||||
user_region = 'eu-west-1',
|
||||
})
|
||||
assert.equal('eu-west-1', p.user_region)
|
||||
end)
|
||||
|
||||
it('customer_id uses extracted subdomain customer_id when present', function()
|
||||
local p = get_payload(base_session, nil, function(_node)
|
||||
return 'vpaas-magic-cookie-abc123', 'myroom', 'abc123'
|
||||
end)
|
||||
assert.equal('abc123', p.customer_id)
|
||||
end)
|
||||
|
||||
it('customer_id falls back to group when extract_subdomain returns no customer_id', function()
|
||||
local p = get_payload({
|
||||
full_jid = 'user@example.com/res',
|
||||
jitsi_web_query_room = 'myroom',
|
||||
jitsi_web_query_prefix = '',
|
||||
jitsi_meet_context_group = 'my-group',
|
||||
}, nil, function(node) return nil, node, nil end)
|
||||
assert.equal('my-group', p.customer_id)
|
||||
end)
|
||||
|
||||
it('granted_from set from session.granted_jitsi_meet_context_user_id', function()
|
||||
local p = get_payload({
|
||||
full_jid = 'user@example.com/res',
|
||||
jitsi_web_query_room = 'myroom',
|
||||
jitsi_web_query_prefix = '',
|
||||
granted_jitsi_meet_context_user_id = 'granter-uid',
|
||||
})
|
||||
assert.equal('granter-uid', p.granted_from)
|
||||
end)
|
||||
|
||||
it('key passed to jwt.encode is the key read from key_path', function()
|
||||
get_payload(base_session)
|
||||
assert.equal('FAKE-PRIVATE-KEY', mock_jwt.captured_key)
|
||||
end)
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- encode error path
|
||||
-- -------------------------------------------------------------------
|
||||
it('password is empty string when jwt.encode fails', function()
|
||||
local stub, hooks2, log_calls = make_module_stub()
|
||||
load_module(stub)
|
||||
mock_jwt.fail = true
|
||||
|
||||
local occupant = make_occupant('user@example.com/res', nil, 'Alice', nil)
|
||||
local room = make_room('r@conference.example.com', 'mid',
|
||||
{ ['user@example.com/res'] = occupant })
|
||||
_G.get_room_by_name_and_subdomain = function() return room end
|
||||
|
||||
local items = {}
|
||||
local services = { push = function(_, item) items[#items + 1] = item end }
|
||||
hooks2['external_service/credentials']({
|
||||
requested_credentials = iter({ 'short-lived-token:file-sharing:0' }),
|
||||
services = services,
|
||||
origin = { full_jid = 'user@example.com/res', jitsi_web_query_room = 'r',
|
||||
jitsi_web_query_prefix = '' },
|
||||
stanza = {},
|
||||
})
|
||||
mock_jwt.fail = false
|
||||
|
||||
assert.equal(1, #items, 'service entry still pushed')
|
||||
assert.equal('', items[1].password, 'password empty on encode error')
|
||||
|
||||
local found_error = false
|
||||
for _, e in ipairs(log_calls) do
|
||||
if e.level == 'error' then found_error = true end
|
||||
end
|
||||
assert.is_true(found_error, 'error logged on encode failure')
|
||||
end)
|
||||
|
||||
end) -- generateToken payload
|
||||
|
||||
end) -- mod_short_lived_token
|
||||
221
tests/prosody/lua/mod_token_affiliation_spec.lua
Normal file
221
tests/prosody/lua/mod_token_affiliation_spec.lua
Normal file
@@ -0,0 +1,221 @@
|
||||
-- Unit tests for mod_token_affiliation.lua
|
||||
-- Run with busted from resources/prosody-plugins/:
|
||||
-- busted ../../tests/prosody/lua/
|
||||
--
|
||||
-- Stubs every Prosody dependency so no Prosody installation is needed.
|
||||
--
|
||||
-- JWT claim correctness and role/affiliation values are covered by the
|
||||
-- integration tests in mod_token_affiliation_spec.js. These unit tests
|
||||
-- focus on skip conditions (healthcheck rooms, admin occupants, missing tokens)
|
||||
-- and the correct affiliation derived from various token claims.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Captured hooks
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local pre_join_fn = nil
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Stubs
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local is_healthcheck_stub = false
|
||||
local is_admin_stub = false
|
||||
|
||||
local util_stub = {
|
||||
is_admin = function(_jid) return is_admin_stub end,
|
||||
is_healthcheck_room = function(_jid) return is_healthcheck_stub end,
|
||||
}
|
||||
|
||||
_G.module = {
|
||||
host = 'conference.example.com',
|
||||
log = function() end,
|
||||
get_option_string = function(_, _key, default) return default end,
|
||||
require = function(_, name)
|
||||
if name == 'util' then return util_stub end
|
||||
return {}
|
||||
end,
|
||||
hook = function(_, event, fn)
|
||||
if event == 'muc-occupant-pre-join' then pre_join_fn = fn end
|
||||
end,
|
||||
}
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Load the module under test
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local ok, load_err = pcall(dofile, 'mod_token_affiliation.lua')
|
||||
if not ok then
|
||||
describe("mod_token_affiliation", function()
|
||||
it("skipped — failed to load module", function()
|
||||
pending(tostring(load_err):match("([^\n]+)") or tostring(load_err))
|
||||
end)
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
assert(pre_join_fn, "mod_token_affiliation did not hook 'muc-occupant-pre-join'")
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Test helpers
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local jid_counter = 0
|
||||
local function fresh_jid()
|
||||
jid_counter = jid_counter + 1
|
||||
return string.format("user%d@example.com", jid_counter)
|
||||
end
|
||||
|
||||
local function make_room(opts)
|
||||
opts = opts or {}
|
||||
local room = {
|
||||
jid = 'testroom@conference.example.com',
|
||||
affiliation_log = {},
|
||||
_members_only = opts.members_only or false,
|
||||
_affiliations = opts.affiliations or {},
|
||||
}
|
||||
function room:set_affiliation(actor, bare_jid, affiliation)
|
||||
table.insert(self.affiliation_log, { actor = actor, jid = bare_jid, affiliation = affiliation })
|
||||
end
|
||||
function room:get_members_only()
|
||||
return self._members_only
|
||||
end
|
||||
function room:get_affiliation(bare_jid)
|
||||
return self._affiliations[bare_jid]
|
||||
end
|
||||
return room
|
||||
end
|
||||
|
||||
local function make_occupant(bare_jid)
|
||||
return { jid = bare_jid .. '/res', bare_jid = bare_jid, role = 'participant' }
|
||||
end
|
||||
|
||||
local function make_session(opts)
|
||||
opts = opts or {}
|
||||
local auth_token
|
||||
if not opts.no_token then auth_token = 'test.token.value' end
|
||||
return {
|
||||
auth_token = auth_token,
|
||||
jitsi_meet_context_user = opts.context_user,
|
||||
}
|
||||
end
|
||||
|
||||
local function run_pre_join(room, occupant, session)
|
||||
pre_join_fn({ room = room, occupant = occupant, origin = session })
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Tests
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
describe("mod_token_affiliation", function()
|
||||
|
||||
before_each(function()
|
||||
is_healthcheck_stub = false
|
||||
is_admin_stub = false
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("skip conditions in muc-occupant-pre-join", function()
|
||||
|
||||
it("healthcheck room — no affiliation set", function()
|
||||
is_healthcheck_stub = true
|
||||
local room = make_room()
|
||||
local occupant = make_occupant(fresh_jid())
|
||||
local session = make_session({ context_user = { moderator = true } })
|
||||
run_pre_join(room, occupant, session)
|
||||
assert.equal(0, #room.affiliation_log)
|
||||
end)
|
||||
|
||||
it("admin occupant — no affiliation set", function()
|
||||
is_admin_stub = true
|
||||
local room = make_room()
|
||||
local occupant = make_occupant(fresh_jid())
|
||||
local session = make_session({ context_user = { moderator = true } })
|
||||
run_pre_join(room, occupant, session)
|
||||
assert.equal(0, #room.affiliation_log)
|
||||
end)
|
||||
|
||||
it("no token — no affiliation set", function()
|
||||
local room = make_room()
|
||||
local occupant = make_occupant(fresh_jid())
|
||||
local session = make_session({ no_token = true, context_user = { moderator = true } })
|
||||
run_pre_join(room, occupant, session)
|
||||
assert.equal(0, #room.affiliation_log)
|
||||
end)
|
||||
|
||||
it("nil session — no affiliation set", function()
|
||||
local room = make_room()
|
||||
local occupant = make_occupant(fresh_jid())
|
||||
run_pre_join(room, occupant, nil)
|
||||
assert.equal(0, #room.affiliation_log)
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("affiliation assignment", function()
|
||||
|
||||
it("moderator=true sets owner affiliation and moderator role", function()
|
||||
local room = make_room()
|
||||
local occupant = make_occupant(fresh_jid())
|
||||
local session = make_session({ context_user = { moderator = true } })
|
||||
run_pre_join(room, occupant, session)
|
||||
assert.equal(1, #room.affiliation_log)
|
||||
assert.equal("owner", room.affiliation_log[1].affiliation)
|
||||
assert.equal("moderator", occupant.role)
|
||||
end)
|
||||
|
||||
it("moderator='true' sets owner affiliation", function()
|
||||
local room = make_room()
|
||||
local occupant = make_occupant(fresh_jid())
|
||||
local session = make_session({ context_user = { moderator = 'true' } })
|
||||
run_pre_join(room, occupant, session)
|
||||
assert.equal("owner", room.affiliation_log[1].affiliation)
|
||||
end)
|
||||
|
||||
it("affiliation='owner' sets owner", function()
|
||||
local room = make_room()
|
||||
local occupant = make_occupant(fresh_jid())
|
||||
local session = make_session({ context_user = { affiliation = 'owner' } })
|
||||
run_pre_join(room, occupant, session)
|
||||
assert.equal("owner", room.affiliation_log[1].affiliation)
|
||||
end)
|
||||
|
||||
it("affiliation='moderator' sets owner", function()
|
||||
local room = make_room()
|
||||
local occupant = make_occupant(fresh_jid())
|
||||
local session = make_session({ context_user = { affiliation = 'moderator' } })
|
||||
run_pre_join(room, occupant, session)
|
||||
assert.equal("owner", room.affiliation_log[1].affiliation)
|
||||
end)
|
||||
|
||||
it("affiliation='teacher' sets owner", function()
|
||||
local room = make_room()
|
||||
local occupant = make_occupant(fresh_jid())
|
||||
local session = make_session({ context_user = { affiliation = 'teacher' } })
|
||||
run_pre_join(room, occupant, session)
|
||||
assert.equal("owner", room.affiliation_log[1].affiliation)
|
||||
end)
|
||||
|
||||
it("authenticated non-moderator sets member affiliation, role unchanged", function()
|
||||
local room = make_room()
|
||||
local occupant = make_occupant(fresh_jid())
|
||||
local session = make_session({ context_user = { id = 'plainuser' } })
|
||||
run_pre_join(room, occupant, session)
|
||||
assert.equal(1, #room.affiliation_log)
|
||||
assert.equal("member", room.affiliation_log[1].affiliation)
|
||||
assert.equal("participant", occupant.role)
|
||||
end)
|
||||
|
||||
it("moderator=false sets member affiliation", function()
|
||||
local room = make_room()
|
||||
local occupant = make_occupant(fresh_jid())
|
||||
local session = make_session({ context_user = { moderator = false } })
|
||||
run_pre_join(room, occupant, session)
|
||||
assert.equal("member", room.affiliation_log[1].affiliation)
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
end) -- mod_token_affiliation
|
||||
113
tests/prosody/lua/otel_spec.lua
Normal file
113
tests/prosody/lua/otel_spec.lua
Normal file
@@ -0,0 +1,113 @@
|
||||
-- Unit tests for otel.lib.lua
|
||||
-- Run with busted from resources/prosody-plugins/:
|
||||
-- busted spec/lua/
|
||||
-- Requires: lua-cjson
|
||||
--
|
||||
-- Stubs every Prosody dependency so no Prosody installation is needed.
|
||||
|
||||
local os = require "os"
|
||||
local cjson = require "cjson"
|
||||
|
||||
package.preload['util.json'] = function()
|
||||
return {
|
||||
encode = function(x)
|
||||
return cjson.encode(x)
|
||||
end
|
||||
}
|
||||
end
|
||||
package.preload['util.array'] = function()
|
||||
return function(t) return t end
|
||||
end
|
||||
package.preload['util.time'] = function()
|
||||
return {
|
||||
now = function()
|
||||
return os.time();
|
||||
end
|
||||
}
|
||||
end
|
||||
package.preload['util.random'] = function()
|
||||
return {
|
||||
bytes = function(n)
|
||||
local out = {}
|
||||
for i = 1, n do
|
||||
out[i] = string.char(math.random(0, 255))
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
}
|
||||
end
|
||||
package.preload['util.hex'] = function()
|
||||
return {
|
||||
to = function(bytes)
|
||||
local out = {}
|
||||
for i = 1, #bytes do
|
||||
out[i] = string.format("%02x", bytes:byte(i))
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
-- Guard: skip the whole suite if the module's hard dependencies aren't installed.
|
||||
local ok, M = pcall(dofile, "otel.lib.lua")
|
||||
if not ok then
|
||||
describe("otel", function()
|
||||
it("skipped", function()
|
||||
pending(tostring(M):match("([^\n]+)"))
|
||||
end)
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
describe("otel", function()
|
||||
describe("span", function()
|
||||
it("create and export span", function()
|
||||
local request
|
||||
local request_fn = function(url, options, callback) request = options end
|
||||
|
||||
local exporter = M.Exporter.new(request_fn, "endpoint")
|
||||
local processor = M.Processor.new(exporter)
|
||||
local tracer = M.Tracer.new(processor, "service", "scope")
|
||||
|
||||
local span = tracer:start_span("name", {
|
||||
trace_id = "deadbeefdeadbeefdeadbeefdeadbeef",
|
||||
span_id = "deadbeefdeadbeef",
|
||||
})
|
||||
span:set_attribute("key", M.Attribute.string("value"))
|
||||
span:end_span()
|
||||
|
||||
local body = cjson.decode(request.body)
|
||||
assert.are.same(
|
||||
{
|
||||
attributes = {
|
||||
{ key = "service.name", value = { string_value = "service" } }
|
||||
}
|
||||
},
|
||||
body.resource_spans[1].resource)
|
||||
assert.are.same(
|
||||
{
|
||||
name = "scope"
|
||||
},
|
||||
body.resource_spans[1].scope_spans[1].scope
|
||||
)
|
||||
|
||||
local span = body.resource_spans[1].scope_spans[1].spans[1]
|
||||
assert.are.same(
|
||||
{ { key = "key", value = { string_value = "value" } } },
|
||||
span.attributes
|
||||
)
|
||||
assert.are.same(
|
||||
"deadbeefdeadbeefdeadbeefdeadbeef",
|
||||
span.trace_id
|
||||
)
|
||||
assert.are.same(
|
||||
"deadbeefdeadbeef",
|
||||
span.parent_span_id
|
||||
)
|
||||
assert.are.same(1, span.kind)
|
||||
assert.are.same("name", span.name)
|
||||
assert.are.same({ code = 1 }, span.status)
|
||||
end)
|
||||
end)
|
||||
end) -- otel
|
||||
165
tests/prosody/lua/rayo_dial_header_crlf_spec.lua
Normal file
165
tests/prosody/lua/rayo_dial_header_crlf_spec.lua
Normal file
@@ -0,0 +1,165 @@
|
||||
-- Unit test for Rayo dial IQ header serialization.
|
||||
-- Run with busted from resources/prosody-plugins/:
|
||||
-- busted spec/lua/
|
||||
--
|
||||
-- Purpose: a Rayo "dial" IQ (urn:xmpp:rayo:1) carries client-influenced header
|
||||
-- values (e.g. JvbRoomName / JvbRoomPassword). If a header value contains a raw
|
||||
-- carriage-return / line-feed, an unescaped serialization would let a client
|
||||
-- inject extra lines into the XMPP stream — and, downstream, into the SIP
|
||||
-- headers jigasi builds from these values (CRLF / header injection).
|
||||
--
|
||||
-- Prosody's util.stanza serializer neutralizes this by XML-escaping control
|
||||
-- characters: "\r" -> " " and "\n" -> " ". This spec produces a dial IQ
|
||||
-- with such a header and asserts the serialized stanza contains " " and
|
||||
-- leaks no raw CR/LF bytes.
|
||||
--
|
||||
-- It uses Prosody's real util.stanza when it resolves on LUA_PATH; otherwise it
|
||||
-- falls back to a small in-file serializer that mirrors Prosody's escape table,
|
||||
-- so the contract is still asserted in CI (where Prosody is not installed).
|
||||
|
||||
local RAYO_NS = 'urn:xmpp:rayo:1';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Obtain a stanza builder: prefer the real util.stanza, else a faithful stub.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local st, source;
|
||||
|
||||
local ok_real, real = pcall(require, 'util.stanza');
|
||||
if ok_real and type(real) == 'table' and real.iq then
|
||||
st = real;
|
||||
source = 'util.stanza (real Prosody)';
|
||||
else
|
||||
source = 'in-file reference serializer';
|
||||
|
||||
-- Mirrors Prosody's util.stanza escape table: control characters are
|
||||
-- escaped to numeric entities so they cannot break out of an attribute.
|
||||
local escape_table = {
|
||||
["'"] = "'",
|
||||
['"'] = """,
|
||||
["<"] = "<",
|
||||
[">"] = ">",
|
||||
["&"] = "&",
|
||||
["\t"] = "	",
|
||||
["\n"] = " ",
|
||||
["\r"] = " ",
|
||||
};
|
||||
local function xml_escape(str)
|
||||
return (str:gsub("['&<>\"\t\n\r]", escape_table));
|
||||
end
|
||||
|
||||
local stanza_mt = {};
|
||||
stanza_mt.__index = stanza_mt;
|
||||
|
||||
local function new_stanza(name, attr)
|
||||
return setmetatable({
|
||||
name = name,
|
||||
attr = attr or {},
|
||||
tags = {},
|
||||
last_add = {},
|
||||
}, stanza_mt);
|
||||
end
|
||||
|
||||
function stanza_mt:tag(name, attr)
|
||||
local child = new_stanza(name, attr);
|
||||
local parent = self.last_add[#self.last_add] or self;
|
||||
|
||||
table.insert(parent.tags, child);
|
||||
table.insert(self.last_add, child);
|
||||
|
||||
return self;
|
||||
end
|
||||
|
||||
function stanza_mt:up()
|
||||
table.remove(self.last_add);
|
||||
|
||||
return self;
|
||||
end
|
||||
|
||||
local function serialize(node, buf)
|
||||
table.insert(buf, "<" .. node.name);
|
||||
for k, v in pairs(node.attr) do
|
||||
table.insert(buf, " " .. k .. '="' .. xml_escape(tostring(v)) .. '"');
|
||||
end
|
||||
|
||||
if #node.tags == 0 then
|
||||
table.insert(buf, "/>");
|
||||
else
|
||||
table.insert(buf, ">");
|
||||
for _, child in ipairs(node.tags) do
|
||||
serialize(child, buf);
|
||||
end
|
||||
table.insert(buf, "</" .. node.name .. ">");
|
||||
end
|
||||
|
||||
return buf;
|
||||
end
|
||||
|
||||
stanza_mt.__tostring = function(self)
|
||||
return table.concat(serialize(self, {}));
|
||||
end
|
||||
|
||||
st = {
|
||||
iq = function(attr)
|
||||
return new_stanza('iq', attr);
|
||||
end,
|
||||
};
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Test
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
describe("Rayo dial IQ header CRLF escaping [" .. source .. "]", function()
|
||||
|
||||
-- Builds a dial IQ the same shape mod_muc_jigasi_invite.lua produces:
|
||||
-- <iq><dial xmlns=rayo><header name=... value=.../></dial></iq>
|
||||
-- with a JvbRoomName header whose value is spiked with a raw CRLF.
|
||||
local function build_dial_iq(room_value)
|
||||
return st.iq({
|
||||
xmlns = "jabber:client",
|
||||
type = "set",
|
||||
to = "jvb@example.com",
|
||||
from = "focus@example.com",
|
||||
id = "dial-1",
|
||||
})
|
||||
:tag("dial", { xmlns = RAYO_NS, from = "fromnumber", to = "sip:test@example.com" })
|
||||
:tag("header", { xmlns = RAYO_NS, name = "JvbRoomName", value = room_value })
|
||||
:up()
|
||||
:up();
|
||||
end
|
||||
|
||||
it("escapes a CRLF-injected header value to ", function()
|
||||
-- A client attempting to smuggle an extra SIP header via CRLF injection.
|
||||
local malicious = "room@conference.example.com\r\nInjected-Header: evil";
|
||||
local iq = build_dial_iq(malicious);
|
||||
|
||||
local xml = tostring(iq);
|
||||
|
||||
assert.is_truthy(xml:find(" ", 1, true),
|
||||
"serialized dial IQ must escape the raw CRLF to ");
|
||||
end)
|
||||
|
||||
it("leaks no raw CR or LF bytes in the serialized stanza", function()
|
||||
local malicious = "room@conference.example.com\r\nInjected-Header: evil";
|
||||
local iq = build_dial_iq(malicious);
|
||||
|
||||
local xml = tostring(iq);
|
||||
|
||||
assert.is_nil(xml:find("[\r\n]"),
|
||||
"serialized dial IQ must not contain any raw CR/LF byte");
|
||||
end)
|
||||
|
||||
it("preserves the room name text around the escaped CRLF", function()
|
||||
local malicious = "room@conference.example.com\r\nInjected-Header: evil";
|
||||
local iq = build_dial_iq(malicious);
|
||||
|
||||
local xml = tostring(iq);
|
||||
|
||||
-- The header name and the surrounding value text survive; only the
|
||||
-- control characters are entity-encoded.
|
||||
assert.is_truthy(xml:find("JvbRoomName", 1, true));
|
||||
assert.is_truthy(xml:find("room@conference.example.com Injected%-Header: evil"));
|
||||
end)
|
||||
|
||||
end);
|
||||
329
tests/prosody/lua/speakerstats_component_spec.lua
Normal file
329
tests/prosody/lua/speakerstats_component_spec.lua
Normal file
@@ -0,0 +1,329 @@
|
||||
-- Unit tests for SpeakerStats in mod_speakerstats_component.lua
|
||||
-- Run with busted from resources/prosody-plugins/:
|
||||
-- busted spec/lua/
|
||||
--
|
||||
-- Stubs every Prosody dependency so no Prosody installation is needed.
|
||||
-- Only the pure SpeakerStats class (new_SpeakerStats / setDominantSpeaker /
|
||||
-- isDominantSpeaker / isSilent) is exercised here. Functions that depend on
|
||||
-- live rooms, stanzas, or XMPP routing are covered by the integration tests.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Controllable clock
|
||||
-- socket.gettime() returns seconds (float); the module multiplies by 1000.
|
||||
-- Call set_clock(ms) to advance time before each action.
|
||||
-- ---------------------------------------------------------------------------
|
||||
local _clock_ms = 0
|
||||
|
||||
local function set_clock(ms)
|
||||
_clock_ms = ms
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Package preloads — registered before dofile() so the module sees them
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
package.preload['socket'] = function()
|
||||
return {
|
||||
gettime = function() return _clock_ms / 1000 end,
|
||||
}
|
||||
end
|
||||
|
||||
package.preload['util.jid'] = function()
|
||||
local function split(jid)
|
||||
if not jid then return nil, nil, nil end
|
||||
local node = jid:match('^([^@]+)@')
|
||||
local host = jid:match('@([^/]+)')
|
||||
local res = jid:match('/(.+)$')
|
||||
return node, host, res
|
||||
end
|
||||
return {
|
||||
resource = function(jid)
|
||||
if not jid then return nil end
|
||||
return jid:match('/(.+)$')
|
||||
end,
|
||||
split = split,
|
||||
}
|
||||
end
|
||||
|
||||
package.preload['util.stanza'] = function()
|
||||
-- Only used by occupant_joined (route_stanza) — not exercised here.
|
||||
return {
|
||||
message = function()
|
||||
local s = {}
|
||||
s.tag = function(self, _, _) return self end
|
||||
s.text = function(self, _) return self end
|
||||
s.up = function(self) return self end
|
||||
return s
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
package.preload['cjson.safe'] = function()
|
||||
return {
|
||||
encode = function(_) return '{}', nil end,
|
||||
decode = function(_) return {}, nil end,
|
||||
}
|
||||
end
|
||||
|
||||
package.preload['util.queue'] = function()
|
||||
return {
|
||||
new = function(max_size)
|
||||
local items = {}
|
||||
local q = {}
|
||||
function q:push(v)
|
||||
table.insert(items, v)
|
||||
while #items > max_size do
|
||||
table.remove(items, 1)
|
||||
end
|
||||
end
|
||||
function q:items()
|
||||
local i = 0
|
||||
return function()
|
||||
i = i + 1
|
||||
return items[i]
|
||||
end
|
||||
end
|
||||
function q:count() return #items end
|
||||
return q
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
package.preload['util.array'] = function()
|
||||
-- Prosody's util.array module is itself callable as a constructor.
|
||||
local arr_mt = {}
|
||||
arr_mt.__index = arr_mt
|
||||
return setmetatable({}, {
|
||||
__call = function(_, iter)
|
||||
local t = setmetatable({}, arr_mt)
|
||||
if iter then
|
||||
for v in iter do t[#t + 1] = v end
|
||||
end
|
||||
return t
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
package.preload['util.async'] = function() return {} end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Global stubs required by mod_speakerstats_component.lua at load time
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
_G.prosody = { hosts = {}, version = 'test' }
|
||||
|
||||
-- Stub for the util library loaded via module:require "util"
|
||||
local util_stub = {
|
||||
is_admin = function() return false end,
|
||||
get_room_from_jid = function() return nil end,
|
||||
room_jid_match_rewrite = function(jid) return jid end,
|
||||
is_jibri = function() return false end,
|
||||
is_healthcheck_room = function() return false end,
|
||||
is_transcriber = function() return false end,
|
||||
-- noop: prevents hook registration against live muc components
|
||||
process_host_module = function() end,
|
||||
}
|
||||
|
||||
local mod = { host = 'speakerstats.test.example.com' }
|
||||
mod.log = function() end
|
||||
mod.get_option_string = function(_, key, default)
|
||||
if key == 'muc_component' then return 'conference.test.example.com' end
|
||||
if key == 'muc_mapper_domain_base' then return 'test.example.com' end
|
||||
return default
|
||||
end
|
||||
mod.get_option_number = function(_, _, default) return default end
|
||||
mod.hook = function() end
|
||||
mod.fire_event = function() end
|
||||
mod.context = function() return { fire_event = function() end } end
|
||||
mod.require = function(_, name)
|
||||
if name == 'util' then return util_stub end
|
||||
error('unexpected module:require("' .. tostring(name) .. '")')
|
||||
end
|
||||
|
||||
_G.module = mod
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Load the module under test
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local ok, err = pcall(dofile, 'mod_speakerstats_component.lua')
|
||||
if not ok then
|
||||
describe('mod_speakerstats_component', function()
|
||||
it('skipped — failed to load', function()
|
||||
pending(tostring(err):match('([^\n]+)') or tostring(err))
|
||||
end)
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
-- new_SpeakerStats is a module-level global (no `local` prefix in the source).
|
||||
assert(type(new_SpeakerStats) == 'function',
|
||||
'new_SpeakerStats must be a global function after loading the module')
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Helpers
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function new_stats(nick)
|
||||
return new_SpeakerStats(nick or 'testnick', nil)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Tests
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
describe('SpeakerStats', function()
|
||||
|
||||
-- Reset clock before every test so time never leaks between cases.
|
||||
before_each(function() set_clock(0) end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe('initial state', function()
|
||||
|
||||
it('is not dominant speaker', function()
|
||||
assert.is_false(new_stats():isDominantSpeaker())
|
||||
end)
|
||||
|
||||
it('is not silent', function()
|
||||
assert.is_false(new_stats():isSilent())
|
||||
end)
|
||||
|
||||
it('has zero total speaking time', function()
|
||||
assert.equal(0, new_stats().totalDominantSpeakerTime)
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe('setDominantSpeaker', function()
|
||||
|
||||
it('accumulates time when dominant speaker stops', function()
|
||||
local s = new_stats()
|
||||
|
||||
set_clock(0)
|
||||
s:setDominantSpeaker(true, false)
|
||||
|
||||
set_clock(1000)
|
||||
s:setDominantSpeaker(false, false)
|
||||
|
||||
assert.equal(1000, s.totalDominantSpeakerTime)
|
||||
end)
|
||||
|
||||
it('does not count time when starting dominant+silent', function()
|
||||
local s = new_stats()
|
||||
|
||||
set_clock(0)
|
||||
s:setDominantSpeaker(true, true) -- dominant but muted from the start
|
||||
|
||||
set_clock(5000)
|
||||
s:setDominantSpeaker(false, false)
|
||||
|
||||
assert.equal(0, s.totalDominantSpeakerTime)
|
||||
end)
|
||||
|
||||
it('accumulates time up to the moment of going silent', function()
|
||||
local s = new_stats()
|
||||
|
||||
set_clock(0)
|
||||
s:setDominantSpeaker(true, false) -- start speaking
|
||||
|
||||
set_clock(500)
|
||||
s:setDominantSpeaker(true, true) -- go silent while still dominant
|
||||
|
||||
assert.equal(500, s.totalDominantSpeakerTime)
|
||||
end)
|
||||
|
||||
it('resumes accumulating after returning from silence', function()
|
||||
local s = new_stats()
|
||||
|
||||
set_clock(0)
|
||||
s:setDominantSpeaker(true, false) -- speak: timer starts at 0
|
||||
|
||||
set_clock(500)
|
||||
s:setDominantSpeaker(true, true) -- mute: +500 ms accumulated
|
||||
|
||||
set_clock(700)
|
||||
s:setDominantSpeaker(true, false) -- unmute: timer restarts at 700
|
||||
|
||||
set_clock(900)
|
||||
s:setDominantSpeaker(false, false) -- stop: +200 ms
|
||||
|
||||
assert.equal(700, s.totalDominantSpeakerTime) -- 500 + 200
|
||||
end)
|
||||
|
||||
it('does not double-count when stopped twice in a row', function()
|
||||
local s = new_stats()
|
||||
|
||||
set_clock(0)
|
||||
s:setDominantSpeaker(true, false)
|
||||
|
||||
set_clock(300)
|
||||
s:setDominantSpeaker(false, false) -- first stop: +300 ms
|
||||
|
||||
set_clock(9999)
|
||||
s:setDominantSpeaker(false, false) -- second stop — must be a no-op
|
||||
|
||||
assert.equal(300, s.totalDominantSpeakerTime)
|
||||
end)
|
||||
|
||||
it('accumulates across multiple dominant speaker episodes', function()
|
||||
local s = new_stats()
|
||||
|
||||
set_clock(0)
|
||||
s:setDominantSpeaker(true, false)
|
||||
|
||||
set_clock(400)
|
||||
s:setDominantSpeaker(false, false) -- first episode: +400 ms
|
||||
|
||||
set_clock(1000)
|
||||
s:setDominantSpeaker(true, false)
|
||||
|
||||
set_clock(1600)
|
||||
s:setDominantSpeaker(false, false) -- second episode: +600 ms
|
||||
|
||||
assert.equal(1000, s.totalDominantSpeakerTime)
|
||||
end)
|
||||
|
||||
it('updates isDominantSpeaker state', function()
|
||||
local s = new_stats()
|
||||
|
||||
s:setDominantSpeaker(true, false)
|
||||
assert.is_true(s:isDominantSpeaker())
|
||||
|
||||
s:setDominantSpeaker(false, false)
|
||||
assert.is_false(s:isDominantSpeaker())
|
||||
end)
|
||||
|
||||
it('updates isSilent state', function()
|
||||
local s = new_stats()
|
||||
|
||||
s:setDominantSpeaker(true, false)
|
||||
assert.is_false(s:isSilent())
|
||||
|
||||
s:setDominantSpeaker(true, true)
|
||||
assert.is_true(s:isSilent())
|
||||
|
||||
s:setDominantSpeaker(true, false)
|
||||
assert.is_false(s:isSilent())
|
||||
end)
|
||||
|
||||
it('does not start timer when becoming dominant while silent', function()
|
||||
-- Silence=true on the transition to dominant → no timer started.
|
||||
-- Stopping later must not add spurious time.
|
||||
local s = new_stats()
|
||||
|
||||
set_clock(0)
|
||||
s:setDominantSpeaker(true, true) -- dominant+silent, no timer
|
||||
|
||||
set_clock(2000)
|
||||
s:setDominantSpeaker(true, false) -- unmute: timer starts NOW at t=2000
|
||||
|
||||
set_clock(2500)
|
||||
s:setDominantSpeaker(false, false) -- stop: +500 ms
|
||||
|
||||
assert.equal(500, s.totalDominantSpeakerTime)
|
||||
end)
|
||||
|
||||
end) -- setDominantSpeaker
|
||||
|
||||
end) -- SpeakerStats
|
||||
919
tests/prosody/lua/util_spec.lua
Normal file
919
tests/prosody/lua/util_spec.lua
Normal file
@@ -0,0 +1,919 @@
|
||||
-- Unit tests for util.lib.lua
|
||||
-- Run with busted from resources/prosody-plugins/:
|
||||
-- busted spec/lua/
|
||||
--
|
||||
-- Stubs every Prosody dependency so no Prosody installation is needed.
|
||||
-- Functions that rely on live Prosody objects (async HTTP, room lookup,
|
||||
-- stanza mutation, etc.) are not covered here — they are exercised by the
|
||||
-- integration tests.
|
||||
--
|
||||
-- Module is loaded with:
|
||||
-- muc_domain_prefix = "conference" (default)
|
||||
-- muc_domain_base = "test.example.com" (module.host default)
|
||||
-- muc_domain = "conference.test.example.com"
|
||||
-- moderated_subdomains = {"moderated-subdomain"}
|
||||
-- moderated_rooms = {"moderatedroom"}
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Minimal Set implementation (mirrors Prosody's util.set behaviour)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local Set_mt = {}
|
||||
Set_mt.__index = Set_mt
|
||||
-- set / fn → new set with fn mapped over every element
|
||||
Set_mt.__div = function(self, fn)
|
||||
local new_s = setmetatable({ _data = {}, _is_set = true }, Set_mt)
|
||||
for k in pairs(self._data) do
|
||||
local mapped = fn(k)
|
||||
if mapped ~= nil then new_s._data[mapped] = true end
|
||||
end
|
||||
return new_s
|
||||
end
|
||||
function Set_mt:contains(v) return self._data[v] == true end
|
||||
function Set_mt:empty() return next(self._data) == nil end
|
||||
function Set_mt:items() return pairs(self._data) end
|
||||
|
||||
local function new_set(list)
|
||||
local s = setmetatable({ _data = {}, _is_set = true }, Set_mt)
|
||||
for _, v in ipairs(list or {}) do s._data[v] = true end
|
||||
return s
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Package preloads — must be registered before dofile()
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
package.preload['util.set'] = function()
|
||||
return {
|
||||
is_set = function(v) return type(v) == 'table' and v._is_set == true end,
|
||||
new = new_set,
|
||||
}
|
||||
end
|
||||
|
||||
package.preload['util.jid'] = function()
|
||||
local function jid_split(j)
|
||||
if not j then return nil, nil, nil end
|
||||
local node, host, resource
|
||||
local at = j:find('@')
|
||||
local slash
|
||||
if at then
|
||||
slash = j:find('/', at + 1)
|
||||
node = j:sub(1, at - 1)
|
||||
if slash then
|
||||
host = j:sub(at + 1, slash - 1)
|
||||
resource = j:sub(slash + 1)
|
||||
else
|
||||
host = j:sub(at + 1)
|
||||
end
|
||||
else
|
||||
slash = j:find('/')
|
||||
if slash then
|
||||
host = j:sub(1, slash - 1)
|
||||
resource = j:sub(slash + 1)
|
||||
else
|
||||
host = j
|
||||
end
|
||||
end
|
||||
node = (node and #node > 0) and node or nil
|
||||
host = (host and #host > 0) and host or nil
|
||||
resource = (resource and #resource > 0) and resource or nil
|
||||
return node, host, resource
|
||||
end
|
||||
|
||||
return {
|
||||
split = jid_split,
|
||||
join = function(node, host, resource)
|
||||
local j = ''
|
||||
if node then j = node .. '@' end
|
||||
j = j .. (host or '')
|
||||
if resource then j = j .. '/' .. resource end
|
||||
return j
|
||||
end,
|
||||
prep = function(j) return j end,
|
||||
bare = function(j)
|
||||
if not j then return nil end
|
||||
local node, host = jid_split(j)
|
||||
if node then return node .. '@' .. (host or '') end
|
||||
return host
|
||||
end,
|
||||
node = function(j)
|
||||
if not j then return nil end
|
||||
local node = jid_split(j)
|
||||
return node
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
package.preload['util.cache'] = function()
|
||||
return {
|
||||
new = function(_size)
|
||||
local t = {}
|
||||
return {
|
||||
get = function(_, k) return t[k] end,
|
||||
set = function(_, k, v) t[k] = v end,
|
||||
}
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
package.preload['util.array'] = function()
|
||||
local Array_mt = {}
|
||||
Array_mt.__index = Array_mt
|
||||
function Array_mt:push(v)
|
||||
self[#self + 1] = v
|
||||
return self
|
||||
end
|
||||
return function()
|
||||
return setmetatable({}, Array_mt)
|
||||
end
|
||||
end
|
||||
|
||||
-- Modules used only by functions we do not test — empty stubs suffice.
|
||||
package.preload['util.stanza'] = function() return {} end
|
||||
package.preload['util.timer'] = function() return { add_task = function() end } end
|
||||
package.preload['util.async'] = function() return {} end
|
||||
package.preload['net.http'] = function() return {} end
|
||||
package.preload['net.http.server'] = function() return {} end
|
||||
package.preload['core.usermanager'] = function() return {} end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Global stubs required by util.lib.lua at module load time
|
||||
--
|
||||
-- Busted sandboxes spec files via setfenv: bare assignments like
|
||||
-- module = {...}
|
||||
-- go into the busted context env, not _G. But dofile() always runs chunks
|
||||
-- in _G. So any global that util.lib.lua reads at load time must be written
|
||||
-- directly into _G via _G.key = value.
|
||||
-- package.preload is exempt — setting a key there mutates the real table.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
_G.prosody = { version = "test", platform = "posix" }
|
||||
_G.hosts = {}
|
||||
|
||||
local mod = { host = "test.example.com" }
|
||||
|
||||
mod.log = function() end
|
||||
mod.get_option_string = function(_, _key, default) return default end
|
||||
|
||||
mod.get_option_set = function(_, key, default)
|
||||
-- Populate the sets inspected by is_moderated() with test fixtures.
|
||||
if key == "allowners_moderated_subdomains" then
|
||||
return new_set({ "moderated-subdomain" })
|
||||
elseif key == "allowners_moderated_rooms" then
|
||||
return new_set({ "moderatedroom" })
|
||||
end
|
||||
return new_set(type(default) == 'table' and default or {})
|
||||
end
|
||||
|
||||
mod.get_option_inherited_set = function(_, _key, default)
|
||||
return new_set(type(default) == 'table' and default or {})
|
||||
end
|
||||
|
||||
mod.context = function(_, _host)
|
||||
return {
|
||||
get_option_set = function(_, _key, default)
|
||||
return new_set(type(default) == 'table' and default or {})
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
_G.module = mod
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Load the module under test
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local ok, M = pcall(dofile, "util.lib.lua")
|
||||
if not ok then
|
||||
describe("util.lib", function()
|
||||
it("skipped — failed to load", function()
|
||||
pending(tostring(M):match("([^\n]+)") or tostring(M))
|
||||
end)
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
-- Convenience: muc_domain derived from the defaults above
|
||||
local MUC_DOMAIN = "conference.test.example.com"
|
||||
local MUC_BASE = "test.example.com"
|
||||
local MUC_PREFIX = "conference"
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Tests
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
describe("util.lib", function()
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("starts_with", function()
|
||||
it("returns false for nil string", function()
|
||||
assert.is_false(M.starts_with(nil, "x"))
|
||||
end)
|
||||
|
||||
it("returns true when string starts with prefix", function()
|
||||
assert.is_true(M.starts_with("hello world", "hello"))
|
||||
end)
|
||||
|
||||
it("returns false when string does not start with prefix", function()
|
||||
assert.is_false(M.starts_with("hello", "world"))
|
||||
end)
|
||||
|
||||
it("returns true for empty prefix", function()
|
||||
assert.is_true(M.starts_with("hello", ""))
|
||||
end)
|
||||
|
||||
it("returns true when string equals prefix exactly", function()
|
||||
assert.is_true(M.starts_with("abc", "abc"))
|
||||
end)
|
||||
|
||||
it("returns false when prefix is longer than string", function()
|
||||
assert.is_false(M.starts_with("hi", "hello"))
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("ends_with", function()
|
||||
it("returns false for nil string", function()
|
||||
assert.is_false(M.ends_with(nil, "x"))
|
||||
end)
|
||||
|
||||
it("returns true when string ends with suffix", function()
|
||||
assert.is_true(M.ends_with("hello", "llo"))
|
||||
end)
|
||||
|
||||
it("returns false when string does not end with suffix", function()
|
||||
assert.is_false(M.ends_with("hello", "world"))
|
||||
end)
|
||||
|
||||
it("returns true for empty suffix", function()
|
||||
assert.is_true(M.ends_with("hello", ""))
|
||||
end)
|
||||
|
||||
it("returns true when string equals suffix exactly", function()
|
||||
assert.is_true(M.ends_with("abc", "abc"))
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("starts_with_one_of", function()
|
||||
it("returns false for nil string", function()
|
||||
assert.is_false(M.starts_with_one_of(nil, { "a" }))
|
||||
end)
|
||||
|
||||
it("returns false for nil prefixes", function()
|
||||
assert.is_false(M.starts_with_one_of("hello", nil))
|
||||
end)
|
||||
|
||||
it("matches against a plain table of prefixes", function()
|
||||
assert.is_truthy(M.starts_with_one_of("recorder@example.com", { "recorder@", "jibri@" }))
|
||||
end)
|
||||
|
||||
it("returns false when no prefix in table matches", function()
|
||||
assert.is_false(M.starts_with_one_of("user@example.com", { "recorder@", "jibri@" }))
|
||||
end)
|
||||
|
||||
it("matches against a Set of prefixes", function()
|
||||
local prefixes = new_set({ "recorder@", "jibri@" })
|
||||
assert.is_truthy(M.starts_with_one_of("recorder@example.com", prefixes))
|
||||
end)
|
||||
|
||||
it("returns false when no prefix in Set matches", function()
|
||||
local prefixes = new_set({ "recorder@", "jibri@" })
|
||||
assert.is_false(M.starts_with_one_of("user@example.com", prefixes))
|
||||
end)
|
||||
|
||||
it("returns the matched prefix value (table)", function()
|
||||
assert.equal("rec@", M.starts_with_one_of("rec@host", { "rec@", "other@" }))
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("is_healthcheck_room", function()
|
||||
it("recognises a jicofo health-check room", function()
|
||||
assert.is_true(M.is_healthcheck_room("__jicofo-health-check-abc@conference.example.com"))
|
||||
end)
|
||||
|
||||
it("recognises the bare prefix itself", function()
|
||||
assert.is_true(M.is_healthcheck_room("__jicofo-health-check"))
|
||||
end)
|
||||
|
||||
it("returns false for a regular room", function()
|
||||
assert.is_false(M.is_healthcheck_room("myroom@conference.example.com"))
|
||||
end)
|
||||
|
||||
it("returns false for nil", function()
|
||||
assert.is_false(M.is_healthcheck_room(nil))
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("is_feature_allowed", function()
|
||||
it("returns is_moderator when features table is absent", function()
|
||||
assert.is_true(M.is_feature_allowed("feature", nil, true))
|
||||
assert.is_false(M.is_feature_allowed("feature", nil, false))
|
||||
end)
|
||||
|
||||
it("returns true when feature value is the string 'true'", function()
|
||||
assert.is_true(M.is_feature_allowed("f", { f = "true" }, false))
|
||||
end)
|
||||
|
||||
it("returns true when feature value is boolean true", function()
|
||||
assert.is_true(M.is_feature_allowed("f", { f = true }, false))
|
||||
end)
|
||||
|
||||
it("returns false when feature is present but set to false", function()
|
||||
assert.is_false(M.is_feature_allowed("f", { f = false }, true))
|
||||
end)
|
||||
|
||||
it("returns false when feature is absent from table", function()
|
||||
assert.is_false(M.is_feature_allowed("f", { g = "true" }, false))
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("extract_subdomain", function()
|
||||
it("returns nil subdomain and the node itself when no brackets", function()
|
||||
local subdomain, room, customer_id = M.extract_subdomain("myroom")
|
||||
assert.is_nil(subdomain)
|
||||
assert.equal("myroom", room)
|
||||
assert.is_nil(customer_id)
|
||||
end)
|
||||
|
||||
it("extracts subdomain and room name from [sub]room format", function()
|
||||
local subdomain, room = M.extract_subdomain("[foo]myroom")
|
||||
assert.equal("foo", subdomain)
|
||||
assert.equal("myroom", room)
|
||||
end)
|
||||
|
||||
it("extracts subdomain from vpaas magic cookie node", function()
|
||||
local subdomain, room, customer_id =
|
||||
M.extract_subdomain("[vpaas-magic-cookie-abc123]myroom")
|
||||
assert.equal("vpaas-magic-cookie-abc123", subdomain)
|
||||
assert.equal("myroom", room)
|
||||
assert.equal("abc123", customer_id)
|
||||
end)
|
||||
|
||||
it("returns no customer_id for non-vpaas subdomain", function()
|
||||
local subdomain, room, customer_id = M.extract_subdomain("[tenant]myroom")
|
||||
assert.equal("tenant", subdomain)
|
||||
assert.equal("myroom", room)
|
||||
assert.is_nil(customer_id)
|
||||
end)
|
||||
|
||||
it("returns nil subdomain for empty brackets (pattern requires >=1 char)", function()
|
||||
-- "[]room" does not match ^%[([^%]]+)%](.+)$
|
||||
local subdomain, room = M.extract_subdomain("[]room")
|
||||
assert.is_nil(subdomain)
|
||||
assert.equal("[]room", room)
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
-- room_jid_split_subdomain / room_jid_match_rewrite /
|
||||
-- internal_room_jid_match_rewrite
|
||||
--
|
||||
-- With the default stubs:
|
||||
-- muc_domain_prefix = "conference"
|
||||
-- muc_domain_base = "test.example.com"
|
||||
-- target_subdomain_pattern matches conference.<sub>.test.example.com
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("room_jid_split_subdomain", function()
|
||||
it("extracts node, host, resource and subdomain from a multidomain JID", function()
|
||||
local node, host, resource, subdomain =
|
||||
M.room_jid_split_subdomain("room1@conference.foo.test.example.com/res")
|
||||
assert.equal("room1", node)
|
||||
assert.equal("conference.foo.test.example.com", host)
|
||||
assert.equal("res", resource)
|
||||
assert.equal("foo", subdomain)
|
||||
end)
|
||||
|
||||
it("returns nil subdomain for a non-multidomain MUC JID", function()
|
||||
local node, host, _, subdomain =
|
||||
M.room_jid_split_subdomain("room1@conference.test.example.com")
|
||||
assert.equal("room1", node)
|
||||
assert.equal("conference.test.example.com", host)
|
||||
assert.is_nil(subdomain)
|
||||
end)
|
||||
|
||||
it("handles JID with no node (bare host)", function()
|
||||
local node, host, _, subdomain =
|
||||
M.room_jid_split_subdomain("conference.foo.test.example.com")
|
||||
assert.is_nil(node)
|
||||
assert.equal("conference.foo.test.example.com", host)
|
||||
assert.equal("foo", subdomain)
|
||||
end)
|
||||
|
||||
-- Regression: target_subdomain_pattern used an unescaped '.' between the
|
||||
-- prefix and the subdomain capture group. In Lua patterns '.' matches any
|
||||
-- character, so "conference-foo.test.example.com" (hyphen) was incorrectly
|
||||
-- treated as a subdomain JID with subdomain "foo". The correct pattern
|
||||
-- requires a literal dot: "conference%.foo.test.example.com".
|
||||
it("returns nil subdomain for a hyphenated-prefix host (regression: unescaped dot)", function()
|
||||
-- 'conference-foo' shares the same base domain but is NOT a subdomain
|
||||
-- MUC component; the separator is a hyphen, not a dot.
|
||||
local _, _, _, subdomain =
|
||||
M.room_jid_split_subdomain("room@conference-foo.test.example.com")
|
||||
assert.is_nil(subdomain)
|
||||
end)
|
||||
|
||||
it("returns nil subdomain when only the suffix differs by a non-dot character", function()
|
||||
-- e.g. conference.foo-test.example.com — the subdomain capture is
|
||||
-- [^%.]+, so a hyphen inside 'foo-test' is fine, but this test
|
||||
-- confirms the prefix boundary is strict.
|
||||
local _, _, _, subdomain =
|
||||
M.room_jid_split_subdomain("room@conferenceXfoo.test.example.com")
|
||||
assert.is_nil(subdomain)
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("room_jid_match_rewrite", function()
|
||||
it("rewrites a multidomain JID to internal [sub]room@muc_domain form", function()
|
||||
local result = M.room_jid_match_rewrite("room1@conference.foo.test.example.com")
|
||||
assert.equal("[foo]room1@" .. MUC_DOMAIN, result)
|
||||
end)
|
||||
|
||||
it("preserves the resource in the rewritten JID", function()
|
||||
local result = M.room_jid_match_rewrite("room1@conference.foo.test.example.com/r")
|
||||
assert.equal("[foo]room1@" .. MUC_DOMAIN .. "/r", result)
|
||||
end)
|
||||
|
||||
it("returns the JID unchanged when there is no subdomain", function()
|
||||
local jid = "room1@" .. MUC_DOMAIN
|
||||
assert.equal(jid, M.room_jid_match_rewrite(jid))
|
||||
end)
|
||||
|
||||
it("handles host-only JID (no node) — stores id in roomless_iqs and rewrites host", function()
|
||||
local stanza = { attr = { id = "iq-roomless-1", to = "conference.foo.test.example.com" } }
|
||||
local result = M.room_jid_match_rewrite("conference.foo.test.example.com", stanza)
|
||||
-- No node → only host is rewritten
|
||||
assert.equal(MUC_DOMAIN, result)
|
||||
end)
|
||||
|
||||
-- Regression: unescaped '.' in target_subdomain_pattern caused
|
||||
-- "conference-internal.test.example.com" to be treated as a subdomain
|
||||
-- MUC host and rewritten to "[internal]room@conference.test.example.com".
|
||||
it("does not rewrite a JID whose host uses a hyphen after the prefix (regression)", function()
|
||||
local jid = "room@conference-internal.test.example.com"
|
||||
assert.equal(jid, M.room_jid_match_rewrite(jid))
|
||||
end)
|
||||
|
||||
it("does not rewrite a JID whose host uses an arbitrary non-dot separator after the prefix", function()
|
||||
local jid = "room@conferenceXinternal.test.example.com"
|
||||
assert.equal(jid, M.room_jid_match_rewrite(jid))
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("internal_room_jid_match_rewrite", function()
|
||||
it("rewrites an internal [sub]room JID back to pretty conference.sub.base/room form", function()
|
||||
local result = M.internal_room_jid_match_rewrite("[foo]room1@" .. MUC_DOMAIN)
|
||||
assert.equal("room1@" .. MUC_PREFIX .. ".foo." .. MUC_BASE, result)
|
||||
end)
|
||||
|
||||
it("preserves resource in the rewritten JID", function()
|
||||
local result = M.internal_room_jid_match_rewrite("[foo]room1@" .. MUC_DOMAIN .. "/res")
|
||||
assert.equal("room1@" .. MUC_PREFIX .. ".foo." .. MUC_BASE .. "/res", result)
|
||||
end)
|
||||
|
||||
it("returns JID unchanged when host is not the MUC domain", function()
|
||||
local jid = "room1@other.domain.com"
|
||||
assert.equal(jid, M.internal_room_jid_match_rewrite(jid))
|
||||
end)
|
||||
|
||||
it("returns JID unchanged when node lacks [sub] prefix", function()
|
||||
local jid = "room1@" .. MUC_DOMAIN
|
||||
assert.equal(jid, M.internal_room_jid_match_rewrite(jid))
|
||||
end)
|
||||
|
||||
it("resolves a roomless IQ stored by room_jid_match_rewrite", function()
|
||||
local stanza = { attr = { id = "iq-roomless-2", to = "conference.bar.test.example.com" } }
|
||||
-- Trigger storage of the roomless IQ
|
||||
M.room_jid_match_rewrite("conference.bar.test.example.com", stanza)
|
||||
-- internal_room_jid_match_rewrite should return the stored value
|
||||
local result = M.internal_room_jid_match_rewrite("anything@" .. MUC_DOMAIN, stanza)
|
||||
assert.equal("conference.bar.test.example.com", result)
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("table helpers", function()
|
||||
|
||||
describe("table_shallow_copy", function()
|
||||
it("copies all key-value pairs", function()
|
||||
local orig = { a = 1, b = "x", c = true }
|
||||
local copy = M.table_shallow_copy(orig)
|
||||
assert.equal(1, copy.a)
|
||||
assert.equal("x", copy.b)
|
||||
assert.equal(true, copy.c)
|
||||
end)
|
||||
|
||||
it("returns a distinct table (not the same reference)", function()
|
||||
local orig = { x = 1 }
|
||||
local copy = M.table_shallow_copy(orig)
|
||||
assert.not_equal(orig, copy)
|
||||
end)
|
||||
|
||||
it("does not deep-copy nested tables", function()
|
||||
local inner = { z = 9 }
|
||||
local orig = { a = inner }
|
||||
local copy = M.table_shallow_copy(orig)
|
||||
assert.equal(inner, copy.a) -- same reference
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
describe("table_find", function()
|
||||
it("returns the index of a found value", function()
|
||||
assert.equal(2, M.table_find({ "a", "b", "c" }, "b"))
|
||||
end)
|
||||
|
||||
it("returns nil when value is not present", function()
|
||||
assert.is_nil(M.table_find({ "a", "b" }, "z"))
|
||||
end)
|
||||
|
||||
it("returns nil for nil table", function()
|
||||
assert.is_nil(M.table_find(nil, "x"))
|
||||
end)
|
||||
|
||||
it("returns nil when searching for nil", function()
|
||||
assert.is_nil(M.table_find({ "a", "b" }, nil))
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
describe("table_add", function()
|
||||
it("appends all values from t2 onto t1", function()
|
||||
local t1 = { "a", "b" }
|
||||
local t2 = { "c", "d" }
|
||||
M.table_add(t1, t2)
|
||||
assert.same({ "a", "b", "c", "d" }, t1)
|
||||
end)
|
||||
|
||||
it("leaves t1 unchanged when t2 is empty", function()
|
||||
local t1 = { "a" }
|
||||
M.table_add(t1, {})
|
||||
assert.same({ "a" }, t1)
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
describe("table_compare", function()
|
||||
it("reports removed keys", function()
|
||||
local removed, added, modified = M.table_compare({ a = 1, b = 2 }, { a = 1 })
|
||||
assert.equal(1, #removed)
|
||||
assert.equal("b", removed[1])
|
||||
assert.equal(0, #added)
|
||||
assert.equal(0, #modified)
|
||||
end)
|
||||
|
||||
it("reports added keys", function()
|
||||
local removed, added, modified = M.table_compare({ a = 1 }, { a = 1, b = 2 })
|
||||
assert.equal(0, #removed)
|
||||
assert.equal(1, #added)
|
||||
assert.equal("b", added[1])
|
||||
assert.equal(0, #modified)
|
||||
end)
|
||||
|
||||
it("reports modified values", function()
|
||||
local removed, added, modified = M.table_compare({ a = 1 }, { a = 99 })
|
||||
assert.equal(0, #removed)
|
||||
assert.equal(0, #added)
|
||||
assert.equal(1, #modified)
|
||||
assert.equal("a", modified[1])
|
||||
end)
|
||||
|
||||
it("returns three empty lists for identical tables", function()
|
||||
local removed, added, modified = M.table_compare({ x = 1 }, { x = 1 })
|
||||
assert.equal(0, #removed)
|
||||
assert.equal(0, #added)
|
||||
assert.equal(0, #modified)
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
describe("table_equals", function()
|
||||
it("returns true for two identical tables", function()
|
||||
assert.is_true(M.table_equals({ a = 1, b = 2 }, { a = 1, b = 2 }))
|
||||
end)
|
||||
|
||||
it("returns false when values differ", function()
|
||||
assert.is_false(M.table_equals({ a = 1 }, { a = 2 }))
|
||||
end)
|
||||
|
||||
it("returns false when keys differ", function()
|
||||
assert.is_false(M.table_equals({ a = 1 }, { b = 1 }))
|
||||
end)
|
||||
|
||||
it("returns true when both arguments are nil", function()
|
||||
assert.is_true(M.table_equals(nil, nil))
|
||||
end)
|
||||
|
||||
it("returns false when only one argument is nil", function()
|
||||
assert.is_false(M.table_equals(nil, {}))
|
||||
assert.is_false(M.table_equals({}, nil))
|
||||
end)
|
||||
end)
|
||||
|
||||
end) -- table helpers
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("split_string", function()
|
||||
it("splits a simple comma-delimited string", function()
|
||||
local result = M.split_string("a,b,c", ",")
|
||||
assert.equal("a", result[1])
|
||||
assert.equal("b", result[2])
|
||||
assert.equal("c", result[3])
|
||||
end)
|
||||
|
||||
it("produces a single element when delimiter is absent", function()
|
||||
local result = M.split_string("hello", ",")
|
||||
assert.equal("hello", result[1])
|
||||
assert.is_nil(result[2])
|
||||
end)
|
||||
|
||||
it("produces empty strings at boundaries", function()
|
||||
local result = M.split_string(",a,", ",")
|
||||
assert.equal("", result[1])
|
||||
assert.equal("a", result[2])
|
||||
assert.equal("", result[3])
|
||||
end)
|
||||
|
||||
it("handles multi-character delimiter", function()
|
||||
local result = M.split_string("x::y::z", "::")
|
||||
assert.equal("x", result[1])
|
||||
assert.equal("y", result[2])
|
||||
assert.equal("z", result[3])
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("get_sip_jibri_email_prefix", function()
|
||||
it("returns nil for nil email", function()
|
||||
assert.is_nil(M.get_sip_jibri_email_prefix(nil))
|
||||
end)
|
||||
|
||||
it("returns nil for unrecognised email", function()
|
||||
assert.is_nil(M.get_sip_jibri_email_prefix("user@example.com"))
|
||||
end)
|
||||
|
||||
it("recognises outbound-sip-jibri@ prefix", function()
|
||||
assert.equal("outbound-sip-jibri@",
|
||||
M.get_sip_jibri_email_prefix("outbound-sip-jibri@example.com"))
|
||||
end)
|
||||
|
||||
it("recognises sipjibriouta@ prefix", function()
|
||||
assert.equal("sipjibriouta@",
|
||||
M.get_sip_jibri_email_prefix("sipjibriouta@example.com"))
|
||||
end)
|
||||
|
||||
it("recognises sipjibrioutb@ prefix", function()
|
||||
assert.equal("sipjibrioutb@",
|
||||
M.get_sip_jibri_email_prefix("sipjibrioutb@example.com"))
|
||||
end)
|
||||
|
||||
it("recognises inbound-sip-jibri@ prefix", function()
|
||||
assert.equal("inbound-sip-jibri@",
|
||||
M.get_sip_jibri_email_prefix("inbound-sip-jibri@example.com"))
|
||||
end)
|
||||
|
||||
it("recognises sipjibriina@ prefix", function()
|
||||
assert.equal("sipjibriina@",
|
||||
M.get_sip_jibri_email_prefix("sipjibriina@example.com"))
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("is_jibri", function()
|
||||
it("returns truthy for recorder@ prefixed JID string", function()
|
||||
assert.is_truthy(M.is_jibri("recorder@recorder.example.com"))
|
||||
end)
|
||||
|
||||
it("returns truthy for jibria@ prefixed JID string", function()
|
||||
assert.is_truthy(M.is_jibri("jibria@recorder.example.com"))
|
||||
end)
|
||||
|
||||
it("returns truthy for jibrib@ prefixed JID string", function()
|
||||
assert.is_truthy(M.is_jibri("jibrib@recorder.example.com"))
|
||||
end)
|
||||
|
||||
it("returns false for a non-recorder JID string", function()
|
||||
assert.is_false(M.is_jibri("user@example.com"))
|
||||
end)
|
||||
|
||||
it("accepts an occupant object and inspects its .jid field", function()
|
||||
local occupant = { jid = "recorder@recorder.example.com" }
|
||||
assert.is_truthy(M.is_jibri(occupant))
|
||||
end)
|
||||
|
||||
it("returns false for occupant object with non-recorder JID", function()
|
||||
local occupant = { jid = "user@example.com" }
|
||||
assert.is_false(M.is_jibri(occupant))
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("is_transcriber", function()
|
||||
it("returns truthy for transcriber@ JID", function()
|
||||
assert.is_truthy(M.is_transcriber("transcriber@recorder.example.com"))
|
||||
end)
|
||||
|
||||
it("returns truthy for transcribera@ JID", function()
|
||||
assert.is_truthy(M.is_transcriber("transcribera@recorder.example.com"))
|
||||
end)
|
||||
|
||||
it("returns truthy for transcriberb@ JID", function()
|
||||
assert.is_truthy(M.is_transcriber("transcriberb@recorder.example.com"))
|
||||
end)
|
||||
|
||||
it("returns false for a regular user JID", function()
|
||||
assert.is_false(M.is_transcriber("user@example.com"))
|
||||
end)
|
||||
|
||||
it("returns false for nil", function()
|
||||
assert.is_false(M.is_transcriber(nil))
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("is_vpaas", function()
|
||||
-- muc_domain = "conference.test.example.com" (derived from defaults)
|
||||
|
||||
it("returns false for nil room", function()
|
||||
assert.is_false(M.is_vpaas(nil))
|
||||
end)
|
||||
|
||||
it("returns true for a room whose tenant starts with vpaas-magic-cookie-", function()
|
||||
local room = { jid = "[vpaas-magic-cookie-xyz]myroom@" .. MUC_DOMAIN }
|
||||
assert.is_true(M.is_vpaas(room))
|
||||
end)
|
||||
|
||||
it("caches the result in room.is_vpaas", function()
|
||||
local room = { jid = "[vpaas-magic-cookie-xyz]myroom@" .. MUC_DOMAIN }
|
||||
M.is_vpaas(room)
|
||||
assert.is_true(room.is_vpaas)
|
||||
end)
|
||||
|
||||
it("returns false for a room on the wrong MUC domain", function()
|
||||
local room = { jid = "[vpaas-magic-cookie-xyz]myroom@other.domain.com" }
|
||||
assert.is_false(M.is_vpaas(room))
|
||||
end)
|
||||
|
||||
it("returns false for a room without a tenant bracket prefix", function()
|
||||
local room = { jid = "myroom@" .. MUC_DOMAIN }
|
||||
assert.is_false(M.is_vpaas(room))
|
||||
end)
|
||||
|
||||
it("returns false for a room whose tenant does not start with vpaas-magic-cookie-", function()
|
||||
local room = { jid = "[othertenant]myroom@" .. MUC_DOMAIN }
|
||||
assert.is_false(M.is_vpaas(room))
|
||||
end)
|
||||
|
||||
it("returns cached is_vpaas value when present", function()
|
||||
local room = { jid = "[vpaas-magic-cookie-xyz]myroom@" .. MUC_DOMAIN, is_vpaas = false }
|
||||
-- Cached false should be returned without re-evaluating the JID
|
||||
assert.is_false(M.is_vpaas(room))
|
||||
end)
|
||||
end)
|
||||
|
||||
-- -----------------------------------------------------------------------
|
||||
describe("is_moderated", function()
|
||||
-- moderated_subdomains = {"moderated-subdomain"} (configured in stub)
|
||||
-- moderated_rooms = {"moderatedroom"}
|
||||
|
||||
it("returns true and room/subdomain for a moderated subdomain", function()
|
||||
local result, room_name, subdomain =
|
||||
M.is_moderated("[moderated-subdomain]someroom@" .. MUC_DOMAIN)
|
||||
assert.is_true(result)
|
||||
assert.equal("someroom", room_name)
|
||||
assert.equal("moderated-subdomain", subdomain)
|
||||
end)
|
||||
|
||||
it("returns true and room name for a moderated room (no subdomain)", function()
|
||||
local result, room_name, subdomain =
|
||||
M.is_moderated("moderatedroom@" .. MUC_DOMAIN)
|
||||
assert.is_true(result)
|
||||
assert.equal("moderatedroom", room_name)
|
||||
assert.is_nil(subdomain)
|
||||
end)
|
||||
|
||||
it("returns false for an unmoderated room with a subdomain", function()
|
||||
assert.is_false(M.is_moderated("[other-subdomain]room@" .. MUC_DOMAIN))
|
||||
end)
|
||||
|
||||
it("returns false for an unmoderated room without a subdomain", function()
|
||||
assert.is_false(M.is_moderated("normalroom@" .. MUC_DOMAIN))
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("is_focus", function()
|
||||
|
||||
it("nick ending in /focus returns true", function()
|
||||
assert.is_true(M.is_focus("room@conference.example.com/focus"))
|
||||
end)
|
||||
|
||||
it("plain /focus string returns true", function()
|
||||
assert.is_true(M.is_focus("/focus"))
|
||||
end)
|
||||
|
||||
it("regular nick returns false", function()
|
||||
assert.is_false(M.is_focus("room@conference.example.com/user1"))
|
||||
end)
|
||||
|
||||
it("nick containing focus but not ending in /focus returns false", function()
|
||||
assert.is_false(M.is_focus("room@conference.example.com/focususer"))
|
||||
end)
|
||||
|
||||
it("bare word focus without slash returns false", function()
|
||||
assert.is_false(M.is_focus("focus"))
|
||||
end)
|
||||
|
||||
it("empty string returns false", function()
|
||||
assert.is_false(M.is_focus(""))
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
describe("is_focus_nick", function()
|
||||
|
||||
it("returns true for the bare string 'focus'", function()
|
||||
assert.is_true(M.is_focus_nick("focus"))
|
||||
end)
|
||||
|
||||
it("returns false for a regular nick", function()
|
||||
assert.is_false(M.is_focus_nick("user1"))
|
||||
end)
|
||||
|
||||
it("returns false for a nick that contains focus but is not exactly focus", function()
|
||||
assert.is_false(M.is_focus_nick("focususer"))
|
||||
end)
|
||||
|
||||
it("returns false for a full MUC JID (slash-qualified)", function()
|
||||
assert.is_false(M.is_focus_nick("room@conference.example.com/focus"))
|
||||
end)
|
||||
|
||||
it("returns false for empty string", function()
|
||||
assert.is_false(M.is_focus_nick(""))
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
describe("is_focus_jid", function()
|
||||
|
||||
it("returns true for focus@auth.example.com", function()
|
||||
assert.is_true(M.is_focus_jid("focus@auth.example.com"))
|
||||
end)
|
||||
|
||||
it("returns true for focus@auth.example.com/resource", function()
|
||||
assert.is_true(M.is_focus_jid("focus@auth.example.com/res123"))
|
||||
end)
|
||||
|
||||
it("returns false for a regular user JID", function()
|
||||
assert.is_false(M.is_focus_jid("user@auth.example.com"))
|
||||
end)
|
||||
|
||||
it("returns false for JID whose node contains but is not exactly focus", function()
|
||||
assert.is_false(M.is_focus_jid("focususer@auth.example.com"))
|
||||
end)
|
||||
|
||||
it("returns false for a host-only JID (no node)", function()
|
||||
assert.is_false(M.is_focus_jid("auth.example.com"))
|
||||
end)
|
||||
|
||||
it("returns false for nil", function()
|
||||
assert.is_false(M.is_focus_jid(nil))
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
describe("build_room_address", function()
|
||||
-- muc_domain_prefix = "conference" (set in module stub above)
|
||||
|
||||
it("builds room JID with no subdomain", function()
|
||||
assert.equal("myroom@conference.example.com",
|
||||
M.build_room_address("myroom", "example.com", nil))
|
||||
end)
|
||||
|
||||
it("prepends [subdomain] when subdomain is given", function()
|
||||
assert.equal("[tenant]myroom@conference.example.com",
|
||||
M.build_room_address("myroom", "example.com", "tenant"))
|
||||
end)
|
||||
|
||||
it("treats empty-string subdomain as no subdomain", function()
|
||||
assert.equal("myroom@conference.example.com",
|
||||
M.build_room_address("myroom", "example.com", ""))
|
||||
end)
|
||||
|
||||
it("uses the configured muc_domain_prefix (conference)", function()
|
||||
assert.equal("room@conference.meet.jit.si",
|
||||
M.build_room_address("room", "meet.jit.si", nil))
|
||||
end)
|
||||
|
||||
it("combines subdomain with multi-part base domain", function()
|
||||
assert.equal("[vpaas-magic-cookie-abc]r@conference.meet.jit.si",
|
||||
M.build_room_address("r", "meet.jit.si", "vpaas-magic-cookie-abc"))
|
||||
end)
|
||||
|
||||
end)
|
||||
|
||||
end) -- util.lib
|
||||
Reference in New Issue
Block a user