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

670 lines
27 KiB
Lua

-- 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