From b9df049e4c912481b94662f062bc69444d40c80e Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 16 Dec 2025 18:52:16 -0300 Subject: [PATCH 01/11] update group DMs names --- .../lib/server/functions/createDirectRoom.ts | 18 ++-- apps/meteor/server/services/room/service.ts | 7 +- .../federation-matrix/src/FederationMatrix.ts | 4 +- .../federation-matrix/src/events/member.ts | 25 ++++-- .../tests/end-to-end/dms.spec.ts | 86 +++++++++++++------ .../core-services/src/types/IRoomService.ts | 3 +- 6 files changed, 99 insertions(+), 44 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/createDirectRoom.ts b/apps/meteor/app/lib/server/functions/createDirectRoom.ts index 6ed0c96143c0a..9ae39f3d9d9e9 100644 --- a/apps/meteor/app/lib/server/functions/createDirectRoom.ts +++ b/apps/meteor/app/lib/server/functions/createDirectRoom.ts @@ -42,6 +42,7 @@ export async function createDirectRoom( members: IUser[] | string[], roomExtraData: Partial = {}, options: { + forceNew?: boolean; creator?: IUser['_id']; subscriptionExtra?: ISubscriptionExtraData; }, @@ -77,15 +78,20 @@ export async function createDirectRoom( const uids = roomMembers.map(({ _id }) => _id).sort(); // Deprecated: using users' _id to compose the room _id is deprecated - const room: IRoom | null = - uids.length === 2 - ? await Rooms.findOneById(uids.join(''), { projection: { _id: 1 } }) - : await Rooms.findOneDirectRoomContainingAllUserIDs(uids, { projection: { _id: 1 } }); + const room: IRoom | null = await (async () => { + if (options?.forceNew) { + return null; + } + + return uids.length === 2 + ? Rooms.findOneById(uids.join(''), { projection: { _id: 1 } }) + : Rooms.findOneDirectRoomContainingAllUserIDs(uids, { projection: { _id: 1 } }); + })(); - const isNewRoom = !room; + const isNewRoom = options?.forceNew ? true : !room; const roomInfo = { - ...(uids.length === 2 && { _id: uids.join('') }), // Deprecated: using users' _id to compose the room _id is deprecated + ...(uids.length === 2 && !isNewRoom && { _id: uids.join('') }), // Deprecated: using users' _id to compose the room _id is deprecated t: 'd', usernames, usersCount: members.length, diff --git a/apps/meteor/server/services/room/service.ts b/apps/meteor/server/services/room/service.ts index 49b1336c5c99c..adad895d09df9 100644 --- a/apps/meteor/server/services/room/service.ts +++ b/apps/meteor/server/services/room/service.ts @@ -37,7 +37,7 @@ export class RoomService extends ServiceClassInternal implements IRoomService { protected name = 'room'; async updateDirectMessageRoomName(room: IRoom): Promise { - const subs = await Subscriptions.findByRoomId(room._id, { projection: { u: 1 } }).toArray(); + const subs = await Subscriptions.findByRoomId(room._id, { projection: { u: 1, status: 1 } }).toArray(); const uids = subs.map((sub) => sub.u._id); @@ -46,6 +46,10 @@ export class RoomService extends ServiceClassInternal implements IRoomService { const roomNames = getNameForDMs(roomMembers); for await (const sub of subs) { + // don't update the name if the user is invited but hasn't accepted yet + if (sub.status === 'INVITED') { + continue; + } await Subscriptions.updateOne({ _id: sub._id }, { $set: roomNames[sub.u._id] }); void notifyOnSubscriptionChangedByRoomIdAndUserId(room._id, sub.u._id, 'updated'); @@ -271,6 +275,7 @@ export class RoomService extends ServiceClassInternal implements IRoomService { }): Promise { const autoTranslateConfig = getSubscriptionAutotranslateDefaultConfig(userToBeAdded); + // TODO need to provide name and fname for DMs (room object won't have it) const { insertedId } = await Subscriptions.createWithRoomAndUser(room, userToBeAdded, { ts, open: !createAsHidden, diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 9d5f0a4cf0ab2..259f25e3b3bc8 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -918,9 +918,9 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS const matrixUserId = isUserNativeFederated(user) ? user.federation.mui : `@${user.username}:${this.serverName}`; if (action === 'accept') { - await federationSDK.acceptInvite(room.federation.mrid, matrixUserId); - await Room.performAcceptRoomInvite(room, subscription, user); + + await federationSDK.acceptInvite(room.federation.mrid, matrixUserId); } if (action === 'reject') { try { diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index ac7a7050748ea..5b643b40e03bd 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -68,6 +68,7 @@ async function getOrCreateFederatedRoom({ name: roomName, members: inviteeUsername ? [inviteeUsername, inviterUsername] : [inviterUsername], options: { + forceNew: true, // an invite means the room does not exist yet creator: inviterUserId, }, extraData: { @@ -137,10 +138,6 @@ async function handleInvite({ const joinRuleType = getJoinRuleType(strippedState); - // DMs do not have a join rule type (they are treated as invite only rooms), - // so we use 'd' for direct messages translation to RC. - const roomType = content?.is_direct ? 'd' : joinRuleType; - const roomOriginDomain = senderId.split(':')?.pop(); if (!roomOriginDomain) { throw new Error(`Room origin domain not found: ${roomId}`); @@ -149,9 +146,14 @@ async function handleInvite({ const roomNameState = strippedState?.find((state: PduForType<'m.room.name'>) => state.type === 'm.room.name'); const matrixRoomName = roomNameState?.content?.name; + // DMs do not have a join rule type (they are treated as invite only rooms), + // so we use 'd' for direct messages translation to RC. + const roomType = content?.is_direct || !matrixRoomName ? 'd' : joinRuleType; + let roomName: string; let roomFName: string; - if (content?.is_direct) { + + if (roomType === 'd') { roomName = senderId; roomFName = senderId; } else { @@ -166,8 +168,9 @@ async function handleInvite({ roomType, inviterUserId: inviterUser._id, inviterUsername: inviterUser.username as string, // TODO: Remove force cast - inviteeUsername: content?.is_direct ? inviteeUser.username : undefined, + inviteeUsername: roomType === 'd' ? inviteeUser.username : undefined, }); + if (!room) { throw new Error(`Room not found or could not be created: ${roomId}`); } @@ -184,6 +187,11 @@ async function handleInvite({ inviter: inviterUser, status: 'INVITED', }); + + // if an invite is sent to a DM, we need to update the room name to reflect all participants + if (room.t === 'd') { + await Room.updateDirectMessageRoomName(room); + } } async function handleJoin({ @@ -205,6 +213,11 @@ async function handleJoin({ throw new Error(`Subscription not found while joining user ${userId} to room ${roomId}`); } + // update room name for DMs + if (room.t === 'd') { + await Room.updateDirectMessageRoomName(room); + } + if (!subscription.status) { logger.info('User is already joined to the room, skipping...'); return; diff --git a/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts b/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts index a899c0feb4165..0b6db39bf8c24 100644 --- a/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts +++ b/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts @@ -334,23 +334,28 @@ const waitForRoomEvent = async ( describe('Multiple user DMs', () => { describe('Synapse as the resident server', () => { let rcUser1: TestUser; - // let rcUser2: TestUser; + let rcUser2: TestUser; let rcUserConfig1: IRequestConfig; - // let rcUserConfig2: IRequestConfig; + let rcUserConfig2: IRequestConfig; - let hs1Room: Room | null; + let rcRoom1: IRoom; - let pendingInvitation1: ISubscription | undefined; + let hs1Room: Room; + + let pendingInvitation1: ISubscription; + let pendingInvitation2: ISubscription; // let pendingInvitation2: any; - let invitedRoomId1: string; + // let invitedRoomId1: string; // let invitedRoomId2: string; const userDm1 = `dm-federation-user1-${Date.now()}`; + const userDm1Name = `DM Federation User1 ${Date.now()}`; const userDmId1 = `@${userDm1}:${federationConfig.rc1.domain}`; const userDm2 = `dm-federation-user2-${Date.now()}`; + const userDm2Name = `DM Federation User2 ${Date.now()}`; const userDmId2 = `@${userDm2}:${federationConfig.rc1.domain}`; beforeAll(async () => { @@ -360,80 +365,105 @@ const waitForRoomEvent = async ( username: userDm1, password: 'random', email: `${userDm1}}@rocket.chat`, - name: `DM Federation User ${Date.now()}`, + name: userDm1Name, }, rc1AdminRequestConfig, ); rcUserConfig1 = await getRequestConfig(federationConfig.rc1.url, rcUser1.username, 'random'); - await createUser( + rcUser2 = await createUser( { username: userDm2, password: 'random', email: `${userDm2}}@rocket.chat`, - name: `DM Federation User ${Date.now()}`, + name: userDm2Name, }, rc1AdminRequestConfig, ); - // rcUserConfig2 = await getRequestConfig(federationConfig.rc1.url, rcUser2.username, 'random'); + rcUserConfig2 = await getRequestConfig(federationConfig.rc1.url, rcUser2.username, 'random'); }); afterAll(async () => { // delete both RC and Synapse users - // await Promise.all([deleteUser(rcUser1, {}, rc1AdminRequestConfig), deleteUser(rcUser2, {}, rc1AdminRequestConfig)]); + await Promise.all([deleteUser(rcUser1, {}, rc1AdminRequestConfig), deleteUser(rcUser2, {}, rc1AdminRequestConfig)]); }); describe('Room list name validations', () => { it('should create a group DM with multiple RC users', async () => { - hs1Room = await hs1AdminApp.createDM([userDmId1, userDmId2]); + hs1Room = (await hs1AdminApp.createDM([userDmId1, userDmId2])) as Room; expect(hs1Room).toHaveProperty('roomId'); - const subs1 = await getSubscriptions(rcUserConfig1); + await retry('this is an async operation, so we need to wait for the room to be created in RC', async () => { + const roomsResponse = await rcUserConfig1.request.get(api('rooms.get')).set(rcUserConfig1.credentials).expect(200); - pendingInvitation1 = subs1.update.find( - (subscription) => - subscription.status === 'INVITED' && - subscription.fname?.includes(`@${federationConfig.hs1.adminUser}:${federationConfig.hs1.domain}`), - ); + expect(roomsResponse.body).toHaveProperty('success', true); + expect(roomsResponse.body).toHaveProperty('update'); + + rcRoom1 = roomsResponse.body.update.find((room: IRoomNativeFederated) => room.federation.mrid === hs1Room.roomId); - expect(pendingInvitation1).toHaveProperty('rid'); + expect(rcRoom1).toHaveProperty('_id'); + expect(rcRoom1).toHaveProperty('t', 'd'); + expect(rcRoom1).toHaveProperty('uids'); + expect(rcRoom1).not.toHaveProperty('fname'); - const membersBefore = await hs1Room!.getMembers(); + // check pending invitations for user 1 + pendingInvitation1 = await getSubscriptionByRoomId(rcRoom1._id, rcUserConfig1.credentials); + + expect(pendingInvitation1).toHaveProperty('status', 'INVITED'); + expect(pendingInvitation1).toHaveProperty('fname', `@${federationConfig.hs1.adminUser}:${federationConfig.hs1.domain}`); + + // check pending invitations for user 2 + pendingInvitation2 = await getSubscriptionByRoomId(rcRoom1._id, rcUserConfig2.credentials); + + expect(pendingInvitation2).toHaveProperty('status', 'INVITED'); + + // TODO fix fname update + // expect(pendingInvitation2).toHaveProperty('fname', `@${federationConfig.hs1.adminUser}:${federationConfig.hs1.domain}`); + }); + const membersBefore = await hs1Room.getMembers(); expect(membersBefore.length).toBe(3); const invitedMember = membersBefore.find((member) => member.userId === userDmId1); expect(invitedMember).toHaveProperty('membership', 'invite'); - - invitedRoomId1 = pendingInvitation1!.rid; }); it('should display the name of the inviter on RC', async () => { expect(pendingInvitation1).toHaveProperty('fname', federationConfig.hs1.adminMatrixUserId); }); - it.failing('should display the name of all users on RC after the invited user accepts the invitation', async () => { - const waitForRoomEventPromise1 = waitForRoomEvent(hs1Room!, RoomStateEvent.Members, ({ event }) => { + it('should display the name of all users on RC after the invited user accepts the invitation', async () => { + const waitForRoomEventPromise1 = waitForRoomEvent(hs1Room, RoomStateEvent.Members, ({ event }) => { expect(event).toHaveProperty('content.membership', 'join'); expect(event).toHaveProperty('state_key', userDmId1); }); - const response = await acceptRoomInvite(invitedRoomId1, rcUserConfig1); + const response = await acceptRoomInvite(rcRoom1._id, rcUserConfig1); expect(response.success).toBe(true); await waitForRoomEventPromise1; - const subs1After = await getSubscriptions(rcUserConfig1); - - const joinedSubscription1 = subs1After.update.find((subscription) => subscription.rid === invitedRoomId1); + await retry( + 'this is an async operation, so we need to wait for the event to be processed', + async () => { + const sub = await getSubscriptionByRoomId(rcRoom1._id, rcUserConfig1.credentials); - expect(joinedSubscription1).toHaveProperty('fname', `${federationConfig.hs1.adminMatrixUserId}, ${userDm1}, ${userDm2}`); + expect(sub).not.toHaveProperty('status'); + expect(sub).toHaveProperty('name', `${federationConfig.hs1.adminMatrixUserId}, ${userDm2}`); + expect(sub).toHaveProperty('fname', `${federationConfig.hs1.adminMatrixUserId}, ${userDm2Name}`); + }, + { delayMs: 100 }, + ); }); it.todo('should update the display the name if the inviter from Synapse leaves the group DM'); + + it.todo('should respect max users allowed in a group DM when adding users'); + it.todo('should update roomsCount after first user accept the invitation'); + it.todo('should not update last message when third user accepts the invitation'); }); describe('Permission validations', () => { it.todo('should allow a user to add another user to the group DM'); diff --git a/packages/core-services/src/types/IRoomService.ts b/packages/core-services/src/types/IRoomService.ts index d38b400a4b3b7..803a8ceb7d006 100644 --- a/packages/core-services/src/types/IRoomService.ts +++ b/packages/core-services/src/types/IRoomService.ts @@ -7,7 +7,8 @@ export interface ISubscriptionExtraData { roles?: string[]; } -export interface ICreateRoomOptions extends Partial> { +export interface ICreateRoomOptions extends Partial> { + forceNew?: boolean; creator: string; subscriptionExtra?: ISubscriptionExtraData; } From 6258fc1626bc85ef39a1cc1fc1f137149f88df88 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Wed, 17 Dec 2025 14:43:51 -0300 Subject: [PATCH 02/11] fix invited subscription without name --- apps/meteor/server/services/room/service.ts | 1 + .../tests/end-to-end/dms.spec.ts | 166 ++++++++++++++---- 2 files changed, 135 insertions(+), 32 deletions(-) diff --git a/apps/meteor/server/services/room/service.ts b/apps/meteor/server/services/room/service.ts index adad895d09df9..52bacd056217f 100644 --- a/apps/meteor/server/services/room/service.ts +++ b/apps/meteor/server/services/room/service.ts @@ -287,6 +287,7 @@ export class RoomService extends ServiceClassInternal implements IRoomService { ...(inviter && { inviter: { _id: inviter._id, username: inviter.username!, name: inviter.name } }), ...autoTranslateConfig, ...getDefaultSubscriptionPref(userToBeAdded), + ...(room.t === 'd' && inviter && { fname: inviter.name, name: inviter.username }), }); if (insertedId) { diff --git a/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts b/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts index 0b6db39bf8c24..1707b86761d12 100644 --- a/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts +++ b/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts @@ -283,6 +283,123 @@ const waitForRoomEvent = async ( expect(sub).toHaveProperty('fname', federationConfig.hs1.adminMatrixUserId); }); + it('should return room name as empty after the user from Synapse leaves the DM', async () => { + await hs1AdminApp.matrixClient.leave(hs1Room.roomId); + + await retry('this is an async operation, so we need to wait for the event to be processed', async () => { + const sub = await getSubscriptionByRoomId(rcRoom._id, rcUserConfig.credentials, rcUserConfig.request); + + expect(sub).toHaveProperty('name', 'empty'); + expect(sub).toHaveProperty('fname', 'Empty Room'); + + const roomInfo = await getRoomInfo(rcRoom._id, rcUserConfig); + + expect(roomInfo).toHaveProperty('room'); + + expect(roomInfo.room).toHaveProperty('usersCount', 1); + expect(roomInfo.room).not.toHaveProperty('fname'); + expect(roomInfo.room).toHaveProperty('uids'); + expect(roomInfo.room?.uids).toHaveLength(1); + expect(roomInfo.room?.uids).toEqual([rcUser._id]); + + expect(roomInfo.room).toHaveProperty('usernames'); + expect(roomInfo.room?.usernames).toHaveLength(1); + expect(roomInfo.room?.usernames).toEqual([rcUser.username]); + }); + }); + }); + + describe('Permission validations', () => { + let rcUser: TestUser; + let rcUserConfig: IRequestConfig; + let hs1Room: Room; + let subscriptionInvite: ISubscription; + let rcRoom: IRoom; + + const userDm = `dm-federation-user-${Date.now()}`; + const userDmId = `@${userDm}:${federationConfig.rc1.domain}`; + + beforeAll(async () => { + // create both RC and Synapse users + rcUser = await createUser( + { + username: userDm, + password: 'random', + email: `${userDm}}@rocket.chat`, + name: `DM Federation User ${Date.now()}`, + }, + rc1AdminRequestConfig, + ); + + rcUserConfig = await getRequestConfig(federationConfig.rc1.url, rcUser.username, 'random'); + }); + + afterAll(async () => { + // delete both RC and Synapse users + await deleteUser(rcUser, {}, rc1AdminRequestConfig); + }); + + it('should create a DM and invite user from rc', async () => { + hs1Room = (await hs1AdminApp.createDM([userDmId])) as Room; + + expect(hs1Room).toHaveProperty('roomId'); + + await retry('this is an async operation, so we need to wait for the room to be created in RC', async () => { + console.log('waiting for the room to be created in RC'); + const roomsResponse = await rcUserConfig.request.get(api('rooms.get')).set(rcUserConfig.credentials).expect(200); + console.log('roomsResponse', roomsResponse.body); + expect(roomsResponse.body).toHaveProperty('success', true); + expect(roomsResponse.body).toHaveProperty('update'); + + rcRoom = roomsResponse.body.update.find((room: IRoomNativeFederated) => room.federation.mrid === hs1Room.roomId); + + console.log('rcRoom', rcRoom); + expect(rcRoom).toHaveProperty('_id'); + expect(rcRoom).toHaveProperty('t', 'd'); + expect(rcRoom).toHaveProperty('uids'); + expect(rcRoom).not.toHaveProperty('fname'); + + console.log('getting subscription invite'); + subscriptionInvite = await getSubscriptionByRoomId(rcRoom._id, rcUserConfig.credentials, rcUserConfig.request).catch( + (error) => { + console.log('error', error); + throw error; + }, + ); + + console.log('subscriptionInvite', subscriptionInvite); + expect(subscriptionInvite).toHaveProperty('status', 'INVITED'); + expect(subscriptionInvite).toHaveProperty('fname', `@${federationConfig.hs1.adminUser}:${federationConfig.hs1.domain}`); + }); + }); + + it('should accept the DM invitation from rc', async () => { + const membersBefore = await hs1Room.getMembers(); + + expect(membersBefore.length).toBe(2); + + const invitedMember = membersBefore.find((member) => member.userId === userDmId); + + expect(invitedMember).toHaveProperty('membership', 'invite'); + + await Promise.all([ + waitForRoomEvent(hs1Room, RoomStateEvent.Members, ({ event }) => { + expect(event).toHaveProperty('content.membership', 'join'); + expect(event).toHaveProperty('state_key', userDmId); + }), + (async () => { + const response = await acceptRoomInvite(rcRoom._id, rcUserConfig); + expect(response.success).toBe(true); + })(), + ]); + }); + + it('should display the fname properly', async () => { + const sub = await getSubscriptionByRoomId(rcRoom._id, rcUserConfig.credentials, rcUserConfig.request); + + expect(sub).toHaveProperty('fname', federationConfig.hs1.adminMatrixUserId); + }); + it('should leave the DM from Rocket.Chat', async () => { const leaveEventPromise = waitForRoomEvent(hs1Room, RoomStateEvent.Members, ({ event }) => { expect(event).toHaveProperty('content.membership', 'leave'); @@ -308,22 +425,6 @@ const waitForRoomEvent = async ( describe('Rocket.Chat as the resident server', () => { it.todo('should create a DM and invite user from synapse'); - // const createResponse = await createDirectMessage({ - // usernames: [federationConfig.hs1.adminMatrixUserId], - // config: rc1AdminRequestConfig, - // }); - - // expect(createResponse.status).toBe(200); - // expect(createResponse.body).toHaveProperty('success', true); - // // createResponse.body.room._rid; - - // const sub = await getSubscriptions(rc1AdminRequestConfig).then((subs) => - // subs.update.find((subscription) => subscription.rid === createResponse.body.room._rid), - // ); - // expect(sub).toHaveProperty('rid', createResponse.body.room._rid); - - // expect(sub).toHaveProperty('fname', federationConfig.hs1.adminMatrixUserId); - it.todo('should display the fname properly after reject the invitation'); it.todo('should display the fname properly after accept the invitation'); it.todo('should allow the user to leave the DM if it is not the only member'); @@ -408,20 +509,6 @@ const waitForRoomEvent = async ( expect(rcRoom1).toHaveProperty('t', 'd'); expect(rcRoom1).toHaveProperty('uids'); expect(rcRoom1).not.toHaveProperty('fname'); - - // check pending invitations for user 1 - pendingInvitation1 = await getSubscriptionByRoomId(rcRoom1._id, rcUserConfig1.credentials); - - expect(pendingInvitation1).toHaveProperty('status', 'INVITED'); - expect(pendingInvitation1).toHaveProperty('fname', `@${federationConfig.hs1.adminUser}:${federationConfig.hs1.domain}`); - - // check pending invitations for user 2 - pendingInvitation2 = await getSubscriptionByRoomId(rcRoom1._id, rcUserConfig2.credentials); - - expect(pendingInvitation2).toHaveProperty('status', 'INVITED'); - - // TODO fix fname update - // expect(pendingInvitation2).toHaveProperty('fname', `@${federationConfig.hs1.adminUser}:${federationConfig.hs1.domain}`); }); const membersBefore = await hs1Room.getMembers(); @@ -432,10 +519,24 @@ const waitForRoomEvent = async ( expect(invitedMember).toHaveProperty('membership', 'invite'); }); - it('should display the name of the inviter on RC', async () => { + it('should display the name of the inviter to user1 on RC', async () => { + // check pending invitations for user 1 + pendingInvitation1 = await getSubscriptionByRoomId(rcRoom1._id, rcUserConfig1.credentials, rcUserConfig1.request); + + expect(pendingInvitation1).toHaveProperty('status', 'INVITED'); + expect(pendingInvitation1).toHaveProperty('fname', `@${federationConfig.hs1.adminUser}:${federationConfig.hs1.domain}`); expect(pendingInvitation1).toHaveProperty('fname', federationConfig.hs1.adminMatrixUserId); }); + it('should display the name of the inviter to user2on RC', async () => { + // check pending invitations for user 1 + pendingInvitation2 = await getSubscriptionByRoomId(rcRoom1._id, rcUserConfig2.credentials, rcUserConfig2.request); + + expect(pendingInvitation2).toHaveProperty('status', 'INVITED'); + expect(pendingInvitation2).toHaveProperty('fname', `@${federationConfig.hs1.adminUser}:${federationConfig.hs1.domain}`); + expect(pendingInvitation2).toHaveProperty('fname', federationConfig.hs1.adminMatrixUserId); + }); + it('should display the name of all users on RC after the invited user accepts the invitation', async () => { const waitForRoomEventPromise1 = waitForRoomEvent(hs1Room, RoomStateEvent.Members, ({ event }) => { expect(event).toHaveProperty('content.membership', 'join'); @@ -450,7 +551,7 @@ const waitForRoomEvent = async ( await retry( 'this is an async operation, so we need to wait for the event to be processed', async () => { - const sub = await getSubscriptionByRoomId(rcRoom1._id, rcUserConfig1.credentials); + const sub = await getSubscriptionByRoomId(rcRoom1._id, rcUserConfig1.credentials, rcUserConfig1.request); expect(sub).not.toHaveProperty('status'); expect(sub).toHaveProperty('name', `${federationConfig.hs1.adminMatrixUserId}, ${userDm2}`); @@ -500,6 +601,7 @@ const waitForRoomEvent = async ( describe('Turning a 1:1 DM into a group DM', () => { it.todo('should show the invite to the third user'); it.todo('should update the room name to reflect the three users after the third user accepts the invitation'); + it.todo('should invite a third user from Rocket.Chat by a Synapse user'); }); }); }); From 4721a3ca109a4f9f81951705a98be159021b5c2e Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Wed, 17 Dec 2025 14:55:03 -0300 Subject: [PATCH 03/11] more test cases --- .../tests/end-to-end/dms.spec.ts | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts b/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts index 1707b86761d12..3da910fddd4e6 100644 --- a/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts +++ b/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts @@ -560,11 +560,38 @@ const waitForRoomEvent = async ( { delayMs: 100 }, ); }); - it.todo('should update the display the name if the inviter from Synapse leaves the group DM'); + + it('should have a correct roomsCount as 3 after first user accept the invitation', async () => { + const roomInfo = await getRoomInfo(rcRoom1._id, rcUserConfig1); + + expect(roomInfo).toHaveProperty('room'); + expect(roomInfo.room).toHaveProperty('usersCount', 3); + }); + + it('should update the display name if the inviter from Synapse leaves the group DM', async () => { + await hs1AdminApp.matrixClient.leave(hs1Room.roomId); + + await retry( + 'this is an async operation, so we need to wait for the event to be processed', + async () => { + const sub = await getSubscriptionByRoomId(rcRoom1._id, rcUserConfig1.credentials); + + expect(sub).not.toHaveProperty('status'); + expect(sub).toHaveProperty('name', userDm2); + expect(sub).toHaveProperty('fname', userDm2Name); + }, + { delayMs: 100 }, + ); + }); + + it('should have a correct roomsCount as 2 after user leaves', async () => { + const roomInfo = await getRoomInfo(rcRoom1._id, rcUserConfig1); + + expect(roomInfo).toHaveProperty('room'); + expect(roomInfo.room).toHaveProperty('usersCount', 2); + }); it.todo('should respect max users allowed in a group DM when adding users'); - it.todo('should update roomsCount after first user accept the invitation'); - it.todo('should not update last message when third user accepts the invitation'); }); describe('Permission validations', () => { it.todo('should allow a user to add another user to the group DM'); From 5b800435eb2300d91a58b36a536328bdf10fc1b0 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Wed, 17 Dec 2025 15:30:04 -0300 Subject: [PATCH 04/11] fix: allow invite users on federated DMs --- .../app/lib/server/methods/addUsersToRoom.ts | 5 +- apps/meteor/server/services/room/service.ts | 3 + .../federation-matrix/src/events/member.ts | 1 + .../tests/end-to-end/dms.spec.ts | 66 ++++++++++++++++--- .../core-services/src/types/IRoomService.ts | 1 + 5 files changed, 63 insertions(+), 13 deletions(-) diff --git a/apps/meteor/app/lib/server/methods/addUsersToRoom.ts b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts index 5f1e3f1200d28..0ceda8ab359e9 100644 --- a/apps/meteor/app/lib/server/methods/addUsersToRoom.ts +++ b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts @@ -1,5 +1,5 @@ import { api } from '@rocket.chat/core-services'; -import type { IUser } from '@rocket.chat/core-typings'; +import { isRoomNativeFederated, type IUser } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Subscriptions, Users, Rooms } from '@rocket.chat/models'; import { Match } from 'meteor/check'; @@ -52,8 +52,7 @@ export const addUsersToRoomMethod = async (userId: string, data: { rid: string; }); const userInRoom = subscription != null; - // TODO: Can't add to direct room ever, unless it's a federated room - if (room.t === 'd') { + if (room.t === 'd' && !isRoomNativeFederated(room)) { throw new Meteor.Error('error-cant-invite-for-direct-room', "Can't invite user to direct rooms", { method: 'addUsersToRoom', }); diff --git a/apps/meteor/server/services/room/service.ts b/apps/meteor/server/services/room/service.ts index 52bacd056217f..c1bd718caaea3 100644 --- a/apps/meteor/server/services/room/service.ts +++ b/apps/meteor/server/services/room/service.ts @@ -263,6 +263,7 @@ export class RoomService extends ServiceClassInternal implements IRoomService { skipAlertSound = false, skipSystemMessage = false, status, + roles, }: { room: IRoom; ts: Date; @@ -272,6 +273,7 @@ export class RoomService extends ServiceClassInternal implements IRoomService { skipAlertSound?: boolean; skipSystemMessage?: boolean; status?: 'INVITED'; + roles?: ISubscription['roles']; }): Promise { const autoTranslateConfig = getSubscriptionAutotranslateDefaultConfig(userToBeAdded); @@ -283,6 +285,7 @@ export class RoomService extends ServiceClassInternal implements IRoomService { unread: 1, userMentions: 1, groupMentions: 0, + ...(roles && { roles }), ...(status && { status }), ...(inviter && { inviter: { _id: inviter._id, username: inviter.username!, name: inviter.name } }), ...autoTranslateConfig, diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index 5b643b40e03bd..88466aeb06b11 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -186,6 +186,7 @@ async function handleInvite({ userToBeAdded: inviteeUser, inviter: inviterUser, status: 'INVITED', + ...(roomType === 'd' && { roles: ['owner'] }), }); // if an invite is sent to a DM, we need to update the room name to reflect all participants diff --git a/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts b/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts index 3da910fddd4e6..fa5d504944096 100644 --- a/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts +++ b/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts @@ -3,12 +3,7 @@ import type { MatrixEvent, Room, RoomEmittedEvents } from 'matrix-js-sdk'; import { RoomStateEvent } from 'matrix-js-sdk'; import { api } from '../../../../../apps/meteor/tests/data/api-data'; -import { - acceptRoomInvite, - getRoomInfo, - getSubscriptionByRoomId, - getSubscriptions, -} from '../../../../../apps/meteor/tests/data/rooms.helper'; +import { acceptRoomInvite, addUserToRoom, getRoomInfo, getSubscriptionByRoomId } from '../../../../../apps/meteor/tests/data/rooms.helper'; import { getRequestConfig, createUser, deleteUser } from '../../../../../apps/meteor/tests/data/users.helper'; import type { TestUser, IRequestConfig } from '../../../../../apps/meteor/tests/data/users.helper'; import { IS_EE } from '../../../../../apps/meteor/tests/e2e/config/constants'; @@ -435,11 +430,14 @@ const waitForRoomEvent = async ( describe('Multiple user DMs', () => { describe('Synapse as the resident server', () => { let rcUser1: TestUser; - let rcUser2: TestUser; - let rcUserConfig1: IRequestConfig; + + let rcUser2: TestUser; let rcUserConfig2: IRequestConfig; + let rcUser3: TestUser; + let rcUserConfig3: IRequestConfig; + let rcRoom1: IRoom; let hs1Room: Room; @@ -488,7 +486,11 @@ const waitForRoomEvent = async ( afterAll(async () => { // delete both RC and Synapse users - await Promise.all([deleteUser(rcUser1, {}, rc1AdminRequestConfig), deleteUser(rcUser2, {}, rc1AdminRequestConfig)]); + await Promise.all([ + deleteUser(rcUser1, {}, rc1AdminRequestConfig), + deleteUser(rcUser2, {}, rc1AdminRequestConfig), + rcUser3 && deleteUser(rcUser3, {}, rc1AdminRequestConfig), + ]); }); describe('Room list name validations', () => { @@ -528,6 +530,11 @@ const waitForRoomEvent = async ( expect(pendingInvitation1).toHaveProperty('fname', federationConfig.hs1.adminMatrixUserId); }); + it('should have user1 as owner of the group DM on RC', async () => { + expect(pendingInvitation1).toHaveProperty('roles'); + expect(pendingInvitation1.roles).toContain('owner'); + }); + it('should display the name of the inviter to user2on RC', async () => { // check pending invitations for user 1 pendingInvitation2 = await getSubscriptionByRoomId(rcRoom1._id, rcUserConfig2.credentials, rcUserConfig2.request); @@ -537,6 +544,11 @@ const waitForRoomEvent = async ( expect(pendingInvitation2).toHaveProperty('fname', federationConfig.hs1.adminMatrixUserId); }); + it('should have user2 as owner of the group DM on RC', async () => { + expect(pendingInvitation2).toHaveProperty('roles'); + expect(pendingInvitation2.roles).toContain('owner'); + }); + it('should display the name of all users on RC after the invited user accepts the invitation', async () => { const waitForRoomEventPromise1 = waitForRoomEvent(hs1Room, RoomStateEvent.Members, ({ event }) => { expect(event).toHaveProperty('content.membership', 'join'); @@ -594,7 +606,41 @@ const waitForRoomEvent = async ( it.todo('should respect max users allowed in a group DM when adding users'); }); describe('Permission validations', () => { - it.todo('should allow a user to add another user to the group DM'); + const userDm3 = `dm-federation-user3-${Date.now()}`; + const userDm3Name = `DM Federation User3 ${Date.now()}`; + const userDmId3 = `@${userDm3}:${federationConfig.rc1.domain}`; + + beforeAll(async () => { + rcUser3 = await createUser( + { + username: userDm3, + password: 'random', + email: `${userDm3}}@rocket.chat`, + name: userDm3Name, + }, + rc1AdminRequestConfig, + ); + + rcUserConfig3 = await getRequestConfig(federationConfig.rc1.url, rcUser3.username, 'random'); + }); + + it('should allow a user from rc to add another user to the group DM', async () => { + const response = await addUserToRoom({ + usernames: [userDmId3], + rid: rcRoom1._id, + config: rcUserConfig1, + }); + + expect(response.body).toHaveProperty('success', true); + expect(response.body).toHaveProperty('message'); + + // Parse the error message from the DDP response + const messageData = JSON.parse(response.body.message); + + expect(messageData).not.toHaveProperty('error'); + expect(messageData).not.toHaveProperty('result', true); + }); + it.todo('should allow a user to leave the group DM'); }); describe('Turning a 1:1 DM into a group DM', () => { diff --git a/packages/core-services/src/types/IRoomService.ts b/packages/core-services/src/types/IRoomService.ts index 803a8ceb7d006..76f909b97d61c 100644 --- a/packages/core-services/src/types/IRoomService.ts +++ b/packages/core-services/src/types/IRoomService.ts @@ -67,6 +67,7 @@ export interface IRoomService { skipAlertSound?: boolean; skipSystemMessage?: boolean; status?: 'INVITED'; + roles?: ISubscription['roles']; }): Promise; updateDirectMessageRoomName(room: IRoom): Promise; } From c922b61f403d743a7e1977d012e38cdeb86b6185 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Wed, 17 Dec 2025 17:32:47 -0300 Subject: [PATCH 05/11] fix roles in DMs --- .../lib/server/functions/createDirectRoom.ts | 1 + apps/meteor/server/services/room/service.ts | 1 - .../federation-matrix/src/events/member.ts | 1 - .../tests/end-to-end/dms.spec.ts | 30 ++++++++++++------- 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/createDirectRoom.ts b/apps/meteor/app/lib/server/functions/createDirectRoom.ts index 9ae39f3d9d9e9..a18e85da452f6 100644 --- a/apps/meteor/app/lib/server/functions/createDirectRoom.ts +++ b/apps/meteor/app/lib/server/functions/createDirectRoom.ts @@ -192,6 +192,7 @@ export async function createDirectRoom( ...options?.subscriptionExtra, ...(options?.creator !== member._id && { open: members.length > 2 }), ...subscriptionStatus, + ...(roomExtraData.federated && member._id === options?.creator && { roles: ['owner'] }), }), }, { upsert: true }, diff --git a/apps/meteor/server/services/room/service.ts b/apps/meteor/server/services/room/service.ts index c1bd718caaea3..a860e965a9842 100644 --- a/apps/meteor/server/services/room/service.ts +++ b/apps/meteor/server/services/room/service.ts @@ -277,7 +277,6 @@ export class RoomService extends ServiceClassInternal implements IRoomService { }): Promise { const autoTranslateConfig = getSubscriptionAutotranslateDefaultConfig(userToBeAdded); - // TODO need to provide name and fname for DMs (room object won't have it) const { insertedId } = await Subscriptions.createWithRoomAndUser(room, userToBeAdded, { ts, open: !createAsHidden, diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index 88466aeb06b11..5b643b40e03bd 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -186,7 +186,6 @@ async function handleInvite({ userToBeAdded: inviteeUser, inviter: inviterUser, status: 'INVITED', - ...(roomType === 'd' && { roles: ['owner'] }), }); // if an invite is sent to a DM, we need to update the room name to reflect all participants diff --git a/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts b/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts index fa5d504944096..51cbe753a2457 100644 --- a/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts +++ b/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts @@ -530,9 +530,8 @@ const waitForRoomEvent = async ( expect(pendingInvitation1).toHaveProperty('fname', federationConfig.hs1.adminMatrixUserId); }); - it('should have user1 as owner of the group DM on RC', async () => { - expect(pendingInvitation1).toHaveProperty('roles'); - expect(pendingInvitation1.roles).toContain('owner'); + it('should have user1 as regular user of the group DM on RC', async () => { + expect(pendingInvitation1).not.toHaveProperty('roles'); }); it('should display the name of the inviter to user2on RC', async () => { @@ -544,9 +543,8 @@ const waitForRoomEvent = async ( expect(pendingInvitation2).toHaveProperty('fname', federationConfig.hs1.adminMatrixUserId); }); - it('should have user2 as owner of the group DM on RC', async () => { - expect(pendingInvitation2).toHaveProperty('roles'); - expect(pendingInvitation2.roles).toContain('owner'); + it('should have user2 as regular user of the group DM on RC', async () => { + expect(pendingInvitation2).not.toHaveProperty('roles'); }); it('should display the name of all users on RC after the invited user accepts the invitation', async () => { @@ -624,7 +622,8 @@ const waitForRoomEvent = async ( rcUserConfig3 = await getRequestConfig(federationConfig.rc1.url, rcUser3.username, 'random'); }); - it('should allow a user from rc to add another user to the group DM', async () => { + // TODO maybe we should allow it + it('should fail if a user from rc try to add another user to the group DM', async () => { const response = await addUserToRoom({ usernames: [userDmId3], rid: rcRoom1._id, @@ -637,11 +636,22 @@ const waitForRoomEvent = async ( // Parse the error message from the DDP response const messageData = JSON.parse(response.body.message); - expect(messageData).not.toHaveProperty('error'); - expect(messageData).not.toHaveProperty('result', true); + expect(messageData).toHaveProperty('error.error', 'error-not-allowed'); }); - it.todo('should allow a user to leave the group DM'); + it('should allow a user to leave the group DM', async () => { + const response = await rcUserConfig1.request + .post(api('rooms.leave')) + .set(rcUserConfig1.credentials) + .send({ + roomId: rcRoom1._id, + }) + .expect(200); + + expect(response.body).toHaveProperty('success', true); + }); + + it.todo('should delete the room entirely if no local users in the room'); }); describe('Turning a 1:1 DM into a group DM', () => { it.todo('should show the invite to the third user'); From 31faf32fec2aa76aebc7a52b4c4ed522e1763ee2 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Wed, 17 Dec 2025 19:19:28 -0300 Subject: [PATCH 06/11] code cleanup --- .../tests/end-to-end/dms.spec.ts | 125 +----------------- 1 file changed, 2 insertions(+), 123 deletions(-) diff --git a/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts b/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts index 51cbe753a2457..8d7e75b632b7e 100644 --- a/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts +++ b/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts @@ -234,7 +234,6 @@ const waitForRoomEvent = async ( await retry('this is an async operation, so we need to wait for the room to be created in RC', async () => { const roomsResponse = await rcUserConfig.request.get(api('rooms.get')).set(rcUserConfig.credentials).expect(200); - expect(roomsResponse.body).toHaveProperty('success', true); expect(roomsResponse.body).toHaveProperty('update'); @@ -252,122 +251,6 @@ const waitForRoomEvent = async ( }); }); - it('should accept the DM invitation from rc', async () => { - const membersBefore = await hs1Room.getMembers(); - - expect(membersBefore.length).toBe(2); - - const invitedMember = membersBefore.find((member) => member.userId === userDmId); - - expect(invitedMember).toHaveProperty('membership', 'invite'); - - const waitForRoomEventPromise = waitForRoomEvent(hs1Room, RoomStateEvent.Members, ({ event }) => { - expect(event).toHaveProperty('content.membership', 'join'); - expect(event).toHaveProperty('state_key', userDmId); - }); - - const response = await acceptRoomInvite(rcRoom._id, rcUserConfig); - expect(response.success).toBe(true); - - await waitForRoomEventPromise; - }); - - it('should display the fname properly', async () => { - const sub = await getSubscriptionByRoomId(rcRoom._id, rcUserConfig.credentials, rcUserConfig.request); - - expect(sub).toHaveProperty('fname', federationConfig.hs1.adminMatrixUserId); - }); - - it('should return room name as empty after the user from Synapse leaves the DM', async () => { - await hs1AdminApp.matrixClient.leave(hs1Room.roomId); - - await retry('this is an async operation, so we need to wait for the event to be processed', async () => { - const sub = await getSubscriptionByRoomId(rcRoom._id, rcUserConfig.credentials, rcUserConfig.request); - - expect(sub).toHaveProperty('name', 'empty'); - expect(sub).toHaveProperty('fname', 'Empty Room'); - - const roomInfo = await getRoomInfo(rcRoom._id, rcUserConfig); - - expect(roomInfo).toHaveProperty('room'); - - expect(roomInfo.room).toHaveProperty('usersCount', 1); - expect(roomInfo.room).not.toHaveProperty('fname'); - expect(roomInfo.room).toHaveProperty('uids'); - expect(roomInfo.room?.uids).toHaveLength(1); - expect(roomInfo.room?.uids).toEqual([rcUser._id]); - - expect(roomInfo.room).toHaveProperty('usernames'); - expect(roomInfo.room?.usernames).toHaveLength(1); - expect(roomInfo.room?.usernames).toEqual([rcUser.username]); - }); - }); - }); - - describe('Permission validations', () => { - let rcUser: TestUser; - let rcUserConfig: IRequestConfig; - let hs1Room: Room; - let subscriptionInvite: ISubscription; - let rcRoom: IRoom; - - const userDm = `dm-federation-user-${Date.now()}`; - const userDmId = `@${userDm}:${federationConfig.rc1.domain}`; - - beforeAll(async () => { - // create both RC and Synapse users - rcUser = await createUser( - { - username: userDm, - password: 'random', - email: `${userDm}}@rocket.chat`, - name: `DM Federation User ${Date.now()}`, - }, - rc1AdminRequestConfig, - ); - - rcUserConfig = await getRequestConfig(federationConfig.rc1.url, rcUser.username, 'random'); - }); - - afterAll(async () => { - // delete both RC and Synapse users - await deleteUser(rcUser, {}, rc1AdminRequestConfig); - }); - - it('should create a DM and invite user from rc', async () => { - hs1Room = (await hs1AdminApp.createDM([userDmId])) as Room; - - expect(hs1Room).toHaveProperty('roomId'); - - await retry('this is an async operation, so we need to wait for the room to be created in RC', async () => { - console.log('waiting for the room to be created in RC'); - const roomsResponse = await rcUserConfig.request.get(api('rooms.get')).set(rcUserConfig.credentials).expect(200); - console.log('roomsResponse', roomsResponse.body); - expect(roomsResponse.body).toHaveProperty('success', true); - expect(roomsResponse.body).toHaveProperty('update'); - - rcRoom = roomsResponse.body.update.find((room: IRoomNativeFederated) => room.federation.mrid === hs1Room.roomId); - - console.log('rcRoom', rcRoom); - expect(rcRoom).toHaveProperty('_id'); - expect(rcRoom).toHaveProperty('t', 'd'); - expect(rcRoom).toHaveProperty('uids'); - expect(rcRoom).not.toHaveProperty('fname'); - - console.log('getting subscription invite'); - subscriptionInvite = await getSubscriptionByRoomId(rcRoom._id, rcUserConfig.credentials, rcUserConfig.request).catch( - (error) => { - console.log('error', error); - throw error; - }, - ); - - console.log('subscriptionInvite', subscriptionInvite); - expect(subscriptionInvite).toHaveProperty('status', 'INVITED'); - expect(subscriptionInvite).toHaveProperty('fname', `@${federationConfig.hs1.adminUser}:${federationConfig.hs1.domain}`); - }); - }); - it('should accept the DM invitation from rc', async () => { const membersBefore = await hs1Room.getMembers(); @@ -395,7 +278,7 @@ const waitForRoomEvent = async ( expect(sub).toHaveProperty('fname', federationConfig.hs1.adminMatrixUserId); }); - it('should leave the DM from Rocket.Chat', async () => { + it('should be able to leave the DM from Rocket.Chat', async () => { const leaveEventPromise = waitForRoomEvent(hs1Room, RoomStateEvent.Members, ({ event }) => { expect(event).toHaveProperty('content.membership', 'leave'); expect(event).toHaveProperty('state_key', userDmId); @@ -414,8 +297,6 @@ const waitForRoomEvent = async ( await leaveEventPromise; }); }); - - it.todo('should reflect the revoke invitation in the RC user subscriptions'); }); describe('Rocket.Chat as the resident server', () => { @@ -436,7 +317,7 @@ const waitForRoomEvent = async ( let rcUserConfig2: IRequestConfig; let rcUser3: TestUser; - let rcUserConfig3: IRequestConfig; + // let rcUserConfig3: IRequestConfig; let rcRoom1: IRoom; @@ -618,8 +499,6 @@ const waitForRoomEvent = async ( }, rc1AdminRequestConfig, ); - - rcUserConfig3 = await getRequestConfig(federationConfig.rc1.url, rcUser3.username, 'random'); }); // TODO maybe we should allow it From d58d7ee1854c98bff6d4240786c9fee38705e595 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Wed, 17 Dec 2025 19:26:05 -0300 Subject: [PATCH 07/11] add missing tests --- .../tests/end-to-end/dms.spec.ts | 137 +++++++++++++++++- 1 file changed, 135 insertions(+), 2 deletions(-) diff --git a/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts b/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts index 8d7e75b632b7e..209732a1ac0c4 100644 --- a/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts +++ b/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts @@ -533,8 +533,141 @@ const waitForRoomEvent = async ( it.todo('should delete the room entirely if no local users in the room'); }); describe('Turning a 1:1 DM into a group DM', () => { - it.todo('should show the invite to the third user'); - it.todo('should update the room name to reflect the three users after the third user accepts the invitation'); + let rcUserA: TestUser; + let rcUserConfigA: IRequestConfig; + + let rcUserB: TestUser; + let rcUserConfigB: IRequestConfig; + + let hs1RoomConverted: Room; + let rcRoomConverted: IRoom; + + const userDmA = `dm-federation-userA-${Date.now()}`; + const userDmAName = `DM Federation UserA ${Date.now()}`; + const userDmIdA = `@${userDmA}:${federationConfig.rc1.domain}`; + + const userDmB = `dm-federation-userB-${Date.now()}`; + const userDmBName = `DM Federation UserB ${Date.now()}`; + const userDmIdB = `@${userDmB}:${federationConfig.rc1.domain}`; + + beforeAll(async () => { + // Create two RC users + rcUserA = await createUser( + { + username: userDmA, + password: 'random', + email: `${userDmA}@rocket.chat`, + name: userDmAName, + }, + rc1AdminRequestConfig, + ); + + rcUserConfigA = await getRequestConfig(federationConfig.rc1.url, rcUserA.username, 'random'); + + rcUserB = await createUser( + { + username: userDmB, + password: 'random', + email: `${userDmB}@rocket.chat`, + name: userDmBName, + }, + rc1AdminRequestConfig, + ); + + rcUserConfigB = await getRequestConfig(federationConfig.rc1.url, rcUserB.username, 'random'); + + // Create 1:1 DM from Synapse with userA + hs1RoomConverted = (await hs1AdminApp.createDM([userDmIdA])) as Room; + + expect(hs1RoomConverted).toHaveProperty('roomId'); + + await retry('this is an async operation, so we need to wait for the room to be created in RC', async () => { + const roomsResponse = await rcUserConfigA.request.get(api('rooms.get')).set(rcUserConfigA.credentials).expect(200); + + expect(roomsResponse.body).toHaveProperty('success', true); + expect(roomsResponse.body).toHaveProperty('update'); + + rcRoomConverted = roomsResponse.body.update.find( + (room: IRoomNativeFederated) => room.federation.mrid === hs1RoomConverted.roomId, + ); + + expect(rcRoomConverted).toHaveProperty('_id'); + expect(rcRoomConverted).toHaveProperty('t', 'd'); + }); + + // UserA accepts the invitation + const waitForJoinEventPromise = waitForRoomEvent(hs1RoomConverted, RoomStateEvent.Members, ({ event }) => { + expect(event).toHaveProperty('content.membership', 'join'); + expect(event).toHaveProperty('state_key', userDmIdA); + }); + + const response = await acceptRoomInvite(rcRoomConverted._id, rcUserConfigA); + expect(response.success).toBe(true); + + await waitForJoinEventPromise; + + // Now add userB to convert it to a group DM + await hs1AdminApp.matrixClient.invite(hs1RoomConverted.roomId, userDmIdB); + }); + + afterAll(async () => { + await Promise.all([deleteUser(rcUserA, {}, rc1AdminRequestConfig), deleteUser(rcUserB, {}, rc1AdminRequestConfig)]); + }); + + it('should show the invite to the third user', async () => { + await retry('this is an async operation, so we need to wait for the invite to reach RC', async () => { + const pendingInvitationB = await getSubscriptionByRoomId(rcRoomConverted._id, rcUserConfigB.credentials, rcUserConfigB.request); + + expect(pendingInvitationB).toHaveProperty('status', 'INVITED'); + expect(pendingInvitationB).toHaveProperty('fname', federationConfig.hs1.adminMatrixUserId); + }); + + const membersInMatrix = await hs1RoomConverted.getMembers(); + + expect(membersInMatrix.length).toBe(3); + + const invitedMemberB = membersInMatrix.find((member) => member.userId === userDmIdB); + + expect(invitedMemberB).toHaveProperty('membership', 'invite'); + }); + + it('should update the room name to reflect the three users after the third user accepts the invitation', async () => { + const waitForRoomEventPromise = waitForRoomEvent(hs1RoomConverted, RoomStateEvent.Members, ({ event }) => { + expect(event).toHaveProperty('content.membership', 'join'); + expect(event).toHaveProperty('state_key', userDmIdB); + }); + + const response = await acceptRoomInvite(rcRoomConverted._id, rcUserConfigB); + expect(response.success).toBe(true); + + await waitForRoomEventPromise; + + await retry( + 'this is an async operation, so we need to wait for the room name to be updated', + async () => { + // Check userA's subscription + const subA = await getSubscriptionByRoomId(rcRoomConverted._id, rcUserConfigA.credentials, rcUserConfigA.request); + + expect(subA).not.toHaveProperty('status'); + expect(subA).toHaveProperty('name', `${federationConfig.hs1.adminMatrixUserId}, ${userDmB}`); + expect(subA).toHaveProperty('fname', `${federationConfig.hs1.adminMatrixUserId}, ${userDmBName}`); + + // Check userB's subscription + const subB = await getSubscriptionByRoomId(rcRoomConverted._id, rcUserConfigB.credentials, rcUserConfigB.request); + + expect(subB).not.toHaveProperty('status'); + expect(subB).toHaveProperty('name', `${federationConfig.hs1.adminMatrixUserId}, ${userDmA}`); + expect(subB).toHaveProperty('fname', `${federationConfig.hs1.adminMatrixUserId}, ${userDmAName}`); + }, + { delayMs: 100 }, + ); + + // Verify room info shows correct user count + const roomInfo = await getRoomInfo(rcRoomConverted._id, rcUserConfigA); + + expect(roomInfo).toHaveProperty('room'); + expect(roomInfo.room).toHaveProperty('usersCount', 3); + }); }); }); describe('Rocket.Chat as the resident server', () => { From b8d0cc7a95305e707baec9ac4c00bbed7915f006 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 18 Dec 2025 10:47:19 -0300 Subject: [PATCH 08/11] dont rely on dm _id as user ids --- .../app/lib/server/functions/createDirectRoom.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/createDirectRoom.ts b/apps/meteor/app/lib/server/functions/createDirectRoom.ts index a18e85da452f6..029cebc352d50 100644 --- a/apps/meteor/app/lib/server/functions/createDirectRoom.ts +++ b/apps/meteor/app/lib/server/functions/createDirectRoom.ts @@ -78,20 +78,11 @@ export async function createDirectRoom( const uids = roomMembers.map(({ _id }) => _id).sort(); // Deprecated: using users' _id to compose the room _id is deprecated - const room: IRoom | null = await (async () => { - if (options?.forceNew) { - return null; - } - - return uids.length === 2 - ? Rooms.findOneById(uids.join(''), { projection: { _id: 1 } }) - : Rooms.findOneDirectRoomContainingAllUserIDs(uids, { projection: { _id: 1 } }); - })(); + const room: IRoom | null = options?.forceNew ? null : await Rooms.findOneDirectRoomContainingAllUserIDs(uids, { projection: { _id: 1 } }); - const isNewRoom = options?.forceNew ? true : !room; + const isNewRoom = !room; const roomInfo = { - ...(uids.length === 2 && !isNewRoom && { _id: uids.join('') }), // Deprecated: using users' _id to compose the room _id is deprecated t: 'd', usernames, usersCount: members.length, From e313158d4a2efc5379b62a86b47cd280983ea7ed Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 18 Dec 2025 11:54:08 -0300 Subject: [PATCH 09/11] fix accepting revoked invite --- ee/packages/federation-matrix/src/FederationMatrix.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 259f25e3b3bc8..52c1cebfc6727 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -918,10 +918,11 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS const matrixUserId = isUserNativeFederated(user) ? user.federation.mui : `@${user.username}:${this.serverName}`; if (action === 'accept') { - await Room.performAcceptRoomInvite(room, subscription, user); - await federationSDK.acceptInvite(room.federation.mrid, matrixUserId); + + await Room.performAcceptRoomInvite(room, subscription, user); } + if (action === 'reject') { try { await federationSDK.rejectInvite(room.federation.mrid, matrixUserId); From 0045e278fb7613c445fd80da6bd0c82431c9a91f Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 18 Dec 2025 13:50:25 -0300 Subject: [PATCH 10/11] fix test --- ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts b/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts index 209732a1ac0c4..4d1fdcd7583f3 100644 --- a/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts +++ b/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts @@ -465,7 +465,7 @@ const waitForRoomEvent = async ( await retry( 'this is an async operation, so we need to wait for the event to be processed', async () => { - const sub = await getSubscriptionByRoomId(rcRoom1._id, rcUserConfig1.credentials); + const sub = await getSubscriptionByRoomId(rcRoom1._id, rcUserConfig1.credentials, rcUserConfig1.request); expect(sub).not.toHaveProperty('status'); expect(sub).toHaveProperty('name', userDm2); From aa6dbc8493bd6e74f4e557827c214c17e6ae5452 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 18 Dec 2025 13:53:32 -0300 Subject: [PATCH 11/11] test: remove URL check --- apps/meteor/tests/e2e/create-direct.spec.ts | 2 +- apps/meteor/tests/e2e/e2e-encryption/e2ee-otr.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/meteor/tests/e2e/create-direct.spec.ts b/apps/meteor/tests/e2e/create-direct.spec.ts index 1b4e2d7c752d3..0474ec498c4d1 100644 --- a/apps/meteor/tests/e2e/create-direct.spec.ts +++ b/apps/meteor/tests/e2e/create-direct.spec.ts @@ -22,6 +22,6 @@ test.describe.serial('channel-direct-message', () => { await page.keyboard.press('Enter'); await poHomeChannel.sidenav.btnCreate.click(); - await expect(page).toHaveURL('direct/rocket.catrocketchat.internal.admin.test'); + await expect(page).toHaveURL(/direct\/.*/); }); }); diff --git a/apps/meteor/tests/e2e/e2e-encryption/e2ee-otr.spec.ts b/apps/meteor/tests/e2e/e2e-encryption/e2ee-otr.spec.ts index 0101f190ca27c..e078e7a44485c 100644 --- a/apps/meteor/tests/e2e/e2e-encryption/e2ee-otr.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption/e2ee-otr.spec.ts @@ -38,7 +38,7 @@ test.describe('E2EE OTR (Off-The-Record)', () => { await page.keyboard.press('Enter'); await poHomeChannel.sidenav.btnCreate.click(); - await expect(page).toHaveURL(`/direct/user2${Users.userE2EE.data.username}`); + await expect(page).toHaveURL(/direct\/.*/); await poHomeChannel.tabs.kebab.click({ force: true }); if (await poHomeChannel.tabs.btnDisableE2E.isVisible()) {