diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a30ee885d98c..6ba94e46301eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -655,6 +655,7 @@ jobs: env: ROCKETCHAT_IMAGE: ghcr.io/${{ needs.release-versions.outputs.lowercase-repo }}/rocket.chat:${{ needs.release-versions.outputs.gh-docker-tag }} ENTERPRISE_LICENSE_RC1: ${{ secrets.ENTERPRISE_LICENSE_RC1 }} + QASE_TESTOPS_JEST_API_TOKEN: ${{ secrets.QASE_TESTOPS_JEST_API_TOKEN }} run: yarn test:integration --image "${ROCKETCHAT_IMAGE}" report-coverage: diff --git a/apps/meteor/app/lib/server/functions/createRoom.ts b/apps/meteor/app/lib/server/functions/createRoom.ts index 233c6cb9548cc..3d9e3b1810570 100644 --- a/apps/meteor/app/lib/server/functions/createRoom.ts +++ b/apps/meteor/app/lib/server/functions/createRoom.ts @@ -120,6 +120,7 @@ async function createUsersSubscriptions({ await Rooms.incUsersCountById(room._id, subs.length); } +// eslint-disable-next-line complexity export const createRoom = async ( type: T, name: T extends 'd' ? undefined : string, diff --git a/apps/meteor/tests/data/file.helper.ts b/apps/meteor/tests/data/file.helper.ts new file mode 100644 index 0000000000000..23230a83053a3 --- /dev/null +++ b/apps/meteor/tests/data/file.helper.ts @@ -0,0 +1,170 @@ +import * as fs from 'fs'; + +import type { IMessage } from '@rocket.chat/core-typings'; + +import { api } from './api-data'; +import type { IRequestConfig } from './users.helper'; + +/** + * Uploads a file to Rocket.Chat using the two-step process (rooms.media then rooms.mediaConfirm). + * + * @param roomId - The room ID where the file will be uploaded + * @param filePath - Path to the file to upload + * @param description - Description for the file + * @param config - Request configuration with credentials and request instance + * @param message - Optional message text to include with the file + * @returns Promise resolving to the message response + */ +export async function uploadFileToRC( + roomId: string, + filePath: string, + description: string, + config: IRequestConfig, + message = '', +): Promise<{ message: IMessage }> { + const requestInstance = config.request; + const credentialsInstance = config.credentials; + + // Step 1: Upload file to rooms.media/:rid + const mediaResponse = await requestInstance + .post(api(`rooms.media/${roomId}`)) + .set(credentialsInstance) + .attach('file', filePath) + .expect('Content-Type', 'application/json') + .expect(200); + + if (!mediaResponse.body.success || !mediaResponse.body.file?._id) { + throw new Error(`File upload failed: ${JSON.stringify(mediaResponse.body)}`); + } + + const fileId = mediaResponse.body.file._id; + + // Step 2: Confirm and send message with rooms.mediaConfirm/:rid/:fileId + const confirmResponse = await requestInstance + .post(api(`rooms.mediaConfirm/${roomId}/${fileId}`)) + .set(credentialsInstance) + .send({ + msg: message, + description, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + if (!confirmResponse.body.success || !confirmResponse.body.message) { + throw new Error(`File confirmation failed: ${JSON.stringify(confirmResponse.body)}`); + } + + return confirmResponse.body; +} + +/** + * Gets the list of files for a room. + * + * @param roomId - The room ID + * @param config - Request configuration + * @param options - Optional query parameters (name for filtering, count, offset) + * @returns Promise resolving to the files list response + */ +export async function getFilesList( + roomId: string, + config: IRequestConfig, + options: { name?: string; count?: number; offset?: number } = {}, +): Promise<{ + files: Array<{ + _id: string; + name: string; + size: number; + type: string; + rid: string; + userId: string; + path?: string; + url?: string; + uploadedAt?: string; + federation?: { + mrid?: string; + mxcUri?: string; + serverName?: string; + mediaId?: string; + }; + }>; + count: number; + offset: number; + total: number; + success: boolean; +}> { + const requestInstance = config.request; + const credentialsInstance = config.credentials; + + const queryParams: Record = { + roomId, + count: String(options.count || 10), + offset: String(options.offset || 0), + sort: JSON.stringify({ uploadedAt: -1 }), + }; + + if (options.name) { + queryParams.name = options.name; + } + + const response = await requestInstance + .get(api('groups.files')) + .set(credentialsInstance) + .query(queryParams) + .expect('Content-Type', 'application/json') + .expect(200); + + if (!response.body.success) { + throw new Error(`Failed to get files list: ${JSON.stringify(response.body)}`); + } + + return response.body; +} + +/** + * Downloads a file and verifies it matches the original file using binary comparison. + * + * @param fileUrl - The URL to download the file from (relative path like /file-upload/...) + * @param originalFilePath - Path to the original file to compare against + * @param config - Request configuration + * @returns Promise resolving to true if files match byte-by-byte + */ +export async function downloadFileAndVerifyBinary(fileUrl: string, originalFilePath: string, config: IRequestConfig): Promise { + const requestInstance = config.request; + const credentialsInstance = config.credentials; + + const response = await requestInstance.get(fileUrl).set(credentialsInstance).expect(200); + + // Handle different response types: + // - For text/plain, supertest parses as JSON (resulting in {}), so use response.text + // - For binary files, response.body might be a Buffer + // - For other text types, response.text contains the content + let downloadedBuffer: Buffer; + if (Buffer.isBuffer(response.body)) { + // Binary file - response.body is already a Buffer + downloadedBuffer = response.body; + } else if (response.text !== undefined) { + // Text response (including text/plain) - use response.text to avoid JSON parsing + // Convert to Buffer using binary encoding to preserve exact bytes + downloadedBuffer = Buffer.from(response.text, 'binary'); + } else if (typeof response.body === 'string') { + // Fallback: if body is a string, convert to buffer + downloadedBuffer = Buffer.from(response.body, 'binary'); + } else { + // If body is an object (like {} from JSON parsing), this is an error + throw new Error( + `Failed to get file content. Response body type: ${typeof response.body}. ` + + `This usually means supertest parsed a text/plain response as JSON. ` + + `Response text available: ${response.text !== undefined ? 'yes' : 'no'}`, + ); + } + + // Read the original file + const originalBuffer = fs.readFileSync(originalFilePath); + + // Compare buffers byte-by-byte + if (downloadedBuffer.length !== originalBuffer.length) { + return false; + } + + return downloadedBuffer.equals(originalBuffer); +} diff --git a/ee/packages/federation-matrix/jest.config.federation.ts b/ee/packages/federation-matrix/jest.config.federation.ts index f123c918481dd..b1dbdc1d1c83a 100644 --- a/ee/packages/federation-matrix/jest.config.federation.ts +++ b/ee/packages/federation-matrix/jest.config.federation.ts @@ -21,6 +21,27 @@ export default { forceExit: true, // Force Jest to exit after tests complete detectOpenHandles: true, // Detect open handles that prevent Jest from exiting globalTeardown: '/tests/teardown.ts', + // To disable Qase integration, remove this line or comment it out + setupFilesAfterEnv: ['/tests/setup-qase.ts'], verbose: false, silent: false, + reporters: [ + 'default', + ...(process.env.QASE_TESTOPS_JEST_API_TOKEN + ? [ + [ + 'jest-qase-reporter', + { + mode: 'testops', + testops: { + api: { token: process.env.QASE_TESTOPS_JEST_API_TOKEN }, + project: 'RC', + run: { complete: true }, + }, + debug: true, + }, + ] as [string, { [x: string]: unknown }], + ] + : []), + ] as Config['reporters'], } satisfies Config; diff --git a/ee/packages/federation-matrix/tests/end-to-end/messaging.spec.ts b/ee/packages/federation-matrix/tests/end-to-end/messaging.spec.ts index 06abc2d47434b..fef26ad1678b5 100644 --- a/ee/packages/federation-matrix/tests/end-to-end/messaging.spec.ts +++ b/ee/packages/federation-matrix/tests/end-to-end/messaging.spec.ts @@ -1,5 +1,12 @@ +import * as path from 'path'; + import type { IMessage } from '@rocket.chat/core-typings'; +import { + uploadFileToRC, + getFilesList, + downloadFileAndVerifyBinary as downloadFileAndCompareBinary, +} from '../../../../../apps/meteor/tests/data/file.helper'; import { sendMessage } from '../../../../../apps/meteor/tests/data/messages.helper'; import { createRoom, loadHistory } from '../../../../../apps/meteor/tests/data/rooms.helper'; import { getRequestConfig, createUser } from '../../../../../apps/meteor/tests/data/users.helper'; @@ -83,9 +90,6 @@ import { SynapseClient } from '../helper/synapse-client'; // Accept invitation for the federated user const acceptedRoomId = await hs1AdminApp.acceptInvitationForRoomName(channelName); expect(acceptedRoomId).not.toBe(''); - - // Wait for federation synchronization - await new Promise((resolve) => setTimeout(resolve, 2000)); }, 10000); it('Send a text message', async () => { @@ -416,9 +420,6 @@ import { SynapseClient } from '../helper/synapse-client'; // Accept invitation for the federated user const acceptedRoomId = await hs1AdminApp.acceptInvitationForRoomName(channelName); expect(acceptedRoomId).not.toBe(''); - - // Wait for federation synchronization - await new Promise((resolve) => setTimeout(resolve, 2000)); }, 10000); it('Send a text message', async () => { @@ -671,5 +672,716 @@ import { SynapseClient } from '../helper/synapse-client'; }); }); }); + + describe('Media', () => { + // Test file resources + const resourcesDir = path.join(__dirname, '../resources'); + const testFiles = { + image: { + path: path.join(resourcesDir, 'sample_image.webp'), + fileName: 'sample_image.webp', + description: 'Image upload test', + }, + pdf: { + path: path.join(resourcesDir, 'sample_pdf.pdf'), + fileName: 'sample_pdf.pdf', + description: 'PDF document test', + }, + video: { + path: path.join(resourcesDir, 'sample_video.webm'), + fileName: 'sample_video.webm', + description: 'Video upload test', + }, + audio: { + path: path.join(resourcesDir, 'sample_audio.mp3'), + fileName: 'sample_audio.mp3', + description: 'Audio upload test', + }, + text: { + path: path.join(resourcesDir, 'sample_text.txt'), + fileName: 'sample_text.txt', + description: 'Text file upload test', + }, + }; + + describe('On RC', () => { + let channelName: string; + let federatedChannel: any; + + beforeAll(async () => { + channelName = `federated-room-media-rc-${Date.now()}`; + + // Create a federated private room with federated user + const createResponse = await createRoom({ + type: 'p', + name: channelName, + members: [federationConfig.hs1.adminMatrixUserId], + extraData: { + federated: true, + }, + config: rc1AdminRequestConfig, + }); + + federatedChannel = createResponse.body.group; + + expect(federatedChannel).toHaveProperty('_id'); + expect(federatedChannel).toHaveProperty('name', channelName); + expect(federatedChannel).toHaveProperty('t', 'p'); + expect(federatedChannel).toHaveProperty('federated', true); + + // Accept invitation for the federated user + const acceptedRoomId = await hs1AdminApp.acceptInvitationForRoomName(channelName); + expect(acceptedRoomId).not.toBe(''); + }, 10000); + + describe('Upload one image, and add a description', () => { + it('should appear correctly locally and on the remote Element as messages', async () => { + const fileInfo = testFiles.image; + const uploadResponse = await uploadFileToRC(federatedChannel._id, fileInfo.path, fileInfo.description, rc1AdminRequestConfig); + + expect(uploadResponse.message).toBeDefined(); + + // RC view: Verify in RC loadHistory + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.files?.[0]?.name === fileInfo.fileName); + expect(rcMessage).toBeDefined(); + + // RC view: Verify files array + expect(rcMessage?.files).toBeDefined(); + expect(rcMessage?.files?.[0]?.name).toBe(fileInfo.fileName); + expect(rcMessage?.files?.[0]?.type).toBe('image/webp'); + expect(rcMessage?.files?.[0]?.size).toBe(uploadResponse.message.files?.[0]?.size); + + // RC view: Verify attachments array + expect(rcMessage?.attachments).toBeDefined(); + expect(rcMessage?.attachments?.[0]?.title).toBe(fileInfo.fileName); + expect(rcMessage?.attachments?.[0]?.title_link).toMatch(/^\/file-upload\/[^/]+\/.+$/); + expect(rcMessage?.attachments?.[0]?.title_link_download).toBe(true); + expect((rcMessage?.attachments?.[0] as any)?.type).toBe('file'); + expect(rcMessage?.attachments?.[0]?.description).toBe(fileInfo.description); + expect((rcMessage?.attachments?.[0] as any)?.image_url).toMatch(/^\/file-upload\/[^/]+\/.+$/); + expect((rcMessage?.attachments?.[0] as any)?.image_type).toBe('image/webp'); + expect((rcMessage?.attachments?.[0] as any)?.image_size).toBe(uploadResponse.message.files?.[0]?.size); + + // RC view: Verify federation + expect(rcMessage?.federation?.eventId).not.toBe(''); + + // Element view: Verify in Element + const synapseMessage = await hs1AdminApp.findFileMessageInRoom(channelName, fileInfo.fileName); + expect(synapseMessage).not.toBeNull(); + expect(synapseMessage?.content.body).toBe(fileInfo.fileName); + expect(synapseMessage?.content.msgtype).toBe('m.image'); + expect(rcMessage?.federation?.eventId).toBe(synapseMessage?.event_id); + }); + + it('should appear in the list of files locally', async () => { + const fileInfo = testFiles.image; + + // RC view: Verify in RC file list + const rcFilesList = await getFilesList(federatedChannel._id, rc1AdminRequestConfig); + const rcFile = rcFilesList.files.find((file) => file.name === fileInfo.fileName); + expect(rcFile).toBeDefined(); + expect(rcFile?.type).toBe('image/webp'); + expect(rcFile?.federation).toBeDefined(); + + // RC view: The file should have federation metadata + expect(rcFile?.federation?.mxcUri).toBeDefined(); + }); + + it('should be able to download the files locally and on the remote Element', async () => { + const fileInfo = testFiles.image; + + // RC view: Get the file from history to get download URL + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.files?.[0]?.name === fileInfo.fileName); + expect(rcMessage).toBeDefined(); + expect(rcMessage?.attachments?.[0]?.title_link).toBeDefined(); + + // RC view: Download and verify binary match from RC + const downloadUrl = rcMessage?.attachments?.[0]?.title_link as string; + const rcFilesMatch = await downloadFileAndCompareBinary(downloadUrl, fileInfo.path, rc1AdminRequestConfig); + expect(rcFilesMatch).toBe(true); + + // Element view: Download and verify binary match from Element + const synapseMessage = await hs1AdminApp.findFileMessageInRoom(channelName, fileInfo.fileName); + expect(synapseMessage).not.toBeNull(); + expect(synapseMessage?.content.url).toBeDefined(); + + const synapseFilesMatch = await hs1AdminApp.downloadFileAndCompareBinary(synapseMessage?.content.url as string, fileInfo.path); + expect(synapseFilesMatch).toBe(true); + }); + }); + + describe('Upload one PDF, and add a description', () => { + it('should appear correctly locally and on the remote Element as messages', async () => { + const fileInfo = testFiles.pdf; + const uploadResponse = await uploadFileToRC(federatedChannel._id, fileInfo.path, fileInfo.description, rc1AdminRequestConfig); + + expect(uploadResponse.message).toBeDefined(); + + // RC view: Verify in RC loadHistory + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.files?.[0]?.name === fileInfo.fileName); + expect(rcMessage).toBeDefined(); + + // RC view: Verify files array + expect(rcMessage?.files).toBeDefined(); + expect(rcMessage?.files?.[0]?.name).toBe(fileInfo.fileName); + expect(rcMessage?.files?.[0]?.type).toBe('application/pdf'); + expect(rcMessage?.files?.[0]?.size).toBe(uploadResponse.message.files?.[0]?.size); + + // RC view: Verify attachments array + expect(rcMessage?.attachments).toBeDefined(); + expect(rcMessage?.attachments?.[0]?.title).toBe(fileInfo.fileName); + expect(rcMessage?.attachments?.[0]?.title_link).toMatch(/^\/file-upload\/[^/]+\/.+$/); + expect(rcMessage?.attachments?.[0]?.title_link_download).toBe(true); + expect((rcMessage?.attachments?.[0] as any)?.type).toBe('file'); + expect(rcMessage?.attachments?.[0]?.description).toBe(fileInfo.description); + expect(rcMessage?.attachments?.[0]?.size).toBe(uploadResponse.message.files?.[0]?.size); + + // RC view: Verify federation + expect(rcMessage?.federation?.eventId).not.toBe(''); + + // Element view: Verify in Element + const synapseMessage = await hs1AdminApp.findFileMessageInRoom(channelName, fileInfo.fileName); + expect(synapseMessage).not.toBeNull(); + expect(synapseMessage?.content.body).toBe(fileInfo.fileName); + expect(synapseMessage?.content.msgtype).toBe('m.file'); + expect(rcMessage?.federation?.eventId).toBe(synapseMessage?.event_id); + }); + + it('should appear in the list of files locally', async () => { + const fileInfo = testFiles.pdf; + + // RC view: Verify in RC file list + const rcFilesList = await getFilesList(federatedChannel._id, rc1AdminRequestConfig); + const rcFile = rcFilesList.files.find((file) => file.name === fileInfo.fileName); + expect(rcFile).toBeDefined(); + expect(rcFile?.type).toBe('application/pdf'); + expect(rcFile?.federation).toBeDefined(); + + // RC view: The file should have federation metadata + expect(rcFile?.federation?.mxcUri).toBeDefined(); + }); + + it('should be able to download the files locally and on the remote Element', async () => { + const fileInfo = testFiles.pdf; + + // RC view: Get the file from history to get download URL + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.files?.[0]?.name === fileInfo.fileName); + expect(rcMessage).toBeDefined(); + expect(rcMessage?.attachments?.[0]?.title_link).toBeDefined(); + + // RC view: Download and verify binary match from RC + const downloadUrl = rcMessage?.attachments?.[0]?.title_link as string; + const rcFilesMatch = await downloadFileAndCompareBinary(downloadUrl, fileInfo.path, rc1AdminRequestConfig); + expect(rcFilesMatch).toBe(true); + + // Element view: Download and verify binary match from Element + const synapseMessage = await hs1AdminApp.findFileMessageInRoom(channelName, fileInfo.fileName); + expect(synapseMessage).not.toBeNull(); + expect(synapseMessage?.content.url).toBeDefined(); + + const synapseFilesMatch = await hs1AdminApp.downloadFileAndCompareBinary(synapseMessage?.content.url as string, fileInfo.path); + expect(synapseFilesMatch).toBe(true); + }); + }); + + describe('Upload one Video, and add a description', () => { + it('should appear correctly locally and on the remote Element as messages', async () => { + const fileInfo = testFiles.video; + const uploadResponse = await uploadFileToRC(federatedChannel._id, fileInfo.path, fileInfo.description, rc1AdminRequestConfig); + + expect(uploadResponse.message).toBeDefined(); + + // RC view: Verify in RC loadHistory + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.files?.[0]?.name === fileInfo.fileName); + expect(rcMessage).toBeDefined(); + + // RC view: Verify files array + expect(rcMessage?.files).toBeDefined(); + expect(rcMessage?.files?.[0]?.name).toBe(fileInfo.fileName); + expect(rcMessage?.files?.[0]?.type).toBe('video/webm'); + expect(rcMessage?.files?.[0]?.size).toBe(uploadResponse.message.files?.[0]?.size); + + // RC view: Verify attachments array + expect(rcMessage?.attachments).toBeDefined(); + expect(rcMessage?.attachments?.[0]?.title).toBe(fileInfo.fileName); + expect(rcMessage?.attachments?.[0]?.title_link).toMatch(/^\/file-upload\/[^/]+\/.+$/); + expect(rcMessage?.attachments?.[0]?.title_link_download).toBe(true); + expect((rcMessage?.attachments?.[0] as any)?.type).toBe('file'); + expect(rcMessage?.attachments?.[0]?.description).toBe(fileInfo.description); + expect((rcMessage?.attachments?.[0] as any)?.video_url).toMatch(/^\/file-upload\/[^/]+\/.+$/); + expect((rcMessage?.attachments?.[0] as any)?.video_type).toBe('video/webm'); + expect((rcMessage?.attachments?.[0] as any)?.video_size).toBe(uploadResponse.message.files?.[0]?.size); + + // RC view: Verify federation + expect(rcMessage?.federation?.eventId).not.toBe(''); + + // Element view: Verify in Element + const synapseMessage = await hs1AdminApp.findFileMessageInRoom(channelName, fileInfo.fileName); + expect(synapseMessage).not.toBeNull(); + expect(synapseMessage?.content.body).toBe(fileInfo.fileName); + expect(synapseMessage?.content.msgtype).toBe('m.video'); + expect(rcMessage?.federation?.eventId).toBe(synapseMessage?.event_id); + }); + + it('should appear in the list of files locally', async () => { + const fileInfo = testFiles.video; + + // RC view: Verify in RC file list + const rcFilesList = await getFilesList(federatedChannel._id, rc1AdminRequestConfig); + const rcFile = rcFilesList.files.find((file) => file.name === fileInfo.fileName); + expect(rcFile).toBeDefined(); + expect(rcFile?.type).toBe('video/webm'); + expect(rcFile?.federation).toBeDefined(); + + // RC view: The file should have federation metadata + expect(rcFile?.federation?.mxcUri).toBeDefined(); + }); + + it('should be able to download the files locally and on the remote Element', async () => { + const fileInfo = testFiles.video; + + // RC view: Get the file from history to get download URL + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.files?.[0]?.name === fileInfo.fileName); + expect(rcMessage).toBeDefined(); + expect(rcMessage?.attachments?.[0]?.title_link).toBeDefined(); + + // RC view: Download and verify binary match from RC + const downloadUrl = rcMessage?.attachments?.[0]?.title_link as string; + const rcFilesMatch = await downloadFileAndCompareBinary(downloadUrl, fileInfo.path, rc1AdminRequestConfig); + expect(rcFilesMatch).toBe(true); + + // Element view: Download and verify binary match from Element + const synapseMessage = await hs1AdminApp.findFileMessageInRoom(channelName, fileInfo.fileName); + expect(synapseMessage).not.toBeNull(); + expect(synapseMessage?.content.url).toBeDefined(); + + const synapseFilesMatch = await hs1AdminApp.downloadFileAndCompareBinary(synapseMessage?.content.url as string, fileInfo.path); + expect(synapseFilesMatch).toBe(true); + }); + }); + + describe('Upload one Audio, and add a description', () => { + it('should appear correctly locally and on the remote Element as messages', async () => { + const fileInfo = testFiles.audio; + const uploadResponse = await uploadFileToRC(federatedChannel._id, fileInfo.path, fileInfo.description, rc1AdminRequestConfig); + + expect(uploadResponse.message).toBeDefined(); + + // RC view: Verify in RC loadHistory + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.files?.[0]?.name === fileInfo.fileName); + expect(rcMessage).toBeDefined(); + + // RC view: Verify files array + expect(rcMessage?.files).toBeDefined(); + expect(rcMessage?.files?.[0]?.name).toBe(fileInfo.fileName); + expect(rcMessage?.files?.[0]?.type).toBe('audio/mpeg'); + expect(rcMessage?.files?.[0]?.size).toBe(uploadResponse.message.files?.[0]?.size); + + // RC view: Verify attachments array + expect(rcMessage?.attachments).toBeDefined(); + expect(rcMessage?.attachments?.[0]?.title).toBe(fileInfo.fileName); + expect(rcMessage?.attachments?.[0]?.title_link).toMatch(/^\/file-upload\/[^/]+\/.+$/); + expect(rcMessage?.attachments?.[0]?.title_link_download).toBe(true); + expect((rcMessage?.attachments?.[0] as any)?.type).toBe('file'); + expect(rcMessage?.attachments?.[0]?.description).toBe(fileInfo.description); + expect((rcMessage?.attachments?.[0] as any)?.audio_url).toMatch(/^\/file-upload\/[^/]+\/.+$/); + expect((rcMessage?.attachments?.[0] as any)?.audio_type).toBe('audio/mpeg'); + expect((rcMessage?.attachments?.[0] as any)?.audio_size).toBe(uploadResponse.message.files?.[0]?.size); + + // RC view: Verify federation + expect(rcMessage?.federation?.eventId).not.toBe(''); + + // Element view: Verify in Element + const synapseMessage = await hs1AdminApp.findFileMessageInRoom(channelName, fileInfo.fileName); + expect(synapseMessage).not.toBeNull(); + expect(synapseMessage?.content.body).toBe(fileInfo.fileName); + expect(synapseMessage?.content.msgtype).toBe('m.audio'); + expect(rcMessage?.federation?.eventId).toBe(synapseMessage?.event_id); + }); + + it('should appear in the list of files locally', async () => { + const fileInfo = testFiles.audio; + + // RC view: Verify in RC file list + const rcFilesList = await getFilesList(federatedChannel._id, rc1AdminRequestConfig); + const rcFile = rcFilesList.files.find((file) => file.name === fileInfo.fileName); + expect(rcFile).toBeDefined(); + expect(rcFile?.type).toBe('audio/mpeg'); + expect(rcFile?.federation).toBeDefined(); + + // RC view: The file should have federation metadata + expect(rcFile?.federation?.mxcUri).toBeDefined(); + }); + + it('should be able to download the files locally and on the remote Element', async () => { + const fileInfo = testFiles.audio; + + // RC view: Get the file from history to get download URL + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.files?.[0]?.name === fileInfo.fileName); + expect(rcMessage).toBeDefined(); + expect(rcMessage?.attachments?.[0]?.title_link).toBeDefined(); + + // RC view: Download and verify binary match from RC + const downloadUrl = rcMessage?.attachments?.[0]?.title_link as string; + const rcFilesMatch = await downloadFileAndCompareBinary(downloadUrl, fileInfo.path, rc1AdminRequestConfig); + expect(rcFilesMatch).toBe(true); + + // Element view: Download and verify binary match from Element + const synapseMessage = await hs1AdminApp.findFileMessageInRoom(channelName, fileInfo.fileName); + expect(synapseMessage).not.toBeNull(); + expect(synapseMessage?.content.url).toBeDefined(); + + const synapseFilesMatch = await hs1AdminApp.downloadFileAndCompareBinary(synapseMessage?.content.url as string, fileInfo.path); + expect(synapseFilesMatch).toBe(true); + }); + }); + + describe('Upload one Text File, and add a description', () => { + it('should appear correctly locally and on the remote Element as messages', async () => { + const fileInfo = testFiles.text; + const uploadResponse = await uploadFileToRC(federatedChannel._id, fileInfo.path, fileInfo.description, rc1AdminRequestConfig); + + expect(uploadResponse.message).toBeDefined(); + + // RC view: Verify in RC loadHistory + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.files?.[0]?.name === fileInfo.fileName); + expect(rcMessage).toBeDefined(); + + // RC view: Verify files array + expect(rcMessage?.files).toBeDefined(); + expect(rcMessage?.files?.[0]?.name).toBe(fileInfo.fileName); + expect(rcMessage?.files?.[0]?.type).toBe('text/plain'); + expect(rcMessage?.files?.[0]?.size).toBe(uploadResponse.message.files?.[0]?.size); + + // RC view: Verify attachments array + expect(rcMessage?.attachments).toBeDefined(); + expect(rcMessage?.attachments?.[0]?.title).toBe(fileInfo.fileName); + expect(rcMessage?.attachments?.[0]?.title_link).toMatch(/^\/file-upload\/[^/]+\/.+$/); + expect(rcMessage?.attachments?.[0]?.title_link_download).toBe(true); + expect((rcMessage?.attachments?.[0] as any)?.type).toBe('file'); + expect(rcMessage?.attachments?.[0]?.description).toBe(fileInfo.description); + + // RC view: Verify federation + expect(rcMessage?.federation?.eventId).not.toBe(''); + + // Element view: Verify in Element + const synapseMessage = await hs1AdminApp.findFileMessageInRoom(channelName, fileInfo.fileName); + expect(synapseMessage).not.toBeNull(); + expect(synapseMessage?.content.body).toBe(fileInfo.fileName); + expect(synapseMessage?.content.msgtype).toBe('m.file'); + expect(rcMessage?.federation?.eventId).toBe(synapseMessage?.event_id); + }); + + it('should appear in the list of files locally', async () => { + const fileInfo = testFiles.text; + + // RC view: Verify in RC file list + const rcFilesList = await getFilesList(federatedChannel._id, rc1AdminRequestConfig); + const rcFile = rcFilesList.files.find((file) => file.name === fileInfo.fileName); + expect(rcFile).toBeDefined(); + expect(rcFile?.type).toBe('text/plain'); + expect(rcFile?.federation).toBeDefined(); + + // RC view: The file should have federation metadata + expect(rcFile?.federation?.mxcUri).toBeDefined(); + }); + + it('should be able to download the files locally and on the remote Element', async () => { + const fileInfo = testFiles.text; + + // RC view: Get the file from history to get download URL + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.files?.[0]?.name === fileInfo.fileName); + expect(rcMessage).toBeDefined(); + expect(rcMessage?.attachments?.[0]?.title_link).toBeDefined(); + + // RC view: Download and verify binary match from RC + const downloadUrl = rcMessage?.attachments?.[0]?.title_link as string; + const rcFilesMatch = await downloadFileAndCompareBinary(downloadUrl, fileInfo.path, rc1AdminRequestConfig); + expect(rcFilesMatch).toBe(true); + + // Element view: Download and verify binary match from Element + const synapseMessage = await hs1AdminApp.findFileMessageInRoom(channelName, fileInfo.fileName); + expect(synapseMessage).not.toBeNull(); + expect(synapseMessage?.content.url).toBeDefined(); + + const synapseFilesMatch = await hs1AdminApp.downloadFileAndCompareBinary(synapseMessage?.content.url as string, fileInfo.path); + expect(synapseFilesMatch).toBe(true); + }); + }); + }); + + describe('On Element', () => { + let channelName: string; + let federatedChannel: any; + + beforeAll(async () => { + channelName = `federated-room-media-element-${Date.now()}`; + + // Create a federated private room with federated user + const createResponse = await createRoom({ + type: 'p', + name: channelName, + members: [federationConfig.hs1.adminMatrixUserId], + extraData: { + federated: true, + }, + config: rc1AdminRequestConfig, + }); + + federatedChannel = createResponse.body.group; + + expect(federatedChannel).toHaveProperty('_id'); + expect(federatedChannel).toHaveProperty('name', channelName); + expect(federatedChannel).toHaveProperty('t', 'p'); + expect(federatedChannel).toHaveProperty('federated', true); + + // Accept invitation for the federated user + const acceptedRoomId = await hs1AdminApp.acceptInvitationForRoomName(channelName); + expect(acceptedRoomId).not.toBe(''); + }, 10000); + + describe('Upload one image', () => { + it('should appear correctly on the remote RC as messages', async () => { + const fileInfo = testFiles.image; + await hs1AdminApp.uploadFile(channelName, fileInfo.path, fileInfo.fileName); + + // Element view: Verify in Element + const synapseMessage = await hs1AdminApp.findFileMessageInRoom(channelName, fileInfo.fileName); + expect(synapseMessage).not.toBeNull(); + expect(synapseMessage?.content.msgtype).toBe('m.image'); + + // RC view: Verify in RC loadHistory + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.attachments?.[0]?.title === fileInfo.fileName); + expect(rcMessage).toBeDefined(); + expect((rcMessage?.attachments?.[0] as any)?.type).toBe('file'); + expect(rcMessage?.federation?.eventId).toBe(synapseMessage?.event_id); + }); + + it('should appear in the list of files on the remote RC', async () => { + const fileInfo = testFiles.image; + + // RC view: Verify in RC file list + const rcFilesList = await getFilesList(federatedChannel._id, rc1AdminRequestConfig); + const rcFile = rcFilesList.files.find((file) => file.name === fileInfo.fileName); + expect(rcFile).toBeDefined(); + }); + + it('should be able to download the files on the remote RC', async () => { + const fileInfo = testFiles.image; + + // RC view: Download and verify binary match from RC + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.attachments?.[0]?.title === fileInfo.fileName); + expect(rcMessage?.attachments?.[0]?.title_link).toBeDefined(); + const downloadUrl = rcMessage?.attachments?.[0]?.title_link as string; + const rcFilesMatch = await downloadFileAndCompareBinary(downloadUrl, fileInfo.path, rc1AdminRequestConfig); + expect(rcFilesMatch).toBe(true); + }); + + it('should be possible to filter the list of files on the remote RC', async () => { + const fileInfo = testFiles.image; + const filteredFiles = await getFilesList(federatedChannel._id, rc1AdminRequestConfig, { + name: fileInfo.fileName, + }); + expect(filteredFiles.files.find((file) => file.name === fileInfo.fileName)).toBeDefined(); + }); + }); + + describe('Upload one PDF', () => { + it('should appear correctly on the remote RC as messages', async () => { + const fileInfo = testFiles.pdf; + await hs1AdminApp.uploadFile(channelName, fileInfo.path, fileInfo.fileName); + + // Element view: Verify in Element + const synapseMessage = await hs1AdminApp.findFileMessageInRoom(channelName, fileInfo.fileName); + expect(synapseMessage).not.toBeNull(); + expect(synapseMessage?.content.msgtype).toBe('m.file'); + + // RC view: Verify in RC loadHistory + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.attachments?.[0]?.title === fileInfo.fileName); + expect(rcMessage).toBeDefined(); + expect(rcMessage?.federation?.eventId).toBe(synapseMessage?.event_id); + }); + + it('should appear in the list of files on the remote RC', async () => { + const fileInfo = testFiles.pdf; + + // RC view: Verify in RC file list + const rcFilesList = await getFilesList(federatedChannel._id, rc1AdminRequestConfig); + expect(rcFilesList.files.find((file) => file.name === fileInfo.fileName)).toBeDefined(); + }); + + it('should be able to download the files on the remote RC', async () => { + const fileInfo = testFiles.pdf; + + // RC view: Download and verify binary match from RC + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.attachments?.[0]?.title === fileInfo.fileName); + expect(rcMessage?.attachments?.[0]?.title_link).toBeDefined(); + const downloadUrl = rcMessage?.attachments?.[0]?.title_link as string; + const rcFilesMatch = await downloadFileAndCompareBinary(downloadUrl, fileInfo.path, rc1AdminRequestConfig); + expect(rcFilesMatch).toBe(true); + }); + + it('should be possible to filter the list of files on the remote RC', async () => { + const fileInfo = testFiles.pdf; + const filteredFiles = await getFilesList(federatedChannel._id, rc1AdminRequestConfig, { + name: fileInfo.fileName, + }); + expect(filteredFiles.files.find((file) => file.name === fileInfo.fileName)).toBeDefined(); + }); + }); + + describe('Upload one Video', () => { + it('should appear correctly on the remote RC as messages', async () => { + const fileInfo = testFiles.video; + await hs1AdminApp.uploadFile(channelName, fileInfo.path, fileInfo.fileName); + + // Element view: Verify in Element + const synapseMessage = await hs1AdminApp.findFileMessageInRoom(channelName, fileInfo.fileName); + expect(synapseMessage).not.toBeNull(); + expect(synapseMessage?.content.msgtype).toBe('m.video'); + + // RC view: Verify in RC loadHistory + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.attachments?.[0]?.title === fileInfo.fileName); + expect(rcMessage).toBeDefined(); + expect(rcMessage?.federation?.eventId).toBe(synapseMessage?.event_id); + }); + + it('should appear in the list of files on the remote RC', async () => { + const fileInfo = testFiles.video; + + // RC view: Verify in RC file list + const rcFilesList = await getFilesList(federatedChannel._id, rc1AdminRequestConfig); + expect(rcFilesList.files.find((file) => file.name === fileInfo.fileName)).toBeDefined(); + }); + + it('should be able to download the files on the remote RC', async () => { + const fileInfo = testFiles.video; + + // RC view: Download and verify binary match from RC + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.attachments?.[0]?.title === fileInfo.fileName); + expect(rcMessage?.attachments?.[0]?.title_link).toBeDefined(); + const downloadUrl = rcMessage?.attachments?.[0]?.title_link as string; + const rcFilesMatch = await downloadFileAndCompareBinary(downloadUrl, fileInfo.path, rc1AdminRequestConfig); + expect(rcFilesMatch).toBe(true); + }); + + it('should be possible to filter the list of files on the remote RC', async () => { + const fileInfo = testFiles.video; + const filteredFiles = await getFilesList(federatedChannel._id, rc1AdminRequestConfig, { + name: fileInfo.fileName, + }); + expect(filteredFiles.files.find((file) => file.name === fileInfo.fileName)).toBeDefined(); + }); + }); + + describe('Upload one Audio', () => { + it('should appear correctly on the remote RC as messages', async () => { + const fileInfo = testFiles.audio; + await hs1AdminApp.uploadFile(channelName, fileInfo.path, fileInfo.fileName); + + // Element view: Verify in Element + const synapseMessage = await hs1AdminApp.findFileMessageInRoom(channelName, fileInfo.fileName); + expect(synapseMessage).not.toBeNull(); + expect(synapseMessage?.content.msgtype).toBe('m.audio'); + + // RC view: Verify in RC loadHistory + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.attachments?.[0]?.title === fileInfo.fileName); + expect(rcMessage).toBeDefined(); + expect(rcMessage?.federation?.eventId).toBe(synapseMessage?.event_id); + }); + + it('should appear in the list of files on the remote RC', async () => { + const fileInfo = testFiles.audio; + + // RC view: Verify in RC file list + const rcFilesList = await getFilesList(federatedChannel._id, rc1AdminRequestConfig); + expect(rcFilesList.files.find((file) => file.name === fileInfo.fileName)).toBeDefined(); + }); + + it('should be able to download the files on the remote RC', async () => { + const fileInfo = testFiles.audio; + + // RC view: Download and verify binary match from RC + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.attachments?.[0]?.title === fileInfo.fileName); + expect(rcMessage?.attachments?.[0]?.title_link).toBeDefined(); + const downloadUrl = rcMessage?.attachments?.[0]?.title_link as string; + const rcFilesMatch = await downloadFileAndCompareBinary(downloadUrl, fileInfo.path, rc1AdminRequestConfig); + expect(rcFilesMatch).toBe(true); + }); + + it('should be possible to filter the list of files on the remote RC', async () => { + const fileInfo = testFiles.audio; + const filteredFiles = await getFilesList(federatedChannel._id, rc1AdminRequestConfig, { + name: fileInfo.fileName, + }); + expect(filteredFiles.files.find((file) => file.name === fileInfo.fileName)).toBeDefined(); + }); + }); + + describe('Upload one Text File', () => { + it('should appear correctly on the remote RC as messages', async () => { + const fileInfo = testFiles.text; + await hs1AdminApp.uploadFile(channelName, fileInfo.path, fileInfo.fileName); + + // Element view: Verify in Element + const synapseMessage = await hs1AdminApp.findFileMessageInRoom(channelName, fileInfo.fileName); + expect(synapseMessage).not.toBeNull(); + expect(synapseMessage?.content.msgtype).toBe('m.file'); + + // RC view: Verify in RC loadHistory + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.attachments?.[0]?.title === fileInfo.fileName); + expect(rcMessage).toBeDefined(); + expect(rcMessage?.federation?.eventId).toBe(synapseMessage?.event_id); + }); + + it('should appear in the list of files on the remote RC', async () => { + const fileInfo = testFiles.text; + + // RC view: Verify in RC file list + const rcFilesList = await getFilesList(federatedChannel._id, rc1AdminRequestConfig); + expect(rcFilesList.files.find((file) => file.name === fileInfo.fileName)).toBeDefined(); + }); + + it('should be able to download the files on the remote RC', async () => { + const fileInfo = testFiles.text; + + // RC view: Download and verify binary match from RC + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.attachments?.[0]?.title === fileInfo.fileName); + expect(rcMessage?.attachments?.[0]?.title_link).toBeDefined(); + const downloadUrl = rcMessage?.attachments?.[0]?.title_link as string; + const rcFilesMatch = await downloadFileAndCompareBinary(downloadUrl, fileInfo.path, rc1AdminRequestConfig); + expect(rcFilesMatch).toBe(true); + }); + + it('should be possible to filter the list of files on the remote RC', async () => { + const fileInfo = testFiles.text; + const filteredFiles = await getFilesList(federatedChannel._id, rc1AdminRequestConfig, { + name: fileInfo.fileName, + }); + expect(filteredFiles.files.find((file) => file.name === fileInfo.fileName)).toBeDefined(); + }); + }); + }); + }); }); }); diff --git a/ee/packages/federation-matrix/tests/end-to-end/room.spec.ts b/ee/packages/federation-matrix/tests/end-to-end/room.spec.ts index 0927e53620c76..97c953009152a 100644 --- a/ee/packages/federation-matrix/tests/end-to-end/room.spec.ts +++ b/ee/packages/federation-matrix/tests/end-to-end/room.spec.ts @@ -226,8 +226,6 @@ import { SynapseClient } from '../helper/synapse-client'; config: rc1AdminRequestConfig, }); - console.log('response', response.body); - expect(response.body).toHaveProperty('success', true); expect(response.body).toHaveProperty('message'); diff --git a/ee/packages/federation-matrix/tests/helper/ddp-listener.ts b/ee/packages/federation-matrix/tests/helper/ddp-listener.ts index c71930c80b015..a40b61d883a78 100644 --- a/ee/packages/federation-matrix/tests/helper/ddp-listener.ts +++ b/ee/packages/federation-matrix/tests/helper/ddp-listener.ts @@ -84,7 +84,6 @@ export class DDPListener { return new Promise((resolve, reject) => { // Check if message already exists const existingMessage = this.ephemeralMessages.find((msg) => { - console.log('msg', msg); const contentMatches = msg.msg?.includes(expectedContent); const roomMatches = !roomId || msg.rid === roomId; return contentMatches && roomMatches; @@ -107,7 +106,6 @@ export class DDPListener { const checkMessages = () => { const message = this.ephemeralMessages.find((msg) => { - console.log('msg', msg); const contentMatches = msg.msg?.includes(expectedContent); const roomMatches = !roomId || msg.rid === roomId; return contentMatches && roomMatches; diff --git a/ee/packages/federation-matrix/tests/helper/synapse-client.ts b/ee/packages/federation-matrix/tests/helper/synapse-client.ts index 63268e14c7170..dcb53dde8991e 100644 --- a/ee/packages/federation-matrix/tests/helper/synapse-client.ts +++ b/ee/packages/federation-matrix/tests/helper/synapse-client.ts @@ -4,6 +4,9 @@ * This file provides validated federation configuration for federation tests. */ +import * as fs from 'fs'; +import * as path from 'path'; + import { createClient, type MatrixClient, KnownMembership, type Room, type RoomMember } from 'matrix-js-sdk'; /** @@ -399,6 +402,252 @@ export class SynapseClient { return null; } + /** + * Uploads a file to a room using Matrix JS SDK. + * + * Uploads a file to the specified room and sends it as a file message. + * Determines the appropriate msgtype based on file extension and mime type. + * + * @param roomName - The display name of the room to upload the file to + * @param filePath - Path to the file to upload + * @param fileName - The file name to use in the message body (used by findFileMessageInRoom) + * @returns Promise resolving to the Matrix event ID of the sent file message + * @throws Error if client is not initialized or room is not found + */ + async uploadFile(roomName: string, filePath: string, fileName: string): Promise { + if (!this.matrixClient) { + throw new Error('Matrix client is not initialized'); + } + const room = this.getRoom(roomName); + + // Read file + const fileBuffer = fs.readFileSync(filePath); + const fileExtension = path.extname(fileName).toLowerCase().slice(1); + + // Determine mime type based on extension + const mimeTypes: Record = { + webp: 'image/webp', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + pdf: 'application/pdf', + webm: 'video/webm', + mp4: 'video/mp4', + mp3: 'audio/mpeg', + wav: 'audio/wav', + txt: 'text/plain', + }; + + const mimeType = mimeTypes[fileExtension] || 'application/octet-stream'; + + // Determine msgtype based on file type + let msgtype: string; + if (mimeType.startsWith('image/')) { + msgtype = 'm.image'; + } else if (mimeType.startsWith('video/')) { + msgtype = 'm.video'; + } else if (mimeType.startsWith('audio/')) { + msgtype = 'm.audio'; + } else { + msgtype = 'm.file'; + } + + // Upload file content + const uploadResponse = await this.matrixClient.uploadContent(fileBuffer, { + name: fileName, + type: mimeType, + }); + + if (!uploadResponse.content_uri) { + throw new Error('File upload failed: no content URI returned'); + } + + // Send file message + const content: any = { + msgtype, + body: fileName, + url: uploadResponse.content_uri, + info: { + mimetype: mimeType, + size: fileBuffer.length, + }, + }; + + const response = await this.matrixClient.sendMessage(room.roomId, content); + return response.event_id; + } + + /** + * Retrieves all file/media messages from a room's timeline. + * + * Gets all file message events (images, videos, audio, files) from the room's timeline. + * Useful for verifying file synchronization in federation testing. + * + * @param roomName - The display name of the room + * @returns Array of file message events from the room's timeline + * @throws Error if client is not initialized or room is not found + */ + getRoomFileMessages(roomName: string): Array<{ + content: { body: string; msgtype: string; url?: string; info?: any }; + event_id: string; + sender: string; + }> { + if (!this.matrixClient) { + throw new Error('Matrix client is not initialized'); + } + const room = this.getRoom(roomName); + const { timeline } = room; + const messages: Array<{ + content: { body: string; msgtype: string; url?: string; info?: any }; + event_id: string; + sender: string; + }> = []; + + for (const event of timeline) { + if (event.getType() === 'm.room.message') { + const content = event.getContent(); + if ( + content.msgtype === 'm.image' || + content.msgtype === 'm.video' || + content.msgtype === 'm.audio' || + content.msgtype === 'm.file' + ) { + messages.push({ + content: { + body: content.body || '', + msgtype: content.msgtype, + url: content.url, + info: content.info, + }, + event_id: event.getId() || '', + sender: event.getSender() || '', + }); + } + } + } + + return messages; + } + + /** + * Finds a file message in a room's timeline by file name. + * + * Searches for a file message in the room's timeline that matches the specified + * file name. Useful for verifying that file messages appear correctly on + * the remote side in federation tests. + * + * @param roomName - The display name of the room to search + * @param fileName - The file name to find + * @param options - Retry configuration options + * @param options.maxRetries - Maximum number of retry attempts (default: 5) + * @param options.delay - Delay between retries in milliseconds (default: 1000) + * @param options.initialDelay - Initial delay before first attempt in milliseconds (default: 2000) + * @returns The file message event if found, null otherwise + */ + async findFileMessageInRoom( + roomName: string, + fileName: string, + options: { maxRetries?: number; delay?: number; initialDelay?: number } = {}, + ): Promise<{ + content: { body: string; msgtype: string; url?: string; info?: any }; + event_id: string; + sender: string; + } | null> { + const { maxRetries = 5, delay = 1000, initialDelay = 2000 } = options; + + if (initialDelay > 0) { + await wait(initialDelay); + } + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const messages = this.getRoomFileMessages(roomName); + const message = messages.find((msg) => msg.content.body === fileName || msg.content.body?.includes(fileName)); + + if (message) { + return message; + } + + if (attempt < maxRetries) { + await wait(delay); + } + } catch (error) { + console.warn(`Attempt ${attempt} to find file message in room failed:`, error); + + if (attempt < maxRetries) { + await wait(delay); + } + } + } + + return null; + } + + /** + * Downloads a file from Matrix and verifies it matches the original file using binary comparison. + * + * Uses the Matrix JS SDK to download media files from the homeserver and compares + * them byte-by-byte with the original file. The MXC URI format is: mxc://serverName/mediaId + * + * @param mxcUri - The MXC URI of the media to download (e.g., "mxc://serverName/mediaId") + * @param originalFilePath - Path to the original file to compare against + * @returns Promise resolving to true if files match byte-by-byte + * @throws Error if client is not initialized or download fails + */ + async downloadFileAndCompareBinary(mxcUri: string, originalFilePath: string): Promise { + if (!this.matrixClient) { + throw new Error('Matrix client is not initialized'); + } + + try { + // Use Matrix JS SDK's mxcUrlToHttp with useAuthentication=true to get the client v1 endpoint + // This generates: https://hs1/_matrix/client/v1/media/download/{serverName}/{mediaId}?allow_redirect=true + // Parameters: mxcUrl, width, height, resizeMethod, allowDirectLinks, allowRedirects, useAuthentication + const downloadUrl = this.matrixClient.mxcUrlToHttp(mxcUri, undefined, undefined, undefined, false, true, true); + if (!downloadUrl) { + throw new Error(`Failed to convert MXC URI to HTTP URL: ${mxcUri}`); + } + + // Add allow_remote=true parameter to ensure Synapse fetches from remote servers + const urlWithRemote = new URL(downloadUrl); + urlWithRemote.searchParams.set('allow_remote', 'true'); + const finalDownloadUrl = urlWithRemote.toString(); + + const accessToken = this.matrixClient.getAccessToken(); + if (!accessToken) { + throw new Error('Matrix client access token not available'); + } + + const response = await fetch(finalDownloadUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + redirect: 'follow', + }); + + if (!response.ok) { + throw new Error(`Failed to download media: ${response.status} ${response.statusText}`); + } + + const arrayBuffer = await response.arrayBuffer(); + const downloadedBuffer = Buffer.from(arrayBuffer); + + // Read the original file + const originalBuffer = fs.readFileSync(originalFilePath); + + // Compare buffers byte-by-byte + if (downloadedBuffer.length !== originalBuffer.length) { + return false; + } + + return downloadedBuffer.equals(originalBuffer); + } catch (error) { + throw new Error(`Failed to download and compare media from ${mxcUri}: ${error}`); + } + } + /** * Closes the Matrix client connection and cleans up resources. * diff --git a/ee/packages/federation-matrix/tests/resources/sample_audio.mp3 b/ee/packages/federation-matrix/tests/resources/sample_audio.mp3 new file mode 100644 index 0000000000000..23c2e3aaa31e1 Binary files /dev/null and b/ee/packages/federation-matrix/tests/resources/sample_audio.mp3 differ diff --git a/ee/packages/federation-matrix/tests/resources/sample_image.webp b/ee/packages/federation-matrix/tests/resources/sample_image.webp new file mode 100644 index 0000000000000..122741b605f31 Binary files /dev/null and b/ee/packages/federation-matrix/tests/resources/sample_image.webp differ diff --git a/ee/packages/federation-matrix/tests/resources/sample_pdf.pdf b/ee/packages/federation-matrix/tests/resources/sample_pdf.pdf new file mode 100644 index 0000000000000..c01805e89c168 Binary files /dev/null and b/ee/packages/federation-matrix/tests/resources/sample_pdf.pdf differ diff --git a/ee/packages/federation-matrix/tests/resources/sample_text.txt b/ee/packages/federation-matrix/tests/resources/sample_text.txt new file mode 100644 index 0000000000000..e09445457ab97 --- /dev/null +++ b/ee/packages/federation-matrix/tests/resources/sample_text.txt @@ -0,0 +1 @@ +sample text file diff --git a/ee/packages/federation-matrix/tests/resources/sample_video.webm b/ee/packages/federation-matrix/tests/resources/sample_video.webm new file mode 100644 index 0000000000000..7456528682f20 Binary files /dev/null and b/ee/packages/federation-matrix/tests/resources/sample_video.webm differ diff --git a/ee/packages/federation-matrix/tests/setup-qase.ts b/ee/packages/federation-matrix/tests/setup-qase.ts new file mode 100644 index 0000000000000..784af77fa44a5 --- /dev/null +++ b/ee/packages/federation-matrix/tests/setup-qase.ts @@ -0,0 +1,121 @@ +/** + * Jest setup file that automatically wraps describe and it/test functions + * to register suites and tests with Qase. + * + * The Qase Jest reporter reports the directory structure up from the tests directory, + * making it not consistent with the test suite structure we currently follow in Qase. + * The solution is to wrap describe and it/test functions to automatically set the suite + * at the very start of the test to what we really want the reporting structure to be. + * + * This file is loaded via setupFilesAfterEnv in jest.config.federation.ts. + * Qase integration is only enabled when QASE_TESTOPS_JEST_API_TOKEN is set. + */ + +import { qase } from 'jest-qase-reporter/jest'; + +const ROOT_SUITE = 'Rocket.Chat Federation Automation'; + +/** + * Stack to track the current suite path hierarchy + */ +const suitePathStack: string[] = []; + +/** + * Store the original Jest describe function before we replace it + */ +const originalDescribe = global.describe; + +/** + * Gets the full suite path including root + */ +function getFullSuitePath(): string { + return [ROOT_SUITE, ...suitePathStack].join('\t'); +} + +/** + * Wraps describe to automatically track suite hierarchy and set suite for tests + */ +function describeImpl(name: string, fn: () => void): void { + suitePathStack.push(name); + const currentPath = getFullSuitePath(); + + originalDescribe(name, () => { + // Add beforeEach to set suite for all tests in this describe block + // This must be called before the test runs so the reporter picks it up + global.beforeEach(() => { + qase.suite(currentPath); + }); + + // Store current it and test wrappers (they might be wrapped by parent describe) + const currentIt = global.it; + const currentTest = global.test; + + // Wrap it() to automatically set suite at the very start + global.it = ((testName: any, fn?: any, timeout?: number) => { + // Handle qase-wrapped test names (qase returns a string) + if (typeof testName === 'string' && fn) { + return currentIt( + testName, + async () => { + // Set suite immediately at the start of the test + qase.suite(currentPath); + // Call the original test function and return the result + return fn(); + }, + timeout, + ); + } + // Handle cases where testName might be a number or other type + return currentIt(testName, fn, timeout); + }) as typeof global.it; + + // Wrap test() to automatically set suite at the very start + global.test = ((testName: any, fn?: any, timeout?: number) => { + if (typeof testName === 'string' && fn) { + return currentTest( + testName, + async () => { + // Set suite immediately at the start of the test + qase.suite(currentPath); + // Call the original test function and return the result + return fn(); + }, + timeout, + ); + } + return currentTest(testName, fn, timeout); + }) as typeof global.test; + + // Execute the describe block + fn(); + + // Restore previous wrappers + global.it = currentIt; + global.test = currentTest; + }); + + suitePathStack.pop(); +} + +// Only apply qase wrapping if the environment variable is set +if (process.env.QASE_TESTOPS_JEST_API_TOKEN) { + // Replace global describe with our wrapper + (global as any).describe = Object.assign(describeImpl, { + skip: (name: string, fn: () => void) => { + suitePathStack.push(name); + try { + originalDescribe.skip(name, fn); + } finally { + suitePathStack.pop(); + } + }, + only: (name: string, fn: () => void) => { + suitePathStack.push(name); + try { + originalDescribe.only(name, fn); + } finally { + suitePathStack.pop(); + } + }, + }) as typeof global.describe; +} diff --git a/package.json b/package.json index e120cba1f7132..673cc91fccad5 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@types/chart.js": "^2.9.41", "@types/js-yaml": "^4.0.9", "@types/node": "~22.16.5", + "jest-qase-reporter": "^2.1.3", "ts-node": "^10.9.2", "turbo": "~2.6.1", "typescript": "~5.9.3" diff --git a/yarn.lock b/yarn.lock index bb1ecf8edbd17..7b1ba75d6ffd0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -25727,6 +25727,20 @@ __metadata: languageName: node linkType: hard +"jest-qase-reporter@npm:^2.1.3": + version: 2.1.3 + resolution: "jest-qase-reporter@npm:2.1.3" + dependencies: + lodash.get: "npm:^4.4.2" + lodash.has: "npm:^4.5.2" + qase-javascript-commons: "npm:~2.4.2" + uuid: "npm:^9.0.0" + peerDependencies: + jest: ">=28.0.0" + checksum: 10/1c19643adaffd514674d1dbdc92d6377beb859d4036228133a8ad2dadadbc879bc65b74df5f14ad371b5e269976720da3b787afbfba034ff8be0f1a1792324c7 + languageName: node + linkType: hard + "jest-regex-util@npm:30.0.1": version: 30.0.1 resolution: "jest-regex-util@npm:30.0.1" @@ -27002,6 +27016,13 @@ __metadata: languageName: node linkType: hard +"lodash.has@npm:^4.5.2": + version: 4.5.2 + resolution: "lodash.has@npm:4.5.2" + checksum: 10/35c0862e715bc22528dd3cd34f1e66d25d58f0ecef9a43aa409fb7ddebaf6495cb357ae242f141e4b2325258f4a6bafdd8928255d51f1c0a741ae9b93951c743 + languageName: node + linkType: hard + "lodash.includes@npm:^4.3.0": version: 4.3.0 resolution: "lodash.includes@npm:4.3.0" @@ -32916,6 +32937,7 @@ __metadata: "@types/js-yaml": "npm:^4.0.9" "@types/node": "npm:~22.16.5" "@types/stream-buffers": "npm:^3.0.8" + jest-qase-reporter: "npm:^2.1.3" node-gyp: "npm:^10.2.0" ts-node: "npm:^10.9.2" turbo: "npm:~2.6.1"