diff --git a/backend/tests/helpers/request-helpers.ts b/backend/tests/helpers/request-helpers.ts index 911eff6..4b8c03d 100644 --- a/backend/tests/helpers/request-helpers.ts +++ b/backend/tests/helpers/request-helpers.ts @@ -18,7 +18,14 @@ import { } from '../../src/environment.js'; import { createApp, registerDependencies } from '../../src/server.js'; import { RecordingService, RoomService } from '../../src/services/index.js'; -import { AuthMode, AuthType, MeetRoom, MeetRoomOptions, UserRole } from '../../src/typings/ce/index.js'; +import { + AuthMode, + AuthType, + MeetRoom, + MeetRoomOptions, + UserRole, + WebhookPreferences +} from '../../src/typings/ce/index.js'; const CREDENTIALS = { user: { @@ -302,6 +309,17 @@ export const joinFakeParticipant = async (roomId: string, participantName: strin await sleep('1s'); }; +export const endMeeting = async (roomId: string, moderatorCookie: string) => { + checkAppIsRunning(); + + const response = await request(app) + .delete(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/meetings/${roomId}`) + .set('Cookie', moderatorCookie) + .send(); + await sleep('1s'); + return response; +}; + export const disconnectFakeParticipants = async () => { fakeParticipantsProcesses.forEach((process, participantName) => { process.kill(); @@ -312,6 +330,18 @@ export const disconnectFakeParticipants = async () => { await sleep('1s'); }; +export const updateWebbhookPreferences = async (preferences: WebhookPreferences) => { + checkAppIsRunning(); + + const userCookie = await loginUserAsRole(UserRole.ADMIN); + const response = await request(app) + .put(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/preferences/webhooks`) + .set('Cookie', userCookie) + .send(preferences); + + return response; +}; + /** * Generates a token for retrieving/deleting recordings from a room and returns the cookie containing the token */ diff --git a/backend/tests/helpers/test-scenarios.ts b/backend/tests/helpers/test-scenarios.ts index 857a199..c4135c6 100644 --- a/backend/tests/helpers/test-scenarios.ts +++ b/backend/tests/helpers/test-scenarios.ts @@ -1,6 +1,6 @@ import { StringValue } from 'ms'; import { MeetRoomHelper } from '../../src/helpers'; -import { MeetRoom } from '../../src/typings/ce'; +import { MeetRoom, MeetWebhookEvent } from '../../src/typings/ce'; import { expectValidStartRecordingResponse } from './assertion-helpers'; import { createRoom, @@ -10,6 +10,10 @@ import { startRecording, stopRecording } from './request-helpers'; +import express, { Request, Response } from 'express'; +import http from 'http'; + +let mockWebhookServer: http.Server; export interface RoomData { room: MeetRoom; @@ -169,3 +173,31 @@ export const setupMultiRecordingsTestContext = async ( return testContext; }; + +export const startWebhookServer = async ( + port: number, + webhookReceivedCallback: (event: Request) => void +): Promise => { + const app = express(); + app.use(express.json()); + + app.post('/webhook', (req: Request, res: Response) => { + webhookReceivedCallback(req); + res.status(200).send({ success: true }); + }); + + return new Promise((resolve) => { + mockWebhookServer = app.listen(port, () => { + console.log(`Webhook server listening on port ${port}`); + resolve(); + }); + }); +}; + +export const stopWebhookServer = async (): Promise => { + if (mockWebhookServer) { + await new Promise((resolve) => { + mockWebhookServer.close(() => resolve()); + }); + } +}; diff --git a/backend/tests/integration/api/webhooks/webhook.test.ts b/backend/tests/integration/api/webhooks/webhook.test.ts new file mode 100644 index 0000000..0258116 --- /dev/null +++ b/backend/tests/integration/api/webhooks/webhook.test.ts @@ -0,0 +1,167 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'; +import { Request } from 'express'; +import http from 'http'; +import { container } from '../../../../src/config/dependency-injector.config.js'; +import { MeetStorageService } from '../../../../src/services/index.js'; +import { + startTestServer, + deleteAllRecordings, + sleep, + endMeeting, + updateWebbhookPreferences +} from '../../../helpers/request-helpers'; +import { MeetWebhookEvent, MeetWebhookEventType } from '../../../../src/typings/ce/webhook.model.js'; + +import { + setupSingleRoom, + setupSingleRoomWithRecording, + startWebhookServer, + stopWebhookServer +} from '../../../helpers/test-scenarios.js'; +import { MeetRecordingInfo, MeetRecordingStatus } from '../../../../src/typings/ce/recording.model.js'; + +describe('Webhook Integration Tests', () => { + let receivedWebhooks: { headers: http.IncomingHttpHeaders; body: MeetWebhookEvent }[] = []; + let storageService: MeetStorageService; + + beforeAll(async () => { + startTestServer(); + storageService = container.get(MeetStorageService); + + // Start test server for webhooks + await startWebhookServer(5080, (req: Request) => { + receivedWebhooks.push({ + headers: req.headers, + body: req.body + }); + }); + }); + + beforeEach(async () => { + receivedWebhooks = []; + // Enable webhooks in global preferences + await updateWebbhookPreferences({ + enabled: true, + url: `http://localhost:5080/webhook` + }); + }); + + afterAll(async () => { + await stopWebhookServer(); + const defaultPreferences = await storageService['getDefaultPreferences'](); + await updateWebbhookPreferences(defaultPreferences.webhooksPreferences); + await deleteAllRecordings(); + }); + + it('should not send webhooks when disabled', async () => { + await updateWebbhookPreferences({ + enabled: false + }); + + await setupSingleRoom(true); + + // Wait for the room to be created + await sleep('3s'); + expect(receivedWebhooks.length).toBe(0); + }); + + it('should send meeting_started webhook when room is created', async () => { + const context = await setupSingleRoom(true); + const roomData = context.room; + + // Wait for the room to be created + await sleep('1s'); + + // Verify 'meetingStarted' webhook is sent + expect(receivedWebhooks.length).toBeGreaterThanOrEqual(1); + const meetingStartedWebhook = receivedWebhooks.find( + (w) => w.body.event === MeetWebhookEventType.MEETING_STARTED + ); + expect(meetingStartedWebhook).toBeDefined(); + expect(meetingStartedWebhook?.body.data.roomId).toBe(roomData.roomId); + expect(meetingStartedWebhook?.body.creationDate).toBeLessThanOrEqual(Date.now()); + expect(meetingStartedWebhook?.body.creationDate).toBeGreaterThanOrEqual(Date.now() - 3000); + expect(meetingStartedWebhook?.headers['x-signature']).toBeDefined(); + expect(meetingStartedWebhook?.headers['x-timestamp']).toBeDefined(); + }); + + it('should send meeting_ended webhook when room is closed', async () => { + const context = await setupSingleRoom(true); + const roomData = context.room; + const moderatorCookie = context.moderatorCookie; + + // Close the room + await endMeeting(roomData.roomId, moderatorCookie); + + // Wait for the room to be closed + await sleep('1s'); + + // Verify 'meetingEnded' webhook is sent + expect(receivedWebhooks.length).toBeGreaterThanOrEqual(1); + const meetingEndedWebhook = receivedWebhooks.find((w) => w.body.event === MeetWebhookEventType.MEETING_ENDED); + expect(meetingEndedWebhook).toBeDefined(); + expect(meetingEndedWebhook?.body.data.roomId).toBe(roomData.roomId); + expect(meetingEndedWebhook?.body.creationDate).toBeLessThanOrEqual(Date.now()); + expect(meetingEndedWebhook?.body.creationDate).toBeGreaterThanOrEqual(Date.now() - 3000); + expect(meetingEndedWebhook?.headers['x-signature']).toBeDefined(); + expect(meetingEndedWebhook?.headers['x-timestamp']).toBeDefined(); + }); + + it('should send recordingStarted, recordingUpdated and recordingEnded webhooks when recording is started and stopped', async () => { + const startDate = Date.now(); + const context = await setupSingleRoomWithRecording(true, '2s'); + const roomData = context.room; + const recordingId = context.recordingId; + + const recordingWebhooks = receivedWebhooks.filter((w) => w.body.event.startsWith('recording')); + // STARTED, ACTIVE, ENDING, COMPLETE + expect(recordingWebhooks.length).toBe(4); + + // Check recording_started webhook + const recordingStartedWebhook = receivedWebhooks.find( + (w) => w.body.event === MeetWebhookEventType.RECORDING_STARTED + ); + + let data = recordingStartedWebhook?.body.data as MeetRecordingInfo; + expect(recordingStartedWebhook).toBeDefined(); + expect(data.roomId).toBe(roomData.roomId); + expect(data.recordingId).toBe(recordingId); + expect(recordingStartedWebhook?.body.creationDate).toBeLessThan(Date.now()); + expect(recordingStartedWebhook?.body.creationDate).toBeGreaterThan(startDate); + expect(recordingStartedWebhook?.headers['x-signature']).toBeDefined(); + expect(recordingStartedWebhook?.headers['x-timestamp']).toBeDefined(); + expect(recordingStartedWebhook?.body.event).toBe(MeetWebhookEventType.RECORDING_STARTED); + expect(data.status).toBe(MeetRecordingStatus.STARTING); + + // Check recording_updated webhook + const recordingUpdatedWebhook = receivedWebhooks.find( + (w) => w.body.event === MeetWebhookEventType.RECORDING_UPDATED + ); + data = recordingUpdatedWebhook?.body.data as MeetRecordingInfo; + expect(recordingUpdatedWebhook).toBeDefined(); + expect(data.roomId).toBe(roomData.roomId); + expect(data.recordingId).toBe(recordingId); + expect(recordingUpdatedWebhook?.body.creationDate).toBeLessThan(Date.now()); + expect(recordingUpdatedWebhook?.body.creationDate).toBeGreaterThan(startDate); + expect(recordingUpdatedWebhook?.headers['x-signature']).toBeDefined(); + expect(recordingUpdatedWebhook?.headers['x-timestamp']).toBeDefined(); + expect(recordingUpdatedWebhook?.body.event).toBe(MeetWebhookEventType.RECORDING_UPDATED); + expect(data.status).toBe(MeetRecordingStatus.ACTIVE); + + // Check recording_ended webhook + const recordingEndedWebhook = receivedWebhooks.find( + (w) => w.body.event === MeetWebhookEventType.RECORDING_ENDED + ); + data = recordingEndedWebhook?.body.data as MeetRecordingInfo; + expect(recordingEndedWebhook).toBeDefined(); + expect(data.roomId).toBe(roomData.roomId); + expect(data.recordingId).toBe(recordingId); + expect(recordingEndedWebhook?.body.creationDate).toBeLessThan(Date.now()); + expect(recordingEndedWebhook?.body.creationDate).toBeGreaterThan(startDate); + expect(recordingEndedWebhook?.headers['x-signature']).toBeDefined(); + expect(recordingEndedWebhook?.headers['x-timestamp']).toBeDefined(); + expect(recordingEndedWebhook?.body.event).toBe(MeetWebhookEventType.RECORDING_ENDED); + expect(data.status).not.toBe(MeetRecordingStatus.ENDING); + expect(data.status).toBe(MeetRecordingStatus.COMPLETE); + }); +});