diff --git a/meet-ce/backend/openapi/components/responses/forbidden-error.yaml b/meet-ce/backend/openapi/components/responses/forbidden-error.yaml index 78cdf1c0..3c97503c 100644 --- a/meet-ce/backend/openapi/components/responses/forbidden-error.yaml +++ b/meet-ce/backend/openapi/components/responses/forbidden-error.yaml @@ -1,4 +1,4 @@ -description: Forbidden — Insufficient permissions +description: Forbidden - Insufficient Permissions content: application/json: schema: diff --git a/meet-ce/backend/openapi/components/responses/forbidden-not-allowed-error.yaml b/meet-ce/backend/openapi/components/responses/forbidden-not-allowed-error.yaml new file mode 100644 index 00000000..61009dc6 --- /dev/null +++ b/meet-ce/backend/openapi/components/responses/forbidden-not-allowed-error.yaml @@ -0,0 +1,16 @@ +description: Forbidden +content: + application/json: + schema: + $ref: '../schemas/error.yaml' + examples: + forbidden_error: + summary: Forbidden Error Example + value: + error: Authorization Error + message: 'Insufficient permissions to access this resource' + recording_not_allowed: + summary: Recording Not Allowed in Room Example + value: + error: Recording Error + message: 'Recording is disabled for room room-123' diff --git a/meet-ce/backend/openapi/components/responses/success-get-room.yaml b/meet-ce/backend/openapi/components/responses/success-get-room.yaml index 67a28218..8cdd2eba 100644 --- a/meet-ce/backend/openapi/components/responses/success-get-room.yaml +++ b/meet-ce/backend/openapi/components/responses/success-get-room.yaml @@ -21,6 +21,8 @@ content: enabled: false virtualBackground: enabled: true + e2ee: + enabled: false moderatorUrl: 'http://localhost:6080/room/room-123?secret=123456' speakerUrl: 'http://localhost:6080/room/room-123?secret=654321' status: open @@ -45,6 +47,8 @@ content: enabled: false virtualBackground: enabled: true + e2ee: + enabled: false fields=moderatorUrl,speakerUrl: summary: Response containing only moderator and speaker URLs diff --git a/meet-ce/backend/openapi/components/responses/success-get-rooms.yaml b/meet-ce/backend/openapi/components/responses/success-get-rooms.yaml index ffd1d07a..5e9221c8 100644 --- a/meet-ce/backend/openapi/components/responses/success-get-rooms.yaml +++ b/meet-ce/backend/openapi/components/responses/success-get-rooms.yaml @@ -30,6 +30,8 @@ content: enabled: false virtualBackground: enabled: true + e2ee: + enabled: false moderatorUrl: 'http://localhost:6080/room/room-123?secret=123456' speakerUrl: 'http://localhost:6080/room/room-123?secret=654321' status: open @@ -48,6 +50,8 @@ content: enabled: true virtualBackground: enabled: false + e2ee: + enabled: false moderatorUrl: 'http://localhost:6080/room/room-456?secret=789012' speakerUrl: 'http://localhost:6080/room/room-456?secret=210987' status: open @@ -80,6 +84,8 @@ content: enabled: false virtualBackground: enabled: true + e2ee: + enabled: false - roomId: 'room-456' roomName: 'room' creationDate: 1620001000000 @@ -91,6 +97,8 @@ content: enabled: true virtualBackground: enabled: false + e2ee: + enabled: false pagination: isTruncated: true nextPageToken: 'abc123' diff --git a/meet-ce/backend/openapi/components/schemas/meet-room-config.yaml b/meet-ce/backend/openapi/components/schemas/meet-room-config.yaml index 7852a587..34012fe0 100644 --- a/meet-ce/backend/openapi/components/schemas/meet-room-config.yaml +++ b/meet-ce/backend/openapi/components/schemas/meet-room-config.yaml @@ -10,6 +10,9 @@ MeetRoomConfig: virtualBackground: $ref: '#/MeetVirtualBackgroundConfig' description: Config for virtual background in the room. + e2ee: + $ref: '#/MeetE2EEConfig' + description: Config for End-to-End Encryption (E2EE) in the room. MeetChatConfig: type: object properties: @@ -47,3 +50,14 @@ MeetVirtualBackgroundConfig: default: true example: true description: If true, the room will be allowed to use virtual background. +MeetE2EEConfig: + type: object + properties: + enabled: + type: boolean + default: false + example: false + description: > + If true, the room will have End-to-End Encryption (E2EE) enabled.
+ This ensures that the media streams are encrypted from the sender to the receiver, providing enhanced privacy and security for the participants.
+ **Enabling E2EE will disable the recording feature for the room**. diff --git a/meet-ce/backend/openapi/paths/internal/recordings.yaml b/meet-ce/backend/openapi/paths/internal/recordings.yaml index 8a6f05dc..06091045 100644 --- a/meet-ce/backend/openapi/paths/internal/recordings.yaml +++ b/meet-ce/backend/openapi/paths/internal/recordings.yaml @@ -20,7 +20,7 @@ '401': $ref: '../../components/responses/unauthorized-error.yaml' '403': - $ref: '../../components/responses/forbidden-error.yaml' + $ref: '../../components/responses/forbidden-not-allowed-error.yaml' '404': $ref: '../../components/responses/error-room-not-found.yaml' '409': diff --git a/meet-ce/backend/package.json b/meet-ce/backend/package.json index e8d7f6a7..c89c2bae 100644 --- a/meet-ce/backend/package.json +++ b/meet-ce/backend/package.json @@ -45,7 +45,8 @@ "test:integration-users": "node --experimental-vm-modules ../../node_modules/.bin/jest --runInBand --forceExit --testPathPattern \"tests/integration/api/users\" --ci --reporters=default --reporters=jest-junit", "test:unit": "node --experimental-vm-modules ../../node_modules/.bin/jest --runInBand --forceExit --testPathPattern \"tests/unit\" --ci --reporters=default --reporters=jest-junit", "lint:fix": "eslint src --fix", - "format:code": "prettier --ignore-path .gitignore --write '**/*.{ts,js,json,md}'" + "format:code": "prettier --ignore-path .gitignore --write '**/*.{ts,js,json,md}'", + "clean": "rm -rf node_modules dist public test-results" }, "dependencies": { "@openvidu-meet/typings": "workspace:*", diff --git a/meet-ce/backend/src/middlewares/request-validators/room-validator.middleware.ts b/meet-ce/backend/src/middlewares/request-validators/room-validator.middleware.ts index 20c7cbc8..0ef209f2 100644 --- a/meet-ce/backend/src/middlewares/request-validators/room-validator.middleware.ts +++ b/meet-ce/backend/src/middlewares/request-validators/room-validator.middleware.ts @@ -1,5 +1,6 @@ import { MeetChatConfig, + MeetE2EEConfig, MeetRecordingAccess, MeetRecordingConfig, MeetRoomAutoDeletionPolicy, @@ -90,6 +91,10 @@ const VirtualBackgroundConfigSchema: z.ZodType = z. enabled: z.boolean() }); +const E2EEConfigSchema: z.ZodType = z.object({ + enabled: z.boolean() +}); + const ThemeModeSchema: z.ZodType = z.enum([MeetRoomThemeMode.LIGHT, MeetRoomThemeMode.DARK]); const hexColorSchema = z @@ -118,12 +123,28 @@ export const AppearanceConfigSchema = z.object({ themes: z.array(RoomThemeSchema).length(1, 'There must be exactly one theme defined') }); -const RoomConfigSchema: z.ZodType = z.object({ - recording: RecordingConfigSchema, - chat: ChatConfigSchema, - virtualBackground: VirtualBackgroundConfigSchema - // appearance: AppearanceConfigSchema, -}); +const RoomConfigSchema: z.ZodType = z + .object({ + recording: RecordingConfigSchema, + chat: ChatConfigSchema, + virtualBackground: VirtualBackgroundConfigSchema, + e2ee: E2EEConfigSchema.optional().default({ enabled: false }), + // appearance: AppearanceConfigSchema, + }) + .transform((data) => { + // Automatically disable recording when E2EE is enabled + if (data.e2ee?.enabled && data.recording.enabled) { + return { + ...data, + recording: { + ...data.recording, + enabled: false + } + }; + } + + return data; + }); const RoomDeletionPolicyWithMeetingSchema: z.ZodType = z.enum([ MeetRoomDeletionPolicyWithMeeting.FORCE, @@ -183,7 +204,8 @@ const RoomRequestOptionsSchema: z.ZodType = z.object({ config: RoomConfigSchema.optional().default({ recording: { enabled: true, allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER }, chat: { enabled: true }, - virtualBackground: { enabled: true } + virtualBackground: { enabled: true }, + e2ee: { enabled: false } }) // maxParticipants: z // .number() diff --git a/meet-ce/backend/src/services/room.service.ts b/meet-ce/backend/src/services/room.service.ts index 2869daa1..8f3b1d82 100644 --- a/meet-ce/backend/src/services/room.service.ts +++ b/meet-ce/backend/src/services/room.service.ts @@ -132,7 +132,7 @@ export class RoomService { } /** - * Updates the config of a specific meeting room. + * Updates the configuration of a specific meeting room. * * @param roomId - The unique identifier of the meeting room to update * @param config - The new config to apply to the meeting room diff --git a/meet-ce/backend/tests/helpers/assertion-helpers.ts b/meet-ce/backend/tests/helpers/assertion-helpers.ts index a2510d24..a5609faf 100644 --- a/meet-ce/backend/tests/helpers/assertion-helpers.ts +++ b/meet-ce/backend/tests/helpers/assertion-helpers.ts @@ -2,6 +2,8 @@ import { expect } from '@jest/globals'; import { container } from '../../src/config/dependency-injector.config'; import { INTERNAL_CONFIG } from '../../src/config/internal-config'; import { TokenService } from '../../src/services'; +import { Response } from 'supertest'; + import { MeetingEndAction, MeetRecordingAccess, @@ -18,7 +20,7 @@ import { } from '@openvidu-meet/typings'; export const expectErrorResponse = ( - response: any, + response: Response, status = 422, error = 'Unprocessable Entity', message = 'Invalid request', @@ -51,7 +53,7 @@ export const expectErrorResponse = ( ); }; -export const expectValidationError = (response: any, field: string, message: string) => { +export const expectValidationError = (response: Response, field: string, message: string) => { expectErrorResponse(response, 422, 'Unprocessable Entity', 'Invalid request', [{ field, message }]); }; @@ -68,7 +70,7 @@ export const expectValidationError = (response: any, field: string, message: str * if false, expects nextPageToken to be undefined) */ export const expectSuccessRoomsResponse = ( - response: any, + response: Response, expectedRoomLength: number, expectedMaxItems: number, expectedTruncated: boolean, @@ -90,7 +92,7 @@ export const expectSuccessRoomsResponse = ( }; export const expectSuccessRoomResponse = ( - response: any, + response: Response, roomName: string, autoDeletionDate?: number, config?: MeetRoomConfig @@ -99,7 +101,7 @@ export const expectSuccessRoomResponse = ( expectValidRoom(response.body, roomName, config, autoDeletionDate); }; -export const expectSuccessRoomConfigResponse = (response: any, config: MeetRoomConfig) => { +export const expectSuccessRoomConfigResponse = (response: Response, config: MeetRoomConfig) => { expect(response.status).toBe(200); expect(response.body).toBeDefined(); expect(response.body).toEqual(config); @@ -151,7 +153,8 @@ export const expectValidRoom = ( allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER }, chat: { enabled: true }, - virtualBackground: { enabled: true } + virtualBackground: { enabled: true }, + e2ee: { enabled: false } }); } @@ -197,7 +200,7 @@ export const expectValidRecordingWithFields = (rec: MeetRecordingInfo, fields: s expectObjectFields(rec, fields); }; -const expectObjectFields = (obj: any, present: string[] = [], absent: string[] = []) => { +const expectObjectFields = (obj: unknown, present: string[] = [], absent: string[] = []) => { present.forEach((key) => { expect(obj).toHaveProperty(key); expect((obj as any)[key]).not.toBeUndefined(); @@ -209,7 +212,7 @@ const expectObjectFields = (obj: any, present: string[] = [], absent: string[] = }; // Validate recording location header in the response -export const expectValidRecordingLocationHeader = (response: any) => { +export const expectValidRecordingLocationHeader = (response: Response) => { const locationHeader = response.headers.location; expect(locationHeader).toBeDefined(); const locationHeaderUrl = new URL(locationHeader); @@ -230,7 +233,7 @@ export const expectValidRecordingLocationHeader = (response: any) => { * - expectedStatus: Override the expected status code (default: auto-determined based on range) */ export const expectSuccessRecordingMediaResponse = ( - response: any, + response: Response, range?: string, fullSize?: number, options?: { @@ -348,7 +351,7 @@ export const expectSuccessRecordingMediaResponse = ( } }; -export const expectValidStartRecordingResponse = (response: any, roomId: string, roomName: string) => { +export const expectValidStartRecordingResponse = (response: Response, roomId: string, roomName: string) => { expect(response.status).toBe(201); expect(response.body).toHaveProperty('recordingId'); @@ -369,7 +372,7 @@ export const expectValidStartRecordingResponse = (response: any, roomId: string, }; export const expectValidStopRecordingResponse = ( - response: any, + response: Response, recordingId: string, roomId: string, roomName: string @@ -388,7 +391,7 @@ export const expectValidStopRecordingResponse = ( }; export const expectValidGetRecordingResponse = ( - response: any, + response: Response, recordingId: string, roomId: string, roomName: string, @@ -444,7 +447,7 @@ export const expectValidGetRecordingResponse = ( }; export const expectSuccessListRecordingResponse = ( - response: any, + response: Response, recordingLength: number, isTruncated: boolean, nextPageToken: boolean, @@ -470,7 +473,7 @@ export const expectSuccessListRecordingResponse = ( expect(response.body.pagination.maxItems).toBe(maxItems); }; -export const expectValidGetRecordingUrlResponse = (response: any, recordingId: string) => { +export const expectValidGetRecordingUrlResponse = (response: Response, recordingId: string) => { expect(response.status).toBe(200); const recordingUrl = response.body.url; expect(recordingUrl).toBeDefined(); @@ -480,7 +483,7 @@ export const expectValidGetRecordingUrlResponse = (response: any, recordingId: s expect(parsedUrl.searchParams.get('secret')).toBeDefined(); }; -export const expectValidRoomRolesAndPermissionsResponse = (response: any, roomId: string) => { +export const expectValidRoomRolesAndPermissionsResponse = (response: Response, roomId: string) => { expect(response.status).toBe(200); expect(response.body).toEqual( expect.arrayContaining([ @@ -497,7 +500,7 @@ export const expectValidRoomRolesAndPermissionsResponse = (response: any, roomId }; export const expectValidRoomRoleAndPermissionsResponse = ( - response: any, + response: Response, roomId: string, participantRole: ParticipantRole ) => { @@ -552,7 +555,7 @@ export const getPermissions = ( }; export const expectValidParticipantTokenResponse = ( - response: any, + response: Response, roomId: string, participantRole: ParticipantRole, participantName?: string, @@ -601,7 +604,7 @@ export const expectValidParticipantTokenResponse = ( }; export const expectValidRecordingTokenResponse = ( - response: any, + response: Response, roomId: string, participantRole: ParticipantRole, canRetrieveRecordings: boolean, diff --git a/meet-ce/backend/tests/helpers/request-helpers.ts b/meet-ce/backend/tests/helpers/request-helpers.ts index d7709fe3..38872282 100644 --- a/meet-ce/backend/tests/helpers/request-helpers.ts +++ b/meet-ce/backend/tests/helpers/request-helpers.ts @@ -22,6 +22,7 @@ import { MeetRecordingInfo, MeetRecordingStatus, MeetRoom, + MeetRoomConfig, MeetRoomDeletionPolicyWithMeeting, MeetRoomDeletionPolicyWithRecordings, MeetRoomOptions, @@ -284,7 +285,7 @@ export const getRoom = async (roomId: string, fields?: string, participantToken? return await req; }; -export const getRoomConfig = async (roomId: string) => { +export const getRoomConfig = async (roomId: string): Promise => { checkAppIsRunning(); return await request(app) @@ -293,7 +294,7 @@ export const getRoomConfig = async (roomId: string) => { .send(); }; -export const updateRoomConfig = async (roomId: string, config: any) => { +export const updateRoomConfig = async (roomId: string, config: MeetRoomConfig) => { checkAppIsRunning(); return await request(app) diff --git a/meet-ce/backend/tests/helpers/test-scenarios.ts b/meet-ce/backend/tests/helpers/test-scenarios.ts index 042488bf..bb88c630 100644 --- a/meet-ce/backend/tests/helpers/test-scenarios.ts +++ b/meet-ce/backend/tests/helpers/test-scenarios.ts @@ -76,11 +76,11 @@ export const setupSingleRoom = async ( * @param withParticipants Whether to join fake participants in the rooms. * @returns Test context with created rooms and their data. */ -export const setupMultiRoomTestContext = async (numRooms: number, withParticipants: boolean): Promise => { +export const setupMultiRoomTestContext = async (numRooms: number, withParticipants: boolean, roomConfig?: MeetRoomConfig): Promise => { const rooms: RoomData[] = []; for (let i = 0; i < numRooms; i++) { - const roomData = await setupSingleRoom(withParticipants, 'TEST_ROOM'); + const roomData = await setupSingleRoom(withParticipants, 'TEST_ROOM', roomConfig); rooms.push(roomData); } diff --git a/meet-ce/backend/tests/integration/api/rooms/create-room.test.ts b/meet-ce/backend/tests/integration/api/rooms/create-room.test.ts index 71f43d58..ffad8f29 100644 --- a/meet-ce/backend/tests/integration/api/rooms/create-room.test.ts +++ b/meet-ce/backend/tests/integration/api/rooms/create-room.test.ts @@ -63,7 +63,8 @@ describe('Room API Tests', () => { allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER }, chat: { enabled: false }, - virtualBackground: { enabled: true } + virtualBackground: { enabled: true }, + e2ee: { enabled: true } } }; diff --git a/meet-ce/backend/tests/integration/api/rooms/e2ee-room-config.test.ts b/meet-ce/backend/tests/integration/api/rooms/e2ee-room-config.test.ts new file mode 100644 index 00000000..e5945fd2 --- /dev/null +++ b/meet-ce/backend/tests/integration/api/rooms/e2ee-room-config.test.ts @@ -0,0 +1,263 @@ +import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'; +import { Express } from 'express'; +import request from 'supertest'; +import { INTERNAL_CONFIG } from '../../../../src/config/internal-config.js'; +import { MEET_INITIAL_API_KEY } from '../../../../src/environment.js'; +import { MeetRecordingAccess, MeetRoom } from '@openvidu-meet/typings'; +import { expectValidRoom } from '../../../helpers/assertion-helpers.js'; +import { + createRoom, + deleteAllRecordings, + deleteAllRooms, + getRoomConfig, + startRecording, + startTestServer, + updateRoomConfig +} from '../../../helpers/request-helpers.js'; +import { setupMultiRoomTestContext } from '../../../helpers/test-scenarios.js'; + +const ROOMS_PATH = `${INTERNAL_CONFIG.API_BASE_PATH_V1}/rooms`; + +describe('E2EE Room Configuration Tests', () => { + let app: Express; + + beforeAll(async () => { + app = startTestServer(); + }); + + afterAll(async () => { + await deleteAllRecordings(); + await deleteAllRooms(); + }); + + describe('E2EE Default Configuration', () => { + it('Should create a room with E2EE disabled by default', async () => { + const room = await createRoom({ + roomName: 'Test E2EE Default' + }); + + expectValidRoom(room, 'Test E2EE Default'); + expect(room.config.e2ee).toBeDefined(); + expect(room.config.e2ee?.enabled).toBe(false); + }); + }); + + describe('E2EE Enabled Configuration', () => { + it('Should create a room with E2EE enabled and recording automatically disabled', async () => { + const payload = { + roomName: 'Test E2EE Enabled', + config: { + recording: { + enabled: true, // This should be automatically disabled + allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER + }, + chat: { enabled: true }, + virtualBackground: { enabled: true }, + e2ee: { enabled: true } + } + }; + + const room = await createRoom(payload); + + expect(room.roomName).toBe('Test E2EE Enabled'); + expect(room.config.e2ee?.enabled).toBe(true); + expect(room.config.recording.enabled).toBe(false); // Recording should be disabled + }); + }); + + describe('E2EE and Recording Interaction', () => { + it('Should not allow starting recording in a room with E2EE enabled', async () => { + const context = await setupMultiRoomTestContext(1, true, { + recording: { + enabled: true, + allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER + }, + chat: { enabled: true }, + virtualBackground: { enabled: true }, + e2ee: { enabled: true } + }); + + const { room, moderatorToken } = context.getRoomByIndex(0)!; + + // Try to start recording (should fail because recording is not enabled in room config) + const response = await startRecording(room.roomId, moderatorToken); + + // The endpoint returns 404 when the recording endpoint doesn't exist for disabled recording rooms + expect(403).toBe(response.status); + expect(response.body.message).toBe(`Recording is disabled for room '${room.roomId}'`); + }); + + it('Should disable recording when updating room config to enable E2EE', async () => { + // Create room with recording enabled and E2EE disabled + const room = await createRoom({ + roomName: 'Test E2EE Update', + config: { + recording: { + enabled: true, + allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER + }, + chat: { enabled: true }, + virtualBackground: { enabled: true }, + e2ee: { enabled: false } + } + }); + + expect(room.config.recording.enabled).toBe(true); + expect(room.config.e2ee?.enabled).toBe(false); + + // Update room to enable E2EE (recording should be automatically disabled) + const updatedConfig = { + recording: { + enabled: true, // This should be automatically disabled + allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER + }, + chat: { enabled: true }, + virtualBackground: { enabled: true }, + e2ee: { enabled: true } + }; + + const response = await updateRoomConfig(room.roomId, updatedConfig); + + expect(response.status).toBe(200); + + // Fetch the updated room to verify changes + const { status, body: config } = await getRoomConfig(room.roomId); + + expect(status).toBe(200); + expect(config.e2ee?.enabled).toBe(true); + expect(config.recording.enabled).toBe(false); + }); + + // TODO: Add test for enabling E2EE when there are active recordings in the room + }); + + describe('E2EE Validation Tests', () => { + it('Should fail when e2ee is not an object', async () => { + const payload = { + roomName: 'Test Invalid E2EE', + config: { + recording: { + enabled: true, + allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER + }, + chat: { enabled: true }, + virtualBackground: { enabled: true }, + e2ee: 'invalid-e2ee' // Should be an object + } + }; + + const response = await request(app) + .post(ROOMS_PATH) + .set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_INITIAL_API_KEY) + .send(payload) + .expect(422); + + expect(JSON.stringify(response.body.details)).toContain('Expected object'); + }); + + it('Should fail when e2ee.enabled is not a boolean', async () => { + const payload = { + roomName: 'Test Invalid E2EE Enabled', + config: { + recording: { + enabled: true, + allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER + }, + chat: { enabled: true }, + virtualBackground: { enabled: true }, + e2ee: { enabled: 'yes' } // Should be a boolean + } + }; + + const response = await request(app) + .post(ROOMS_PATH) + .set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_INITIAL_API_KEY) + .send(payload) + .expect(422); + + expect(JSON.stringify(response.body.details)).toContain('Expected boolean'); + }); + }); + + describe('E2EE Update Configuration Tests', () => { + it('Should successfully update room config with E2EE disabled to enabled', async () => { + const room = await createRoom({ + roomName: 'Test E2EE Update Enabled' + }); + + expect(room.config.e2ee?.enabled).toBe(false); + + const { status, body } = await updateRoomConfig(room.roomId, { + recording: { + enabled: false, + allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER + }, + chat: { enabled: true }, + virtualBackground: { enabled: true }, + e2ee: { enabled: true } + }); + + expect(status).toBe(200); + expect(body.message).toBeDefined(); + + // Fetch the updated room to verify changes + const { body: config } = await getRoomConfig(room.roomId); + + expect(config.e2ee?.enabled).toBe(true); + expect(config.recording.enabled).toBe(false); + }); + }); + + describe('E2EE and Room Status Tests', () => { + it('Should return E2EE configuration when listing rooms', async () => { + await deleteAllRooms(); + + const room1 = await createRoom({ + roomName: 'E2EE Enabled Room', + config: { + recording: { + enabled: false, + allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER + }, + chat: { enabled: true }, + virtualBackground: { enabled: true }, + e2ee: { enabled: true } + } + }); + + const room2 = await createRoom({ + roomName: 'E2EE Disabled Room', + config: { + recording: { + enabled: true, + allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER + }, + chat: { enabled: true }, + virtualBackground: { enabled: true }, + e2ee: { enabled: false } + } + }); + + const response = await request(app) + .get(ROOMS_PATH) + .set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_INITIAL_API_KEY) + .expect(200); + + // Filter out any rooms from other test suites + const testRooms = response.body.rooms.filter( + (r: MeetRoom) => r.roomId === room1.roomId || r.roomId === room2.roomId + ); + + expect(testRooms).toHaveLength(2); + + const e2eeEnabledRoom = testRooms.find((r: MeetRoom) => r.roomId === room1.roomId); + const e2eeDisabledRoom = testRooms.find((r: MeetRoom) => r.roomId === room2.roomId); + + expect(e2eeEnabledRoom.config.e2ee?.enabled).toBe(true); + expect(e2eeEnabledRoom.config.recording.enabled).toBe(false); + + expect(e2eeDisabledRoom.config.e2ee?.enabled).toBe(false); + expect(e2eeDisabledRoom.config.recording.enabled).toBe(true); + }); + }); +}); diff --git a/meet-ce/backend/tests/integration/api/rooms/get-room-preferences.test.ts b/meet-ce/backend/tests/integration/api/rooms/get-room-preferences.test.ts index c6b492de..e420b409 100644 --- a/meet-ce/backend/tests/integration/api/rooms/get-room-preferences.test.ts +++ b/meet-ce/backend/tests/integration/api/rooms/get-room-preferences.test.ts @@ -3,6 +3,7 @@ import { MeetRecordingAccess } from '@openvidu-meet/typings'; import { expectSuccessRoomConfigResponse } from '../../../helpers/assertion-helpers.js'; import { deleteAllRooms, getRoomConfig, startTestServer } from '../../../helpers/request-helpers.js'; import { setupSingleRoom } from '../../../helpers/test-scenarios.js'; +import { Response } from 'supertest'; describe('Room API Tests', () => { const DEFAULT_CONFIG = { @@ -11,7 +12,8 @@ describe('Room API Tests', () => { allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER }, chat: { enabled: true }, - virtualBackground: { enabled: true } + virtualBackground: { enabled: true }, + e2ee: { enabled: false } }; beforeAll(() => { @@ -28,7 +30,7 @@ describe('Room API Tests', () => { const roomData = await setupSingleRoom(); const roomId = roomData.room.roomId; - const response = await getRoomConfig(roomId); + const response: Response = await getRoomConfig(roomId); expectSuccessRoomConfigResponse(response, DEFAULT_CONFIG); }); @@ -41,7 +43,8 @@ describe('Room API Tests', () => { allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER }, chat: { enabled: true }, - virtualBackground: { enabled: false } + virtualBackground: { enabled: false }, + e2ee: { enabled: false } } }; diff --git a/meet-ce/backend/tests/integration/api/rooms/get-room.test.ts b/meet-ce/backend/tests/integration/api/rooms/get-room.test.ts index 22106a16..7f5f69b9 100644 --- a/meet-ce/backend/tests/integration/api/rooms/get-room.test.ts +++ b/meet-ce/backend/tests/integration/api/rooms/get-room.test.ts @@ -41,7 +41,8 @@ describe('Room API Tests', () => { allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER }, chat: { enabled: true }, - virtualBackground: { enabled: false } + virtualBackground: { enabled: false }, + e2ee: { enabled: false } } }; // Create a room with custom config diff --git a/meet-ce/backend/tests/integration/api/rooms/update-room-preferences.test.ts b/meet-ce/backend/tests/integration/api/rooms/update-room-preferences.test.ts index 33c7d81e..aecaa809 100644 --- a/meet-ce/backend/tests/integration/api/rooms/update-room-preferences.test.ts +++ b/meet-ce/backend/tests/integration/api/rooms/update-room-preferences.test.ts @@ -38,7 +38,8 @@ describe('Room API Tests', () => { allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER }, chat: { enabled: true }, - virtualBackground: { enabled: true } + virtualBackground: { enabled: true }, + e2ee: { enabled: false } } }); @@ -49,7 +50,8 @@ describe('Room API Tests', () => { allowAccessTo: MeetRecordingAccess.ADMIN }, chat: { enabled: false }, - virtualBackground: { enabled: false } + virtualBackground: { enabled: false }, + e2ee: { enabled: true } }; const updateResponse = await updateRoomConfig(createdRoom.roomId, updatedConfig); @@ -86,7 +88,8 @@ describe('Room API Tests', () => { allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER }, chat: { enabled: true }, - virtualBackground: { enabled: true } + virtualBackground: { enabled: true }, + e2ee: { enabled: false } } }); @@ -97,7 +100,8 @@ describe('Room API Tests', () => { allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER }, chat: { enabled: true }, - virtualBackground: { enabled: true } + virtualBackground: { enabled: true }, + e2ee: { enabled: false } }; const updateResponse = await updateRoomConfig(createdRoom.roomId, partialConfig); diff --git a/meet-ce/frontend/angular.json b/meet-ce/frontend/angular.json index 89cf8eda..1ab52503 100644 --- a/meet-ce/frontend/angular.json +++ b/meet-ce/frontend/angular.json @@ -24,7 +24,15 @@ "polyfills": ["zone.js"], "tsConfig": "src/tsconfig.app.json", "inlineStyleLanguage": "scss", - "assets": ["src/favicon.ico", "src/assets"], + "assets": [ + "src/favicon.ico", + "src/assets", + { + "glob": "livekit-client.e2ee.worker.mjs", + "input": "../../node_modules/livekit-client/dist/", + "output": "assets/livekit/" + } + ], "styles": ["src/styles.scss"], "scripts": [], "preserveSymlinks": true @@ -186,31 +194,31 @@ } } } - }, - "schematics": { - "@schematics/angular:component": { - "type": "component" - }, - "@schematics/angular:directive": { - "type": "directive" - }, - "@schematics/angular:service": { - "type": "service" - }, - "@schematics/angular:guard": { - "typeSeparator": "." - }, - "@schematics/angular:interceptor": { - "typeSeparator": "." - }, - "@schematics/angular:module": { - "typeSeparator": "." - }, - "@schematics/angular:pipe": { - "typeSeparator": "." - }, - "@schematics/angular:resolver": { - "typeSeparator": "." - } - } + }, + "schematics": { + "@schematics/angular:component": { + "type": "component" + }, + "@schematics/angular:directive": { + "type": "directive" + }, + "@schematics/angular:service": { + "type": "service" + }, + "@schematics/angular:guard": { + "typeSeparator": "." + }, + "@schematics/angular:interceptor": { + "typeSeparator": "." + }, + "@schematics/angular:module": { + "typeSeparator": "." + }, + "@schematics/angular:pipe": { + "typeSeparator": "." + }, + "@schematics/angular:resolver": { + "typeSeparator": "." + } + } } diff --git a/meet-ce/frontend/package.json b/meet-ce/frontend/package.json index 5556c840..29ce9f27 100644 --- a/meet-ce/frontend/package.json +++ b/meet-ce/frontend/package.json @@ -2,6 +2,7 @@ "name": "@openvidu-meet/frontend", "version": "3.4.1", "scripts": { + "clean": "rm -rf node_modules dist test-results", "dev": "pnpm exec ng build --configuration development --watch", "build": "func() { pnpm exec ng build --configuration production --base-href=\"${1:-/}\"; }; func", "lib:serve": "ng build shared-meet-components --watch", diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-lobby/meeting-lobby.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-lobby/meeting-lobby.component.html index 3531f4ef..2d5efd5e 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-lobby/meeting-lobby.component.html +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-lobby/meeting-lobby.component.html @@ -5,6 +5,12 @@ video_chat

{{ roomName }}

+ @if (isE2EEEnabled) { + + lock + This meeting is end-to-end encrypted + + }
@@ -27,6 +33,7 @@ @if (!roomClosed) {
+ Your display name + + @if (isE2EEEnabled) { + + Encryption Key + + vpn_key + @if (participantForm.get('e2eeKey')?.hasError('required')) { + The encryption key is required + } + This room requires an encryption key to join + + } + @@ -66,7 +93,7 @@ - @if (showRecordingsCard) { + @if (showRecordingCard) { video_library diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-lobby/meeting-lobby.component.scss b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-lobby/meeting-lobby.component.scss index 8d9588da..9bca5652 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-lobby/meeting-lobby.component.scss +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-lobby/meeting-lobby.component.scss @@ -14,7 +14,7 @@ @include design-tokens.ov-flex-center; flex-direction: column; gap: var(--ov-meet-spacing-md); - margin-bottom: var(--ov-meet-spacing-xxl); + margin-bottom: var(--ov-meet-spacing-lg); text-align: center; .room-icon { @@ -24,16 +24,58 @@ } .room-info { + flex: 1; .room-title { margin: 0; font-size: var(--ov-meet-font-size-hero); font-weight: var(--ov-meet-font-weight-light); color: var(--ov-meet-text-primary); line-height: var(--ov-meet-line-height-tight); + display: flex; + align-items: center; + justify-content: center; + gap: var(--ov-meet-spacing-md); + flex-wrap: wrap; + + @include design-tokens.ov-tablet-down { + font-size: var(--ov-meet-font-size-xxl); + flex-direction: column; + gap: var(--ov-meet-spacing-sm); + + .encryption-badge { + font-size: var(--ov-meet-font-size-xs); + } + } + } + + .encryption-badge { + display: inline-flex; + align-items: center; + gap: var(--ov-meet-spacing-xs); + padding: var(--ov-meet-spacing-xs) var(--ov-meet-spacing-md); + background: linear-gradient(135deg, rgba(33, 150, 243, 0.15) 0%, rgba(33, 150, 243, 0.08) 100%); + border: 1px solid rgba(33, 150, 243, 0.3); + border-radius: var(--ov-meet-radius-lg); + font-size: var(--ov-meet-font-size-sm); + font-weight: var(--ov-meet-font-weight-medium); + color: var(--ov-meet-color-info); + + .badge-icon { + @include design-tokens.ov-icon(sm); + color: var(--ov-meet-color-info); + } } } } +// E2EE Warning Container +.e2ee-warning-container { + margin-bottom: var(--ov-meet-spacing-lg); + max-width: 800px; + margin-left: auto; + margin-right: auto; +} + // Action Cards Grid - Responsive layout .action-cards-grid { @include design-tokens.ov-grid-responsive(320px); @@ -172,15 +214,21 @@ .join-form { display: flex; flex-direction: column; - gap: var(--ov-meet-spacing-lg); + gap: var(--ov-meet-spacing-sm); flex: 1; .name-field { width: 100%; + transition: all 0.3s ease; + } - .mat-mdc-form-field-icon-suffix { - color: var(--ov-meet-text-hint); - } + .e2eeey-field { + width: 100%; + animation: slideDown 0.3s ease-out; + } + + .mat-mdc-form-field-icon-suffix { + color: var(--ov-meet-text-hint); } .join-button { diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-lobby/meeting-lobby.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-lobby/meeting-lobby.component.ts index 6308a197..5b7a7a98 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-lobby/meeting-lobby.component.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-lobby/meeting-lobby.component.ts @@ -6,7 +6,7 @@ import { MatCardModule } from '@angular/material/card'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; -import { ShareMeetingLinkComponent } from '../share-meeting-link/share-meeting-link.component'; +import { ShareMeetingLinkComponent } from '../../components'; /** * Reusable component for the meeting lobby page. @@ -45,9 +45,9 @@ export class MeetingLobbyComponent { @Input() roomClosed = false; /** - * Whether to show the recordings card + * Whether to show the recording card */ - @Input() showRecordingsCard = false; + @Input() showRecordingCard = false; /** * Whether to show the share meeting link component @@ -64,6 +64,11 @@ export class MeetingLobbyComponent { */ @Input() backButtonText = 'Back'; + /** + * Whether E2EE is enabled for the meeting + */ + @Input() isE2EEEnabled = false; + /** * The participant form group */ diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/models/lobby.model.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/models/lobby.model.ts index bb56c54e..950e4429 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/models/lobby.model.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/models/lobby.model.ts @@ -13,6 +13,7 @@ export interface LobbyState { showRecordingCard: boolean; showBackButton: boolean; backButtonText: string; + isE2EEEnabled: boolean; participantForm: FormGroup; participantToken: string; } diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/room-wizard.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/room-wizard.component.html index 5ec5e63a..fd6d1c5e 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/room-wizard.component.html +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/room-wizard.component.html @@ -46,7 +46,7 @@ @if (isBasicCreation()) { } @else { @@ -93,7 +93,7 @@ (next)="onNext()" (cancel)="onCancel()" (back)="onBack()" - (finish)="onFinish()" + (finish)="createRoomAdvance()" > } diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/room-wizard.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/room-wizard.component.ts index 8522366a..c7bef22e 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/room-wizard.component.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/room-wizard.component.ts @@ -8,7 +8,7 @@ import { ActivatedRoute } from '@angular/router'; import { StepIndicatorComponent, WizardNavComponent } from '../../../../components'; import { WizardNavigationConfig, WizardStep } from '../../../../models'; import { NavigationService, NotificationService, RoomService, RoomWizardStateService } from '../../../../services'; -import { MeetRoomOptions } from '@openvidu-meet/typings'; +import { BaseRoomOptions, MeetRoomOptions } from '@openvidu-meet/typings'; import { RoomBasicCreationComponent } from '../room-basic-creation/room-basic-creation.component'; import { RecordingConfigComponent } from './steps/recording-config/recording-config.component'; import { RecordingLayoutComponent } from './steps/recording-layout/recording-layout.component'; @@ -125,9 +125,18 @@ export class RoomWizardComponent implements OnInit { await this.navigationService.navigateTo('rooms', undefined, true); } - async createRoom(roomName?: string) { + async createRoomBasic(roomName?: string) { try { - const { moderatorUrl } = await this.roomService.createRoom({ roomName }); + // Create room with basic config including e2ee: false (default settings) + const { moderatorUrl } = await this.roomService.createRoom({ + roomName, + config: { + chat: { enabled: true }, + recording: { enabled: true }, + virtualBackground: { enabled: true }, + e2ee: { enabled: false } + } + }); // Extract the path and query parameters from the moderator URL and navigate to it const url = new URL(moderatorUrl); @@ -144,8 +153,8 @@ export class RoomWizardComponent implements OnInit { } } - async onFinish() { - const roomOptions = this.wizardService.roomOptions(); + async createRoomAdvance() { + const roomOptions: BaseRoomOptions = this.wizardService.roomOptions(); console.log('Wizard completed with data:', roomOptions); // Activate loading state diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-config/recording-config.component.scss b/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-config/recording-config.component.scss index b07c8c43..28693736 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-config/recording-config.component.scss +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-config/recording-config.component.scss @@ -41,9 +41,39 @@ margin-bottom: var(--ov-meet-spacing-md); .recording-form { + .e2ee-warning { + display: flex; + align-items: flex-start; + gap: var(--ov-meet-spacing-sm); + padding: var(--ov-meet-spacing-md); + margin-bottom: var(--ov-meet-spacing-lg); + background: linear-gradient(135deg, var(--ov-meet-color-info-alpha-10) 0%, var(--ov-meet-color-info-alpha-5) 100%); + border: 1px solid var(--ov-meet-color-info-alpha-30); + border-radius: var(--ov-meet-border-radius-md); + + .warning-icon { + @include design-tokens.ov-icon(md); + color: var(--ov-meet-color-info); + margin-top: 2px; + } + + .warning-text { + flex: 1; + margin: 0; + font-size: var(--ov-meet-font-size-sm); + color: var(--ov-meet-text-primary); + line-height: var(--ov-meet-line-height-normal); + } + } + .options-grid { @include design-tokens.ov-grid-responsive(260px); gap: var(--ov-meet-spacing-md); + + &.disabled-grid { + pointer-events: none; + opacity: 0.5; + } } .access-selection-section { diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-config/recording-config.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-config/recording-config.component.ts index f80b030c..b00655fc 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-config/recording-config.component.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-config/recording-config.component.ts @@ -1,4 +1,3 @@ - import { Component, OnDestroy } from '@angular/core'; import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; @@ -18,30 +17,33 @@ interface RecordingAccessOption { } @Component({ - selector: 'ov-recording-config', - imports: [ - ReactiveFormsModule, - MatButtonModule, - MatIconModule, - MatCardModule, - MatRadioModule, - MatSelectModule, - MatFormFieldModule, - SelectableCardComponent -], - templateUrl: './recording-config.component.html', - styleUrl: './recording-config.component.scss' + selector: 'ov-recording-config', + imports: [ + ReactiveFormsModule, + MatButtonModule, + MatIconModule, + MatCardModule, + MatRadioModule, + MatSelectModule, + MatFormFieldModule, + SelectableCardComponent + ], + templateUrl: './recording-config.component.html', + styleUrl: './recording-config.component.scss' }) export class RecordingConfigComponent implements OnDestroy { recordingForm: FormGroup; isAnimatingOut = false; + // Store the previous E2EE state before recording disables it + private e2eeStateBeforeRecording: boolean | null = null; + recordingOptions: SelectableOption[] = [ { id: 'enabled', title: 'Allow Recording', description: - 'Enable recording capabilities for this room. Recordings can be started manually or automatically.', + 'Enable recording features for this room, allowing authorized participants to start and manage recordings.', icon: 'video_library' // recommended: true }, @@ -102,7 +104,43 @@ export class RecordingConfigComponent implements OnDestroy { const previouslyEnabled = this.isRecordingEnabled; const willBeEnabled = event.optionId === 'enabled'; - // If we are disabling the recording, we want to animate out + const configStep = this.wizardState.steps().find((step) => step.id === 'config'); + + // Handle E2EE state when recording changes + if (configStep) { + if (!previouslyEnabled && willBeEnabled) { + // Enabling recording: save E2EE state and disable it if needed + const e2eeEnabled = configStep.formGroup.get('e2eeEnabled')?.value; + + if (e2eeEnabled) { + // Save the E2EE state before disabling it + this.e2eeStateBeforeRecording = true; + + // Disable E2EE when enabling recording + configStep.formGroup.patchValue( + { + e2eeEnabled: false + }, + { emitEvent: true } + ); + } + } else if (previouslyEnabled && !willBeEnabled) { + // Disabling recording: restore E2EE state if it was saved + if (this.e2eeStateBeforeRecording !== null) { + configStep.formGroup.patchValue( + { + e2eeEnabled: this.e2eeStateBeforeRecording + }, + { emitEvent: true } + ); + + // Clear the saved state + this.e2eeStateBeforeRecording = null; + } + } + } + + // Handle recording form update with animation if (previouslyEnabled && !willBeEnabled) { this.isAnimatingOut = true; // Wait for the animation to finish before updating the form diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/room-config/room-config.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/room-config/room-config.component.html index 972ec165..d48d2669 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/room-config/room-config.component.html +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/room-config/room-config.component.html @@ -13,6 +13,55 @@
+ + + +
+
+ lock +
+

End-to-End Encryption

+

+ Add an extra layer of security to your meetings. No one outside the meeting, not + even anyone with server access, can see or hear the conversation. +

+
+
+ + +
+ + + @if (e2eeEnabled) { +
+
+ warning +
+

Restrictions

+
    +
  • + All participants must use the same encryption key to see and hear each + other. + + Participants who enter an incorrect key will not receive an error + message but will be unable to see or hear others. + +
  • +
  • Recording is unavailable while encryption is enabled.
  • +
  • Chat messages are not protected by end-to-end encryption.
  • +
+
+
+
+ } +
+
+ diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/room-config/room-config.component.scss b/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/room-config/room-config.component.scss index 69a81674..8b3a5be4 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/room-config/room-config.component.scss +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/room-config/room-config.component.scss @@ -65,6 +65,12 @@ transition: all 0.2s ease-in-out; cursor: default; + &.e2ee-card { + .feature-icon { + color: var(--ov-meet-color-info) !important; + } + } + &:hover { border-color: var(--ov-meet-border-primary); box-shadow: var(--ov-meet-shadow-md); @@ -118,6 +124,78 @@ align-self: flex-start; } } + + .e2ee-info-section { + margin-top: var(--ov-meet-spacing-md); + display: flex; + flex-direction: column; + gap: var(--ov-meet-spacing-sm); + animation: slideIn 0.3s ease-out; + + .warning-card { + display: flex; + align-items: flex-start; + gap: var(--ov-meet-spacing-sm); + padding: var(--ov-meet-spacing-md); + border-radius: var(--ov-meet-radius-sm); + } + + .warning-card { + background: rgba(255, 152, 0, 0.08); + border: 1px solid rgba(255, 152, 0, 0.2); + + .warning-icon { + @include design-tokens.ov-icon(md); + color: var(--ov-meet-color-warning); + flex-shrink: 0; + margin-top: 2px; + } + + .warning-content { + flex: 1; + + .warning-title { + margin: 0 0 var(--ov-meet-spacing-xs) 0; + font-size: var(--ov-meet-font-size-sm); + font-weight: var(--ov-meet-font-weight-semibold); + color: var(--ov-meet-color-warning); + } + + .restrictions-list { + margin: 0; + padding-left: var(--ov-meet-spacing-md); + font-size: var(--ov-meet-font-size-sm); + line-height: var(--ov-meet-line-height-relaxed); + color: var(--ov-meet-text-primary); + + li { + margin-bottom: var(--ov-meet-spacing-xs); + + &:last-child { + margin-bottom: 0; + } + } + + .restriction-detail { + display: block; + color: var(--ov-meet-text-secondary); + margin-top: var(--ov-meet-spacing-xs); + } + } + } + } + } + } +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); } } diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/room-config/room-config.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/room-config/room-config.component.ts index a8f13825..98ecd203 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/room-config/room-config.component.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/room-config/room-config.component.ts @@ -16,6 +16,8 @@ export class RoomConfigComponent implements OnDestroy { configForm: FormGroup; private destroy$ = new Subject(); + // Store the previous recording state before E2EE disables it + private recordingStateBeforeE2EE: string | null = null; constructor(private wizardService: RoomWizardStateService) { const currentStep = this.wizardService.currentStep(); @@ -32,13 +34,18 @@ export class RoomConfigComponent implements OnDestroy { } private saveFormData(formValue: any): void { + const isE2EEEnabled = formValue.e2eeEnabled ?? false; + const stepData: any = { config: { chat: { - enabled: formValue.chatEnabled + enabled: formValue.chatEnabled ?? false }, virtualBackground: { - enabled: formValue.virtualBackgroundsEnabled + enabled: formValue.virtualBackgroundsEnabled ?? false + }, + e2ee: { + enabled: isE2EEEnabled } } }; @@ -46,6 +53,41 @@ export class RoomConfigComponent implements OnDestroy { this.wizardService.updateStepData('config', stepData); } + onE2EEToggleChange(event: any): void { + const isEnabled = event.checked; + this.configForm.patchValue({ + e2eeEnabled: isEnabled + }); + + const recordingStep = this.wizardService.steps().find(step => step.id === 'recording'); + if (!recordingStep) return; + + if (isEnabled) { + // Save the current recording state before disabling it + const currentRecordingValue = recordingStep.formGroup.get('recordingEnabled')?.value; + + // Only save if it's not already 'disabled' (to preserve user's original choice) + if (currentRecordingValue !== 'disabled') { + this.recordingStateBeforeE2EE = currentRecordingValue; + } + + // Disable recording automatically + recordingStep.formGroup.patchValue({ + recordingEnabled: 'disabled' + }, { emitEvent: true }); + } else { + // Restore the previous recording state when E2EE is disabled + if (this.recordingStateBeforeE2EE !== null) { + recordingStep.formGroup.patchValue({ + recordingEnabled: this.recordingStateBeforeE2EE + }, { emitEvent: true }); + + // Clear the saved state + this.recordingStateBeforeE2EE = null; + } + } + } + onChatToggleChange(event: any): void { const isEnabled = event.checked; this.configForm.patchValue({ chatEnabled: isEnabled }); @@ -61,6 +103,10 @@ export class RoomConfigComponent implements OnDestroy { } get virtualBackgroundsEnabled(): boolean { - return this.configForm.value.virtualBackgroundsEnabled || false; + return this.configForm.value.virtualBackgroundEnabled ?? false; + } + + get e2eeEnabled(): boolean { + return this.configForm.value.e2eeEnabled ?? false; } } diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/meeting/meeting.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/meeting/meeting.component.html index 293a0592..448eb3fc 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/meeting/meeting.component.html +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/meeting/meeting.component.html @@ -20,6 +20,7 @@ [prejoinDisplayParticipantName]="false" [videoEnabled]="features().videoEnabled" [audioEnabled]="features().audioEnabled" + [e2eeKey]="e2eeKey" [toolbarRoomName]="roomName" [toolbarCameraButton]="features().showCamera" [toolbarMicrophoneButton]="features().showMicrophone" diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/meeting/meeting.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/meeting/meeting.component.ts index 187ee5bd..d4117d73 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/meeting/meeting.component.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/meeting/meeting.component.ts @@ -143,14 +143,9 @@ export class MeetingComponent implements OnInit { protected lobbyInputs = computed(() => { if (!this.lobbyState) return {}; return this.pluginManager.getLobbyInputs( - this.roomName, - `${this.hostname}/room/${this.roomId}`, - this.lobbyState.roomClosed, - this.lobbyState.showRecordingCard, - !this.lobbyState.roomClosed && this.features().canModerateRoom, - this.lobbyState.showBackButton, - this.lobbyState.backButtonText, - this.lobbyState.participantForm, + this.lobbyState, + this.hostname, + this.features().canModerateRoom, () => this.submitAccessMeeting(), () => this.lobbyService.goToRecordings(), () => this.lobbyService.goBack(), @@ -183,6 +178,9 @@ export class MeetingComponent implements OnInit { get participantName(): string { return this.lobbyService.participantName; } + get e2eeKey(): string { + return this.lobbyService.e2eeKey; + } get participantToken(): string { return this.lobbyState!.participantToken; diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/services/meeting/meeting-lobby.service.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/services/meeting/meeting-lobby.service.ts index a3d39fd5..bede6ce6 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/services/meeting/meeting-lobby.service.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/services/meeting/meeting-lobby.service.ts @@ -36,8 +36,10 @@ export class MeetingLobbyService { showRecordingCard: false, showBackButton: true, backButtonText: 'Back', + isE2EEEnabled: false, participantForm: new FormGroup({ - name: new FormControl('', [Validators.required]) + name: new FormControl('', [Validators.required]), + e2eeKey: new FormControl('') }), participantToken: '' }; @@ -70,6 +72,14 @@ export class MeetingLobbyService { return value.name.trim(); } + get e2eeKey(): string { + const { valid, value } = this.state.participantForm; + if (!valid || !value.e2eeKey?.trim()) { + return ''; + } + return value.e2eeKey.trim(); + } + /** * Initializes the lobby state by fetching room data and configuring UI */ @@ -78,6 +88,13 @@ export class MeetingLobbyService { this.state.roomSecret = this.roomService.getRoomSecret(); this.state.room = await this.roomService.getRoom(this.state.roomId); this.state.roomClosed = this.state.room.status === MeetRoomStatus.CLOSED; + this.state.isE2EEEnabled = this.state.room.config.e2ee?.enabled || false; + + // If E2EE is enabled, require e2eeKey + if (this.state.isE2EEEnabled) { + this.state.participantForm.get('e2eeKey')?.setValidators([Validators.required]); + this.state.participantForm.get('e2eeKey')?.updateValueAndValidity(); + } await this.setBackButtonText(); await this.checkForRecordings(); @@ -86,7 +103,6 @@ export class MeetingLobbyService { return this.state; } - /** * Handles the back button click event and navigates accordingly * If in embedded mode, it closes the WebComponentManagerService @@ -134,6 +150,12 @@ export class MeetingLobbyService { throw new Error('Participant form is invalid'); } + // For E2EE rooms, validate passkey + if (this.state.isE2EEEnabled && !this.e2eeKey) { + console.warn('E2EE key is required for encrypted rooms.'); + return; + } + await this.generateParticipantToken(); await this.addParticipantNameToUrl(); await this.roomService.loadRoomConfig(this.state.roomId); diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/services/meeting/meeting-plugin-manager.service.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/services/meeting/meeting-plugin-manager.service.ts index 2a636af2..6df06633 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/services/meeting/meeting-plugin-manager.service.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/services/meeting/meeting-plugin-manager.service.ts @@ -1,6 +1,5 @@ import { Injectable, Optional, Inject } from '@angular/core'; -import { FormGroup } from '@angular/forms'; -import { CustomParticipantModel } from '../../models'; +import { CustomParticipantModel, LobbyState } from '../../models'; import { MeetingActionHandler, MEETING_ACTION_HANDLER_TOKEN, ParticipantControls } from '../../customization'; import { ParticipantService } from '../participant.service'; @@ -27,11 +26,7 @@ export class MeetingPluginManagerService { /** * Prepares inputs for the toolbar additional buttons plugin */ - getToolbarAdditionalButtonsInputs( - canModerateRoom: boolean, - isMobile: boolean, - onCopyLink: () => void - ) { + getToolbarAdditionalButtonsInputs(canModerateRoom: boolean, isMobile: boolean, onCopyLink: () => void) { return { showCopyLinkButton: canModerateRoom, showLeaveMenu: false, @@ -61,11 +56,7 @@ export class MeetingPluginManagerService { /** * Prepares inputs for the participant panel "after local participant" plugin */ - getParticipantPanelAfterLocalInputs( - canModerateRoom: boolean, - meetingUrl: string, - onCopyLink: () => void - ) { + getParticipantPanelAfterLocalInputs(canModerateRoom: boolean, meetingUrl: string, onCopyLink: () => void) { return { showShareLink: canModerateRoom, meetingUrl, @@ -76,11 +67,7 @@ export class MeetingPluginManagerService { /** * Prepares inputs for the layout additional elements plugin */ - getLayoutAdditionalElementsInputs( - showOverlay: boolean, - meetingUrl: string, - onCopyLink: () => void - ) { + getLayoutAdditionalElementsInputs(showOverlay: boolean, meetingUrl: string, onCopyLink: () => void) { return { showOverlay, meetingUrl, @@ -118,27 +105,36 @@ export class MeetingPluginManagerService { * Prepares inputs for the lobby plugin */ getLobbyInputs( - roomName: string, - meetingUrl: string, - roomClosed: boolean, - showRecordingCard: boolean, - showShareLink: boolean, - showBackButton: boolean, - backButtonText: string, - participantForm: FormGroup, + lobbyState: LobbyState, + hostname: string, + canModerateRoom: boolean, onFormSubmit: () => void, onViewRecordings: () => void, onBack: () => void, onCopyLink: () => void ) { + const { + room, + roomId, + roomClosed, + showRecordingCard, + showBackButton, + backButtonText, + isE2EEEnabled, + participantForm + } = lobbyState; + const meetingUrl = `${hostname}/room/${roomId}`; + const showShareLink = !roomClosed && canModerateRoom; + return { - roomName, + roomName: room?.roomName || 'Room', meetingUrl, roomClosed, - showRecordingsCard: showRecordingCard, + showRecordingCard, showShareLink, showBackButton, backButtonText, + isE2EEEnabled, participantForm, formSubmittedFn: onFormSubmit, viewRecordingsClickedFn: onViewRecordings, @@ -177,7 +173,8 @@ export class MeetingPluginManagerService { // Calculate if current moderator can revoke the moderator role from the target participant // Only allow if target is not an original moderator - const canRevokeModeratorRole = currentUserIsModerator && !isCurrentUser && participantIsModerator && !participantIsOriginalModerator; + const canRevokeModeratorRole = + currentUserIsModerator && !isCurrentUser && participantIsModerator && !participantIsOriginalModerator; // Calculate if current moderator can kick the target participant // Only allow if target is not an original moderator diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/services/wizard-state.service.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/services/wizard-state.service.ts index 5a1593c3..e368fc07 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/services/wizard-state.service.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/services/wizard-state.service.ts @@ -16,7 +16,8 @@ const DEFAULT_CONFIG: MeetRoomConfig = { allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER }, chat: { enabled: true }, - virtualBackground: { enabled: true } + virtualBackground: { enabled: true }, + e2ee: { enabled: false } }; /** @@ -188,7 +189,8 @@ export class RoomWizardStateService { isVisible: true, formGroup: this.formBuilder.group({ chatEnabled: initialRoomOptions.config!.chat.enabled, - virtualBackgroundsEnabled: initialRoomOptions.config!.virtualBackground.enabled + virtualBackgroundsEnabled: initialRoomOptions.config!.virtualBackground.enabled, + e2eeEnabled: initialRoomOptions.config!.e2ee?.enabled ?? false }) } ]; @@ -259,9 +261,16 @@ export class RoomWizardStateService { ...currentOptions.config?.virtualBackground, ...stepData.config?.virtualBackground }, + e2ee: { + ...currentOptions.config?.e2ee, + ...stepData.config?.e2ee + }, recording: { ...currentOptions.config?.recording, - ...stepData.config?.recording + // If recording is explicitly set in stepData, use it + ...(stepData.config?.recording?.enabled !== undefined && { + enabled: stepData.config.recording.enabled + }) } } as MeetRoomConfig }; diff --git a/meet-ce/frontend/src/assets/styles/_animations.scss b/meet-ce/frontend/src/assets/styles/_animations.scss index 3fe4a581..c7bdf3e0 100644 --- a/meet-ce/frontend/src/assets/styles/_animations.scss +++ b/meet-ce/frontend/src/assets/styles/_animations.scss @@ -24,6 +24,22 @@ } } +// Animations +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + max-height: 0; + margin: 0; + } + to { + opacity: 1; + transform: translateY(0); + max-height: 150px; + margin: var(--ov-meet-spacing-lg) 0 0 0; + } +} + @keyframes slideInFromTop { from { opacity: 0; diff --git a/meet-ce/frontend/webcomponent/package.json b/meet-ce/frontend/webcomponent/package.json index a3b01ea7..8cb0cb50 100644 --- a/meet-ce/frontend/webcomponent/package.json +++ b/meet-ce/frontend/webcomponent/package.json @@ -13,6 +13,7 @@ "test:e2e-core-events": "playwright test tests/e2e/core/events.test.ts", "test:e2e-core-webhooks": "playwright test tests/e2e/core/webhooks.test.ts", "test:e2e-ui-features": "playwright test tests/e2e/ui-feature-config.test.ts", + "test:e2e-e2ee-ui": "playwright test tests/e2e/e2ee-ui.test.ts", "test:e2e-recording-access": "playwright test tests/e2e/recording-access.test.ts", "lint": "eslint 'src/**/*.ts'" }, diff --git a/meet-ce/frontend/webcomponent/tests/e2e/core/moderation.test.ts b/meet-ce/frontend/webcomponent/tests/e2e/core/moderation.test.ts index 6b6e08e8..73fc7001 100644 --- a/meet-ce/frontend/webcomponent/tests/e2e/core/moderation.test.ts +++ b/meet-ce/frontend/webcomponent/tests/e2e/core/moderation.test.ts @@ -15,7 +15,7 @@ import { openParticipantsPanel, prepareForJoiningRoom, removeParticipantModerator, - waitForElementInIframe + waitForElementInIframe, } from '../../helpers/function-helpers.js'; let subscribedToAppErrors = false; @@ -39,6 +39,16 @@ test.describe('Moderation Functionality Tests', () => { roomId = await createTestRoom('moderation-test-room'); }); + test.afterAll(async ({ browser }) => { + const tempContext = await browser.newContext(); + const tempPage = await tempContext.newPage(); + await deleteAllRooms(tempPage); + await deleteAllRecordings(tempPage); + + await tempContext.close(); + await tempPage.close(); + }); + test.beforeEach(async ({ page }) => { if (!subscribedToAppErrors) { page.on('console', (msg) => { diff --git a/meet-ce/frontend/webcomponent/tests/e2e/e2ee-ui.test.ts b/meet-ce/frontend/webcomponent/tests/e2e/e2ee-ui.test.ts new file mode 100644 index 00000000..e9f37700 --- /dev/null +++ b/meet-ce/frontend/webcomponent/tests/e2e/e2ee-ui.test.ts @@ -0,0 +1,412 @@ +import { expect, test } from '@playwright/test'; +import { MeetRecordingAccess } from '../../../../typings/src/room-config'; +import { MEET_TESTAPP_URL } from '../config'; +import { + closeMoreOptionsMenu, + countElementsInIframe, + createTestRoom, + deleteAllRecordings, + deleteAllRooms, + interactWithElementInIframe, + joinRoomAs, + leaveRoom, + openMoreOptionsMenu, + prepareForJoiningRoom, + updateRoomConfig, + waitForElementInIframe +} from '../helpers/function-helpers'; + +let subscribedToAppErrors = false; + +test.describe('E2EE UI Tests', () => { + let roomId: string; + let participantName: string; + + // ========================================== + // SETUP & TEARDOWN + // ========================================== + + test.beforeAll(async () => { + // Create a test room before all tests + roomId = await createTestRoom('test-room-e2ee'); + }); + + test.beforeEach(async ({ page }) => { + if (!subscribedToAppErrors) { + page.on('console', (msg) => { + const type = msg.type(); + const tag = type === 'error' ? 'ERROR' : type === 'warning' ? 'WARNING' : 'LOG'; + console.log('[' + tag + ']', msg.text()); + }); + subscribedToAppErrors = true; + } + + participantName = `P-${Math.random().toString(36).substring(2, 9)}`; + }); + + test.afterAll(async ({ browser }) => { + const tempContext = await browser.newContext(); + const tempPage = await tempContext.newPage(); + await deleteAllRooms(tempPage); + await deleteAllRecordings(tempPage); + await tempContext.close(); + await tempPage.close(); + }); + + // ========================================== + // E2EE LOBBY UI TESTS + // ========================================== + + test.describe('E2EE Lobby Elements', () => { + test('should show E2EE key input and badge in lobby when E2EE is enabled', async ({ page }) => { + // Enable E2EE + await updateRoomConfig(roomId, { + chat: { enabled: true }, + recording: { enabled: false, allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER }, + virtualBackground: { enabled: true }, + e2ee: { enabled: true } + }); + + await prepareForJoiningRoom(page, MEET_TESTAPP_URL, roomId); + await page.click('#join-as-speaker'); + + const component = page.locator('openvidu-meet'); + await expect(component).toBeVisible(); + + // Wait for participant name input + await waitForElementInIframe(page, '#participant-name-input', { state: 'visible' }); + + // Check that E2EE badge is visible + const e2eeBadge = await waitForElementInIframe(page, '.encryption-badge', { state: 'visible' }); + await expect(e2eeBadge).toBeVisible(); + await expect(e2eeBadge).toContainText('end-to-end encrypted'); + + // Check that E2EE key input is visible + const e2eeKeyInput = await waitForElementInIframe(page, '#participant-e2eekey-input', { + state: 'visible' + }); + await expect(e2eeKeyInput).toBeVisible(); + + // Check that the input has correct attributes + await expect(e2eeKeyInput).toHaveAttribute('type', 'password'); + await expect(e2eeKeyInput).toHaveAttribute('required'); + }); + + test('should hide E2EE elements in lobby when E2EE is disabled', async ({ page }) => { + // Disable E2EE + await updateRoomConfig(roomId, { + chat: { enabled: true }, + recording: { enabled: true, allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER }, + virtualBackground: { enabled: true }, + e2ee: { enabled: false } + }); + + await page.goto(MEET_TESTAPP_URL); + await prepareForJoiningRoom(page, MEET_TESTAPP_URL, roomId); + await page.click('#join-as-speaker'); + + const component = page.locator('openvidu-meet'); + await expect(component).toBeVisible(); + + // Wait for participant name input + await waitForElementInIframe(page, '#participant-name-input', { state: 'visible' }); + + // Check that E2EE badge is hidden + const e2eeBadge = await waitForElementInIframe(page, '.encryption-badge', { state: 'hidden' }); + await expect(e2eeBadge).toBeHidden(); + + // Check that E2EE key input is hidden + const e2eeKeyInput = await waitForElementInIframe(page, '#participant-e2eekey-input', { + state: 'hidden' + }); + await expect(e2eeKeyInput).toBeHidden(); + }); + }); + + // ========================================== + // E2EE SESSION TESTS + // ========================================== + + test.describe('E2EE in Session', () => { + test.afterEach(async ({ page }) => { + try { + await leaveRoom(page); + } catch (error) { + // Ignore errors if already left + } + }); + + test('should allow participants to see and hear each other with correct E2EE key', async ({ + page, + context + }) => { + // Enable E2EE + await updateRoomConfig(roomId, { + chat: { enabled: true }, + recording: { enabled: false, allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER }, + virtualBackground: { enabled: true }, + e2ee: { enabled: true } + }); + + // Create a second page for participant 2 + const page2 = await context.newPage(); + + // Participant 1 joins with E2EE key + await page.goto(MEET_TESTAPP_URL); + await prepareForJoiningRoom(page, MEET_TESTAPP_URL, roomId); + await page.click('#join-as-speaker'); + + await waitForElementInIframe(page, '#participant-name-input', { state: 'visible' }); + await interactWithElementInIframe(page, '#participant-name-input', { + action: 'fill', + value: participantName + }); + + // Fill E2EE key + const e2eeKey = 'test-encryption-key-123'; + await interactWithElementInIframe(page, '#participant-e2eekey-input', { + action: 'fill', + value: e2eeKey + }); + + await interactWithElementInIframe(page, '#participant-name-submit', { action: 'click' }); + + // Wait for prejoin page and join + await waitForElementInIframe(page, 'ov-pre-join', { state: 'visible' }); + await interactWithElementInIframe(page, '#join-button', { action: 'click' }); + await waitForElementInIframe(page, 'ov-session', { state: 'visible' }); + + // Participant 2 joins with same E2EE key + const participant2Name = `P2-${Math.random().toString(36).substring(2, 9)}`; + await page2.goto(MEET_TESTAPP_URL); + await prepareForJoiningRoom(page2, MEET_TESTAPP_URL, roomId); + await page2.click('#join-as-speaker'); + + await waitForElementInIframe(page2, '#participant-name-input', { state: 'visible' }); + await interactWithElementInIframe(page2, '#participant-name-input', { + action: 'fill', + value: participant2Name + }); + + // Fill same E2EE key + await interactWithElementInIframe(page2, '#participant-e2eekey-input', { + action: 'fill', + value: e2eeKey + }); + + await interactWithElementInIframe(page2, '#participant-name-submit', { action: 'click' }); + + // Wait for prejoin page and join + await waitForElementInIframe(page2, 'ov-pre-join', { state: 'visible' }); + await interactWithElementInIframe(page2, '#join-button', { action: 'click' }); + await waitForElementInIframe(page2, 'ov-session', { state: 'visible' }); + + // Wait a bit for media to flow + await page.waitForTimeout(2000); + + // Check that both participants can see each other's video elements + const videoCount1 = await countElementsInIframe(page, '.OV_video-element'); + expect(videoCount1).toBeGreaterThanOrEqual(2); + + const videoCount2 = await countElementsInIframe(page2, '.OV_video-element'); + expect(videoCount2).toBeGreaterThanOrEqual(2); + + // Check that no encryption error poster is shown + const encryptionError1 = await waitForElementInIframe(page, '.encryption-error-poster', { + state: 'hidden' + }); + await expect(encryptionError1).toBeHidden(); + + const encryptionError2 = await waitForElementInIframe(page2, '.encryption-error-poster', { + state: 'hidden' + }); + await expect(encryptionError2).toBeHidden(); + + // Cleanup participant 2 + await leaveRoom(page2); + await page2.close(); + }); + + test('should show encryption error poster when using wrong E2EE key', async ({ page, context }) => { + // Enable E2EE + await updateRoomConfig(roomId, { + chat: { enabled: true }, + recording: { enabled: false, allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER }, + virtualBackground: { enabled: true }, + e2ee: { enabled: true } + }); + + // Create a second page for participant 2 + const page2 = await context.newPage(); + + // Participant 1 joins with E2EE key + await prepareForJoiningRoom(page, MEET_TESTAPP_URL, roomId); + await page.click('#join-as-speaker'); + + await waitForElementInIframe(page, '#participant-name-input', { state: 'visible' }); + await interactWithElementInIframe(page, '#participant-name-input', { + action: 'fill', + value: participantName + }); + + // Fill E2EE key + const e2eeKey1 = 'correct-key-abc'; + await interactWithElementInIframe(page, '#participant-e2eekey-input', { + action: 'fill', + value: e2eeKey1 + }); + + await interactWithElementInIframe(page, '#participant-name-submit', { action: 'click' }); + + // Wait for prejoin page and join + await waitForElementInIframe(page, 'ov-pre-join', { state: 'visible' }); + await interactWithElementInIframe(page, '#join-button', { action: 'click' }); + await waitForElementInIframe(page, 'ov-session', { state: 'visible' }); + + // Participant 2 joins with DIFFERENT E2EE key + const participant2Name = `P2-${Math.random().toString(36).substring(2, 9)}`; + await prepareForJoiningRoom(page2, MEET_TESTAPP_URL, roomId); + await page2.click('#join-as-speaker'); + + await waitForElementInIframe(page2, '#participant-name-input', { state: 'visible' }); + await interactWithElementInIframe(page2, '#participant-name-input', { + action: 'fill', + value: participant2Name + }); + + // Fill DIFFERENT E2EE key + const e2eeKey2 = 'wrong-key-xyz'; + await interactWithElementInIframe(page2, '#participant-e2eekey-input', { + action: 'fill', + value: e2eeKey2 + }); + + await interactWithElementInIframe(page2, '#participant-name-submit', { action: 'click' }); + + // Wait for prejoin page and join + await waitForElementInIframe(page2, 'ov-pre-join', { state: 'visible' }); + await interactWithElementInIframe(page2, '#join-button', { action: 'click' }); + await waitForElementInIframe(page2, 'ov-session', { state: 'visible' }); + + // Wait for encryption error to be detected + await page.waitForTimeout(3000); + + // Check that encryption error poster is shown on both sides + // Each participant should see an encryption error for the other's video + const videoPosterCount = await countElementsInIframe(page, '.encryption-error-poster'); + + //! FIXME: Temporarily expecting 2 posters due to audio and video streams (needs to be fixed in ov-components) + expect(videoPosterCount).toBe(2); + + const videoPosterCount2 = await countElementsInIframe(page2, '.encryption-error-poster'); + //! FIXME: Temporarily expecting 2 posters due to audio and video streams (needs to be fixed in ov-components) + expect(videoPosterCount2).toBe(2); + + // Add additional participant with correct key to verify they can see/hear each other + const page3 = await context.newPage(); + const participant3Name = `P3-${Math.random().toString(36).substring(2, 9)}`; + await prepareForJoiningRoom(page3, MEET_TESTAPP_URL, roomId); + await page3.click('#join-as-speaker'); + + await waitForElementInIframe(page3, '#participant-name-input', { state: 'visible' }); + await interactWithElementInIframe(page3, '#participant-name-input', { + action: 'fill', + value: participant3Name + }); + + // Fill CORRECT E2EE key + await interactWithElementInIframe(page3, '#participant-e2eekey-input', { + action: 'fill', + value: e2eeKey1 + }); + + await interactWithElementInIframe(page3, '#participant-name-submit', { action: 'click' }); + + // Wait for prejoin page and join + await waitForElementInIframe(page3, 'ov-pre-join', { state: 'visible' }); + await interactWithElementInIframe(page3, '#join-button', { action: 'click' }); + await waitForElementInIframe(page3, 'ov-session', { state: 'visible' }); + + // Wait a bit for media to flow + await page3.waitForTimeout(2000); + + // Check that participant 3 can see participant 1's video + const videoCount3 = await countElementsInIframe(page3, '.OV_video-element'); + expect(videoCount3).toBeGreaterThanOrEqual(2); + + const videoPosterCount3 = await countElementsInIframe(page3, '.encryption-error-poster'); + //! FIXME: Temporarily expecting 2 posters due to audio and video streams (needs to be fixed in ov-components) + expect(videoPosterCount3).toBe(2); + + // Cleanup participant 2 + await Promise.all([leaveRoom(page2), leaveRoom(page3)]); + await Promise.all([page2.close(), page3.close()]); + }); + }); + + // ========================================== + // E2EE AND RECORDING INTERACTION TESTS + // ========================================== + + test.describe('E2EE and Recording', () => { + test.afterEach(async ({ page }) => { + try { + await leaveRoom(page, 'moderator'); + } catch (error) { + // Ignore errors if already left + } + }); + + test('should hide recording button when E2EE is enabled', async ({ page }) => { + // Enable E2EE (which should auto-disable recording) + await updateRoomConfig(roomId, { + chat: { enabled: true }, + recording: { enabled: false, allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER }, + virtualBackground: { enabled: true }, + e2ee: { enabled: true } + }); + + await page.goto(MEET_TESTAPP_URL); + await prepareForJoiningRoom(page, MEET_TESTAPP_URL, roomId); + + // Join as moderator to access recording controls + await page.click('#join-as-moderator'); + const component = page.locator('openvidu-meet'); + await expect(component).toBeVisible(); + + // Fill participant name + await waitForElementInIframe(page, '#participant-name-input', { state: 'visible' }); + await interactWithElementInIframe(page, '#participant-name-input', { + action: 'fill', + value: participantName + }); + + // Fill E2EE key + const e2eeKey = 'test-key-recording'; + await interactWithElementInIframe(page, '#participant-e2eekey-input', { + action: 'fill', + value: e2eeKey + }); + + await interactWithElementInIframe(page, '#participant-name-submit', { action: 'click' }); + + // Wait for prejoin page and join + await waitForElementInIframe(page, 'ov-pre-join', { state: 'visible' }); + await interactWithElementInIframe(page, '#join-button', { action: 'click' }); + await waitForElementInIframe(page, 'ov-session', { state: 'visible' }); + + // Open more options menu + await openMoreOptionsMenu(page); + + // Check that recording button is not visible + const recordingButton = await waitForElementInIframe(page, '#recording-btn', { state: 'hidden' }); + await expect(recordingButton).toBeHidden(); + + await closeMoreOptionsMenu(page); + + // Also check that recording activities panel is not available + const activitiesButton = await waitForElementInIframe(page, '#activities-panel-btn', { state: 'hidden' }); + await expect(activitiesButton).toBeHidden(); + }); + }); +}); diff --git a/meet-ce/frontend/webcomponent/tests/helpers/function-helpers.ts b/meet-ce/frontend/webcomponent/tests/helpers/function-helpers.ts index 5198ec01..a60448ad 100644 --- a/meet-ce/frontend/webcomponent/tests/helpers/function-helpers.ts +++ b/meet-ce/frontend/webcomponent/tests/helpers/function-helpers.ts @@ -58,6 +58,24 @@ export async function waitForElementInIframe( return elementLocator; } +export async function countElementsInIframe( + page: Page, + elementSelector: string, + options: { + componentSelector?: string; + iframeSelector?: string; + timeout?: number; + state?: 'attached' | 'visible'; + } = {} +): Promise { + const { componentSelector = 'openvidu-meet', iframeSelector = 'iframe' } = options; + + const frameLocator = await getIframeInShadowDom(page, componentSelector, iframeSelector); + const elements = frameLocator.locator(elementSelector); + + return await elements.count(); +} + // Interact with an element inside an iframe within Shadow DOM export async function interactWithElementInIframe( page: Page, diff --git a/meet-ce/typings/src/room-config.ts b/meet-ce/typings/src/room-config.ts index 01c912de..f0f4b7b9 100644 --- a/meet-ce/typings/src/room-config.ts +++ b/meet-ce/typings/src/room-config.ts @@ -5,6 +5,7 @@ export interface MeetRoomConfig { chat: MeetChatConfig; recording: MeetRecordingConfig; virtualBackground: MeetVirtualBackgroundConfig; + e2ee?: MeetE2EEConfig; // appearance: MeetAppearanceConfig; } @@ -30,6 +31,10 @@ export interface MeetVirtualBackgroundConfig { enabled: boolean; } +export interface MeetE2EEConfig { + enabled: boolean; +} + export interface MeetAppearanceConfig { themes: MeetRoomTheme[]; } diff --git a/package.json b/package.json index 2fd77666..db19dc4b 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "description": "OpenVidu Meet Monorepo", "scripts": { "build": "pnpm run build:typings && pnpm run build:backend && pnpm run build:shared-components && pnpm run build:frontend && pnpm run build:webcomponent", + "clean": "pnpm --filter @openvidu-meet/backend run clean && pnpm --filter @openvidu-meet/frontend run clean", "build:frontend": "pnpm --filter @openvidu-meet/frontend run build ${BASE_HREF:-/}", "build:backend": "pnpm --filter @openvidu-meet/backend run build", "build:webcomponent": "pnpm --filter openvidu-meet-webcomponent run build", diff --git a/testapp/public/views/index.mustache b/testapp/public/views/index.mustache index 0723ec45..9ebe3e2a 100644 --- a/testapp/public/views/index.mustache +++ b/testapp/public/views/index.mustache @@ -320,6 +320,51 @@
+ + +
+

+ +

+
+
+
+ + +
+ +
+
+
diff --git a/testapp/src/controllers/homeController.ts b/testapp/src/controllers/homeController.ts index 82fd4db4..1f533cf7 100644 --- a/testapp/src/controllers/homeController.ts +++ b/testapp/src/controllers/homeController.ts @@ -170,6 +170,9 @@ const processFormConfig = (body: any): any => { }, virtualBackground: { enabled: body['config.virtualBackground.enabled'] === 'on' + }, + e2ee: { + enabled: body['config.e2ee.enabled'] === 'on' } };