diff --git a/backend/tests/helpers/assertion-helpers.ts b/backend/tests/helpers/assertion-helpers.ts index 0d9a750..c8856fa 100644 --- a/backend/tests/helpers/assertion-helpers.ts +++ b/backend/tests/helpers/assertion-helpers.ts @@ -492,7 +492,7 @@ export const expectValidRoomRoleAndPermissionsResponse = ( }); }; -const getPermissions = (roomId: string, role: ParticipantRole, addJoinPermission = true): ParticipantPermissions => { +export const getPermissions = (roomId: string, role: ParticipantRole, addJoinPermission = true): ParticipantPermissions => { switch (role) { case ParticipantRole.MODERATOR: return { @@ -568,6 +568,7 @@ export const expectValidParticipantTokenResponse = ( const metadata = JSON.parse(decodedToken.metadata || '{}'); expect(metadata).toHaveProperty('roles'); expect(metadata.roles).toEqual(expect.arrayContaining(rolesAndPermissions)); + expect(metadata).toHaveProperty('selectedRole', participantRole); // Check that the token is included in a cookie expect(response.headers['set-cookie']).toBeDefined(); diff --git a/backend/tests/helpers/request-helpers.ts b/backend/tests/helpers/request-helpers.ts index dbaab04..846f912 100644 --- a/backend/tests/helpers/request-helpers.ts +++ b/backend/tests/helpers/request-helpers.ts @@ -425,15 +425,14 @@ export const refreshParticipantToken = async (participantOptions: any, cookie: s * Adds a fake participant to a LiveKit room for testing purposes. * * @param roomId The ID of the room to join - * @param participantName The name for the fake participant + * @param participantIdentity The identity for the fake participant */ -export const joinFakeParticipant = async (roomId: string, participantName: string) => { - await ensureLivekitCliInstalled(); +export const joinFakeParticipant = async (roomId: string, participantIdentity: string) => { const process = spawn('lk', [ 'room', 'join', '--identity', - participantName, + participantIdentity, '--publish-demo', roomId, '--api-key', @@ -443,7 +442,34 @@ export const joinFakeParticipant = async (roomId: string, participantName: strin ]); // Store the process to be able to terminate it later - fakeParticipantsProcesses.set(`${roomId}-${participantName}`, process); + fakeParticipantsProcesses.set(`${roomId}-${participantIdentity}`, process); + await sleep('1s'); +}; + +/** + * Updates the metadata for a participant in a LiveKit room. + * + * @param roomId The ID of the room + * @param participantIdentity The identity of the participant + * @param metadata The metadata to update + */ +export const updateParticipantMetadata = async (roomId: string, participantIdentity: string, metadata: any) => { + await ensureLivekitCliInstalled(); + spawn('lk', [ + 'room', + 'participants', + 'update', + '--room', + roomId, + '--identity', + participantIdentity, + '--metadata', + JSON.stringify(metadata), + '--api-key', + LIVEKIT_API_KEY, + '--api-secret', + LIVEKIT_API_SECRET + ]); await sleep('1s'); }; @@ -496,20 +522,36 @@ const ensureLivekitCliInstalled = async (): Promise => { }; export const disconnectFakeParticipants = async () => { - fakeParticipantsProcesses.forEach((process, participantName) => { + fakeParticipantsProcesses.forEach((process, participant) => { process.kill(); - console.log(`Stopped process for participant ${participantName}`); + console.log(`Stopped process for participant '${participant}'`); }); fakeParticipantsProcesses.clear(); await sleep('1s'); }; -export const deleteParticipant = async (roomId: string, participantName: string, moderatorCookie: string) => { +export const updateParticipant = async ( + roomId: string, + participantIdentity: string, + newRole: ParticipantRole, + moderatorCookie: string +) => { checkAppIsRunning(); const response = await request(app) - .delete(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/meetings/${roomId}/participants/${participantName}`) + .patch(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/meetings/${roomId}/participants/${participantIdentity}`) + .set('Cookie', moderatorCookie) + .set(INTERNAL_CONFIG.PARTICIPANT_ROLE_HEADER, ParticipantRole.MODERATOR) + .send({ role: newRole }); + return response; +}; + +export const deleteParticipant = async (roomId: string, participantIdentity: string, moderatorCookie: string) => { + checkAppIsRunning(); + + const response = await request(app) + .delete(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/meetings/${roomId}/participants/${participantIdentity}`) .set('Cookie', moderatorCookie) .set(INTERNAL_CONFIG.PARTICIPANT_ROLE_HEADER, ParticipantRole.MODERATOR) .send(); diff --git a/backend/tests/integration/api/meetings/update-participant.test.ts b/backend/tests/integration/api/meetings/update-participant.test.ts new file mode 100644 index 0000000..82ae2f1 --- /dev/null +++ b/backend/tests/integration/api/meetings/update-participant.test.ts @@ -0,0 +1,157 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { container } from '../../../../src/config/index.js'; +import { LIVEKIT_URL } from '../../../../src/environment.js'; +import { FrontendEventService, LiveKitService } from '../../../../src/services/index.js'; +import { MeetTokenMetadata, ParticipantRole } from '../../../../src/typings/ce/index.js'; +import { getPermissions } from '../../../helpers/assertion-helpers.js'; +import { + deleteAllRooms, + deleteRoom, + disconnectFakeParticipants, + startTestServer, + updateParticipant, + updateParticipantMetadata +} from '../../../helpers/request-helpers.js'; +import { RoomData, setupSingleRoom } from '../../../helpers/test-scenarios.js'; +import { MeetSignalType } from '../../../../src/typings/ce/event.model.js'; + +const participantIdentity = 'TEST_PARTICIPANT'; + +describe('Meetings API Tests', () => { + let livekitService: LiveKitService; + let roomData: RoomData; + + beforeAll(async () => { + startTestServer(); + livekitService = container.get(LiveKitService); + }); + + afterAll(async () => { + await disconnectFakeParticipants(); + await deleteAllRooms(); + }); + + describe('Update Participant Tests', () => { + const setParticipantMetadata = async (roomId: string, role: ParticipantRole) => { + const metadata: MeetTokenMetadata = { + livekitUrl: LIVEKIT_URL, + roles: [ + { + role: role, + permissions: getPermissions(roomId, role).openvidu + } + ], + selectedRole: role + }; + await updateParticipantMetadata(roomId, participantIdentity, metadata); + }; + + beforeEach(async () => { + roomData = await setupSingleRoom(true); + }); + + it('should update participant role from speaker to moderator', async () => { + const frontendEventService = container.get(FrontendEventService); + const sendSignalSpy = jest.spyOn(frontendEventService as any, 'sendSignal'); + + await setParticipantMetadata(roomData.room.roomId, ParticipantRole.SPEAKER); + + const response = await updateParticipant( + roomData.room.roomId, + participantIdentity, + ParticipantRole.MODERATOR, + roomData.moderatorCookie + ); + expect(response.status).toBe(200); + + // Check if the participant has been updated + const participant = await livekitService.getParticipant(roomData.room.roomId, participantIdentity); + expect(participant).toBeDefined(); + expect(participant).toHaveProperty('metadata'); + const metadata = JSON.parse(participant.metadata || '{}'); + expect(metadata).toHaveProperty('roles'); + expect(metadata.roles).toContainEqual(expect.objectContaining({ role: ParticipantRole.MODERATOR })); + expect(metadata).toHaveProperty('selectedRole', ParticipantRole.MODERATOR); + + // Verify sendSignal method has been called twice + expect(sendSignalSpy).toHaveBeenCalledTimes(2); + + expect(sendSignalSpy).toHaveBeenNthCalledWith(1, + roomData.room.roomId, + { + roomId: roomData.room.roomId, + participantIdentity, + newRole: ParticipantRole.MODERATOR, + secret: expect.any(String), + timestamp: expect.any(Number) + }, + { + topic: MeetSignalType.MEET_PARTICIPANT_ROLE_UPDATED, + destinationIdentities: [participantIdentity] + } + ); + + expect(sendSignalSpy).toHaveBeenNthCalledWith(2, + roomData.room.roomId, + { + roomId: roomData.room.roomId, + participantIdentity, + newRole: ParticipantRole.MODERATOR, + secret: undefined, + timestamp: expect.any(Number) + }, + { + topic: MeetSignalType.MEET_PARTICIPANT_ROLE_UPDATED, + destinationIdentities: [] + } + ); + }); + + it('should update participant role from moderator to speaker', async () => { + await setParticipantMetadata(roomData.room.roomId, ParticipantRole.MODERATOR); + + const response = await updateParticipant( + roomData.room.roomId, + participantIdentity, + ParticipantRole.SPEAKER, + roomData.moderatorCookie + ); + expect(response.status).toBe(200); + + // Check if the participant has been updated + const participant = await livekitService.getParticipant(roomData.room.roomId, participantIdentity); + expect(participant).toBeDefined(); + expect(participant).toHaveProperty('metadata'); + const metadata = JSON.parse(participant.metadata || '{}'); + expect(metadata).toHaveProperty('roles'); + expect(metadata.roles).toContainEqual(expect.objectContaining({ role: ParticipantRole.SPEAKER })); + expect(metadata).toHaveProperty('selectedRole', ParticipantRole.SPEAKER); + }); + + it('should fail with 404 if participant does not exist', async () => { + const response = await updateParticipant( + roomData.room.roomId, + 'NON_EXISTENT_PARTICIPANT', + ParticipantRole.MODERATOR, + roomData.moderatorCookie + ); + expect(response.status).toBe(404); + expect(response.body.error).toBe('Participant Error'); + }); + + it('should fail with 404 if room does not exist', async () => { + // Delete the room to ensure it does not exist + let response = await deleteRoom(roomData.room.roomId, { force: true }); + expect(response.status).toBe(204); + + response = await updateParticipant( + roomData.room.roomId, + participantIdentity, + ParticipantRole.MODERATOR, + roomData.moderatorCookie + ); + expect(response.status).toBe(404); + expect(response.body.error).toBe('Room Error'); + }); + }); +}); diff --git a/backend/tests/integration/api/security/meeting-security.test.ts b/backend/tests/integration/api/security/meeting-security.test.ts index e0b927e..26f1b33 100644 --- a/backend/tests/integration/api/security/meeting-security.test.ts +++ b/backend/tests/integration/api/security/meeting-security.test.ts @@ -2,13 +2,15 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/glo import { Express } from 'express'; import request from 'supertest'; import INTERNAL_CONFIG from '../../../../src/config/internal-config.js'; -import { MEET_API_KEY } from '../../../../src/environment.js'; -import { ParticipantRole } from '../../../../src/typings/ce'; +import { LIVEKIT_URL, MEET_API_KEY } from '../../../../src/environment.js'; +import { MeetTokenMetadata, ParticipantRole } from '../../../../src/typings/ce'; +import { getPermissions } from '../../../helpers/assertion-helpers.js'; import { deleteAllRooms, disconnectFakeParticipants, loginUser, - startTestServer + startTestServer, + updateParticipantMetadata } from '../../../helpers/request-helpers.js'; import { RoomData, setupSingleRoom } from '../../../helpers/test-scenarios.js'; @@ -75,26 +77,90 @@ describe('Meeting API Security Tests', () => { }); }); - describe('Delete Participant from Meeting Tests', () => { + describe('Update Participant in Meeting Tests', () => { const PARTICIPANT_NAME = 'TEST_PARTICIPANT'; + const role = ParticipantRole.MODERATOR; + + beforeEach(async () => { + const metadata: MeetTokenMetadata = { + livekitUrl: LIVEKIT_URL, + roles: [ + { + role: ParticipantRole.SPEAKER, + permissions: getPermissions(roomData.room.roomId, ParticipantRole.SPEAKER).openvidu + } + ], + selectedRole: ParticipantRole.SPEAKER + }; + await updateParticipantMetadata(roomData.room.roomId, PARTICIPANT_NAME, metadata); + }); it('should fail when request includes API key', async () => { const response = await request(app) - .delete(`${MEETINGS_PATH}/${roomData.room.roomId}/participants/${PARTICIPANT_NAME}`) + .patch(`${MEETINGS_PATH}/${roomData.room.roomId}/participants/${PARTICIPANT_NAME}`) + .set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_API_KEY) + .send({ role }); + expect(response.status).toBe(401); + }); + + it('should fail when user is authenticated as admin', async () => { + const response = await request(app) + .patch(`${MEETINGS_PATH}/${roomData.room.roomId}/participants/${PARTICIPANT_NAME}`) + .set('Cookie', adminCookie) + .send({ role }); + expect(response.status).toBe(401); + }); + + it('should succeed when participant is moderator', async () => { + const response = await request(app) + .patch(`${MEETINGS_PATH}/${roomData.room.roomId}/participants/${PARTICIPANT_NAME}`) + .set('Cookie', roomData.moderatorCookie) + .set(INTERNAL_CONFIG.PARTICIPANT_ROLE_HEADER, ParticipantRole.MODERATOR) + .send({ role }); + expect(response.status).toBe(200); + }); + + it('should fail when participant is moderator of a different room', async () => { + const newRoomData = await setupSingleRoom(); + + const response = await request(app) + .patch(`${MEETINGS_PATH}/${roomData.room.roomId}/participants/${PARTICIPANT_NAME}`) + .set('Cookie', newRoomData.moderatorCookie) + .set(INTERNAL_CONFIG.PARTICIPANT_ROLE_HEADER, ParticipantRole.MODERATOR) + .send({ role }); + expect(response.status).toBe(403); + }); + + it('should fail when participant is speaker', async () => { + const response = await request(app) + .patch(`${MEETINGS_PATH}/${roomData.room.roomId}/participants/${PARTICIPANT_NAME}`) + .set('Cookie', roomData.speakerCookie) + .set(INTERNAL_CONFIG.PARTICIPANT_ROLE_HEADER, ParticipantRole.SPEAKER) + .send({ role }); + expect(response.status).toBe(403); + }); + }); + + describe('Delete Participant from Meeting Tests', () => { + const PARTICIPANT_IDENTITY = 'TEST_PARTICIPANT'; + + it('should fail when request includes API key', async () => { + const response = await request(app) + .delete(`${MEETINGS_PATH}/${roomData.room.roomId}/participants/${PARTICIPANT_IDENTITY}`) .set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_API_KEY); expect(response.status).toBe(401); }); it('should fail when user is authenticated as admin', async () => { const response = await request(app) - .delete(`${MEETINGS_PATH}/${roomData.room.roomId}/participants/${PARTICIPANT_NAME}`) + .delete(`${MEETINGS_PATH}/${roomData.room.roomId}/participants/${PARTICIPANT_IDENTITY}`) .set('Cookie', adminCookie); expect(response.status).toBe(401); }); it('should succeed when participant is moderator', async () => { const response = await request(app) - .delete(`${MEETINGS_PATH}/${roomData.room.roomId}/participants/${PARTICIPANT_NAME}`) + .delete(`${MEETINGS_PATH}/${roomData.room.roomId}/participants/${PARTICIPANT_IDENTITY}`) .set('Cookie', roomData.moderatorCookie) .set(INTERNAL_CONFIG.PARTICIPANT_ROLE_HEADER, ParticipantRole.MODERATOR); expect(response.status).toBe(200); @@ -104,7 +170,7 @@ describe('Meeting API Security Tests', () => { const newRoomData = await setupSingleRoom(); const response = await request(app) - .delete(`${MEETINGS_PATH}/${roomData.room.roomId}/participants/${PARTICIPANT_NAME}`) + .delete(`${MEETINGS_PATH}/${roomData.room.roomId}/participants/${PARTICIPANT_IDENTITY}`) .set('Cookie', newRoomData.moderatorCookie) .set(INTERNAL_CONFIG.PARTICIPANT_ROLE_HEADER, ParticipantRole.MODERATOR); expect(response.status).toBe(403); @@ -112,7 +178,7 @@ describe('Meeting API Security Tests', () => { it('should fail when participant is speaker', async () => { const response = await request(app) - .delete(`${MEETINGS_PATH}/${roomData.room.roomId}/participants/${PARTICIPANT_NAME}`) + .delete(`${MEETINGS_PATH}/${roomData.room.roomId}/participants/${PARTICIPANT_IDENTITY}`) .set('Cookie', roomData.speakerCookie) .set(INTERNAL_CONFIG.PARTICIPANT_ROLE_HEADER, ParticipantRole.SPEAKER); expect(response.status).toBe(403);