diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3e81ea150324..3feb7ef866dd3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -654,6 +654,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 b936d30145069..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, @@ -136,6 +137,7 @@ export const createRoom = async ( > => { const { teamId, ...optionalExtraData } = roomExtraData || ({} as IRoom); + // TODO: use a shared helper to check whether a user is federated const hasFederatedMembers = members.some((member) => { if (typeof member === 'string') { return member.includes(':') && member.includes('@'); diff --git a/apps/meteor/package.json b/apps/meteor/package.json index fd45d790aedec..b734de5a0e05c 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -252,7 +252,7 @@ "@rocket.chat/emitter": "~0.31.25", "@rocket.chat/favicon": "workspace:^", "@rocket.chat/federation-matrix": "workspace:^", - "@rocket.chat/federation-sdk": "0.3.0", + "@rocket.chat/federation-sdk": "0.3.2", "@rocket.chat/freeswitch": "workspace:^", "@rocket.chat/fuselage": "^0.68.1", "@rocket.chat/fuselage-forms": "~0.1.1", diff --git a/apps/meteor/server/methods/createDirectMessage.ts b/apps/meteor/server/methods/createDirectMessage.ts index ac9a9479a9bce..ffe355f85ed94 100644 --- a/apps/meteor/server/methods/createDirectMessage.ts +++ b/apps/meteor/server/methods/createDirectMessage.ts @@ -42,7 +42,11 @@ export async function createDirectMessage( const users = await Promise.all(usernames.filter((username) => username !== me.username)); const options: Exclude = { creator: me._id }; const roomUsers = excludeSelf ? users : [me, ...users]; - const federated = false; + + // TODO: use a shared helper to check whether a user is federated + // since the DM creation API doesn't tell us if the room is federated (unlike normal channels), + // we're currently inferring it: if any participant has a Matrix-style ID (@user:server), we treat the DM as federated + const hasFederatedMembers = roomUsers.some((user) => typeof user === 'string' && user.includes(':') && user.includes('@')); // allow self-DMs if (roomUsers.length === 1 && roomUsers[0] !== undefined && typeof roomUsers[0] !== 'string' && roomUsers[0]._id !== me._id) { @@ -91,7 +95,7 @@ export async function createDirectMessage( false, undefined, { - federated, + ...(hasFederatedMembers && { federated: true }), }, options, ); 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/package.json b/ee/packages/federation-matrix/package.json index 1de8ea38fbe19..fa2ec30d75191 100644 --- a/ee/packages/federation-matrix/package.json +++ b/ee/packages/federation-matrix/package.json @@ -42,7 +42,7 @@ "@rocket.chat/core-services": "workspace:^", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/emitter": "^0.31.25", - "@rocket.chat/federation-sdk": "0.3.0", + "@rocket.chat/federation-sdk": "0.3.2", "@rocket.chat/http-router": "workspace:^", "@rocket.chat/license": "workspace:^", "@rocket.chat/models": "workspace:^", diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index b3fbb472d64df..7eee72b7a9ed5 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -882,7 +882,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS errcode: string; error: string; } - >({ homeserverUrl, userId }); + >({ homeserverUrl, userId: matrixId }); if ('errcode' in result && result.errcode === 'M_NOT_FOUND') { return [matrixId, 'UNVERIFIED']; diff --git a/ee/packages/federation-matrix/src/events/room.ts b/ee/packages/federation-matrix/src/events/room.ts index 68a12b30192dc..45668831e52f8 100644 --- a/ee/packages/federation-matrix/src/events/room.ts +++ b/ee/packages/federation-matrix/src/events/room.ts @@ -6,8 +6,12 @@ import { Rooms, Users } from '@rocket.chat/models'; import { getUsernameServername } from '../FederationMatrix'; export function room(emitter: Emitter) { - emitter.on('homeserver.matrix.room.name', async (data) => { - const { room_id: roomId, name, user_id: userId } = data; + emitter.on('homeserver.matrix.room.name', async ({ event }) => { + const { + room_id: roomId, + content: { name }, + sender: userId, + } = event; const localRoomId = await Rooms.findOne({ 'federation.mrid': roomId }, { projection: { _id: 1 } }); if (!localRoomId) { diff --git a/ee/packages/federation-matrix/src/helpers/message.parsers.ts b/ee/packages/federation-matrix/src/helpers/message.parsers.ts index 9838e2f91d3c7..af4ff6ed7867d 100644 --- a/ee/packages/federation-matrix/src/helpers/message.parsers.ts +++ b/ee/packages/federation-matrix/src/helpers/message.parsers.ts @@ -2,7 +2,7 @@ import type { EventID, HomeserverEventSignatures } from '@rocket.chat/federation import { marked } from 'marked'; import sanitizeHtml from 'sanitize-html'; -type MatrixMessageContent = HomeserverEventSignatures['homeserver.matrix.message']['content'] & { format?: string }; +type MatrixMessageContent = HomeserverEventSignatures['homeserver.matrix.message']['event']['content'] & { format?: string }; type MatrixEvent = { content?: { body?: string; formatted_body?: string }; diff --git a/ee/packages/federation-matrix/src/setup.ts b/ee/packages/federation-matrix/src/setup.ts index eeb4003c7a3cd..1630741dda59e 100644 --- a/ee/packages/federation-matrix/src/setup.ts +++ b/ee/packages/federation-matrix/src/setup.ts @@ -101,17 +101,12 @@ export function configureFederationMatrixSettings(settings: { } export async function setupFederationMatrix() { - // TODO are these required? - const mongoUri = process.env.MONGO_URL || 'mongodb://localhost:3001/meteor'; - const dbName = process.env.DATABASE_NAME || new URL(mongoUri).pathname.slice(1); - const eventHandler = new Emitter(); await init({ emitter: eventHandler, dbConfig: { - uri: mongoUri, - name: dbName, + uri: process.env.MONGO_URL || 'mongodb://localhost:3001/meteor', poolSize: Number.parseInt(process.env.DATABASE_POOL_SIZE || '10', 10), }, }); 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 3987331816fd1..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 @@ -1,4 +1,4 @@ -import type { IMessage } from '@rocket.chat/core-typings'; +import type { IMessage, IUser } from '@rocket.chat/core-typings'; import { createRoom, @@ -8,7 +8,7 @@ import { addUserToRoom, addUserToRoomSlashCommand, } from '../../../../../apps/meteor/tests/data/rooms.helper'; -import { getRequestConfig, createUser } from '../../../../../apps/meteor/tests/data/users.helper'; +import { type IRequestConfig, getRequestConfig, createUser, deleteUser } from '../../../../../apps/meteor/tests/data/users.helper'; import { IS_EE } from '../../../../../apps/meteor/tests/e2e/config/constants'; import { federationConfig } from '../helper/config'; import { createDDPListener } from '../helper/ddp-listener'; @@ -18,8 +18,8 @@ import { SynapseClient } from '../helper/synapse-client'; // import { t } from 'i18next'; (IS_EE ? describe : describe.skip)('Federation', () => { - let rc1AdminRequestConfig: any; - let rc1User1RequestConfig: any; + let rc1AdminRequestConfig: IRequestConfig; + let rc1User1RequestConfig: IRequestConfig; let hs1AdminApp: SynapseClient; let hs1User1App: SynapseClient; @@ -72,6 +72,56 @@ import { SynapseClient } from '../helper/synapse-client'; }); describe('Rooms', () => { + describe('Create direct message rooms', () => { + // Creating a fresh user for this test suite to avoid collisions, + // since DMs are unique for each user pair + let userRequestConfig: IRequestConfig; + let createdUser: IUser; + beforeAll(async () => { + const user = { username: `user-${Date.now()}`, password: '123' }; + createdUser = await createUser(user, rc1AdminRequestConfig); + userRequestConfig = await getRequestConfig(federationConfig.rc1.apiUrl, user.username, user.password); + }); + + afterAll(async () => { + await deleteUser(createdUser, {}, rc1AdminRequestConfig); + }); + + it('It should create a federated room when federated members are added', async () => { + const response = await createRoom({ + type: 'd', + username: federationConfig.hs1.adminMatrixUserId, + config: userRequestConfig, + }); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('success', true); + expect(response.body).toHaveProperty('room'); + expect(response.body.room).toHaveProperty('_id'); + expect(response.body.room).toHaveProperty('t', 'd'); + + const roomInfo = await getRoomInfo(response.body.room._id, userRequestConfig); + expect(roomInfo.room).toHaveProperty('federated', true); + }); + + it('It should create a non-federated room when only local members are added', async () => { + const response = await createRoom({ + type: 'd', + username: federationConfig.rc1.additionalUser1.username, + config: userRequestConfig, + }); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('success', true); + expect(response.body).toHaveProperty('room'); + expect(response.body.room).toHaveProperty('_id'); + expect(response.body.room).toHaveProperty('t', 'd'); + + const roomInfo = await getRoomInfo(response.body.room._id, userRequestConfig); + expect(roomInfo.room).not.toHaveProperty('federated'); + }); + }); + describe('Create a room on RC as private, explicitly not federated, with federated users in creation modal', () => { describe('Add 1 federated user in the creation modal', () => { it('It should not allow the creation of the room', async () => { @@ -176,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 ae2c2e1c536e1..9c0922df41d9d 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/packages/core-services/package.json b/packages/core-services/package.json index 010b5a67b33c7..95daf598fa489 100644 --- a/packages/core-services/package.json +++ b/packages/core-services/package.json @@ -35,7 +35,7 @@ }, "dependencies": { "@rocket.chat/core-typings": "workspace:^", - "@rocket.chat/federation-sdk": "0.3.0", + "@rocket.chat/federation-sdk": "0.3.2", "@rocket.chat/http-router": "workspace:^", "@rocket.chat/icons": "^0.45.0", "@rocket.chat/media-signaling": "workspace:^", diff --git a/yarn.lock b/yarn.lock index ef60b63ad81ee..9de5d46e0bca7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2966,13 +2966,6 @@ __metadata: languageName: node linkType: hard -"@datastructures-js/heap@npm:^4.3.3": - version: 4.3.5 - resolution: "@datastructures-js/heap@npm:4.3.5" - checksum: 10/9360ae87e517aaf547251db4faea77388511dd7e4e554da1a3857e072cf34edba4e33faf8c93d738c3a78ddcdee903faefd02238371f0b201bb129d73f03a2ef - languageName: node - linkType: hard - "@datastructures-js/heap@npm:^4.3.7": version: 4.3.7 resolution: "@datastructures-js/heap@npm:4.3.7" @@ -2980,15 +2973,6 @@ __metadata: languageName: node linkType: hard -"@datastructures-js/priority-queue@npm:^6.3.3": - version: 6.3.4 - resolution: "@datastructures-js/priority-queue@npm:6.3.4" - dependencies: - "@datastructures-js/heap": "npm:^4.3.3" - checksum: 10/7c2fbfc1c3a1f9d1f1d0c540a38f41865400d72dc40c13d621657386513ed5faddba4366dcf76b4e52accefa1be67d13602f73bc4a43afa96603071f78b427fe - languageName: node - linkType: hard - "@datastructures-js/priority-queue@npm:^6.3.5": version: 6.3.5 resolution: "@datastructures-js/priority-queue@npm:6.3.5" @@ -8325,7 +8309,7 @@ __metadata: "@rocket.chat/apps-engine": "workspace:^" "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/federation-sdk": "npm:0.3.0" + "@rocket.chat/federation-sdk": "npm:0.3.2" "@rocket.chat/http-router": "workspace:^" "@rocket.chat/icons": "npm:^0.45.0" "@rocket.chat/jest-presets": "workspace:~" @@ -8537,7 +8521,7 @@ __metadata: "@rocket.chat/ddp-client": "workspace:^" "@rocket.chat/emitter": "npm:^0.31.25" "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/federation-sdk": "npm:0.3.0" + "@rocket.chat/federation-sdk": "npm:0.3.2" "@rocket.chat/http-router": "workspace:^" "@rocket.chat/license": "workspace:^" "@rocket.chat/models": "workspace:^" @@ -8563,11 +8547,11 @@ __metadata: languageName: unknown linkType: soft -"@rocket.chat/federation-sdk@npm:0.3.0": - version: 0.3.0 - resolution: "@rocket.chat/federation-sdk@npm:0.3.0" +"@rocket.chat/federation-sdk@npm:0.3.2": + version: 0.3.2 + resolution: "@rocket.chat/federation-sdk@npm:0.3.2" dependencies: - "@datastructures-js/priority-queue": "npm:^6.3.3" + "@datastructures-js/priority-queue": "npm:^6.3.5" "@noble/ed25519": "npm:^3.0.0" "@rocket.chat/emitter": "npm:^0.31.25" mongodb: "npm:^6.16.0" @@ -8578,7 +8562,7 @@ __metadata: zod: "npm:^3.24.1" peerDependencies: typescript: ~5.9.2 - checksum: 10/98cfc09d337855a5ecde98e80e480c10ae7cb76602054fef7202ef3e058fbd00cb9ff5751cb4199ea9e18d08984c59af4309987f95e1413d634952f08c5f886d + checksum: 10/53c0179437425b731a5f77792ee8bf271526499474db4f9e1fdb946ef3b2697a4fd6feceec8d9576f50619ffade51d2ef1bd0cf3f8200024ceb9dcc944a4c561 languageName: node linkType: hard @@ -9257,7 +9241,7 @@ __metadata: "@rocket.chat/eslint-config": "workspace:^" "@rocket.chat/favicon": "workspace:^" "@rocket.chat/federation-matrix": "workspace:^" - "@rocket.chat/federation-sdk": "npm:0.3.0" + "@rocket.chat/federation-sdk": "npm:0.3.2" "@rocket.chat/freeswitch": "workspace:^" "@rocket.chat/fuselage": "npm:^0.68.1" "@rocket.chat/fuselage-forms": "npm:~0.1.1" @@ -10764,7 +10748,7 @@ __metadata: peerDependencies: "@rocket.chat/layout": "*" "@rocket.chat/tools": 0.2.3 - "@rocket.chat/ui-contexts": 24.0.0 + "@rocket.chat/ui-contexts": 25.0.0-rc.0 "@tanstack/react-query": "*" react: "*" react-hook-form: "*" @@ -25692,6 +25676,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" @@ -26967,6 +26965,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" @@ -32888,6 +32893,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"