Files
upstream-jitsi-meet/tests/prosody/mod_muc_flip_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

389 lines
13 KiB
JavaScript

import { xml } from '@xmpp/client';
import assert from 'assert';
import {
enableLobby, getRoomParticipants, setAffiliation, setRoomMaxOccupants, setSessionContext
} from './helpers/test_observer.js';
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
const CONFERENCE = 'conference.localhost';
let _roomCounter = 0;
const room = () => `flip-${++_roomCounter}@${CONFERENCE}`;
// Shared JWT user IDs used across tests.
const USER_A_ID = 'flip-test-user-a';
const USER_B_ID = 'flip-test-user-b';
/**
* Returns the default MUC nick for a client. Matches the logic in xmpp_client.js:
* first 8 characters of the JID local part, which is also what mod_muc_resource_validate
* enforces in anonymous_strict mode.
*
* @param {string} jid full JID, e.g. 'abc12345def@localhost/resource'
*/
function defaultNick(jid) {
return jid.split('@')[0].slice(0, 8);
}
/** Extracts the occupant nick from a MUC join presence (the resource part of from=). */
function nickFrom(presence) {
return presence.attrs.from.split('/')[1];
}
describe('mod_muc_flip', () => {
let clients;
beforeEach(() => {
clients = [];
});
afterEach(async () => {
await Promise.all(clients.map(c => c.disconnect()));
});
/**
* Creates and connects an XMPP client, tracking it for cleanup.
*
* @returns {Promise<object>}
*/
async function connect() {
const c = await createXmppClient();
clients.push(c);
return c;
}
/**
* Joins a room as focus and tracks the client for cleanup.
*
* @param {string} roomJid - Room JID to join.
* @returns {Promise<object>}
*/
async function focusJoin(roomJid) {
const c = await joinWithFocus(roomJid);
clients.push(c);
return c;
}
// -------------------------------------------------------------------------
// flip_device tag stripping
// -------------------------------------------------------------------------
describe('flip_device tag stripping', () => {
it('strips flip_device tag from guest (no JWT context)', async () => {
const r = room();
await focusJoin(r);
const observer = await connect();
await observer.joinRoom(r);
// Connect guest before joining so we can predict their nick.
const guest = await connect();
// No setSessionContext — guest has no JWT context.
// waitForPresenceFrom is exact-JID so it won't match stale presences
// from other occupants that arrived during the observer's own join.
const joinBroadcast = observer.waitForPresenceFrom(`${r}/${defaultNick(guest.jid)}`);
await guest.joinRoom(r, null, { extensions: [ xml('flip_device') ] });
const presence = await joinBroadcast;
assert.ok(
!presence.getChild('flip_device'),
'flip_device tag must be stripped from guest join presence'
);
});
it('strips flip_device tag when flip feature is disabled in JWT', async () => {
const r = room();
await focusJoin(r);
await setRoomMaxOccupants(r, 10);
const clientA = await connect();
await setSessionContext(clientA.jid, USER_A_ID, { flip: false });
await clientA.joinRoom(r);
const observer = await connect();
await observer.joinRoom(r);
const clientA2 = await connect();
await setSessionContext(clientA2.jid, USER_A_ID, { flip: false });
const joinBroadcast = observer.waitForPresenceFrom(
`${r}/${defaultNick(clientA2.jid)}`
);
await clientA2.joinRoom(r, null, { extensions: [ xml('flip_device') ] });
const presence = await joinBroadcast;
assert.ok(
!presence.getChild('flip_device'),
'flip_device tag must be stripped when flip feature is disabled'
);
const state = await getRoomParticipants(r);
assert.ok(
!state.kicked_participant_nick,
'no participant should have been kicked'
);
});
it('strips flip_device tag when the user is not already in the room', async () => {
const r = room();
await focusJoin(r);
const observer = await connect();
await observer.joinRoom(r);
// USER_B_ID has never joined this room — no entry in participants_details.
const clientB = await connect();
await setSessionContext(clientB.jid, USER_B_ID, { flip: true });
const joinBroadcast = observer.waitForPresenceFrom(
`${r}/${defaultNick(clientB.jid)}`
);
await clientB.joinRoom(r, null, { extensions: [ xml('flip_device') ] });
const presence = await joinBroadcast;
assert.ok(
!presence.getChild('flip_device'),
'flip_device tag must be stripped when user has no existing device in room'
);
const state = await getRoomParticipants(r);
assert.ok(!state.kicked_participant_nick, 'no participant should have been kicked');
});
}); // flip_device tag stripping
// -------------------------------------------------------------------------
// Successful flip
// -------------------------------------------------------------------------
describe('flip', () => {
it('kicks the old device when the same user joins from a new device', async () => {
const r = room();
await focusJoin(r);
const device1 = await connect();
await setSessionContext(device1.jid, USER_A_ID, { flip: true });
const d1Presence = await device1.joinRoom(r);
const d1Nick = nickFrom(d1Presence);
// Start watching for device1 to be kicked before device2 joins,
// so the unavailable presence is not missed if it arrives quickly.
const kickedPromise = device1.waitForPresenceFrom(
`${r}/${d1Nick}`, { type: 'unavailable' }
);
const device2 = await connect();
await setSessionContext(device2.jid, USER_A_ID, { flip: true });
await device2.joinRoom(r, null, { extensions: [ xml('flip_device') ] });
const kickedPresence = await kickedPromise;
assert.equal(
kickedPresence.attrs.type, 'unavailable',
'device1 must receive an unavailable presence (kicked)'
);
});
it('adds flip_device tag to the kicked occupant unavailable presence', async () => {
const r = room();
await focusJoin(r);
await setRoomMaxOccupants(r, 10);
const device1 = await connect();
await setSessionContext(device1.jid, USER_A_ID, { flip: true });
const d1Presence = await device1.joinRoom(r);
const d1Nick = nickFrom(d1Presence);
// Observer receives the broadcast of the kick so we can inspect the stanza.
const observer = await connect();
await observer.joinRoom(r);
const kickBroadcastPromise = observer.waitForPresenceFrom(
`${r}/${d1Nick}`, { type: 'unavailable' }
);
const device2 = await connect();
await setSessionContext(device2.jid, USER_A_ID, { flip: true });
await device2.joinRoom(r, null, { extensions: [ xml('flip_device') ] });
const kickedPresence = await kickBroadcastPromise;
assert.ok(
kickedPresence.getChild('flip_device'),
'unavailable presence for the kicked device must contain <flip_device/>'
);
});
it('transfers moderator role to flip device and tags the kicked occupant presence', async () => {
const r = room();
await focusJoin(r);
await setRoomMaxOccupants(r, 10);
const device1 = await connect();
await setSessionContext(device1.jid, USER_A_ID, { flip: true });
const d1Presence = await device1.joinRoom(r);
const d1Nick = nickFrom(d1Presence);
// Promote device1 to owner so it holds moderator role.
await setAffiliation(r, device1.jid.split('/')[0], 'owner');
// Watch for device1's own kick before device2 joins to avoid missing it.
const d1KickPromise = device1.waitForPresenceFrom(
`${r}/${d1Nick}`, { type: 'unavailable' }
);
const observer = await connect();
const observerJoinPresence = await observer.joinRoom(r);
const observerNick = nickFrom(observerJoinPresence);
const d1FlipTagPromise = observer.waitForPresenceFrom(
`${r}/${d1Nick}`, { type: 'unavailable' }
);
const device2 = await connect();
await setSessionContext(device2.jid, USER_A_ID, { flip: true });
await device2.joinRoom(r, null, { extensions: [ xml('flip_device') ] });
await d1KickPromise;
const d1KickPresence = await d1FlipTagPromise;
assert.ok(
d1KickPresence.getChild('flip_device'),
'kicked device presence must contain flip_device tag'
);
// Verify device2 has moderator role by performing a moderator-only action:
// kicking the observer. Only a moderator can set role='none'.
const observerKickPromise = observer.waitForPresenceFrom(
`${r}/${observerNick}`, { type: 'unavailable' }
);
const kickResponse = await device2.sendMucAdmin(r, {
nick: observerNick,
role: 'none'
});
assert.notEqual(kickResponse.attrs.type, 'error', 'device2 must have moderator role');
await observerKickPromise;
});
}); // flip
// -------------------------------------------------------------------------
// Lobby interactions
// -------------------------------------------------------------------------
describe('lobby interactions', () => {
it('flip device bypasses lobby when first device is in main room', async () => {
const r = room();
await focusJoin(r);
// device1 joins before lobby is enabled.
const device1 = await connect();
await setSessionContext(device1.jid, USER_A_ID, { flip: true });
await device1.joinRoom(r);
await enableLobby(r);
// Start listening for device1's kick before device2 joins.
const d1KickedPromise = device1.waitForPresence(p => p.attrs.type === 'unavailable');
const device2 = await connect();
await setSessionContext(device2.jid, USER_A_ID, { flip: true });
// device2 joins with flip_device — mod_muc_flip grants it member affiliation
// in muc-occupant-pre-join, which causes the lobby module to let it through.
// A displayName is required because the lobby checks for it before the affiliation.
const joinPresence = await device2.joinRoom(
r, null, {
displayName: 'Device Two',
extensions: [ xml('flip_device') ]
});
assert.notEqual(
joinPresence.attrs.type, 'error',
'flip device must bypass lobby when first device is in main room'
);
// The flip also kicks device1 from the main room.
await d1KickedPromise;
});
it('requires first device to be in main room for flip to proceed', async () => {
const r = room();
await focusJoin(r);
await enableLobby(r);
// device1 joins but does not reach the main room.
const device1 = await connect();
await setSessionContext(device1.jid, USER_A_ID, { flip: true });
const d1JoinResult = await device1.joinRoom(r, null, { displayName: 'Device One' });
assert.equal(
d1JoinResult.attrs.type, 'error',
'device1 must not reach the main room (precondition)'
);
// device2 joins with flip_device and the same JWT user id.
// No occupant with that id is in the main room, so the flip tag
// is ignored and device2 is treated as a regular join.
const device2 = await connect();
await setSessionContext(device2.jid, USER_A_ID, { flip: true });
const d2JoinResult = await device2.joinRoom(r, null, {
displayName: 'Device Two',
extensions: [ xml('flip_device') ]
});
assert.equal(
d2JoinResult.attrs.type, 'error',
'flip must not proceed when no matching occupant is in the main room'
);
});
}); // lobby interactions
});