diff --git a/apps/meteor/app/lib/server/functions/createDirectRoom.ts b/apps/meteor/app/lib/server/functions/createDirectRoom.ts index 6ed0c96143c..029cebc352d 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,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 = - uids.length === 2 - ? await Rooms.findOneById(uids.join(''), { projection: { _id: 1 } }) - : await Rooms.findOneDirectRoomContainingAllUserIDs(uids, { projection: { _id: 1 } }); + const room: IRoom | null = options?.forceNew ? null : await Rooms.findOneDirectRoomContainingAllUserIDs(uids, { projection: { _id: 1 } }); const isNewRoom = !room; const roomInfo = { - ...(uids.length === 2 && { _id: uids.join('') }), // Deprecated: using users' _id to compose the room _id is deprecated t: 'd', usernames, usersCount: members.length, @@ -186,6 +183,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/app/lib/server/methods/addUsersToRoom.ts b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts index 5f1e3f1200d..0ceda8ab359 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 49b1336c5c9..a860e965a98 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'); @@ -259,6 +263,7 @@ export class RoomService extends ServiceClassInternal implements IRoomService { skipAlertSound = false, skipSystemMessage = false, status, + roles, }: { room: IRoom; ts: Date; @@ -268,6 +273,7 @@ export class RoomService extends ServiceClassInternal implements IRoomService { skipAlertSound?: boolean; skipSystemMessage?: boolean; status?: 'INVITED'; + roles?: ISubscription['roles']; }): Promise { const autoTranslateConfig = getSubscriptionAutotranslateDefaultConfig(userToBeAdded); @@ -278,10 +284,12 @@ 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, ...getDefaultSubscriptionPref(userToBeAdded), + ...(room.t === 'd' && inviter && { fname: inviter.name, name: inviter.username }), }); if (insertedId) { diff --git a/apps/meteor/tests/e2e/create-direct.spec.ts b/apps/meteor/tests/e2e/create-direct.spec.ts index 1b4e2d7c752..0474ec498c4 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 0101f190ca2..e078e7a4448 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()) { diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 9d5f0a4cf0a..52c1cebfc67 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -922,6 +922,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS await Room.performAcceptRoomInvite(room, subscription, user); } + if (action === 'reject') { try { await federationSDK.rejectInvite(room.federation.mrid, matrixUserId); diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index ac7a7050748..5b643b40e03 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 a899c0feb41..4d1fdcd7583 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'; @@ -239,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'); @@ -266,15 +260,16 @@ const waitForRoomEvent = async ( 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; + 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 () => { @@ -283,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); @@ -302,28 +297,10 @@ const waitForRoomEvent = async ( await leaveEventPromise; }); }); - - it.todo('should reflect the revoke invitation in the RC user subscriptions'); }); 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'); @@ -334,23 +311,31 @@ const waitForRoomEvent = async ( describe('Multiple user DMs', () => { describe('Synapse as the resident server', () => { let rcUser1: TestUser; - // let rcUser2: TestUser; - let rcUserConfig1: IRequestConfig; - // let rcUserConfig2: IRequestConfig; - let hs1Room: Room | null; + let rcUser2: TestUser; + let rcUserConfig2: IRequestConfig; - let pendingInvitation1: ISubscription | undefined; + let rcUser3: TestUser; + // let rcUserConfig3: IRequestConfig; + + let rcRoom1: IRoom; + + 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,88 +345,329 @@ 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), + rcUser3 && deleteUser(rcUser3, {}, 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'); - expect(pendingInvitation1).toHaveProperty('rid'); + rcRoom1 = roomsResponse.body.update.find((room: IRoomNativeFederated) => room.federation.mrid === hs1Room.roomId); - const membersBefore = await hs1Room!.getMembers(); + expect(rcRoom1).toHaveProperty('_id'); + expect(rcRoom1).toHaveProperty('t', 'd'); + expect(rcRoom1).toHaveProperty('uids'); + expect(rcRoom1).not.toHaveProperty('fname'); + }); + 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 () => { + 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.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 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 () => { + // 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 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 () => { + 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); + 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, rcUserConfig1.request); - const joinedSubscription1 = subs1After.update.find((subscription) => subscription.rid === invitedRoomId1); - - 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('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, rcUserConfig1.request); + + 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'); }); describe('Permission validations', () => { - it.todo('should allow a user to add another user to the group DM'); - it.todo('should allow a user to leave 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, + ); + }); + + // 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, + 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).toHaveProperty('error.error', 'error-not-allowed'); + }); + + 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'); - 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', () => { @@ -470,6 +696,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'); }); }); }); diff --git a/packages/core-services/src/types/IRoomService.ts b/packages/core-services/src/types/IRoomService.ts index d38b400a4b3..76f909b97d6 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; } @@ -66,6 +67,7 @@ export interface IRoomService { skipAlertSound?: boolean; skipSystemMessage?: boolean; status?: 'INVITED'; + roles?: ISubscription['roles']; }): Promise; updateDirectMessageRoomName(room: IRoom): Promise; }