Files
upstream-jitsi-meet/tests/prosody/mod_muc_auth_ban_spec.js
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

263 lines
12 KiB
JavaScript

import assert from 'assert';
import { getContainer } from './helpers/container.js';
import { mintAsapToken } from './helpers/jwt.js';
import { setAccessManagerResponse } from './helpers/test_observer.js';
import { createXmppClient } from './helpers/xmpp_client.js';
// VPaaS tenant prefix — mod_muc_auth_ban only calls the access manager for
// sessions whose jitsi_web_query_prefix starts with this string.
// The prefix is set from the ?prefix= WebSocket URL param by mod_jitsi_session.
const VPAAS_PREFIX = 'vpaas-magic-cookie-test';
// Tokens are RS256 (deterministic): same payload in the same second → same
// token string. Use a per-test jti so each test gets a unique token and cannot
// accidentally match a token that was cached as banned by an earlier test.
let _tokenCounter = 0;
const freshToken = () => mintAsapToken({ room: '*',
jti: `ban-test-${++_tokenCounter}` });
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Creates an XMPP client that identifies as a VPaaS session.
* Passes ?prefix=<VPAAS_PREFIX>&token=<token> in the WebSocket URL so that
* mod_jitsi_session populates jitsi_web_query_prefix, which mod_muc_auth_ban
* uses to decide whether to call the external access manager.
*
* @param {string} token A valid login JWT (RS256 / HS256).
* @returns {Promise<XmppTestClient>}
*/
function createVpaasClient(token) {
return createXmppClient({
params: { prefix: VPAAS_PREFIX,
token }
});
}
/**
* Asserts that a VPaaS session is banned, whichever way the ban surfaces.
*
* mod_muc_auth_ban's HTTP callback calls session:close(), and where that lands
* depends on when the access manager answers. Answering within the same event
* loop tick as the SASL auth refuses the connection outright; answering a tick
* later lets the session establish and then closes it. Both are the same ban,
* and which one happens is timing, so accept either rather than only the first.
*
* @param {string} token A login JWT the access manager will reject.
* @param {string} message Assertion message.
* @returns {Promise<void>}
*/
async function assertBanned(token, message) {
let client;
try {
client = await createVpaasClient(token);
} catch (e) {
// refused during SASL
assert.match(String(e), /not-allowed/, message);
return;
}
// established, so the close must follow
const disconnected = await client.waitForDisconnect(5000).then(() => true, () => false);
if (!disconnected) {
await client.disconnect();
}
assert.ok(disconnected, message);
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('mod_muc_auth_ban', () => {
afterEach(async () => {
// Reset the mock access manager to the default (allow, HTTP 200)
// so that each test starts from a clean state.
await setAccessManagerResponse({ access: true,
status: 200 });
});
// ── No token ─────────────────────────────────────────────────────────────
//
// mod_muc_auth_ban checks session.auth_token first. When nil it returns
// nil (not false), so the ban-check event has no effect and auth proceeds.
it('user without a token is allowed (ban check is a no-op)', async () => {
// No token param → session.auth_token is nil → shouldAllow returns nil.
// VirtualHost "localhost" has allow_empty_token = true so auth still succeeds.
const c = await createXmppClient();
await c.disconnect();
});
// ── Non-VPaaS tenant ─────────────────────────────────────────────────────
//
// mod_muc_auth_ban returns true immediately for tenants whose prefix does
// NOT start with "vpaas-magic-cookie-". The access manager is never called.
it('non-VPaaS user with a valid token is allowed without an HTTP check', async () => {
// Configure the mock to return an error — if the module calls it for
// non-VPaaS sessions this test will catch that regression.
await setAccessManagerResponse({ status: 500 });
const token = freshToken();
// No ?prefix= param → jitsi_web_query_prefix is "" → bypasses VPaaS check.
const c = await createXmppClient({ params: { token } });
await c.disconnect();
});
// ── VPaaS — access granted ────────────────────────────────────────────────
it('VPaaS user is allowed when access manager returns access=true', async () => {
await setAccessManagerResponse({ access: true });
const token = freshToken();
const c = await createVpaasClient(token);
// Stay connected for a moment to confirm the async HTTP response does
// not trigger a session close.
await new Promise(resolve => setTimeout(resolve, 500));
await c.disconnect();
});
// ── VPaaS — access denied ─────────────────────────────────────────────────
//
// When access=false, mod_muc_auth_ban's HTTP callback calls session:close()
// and caches the token. The access manager runs on the same Prosody process
// (loopback), so the callback usually resolves within the same event loop
// tick as the SASL auth and the ban surfaces as a SASL failure, but under
// load it can resolve a tick later and close an established session instead.
// assertBanned accepts either.
it('VPaaS user is rejected when access manager returns access=false', async () => {
await setAccessManagerResponse({ access: false });
await assertBanned(freshToken(),
'VPaaS user must be rejected when access manager returns access=false');
});
// ── Cached ban ────────────────────────────────────────────────────────────
//
// When a token is rejected (access=false), mod_muc_auth_ban caches it with a
// 5-minute TTL. Subsequent connections with the same token hit the Lua LRU
// cache (no HTTP request) and are rejected immediately — even after the mock
// access manager is reset to allow. This verifies the cache is active.
it('cached banned token is rejected even when access manager is reset to allow', async () => {
// Step 1: Ban the token — cb fires, token enters the LRU cache.
await setAccessManagerResponse({ access: false });
const token = freshToken();
await assertBanned(token, 'initial ban must be rejected');
// Step 2: Reset the mock to allow. A fresh token must now succeed,
// proving the cache — not the mock response — drives the next rejection.
await setAccessManagerResponse({ access: true });
const fresh = await createVpaasClient(freshToken());
await fresh.disconnect();
// Step 3: Same banned token must still be rejected (cache wins over mock).
await assertBanned(token,
'cached banned token must be rejected even when mock is reset to allow');
});
// ── HTTP error — fail open ────────────────────────────────────────────────
//
// If the access manager returns a non-200 response, mod_muc_auth_ban logs
// a warning and does nothing — the session is NOT closed (fail open).
it('HTTP error from access manager does not ban the user (fail open)', async () => {
await setAccessManagerResponse({ status: 500 });
const token = freshToken();
const c = await createVpaasClient(token);
// Wait long enough for the async HTTP callback to have fired.
await new Promise(resolve => setTimeout(resolve, 500));
// The client must still be connected. waitForDisconnect would reject
// after the timeout — instead, assert the connection is alive by
// verifying disconnect() completes cleanly.
await c.disconnect();
});
// ── Non-JSON 200 — should fail open without crashing ─────────────────────
//
// When the access manager returns HTTP 200 but with a non-JSON body,
// json.decode() returns nil. mod_muc_auth_ban then calls r['access'] on
// nil, which crashes the callback. The crash must not affect the session
// (fail open: user stays connected).
it('non-JSON 200 from access manager does not crash or ban the user (fail open)', async () => {
await setAccessManagerResponse({ nonJson: true });
const token = freshToken();
const c = await createVpaasClient(token);
// Wait long enough for the async HTTP callback to have fired and crashed.
await new Promise(resolve => setTimeout(resolve, 500));
// Session must still be alive despite the callback crash.
const disconnected = await c.waitForDisconnect(300).then(() => true, () => false);
assert.strictEqual(disconnected, false,
'session must remain alive when access manager returns non-JSON 200');
await c.disconnect();
});
// ── Non-JSON 200 — Lua error in callback ─────────────────────────────────
//
// cjson.safe returns nil (not an error) for invalid JSON, so json.decode()
// returns nil when the access manager body is not JSON. Without a nil guard,
// r['access'] on the nil value crashes the callback with "attempt to index
// a nil value". This test catches that crash by asserting that no such error
// appears in the Prosody log during the test window.
it('non-JSON 200 does not produce a Lua error in the HTTP callback', async () => {
const since = Math.floor(Date.now() / 1000);
await setAccessManagerResponse({ nonJson: true });
const token = freshToken();
const c = await createVpaasClient(token);
// Give the async HTTP callback time to fire (and crash if the bug is present).
await new Promise(resolve => setTimeout(resolve, 600));
await c.disconnect();
// Collect Prosody log output from this test's window.
const until = Math.floor(Date.now() / 1000) + 1;
const container = getContainer();
const stream = await container.logs({ since,
until });
const logs = await new Promise((resolve, reject) => {
const chunks = [];
const timer = setTimeout(() => resolve(chunks.join('')), 2000);
stream.on('data', chunk => chunks.push(chunk.toString()));
stream.on('end', () => {
clearTimeout(timer);
resolve(chunks.join(''));
});
stream.on('error', reject);
});
assert.ok(
!logs.includes('attempt to index a nil value'),
'mod_muc_auth_ban HTTP callback must not crash on non-JSON 200 response '
+ '(missing nil guard on json.decode result)'
);
});
});