Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions apps/meteor/app/lib/server/functions/createRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ async function createUsersSubscriptions({
await Rooms.incUsersCountById(room._id, subs.length);
}

// eslint-disable-next-line complexity
export const createRoom = async <T extends RoomType>(
type: T,
name: T extends 'd' ? undefined : string,
Expand All @@ -136,6 +137,7 @@ export const createRoom = async <T extends RoomType>(
> => {
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('@');
Expand Down
2 changes: 1 addition & 1 deletion apps/meteor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 6 additions & 2 deletions apps/meteor/server/methods/createDirectMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ export async function createDirectMessage(
const users = await Promise.all(usernames.filter((username) => username !== me.username));
const options: Exclude<ICreateRoomParams['options'], undefined> = { 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) {
Expand Down Expand Up @@ -91,7 +95,7 @@ export async function createDirectMessage(
false,
undefined,
{
federated,
...(hasFederatedMembers && { federated: true }),
},
options,
);
Expand Down
170 changes: 170 additions & 0 deletions apps/meteor/tests/data/file.helper.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<boolean> {
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);
}
21 changes: 21 additions & 0 deletions ee/packages/federation-matrix/jest.config.federation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<rootDir>/tests/teardown.ts',
// To disable Qase integration, remove this line or comment it out
setupFilesAfterEnv: ['<rootDir>/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;
2 changes: 1 addition & 1 deletion ee/packages/federation-matrix/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:^",
Expand Down
2 changes: 1 addition & 1 deletion ee/packages/federation-matrix/src/FederationMatrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down
8 changes: 6 additions & 2 deletions ee/packages/federation-matrix/src/events/room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@ import { Rooms, Users } from '@rocket.chat/models';
import { getUsernameServername } from '../FederationMatrix';

export function room(emitter: Emitter<HomeserverEventSignatures>) {
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
7 changes: 1 addition & 6 deletions ee/packages/federation-matrix/src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HomeserverEventSignatures>();

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),
},
});
Expand Down
Loading
Loading