import assert from 'assert'; import { mintAsapToken, mintSystemToken } from './helpers/jwt.js'; import { sendSystemChatMessage } from './helpers/test_observer.js'; import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js'; const CONFERENCE = 'conference.localhost'; let _roomCounter = 0; const room = () => `system-chat-${++_roomCounter}@${CONFERENCE}`; // ─── Helpers ───────────────────────────────────────────────────────────────── /** * Creates a room with a focus participant. * * @returns {Promise<{roomJid: string, focus: object}>} */ async function createRoom() { const roomJid = room(); const focus = await joinWithFocus(roomJid); return { roomJid, focus }; } /** * Disconnects all provided clients. * * @param {...object} clients - Clients to disconnect. * @returns {Promise} */ async function disconnectAll(...clients) { await Promise.all(clients.map(c => c.disconnect())); } // ─── Tests ──────────────────────────────────────────────────────────────────── describe('mod_system_chat_message', () => { // ── Authentication ─────────────────────────────────────────────────────── // // mod_system_chat_message uses prosody_password_public_key_repo_url as its // key server — a separate key pair from login tokens (asap_key_server). // Auth failures return 401. describe('authentication', () => { it('returns 401 when Authorization header is absent', async () => { const { roomJid, focus } = await createRoom(); try { const { status } = await sendSystemChatMessage( roomJid, [ focus.jid ], 'hello', null, { omitAuth: true }); assert.strictEqual(status, 401); } finally { await disconnectAll(focus); } }); it('returns 401 when a login token is used instead of a system token', async () => { const { roomJid, focus } = await createRoom(); try { const loginToken = mintAsapToken({ room: '*' }); const { status } = await sendSystemChatMessage( roomJid, [ focus.jid ], 'hello', loginToken); assert.strictEqual(status, 401, 'login token must be rejected by the system key server'); } finally { await disconnectAll(focus); } }); it('returns 401 when token is expired', async () => { const { roomJid, focus } = await createRoom(); try { const token = mintSystemToken({}, { expired: true }); const { status } = await sendSystemChatMessage( roomJid, [ focus.jid ], 'hello', token); assert.strictEqual(status, 401); } finally { await disconnectAll(focus); } }); }); // ── Parameter validation ───────────────────────────────────────────────── describe('parameter validation', () => { it('returns 400 when Content-Type is not application/json', async () => { const token = mintSystemToken(); const res = await fetch( 'http://localhost:5280/send-system-chat-message', { method: 'POST', headers: { 'Content-Type': 'text/plain', Authorization: `Bearer ${token}` }, body: '{"room":"r@conference.localhost","connectionJIDs":[],"message":"hi"}' } ); assert.strictEqual(res.status, 400); }); it('returns 400 when body is empty', async () => { const token = mintSystemToken(); const res = await fetch( 'http://localhost:5280/send-system-chat-message', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: '' } ); assert.strictEqual(res.status, 400); }); it('returns 400 when message is missing', async () => { const { roomJid, focus } = await createRoom(); try { const token = mintSystemToken(); const res = await fetch( 'http://localhost:5280/send-system-chat-message', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ room: roomJid, connectionJIDs: [ focus.jid ] }) } ); assert.strictEqual(res.status, 400); } finally { await disconnectAll(focus); } }); it('returns 400 when connectionJIDs is missing', async () => { const { roomJid, focus } = await createRoom(); try { const token = mintSystemToken(); const res = await fetch( 'http://localhost:5280/send-system-chat-message', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ room: roomJid, message: 'hi' }) } ); assert.strictEqual(res.status, 400); } finally { await disconnectAll(focus); } }); it('returns 400 when room is missing', async () => { const token = mintSystemToken(); const res = await fetch( 'http://localhost:5280/send-system-chat-message', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ connectionJIDs: [], message: 'hi' }) } ); assert.strictEqual(res.status, 400); }); }); // ── Room lookup ────────────────────────────────────────────────────────── describe('room lookup', () => { it('returns 404 when room does not exist', async () => { const token = mintSystemToken(); const { status } = await sendSystemChatMessage( `nonexistent@${CONFERENCE}`, [], 'hi', token); assert.strictEqual(status, 404); }); }); // ── Message delivery ───────────────────────────────────────────────────── describe('message delivery', () => { // Filter that matches only system_chat_message payloads, skipping // metadata broadcasts that arrive on the same json-message namespace. const isSystemChat = m => { const el = m.getChild('json-message', 'http://jitsi.org/jitmeet'); if (!el) { return false; } try { return JSON.parse(el.text()).type === 'system_chat_message'; } catch { return false; } }; it('returns 200 and delivers the message to the recipient', async () => { const { roomJid, focus } = await createRoom(); const user = await createXmppClient(); await user.joinRoom(roomJid); try { const token = mintSystemToken(); const text = 'Hello from system'; const { status } = await sendSystemChatMessage( roomJid, [ user.jid ], text, token); assert.strictEqual(status, 200); const msg = await user.waitForMessage(isSystemChat); const jsonMessage = msg.getChild('json-message', 'http://jitsi.org/jitmeet'); assert.ok(jsonMessage, 'message must contain a json-message element'); const payload = JSON.parse(jsonMessage.text()); assert.strictEqual(payload.type, 'system_chat_message'); assert.strictEqual(payload.message, text); } finally { await disconnectAll(focus, user); } }); it('includes displayName in the payload when provided', async () => { const { roomJid, focus } = await createRoom(); const user = await createXmppClient(); await user.joinRoom(roomJid); try { const token = mintSystemToken(); const { status } = await sendSystemChatMessage( roomJid, [ user.jid ], 'hi', token, { displayName: 'System' }); assert.strictEqual(status, 200); const msg = await user.waitForMessage(isSystemChat); const payload = JSON.parse( msg.getChild('json-message', 'http://jitsi.org/jitmeet').text()); assert.strictEqual(payload.displayName, 'System'); } finally { await disconnectAll(focus, user); } }); it('returns 200 when connectionJIDs contains a JID not in the room (no occupancy validation)', async () => { // mod_system_chat_message routes stanzas to every JID in // connectionJIDs without verifying they are room occupants. // This test documents the current behaviour: the call succeeds // (200) even for non-occupant JIDs. const { roomJid, focus } = await createRoom(); try { const token = mintSystemToken(); const nonOccupant = 'nobody@localhost/ghost-resource'; const { status } = await sendSystemChatMessage( roomJid, [ nonOccupant ], 'hello', token); assert.strictEqual(status, 200, 'module must not crash when connectionJIDs contains a non-occupant JID'); } finally { await disconnectAll(focus); } }); it('delivers to multiple recipients', async () => { const { roomJid, focus } = await createRoom(); const alice = await createXmppClient(); const bob = await createXmppClient(); await alice.joinRoom(roomJid); await bob.joinRoom(roomJid); try { const token = mintSystemToken(); const { status } = await sendSystemChatMessage( roomJid, [ alice.jid, bob.jid ], 'broadcast', token); assert.strictEqual(status, 200); const [ msgA, msgB ] = await Promise.all([ alice.waitForMessage(isSystemChat), bob.waitForMessage(isSystemChat) ]); for (const msg of [ msgA, msgB ]) { const payload = JSON.parse( msg.getChild('json-message', 'http://jitsi.org/jitmeet').text()); assert.strictEqual(payload.message, 'broadcast'); } } finally { await disconnectAll(focus, alice, bob); } }); }); });